diff --git a/distribution-admin/src/AppShell.tsx b/distribution-admin/src/AppShell.tsx index 09a7f85..4c127bb 100644 --- a/distribution-admin/src/AppShell.tsx +++ b/distribution-admin/src/AppShell.tsx @@ -16,6 +16,7 @@ import { WalletOutlined, } from '@ant-design/icons' import { getMe, logout, type CurrentMember, getStoredToken } from './api' +import { MemberContext } from './MemberContext' import Dashboard from './pages/Dashboard' import Sites from './pages/Sites' import Rules from './pages/Rules' @@ -61,7 +62,8 @@ function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: () : '分销成员' return ( - + +
@@ -149,6 +151,7 @@ function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: () + ) } diff --git a/distribution-admin/src/MemberContext.tsx b/distribution-admin/src/MemberContext.tsx new file mode 100644 index 0000000..86dad41 --- /dev/null +++ b/distribution-admin/src/MemberContext.tsx @@ -0,0 +1,8 @@ +import { createContext, useContext } from 'react' +import type { CurrentMember } from './api' + +export const MemberContext = createContext(null) + +export function useMember(): CurrentMember | null { + return useContext(MemberContext) +} diff --git a/distribution-admin/src/pages/Accounts.tsx b/distribution-admin/src/pages/Accounts.tsx index e3eb5e6..5f2f213 100644 --- a/distribution-admin/src/pages/Accounts.tsx +++ b/distribution-admin/src/pages/Accounts.tsx @@ -1,6 +1,33 @@ import { useEffect, useState, useMemo } from 'react' -import { Card, Table, Typography, Space, Select, Tag, message, Button, Input, Modal, InputNumber, Empty } from 'antd' -import { SyncOutlined, SearchOutlined, CheckCircleOutlined, StopOutlined, EditOutlined, DollarCircleOutlined } from '@ant-design/icons' +import { + Card, + Table, + Typography, + Space, + Select, + Tag, + message, + Button, + Input, + Modal, + InputNumber, + Empty, + Alert, + Tooltip, + Divider, +} from 'antd' +import { + SyncOutlined, + SearchOutlined, + CheckCircleOutlined, + StopOutlined, + EditOutlined, + DollarCircleOutlined, + ReloadOutlined, + FilterOutlined, + ShopOutlined, + ThunderboltOutlined, +} from '@ant-design/icons' import { listAccountDisplays, listSites, @@ -9,70 +36,128 @@ import { updateAccountMarkup, batchUpdateAccountMarkup, formatMoney, + getMe, type AccountDisplay, type Site, } from '../api' +import { useMember } from '../MemberContext' -const STATUS_LABELS: Record = { - enabled: { label: '展示中', color: 'success' }, - disabled: { label: '已下架', color: 'default' }, - sold: { label: '已售出', color: 'blue' }, - offline: { label: '已离线', color: 'error' }, +const SOURCE_OPTIONS = [ + { label: '全部货源', value: 'all' }, + { label: '主站自营 (平台共享池)', value: 'main' }, + { label: '本站收号 (独家货源)', value: 'self' }, + { label: '合作分站 (跨站共享)', value: 'collab' }, +] + +const MARKUP_OPTIONS = [ + { label: '全部提价状态', value: 'all' }, + { label: '已自主提价 (赚差价)', value: 'marked' }, + { label: '未加价 (基准销售价)', value: 'unmarked' }, +] + +const DISPLAY_OPTIONS = [ + { label: '全部展示状态', value: 'all' }, + { label: '展示中 (在售货架)', value: 'enabled' }, + { label: '已下架 (隐藏货架)', value: 'disabled' }, +] + +const TX_OPTIONS = [ + { label: '全部租借状态', value: 'all' }, + { label: '待租中 (空闲可租)', value: 'idle' }, + { label: '出租中 (租赁履约)', value: 'rented' }, +] + +function formatGameName(name?: string) { + if (!name) return '默认游戏' + if (name === 'delta_force') return '三角洲行动' + return name } export default function Accounts() { + const member = useMember() const [sites, setSites] = useState([]) - const [siteID, setSiteID] = useState() - const [status, setStatus] = useState('') - const [keyword, setKeyword] = useState('') + const [siteID, setSiteID] = useState(member?.site_id) const [items, setItems] = useState([]) const [loading, setLoading] = useState(false) const [syncing, setSyncing] = useState(false) const [selectedRowKeys, setSelectedRowKeys] = useState([]) + // Shelf product filter states + const [sourceFilter, setSourceFilter] = useState('all') + const [markupFilter, setMarkupFilter] = useState('all') + const [displayFilter, setDisplayFilter] = useState('all') + const [txFilter, setTxFilter] = useState('all') + const [keyword, setKeyword] = useState('') + // Markup modal state const [markupModalVisible, setMarkupModalVisible] = useState(false) const [targetAccount, setTargetAccount] = useState(null) const [markupRatio, setMarkupRatio] = useState(0) const [markupSaving, setMarkupSaving] = useState(false) - async function load(site?: number, statusFilter?: string) { + async function load(site?: number) { if (!site) return setLoading(true) try { - setItems(await listAccountDisplays(site, statusFilter)) + // Fetch full shelf goods to allow fast client-side multidimensional filtering + const rows = await listAccountDisplays(site, '') + setItems(rows) } catch { - message.error('账号展示加载失败') + message.error('账号展示货架加载失败') } finally { setLoading(false) } } + // Initialize site context: + // Normal distribution merchant is strictly bound to member.site_id + // Only superadmin can fetch sites and switch between tenants useEffect(() => { - listSites() - .then((rows) => { - setSites(rows) - if (rows.length > 0) { - setSiteID(rows[0].id) - void load(rows[0].id) + async function init() { + let currentMember = member + if (!currentMember) { + try { + currentMember = await getMe() + } catch { + // ignore } - }) - .catch(() => message.error('站点加载失败')) - }, []) + } + + const activeSiteID = currentMember?.site_id + if (activeSiteID) { + setSiteID(activeSiteID) + void load(activeSiteID) + } + + if (currentMember?.is_superadmin) { + try { + const rows = await listSites() + setSites(rows) + if (!activeSiteID && rows.length > 0) { + setSiteID(rows[0].id) + void load(rows[0].id) + } + } catch { + // ignore + } + } + } + void init() + }, [member]) async function handleSync() { if (!siteID) { - message.warning('请先选择分销站点') + message.warning('无法获取当前分销站点信息') return } setSyncing(true) try { const res = await syncSiteAccounts(siteID) message.success( - `同步完成!成功同步新增商品 ${res.synced_count} 个,已在货架 ${res.skipped_count} 个(平台在售共 ${res.total_active} 个)`, + `同步完成!成功同步新增商品 ${res.synced_count} 个,已在货架 ${res.skipped_count} 个(主站共享池在售共 ${res.total_active} 个)`, 5, ) - await load(siteID, status) + await load(siteID) } catch (error: unknown) { const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message message.error(respMessage ?? '同步主站商品失败') @@ -85,8 +170,8 @@ export default function Accounts() { const next = item.display_status === 'enabled' ? 'disabled' : 'enabled' try { await updateAccountDisplay(item.id, next) - message.success(next === 'enabled' ? '已恢复展示' : '已下架') - if (siteID) void load(siteID, status) + message.success(next === 'enabled' ? '已恢复货架展示' : '已从货架下架') + if (siteID) void load(siteID) } catch (error: unknown) { const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message message.error(respMessage ?? '状态更新失败') @@ -114,7 +199,7 @@ export default function Accounts() { } message.success(`已完成 ${successCount}/${selectedRowKeys.length} 个账号的${actionLabel}`) setSelectedRowKeys([]) - if (siteID) void load(siteID, status) + if (siteID) void load(siteID) }, }) } @@ -137,7 +222,7 @@ export default function Accounts() { async function handleSaveMarkup() { if (!siteID) return if (markupRatio < 0) { - message.error('加价比例不能小于0') + message.error('提价调减比例不能小于0') return } setMarkupSaving(true) @@ -154,43 +239,174 @@ export default function Accounts() { return } await batchUpdateAccountMarkup(siteID, listingIDs, markupRatio) - message.success(`已成功为选中的 ${listingIDs.length} 个商品批量设置加价!`) + message.success(`已成功为选中的 ${listingIDs.length} 个商品批量设置提价!`) setSelectedRowKeys([]) } setMarkupModalVisible(false) - void load(siteID, status) + void load(siteID) } catch (error: unknown) { const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message - message.error(respMessage || '加价设置保存失败') + message.error(respMessage || '提价设置保存失败') } finally { setMarkupSaving(false) } } - const filteredItems = useMemo(() => { - const kw = keyword.trim().toLowerCase() - if (!kw) return items - return items.filter( - (item) => - (item.title && item.title.toLowerCase().includes(kw)) || - (item.listing_no && item.listing_no.toLowerCase().includes(kw)) || - (item.game_name && item.game_name.toLowerCase().includes(kw)) || - (item.server_region && item.server_region.toLowerCase().includes(kw)), - ) - }, [items, keyword]) + function handleResetFilters() { + setSourceFilter('all') + setMarkupFilter('all') + setDisplayFilter('all') + setTxFilter('all') + setKeyword('') + } - const currentSite = sites.find((s) => s.id === siteID) + // Multidimensional client-side filter + const filteredItems = useMemo(() => { + return items.filter((item) => { + // 1. Source filter + if (sourceFilter === 'main') { + if (item.listing_site_id !== null && item.listing_site_id !== undefined) return false + } else if (sourceFilter === 'self') { + if (item.listing_site_id !== siteID) return false + } else if (sourceFilter === 'collab') { + if (item.listing_site_id === null || item.listing_site_id === undefined || item.listing_site_id === siteID) { + return false + } + } + + // 2. Markup filter + if (markupFilter === 'marked') { + if (!item.shelf_ratio_delta || item.shelf_ratio_delta <= 0) return false + } else if (markupFilter === 'unmarked') { + if (item.shelf_ratio_delta && item.shelf_ratio_delta > 0) return false + } + + // 3. Display status filter + if (displayFilter !== 'all') { + if (item.display_status !== displayFilter) return false + } + + // 4. Transaction filter + if (txFilter === 'idle') { + if (item.in_transaction) return false + } else if (txFilter === 'rented') { + if (!item.in_transaction) return false + } + + // 5. Keyword search + if (keyword.trim()) { + const kw = keyword.trim().toLowerCase() + const matchTitle = item.title && item.title.toLowerCase().includes(kw) + const matchNo = item.listing_no && item.listing_no.toLowerCase().includes(kw) + const matchGame = item.game_name && item.game_name.toLowerCase().includes(kw) + const matchRegion = item.server_region && item.server_region.toLowerCase().includes(kw) + const matchPlatform = item.login_platform && item.login_platform.toLowerCase().includes(kw) + const matchId = String(item.account_id).includes(kw) || String(item.listing_id).includes(kw) + if (!matchTitle && !matchNo && !matchGame && !matchRegion && !matchPlatform && !matchId) { + return false + } + } + + return true + }) + }, [items, sourceFilter, markupFilter, displayFilter, txFilter, keyword, siteID]) + + // Statistics counters + const counts = useMemo(() => { + let mainCount = 0 + let selfCount = 0 + let collabCount = 0 + let markedCount = 0 + let enabledCount = 0 + + for (const item of items) { + if (item.listing_site_id === null || item.listing_site_id === undefined) { + mainCount++ + } else if (item.listing_site_id === siteID) { + selfCount++ + } else { + collabCount++ + } + + if (item.shelf_ratio_delta && item.shelf_ratio_delta > 0) { + markedCount++ + } + if (item.display_status === 'enabled') { + enabledCount++ + } + } + + const hasActiveFilters = + sourceFilter !== 'all' || + markupFilter !== 'all' || + displayFilter !== 'all' || + txFilter !== 'all' || + keyword.trim() !== '' + + return { + total: items.length, + main: mainCount, + self: selfCount, + collab: collabCount, + marked: markedCount, + enabled: enabledCount, + filtered: filteredItems.length, + hasActiveFilters, + } + }, [items, siteID, sourceFilter, markupFilter, displayFilter, txFilter, keyword, filteredItems.length]) + + const currentSiteName = + member?.site_name || sites.find((s) => s.id === siteID)?.site_name || '当前分销商' return ( -
+ {/* Superadmin Cross-tenant Inspection Banner */} + {member?.is_superadmin && sites.length > 0 && ( + + 平台超管巡检模式 + 切换巡查分销站点: + ({ - label: `${site.site_name} (${site.site_code})`, - value: site.id, - }))} - onChange={(value) => { - setSiteID(value) - setSelectedRowKeys([]) - void load(value, status) - }} - /> - } - style={{ width: 240 }} - allowClear - value={keyword} - onChange={(e) => setKeyword(e.target.value)} - /> - + + {/* Controls row */} +
+ + {/* 货源归属 */} + + + {/* 展示状态 */} + + + {/* 搜索框 */} + } + style={{ width: 250 }} + allowClear + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + /> + + {counts.hasActiveFilters && ( + + )} - )} + + {/* Batch actions */} + {selectedRowKeys.length > 0 && ( + + + 已选 {selectedRowKeys.length} 项 + + + + + + + )} +
+ + + + {/* Quick Statistics and Data Scope Badges */} +
+ + + 货架概览: + + 货架总计 {counts.total} 件 + 主站自营 {counts.main} 件 + 本站收号 {counts.self} 件 + {counts.collab > 0 && 合作分站 {counts.collab} 件} + 已自主加价 {counts.marked} 件 + 正常展示中 {counts.enabled} 件 + + + {counts.hasActiveFilters && ( + + 已按条件筛选:展示 {counts.filtered} / {counts.total} 件 + + )} +
+ {/* Products Table */} - + {counts.hasActiveFilters ? ( + + ) : ( + + )} ), }} @@ -315,7 +588,15 @@ export default function Accounts() { {v || `#${row.listing_id}`} - {row.in_transaction && 出租中} + {row.in_transaction ? ( + + 出租中 + + ) : ( + + 在售空闲 + + )} ), }, @@ -324,12 +605,24 @@ export default function Accounts() { width: 110, render: (_: unknown, row: AccountDisplay) => { if (row.listing_site_id === null || row.listing_site_id === undefined) { - return 主站自营 + return ( + + 主站自营 + + ) } if (row.listing_site_id === siteID) { - return 本站收号 + return ( + + 本站收号 + + ) } - return 合作分站 + return ( + + 合作分站 + + ) }, }, { @@ -342,7 +635,7 @@ export default function Accounts() { {row.title || `账号 #${row.account_id}`} - {row.game_name || '默认游戏'} + {formatGameName(row.game_name)} {row.server_region && {row.server_region}} {row.login_platform && {row.login_platform}} @@ -365,17 +658,26 @@ export default function Accounts() { }, { title: '租金 / 押金', - width: 160, - render: (_: unknown, row: AccountDisplay) => ( -
- - {formatMoney(row.site_price_cent || row.price_cent)} - -
- 押金: {formatMoney(row.deposit_amount_cent)} + width: 170, + render: (_: unknown, row: AccountDisplay) => { + const finalPrice = row.site_price_cent || row.price_cent + const hasMarkup = row.shelf_ratio_delta && row.shelf_ratio_delta > 0 + return ( +
+ + {formatMoney(finalPrice)} + +
+ 押金: {formatMoney(row.deposit_amount_cent)} +
+ {hasMarkup && ( +
+ 含提价溢价: +{formatMoney(row.markup_amount_cent)} +
+ )}
-
- ), + ) + }, }, { title: '加价比例', @@ -385,17 +687,17 @@ export default function Accounts() { if (v && v > 0) { return ( - +{v} + +{v} 比例点 ) } - return 无加价 + return 无加价 (基准) }, }, { title: '主站状态', dataIndex: 'listing_status', - width: 110, + width: 100, render: (v: string | undefined) => ( {v === 'published' ? '主站在售' : v || '未知'} @@ -405,15 +707,16 @@ export default function Accounts() { { title: '分销展示', dataIndex: 'display_status', - width: 110, - render: (v: string) => { - const conf = STATUS_LABELS[v] ?? { label: v, color: 'default' } - return {conf.label} - }, + width: 100, + render: (v: string) => ( + + {v === 'enabled' ? '展示中' : '已下架'} + + ), }, { title: '操作', - width: 160, + width: 140, render: (_: unknown, row: AccountDisplay) => (
- ({ label: `${site.site_name} (${site.site_code})`, value: site.id }))} + onChange={(value) => setSiteID(value)} + /> + )} {kind === 'receive' ? ( diff --git a/distribution-admin/src/pages/Settlements.tsx b/distribution-admin/src/pages/Settlements.tsx index ce8ec17..4c19ce5 100644 --- a/distribution-admin/src/pages/Settlements.tsx +++ b/distribution-admin/src/pages/Settlements.tsx @@ -7,15 +7,18 @@ import { listSettlementItems, listSites, settleBatch, + getMe, type SettlementBatch, type SettlementItem, type Site, } from '../api' +import { useMember } from '../MemberContext' import type { Dayjs } from 'dayjs' export default function Settlements() { + const member = useMember() const [sites, setSites] = useState([]) - const [siteID, setSiteID] = useState() + const [siteID, setSiteID] = useState(member?.site_id) const [batches, setBatches] = useState([]) const [loading, setLoading] = useState(false) const [range, setRange] = useState<[Dayjs | null, Dayjs | null] | null>(null) @@ -34,16 +37,36 @@ export default function Settlements() { } useEffect(() => { - listSites() - .then((rows) => { - setSites(rows) - if (rows.length > 0) { - setSiteID(rows[0].id) - void load(rows[0].id) + async function init() { + let currentMember = member + if (!currentMember) { + try { + currentMember = await getMe() + } catch { + // ignore } - }) - .catch(() => message.error('站点加载失败')) - }, []) + } + const activeSiteID = currentMember?.site_id + if (activeSiteID) { + setSiteID(activeSiteID) + void load(activeSiteID) + } + + if (currentMember?.is_superadmin) { + try { + const rows = await listSites() + setSites(rows) + if (!activeSiteID && rows.length > 0) { + setSiteID(rows[0].id) + void load(rows[0].id) + } + } catch { + // ignore + } + } + } + void init() + }, [member]) async function handleCreate() { if (!siteID || !range?.[0] || !range?.[1]) { @@ -84,16 +107,18 @@ export default function Settlements() { 结算管理 - ({ label: site.site_name, value: site.id }))} + onChange={(value) => { + setSiteID(value) + void load(value) + }} + /> + )} setRange(value)} /> diff --git a/distribution-admin/src/pages/Wallet.tsx b/distribution-admin/src/pages/Wallet.tsx index 5792aad..d0d0a7d 100644 --- a/distribution-admin/src/pages/Wallet.tsx +++ b/distribution-admin/src/pages/Wallet.tsx @@ -31,10 +31,12 @@ import { applySiteWithdrawal, listSites, formatMoney, + getMe, type SiteWallet, type SiteWithdrawal, type Site, } from '../api' +import { useMember } from '../MemberContext' const STATUS_CONFIG: Record = { pending: { label: '待审核', color: 'orange' }, @@ -44,8 +46,9 @@ const STATUS_CONFIG: Record = { } export default function Wallet() { + const member = useMember() const [sites, setSites] = useState([]) - const [siteID, setSiteID] = useState() + const [siteID, setSiteID] = useState(member?.site_id) const [wallet, setWallet] = useState(null) const [withdrawals, setWithdrawals] = useState([]) const [total, setTotal] = useState(0) @@ -77,13 +80,34 @@ export default function Wallet() { }, [siteID]) useEffect(() => { - listSites() - .then((rows) => { - setSites(rows) - if (rows.length > 0) setSiteID(rows[0].id) - }) - .catch(() => message.error('站点加载失败')) - }, []) + async function init() { + let currentMember = member + if (!currentMember) { + try { + currentMember = await getMe() + } catch { + // ignore + } + } + const activeSiteID = currentMember?.site_id + if (activeSiteID) { + setSiteID(activeSiteID) + } + + if (currentMember?.is_superadmin) { + try { + const rows = await listSites() + setSites(rows) + if (!activeSiteID && rows.length > 0) { + setSiteID(rows[0].id) + } + } catch { + // ignore + } + } + } + void init() + }, [member]) useEffect(() => { void loadData(1) @@ -145,13 +169,15 @@ export default function Wallet() { - ({ label: `${site.site_name} (${site.site_code})`, value: site.id }))} + onChange={(value) => setSiteID(value)} + /> + )}