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:
hailin 2026-02-05 05:00:25 -08:00
parent 34ba209e44
commit 207b522754
13 changed files with 790 additions and 39 deletions

View File

@ -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 - 在线状态服务

View File

@ -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());

View File

@ -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")
}

View File

@ -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,
}))
}
}

View File

@ -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,

View File

@ -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}>

View File

@ -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%;

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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;
}
}
}

View File

@ -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')

View File

@ -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(