feat(customer-service): 客服联系方式从硬编码改为后台可配置
将 mobile-app "联系客服" 弹窗的微信/QQ联系方式从硬编码改为
admin-web 后台动态配置,支持任意数量的联系方式管理。
## Backend (admin-service)
- 新增 Prisma 模型: ContactType 枚举(WECHAT/QQ) + CustomerServiceContact
- 新增迁移 SQL: 建表 + 2条索引 + 4条种子数据(保留现有硬编码联系方式)
- 新增双 Controller (参考 app-asset.controller.ts 模式):
- AdminCustomerServiceContactController (admin/customer-service-contacts)
GET 列表 / POST 新增 / PUT 更新 / DELETE 删除
- PublicCustomerServiceContactController (customer-service-contacts)
GET 仅返回 isEnabled=true,按 sortOrder 排序
- 注意: 公开 Controller 用 @Controller('customer-service-contacts')
避免与全局前缀 api/v1 双重叠加
## Kong 网关
- 新增路由 admin-customer-service-contacts-public
路径 /api/v1/customer-service-contacts → admin-service:3010
- Admin 端点由已有 admin-api 路由 (/api/v1/admin) 覆盖
## Admin-web
- endpoints.ts: 新增 CUSTOMER_SERVICE_CONTACTS 端点组
- customerServiceContactService.ts: CRUD 服务 (list/create/update/delete)
- settings/page.tsx: 新增"客服联系方式管理"区块
表格展示(排序/类型/标签/联系方式/启停/操作) + 内联新增/编辑表单
- settings.module.scss: contactTable / contactForm / contactFormFields 样式
## Flutter Mobile-app
- storage_keys.dart: 新增 cachedCustomerServiceContacts 缓存 key
- customer_service_contact_service.dart: API + 缓存服务
(内存5分钟TTL + SharedPreferences持久化 + 后台静默刷新)
- injection_container.dart: 注册 customerServiceContactServiceProvider
- profile_page.dart: _showCustomerServiceDialog() 从硬编码改为
动态 API 加载,contacts 为空时显示"暂无客服联系方式"占位符
## 文件清单 (4 新建 + 9 修改)
新建:
- backend/.../migrations/20260205100000_add_customer_service_contacts/migration.sql
- backend/.../controllers/customer-service-contact.controller.ts
- frontend/admin-web/src/services/customerServiceContactService.ts
- frontend/mobile-app/lib/core/services/customer_service_contact_service.dart
修改:
- backend/.../prisma/schema.prisma
- backend/.../src/app.module.ts
- backend/api-gateway/kong.yml
- frontend/admin-web/src/infrastructure/api/endpoints.ts
- frontend/admin-web/src/app/(dashboard)/settings/page.tsx
- frontend/admin-web/src/app/(dashboard)/settings/settings.module.scss
- frontend/mobile-app/lib/core/storage/storage_keys.dart
- frontend/mobile-app/lib/core/di/injection_container.dart
- frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
34ba209e44
commit
207b522754
|
|
@ -236,6 +236,10 @@ services:
|
|||
paths:
|
||||
- /api/v1/system-config
|
||||
strip_path: false
|
||||
- name: admin-customer-service-contacts-public
|
||||
paths:
|
||||
- /api/v1/customer-service-contacts
|
||||
strip_path: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Presence Service - 在线状态服务
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
-- =============================================================================
|
||||
-- Customer Service Contacts Migration
|
||||
-- 客服联系方式管理
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1. 客服联系方式类型枚举
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE TYPE "ContactType" AS ENUM ('WECHAT', 'QQ');
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. 客服联系方式表
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE "customer_service_contacts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" "ContactType" NOT NULL,
|
||||
"label" VARCHAR(100) NOT NULL,
|
||||
"value" VARCHAR(200) NOT NULL,
|
||||
"sort_order" INTEGER NOT NULL,
|
||||
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "customer_service_contacts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- 按类型和启用状态查询索引
|
||||
CREATE INDEX "customer_service_contacts_type_is_enabled_idx" ON "customer_service_contacts"("type", "is_enabled");
|
||||
|
||||
-- 按排序查询索引
|
||||
CREATE INDEX "customer_service_contacts_sort_order_idx" ON "customer_service_contacts"("sort_order");
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 3. 初始数据 (保留现有硬编码的联系方式)
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO "customer_service_contacts" ("id", "type", "label", "value", "sort_order", "is_enabled", "created_at", "updated_at") VALUES
|
||||
(gen_random_uuid(), 'WECHAT', '客服微信1', 'liulianhuanghou1', 1, true, NOW(), NOW()),
|
||||
(gen_random_uuid(), 'WECHAT', '客服微信2', 'liulianhuanghou2', 2, true, NOW(), NOW()),
|
||||
(gen_random_uuid(), 'QQ', '客服QQ1', '1502109619', 3, true, NOW(), NOW()),
|
||||
(gen_random_uuid(), 'QQ', '客服QQ2', '2171447109', 4, true, NOW(), NOW());
|
||||
|
|
@ -1177,3 +1177,29 @@ model AppAsset {
|
|||
@@index([type, isEnabled])
|
||||
@@map("app_assets")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Customer Service Contacts (客服联系方式)
|
||||
// =============================================================================
|
||||
|
||||
/// 客服联系方式类型
|
||||
enum ContactType {
|
||||
WECHAT // 微信
|
||||
QQ // QQ
|
||||
}
|
||||
|
||||
/// 客服联系方式 - 管理员配置的客服联系信息
|
||||
model CustomerServiceContact {
|
||||
id String @id @default(uuid())
|
||||
type ContactType
|
||||
label String @db.VarChar(100)
|
||||
value String @db.VarChar(200)
|
||||
sortOrder Int @map("sort_order")
|
||||
isEnabled Boolean @default(true) @map("is_enabled")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([type, isEnabled])
|
||||
@@index([sortOrder])
|
||||
@@map("customer_service_contacts")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,196 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
Logger,
|
||||
} from '@nestjs/common'
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'
|
||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service'
|
||||
import { ContactType } from '@prisma/client'
|
||||
|
||||
// ===== DTOs =====
|
||||
|
||||
interface CreateContactDto {
|
||||
type: string
|
||||
label: string
|
||||
value: string
|
||||
sortOrder: number
|
||||
isEnabled?: boolean
|
||||
}
|
||||
|
||||
interface UpdateContactDto {
|
||||
type?: string
|
||||
label?: string
|
||||
value?: string
|
||||
sortOrder?: number
|
||||
isEnabled?: boolean
|
||||
}
|
||||
|
||||
interface ContactResponseDto {
|
||||
id: string
|
||||
type: ContactType
|
||||
label: string
|
||||
value: string
|
||||
sortOrder: number
|
||||
isEnabled: boolean
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Admin Controller (需要认证)
|
||||
// =============================================================================
|
||||
|
||||
@ApiTags('Customer Service Contact Management')
|
||||
@Controller('admin/customer-service-contacts')
|
||||
export class AdminCustomerServiceContactController {
|
||||
private readonly logger = new Logger(AdminCustomerServiceContactController.name)
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '查询客服联系方式列表 (全部)' })
|
||||
async list(): Promise<ContactResponseDto[]> {
|
||||
const contacts = await this.prisma.customerServiceContact.findMany({
|
||||
orderBy: [{ sortOrder: 'asc' }],
|
||||
})
|
||||
return contacts.map(this.toDto)
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '新增客服联系方式' })
|
||||
async create(@Body() body: CreateContactDto): Promise<ContactResponseDto> {
|
||||
const contactType = body.type as ContactType
|
||||
if (!Object.values(ContactType).includes(contactType)) {
|
||||
throw new BadRequestException(`Invalid type: ${body.type}. Must be WECHAT or QQ`)
|
||||
}
|
||||
if (!body.label || !body.value) {
|
||||
throw new BadRequestException('label and value are required')
|
||||
}
|
||||
if (body.sortOrder === undefined || body.sortOrder < 0) {
|
||||
throw new BadRequestException('sortOrder must be a non-negative integer')
|
||||
}
|
||||
|
||||
const contact = await this.prisma.customerServiceContact.create({
|
||||
data: {
|
||||
type: contactType,
|
||||
label: body.label,
|
||||
value: body.value,
|
||||
sortOrder: body.sortOrder,
|
||||
isEnabled: body.isEnabled ?? true,
|
||||
},
|
||||
})
|
||||
|
||||
this.logger.log(`Created customer service contact: id=${contact.id}, type=${contact.type}, label=${contact.label}`)
|
||||
return this.toDto(contact)
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '更新客服联系方式' })
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() body: UpdateContactDto,
|
||||
): Promise<ContactResponseDto> {
|
||||
const existing = await this.prisma.customerServiceContact.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Contact not found')
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {}
|
||||
if (body.type !== undefined) {
|
||||
const contactType = body.type as ContactType
|
||||
if (!Object.values(ContactType).includes(contactType)) {
|
||||
throw new BadRequestException(`Invalid type: ${body.type}`)
|
||||
}
|
||||
data.type = contactType
|
||||
}
|
||||
if (body.label !== undefined) data.label = body.label
|
||||
if (body.value !== undefined) data.value = body.value
|
||||
if (body.sortOrder !== undefined) data.sortOrder = body.sortOrder
|
||||
if (body.isEnabled !== undefined) data.isEnabled = body.isEnabled
|
||||
|
||||
const updated = await this.prisma.customerServiceContact.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
|
||||
return this.toDto(updated)
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: '删除客服联系方式' })
|
||||
async delete(@Param('id') id: string): Promise<void> {
|
||||
const existing = await this.prisma.customerServiceContact.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Contact not found')
|
||||
}
|
||||
await this.prisma.customerServiceContact.delete({ where: { id } })
|
||||
this.logger.log(`Deleted customer service contact: id=${id}, type=${existing.type}`)
|
||||
}
|
||||
|
||||
private toDto(contact: {
|
||||
id: string
|
||||
type: ContactType
|
||||
label: string
|
||||
value: string
|
||||
sortOrder: number
|
||||
isEnabled: boolean
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}): ContactResponseDto {
|
||||
return {
|
||||
id: contact.id,
|
||||
type: contact.type,
|
||||
label: contact.label,
|
||||
value: contact.value,
|
||||
sortOrder: contact.sortOrder,
|
||||
isEnabled: contact.isEnabled,
|
||||
createdAt: contact.createdAt,
|
||||
updatedAt: contact.updatedAt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Public Controller (移动端调用,无需认证)
|
||||
// =============================================================================
|
||||
|
||||
@ApiTags('Customer Service Contacts (Public)')
|
||||
@Controller('customer-service-contacts')
|
||||
export class PublicCustomerServiceContactController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取已启用的客服联系方式 (移动端)' })
|
||||
async list(): Promise<ContactResponseDto[]> {
|
||||
const contacts = await this.prisma.customerServiceContact.findMany({
|
||||
where: { isEnabled: true },
|
||||
orderBy: [{ sortOrder: 'asc' }],
|
||||
})
|
||||
|
||||
return contacts.map((c) => ({
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
label: c.label,
|
||||
value: c.value,
|
||||
sortOrder: c.sortOrder,
|
||||
isEnabled: c.isEnabled,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
@ -77,7 +77,9 @@ import { SystemMaintenanceRepositoryImpl } from './infrastructure/persistence/re
|
|||
import { AdminMaintenanceController, MobileMaintenanceController } from './api/controllers/system-maintenance.controller';
|
||||
import { MaintenanceInterceptor } from './api/interceptors/maintenance.interceptor';
|
||||
// App Asset imports
|
||||
import { AdminAppAssetController, PublicAppAssetController } from './api/controllers/app-asset.controller';
|
||||
import { AdminAppAssetController, PublicAppAssetController } from './api/controllers/app-asset.controller'
|
||||
// Customer Service Contact imports
|
||||
import { AdminCustomerServiceContactController, PublicCustomerServiceContactController } from './api/controllers/customer-service-contact.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -116,6 +118,9 @@ import { AdminAppAssetController, PublicAppAssetController } from './api/control
|
|||
// App Asset Controllers
|
||||
AdminAppAssetController,
|
||||
PublicAppAssetController,
|
||||
// Customer Service Contact Controllers
|
||||
AdminCustomerServiceContactController,
|
||||
PublicCustomerServiceContactController,
|
||||
],
|
||||
providers: [
|
||||
PrismaService,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { cn } from '@/utils/helpers';
|
|||
import styles from './settings.module.scss';
|
||||
import { systemConfigService, type DisplaySettings } from '@/services/systemConfigService';
|
||||
import { appAssetService, type AppAsset, type AppAssetType } from '@/services/appAssetService';
|
||||
import { customerServiceContactService, type CustomerServiceContact, type ContactType } from '@/services/customerServiceContactService';
|
||||
|
||||
/**
|
||||
* 后台账号数据接口
|
||||
|
|
@ -96,6 +97,12 @@ export default function SettingsPage() {
|
|||
const [assetsLoading, setAssetsLoading] = useState(false);
|
||||
const [uploadingSlot, setUploadingSlot] = useState<string | null>(null);
|
||||
|
||||
// 客服联系方式管理
|
||||
const [contacts, setContacts] = useState<CustomerServiceContact[]>([]);
|
||||
const [contactsLoading, setContactsLoading] = useState(false);
|
||||
const [editingContact, setEditingContact] = useState<Partial<CustomerServiceContact> | null>(null);
|
||||
const [contactSaving, setContactSaving] = useState(false);
|
||||
|
||||
// 后台账号与安全
|
||||
const [approvalCount, setApprovalCount] = useState('3');
|
||||
const [sensitiveOperations, setSensitiveOperations] = useState(['修改结算参数', '删除用户']);
|
||||
|
|
@ -205,11 +212,82 @@ export default function SettingsPage() {
|
|||
}
|
||||
}, [loadAppAssets]);
|
||||
|
||||
// 加载客服联系方式
|
||||
const loadContacts = useCallback(async () => {
|
||||
setContactsLoading(true);
|
||||
try {
|
||||
const list = await customerServiceContactService.list();
|
||||
setContacts(Array.isArray(list) ? list.sort((a: CustomerServiceContact, b: CustomerServiceContact) => a.sortOrder - b.sortOrder) : []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load contacts:', error);
|
||||
} finally {
|
||||
setContactsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 保存客服联系方式 (新增或编辑)
|
||||
const handleContactSave = useCallback(async () => {
|
||||
if (!editingContact) return;
|
||||
if (!editingContact.type || !editingContact.label || !editingContact.value) {
|
||||
alert('请填写完整信息');
|
||||
return;
|
||||
}
|
||||
setContactSaving(true);
|
||||
try {
|
||||
if (editingContact.id) {
|
||||
await customerServiceContactService.update(editingContact.id, {
|
||||
type: editingContact.type as ContactType,
|
||||
label: editingContact.label,
|
||||
value: editingContact.value,
|
||||
sortOrder: editingContact.sortOrder ?? 0,
|
||||
isEnabled: editingContact.isEnabled,
|
||||
});
|
||||
} else {
|
||||
await customerServiceContactService.create({
|
||||
type: editingContact.type as ContactType,
|
||||
label: editingContact.label,
|
||||
value: editingContact.value,
|
||||
sortOrder: editingContact.sortOrder ?? contacts.length,
|
||||
isEnabled: editingContact.isEnabled ?? true,
|
||||
});
|
||||
}
|
||||
setEditingContact(null);
|
||||
await loadContacts();
|
||||
} catch (error) {
|
||||
console.error('Save contact failed:', error);
|
||||
alert('保存失败,请重试');
|
||||
} finally {
|
||||
setContactSaving(false);
|
||||
}
|
||||
}, [editingContact, contacts.length, loadContacts]);
|
||||
|
||||
// 删除客服联系方式
|
||||
const handleContactDelete = useCallback(async (id: string) => {
|
||||
if (!confirm('确定要删除此联系方式吗?')) return;
|
||||
try {
|
||||
await customerServiceContactService.delete(id);
|
||||
await loadContacts();
|
||||
} catch (error) {
|
||||
console.error('Delete contact failed:', error);
|
||||
}
|
||||
}, [loadContacts]);
|
||||
|
||||
// 切换客服联系方式启停
|
||||
const handleContactToggle = useCallback(async (id: string, isEnabled: boolean) => {
|
||||
try {
|
||||
await customerServiceContactService.update(id, { isEnabled });
|
||||
await loadContacts();
|
||||
} catch (error) {
|
||||
console.error('Toggle contact failed:', error);
|
||||
}
|
||||
}, [loadContacts]);
|
||||
|
||||
// 组件挂载时加载展示设置
|
||||
useEffect(() => {
|
||||
loadDisplaySettings();
|
||||
loadAppAssets();
|
||||
}, [loadDisplaySettings, loadAppAssets]);
|
||||
loadContacts();
|
||||
}, [loadDisplaySettings, loadAppAssets, loadContacts]);
|
||||
|
||||
// 切换货币选择
|
||||
const toggleCurrency = (currency: string) => {
|
||||
|
|
@ -692,6 +770,136 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* 客服联系方式管理 */}
|
||||
<section className={styles.settings__section}>
|
||||
<div className={styles.settings__sectionHeader}>
|
||||
<h2 className={styles.settings__sectionTitle}>客服联系方式管理</h2>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className={styles.settings__resetBtn}
|
||||
onClick={loadContacts}
|
||||
disabled={contactsLoading}
|
||||
>
|
||||
{contactsLoading ? '加载中...' : '刷新'}
|
||||
</button>
|
||||
<button
|
||||
className={styles.settings__addBtn}
|
||||
onClick={() => setEditingContact({ type: 'WECHAT', label: '', value: '', sortOrder: contacts.length, isEnabled: true })}
|
||||
>
|
||||
新增联系方式
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.settings__content}>
|
||||
{contacts.length === 0 && !contactsLoading && (
|
||||
<p style={{ color: '#9ca3af', fontSize: 14 }}>暂无客服联系方式,请点击"新增联系方式"添加</p>
|
||||
)}
|
||||
{contacts.length > 0 && (
|
||||
<table className={styles.settings__contactTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>排序</th>
|
||||
<th>类型</th>
|
||||
<th>标签</th>
|
||||
<th>联系方式</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{contacts.map((contact) => (
|
||||
<tr key={contact.id}>
|
||||
<td>{contact.sortOrder}</td>
|
||||
<td>{contact.type === 'WECHAT' ? '微信' : 'QQ'}</td>
|
||||
<td>{contact.label}</td>
|
||||
<td>{contact.value}</td>
|
||||
<td>
|
||||
<Toggle
|
||||
checked={contact.isEnabled}
|
||||
onChange={(v: boolean) => handleContactToggle(contact.id, v)}
|
||||
size="small"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className={styles.settings__actionLink}
|
||||
onClick={() => setEditingContact({ ...contact })}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
className={cn(styles.settings__actionLink, styles['settings__actionLink--danger'])}
|
||||
onClick={() => handleContactDelete(contact.id)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{editingContact && (
|
||||
<div className={styles.settings__contactForm}>
|
||||
<h3>{editingContact.id ? '编辑联系方式' : '新增联系方式'}</h3>
|
||||
<div className={styles.settings__contactFormFields}>
|
||||
<select
|
||||
className={styles.settings__input}
|
||||
value={editingContact.type || 'WECHAT'}
|
||||
onChange={(e) => setEditingContact({ ...editingContact, type: e.target.value as ContactType })}
|
||||
>
|
||||
<option value="WECHAT">微信</option>
|
||||
<option value="QQ">QQ</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.settings__input}
|
||||
placeholder="标签 (如:客服微信1)"
|
||||
value={editingContact.label || ''}
|
||||
onChange={(e) => setEditingContact({ ...editingContact, label: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.settings__input}
|
||||
placeholder="联系方式 (如:liulianhuanghou1)"
|
||||
value={editingContact.value || ''}
|
||||
onChange={(e) => setEditingContact({ ...editingContact, value: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
className={styles.settings__input}
|
||||
placeholder="排序"
|
||||
value={editingContact.sortOrder ?? 0}
|
||||
onChange={(e) => setEditingContact({ ...editingContact, sortOrder: parseInt(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<button
|
||||
className={styles.settings__saveBtn}
|
||||
onClick={handleContactSave}
|
||||
disabled={contactSaving}
|
||||
>
|
||||
{contactSaving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
<button
|
||||
className={styles.settings__resetBtn}
|
||||
onClick={() => setEditingContact(null)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className={styles.settings__hint}>
|
||||
提示:移动端"联系客服"弹窗将按排序顺序显示所有已启用的联系方式。修改后用户下次打开弹窗时生效。
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 后台账号与安全 */}
|
||||
<section className={styles.settings__section}>
|
||||
<div className={styles.settings__sectionHeader}>
|
||||
|
|
|
|||
|
|
@ -1216,6 +1216,54 @@
|
|||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 客服联系方式管理 */
|
||||
.settings__contactTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
th, td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
background: #f9fafb;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
td {
|
||||
color: #4b5563;
|
||||
}
|
||||
}
|
||||
|
||||
.settings__contactForm {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.settings__contactFormFields {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr 1fr 80px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 页面底部操作区 */
|
||||
.settings__footer {
|
||||
width: 100%;
|
||||
|
|
|
|||
|
|
@ -261,4 +261,12 @@ export const API_ENDPOINTS = {
|
|||
UPDATE: (id: string) => `/v1/admin/app-assets/${id}`,
|
||||
DELETE: (id: string) => `/v1/admin/app-assets/${id}`,
|
||||
},
|
||||
|
||||
// 客服联系方式管理 (admin-service)
|
||||
CUSTOMER_SERVICE_CONTACTS: {
|
||||
LIST: '/v1/admin/customer-service-contacts',
|
||||
CREATE: '/v1/admin/customer-service-contacts',
|
||||
UPDATE: (id: string) => `/v1/admin/customer-service-contacts/${id}`,
|
||||
DELETE: (id: string) => `/v1/admin/customer-service-contacts/${id}`,
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import apiClient from '@/infrastructure/api/client';
|
||||
import { API_ENDPOINTS } from '@/infrastructure/api/endpoints';
|
||||
|
||||
export type ContactType = 'WECHAT' | 'QQ';
|
||||
|
||||
export interface CustomerServiceContact {
|
||||
id: string;
|
||||
type: ContactType;
|
||||
label: string;
|
||||
value: string;
|
||||
sortOrder: number;
|
||||
isEnabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateContactPayload {
|
||||
type: ContactType;
|
||||
label: string;
|
||||
value: string;
|
||||
sortOrder: number;
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateContactPayload {
|
||||
type?: ContactType;
|
||||
label?: string;
|
||||
value?: string;
|
||||
sortOrder?: number;
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const customerServiceContactService = {
|
||||
/** 查询所有客服联系方式 */
|
||||
async list(): Promise<CustomerServiceContact[]> {
|
||||
return apiClient.get(API_ENDPOINTS.CUSTOMER_SERVICE_CONTACTS.LIST);
|
||||
},
|
||||
|
||||
/** 新增客服联系方式 */
|
||||
async create(data: CreateContactPayload): Promise<CustomerServiceContact> {
|
||||
return apiClient.post(API_ENDPOINTS.CUSTOMER_SERVICE_CONTACTS.CREATE, data);
|
||||
},
|
||||
|
||||
/** 更新客服联系方式 */
|
||||
async update(id: string, data: UpdateContactPayload): Promise<CustomerServiceContact> {
|
||||
return apiClient.put(API_ENDPOINTS.CUSTOMER_SERVICE_CONTACTS.UPDATE(id), data);
|
||||
},
|
||||
|
||||
/** 删除客服联系方式 */
|
||||
async delete(id: string): Promise<void> {
|
||||
return apiClient.delete(API_ENDPOINTS.CUSTOMER_SERVICE_CONTACTS.DELETE(id));
|
||||
},
|
||||
};
|
||||
|
||||
export default customerServiceContactService;
|
||||
|
|
@ -13,6 +13,7 @@ import '../services/reward_service.dart';
|
|||
import '../services/notification_service.dart';
|
||||
import '../services/system_config_service.dart';
|
||||
import '../services/app_asset_service.dart';
|
||||
import '../services/customer_service_contact_service.dart';
|
||||
import '../services/leaderboard_service.dart';
|
||||
import '../services/contract_signing_service.dart';
|
||||
import '../services/contract_check_service.dart';
|
||||
|
|
@ -119,6 +120,13 @@ final appAssetServiceProvider = Provider<AppAssetService>((ref) {
|
|||
return AppAssetService(apiClient: apiClient, localStorage: localStorage);
|
||||
});
|
||||
|
||||
// Customer Service Contact Service Provider (调用 admin-service)
|
||||
final customerServiceContactServiceProvider = Provider<CustomerServiceContactService>((ref) {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
final localStorage = ref.watch(localStorageProvider);
|
||||
return CustomerServiceContactService(apiClient: apiClient, localStorage: localStorage);
|
||||
});
|
||||
|
||||
// Leaderboard Service Provider (调用 leaderboard-service)
|
||||
final leaderboardServiceProvider = Provider<LeaderboardService>((ref) {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../network/api_client.dart';
|
||||
import '../storage/local_storage.dart';
|
||||
import '../storage/storage_keys.dart';
|
||||
|
||||
/// 客服联系方式数据模型
|
||||
class CustomerServiceContactData {
|
||||
final String id;
|
||||
final String type; // 'WECHAT' | 'QQ'
|
||||
final String label;
|
||||
final String value;
|
||||
final int sortOrder;
|
||||
|
||||
const CustomerServiceContactData({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.sortOrder,
|
||||
});
|
||||
|
||||
factory CustomerServiceContactData.fromJson(Map<String, dynamic> json) {
|
||||
return CustomerServiceContactData(
|
||||
id: json['id'] as String,
|
||||
type: json['type'] as String,
|
||||
label: json['label'] as String,
|
||||
value: json['value'] as String,
|
||||
sortOrder: json['sortOrder'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'type': type,
|
||||
'label': label,
|
||||
'value': value,
|
||||
'sortOrder': sortOrder,
|
||||
};
|
||||
|
||||
bool get isWechat => type == 'WECHAT';
|
||||
bool get isQQ => type == 'QQ';
|
||||
}
|
||||
|
||||
/// 客服联系方式服务
|
||||
///
|
||||
/// 策略与 AppAssetService 一致:
|
||||
/// 1. 优先使用 SharedPreferences 中的持久化缓存
|
||||
/// 2. 后台静默刷新,成功后更新缓存
|
||||
/// 3. 缓存为空时返回空列表
|
||||
class CustomerServiceContactService {
|
||||
final ApiClient _apiClient;
|
||||
final LocalStorage _localStorage;
|
||||
|
||||
/// 内存缓存
|
||||
List<CustomerServiceContactData>? _memoryCache;
|
||||
|
||||
/// 缓存有效期
|
||||
static const Duration _cacheExpiration = Duration(minutes: 5);
|
||||
|
||||
/// 上次网络请求时间
|
||||
DateTime? _lastFetchTime;
|
||||
|
||||
CustomerServiceContactService({
|
||||
required ApiClient apiClient,
|
||||
required LocalStorage localStorage,
|
||||
}) : _apiClient = apiClient,
|
||||
_localStorage = localStorage;
|
||||
|
||||
/// 获取客服联系方式列表
|
||||
///
|
||||
/// 返回顺序:内存缓存 → 持久化缓存 → 空列表
|
||||
/// 同时在后台静默刷新数据。
|
||||
Future<List<CustomerServiceContactData>> getContacts({bool forceRefresh = false}) async {
|
||||
// 1. 检查内存缓存
|
||||
if (!forceRefresh && _memoryCache != null && _lastFetchTime != null) {
|
||||
final cacheAge = DateTime.now().difference(_lastFetchTime!);
|
||||
if (cacheAge < _cacheExpiration) {
|
||||
return _memoryCache!;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 从 SharedPreferences 加载
|
||||
if (_memoryCache == null) {
|
||||
try {
|
||||
final cachedJson = _localStorage.getString(StorageKeys.cachedCustomerServiceContacts);
|
||||
if (cachedJson != null && cachedJson.isNotEmpty) {
|
||||
final list = jsonDecode(cachedJson) as List<dynamic>;
|
||||
_memoryCache = list
|
||||
.map((e) => CustomerServiceContactData.fromJson(e as Map<String, dynamic>))
|
||||
.toList()
|
||||
..sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
|
||||
debugPrint('[CustomerServiceContactService] 从持久化缓存加载: ${_memoryCache!.length} contacts');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[CustomerServiceContactService] 读取持久化缓存失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 后台静默刷新
|
||||
_refreshInBackground();
|
||||
|
||||
return _memoryCache ?? [];
|
||||
}
|
||||
|
||||
/// 后台静默刷新
|
||||
void _refreshInBackground() {
|
||||
_fetchFromApi().then((contacts) {
|
||||
if (contacts != null) {
|
||||
_memoryCache = contacts;
|
||||
_lastFetchTime = DateTime.now();
|
||||
// 持久化
|
||||
final jsonStr = jsonEncode(contacts.map((e) => e.toJson()).toList());
|
||||
_localStorage.setString(StorageKeys.cachedCustomerServiceContacts, jsonStr);
|
||||
debugPrint('[CustomerServiceContactService] 后台刷新成功: ${contacts.length} contacts');
|
||||
}
|
||||
}).catchError((e) {
|
||||
debugPrint('[CustomerServiceContactService] 后台刷新失败: $e');
|
||||
});
|
||||
}
|
||||
|
||||
/// 从 API 获取数据
|
||||
Future<List<CustomerServiceContactData>?> _fetchFromApi() async {
|
||||
try {
|
||||
final response = await _apiClient.get('/customer-service-contacts');
|
||||
List<dynamic>? list;
|
||||
if (response.data is List) {
|
||||
list = response.data as List;
|
||||
} else if (response.data is Map && response.data['data'] is List) {
|
||||
list = response.data['data'] as List;
|
||||
}
|
||||
if (list != null) {
|
||||
return list
|
||||
.map((e) => CustomerServiceContactData.fromJson(e as Map<String, dynamic>))
|
||||
.toList()
|
||||
..sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('[CustomerServiceContactService] API 请求失败: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -50,6 +50,7 @@ class StorageKeys {
|
|||
static const String cachedRankingData = 'cached_ranking_data';
|
||||
static const String cachedMiningStatus = 'cached_mining_status';
|
||||
static const String cachedAppAssets = 'cached_app_assets';
|
||||
static const String cachedCustomerServiceContacts = 'cached_customer_service_contacts';
|
||||
|
||||
// ===== 已废弃 (保留用于迁移旧数据) =====
|
||||
@Deprecated('Use userSerialNum instead')
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import '../widgets/team_tree_widget.dart';
|
|||
import '../widgets/stacked_cards_widget.dart';
|
||||
import '../../../authorization/presentation/widgets/stickman_race_widget.dart';
|
||||
import '../../../kyc/data/kyc_service.dart';
|
||||
import '../../../../core/services/customer_service_contact_service.dart';
|
||||
|
||||
/// 个人中心页面 - 显示用户信息、社区数据、收益和设置
|
||||
/// 包含用户资料、推荐信息、社区考核、收益领取等功能
|
||||
|
|
@ -4346,8 +4347,13 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
context.push(RoutePaths.accountSwitch);
|
||||
}
|
||||
|
||||
/// 显示客服联系方式弹窗
|
||||
void _showCustomerServiceDialog() {
|
||||
/// 显示客服联系方式弹窗 (从后台动态加载)
|
||||
void _showCustomerServiceDialog() async {
|
||||
final contactService = ref.read(customerServiceContactServiceProvider);
|
||||
final contacts = await contactService.getContacts();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => Dialog(
|
||||
|
|
@ -4385,41 +4391,40 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// 微信客服1
|
||||
_buildContactItem(
|
||||
icon: 'assets/icons/wechat_icon.png',
|
||||
iconFallback: Icons.message,
|
||||
label: '客服微信1',
|
||||
value: 'liulianhuanghou1',
|
||||
onCopy: () => _copyToClipboard('liulianhuanghou1', '微信号'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 微信客服2
|
||||
_buildContactItem(
|
||||
icon: 'assets/icons/wechat_icon.png',
|
||||
iconFallback: Icons.message,
|
||||
label: '客服微信2',
|
||||
value: 'liulianhuanghou2',
|
||||
onCopy: () => _copyToClipboard('liulianhuanghou2', '微信号'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// QQ客服1
|
||||
_buildContactItem(
|
||||
icon: 'assets/icons/qq_icon.png',
|
||||
iconFallback: Icons.chat_bubble,
|
||||
label: '客服QQ1',
|
||||
value: '1502109619',
|
||||
onCopy: () => _copyToClipboard('1502109619', 'QQ号'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// QQ客服2
|
||||
_buildContactItem(
|
||||
icon: 'assets/icons/qq_icon.png',
|
||||
iconFallback: Icons.chat_bubble,
|
||||
label: '客服QQ2',
|
||||
value: '2171447109',
|
||||
onCopy: () => _copyToClipboard('2171447109', 'QQ号'),
|
||||
),
|
||||
// 动态联系方式列表
|
||||
if (contacts.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20),
|
||||
child: Text(
|
||||
'暂无客服联系方式',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF8B5A2B),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
...contacts.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final contact = entry.value;
|
||||
final copyLabel = contact.isWechat ? '微信号' : 'QQ号';
|
||||
return Column(
|
||||
children: [
|
||||
if (index > 0) const SizedBox(height: 12),
|
||||
_buildContactItem(
|
||||
icon: contact.isWechat
|
||||
? 'assets/icons/wechat_icon.png'
|
||||
: 'assets/icons/qq_icon.png',
|
||||
iconFallback: contact.isWechat
|
||||
? Icons.message
|
||||
: Icons.chat_bubble,
|
||||
label: contact.label,
|
||||
value: contact.value,
|
||||
onCopy: () => _copyToClipboard(contact.value, copyLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 24),
|
||||
// 关闭按钮
|
||||
SizedBox(
|
||||
|
|
|
|||
Loading…
Reference in New Issue