政策相关
This commit is contained in:
parent
b96fdb4f6a
commit
49f54e0463
@ -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。
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 }),
|
||||
],
|
||||
},
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
560
src/features/host-agency-policy/api.ts
Normal file
560
src/features/host-agency-policy/api.ts
Normal 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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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工资";
|
||||
}
|
||||
362
src/features/host-agency-policy/hooks/useHostAgencyPolicyPage.js
Normal file
362
src/features/host-agency-policy/hooks/useHostAgencyPolicyPage.js
Normal 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;
|
||||
}
|
||||
321
src/features/host-agency-policy/hooks/useTeamSalaryPolicyPage.js
Normal file
321
src/features/host-agency-policy/hooks/useTeamSalaryPolicyPage.js
Normal 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;
|
||||
}
|
||||
296
src/features/host-agency-policy/host-agency-policy.module.css
Normal file
296
src/features/host-agency-policy/host-agency-policy.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
360
src/features/host-agency-policy/pages/HostAgencyPolicyPage.jsx
Normal file
360
src/features/host-agency-policy/pages/HostAgencyPolicyPage.jsx
Normal 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));
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
// 默认值也纳入 hasFilters,这样运营能一键回到“BD + 自动政策 + 上月周期”的标准入口。
|
||||
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));
|
||||
}
|
||||
271
src/features/host-agency-policy/pages/TeamSalaryPolicyPanel.jsx
Normal file
271
src/features/host-agency-policy/pages/TeamSalaryPolicyPanel.jsx
Normal 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 || "-";
|
||||
}
|
||||
20
src/features/host-agency-policy/permissions.js
Normal file
20
src/features/host-agency-policy/permissions.js
Normal 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),
|
||||
};
|
||||
}
|
||||
20
src/features/host-agency-policy/routes.js
Normal file
20
src/features/host-agency-policy/routes.js
Normal 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,
|
||||
},
|
||||
];
|
||||
230
src/features/host-agency-policy/schema.ts
Normal file
230
src/features/host-agency-policy/schema.ts
Normal 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;
|
||||
}
|
||||
@ -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), {
|
||||
|
||||
@ -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;
|
||||
|
||||
260
src/features/operations/pages/ReportListPage.jsx
Normal file
260
src/features/operations/pages/ReportListPage.jsx
Normal 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 || "-";
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
292
src/shared/api/generated/schema.d.ts
vendored
292
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
35
src/theme.js
35
src/theme.js
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user