diff --git a/AGENTS.md b/AGENTS.md index 8abc3c1..009b5df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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。 diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index b2071e6..951d8ff 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -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", diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js index 7600599..3f16d28 100644 --- a/src/app/navigation/menu.js +++ b/src/app/navigation/menu.js @@ -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 }), ], }, diff --git a/src/app/permissions.ts b/src/app/permissions.ts index 6c5ab18..a39d24c 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -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", diff --git a/src/app/router/routeConfig.ts b/src/app/router/routeConfig.ts index 754978b..ff55d10 100644 --- a/src/app/router/routeConfig.ts +++ b/src/app/router/routeConfig.ts @@ -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, diff --git a/src/features/host-agency-policy/api.ts b/src/features/host-agency-policy/api.ts new file mode 100644 index 0000000..0349b85 --- /dev/null +++ b/src/features/host-agency-policy/api.ts @@ -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; +type RawLevel = HostAgencyPolicyLevelDto & Record; + +export function listHostAgencyPolicies(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listHostAgencyPolicies; + // 统一走 OpenAPI 生成的 operation path,避免手写路径和后端契约漂移。 + return apiRequest>(apiEndpointPath(API_OPERATIONS.listHostAgencyPolicies), { + method: endpoint.method, + query, + }).then((page) => ({ + ...page, + items: (page.items || []).map(normalizePolicy), + })); +} + +export function createHostAgencyPolicy(payload: HostAgencyPolicyPayload): Promise { + const endpoint = API_ENDPOINTS.createHostAgencyPolicy; + return apiRequest(apiEndpointPath(API_OPERATIONS.createHostAgencyPolicy), { + body: payload, + method: endpoint.method, + }).then(normalizePolicy); +} + +export function updateHostAgencyPolicy( + policyId: EntityId, + payload: HostAgencyPolicyPayload, +): Promise { + const endpoint = API_ENDPOINTS.updateHostAgencyPolicy; + return apiRequest( + apiEndpointPath(API_OPERATIONS.updateHostAgencyPolicy, { policy_id: policyId }), + { + body: payload, + method: endpoint.method, + }, + ).then(normalizePolicy); +} + +export function deleteHostAgencyPolicy(policyId: EntityId): Promise { + const endpoint = API_ENDPOINTS.deleteHostAgencyPolicy; + return apiRequest(apiEndpointPath(API_OPERATIONS.deleteHostAgencyPolicy, { policy_id: policyId }), { + method: endpoint.method, + }); +} + +export function publishHostAgencyPolicy(policyId: EntityId): Promise { + const endpoint = API_ENDPOINTS.publishHostAgencyPolicy; + return apiRequest(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; +type RawTeamLevel = TeamSalaryPolicyLevelDto & Record; + +export function listTeamSalaryPolicies(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listTeamSalaryPolicies; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listTeamSalaryPolicies), { + method: endpoint.method, + query, + }).then((page) => ({ + ...page, + items: (page.items || []).map(normalizeTeamPolicy), + })); +} + +export function createTeamSalaryPolicy(payload: TeamSalaryPolicyPayload): Promise { + const endpoint = API_ENDPOINTS.createTeamSalaryPolicy; + return apiRequest(apiEndpointPath(API_OPERATIONS.createTeamSalaryPolicy), { + body: payload, + method: endpoint.method, + }).then(normalizeTeamPolicy); +} + +export function updateTeamSalaryPolicy( + policyId: EntityId, + payload: TeamSalaryPolicyPayload, +): Promise { + const endpoint = API_ENDPOINTS.updateTeamSalaryPolicy; + return apiRequest( + apiEndpointPath(API_OPERATIONS.updateTeamSalaryPolicy, { policy_id: policyId }), + { + body: payload, + method: endpoint.method, + }, + ).then(normalizeTeamPolicy); +} + +export function deleteTeamSalaryPolicy(policyType: string, policyId: EntityId): Promise { + 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; + +export function listHostSalarySettlements(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listHostSalarySettlements; + return apiRequest>(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; +type RawTeamRecord = TeamSalarySettlementRecordDto & Record; +type RawTeamSettleResult = TeamSalarySettleResult & Record; + +export function listTeamSalarySettlementPending( + query: PageQuery = {}, +): Promise> { + // 新增结算接口尚未进入生成端点,先集中在本 feature API 封装手写路径,避免页面散落裸字符串。 + return apiRequest>("/v1/admin/team-salary-settlements/pending", { + method: "GET", + query, + }).then((page) => ({ + ...page, + items: (page.items || []).map(normalizeTeamPending), + })); +} + +export function settleTeamSalary(payload: TeamSalarySettlePayload): Promise { + // 批量结算是写钱包动作;payload 保持后端 snake_case,减少序列化时的字段转换风险。 + return apiRequest("/v1/admin/team-salary-settlements/settle", { + body: payload, + method: "POST", + }).then(normalizeTeamSettleResult); +} + +export function listTeamSalarySettlementRecords( + query: PageQuery = {}, +): Promise> { + // 记录查询与待结算分开,避免运营查历史记录时触发候选重算。 + return apiRequest>("/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): 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 { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +} + +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; +} diff --git a/src/features/host-agency-policy/components/HostAgencyPolicyDrawer.jsx b/src/features/host-agency-policy/components/HostAgencyPolicyDrawer.jsx new file mode 100644 index 0000000..3deeb7c --- /dev/null +++ b/src/features/host-agency-policy/components/HostAgencyPolicyDrawer.jsx @@ -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 ( + + + + + } + as="form" + className={styles.policyDrawer} + contentClassName={styles.drawerBody} + open={page.drawerOpen} + title={page.editingPolicy ? "编辑工资政策" : "新增工资政策"} + width="wide" + onClose={saving ? undefined : page.closeDrawer} + onSubmit={page.submitPolicy} + > + + + + ); +} + +function PolicyBaseFields({ disabled, page }) { + const form = page.form; + const setForm = (patch) => page.setForm((current) => ({ ...current, ...patch })); + return ( +
+
+

基础配置

+
+
+ setForm({ name: event.target.value })} + /> + setForm({ regionId: event.target.value })} + > + {page.regionOptions.map((region) => ( + + {region.label} + + ))} + + setForm({ settlementMode: event.target.value })} + > + {settlementModeOptions.map(([value, label]) => ( + + {label} + + ))} + + setForm({ settlementTriggerMode: event.target.value })} + > + {settlementTriggerOptions.map(([value, label]) => ( + + {label} + + ))} + + setForm({ giftCoinToDiamondRatio: event.target.value })} + /> + setForm({ residualDiamondToUsdRate: event.target.value })} + /> + setForm({ effectiveFrom: range.startMs, effectiveTo: range.endMs })} + /> +
+ 政策状态 + setForm({ enabled: event.target.checked })} + /> +
+
+
+ ); +} + +function PolicyLevelEditor({ disabled, page }) { + return ( +
+
+

等级配置

+ +
+
+
+ 等级 + 所需钻石 + 主播美元 + 金币奖励 + 代理美元 + 状态 + +
+
+ {page.form.levels.map((level, index) => ( +
+ + page.updateLevel(index, { + level: event.target.value, + sortOrder: event.target.value, + }) + } + /> + page.updateLevel(index, { requiredDiamonds: event.target.value })} + /> + page.updateLevel(index, { hostSalaryUsd: event.target.value })} + /> + page.updateLevel(index, { hostCoinReward: event.target.value })} + /> + page.updateLevel(index, { agencySalaryUsd: event.target.value })} + /> + + page.updateLevel(index, { status: event.target.checked ? "active" : "disabled" }) + } + /> + page.removeLevel(index)} + > + + +
+ ))} +
+
+
+ ); +} diff --git a/src/features/host-agency-policy/components/TeamSalaryPolicyDrawer.jsx b/src/features/host-agency-policy/components/TeamSalaryPolicyDrawer.jsx new file mode 100644 index 0000000..322cfc6 --- /dev/null +++ b/src/features/host-agency-policy/components/TeamSalaryPolicyDrawer.jsx @@ -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 ( + + + + + } + 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} + > + + + + ); +} + +function TeamPolicyBaseFields({ disabled, page }) { + const form = page.form; + const setForm = (patch) => page.setForm((current) => ({ ...current, ...patch })); + return ( +
+
+

基础配置

+
+
+ setForm({ name: event.target.value })} + /> + setForm({ regionId: event.target.value })} + > + {page.regionOptions.map((region) => ( + + {region.label} + + ))} + + setForm({ settlementTriggerMode: event.target.value })} + > + {settlementTriggerOptions.map(([value, label]) => ( + + {label} + + ))} + + setForm({ effectiveFrom: range.startMs, effectiveTo: range.endMs })} + /> +
+ 政策状态 + setForm({ enabled: event.target.checked })} + /> +
+
+
+ ); +} + +function TeamPolicyLevelEditor({ disabled, page }) { + return ( +
+
+

等级配置

+ +
+
+
+ 等级 + {thresholdLabel(page.policyType)} + 比例% + {policyTitle(page.policyType)}美元 + 状态 + +
+
+ {page.form.levels.map((level, index) => ( +
+ + page.updateLevel(index, { + level: event.target.value, + sortOrder: event.target.value, + }) + } + /> + page.updateLevel(index, { thresholdUsd: event.target.value })} + /> + page.updateLevel(index, { ratePercent: event.target.value })} + /> + page.updateLevel(index, { salaryUsd: event.target.value })} + /> + + page.updateLevel(index, { status: event.target.checked ? "active" : "disabled" }) + } + /> + page.removeLevel(index)} + > + + +
+ ))} +
+
+
+ ); +} + +function policyTitle(policyType) { + return policyType === "bd" ? "BD" : "Admin"; +} + +function thresholdLabel(policyType) { + return policyType === "bd" ? "下属主播工资" : "BD工资"; +} diff --git a/src/features/host-agency-policy/hooks/useHostAgencyPolicyPage.js b/src/features/host-agency-policy/hooks/useHostAgencyPolicyPage.js new file mode 100644 index 0000000..cc3084b --- /dev/null +++ b/src/features/host-agency-policy/hooks/useHostAgencyPolicyPage.js @@ -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; +} diff --git a/src/features/host-agency-policy/hooks/useTeamSalaryPolicyPage.js b/src/features/host-agency-policy/hooks/useTeamSalaryPolicyPage.js new file mode 100644 index 0000000..d05ecbf --- /dev/null +++ b/src/features/host-agency-policy/hooks/useTeamSalaryPolicyPage.js @@ -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; +} diff --git a/src/features/host-agency-policy/host-agency-policy.module.css b/src/features/host-agency-policy/host-agency-policy.module.css new file mode 100644 index 0000000..38c82ca --- /dev/null +++ b/src/features/host-agency-policy/host-agency-policy.module.css @@ -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; + } +} diff --git a/src/features/host-agency-policy/pages/HostAgencyPolicyPage.jsx b/src/features/host-agency-policy/pages/HostAgencyPolicyPage.jsx new file mode 100644 index 0000000..664df9f --- /dev/null +++ b/src/features/host-agency-policy/pages/HostAgencyPolicyPage.jsx @@ -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) => , + }, + { + key: "settlement", + label: "结算与比例", + width: "minmax(190px, 0.9fr)", + render: (item) => , + }, + { + key: "levels", + label: "等级", + width: "minmax(180px, 0.8fr)", + render: (item) => , + }, + { + key: "effective", + label: "生效时间", + width: "minmax(220px, 1fr)", + render: (item) => , + }, + { + key: "publish", + label: "发布", + width: "minmax(180px, 0.8fr)", + render: (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 ( + +
+ setActiveTab(value)}> + + + + +
+ {activeTab === "host" ? : } +
+ ); +} + +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) => , + }; + } + if (column.key === "actions") { + return { + ...column, + render: (item) => , + }; + } + return column; + }); + + return ( + <> + + + + ) : null + } + filters={ +
+ + + +
+ } + /> + + + 0 + ? { + page: page.page, + pageSize: page.data.pageSize || 50, + total, + onPageChange: page.setPage, + } + : undefined + } + rowKey={(item) => item.id} + /> + + + + + ); +} + +function PolicyIdentity({ item, regionLabels }) { + // regionLabels 来自区域配置接口,后端政策列表只返回 region_id,避免列表接口重复拼区域名称。 + const labels = regionLabels || {}; + return ( +
+ {item.name} + {labels[item.regionId] || `区域 ${item.regionId}`} +
+ ); +} + +function SettlementSummary({ item }) { + return ( +
+ {settlementLabel(item.settlementMode)} + {settlementTriggerLabel(item.settlementTriggerMode)} + 金币转钻石 {item.giftCoinToDiamondRatio || "1"} + 剩余钻石转美元 {item.residualDiamondToUsdRate || "0"} +
+ ); +} + +function LevelSummary({ item }) { + const first = item.levels?.[0]; + const last = item.levels?.[item.levels.length - 1]; + // 等级摘要展示门槛范围和最高累计主播工资,帮助运营快速识别政策梯度是否录错。 + return ( +
+ {item.levels?.length || 0} 个等级 + + {first && last + ? `${formatNumber(first.requiredDiamonds)} - ${formatNumber(last.requiredDiamonds)}` + : "-"} + + {last ? `最高工资 $${last.hostSalaryUsd}` : ""} +
+ ); +} + +function EffectiveTime({ item }) { + return ( +
+ {item.effectiveFromMs ? formatMillis(item.effectiveFromMs) : "立即生效"} + {item.effectiveToMs ? formatMillis(item.effectiveToMs) : "长期有效"} +
+ ); +} + +function PublishSummary({ item }) { + const status = item.publishStatus || "draft"; + // publish_error 只在失败态显示,避免成功发布后的旧错误文案干扰运营判断。 + return ( +
+ {publishLabel(status)} + {item.publishedAtMs ? formatMillis(item.publishedAtMs) : "未发布"} + {status === "failed" && item.publishError ? {item.publishError} : null} +
+ ); +} + +function PolicyStatusSwitch({ item, page }) { + const disabled = !page.abilities.canUpdate || page.loadingAction === `policy-status-${item.id}`; + // 行内启停最终仍走 update 接口,后端会重新执行同区域时间段冲突校验。 + return ( + page.togglePolicyStatus(item, event.target.checked)} + /> + ); +} + +function PolicyActions({ item, page }) { + return ( +
+ page.publishPolicy(item)} + > + + + page.openEdit(item)} + > + + + page.removePolicy(item)} + > + + +
+ ); +} + +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)); +} diff --git a/src/features/host-agency-policy/pages/HostSalarySettlementPage.jsx b/src/features/host-agency-policy/pages/HostSalarySettlementPage.jsx new file mode 100644 index 0000000..b4d3efe --- /dev/null +++ b/src/features/host-agency-policy/pages/HostSalarySettlementPage.jsx @@ -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 ( + +
+ setActiveTab(value)}> + + + + +
+ {activeTab === "team-pending" ? ( + + ) : activeTab === "team-records" ? ( + + ) : ( + + )} +
+ ); +} + +// 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 ( + <> + + + + + + } + /> + + + item.settlementId} + /> + + + + ); +} + +// 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: ( + 0 && !allVisibleSelected} + size="small" + onChange={toggleVisible} + /> + ), + width: "64px", + resizable: false, + render: (item) => ( + toggleOne(item)} /> + ), + }, + ...teamPendingColumns, + ]; + // 默认值也纳入 hasFilters,这样运营能一键回到“BD + 自动政策 + 上月周期”的标准入口。 + const hasFilters = policyType !== "bd" || triggerMode !== "automatic" || cycleKey !== defaultCycleKey() || countryCode || regionId; + + return ( + <> + } + variant="primary" + onClick={settleSelected} + > + 批量结算 + + } + filters={ +
+ + + + + + +
+ } + /> + + + + + + + ); +} + +// 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 ( + <> + + { setTimeRange(value); setPage(1); }} /> + + + + + + } + /> + + + item.settlementId} + /> + + + + ); +} + +// hostRecordColumns 展示 Host/Agency 的原始结算事实,包括主播工资、金币奖励、代理工资和剩余折美元。 +const hostRecordColumns = [ + { + key: "host", + label: "主播", + width: "minmax(240px, 1.1fr)", + render: (item) => , + }, + { + key: "agency", + label: "代理", + width: "minmax(220px, 1fr)", + render: (item) => , + }, + { + key: "cycle", + label: "周期", + width: "minmax(170px, 0.8fr)", + render: (item) => , + }, + { + key: "level", + label: "等级/钻石", + width: "minmax(150px, 0.7fr)", + render: (item) => , + }, + { + key: "hostReward", + label: "主播奖励", + width: "minmax(170px, 0.8fr)", + render: (item) => , + }, + { + key: "agencyReward", + label: "代理/剩余", + width: "minmax(170px, 0.8fr)", + render: (item) => , + }, + { + key: "status", + label: "状态", + width: "minmax(120px, 0.55fr)", + render: (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) => , + }, + { + key: "scope", + label: "周期/区域", + width: "minmax(180px, 0.8fr)", + render: (item) => ( +
+ {roleLabel(item.policyType)} · {item.cycleKey || "-"} + {item.regionName || `区域 ${item.regionId || "-"}`} +
+ ), + }, + { + key: "income", + label: "收入基数", + width: "minmax(150px, 0.7fr)", + render: (item) => , + }, + { + key: "level", + label: "政策/档位", + width: "minmax(210px, 0.9fr)", + render: (item) => ( +
+ {item.levelNo > 0 ? `等级 ${item.levelNo}` : "未达等级"} + {item.policyName || `政策 ${item.policyId || "-"}`} +
+ ), + }, + { + key: "salary", + label: "待发工资", + width: "minmax(170px, 0.8fr)", + render: (item) => ( +
+ {formatMoneyDelta(item.salaryUsdDelta)} + 累计目标 ${item.salaryUsdTarget || "0"} +
+ ), + }, + { + key: "progress", + label: "已结进度", + width: "minmax(180px, 0.8fr)", + render: (item) => ( +
+ ${item.settledSalaryUsd || "0"} + 已结等级 {item.settledLevelNo || 0} +
+ ), + }, +]; + +// teamRecordColumns 展示已落库的 BD/Admin 结算记录;settlementId 通过 rowKey 保持唯一。 +const teamRecordColumns = [ + { + key: "user", + label: "用户", + width: "minmax(230px, 1fr)", + render: (item) => , + }, + { + key: "cycle", + label: "周期/角色", + width: "minmax(170px, 0.8fr)", + render: (item) => ( +
+ {item.cycleKey || "-"} + {roleLabel(item.policyType)} · {triggerLabel(item.triggerMode)} +
+ ), + }, + { + key: "income", + label: "收入基数", + width: "minmax(150px, 0.7fr)", + render: (item) => , + }, + { + key: "salary", + label: "发放工资", + width: "minmax(160px, 0.75fr)", + render: (item) => {formatMoneyDelta(item.salaryUsdDelta)}, + }, + { + key: "policy", + label: "政策", + width: "minmax(130px, 0.55fr)", + render: (item) => `政策 ${item.policyId || "-"}`, + }, + { + key: "status", + label: "状态", + width: "minmax(120px, 0.55fr)", + render: (item) => , + }, + { + key: "createdAtMs", + label: "时间", + width: "minmax(170px, 0.8fr)", + render: (item) => formatMillis(item.createdAtMs), + }, +]; + +// InlineFilter 是本页面的轻量文本筛选控件,用于周期、国家代码、区域 ID 这类短字段。 +function InlineFilter({ label, onChange, placeholder, value }) { + return ( + + ); +} + +// UserCell 统一展示用户资料;没有资料时仍显示 user_id,确保查账可定位。 +function UserCell({ fallbackId, user }) { + const idText = userIdText(user, fallbackId); + if (!idText) { + return "-"; + } + return ( +
+ + {user?.avatar ? : } + +
+ {user?.username || "-"} + {idText} +
+
+ ); +} + +// CycleCell 展示 Host/Agency 周期和结算类型。 +function CycleCell({ item }) { + return ( +
+ {item.cycleKey || "-"} + {settlementTypeLabel(item.settlementType)} +
+ ); +} + +// HostLevelCell 展示主播档位和钻石累计,方便对照 Host 政策表。 +function HostLevelCell({ item }) { + return ( +
+ {item.levelNo > 0 ? `等级 ${item.levelNo}` : "未达等级"} + {formatNumber(item.totalDiamonds)} 钻石 + 政策 {item.policyId || "-"} +
+ ); +} + +// HostRewardCell 展示主播侧本次增量奖励。 +function HostRewardCell({ item }) { + return ( +
+ {formatMoneyDelta(item.hostSalaryUsdDelta)} + 金币 {formatSignedInteger(item.hostCoinRewardDelta)} +
+ ); +} + +// AgencyRewardCell 展示代理工资和月底剩余钻石折美元。 +function AgencyRewardCell({ item }) { + return ( +
+ {formatMoneyDelta(item.agencySalaryUsdDelta)} + 剩余折美元 {formatMoneyDelta(item.residualUsdDelta)} +
+ ); +} + +// MoneyStack 固定两行金额展示,避免表格列因文案变化跳动。 +function MoneyStack({ primary, secondary }) { + return ( +
+ ${primary || "0"} + {secondary} +
+ ); +} + +// StatusCell 统一状态徽标,失败或跳过原因放在第二行。 +function StatusCell({ item }) { + return ( +
+ {statusLabel(item.status)} + {item.reason ? {item.reason} : null} +
+ ); +} + +// 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)); +} diff --git a/src/features/host-agency-policy/pages/TeamSalaryPolicyPanel.jsx b/src/features/host-agency-policy/pages/TeamSalaryPolicyPanel.jsx new file mode 100644 index 0000000..d9d786a --- /dev/null +++ b/src/features/host-agency-policy/pages/TeamSalaryPolicyPanel.jsx @@ -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) => ( + + ), + }, + { + key: "settlement", + label: "触发与周期", + width: "minmax(180px, 0.8fr)", + render: (item) => , + }, + { + key: "levels", + label: "等级", + width: "minmax(220px, 0.95fr)", + render: (item, _rowIndex, context) => , + }, + { + key: "effective", + label: "生效时间", + width: "minmax(220px, 1fr)", + render: (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) => , + }; + } + if (column.key === "actions") { + return { + ...column, + render: (item) => , + }; + } + return column; + }); + + return ( + <> + + + + ) : null + } + filters={ +
+ + +
+ } + /> + + + 0 + ? { + page: page.page, + pageSize: page.data.pageSize || 50, + total, + onPageChange: page.setPage, + } + : undefined + } + rowKey={(item) => item.id} + /> + + + + + ); +} + +function PolicyIdentity({ item, policyType, regionLabels }) { + const labels = regionLabels || {}; + return ( +
+ {item.name} + + {policyTitle(policyType)} · {labels[item.regionId] || `区域 ${item.regionId}`} + +
+ ); +} + +function SettlementSummary({ item }) { + return ( +
+ {settlementTriggerLabel(item.settlementTriggerMode)} + 完整自然月 + 次月 5 日可结算 +
+ ); +} + +function LevelSummary({ item, policyType }) { + const first = item.levels?.[0]; + const last = item.levels?.[item.levels.length - 1]; + return ( +
+ {item.levels?.length || 0} 个等级 + + {first && last ? `$${first.thresholdUsd} - $${last.thresholdUsd} ${thresholdUnit(policyType)}` : "-"} + + {last ? `最高工资 $${last.salaryUsd}` : ""} +
+ ); +} + +function EffectiveTime({ item }) { + return ( +
+ {item.effectiveFromMs ? formatMillis(item.effectiveFromMs) : "立即生效"} + {item.effectiveToMs ? formatMillis(item.effectiveToMs) : "长期有效"} +
+ ); +} + +function PolicyStatusSwitch({ item, page }) { + const disabled = !page.abilities.canTeamUpdate || page.loadingAction === `team-policy-status-${item.id}`; + return ( + page.togglePolicyStatus(item, event.target.checked)} + /> + ); +} + +function PolicyActions({ item, page }) { + return ( +
+ page.openEdit(item)} + > + + + page.removePolicy(item)} + > + + +
+ ); +} + +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 || "-"; +} diff --git a/src/features/host-agency-policy/permissions.js b/src/features/host-agency-policy/permissions.js new file mode 100644 index 0000000..cac41f4 --- /dev/null +++ b/src/features/host-agency-policy/permissions.js @@ -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), + }; +} diff --git a/src/features/host-agency-policy/routes.js b/src/features/host-agency-policy/routes.js new file mode 100644 index 0000000..14ef58a --- /dev/null +++ b/src/features/host-agency-policy/routes.js @@ -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, + }, +]; diff --git a/src/features/host-agency-policy/schema.ts b/src/features/host-agency-policy/schema.ts new file mode 100644 index 0000000..f9bec7d --- /dev/null +++ b/src/features/host-agency-policy/schema.ts @@ -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(); + 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(); + 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; +} diff --git a/src/features/operations/api.ts b/src/features/operations/api.ts index 8494d9e..66ffa16 100644 --- a/src/features/operations/api.ts +++ b/src/features/operations/api.ts @@ -8,6 +8,7 @@ import type { CoinLedgerEntryDto, CoinLedgerUserDto, PageQuery, + ReportDto, } from "@/shared/api/types"; export function listCoinLedger(query: PageQuery = {}): Promise> { @@ -26,6 +27,14 @@ export function listCoinAdjustments(query: PageQuery = {}): Promise> { + const endpoint = API_ENDPOINTS.listReports; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listReports), { + method: endpoint.method, + query, + }); +} + export function lookupCoinAdjustmentTarget(userId: string): Promise { const endpoint = API_ENDPOINTS.lookupCoinAdjustmentTarget; return apiRequest(apiEndpointPath(API_OPERATIONS.lookupCoinAdjustmentTarget), { diff --git a/src/features/operations/operations.module.css b/src/features/operations/operations.module.css index ea8d9c4..8c76bd6 100644 --- a/src/features/operations/operations.module.css +++ b/src/features/operations/operations.module.css @@ -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; diff --git a/src/features/operations/pages/ReportListPage.jsx b/src/features/operations/pages/ReportListPage.jsx new file mode 100644 index 0000000..ef241d1 --- /dev/null +++ b/src/features/operations/pages/ReportListPage.jsx @@ -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) => , + width: "minmax(120px, 0.55fr)", + }, + { + key: "target", + label: "举报信息", + render: (report) => , + width: "minmax(300px, 1.3fr)", + }, + { + key: "reportType", + label: "举报类型", + render: (report) => reportTypeLabel(report.reportType), + width: "minmax(150px, 0.7fr)", + }, + { + key: "reason", + label: "举报理由", + render: (report) => {report.reason || "-"}, + width: "minmax(220px, 1fr)", + }, + { + key: "imageUrls", + label: "举报图片", + render: (report) => , + 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 ( + + + + + + } + /> + + + 0 + ? { + page, + pageSize: data.pageSize || pageSize, + total, + onPageChange: setPage, + } + : undefined + } + rowKey={(report) => report.reportId} + /> + + + + ); +} + +function TargetTypeLabel({ report }) { + const isRoom = report.targetType === "room"; + return ( + + {isRoom ? : } + {isRoom ? "房间" : "用户"} + + ); +} + +function TargetCell({ report }) { + const target = report.target || {}; + if (report.targetType === "room") { + const room = target.room || {}; + return ( +
+ + {room.coverUrl ? : } + + + {room.title || "-"} + {targetMeta(room.roomShortId, room.roomId || report.roomId)} + +
+ ); + } + const user = target.user || {}; + return ( +
+ + {user.avatar ? : } + + + {user.username || "-"} + + {targetMeta(user.displayUserId || user.defaultDisplayUserId, user.userId || report.userId)} + + +
+ ); +} + +function EvidenceImages({ imageUrls = [] }) { + const urls = Array.isArray(imageUrls) ? imageUrls.filter(Boolean) : []; + if (!urls.length) { + return "-"; + } + return ( +
+ {urls.slice(0, 4).map((url) => ( + + + + ))} + {urls.length > 4 ? +{urls.length - 4} : null} +
+ ); +} + +function targetMeta(shortId, longId) { + return `短ID ${shortId || "-"} · 长ID ${longId || "-"}`; +} + +function reportTypeLabel(value) { + return reportTypeOptions.find((item) => item.value === value)?.label || value || "-"; +} diff --git a/src/features/operations/routes.js b/src/features/operations/routes.js index 5130c67..e23f83a 100644 --- a/src/features/operations/routes.js +++ b/src/features/operations/routes.js @@ -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, + }, ]; diff --git a/src/features/red-packets/api.ts b/src/features/red-packets/api.ts index 1a5a3d6..6062942 100644 --- a/src/features/red-packets/api.ts +++ b/src/features/red-packets/api.ts @@ -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; @@ -102,6 +109,14 @@ export function getRedPacket(packetId: string): Promise { }).then(normalizePacket); } +export function retryRedPacketRefund(packetId: string): Promise { + 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), }; } diff --git a/src/features/red-packets/components/RedPacketConfigDrawer.jsx b/src/features/red-packets/components/RedPacketConfigDrawer.jsx index caa01d4..f18dec5 100644 --- a/src/features/red-packets/components/RedPacketConfigDrawer.jsx +++ b/src/features/red-packets/components/RedPacketConfigDrawer.jsx @@ -61,30 +61,38 @@ export function RedPacketConfigDrawer({
updateValue("delayedOpenSeconds", event.target.value)} /> updateValue("expireSeconds", event.target.value)} /> updateValue("dailySendLimit", event.target.value)} />
+
+ updateValue("ruleUrl", event.target.value)} + /> +
onUpdate(tierKey, index, event.target.value)} diff --git a/src/features/red-packets/components/RedPacketConfigSummary.jsx b/src/features/red-packets/components/RedPacketConfigSummary.jsx index e23d1df..ae95177 100644 --- a/src/features/red-packets/components/RedPacketConfigSummary.jsx +++ b/src/features/red-packets/components/RedPacketConfigSummary.jsx @@ -20,9 +20,10 @@ export function RedPacketConfigSummary({ canUpdate, config, configLoading, onEdi {formatTiers(config?.countTiers)} {formatTiers(config?.amountTiers)} - {Number(config?.delayedOpenSeconds || 0)}s / {Number(config?.expireSeconds || 0)}s + {Number(config?.delayedOpenSeconds || 0)}s / 24h {Number(config?.dailySendLimit || 0)} 次 + {config?.ruleUrl || "-"}
), }, + { + key: "claimed", + label: "已领", + width: "minmax(145px, 0.6fr)", + render: (packet) => ( +
+ {formatNumber(packet.totalAmount - packet.remainingAmount)} 金币 + {packet.packetCount - packet.remainingCount} 个 +
+ ), + }, { 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) => ( + {page.abilities.canUpdate && canRetryRefund(packet) ? ( + page.retryRefund(packet.packetId)}> + + + ) : null} page.openPacketDetail(packet.packetId)}> @@ -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"; +} diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 64aeb9a..c2f4d80 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 0bf56a1..876b149 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -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; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index e04ba66..60e896f 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -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; diff --git a/src/styles/app.css b/src/styles/app.css index 1830843..e7e0fed 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -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); +} diff --git a/src/styles/shared-ui.css b/src/styles/shared-ui.css index 909143a..97ba5f1 100644 --- a/src/styles/shared-ui.css +++ b/src/styles/shared-ui.css @@ -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 { diff --git a/src/styles/tokens.css b/src/styles/tokens.css index c7d5b29..b86911e 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -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); diff --git a/src/theme.js b/src/theme.js index ced98d6..5ff4de0 100644 --- a/src/theme.js +++ b/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" + } } } });