feat(admin): 新增后台分销站点管理
This commit is contained in:
+3
-3
@@ -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. 移动端、订单、分销轻改与首屏优化均已提交;商品管理、审核、退款、提现、用户、申诉、财务、公告、审计日志、系统配置与分销站点已按批次提交。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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. 移动端下单、支付与交接/归还/结账。
|
||||
+1
-1
@@ -395,7 +395,7 @@ hfb_jysh/
|
||||
|
||||
### Phase 4:管理后台核心(4–5 周)
|
||||
|
||||
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、公告管理、审计日志与系统配置;线下出款/运营支出待迁移。
|
||||
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、公告管理、审计日志、系统配置与分销站点;线下出款/运营支出及分销长尾能力待迁移。
|
||||
|
||||
- 后台登录、工作台、订单列表、订单详情。
|
||||
- 商品列表、商品详情、审核。
|
||||
|
||||
@@ -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() {
|
||||
<Route path="announcements" element={<AdminAnnouncementsPage />} />
|
||||
<Route path="audit-logs" element={<AdminAuditLogsPage />} />
|
||||
<Route path="system-configs" element={<AdminSystemConfigsPage />} />
|
||||
{adminSections.map(section => (
|
||||
<Route
|
||||
key={section.path}
|
||||
path={section.path}
|
||||
element={
|
||||
<AdminSectionPlaceholder
|
||||
title={section.title}
|
||||
description={section.description}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Route path="distribution/sites" element={<AdminDistributionSitesPage />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,795 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Tag,
|
||||
Typography,
|
||||
type TableColumnsType,
|
||||
} from 'antd'
|
||||
import {
|
||||
EditOutlined,
|
||||
ExportOutlined,
|
||||
EyeOutlined,
|
||||
ReloadOutlined,
|
||||
ShopOutlined,
|
||||
} from '@ant-design/icons'
|
||||
|
||||
import { fetchAdminDistributionSites, updateAdminDistributionSite } from '@/features/admin/api'
|
||||
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
|
||||
import { DataTable } from '@/features/admin/components/DataTable'
|
||||
import { DetailDrawer, type DetailSection } from '@/features/admin/components/DetailDrawer'
|
||||
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
|
||||
import { StatusTag } from '@/features/admin/components/StatusTag'
|
||||
import {
|
||||
announcementModeMap,
|
||||
distributionMemberTypeMap,
|
||||
distributionSiteStatusMap,
|
||||
distributionSiteStatusOptions,
|
||||
readStatusMeta,
|
||||
} from '@/features/admin/labels'
|
||||
import type {
|
||||
AdminDistributionSite,
|
||||
AdminDistributionSiteUpdatePayload,
|
||||
} from '@/features/admin/types'
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
import { formatDateTime, formatText } from '@/shared/utils/format'
|
||||
|
||||
interface SiteFormValues {
|
||||
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
|
||||
}
|
||||
|
||||
function formatBps(bps?: number | null) {
|
||||
if (bps == null) return '—'
|
||||
return `${(bps / 100).toFixed(2)}%`
|
||||
}
|
||||
|
||||
function hasJson(value?: string) {
|
||||
const text = value?.trim()
|
||||
return Boolean(text && text !== '{}' && text !== '[]' && text !== 'null')
|
||||
}
|
||||
|
||||
function siteUrl(domain?: string) {
|
||||
const text = domain?.trim()
|
||||
if (!text) return ''
|
||||
return /^https?:\/\//.test(text) ? text : `https://${text}`
|
||||
}
|
||||
|
||||
function adminName(site: AdminDistributionSite) {
|
||||
if (site.admin_nickname || site.admin_username) {
|
||||
return site.admin_nickname || site.admin_username || ''
|
||||
}
|
||||
return site.admin_user_id != null ? `管理员 #${site.admin_user_id}` : '未绑定站长'
|
||||
}
|
||||
|
||||
function buildSections(site: AdminDistributionSite): DetailSection[] {
|
||||
const brandFields = [
|
||||
{ key: 'logo', label: 'Logo', value: site.logo_url },
|
||||
{ key: 'favicon', label: 'Favicon', value: site.favicon_url },
|
||||
{ key: 'qrcode', label: '发布二维码', value: site.listing_qrcode_url },
|
||||
].filter(field => field.value)
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'overview',
|
||||
title: '站点概览',
|
||||
icon: <ShopOutlined />,
|
||||
fields: [
|
||||
{ key: 'name', label: '站点名称', value: site.site_name },
|
||||
{ key: 'code', label: '站点标识', value: <span className="admin-mono">{site.site_code}</span> },
|
||||
{
|
||||
key: 'domain',
|
||||
label: '专属域名',
|
||||
value: site.domain ? (
|
||||
<a href={siteUrl(site.domain)} target="_blank" rel="noreferrer">
|
||||
{site.domain}
|
||||
</a>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '站点状态',
|
||||
value: <StatusTag meta={readStatusMeta(distributionSiteStatusMap, site.status)} />,
|
||||
},
|
||||
{
|
||||
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: (
|
||||
<div className="admin-stack">
|
||||
<strong>{adminName(site)}</strong>
|
||||
<span className="admin-mono">
|
||||
{site.admin_user_id != null ? `ID ${site.admin_user_id}` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
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: <span className="admin-mono">{site.receive_ratio_delta} 个点</span>,
|
||||
},
|
||||
{
|
||||
key: 'cost_delta',
|
||||
label: '收号成本系数',
|
||||
value: <span className="admin-mono">{site.receive_cost_delta} 个点</span>,
|
||||
},
|
||||
{
|
||||
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: (
|
||||
<a href={field.value} target="_blank" rel="noreferrer" className="admin-mono">
|
||||
{field.value}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
: [
|
||||
{
|
||||
key: 'brand_empty',
|
||||
label: '品牌素材',
|
||||
value: '沿用主站品牌素材',
|
||||
},
|
||||
]),
|
||||
{
|
||||
key: 'configs',
|
||||
label: '页面配置',
|
||||
span: 2,
|
||||
value: (
|
||||
<Space size={4} wrap>
|
||||
<Tag color={hasJson(site.banners_json) ? 'blue' : undefined}>轮播</Tag>
|
||||
<Tag color={hasJson(site.announcements_json) ? 'blue' : undefined}>公告</Tag>
|
||||
<Tag color={hasJson(site.footer_info_json) ? 'blue' : undefined}>页脚</Tag>
|
||||
<Tag color={hasJson(site.aw_recycle_json) ? 'blue' : undefined}>回收入口</Tag>
|
||||
<Tag color={hasJson(site.cooperation_feedback_json) ? 'blue' : undefined}>
|
||||
合作反馈
|
||||
</Tag>
|
||||
<Tag color={hasJson(site.storefront_text_json) ? 'blue' : undefined}>卖场文案</Tag>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function buildColumns(
|
||||
onOpen: (site: AdminDistributionSite) => void,
|
||||
onEdit: (site: AdminDistributionSite) => void,
|
||||
canManage: boolean
|
||||
): TableColumnsType<AdminDistributionSite> {
|
||||
return [
|
||||
{
|
||||
title: '站点',
|
||||
dataIndex: 'site_name',
|
||||
width: 280,
|
||||
fixed: 'left',
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<button type="button" className="admin-order-link" onClick={() => onOpen(row)}>
|
||||
{row.site_name}
|
||||
</button>
|
||||
<span className="admin-mono">{row.site_code}</span>
|
||||
<span>{row.domain || '未绑定专属域名'}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '站长',
|
||||
key: 'admin',
|
||||
width: 190,
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<strong>{adminName(row)}</strong>
|
||||
<span>
|
||||
{row.member_type
|
||||
? distributionMemberTypeMap[row.member_type] ?? row.member_type
|
||||
: '平台账号'}
|
||||
</span>
|
||||
<span className="admin-mono">
|
||||
{row.admin_user_id != null ? `ID ${row.admin_user_id}` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: value => <StatusTag meta={readStatusMeta(distributionSiteStatusMap, value)} />,
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
key: 'users',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
render: (_, row) => (
|
||||
<div className="admin-amount">
|
||||
<strong>{row.user_count}</strong>
|
||||
<span>下单 {row.order_user_count}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '分成 / 结算',
|
||||
key: 'settlement',
|
||||
width: 190,
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<span className="admin-mono">比例 {row.receive_ratio_delta} 点</span>
|
||||
<span className="admin-mono">成本 {row.receive_cost_delta} 点</span>
|
||||
<span>
|
||||
冷却 {row.settlement_hold_days} 天 ·{' '}
|
||||
{row.tax_enabled ? formatBps(row.tax_rate_bps) : '无税'}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 160,
|
||||
render: value => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => onOpen(row)}>
|
||||
详情
|
||||
</Button>
|
||||
{canManage ? (
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => onEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function AdminDistributionSitesPage() {
|
||||
const { message, modal } = App.useApp()
|
||||
const [form] = Form.useForm<SiteFormValues>()
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [selected, setSelected] = useState<AdminDistributionSite | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<AdminDistributionSite | null>(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 ? (
|
||||
<Space>
|
||||
<Button onClick={() => setDetailOpen(false)}>关闭</Button>
|
||||
{canManage ? (
|
||||
<Button icon={<EditOutlined />} onClick={() => openEditor(selected)}>
|
||||
编辑站点
|
||||
</Button>
|
||||
) : null}
|
||||
{canManage && !selected.member_type ? (
|
||||
<Button
|
||||
danger={selected.status === 'active'}
|
||||
type={selected.status === 'active' ? 'default' : 'primary'}
|
||||
onClick={() => toggleStatus(selected)}
|
||||
>
|
||||
{selected.status === 'active' ? '停用站点' : '启用站点'}
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader
|
||||
eyebrow="JIEYOU OPS · 分销"
|
||||
title="分销站点"
|
||||
description="查看分站域名、站长、分成系数与结算冷却;开通分销商沿用现有分销后台流程。"
|
||||
meta={
|
||||
<Space size={8} wrap>
|
||||
<Tag color="geekblue">共 {sites.length} 个站点</Tag>
|
||||
<Tag color="green">
|
||||
运营中 {sites.filter(item => item.status === 'active').length}
|
||||
</Tag>
|
||||
{isFetching && !isPending ? <Tag color="processing">正在更新</Tag> : null}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
{distributionAdminUrl ? (
|
||||
<Button
|
||||
icon={<ExportOutlined />}
|
||||
href={distributionAdminUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
前往分销后台
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => void refetch()}
|
||||
loading={isFetching}
|
||||
disabled={!canView}
|
||||
>
|
||||
刷新数据
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
{!canView ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="当前账号没有分销站点查看权限"
|
||||
description="需要 distribution:site_view 或 qrcode:view 权限才能读取站点列表。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<FilterBar
|
||||
onReset={
|
||||
hasActiveFilters
|
||||
? () => {
|
||||
setKeyword('')
|
||||
setStatus('')
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
loading={isFetching}
|
||||
extra={
|
||||
<Typography.Text type="secondary">
|
||||
{filtered.length === sites.length
|
||||
? '显示全部站点'
|
||||
: `筛选出 ${filtered.length} / ${sites.length} 个站点`}
|
||||
</Typography.Text>
|
||||
}
|
||||
>
|
||||
<FilterField label="关键字" width={320}>
|
||||
<Input
|
||||
allowClear
|
||||
value={keyword}
|
||||
placeholder="站点名称 / 标识 / 域名 / 站长"
|
||||
onChange={event => setKeyword(event.target.value)}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="站点状态" width={180}>
|
||||
<Select
|
||||
allowClear
|
||||
value={status || undefined}
|
||||
placeholder="全部状态"
|
||||
options={distributionSiteStatusOptions}
|
||||
onChange={value => setStatus(value ?? '')}
|
||||
/>
|
||||
</FilterField>
|
||||
</FilterBar>
|
||||
|
||||
{isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="分销站点没有加载出来"
|
||||
description={errorText}
|
||||
action={
|
||||
<Button size="small" onClick={() => void refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DataTable<AdminDistributionSite>
|
||||
title={<span>站点列表</span>}
|
||||
extra={
|
||||
<Typography.Text type="secondary">
|
||||
{isPending ? '加载中' : `当前 ${filtered.length} 个站点`}
|
||||
</Typography.Text>
|
||||
}
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={filtered}
|
||||
loading={isFetching}
|
||||
onRowClick={openDetail}
|
||||
emptyDescription={
|
||||
canView
|
||||
? '还没有分销站点;需要开通时请前往分销后台创建站点与站长账号'
|
||||
: '当前账号没有查看权限'
|
||||
}
|
||||
/>
|
||||
|
||||
<DetailDrawer
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
title={selected?.site_name ?? '分销站点'}
|
||||
subtitle={
|
||||
selected
|
||||
? `${selected.site_code} · ${selected.domain || '未绑定域名'} · ${adminName(selected)}`
|
||||
: undefined
|
||||
}
|
||||
tags={
|
||||
selected ? (
|
||||
<Space size={6} wrap>
|
||||
<StatusTag meta={readStatusMeta(distributionSiteStatusMap, selected.status)} />
|
||||
{selected.member_type ? (
|
||||
<Tag>{distributionMemberTypeMap[selected.member_type] ?? selected.member_type}</Tag>
|
||||
) : (
|
||||
<Tag color="geekblue">平台全量</Tag>
|
||||
)}
|
||||
</Space>
|
||||
) : undefined
|
||||
}
|
||||
sections={selected ? buildSections(selected) : []}
|
||||
footer={detailFooter}
|
||||
width={820}
|
||||
emptyDescription="没有可展示的站点详情"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={Boolean(editing)}
|
||||
title={
|
||||
<div className="admin-detail-drawer__head">
|
||||
<div className="admin-detail-drawer__title">
|
||||
{limited ? '调整分成系数' : '编辑分销站点'}
|
||||
</div>
|
||||
{editing ? (
|
||||
<div className="admin-detail-drawer__subtitle">
|
||||
{editing.site_name} · {editing.site_code}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
width={760}
|
||||
onCancel={closeEditor}
|
||||
maskClosable={!saving}
|
||||
closable={!saving}
|
||||
destroyOnHidden
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={closeEditor} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" loading={saving} onClick={() => void saveSite()}>
|
||||
保存站点
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{editing ? (
|
||||
<Form<SiteFormValues> form={form} layout="vertical" disabled={saving}>
|
||||
{limited ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="站长账号编辑范围"
|
||||
description="当前账号是该站点成员,只能调整本站收号比例与收号成本系数,不能修改站点资料、域名或状态。"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="site_name"
|
||||
label="站点名称"
|
||||
rules={[{ required: true, message: '请输入站点名称' }]}
|
||||
>
|
||||
<Input maxLength={128} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="domain"
|
||||
label="专属域名"
|
||||
rules={[{ required: true, message: '请输入专属域名' }]}
|
||||
>
|
||||
<Input maxLength={255} placeholder="例如 sub.zuhaoc.com" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="status" label="站点状态">
|
||||
<Select options={distributionSiteStatusOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="display_account_enabled"
|
||||
label="展示账号"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="settlement_hold_days"
|
||||
label="结算冷却天数"
|
||||
rules={[{ required: true, message: '请输入结算冷却天数' }]}
|
||||
>
|
||||
<InputNumber min={0} max={365} precision={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="listing_qrcode_url" label="发布二维码地址">
|
||||
<Input placeholder="https://..." />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="tax_enabled" label="启用税费" valuePropName="checked">
|
||||
<Switch checkedChildren="启用" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="tax_rate_bps"
|
||||
label="税率(bps)"
|
||||
extra="100 bps = 1%,关闭税费时保存为 0"
|
||||
>
|
||||
<InputNumber min={0} max={10000} precision={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="receive_ratio_delta"
|
||||
label="收号比例调整点数"
|
||||
extra="范围 0-20,按后端分销定价规则参与结算"
|
||||
rules={[{ required: true, message: '请输入收号比例调整点数' }]}
|
||||
>
|
||||
<InputNumber min={0} max={20} step={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="receive_cost_delta"
|
||||
label="收号成本调整点数"
|
||||
extra="范围 0-20,按后端分销定价规则参与结算"
|
||||
rules={[{ required: true, message: '请输入收号成本调整点数' }]}
|
||||
>
|
||||
<InputNumber min={0} max={20} step={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<ApiResponse<{ items?: AdminDistributionSite[] }>>(
|
||||
'/admin/distribution/sites'
|
||||
)
|
||||
return data.data.items ?? []
|
||||
}
|
||||
|
||||
export async function updateAdminDistributionSite(
|
||||
id: number | string,
|
||||
payload: AdminDistributionSiteUpdatePayload
|
||||
) {
|
||||
const { data } = await adminClient.put<ApiResponse<AdminDistributionSite>>(
|
||||
`/admin/distribution/sites/${id}`,
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -336,3 +336,21 @@ export function systemConfigCategoryLabel(key: string) {
|
||||
const category = systemConfigCategory(key)
|
||||
return systemConfigCategoryMap[category] ?? category
|
||||
}
|
||||
|
||||
export const distributionSiteStatusMap: Record<string, StatusMeta> = {
|
||||
active: { label: '运营中', tone: 'success' },
|
||||
disabled: { label: '已停用', tone: 'default' },
|
||||
}
|
||||
|
||||
export const distributionSiteStatusOptions = toOptions(distributionSiteStatusMap)
|
||||
|
||||
export const distributionMemberTypeMap: Record<string, string> = {
|
||||
operator: '运营',
|
||||
finance: '财务',
|
||||
customer_service: '客服',
|
||||
}
|
||||
|
||||
export const announcementModeMap: Record<string, string> = {
|
||||
inherit: '继承主站公告',
|
||||
own_only: '仅本站公告',
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user