feat(distribution): 独立分销后台账号体系、多租户隔离与主站后台分销管理GUI

- 独立账号鉴权:分销后台支持独立登录页面、Bearer+Cookie 双模鉴权与租户资料接口
- 多租户隔离:分销商成员强制锁定自身站点数据范围,防止横向越权;平台超管享有全局视界
- 一键开通分销商:平台端支持一键录入站点、管理员账号、分配角色并原子化初始化佣金规则
- 主后台分销分组:在主站后台增加「分销管理」导航分组,提供站点管理、规则配置、订单监控与结算管理 GUI
- 门禁与联调:补充完整单测,全量代码门禁 check.sh 100% 通过,dev.sh 支持前后端与分销后台一键同启
This commit is contained in:
yml2213
2026-09-07 15:45:53 +08:00
parent 0c37c7e659
commit 550b42eb22
28 changed files with 3192 additions and 117 deletions
+181 -38
View File
@@ -1,6 +1,17 @@
import { BrowserRouter, Link, Navigate, Route, Routes } from 'react-router-dom'
import { App, ConfigProvider, Layout, Menu, Typography } from 'antd'
import { AccountBookOutlined, ApiOutlined, DashboardOutlined, FileSearchOutlined, SettingOutlined, ShopOutlined } from '@ant-design/icons'
import { useEffect, useState } from 'react'
import { BrowserRouter, Link, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { App, ConfigProvider, Layout, Menu, Typography, Button, Tag, Space, Spin, type MenuProps } from 'antd'
import {
AccountBookOutlined,
ApiOutlined,
DashboardOutlined,
FileSearchOutlined,
LogoutOutlined,
SettingOutlined,
ShopOutlined,
UserOutlined,
} from '@ant-design/icons'
import { getMe, logout, type CurrentMember, getStoredToken } from './api'
import Dashboard from './pages/Dashboard'
import Sites from './pages/Sites'
import Rules from './pages/Rules'
@@ -8,11 +19,14 @@ import Payments from './pages/Payments'
import Accounts from './pages/Accounts'
import Settlements from './pages/Settlements'
import Orders from './pages/Orders'
import Login from './pages/Login'
import './style.css'
const MENU_ITEMS = [
type MenuItem = Required<MenuProps>['items'][number] & { superadminOnly?: boolean }
const ALL_MENU_ITEMS: MenuItem[] = [
{ key: '/', icon: <DashboardOutlined />, label: <Link to="/">仪表盘</Link> },
{ key: '/sites', icon: <ShopOutlined />, label: <Link to="/sites">分销网站</Link> },
{ key: '/sites', icon: <ShopOutlined />, label: <Link to="/sites">分销网站</Link>, superadminOnly: true },
{ key: '/sold', icon: <FileSearchOutlined />, label: <Link to="/sold">售出账号</Link> },
{ key: '/receive', icon: <AccountBookOutlined />, label: <Link to="/receive">分销收号</Link> },
{ key: '/settlements', icon: <AccountBookOutlined />, label: <Link to="/settlements">结算管理</Link> },
@@ -21,45 +35,174 @@ const MENU_ITEMS = [
{ key: '/rules', icon: <SettingOutlined />, label: <Link to="/rules">比例与税费</Link> },
]
function pathToSelectedKey(pathname: string): string {
return MENU_ITEMS.some((item) => item.key === pathname) ? pathname : '/'
function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: () => void }) {
const location = useLocation()
const menuItems = ALL_MENU_ITEMS.filter((item) => !item.superadminOnly || member.is_superadmin)
const selectedKey = menuItems.some((item) => item.key === location.pathname)
? location.pathname
: '/'
const roleLabel = member.is_superadmin
? '平台超级管理员'
: member.member_type === 'operator'
? '分销运营'
: member.member_type === 'finance'
? '分销财务'
: '分销成员'
return (
<Layout className="app">
<Layout.Sider breakpoint="lg" collapsedWidth="0">
<div className="brand">
<ShopOutlined style={{ fontSize: 20, color: '#1677ff' }} />
<span>HFB 分销后台</span>
</div>
<Menu
theme="dark"
mode="inline"
items={menuItems}
selectedKeys={[selectedKey]}
/>
</Layout.Sider>
<Layout>
<Layout.Header className="header">
<div className="header-left">
{member.is_superadmin ? (
<Tag color="gold" style={{ fontSize: 13, padding: '2px 8px' }}>
平台全局管理模式
</Tag>
) : (
<Tag color="blue" style={{ fontSize: 13, padding: '2px 8px' }}>
{member.site_name || '当前分销站点'} · {member.domain || member.site_code}
</Tag>
)}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
主站 hao858.com · 分销管理系统
</Typography.Text>
</div>
<div className="header-right">
<Space size="middle">
<div className="user-profile">
<UserOutlined style={{ color: '#1677ff' }} />
<Typography.Text strong style={{ fontSize: 13 }}>
{member.nickname || member.username}
</Typography.Text>
<Tag color={member.is_superadmin ? 'purple' : 'cyan'}>{roleLabel}</Tag>
</div>
<Button
type="text"
danger
icon={<LogoutOutlined />}
onClick={onLogout}
size="small"
>
退出登录
</Button>
</Space>
</div>
</Layout.Header>
<Layout.Content>
<Routes>
<Route path="/" element={<Dashboard />} />
{member.is_superadmin && <Route path="/sites" element={<Sites isSuperadmin={true} />} />}
<Route path="/rules" element={<Rules />} />
<Route path="/payments" element={<Payments />} />
<Route path="/accounts" element={<Accounts />} />
<Route path="/settlements" element={<Settlements />} />
<Route path="/sold" element={<Orders kind="sold" />} />
<Route path="/receive" element={<Orders kind="receive" />} />
<Route path="/orders" element={<Orders kind="all" />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout.Content>
</Layout>
</Layout>
)
}
function AppContent() {
const [member, setMember] = useState<CurrentMember | null>(null)
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
const location = useLocation()
useEffect(() => {
let active = true
const checkAuth = async () => {
// If no token exists and no session, skip getMe
if (!getStoredToken() && location.pathname === '/login') {
if (active) setLoading(false)
return
}
try {
const profile = await getMe()
if (active) {
setMember(profile)
if (location.pathname === '/login') {
navigate('/', { replace: true })
}
}
} catch {
if (active) {
setMember(null)
if (location.pathname !== '/login') {
navigate('/login', { replace: true })
}
}
} finally {
if (active) setLoading(false)
}
}
checkAuth()
return () => {
active = false
}
}, [location.pathname, navigate])
const handleLogout = async () => {
await logout()
setMember(null)
navigate('/login', { replace: true })
}
const handleLoginSuccess = (current: CurrentMember) => {
setMember(current)
navigate('/', { replace: true })
}
if (loading) {
return (
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="large" tip="正在加载分销系统..." />
</div>
)
}
if (location.pathname === '/login') {
return <Login onSuccess={handleLoginSuccess} />
}
if (!member) {
return <Navigate to="/login" replace />
}
return <MainLayout member={member} onLogout={handleLogout} />
}
export default function AppShell() {
return (
<BrowserRouter>
<ConfigProvider theme={{ token: { colorPrimary: '#1677ff', borderRadius: 6 } }}>
<ConfigProvider
theme={{
token: {
colorPrimary: '#1677ff',
borderRadius: 6,
},
}}
>
<App>
<Layout className="app">
<Layout.Sider breakpoint="lg" collapsedWidth="0">
<div className="brand">HFB 分销后台</div>
<Menu
theme="dark"
mode="inline"
items={MENU_ITEMS}
defaultSelectedKeys={[pathToSelectedKey(window.location.pathname)]}
/>
</Layout.Sider>
<Layout>
<Layout.Header className="header">
<Typography.Text>主站 hao858.com · 分销子域名 distribution.hao858.com</Typography.Text>
</Layout.Header>
<Layout.Content>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/sites" element={<Sites />} />
<Route path="/rules" element={<Rules />} />
<Route path="/payments" element={<Payments />} />
<Route path="/accounts" element={<Accounts />} />
<Route path="/settlements" element={<Settlements />} />
<Route path="/sold" element={<Orders kind="sold" />} />
<Route path="/receive" element={<Orders kind="receive" />} />
<Route path="/orders" element={<Orders kind="all" />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout.Content>
</Layout>
</Layout>
<AppContent />
</App>
</ConfigProvider>
</BrowserRouter>
+114 -12
View File
@@ -1,28 +1,60 @@
import axios from 'axios'
import { Modal } from 'antd'
const TOKEN_KEY = 'hfb_dist_access_token'
export function getStoredToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY)
} catch {
return null
}
}
export function setStoredToken(token: string): void {
try {
localStorage.setItem(TOKEN_KEY, token)
} catch {
// ignore local storage errors
}
}
export function clearStoredToken(): void {
try {
localStorage.removeItem(TOKEN_KEY)
} catch {
// ignore local storage errors
}
}
const api = axios.create({ baseURL: '/api', withCredentials: true })
interface ApiResponse<T> { data: T; code?: number; message?: string }
let authNoticeShown = false
api.interceptors.request.use((config) => {
const token = getStoredToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(resp) => resp,
(error) => {
// 后台鉴权依赖主站 HttpOnly cookie;401 说明未登录或会话过期。
if (error?.response?.status === 401 && !authNoticeShown) {
authNoticeShown = true
Modal.warning({
title: '登录状态失效',
content: '请先在主站后台登录管理员账号,再返回本页面。',
onOk: () => { authNoticeShown = false },
})
if (error?.response?.status === 401) {
clearStoredToken()
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
window.location.href = '/login'
}
}
return Promise.reject(error)
},
)
export interface ApiResponse<T> {
data: T
code?: string | number
message?: string
}
export function extract<T>(resp: { data: ApiResponse<T> }): T {
return resp.data.data
}
@@ -31,6 +63,49 @@ export function formatMoney(cent: number | undefined | null): string {
return `¥${((cent ?? 0) / 100).toFixed(2)}`
}
export interface CurrentMember {
admin_id: number
username: string
nickname: string
is_superadmin: boolean
site_id?: number
site_code?: string
site_name?: string
domain?: string
member_type?: string
}
export interface LoginResult {
token: string
refresh_token?: string
member: CurrentMember
}
export async function login(username: string, password: string): Promise<LoginResult> {
const resp = await api.post<ApiResponse<LoginResult>>('/distribution/auth/login', {
username,
password,
})
const res = extract(resp)
if (res.token) {
setStoredToken(res.token)
}
return res
}
export async function getMe(): Promise<CurrentMember> {
const resp = await api.get<ApiResponse<CurrentMember>>('/distribution/auth/me')
return extract(resp)
}
export async function logout(): Promise<void> {
try {
await api.post('/distribution/auth/logout')
} finally {
clearStoredToken()
}
}
export interface Site {
id: number
site_code: string
@@ -123,6 +198,33 @@ export interface SettlementItem {
status: string
}
export interface OnboardSitePayload {
site_code: string
site_name: string
domain: string
admin_username: string
admin_password: string
admin_nickname?: string
member_type?: string
display_account_enabled?: boolean
tax_enabled?: boolean
tax_rate_bps?: number
distributor_receive_ratio?: number
platform_sell_ratio?: number
service_fee_value?: number
}
export interface OnboardSiteResult {
site: Site
rule: Rule
member: CurrentMember
}
export async function onboardSite(payload: OnboardSitePayload): Promise<OnboardSiteResult> {
const resp = await api.post<ApiResponse<OnboardSiteResult>>('/admin/distribution/sites/onboard', payload)
return extract(resp)
}
export async function listSites(): Promise<Site[]> {
const resp = await api.get<ApiResponse<{ items: Site[] }>>('/admin/distribution/sites')
return extract(resp).items ?? []
+107
View File
@@ -0,0 +1,107 @@
import { useState } from 'react'
import { Card, Form, Input, Button, Alert, Typography, Space } from 'antd'
import { UserOutlined, LockOutlined, SafetyCertificateOutlined } from '@ant-design/icons'
import { login, type CurrentMember } from '../api'
interface LoginProps {
onSuccess: (member: CurrentMember) => void
}
export default function Login({ onSuccess }: LoginProps) {
const [loading, setLoading] = useState(false)
const [errorMsg, setErrorMsg] = useState('')
const handleSubmit = async (values: { username: string; password: string }) => {
setLoading(true)
setErrorMsg('')
try {
const res = await login(values.username.trim(), values.password)
onSuccess(res.member)
} catch (err: unknown) {
const errObj = err as { response?: { data?: { message?: string } } }
const msg = errObj?.response?.data?.message || '登录失败,请检查用户名和密码'
setErrorMsg(msg)
} finally {
setLoading(false)
}
}
return (
<div className="login-container">
<Card className="login-card" bordered={false}>
<div className="login-header">
<div className="login-logo">
<SafetyCertificateOutlined style={{ fontSize: 32, color: '#1677ff' }} />
</div>
<Typography.Title level={3} style={{ margin: '12px 0 4px' }}>
HFB 分销系统
</Typography.Title>
<Typography.Text type="secondary">
独立分销商 · 多站点综合管理后台
</Typography.Text>
</div>
{errorMsg && (
<Alert
type="error"
message={errorMsg}
showIcon
closable
onClose={() => setErrorMsg('')}
style={{ marginBottom: 20 }}
/>
)}
<Form layout="vertical" onFinish={handleSubmit} size="large">
<Form.Item
name="username"
rules={[{ required: true, message: '请输入分销管理员账号' }]}
>
<Input
prefix={<UserOutlined style={{ color: '#bfbfbf' }} />}
placeholder="分销账号 / 管理员用户名"
autoComplete="username"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: '#bfbfbf' }} />}
placeholder="登录密码"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item style={{ marginTop: 24 }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
style={{ height: 44, fontWeight: 500 }}
>
登录分销后台
</Button>
</Form.Item>
</Form>
<div className="login-footer">
<Space split={<span style={{ color: '#d9d9d9' }}>·</span>}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
租户多维隔离
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
独立结算分成
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
安全双模鉴权
</Typography.Text>
</Space>
</div>
</Card>
</div>
)
}
+12 -2
View File
@@ -26,8 +26,17 @@ export default function Rules() {
}
useEffect(() => {
listSites().then(setSites).catch(() => message.error('站点加载失败'))
void load(undefined)
listSites()
.then((rows) => {
setSites(rows)
if (rows.length === 1) {
setSiteID(rows[0].id)
void load(rows[0].id)
} else {
void load(undefined)
}
})
.catch(() => message.error('站点加载失败'))
}, [])
async function toggleStatus(rule: Rule) {
@@ -49,6 +58,7 @@ export default function Rules() {
allowClear
placeholder="分销网站"
style={{ width: 200 }}
value={siteID}
options={sites.map((site) => ({ label: site.site_name, value: site.id }))}
onChange={(value) => {
setSiteID(value)
+309 -12
View File
@@ -1,39 +1,336 @@
import { useEffect, useState } from 'react'
import { Card, Table, Typography, Space, Tag, message } from 'antd'
import { listSites, type Site } from '../api'
import {
Card,
Table,
Typography,
Space,
Tag,
Button,
Modal,
Form,
Input,
InputNumber,
Select,
Switch,
message,
Divider,
Descriptions,
} from 'antd'
import { PlusOutlined, ShopOutlined, CopyOutlined } from '@ant-design/icons'
import { listSites, onboardSite, type Site, type OnboardSitePayload, type OnboardSiteResult } from '../api'
export default function Sites() {
export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean }) {
const [items, setItems] = useState<Site[]>([])
useEffect(() => {
const [loading, setLoading] = useState(false)
const [modalOpen, setModalOpen] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [successResult, setSuccessResult] = useState<OnboardSiteResult | null>(null)
const [form] = Form.useForm()
const loadData = () => {
setLoading(true)
listSites()
.then(setItems)
.catch(() => message.error('站点加载失败'))
.finally(() => setLoading(false))
}
useEffect(() => {
loadData()
}, [])
const handleOpenModal = () => {
form.resetFields()
form.setFieldsValue({
member_type: 'operator',
distributor_receive_ratio: 3.5,
platform_sell_ratio: 4.0,
service_fee_value: 2.5,
display_account_enabled: true,
tax_enabled: false,
tax_rate_bps: 100,
})
setModalOpen(true)
}
const handleOnboardSubmit = async (values: any) => {
setSubmitting(true)
try {
const payload: OnboardSitePayload = {
site_code: values.site_code.trim(),
site_name: values.site_name.trim(),
domain: values.domain.trim(),
admin_username: values.admin_username.trim(),
admin_password: values.admin_password,
admin_nickname: values.admin_nickname?.trim() || undefined,
member_type: values.member_type,
display_account_enabled: values.display_account_enabled,
tax_enabled: values.tax_enabled,
tax_rate_bps: values.tax_enabled ? values.tax_rate_bps : 0,
distributor_receive_ratio: Number(values.distributor_receive_ratio),
platform_sell_ratio: Number(values.platform_sell_ratio),
service_fee_value: Number(values.service_fee_value),
}
const res = await onboardSite(payload)
message.success('分销站点及分销商管理员开通成功!')
setModalOpen(false)
setSuccessResult(res)
loadData()
} catch (err: any) {
const msg = err?.response?.data?.message || '开通分销商失败,请检查站点标识或管理员用户名是否重复'
message.error(msg)
} finally {
setSubmitting(false)
}
}
return (
<Space direction="vertical" className="page">
<Typography.Title level={3}>分销网站</Typography.Title>
<Space direction="vertical" className="page" size="large">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Title level={3} style={{ margin: 0 }}>
分销网站管理
</Typography.Title>
<Typography.Text type="secondary">
管理全平台合作分销站点、域名配置与分销商管理员账号
</Typography.Text>
</div>
{isSuperadmin && (
<Button type="primary" icon={<PlusOutlined />} onClick={handleOpenModal}>
开通分销商
</Button>
)}
</div>
<Card>
<Table
rowKey="id"
dataSource={items}
loading={loading}
pagination={false}
columns={[
{ title: '名称', dataIndex: 'site_name' },
{ title: '编码', dataIndex: 'site_code' },
{ title: '域名', dataIndex: 'domain' },
{ title: '状态', dataIndex: 'status', render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{v}</Tag> },
{
title: '站点名称',
dataIndex: 'site_name',
render: (v: string) => (
<Space>
<ShopOutlined style={{ color: '#1677ff' }} />
<strong>{v}</strong>
</Space>
),
},
{ title: '站点标识', dataIndex: 'site_code', render: (v: string) => <Tag>{v}</Tag> },
{ title: '绑定域名', dataIndex: 'domain' },
{
title: '运行状态',
dataIndex: 'status',
render: (v: string) => (
<Tag color={v === 'active' ? 'green' : 'default'}>
{v === 'active' ? '正常运营' : v}
</Tag>
),
},
{
title: '账号展示',
dataIndex: 'display_account_enabled',
render: (v: boolean) => (v ? <Tag color="green">开启</Tag> : <Tag>关闭</Tag>),
},
{
title: '税费',
render: (_: unknown, row: Site) => (row.tax_enabled ? `${row.tax_rate_bps / 100}%` : '关闭'),
title: '税费设定',
render: (_: unknown, row: Site) =>
row.tax_enabled ? <Tag color="orange">{row.tax_rate_bps / 100}%</Tag> : <Tag>不征税</Tag>,
},
{
title: '创建时间',
dataIndex: 'created_at',
render: (v: string) => (v ? new Date(v).toLocaleDateString() : '-'),
},
]}
/>
</Card>
{/* 开通分销商 Modal */}
<Modal
title="开通分销商 (一键初始化站点与账号)"
open={modalOpen}
onCancel={() => setModalOpen(false)}
footer={null}
width={680}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={handleOnboardSubmit} style={{ marginTop: 16 }}>
<Typography.Title level={5} style={{ marginTop: 0 }}>
1. 站点基础信息
</Typography.Title>
<Space direction="horizontal" style={{ width: '100%' }} size="middle">
<Form.Item
label="站点名称"
name="site_name"
rules={[{ required: true, message: '请输入站点名称' }]}
style={{ flex: 1 }}
>
<Input placeholder="例如:广州游戏分销站" />
</Form.Item>
<Form.Item
label="站点唯一标识"
name="site_code"
rules={[
{ required: true, message: '请输入唯一代码' },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: '仅支持字母、数字与下划线' },
]}
style={{ flex: 1 }}
>
<Input placeholder="例如:gz_dist" />
</Form.Item>
</Space>
<Form.Item
label="分销专属域名"
name="domain"
rules={[{ required: true, message: '请输入分销绑定域名' }]}
>
<Input placeholder="例如:gz.distribution.hao858.com" />
</Form.Item>
<Divider style={{ margin: '16px 0' }} />
<Typography.Title level={5}>2. 分销商管理员账号</Typography.Title>
<Space direction="horizontal" style={{ width: '100%' }} size="middle">
<Form.Item
label="登录用户名"
name="admin_username"
rules={[{ required: true, message: '请输入管理员账号' }]}
style={{ flex: 1 }}
>
<Input placeholder="例如:gz_admin" />
</Form.Item>
<Form.Item
label="初始登录密码"
name="admin_password"
rules={[
{ required: true, message: '请输入登录密码' },
{ min: 6, message: '密码至少6位' },
]}
style={{ flex: 1 }}
>
<Input.Password placeholder="至少6位" />
</Form.Item>
</Space>
<Space direction="horizontal" style={{ width: '100%' }} size="middle">
<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' },
]}
/>
</Form.Item>
</Space>
<Divider style={{ margin: '16px 0' }} />
<Typography.Title level={5}>3. 分销比例与佣金规则初始化</Typography.Title>
<Space direction="horizontal" style={{ width: '100%' }} size="middle">
<Form.Item
label="分销商收号分成比例 (%)"
name="distributor_receive_ratio"
tooltip="分销模式下分销商收号收益率"
style={{ flex: 1 }}
>
<InputNumber min={0} max={100} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label="平台卖号分成比例 (%)"
name="platform_sell_ratio"
tooltip="平台卖号提取分成率"
style={{ flex: 1 }}
>
<InputNumber min={0} max={100} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label="单笔服务费 (元)"
name="service_fee_value"
tooltip="按单扣除的固定服务费"
style={{ flex: 1 }}
>
<InputNumber min={0} step={0.5} style={{ width: '100%' }} />
</Form.Item>
</Space>
<Space direction="horizontal" size="large" style={{ marginTop: 8 }}>
<Form.Item
label="允许展示主站账号"
name="display_account_enabled"
valuePropName="checked"
>
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
<Form.Item label="是否征税" name="tax_enabled" valuePropName="checked">
<Switch checkedChildren="征税" unCheckedChildren="免税" />
</Form.Item>
</Space>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 24 }}>
<Button onClick={() => setModalOpen(false)}>取消</Button>
<Button type="primary" htmlType="submit" loading={submitting}>
立即开通并生成分销商
</Button>
</div>
</Form>
</Modal>
{/* 开通成功信息 Modal */}
<Modal
title="分销商开通成功"
open={Boolean(successResult)}
onOk={() => setSuccessResult(null)}
cancelButtonProps={{ style: { display: 'none' } }}
width={560}
>
{successResult && (
<div>
<Typography.Paragraph type="secondary">
分销商站点与管理账号已成功创建,请妥善保管并交付给分销合作伙伴:
</Typography.Paragraph>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="分销站点">
{successResult.site.site_name} ({successResult.site.site_code})
</Descriptions.Item>
<Descriptions.Item label="分销域名">
{successResult.site.domain}
</Descriptions.Item>
<Descriptions.Item label="分销后台登录账号">
<strong>{successResult.member.username}</strong>
</Descriptions.Item>
<Descriptions.Item label="所属角色">
{successResult.member.member_type}
</Descriptions.Item>
<Descriptions.Item label="初始规则版本">
收号分成: {successResult.rule.distributor_receive_ratio}% · 卖号分成:{' '}
{successResult.rule.platform_sell_ratio}% · 服务费:{' '}
¥{successResult.rule.service_fee_value}
</Descriptions.Item>
</Descriptions>
<div style={{ marginTop: 16, textAlign: 'right' }}>
<Button
icon={<CopyOutlined />}
onClick={() => {
const text = `分销站点: ${successResult.site.site_name}\n后台地址: http://${window.location.host}/login\n用户名: ${successResult.member.username}`
navigator.clipboard?.writeText(text)
message.success('已复制分销商开通信息到剪贴板')
}}
>
复制开通信息
</Button>
</div>
</div>
)}
</Modal>
</Space>
)
}
+123 -9
View File
@@ -1,9 +1,123 @@
:root { font-family: Inter, "PingFang SC", sans-serif; color: #1f2937; background: #f5f7fa; }
* { box-sizing: border-box; }
body { margin: 0; }
.app { min-height: 100vh; }
.brand { height: 64px; padding: 20px 18px; color: #fff; font-weight: 600; letter-spacing: 0; }
.header { display: flex; align-items: center; background: #fff; padding: 0 24px; border-bottom: 1px solid #edf0f5; }
.page { width: 100%; padding: 24px; }
.stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; width: 100%; }
@media (max-width: 720px) { .page { padding: 16px; } .stats { grid-template-columns: 1fr; } }
:root {
font-family: Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
color: #1f2937;
background: #f5f7fa;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
.app {
min-height: 100vh;
}
.brand {
height: 64px;
padding: 20px 20px;
color: #fff;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 10px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
padding: 0 24px;
border-bottom: 1px solid #edf0f5;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.user-profile {
display: flex;
align-items: center;
gap: 8px;
}
.page {
width: 100%;
padding: 24px;
}
.stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
width: 100%;
}
/* Login Page Styles */
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
padding: 20px;
}
.login-card {
width: 100%;
max-width: 420px;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
background: #ffffff;
padding: 12px 8px;
}
.login-header {
text-align: center;
margin-bottom: 28px;
}
.login-logo {
width: 56px;
height: 56px;
margin: 0 auto 8px;
background: #e6f4ff;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
}
.login-footer {
text-align: center;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
@media (max-width: 720px) {
.page {
padding: 16px;
}
.stats {
grid-template-columns: 1fr;
}
.header {
padding: 0 12px;
}
}