政策相关

This commit is contained in:
zhx 2026-05-29 19:56:45 +08:00
parent b96fdb4f6a
commit 49f54e0463
34 changed files with 4946 additions and 70 deletions

View File

@ -27,6 +27,7 @@
- 页面:按业务放在 `src/features/*/pages/`
- 应用壳:`src/app/App.jsx``src/app/layout/AdminLayout.jsx``src/app/router/routeConfig.ts`
- 页面加载占位:`src/shared/ui/PageSkeleton.jsx`
- 公共时间选择器:`src/shared/ui/TimeRangeFilter.jsx`,使用 `{ startMs, endMs }` 毫秒值作为输入输出。
- 全局资源组选择抽屉:`src/shared/ui/ResourceGroupSelectDrawer.jsx`,资源组字段优先使用该组件,不在业务页面里重复手写普通下拉。
- 顶部搜索弹窗:`src/features/search/components/SearchDialog.jsx`
- MUI 主题:`src/theme.js`
@ -91,6 +92,7 @@ import { Refresh } from "@mui/icons-material";
- 表格里的启用/禁用状态优先使用行内 Switch 快捷操作,不使用单独的启用/禁用图标按钮。
- 表格状态筛选使用 Select 下拉框,默认值为 `全部状态`,不使用状态 chip 组。
- 后台列表页的时间区间筛选必须使用统一的单入口时间区间筛选框(`src/shared/ui/TimeRangeFilter.jsx`);不要在工具栏里并排放两个开始/结束时间输入框,也不要使用浏览器原生日期/时间输入样式。时间区间弹层内使用快捷项、日历和时分下拉点选,不使用手写时间文本框。
- 后台表单里的生效时间、有效期、投放时间等时间区间必须使用统一公共时间选择器(`src/shared/ui/TimeRangeFilter.jsx`);不要并排放两个开始/结束时间输入框,也不要使用浏览器原生日期/时间输入样式。
- 资源组选择必须优先使用统一的 `src/shared/ui/ResourceGroupSelectDrawer.jsx`,点击输入框打开右侧资源组图标抽屉,点击资源组后自动回填并关闭,不要在业务页面里重复实现资源组 Select 下拉。
- 区域编辑弹窗必须同时包含区域基础字段和国家码字段;国家码使用可搜索多选下拉框,不再拆成独立“区域国家”弹窗。
- 头像、图片、封面等图片资源字段必须使用 `src/shared/ui/UploadField.jsx` 上传表单组件,不使用普通文本输入框承载 URL。

View File

@ -132,7 +132,7 @@
"x-permissions": ["user-leaderboard:view"]
}
},
"/admin/activity/red-packets": {
"/resident-activity/voice-room-red-packet/records": {
"get": {
"operationId": "listRedPackets",
"responses": {
@ -144,7 +144,7 @@
"x-permissions": ["red-packet:view"]
}
},
"/admin/activity/red-packets/{packet_id}": {
"/resident-activity/voice-room-red-packet/records/{packet_id}": {
"get": {
"operationId": "getRedPacket",
"responses": {
@ -166,7 +166,7 @@
"x-permissions": ["red-packet:view"]
}
},
"/admin/activity/red-packets/config": {
"/resident-activity/voice-room-red-packet/config": {
"get": {
"operationId": "getRedPacketConfig",
"responses": {
@ -177,7 +177,7 @@
"x-permission": "red-packet:view",
"x-permissions": ["red-packet:view"]
},
"put": {
"post": {
"operationId": "updateRedPacketConfig",
"responses": {
"200": {
@ -188,6 +188,18 @@
"x-permissions": ["red-packet:update"]
}
},
"/resident-activity/voice-room-red-packet/refund/retry": {
"post": {
"operationId": "retryRedPacketRefund",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "red-packet:update",
"x-permissions": ["red-packet:update"]
}
},
"/admin/activity/lucky-gifts/v2/config": {
"get": {
"operationId": "getLuckyGiftConfig",
@ -1425,6 +1437,168 @@
"x-permissions": ["host:view"]
}
},
"/admin/host-agency-policies": {
"get": {
"operationId": "listHostAgencyPolicies",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "host-agency-policy:view",
"x-permissions": ["host-agency-policy:view"]
},
"post": {
"operationId": "createHostAgencyPolicy",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "host-agency-policy:create",
"x-permissions": ["host-agency-policy:create"]
}
},
"/admin/host-agency-policies/{policy_id}": {
"put": {
"operationId": "updateHostAgencyPolicy",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "policy_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "host-agency-policy:update",
"x-permissions": ["host-agency-policy:update"]
},
"delete": {
"operationId": "deleteHostAgencyPolicy",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "policy_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "host-agency-policy:delete",
"x-permissions": ["host-agency-policy:delete"]
}
},
"/admin/host-agency-policies/{policy_id}/publish": {
"post": {
"operationId": "publishHostAgencyPolicy",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "policy_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "host-agency-policy:publish",
"x-permissions": ["host-agency-policy:publish"]
}
},
"/admin/host-salary-settlements": {
"get": {
"operationId": "listHostSalarySettlements",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "host-salary-settlement:view",
"x-permissions": ["host-salary-settlement:view"]
}
},
"/admin/team-salary-policies": {
"get": {
"operationId": "listTeamSalaryPolicies",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "team-salary-policy:view",
"x-permissions": ["team-salary-policy:view"]
},
"post": {
"operationId": "createTeamSalaryPolicy",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "team-salary-policy:create",
"x-permissions": ["team-salary-policy:create"]
}
},
"/admin/team-salary-policies/{policy_id}": {
"put": {
"operationId": "updateTeamSalaryPolicy",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "policy_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "team-salary-policy:update",
"x-permissions": ["team-salary-policy:update"]
},
"delete": {
"operationId": "deleteTeamSalaryPolicy",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "policy_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "team-salary-policy:delete",
"x-permissions": ["team-salary-policy:delete"]
}
},
"/admin/operations/coin-adjustments": {
"get": {
"operationId": "listCoinAdjustments",
@ -1471,6 +1645,18 @@
"x-permissions": ["coin-ledger:view"]
}
},
"/admin/operations/reports": {
"get": {
"operationId": "listReports",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "report:view",
"x-permissions": ["report:view"]
}
},
"/admin/payment/recharge-bills": {
"get": {
"operationId": "listRechargeBills",

View File

@ -6,6 +6,7 @@ import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
import CategoryOutlined from "@mui/icons-material/CategoryOutlined";
import DashboardOutlined from "@mui/icons-material/DashboardOutlined";
import EventAvailableOutlined from "@mui/icons-material/EventAvailableOutlined";
import FlagOutlined from "@mui/icons-material/FlagOutlined";
import GroupsOutlined from "@mui/icons-material/GroupsOutlined";
import HistoryOutlined from "@mui/icons-material/HistoryOutlined";
import HubOutlined from "@mui/icons-material/HubOutlined";
@ -44,6 +45,7 @@ const iconMap = {
dashboard: DashboardOutlined,
event_available: EventAvailableOutlined,
explore: MapOutlined,
flag: FlagOutlined,
gift: CardGiftcardOutlined,
inventory: Inventory2Outlined,
leaderboard: LeaderboardOutlined,
@ -144,6 +146,7 @@ export const fallbackNavigation = [
children: [
routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined }),
routeNavItem("operation-coin-adjustment", { icon: WalletOutlined }),
routeNavItem("operation-reports", { icon: FlagOutlined }),
routeNavItem("lucky-gift", { icon: RedeemOutlined }),
],
},
@ -198,6 +201,8 @@ export const fallbackNavigation = [
routeNavItem("host-org-bd-leaders", { icon: ShieldOutlined }),
routeNavItem("host-org-bds", { icon: GroupsOutlined }),
routeNavItem("host-org-hosts", { icon: ManageAccountsOutlined }),
routeNavItem("host-agency-policy", { icon: WalletOutlined }),
routeNavItem("host-salary-settlement", { icon: ReceiptLongOutlined }),
routeNavItem("host-org-coin-sellers", { icon: WalletOutlined }),
],
},

View File

@ -15,6 +15,17 @@ export const PERMISSIONS = {
regionUpdate: "region:update",
regionStatus: "region:status",
hostView: "host:view",
hostAgencyPolicyView: "host-agency-policy:view",
hostAgencyPolicyCreate: "host-agency-policy:create",
hostAgencyPolicyUpdate: "host-agency-policy:update",
hostAgencyPolicyDelete: "host-agency-policy:delete",
hostAgencyPolicyPublish: "host-agency-policy:publish",
teamSalaryPolicyView: "team-salary-policy:view",
teamSalaryPolicyCreate: "team-salary-policy:create",
teamSalaryPolicyUpdate: "team-salary-policy:update",
teamSalaryPolicyDelete: "team-salary-policy:delete",
hostSalarySettlementView: "host-salary-settlement:view",
hostSalarySettlementSettle: "host-salary-settlement:settle",
agencyView: "agency:view",
agencyCreate: "agency:create",
agencyStatus: "agency:status",
@ -28,6 +39,7 @@ export const PERMISSIONS = {
coinLedgerView: "coin-ledger:view",
coinAdjustmentView: "coin-adjustment:view",
coinAdjustmentCreate: "coin-adjustment:create",
reportView: "report:view",
paymentBillView: "payment-bill:view",
paymentProductView: "payment-product:view",
paymentProductCreate: "payment-product:create",
@ -160,6 +172,7 @@ export const MENU_CODES = {
operations: "operations",
operationCoinLedger: "operation-coin-ledger",
operationCoinAdjustment: "operation-coin-adjustment",
operationReports: "operation-reports",
luckyGift: "lucky-gift",
activities: "activities",
dailyTaskList: "daily-task-list",
@ -180,6 +193,8 @@ export const MENU_CODES = {
hostOrgBdLeaders: "host-org-bd-leaders",
hostOrgBds: "host-org-bds",
hostOrgHosts: "host-org-hosts",
hostAgencyPolicy: "host-agency-policy",
hostSalarySettlement: "host-salary-settlement",
hostOrgCoinSellers: "host-org-coin-sellers",
payment: "payment",
paymentBillList: "payment-bill-list",

View File

@ -6,6 +6,7 @@ import { dashboardRoutes } from "@/features/dashboard/routes.js";
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/routes.js";
import { gameRoutes } from "@/features/games/routes.js";
import { hostAgencyPolicyRoutes } from "@/features/host-agency-policy/routes.js";
import { hostOrgRoutes } from "@/features/host-org/routes.js";
import { levelConfigRoutes } from "@/features/level-config/routes.js";
import { logsRoutes } from "@/features/logs/routes.js";
@ -51,6 +52,7 @@ export const adminRoutes: AdminRoute[] = [
...paymentRoutes,
...gameRoutes,
...usersRoutes,
...hostAgencyPolicyRoutes,
...hostOrgRoutes,
...rolesRoutes,
...menusRoutes,

View File

@ -0,0 +1,560 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type { ApiPage, EntityId, PageQuery } from "@/shared/api/types";
export interface HostAgencyPolicyLevelDto {
id?: number;
policyId?: number;
level: number;
// 后端保存的是达到该等级后的累计权益,列表里展示的最高工资也基于累计值。
requiredDiamonds: number;
hostSalaryUsd: string;
hostCoinReward: number;
agencySalaryUsd: string;
totalSalaryUsd?: string;
status: string;
sortOrder?: number;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface HostAgencyPolicyDto {
id: number;
appCode?: string;
name: string;
regionId: number;
status: string;
settlementMode: string;
settlementTriggerMode: string;
giftCoinToDiamondRatio: string;
residualDiamondToUsdRate: string;
effectiveFromMs?: number;
effectiveToMs?: number;
createdByAdminId?: number;
updatedByAdminId?: number;
publishStatus?: string;
publishError?: string;
publishedAtMs?: number;
publishedByAdminId?: number;
createdAtMs?: number;
updatedAtMs?: number;
levels: HostAgencyPolicyLevelDto[];
}
export interface HostAgencyPolicyPayload {
name: string;
// 第一阶段按单区域配置;同一区域同一生效时间只能启用一条政策。
region_id: number;
status: string;
settlement_mode: string;
settlement_trigger_mode: string;
gift_coin_to_diamond_ratio: string;
residual_diamond_to_usd_rate: string;
effective_from_ms: number;
effective_to_ms: number;
levels: Array<{
level: number;
required_diamonds: number;
host_salary_usd: string;
host_coin_reward: number;
agency_salary_usd: string;
status: string;
sort_order: number;
}>;
}
type RawPolicy = HostAgencyPolicyDto & Record<string, unknown>;
type RawLevel = HostAgencyPolicyLevelDto & Record<string, unknown>;
export function listHostAgencyPolicies(query: PageQuery = {}): Promise<ApiPage<HostAgencyPolicyDto>> {
const endpoint = API_ENDPOINTS.listHostAgencyPolicies;
// 统一走 OpenAPI 生成的 operation path避免手写路径和后端契约漂移。
return apiRequest<ApiPage<RawPolicy>>(apiEndpointPath(API_OPERATIONS.listHostAgencyPolicies), {
method: endpoint.method,
query,
}).then((page) => ({
...page,
items: (page.items || []).map(normalizePolicy),
}));
}
export function createHostAgencyPolicy(payload: HostAgencyPolicyPayload): Promise<HostAgencyPolicyDto> {
const endpoint = API_ENDPOINTS.createHostAgencyPolicy;
return apiRequest<RawPolicy, HostAgencyPolicyPayload>(apiEndpointPath(API_OPERATIONS.createHostAgencyPolicy), {
body: payload,
method: endpoint.method,
}).then(normalizePolicy);
}
export function updateHostAgencyPolicy(
policyId: EntityId,
payload: HostAgencyPolicyPayload,
): Promise<HostAgencyPolicyDto> {
const endpoint = API_ENDPOINTS.updateHostAgencyPolicy;
return apiRequest<RawPolicy, HostAgencyPolicyPayload>(
apiEndpointPath(API_OPERATIONS.updateHostAgencyPolicy, { policy_id: policyId }),
{
body: payload,
method: endpoint.method,
},
).then(normalizePolicy);
}
export function deleteHostAgencyPolicy(policyId: EntityId): Promise<unknown> {
const endpoint = API_ENDPOINTS.deleteHostAgencyPolicy;
return apiRequest(apiEndpointPath(API_OPERATIONS.deleteHostAgencyPolicy, { policy_id: policyId }), {
method: endpoint.method,
});
}
export function publishHostAgencyPolicy(policyId: EntityId): Promise<HostAgencyPolicyDto> {
const endpoint = API_ENDPOINTS.publishHostAgencyPolicy;
return apiRequest<RawPolicy>(apiEndpointPath(API_OPERATIONS.publishHostAgencyPolicy, { policy_id: policyId }), {
method: endpoint.method,
}).then(normalizePolicy);
}
export interface TeamSalaryPolicyLevelDto {
id?: number;
policyId?: number;
level: number;
thresholdUsd: string;
ratePercent: string;
salaryUsd: string;
status: string;
sortOrder?: number;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface TeamSalaryPolicyDto {
id: number;
appCode?: string;
policyType: "bd" | "admin";
name: string;
regionId: number;
status: string;
settlementTriggerMode: string;
effectiveFromMs?: number;
effectiveToMs?: number;
createdByAdminId?: number;
updatedByAdminId?: number;
createdAtMs?: number;
updatedAtMs?: number;
levels: TeamSalaryPolicyLevelDto[];
}
export interface TeamSalaryPolicyPayload {
policy_type: "bd" | "admin";
name: string;
region_id: number;
status: string;
settlement_trigger_mode: string;
effective_from_ms: number;
effective_to_ms: number;
description?: string;
levels: Array<{
level: number;
threshold_usd: string;
rate_percent: string;
salary_usd: string;
status: string;
sort_order: number;
}>;
}
type RawTeamPolicy = TeamSalaryPolicyDto & Record<string, unknown>;
type RawTeamLevel = TeamSalaryPolicyLevelDto & Record<string, unknown>;
export function listTeamSalaryPolicies(query: PageQuery = {}): Promise<ApiPage<TeamSalaryPolicyDto>> {
const endpoint = API_ENDPOINTS.listTeamSalaryPolicies;
return apiRequest<ApiPage<RawTeamPolicy>>(apiEndpointPath(API_OPERATIONS.listTeamSalaryPolicies), {
method: endpoint.method,
query,
}).then((page) => ({
...page,
items: (page.items || []).map(normalizeTeamPolicy),
}));
}
export function createTeamSalaryPolicy(payload: TeamSalaryPolicyPayload): Promise<TeamSalaryPolicyDto> {
const endpoint = API_ENDPOINTS.createTeamSalaryPolicy;
return apiRequest<RawTeamPolicy, TeamSalaryPolicyPayload>(apiEndpointPath(API_OPERATIONS.createTeamSalaryPolicy), {
body: payload,
method: endpoint.method,
}).then(normalizeTeamPolicy);
}
export function updateTeamSalaryPolicy(
policyId: EntityId,
payload: TeamSalaryPolicyPayload,
): Promise<TeamSalaryPolicyDto> {
const endpoint = API_ENDPOINTS.updateTeamSalaryPolicy;
return apiRequest<RawTeamPolicy, TeamSalaryPolicyPayload>(
apiEndpointPath(API_OPERATIONS.updateTeamSalaryPolicy, { policy_id: policyId }),
{
body: payload,
method: endpoint.method,
},
).then(normalizeTeamPolicy);
}
export function deleteTeamSalaryPolicy(policyType: string, policyId: EntityId): Promise<unknown> {
const endpoint = API_ENDPOINTS.deleteTeamSalaryPolicy;
return apiRequest(apiEndpointPath(API_OPERATIONS.deleteTeamSalaryPolicy, { policy_id: policyId }), {
method: endpoint.method,
query: { policy_type: policyType },
});
}
export interface HostSalarySettlementUserDto {
userId: string;
displayUserId?: string;
username?: string;
avatar?: string;
}
export interface HostSalarySettlementDto {
settlementId: string;
commandId: string;
transactionId: string;
settlementType: string;
userId: string;
user: HostSalarySettlementUserDto;
agencyOwnerUserId: string;
agencyOwner: HostSalarySettlementUserDto;
cycleKey: string;
policyId: number;
levelNo: number;
totalDiamonds: number;
hostSalaryUsdMinorDelta: number;
hostSalaryUsdDelta: string;
hostCoinRewardDelta: number;
agencySalaryUsdMinorDelta: number;
agencySalaryUsdDelta: string;
residualUsdMinorDelta: number;
residualUsdDelta: string;
status: string;
reason: string;
createdAtMs: number;
}
type RawSettlement = HostSalarySettlementDto & Record<string, unknown>;
export function listHostSalarySettlements(query: PageQuery = {}): Promise<ApiPage<HostSalarySettlementDto>> {
const endpoint = API_ENDPOINTS.listHostSalarySettlements;
return apiRequest<ApiPage<RawSettlement>>(apiEndpointPath(API_OPERATIONS.listHostSalarySettlements), {
method: endpoint.method,
query,
}).then((page) => ({
...page,
items: (page.items || []).map(normalizeSettlement),
}));
}
export interface TeamSalarySettlementPendingDto {
policyType: "bd" | "admin";
userId: string;
user: HostSalarySettlementUserDto;
cycleKey: string;
regionId: number;
regionName?: string;
policyId: number;
policyName: string;
levelNo: number;
incomeUsdMinor: number;
incomeUsd: string;
salaryUsdMinorTarget: number;
salaryUsdTarget: string;
salaryUsdMinorDelta: number;
salaryUsdDelta: string;
settledLevelNo: number;
settledSalaryUsdMinor: number;
settledSalaryUsd: string;
lastSettledIncomeUsdMinor: number;
lastSettledIncomeUsd: string;
monthClosedAtMs: number;
}
export interface TeamSalarySettlementRecordDto {
settlementId: string;
commandId: string;
transactionId: string;
policyType: "bd" | "admin";
triggerMode: string;
userId: string;
user: HostSalarySettlementUserDto;
cycleKey: string;
regionId: number;
policyId: number;
levelNo: number;
incomeUsdMinor: number;
incomeUsd: string;
salaryUsdMinorDelta: number;
salaryUsdDelta: string;
status: string;
reason: string;
createdAtMs: number;
}
export interface TeamSalarySettlePayload {
policy_type: "bd" | "admin";
trigger_mode: "manual" | "automatic";
cycle_key: string;
region_id?: number;
country_code?: string;
user_ids?: number[];
}
export interface TeamSalarySettleResult {
policyType: "bd" | "admin";
cycleKey: string;
requestedCount: number;
processedCount: number;
successCount: number;
skippedCount: number;
failureCount: number;
records: TeamSalarySettlementRecordDto[];
}
type RawTeamPending = TeamSalarySettlementPendingDto & Record<string, unknown>;
type RawTeamRecord = TeamSalarySettlementRecordDto & Record<string, unknown>;
type RawTeamSettleResult = TeamSalarySettleResult & Record<string, unknown>;
export function listTeamSalarySettlementPending(
query: PageQuery = {},
): Promise<ApiPage<TeamSalarySettlementPendingDto>> {
// 新增结算接口尚未进入生成端点,先集中在本 feature API 封装手写路径,避免页面散落裸字符串。
return apiRequest<ApiPage<RawTeamPending>>("/v1/admin/team-salary-settlements/pending", {
method: "GET",
query,
}).then((page) => ({
...page,
items: (page.items || []).map(normalizeTeamPending),
}));
}
export function settleTeamSalary(payload: TeamSalarySettlePayload): Promise<TeamSalarySettleResult> {
// 批量结算是写钱包动作payload 保持后端 snake_case减少序列化时的字段转换风险。
return apiRequest<RawTeamSettleResult, TeamSalarySettlePayload>("/v1/admin/team-salary-settlements/settle", {
body: payload,
method: "POST",
}).then(normalizeTeamSettleResult);
}
export function listTeamSalarySettlementRecords(
query: PageQuery = {},
): Promise<ApiPage<TeamSalarySettlementRecordDto>> {
// 记录查询与待结算分开,避免运营查历史记录时触发候选重算。
return apiRequest<ApiPage<RawTeamRecord>>("/v1/admin/team-salary-settlements/records", {
method: "GET",
query,
}).then((page) => ({
...page,
items: (page.items || []).map(normalizeTeamRecord),
}));
}
function normalizePolicy(item: RawPolicy): HostAgencyPolicyDto {
// 响应兼容 snake_case/camelCase是为了后端 DTO 和前端内部模型之间只有这一处转换。
return {
id: numberValue(item.id),
appCode: stringValue(item.appCode ?? item.app_code),
name: stringValue(item.name),
regionId: numberValue(item.regionId ?? item.region_id),
status: stringValue(item.status) || "disabled",
settlementMode: stringValue(item.settlementMode ?? item.settlement_mode) || "daily",
settlementTriggerMode: stringValue(item.settlementTriggerMode ?? item.settlement_trigger_mode) || "automatic",
giftCoinToDiamondRatio: stringValue(item.giftCoinToDiamondRatio ?? item.gift_coin_to_diamond_ratio) || "1",
residualDiamondToUsdRate:
stringValue(item.residualDiamondToUsdRate ?? item.residual_diamond_to_usd_rate) || "0",
effectiveFromMs: numberValue(item.effectiveFromMs ?? item.effective_from_ms),
effectiveToMs: numberValue(item.effectiveToMs ?? item.effective_to_ms),
createdByAdminId: numberValue(item.createdByAdminId ?? item.created_by_admin_id),
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
publishStatus: stringValue(item.publishStatus ?? item.publish_status) || "draft",
publishError: stringValue(item.publishError ?? item.publish_error),
publishedAtMs: numberValue(item.publishedAtMs ?? item.published_at_ms),
publishedByAdminId: numberValue(item.publishedByAdminId ?? item.published_by_admin_id),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
levels: arrayValue(item.levels).map(normalizeLevel),
};
}
function normalizeSettlement(raw: RawSettlement): HostSalarySettlementDto {
const user = asRecord(raw.user);
const agencyOwner = asRecord(raw.agencyOwner ?? raw.agency_owner);
return {
settlementId: stringValue(raw.settlementId ?? raw.settlement_id),
commandId: stringValue(raw.commandId ?? raw.command_id),
transactionId: stringValue(raw.transactionId ?? raw.transaction_id),
settlementType: stringValue(raw.settlementType ?? raw.settlement_type),
userId: stringValue(raw.userId ?? raw.user_id),
user: normalizeSettlementUser(user),
agencyOwnerUserId: stringValue(raw.agencyOwnerUserId ?? raw.agency_owner_user_id),
agencyOwner: normalizeSettlementUser(agencyOwner),
cycleKey: stringValue(raw.cycleKey ?? raw.cycle_key),
policyId: numberValue(raw.policyId ?? raw.policy_id),
levelNo: numberValue(raw.levelNo ?? raw.level_no),
totalDiamonds: numberValue(raw.totalDiamonds ?? raw.total_diamonds),
hostSalaryUsdMinorDelta: numberValue(raw.hostSalaryUsdMinorDelta ?? raw.host_salary_usd_minor_delta),
hostSalaryUsdDelta: stringValue(raw.hostSalaryUsdDelta ?? raw.host_salary_usd_delta),
hostCoinRewardDelta: numberValue(raw.hostCoinRewardDelta ?? raw.host_coin_reward_delta),
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),
status: stringValue(raw.status) || "succeeded",
reason: stringValue(raw.reason),
createdAtMs: numberValue(raw.createdAtMs ?? raw.created_at_ms),
};
}
function normalizeSettlementUser(raw: Record<string, unknown>): HostSalarySettlementUserDto {
return {
userId: stringValue(raw.userId ?? raw.user_id),
displayUserId: stringValue(raw.displayUserId ?? raw.display_user_id),
username: stringValue(raw.username),
avatar: stringValue(raw.avatar),
};
}
function normalizeTeamPending(raw: RawTeamPending): TeamSalarySettlementPendingDto {
const user = asRecord(raw.user);
// 待结算 DTO 兼容 snake_case/camelCase方便后端 Go JSON tag 与前端内部模型独立演进。
return {
policyType: (stringValue(raw.policyType ?? raw.policy_type) || "bd") as "bd" | "admin",
userId: stringValue(raw.userId ?? raw.user_id),
user: normalizeSettlementUser(user),
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),
levelNo: numberValue(raw.levelNo ?? raw.level_no),
incomeUsdMinor: numberValue(raw.incomeUsdMinor ?? raw.income_usd_minor),
incomeUsd: stringValue(raw.incomeUsd ?? raw.income_usd),
salaryUsdMinorTarget: numberValue(raw.salaryUsdMinorTarget ?? raw.salary_usd_minor_target),
salaryUsdTarget: stringValue(raw.salaryUsdTarget ?? raw.salary_usd_target),
salaryUsdMinorDelta: numberValue(raw.salaryUsdMinorDelta ?? raw.salary_usd_minor_delta),
salaryUsdDelta: stringValue(raw.salaryUsdDelta ?? raw.salary_usd_delta),
settledLevelNo: numberValue(raw.settledLevelNo ?? raw.settled_level_no),
settledSalaryUsdMinor: numberValue(raw.settledSalaryUsdMinor ?? raw.settled_salary_usd_minor),
settledSalaryUsd: stringValue(raw.settledSalaryUsd ?? raw.settled_salary_usd),
lastSettledIncomeUsdMinor: numberValue(raw.lastSettledIncomeUsdMinor ?? raw.last_settled_income_usd_minor),
lastSettledIncomeUsd: stringValue(raw.lastSettledIncomeUsd ?? raw.last_settled_income_usd),
monthClosedAtMs: numberValue(raw.monthClosedAtMs ?? raw.month_closed_at_ms),
};
}
function normalizeTeamRecord(raw: RawTeamRecord): TeamSalarySettlementRecordDto {
const user = asRecord(raw.user);
// 金额字段同时保留 minor 和格式化字符串;表格展示用字符串,后续导出/统计可用 minor。
return {
settlementId: stringValue(raw.settlementId ?? raw.settlement_id),
commandId: stringValue(raw.commandId ?? raw.command_id),
transactionId: stringValue(raw.transactionId ?? raw.transaction_id),
policyType: (stringValue(raw.policyType ?? raw.policy_type) || "bd") as "bd" | "admin",
triggerMode: stringValue(raw.triggerMode ?? raw.trigger_mode),
userId: stringValue(raw.userId ?? raw.user_id),
user: normalizeSettlementUser(user),
cycleKey: stringValue(raw.cycleKey ?? raw.cycle_key),
regionId: numberValue(raw.regionId ?? raw.region_id),
policyId: numberValue(raw.policyId ?? raw.policy_id),
levelNo: numberValue(raw.levelNo ?? raw.level_no),
incomeUsdMinor: numberValue(raw.incomeUsdMinor ?? raw.income_usd_minor),
incomeUsd: stringValue(raw.incomeUsd ?? raw.income_usd),
salaryUsdMinorDelta: numberValue(raw.salaryUsdMinorDelta ?? raw.salary_usd_minor_delta),
salaryUsdDelta: stringValue(raw.salaryUsdDelta ?? raw.salary_usd_delta),
status: stringValue(raw.status) || "succeeded",
reason: stringValue(raw.reason),
createdAtMs: numberValue(raw.createdAtMs ?? raw.created_at_ms),
};
}
function normalizeTeamSettleResult(raw: RawTeamSettleResult): TeamSalarySettleResult {
// 批量结算结果保留 records结算后需要立即展示或定位 settlement_id 时无需再次查列表。
return {
policyType: (stringValue(raw.policyType ?? raw.policy_type) || "bd") as "bd" | "admin",
cycleKey: stringValue(raw.cycleKey ?? raw.cycle_key),
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) => normalizeTeamRecord(asRecord(item) as RawTeamRecord)),
};
}
function normalizeTeamPolicy(item: RawTeamPolicy): TeamSalaryPolicyDto {
return {
id: numberValue(item.id),
appCode: stringValue(item.appCode ?? item.app_code),
policyType: (stringValue(item.policyType ?? item.policy_type) || "bd") as "bd" | "admin",
name: stringValue(item.name),
regionId: numberValue(item.regionId ?? item.region_id),
status: stringValue(item.status) || "disabled",
settlementTriggerMode: stringValue(item.settlementTriggerMode ?? item.settlement_trigger_mode) || "automatic",
effectiveFromMs: numberValue(item.effectiveFromMs ?? item.effective_from_ms),
effectiveToMs: numberValue(item.effectiveToMs ?? item.effective_to_ms),
createdByAdminId: numberValue(item.createdByAdminId ?? item.created_by_admin_id),
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
levels: arrayValue(item.levels).map(normalizeTeamLevel),
};
}
function normalizeTeamLevel(raw: unknown): TeamSalaryPolicyLevelDto {
const item = asRecord(raw) as RawTeamLevel;
return {
id: numberValue(item.id),
policyId: numberValue(item.policyId ?? item.policy_id),
level: numberValue(item.level),
thresholdUsd: stringValue(item.thresholdUsd ?? item.threshold_usd),
ratePercent: stringValue(item.ratePercent ?? item.rate_percent),
salaryUsd: stringValue(item.salaryUsd ?? item.salary_usd),
status: stringValue(item.status) || "active",
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function normalizeLevel(raw: unknown): HostAgencyPolicyLevelDto {
const item = asRecord(raw) as RawLevel;
// 金额字段保持字符串,避免前端 Number 格式化吞掉小数位或引入精度问题。
return {
id: numberValue(item.id),
policyId: numberValue(item.policyId ?? item.policy_id),
level: numberValue(item.level),
requiredDiamonds: numberValue(item.requiredDiamonds ?? item.required_diamonds),
hostSalaryUsd: stringValue(item.hostSalaryUsd ?? item.host_salary_usd),
hostCoinReward: numberValue(item.hostCoinReward ?? item.host_coin_reward),
agencySalaryUsd: stringValue(item.agencySalaryUsd ?? item.agency_salary_usd),
totalSalaryUsd: stringValue(item.totalSalaryUsd ?? item.total_salary_usd),
status: stringValue(item.status) || "active",
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function arrayValue(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
}
function numberValue(value: unknown) {
// 列表展示允许缺省值兜底为 0真正的必填和正数校验在表单 schema 与后端 service 完成。
const number = Number(value || 0);
return Number.isFinite(number) ? number : 0;
}

View File

@ -0,0 +1,253 @@
import Add from "@mui/icons-material/Add";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import styles from "@/features/host-agency-policy/host-agency-policy.module.css";
const settlementModeOptions = [
["daily", "日结"],
["half_month", "半月结算"],
];
const settlementTriggerOptions = [
["automatic", "自动结算"],
["manual", "手动结算"],
];
export function HostAgencyPolicyDrawer({ page }) {
const saving = page.loadingAction === "policy-create" || page.loadingAction === "policy-update";
const disabled = saving || (!page.editingPolicy ? !page.abilities.canCreate : !page.abilities.canUpdate);
return (
<SideDrawer
actions={
<>
<Button disabled={saving} onClick={page.closeDrawer}>
取消
</Button>
<Button disabled={disabled || page.loadingRegions} type="submit" variant="primary">
保存
</Button>
</>
}
as="form"
className={styles.policyDrawer}
contentClassName={styles.drawerBody}
open={page.drawerOpen}
title={page.editingPolicy ? "编辑工资政策" : "新增工资政策"}
width="wide"
onClose={saving ? undefined : page.closeDrawer}
onSubmit={page.submitPolicy}
>
<PolicyBaseFields disabled={disabled} page={page} />
<PolicyLevelEditor disabled={disabled} page={page} />
</SideDrawer>
);
}
function PolicyBaseFields({ disabled, page }) {
const form = page.form;
const setForm = (patch) => page.setForm((current) => ({ ...current, ...patch }));
return (
<section className={[styles.formSection, styles.baseSection].join(" ")}>
<div className={styles.sectionHeader}>
<h3>基础配置</h3>
</div>
<div className={styles.formGrid}>
<TextField
required
className={styles.fieldWide}
disabled={disabled}
label="政策名称"
size="small"
value={form.name}
onChange={(event) => setForm({ name: event.target.value })}
/>
<TextField
required
select
className={styles.fieldWide}
disabled={disabled || page.loadingRegions}
label="适用区域"
size="small"
value={form.regionId}
onChange={(event) => setForm({ regionId: event.target.value })}
>
{page.regionOptions.map((region) => (
<MenuItem key={region.value} value={region.value}>
{region.label}
</MenuItem>
))}
</TextField>
<TextField
required
select
className={styles.fieldCompact}
disabled={disabled}
label="结算方式"
size="small"
value={form.settlementMode}
onChange={(event) => setForm({ settlementMode: event.target.value })}
>
{settlementModeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
required
select
className={styles.fieldCompact}
disabled={disabled}
label="触发方式"
size="small"
value={form.settlementTriggerMode}
onChange={(event) => setForm({ settlementTriggerMode: event.target.value })}
>
{settlementTriggerOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
required
className={styles.fieldCompact}
disabled={disabled}
label="金币转钻石比例"
size="small"
value={form.giftCoinToDiamondRatio}
onChange={(event) => setForm({ giftCoinToDiamondRatio: event.target.value })}
/>
<TextField
required
className={styles.fieldCompact}
disabled={disabled}
label="剩余钻石转美元比例"
size="small"
value={form.residualDiamondToUsdRate}
onChange={(event) => setForm({ residualDiamondToUsdRate: event.target.value })}
/>
<TimeRangeFilter
className={styles.timeRangeField}
disabled={disabled}
label="生效时间"
value={{ endMs: form.effectiveTo, startMs: form.effectiveFrom }}
onChange={(range) => setForm({ effectiveFrom: range.startMs, effectiveTo: range.endMs })}
/>
<div className={styles.enabledField}>
<span className={styles.toggleLabel}>政策状态</span>
<AdminSwitch
checked={form.enabled}
checkedLabel="启用"
disabled={disabled}
label="政策状态"
uncheckedLabel="停用"
onChange={(event) => setForm({ enabled: event.target.checked })}
/>
</div>
</div>
</section>
);
}
function PolicyLevelEditor({ disabled, page }) {
return (
<section className={[styles.formSection, styles.levelSection].join(" ")}>
<div className={styles.sectionHeader}>
<h3>等级配置</h3>
<Button
className={styles.addLevelButton}
disabled={disabled}
startIcon={<Add fontSize="small" />}
onClick={page.addLevel}
>
添加等级
</Button>
</div>
<div className={styles.levelTable}>
<div className={styles.levelHeader}>
<span>等级</span>
<span>所需钻石</span>
<span>主播美元</span>
<span>金币奖励</span>
<span>代理美元</span>
<span>状态</span>
<span />
</div>
<div className={styles.levelList}>
{page.form.levels.map((level, index) => (
<div className={styles.levelRow} key={`${level.level}-${index}`}>
<TextField
required
disabled={disabled}
size="small"
type="number"
value={level.level}
onChange={(event) =>
page.updateLevel(index, {
level: event.target.value,
sortOrder: event.target.value,
})
}
/>
<TextField
required
disabled={disabled}
size="small"
type="number"
value={level.requiredDiamonds}
onChange={(event) => page.updateLevel(index, { requiredDiamonds: event.target.value })}
/>
<TextField
required
disabled={disabled}
size="small"
value={level.hostSalaryUsd}
onChange={(event) => page.updateLevel(index, { hostSalaryUsd: event.target.value })}
/>
<TextField
required
disabled={disabled}
size="small"
type="number"
value={level.hostCoinReward}
onChange={(event) => page.updateLevel(index, { hostCoinReward: event.target.value })}
/>
<TextField
required
disabled={disabled}
size="small"
value={level.agencySalaryUsd}
onChange={(event) => page.updateLevel(index, { agencySalaryUsd: event.target.value })}
/>
<AdminSwitch
checked={level.status === "active"}
checkedLabel="启用"
disabled={disabled}
label={`等级 ${level.level || index + 1} 状态`}
uncheckedLabel="停用"
onChange={(event) =>
page.updateLevel(index, { status: event.target.checked ? "active" : "disabled" })
}
/>
<IconButton
disabled={disabled || page.form.levels.length <= 1}
label="删除等级"
sx={{ height: 34, minHeight: 34, width: 34 }}
onClick={() => page.removeLevel(index)}
>
<DeleteOutlineOutlined fontSize="small" />
</IconButton>
</div>
))}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,216 @@
import Add from "@mui/icons-material/Add";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import styles from "@/features/host-agency-policy/host-agency-policy.module.css";
const settlementTriggerOptions = [
["automatic", "自动结算"],
["manual", "手动结算"],
];
export function TeamSalaryPolicyDrawer({ page }) {
const saving = page.loadingAction === "team-policy-create" || page.loadingAction === "team-policy-update";
const disabled = saving || (!page.editingPolicy ? !page.abilities.canTeamCreate : !page.abilities.canTeamUpdate);
return (
<SideDrawer
actions={
<>
<Button disabled={saving} onClick={page.closeDrawer}>
取消
</Button>
<Button disabled={disabled || page.loadingRegions} type="submit" variant="primary">
保存
</Button>
</>
}
as="form"
className={styles.policyDrawer}
contentClassName={styles.drawerBody}
open={page.drawerOpen}
title={
page.editingPolicy
? `编辑${policyTitle(page.policyType)}政策`
: `新增${policyTitle(page.policyType)}政策`
}
width="wide"
onClose={saving ? undefined : page.closeDrawer}
onSubmit={page.submitPolicy}
>
<TeamPolicyBaseFields disabled={disabled} page={page} />
<TeamPolicyLevelEditor disabled={disabled} page={page} />
</SideDrawer>
);
}
function TeamPolicyBaseFields({ disabled, page }) {
const form = page.form;
const setForm = (patch) => page.setForm((current) => ({ ...current, ...patch }));
return (
<section className={[styles.formSection, styles.baseSection].join(" ")}>
<div className={styles.sectionHeader}>
<h3>基础配置</h3>
</div>
<div className={styles.formGrid}>
<TextField
required
className={styles.fieldWide}
disabled={disabled}
label="政策名称"
size="small"
value={form.name}
onChange={(event) => setForm({ name: event.target.value })}
/>
<TextField
required
select
className={styles.fieldWide}
disabled={disabled || page.loadingRegions}
label="适用区域"
size="small"
value={form.regionId}
onChange={(event) => setForm({ regionId: event.target.value })}
>
{page.regionOptions.map((region) => (
<MenuItem key={region.value} value={region.value}>
{region.label}
</MenuItem>
))}
</TextField>
<TextField
required
select
className={styles.fieldCompact}
disabled={disabled}
label="触发方式"
size="small"
value={form.settlementTriggerMode}
onChange={(event) => setForm({ settlementTriggerMode: event.target.value })}
>
{settlementTriggerOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TimeRangeFilter
className={styles.timeRangeField}
disabled={disabled}
label="生效时间"
value={{ endMs: form.effectiveTo, startMs: form.effectiveFrom }}
onChange={(range) => setForm({ effectiveFrom: range.startMs, effectiveTo: range.endMs })}
/>
<div className={styles.enabledField}>
<span className={styles.toggleLabel}>政策状态</span>
<AdminSwitch
checked={form.enabled}
checkedLabel="启用"
disabled={disabled}
label="政策状态"
uncheckedLabel="停用"
onChange={(event) => setForm({ enabled: event.target.checked })}
/>
</div>
</div>
</section>
);
}
function TeamPolicyLevelEditor({ disabled, page }) {
return (
<section className={[styles.formSection, styles.levelSection].join(" ")}>
<div className={styles.sectionHeader}>
<h3>等级配置</h3>
<Button
className={styles.addLevelButton}
disabled={disabled}
startIcon={<Add fontSize="small" />}
onClick={page.addLevel}
>
添加等级
</Button>
</div>
<div className={styles.levelTable}>
<div className={styles.teamLevelHeader}>
<span>等级</span>
<span>{thresholdLabel(page.policyType)}</span>
<span>比例%</span>
<span>{policyTitle(page.policyType)}美元</span>
<span>状态</span>
<span />
</div>
<div className={styles.levelList}>
{page.form.levels.map((level, index) => (
<div className={styles.teamLevelRow} key={`${level.level}-${index}`}>
<TextField
required
disabled={disabled}
size="small"
type="number"
value={level.level}
onChange={(event) =>
page.updateLevel(index, {
level: event.target.value,
sortOrder: event.target.value,
})
}
/>
<TextField
required
disabled={disabled}
size="small"
value={level.thresholdUsd}
onChange={(event) => page.updateLevel(index, { thresholdUsd: event.target.value })}
/>
<TextField
required
disabled={disabled}
size="small"
value={level.ratePercent}
onChange={(event) => page.updateLevel(index, { ratePercent: event.target.value })}
/>
<TextField
required
disabled={disabled}
size="small"
value={level.salaryUsd}
onChange={(event) => page.updateLevel(index, { salaryUsd: event.target.value })}
/>
<AdminSwitch
checked={level.status === "active"}
checkedLabel="启用"
disabled={disabled}
label={`等级 ${level.level || index + 1} 状态`}
uncheckedLabel="停用"
onChange={(event) =>
page.updateLevel(index, { status: event.target.checked ? "active" : "disabled" })
}
/>
<IconButton
disabled={disabled || page.form.levels.length <= 1}
label="删除等级"
sx={{ height: 34, minHeight: 34, width: 34 }}
onClick={() => page.removeLevel(index)}
>
<DeleteOutlineOutlined fontSize="small" />
</IconButton>
</div>
))}
</div>
</div>
</section>
);
}
function policyTitle(policyType) {
return policyType === "bd" ? "BD" : "Admin";
}
function thresholdLabel(policyType) {
return policyType === "bd" ? "下属主播工资" : "BD工资";
}

View File

@ -0,0 +1,362 @@
import { useMemo, useState } from "react";
import { toPageQuery } from "@/shared/api/query";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { parseForm } from "@/shared/forms/validation";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import {
createHostAgencyPolicy,
deleteHostAgencyPolicy,
listHostAgencyPolicies,
publishHostAgencyPolicy,
updateHostAgencyPolicy,
} from "@/features/host-agency-policy/api";
import { useHostAgencyPolicyAbilities } from "@/features/host-agency-policy/permissions.js";
import { hostAgencyPolicyFormSchema } from "@/features/host-agency-policy/schema";
const pageSize = 50;
// 产品文档给出的 Lalu 首版等级表:每项分别是等级、钻石门槛、主播累计美元、主播累计金币奖励、代理累计美元。
// 这些值只用于“新建政策”的表单预填,编辑已有政策时始终以后端返回的配置为准。
const defaultLevels = [
[1, 350000, "1.5", 90000, "0.5"],
[2, 700000, "3", 198000, "0.8"],
[3, 1180000, "4", 350000, "1.3"],
[4, 1770000, "6", 531000, "1.6"],
[5, 2660000, "9", 800000, "2.5"],
[6, 4000000, "13", 1200000, "4"],
[7, 6000000, "20", 1800000, "6"],
[8, 9000000, "30", 2700000, "9"],
[9, 13500000, "45", 4050000, "13"],
[10, 20260000, "67", 6078000, "19"],
[11, 30390000, "100", 9200000, "29"],
[12, 45590000, "152", 13600000, "43"],
[13, 68380000, "182", 25000000, "65"],
[14, 102580000, "342", 30700000, "97"],
[15, 153870000, "513", 46000000, "146"],
[16, 230800000, "768", 69200000, "219"],
[17, 346200000, "1151", 103800000, "328"],
[18, 519310000, "1727", 155700000, "493"],
[19, 778970000, "2589", 233700000, "739"],
[20, 1168450000, "3884", 350500000, "1108"],
[21, 1752680000, "5826", 525800000, "1165"],
[22, 2629030000, "8739", 788700000, "2494"],
[23, 3943540000, "13109", 1183000000, "3741"],
[24, 5915310000, "19664", 1774500000, "5611"],
];
const emptyForm = (policy = null) => ({
// 后端用 epoch ms 保存生效范围;表单里用空字符串表示“未设置”,方便时间控件展示占位。
effectiveFrom: normalizeTimeMs(policy?.effectiveFromMs),
effectiveTo: normalizeTimeMs(policy?.effectiveToMs),
// 新建默认停用,运营保存草稿后再手动启用,避免半配置状态影响结算匹配。
enabled: policy ? policy.status === "active" : false,
giftCoinToDiamondRatio: policy?.giftCoinToDiamondRatio || "1",
// 编辑时保留后端等级;新建时一次铺满 24 个默认等级,减少运营逐级录入成本。
levels: policy?.levels?.length ? policy.levels.map(levelToForm) : defaultLevels.map(defaultLevelToForm),
name: policy?.name || "Lalu Host & Agency Salary Policy",
regionId: policy?.regionId ? String(policy.regionId) : "",
residualDiamondToUsdRate: policy?.residualDiamondToUsdRate || "0",
settlementMode: policy?.settlementMode || "daily",
settlementTriggerMode: policy?.settlementTriggerMode || "automatic",
});
export function useHostAgencyPolicyPage() {
const abilities = useHostAgencyPolicyAbilities();
const { showToast } = useToast();
const { loadingRegions, regionOptions } = useRegionOptions();
const [query, setQuery] = useState("");
const [regionId, setRegionId] = useState("");
const [status, setStatus] = useState("");
const [settlementMode, setSettlementMode] = useState("");
const [settlementTriggerMode, setSettlementTriggerMode] = useState("");
const [page, setPage] = useState(1);
const [form, setForm] = useState(() => emptyForm());
const [editingPolicy, setEditingPolicy] = useState(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [loadingAction, setLoadingAction] = useState("");
// 列表筛选和表单状态分开维护,避免打开抽屉编辑时污染当前列表查询条件。
const filters = useMemo(
() => ({
keyword: query,
region_id: regionId,
settlement_mode: settlementMode,
settlement_trigger_mode: settlementTriggerMode,
status,
}),
[query, regionId, settlementMode, settlementTriggerMode, status],
);
const result = usePaginatedQuery({
errorMessage: "加载工资政策失败",
fetcher: listHostAgencyPolicies,
filters,
page,
pageSize,
// queryKey 带上规整后的分页参数,筛选项变化时能稳定触发重新请求。
queryKey: ["host-agency-policy", toPageQuery({ ...filters, page, pageSize })],
});
const regionLabels = useMemo(
() =>
regionOptions.reduce((labels, item) => {
// 后端只返回 region_id页面展示名称依赖区域配置接口做本地映射。
labels[Number(item.regionId)] = item.label;
return labels;
}, {}),
[regionOptions],
);
const openCreate = () => {
setEditingPolicy(null);
setForm(emptyForm());
setDrawerOpen(true);
};
const openEdit = (policy) => {
setEditingPolicy(policy);
setForm(emptyForm(policy));
setDrawerOpen(true);
};
const closeDrawer = () => {
setDrawerOpen(false);
setEditingPolicy(null);
};
const submitPolicy = async (event) => {
event.preventDefault();
const editing = Boolean(editingPolicy?.id);
await runAction(
editing ? "policy-update" : "policy-create",
editing ? "工资政策已更新" : "工资政策已创建",
async () => {
// 保存前先跑前端 schema尽早拦截等级递增和金额格式问题后端仍保留同款强校验。
const payload = buildPayload(parseForm(hostAgencyPolicyFormSchema, form));
if (editing) {
await updateHostAgencyPolicy(editingPolicy.id, payload);
} else {
await createHostAgencyPolicy(payload);
}
closeDrawer();
setForm(emptyForm());
await result.reload();
},
);
};
const removePolicy = async (policy) => {
if (!policy?.id || !abilities.canDelete) {
return;
}
await runAction(`policy-delete-${policy.id}`, "工资政策已删除", async () => {
await deleteHostAgencyPolicy(policy.id);
await result.reload();
});
};
const publishPolicy = async (policy) => {
if (!policy?.id || !abilities.canPublish) {
return;
}
await runAction(`policy-publish-${policy.id}`, "工资政策已发布到结算运行表", async () => {
// 发布是后台草稿配置到 wallet 运行快照的唯一入口,结算任务只读取发布后的运行表。
await publishHostAgencyPolicy(policy.id);
await result.reload();
});
};
const togglePolicyStatus = async (policy, nextEnabled = policy.status !== "active") => {
if (!policy?.id || !abilities.canUpdate || (policy.status === "active") === nextEnabled) {
return;
}
await runAction(`policy-status-${policy.id}`, nextEnabled ? "工资政策已启用" : "工资政策已停用", async () => {
// 启停只改变 status其余字段走完整 payload确保后端 PUT 整包替换时不会丢等级配置。
await updateHostAgencyPolicy(policy.id, buildPayload({ ...emptyForm(policy), enabled: nextEnabled }));
await result.reload();
});
};
const resetFilters = () => {
setQuery("");
setRegionId("");
setStatus("");
setSettlementMode("");
setSettlementTriggerMode("");
setPage(1);
};
const updateLevel = (index, patch) => {
// 等级表使用受控数组,单格编辑只更新当前行,保存时再统一排序和校验。
setForm((current) => ({
...current,
levels: current.levels.map((level, levelIndex) => (levelIndex === index ? { ...level, ...patch } : level)),
}));
};
const addLevel = () => {
setForm((current) => {
const last = current.levels[current.levels.length - 1];
const nextLevel = Number(last?.level || current.levels.length) + 1;
// 新增等级沿用上一行金额作为起点,运营只需要调整差异字段;最终递增规则仍由 schema/backend 保底。
return {
...current,
levels: [
...current.levels,
{
agencySalaryUsd: last?.agencySalaryUsd || "0",
hostCoinReward: last?.hostCoinReward || "0",
hostSalaryUsd: last?.hostSalaryUsd || "0",
level: String(nextLevel),
requiredDiamonds: "",
sortOrder: String(nextLevel),
status: "active",
},
],
};
});
};
const removeLevel = (index) => {
setForm((current) => ({
...current,
levels: current.levels.filter((_, levelIndex) => levelIndex !== index),
}));
};
const runAction = async (action, successMessage, submitter) => {
// loadingAction 带资源 ID删除/启停这类行内操作不会把整页按钮全部锁死。
setLoadingAction(action);
try {
await submitter();
showToast(successMessage, "success");
} catch (err) {
showToast(err.message || "操作失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
addLevel,
changeQuery: (value) => {
setQuery(value);
setPage(1);
},
changeRegionId: (value) => {
setRegionId(value);
setPage(1);
},
changeSettlementMode: (value) => {
setSettlementMode(value);
setPage(1);
},
changeSettlementTriggerMode: (value) => {
setSettlementTriggerMode(value);
setPage(1);
},
changeStatus: (value) => {
setStatus(value);
setPage(1);
},
closeDrawer,
data: result.data,
drawerOpen,
editingPolicy,
error: result.error,
form,
loading: result.loading,
loadingAction,
loadingRegions,
openCreate,
openEdit,
page,
publishPolicy,
query,
regionId,
regionLabels,
regionOptions,
reload: result.reload,
removeLevel,
removePolicy,
resetFilters,
setForm,
setPage,
settlementMode,
settlementTriggerMode,
status,
submitPolicy,
togglePolicyStatus,
updateLevel,
};
}
function levelToForm(level) {
// 接口层已经把 snake_case 转成 camelCase这里只负责把数值转成输入框友好的字符串。
return {
agencySalaryUsd: level.agencySalaryUsd || "0",
hostCoinReward: String(level.hostCoinReward || 0),
hostSalaryUsd: level.hostSalaryUsd || "0",
level: String(level.level || ""),
requiredDiamonds: String(level.requiredDiamonds || ""),
sortOrder: String(level.sortOrder || level.level || ""),
status: level.status || "active",
};
}
function defaultLevelToForm([level, requiredDiamonds, hostSalaryUsd, hostCoinReward, agencySalaryUsd]) {
// 默认模板中的工资和奖励都是累计值,不是本等级增量;实际差额由后续结算任务计算。
return {
agencySalaryUsd,
hostCoinReward: String(hostCoinReward),
hostSalaryUsd,
level: String(level),
requiredDiamonds: String(requiredDiamonds),
sortOrder: String(level),
status: "active",
};
}
function buildPayload(form) {
// 后端接口使用 snake_case这里是唯一的表单 -> API payload 转换边界。
return {
effective_from_ms: timeValueToMs(form.effectiveFrom),
effective_to_ms: timeValueToMs(form.effectiveTo),
gift_coin_to_diamond_ratio: String(form.giftCoinToDiamondRatio || "1").trim(),
levels: form.levels
.map((level) => ({
// 保存等级时统一转 number避免字符串数字进入后端后造成弱类型歧义。
agency_salary_usd: String(level.agencySalaryUsd || "0").trim(),
host_coin_reward: Number(level.hostCoinReward || 0),
host_salary_usd: String(level.hostSalaryUsd || "0").trim(),
level: Number(level.level || 0),
required_diamonds: Number(level.requiredDiamonds || 0),
sort_order: Number(level.sortOrder || level.level || 0),
status: level.status || "active",
}))
// 后端也会排序校验,前端先排序可以让保存后的返回和当前表单顺序更一致。
.sort((left, right) => left.level - right.level),
name: String(form.name || "").trim(),
region_id: Number(form.regionId || 0),
residual_diamond_to_usd_rate: String(form.residualDiamondToUsdRate || "0").trim(),
settlement_mode: form.settlementMode || "daily",
// 触发方式会被发布到 wallet 运行表manual 表示后续只能从工资结算页人工触发。
settlement_trigger_mode: form.settlementTriggerMode || "automatic",
status: form.enabled ? "active" : "disabled",
};
}
function normalizeTimeMs(value) {
const ms = Number(value || 0);
return Number.isFinite(ms) && ms > 0 ? ms : "";
}
function timeValueToMs(value) {
if (value === "" || value === null || value === undefined) {
return 0;
}
const number = Number(value);
if (Number.isFinite(number) && number > 0) {
return number;
}
return 0;
}

View File

@ -0,0 +1,321 @@
import { useMemo, useState } from "react";
import { toPageQuery } from "@/shared/api/query";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { parseForm } from "@/shared/forms/validation";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import {
createTeamSalaryPolicy,
deleteTeamSalaryPolicy,
listTeamSalaryPolicies,
updateTeamSalaryPolicy,
} from "@/features/host-agency-policy/api";
import { useHostAgencyPolicyAbilities } from "@/features/host-agency-policy/permissions.js";
import { teamSalaryPolicyFormSchema } from "@/features/host-agency-policy/schema";
const pageSize = 50;
const defaultLevelsByType = {
// BD 门槛来自下属 Agency 管理主播的 Host Salary 合计,工资字段是达到该门槛后的 BD 累计应得。
bd: [
[1, "100", "8", "8"],
[2, "200", "8", "16"],
[3, "500", "8", "40"],
[4, "1200", "8", "96"],
[5, "2000", "8", "160"],
[6, "5000", "9", "450"],
[7, "10000", "10", "1000"],
],
// Admin 门槛来自下属 BD 的 BD Salary 合计,工资字段是达到该门槛后的 Admin 累计应得。
admin: [
[1, "500", "20", "100"],
[2, "1000", "20", "200"],
[3, "3000", "20", "600"],
[4, "5000", "20", "1000"],
[5, "10000", "20", "2000"],
[6, "20000", "20", "4000"],
],
};
const emptyForm = (policyType, policy = null) => ({
effectiveFrom: normalizeTimeMs(policy?.effectiveFromMs),
effectiveTo: normalizeTimeMs(policy?.effectiveToMs),
enabled: policy ? policy.status === "active" : false,
levels: policy?.levels?.length
? policy.levels.map(levelToForm)
: defaultLevelsByType[policyType].map(defaultLevelToForm),
name: policy?.name || defaultPolicyName(policyType),
policyType,
regionId: policy?.regionId ? String(policy.regionId) : "",
settlementTriggerMode: policy?.settlementTriggerMode || "automatic",
});
export function useTeamSalaryPolicyPage(policyType) {
const abilities = useHostAgencyPolicyAbilities();
const { showToast } = useToast();
const { loadingRegions, regionOptions } = useRegionOptions();
const [query, setQuery] = useState("");
const [regionId, setRegionId] = useState("");
const [status, setStatus] = useState("");
const [settlementTriggerMode, setSettlementTriggerMode] = useState("");
const [page, setPage] = useState(1);
const [form, setForm] = useState(() => emptyForm(policyType));
const [editingPolicy, setEditingPolicy] = useState(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [loadingAction, setLoadingAction] = useState("");
const filters = useMemo(
() => ({
keyword: query,
policy_type: policyType,
region_id: regionId,
settlement_trigger_mode: settlementTriggerMode,
status,
}),
[policyType, query, regionId, settlementTriggerMode, status],
);
const result = usePaginatedQuery({
errorMessage: policyType === "bd" ? "加载 BD 工资政策失败" : "加载 Admin 工资政策失败",
fetcher: listTeamSalaryPolicies,
filters,
page,
pageSize,
queryKey: ["team-salary-policy", policyType, toPageQuery({ ...filters, page, pageSize })],
});
const regionLabels = useMemo(
() =>
regionOptions.reduce((labels, item) => {
labels[Number(item.regionId)] = item.label;
return labels;
}, {}),
[regionOptions],
);
const openCreate = () => {
setEditingPolicy(null);
setForm(emptyForm(policyType));
setDrawerOpen(true);
};
const openEdit = (policy) => {
setEditingPolicy(policy);
setForm(emptyForm(policyType, policy));
setDrawerOpen(true);
};
const closeDrawer = () => {
setDrawerOpen(false);
setEditingPolicy(null);
};
const submitPolicy = async (event) => {
event.preventDefault();
const editing = Boolean(editingPolicy?.id);
await runAction(
editing ? "team-policy-update" : "team-policy-create",
editing ? "工资政策已更新" : "工资政策已创建",
async () => {
const payload = buildPayload(parseForm(teamSalaryPolicyFormSchema, form));
if (editing) {
await updateTeamSalaryPolicy(editingPolicy.id, payload);
} else {
await createTeamSalaryPolicy(payload);
}
closeDrawer();
setForm(emptyForm(policyType));
await result.reload();
},
);
};
const removePolicy = async (policy) => {
if (!policy?.id || !abilities.canTeamDelete) {
return;
}
await runAction(`team-policy-delete-${policy.id}`, "工资政策已删除", async () => {
await deleteTeamSalaryPolicy(policyType, policy.id);
await result.reload();
});
};
const togglePolicyStatus = async (policy, nextEnabled = policy.status !== "active") => {
if (!policy?.id || !abilities.canTeamUpdate || (policy.status === "active") === nextEnabled) {
return;
}
await runAction(
`team-policy-status-${policy.id}`,
nextEnabled ? "工资政策已启用" : "工资政策已停用",
async () => {
await updateTeamSalaryPolicy(
policy.id,
buildPayload({ ...emptyForm(policyType, policy), enabled: nextEnabled }),
);
await result.reload();
},
);
};
const resetFilters = () => {
setQuery("");
setRegionId("");
setStatus("");
setSettlementTriggerMode("");
setPage(1);
};
const updateLevel = (index, patch) => {
setForm((current) => ({
...current,
levels: current.levels.map((level, levelIndex) => (levelIndex === index ? { ...level, ...patch } : level)),
}));
};
const addLevel = () => {
setForm((current) => {
const last = current.levels[current.levels.length - 1];
const nextLevel = Number(last?.level || current.levels.length) + 1;
return {
...current,
levels: [
...current.levels,
{
level: String(nextLevel),
ratePercent: last?.ratePercent || "0",
salaryUsd: last?.salaryUsd || "0",
sortOrder: String(nextLevel),
status: "active",
thresholdUsd: "",
},
],
};
});
};
const removeLevel = (index) => {
setForm((current) => ({
...current,
levels: current.levels.filter((_, levelIndex) => levelIndex !== index),
}));
};
const runAction = async (action, successMessage, submitter) => {
setLoadingAction(action);
try {
await submitter();
showToast(successMessage, "success");
} catch (err) {
showToast(err.message || "操作失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
addLevel,
changeQuery: (value) => {
setQuery(value);
setPage(1);
},
changeRegionId: (value) => {
setRegionId(value);
setPage(1);
},
changeSettlementTriggerMode: (value) => {
setSettlementTriggerMode(value);
setPage(1);
},
changeStatus: (value) => {
setStatus(value);
setPage(1);
},
closeDrawer,
data: result.data,
drawerOpen,
editingPolicy,
error: result.error,
form,
loading: result.loading,
loadingAction,
loadingRegions,
openCreate,
openEdit,
page,
policyType,
query,
regionId,
regionLabels,
regionOptions,
reload: result.reload,
removeLevel,
removePolicy,
resetFilters,
setForm,
setPage,
settlementTriggerMode,
status,
submitPolicy,
togglePolicyStatus,
updateLevel,
};
}
function levelToForm(level) {
return {
level: String(level.level || ""),
ratePercent: level.ratePercent || "0",
salaryUsd: level.salaryUsd || "0",
sortOrder: String(level.sortOrder || level.level || ""),
status: level.status || "active",
thresholdUsd: level.thresholdUsd || "0",
};
}
function defaultLevelToForm([level, thresholdUsd, ratePercent, salaryUsd]) {
return {
level: String(level),
ratePercent,
salaryUsd,
sortOrder: String(level),
status: "active",
thresholdUsd,
};
}
function buildPayload(form) {
return {
effective_from_ms: timeValueToMs(form.effectiveFrom),
effective_to_ms: timeValueToMs(form.effectiveTo),
levels: form.levels
.map((level) => ({
level: Number(level.level || 0),
rate_percent: String(level.ratePercent || "0").trim(),
salary_usd: String(level.salaryUsd || "0").trim(),
sort_order: Number(level.sortOrder || level.level || 0),
status: level.status || "active",
threshold_usd: String(level.thresholdUsd || "0").trim(),
}))
.sort((left, right) => left.level - right.level),
name: String(form.name || "").trim(),
policy_type: form.policyType,
region_id: Number(form.regionId || 0),
settlement_trigger_mode: form.settlementTriggerMode || "automatic",
status: form.enabled ? "active" : "disabled",
};
}
function defaultPolicyName(policyType) {
return policyType === "bd" ? "Lalu BD Salary Policy" : "Lalu Admin Salary Policy";
}
function normalizeTimeMs(value) {
const ms = Number(value || 0);
return Number.isFinite(ms) && ms > 0 ? ms : "";
}
function timeValueToMs(value) {
if (value === "" || value === null || value === undefined) {
return 0;
}
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : 0;
}

View File

@ -0,0 +1,296 @@
.stack {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.name {
overflow: hidden;
color: var(--text-primary);
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
overflow: hidden;
color: var(--text-tertiary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbarFilters {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.inlineFilter {
display: inline-flex;
height: var(--control-height);
min-width: 132px;
align-items: center;
gap: 8px;
padding: 0 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
color: var(--text-secondary);
font-size: 13px;
font-weight: 650;
}
.inlineFilter input {
width: 96px;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--text-primary);
font: inherit;
}
.inlineFilter input::placeholder {
color: var(--text-tertiary);
}
.policyTabsBar {
display: flex;
align-items: center;
min-height: 44px;
padding: 0 2px;
border-bottom: 1px solid var(--border);
}
.policyTab {
min-height: 40px !important;
padding-right: 18px !important;
padding-left: 18px !important;
font-weight: 700 !important;
text-transform: none !important;
}
.rowActions {
display: flex;
align-items: center;
gap: 8px;
}
.identity {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
}
.avatar {
display: inline-flex;
width: 36px;
height: 36px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 50%;
background: var(--bg-card-strong);
color: var(--text-secondary);
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.identityText {
display: grid;
min-width: 0;
gap: 3px;
}
.primaryText {
overflow: hidden;
color: var(--text-primary);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.amountPositive {
color: var(--success);
font-weight: 760;
white-space: nowrap;
}
.policyDrawer {
width: min(720px, calc(100vw - 40px)) !important;
}
.drawerBody {
gap: 16px;
}
.formSection {
display: flex;
flex-direction: column;
gap: 10px;
}
.baseSection {
padding-bottom: 2px;
}
.formGrid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
align-items: center;
gap: 10px;
}
.fieldWide {
grid-column: span 3;
}
.fieldCompact {
grid-column: span 2;
}
.enabledField {
display: flex;
min-height: var(--control-height);
grid-column: span 2;
align-items: center;
gap: 10px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-input);
}
.toggleLabel {
color: var(--text-tertiary);
font-size: 12px;
font-weight: 650;
line-height: 16px;
white-space: nowrap;
}
.timeRangeField {
grid-column: span 4;
width: 100% !important;
max-width: none !important;
}
.sectionHeader {
display: flex;
align-items: center;
gap: 10px;
}
.sectionHeader h3 {
margin: 0;
color: var(--text-primary);
font-size: 15px;
font-weight: 720;
}
.levelSection {
min-width: 0;
padding-top: 16px;
padding-bottom: 2px;
border-top: 1px solid var(--border);
}
.addLevelButton {
height: 34px !important;
min-height: 34px !important;
padding-right: 10px !important;
padding-left: 10px !important;
}
.levelTable {
min-width: 0;
overflow-x: auto;
padding-bottom: 2px;
}
.levelHeader,
.levelRow {
display: grid;
min-width: 620px;
grid-template-columns: 54px minmax(108px, 1fr) 82px minmax(104px, 1fr) 82px 64px 34px;
align-items: center;
gap: 6px;
}
.teamLevelHeader,
.teamLevelRow {
display: grid;
min-width: 580px;
grid-template-columns: 54px minmax(130px, 1fr) 86px minmax(108px, 1fr) 64px 34px;
align-items: center;
gap: 6px;
}
.levelHeader {
color: var(--text-tertiary);
font-size: 12px;
font-weight: 650;
line-height: 16px;
}
.teamLevelHeader {
color: var(--text-tertiary);
font-size: 12px;
font-weight: 650;
line-height: 16px;
}
.levelList {
display: flex;
flex-direction: column;
gap: 6px;
}
.levelRow :global(.MuiTextField-root),
.levelRow :global(.MuiFormControl-root),
.teamLevelRow :global(.MuiTextField-root),
.teamLevelRow :global(.MuiFormControl-root) {
--admin-control-height: 34px;
--admin-control-font-size: 13px;
}
@media (max-width: 760px) {
.formGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.fieldWide,
.fieldCompact,
.timeRangeField,
.enabledField {
grid-column: span 2;
}
.levelHeader,
.teamLevelHeader {
display: none;
}
.levelRow,
.teamLevelRow {
min-width: 0;
grid-template-columns: repeat(2, minmax(0, 1fr)) 44px;
}
.levelRow > :nth-child(2),
.levelRow > :nth-child(4),
.teamLevelRow > :nth-child(2),
.teamLevelRow > :nth-child(4) {
grid-column: span 2;
}
}

View File

@ -0,0 +1,360 @@
import { useState } from "react";
import Add from "@mui/icons-material/Add";
import CloudUploadOutlined from "@mui/icons-material/CloudUploadOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
AdminActionIconButton,
AdminFilterResetButton,
AdminFilterSelect,
AdminListBody,
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import { HostAgencyPolicyDrawer } from "@/features/host-agency-policy/components/HostAgencyPolicyDrawer.jsx";
import { useHostAgencyPolicyPage } from "@/features/host-agency-policy/hooks/useHostAgencyPolicyPage.js";
import { TeamSalaryPolicyPanel } from "@/features/host-agency-policy/pages/TeamSalaryPolicyPanel.jsx";
import styles from "@/features/host-agency-policy/host-agency-policy.module.css";
const statusOptions = [
["", "全部状态"],
["active", "启用"],
["disabled", "停用"],
];
const settlementModeOptions = [
["", "全部结算"],
["daily", "日结"],
["half_month", "半月结算"],
];
const settlementTriggerOptions = [
["", "全部触发"],
["automatic", "自动结算"],
["manual", "手动结算"],
];
const columnsBase = [
{
key: "policy",
label: "政策",
width: "minmax(260px, 1.2fr)",
// DataTable render (item, rowIndex, context) rowIndex
render: (item, _rowIndex, context) => <PolicyIdentity item={item} regionLabels={context.regionLabels} />,
},
{
key: "settlement",
label: "结算与比例",
width: "minmax(190px, 0.9fr)",
render: (item) => <SettlementSummary item={item} />,
},
{
key: "levels",
label: "等级",
width: "minmax(180px, 0.8fr)",
render: (item) => <LevelSummary item={item} />,
},
{
key: "effective",
label: "生效时间",
width: "minmax(220px, 1fr)",
render: (item) => <EffectiveTime item={item} />,
},
{
key: "publish",
label: "发布",
width: "minmax(180px, 0.8fr)",
render: (item) => <PublishSummary item={item} />,
},
{
key: "status",
label: "状态",
width: "minmax(110px, 0.5fr)",
},
{
key: "updatedAt",
label: "更新时间",
width: "minmax(170px, 0.75fr)",
render: (item) => formatMillis(item.updatedAtMs || item.createdAtMs),
},
{
key: "actions",
label: "操作",
width: "156px",
resizable: false,
},
];
export function HostAgencyPolicyPage() {
const [activeTab, setActiveTab] = useState("host");
return (
<AdminListPage>
<div className={styles.policyTabsBar}>
<Tabs value={activeTab} onChange={(_, value) => setActiveTab(value)}>
<Tab className={styles.policyTab} label="Host 政策" value="host" />
<Tab className={styles.policyTab} label="BD 政策" value="bd" />
<Tab className={styles.policyTab} label="Admin 政策" value="admin" />
</Tabs>
</div>
{activeTab === "host" ? <HostPolicyPanel /> : <TeamSalaryPolicyPanel policyType={activeTab} />}
</AdminListPage>
);
}
function HostPolicyPanel() {
const page = useHostAgencyPolicyPage();
const items = page.data.items || [];
const total = page.data.total || 0;
// region_id
const regionFilterOptions = [["", "全部区域"], ...page.regionOptions.map((region) => [region.value, region.label])];
const columns = columnsBase.map((column) => {
// DataTable
if (column.key === "policy") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索政策名称",
value: page.query,
onChange: page.changeQuery,
}),
};
}
if (column.key === "settlement") {
return {
...column,
filter: createOptionsColumnFilter({
//
options: settlementModeOptions,
placeholder: "搜索结算",
value: page.settlementMode,
onChange: page.changeSettlementMode,
}),
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: statusOptions,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
render: (item) => <PolicyStatusSwitch item={item} page={page} />,
};
}
if (column.key === "actions") {
return {
...column,
render: (item) => <PolicyActions item={item} page={page} />,
};
}
return column;
});
return (
<>
<AdminListToolbar
actions={
page.abilities.canCreate ? (
<AdminActionIconButton label="新增工资政策" primary onClick={page.openCreate}>
<Add fontSize="small" />
</AdminActionIconButton>
) : null
}
filters={
<div className={styles.toolbarFilters}>
<AdminFilterSelect
label="区域"
options={regionFilterOptions}
value={page.regionId}
onChange={page.changeRegionId}
/>
<AdminFilterSelect
label="触发方式"
options={settlementTriggerOptions}
value={page.settlementTriggerMode}
onChange={page.changeSettlementTriggerMode}
/>
<AdminFilterResetButton
disabled={
!page.query &&
!page.regionId &&
!page.status &&
!page.settlementMode &&
!page.settlementTriggerMode
}
onClick={page.resetFilters}
/>
</div>
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable
columns={columns}
context={{ regionLabels: page.regionLabels }}
items={items}
minWidth="1420px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 50,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(item) => item.id}
/>
</AdminListBody>
</DataState>
<HostAgencyPolicyDrawer page={page} />
</>
);
}
function PolicyIdentity({ item, regionLabels }) {
// regionLabels region_id
const labels = regionLabels || {};
return (
<div className={styles.stack}>
<span className={styles.name}>{item.name}</span>
<span className={styles.meta}>{labels[item.regionId] || `区域 ${item.regionId}`}</span>
</div>
);
}
function SettlementSummary({ item }) {
return (
<div className={styles.stack}>
<span>{settlementLabel(item.settlementMode)}</span>
<span className={styles.meta}>{settlementTriggerLabel(item.settlementTriggerMode)}</span>
<span className={styles.meta}>金币转钻石 {item.giftCoinToDiamondRatio || "1"}</span>
<span className={styles.meta}>剩余钻石转美元 {item.residualDiamondToUsdRate || "0"}</span>
</div>
);
}
function LevelSummary({ item }) {
const first = item.levels?.[0];
const last = item.levels?.[item.levels.length - 1];
//
return (
<div className={styles.stack}>
<span>{item.levels?.length || 0} 个等级</span>
<span className={styles.meta}>
{first && last
? `${formatNumber(first.requiredDiamonds)} - ${formatNumber(last.requiredDiamonds)}`
: "-"}
</span>
<span className={styles.meta}>{last ? `最高工资 $${last.hostSalaryUsd}` : ""}</span>
</div>
);
}
function EffectiveTime({ item }) {
return (
<div className={styles.stack}>
<span>{item.effectiveFromMs ? formatMillis(item.effectiveFromMs) : "立即生效"}</span>
<span className={styles.meta}>{item.effectiveToMs ? formatMillis(item.effectiveToMs) : "长期有效"}</span>
</div>
);
}
function PublishSummary({ item }) {
const status = item.publishStatus || "draft";
// publish_error
return (
<div className={styles.stack}>
<span className={`status-badge status-badge--${publishTone(status)}`}>{publishLabel(status)}</span>
<span className={styles.meta}>{item.publishedAtMs ? formatMillis(item.publishedAtMs) : "未发布"}</span>
{status === "failed" && item.publishError ? <span className={styles.meta}>{item.publishError}</span> : null}
</div>
);
}
function PolicyStatusSwitch({ item, page }) {
const disabled = !page.abilities.canUpdate || page.loadingAction === `policy-status-${item.id}`;
// update
return (
<AdminSwitch
checked={item.status === "active"}
checkedLabel="启用"
disabled={disabled}
label="切换政策状态"
uncheckedLabel="停用"
onChange={(event) => page.togglePolicyStatus(item, event.target.checked)}
/>
);
}
function PolicyActions({ item, page }) {
return (
<div className={styles.rowActions}>
<AdminActionIconButton
disabled={!page.abilities.canPublish || page.loadingAction === `policy-publish-${item.id}`}
label="发布工资政策"
onClick={() => page.publishPolicy(item)}
>
<CloudUploadOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton
disabled={!page.abilities.canUpdate}
label="编辑工资政策"
onClick={() => page.openEdit(item)}
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton
disabled={!page.abilities.canDelete || page.loadingAction === `policy-delete-${item.id}`}
label="删除工资政策"
onClick={() => page.removePolicy(item)}
>
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
</div>
);
}
function settlementLabel(value) {
return settlementModeOptions.find(([mode]) => mode === value)?.[1] || value || "-";
}
function settlementTriggerLabel(value) {
return settlementTriggerOptions.find(([mode]) => mode === value)?.[1] || value || "-";
}
function publishLabel(value) {
return (
{
draft: "草稿",
failed: "发布失败",
published: "已发布",
}[value] ||
value ||
"-"
);
}
function publishTone(value) {
return (
{
draft: "warning",
failed: "danger",
published: "running",
}[value] || "stopped"
);
}
function formatNumber(value) {
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}

View File

@ -0,0 +1,881 @@
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import Checkbox from "@mui/material/Checkbox";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
import { useEffect, useMemo, useState } from "react";
import {
listHostSalarySettlements,
listTeamSalarySettlementPending,
listTeamSalarySettlementRecords,
settleTeamSalary,
} from "@/features/host-agency-policy/api";
import { useHostAgencyPolicyAbilities } from "@/features/host-agency-policy/permissions.js";
import {
AdminFilterResetButton,
AdminFilterSelect,
AdminListBody,
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import styles from "@/features/host-agency-policy/host-agency-policy.module.css";
const pageSize = 50;
const settlementTypeOptions = [
["", "全部类型"],
["daily", "日结"],
["half_month", "半月结算"],
["month_end", "月底清算"],
];
const statusOptions = [
["", "全部状态"],
["succeeded", "成功"],
["skipped", "跳过"],
["failed", "失败"],
];
const policyTypeOptions = [
["bd", "BD"],
["admin", "Admin"],
];
const policyTypeAllOptions = [
["", "全部角色"],
...policyTypeOptions,
];
const triggerOptions = [
["automatic", "自动政策"],
["manual", "手动政策"],
];
const triggerAllOptions = [
["", "全部触发"],
...triggerOptions,
];
export function HostSalarySettlementPage() {
const [activeTab, setActiveTab] = useState("team-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="BD/Admin 记录" value="team-records" />
<Tab className={styles.policyTab} label="Host/Agency 记录" value="host-records" />
</Tabs>
</div>
{activeTab === "team-pending" ? (
<TeamPendingPanel />
) : activeTab === "team-records" ? (
<TeamRecordsPanel />
) : (
<HostRecordsPanel />
)}
</AdminListPage>
);
}
// HostRecordsPanel Host/Agency BD/Admin tab
function HostRecordsPanel() {
const [page, setPage] = useState(1);
const [cycleKey, setCycleKey] = useState("");
const [userId, setUserId] = useState("");
const [agencyOwnerUserId, setAgencyOwnerUserId] = useState("");
const [settlementType, setSettlementType] = useState("");
const [status, setStatus] = useState("");
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
// filters 使 snake_case apiRequest query
const filters = useMemo(
() => ({
agency_owner_user_id: agencyOwnerUserId,
cycle_key: cycleKey,
end_at_ms: timeRange.endMs || "",
settlement_type: settlementType,
start_at_ms: timeRange.startMs || "",
status,
user_id: userId,
}),
[agencyOwnerUserId, cycleKey, settlementType, status, timeRange.endMs, timeRange.startMs, userId],
);
const query = usePaginatedQuery({
errorMessage: "获取工资结算记录失败",
fetcher: listHostSalarySettlements,
filters,
page,
pageSize,
queryKey: ["host-salary-settlements", filters, page],
});
const data = query.data || { items: [], page, pageSize, total: 0 };
const items = data.items || [];
const total = data.total || 0;
const changeFilter = (setter) => (value) => {
//
setter(value);
setPage(1);
};
const changeTimeRange = (nextRange) => {
setTimeRange(nextRange);
setPage(1);
};
const resetFilters = () => {
setAgencyOwnerUserId("");
setCycleKey("");
setSettlementType("");
setStatus("");
setTimeRange({ endMs: "", startMs: "" });
setUserId("");
setPage(1);
};
const tableColumns = hostRecordColumns.map((column) => {
// DataTable UI
if (column.key === "host") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "主播用户 ID",
value: userId,
onChange: changeFilter(setUserId),
}),
};
}
if (column.key === "agency") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "代理用户 ID",
value: agencyOwnerUserId,
onChange: changeFilter(setAgencyOwnerUserId),
}),
};
}
if (column.key === "cycle") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "周期 key",
value: cycleKey,
onChange: changeFilter(setCycleKey),
}),
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: statusOptions,
value: status,
onChange: changeFilter(setStatus),
}),
};
}
return column;
});
const hasFilters =
agencyOwnerUserId ||
cycleKey ||
settlementType ||
status ||
timeRange.startMs ||
timeRange.endMs ||
userId;
return (
<>
<AdminListToolbar
filters={
<div className={styles.toolbarFilters}>
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
<AdminFilterSelect
label="结算类型"
options={settlementTypeOptions}
value={settlementType}
onChange={changeFilter(setSettlementType)}
/>
<AdminFilterResetButton disabled={!hasFilters} onClick={resetFilters} />
</div>
}
/>
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
<AdminListBody>
<DataTable
columns={tableColumns}
items={items}
minWidth="1420px"
pagination={paginationProps(data, page, total, setPage)}
rowKey={(item) => item.settlementId}
/>
</AdminListBody>
</DataState>
</>
);
}
// TeamPendingPanel
function TeamPendingPanel() {
const abilities = useHostAgencyPolicyAbilities();
const { showToast } = useToast();
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,
region_id: regionId,
trigger_mode: triggerMode,
}),
[countryCode, cycleKey, policyType, regionId, triggerMode],
);
const query = usePaginatedQuery({
errorMessage: "获取 BD/Admin 待结算列表失败",
fetcher: listTeamSalarySettlementPending,
filters,
page,
pageSize,
queryKey: ["team-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(teamPendingKey(item)));
useEffect(() => {
//
setSelected(new Set());
}, [filters]);
const changeFilter = (setter) => (value) => {
// //
setter(value);
setPage(1);
};
const resetFilters = () => {
setPolicyType("bd");
setTriggerMode("automatic");
setCycleKey(defaultCycleKey());
setCountryCode("");
setRegionId("");
setPage(1);
};
const toggleOne = (item) => {
setSelected((current) => {
// key
const next = new Set(current);
const key = teamPendingKey(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 = teamPendingKey(item);
if (allVisibleSelected) {
next.delete(key);
} else {
next.add(key);
}
});
return next;
});
};
const settleSelected = async () => {
// items user_ids
const userIds = items
.filter((item) => selected.has(teamPendingKey(item)))
.map((item) => Number(item.userId))
.filter((id) => Number.isFinite(id) && id > 0);
if (!userIds.length) {
return;
}
setSettling(true);
try {
// trigger_mode
const result = await settleTeamSalary({
country_code: countryCode || undefined,
cycle_key: cycleKey,
policy_type: policyType,
region_id: Number(regionId || 0) || undefined,
trigger_mode: triggerMode,
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 || "工资结算失败", severity: "error" });
} finally {
setSettling(false);
}
};
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(teamPendingKey(item))} size="small" onChange={() => toggleOne(item)} />
),
},
...teamPendingColumns,
];
// hasFiltersBD + +
const hasFilters = policyType !== "bd" || triggerMode !== "automatic" || cycleKey !== defaultCycleKey() || 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={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)} />
<AdminFilterResetButton disabled={!hasFilters} onClick={resetFilters} />
</div>
}
/>
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
<AdminListBody>
<DataTable
columns={tableColumns}
items={items}
minWidth="1280px"
pagination={paginationProps(data, page, total, setPage)}
rowKey={teamPendingKey}
/>
</AdminListBody>
</DataState>
</>
);
}
// TeamRecordsPanel BD/Admin
function TeamRecordsPanel() {
const [page, setPage] = useState(1);
const [policyType, setPolicyType] = useState("");
const [triggerMode, setTriggerMode] = useState("");
const [cycleKey, setCycleKey] = useState("");
const [countryCode, setCountryCode] = useState("");
const [userId, setUserId] = useState("");
const [status, setStatus] = useState("");
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
// region_id
const filters = useMemo(
() => ({
country_code: countryCode,
cycle_key: cycleKey,
end_at_ms: timeRange.endMs || "",
policy_type: policyType,
start_at_ms: timeRange.startMs || "",
status,
trigger_mode: triggerMode,
user_id: userId,
}),
[countryCode, cycleKey, policyType, status, timeRange.endMs, timeRange.startMs, triggerMode, userId],
);
const query = usePaginatedQuery({
errorMessage: "获取 BD/Admin 结算记录失败",
fetcher: listTeamSalarySettlementRecords,
filters,
page,
pageSize,
queryKey: ["team-salary-records", filters, page],
});
const data = query.data || { items: [], page, pageSize, total: 0 };
const items = data.items || [];
const total = data.total || 0;
const changeFilter = (setter) => (value) => {
//
setter(value);
setPage(1);
};
const resetFilters = () => {
setPolicyType("");
setTriggerMode("");
setCycleKey("");
setCountryCode("");
setStatus("");
setTimeRange({ endMs: "", startMs: "" });
setUserId("");
setPage(1);
};
const tableColumns = teamRecordColumns.map((column) => {
// //便
if (column.key === "user") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "用户 ID",
value: userId,
onChange: changeFilter(setUserId),
}),
};
}
if (column.key === "cycle") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "周期 key",
value: cycleKey,
onChange: changeFilter(setCycleKey),
}),
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: statusOptions,
value: status,
onChange: changeFilter(setStatus),
}),
};
}
return column;
});
const hasFilters =
policyType ||
triggerMode ||
cycleKey ||
countryCode ||
status ||
timeRange.startMs ||
timeRange.endMs ||
userId;
return (
<>
<AdminListToolbar
filters={
<div className={styles.toolbarFilters}>
<TimeRangeFilter value={timeRange} onChange={(value) => { setTimeRange(value); setPage(1); }} />
<AdminFilterSelect label="角色" options={policyTypeAllOptions} value={policyType} onChange={changeFilter(setPolicyType)} />
<AdminFilterSelect label="触发" options={triggerAllOptions} value={triggerMode} onChange={changeFilter(setTriggerMode)} />
<InlineFilter label="国家" placeholder="国家代码" value={countryCode} onChange={changeFilter(setCountryCode)} />
<AdminFilterResetButton disabled={!hasFilters} onClick={resetFilters} />
</div>
}
/>
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
<AdminListBody>
<DataTable
columns={tableColumns}
items={items}
minWidth="1280px"
pagination={paginationProps(data, page, total, setPage)}
rowKey={(item) => item.settlementId}
/>
</AdminListBody>
</DataState>
</>
);
}
// hostRecordColumns Host/Agency
const hostRecordColumns = [
{
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: "cycle",
label: "周期",
width: "minmax(170px, 0.8fr)",
render: (item) => <CycleCell item={item} />,
},
{
key: "level",
label: "等级/钻石",
width: "minmax(150px, 0.7fr)",
render: (item) => <HostLevelCell item={item} />,
},
{
key: "hostReward",
label: "主播奖励",
width: "minmax(170px, 0.8fr)",
render: (item) => <HostRewardCell item={item} />,
},
{
key: "agencyReward",
label: "代理/剩余",
width: "minmax(170px, 0.8fr)",
render: (item) => <AgencyRewardCell item={item} />,
},
{
key: "status",
label: "状态",
width: "minmax(120px, 0.55fr)",
render: (item) => <StatusCell item={item} />,
},
{
key: "createdAtMs",
label: "时间",
width: "minmax(170px, 0.8fr)",
render: (item) => formatMillis(item.createdAtMs),
},
];
// teamPendingColumns BD/Admin
const teamPendingColumns = [
{
key: "user",
label: "用户",
width: "minmax(230px, 1fr)",
render: (item) => <UserCell fallbackId={item.userId} user={item.user} />,
},
{
key: "scope",
label: "周期/区域",
width: "minmax(180px, 0.8fr)",
render: (item) => (
<div className={styles.stack}>
<span>{roleLabel(item.policyType)} · {item.cycleKey || "-"}</span>
<span className={styles.meta}>{item.regionName || `区域 ${item.regionId || "-"}`}</span>
</div>
),
},
{
key: "income",
label: "收入基数",
width: "minmax(150px, 0.7fr)",
render: (item) => <MoneyStack primary={item.incomeUsd} secondary="累计收入" />,
},
{
key: "level",
label: "政策/档位",
width: "minmax(210px, 0.9fr)",
render: (item) => (
<div className={styles.stack}>
<span>{item.levelNo > 0 ? `等级 ${item.levelNo}` : "未达等级"}</span>
<span className={styles.meta}>{item.policyName || `政策 ${item.policyId || "-"}`}</span>
</div>
),
},
{
key: "salary",
label: "待发工资",
width: "minmax(170px, 0.8fr)",
render: (item) => (
<div className={styles.stack}>
<span className={styles.amountPositive}>{formatMoneyDelta(item.salaryUsdDelta)}</span>
<span className={styles.meta}>累计目标 ${item.salaryUsdTarget || "0"}</span>
</div>
),
},
{
key: "progress",
label: "已结进度",
width: "minmax(180px, 0.8fr)",
render: (item) => (
<div className={styles.stack}>
<span>${item.settledSalaryUsd || "0"}</span>
<span className={styles.meta}>已结等级 {item.settledLevelNo || 0}</span>
</div>
),
},
];
// teamRecordColumns BD/Admin settlementId rowKey
const teamRecordColumns = [
{
key: "user",
label: "用户",
width: "minmax(230px, 1fr)",
render: (item) => <UserCell fallbackId={item.userId} user={item.user} />,
},
{
key: "cycle",
label: "周期/角色",
width: "minmax(170px, 0.8fr)",
render: (item) => (
<div className={styles.stack}>
<span>{item.cycleKey || "-"}</span>
<span className={styles.meta}>{roleLabel(item.policyType)} · {triggerLabel(item.triggerMode)}</span>
</div>
),
},
{
key: "income",
label: "收入基数",
width: "minmax(150px, 0.7fr)",
render: (item) => <MoneyStack primary={item.incomeUsd} secondary={`等级 ${item.levelNo || 0}`} />,
},
{
key: "salary",
label: "发放工资",
width: "minmax(160px, 0.75fr)",
render: (item) => <span className={styles.amountPositive}>{formatMoneyDelta(item.salaryUsdDelta)}</span>,
},
{
key: "policy",
label: "政策",
width: "minmax(130px, 0.55fr)",
render: (item) => `政策 ${item.policyId || "-"}`,
},
{
key: "status",
label: "状态",
width: "minmax(120px, 0.55fr)",
render: (item) => <StatusCell item={item} />,
},
{
key: "createdAtMs",
label: "时间",
width: "minmax(170px, 0.8fr)",
render: (item) => formatMillis(item.createdAtMs),
},
];
// InlineFilter ID
function InlineFilter({ label, onChange, placeholder, value }) {
return (
<label className={styles.inlineFilter}>
<span>{label}</span>
<input placeholder={placeholder} value={value} onChange={(event) => onChange(event.target.value)} />
</label>
);
}
// UserCell user_id
function UserCell({ fallbackId, user }) {
const idText = userIdText(user, fallbackId);
if (!idText) {
return "-";
}
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{user?.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<div className={styles.identityText}>
<span className={styles.primaryText}>{user?.username || "-"}</span>
<span className={styles.meta}>{idText}</span>
</div>
</div>
);
}
// CycleCell Host/Agency
function CycleCell({ item }) {
return (
<div className={styles.stack}>
<span>{item.cycleKey || "-"}</span>
<span className={styles.meta}>{settlementTypeLabel(item.settlementType)}</span>
</div>
);
}
// HostLevelCell 便 Host
function HostLevelCell({ item }) {
return (
<div className={styles.stack}>
<span>{item.levelNo > 0 ? `等级 ${item.levelNo}` : "未达等级"}</span>
<span className={styles.meta}>{formatNumber(item.totalDiamonds)} 钻石</span>
<span className={styles.meta}>政策 {item.policyId || "-"}</span>
</div>
);
}
// HostRewardCell
function HostRewardCell({ item }) {
return (
<div className={styles.stack}>
<span className={styles.amountPositive}>{formatMoneyDelta(item.hostSalaryUsdDelta)}</span>
<span className={styles.meta}>金币 {formatSignedInteger(item.hostCoinRewardDelta)}</span>
</div>
);
}
// AgencyRewardCell
function AgencyRewardCell({ item }) {
return (
<div className={styles.stack}>
<span className={styles.amountPositive}>{formatMoneyDelta(item.agencySalaryUsdDelta)}</span>
<span className={styles.meta}>剩余折美元 {formatMoneyDelta(item.residualUsdDelta)}</span>
</div>
);
}
// MoneyStack
function MoneyStack({ primary, secondary }) {
return (
<div className={styles.stack}>
<span>${primary || "0"}</span>
<span className={styles.meta}>{secondary}</span>
</div>
);
}
// StatusCell
function StatusCell({ item }) {
return (
<div className={styles.stack}>
<span className={`status-badge status-badge--${statusTone(item.status)}`}>{statusLabel(item.status)}</span>
{item.reason ? <span className={styles.meta}>{item.reason}</span> : null}
</div>
);
}
// paginationProps total > 0
function paginationProps(data, page, total, onPageChange) {
return total > 0
? {
page,
pageSize: data.pageSize || pageSize,
total,
onPageChange,
}
: undefined;
}
// teamPendingKey
function teamPendingKey(item) {
return `${item.policyType}:${item.cycleKey}:${item.regionId}:${item.userId}`;
}
// userIdText ID / ID user_id
function userIdText(user, fallbackId) {
const id = String(user?.userId || fallbackId || "").trim();
if (!id || id === "0") {
return "";
}
const display = String(user?.displayUserId || "").trim();
return display ? `${display} / ${id}` : id;
}
// defaultCycleKey UTC previousMonthCycleKey
function defaultCycleKey() {
const now = new Date();
const firstOfMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
firstOfMonth.setUTCMonth(firstOfMonth.getUTCMonth() - 1);
return `${firstOfMonth.getUTCFullYear()}-${String(firstOfMonth.getUTCMonth() + 1).padStart(2, "0")}`;
}
// settlementTypeLabel Host/Agency
function settlementTypeLabel(value) {
return (
{
daily: "日结",
half_month: "半月结算",
month_end: "月底清算",
}[value] || value || "-"
);
}
// statusLabel
function statusLabel(value) {
return (
{
failed: "失败",
skipped: "跳过",
succeeded: "成功",
}[value] || value || "-"
);
}
// statusTone status-badge
function statusTone(value) {
return (
{
failed: "danger",
skipped: "warning",
succeeded: "running",
}[value] || "stopped"
);
}
// roleLabel policy_type
function roleLabel(value) {
return value === "admin" ? "Admin" : value === "bd" ? "BD" : value || "-";
}
// triggerLabel /
function triggerLabel(value) {
return value === "automatic" ? "自动" : value === "manual" ? "手动" : value || "-";
}
// formatMoneyDelta +0
function formatMoneyDelta(value) {
const number = Number(value || 0);
if (!Number.isFinite(number) || number === 0) {
return "$0.00";
}
const prefix = number > 0 ? "+$" : "-$";
return `${prefix}${Math.abs(number).toFixed(2)}`;
}
// formatSignedInteger +
function formatSignedInteger(value) {
const number = Number(value || 0);
if (!Number.isFinite(number) || number === 0) {
return "0";
}
return `${number > 0 ? "+" : ""}${formatNumber(number)}`;
}
// formatNumber
function formatNumber(value) {
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}

View File

@ -0,0 +1,271 @@
import Add from "@mui/icons-material/Add";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
AdminActionIconButton,
AdminFilterResetButton,
AdminFilterSelect,
AdminListBody,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import { TeamSalaryPolicyDrawer } from "@/features/host-agency-policy/components/TeamSalaryPolicyDrawer.jsx";
import { useTeamSalaryPolicyPage } from "@/features/host-agency-policy/hooks/useTeamSalaryPolicyPage.js";
import styles from "@/features/host-agency-policy/host-agency-policy.module.css";
const statusOptions = [
["", "全部状态"],
["active", "启用"],
["disabled", "停用"],
];
const settlementTriggerOptions = [
["", "全部触发"],
["automatic", "自动结算"],
["manual", "手动结算"],
];
const columnsBase = [
{
key: "policy",
label: "政策",
width: "minmax(260px, 1.2fr)",
render: (item, _rowIndex, context) => (
<PolicyIdentity item={item} policyType={context.policyType} regionLabels={context.regionLabels} />
),
},
{
key: "settlement",
label: "触发与周期",
width: "minmax(180px, 0.8fr)",
render: (item) => <SettlementSummary item={item} />,
},
{
key: "levels",
label: "等级",
width: "minmax(220px, 0.95fr)",
render: (item, _rowIndex, context) => <LevelSummary item={item} policyType={context.policyType} />,
},
{
key: "effective",
label: "生效时间",
width: "minmax(220px, 1fr)",
render: (item) => <EffectiveTime item={item} />,
},
{
key: "status",
label: "状态",
width: "minmax(110px, 0.5fr)",
},
{
key: "updatedAt",
label: "更新时间",
width: "minmax(170px, 0.75fr)",
render: (item) => formatMillis(item.updatedAtMs || item.createdAtMs),
},
{
key: "actions",
label: "操作",
width: "112px",
resizable: false,
},
];
export function TeamSalaryPolicyPanel({ policyType }) {
const page = useTeamSalaryPolicyPage(policyType);
const items = page.data.items || [];
const total = page.data.total || 0;
const regionFilterOptions = [["", "全部区域"], ...page.regionOptions.map((region) => [region.value, region.label])];
const columns = columnsBase.map((column) => {
if (column.key === "policy") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索政策名称",
value: page.query,
onChange: page.changeQuery,
}),
};
}
if (column.key === "settlement") {
return {
...column,
filter: createOptionsColumnFilter({
options: settlementTriggerOptions,
placeholder: "搜索触发方式",
value: page.settlementTriggerMode,
onChange: page.changeSettlementTriggerMode,
}),
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: statusOptions,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
render: (item) => <PolicyStatusSwitch item={item} page={page} />,
};
}
if (column.key === "actions") {
return {
...column,
render: (item) => <PolicyActions item={item} page={page} />,
};
}
return column;
});
return (
<>
<AdminListToolbar
actions={
page.abilities.canTeamCreate ? (
<AdminActionIconButton
label={`新增${policyTitle(policyType)}政策`}
primary
onClick={page.openCreate}
>
<Add fontSize="small" />
</AdminActionIconButton>
) : null
}
filters={
<div className={styles.toolbarFilters}>
<AdminFilterSelect
label="区域"
options={regionFilterOptions}
value={page.regionId}
onChange={page.changeRegionId}
/>
<AdminFilterResetButton
disabled={!page.query && !page.regionId && !page.status && !page.settlementTriggerMode}
onClick={page.resetFilters}
/>
</div>
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable
columns={columns}
context={{ policyType, regionLabels: page.regionLabels }}
items={items}
minWidth="1240px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 50,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(item) => item.id}
/>
</AdminListBody>
</DataState>
<TeamSalaryPolicyDrawer page={page} />
</>
);
}
function PolicyIdentity({ item, policyType, regionLabels }) {
const labels = regionLabels || {};
return (
<div className={styles.stack}>
<span className={styles.name}>{item.name}</span>
<span className={styles.meta}>
{policyTitle(policyType)} · {labels[item.regionId] || `区域 ${item.regionId}`}
</span>
</div>
);
}
function SettlementSummary({ item }) {
return (
<div className={styles.stack}>
<span>{settlementTriggerLabel(item.settlementTriggerMode)}</span>
<span className={styles.meta}>完整自然月</span>
<span className={styles.meta}>次月 5 日可结算</span>
</div>
);
}
function LevelSummary({ item, policyType }) {
const first = item.levels?.[0];
const last = item.levels?.[item.levels.length - 1];
return (
<div className={styles.stack}>
<span>{item.levels?.length || 0} 个等级</span>
<span className={styles.meta}>
{first && last ? `$${first.thresholdUsd} - $${last.thresholdUsd} ${thresholdUnit(policyType)}` : "-"}
</span>
<span className={styles.meta}>{last ? `最高工资 $${last.salaryUsd}` : ""}</span>
</div>
);
}
function EffectiveTime({ item }) {
return (
<div className={styles.stack}>
<span>{item.effectiveFromMs ? formatMillis(item.effectiveFromMs) : "立即生效"}</span>
<span className={styles.meta}>{item.effectiveToMs ? formatMillis(item.effectiveToMs) : "长期有效"}</span>
</div>
);
}
function PolicyStatusSwitch({ item, page }) {
const disabled = !page.abilities.canTeamUpdate || page.loadingAction === `team-policy-status-${item.id}`;
return (
<AdminSwitch
checked={item.status === "active"}
checkedLabel="启用"
disabled={disabled}
label="切换政策状态"
uncheckedLabel="停用"
onChange={(event) => page.togglePolicyStatus(item, event.target.checked)}
/>
);
}
function PolicyActions({ item, page }) {
return (
<div className={styles.rowActions}>
<AdminActionIconButton
disabled={!page.abilities.canTeamUpdate}
label="编辑工资政策"
onClick={() => page.openEdit(item)}
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton
disabled={!page.abilities.canTeamDelete || page.loadingAction === `team-policy-delete-${item.id}`}
label="删除工资政策"
onClick={() => page.removePolicy(item)}
>
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
</div>
);
}
function policyTitle(policyType) {
return policyType === "bd" ? "BD" : "Admin";
}
function thresholdUnit(policyType) {
return policyType === "bd" ? "主播工资" : "BD工资";
}
function settlementTriggerLabel(value) {
return settlementTriggerOptions.find(([mode]) => mode === value)?.[1] || value || "-";
}

View File

@ -0,0 +1,20 @@
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PERMISSIONS } from "@/app/permissions";
export function useHostAgencyPolicyAbilities() {
const { can } = useAuth();
return {
canCreate: can(PERMISSIONS.hostAgencyPolicyCreate),
canDelete: can(PERMISSIONS.hostAgencyPolicyDelete),
canPublish: can(PERMISSIONS.hostAgencyPolicyPublish),
canTeamCreate: can(PERMISSIONS.teamSalaryPolicyCreate),
canTeamDelete: can(PERMISSIONS.teamSalaryPolicyDelete),
canTeamUpdate: can(PERMISSIONS.teamSalaryPolicyUpdate),
canTeamView: can(PERMISSIONS.teamSalaryPolicyView),
canSettleSalary: can(PERMISSIONS.hostSalarySettlementSettle),
canViewSettlements: can(PERMISSIONS.hostSalarySettlementView),
canUpdate: can(PERMISSIONS.hostAgencyPolicyUpdate),
canView: can(PERMISSIONS.hostAgencyPolicyView),
};
}

View File

@ -0,0 +1,20 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const hostAgencyPolicyRoutes = [
{
label: "工资政策",
loader: () => import("./pages/HostAgencyPolicyPage.jsx").then((module) => module.HostAgencyPolicyPage),
menuCode: MENU_CODES.hostAgencyPolicy,
pageKey: "host-agency-policy",
path: "/host/salary-policies",
permission: PERMISSIONS.hostAgencyPolicyView,
},
{
label: "工资结算",
loader: () => import("./pages/HostSalarySettlementPage.jsx").then((module) => module.HostSalarySettlementPage),
menuCode: MENU_CODES.hostSalarySettlement,
pageKey: "host-salary-settlement",
path: "/host/salary-settlements",
permission: PERMISSIONS.hostSalarySettlementView,
},
];

View File

@ -0,0 +1,230 @@
import { z } from "zod";
const statusValues = ["active", "disabled"] as const;
const settlementModes = ["daily", "half_month"] as const;
const settlementTriggerModes = ["automatic", "manual"] as const;
const teamPolicyTypes = ["bd", "admin"] as const;
const levelSchema = z.object({
// 输入框值可能是 string复制/恢复表单时也可能是 number统一在 superRefine 中转数值校验。
agencySalaryUsd: z.union([z.string(), z.number()]),
hostCoinReward: z.union([z.string(), z.number()]),
hostSalaryUsd: z.union([z.string(), z.number()]),
level: z.union([z.string(), z.number()]),
requiredDiamonds: z.union([z.string(), z.number()]),
sortOrder: z.union([z.string(), z.number()]).optional(),
status: z.enum(statusValues),
});
export const hostAgencyPolicyFormSchema = z
.object({
effectiveFrom: z.union([z.string(), z.number()]).optional(),
effectiveTo: z.union([z.string(), z.number()]).optional(),
enabled: z.boolean(),
giftCoinToDiamondRatio: z.union([z.string(), z.number()]),
levels: z.array(levelSchema).min(1, "请至少配置一个等级"),
name: z.string().trim().min(1, "请输入政策名称").max(120, "政策名称不能超过 120 个字符"),
regionId: z.union([z.string(), z.number()]),
residualDiamondToUsdRate: z.union([z.string(), z.number()]),
settlementMode: z.enum(settlementModes),
settlementTriggerMode: z.enum(settlementTriggerModes),
})
.superRefine((value, context) => {
// 前端先做完整业务校验,降低运营误填成本;后端仍是最终可信校验边界。
const regionId = Number(value.regionId);
if (!Number.isInteger(regionId) || regionId <= 0) {
context.addIssue({ code: "custom", message: "请选择适用区域", path: ["regionId"] });
}
if (!isFixedDecimal(value.giftCoinToDiamondRatio, 6, false)) {
context.addIssue({ code: "custom", message: "金币转钻石比例不正确", path: ["giftCoinToDiamondRatio"] });
}
if (!isFixedDecimal(value.residualDiamondToUsdRate, 12, true)) {
context.addIssue({
code: "custom",
message: "剩余钻石转美元比例不正确",
path: ["residualDiamondToUsdRate"],
});
}
const effectiveFromMs = timeValueToMs(value.effectiveFrom);
const effectiveToMs = timeValueToMs(value.effectiveTo);
if (effectiveFromMs < 0 || effectiveToMs < 0 || (effectiveToMs > 0 && effectiveToMs <= effectiveFromMs)) {
context.addIssue({ code: "custom", message: "生效时间范围不正确", path: ["effectiveTo"] });
}
const normalizedLevels = [...value.levels].sort((left, right) => Number(left.level) - Number(right.level));
const seenLevels = new Set<number>();
let previousRequiredDiamonds = 0;
let previousHostSalary = 0;
let previousHostCoinReward = 0;
let previousAgencySalary = 0;
normalizedLevels.forEach((level, index) => {
// 等级号、钻石门槛和三类累计权益必须单调,保证后续差额结算不会出现负值或多重命中。
const levelNo = Number(level.level);
if (!Number.isInteger(levelNo) || levelNo <= 0 || seenLevels.has(levelNo)) {
context.addIssue({
code: "custom",
message: "等级必须为不重复的正整数",
path: ["levels", index, "level"],
});
}
seenLevels.add(levelNo);
const requiredDiamonds = Number(level.requiredDiamonds);
if (
!Number.isInteger(requiredDiamonds) ||
requiredDiamonds <= 0 ||
requiredDiamonds <= previousRequiredDiamonds
) {
context.addIssue({
code: "custom",
message: "钻石门槛必须逐级递增",
path: ["levels", index, "requiredDiamonds"],
});
}
const hostSalary = decimalToScaled(level.hostSalaryUsd, 2);
if (hostSalary < 0 || hostSalary < previousHostSalary) {
context.addIssue({
code: "custom",
message: "主播工资不能小于上一等级",
path: ["levels", index, "hostSalaryUsd"],
});
}
const hostCoinReward = Number(level.hostCoinReward);
if (!Number.isInteger(hostCoinReward) || hostCoinReward < 0 || hostCoinReward < previousHostCoinReward) {
context.addIssue({
code: "custom",
message: "金币奖励不能小于上一等级",
path: ["levels", index, "hostCoinReward"],
});
}
const agencySalary = decimalToScaled(level.agencySalaryUsd, 2);
if (agencySalary < 0 || agencySalary < previousAgencySalary) {
context.addIssue({
code: "custom",
message: "代理工资不能小于上一等级",
path: ["levels", index, "agencySalaryUsd"],
});
}
previousRequiredDiamonds = requiredDiamonds;
previousHostSalary = hostSalary;
previousHostCoinReward = hostCoinReward;
previousAgencySalary = agencySalary;
});
});
const teamLevelSchema = z.object({
level: z.union([z.string(), z.number()]),
ratePercent: z.union([z.string(), z.number()]),
salaryUsd: z.union([z.string(), z.number()]),
sortOrder: z.union([z.string(), z.number()]).optional(),
status: z.enum(statusValues),
thresholdUsd: z.union([z.string(), z.number()]),
});
export const teamSalaryPolicyFormSchema = z
.object({
effectiveFrom: z.union([z.string(), z.number()]).optional(),
effectiveTo: z.union([z.string(), z.number()]).optional(),
enabled: z.boolean(),
levels: z.array(teamLevelSchema).min(1, "请至少配置一个等级"),
name: z.string().trim().min(1, "请输入政策名称").max(120, "政策名称不能超过 120 个字符"),
policyType: z.enum(teamPolicyTypes),
regionId: z.union([z.string(), z.number()]),
settlementTriggerMode: z.enum(settlementTriggerModes),
})
.superRefine((value, context) => {
const regionId = Number(value.regionId);
if (!Number.isInteger(regionId) || regionId <= 0) {
context.addIssue({ code: "custom", message: "请选择适用区域", path: ["regionId"] });
}
const effectiveFromMs = timeValueToMs(value.effectiveFrom);
const effectiveToMs = timeValueToMs(value.effectiveTo);
if (effectiveFromMs < 0 || effectiveToMs < 0 || (effectiveToMs > 0 && effectiveToMs <= effectiveFromMs)) {
context.addIssue({ code: "custom", message: "生效时间范围不正确", path: ["effectiveTo"] });
}
const normalizedLevels = [...value.levels].sort((left, right) => Number(left.level) - Number(right.level));
const seenLevels = new Set<number>();
let previousThreshold = 0;
let previousSalary = 0;
normalizedLevels.forEach((level, index) => {
// BD/Admin 等级表保存累计门槛和累计工资,递增校验保证后续差额结算不会倒扣。
const levelNo = Number(level.level);
if (!Number.isInteger(levelNo) || levelNo <= 0 || seenLevels.has(levelNo)) {
context.addIssue({
code: "custom",
message: "等级必须为不重复的正整数",
path: ["levels", index, "level"],
});
}
seenLevels.add(levelNo);
const threshold = decimalToScaled(level.thresholdUsd, 2, false);
if (threshold <= 0 || threshold <= previousThreshold) {
context.addIssue({
code: "custom",
message: "收入门槛必须逐级递增",
path: ["levels", index, "thresholdUsd"],
});
}
const rate = decimalToScaled(level.ratePercent, 4, false);
if (rate <= 0) {
context.addIssue({
code: "custom",
message: "提成比例必须大于 0",
path: ["levels", index, "ratePercent"],
});
}
const salary = decimalToScaled(level.salaryUsd, 2);
if (salary < 0 || salary < previousSalary) {
context.addIssue({
code: "custom",
message: "累计工资不能小于上一等级",
path: ["levels", index, "salaryUsd"],
});
}
previousThreshold = threshold;
previousSalary = salary;
});
});
function isFixedDecimal(value: unknown, scale: number, allowZero: boolean) {
return decimalToScaled(value, scale, allowZero) >= 0;
}
function decimalToScaled(value: unknown, scale: number, allowZero = true) {
// 小数校验转换成整数刻度比较,避免 JS 浮点数直接参与金额大小判断。
const raw = String(value ?? "").trim();
if (!raw || raw.startsWith("-")) {
return -1;
}
const pattern = new RegExp(`^\\d+(\\.\\d{1,${scale}})?$`);
if (!pattern.test(raw)) {
return -1;
}
const [whole, fraction = ""] = raw.split(".");
const scaled = Number(whole) * 10 ** scale + Number(fraction.padEnd(scale, "0"));
if (!Number.isSafeInteger(scaled) || (!allowZero && scaled <= 0)) {
return -1;
}
return scaled;
}
function timeValueToMs(value: unknown) {
// 空值表示长期/立即生效;非法日期返回 -1让上层统一提示时间范围错误。
if (value === "" || value === null || value === undefined) {
return 0;
}
if (typeof value === "number") {
return Number.isFinite(value) && value >= 0 ? value : -1;
}
const text = String(value).trim();
if (!text) {
return 0;
}
const number = Number(value);
if (/^\d+$/.test(text)) {
return Number.isFinite(number) ? number : -1;
}
const date = new Date(text);
const ms = date.getTime();
return Number.isFinite(ms) ? ms : -1;
}

View File

@ -8,6 +8,7 @@ import type {
CoinLedgerEntryDto,
CoinLedgerUserDto,
PageQuery,
ReportDto,
} from "@/shared/api/types";
export function listCoinLedger(query: PageQuery = {}): Promise<ApiPage<CoinLedgerEntryDto>> {
@ -26,6 +27,14 @@ export function listCoinAdjustments(query: PageQuery = {}): Promise<ApiPage<Coin
});
}
export function listReports(query: PageQuery = {}): Promise<ApiPage<ReportDto>> {
const endpoint = API_ENDPOINTS.listReports;
return apiRequest<ApiPage<ReportDto>>(apiEndpointPath(API_OPERATIONS.listReports), {
method: endpoint.method,
query,
});
}
export function lookupCoinAdjustmentTarget(userId: string): Promise<CoinLedgerUserDto> {
const endpoint = API_ENDPOINTS.lookupCoinAdjustmentTarget;
return apiRequest<CoinLedgerUserDto>(apiEndpointPath(API_OPERATIONS.lookupCoinAdjustmentTarget), {

View File

@ -185,6 +185,56 @@
background: var(--bg-card-strong);
}
.reportTargetType {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--text-primary);
font-weight: 700;
white-space: nowrap;
}
.reportReason {
display: -webkit-box;
overflow: hidden;
color: var(--text-primary);
line-height: 1.55;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.reportImages {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.reportImage,
.reportImageCount {
display: inline-flex;
flex: 0 0 auto;
width: 44px;
height: 44px;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-card-strong);
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
text-decoration: none;
}
.reportImage img {
width: 100%;
height: 100%;
object-fit: cover;
}
@media (max-width: 640px) {
.detailGrid {
grid-template-columns: 1fr;

View File

@ -0,0 +1,260 @@
import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { useMemo, useState } from "react";
import { listReports } from "@/features/operations/api";
import styles from "@/features/operations/operations.module.css";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import {
AdminFilterResetButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
const pageSize = 50;
const targetTypeOptions = [
{ label: "全部目标", value: "" },
{ label: "用户", value: "user" },
{ label: "房间", value: "room" },
];
const reportTypeOptions = [
{ label: "全部类型", value: "" },
{ label: "政治", value: "politics" },
{ label: "色情", value: "pornography" },
{ label: "血腥暴力", value: "graphic_violence" },
{ label: "言语辱骂", value: "verbal_abuse" },
{ label: "欺诈", value: "fraud" },
{ label: "违法内容", value: "illegal_content" },
{ label: "未成年人", value: "minors" },
{ label: "线下交易", value: "in_person_transactions" },
{ label: "其他", value: "other" },
];
const columns = [
{
key: "targetType",
label: "举报目标",
render: (report) => <TargetTypeLabel report={report} />,
width: "minmax(120px, 0.55fr)",
},
{
key: "target",
label: "举报信息",
render: (report) => <TargetCell report={report} />,
width: "minmax(300px, 1.3fr)",
},
{
key: "reportType",
label: "举报类型",
render: (report) => reportTypeLabel(report.reportType),
width: "minmax(150px, 0.7fr)",
},
{
key: "reason",
label: "举报理由",
render: (report) => <span className={styles.reportReason}>{report.reason || "-"}</span>,
width: "minmax(220px, 1fr)",
},
{
key: "imageUrls",
label: "举报图片",
render: (report) => <EvidenceImages imageUrls={report.imageUrls} />,
width: "minmax(180px, 0.8fr)",
},
{
key: "createdAtMs",
label: "提交时间",
render: (report) => formatMillis(report.createdAtMs),
width: "minmax(170px, 0.75fr)",
},
];
export function ReportListPage() {
const [page, setPage] = useState(1);
const [keyword, setKeyword] = useState("");
const [targetType, setTargetType] = useState("");
const [reportType, setReportType] = useState("");
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
const filters = useMemo(
() => ({
end_at_ms: timeRange.endMs || "",
keyword,
report_type: reportType,
start_at_ms: timeRange.startMs || "",
target_type: targetType,
}),
[keyword, reportType, targetType, timeRange.endMs, timeRange.startMs],
);
const query = usePaginatedQuery({
errorMessage: "获取举报列表失败",
fetcher: listReports,
filters,
page,
pageSize,
queryKey: ["reports", filters, page],
});
const data = query.data || { items: [], total: 0, page, pageSize };
const items = data.items || [];
const total = data.total || 0;
const changeFilter = (setter) => (value) => {
setter(value);
setPage(1);
};
const changeTimeRange = (nextRange) => {
setTimeRange(nextRange);
setPage(1);
};
const resetFilters = () => {
setKeyword("");
setTargetType("");
setReportType("");
setTimeRange({ endMs: "", startMs: "" });
setPage(1);
};
const tableColumns = columns.map((column) => {
if (column.key === "target") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "长 ID / 短 ID / 昵称",
value: keyword,
onChange: changeFilter(setKeyword),
}),
};
}
if (column.key === "targetType") {
return {
...column,
filter: createOptionsColumnFilter({
options: targetTypeOptions,
value: targetType,
onChange: changeFilter(setTargetType),
}),
};
}
if (column.key === "reportType") {
return {
...column,
filter: createOptionsColumnFilter({
options: reportTypeOptions,
value: reportType,
onChange: changeFilter(setReportType),
}),
};
}
return column;
});
return (
<AdminListPage>
<AdminListToolbar
filters={
<>
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
<AdminFilterResetButton
disabled={!keyword && !targetType && !reportType && !timeRange.startMs && !timeRange.endMs}
onClick={resetFilters}
/>
</>
}
/>
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
<AdminListBody>
<DataTable
columns={tableColumns}
items={items}
minWidth="1140px"
pagination={
total > 0
? {
page,
pageSize: data.pageSize || pageSize,
total,
onPageChange: setPage,
}
: undefined
}
rowKey={(report) => report.reportId}
/>
</AdminListBody>
</DataState>
</AdminListPage>
);
}
function TargetTypeLabel({ report }) {
const isRoom = report.targetType === "room";
return (
<span className={styles.reportTargetType}>
{isRoom ? <MeetingRoomOutlined fontSize="small" /> : <PersonOutlineOutlined fontSize="small" />}
{isRoom ? "房间" : "用户"}
</span>
);
}
function TargetCell({ report }) {
const target = report.target || {};
if (report.targetType === "room") {
const room = target.room || {};
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{room.coverUrl ? <img alt="" src={room.coverUrl} /> : <MeetingRoomOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.primaryText}>{room.title || "-"}</span>
<span className={styles.meta}>{targetMeta(room.roomShortId, room.roomId || report.roomId)}</span>
</span>
</div>
);
}
const user = target.user || {};
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.primaryText}>{user.username || "-"}</span>
<span className={styles.meta}>
{targetMeta(user.displayUserId || user.defaultDisplayUserId, user.userId || report.userId)}
</span>
</span>
</div>
);
}
function EvidenceImages({ imageUrls = [] }) {
const urls = Array.isArray(imageUrls) ? imageUrls.filter(Boolean) : [];
if (!urls.length) {
return "-";
}
return (
<div className={styles.reportImages}>
{urls.slice(0, 4).map((url) => (
<a className={styles.reportImage} href={url} key={url} rel="noreferrer" target="_blank">
<img alt="" src={url} />
</a>
))}
{urls.length > 4 ? <span className={styles.reportImageCount}>+{urls.length - 4}</span> : null}
</div>
);
}
function targetMeta(shortId, longId) {
return `短ID ${shortId || "-"} · 长ID ${longId || "-"}`;
}
function reportTypeLabel(value) {
return reportTypeOptions.find((item) => item.value === value)?.label || value || "-";
}

View File

@ -17,4 +17,12 @@ export const operationsRoutes = [
path: "/operations/coin-adjustments",
permission: PERMISSIONS.coinAdjustmentView,
},
{
label: "举报列表",
loader: () => import("./pages/ReportListPage.jsx").then((module) => module.ReportListPage),
menuCode: MENU_CODES.operationReports,
pageKey: "operation-reports",
path: "/operations/reports",
permission: PERMISSIONS.reportView,
},
];

View File

@ -10,6 +10,7 @@ export interface RedPacketConfigDto {
delayedOpenSeconds: number;
expireSeconds: number;
dailySendLimit: number;
ruleUrl?: string;
updatedByAdminId?: number;
createdAtMs?: number;
updatedAtMs?: number;
@ -20,8 +21,9 @@ export interface RedPacketConfigPayload {
count_tiers: number[];
amount_tiers: number[];
delayed_open_seconds: number;
expire_seconds: number;
expire_seconds?: number;
daily_send_limit: number;
rule_url?: string;
}
export interface RedPacketClaimDto {
@ -54,15 +56,20 @@ export interface RedPacketDto {
expiresAtMs: number;
createdAtMs: number;
updatedAtMs?: number;
refundAmount?: number;
refundStatus?: string;
refundedAtMs?: number;
claims?: RedPacketClaimDto[];
}
export interface RedPacketQuery extends PageQuery {
room_id?: string;
sender_user_id?: string;
roomId?: string;
senderUserId?: string;
region_id?: string;
packet_type?: string;
packetMode?: string;
status?: string;
startTime?: string;
endTime?: string;
}
type RawConfig = RedPacketConfigDto & Record<string, unknown>;
@ -102,6 +109,14 @@ export function getRedPacket(packetId: string): Promise<RedPacketDto> {
}).then(normalizePacket);
}
export function retryRedPacketRefund(packetId: string): Promise<unknown> {
const endpoint = API_ENDPOINTS.retryRedPacketRefund;
return apiRequest(apiEndpointPath(API_OPERATIONS.retryRedPacketRefund), {
body: { packetNo: packetId },
method: endpoint.method,
});
}
function normalizeConfig(item: RawConfig): RedPacketConfigDto {
return {
appCode: stringValue(item.appCode ?? item.app_code),
@ -111,6 +126,7 @@ function normalizeConfig(item: RawConfig): RedPacketConfigDto {
delayedOpenSeconds: numberValue(item.delayedOpenSeconds ?? item.delayed_open_seconds),
expireSeconds: numberValue(item.expireSeconds ?? item.expire_seconds),
dailySendLimit: numberValue(item.dailySendLimit ?? item.daily_send_limit),
ruleUrl: stringValue(item.ruleUrl ?? item.rule_url),
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
@ -136,6 +152,9 @@ function normalizePacket(item: RawPacket): RedPacketDto {
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
refundAmount: numberValue(item.refundAmount ?? item.refund_amount),
refundStatus: stringValue(item.refundStatus ?? item.refund_status),
refundedAtMs: numberValue(item.refundedAtMs ?? item.refunded_at_ms),
claims: rawClaims.map(normalizeClaim),
};
}

View File

@ -61,30 +61,38 @@ export function RedPacketConfigDrawer({
<div className="form-drawer__grid">
<TextField
disabled={disabled}
inputProps={{ min: 0 }}
label="延迟红包秒数"
slotProps={{ htmlInput: { min: 0 } }}
type="number"
value={form.delayedOpenSeconds}
onChange={(event) => updateValue("delayedOpenSeconds", event.target.value)}
/>
<TextField
disabled={disabled}
inputProps={{ min: 1 }}
label="过期退款秒数"
disabled
label="过期退款秒数(固定)"
slotProps={{ htmlInput: { min: 1 } }}
type="number"
value={form.expireSeconds}
onChange={(event) => updateValue("expireSeconds", event.target.value)}
/>
<TextField
disabled={disabled}
inputProps={{ min: 1 }}
label="每用户每日最多发送"
slotProps={{ htmlInput: { min: 1 } }}
type="number"
value={form.dailySendLimit}
onChange={(event) => updateValue("dailySendLimit", event.target.value)}
/>
</div>
</section>
<section className="form-drawer__section">
<TextField
fullWidth
disabled={disabled}
label="规则说明 URL"
value={form.ruleUrl}
onChange={(event) => updateValue("ruleUrl", event.target.value)}
/>
</section>
<TierSection
disabled={disabled}
label="红包数量档位"
@ -140,8 +148,8 @@ function TierSection({ disabled, label, onAdd, onRemove, onUpdate, tierKey, valu
<div className={styles.tierGrid} key={`${tierKey}-${index}`}>
<TextField
disabled={disabled}
inputProps={{ min: 1 }}
label={`${label} ${index + 1}`}
slotProps={{ htmlInput: { min: 1 } }}
type="number"
value={value}
onChange={(event) => onUpdate(tierKey, index, event.target.value)}

View File

@ -20,9 +20,10 @@ export function RedPacketConfigSummary({ canUpdate, config, configLoading, onEdi
<SummaryItem label="数量档位">{formatTiers(config?.countTiers)}</SummaryItem>
<SummaryItem label="金额档位">{formatTiers(config?.amountTiers)}</SummaryItem>
<SummaryItem label="延迟/过期">
{Number(config?.delayedOpenSeconds || 0)}s / {Number(config?.expireSeconds || 0)}s
{Number(config?.delayedOpenSeconds || 0)}s / 24h
</SummaryItem>
<SummaryItem label="每日上限">{Number(config?.dailySendLimit || 0)} </SummaryItem>
<SummaryItem label="规则 URL">{config?.ruleUrl || "-"}</SummaryItem>
</div>
<div className={styles.summaryActions}>
<Button disabled={configLoading} startIcon={<RefreshOutlined fontSize="small" />} onClick={onRefresh}>

View File

@ -53,6 +53,20 @@ export function RedPacketDetailDrawer({ loading, onClose, open, packet }) {
<InfoRow label="状态" value={packetStatusLabel(packet.status)} />
<InfoRow label="金额" value={`${formatNumber(packet.totalAmount)} 金币`} />
<InfoRow label="剩余" value={`${formatNumber(packet.remainingAmount)} / ${packet.remainingCount}`} />
<InfoRow
label="已领"
value={`${formatNumber(packet.totalAmount - packet.remainingAmount)} / ${
packet.packetCount - packet.remainingCount
} `}
/>
<InfoRow
label="退款"
value={
packet.refundStatus
? `${packetStatusLabel(packet.refundStatus)} ${formatNumber(packet.refundAmount)} 金币`
: "-"
}
/>
<InfoRow label="开启时间" value={formatMillis(packet.openAtMs)} />
<InfoRow label="过期时间" value={formatMillis(packet.expiresAtMs)} />
<DataTable
@ -81,7 +95,7 @@ function InfoRow({ label, value }) {
}
function packetTypeLabel(type) {
return type === "delayed" ? "延迟红包" : "普通红包";
return String(type || "").toLowerCase() === "delayed" ? "延时红包" : "即时红包";
}
function packetStatusLabel(status) {
@ -91,7 +105,7 @@ function packetStatusLabel(status) {
refunded: "已退款",
waiting_open: "待开启",
};
return labels[status] || status || "-";
return labels[String(status || "").toLowerCase()] || status || "-";
}
function claimStatusLabel(status) {

View File

@ -3,6 +3,7 @@ import {
getRedPacket,
getRedPacketConfig,
listRedPackets,
retryRedPacketRefund,
updateRedPacketConfig,
} from "@/features/red-packets/api";
import { useRedPacketAbilities } from "@/features/red-packets/permissions.js";
@ -17,8 +18,9 @@ const emptyForm = {
countTiers: [""],
amountTiers: [""],
delayedOpenSeconds: "0",
expireSeconds: "300",
expireSeconds: "86400",
dailySendLimit: "5",
ruleUrl: "",
};
export function useRedPacketPage() {
@ -40,9 +42,9 @@ export function useRedPacketPage() {
const filters = useMemo(
() => ({
room_id: roomId.trim() || undefined,
sender_user_id: senderUserId.trim() || undefined,
packet_type: packetType || undefined,
roomId: roomId.trim() || undefined,
senderUserId: senderUserId.trim() || undefined,
packetMode: packetType || undefined,
status: status || undefined,
}),
[packetType, roomId, senderUserId, status],
@ -124,6 +126,22 @@ export function useRedPacketPage() {
}
};
const retryRefund = async (packetId) => {
if (!packetId || !abilities.canUpdate) {
return;
}
try {
await retryRedPacketRefund(packetId);
showToast("退款重试已提交", "success");
await reloadPackets();
if (selectedPacket?.packetId === packetId) {
await openPacketDetail(packetId);
}
} catch (err) {
showToast(err.message || "退款重试失败", "error");
}
};
const closePacketDetail = () => {
setDetailOpen(false);
setSelectedPacket(null);
@ -163,6 +181,7 @@ export function useRedPacketPage() {
reloadConfig,
reloadPackets,
resetFilters,
retryRefund,
roomId,
selectedPacket,
senderUserId,
@ -183,8 +202,9 @@ function formFromConfig(config) {
countTiers: valuesToForm(config.countTiers),
amountTiers: valuesToForm(config.amountTiers),
delayedOpenSeconds: String(config.delayedOpenSeconds || 0),
expireSeconds: String(config.expireSeconds || 300),
expireSeconds: "86400",
dailySendLimit: String(config.dailySendLimit || 0),
ruleUrl: config.ruleUrl || "",
};
}
@ -196,7 +216,7 @@ function payloadFromForm(form) {
const countTiers = normalizePositiveIntegers(form.countTiers);
const amountTiers = normalizePositiveIntegers(form.amountTiers);
const delayedOpenSeconds = Number(form.delayedOpenSeconds || 0);
const expireSeconds = Number(form.expireSeconds || 0);
const expireSeconds = 86400;
const dailySendLimit = Number(form.dailySendLimit || 0);
if (!countTiers.length) {
throw new Error("红包数量档位不能为空");
@ -220,6 +240,7 @@ function payloadFromForm(form) {
delayed_open_seconds: delayedOpenSeconds,
expire_seconds: expireSeconds,
daily_send_limit: dailySendLimit,
rule_url: String(form.ruleUrl || "").trim(),
};
}

View File

@ -1,4 +1,5 @@
import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined";
import ReplayOutlined from "@mui/icons-material/ReplayOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { RedPacketConfigDrawer } from "@/features/red-packets/components/RedPacketConfigDrawer.jsx";
@ -20,8 +21,8 @@ import { formatMillis } from "@/shared/utils/time.js";
const packetTypeOptions = [
["", "全部类型"],
["normal", "普通红包"],
["delayed", "延迟红包"],
["IMMEDIATE", "即时红包"],
["DELAYED", "延时红包"],
];
const statusOptions = [
@ -83,6 +84,17 @@ export function RedPacketPage() {
</div>
),
},
{
key: "claimed",
label: "已领",
width: "minmax(145px, 0.6fr)",
render: (packet) => (
<div className={styles.stack}>
<span>{formatNumber(packet.totalAmount - packet.remainingAmount)} 金币</span>
<span className={styles.meta}>{packet.packetCount - packet.remainingCount} </span>
</div>
),
},
{
key: "status",
label: "状态",
@ -101,12 +113,26 @@ export function RedPacketPage() {
width: "minmax(180px, 0.75fr)",
render: (packet) => formatMillis(packet.expiresAtMs),
},
{
key: "refund",
label: "退款",
width: "minmax(120px, 0.55fr)",
render: (packet) =>
packet.refundStatus
? `${packetStatusLabel(packet.refundStatus)} ${formatNumber(packet.refundAmount)}`
: "-",
},
{
key: "actions",
label: "操作",
width: "88px",
width: "128px",
render: (packet) => (
<AdminRowActions>
{page.abilities.canUpdate && canRetryRefund(packet) ? (
<AdminActionIconButton label="重试退款" onClick={() => page.retryRefund(packet.packetId)}>
<ReplayOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
<AdminActionIconButton label="查看详情" onClick={() => page.openPacketDetail(packet.packetId)}>
<VisibilityOutlined fontSize="small" />
</AdminActionIconButton>
@ -210,13 +236,20 @@ export function RedPacketPage() {
}
function packetTypeLabel(type) {
return type === "delayed" ? "延迟红包" : "普通红包";
const normalized = String(type || "").toLowerCase();
return normalized === "delayed" ? "延时红包" : "即时红包";
}
function packetStatusLabel(status) {
return statusOptions.find(([value]) => value === status)?.[1] || status || "-";
const normalized = String(status || "").toLowerCase();
return statusOptions.find(([value]) => value === normalized)?.[1] || status || "-";
}
function formatNumber(value) {
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}
function canRetryRefund(packet) {
const status = String(packet.status || "").toLowerCase();
return status === "active" || status === "waiting_open";
}

View File

@ -40,6 +40,7 @@ export const API_OPERATIONS = {
createExploreTab: "createExploreTab",
createGift: "createGift",
createH5Link: "createH5Link",
createHostAgencyPolicy: "createHostAgencyPolicy",
createMenu: "createMenu",
createPermission: "createPermission",
createPlatform: "createPlatform",
@ -50,6 +51,7 @@ export const API_OPERATIONS = {
createRole: "createRole",
createRoomPin: "createRoomPin",
createTaskDefinition: "createTaskDefinition",
createTeamSalaryPolicy: "createTeamSalaryPolicy",
createTier: "createTier",
createUser: "createUser",
creditCoinSellerStock: "creditCoinSellerStock",
@ -61,11 +63,13 @@ export const API_OPERATIONS = {
deleteCountry: "deleteCountry",
deleteExploreTab: "deleteExploreTab",
deleteH5Link: "deleteH5Link",
deleteHostAgencyPolicy: "deleteHostAgencyPolicy",
deleteMenu: "deleteMenu",
deletePermission: "deletePermission",
deleteRechargeProduct: "deleteRechargeProduct",
deleteRole: "deleteRole",
deleteRoom: "deleteRoom",
deleteTeamSalaryPolicy: "deleteTeamSalaryPolicy",
disableCountry: "disableCountry",
disableGift: "disableGift",
disableRegion: "disableRegion",
@ -117,7 +121,9 @@ export const API_OPERATIONS = {
listGifts: "listGifts",
listGiftTypes: "listGiftTypes",
listH5Links: "listH5Links",
listHostAgencyPolicies: "listHostAgencyPolicies",
listHosts: "listHosts",
listHostSalarySettlements: "listHostSalarySettlements",
listLevelConfig: "listLevelConfig",
listLoginLogs: "listLoginLogs",
listLuckyGiftConfigs: "listLuckyGiftConfigs",
@ -131,6 +137,7 @@ export const API_OPERATIONS = {
listRegionBlocks: "listRegionBlocks",
listRegions: "listRegions",
listRegistrationRewardClaims: "listRegistrationRewardClaims",
listReports: "listReports",
listResourceGrants: "listResourceGrants",
listResourceGroups: "listResourceGroups",
listResources: "listResources",
@ -141,6 +148,7 @@ export const API_OPERATIONS = {
listSevenDayCheckInClaims: "listSevenDayCheckInClaims",
listSystemMenus: "listSystemMenus",
listTaskDefinitions: "listTaskDefinitions",
listTeamSalaryPolicies: "listTeamSalaryPolicies",
listUserLeaderboards: "listUserLeaderboards",
listUsers: "listUsers",
login: "login",
@ -148,12 +156,14 @@ export const API_OPERATIONS = {
lookupCoinAdjustmentTarget: "lookupCoinAdjustmentTarget",
me: "me",
navigationMenus: "navigationMenus",
publishHostAgencyPolicy: "publishHostAgencyPolicy",
refresh: "refresh",
replaceRegionBlocks: "replaceRegionBlocks",
replaceRegionCountries: "replaceRegionCountries",
replaceRoleDataScopes: "replaceRoleDataScopes",
replaceRolePermissions: "replaceRolePermissions",
resetUserPassword: "resetUserPassword",
retryRedPacketRefund: "retryRedPacketRefund",
search: "search",
setAgencyJoinEnabled: "setAgencyJoinEnabled",
setBDLeaderStatus: "setBDLeaderStatus",
@ -175,6 +185,7 @@ export const API_OPERATIONS = {
updateGiftTypes: "updateGiftTypes",
updateH5Link: "updateH5Link",
updateH5Links: "updateH5Links",
updateHostAgencyPolicy: "updateHostAgencyPolicy",
updateMenu: "updateMenu",
updateMenuVisible: "updateMenuVisible",
updatePermission: "updatePermission",
@ -192,6 +203,7 @@ export const API_OPERATIONS = {
updateRoomTreasureConfig: "updateRoomTreasureConfig",
updateSevenDayCheckInConfig: "updateSevenDayCheckInConfig",
updateTaskDefinition: "updateTaskDefinition",
updateTeamSalaryPolicy: "updateTeamSalaryPolicy",
updateTier: "updateTier",
updateTrack: "updateTrack",
updateUser: "updateUser",
@ -393,6 +405,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "app-config:update",
permissions: ["app-config:update"]
},
createHostAgencyPolicy: {
method: "POST",
operationId: API_OPERATIONS.createHostAgencyPolicy,
path: "/v1/admin/host-agency-policies",
permission: "host-agency-policy:create",
permissions: ["host-agency-policy:create"]
},
createMenu: {
method: "POST",
operationId: API_OPERATIONS.createMenu,
@ -463,6 +482,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "daily-task:create",
permissions: ["daily-task:create"]
},
createTeamSalaryPolicy: {
method: "POST",
operationId: API_OPERATIONS.createTeamSalaryPolicy,
path: "/v1/admin/team-salary-policies",
permission: "team-salary-policy:create",
permissions: ["team-salary-policy:create"]
},
createTier: {
method: "POST",
operationId: API_OPERATIONS.createTier,
@ -540,6 +566,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "app-config:update",
permissions: ["app-config:update"]
},
deleteHostAgencyPolicy: {
method: "DELETE",
operationId: API_OPERATIONS.deleteHostAgencyPolicy,
path: "/v1/admin/host-agency-policies/{policy_id}",
permission: "host-agency-policy:delete",
permissions: ["host-agency-policy:delete"]
},
deleteMenu: {
method: "DELETE",
operationId: API_OPERATIONS.deleteMenu,
@ -575,6 +608,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "room:delete",
permissions: ["room:delete"]
},
deleteTeamSalaryPolicy: {
method: "DELETE",
operationId: API_OPERATIONS.deleteTeamSalaryPolicy,
path: "/v1/admin/team-salary-policies/{policy_id}",
permission: "team-salary-policy:delete",
permissions: ["team-salary-policy:delete"]
},
disableCountry: {
method: "POST",
operationId: API_OPERATIONS.disableCountry,
@ -711,14 +751,14 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
getRedPacket: {
method: "GET",
operationId: API_OPERATIONS.getRedPacket,
path: "/v1/admin/activity/red-packets/{packet_id}",
path: "/v1/resident-activity/voice-room-red-packet/records/{packet_id}",
permission: "red-packet:view",
permissions: ["red-packet:view"]
},
getRedPacketConfig: {
method: "GET",
operationId: API_OPERATIONS.getRedPacketConfig,
path: "/v1/admin/activity/red-packets/config",
path: "/v1/resident-activity/voice-room-red-packet/config",
permission: "red-packet:view",
permissions: ["red-packet:view"]
},
@ -930,6 +970,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "app-config:view",
permissions: ["app-config:view"]
},
listHostAgencyPolicies: {
method: "GET",
operationId: API_OPERATIONS.listHostAgencyPolicies,
path: "/v1/admin/host-agency-policies",
permission: "host-agency-policy:view",
permissions: ["host-agency-policy:view"]
},
listHosts: {
method: "GET",
operationId: API_OPERATIONS.listHosts,
@ -937,6 +984,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "host:view",
permissions: ["host:view"]
},
listHostSalarySettlements: {
method: "GET",
operationId: API_OPERATIONS.listHostSalarySettlements,
path: "/v1/admin/host-salary-settlements",
permission: "host-salary-settlement:view",
permissions: ["host-salary-settlement:view"]
},
listLevelConfig: {
method: "GET",
operationId: API_OPERATIONS.listLevelConfig,
@ -1003,7 +1057,7 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
listRedPackets: {
method: "GET",
operationId: API_OPERATIONS.listRedPackets,
path: "/v1/admin/activity/red-packets",
path: "/v1/resident-activity/voice-room-red-packet/records",
permission: "red-packet:view",
permissions: ["red-packet:view"]
},
@ -1028,6 +1082,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "registration-reward:view",
permissions: ["registration-reward:view"]
},
listReports: {
method: "GET",
operationId: API_OPERATIONS.listReports,
path: "/v1/admin/operations/reports",
permission: "report:view",
permissions: ["report:view"]
},
listResourceGrants: {
method: "GET",
operationId: API_OPERATIONS.listResourceGrants,
@ -1098,6 +1159,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "daily-task:view",
permissions: ["daily-task:view"]
},
listTeamSalaryPolicies: {
method: "GET",
operationId: API_OPERATIONS.listTeamSalaryPolicies,
path: "/v1/admin/team-salary-policies",
permission: "team-salary-policy:view",
permissions: ["team-salary-policy:view"]
},
listUserLeaderboards: {
method: "GET",
operationId: API_OPERATIONS.listUserLeaderboards,
@ -1139,6 +1207,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
operationId: API_OPERATIONS.navigationMenus,
path: "/v1/navigation/menus"
},
publishHostAgencyPolicy: {
method: "POST",
operationId: API_OPERATIONS.publishHostAgencyPolicy,
path: "/v1/admin/host-agency-policies/{policy_id}/publish",
permission: "host-agency-policy:publish",
permissions: ["host-agency-policy:publish"]
},
refresh: {
method: "POST",
operationId: API_OPERATIONS.refresh,
@ -1179,6 +1254,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "user:reset-password",
permissions: ["user:reset-password"]
},
retryRedPacketRefund: {
method: "POST",
operationId: API_OPERATIONS.retryRedPacketRefund,
path: "/v1/resident-activity/voice-room-red-packet/refund/retry",
permission: "red-packet:update",
permissions: ["red-packet:update"]
},
search: {
method: "GET",
operationId: API_OPERATIONS.search,
@ -1324,6 +1406,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "app-config:update",
permissions: ["app-config:update"]
},
updateHostAgencyPolicy: {
method: "PUT",
operationId: API_OPERATIONS.updateHostAgencyPolicy,
path: "/v1/admin/host-agency-policies/{policy_id}",
permission: "host-agency-policy:update",
permissions: ["host-agency-policy:update"]
},
updateMenu: {
method: "PATCH",
operationId: API_OPERATIONS.updateMenu,
@ -1360,9 +1449,9 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permissions: ["payment-product:update"]
},
updateRedPacketConfig: {
method: "PUT",
method: "POST",
operationId: API_OPERATIONS.updateRedPacketConfig,
path: "/v1/admin/activity/red-packets/config",
path: "/v1/resident-activity/voice-room-red-packet/config",
permission: "red-packet:update",
permissions: ["red-packet:update"]
},
@ -1443,6 +1532,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "daily-task:update",
permissions: ["daily-task:update"]
},
updateTeamSalaryPolicy: {
method: "PUT",
operationId: API_OPERATIONS.updateTeamSalaryPolicy,
path: "/v1/admin/team-salary-policies/{policy_id}",
permission: "team-salary-policy:update",
permissions: ["team-salary-policy:update"]
},
updateTier: {
method: "PUT",
operationId: API_OPERATIONS.updateTier,

View File

@ -100,7 +100,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/activity/red-packets": {
"/resident-activity/voice-room-red-packet/records": {
parameters: {
query?: never;
header?: never;
@ -116,7 +116,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/activity/red-packets/{packet_id}": {
"/resident-activity/voice-room-red-packet/records/{packet_id}": {
parameters: {
query?: never;
header?: never;
@ -132,7 +132,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/activity/red-packets/config": {
"/resident-activity/voice-room-red-packet/config": {
parameters: {
query?: never;
header?: never;
@ -140,8 +140,24 @@ export interface paths {
cookie?: never;
};
get: operations["getRedPacketConfig"];
put: operations["updateRedPacketConfig"];
post?: never;
put?: never;
post: operations["updateRedPacketConfig"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/resident-activity/voice-room-red-packet/refund/retry": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["retryRedPacketRefund"];
delete?: never;
options?: never;
head?: never;
@ -964,6 +980,102 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/host-agency-policies": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listHostAgencyPolicies"];
put?: never;
post: operations["createHostAgencyPolicy"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/host-agency-policies/{policy_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["updateHostAgencyPolicy"];
post?: never;
delete: operations["deleteHostAgencyPolicy"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/host-agency-policies/{policy_id}/publish": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["publishHostAgencyPolicy"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/host-salary-settlements": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listHostSalarySettlements"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/team-salary-policies": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listTeamSalaryPolicies"];
put?: never;
post: operations["createTeamSalaryPolicy"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/team-salary-policies/{policy_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["updateTeamSalaryPolicy"];
post?: never;
delete: operations["deleteTeamSalaryPolicy"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/operations/coin-adjustments": {
parameters: {
query?: never;
@ -1012,6 +1124,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/operations/reports": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listReports"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/payment/recharge-bills": {
parameters: {
query?: never;
@ -2718,6 +2846,18 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
retryRedPacketRefund: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
getLuckyGiftConfig: {
parameters: {
query?: never;
@ -3737,6 +3877,136 @@ export interface operations {
200: components["responses"]["HostProfilePageResponse"];
};
};
listHostAgencyPolicies: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
createHostAgencyPolicy: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
updateHostAgencyPolicy: {
parameters: {
query?: never;
header?: never;
path: {
policy_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
deleteHostAgencyPolicy: {
parameters: {
query?: never;
header?: never;
path: {
policy_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
publishHostAgencyPolicy: {
parameters: {
query?: never;
header?: never;
path: {
policy_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listHostSalarySettlements: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listTeamSalaryPolicies: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
createTeamSalaryPolicy: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
updateTeamSalaryPolicy: {
parameters: {
query?: never;
header?: never;
path: {
policy_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
deleteTeamSalaryPolicy: {
parameters: {
query?: never;
header?: never;
path: {
policy_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listCoinAdjustments: {
parameters: {
query?: never;
@ -3785,6 +4055,18 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
listReports: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listRechargeBills: {
parameters: {
query?: never;

View File

@ -245,6 +245,43 @@ export interface CoinAdjustmentCreateDto {
user: CoinLedgerUserDto;
}
export interface ReportUserDto {
avatar?: string;
defaultDisplayUserId?: string;
displayUserId?: string;
userId?: string;
username?: string;
}
export interface ReportRoomDto {
coverUrl?: string;
roomId?: string;
roomShortId?: string;
title?: string;
}
export interface ReportTargetDto {
room?: ReportRoomDto;
type: "user" | "room" | string;
user?: ReportUserDto;
}
export interface ReportDto {
createdAtMs?: number;
imageUrls?: string[];
reason?: string;
reportId: string;
reporterUserId?: string;
reportType?: string;
requestId?: string;
roomId?: string;
status?: string;
target: ReportTargetDto;
targetType: "user" | "room" | string;
updatedAtMs?: number;
userId?: string;
}
export interface RechargeBillDto {
appCode?: string;
coinAmount: number;

View File

@ -29,3 +29,11 @@
.MuiTooltip-popper :is(h1, h2, h3, h4, h5, h6, p, span, small, strong, em, label, button, input, textarea, select, option, a, li, dt, dd, th, td, div, pre, code) {
font-size: var(--admin-font-size);
}
:is(#app, .MuiAutocomplete-popper, .MuiDialog-root, .MuiMenu-root, .MuiModal-root, .MuiPopover-root, .MuiPopper-root, .MuiSnackbar-root, .MuiTooltip-popper)
:is(button, input, textarea, select, option, .MuiAutocomplete-option, .MuiInputBase-root, .MuiInputBase-input, .MuiInputLabel-root, .MuiFormLabel-root, .MuiSelect-select, .MuiButton-root, .MuiMenuItem-root),
:is(#app, .MuiAutocomplete-popper, .MuiDialog-root, .MuiMenu-root, .MuiModal-root, .MuiPopover-root, .MuiPopper-root, .MuiSnackbar-root, .MuiTooltip-popper)
button
:is(span, small, strong, em) {
font-size: var(--control-font-size);
}

View File

@ -31,14 +31,14 @@
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: 0 var(--space-3);
padding: 0 var(--control-padding-x);
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-card);
color: var(--text-secondary);
cursor: pointer;
font-size: var(--admin-font-size);
line-height: var(--admin-line-height);
font-size: var(--control-font-size);
line-height: var(--control-line-height);
white-space: nowrap;
transition:
background var(--motion-fast) var(--ease-standard),
@ -84,10 +84,10 @@
.button.MuiButton-root {
height: var(--control-height);
min-height: var(--control-height);
padding: 0 var(--space-3);
padding: 0 var(--control-padding-x);
border-radius: var(--radius-control);
font-size: var(--admin-font-size);
line-height: var(--admin-line-height);
font-size: var(--control-font-size);
line-height: var(--control-line-height);
text-transform: none;
}
@ -106,8 +106,8 @@
.MuiTextField-root,
.MuiFormControl-root {
--admin-control-height: var(--control-height);
--admin-control-font-size: var(--admin-font-size);
--admin-control-line-height: var(--admin-line-height);
--admin-control-font-size: var(--control-font-size);
--admin-control-line-height: var(--control-line-height);
}
.region-select.MuiTextField-root,
@ -146,13 +146,13 @@
height: var(--control-height);
padding-top: 0;
padding-bottom: 0;
font-size: var(--admin-font-size);
line-height: var(--admin-line-height);
font-size: var(--control-font-size);
line-height: var(--control-line-height);
}
.MuiInputBase-root .MuiInputBase-input::placeholder {
font-size: var(--admin-font-size);
line-height: var(--admin-line-height);
font-size: var(--control-font-size);
line-height: var(--control-line-height);
}
.MuiInputBase-input:focus {
@ -200,7 +200,7 @@
.MuiFormControl-root .MuiOutlinedInput-input {
box-sizing: border-box;
height: var(--admin-control-height);
padding: 0 var(--space-3);
padding: 0 var(--control-padding-x);
color: var(--text-primary);
font-size: var(--admin-control-font-size);
line-height: var(--admin-control-line-height);
@ -219,12 +219,12 @@
color: var(--text-tertiary);
font-size: var(--admin-control-font-size);
line-height: var(--admin-control-line-height);
transform: translate(14px, 8px) scale(1);
transform: translate(12px, 6px) scale(1);
}
.MuiTextField-root .MuiInputLabel-outlined.MuiInputLabel-shrink,
.MuiFormControl-root .MuiInputLabel-outlined.MuiInputLabel-shrink {
transform: translate(14px, -8px) scale(0.75);
transform: translate(12px, -7px) scale(0.75);
}
.MuiTextField-root .MuiFormLabel-root.Mui-focused,
@ -239,7 +239,7 @@
height: var(--admin-control-height);
min-height: var(--admin-control-height) !important;
align-items: center;
padding: 0 var(--space-8) 0 var(--space-3) !important;
padding: 0 var(--space-8) 0 var(--control-padding-x) !important;
font-size: var(--admin-control-font-size);
line-height: var(--admin-control-line-height);
}
@ -252,7 +252,7 @@
}
.MuiAutocomplete-root {
--admin-autocomplete-input-height: 24px;
--admin-autocomplete-input-height: 22px;
}
.MuiAutocomplete-root .MuiOutlinedInput-root.MuiAutocomplete-inputRoot:not(.MuiInputBase-multiline) {
@ -261,7 +261,7 @@
align-items: center;
flex-wrap: wrap;
gap: var(--space-1);
padding: 4px 40px 4px var(--space-2);
padding: 3px 36px 3px var(--space-2);
}
.MuiAutocomplete-root .MuiOutlinedInput-root.MuiAutocomplete-inputRoot .MuiAutocomplete-input {
@ -855,11 +855,14 @@
}
.side-drawer {
box-sizing: border-box;
display: flex;
width: min(420px, 100vw);
min-height: 100%;
height: 100vh;
max-height: 100vh;
flex-direction: column;
gap: var(--space-4);
overflow: hidden;
padding: var(--space-6);
background: var(--bg-card);
}
@ -870,6 +873,7 @@
.side-drawer__header {
display: flex;
flex: 0 0 auto;
min-height: var(--control-height);
align-items: center;
justify-content: space-between;
@ -885,15 +889,21 @@
.side-drawer__body {
display: grid;
flex: 1 1 auto;
min-height: 0;
gap: var(--space-4);
overflow: auto;
padding-right: 2px;
}
.side-drawer__actions {
display: flex;
flex: 0 0 auto;
justify-content: flex-end;
gap: var(--space-3);
margin-top: auto;
padding-top: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--border);
background: var(--bg-card);
}
.form-section-title {

View File

@ -1,7 +1,10 @@
:root {
--admin-font-size: 14px;
--admin-line-height: 20px;
--control-height: 36px;
--control-font-size: 13px;
--control-line-height: 18px;
--control-height: 32px;
--control-padding-x: 10px;
--status-height: 24px;
--admin-tag-height: var(--status-height);
--admin-tag-radius: var(--radius-pill);

View File

@ -47,15 +47,17 @@ export const theme = createTheme({
components: {
MuiButton: {
defaultProps: {
disableRipple: false
disableRipple: false,
size: "small"
},
styleOverrides: {
root: {
minWidth: 0,
height: token("control-height"),
borderRadius: token("radius-control"),
padding: `0 ${token("space-3")}`,
fontSize: 14,
padding: `0 ${token("control-padding-x")}`,
fontSize: token("control-font-size"),
lineHeight: token("control-line-height"),
transition:
"background-color 150ms cubic-bezier(0.2, 0, 0, 1), border-color 150ms cubic-bezier(0.2, 0, 0, 1), box-shadow 150ms cubic-bezier(0.2, 0, 0, 1), transform 220ms cubic-bezier(0.16, 1, 0.3, 1)"
},
@ -114,6 +116,9 @@ export const theme = createTheme({
}
},
MuiIconButton: {
defaultProps: {
size: "small"
},
styleOverrides: {
root: {
borderRadius: token("radius-control"),
@ -126,10 +131,11 @@ export const theme = createTheme({
MuiInputBase: {
styleOverrides: {
root: {
fontSize: 14
fontSize: token("control-font-size")
},
input: {
fontSize: 14,
fontSize: token("control-font-size"),
lineHeight: token("control-line-height"),
"&[type='number']": {
MozAppearance: "textfield"
},
@ -158,7 +164,7 @@ export const theme = createTheme({
styleOverrides: {
root: {
color: token("text-secondary"),
fontSize: 14
fontSize: token("control-font-size")
},
icon: {
color: token("text-secondary")
@ -243,7 +249,8 @@ export const theme = createTheme({
MuiFormLabel: {
styleOverrides: {
root: {
fontSize: 14
fontSize: token("control-font-size"),
lineHeight: token("control-line-height")
},
asterisk: {
color: token("danger"),
@ -254,16 +261,26 @@ export const theme = createTheme({
MuiFormHelperText: {
styleOverrides: {
root: {
fontSize: 14
fontSize: token("control-font-size")
}
}
},
MuiMenuItem: {
styleOverrides: {
root: {
fontSize: 14
fontSize: token("control-font-size")
}
}
},
MuiTextField: {
defaultProps: {
size: "small"
}
},
MuiFormControl: {
defaultProps: {
size: "small"
}
}
}
});