主播工资

This commit is contained in:
zhx 2026-06-14 23:41:21 +08:00
parent 001eb0d4dc
commit 79da033dc9
12 changed files with 1340 additions and 58 deletions

View File

@ -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<string, unknown>;
type RawHostPending = HostSalarySettlementPendingDto & Record<string, unknown>;
type RawHostSettleResult = HostSalarySettleResult & Record<string, unknown>;
export function listHostSalarySettlements(query: PageQuery = {}): Promise<ApiPage<HostSalarySettlementDto>> {
const endpoint = API_ENDPOINTS.listHostSalarySettlements;
@ -254,6 +324,25 @@ export function listHostSalarySettlements(query: PageQuery = {}): Promise<ApiPag
}));
}
export function listHostSalarySettlementPending(
query: PageQuery = {},
): Promise<ApiPage<HostSalarySettlementPendingDto>> {
return apiRequest<ApiPage<RawHostPending>>("/v1/admin/host-salary-settlements/pending", {
method: "GET",
query,
}).then((page) => ({
...page,
items: (page.items || []).map(normalizeHostPending),
}));
}
export function settleHostSalary(payload: HostSalarySettlePayload): Promise<HostSalarySettleResult> {
return apiRequest<RawHostSettleResult, HostSalarySettlePayload>("/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<string, unknown>): 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),

View File

@ -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 (
<AdminListPage>
<div className={styles.policyTabsBar}>
<Tabs value={activeTab} onChange={(_, value) => setActiveTab(value)}>
<Tab className={styles.policyTab} label="待结算" value="team-pending" />
<Tab className={styles.policyTab} label="待结算" value="pending" />
<Tab className={styles.policyTab} label="BD/Admin 记录" value="team-records" />
<Tab className={styles.policyTab} label="Host/Agency 记录" value="host-records" />
</Tabs>
</div>
{activeTab === "team-pending" ? (
<TeamPendingPanel />
{activeTab === "pending" ? (
<PendingPanel />
) : activeTab === "team-records" ? (
<TeamRecordsPanel />
) : (
@ -80,6 +91,221 @@ export function HostSalarySettlementPage() {
);
}
function PendingPanel() {
const [role, setRole] = useState("bd");
if (role === "host" || role === "agency") {
return <HostPendingPanel role={role} onRoleChange={setRole} />;
}
return <TeamPendingPanel role={role} onRoleChange={setRole} />;
}
// 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: (
<Checkbox
checked={allVisibleSelected}
disabled={!items.length}
indeterminate={selected.size > 0 && !allVisibleSelected}
size="small"
onChange={toggleVisible}
/>
),
width: "64px",
resizable: false,
render: (item) => (
<Checkbox
checked={selected.has(hostPendingKey(item))}
size="small"
onChange={() => toggleOne(item)}
/>
),
},
...hostPendingColumns,
{
key: "actions",
label: "操作",
width: "minmax(120px, 0.55fr)",
render: (item) =>
<Button
disabled={!abilities.canSettleSalary || settling}
size="small"
startIcon={<PlayArrowOutlined fontSize="small" />}
variant="secondary"
onClick={() => settleRows([item])}
>
结算
</Button>
},
];
const hasFilters =
role !== "bd" || settlementType !== "half_month" || cycleKey !== currentCycleKey() || countryCode || regionId;
return (
<>
<AdminListToolbar
actions={
<Button
disabled={!abilities.canSettleSalary || settling || selected.size === 0}
startIcon={<PlayArrowOutlined fontSize="small" />}
variant="primary"
onClick={settleSelected}
>
批量结算
</Button>
}
filters={
<div className={styles.toolbarFilters}>
<AdminFilterSelect label="角色" options={pendingRoleOptions} value={role} onChange={changeRole} />
<AdminFilterSelect
label="结算类型"
options={settlementTypeOptions.filter(([value]) => value !== "")}
value={settlementType}
onChange={changeFilter(setSettlementType)}
/>
<AdminFilterSelect label="账单周期" options={cycleFilterOptions} value={cycleKey} onChange={changeFilter(setCycleKey)} />
<AdminFilterSelect label="国家" options={countryFilterOptions} value={countryCode} onChange={changeFilter(setCountryCode)} />
<AdminFilterSelect label="区域" options={regionFilterOptions} value={regionId} onChange={changeFilter(setRegionId)} />
<AdminFilterResetButton disabled={!hasFilters} onClick={resetFilters} />
</div>
}
/>
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
<AdminListBody>
<DataTable
columns={tableColumns}
items={items}
minWidth="1540px"
pagination={paginationProps(data, page, total, setPage)}
rowKey={hostPendingKey}
/>
</AdminListBody>
</DataState>
</>
);
}
// 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,
];
// hasFiltersBD + +
const hasFilters =
policyType !== "bd" || triggerMode !== "automatic" || cycleKey !== defaultCycleKey() || countryCode || regionId;
// hasFiltersBD + +
const hasFilters = role !== "bd" || cycleKey !== defaultCycleKey() || countryCode || regionId;
return (
<>
@ -368,34 +600,13 @@ function TeamPendingPanel() {
<div className={styles.toolbarFilters}>
<AdminFilterSelect
label="角色"
options={policyTypeOptions}
value={policyType}
onChange={changeFilter(setPolicyType)}
/>
<AdminFilterSelect
label="政策触发"
options={triggerOptions}
value={triggerMode}
onChange={changeFilter(setTriggerMode)}
/>
<InlineFilter
label="周期"
placeholder="YYYY-MM"
value={cycleKey}
onChange={changeFilter(setCycleKey)}
/>
<InlineFilter
label="国家"
placeholder="国家代码"
value={countryCode}
onChange={changeFilter(setCountryCode)}
/>
<InlineFilter
label="区域"
placeholder="区域 ID"
value={regionId}
onChange={changeFilter(setRegionId)}
options={pendingRoleOptions}
value={role}
onChange={changeRole}
/>
<AdminFilterSelect label="账单周期" options={cycleFilterOptions} value={cycleKey} onChange={changeFilter(setCycleKey)} />
<AdminFilterSelect label="国家" options={countryFilterOptions} value={countryCode} onChange={changeFilter(setCountryCode)} />
<AdminFilterSelect label="区域" options={regionFilterOptions} value={regionId} onChange={changeFilter(setRegionId)} />
<AdminFilterResetButton disabled={!hasFilters} onClick={resetFilters} />
</div>
}
@ -612,6 +823,89 @@ const hostRecordColumns = [
},
];
// hostPendingColumns Host/Agency 便
const hostPendingColumns = [
{
key: "host",
label: "主播",
width: "minmax(240px, 1.1fr)",
render: (item) => <UserCell fallbackId={item.userId} user={item.user} />,
},
{
key: "agency",
label: "代理",
width: "minmax(220px, 1fr)",
render: (item) => <UserCell fallbackId={item.agencyOwnerUserId} user={item.agencyOwner} />,
},
{
key: "scope",
label: "周期/政策",
width: "minmax(230px, 1fr)",
render: (item) => (
<div className={styles.stack}>
<span>
{item.cycleKey || "-"} · {settlementTypeLabel(item.settlementType)}
</span>
<span className={styles.meta}>{item.regionName || `区域 ${item.regionId || "-"}`}</span>
<span className={styles.meta}>{item.policyName || `政策 ${item.policyId || "-"}`}</span>
</div>
),
},
{
key: "level",
label: "等级/钻石",
width: "minmax(160px, 0.75fr)",
render: (item) => (
<div className={styles.stack}>
<span>{item.levelNo > 0 ? `等级 ${item.levelNo}` : "未达等级"}</span>
<span className={styles.meta}>{formatNumber(item.totalDiamonds)} 钻石</span>
<span className={styles.meta}>门槛 {formatNumber(item.requiredDiamonds)}</span>
</div>
),
},
{
key: "hostReward",
label: "主播待发",
width: "minmax(170px, 0.8fr)",
render: (item) => (
<div className={styles.stack}>
<span className={styles.amountPositive}>{formatMoneyDelta(item.hostSalaryUsdDelta)}</span>
<span className={styles.meta}>金币 {formatSignedInteger(item.hostCoinRewardDelta)}</span>
</div>
),
},
{
key: "agencyReward",
label: "代理/剩余",
width: "minmax(170px, 0.8fr)",
render: (item) => <AgencyRewardCell item={item} />,
},
{
key: "total",
label: "待发总额",
width: "minmax(150px, 0.7fr)",
render: (item) => <span className={styles.amountPositive}>{formatMoneyDelta(item.totalUsdDelta)}</span>,
},
{
key: "progress",
label: "已结进度",
width: "minmax(170px, 0.8fr)",
render: (item) => (
<div className={styles.stack}>
<span>等级 {item.settledLevelNo || 0}</span>
<span className={styles.meta}>主播 ${item.settledHostSalaryUsd || "0"}</span>
<span className={styles.meta}>代理 ${item.settledAgencySalaryUsd || "0"}</span>
</div>
),
},
{
key: "status",
label: "状态",
width: "minmax(120px, 0.55fr)",
render: (item) => <StatusCell item={item} />,
},
];
// teamPendingColumns BD/Admin
const teamPendingColumns = [
{
@ -751,7 +1045,10 @@ function CycleCell({ item }) {
return (
<div className={styles.stack}>
<span>{item.cycleKey || "-"}</span>
<span className={styles.meta}>{settlementTypeLabel(item.settlementType)}</span>
<span className={styles.meta}>
{settlementTypeLabel(item.settlementType)} · {triggerLabel(item.triggerMode)} ·{" "}
{roleLabel(item.settlementRole)}
</span>
</div>
);
}
@ -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 /

View File

@ -31,6 +31,9 @@ import type {
RegionDto,
RegionPayload,
RegionUpdatePayload,
SalaryWalletAdjustPayload,
SalaryWalletAdjustResultDto,
SalaryWalletHistoryDto,
} from "@/shared/api/types";
export function listCountries(query: PageQuery = {}): Promise<ApiList<CountryDto>> {
@ -179,6 +182,20 @@ export function listCoinSellers(query: PageQuery = {}): Promise<ApiPage<CoinSell
});
}
export function listSalaryWalletHistory(query: PageQuery = {}): Promise<ApiPage<SalaryWalletHistoryDto>> {
return apiRequest<ApiPage<SalaryWalletHistoryDto>>("/v1/admin/host-org/salary-wallets/history", {
method: "GET",
query,
});
}
export function adjustSalaryWallet(payload: SalaryWalletAdjustPayload): Promise<SalaryWalletAdjustResultDto> {
return apiRequest<SalaryWalletAdjustResultDto, SalaryWalletAdjustPayload>("/v1/admin/host-org/salary-wallets/adjust", {
body: payload,
method: "POST",
});
}
export function createBDLeader(payload: CreateBDLeaderPayload): Promise<BDProfileDto> {
const endpoint = API_ENDPOINTS.createBDLeader;
return apiRequest<BDProfileDto, CreateBDLeaderPayload>(apiEndpointPath(API_OPERATIONS.createBDLeader), {

View File

@ -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) => <SalaryHistoryAmount item={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 (
<>
<button
className={styles.salaryWalletButton}
type="button"
onClick={() => setOpen(true)}
>
<span className={styles.salaryWalletAmount}>{formatSalaryWalletAmount(balance)}</span>
<span className={styles.salaryWalletAsset}>{assetType || "-"}</span>
</button>
<SalaryWalletHistoryDrawer
open={open}
role={role}
userId={userId}
userLabel={userLabel}
onClose={() => 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 (
<>
<AdminActionIconButton
disabled={disabled || !role || !userId}
label="工资操作"
onClick={() => setOpen(true)}
>
<PaidOutlined fontSize="small" />
</AdminActionIconButton>
<AdminFormDialog
disabled={disabled}
loading={submitting}
open={open}
size="large"
submitDisabled={disabled || submitting || !role || !userId}
submitLabel="确认"
title="工资操作"
onClose={close}
onSubmit={submit}
>
<AdminFormSection title="目标身份">
<AdminFormFieldGrid columns="repeat(3, minmax(0, 1fr))">
<AdminFormReadOnlyField label="身份" value={salaryWalletRoleLabel(role)} />
<AdminFormReadOnlyField label="用户" value={userLabel || userId || "-"} />
<AdminFormReadOnlyField label="钱包" value={salaryWalletAssetType(role) || "-"} />
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="操作信息">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={disabled || submitting}
label="类型"
required
select
value={form.type}
onChange={(event) => setForm((current) => ({ ...current, type: event.target.value }))}
>
<MenuItem value="increase">工资增加</MenuItem>
<MenuItem value="decrease">工资减少</MenuItem>
</TextField>
<AdminFormAmountField
disabled={disabled || submitting}
label="金额"
required
unit="USD"
value={form.amount}
onChange={(event) => setForm((current) => ({ ...current, amount: event.target.value }))}
/>
</AdminFormFieldGrid>
<TextField
disabled={disabled || submitting}
label="原因"
multiline
minRows={3}
required
value={form.reason}
onChange={(event) => setForm((current) => ({ ...current, reason: event.target.value }))}
/>
</AdminFormSection>
</AdminFormDialog>
</>
);
}
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 (
<SideDrawer
className={styles.salaryWalletDrawer}
contentClassName={styles.salaryWalletDrawerBody}
open={open}
title={`工资钱包历史 - ${userLabel || userId || ""}`}
width="wide"
onClose={onClose}
>
<div className={styles.salaryWalletSummary}>
<AccountBalanceWalletOutlined fontSize="small" />
<span>{salaryWalletRoleLabel(role)}</span>
<span>{salaryWalletAssetType(role) || "-"}</span>
</div>
<DataState error={error} loading={loading} onRetry={loadHistory}>
<DataTable
columns={historyColumns}
emptyLabel="暂无工资钱包历史"
items={data.items || []}
minWidth="930px"
pagination={
total > 0
? {
page,
pageSize: data.pageSize || historyPageSize,
total,
onPageChange: setPage,
}
: undefined
}
rowKey={(item) => item.entryId || item.transactionId}
/>
</DataState>
</SideDrawer>
);
}
function SalaryHistoryAmount({ item }) {
const delta = Number(item.availableDelta ?? item.amountMinor ?? 0);
const expense = delta < 0;
return (
<span className={expense ? styles.salaryWalletExpense : styles.salaryWalletIncome}>
{expense ? "-" : "+"}
{formatSalaryWalletAmount(Math.abs(delta))}
</span>
);
}
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()}`;
}

View File

@ -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);
}

View File

@ -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) => <AgencyOwner item={item} />,
width: "minmax(220px, 1.2fr)",
},
{
key: "salaryWallet",
label: "工资钱包",
render: (item) => (
<SalaryWalletCell
role="agency"
userId={item.ownerUserId}
userLabel={agencyOwnerLabel(item)}
wallet={item.salaryWallet}
/>
),
width: "minmax(140px, 0.85fr)",
},
{
key: "parentBdUserId",
label: "上级 BD",
@ -61,7 +75,7 @@ const agencyColumns = [
key: "actions",
label: "操作",
render: (item, _index, context) => <AgencyActions item={item} page={context?.page} />,
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 (
<AdminRowActions>
{page?.abilities.canSalaryAdjust ? (
<SalaryWalletAdjustButton
role="agency"
userId={item.ownerUserId}
userLabel={agencyOwnerLabel(item)}
onAdjusted={page.reload}
/>
) : null}
{page?.abilities.canDelete ? (
<AdminActionIconButton
disabled={page.loadingAction === `agency-delete-${item.agencyId}`}
@ -269,3 +291,7 @@ const dangerActionSx = {
backgroundColor: "var(--danger-surface)",
},
};
function agencyOwnerLabel(item) {
return item.ownerDisplayUserId || item.ownerUsername || item.ownerUserId;
}

View File

@ -10,10 +10,13 @@ import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLay
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { SalaryWalletAdjustButton, SalaryWalletCell } from "@/features/host-org/components/SalaryWalletControls.jsx";
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.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 { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import styles from "@/features/host-org/host-org.module.css";
const bdLeaderBaseColumns = [
@ -29,6 +32,19 @@ const bdLeaderBaseColumns = [
render: (item, _index, context) => regionName(item, context?.regionOptions),
width: "minmax(130px, 0.75fr)",
},
{
key: "salaryWallet",
label: "工资钱包",
render: (item) => (
<SalaryWalletCell
role="bd_leader"
userId={item.userId}
userLabel={leaderUserLabel(item)}
wallet={item.salaryWallet}
/>
),
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) => <BDLeaderActions item={item} page={page} />,
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}`}
/>
</div>
@ -343,6 +380,14 @@ function SubBDCount({ item, page }) {
function BDLeaderActions({ item, page }) {
return (
<AdminRowActions>
{page.abilities.canSalaryAdjust ? (
<SalaryWalletAdjustButton
role="bd_leader"
userId={item.userId}
userLabel={leaderUserLabel(item)}
onAdjusted={page.reload}
/>
) : null}
{page.abilities.canCreate ? (
<AdminActionIconButton label="增加下级 BD" onClick={() => page.openAddLeaderBD(item)}>
<PersonAddAlt1Outlined fontSize="small" />
@ -402,3 +447,7 @@ const dangerActionSx = {
color: "var(--danger)",
},
};
function leaderUserLabel(item) {
return item.displayUserId || item.username || item.userId;
}

View File

@ -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(<HostBdLeadersPage />);
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",
};
}

View File

@ -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) => (
<SalaryWalletCell
role="bd"
userId={item.userId}
userLabel={bdUserLabel(item)}
wallet={item.salaryWallet}
/>
),
width: "minmax(140px, 0.85fr)",
},
{
key: "parentLeaderUserId",
label: "上级 Leader",
@ -68,7 +83,17 @@ export function HostBdsPage() {
? { icon: <Add fontSize="small" />, 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) => <BDActions item={item} page={context?.page} />,
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 (
<AdminRowActions>
<SalaryWalletAdjustButton
role="bd"
userId={item.userId}
userLabel={bdUserLabel(item)}
onAdjusted={page?.reload}
/>
</AdminRowActions>
);
}
function bdUserLabel(item) {
return item.displayUserId || item.username || item.userId;
}

View File

@ -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) => (
<SalaryWalletCell
role="host"
userId={item.userId}
userLabel={hostUserLabel(item)}
wallet={item.salaryWallet}
/>
),
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) => <HostActions item={item} page={context?.page} />,
width: "minmax(80px, 0.45fr)",
}
: null,
].filter(Boolean).map((column) => {
if (column.key === "user") {
return {
...column,
@ -119,9 +144,9 @@ export function HostHostsPage() {
<div className={styles.listBlock}>
<HostOrgTable
columns={columns}
context={{ regionOptions: page.regionOptions }}
context={{ page, regionOptions: page.regionOptions }}
items={items}
minWidth="1240px"
minWidth="1420px"
pagination={
total > 0
? {
@ -206,3 +231,20 @@ function HostStatusSwitch({ item }) {
/>
);
}
function HostActions({ item, page }) {
return (
<AdminRowActions>
<SalaryWalletAdjustButton
role="host"
userId={item.userId}
userLabel={hostUserLabel(item)}
onAdjusted={page?.reload}
/>
</AdminRowActions>
);
}
function hostUserLabel(item) {
return item.displayUserId || item.username || item.userId;
}

View File

@ -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),
};

View File

@ -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<string, unknown>;
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;