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
+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 ?? []