Files
hfb_jysh/frontend/src/features/admin/AdminDistributionSitesPage.tsx
T

796 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}