主播工资
This commit is contained in:
parent
001eb0d4dc
commit
79da033dc9
@ -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),
|
||||
|
||||
@ -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,
|
||||
];
|
||||
// 默认值也纳入 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() {
|
||||
<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 把触发方式展示为自动/手动。
|
||||
|
||||
@ -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), {
|
||||
|
||||
374
src/features/host-org/components/SalaryWalletControls.jsx
Normal file
374
src/features/host-org/components/SalaryWalletControls.jsx
Normal 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()}`;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
98
src/features/host-org/pages/HostBdLeadersPage.test.jsx
Normal file
98
src/features/host-org/pages/HostBdLeadersPage.test.jsx
Normal 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",
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user