feat(distribution): 分销站点管理支持完整 CRUD、安全删除保护与管理员密码重置

- 后端支持分销站点编辑(域名唯一性校验、收号压价点位及税率更新)
- 后端支持分销站点安全删除校验(阻断存在业务订单、财务流水、结算批次或钱包余额的站点删除;对干净站点连带清理分成规则、展示账号绑定、支付通道配置及专有站长账号)
- 后端支持站长账号展示与超级管理员重置站长密码
- 主管理后台与分销后台前端全面支持筛选搜索、编辑模态窗、状态快捷切换、站长密码重置与安全删除确认
- 补充后端 CRUD、删除保护与密码重置覆盖率测试用例
This commit is contained in:
yml2213
2026-09-17 19:35:52 +08:00
parent 079e18bd3a
commit 1c3a75eef0
10 changed files with 1452 additions and 72 deletions
+32 -2
View File
@@ -130,6 +130,10 @@ export interface Site {
listing_qrcode_url?: string
receive_ratio_delta?: number
receive_cost_delta?: number
admin_user_id?: number
admin_username?: string
admin_nickname?: string
member_type?: string
created_at: string
}
@@ -341,9 +345,35 @@ export async function listSites(): Promise<Site[]> {
export async function updateSite(
siteID: number,
payload: Partial<Pick<Site, 'receive_ratio_delta' | 'receive_cost_delta'>>
payload: Partial<
Pick<
Site,
| 'site_name'
| 'domain'
| 'status'
| 'display_account_enabled'
| 'tax_enabled'
| 'tax_rate_bps'
| 'settlement_hold_days'
| 'listing_qrcode_url'
| 'receive_ratio_delta'
| 'receive_cost_delta'
>
>
): Promise<Site> {
const resp = await api.put<ApiResponse<Site>>(`/distribution/sites/${siteID}`, payload)
const resp = await api.put<ApiResponse<Site>>(`/admin/distribution/sites/${siteID}`, payload)
return extract(resp)
}
export async function deleteSite(siteID: number): Promise<{ deleted: boolean }> {
const resp = await api.delete<ApiResponse<{ deleted: boolean }>>(`/admin/distribution/sites/${siteID}`)
return extract(resp)
}
export async function resetSiteAdminPassword(siteID: number, newPassword: string): Promise<{ message: string }> {
const resp = await api.put<ApiResponse<{ message: string }>>(`/admin/distribution/sites/${siteID}/admin-password`, {
new_password: newPassword,
})
return extract(resp)
}
+422 -45
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, useMemo } from 'react'
import {
Card,
Table,
@@ -12,12 +12,34 @@ import {
InputNumber,
Select,
Switch,
Radio,
message,
Divider,
Descriptions,
Popconfirm,
Tooltip,
} from 'antd'
import { PlusOutlined, ShopOutlined, CopyOutlined } from '@ant-design/icons'
import { listSites, onboardSite, fetchDistributionSiteDefaults, type Site, type OnboardSitePayload, type OnboardSiteResult } from '../api'
import {
PlusOutlined,
ShopOutlined,
CopyOutlined,
EditOutlined,
DeleteOutlined,
KeyOutlined,
SearchOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import {
listSites,
onboardSite,
updateSite,
deleteSite,
resetSiteAdminPassword,
fetchDistributionSiteDefaults,
type Site,
type OnboardSitePayload,
type OnboardSiteResult,
} from '../api'
export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean }) {
const [items, setItems] = useState<Site[]>([])
@@ -28,6 +50,22 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean
const [defaultSettlementHoldDays, setDefaultSettlementHoldDays] = useState(7)
const [form] = Form.useForm()
// 筛选检索
const [searchQuery, setSearchQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('')
// 编辑弹窗
const [editModalOpen, setEditModalOpen] = useState(false)
const [editSubmitting, setEditSubmitting] = useState(false)
const [editingSite, setEditingSite] = useState<Site | null>(null)
const [editForm] = Form.useForm()
// 重置密码弹窗
const [resetPwdModalOpen, setResetPwdModalOpen] = useState(false)
const [resetPwdSubmitting, setResetPwdSubmitting] = useState(false)
const [resetPwdSite, setResetPwdSite] = useState<Site | null>(null)
const [resetPwdForm] = Form.useForm()
const loadData = () => {
setLoading(true)
listSites()
@@ -50,6 +88,25 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean
})
}, [])
const filteredItems = useMemo(() => {
return items.filter((site) => {
if (statusFilter && site.status !== statusFilter) {
return false
}
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase()
const matchName = site.site_name?.toLowerCase().includes(q)
const matchCode = site.site_code?.toLowerCase().includes(q)
const matchDomain = site.domain?.toLowerCase().includes(q)
const matchAdmin = site.admin_username?.toLowerCase().includes(q)
if (!matchName && !matchCode && !matchDomain && !matchAdmin) {
return false
}
}
return true
})
}, [items, searchQuery, statusFilter])
const handleOpenModal = () => {
form.resetFields()
form.setFieldsValue({
@@ -91,6 +148,90 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean
}
}
const handleOpenEdit = (site: Site) => {
setEditingSite(site)
editForm.resetFields()
editForm.setFieldsValue({
site_name: site.site_name,
domain: site.domain,
status: site.status || 'active',
display_account_enabled: site.display_account_enabled,
settlement_hold_days: site.settlement_hold_days ?? defaultSettlementHoldDays,
receive_cost_delta: Number(site.receive_cost_delta || 0),
tax_enabled: site.tax_enabled,
tax_rate_bps: site.tax_rate_bps ?? 100,
})
setEditModalOpen(true)
}
const handleEditSubmit = async (values: any) => {
if (!editingSite) return
setEditSubmitting(true)
try {
await updateSite(editingSite.id, {
site_name: values.site_name.trim(),
domain: values.domain.trim(),
status: values.status,
display_account_enabled: values.display_account_enabled,
settlement_hold_days: Number(values.settlement_hold_days),
receive_cost_delta: Number(values.receive_cost_delta),
tax_enabled: values.tax_enabled,
tax_rate_bps: values.tax_enabled ? values.tax_rate_bps : 0,
})
message.success(`【${values.site_name}】分销站点更新成功`)
setEditModalOpen(false)
loadData()
} catch (err: any) {
const msg = err?.response?.data?.message || '更新分销站点失败'
message.error(msg)
} finally {
setEditSubmitting(false)
}
}
const handleToggleStatus = async (site: Site) => {
const isCurrentlyActive = site.status === 'active'
const newStatus = isCurrentlyActive ? 'disabled' : 'active'
const actionText = isCurrentlyActive ? '停用' : '启用'
try {
await updateSite(site.id, { status: newStatus })
message.success(`分销站点【${site.site_name}】已${actionText}`)
loadData()
} catch (err: any) {
message.error(err?.response?.data?.message || `${actionText}失败`)
}
}
const handleDeleteSite = async (site: Site) => {
try {
await deleteSite(site.id)
message.success(`分销站点【${site.site_name}】已删除`)
loadData()
} catch (err: any) {
message.error(err?.response?.data?.message || '删除分销站点失败')
}
}
const handleOpenResetPwd = (site: Site) => {
setResetPwdSite(site)
resetPwdForm.resetFields()
setResetPwdModalOpen(true)
}
const handleResetPwdSubmit = async (values: any) => {
if (!resetPwdSite) return
setResetPwdSubmitting(true)
try {
await resetSiteAdminPassword(resetPwdSite.id, values.new_password)
message.success(`【${resetPwdSite.site_name}】管理员密码已成功重置`)
setResetPwdModalOpen(false)
} catch (err: any) {
message.error(err?.response?.data?.message || '密码重置失败')
} finally {
setResetPwdSubmitting(false)
}
}
return (
<Space direction="vertical" className="page" size="large" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
@@ -99,28 +240,65 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean
分销网站管理
</Typography.Title>
<Typography.Text type="secondary">
管理全平台合作分销站点、域名配置与分销商管理员账号
管理全平台合作分销站点、域名配置、结算规则与分销商管理员账号
</Typography.Text>
</div>
{isSuperadmin && (
<Button type="primary" icon={<PlusOutlined />} onClick={handleOpenModal}>
开通分销商
一键开通分销商
</Button>
)}
</div>
<Card>
{/* 筛选检索区 */}
<div style={{ display: 'flex', gap: 12, marginBottom: 16, alignItems: 'center', flexWrap: 'wrap' }}>
<Input
placeholder="搜索站点名称 / 标识 / 域名 / 管理员"
prefix={<SearchOutlined />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
allowClear
style={{ width: 280 }}
/>
<Select
placeholder="运行状态"
value={statusFilter}
onChange={setStatusFilter}
style={{ width: 130 }}
options={[
{ label: '全部状态', value: '' },
{ label: '正常运营', value: 'active' },
{ label: '已停用', value: 'disabled' },
]}
/>
<Button
onClick={() => {
setSearchQuery('')
setStatusFilter('')
}}
>
重置筛选
</Button>
<Button icon={<ReloadOutlined />} onClick={loadData}>
刷新
</Button>
<div style={{ marginLeft: 'auto', color: '#666', fontSize: 13 }}>
共 <strong>{filteredItems.length}</strong> 个分销站点
</div>
</div>
<Table
rowKey="id"
dataSource={items}
dataSource={filteredItems}
loading={loading}
pagination={false}
scroll={{ x: 950 }}
scroll={{ x: 1100 }}
columns={[
{
title: '站点名称',
dataIndex: 'site_name',
width: 220,
width: 180,
render: (v: string) => (
<Space>
<ShopOutlined style={{ color: '#1677ff' }} />
@@ -131,61 +309,249 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean
{
title: '站点标识',
dataIndex: 'site_code',
width: 140,
width: 120,
render: (v: string) => <Tag color="blue">{v}</Tag>,
},
{
title: '绑定域名',
dataIndex: 'domain',
width: 220,
width: 200,
render: (v: string) => (
<Typography.Text copyable={{ text: v }} className="mono-code" style={{ fontSize: 13 }}>
{v}
</Typography.Text>
),
},
{
title: '分销管理员',
width: 140,
render: (_: unknown, row: Site) =>
row.admin_username ? (
<Space direction="vertical" size={0}>
<Tag color="cyan">{row.admin_username}</Tag>
{row.admin_nickname && (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
({row.admin_nickname})
</Typography.Text>
)}
</Space>
) : (
<span style={{ color: '#999' }}>未关联</span>
),
},
{
title: '运行状态',
dataIndex: 'status',
width: 110,
render: (v: string) => (
<Tag color={v === 'active' ? 'green' : 'default'}>
{v === 'active' ? '正常运营' : v}
</Tag>
),
render: (v: string, row: Site) =>
isSuperadmin ? (
<Tooltip title={v === 'active' ? '点击停用站点' : '点击启用站点'}>
<Switch
checked={v === 'active'}
checkedChildren="正常"
unCheckedChildren="停用"
onChange={() => handleToggleStatus(row)}
/>
</Tooltip>
) : (
<Tag color={v === 'active' ? 'green' : 'default'}>
{v === 'active' ? '正常' : '已停用'}
</Tag>
),
},
{
title: '账号展示',
dataIndex: 'display_account_enabled',
width: 100,
width: 90,
render: (v: boolean) => (v ? <Tag color="green">开启</Tag> : <Tag>关闭</Tag>),
},
{
title: '税费设定',
width: 110,
width: 100,
render: (_: unknown, row: Site) =>
row.tax_enabled ? <Tag color="orange">{row.tax_rate_bps / 100}%</Tag> : <Tag>不征税</Tag>,
row.tax_enabled ? <Tag color="orange">{row.tax_rate_bps / 100}%</Tag> : <Tag>免税</Tag>,
},
{
title: '结算冷却',
width: 90,
render: (_: unknown, row: Site) =>
row.settlement_hold_days && row.settlement_hold_days > 0 ? (
<Tag color="blue">{row.settlement_hold_days}天</Tag>
) : (
<Tag color="green">立即</Tag>
),
},
{
title: '收号压价',
width: 120,
width: 110,
render: (_: unknown, row: Site) =>
Number(row.receive_cost_delta || 0) > 0 ? (
<Tag color="gold">低 {Number(row.receive_cost_delta).toFixed(1)} 点</Tag>
) : (
<Tag>与主站一致</Tag>
<Tag>同主站</Tag>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 140,
width: 120,
render: (v: string) => (v ? new Date(v).toLocaleDateString() : '-'),
},
...(isSuperadmin
? [
{
title: '操作',
width: 220,
fixed: 'right' as const,
render: (_: unknown, row: Site) => (
<Space size="small">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleOpenEdit(row)}
>
编辑
</Button>
<Button
type="link"
size="small"
icon={<KeyOutlined />}
onClick={() => handleOpenResetPwd(row)}
>
重置密码
</Button>
<Popconfirm
title="确认删除此分销站点?"
description="此操作将清理该站点及其规则与专属账号。若已产生订单则无法删除。"
onConfirm={() => handleDeleteSite(row)}
okText="确认删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
删除
</Button>
</Popconfirm>
</Space>
),
},
]
: []),
]}
/>
</Card>
{/* 编辑分销站点 Modal */}
<Modal
title={editingSite ? `编辑分销站点: ${editingSite.site_name}` : '编辑分销站点'}
open={editModalOpen}
onCancel={() => setEditModalOpen(false)}
footer={null}
width={600}
destroyOnClose
>
<Form form={editForm} layout="vertical" onFinish={handleEditSubmit} style={{ marginTop: 16 }}>
<Space direction="horizontal" style={{ width: '100%' }} size="middle">
<Form.Item label="站点唯一标识" style={{ flex: 1 }}>
<Tag color="blue" style={{ fontSize: 13, padding: '4px 8px' }}>
{editingSite?.site_code}
</Tag>
</Form.Item>
<Form.Item label="管理员账号" style={{ flex: 1 }}>
<Tag style={{ fontSize: 13, padding: '4px 8px' }}>
{editingSite?.admin_username || '未关联'}
</Tag>
</Form.Item>
</Space>
<Form.Item
label="分销站点名称"
name="site_name"
rules={[{ required: true, message: '请输入分销站点名称' }]}
>
<Input placeholder="例如:广州游戏分销站" />
</Form.Item>
<Form.Item
label="绑定独立域名"
name="domain"
rules={[{ required: true, message: '请输入绑定专属域名' }]}
>
<Input placeholder="例如:gz.hao858.com" />
</Form.Item>
<Form.Item label="运行状态" name="status">
<Radio.Group>
<Radio value="active">正常运营</Radio>
<Radio value="disabled">暂停服务 (已停用)</Radio>
</Radio.Group>
</Form.Item>
<Space direction="horizontal" style={{ width: '100%' }} size="middle">
<Form.Item label="展示主站账号" name="display_account_enabled" valuePropName="checked" style={{ flex: 1 }}>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
<Form.Item label="是否征税" name="tax_enabled" valuePropName="checked" style={{ flex: 1 }}>
<Switch checkedChildren="征税" unCheckedChildren="免税" />
</Form.Item>
</Space>
<Space direction="horizontal" style={{ width: '100%' }} size="middle">
<Form.Item label="结算冷却期 (天)" name="settlement_hold_days" style={{ flex: 1 }}>
<InputNumber min={0} max={365} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="收号压价 (点)" name="receive_cost_delta" style={{ flex: 1 }}>
<InputNumber min={0} max={20} step={0.1} precision={1} style={{ width: '100%' }} />
</Form.Item>
</Space>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 24 }}>
<Button onClick={() => setEditModalOpen(false)}>取消</Button>
<Button type="primary" htmlType="submit" loading={editSubmitting}>
保存设置
</Button>
</div>
</Form>
</Modal>
{/* 重置管理员密码 Modal */}
<Modal
title={`重置管理员密码: ${resetPwdSite?.site_name}`}
open={resetPwdModalOpen}
onCancel={() => setResetPwdModalOpen(false)}
footer={null}
width={450}
destroyOnClose
>
<Form form={resetPwdForm} layout="vertical" onFinish={handleResetPwdSubmit} style={{ marginTop: 16 }}>
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="站点名称">{resetPwdSite?.site_name}</Descriptions.Item>
<Descriptions.Item label="管理员账号">
<Tag color="cyan">{resetPwdSite?.admin_username || '专属管理员'}</Tag>
</Descriptions.Item>
</Descriptions>
<Form.Item
label="新登录密码"
name="new_password"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度至少6位' },
]}
>
<Input.Password placeholder="至少6位新登录密码" />
</Form.Item>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 24 }}>
<Button onClick={() => setResetPwdModalOpen(false)}>取消</Button>
<Button type="primary" htmlType="submit" loading={resetPwdSubmitting}>
确认重置
</Button>
</div>
</Form>
</Modal>
{/* 开通分销商 Modal */}
<Modal
title="开通分销商 (一键初始化站点与账号)"
@@ -255,47 +621,58 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean
</Space>
<Space direction="horizontal" style={{ width: '100%' }} size="middle">
<Form.Item label="管理员姓名/昵称" name="admin_nickname" style={{ flex: 1 }}>
<Form.Item label="站长姓名/昵称" name="admin_nickname" style={{ flex: 1 }}>
<Input placeholder="例如:张站长" />
</Form.Item>
<Form.Item label="初始角色权限" name="member_type" style={{ flex: 1 }}>
<Select
options={[
{ label: '分销运营 (operator)', value: 'operator' },
{ label: '分销财务 (finance)', value: 'finance' },
{ label: '客服人员 (customer_service)', value: 'customer_service' },
]}
/>
<Select>
<Select.Option value="operator">分销运营 (operator)</Select.Option>
<Select.Option value="finance">分销财务 (finance)</Select.Option>
<Select.Option value="customer_service">分销客服 (customer_service)</Select.Option>
</Select>
</Form.Item>
</Space>
<Divider style={{ margin: '16px 0' }} />
<Typography.Title level={5}>3. 结算冷却期与展示</Typography.Title>
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
开通后四种分成模式均按默认生效:收号 35% / 卖出 40% / 服务费 25%(本站自卖 75% + 25%)。之后可在规则页调整。
<Typography.Title level={5}>3. 结算冷却期与展示配置</Typography.Title>
<Typography.Paragraph type="secondary" style={{ fontSize: 13, marginBottom: 12 }}>
开通后四种模式均按默认生效:收号 35% / 卖出 40% / 服务费 25%(自卖为 75% + 25%)。之后可在「分销规则」页单独调整。
</Typography.Paragraph>
<Space direction="horizontal" style={{ width: '100%' }} size="middle">
<Form.Item label="结算冷却期 (天)" name="settlement_hold_days" style={{ flex: 1 }}>
<InputNumber min={0} max={365} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label="结算冷却期 (天)"
name="settlement_hold_days"
tooltip="订单完成后进入冷却期,期满自动释放到可提现余额;0 表示完成即释放"
label="展示主站账号"
name="display_account_enabled"
valuePropName="checked"
style={{ flex: 1 }}
>
<InputNumber min={0} max={365} step={1} style={{ width: '100%' }} />
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
</Space>
<Space direction="horizontal" size="large" style={{ marginTop: 8 }}>
<Form.Item
label="允许展示主站账号"
name="display_account_enabled"
valuePropName="checked"
>
<Switch checkedChildren="开" unCheckedChildren="关" />
<Space direction="horizontal" style={{ width: '100%' }} size="middle">
<Form.Item label="是否征税" name="tax_enabled" valuePropName="checked" style={{ flex: 1 }}>
<Switch checkedChildren="征税" unCheckedChildren="不征税" />
</Form.Item>
<Form.Item label="是否征税" name="tax_enabled" valuePropName="checked">
<Switch checkedChildren="征税" unCheckedChildren="免税" />
<Form.Item
noStyle
shouldUpdate={(prev, curr) => prev.tax_enabled !== curr.tax_enabled}
>
{({ getFieldValue }) =>
getFieldValue('tax_enabled') ? (
<Form.Item
label="综合税率 (基点,100 = 1%)"
name="tax_rate_bps"
style={{ flex: 1 }}
>
<InputNumber min={0} max={5000} step={50} style={{ width: '100%' }} />
</Form.Item>
) : null
}
</Form.Item>
</Space>