From 4554f1d5253c95f3756f32fb47981559d48ad438 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 30 Oct 2026 13:36:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=96=B0=E5=A2=9E=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E5=88=86=E9=94=80=E7=AB=99=E7=82=B9=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/Phase1-验收报告.md | 6 +- docs/Phase2-分销站点验收.md | 96 +++ docs/项目计划.md | 2 +- frontend/src/app/AppRouter.tsx | 22 +- .../admin/AdminDistributionSitesPage.tsx | 795 ++++++++++++++++++ frontend/src/features/admin/api.ts | 20 + frontend/src/features/admin/labels.ts | 18 + frontend/src/features/admin/types.ts | 46 + 8 files changed, 985 insertions(+), 20 deletions(-) create mode 100644 docs/Phase2-分销站点验收.md create mode 100644 frontend/src/features/admin/AdminDistributionSitesPage.tsx diff --git a/docs/Phase1-验收报告.md b/docs/Phase1-验收报告.md index 27f3534..26cbcb9 100644 --- a/docs/Phase1-验收报告.md +++ b/docs/Phase1-验收报告.md @@ -194,12 +194,12 @@ dist/assets/AdminLayout-*.js 36.87 kB │ gzip 13.02 kB ## 5. 已知限制 1. 移动端订单列表/详情与消息中心已接入真实接口;下单、支付、交接、归还、结账等操作仍待迁移。 -2. 后台已完成仪表盘、订单列表/详情、退款审核、提现审核、商品列表/详情与审核、用户列表/详情与人工操作、申诉仲裁、财务看板、公告管理、审计日志、系统配置,其余页面仍为统一占位。 +2. 后台导航已完成仪表盘、订单列表/详情、退款审核、提现审核、商品列表/详情与审核、用户列表/详情与人工操作、申诉仲裁、财务看板、公告管理、审计日志、系统配置、分销站点,长尾能力仍待迁移。 3. 分销后台已完成主题、Logo、登录页与 Shell 轻改,页面结构未重写。 -4. 后台菜单权限码按后端逐个核对:订单 `order:list` / `order:view` / `order:close`,商品 `listing:view` / `listing:approve` / `listing:reject` / `listing:offline`,用户 `user:view` / `user:freeze` / `user:unfreeze` / `user:deposit_free` / `user:growth_points` / `user:manual_realname` / `user:revoke_realname` / `user:wallet_adjust`,提现 `withdrawal:list` / `withdrawal:detail` / `withdrawal:review` / `withdrawal:pay`,申诉 `dispute:view` / `dispute:arbitrate`,财务 `wallet:view`,公告 `announcement:view` / `announcement:manage`,审计日志 `audit_log:view`,系统配置 `system_config:view` / `system_config:update`;分销站点仍需核对。 +4. 后台菜单权限码按后端逐个核对:订单 `order:list` / `order:view` / `order:close`,商品 `listing:view` / `listing:approve` / `listing:reject` / `listing:offline`,用户 `user:view` / `user:freeze` / `user:unfreeze` / `user:deposit_free` / `user:growth_points` / `user:manual_realname` / `user:revoke_realname` / `user:wallet_adjust`,提现 `withdrawal:list` / `withdrawal:detail` / `withdrawal:review` / `withdrawal:pay`,申诉 `dispute:view` / `dispute:arbitrate`,财务 `wallet:view`,公告 `announcement:view` / `announcement:manage`,审计日志 `audit_log:view`,系统配置 `system_config:view` / `system_config:update`,分销站点 `distribution:site_view` / `distribution:site_manage`(列表也接受 `qrcode:view`);当前菜单权限码已全部对照后端。 5. SVG 中文尚未转曲。 6. 入口包已降至 495 kB(gzip 161 kB);AntD 共享基础设施仍在入口,后续可拆独立入口。 -7. 移动端、订单、分销轻改与首屏优化均已提交;商品管理、审核、退款、提现、用户、申诉、财务、公告、审计日志与系统配置已按批次提交。 +7. 移动端、订单、分销轻改与首屏优化均已提交;商品管理、审核、退款、提现、用户、申诉、财务、公告、审计日志、系统配置与分销站点已按批次提交。 --- diff --git a/docs/Phase2-分销站点验收.md b/docs/Phase2-分销站点验收.md new file mode 100644 index 0000000..81e5317 --- /dev/null +++ b/docs/Phase2-分销站点验收.md @@ -0,0 +1,96 @@ +# Phase 2 验收报告(后台分销站点) + +> 日期:2026-10-30 +> 项目:解忧商行(hfb_jysh) +> 范围:`/console-jy8k3m/distribution/sites` +> 结论:**本切片通过,分销站点列表、详情、基础资料编辑与启停已接真实接口** + +--- + +## 1. 接口与权限 + +| 能力 | 接口 | 权限码 | +| --- | --- | --- | +| 站点列表 | `GET /api/admin/distribution/sites` | `distribution:site_view` 或 `qrcode:view` | +| 站点详情 | `GET /api/admin/distribution/sites/:id` | `distribution:site_view` | +| 更新站点 | `PUT /api/admin/distribution/sites/:id` | `distribution:site_manage` | + +后端更新规则: + +- 超级管理员或具备全量分销权限的账号可修改站点名称、域名、状态、展示账号、发布二维码、分成系数、税费与结算冷却。 +- 站点成员(`member_type` 非空)只能修改本站 `receive_ratio_delta` 与 `receive_cost_delta`,其他字段会被后端忽略。 +- `receive_ratio_delta` 与 `receive_cost_delta` 取值 0–20,超出会被归一化。 +- `settlement_hold_days` 取值 0–365。 +- 状态仅允许 `active` / `disabled`。 +- 专属域名与其他站点重复时更新失败。 + +## 2. 页面 + +`frontend/src/features/admin/AdminDistributionSitesPage.tsx` + +- 读取全量可见站点,前端按关键字与状态即时筛选 +- 关键字覆盖站点名称、站点标识、域名、站长账号与昵称 +- 列:站点(名称/标识/域名)、站长、状态、用户、分成与结算、创建时间、操作 +- 顶部统计:站点总数、运营中数量 +- 「前往分销后台」按 `VITE_DISTRIBUTION_ADMIN_URL` 打开现有分销后台 +- 无查看权限时停止请求并显示提示 +- 无 `distribution:site_manage` 时只读 + +## 3. 详情抽屉 + +- 站点概览:名称、标识、专属域名、状态、展示账号、公告模式、创建/更新时间 +- 站长与成员:站长账号、成员角色、默认客服群、用户/下单用户数 +- 分成与结算:收号比例系数、收号成本系数、结算冷却、税费 +- 品牌与页面配置:Logo、Favicon、发布二维码;轮播/公告/页脚/回收入口/合作反馈/卖场文案是否已配置 +- 底部操作:编辑站点、停用/启用站点(仅全量权限账号) + +## 4. 编辑弹窗 + +全量权限账号: + +- 站点名称、专属域名、站点状态 +- 展示账号开关 +- 结算冷却天数 0–365 +- 发布二维码地址 +- 税费开关与税率(bps,100 bps = 1%) +- 收号比例调整点数、收号成本调整点数 0–20 + +站点成员: + +- 只展示收号比例与收号成本两个字段 +- 弹窗内提示「只能调整本站分成系数」 + +所有更新保存前二次确认;更新成功后回填详情并刷新列表。 + +## 5. 菜单与路由 + +- 路由:`frontend/src/app/AppRouter.tsx` 新增 `distribution/sites` +- 菜单:`frontend/src/layouts/AdminLayout.tsx` 使用 `distribution:site_view` +- 移除最后一个后台占位页面映射,未知路由仍进入统一占位 + +## 6. 验证结果 + +```text +npx tsc --noEmit 通过 +npm run build 通过(入口 497.59 kB,gzip 161.33 kB) +GET /api/admin/distribution/sites ok,开发库当前 0 个站点 +GET /console-jy8k3m/distribution/sites 200 +``` + +开发库没有分销站点,列表读取已实测为空;未执行 `PUT` 写操作,避免创建或改动分销站点。 + +## 7. 已知限制 + +1. 不提供新建站点/一键开通分销商,开通仍走现有分销后台流程,避免重复实现站长账号与初始权限逻辑。 +2. 不提供删除站点、重置站长密码。 +3. 品牌素材、轮播、公告、页脚等 JSON 不在本页编辑,仍由分销后台或系统配置维护。 +4. 站点钱包、提现审核、短信配置、邀请配置、二维码池、收货商品列表尚未迁入主后台。 +5. 站点成员只能调整分成系数,表单按后端实际权限收窄。 + +## 8. 下一步建议 + +1. 站点钱包与提现审核。 +2. 分销规则版本与结算快照。 +3. 分销后台品牌素材配置迁移。 +4. 线下出款/运营支出登记。 +5. 移动端下单、支付与交接/归还/结账。 diff --git a/docs/项目计划.md b/docs/项目计划.md index 072eb9e..e0bd03f 100644 --- a/docs/项目计划.md +++ b/docs/项目计划.md @@ -395,7 +395,7 @@ hfb_jysh/ ### Phase 4:管理后台核心(4–5 周) -> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、公告管理、审计日志与系统配置;线下出款/运营支出待迁移。 +> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、公告管理、审计日志、系统配置与分销站点;线下出款/运营支出及分销长尾能力待迁移。 - 后台登录、工作台、订单列表、订单详情。 - 商品列表、商品详情、审核。 diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 89bad83..460e700 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -113,6 +113,11 @@ const AdminSystemConfigsPage = lazy(() => default: module.AdminSystemConfigsPage, })) ) +const AdminDistributionSitesPage = lazy(() => + import('@/features/admin/AdminDistributionSitesPage').then(module => ({ + default: module.AdminDistributionSitesPage, + })) +) const AdminLayout = lazy(() => import('@/layouts/AdminLayout').then(module => ({ default: module.AdminLayout })) ) @@ -120,10 +125,6 @@ const AdminSectionPlaceholder = lazy(() => import('@/layouts/AdminLayout').then(module => ({ default: module.AdminSectionPlaceholder })) ) -const adminSections: Array<{ path: string; title: string; description: string }> = [ - { path: 'distribution/sites', title: '分销站点', description: '管理分站、规则、结算与站点钱包。' }, -] - export function AppRouter() { const adminBase = import.meta.env.VITE_ADMIN_BASE_PATH || '/console-jy8k3m' @@ -164,18 +165,7 @@ export function AppRouter() { } /> } /> } /> - {adminSections.map(section => ( - - } - /> - ))} + } /> field.value) + + return [ + { + key: 'overview', + title: '站点概览', + icon: , + fields: [ + { key: 'name', label: '站点名称', value: site.site_name }, + { key: 'code', label: '站点标识', value: {site.site_code} }, + { + key: 'domain', + label: '专属域名', + value: site.domain ? ( + + {site.domain} + + ) : ( + '—' + ), + }, + { + key: 'status', + label: '站点状态', + value: , + }, + { + key: 'display_account', + label: '展示账号', + value: site.display_account_enabled ? '开启' : '关闭', + }, + { + key: 'announcement_mode', + label: '公告模式', + value: announcementModeMap[site.announcement_mode] ?? formatText(site.announcement_mode), + }, + { key: 'created', label: '创建时间', value: formatDateTime(site.created_at) }, + { key: 'updated', label: '更新时间', value: formatDateTime(site.updated_at) }, + ], + }, + { + key: 'manager', + title: '站长与成员', + fields: [ + { + key: 'admin', + label: '绑定站长', + value: ( +
+ {adminName(site)} + + {site.admin_user_id != null ? `ID ${site.admin_user_id}` : '—'} + +
+ ), + }, + { + key: 'member_type', + label: '成员角色', + value: site.member_type + ? distributionMemberTypeMap[site.member_type] ?? site.member_type + : '平台全量可见', + }, + { + key: 'support_group', + label: '默认客服群', + value: + site.default_support_group_id != null + ? `群 #${site.default_support_group_id}` + : '未绑定', + }, + { + key: 'counts', + label: '用户 / 下单用户', + value: `${site.user_count} / ${site.order_user_count}`, + }, + ], + }, + { + key: 'settlement', + title: '分成与结算', + fields: [ + { + key: 'ratio_delta', + label: '收号比例系数', + value: {site.receive_ratio_delta} 个点, + }, + { + key: 'cost_delta', + label: '收号成本系数', + value: {site.receive_cost_delta} 个点, + }, + { + key: 'hold_days', + label: '结算冷却', + value: `${site.settlement_hold_days} 天`, + }, + { + key: 'tax', + label: '税费', + value: site.tax_enabled ? formatBps(site.tax_rate_bps) : '未启用', + }, + ], + }, + { + key: 'assets', + title: '品牌与页面配置', + fields: [ + ...(brandFields.length > 0 + ? brandFields.map(field => ({ + key: field.key, + label: field.label, + value: ( + + {field.value} + + ), + })) + : [ + { + key: 'brand_empty', + label: '品牌素材', + value: '沿用主站品牌素材', + }, + ]), + { + key: 'configs', + label: '页面配置', + span: 2, + value: ( + + 轮播 + 公告 + 页脚 + 回收入口 + + 合作反馈 + + 卖场文案 + + ), + }, + ], + }, + ] +} + +function buildColumns( + onOpen: (site: AdminDistributionSite) => void, + onEdit: (site: AdminDistributionSite) => void, + canManage: boolean +): TableColumnsType { + return [ + { + title: '站点', + dataIndex: 'site_name', + width: 280, + fixed: 'left', + render: (_, row) => ( +
+ + {row.site_code} + {row.domain || '未绑定专属域名'} +
+ ), + }, + { + title: '站长', + key: 'admin', + width: 190, + render: (_, row) => ( +
+ {adminName(row)} + + {row.member_type + ? distributionMemberTypeMap[row.member_type] ?? row.member_type + : '平台账号'} + + + {row.admin_user_id != null ? `ID ${row.admin_user_id}` : '—'} + +
+ ), + }, + { + title: '状态', + dataIndex: 'status', + width: 100, + render: value => , + }, + { + title: '用户', + key: 'users', + width: 130, + align: 'right', + render: (_, row) => ( +
+ {row.user_count} + 下单 {row.order_user_count} +
+ ), + }, + { + title: '分成 / 结算', + key: 'settlement', + width: 190, + render: (_, row) => ( +
+ 比例 {row.receive_ratio_delta} 点 + 成本 {row.receive_cost_delta} 点 + + 冷却 {row.settlement_hold_days} 天 ·{' '} + {row.tax_enabled ? formatBps(row.tax_rate_bps) : '无税'} + +
+ ), + }, + { + title: '创建时间', + dataIndex: 'created_at', + width: 160, + render: value => formatDateTime(value), + }, + { + title: '操作', + key: 'actions', + width: 150, + fixed: 'right', + render: (_, row) => ( + + + {canManage ? ( + + ) : null} + + ), + }, + ] +} + +export function AdminDistributionSitesPage() { + const { message, modal } = App.useApp() + const [form] = Form.useForm() + const [keyword, setKeyword] = useState('') + const [status, setStatus] = useState('') + const [selected, setSelected] = useState(null) + const [detailOpen, setDetailOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [saving, setSaving] = useState(false) + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const canView = Boolean( + permissions?.includes('*') || + permissions?.includes('distribution:site_view') || + permissions?.includes('qrcode:view') + ) + const canManage = Boolean( + permissions?.includes('*') || permissions?.includes('distribution:site_manage') + ) + + const { data, isPending, isFetching, isError, error, refetch } = useQuery({ + queryKey: ['admin-distribution-sites'], + queryFn: fetchAdminDistributionSites, + enabled: canView, + }) + + const sites = data ?? [] + + const filtered = useMemo(() => { + const text = keyword.trim().toLowerCase() + return sites.filter(site => { + if (status && site.status !== status) return false + if (!text) return true + return [site.site_name, site.site_code, site.domain, site.admin_username, site.admin_nickname] + .filter(Boolean) + .some(value => String(value).toLowerCase().includes(text)) + }) + }, [sites, keyword, status]) + + const limited = Boolean(editing?.member_type) + const errorText = error instanceof Error ? error.message : '请稍后重试或重新登录后台。' + const distributionAdminUrl = import.meta.env.VITE_DISTRIBUTION_ADMIN_URL + const hasActiveFilters = Boolean(keyword || status) + + const openDetail = (site: AdminDistributionSite) => { + setSelected(site) + setDetailOpen(true) + } + + const openEditor = (site: AdminDistributionSite) => { + setEditing(site) + form.setFieldsValue({ + site_name: site.site_name, + domain: site.domain, + status: site.status === 'disabled' ? 'disabled' : 'active', + display_account_enabled: site.display_account_enabled, + listing_qrcode_url: site.listing_qrcode_url, + receive_ratio_delta: site.receive_ratio_delta, + receive_cost_delta: site.receive_cost_delta, + tax_enabled: site.tax_enabled, + tax_rate_bps: site.tax_rate_bps, + settlement_hold_days: site.settlement_hold_days, + }) + } + + const closeEditor = () => { + if (saving) return + setEditing(null) + } + + const applyUpdated = (updated: AdminDistributionSite) => { + setSelected(current => (current?.id === updated.id ? updated : current)) + } + + const saveSite = async () => { + if (!editing) return + let values: SiteFormValues + try { + values = await form.validateFields() + } catch { + return + } + + const payload: AdminDistributionSiteUpdatePayload = limited + ? { + receive_ratio_delta: Number(values.receive_ratio_delta), + receive_cost_delta: Number(values.receive_cost_delta), + } + : { + site_name: values.site_name?.trim(), + domain: values.domain?.trim(), + status: values.status, + display_account_enabled: values.display_account_enabled, + listing_qrcode_url: values.listing_qrcode_url?.trim(), + receive_ratio_delta: Number(values.receive_ratio_delta), + receive_cost_delta: Number(values.receive_cost_delta), + tax_enabled: values.tax_enabled, + tax_rate_bps: values.tax_enabled ? Number(values.tax_rate_bps) : 0, + settlement_hold_days: Number(values.settlement_hold_days), + } + + modal.confirm({ + title: `确认更新「${editing.site_name}」?`, + content: limited + ? '站长账号只能调整本站分成系数,其他站点资料不会被修改。' + : '站点名称、域名、分成系数与结算冷却会立即影响前台展示和后续订单结算,请确认已核对。', + okText: '确认更新', + cancelText: '再检查一下', + onOk: async () => { + setSaving(true) + try { + const updated = await updateAdminDistributionSite(editing.id, payload) + message.success('分销站点已更新') + applyUpdated(updated) + setEditing(null) + await refetch() + } catch (err) { + message.error(err instanceof Error ? err.message : '更新失败,请稍后重试') + } finally { + setSaving(false) + } + }, + }) + } + + const toggleStatus = (site: AdminDistributionSite) => { + const next = site.status === 'active' ? 'disabled' : 'active' + const actionText = next === 'active' ? '启用' : '停用' + modal.confirm({ + title: `确认${actionText}「${site.site_name}」?`, + content: + next === 'active' + ? '启用后站点域名与前台页面会恢复对外服务。' + : '停用后站点前台会停止服务,请先确认站内订单与结算已经处理完毕。', + okText: `确认${actionText}`, + okButtonProps: { danger: next === 'disabled' }, + cancelText: '取消', + onOk: async () => { + try { + const updated = await updateAdminDistributionSite(site.id, { status: next }) + message.success(`站点已${actionText}`) + applyUpdated(updated) + await refetch() + } catch (err) { + message.error(err instanceof Error ? err.message : `${actionText}失败,请稍后重试`) + } + }, + }) + } + + const columns = useMemo( + () => buildColumns(openDetail, openEditor, canManage), + [canManage] + ) + + const detailFooter = selected ? ( + + + {canManage ? ( + + ) : null} + {canManage && !selected.member_type ? ( + + ) : null} + + ) : null + + return ( +
+ + 共 {sites.length} 个站点 + + 运营中 {sites.filter(item => item.status === 'active').length} + + {isFetching && !isPending ? 正在更新 : null} + + } + extra={ + + {distributionAdminUrl ? ( + + ) : null} + + + } + /> + + {!canView ? ( + + ) : null} + + { + setKeyword('') + setStatus('') + } + : undefined + } + loading={isFetching} + extra={ + + {filtered.length === sites.length + ? '显示全部站点' + : `筛选出 ${filtered.length} / ${sites.length} 个站点`} + + } + > + + setKeyword(event.target.value)} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + + + + + + + + + + + + + ) : null} + +
+ ) +} diff --git a/frontend/src/features/admin/api.ts b/frontend/src/features/admin/api.ts index 4f60031..2674b5f 100644 --- a/frontend/src/features/admin/api.ts +++ b/frontend/src/features/admin/api.ts @@ -12,6 +12,8 @@ import type { AdminDispute, AdminDisputePage, AdminDisputeQuery, + AdminDistributionSite, + AdminDistributionSiteUpdatePayload, AdminListing, AdminListingPage, AdminListingQuery, @@ -442,3 +444,21 @@ export async function updateAdminSystemConfig( ) return data.data } + +export async function fetchAdminDistributionSites() { + const { data } = await adminClient.get>( + '/admin/distribution/sites' + ) + return data.data.items ?? [] +} + +export async function updateAdminDistributionSite( + id: number | string, + payload: AdminDistributionSiteUpdatePayload +) { + const { data } = await adminClient.put>( + `/admin/distribution/sites/${id}`, + payload + ) + return data.data +} diff --git a/frontend/src/features/admin/labels.ts b/frontend/src/features/admin/labels.ts index 144e7dd..1374163 100644 --- a/frontend/src/features/admin/labels.ts +++ b/frontend/src/features/admin/labels.ts @@ -336,3 +336,21 @@ export function systemConfigCategoryLabel(key: string) { const category = systemConfigCategory(key) return systemConfigCategoryMap[category] ?? category } + +export const distributionSiteStatusMap: Record = { + active: { label: '运营中', tone: 'success' }, + disabled: { label: '已停用', tone: 'default' }, +} + +export const distributionSiteStatusOptions = toOptions(distributionSiteStatusMap) + +export const distributionMemberTypeMap: Record = { + operator: '运营', + finance: '财务', + customer_service: '客服', +} + +export const announcementModeMap: Record = { + inherit: '继承主站公告', + own_only: '仅本站公告', +} diff --git a/frontend/src/features/admin/types.ts b/frontend/src/features/admin/types.ts index 2f1a1a9..f53bce3 100644 --- a/frontend/src/features/admin/types.ts +++ b/frontend/src/features/admin/types.ts @@ -622,3 +622,49 @@ export interface AdminSystemConfigUpdatePayload { value: string description: string } + +export interface AdminDistributionSite { + id: number + site_code: string + site_name: string + domain: string + status: string + display_account_enabled: boolean + logo_url: string + favicon_url: string + banners_json: string + announcements_json: string + footer_info_json: string + aw_recycle_json: string + cooperation_feedback_json: string + storefront_text_json: string + announcement_mode: string + default_support_group_id?: number | null + listing_qrcode_url: string + receive_ratio_delta: number + receive_cost_delta: number + tax_enabled: boolean + tax_rate_bps: number + settlement_hold_days: number + admin_user_id?: number | null + admin_username?: string + admin_nickname?: string + member_type?: string + user_count: number + order_user_count: number + created_at: string + updated_at: string +} + +export interface AdminDistributionSiteUpdatePayload { + site_name?: string + domain?: string + status?: 'active' | 'disabled' + display_account_enabled?: boolean + listing_qrcode_url?: string + receive_ratio_delta?: number + receive_cost_delta?: number + tax_enabled?: boolean + tax_rate_bps?: number + settlement_hold_days?: number +}