From 79da033dc9aca302bb0c3757843beeaa823ff0e3 Mon Sep 17 00:00:00 2001 From: zhx Date: Sun, 14 Jun 2026 23:41:21 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BB=E6=92=AD=E5=B7=A5=E8=B5=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/host-agency-policy/api.ts | 155 +++++++ .../pages/HostSalarySettlementPage.jsx | 429 ++++++++++++++++-- src/features/host-org/api.ts | 17 + .../components/SalaryWalletControls.jsx | 374 +++++++++++++++ src/features/host-org/host-org.module.css | 80 ++++ .../host-org/pages/HostAgenciesPage.jsx | 30 +- .../host-org/pages/HostBdLeadersPage.jsx | 57 ++- .../host-org/pages/HostBdLeadersPage.test.jsx | 98 ++++ src/features/host-org/pages/HostBdsPage.jsx | 46 +- src/features/host-org/pages/HostHostsPage.jsx | 48 +- src/features/host-org/permissions.js | 3 + src/shared/api/types.ts | 61 +++ 12 files changed, 1340 insertions(+), 58 deletions(-) create mode 100644 src/features/host-org/components/SalaryWalletControls.jsx create mode 100644 src/features/host-org/pages/HostBdLeadersPage.test.jsx diff --git a/src/features/host-agency-policy/api.ts b/src/features/host-agency-policy/api.ts index 08692fc..7537661 100644 --- a/src/features/host-agency-policy/api.ts +++ b/src/features/host-agency-policy/api.ts @@ -221,6 +221,8 @@ export interface HostSalarySettlementDto { commandId: string; transactionId: string; settlementType: string; + triggerMode: string; + settlementRole: string; userId: string; user: HostSalarySettlementUserDto; agencyOwnerUserId: string; @@ -241,7 +243,75 @@ export interface HostSalarySettlementDto { createdAtMs: number; } +export interface HostSalarySettlementPendingDto { + userId: string; + user: HostSalarySettlementUserDto; + agencyOwnerUserId: string; + agencyOwner: HostSalarySettlementUserDto; + role: string; + cycleKey: string; + regionId: number; + regionName?: string; + policyId: number; + policyName: string; + settlementType: string; + triggerMode: string; + levelNo: number; + requiredDiamonds: number; + totalDiamonds: number; + hostSalaryUsdMinorTarget: number; + hostSalaryUsdTarget: string; + hostSalaryUsdMinorDelta: number; + hostSalaryUsdDelta: string; + hostCoinRewardTarget: number; + hostCoinRewardDelta: number; + agencySalaryUsdMinorTarget: number; + agencySalaryUsdTarget: string; + agencySalaryUsdMinorDelta: number; + agencySalaryUsdDelta: string; + residualUsdMinorDelta: number; + residualUsdDelta: string; + totalUsdMinorDelta: number; + totalUsdDelta: string; + settledLevelNo: number; + settledHostSalaryUsdMinor: number; + settledHostSalaryUsd: string; + settledHostCoinReward: number; + settledAgencySalaryUsdMinor: number; + settledAgencySalaryUsd: string; + lastSettledTotalDiamonds: number; + residualUsdMinor: number; + residualUsd: string; + monthEndClearedAtMs: number; + status: string; +} + +export interface HostSalarySettlePayload { + role: "host" | "agency"; + settlement_type: "daily" | "half_month" | "month_end"; + trigger_mode: "manual"; + cycle_key: string; + region_id?: number; + country_code?: string; + user_ids?: string[]; +} + +export interface HostSalarySettleResult { + role: string; + cycleKey: string; + settlementType: string; + triggerMode: string; + requestedCount: number; + processedCount: number; + successCount: number; + skippedCount: number; + failureCount: number; + records: HostSalarySettlementDto[]; +} + type RawSettlement = HostSalarySettlementDto & Record; +type RawHostPending = HostSalarySettlementPendingDto & Record; +type RawHostSettleResult = HostSalarySettleResult & Record; export function listHostSalarySettlements(query: PageQuery = {}): Promise> { const endpoint = API_ENDPOINTS.listHostSalarySettlements; @@ -254,6 +324,25 @@ export function listHostSalarySettlements(query: PageQuery = {}): Promise> { + return apiRequest>("/v1/admin/host-salary-settlements/pending", { + method: "GET", + query, + }).then((page) => ({ + ...page, + items: (page.items || []).map(normalizeHostPending), + })); +} + +export function settleHostSalary(payload: HostSalarySettlePayload): Promise { + return apiRequest("/v1/admin/host-salary-settlements/settle", { + body: payload, + method: "POST", + }).then(normalizeHostSettleResult); +} + export interface TeamSalarySettlementPendingDto { policyType: "bd" | "admin"; userId: string; @@ -392,6 +481,8 @@ function normalizeSettlement(raw: RawSettlement): HostSalarySettlementDto { commandId: stringValue(raw.commandId ?? raw.command_id), transactionId: stringValue(raw.transactionId ?? raw.transaction_id), settlementType: stringValue(raw.settlementType ?? raw.settlement_type), + triggerMode: stringValue(raw.triggerMode ?? raw.trigger_mode) || "automatic", + settlementRole: stringValue(raw.settlementRole ?? raw.settlement_role) || "all", userId: stringValue(raw.userId ?? raw.user_id), user: normalizeSettlementUser(user), agencyOwnerUserId: stringValue(raw.agencyOwnerUserId ?? raw.agency_owner_user_id), @@ -413,6 +504,55 @@ function normalizeSettlement(raw: RawSettlement): HostSalarySettlementDto { }; } +function normalizeHostPending(raw: RawHostPending): HostSalarySettlementPendingDto { + const user = asRecord(raw.user); + const agencyOwner = asRecord(raw.agencyOwner ?? raw.agency_owner); + return { + userId: stringValue(raw.userId ?? raw.user_id), + user: normalizeSettlementUser(user), + agencyOwnerUserId: stringValue(raw.agencyOwnerUserId ?? raw.agency_owner_user_id), + agencyOwner: normalizeSettlementUser(agencyOwner), + role: stringValue(raw.role ?? raw.settlementRole ?? raw.settlement_role) || "host", + cycleKey: stringValue(raw.cycleKey ?? raw.cycle_key), + regionId: numberValue(raw.regionId ?? raw.region_id), + regionName: stringValue(raw.regionName ?? raw.region_name), + policyId: numberValue(raw.policyId ?? raw.policy_id), + policyName: stringValue(raw.policyName ?? raw.policy_name), + settlementType: stringValue(raw.settlementType ?? raw.settlement_type) || "half_month", + triggerMode: stringValue(raw.triggerMode ?? raw.trigger_mode) || "manual", + levelNo: numberValue(raw.levelNo ?? raw.level_no), + requiredDiamonds: numberValue(raw.requiredDiamonds ?? raw.required_diamonds), + totalDiamonds: numberValue(raw.totalDiamonds ?? raw.total_diamonds), + hostSalaryUsdMinorTarget: numberValue(raw.hostSalaryUsdMinorTarget ?? raw.host_salary_usd_minor_target), + hostSalaryUsdTarget: stringValue(raw.hostSalaryUsdTarget ?? raw.host_salary_usd_target), + hostSalaryUsdMinorDelta: numberValue(raw.hostSalaryUsdMinorDelta ?? raw.host_salary_usd_minor_delta), + hostSalaryUsdDelta: stringValue(raw.hostSalaryUsdDelta ?? raw.host_salary_usd_delta), + hostCoinRewardTarget: numberValue(raw.hostCoinRewardTarget ?? raw.host_coin_reward_target), + hostCoinRewardDelta: numberValue(raw.hostCoinRewardDelta ?? raw.host_coin_reward_delta), + agencySalaryUsdMinorTarget: numberValue(raw.agencySalaryUsdMinorTarget ?? raw.agency_salary_usd_minor_target), + agencySalaryUsdTarget: stringValue(raw.agencySalaryUsdTarget ?? raw.agency_salary_usd_target), + agencySalaryUsdMinorDelta: numberValue(raw.agencySalaryUsdMinorDelta ?? raw.agency_salary_usd_minor_delta), + agencySalaryUsdDelta: stringValue(raw.agencySalaryUsdDelta ?? raw.agency_salary_usd_delta), + residualUsdMinorDelta: numberValue(raw.residualUsdMinorDelta ?? raw.residual_usd_minor_delta), + residualUsdDelta: stringValue(raw.residualUsdDelta ?? raw.residual_usd_delta), + totalUsdMinorDelta: numberValue(raw.totalUsdMinorDelta ?? raw.total_usd_minor_delta), + totalUsdDelta: stringValue(raw.totalUsdDelta ?? raw.total_usd_delta), + settledLevelNo: numberValue(raw.settledLevelNo ?? raw.settled_level_no), + settledHostSalaryUsdMinor: numberValue(raw.settledHostSalaryUsdMinor ?? raw.settled_host_salary_usd_minor), + settledHostSalaryUsd: stringValue(raw.settledHostSalaryUsd ?? raw.settled_host_salary_usd), + settledHostCoinReward: numberValue(raw.settledHostCoinReward ?? raw.settled_host_coin_reward), + settledAgencySalaryUsdMinor: numberValue( + raw.settledAgencySalaryUsdMinor ?? raw.settled_agency_salary_usd_minor, + ), + settledAgencySalaryUsd: stringValue(raw.settledAgencySalaryUsd ?? raw.settled_agency_salary_usd), + lastSettledTotalDiamonds: numberValue(raw.lastSettledTotalDiamonds ?? raw.last_settled_total_diamonds), + residualUsdMinor: numberValue(raw.residualUsdMinor ?? raw.residual_usd_minor), + residualUsd: stringValue(raw.residualUsd ?? raw.residual_usd), + monthEndClearedAtMs: numberValue(raw.monthEndClearedAtMs ?? raw.month_end_cleared_at_ms), + status: stringValue(raw.status) || "pending", + }; +} + function normalizeSettlementUser(raw: Record): HostSalarySettlementUserDto { return { userId: stringValue(raw.userId ?? raw.user_id), @@ -491,6 +631,21 @@ function normalizeTeamSettleResult(raw: RawTeamSettleResult): TeamSalarySettleRe }; } +function normalizeHostSettleResult(raw: RawHostSettleResult): HostSalarySettleResult { + return { + role: stringValue(raw.role ?? raw.settlementRole ?? raw.settlement_role), + cycleKey: stringValue(raw.cycleKey ?? raw.cycle_key), + settlementType: stringValue(raw.settlementType ?? raw.settlement_type), + triggerMode: stringValue(raw.triggerMode ?? raw.trigger_mode), + requestedCount: numberValue(raw.requestedCount ?? raw.requested_count), + processedCount: numberValue(raw.processedCount ?? raw.processed_count), + successCount: numberValue(raw.successCount ?? raw.success_count), + skippedCount: numberValue(raw.skippedCount ?? raw.skipped_count), + failureCount: numberValue(raw.failureCount ?? raw.failure_count), + records: arrayValue(raw.records).map((item) => normalizeSettlement(asRecord(item) as RawSettlement)), + }; +} + function normalizeTeamPolicy(item: RawTeamPolicy): TeamSalaryPolicyDto { return { id: numberValue(item.id), diff --git a/src/features/host-agency-policy/pages/HostSalarySettlementPage.jsx b/src/features/host-agency-policy/pages/HostSalarySettlementPage.jsx index 0a17e8a..e7eadb6 100644 --- a/src/features/host-agency-policy/pages/HostSalarySettlementPage.jsx +++ b/src/features/host-agency-policy/pages/HostSalarySettlementPage.jsx @@ -5,11 +5,15 @@ import Tabs from "@mui/material/Tabs"; import { useEffect, useMemo, useState } from "react"; import { listHostSalarySettlements, + listHostSalarySettlementPending, listTeamSalarySettlementPending, listTeamSalarySettlementRecords, + settleHostSalary, settleTeamSalary, } from "@/features/host-agency-policy/api"; import { useHostAgencyPolicyAbilities } from "@/features/host-agency-policy/permissions.js"; +import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js"; +import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; import { AdminFilterResetButton, AdminFilterSelect, @@ -51,6 +55,13 @@ const policyTypeOptions = [ const policyTypeAllOptions = [["", "全部角色"], ...policyTypeOptions]; +const pendingRoleOptions = [ + ["bd", "BD"], + ["admin", "Admin"], + ["host", "Host"], + ["agency", "Agency"], +]; + const triggerOptions = [ ["automatic", "自动政策"], ["manual", "手动政策"], @@ -59,18 +70,18 @@ const triggerOptions = [ const triggerAllOptions = [["", "全部触发"], ...triggerOptions]; export function HostSalarySettlementPage() { - const [activeTab, setActiveTab] = useState("team-pending"); + const [activeTab, setActiveTab] = useState("pending"); return (
setActiveTab(value)}> - +
- {activeTab === "team-pending" ? ( - + {activeTab === "pending" ? ( + ) : activeTab === "team-records" ? ( ) : ( @@ -80,6 +91,221 @@ export function HostSalarySettlementPage() { ); } +function PendingPanel() { + const [role, setRole] = useState("bd"); + if (role === "host" || role === "agency") { + return ; + } + return ; +} + +// HostPendingPanel 展示 Host/Agency 动态账单;列表查询不入账,点击结算后才让 wallet-service 写钱包和记录。 +function HostPendingPanel({ onRoleChange, role }) { + const abilities = useHostAgencyPolicyAbilities(); + const { showToast } = useToast(); + const { countryOptions, loadingCountries } = useCountryOptions(); + const { loadingRegions, regionOptions } = useRegionOptions(); + const [page, setPage] = useState(1); + const [settlementType, setSettlementType] = useState("half_month"); + const [cycleKey, setCycleKey] = useState(currentCycleKey); + const [countryCode, setCountryCode] = useState(""); + const [regionId, setRegionId] = useState(""); + const [selected, setSelected] = useState(() => new Set()); + const [settling, setSettling] = useState(false); + + const filters = useMemo( + () => ({ + country_code: countryCode, + cycle_key: cycleKey, + region_id: regionId, + role, + settlement_type: settlementType, + trigger_mode: "manual", + }), + [countryCode, cycleKey, regionId, role, settlementType], + ); + const query = usePaginatedQuery({ + errorMessage: "获取 Host/Agency 待结算列表失败", + fetcher: listHostSalarySettlementPending, + filters, + page, + pageSize, + queryKey: ["host-salary-pending", filters, page], + }); + const data = query.data || { items: [], page, pageSize, total: 0 }; + const items = data.items || []; + const total = data.total || 0; + const allVisibleSelected = items.length > 0 && items.every((item) => selected.has(hostPendingKey(item))); + + useEffect(() => { + setSelected(new Set()); + }, [filters]); + + const countryFilterOptions = useMemo(() => [["", loadingCountries ? "加载国家中" : "全部国家"], ...countryOptions.map((item) => [item.value, item.label])], [countryOptions, loadingCountries]); + const regionFilterOptions = useMemo(() => [["", loadingRegions ? "加载区域中" : "全部区域"], ...regionOptions.map((item) => [item.value, item.label])], [loadingRegions, regionOptions]); + const cycleFilterOptions = useMemo(() => buildCycleOptions(cycleKey, 18), [cycleKey]); + + const changeFilter = (setter) => (value) => { + setter(value); + setPage(1); + }; + const changeRole = (value) => { + onRoleChange(value); + setPage(1); + }; + const resetFilters = () => { + onRoleChange("bd"); + setSettlementType("half_month"); + setCycleKey(currentCycleKey()); + setCountryCode(""); + setRegionId(""); + setPage(1); + }; + const toggleOne = (item) => { + setSelected((current) => { + const next = new Set(current); + const key = hostPendingKey(item); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }; + const toggleVisible = () => { + setSelected((current) => { + const next = new Set(current); + items.forEach((item) => { + const key = hostPendingKey(item); + if (allVisibleSelected) { + next.delete(key); + } else { + next.add(key); + } + }); + return next; + }); + }; + const settleRows = async (rows) => { + const userIds = rows + .map((item) => String(item.userId || "").trim()) + .filter((id) => /^[1-9]\d*$/.test(id)); + if (!userIds.length) { + return; + } + setSettling(true); + try { + const result = await settleHostSalary({ + country_code: countryCode || undefined, + cycle_key: cycleKey, + role, + region_id: Number(regionId || 0) || undefined, + settlement_type: settlementType, + trigger_mode: "manual", + user_ids: userIds, + }); + showToast({ + message: `结算完成:成功 ${result.successCount},跳过 ${result.skippedCount},失败 ${result.failureCount}`, + severity: result.failureCount > 0 ? "warning" : "success", + }); + setSelected(new Set()); + await query.reload(); + } catch (error) { + showToast({ message: error?.message || "Host/Agency 工资结算失败", severity: "error" }); + } finally { + setSettling(false); + } + }; + const settleSelected = () => { + settleRows(items.filter((item) => selected.has(hostPendingKey(item)))); + }; + const tableColumns = [ + { + key: "select", + label: ( + 0 && !allVisibleSelected} + size="small" + onChange={toggleVisible} + /> + ), + width: "64px", + resizable: false, + render: (item) => ( + toggleOne(item)} + /> + ), + }, + ...hostPendingColumns, + { + key: "actions", + label: "操作", + width: "minmax(120px, 0.55fr)", + render: (item) => + + }, + ]; + const hasFilters = + role !== "bd" || settlementType !== "half_month" || cycleKey !== currentCycleKey() || countryCode || regionId; + + return ( + <> + } + variant="primary" + onClick={settleSelected} + > + 批量结算 + + } + filters={ +
+ + value !== "")} + value={settlementType} + onChange={changeFilter(setSettlementType)} + /> + + + + +
+ } + /> + + + + + + + ); +} + // HostRecordsPanel 保留原 Host/Agency 结算记录查询,避免新增 BD/Admin tab 后丢失主播工资查账入口。 function HostRecordsPanel() { const [page, setPage] = useState(1); @@ -212,28 +438,28 @@ function HostRecordsPanel() { } // TeamPendingPanel 是人工结算入口:列表金额来自后端候选计算,批量按钮只提交当前勾选用户。 -function TeamPendingPanel() { +function TeamPendingPanel({ onRoleChange, role }) { const abilities = useHostAgencyPolicyAbilities(); const { showToast } = useToast(); + const { countryOptions, loadingCountries } = useCountryOptions(); + const { loadingRegions, regionOptions } = useRegionOptions(); const [page, setPage] = useState(1); - const [policyType, setPolicyType] = useState("bd"); - const [triggerMode, setTriggerMode] = useState("automatic"); const [cycleKey, setCycleKey] = useState(defaultCycleKey); const [countryCode, setCountryCode] = useState(""); const [regionId, setRegionId] = useState(""); const [selected, setSelected] = useState(() => new Set()); const [settling, setSettling] = useState(false); - // BD/Admin 默认查看 automatic 上月周期;手动政策也可切换 triggerMode 后人工补发。 + // 待结算只处理人工政策;自动政策没有人工待结算入口,只在记录里查实际落库结果。 const filters = useMemo( () => ({ country_code: countryCode, cycle_key: cycleKey, - policy_type: policyType, + policy_type: role, region_id: regionId, - trigger_mode: triggerMode, + trigger_mode: "manual", }), - [countryCode, cycleKey, policyType, regionId, triggerMode], + [countryCode, cycleKey, regionId, role], ); const query = usePaginatedQuery({ errorMessage: "获取 BD/Admin 待结算列表失败", @@ -253,14 +479,21 @@ function TeamPendingPanel() { setSelected(new Set()); }, [filters]); + const countryFilterOptions = useMemo(() => [["", loadingCountries ? "加载国家中" : "全部国家"], ...countryOptions.map((item) => [item.value, item.label])], [countryOptions, loadingCountries]); + const regionFilterOptions = useMemo(() => [["", loadingRegions ? "加载区域中" : "全部区域"], ...regionOptions.map((item) => [item.value, item.label])], [loadingRegions, regionOptions]); + const cycleFilterOptions = useMemo(() => buildCycleOptions(cycleKey, 18), [cycleKey]); + const changeFilter = (setter) => (value) => { // 国家/区域/周期变化后必须重算候选,不保留旧页码。 setter(value); setPage(1); }; + const changeRole = (value) => { + onRoleChange(value); + setPage(1); + }; const resetFilters = () => { - setPolicyType("bd"); - setTriggerMode("automatic"); + onRoleChange("bd"); setCycleKey(defaultCycleKey()); setCountryCode(""); setRegionId(""); @@ -309,9 +542,9 @@ function TeamPendingPanel() { const result = await settleTeamSalary({ country_code: countryCode || undefined, cycle_key: cycleKey, - policy_type: policyType, + policy_type: role, region_id: Number(regionId || 0) || undefined, - trigger_mode: triggerMode, + trigger_mode: "manual", user_ids: userIds, }); showToast({ @@ -347,9 +580,8 @@ function TeamPendingPanel() { }, ...teamPendingColumns, ]; - // 默认值也纳入 hasFilters,这样运营能一键回到“BD + 自动政策 + 上月周期”的标准入口。 - const hasFilters = - policyType !== "bd" || triggerMode !== "automatic" || cycleKey !== defaultCycleKey() || countryCode || regionId; + // 默认值也纳入 hasFilters,这样运营能一键回到“BD + 手动政策 + 上月周期”的标准入口。 + const hasFilters = role !== "bd" || cycleKey !== defaultCycleKey() || countryCode || regionId; return ( <> @@ -368,34 +600,13 @@ function TeamPendingPanel() {
- - - - + + +
} @@ -612,6 +823,89 @@ const hostRecordColumns = [ }, ]; +// hostPendingColumns 展示 Host/Agency 当前账单差额;结算按钮单独放在操作列,便于自动政策行只读展示。 +const hostPendingColumns = [ + { + key: "host", + label: "主播", + width: "minmax(240px, 1.1fr)", + render: (item) => , + }, + { + key: "agency", + label: "代理", + width: "minmax(220px, 1fr)", + render: (item) => , + }, + { + key: "scope", + label: "周期/政策", + width: "minmax(230px, 1fr)", + render: (item) => ( +
+ + {item.cycleKey || "-"} · {settlementTypeLabel(item.settlementType)} + + {item.regionName || `区域 ${item.regionId || "-"}`} + {item.policyName || `政策 ${item.policyId || "-"}`} +
+ ), + }, + { + key: "level", + label: "等级/钻石", + width: "minmax(160px, 0.75fr)", + render: (item) => ( +
+ {item.levelNo > 0 ? `等级 ${item.levelNo}` : "未达等级"} + {formatNumber(item.totalDiamonds)} 钻石 + 门槛 {formatNumber(item.requiredDiamonds)} +
+ ), + }, + { + key: "hostReward", + label: "主播待发", + width: "minmax(170px, 0.8fr)", + render: (item) => ( +
+ {formatMoneyDelta(item.hostSalaryUsdDelta)} + 金币 {formatSignedInteger(item.hostCoinRewardDelta)} +
+ ), + }, + { + key: "agencyReward", + label: "代理/剩余", + width: "minmax(170px, 0.8fr)", + render: (item) => , + }, + { + key: "total", + label: "待发总额", + width: "minmax(150px, 0.7fr)", + render: (item) => {formatMoneyDelta(item.totalUsdDelta)}, + }, + { + key: "progress", + label: "已结进度", + width: "minmax(170px, 0.8fr)", + render: (item) => ( +
+ 等级 {item.settledLevelNo || 0} + 主播 ${item.settledHostSalaryUsd || "0"} + 代理 ${item.settledAgencySalaryUsd || "0"} +
+ ), + }, + { + key: "status", + label: "状态", + width: "minmax(120px, 0.55fr)", + render: (item) => , + }, +]; + // teamPendingColumns 展示 BD/Admin 的“现在会发多少钱”和“此前已发到哪一档”。 const teamPendingColumns = [ { @@ -751,7 +1045,10 @@ function CycleCell({ item }) { return (
{item.cycleKey || "-"} - {settlementTypeLabel(item.settlementType)} + + {settlementTypeLabel(item.settlementType)} · {triggerLabel(item.triggerMode)} ·{" "} + {roleLabel(item.settlementRole)} +
); } @@ -824,6 +1121,10 @@ function teamPendingKey(item) { return `${item.policyType}:${item.cycleKey}:${item.regionId}:${item.userId}`; } +function hostPendingKey(item) { + return `${item.role}:${item.settlementType}:${item.cycleKey}:${item.regionId}:${item.userId}`; +} + // userIdText 展示短 ID / 长 ID;筛选仍然用长 user_id。 function userIdText(user, fallbackId) { const id = String(user?.userId || fallbackId || "").trim(); @@ -842,6 +1143,28 @@ function defaultCycleKey() { return `${firstOfMonth.getUTCFullYear()}-${String(firstOfMonth.getUTCMonth() + 1).padStart(2, "0")}`; } +// currentCycleKey 计算当前 UTC 自然月,和 Host/Agency 周期账户的 yyyy-MM 保持一致。 +function currentCycleKey() { + const now = new Date(); + return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`; +} + +function buildCycleOptions(selectedCycleKey, monthCount) { + const now = new Date(); + const anchor = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + const options = []; + for (let index = 0; index < monthCount; index += 1) { + const item = new Date(anchor); + item.setUTCMonth(anchor.getUTCMonth() - index); + const value = `${item.getUTCFullYear()}-${String(item.getUTCMonth() + 1).padStart(2, "0")}`; + options.push([value, value]); + } + if (selectedCycleKey && !options.some(([value]) => value === selectedCycleKey)) { + return [[selectedCycleKey, selectedCycleKey], ...options]; + } + return options; +} + // settlementTypeLabel 把 Host/Agency 结算类型枚举转成运营文案。 function settlementTypeLabel(value) { return ( @@ -860,6 +1183,7 @@ function statusLabel(value) { return ( { failed: "失败", + pending: "待结算", skipped: "跳过", succeeded: "成功", }[value] || @@ -873,6 +1197,7 @@ function statusTone(value) { return ( { failed: "danger", + pending: "warning", skipped: "warning", succeeded: "running", }[value] || "stopped" @@ -881,7 +1206,17 @@ function statusTone(value) { // roleLabel 把 policy_type 展示为工资角色名称。 function roleLabel(value) { - return value === "admin" ? "Admin" : value === "bd" ? "BD" : value || "-"; + return ( + { + admin: "Admin", + agency: "Agency", + all: "Host/Agency", + bd: "BD", + host: "Host", + }[value] || + value || + "-" + ); } // triggerLabel 把触发方式展示为自动/手动。 diff --git a/src/features/host-org/api.ts b/src/features/host-org/api.ts index 9f5e6d8..30428aa 100644 --- a/src/features/host-org/api.ts +++ b/src/features/host-org/api.ts @@ -31,6 +31,9 @@ import type { RegionDto, RegionPayload, RegionUpdatePayload, + SalaryWalletAdjustPayload, + SalaryWalletAdjustResultDto, + SalaryWalletHistoryDto, } from "@/shared/api/types"; export function listCountries(query: PageQuery = {}): Promise> { @@ -179,6 +182,20 @@ export function listCoinSellers(query: PageQuery = {}): Promise> { + return apiRequest>("/v1/admin/host-org/salary-wallets/history", { + method: "GET", + query, + }); +} + +export function adjustSalaryWallet(payload: SalaryWalletAdjustPayload): Promise { + return apiRequest("/v1/admin/host-org/salary-wallets/adjust", { + body: payload, + method: "POST", + }); +} + export function createBDLeader(payload: CreateBDLeaderPayload): Promise { const endpoint = API_ENDPOINTS.createBDLeader; return apiRequest(apiEndpointPath(API_OPERATIONS.createBDLeader), { diff --git a/src/features/host-org/components/SalaryWalletControls.jsx b/src/features/host-org/components/SalaryWalletControls.jsx new file mode 100644 index 0000000..3e28e95 --- /dev/null +++ b/src/features/host-org/components/SalaryWalletControls.jsx @@ -0,0 +1,374 @@ +import AccountBalanceWalletOutlined from "@mui/icons-material/AccountBalanceWalletOutlined"; +import PaidOutlined from "@mui/icons-material/PaidOutlined"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import { useCallback, useEffect, useState } from "react"; +import { adjustSalaryWallet, listSalaryWalletHistory } from "@/features/host-org/api"; +import styles from "@/features/host-org/host-org.module.css"; +import { + AdminFormAmountField, + AdminFormDialog, + AdminFormFieldGrid, + AdminFormReadOnlyField, + AdminFormSection, +} from "@/shared/ui/AdminFormDialog.jsx"; +import { AdminActionIconButton } from "@/shared/ui/AdminListLayout.jsx"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { SideDrawer } from "@/shared/ui/SideDrawer.jsx"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { formatMillis } from "@/shared/utils/time.js"; + +const historyPageSize = 20; + +const emptyHistory = { items: [], page: 1, pageSize: historyPageSize, total: 0 }; + +const emptyAdjustForm = () => ({ + amount: "", + reason: "", + type: "increase", +}); + +const historyColumns = [ + { + key: "createdAtMs", + label: "时间", + render: (item) => formatMillis(item.createdAtMs), + width: "minmax(170px, 0.9fr)", + }, + { + key: "amountMinor", + label: "金额", + render: (item) => , + width: "minmax(120px, 0.65fr)", + }, + { + key: "balance", + label: "余额", + render: (item) => formatSalaryWalletAmount(item.availableAfter), + width: "minmax(120px, 0.65fr)", + }, + { + key: "bizType", + label: "类型", + render: (item) => salaryWalletBizLabel(item.bizType), + width: "minmax(150px, 0.8fr)", + }, + { + key: "operator", + label: "操作人", + render: (item) => salaryWalletOperatorName(item), + width: "minmax(150px, 0.8fr)", + }, + { + key: "reason", + label: "原因", + render: (item) => item.reason || "-", + width: "minmax(220px, 1.15fr)", + }, +]; + +export function SalaryWalletCell({ role, userId, userLabel, wallet }) { + const [open, setOpen] = useState(false); + const balance = Number(wallet?.availableAmount || 0); + const assetType = wallet?.assetType || salaryWalletAssetType(role); + + return ( + <> + + setOpen(false)} + /> + + ); +} + +export function SalaryWalletAdjustButton({ disabled = false, onAdjusted, role, userId, userLabel }) { + const { showToast } = useToast(); + const [open, setOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [form, setForm] = useState(emptyAdjustForm); + + const close = () => { + if (submitting) { + return; + } + setOpen(false); + setForm(emptyAdjustForm()); + }; + + const submit = async (event) => { + event.preventDefault(); + let amountMinor; + try { + amountMinor = parseSalaryUSDMinor(form.amount); + if (!String(form.reason || "").trim()) { + throw new Error("请填写原因"); + } + } catch (err) { + showToast(err.message || "工资操作参数不正确", "error"); + return; + } + + setSubmitting(true); + try { + const result = await adjustSalaryWallet({ + amountMinor, + commandId: makeSalaryWalletCommandId(role, userId), + reason: String(form.reason || "").trim(), + role, + targetUserId: userId, + type: form.type, + }); + showToast("工资钱包已更新", "success"); + setOpen(false); + setForm(emptyAdjustForm()); + await onAdjusted?.(result); + } catch (err) { + showToast(err.message || "工资钱包操作失败", "error"); + } finally { + setSubmitting(false); + } + }; + + return ( + <> + setOpen(true)} + > + + + + + + + + + + + + + setForm((current) => ({ ...current, type: event.target.value }))} + > + 工资增加 + 工资减少 + + setForm((current) => ({ ...current, amount: event.target.value }))} + /> + + setForm((current) => ({ ...current, reason: event.target.value }))} + /> + + + + ); +} + +function SalaryWalletHistoryDrawer({ onClose, open, role, userId, userLabel }) { + const [page, setPage] = useState(1); + const [data, setData] = useState(emptyHistory); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const loadHistory = useCallback(async () => { + if (!open || !role || !userId) { + return; + } + setError(""); + setLoading(true); + try { + const nextData = await listSalaryWalletHistory({ + page, + page_size: historyPageSize, + role, + user_id: userId, + }); + setData(nextData || emptyHistory); + } catch (err) { + setError(err.message || "加载工资钱包历史失败"); + } finally { + setLoading(false); + } + }, [open, page, role, userId]); + + useEffect(() => { + if (!open) { + return; + } + void loadHistory(); + }, [loadHistory, open]); + + useEffect(() => { + if (open) { + setPage(1); + } + }, [open, role, userId]); + + const total = data.total || 0; + + return ( + +
+ + {salaryWalletRoleLabel(role)} + {salaryWalletAssetType(role) || "-"} +
+ + 0 + ? { + page, + pageSize: data.pageSize || historyPageSize, + total, + onPageChange: setPage, + } + : undefined + } + rowKey={(item) => item.entryId || item.transactionId} + /> + +
+ ); +} + +function SalaryHistoryAmount({ item }) { + const delta = Number(item.availableDelta ?? item.amountMinor ?? 0); + const expense = delta < 0; + return ( + + {expense ? "-" : "+"} + {formatSalaryWalletAmount(Math.abs(delta))} + + ); +} + +export function formatSalaryWalletAmount(value) { + const minor = Number(value || 0); + return `$${(minor / 100).toLocaleString("en-US", { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + })}`; +} + +function parseSalaryUSDMinor(value) { + const raw = String(value || "").trim(); + if (!/^\d+(\.\d{1,2})?$/.test(raw)) { + throw new Error("请输入正确的 USD 金额,最多两位小数"); + } + const [integerPart, decimalPart = ""] = raw.split("."); + const amountMinor = Number(integerPart) * 100 + Number(decimalPart.padEnd(2, "0")); + if (!Number.isSafeInteger(amountMinor) || amountMinor <= 0) { + throw new Error("请输入大于 0 的工资金额"); + } + return amountMinor; +} + +function salaryWalletAssetType(role) { + switch (role) { + case "host": + return "HOST_SALARY_USD"; + case "agency": + return "AGENCY_SALARY_USD"; + case "bd": + return "BD_SALARY_USD"; + case "bd_leader": + return "ADMIN_SALARY_USD"; + default: + return ""; + } +} + +function salaryWalletRoleLabel(role) { + switch (role) { + case "host": + return "Host"; + case "agency": + return "Agency"; + case "bd": + return "BD"; + case "bd_leader": + return "BD Leader"; + default: + return role || "-"; + } +} + +function salaryWalletBizLabel(value) { + switch (value) { + case "manual_credit": + return "手工调整"; + case "salary_settlement": + case "host_salary_settlement": + case "team_salary_settlement": + return "工资结算"; + case "transfer": + return "转账"; + case "withdraw": + return "提现"; + default: + return value || "-"; + } +} + +function salaryWalletOperatorName(item) { + const operator = item.operator || {}; + return operator.name || operator.username || item.operatorUserId || "-"; +} + +function makeSalaryWalletCommandId(role, userId) { + return `salary-wallet-${role || "role"}-${userId || "user"}-${Date.now()}`; +} diff --git a/src/features/host-org/host-org.module.css b/src/features/host-org/host-org.module.css index 9420e07..1ea9474 100644 --- a/src/features/host-org/host-org.module.css +++ b/src/features/host-org/host-org.module.css @@ -276,6 +276,86 @@ text-decoration: underline; } +.salaryWalletButton { + display: inline-flex; + max-width: 100%; + min-height: 34px; + flex-direction: column; + align-items: flex-start; + justify-content: center; + padding: 0; + border: 0; + background: transparent; + color: var(--primary); + cursor: pointer; + font: inherit; + overflow: hidden; + text-align: left; +} + +.salaryWalletButton:hover .salaryWalletAmount { + color: var(--primary-strong); + text-decoration: underline; +} + +.salaryWalletAmount { + max-width: 100%; + overflow: hidden; + color: var(--primary); + font-weight: 780; + text-overflow: ellipsis; + white-space: nowrap; +} + +.salaryWalletAsset { + max-width: 100%; + overflow: hidden; + color: var(--text-tertiary); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.salaryWalletDrawer.salaryWalletDrawer { + width: min(980px, calc(100vw - 32px)); +} + +.salaryWalletDrawerBody.salaryWalletDrawerBody { + display: flex; + flex-direction: column; + gap: var(--space-3); + overflow: hidden; +} + +.salaryWalletDrawerBody :global(.data-state) { + flex: 1; + min-height: 0; +} + +.salaryWalletSummary { + display: inline-flex; + width: fit-content; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-input); + color: var(--text-secondary); + font-size: var(--admin-font-size); + font-weight: 700; +} + +.salaryWalletIncome { + color: var(--success); + font-weight: 780; +} + +.salaryWalletExpense { + color: var(--danger); + font-weight: 780; +} + .contactPlaceholder { color: var(--text-tertiary); } diff --git a/src/features/host-org/pages/HostAgenciesPage.jsx b/src/features/host-org/pages/HostAgenciesPage.jsx index 5486702..f558812 100644 --- a/src/features/host-org/pages/HostAgenciesPage.jsx +++ b/src/features/host-org/pages/HostAgenciesPage.jsx @@ -7,6 +7,7 @@ import { DataState } from "@/shared/ui/DataState.jsx"; import { agencyStatusFilters } from "@/features/host-org/constants.js"; import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx"; import { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx"; +import { SalaryWalletAdjustButton, SalaryWalletCell } from "@/features/host-org/components/SalaryWalletControls.jsx"; import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx"; import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx"; import { useHostAgenciesPage } from "@/features/host-org/hooks/useHostAgenciesPage.js"; @@ -27,6 +28,19 @@ const agencyColumns = [ render: (item) => , width: "minmax(220px, 1.2fr)", }, + { + key: "salaryWallet", + label: "工资钱包", + render: (item) => ( + + ), + width: "minmax(140px, 0.85fr)", + }, { key: "parentBdUserId", label: "上级 BD", @@ -61,7 +75,7 @@ const agencyColumns = [ key: "actions", label: "操作", render: (item, _index, context) => , - width: "minmax(80px, 0.45fr)", + width: "minmax(118px, 0.6fr)", }, ]; @@ -123,7 +137,7 @@ export function HostAgenciesPage() { columns={columns} context={{ page, regionOptions: page.regionOptions }} items={items} - minWidth="1180px" + minWidth="1340px" pagination={ total > 0 ? { @@ -247,6 +261,14 @@ function AgencyJoinSwitch({ item, page }) { function AgencyActions({ item, page }) { return ( + {page?.abilities.canSalaryAdjust ? ( + + ) : null} {page?.abilities.canDelete ? ( regionName(item, context?.regionOptions), width: "minmax(130px, 0.75fr)", }, + { + key: "salaryWallet", + label: "工资钱包", + render: (item) => ( + + ), + width: "minmax(140px, 0.85fr)", + }, { key: "positionAlias", label: "职位别名", @@ -66,17 +82,37 @@ export function HostBdLeadersPage() { const createDisabled = !page.abilities.canCreate; const items = page.data.items || []; const total = page.data.total || 0; + const regionFilter = createRegionColumnFilter({ + loading: page.loadingRegions, + options: page.regionOptions, + value: page.regionId, + onChange: page.changeRegionId, + }); const bdLeaderColumns = [ ...bdLeaderBaseColumns, - page.abilities.canCreate || page.abilities.canUpdate + page.abilities.canCreate || page.abilities.canUpdate || page.abilities.canSalaryAdjust ? { key: "actions", label: "操作", render: (item) => , - width: "minmax(118px, 0.7fr)", + width: "minmax(154px, 0.8fr)", } : null, - ].filter(Boolean); + ] + .filter(Boolean) + .map((column) => { + if (column.key === "user") { + return { + ...column, + filter: createTextColumnFilter({ + placeholder: "搜索用户 ID、展示 ID、用户名", + value: page.query, + onChange: page.changeQuery, + }), + }; + } + return column; + }); const toolbarActions = page.abilities.canCreate ? [ { @@ -99,7 +135,7 @@ export function HostBdLeadersPage() { columns={bdLeaderColumns} context={{ page, regionOptions: page.regionOptions }} items={items} - minWidth="1120px" + minWidth="1300px" pagination={ total > 0 ? { @@ -110,6 +146,7 @@ export function HostBdLeadersPage() { } : undefined } + regionFilter={regionFilter} rowKey={(item) => `bd-leader-${item.userId}`} /> @@ -343,6 +380,14 @@ function SubBDCount({ item, page }) { function BDLeaderActions({ item, page }) { return ( + {page.abilities.canSalaryAdjust ? ( + + ) : null} {page.abilities.canCreate ? ( page.openAddLeaderBD(item)}> @@ -402,3 +447,7 @@ const dangerActionSx = { color: "var(--danger)", }, }; + +function leaderUserLabel(item) { + return item.displayUserId || item.username || item.userId; +} diff --git a/src/features/host-org/pages/HostBdLeadersPage.test.jsx b/src/features/host-org/pages/HostBdLeadersPage.test.jsx new file mode 100644 index 0000000..73e0329 --- /dev/null +++ b/src/features/host-org/pages/HostBdLeadersPage.test.jsx @@ -0,0 +1,98 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import { HostBdLeadersPage } from "./HostBdLeadersPage.jsx"; +import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js"; + +vi.mock("@/features/host-org/hooks/useHostBdsPage.js", () => ({ + useHostBdsPage: vi.fn(), +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +test("bd leader table exposes user and region column filters", () => { + const changeQuery = vi.fn(); + const changeRegionId = vi.fn(); + vi.mocked(useHostBdsPage).mockReturnValue(pageFixture({ changeQuery, changeRegionId })); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "用户" })); + fireEvent.change(screen.getByPlaceholderText("搜索用户 ID、展示 ID、用户名"), { + target: { value: "164453" }, + }); + fireEvent.click(screen.getByRole("button", { name: "搜索" })); + fireEvent.click(screen.getByRole("button", { name: "区域" })); + fireEvent.click(screen.getByRole("menuitem", { name: "南亚区" })); + + expect(changeQuery).toHaveBeenCalledWith("164453"); + expect(changeRegionId).toHaveBeenCalledWith("1"); +}); + +function pageFixture(patch = {}) { + return { + abilities: { + canCreate: false, + canUpdate: false, + canView: true, + }, + activeAction: "", + bdForm: { parentLeaderUserId: "", reason: "", targetUserId: "" }, + bdLeaderForm: { positionAlias: "", reason: "", targetUserId: "" }, + bdUserLookup: { error: "", loading: false, user: null }, + changeParentLeaderUserId: vi.fn(), + changeQuery: vi.fn(), + changeRegionId: vi.fn(), + changeStatus: vi.fn(), + closeAction: vi.fn(), + data: { items: [leaderFixture()], page: 1, pageSize: 50, total: 1 }, + error: null, + leaderBDs: { items: [], page: 1, pageSize: 50, total: 0 }, + leaderBDsError: "", + leaderBDsLoading: false, + loadLeaderBDs: vi.fn(), + loading: false, + loadingAction: "", + loadingRegions: false, + openAddLeaderBD: vi.fn(), + openBDLeaderForm: vi.fn(), + openLeaderBDs: vi.fn(), + page: 1, + parentLeaderUserId: "", + query: "", + regionId: "", + regionOptions: [{ label: "南亚区", value: "1" }], + reload: vi.fn(), + removeBDLeader: vi.fn(), + resetFilters: vi.fn(), + saveBDLeaderPositionAlias: vi.fn(), + selectedLeader: null, + setBDForm: vi.fn(), + setBDLeaderForm: vi.fn(), + setPage: vi.fn(), + status: "", + submitBDLeader: vi.fn(), + submitLeaderBD: vi.fn(), + toggleBDLeader: vi.fn(), + ...patch, + }; +} + +function leaderFixture() { + return { + avatar: "", + createdAtMs: 1760000000000, + createdByDisplayUserId: "164000", + createdByUserId: "1000", + createdByUsername: "hyappadmin", + displayUserId: "164453", + positionAlias: "admin", + regionId: "1", + regionName: "南亚区", + status: "active", + subBdCount: 0, + userId: "3001", + username: "ABER KING", + }; +} diff --git a/src/features/host-org/pages/HostBdsPage.jsx b/src/features/host-org/pages/HostBdsPage.jsx index 8d7d855..b072700 100644 --- a/src/features/host-org/pages/HostBdsPage.jsx +++ b/src/features/host-org/pages/HostBdsPage.jsx @@ -6,9 +6,11 @@ import { DataState } from "@/shared/ui/DataState.jsx"; import { bdStatusFilters } from "@/features/host-org/constants.js"; import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx"; import { HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx"; +import { SalaryWalletAdjustButton, SalaryWalletCell } from "@/features/host-org/components/SalaryWalletControls.jsx"; import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx"; import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx"; import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js"; +import { AdminRowActions } from "@/shared/ui/AdminListLayout.jsx"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import styles from "@/features/host-org/host-org.module.css"; @@ -20,6 +22,19 @@ const bdColumns = [ width: "minmax(220px, 1.25fr)", }, { key: "role", label: "角色", width: "minmax(100px, 0.65fr)" }, + { + key: "salaryWallet", + label: "工资钱包", + render: (item) => ( + + ), + width: "minmax(140px, 0.85fr)", + }, { key: "parentLeaderUserId", label: "上级 Leader", @@ -68,7 +83,17 @@ export function HostBdsPage() { ? { icon: , label: "创建 BD", onClick: page.openBDForm, variant: "primary" } : null, ].filter(Boolean); - const columns = bdColumns.map((column) => { + const columns = [ + ...bdColumns, + page.abilities.canSalaryAdjust + ? { + key: "actions", + label: "操作", + render: (item, _index, context) => , + width: "minmax(80px, 0.45fr)", + } + : null, + ].filter(Boolean).map((column) => { if (column.key === "user") { return { ...column, @@ -116,7 +141,7 @@ export function HostBdsPage() { columns={columns} context={{ page, regionOptions: page.regionOptions }} items={items} - minWidth="1260px" + minWidth="1440px" pagination={ total > 0 ? { @@ -219,3 +244,20 @@ function Creator({ item }) { /> ); } + +function BDActions({ item, page }) { + return ( + + + + ); +} + +function bdUserLabel(item) { + return item.displayUserId || item.username || item.userId; +} diff --git a/src/features/host-org/pages/HostHostsPage.jsx b/src/features/host-org/pages/HostHostsPage.jsx index 5d3630b..d331644 100644 --- a/src/features/host-org/pages/HostHostsPage.jsx +++ b/src/features/host-org/pages/HostHostsPage.jsx @@ -6,8 +6,10 @@ import { formatMillis } from "@/shared/utils/time.js"; import { DataState } from "@/shared/ui/DataState.jsx"; import { hostSourceLabels, hostStatusFilters } from "@/features/host-org/constants.js"; import { HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx"; +import { SalaryWalletAdjustButton, SalaryWalletCell } from "@/features/host-org/components/SalaryWalletControls.jsx"; import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx"; import { useHostHostsPage } from "@/features/host-org/hooks/useHostHostsPage.js"; +import { AdminRowActions } from "@/shared/ui/AdminListLayout.jsx"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import styles from "@/features/host-org/host-org.module.css"; @@ -30,6 +32,19 @@ const hostColumns = [ render: (item) => formatNumber(item.diamond), width: "minmax(110px, 0.7fr)", }, + { + key: "salaryWallet", + label: "工资钱包", + render: (item) => ( + + ), + width: "minmax(140px, 0.85fr)", + }, { key: "regionId", label: "区域", @@ -66,7 +81,17 @@ export function HostHostsPage() { const page = useHostHostsPage(); const items = page.data.items || []; const total = page.data.total || 0; - const columns = hostColumns.map((column) => { + const columns = [ + ...hostColumns, + page.abilities.canSalaryAdjust + ? { + key: "actions", + label: "操作", + render: (item, _index, context) => , + width: "minmax(80px, 0.45fr)", + } + : null, + ].filter(Boolean).map((column) => { if (column.key === "user") { return { ...column, @@ -119,9 +144,9 @@ export function HostHostsPage() {
0 ? { @@ -206,3 +231,20 @@ function HostStatusSwitch({ item }) { /> ); } + +function HostActions({ item, page }) { + return ( + + + + ); +} + +function hostUserLabel(item) { + return item.displayUserId || item.username || item.userId; +} diff --git a/src/features/host-org/permissions.js b/src/features/host-org/permissions.js index f994b02..93c5f0e 100644 --- a/src/features/host-org/permissions.js +++ b/src/features/host-org/permissions.js @@ -29,6 +29,7 @@ export function useAgencyAbilities() { return { canCreate: can(PERMISSIONS.agencyCreate), canDelete: can(PERMISSIONS.agencyDelete), + canSalaryAdjust: can(PERMISSIONS.hostSalarySettlementSettle), canStatus: can(PERMISSIONS.agencyStatus), canView: can(PERMISSIONS.agencyView), }; @@ -38,6 +39,7 @@ export function useHostAbilities() { const { can } = useAuth(); return { + canSalaryAdjust: can(PERMISSIONS.hostSalarySettlementSettle), canView: can(PERMISSIONS.hostView), }; } @@ -47,6 +49,7 @@ export function useBDAbilities() { return { canCreate: can(PERMISSIONS.bdCreate), + canSalaryAdjust: can(PERMISSIONS.hostSalarySettlementSettle), canUpdate: can(PERMISSIONS.bdUpdate), canView: can(PERMISSIONS.bdView), }; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index 1fce49e..f6dd515 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -272,6 +272,64 @@ export interface CoinAdjustmentCreateDto { user: CoinLedgerUserDto; } +export interface SalaryWalletDto { + assetType: string; + availableAmount: number; + frozenAmount?: number; + role: string; + updatedAtMs?: number; + userId: string; +} + +export interface SalaryWalletOperatorDto { + adminId?: string; + name?: string; + username?: string; +} + +export interface SalaryWalletHistoryDto { + amountMinor: number; + assetType: string; + availableAfter: number; + availableDelta: number; + bizType?: string; + commandId?: string; + counterpartyUserId?: string; + createdAtMs?: number; + direction: "income" | "expense" | string; + entryId: number; + externalRef?: string; + frozenAfter?: number; + frozenDelta?: number; + metadata?: Record; + operator?: SalaryWalletOperatorDto; + operatorUserId?: string; + reason?: string; + role: string; + roomId?: string; + transactionId: string; + userId: string; +} + +export interface SalaryWalletAdjustPayload { + amountMinor: number; + commandId?: string; + reason: string; + role: "host" | "agency" | "bd" | "bd_leader" | string; + targetUserId: EntityId; + type: "increase" | "decrease"; +} + +export interface SalaryWalletAdjustResultDto { + amountMinor: number; + assetType: string; + balance: SalaryWalletDto; + reason?: string; + role: string; + transactionId: string; + userId: string; +} + export interface ReportUserDto { avatar?: string; defaultDisplayUserId?: string; @@ -449,6 +507,7 @@ export interface BDProfileDto { regionId?: number; regionName?: string; role?: string; + salaryWallet?: SalaryWalletDto; status?: string; subBdCount?: number; updatedAtMs?: number; @@ -473,6 +532,7 @@ export interface AgencyDto { parentBdUserId?: string; regionId?: number; regionName?: string; + salaryWallet?: SalaryWalletDto; status?: string; updatedAtMs?: number; } @@ -494,6 +554,7 @@ export interface HostProfileDto { firstBecameHostAtMs?: number; regionId?: number; regionName?: string; + salaryWallet?: SalaryWalletDto; source?: string; status?: string; updatedAtMs?: number;