feat(admin): 新增后台分销站点管理

This commit is contained in:
yml2213
2026-10-30 13:36:04 +08:00
parent df251c2c3c
commit 4554f1d525
8 changed files with 985 additions and 20 deletions
+3 -3
View File
@@ -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. 移动端、订单、分销轻改与首屏优化均已提交;商品管理、审核、退款、提现、用户、申诉、财务、公告、审计日志、系统配置与分销站点已按批次提交。
---
+96
View File
@@ -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
View File
@@ -395,7 +395,7 @@ hfb_jysh/
### Phase 4:管理后台核心(4–5 周)
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、公告管理、审计日志与系统配置;线下出款/运营支出待迁移。
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、公告管理、审计日志、系统配置与分销站点;线下出款/运营支出及分销长尾能力待迁移。
- 后台登录、工作台、订单列表、订单详情。
- 商品列表、商品详情、审核。
+6 -16
View File
@@ -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>
)
}
+20
View File
@@ -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
}
+18
View File
@@ -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: '仅本站公告',
}
+46
View File
@@ -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
}