diff --git a/docs/Phase2-二维码池验收.md b/docs/Phase2-二维码池验收.md new file mode 100644 index 0000000..eb615b6 --- /dev/null +++ b/docs/Phase2-二维码池验收.md @@ -0,0 +1,59 @@ +# Phase 2 验收报告(主站二维码池) + +> 日期:2026-10-30 +> 项目:解忧商行(hfb_jysh) +> 范围:企业微信群二维码池的列表、统计、新增、编辑、单删与批删 +> 结论:**页面与路由已落地,tsc/build 通过,只读接口回归 200;写操作未在验收中执行** + +--- + +## 1. 页面与路由 + +- 页面:`frontend/src/features/admin/AdminQrCodePoolPage.tsx` +- 路由:`/console-jy8k3m/qrcode-pool` +- 权限:查看 `qrcode:view`,增删改 `qrcode:manage` +- 菜单:`frontend/src/layouts/AdminLayout.tsx` 客服组新增「二维码池」,`sectionMeta` 同步 + +## 2. 接口 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/admin/chats/qrcodes` | 列表(`status`/`keyword`/`bound`/`expire_status`/`wecom_renamed`/`page`/`limit`) | +| GET | `/api/admin/chats/qrcodes/stats` | 未使用 / 已使用 / 已停用 / 总数 | +| POST | `/api/admin/chats/qrcodes` | 新增 | +| PATCH | `/api/admin/chats/qrcodes/:id` | 编辑(群名、备注、状态、企微改名、过期时间) | +| DELETE | `/api/admin/chats/qrcodes/:id` | 单条删除 | +| POST | `/api/admin/chats/qrcodes/batch-delete` | 批量删除(最多 100) | + +> 注意:该组接口返回 `{ data: ... }` 结构(非 `code/message/data`),前端按 `{ data }` 解包,与其他后台接口不同。 + +## 3. 能力 + +- 顶部四项统计直接来自 `stats` 接口。 +- 筛选:关键字(群名/备注)、状态、绑定情况、过期状态、企微改名。 +- 列表:二维码缩略图、群名与备注、状态与「企微已改名」标签、绑定会话、发放/创建时间、过期时间。 +- 新增:上传图片(`POST /api/files/upload`,scene `qrcode`)、群名称、备注、过期时间(留空后端默认 7 天)。 +- 编辑:群名称、备注、状态、企微改名开关、过期时间;清空时间时提交 `clear_expires_at: true`。 +- 批量删除:勾选后二次确认,调用 `batch-delete` 并提示实际删除数量。 + +## 4. 回归结果 + +```text +前端 http://localhost:5289 +后端 http://127.0.0.1:18089 +Cookie /tmp/jysh-admin-cookie2.txt +``` + +| 检查项 | 状态 | +|---|---| +| `GET /console-jy8k3m/qrcode-pool` | 200 | +| `GET /api/admin/chats/qrcodes?page=1&limit=5` | 200 | +| `GET /api/admin/chats/qrcodes/stats` | 200 | + +`npx tsc --noEmit` 通过;`npm run build` 通过,主入口 `499.16 kB` / gzip `157.08 kB`(接近 500 kB 阈值,后续新增页面需关注拆包)。 + +## 5. 未覆盖 + +- 上传、新增、编辑、删除与批删未执行真实写操作(开发库二维码池为空)。 +- OCR 识别群名(`/chats/qrcodes/ocr-group-name`)依赖 PaddleOCR Token,未接入页面。 +- 未实现「批量添加」(`/chats/qrcodes/batch`)的多图批量录入界面。 diff --git a/docs/项目计划.md b/docs/项目计划.md index 64e6a95..6d4fd1e 100644 --- a/docs/项目计划.md +++ b/docs/项目计划.md @@ -414,7 +414,7 @@ hfb_jysh/ - 已落地:`/wallet`(余额 + 流水)、`/wallet/payment-accounts`、`/wallet/withdrawal`、移动端 `/m/wallet/withdrawal`、卖家 `/seller/earnings`、`/seller/handoffs`、`/seller/pickups`。 - 申诉仲裁、手工订单、提号、客服群、快捷回复。 - 已落地:客服群组(`/chat-support-groups`,权限 `chat:manage`),角色权限矩阵(`/roles`,权限 `role:manage`),手工录单(`/manual-entries`,权限 `cs_manual_entry:*`),快捷回复(`/chat-quick-replies`,权限 `chat:view` / `chat:send`),线下提号(`/pickup`、`/pickup/:id`,权限 `order:pickup`,口径统一为「线下提号」)。 - - 待落地:主站二维码池 `/qrcode-pool`、自动欢迎与商品群欢迎配置。 + - 待落地:自动欢迎与商品群欢迎配置、二维码池 OCR 与批量导入。 - 分销站点、规则、订单、结算、二维码池、分销用户、公告。 - 公告、邀请返利、推送、追踪链接。 - 已落地:公告管理、邀请返利、推广追踪(`/tracking-links`,权限 `ad_tracking:manage`)。 diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 500df95..14782e0 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -246,6 +246,11 @@ const AdminChatsPage = lazy(() => default: module.AdminChatsPage, })) ) +const AdminQrCodePoolPage = lazy(() => + import('@/features/admin/AdminQrCodePoolPage').then(module => ({ + default: module.AdminQrCodePoolPage, + })) +) const AdminPickupDetailPage = lazy(() => import('@/features/admin/AdminPickupDetailPage').then(module => ({ default: module.AdminPickupDetailPage, @@ -414,6 +419,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/features/admin/AdminQrCodePoolPage.tsx b/frontend/src/features/admin/AdminQrCodePoolPage.tsx new file mode 100644 index 0000000..6e9b86d --- /dev/null +++ b/frontend/src/features/admin/AdminQrCodePoolPage.tsx @@ -0,0 +1,580 @@ +import { useMemo, useState } from 'react' +import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query' +import { + Alert, + App, + Button, + DatePicker, + Form, + Image, + Input, + Modal, + Select, + Space, + Switch, + Tag, + Typography, + Upload, + type TableColumnsType, +} from 'antd' +import { + DeleteOutlined, + EditOutlined, + PlusOutlined, + ReloadOutlined, + UploadOutlined, +} from '@ant-design/icons' +import dayjs, { type Dayjs } from 'dayjs' + +import { + batchDeleteAdminQrCodes, + createAdminQrCode, + deleteAdminQrCode, + fetchAdminQrCodeStats, + fetchAdminQrCodes, + updateAdminQrCode, +} from '@/features/admin/api' +import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader' +import { DataTable } from '@/features/admin/components/DataTable' +import { FilterBar, FilterField } from '@/features/admin/components/FilterBar' +import type { AdminQrCode } from '@/features/admin/types' +import { useAdminSessionStore } from '@/stores/adminSession' +import { uploadFile } from '@/shared/api/files' +import { formatDateTime, formatText } from '@/shared/utils/format' + +interface Filters { + status?: string + keyword: string + bound?: string + expire_status?: string + wecom_renamed?: string +} + +interface CreateFormValues { + group_name?: string + note?: string + expires_at?: Dayjs | null +} + +const emptyFilters: Filters = { keyword: '' } + +const statusMeta: Record = { + unused: { label: '未使用', color: 'green' }, + used: { label: '已使用', color: 'blue' }, + disabled: { label: '已停用', color: 'default' }, +} + +function errorText(error: unknown) { + const response = (error as { response?: { data?: { message?: string; error?: string } } })?.response + if (response?.data?.message) return response.data.message + if (response?.data?.error) return response.data.error + return error instanceof Error ? error.message : '请求失败,请稍后重试' +} + +export function AdminQrCodePoolPage() { + const { message, modal } = App.useApp() + const queryClient = useQueryClient() + const [createForm] = Form.useForm() + const [editForm] = Form.useForm<{ + group_name?: string + note?: string + status: string + wecom_renamed: boolean + expires_at?: Dayjs | null + }>() + const [filters, setFilters] = useState(emptyFilters) + const [applied, setApplied] = useState(emptyFilters) + const [page, setPage] = useState(1) + const [limit, setLimit] = useState(20) + const [createOpen, setCreateOpen] = useState(false) + const [editing, setEditing] = useState() + const [imageURL, setImageURL] = useState('') + const [uploading, setUploading] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [selectedRowKeys, setSelectedRowKeys] = useState([]) + + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const canView = Boolean(permissions?.includes('*') || permissions?.includes('qrcode:view')) + const canManage = Boolean(permissions?.includes('*') || permissions?.includes('qrcode:manage')) + + const query = useMemo( + () => ({ + status: applied.status, + keyword: applied.keyword.trim() || undefined, + bound: applied.bound, + expire_status: applied.expire_status, + wecom_renamed: applied.wecom_renamed, + page, + limit, + }), + [applied, page, limit] + ) + + const qrcodesQuery = useQuery({ + queryKey: ['admin-qrcodes', query], + queryFn: () => fetchAdminQrCodes(query), + enabled: canView, + placeholderData: keepPreviousData, + }) + const statsQuery = useQuery({ + queryKey: ['admin-qrcode-stats'], + queryFn: fetchAdminQrCodeStats, + enabled: canView, + }) + + const stats = statsQuery.data + const rows = qrcodesQuery.data?.items ?? [] + + const invalidate = () => + Promise.all([ + queryClient.invalidateQueries({ queryKey: ['admin-qrcodes'] }), + queryClient.invalidateQueries({ queryKey: ['admin-qrcode-stats'] }), + ]) + + const openCreate = () => { + setImageURL('') + createForm.setFieldsValue({ group_name: '', note: '', expires_at: null }) + setCreateOpen(true) + } + + const submitCreate = async () => { + const values = await createForm.validateFields() + if (!imageURL) { + message.warning('请先上传二维码图片') + return + } + setSubmitting(true) + try { + await createAdminQrCode({ + image_url: imageURL, + group_name: values.group_name?.trim(), + note: values.note?.trim(), + expires_at: values.expires_at ? values.expires_at.toISOString() : null, + }) + message.success('二维码已加入池') + setCreateOpen(false) + await invalidate() + } catch (error) { + message.error(errorText(error)) + } finally { + setSubmitting(false) + } + } + + const openEdit = (row: AdminQrCode) => { + setEditing(row) + editForm.setFieldsValue({ + group_name: row.group_name, + note: row.note, + status: row.status, + wecom_renamed: row.wecom_renamed, + expires_at: row.expires_at ? dayjs(row.expires_at) : null, + }) + } + + const submitEdit = async () => { + if (!editing) return + const values = await editForm.validateFields() + setSubmitting(true) + try { + await updateAdminQrCode(editing.id, { + group_name: values.group_name?.trim(), + note: values.note?.trim(), + status: values.status, + wecom_renamed: values.wecom_renamed, + expires_at: values.expires_at ? values.expires_at.toISOString() : null, + clear_expires_at: !values.expires_at, + }) + message.success('二维码已更新') + setEditing(undefined) + await invalidate() + } catch (error) { + message.error(errorText(error)) + } finally { + setSubmitting(false) + } + } + + const removeOne = (row: AdminQrCode) => { + modal.confirm({ + title: `确认删除二维码 #${row.id}?`, + content: '删除后不可恢复;已发放给用户的图片不受影响。', + okText: '删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await deleteAdminQrCode(row.id) + message.success('二维码已删除') + await invalidate() + } catch (error) { + message.error(errorText(error)) + } + }, + }) + } + + const batchRemove = () => { + if (selectedRowKeys.length === 0) return + modal.confirm({ + title: `确认批量删除 ${selectedRowKeys.length} 张二维码?`, + content: '未使用与已停用的二维码会被直接删除,操作不可恢复。', + okText: '批量删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + const deleted = await batchDeleteAdminQrCodes(selectedRowKeys) + message.success(`已删除 ${deleted} 张二维码`) + setSelectedRowKeys([]) + await invalidate() + } catch (error) { + message.error(errorText(error)) + } + }, + }) + } + + const upload = async (file: File) => { + setUploading(true) + try { + const uploaded = await uploadFile(file, 'qrcode') + setImageURL(uploaded.url) + message.success('图片已上传') + } catch (error) { + message.error(errorText(error)) + } finally { + setUploading(false) + } + return false + } + + const columns: TableColumnsType = [ + { + title: '二维码', + key: 'image', + width: 120, + render: (_, row) => ( + {`二维码 + ), + }, + { + title: '群名 / 备注', + key: 'group', + width: 240, + render: (_, row) => ( +
+ {formatText(row.group_name)} + {formatText(row.note)} +
+ ), + }, + { + title: '状态', + key: 'status', + width: 170, + render: (_, row) => { + const meta = statusMeta[row.status] ?? { label: row.status, color: 'default' } + return ( + + + {meta.label} + + {row.wecom_renamed ? ( + + 企微已改名 + + ) : null} + + ) + }, + }, + { + title: '绑定会话', + key: 'bound', + render: (_, row) => formatText(row.bound_conversation_title || row.conversation_id), + }, + { + title: '发放 / 使用时间', + key: 'time', + width: 190, + render: (_, row) => ( +
+ {formatDateTime(row.used_at)} + 创建 {formatDateTime(row.created_at)} +
+ ), + }, + { + title: '过期时间', + dataIndex: 'expires_at', + width: 170, + render: value => (value ? formatDateTime(value) : '—'), + }, + { + title: '操作', + key: 'actions', + width: 150, + fixed: 'right', + render: (_, row) => ( + + + + + ), + }, + ] + + if (!canView) { + return ( +
+ + +
+ ) + } + + return ( +
+ + 未使用 {stats?.unused_count ?? 0} + 已使用 {stats?.used_count ?? 0} + 已停用 {stats?.disabled_count ?? 0} + 总数 {stats?.total_count ?? 0} + + } + extra={ + + + + + + } + /> + + { + setPage(1) + setApplied(filters) + }} + onReset={() => { + setFilters(emptyFilters) + setApplied(emptyFilters) + setPage(1) + }} + > + + + setFilters(current => ({ ...current, keyword: event.target.value })) + } + /> + + + setFilters(current => ({ ...current, bound: value }))} + /> + + + setFilters(current => ({ ...current, wecom_renamed: value }))} + /> + + + + {qrcodesQuery.isError ? ( + + ) : null} + + + columns={columns} + dataSource={rows} + rowKey="id" + loading={qrcodesQuery.isFetching} + total={qrcodesQuery.data?.total ?? 0} + page={qrcodesQuery.data?.page ?? page} + pageSize={qrcodesQuery.data?.page_size ?? limit} + onPageChange={(nextPage, nextSize) => { + setPage(nextPage) + setLimit(nextSize) + }} + rowSelection={{ + selectedRowKeys, + onChange: keys => setSelectedRowKeys(keys as number[]), + getCheckboxProps: () => ({ disabled: !canManage }), + }} + emptyDescription="二维码池为空,请先上传群二维码" + /> + + void submitCreate()} + onCancel={() => (submitting ? undefined : setCreateOpen(false))} + maskClosable={!submitting} + > +
+ + + {imageURL ? ( + 二维码预览 + ) : null} + { + void upload(file as File) + return false + }} + > + + + + + + + + + + + + + +
+
+ + void submitEdit()} + onCancel={() => (submitting ? undefined : setEditing(undefined))} + maskClosable={!submitting} + > +
+ + + + + + + +