政策相关
This commit is contained in:
parent
b96fdb4f6a
commit
49f54e0463
@ -27,6 +27,7 @@
|
|||||||
- 页面:按业务放在 `src/features/*/pages/`。
|
- 页面:按业务放在 `src/features/*/pages/`。
|
||||||
- 应用壳:`src/app/App.jsx`、`src/app/layout/AdminLayout.jsx`、`src/app/router/routeConfig.ts`。
|
- 应用壳:`src/app/App.jsx`、`src/app/layout/AdminLayout.jsx`、`src/app/router/routeConfig.ts`。
|
||||||
- 页面加载占位:`src/shared/ui/PageSkeleton.jsx`。
|
- 页面加载占位:`src/shared/ui/PageSkeleton.jsx`。
|
||||||
|
- 公共时间选择器:`src/shared/ui/TimeRangeFilter.jsx`,使用 `{ startMs, endMs }` 毫秒值作为输入输出。
|
||||||
- 全局资源组选择抽屉:`src/shared/ui/ResourceGroupSelectDrawer.jsx`,资源组字段优先使用该组件,不在业务页面里重复手写普通下拉。
|
- 全局资源组选择抽屉:`src/shared/ui/ResourceGroupSelectDrawer.jsx`,资源组字段优先使用该组件,不在业务页面里重复手写普通下拉。
|
||||||
- 顶部搜索弹窗:`src/features/search/components/SearchDialog.jsx`。
|
- 顶部搜索弹窗:`src/features/search/components/SearchDialog.jsx`。
|
||||||
- MUI 主题:`src/theme.js`。
|
- MUI 主题:`src/theme.js`。
|
||||||
@ -91,6 +92,7 @@ import { Refresh } from "@mui/icons-material";
|
|||||||
- 表格里的启用/禁用状态优先使用行内 Switch 快捷操作,不使用单独的启用/禁用图标按钮。
|
- 表格里的启用/禁用状态优先使用行内 Switch 快捷操作,不使用单独的启用/禁用图标按钮。
|
||||||
- 表格状态筛选使用 Select 下拉框,默认值为 `全部状态`,不使用状态 chip 组。
|
- 表格状态筛选使用 Select 下拉框,默认值为 `全部状态`,不使用状态 chip 组。
|
||||||
- 后台列表页的时间区间筛选必须使用统一的单入口时间区间筛选框(`src/shared/ui/TimeRangeFilter.jsx`);不要在工具栏里并排放两个开始/结束时间输入框,也不要使用浏览器原生日期/时间输入样式。时间区间弹层内使用快捷项、日历和时分下拉点选,不使用手写时间文本框。
|
- 后台列表页的时间区间筛选必须使用统一的单入口时间区间筛选框(`src/shared/ui/TimeRangeFilter.jsx`);不要在工具栏里并排放两个开始/结束时间输入框,也不要使用浏览器原生日期/时间输入样式。时间区间弹层内使用快捷项、日历和时分下拉点选,不使用手写时间文本框。
|
||||||
|
- 后台表单里的生效时间、有效期、投放时间等时间区间必须使用统一公共时间选择器(`src/shared/ui/TimeRangeFilter.jsx`);不要并排放两个开始/结束时间输入框,也不要使用浏览器原生日期/时间输入样式。
|
||||||
- 资源组选择必须优先使用统一的 `src/shared/ui/ResourceGroupSelectDrawer.jsx`,点击输入框打开右侧资源组图标抽屉,点击资源组后自动回填并关闭,不要在业务页面里重复实现资源组 Select 下拉。
|
- 资源组选择必须优先使用统一的 `src/shared/ui/ResourceGroupSelectDrawer.jsx`,点击输入框打开右侧资源组图标抽屉,点击资源组后自动回填并关闭,不要在业务页面里重复实现资源组 Select 下拉。
|
||||||
- 区域编辑弹窗必须同时包含区域基础字段和国家码字段;国家码使用可搜索多选下拉框,不再拆成独立“区域国家”弹窗。
|
- 区域编辑弹窗必须同时包含区域基础字段和国家码字段;国家码使用可搜索多选下拉框,不再拆成独立“区域国家”弹窗。
|
||||||
- 头像、图片、封面等图片资源字段必须使用 `src/shared/ui/UploadField.jsx` 上传表单组件,不使用普通文本输入框承载 URL。
|
- 头像、图片、封面等图片资源字段必须使用 `src/shared/ui/UploadField.jsx` 上传表单组件,不使用普通文本输入框承载 URL。
|
||||||
|
|||||||
@ -132,7 +132,7 @@
|
|||||||
"x-permissions": ["user-leaderboard:view"]
|
"x-permissions": ["user-leaderboard:view"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/admin/activity/red-packets": {
|
"/resident-activity/voice-room-red-packet/records": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listRedPackets",
|
"operationId": "listRedPackets",
|
||||||
"responses": {
|
"responses": {
|
||||||
@ -144,7 +144,7 @@
|
|||||||
"x-permissions": ["red-packet:view"]
|
"x-permissions": ["red-packet:view"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/admin/activity/red-packets/{packet_id}": {
|
"/resident-activity/voice-room-red-packet/records/{packet_id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getRedPacket",
|
"operationId": "getRedPacket",
|
||||||
"responses": {
|
"responses": {
|
||||||
@ -166,7 +166,7 @@
|
|||||||
"x-permissions": ["red-packet:view"]
|
"x-permissions": ["red-packet:view"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/admin/activity/red-packets/config": {
|
"/resident-activity/voice-room-red-packet/config": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getRedPacketConfig",
|
"operationId": "getRedPacketConfig",
|
||||||
"responses": {
|
"responses": {
|
||||||
@ -177,7 +177,7 @@
|
|||||||
"x-permission": "red-packet:view",
|
"x-permission": "red-packet:view",
|
||||||
"x-permissions": ["red-packet:view"]
|
"x-permissions": ["red-packet:view"]
|
||||||
},
|
},
|
||||||
"put": {
|
"post": {
|
||||||
"operationId": "updateRedPacketConfig",
|
"operationId": "updateRedPacketConfig",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
@ -188,6 +188,18 @@
|
|||||||
"x-permissions": ["red-packet:update"]
|
"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": {
|
"/admin/activity/lucky-gifts/v2/config": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getLuckyGiftConfig",
|
"operationId": "getLuckyGiftConfig",
|
||||||
@ -1425,6 +1437,168 @@
|
|||||||
"x-permissions": ["host:view"]
|
"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": {
|
"/admin/operations/coin-adjustments": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listCoinAdjustments",
|
"operationId": "listCoinAdjustments",
|
||||||
@ -1471,6 +1645,18 @@
|
|||||||
"x-permissions": ["coin-ledger:view"]
|
"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": {
|
"/admin/payment/recharge-bills": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listRechargeBills",
|
"operationId": "listRechargeBills",
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
|||||||
import CategoryOutlined from "@mui/icons-material/CategoryOutlined";
|
import CategoryOutlined from "@mui/icons-material/CategoryOutlined";
|
||||||
import DashboardOutlined from "@mui/icons-material/DashboardOutlined";
|
import DashboardOutlined from "@mui/icons-material/DashboardOutlined";
|
||||||
import EventAvailableOutlined from "@mui/icons-material/EventAvailableOutlined";
|
import EventAvailableOutlined from "@mui/icons-material/EventAvailableOutlined";
|
||||||
|
import FlagOutlined from "@mui/icons-material/FlagOutlined";
|
||||||
import GroupsOutlined from "@mui/icons-material/GroupsOutlined";
|
import GroupsOutlined from "@mui/icons-material/GroupsOutlined";
|
||||||
import HistoryOutlined from "@mui/icons-material/HistoryOutlined";
|
import HistoryOutlined from "@mui/icons-material/HistoryOutlined";
|
||||||
import HubOutlined from "@mui/icons-material/HubOutlined";
|
import HubOutlined from "@mui/icons-material/HubOutlined";
|
||||||
@ -44,6 +45,7 @@ const iconMap = {
|
|||||||
dashboard: DashboardOutlined,
|
dashboard: DashboardOutlined,
|
||||||
event_available: EventAvailableOutlined,
|
event_available: EventAvailableOutlined,
|
||||||
explore: MapOutlined,
|
explore: MapOutlined,
|
||||||
|
flag: FlagOutlined,
|
||||||
gift: CardGiftcardOutlined,
|
gift: CardGiftcardOutlined,
|
||||||
inventory: Inventory2Outlined,
|
inventory: Inventory2Outlined,
|
||||||
leaderboard: LeaderboardOutlined,
|
leaderboard: LeaderboardOutlined,
|
||||||
@ -144,6 +146,7 @@ export const fallbackNavigation = [
|
|||||||
children: [
|
children: [
|
||||||
routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined }),
|
routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined }),
|
||||||
routeNavItem("operation-coin-adjustment", { icon: WalletOutlined }),
|
routeNavItem("operation-coin-adjustment", { icon: WalletOutlined }),
|
||||||
|
routeNavItem("operation-reports", { icon: FlagOutlined }),
|
||||||
routeNavItem("lucky-gift", { icon: RedeemOutlined }),
|
routeNavItem("lucky-gift", { icon: RedeemOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -198,6 +201,8 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("host-org-bd-leaders", { icon: ShieldOutlined }),
|
routeNavItem("host-org-bd-leaders", { icon: ShieldOutlined }),
|
||||||
routeNavItem("host-org-bds", { icon: GroupsOutlined }),
|
routeNavItem("host-org-bds", { icon: GroupsOutlined }),
|
||||||
routeNavItem("host-org-hosts", { icon: ManageAccountsOutlined }),
|
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 }),
|
routeNavItem("host-org-coin-sellers", { icon: WalletOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -15,6 +15,17 @@ export const PERMISSIONS = {
|
|||||||
regionUpdate: "region:update",
|
regionUpdate: "region:update",
|
||||||
regionStatus: "region:status",
|
regionStatus: "region:status",
|
||||||
hostView: "host:view",
|
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",
|
agencyView: "agency:view",
|
||||||
agencyCreate: "agency:create",
|
agencyCreate: "agency:create",
|
||||||
agencyStatus: "agency:status",
|
agencyStatus: "agency:status",
|
||||||
@ -28,6 +39,7 @@ export const PERMISSIONS = {
|
|||||||
coinLedgerView: "coin-ledger:view",
|
coinLedgerView: "coin-ledger:view",
|
||||||
coinAdjustmentView: "coin-adjustment:view",
|
coinAdjustmentView: "coin-adjustment:view",
|
||||||
coinAdjustmentCreate: "coin-adjustment:create",
|
coinAdjustmentCreate: "coin-adjustment:create",
|
||||||
|
reportView: "report:view",
|
||||||
paymentBillView: "payment-bill:view",
|
paymentBillView: "payment-bill:view",
|
||||||
paymentProductView: "payment-product:view",
|
paymentProductView: "payment-product:view",
|
||||||
paymentProductCreate: "payment-product:create",
|
paymentProductCreate: "payment-product:create",
|
||||||
@ -160,6 +172,7 @@ export const MENU_CODES = {
|
|||||||
operations: "operations",
|
operations: "operations",
|
||||||
operationCoinLedger: "operation-coin-ledger",
|
operationCoinLedger: "operation-coin-ledger",
|
||||||
operationCoinAdjustment: "operation-coin-adjustment",
|
operationCoinAdjustment: "operation-coin-adjustment",
|
||||||
|
operationReports: "operation-reports",
|
||||||
luckyGift: "lucky-gift",
|
luckyGift: "lucky-gift",
|
||||||
activities: "activities",
|
activities: "activities",
|
||||||
dailyTaskList: "daily-task-list",
|
dailyTaskList: "daily-task-list",
|
||||||
@ -180,6 +193,8 @@ export const MENU_CODES = {
|
|||||||
hostOrgBdLeaders: "host-org-bd-leaders",
|
hostOrgBdLeaders: "host-org-bd-leaders",
|
||||||
hostOrgBds: "host-org-bds",
|
hostOrgBds: "host-org-bds",
|
||||||
hostOrgHosts: "host-org-hosts",
|
hostOrgHosts: "host-org-hosts",
|
||||||
|
hostAgencyPolicy: "host-agency-policy",
|
||||||
|
hostSalarySettlement: "host-salary-settlement",
|
||||||
hostOrgCoinSellers: "host-org-coin-sellers",
|
hostOrgCoinSellers: "host-org-coin-sellers",
|
||||||
payment: "payment",
|
payment: "payment",
|
||||||
paymentBillList: "payment-bill-list",
|
paymentBillList: "payment-bill-list",
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { dashboardRoutes } from "@/features/dashboard/routes.js";
|
|||||||
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
|
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
|
||||||
import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/routes.js";
|
import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/routes.js";
|
||||||
import { gameRoutes } from "@/features/games/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 { hostOrgRoutes } from "@/features/host-org/routes.js";
|
||||||
import { levelConfigRoutes } from "@/features/level-config/routes.js";
|
import { levelConfigRoutes } from "@/features/level-config/routes.js";
|
||||||
import { logsRoutes } from "@/features/logs/routes.js";
|
import { logsRoutes } from "@/features/logs/routes.js";
|
||||||
@ -51,6 +52,7 @@ export const adminRoutes: AdminRoute[] = [
|
|||||||
...paymentRoutes,
|
...paymentRoutes,
|
||||||
...gameRoutes,
|
...gameRoutes,
|
||||||
...usersRoutes,
|
...usersRoutes,
|
||||||
|
...hostAgencyPolicyRoutes,
|
||||||
...hostOrgRoutes,
|
...hostOrgRoutes,
|
||||||
...rolesRoutes,
|
...rolesRoutes,
|
||||||
...menusRoutes,
|
...menusRoutes,
|
||||||
|
|||||||
560
src/features/host-agency-policy/api.ts
Normal file
560
src/features/host-agency-policy/api.ts
Normal file
@ -0,0 +1,560 @@
|
|||||||
|
import { apiRequest } from "@/shared/api/request";
|
||||||
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
|
import type { ApiPage, EntityId, PageQuery } from "@/shared/api/types";
|
||||||
|
|
||||||
|
export interface HostAgencyPolicyLevelDto {
|
||||||
|
id?: number;
|
||||||
|
policyId?: number;
|
||||||
|
level: number;
|
||||||
|
// 后端保存的是达到该等级后的累计权益,列表里展示的最高工资也基于累计值。
|
||||||
|
requiredDiamonds: number;
|
||||||
|
hostSalaryUsd: string;
|
||||||
|
hostCoinReward: number;
|
||||||
|
agencySalaryUsd: string;
|
||||||
|
totalSalaryUsd?: string;
|
||||||
|
status: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
createdAtMs?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostAgencyPolicyDto {
|
||||||
|
id: number;
|
||||||
|
appCode?: string;
|
||||||
|
name: string;
|
||||||
|
regionId: number;
|
||||||
|
status: string;
|
||||||
|
settlementMode: string;
|
||||||
|
settlementTriggerMode: string;
|
||||||
|
giftCoinToDiamondRatio: string;
|
||||||
|
residualDiamondToUsdRate: string;
|
||||||
|
effectiveFromMs?: number;
|
||||||
|
effectiveToMs?: number;
|
||||||
|
createdByAdminId?: number;
|
||||||
|
updatedByAdminId?: number;
|
||||||
|
publishStatus?: string;
|
||||||
|
publishError?: string;
|
||||||
|
publishedAtMs?: number;
|
||||||
|
publishedByAdminId?: number;
|
||||||
|
createdAtMs?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
levels: HostAgencyPolicyLevelDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostAgencyPolicyPayload {
|
||||||
|
name: string;
|
||||||
|
// 第一阶段按单区域配置;同一区域同一生效时间只能启用一条政策。
|
||||||
|
region_id: number;
|
||||||
|
status: string;
|
||||||
|
settlement_mode: string;
|
||||||
|
settlement_trigger_mode: string;
|
||||||
|
gift_coin_to_diamond_ratio: string;
|
||||||
|
residual_diamond_to_usd_rate: string;
|
||||||
|
effective_from_ms: number;
|
||||||
|
effective_to_ms: number;
|
||||||
|
levels: Array<{
|
||||||
|
level: number;
|
||||||
|
required_diamonds: number;
|
||||||
|
host_salary_usd: string;
|
||||||
|
host_coin_reward: number;
|
||||||
|
agency_salary_usd: string;
|
||||||
|
status: string;
|
||||||
|
sort_order: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawPolicy = HostAgencyPolicyDto & Record<string, unknown>;
|
||||||
|
type RawLevel = HostAgencyPolicyLevelDto & Record<string, unknown>;
|
||||||
|
|
||||||
|
export function listHostAgencyPolicies(query: PageQuery = {}): Promise<ApiPage<HostAgencyPolicyDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listHostAgencyPolicies;
|
||||||
|
// 统一走 OpenAPI 生成的 operation path,避免手写路径和后端契约漂移。
|
||||||
|
return apiRequest<ApiPage<RawPolicy>>(apiEndpointPath(API_OPERATIONS.listHostAgencyPolicies), {
|
||||||
|
method: endpoint.method,
|
||||||
|
query,
|
||||||
|
}).then((page) => ({
|
||||||
|
...page,
|
||||||
|
items: (page.items || []).map(normalizePolicy),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHostAgencyPolicy(payload: HostAgencyPolicyPayload): Promise<HostAgencyPolicyDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.createHostAgencyPolicy;
|
||||||
|
return apiRequest<RawPolicy, HostAgencyPolicyPayload>(apiEndpointPath(API_OPERATIONS.createHostAgencyPolicy), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
}).then(normalizePolicy);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateHostAgencyPolicy(
|
||||||
|
policyId: EntityId,
|
||||||
|
payload: HostAgencyPolicyPayload,
|
||||||
|
): Promise<HostAgencyPolicyDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.updateHostAgencyPolicy;
|
||||||
|
return apiRequest<RawPolicy, HostAgencyPolicyPayload>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.updateHostAgencyPolicy, { policy_id: policyId }),
|
||||||
|
{
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
},
|
||||||
|
).then(normalizePolicy);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteHostAgencyPolicy(policyId: EntityId): Promise<unknown> {
|
||||||
|
const endpoint = API_ENDPOINTS.deleteHostAgencyPolicy;
|
||||||
|
return apiRequest(apiEndpointPath(API_OPERATIONS.deleteHostAgencyPolicy, { policy_id: policyId }), {
|
||||||
|
method: endpoint.method,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishHostAgencyPolicy(policyId: EntityId): Promise<HostAgencyPolicyDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.publishHostAgencyPolicy;
|
||||||
|
return apiRequest<RawPolicy>(apiEndpointPath(API_OPERATIONS.publishHostAgencyPolicy, { policy_id: policyId }), {
|
||||||
|
method: endpoint.method,
|
||||||
|
}).then(normalizePolicy);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamSalaryPolicyLevelDto {
|
||||||
|
id?: number;
|
||||||
|
policyId?: number;
|
||||||
|
level: number;
|
||||||
|
thresholdUsd: string;
|
||||||
|
ratePercent: string;
|
||||||
|
salaryUsd: string;
|
||||||
|
status: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
createdAtMs?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamSalaryPolicyDto {
|
||||||
|
id: number;
|
||||||
|
appCode?: string;
|
||||||
|
policyType: "bd" | "admin";
|
||||||
|
name: string;
|
||||||
|
regionId: number;
|
||||||
|
status: string;
|
||||||
|
settlementTriggerMode: string;
|
||||||
|
effectiveFromMs?: number;
|
||||||
|
effectiveToMs?: number;
|
||||||
|
createdByAdminId?: number;
|
||||||
|
updatedByAdminId?: number;
|
||||||
|
createdAtMs?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
levels: TeamSalaryPolicyLevelDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamSalaryPolicyPayload {
|
||||||
|
policy_type: "bd" | "admin";
|
||||||
|
name: string;
|
||||||
|
region_id: number;
|
||||||
|
status: string;
|
||||||
|
settlement_trigger_mode: string;
|
||||||
|
effective_from_ms: number;
|
||||||
|
effective_to_ms: number;
|
||||||
|
description?: string;
|
||||||
|
levels: Array<{
|
||||||
|
level: number;
|
||||||
|
threshold_usd: string;
|
||||||
|
rate_percent: string;
|
||||||
|
salary_usd: string;
|
||||||
|
status: string;
|
||||||
|
sort_order: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawTeamPolicy = TeamSalaryPolicyDto & Record<string, unknown>;
|
||||||
|
type RawTeamLevel = TeamSalaryPolicyLevelDto & Record<string, unknown>;
|
||||||
|
|
||||||
|
export function listTeamSalaryPolicies(query: PageQuery = {}): Promise<ApiPage<TeamSalaryPolicyDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listTeamSalaryPolicies;
|
||||||
|
return apiRequest<ApiPage<RawTeamPolicy>>(apiEndpointPath(API_OPERATIONS.listTeamSalaryPolicies), {
|
||||||
|
method: endpoint.method,
|
||||||
|
query,
|
||||||
|
}).then((page) => ({
|
||||||
|
...page,
|
||||||
|
items: (page.items || []).map(normalizeTeamPolicy),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTeamSalaryPolicy(payload: TeamSalaryPolicyPayload): Promise<TeamSalaryPolicyDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.createTeamSalaryPolicy;
|
||||||
|
return apiRequest<RawTeamPolicy, TeamSalaryPolicyPayload>(apiEndpointPath(API_OPERATIONS.createTeamSalaryPolicy), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
}).then(normalizeTeamPolicy);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTeamSalaryPolicy(
|
||||||
|
policyId: EntityId,
|
||||||
|
payload: TeamSalaryPolicyPayload,
|
||||||
|
): Promise<TeamSalaryPolicyDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.updateTeamSalaryPolicy;
|
||||||
|
return apiRequest<RawTeamPolicy, TeamSalaryPolicyPayload>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.updateTeamSalaryPolicy, { policy_id: policyId }),
|
||||||
|
{
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
},
|
||||||
|
).then(normalizeTeamPolicy);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTeamSalaryPolicy(policyType: string, policyId: EntityId): Promise<unknown> {
|
||||||
|
const endpoint = API_ENDPOINTS.deleteTeamSalaryPolicy;
|
||||||
|
return apiRequest(apiEndpointPath(API_OPERATIONS.deleteTeamSalaryPolicy, { policy_id: policyId }), {
|
||||||
|
method: endpoint.method,
|
||||||
|
query: { policy_type: policyType },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostSalarySettlementUserDto {
|
||||||
|
userId: string;
|
||||||
|
displayUserId?: string;
|
||||||
|
username?: string;
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostSalarySettlementDto {
|
||||||
|
settlementId: string;
|
||||||
|
commandId: string;
|
||||||
|
transactionId: string;
|
||||||
|
settlementType: string;
|
||||||
|
userId: string;
|
||||||
|
user: HostSalarySettlementUserDto;
|
||||||
|
agencyOwnerUserId: string;
|
||||||
|
agencyOwner: HostSalarySettlementUserDto;
|
||||||
|
cycleKey: string;
|
||||||
|
policyId: number;
|
||||||
|
levelNo: number;
|
||||||
|
totalDiamonds: number;
|
||||||
|
hostSalaryUsdMinorDelta: number;
|
||||||
|
hostSalaryUsdDelta: string;
|
||||||
|
hostCoinRewardDelta: number;
|
||||||
|
agencySalaryUsdMinorDelta: number;
|
||||||
|
agencySalaryUsdDelta: string;
|
||||||
|
residualUsdMinorDelta: number;
|
||||||
|
residualUsdDelta: string;
|
||||||
|
status: string;
|
||||||
|
reason: string;
|
||||||
|
createdAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawSettlement = HostSalarySettlementDto & Record<string, unknown>;
|
||||||
|
|
||||||
|
export function listHostSalarySettlements(query: PageQuery = {}): Promise<ApiPage<HostSalarySettlementDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listHostSalarySettlements;
|
||||||
|
return apiRequest<ApiPage<RawSettlement>>(apiEndpointPath(API_OPERATIONS.listHostSalarySettlements), {
|
||||||
|
method: endpoint.method,
|
||||||
|
query,
|
||||||
|
}).then((page) => ({
|
||||||
|
...page,
|
||||||
|
items: (page.items || []).map(normalizeSettlement),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamSalarySettlementPendingDto {
|
||||||
|
policyType: "bd" | "admin";
|
||||||
|
userId: string;
|
||||||
|
user: HostSalarySettlementUserDto;
|
||||||
|
cycleKey: string;
|
||||||
|
regionId: number;
|
||||||
|
regionName?: string;
|
||||||
|
policyId: number;
|
||||||
|
policyName: string;
|
||||||
|
levelNo: number;
|
||||||
|
incomeUsdMinor: number;
|
||||||
|
incomeUsd: string;
|
||||||
|
salaryUsdMinorTarget: number;
|
||||||
|
salaryUsdTarget: string;
|
||||||
|
salaryUsdMinorDelta: number;
|
||||||
|
salaryUsdDelta: string;
|
||||||
|
settledLevelNo: number;
|
||||||
|
settledSalaryUsdMinor: number;
|
||||||
|
settledSalaryUsd: string;
|
||||||
|
lastSettledIncomeUsdMinor: number;
|
||||||
|
lastSettledIncomeUsd: string;
|
||||||
|
monthClosedAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamSalarySettlementRecordDto {
|
||||||
|
settlementId: string;
|
||||||
|
commandId: string;
|
||||||
|
transactionId: string;
|
||||||
|
policyType: "bd" | "admin";
|
||||||
|
triggerMode: string;
|
||||||
|
userId: string;
|
||||||
|
user: HostSalarySettlementUserDto;
|
||||||
|
cycleKey: string;
|
||||||
|
regionId: number;
|
||||||
|
policyId: number;
|
||||||
|
levelNo: number;
|
||||||
|
incomeUsdMinor: number;
|
||||||
|
incomeUsd: string;
|
||||||
|
salaryUsdMinorDelta: number;
|
||||||
|
salaryUsdDelta: string;
|
||||||
|
status: string;
|
||||||
|
reason: string;
|
||||||
|
createdAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamSalarySettlePayload {
|
||||||
|
policy_type: "bd" | "admin";
|
||||||
|
trigger_mode: "manual" | "automatic";
|
||||||
|
cycle_key: string;
|
||||||
|
region_id?: number;
|
||||||
|
country_code?: string;
|
||||||
|
user_ids?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamSalarySettleResult {
|
||||||
|
policyType: "bd" | "admin";
|
||||||
|
cycleKey: string;
|
||||||
|
requestedCount: number;
|
||||||
|
processedCount: number;
|
||||||
|
successCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
records: TeamSalarySettlementRecordDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawTeamPending = TeamSalarySettlementPendingDto & Record<string, unknown>;
|
||||||
|
type RawTeamRecord = TeamSalarySettlementRecordDto & Record<string, unknown>;
|
||||||
|
type RawTeamSettleResult = TeamSalarySettleResult & Record<string, unknown>;
|
||||||
|
|
||||||
|
export function listTeamSalarySettlementPending(
|
||||||
|
query: PageQuery = {},
|
||||||
|
): Promise<ApiPage<TeamSalarySettlementPendingDto>> {
|
||||||
|
// 新增结算接口尚未进入生成端点,先集中在本 feature API 封装手写路径,避免页面散落裸字符串。
|
||||||
|
return apiRequest<ApiPage<RawTeamPending>>("/v1/admin/team-salary-settlements/pending", {
|
||||||
|
method: "GET",
|
||||||
|
query,
|
||||||
|
}).then((page) => ({
|
||||||
|
...page,
|
||||||
|
items: (page.items || []).map(normalizeTeamPending),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function settleTeamSalary(payload: TeamSalarySettlePayload): Promise<TeamSalarySettleResult> {
|
||||||
|
// 批量结算是写钱包动作;payload 保持后端 snake_case,减少序列化时的字段转换风险。
|
||||||
|
return apiRequest<RawTeamSettleResult, TeamSalarySettlePayload>("/v1/admin/team-salary-settlements/settle", {
|
||||||
|
body: payload,
|
||||||
|
method: "POST",
|
||||||
|
}).then(normalizeTeamSettleResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listTeamSalarySettlementRecords(
|
||||||
|
query: PageQuery = {},
|
||||||
|
): Promise<ApiPage<TeamSalarySettlementRecordDto>> {
|
||||||
|
// 记录查询与待结算分开,避免运营查历史记录时触发候选重算。
|
||||||
|
return apiRequest<ApiPage<RawTeamRecord>>("/v1/admin/team-salary-settlements/records", {
|
||||||
|
method: "GET",
|
||||||
|
query,
|
||||||
|
}).then((page) => ({
|
||||||
|
...page,
|
||||||
|
items: (page.items || []).map(normalizeTeamRecord),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePolicy(item: RawPolicy): HostAgencyPolicyDto {
|
||||||
|
// 响应兼容 snake_case/camelCase,是为了后端 DTO 和前端内部模型之间只有这一处转换。
|
||||||
|
return {
|
||||||
|
id: numberValue(item.id),
|
||||||
|
appCode: stringValue(item.appCode ?? item.app_code),
|
||||||
|
name: stringValue(item.name),
|
||||||
|
regionId: numberValue(item.regionId ?? item.region_id),
|
||||||
|
status: stringValue(item.status) || "disabled",
|
||||||
|
settlementMode: stringValue(item.settlementMode ?? item.settlement_mode) || "daily",
|
||||||
|
settlementTriggerMode: stringValue(item.settlementTriggerMode ?? item.settlement_trigger_mode) || "automatic",
|
||||||
|
giftCoinToDiamondRatio: stringValue(item.giftCoinToDiamondRatio ?? item.gift_coin_to_diamond_ratio) || "1",
|
||||||
|
residualDiamondToUsdRate:
|
||||||
|
stringValue(item.residualDiamondToUsdRate ?? item.residual_diamond_to_usd_rate) || "0",
|
||||||
|
effectiveFromMs: numberValue(item.effectiveFromMs ?? item.effective_from_ms),
|
||||||
|
effectiveToMs: numberValue(item.effectiveToMs ?? item.effective_to_ms),
|
||||||
|
createdByAdminId: numberValue(item.createdByAdminId ?? item.created_by_admin_id),
|
||||||
|
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
|
||||||
|
publishStatus: stringValue(item.publishStatus ?? item.publish_status) || "draft",
|
||||||
|
publishError: stringValue(item.publishError ?? item.publish_error),
|
||||||
|
publishedAtMs: numberValue(item.publishedAtMs ?? item.published_at_ms),
|
||||||
|
publishedByAdminId: numberValue(item.publishedByAdminId ?? item.published_by_admin_id),
|
||||||
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
levels: arrayValue(item.levels).map(normalizeLevel),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSettlement(raw: RawSettlement): HostSalarySettlementDto {
|
||||||
|
const user = asRecord(raw.user);
|
||||||
|
const agencyOwner = asRecord(raw.agencyOwner ?? raw.agency_owner);
|
||||||
|
return {
|
||||||
|
settlementId: stringValue(raw.settlementId ?? raw.settlement_id),
|
||||||
|
commandId: stringValue(raw.commandId ?? raw.command_id),
|
||||||
|
transactionId: stringValue(raw.transactionId ?? raw.transaction_id),
|
||||||
|
settlementType: stringValue(raw.settlementType ?? raw.settlement_type),
|
||||||
|
userId: stringValue(raw.userId ?? raw.user_id),
|
||||||
|
user: normalizeSettlementUser(user),
|
||||||
|
agencyOwnerUserId: stringValue(raw.agencyOwnerUserId ?? raw.agency_owner_user_id),
|
||||||
|
agencyOwner: normalizeSettlementUser(agencyOwner),
|
||||||
|
cycleKey: stringValue(raw.cycleKey ?? raw.cycle_key),
|
||||||
|
policyId: numberValue(raw.policyId ?? raw.policy_id),
|
||||||
|
levelNo: numberValue(raw.levelNo ?? raw.level_no),
|
||||||
|
totalDiamonds: numberValue(raw.totalDiamonds ?? raw.total_diamonds),
|
||||||
|
hostSalaryUsdMinorDelta: numberValue(raw.hostSalaryUsdMinorDelta ?? raw.host_salary_usd_minor_delta),
|
||||||
|
hostSalaryUsdDelta: stringValue(raw.hostSalaryUsdDelta ?? raw.host_salary_usd_delta),
|
||||||
|
hostCoinRewardDelta: numberValue(raw.hostCoinRewardDelta ?? raw.host_coin_reward_delta),
|
||||||
|
agencySalaryUsdMinorDelta: numberValue(raw.agencySalaryUsdMinorDelta ?? raw.agency_salary_usd_minor_delta),
|
||||||
|
agencySalaryUsdDelta: stringValue(raw.agencySalaryUsdDelta ?? raw.agency_salary_usd_delta),
|
||||||
|
residualUsdMinorDelta: numberValue(raw.residualUsdMinorDelta ?? raw.residual_usd_minor_delta),
|
||||||
|
residualUsdDelta: stringValue(raw.residualUsdDelta ?? raw.residual_usd_delta),
|
||||||
|
status: stringValue(raw.status) || "succeeded",
|
||||||
|
reason: stringValue(raw.reason),
|
||||||
|
createdAtMs: numberValue(raw.createdAtMs ?? raw.created_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSettlementUser(raw: Record<string, unknown>): HostSalarySettlementUserDto {
|
||||||
|
return {
|
||||||
|
userId: stringValue(raw.userId ?? raw.user_id),
|
||||||
|
displayUserId: stringValue(raw.displayUserId ?? raw.display_user_id),
|
||||||
|
username: stringValue(raw.username),
|
||||||
|
avatar: stringValue(raw.avatar),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTeamPending(raw: RawTeamPending): TeamSalarySettlementPendingDto {
|
||||||
|
const user = asRecord(raw.user);
|
||||||
|
// 待结算 DTO 兼容 snake_case/camelCase,方便后端 Go JSON tag 与前端内部模型独立演进。
|
||||||
|
return {
|
||||||
|
policyType: (stringValue(raw.policyType ?? raw.policy_type) || "bd") as "bd" | "admin",
|
||||||
|
userId: stringValue(raw.userId ?? raw.user_id),
|
||||||
|
user: normalizeSettlementUser(user),
|
||||||
|
cycleKey: stringValue(raw.cycleKey ?? raw.cycle_key),
|
||||||
|
regionId: numberValue(raw.regionId ?? raw.region_id),
|
||||||
|
regionName: stringValue(raw.regionName ?? raw.region_name),
|
||||||
|
policyId: numberValue(raw.policyId ?? raw.policy_id),
|
||||||
|
policyName: stringValue(raw.policyName ?? raw.policy_name),
|
||||||
|
levelNo: numberValue(raw.levelNo ?? raw.level_no),
|
||||||
|
incomeUsdMinor: numberValue(raw.incomeUsdMinor ?? raw.income_usd_minor),
|
||||||
|
incomeUsd: stringValue(raw.incomeUsd ?? raw.income_usd),
|
||||||
|
salaryUsdMinorTarget: numberValue(raw.salaryUsdMinorTarget ?? raw.salary_usd_minor_target),
|
||||||
|
salaryUsdTarget: stringValue(raw.salaryUsdTarget ?? raw.salary_usd_target),
|
||||||
|
salaryUsdMinorDelta: numberValue(raw.salaryUsdMinorDelta ?? raw.salary_usd_minor_delta),
|
||||||
|
salaryUsdDelta: stringValue(raw.salaryUsdDelta ?? raw.salary_usd_delta),
|
||||||
|
settledLevelNo: numberValue(raw.settledLevelNo ?? raw.settled_level_no),
|
||||||
|
settledSalaryUsdMinor: numberValue(raw.settledSalaryUsdMinor ?? raw.settled_salary_usd_minor),
|
||||||
|
settledSalaryUsd: stringValue(raw.settledSalaryUsd ?? raw.settled_salary_usd),
|
||||||
|
lastSettledIncomeUsdMinor: numberValue(raw.lastSettledIncomeUsdMinor ?? raw.last_settled_income_usd_minor),
|
||||||
|
lastSettledIncomeUsd: stringValue(raw.lastSettledIncomeUsd ?? raw.last_settled_income_usd),
|
||||||
|
monthClosedAtMs: numberValue(raw.monthClosedAtMs ?? raw.month_closed_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTeamRecord(raw: RawTeamRecord): TeamSalarySettlementRecordDto {
|
||||||
|
const user = asRecord(raw.user);
|
||||||
|
// 金额字段同时保留 minor 和格式化字符串;表格展示用字符串,后续导出/统计可用 minor。
|
||||||
|
return {
|
||||||
|
settlementId: stringValue(raw.settlementId ?? raw.settlement_id),
|
||||||
|
commandId: stringValue(raw.commandId ?? raw.command_id),
|
||||||
|
transactionId: stringValue(raw.transactionId ?? raw.transaction_id),
|
||||||
|
policyType: (stringValue(raw.policyType ?? raw.policy_type) || "bd") as "bd" | "admin",
|
||||||
|
triggerMode: stringValue(raw.triggerMode ?? raw.trigger_mode),
|
||||||
|
userId: stringValue(raw.userId ?? raw.user_id),
|
||||||
|
user: normalizeSettlementUser(user),
|
||||||
|
cycleKey: stringValue(raw.cycleKey ?? raw.cycle_key),
|
||||||
|
regionId: numberValue(raw.regionId ?? raw.region_id),
|
||||||
|
policyId: numberValue(raw.policyId ?? raw.policy_id),
|
||||||
|
levelNo: numberValue(raw.levelNo ?? raw.level_no),
|
||||||
|
incomeUsdMinor: numberValue(raw.incomeUsdMinor ?? raw.income_usd_minor),
|
||||||
|
incomeUsd: stringValue(raw.incomeUsd ?? raw.income_usd),
|
||||||
|
salaryUsdMinorDelta: numberValue(raw.salaryUsdMinorDelta ?? raw.salary_usd_minor_delta),
|
||||||
|
salaryUsdDelta: stringValue(raw.salaryUsdDelta ?? raw.salary_usd_delta),
|
||||||
|
status: stringValue(raw.status) || "succeeded",
|
||||||
|
reason: stringValue(raw.reason),
|
||||||
|
createdAtMs: numberValue(raw.createdAtMs ?? raw.created_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTeamSettleResult(raw: RawTeamSettleResult): TeamSalarySettleResult {
|
||||||
|
// 批量结算结果保留 records,结算后需要立即展示或定位 settlement_id 时无需再次查列表。
|
||||||
|
return {
|
||||||
|
policyType: (stringValue(raw.policyType ?? raw.policy_type) || "bd") as "bd" | "admin",
|
||||||
|
cycleKey: stringValue(raw.cycleKey ?? raw.cycle_key),
|
||||||
|
requestedCount: numberValue(raw.requestedCount ?? raw.requested_count),
|
||||||
|
processedCount: numberValue(raw.processedCount ?? raw.processed_count),
|
||||||
|
successCount: numberValue(raw.successCount ?? raw.success_count),
|
||||||
|
skippedCount: numberValue(raw.skippedCount ?? raw.skipped_count),
|
||||||
|
failureCount: numberValue(raw.failureCount ?? raw.failure_count),
|
||||||
|
records: arrayValue(raw.records).map((item) => normalizeTeamRecord(asRecord(item) as RawTeamRecord)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTeamPolicy(item: RawTeamPolicy): TeamSalaryPolicyDto {
|
||||||
|
return {
|
||||||
|
id: numberValue(item.id),
|
||||||
|
appCode: stringValue(item.appCode ?? item.app_code),
|
||||||
|
policyType: (stringValue(item.policyType ?? item.policy_type) || "bd") as "bd" | "admin",
|
||||||
|
name: stringValue(item.name),
|
||||||
|
regionId: numberValue(item.regionId ?? item.region_id),
|
||||||
|
status: stringValue(item.status) || "disabled",
|
||||||
|
settlementTriggerMode: stringValue(item.settlementTriggerMode ?? item.settlement_trigger_mode) || "automatic",
|
||||||
|
effectiveFromMs: numberValue(item.effectiveFromMs ?? item.effective_from_ms),
|
||||||
|
effectiveToMs: numberValue(item.effectiveToMs ?? item.effective_to_ms),
|
||||||
|
createdByAdminId: numberValue(item.createdByAdminId ?? item.created_by_admin_id),
|
||||||
|
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
|
||||||
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
levels: arrayValue(item.levels).map(normalizeTeamLevel),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTeamLevel(raw: unknown): TeamSalaryPolicyLevelDto {
|
||||||
|
const item = asRecord(raw) as RawTeamLevel;
|
||||||
|
return {
|
||||||
|
id: numberValue(item.id),
|
||||||
|
policyId: numberValue(item.policyId ?? item.policy_id),
|
||||||
|
level: numberValue(item.level),
|
||||||
|
thresholdUsd: stringValue(item.thresholdUsd ?? item.threshold_usd),
|
||||||
|
ratePercent: stringValue(item.ratePercent ?? item.rate_percent),
|
||||||
|
salaryUsd: stringValue(item.salaryUsd ?? item.salary_usd),
|
||||||
|
status: stringValue(item.status) || "active",
|
||||||
|
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
|
||||||
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLevel(raw: unknown): HostAgencyPolicyLevelDto {
|
||||||
|
const item = asRecord(raw) as RawLevel;
|
||||||
|
// 金额字段保持字符串,避免前端 Number 格式化吞掉小数位或引入精度问题。
|
||||||
|
return {
|
||||||
|
id: numberValue(item.id),
|
||||||
|
policyId: numberValue(item.policyId ?? item.policy_id),
|
||||||
|
level: numberValue(item.level),
|
||||||
|
requiredDiamonds: numberValue(item.requiredDiamonds ?? item.required_diamonds),
|
||||||
|
hostSalaryUsd: stringValue(item.hostSalaryUsd ?? item.host_salary_usd),
|
||||||
|
hostCoinReward: numberValue(item.hostCoinReward ?? item.host_coin_reward),
|
||||||
|
agencySalaryUsd: stringValue(item.agencySalaryUsd ?? item.agency_salary_usd),
|
||||||
|
totalSalaryUsd: stringValue(item.totalSalaryUsd ?? item.total_salary_usd),
|
||||||
|
status: stringValue(item.status) || "active",
|
||||||
|
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
|
||||||
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayValue(value: unknown): unknown[] {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown) {
|
||||||
|
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown) {
|
||||||
|
// 列表展示允许缺省值兜底为 0;真正的必填和正数校验在表单 schema 与后端 service 完成。
|
||||||
|
const number = Number(value || 0);
|
||||||
|
return Number.isFinite(number) ? number : 0;
|
||||||
|
}
|
||||||
@ -0,0 +1,253 @@
|
|||||||
|
import Add from "@mui/icons-material/Add";
|
||||||
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||||
|
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||||
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
|
import styles from "@/features/host-agency-policy/host-agency-policy.module.css";
|
||||||
|
|
||||||
|
const settlementModeOptions = [
|
||||||
|
["daily", "日结"],
|
||||||
|
["half_month", "半月结算"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const settlementTriggerOptions = [
|
||||||
|
["automatic", "自动结算"],
|
||||||
|
["manual", "手动结算"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function HostAgencyPolicyDrawer({ page }) {
|
||||||
|
const saving = page.loadingAction === "policy-create" || page.loadingAction === "policy-update";
|
||||||
|
const disabled = saving || (!page.editingPolicy ? !page.abilities.canCreate : !page.abilities.canUpdate);
|
||||||
|
return (
|
||||||
|
<SideDrawer
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button disabled={saving} onClick={page.closeDrawer}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={disabled || page.loadingRegions} type="submit" variant="primary">
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
as="form"
|
||||||
|
className={styles.policyDrawer}
|
||||||
|
contentClassName={styles.drawerBody}
|
||||||
|
open={page.drawerOpen}
|
||||||
|
title={page.editingPolicy ? "编辑工资政策" : "新增工资政策"}
|
||||||
|
width="wide"
|
||||||
|
onClose={saving ? undefined : page.closeDrawer}
|
||||||
|
onSubmit={page.submitPolicy}
|
||||||
|
>
|
||||||
|
<PolicyBaseFields disabled={disabled} page={page} />
|
||||||
|
<PolicyLevelEditor disabled={disabled} page={page} />
|
||||||
|
</SideDrawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolicyBaseFields({ disabled, page }) {
|
||||||
|
const form = page.form;
|
||||||
|
const setForm = (patch) => page.setForm((current) => ({ ...current, ...patch }));
|
||||||
|
return (
|
||||||
|
<section className={[styles.formSection, styles.baseSection].join(" ")}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<h3>基础配置</h3>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGrid}>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
className={styles.fieldWide}
|
||||||
|
disabled={disabled}
|
||||||
|
label="政策名称"
|
||||||
|
size="small"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) => setForm({ name: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
select
|
||||||
|
className={styles.fieldWide}
|
||||||
|
disabled={disabled || page.loadingRegions}
|
||||||
|
label="适用区域"
|
||||||
|
size="small"
|
||||||
|
value={form.regionId}
|
||||||
|
onChange={(event) => setForm({ regionId: event.target.value })}
|
||||||
|
>
|
||||||
|
{page.regionOptions.map((region) => (
|
||||||
|
<MenuItem key={region.value} value={region.value}>
|
||||||
|
{region.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
select
|
||||||
|
className={styles.fieldCompact}
|
||||||
|
disabled={disabled}
|
||||||
|
label="结算方式"
|
||||||
|
size="small"
|
||||||
|
value={form.settlementMode}
|
||||||
|
onChange={(event) => setForm({ settlementMode: event.target.value })}
|
||||||
|
>
|
||||||
|
{settlementModeOptions.map(([value, label]) => (
|
||||||
|
<MenuItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
select
|
||||||
|
className={styles.fieldCompact}
|
||||||
|
disabled={disabled}
|
||||||
|
label="触发方式"
|
||||||
|
size="small"
|
||||||
|
value={form.settlementTriggerMode}
|
||||||
|
onChange={(event) => setForm({ settlementTriggerMode: event.target.value })}
|
||||||
|
>
|
||||||
|
{settlementTriggerOptions.map(([value, label]) => (
|
||||||
|
<MenuItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
className={styles.fieldCompact}
|
||||||
|
disabled={disabled}
|
||||||
|
label="金币转钻石比例"
|
||||||
|
size="small"
|
||||||
|
value={form.giftCoinToDiamondRatio}
|
||||||
|
onChange={(event) => setForm({ giftCoinToDiamondRatio: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
className={styles.fieldCompact}
|
||||||
|
disabled={disabled}
|
||||||
|
label="剩余钻石转美元比例"
|
||||||
|
size="small"
|
||||||
|
value={form.residualDiamondToUsdRate}
|
||||||
|
onChange={(event) => setForm({ residualDiamondToUsdRate: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TimeRangeFilter
|
||||||
|
className={styles.timeRangeField}
|
||||||
|
disabled={disabled}
|
||||||
|
label="生效时间"
|
||||||
|
value={{ endMs: form.effectiveTo, startMs: form.effectiveFrom }}
|
||||||
|
onChange={(range) => setForm({ effectiveFrom: range.startMs, effectiveTo: range.endMs })}
|
||||||
|
/>
|
||||||
|
<div className={styles.enabledField}>
|
||||||
|
<span className={styles.toggleLabel}>政策状态</span>
|
||||||
|
<AdminSwitch
|
||||||
|
checked={form.enabled}
|
||||||
|
checkedLabel="启用"
|
||||||
|
disabled={disabled}
|
||||||
|
label="政策状态"
|
||||||
|
uncheckedLabel="停用"
|
||||||
|
onChange={(event) => setForm({ enabled: event.target.checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolicyLevelEditor({ disabled, page }) {
|
||||||
|
return (
|
||||||
|
<section className={[styles.formSection, styles.levelSection].join(" ")}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<h3>等级配置</h3>
|
||||||
|
<Button
|
||||||
|
className={styles.addLevelButton}
|
||||||
|
disabled={disabled}
|
||||||
|
startIcon={<Add fontSize="small" />}
|
||||||
|
onClick={page.addLevel}
|
||||||
|
>
|
||||||
|
添加等级
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.levelTable}>
|
||||||
|
<div className={styles.levelHeader}>
|
||||||
|
<span>等级</span>
|
||||||
|
<span>所需钻石</span>
|
||||||
|
<span>主播美元</span>
|
||||||
|
<span>金币奖励</span>
|
||||||
|
<span>代理美元</span>
|
||||||
|
<span>状态</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
<div className={styles.levelList}>
|
||||||
|
{page.form.levels.map((level, index) => (
|
||||||
|
<div className={styles.levelRow} key={`${level.level}-${index}`}>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={level.level}
|
||||||
|
onChange={(event) =>
|
||||||
|
page.updateLevel(index, {
|
||||||
|
level: event.target.value,
|
||||||
|
sortOrder: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={level.requiredDiamonds}
|
||||||
|
onChange={(event) => page.updateLevel(index, { requiredDiamonds: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
size="small"
|
||||||
|
value={level.hostSalaryUsd}
|
||||||
|
onChange={(event) => page.updateLevel(index, { hostSalaryUsd: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={level.hostCoinReward}
|
||||||
|
onChange={(event) => page.updateLevel(index, { hostCoinReward: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
size="small"
|
||||||
|
value={level.agencySalaryUsd}
|
||||||
|
onChange={(event) => page.updateLevel(index, { agencySalaryUsd: event.target.value })}
|
||||||
|
/>
|
||||||
|
<AdminSwitch
|
||||||
|
checked={level.status === "active"}
|
||||||
|
checkedLabel="启用"
|
||||||
|
disabled={disabled}
|
||||||
|
label={`等级 ${level.level || index + 1} 状态`}
|
||||||
|
uncheckedLabel="停用"
|
||||||
|
onChange={(event) =>
|
||||||
|
page.updateLevel(index, { status: event.target.checked ? "active" : "disabled" })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
disabled={disabled || page.form.levels.length <= 1}
|
||||||
|
label="删除等级"
|
||||||
|
sx={{ height: 34, minHeight: 34, width: 34 }}
|
||||||
|
onClick={() => page.removeLevel(index)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,216 @@
|
|||||||
|
import Add from "@mui/icons-material/Add";
|
||||||
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||||
|
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||||
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
|
import styles from "@/features/host-agency-policy/host-agency-policy.module.css";
|
||||||
|
|
||||||
|
const settlementTriggerOptions = [
|
||||||
|
["automatic", "自动结算"],
|
||||||
|
["manual", "手动结算"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TeamSalaryPolicyDrawer({ page }) {
|
||||||
|
const saving = page.loadingAction === "team-policy-create" || page.loadingAction === "team-policy-update";
|
||||||
|
const disabled = saving || (!page.editingPolicy ? !page.abilities.canTeamCreate : !page.abilities.canTeamUpdate);
|
||||||
|
return (
|
||||||
|
<SideDrawer
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button disabled={saving} onClick={page.closeDrawer}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={disabled || page.loadingRegions} type="submit" variant="primary">
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
as="form"
|
||||||
|
className={styles.policyDrawer}
|
||||||
|
contentClassName={styles.drawerBody}
|
||||||
|
open={page.drawerOpen}
|
||||||
|
title={
|
||||||
|
page.editingPolicy
|
||||||
|
? `编辑${policyTitle(page.policyType)}政策`
|
||||||
|
: `新增${policyTitle(page.policyType)}政策`
|
||||||
|
}
|
||||||
|
width="wide"
|
||||||
|
onClose={saving ? undefined : page.closeDrawer}
|
||||||
|
onSubmit={page.submitPolicy}
|
||||||
|
>
|
||||||
|
<TeamPolicyBaseFields disabled={disabled} page={page} />
|
||||||
|
<TeamPolicyLevelEditor disabled={disabled} page={page} />
|
||||||
|
</SideDrawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TeamPolicyBaseFields({ disabled, page }) {
|
||||||
|
const form = page.form;
|
||||||
|
const setForm = (patch) => page.setForm((current) => ({ ...current, ...patch }));
|
||||||
|
return (
|
||||||
|
<section className={[styles.formSection, styles.baseSection].join(" ")}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<h3>基础配置</h3>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGrid}>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
className={styles.fieldWide}
|
||||||
|
disabled={disabled}
|
||||||
|
label="政策名称"
|
||||||
|
size="small"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) => setForm({ name: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
select
|
||||||
|
className={styles.fieldWide}
|
||||||
|
disabled={disabled || page.loadingRegions}
|
||||||
|
label="适用区域"
|
||||||
|
size="small"
|
||||||
|
value={form.regionId}
|
||||||
|
onChange={(event) => setForm({ regionId: event.target.value })}
|
||||||
|
>
|
||||||
|
{page.regionOptions.map((region) => (
|
||||||
|
<MenuItem key={region.value} value={region.value}>
|
||||||
|
{region.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
select
|
||||||
|
className={styles.fieldCompact}
|
||||||
|
disabled={disabled}
|
||||||
|
label="触发方式"
|
||||||
|
size="small"
|
||||||
|
value={form.settlementTriggerMode}
|
||||||
|
onChange={(event) => setForm({ settlementTriggerMode: event.target.value })}
|
||||||
|
>
|
||||||
|
{settlementTriggerOptions.map(([value, label]) => (
|
||||||
|
<MenuItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TimeRangeFilter
|
||||||
|
className={styles.timeRangeField}
|
||||||
|
disabled={disabled}
|
||||||
|
label="生效时间"
|
||||||
|
value={{ endMs: form.effectiveTo, startMs: form.effectiveFrom }}
|
||||||
|
onChange={(range) => setForm({ effectiveFrom: range.startMs, effectiveTo: range.endMs })}
|
||||||
|
/>
|
||||||
|
<div className={styles.enabledField}>
|
||||||
|
<span className={styles.toggleLabel}>政策状态</span>
|
||||||
|
<AdminSwitch
|
||||||
|
checked={form.enabled}
|
||||||
|
checkedLabel="启用"
|
||||||
|
disabled={disabled}
|
||||||
|
label="政策状态"
|
||||||
|
uncheckedLabel="停用"
|
||||||
|
onChange={(event) => setForm({ enabled: event.target.checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TeamPolicyLevelEditor({ disabled, page }) {
|
||||||
|
return (
|
||||||
|
<section className={[styles.formSection, styles.levelSection].join(" ")}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<h3>等级配置</h3>
|
||||||
|
<Button
|
||||||
|
className={styles.addLevelButton}
|
||||||
|
disabled={disabled}
|
||||||
|
startIcon={<Add fontSize="small" />}
|
||||||
|
onClick={page.addLevel}
|
||||||
|
>
|
||||||
|
添加等级
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.levelTable}>
|
||||||
|
<div className={styles.teamLevelHeader}>
|
||||||
|
<span>等级</span>
|
||||||
|
<span>{thresholdLabel(page.policyType)}</span>
|
||||||
|
<span>比例%</span>
|
||||||
|
<span>{policyTitle(page.policyType)}美元</span>
|
||||||
|
<span>状态</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
<div className={styles.levelList}>
|
||||||
|
{page.form.levels.map((level, index) => (
|
||||||
|
<div className={styles.teamLevelRow} key={`${level.level}-${index}`}>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={level.level}
|
||||||
|
onChange={(event) =>
|
||||||
|
page.updateLevel(index, {
|
||||||
|
level: event.target.value,
|
||||||
|
sortOrder: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
size="small"
|
||||||
|
value={level.thresholdUsd}
|
||||||
|
onChange={(event) => page.updateLevel(index, { thresholdUsd: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
size="small"
|
||||||
|
value={level.ratePercent}
|
||||||
|
onChange={(event) => page.updateLevel(index, { ratePercent: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
size="small"
|
||||||
|
value={level.salaryUsd}
|
||||||
|
onChange={(event) => page.updateLevel(index, { salaryUsd: event.target.value })}
|
||||||
|
/>
|
||||||
|
<AdminSwitch
|
||||||
|
checked={level.status === "active"}
|
||||||
|
checkedLabel="启用"
|
||||||
|
disabled={disabled}
|
||||||
|
label={`等级 ${level.level || index + 1} 状态`}
|
||||||
|
uncheckedLabel="停用"
|
||||||
|
onChange={(event) =>
|
||||||
|
page.updateLevel(index, { status: event.target.checked ? "active" : "disabled" })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
disabled={disabled || page.form.levels.length <= 1}
|
||||||
|
label="删除等级"
|
||||||
|
sx={{ height: 34, minHeight: 34, width: 34 }}
|
||||||
|
onClick={() => page.removeLevel(index)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function policyTitle(policyType) {
|
||||||
|
return policyType === "bd" ? "BD" : "Admin";
|
||||||
|
}
|
||||||
|
|
||||||
|
function thresholdLabel(policyType) {
|
||||||
|
return policyType === "bd" ? "下属主播工资" : "BD工资";
|
||||||
|
}
|
||||||
362
src/features/host-agency-policy/hooks/useHostAgencyPolicyPage.js
Normal file
362
src/features/host-agency-policy/hooks/useHostAgencyPolicyPage.js
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { toPageQuery } from "@/shared/api/query";
|
||||||
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
|
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||||
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
import {
|
||||||
|
createHostAgencyPolicy,
|
||||||
|
deleteHostAgencyPolicy,
|
||||||
|
listHostAgencyPolicies,
|
||||||
|
publishHostAgencyPolicy,
|
||||||
|
updateHostAgencyPolicy,
|
||||||
|
} from "@/features/host-agency-policy/api";
|
||||||
|
import { useHostAgencyPolicyAbilities } from "@/features/host-agency-policy/permissions.js";
|
||||||
|
import { hostAgencyPolicyFormSchema } from "@/features/host-agency-policy/schema";
|
||||||
|
|
||||||
|
const pageSize = 50;
|
||||||
|
|
||||||
|
// 产品文档给出的 Lalu 首版等级表:每项分别是等级、钻石门槛、主播累计美元、主播累计金币奖励、代理累计美元。
|
||||||
|
// 这些值只用于“新建政策”的表单预填,编辑已有政策时始终以后端返回的配置为准。
|
||||||
|
const defaultLevels = [
|
||||||
|
[1, 350000, "1.5", 90000, "0.5"],
|
||||||
|
[2, 700000, "3", 198000, "0.8"],
|
||||||
|
[3, 1180000, "4", 350000, "1.3"],
|
||||||
|
[4, 1770000, "6", 531000, "1.6"],
|
||||||
|
[5, 2660000, "9", 800000, "2.5"],
|
||||||
|
[6, 4000000, "13", 1200000, "4"],
|
||||||
|
[7, 6000000, "20", 1800000, "6"],
|
||||||
|
[8, 9000000, "30", 2700000, "9"],
|
||||||
|
[9, 13500000, "45", 4050000, "13"],
|
||||||
|
[10, 20260000, "67", 6078000, "19"],
|
||||||
|
[11, 30390000, "100", 9200000, "29"],
|
||||||
|
[12, 45590000, "152", 13600000, "43"],
|
||||||
|
[13, 68380000, "182", 25000000, "65"],
|
||||||
|
[14, 102580000, "342", 30700000, "97"],
|
||||||
|
[15, 153870000, "513", 46000000, "146"],
|
||||||
|
[16, 230800000, "768", 69200000, "219"],
|
||||||
|
[17, 346200000, "1151", 103800000, "328"],
|
||||||
|
[18, 519310000, "1727", 155700000, "493"],
|
||||||
|
[19, 778970000, "2589", 233700000, "739"],
|
||||||
|
[20, 1168450000, "3884", 350500000, "1108"],
|
||||||
|
[21, 1752680000, "5826", 525800000, "1165"],
|
||||||
|
[22, 2629030000, "8739", 788700000, "2494"],
|
||||||
|
[23, 3943540000, "13109", 1183000000, "3741"],
|
||||||
|
[24, 5915310000, "19664", 1774500000, "5611"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const emptyForm = (policy = null) => ({
|
||||||
|
// 后端用 epoch ms 保存生效范围;表单里用空字符串表示“未设置”,方便时间控件展示占位。
|
||||||
|
effectiveFrom: normalizeTimeMs(policy?.effectiveFromMs),
|
||||||
|
effectiveTo: normalizeTimeMs(policy?.effectiveToMs),
|
||||||
|
// 新建默认停用,运营保存草稿后再手动启用,避免半配置状态影响结算匹配。
|
||||||
|
enabled: policy ? policy.status === "active" : false,
|
||||||
|
giftCoinToDiamondRatio: policy?.giftCoinToDiamondRatio || "1",
|
||||||
|
// 编辑时保留后端等级;新建时一次铺满 24 个默认等级,减少运营逐级录入成本。
|
||||||
|
levels: policy?.levels?.length ? policy.levels.map(levelToForm) : defaultLevels.map(defaultLevelToForm),
|
||||||
|
name: policy?.name || "Lalu Host & Agency Salary Policy",
|
||||||
|
regionId: policy?.regionId ? String(policy.regionId) : "",
|
||||||
|
residualDiamondToUsdRate: policy?.residualDiamondToUsdRate || "0",
|
||||||
|
settlementMode: policy?.settlementMode || "daily",
|
||||||
|
settlementTriggerMode: policy?.settlementTriggerMode || "automatic",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useHostAgencyPolicyPage() {
|
||||||
|
const abilities = useHostAgencyPolicyAbilities();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [regionId, setRegionId] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [settlementMode, setSettlementMode] = useState("");
|
||||||
|
const [settlementTriggerMode, setSettlementTriggerMode] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [form, setForm] = useState(() => emptyForm());
|
||||||
|
const [editingPolicy, setEditingPolicy] = useState(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
|
// 列表筛选和表单状态分开维护,避免打开抽屉编辑时污染当前列表查询条件。
|
||||||
|
const filters = useMemo(
|
||||||
|
() => ({
|
||||||
|
keyword: query,
|
||||||
|
region_id: regionId,
|
||||||
|
settlement_mode: settlementMode,
|
||||||
|
settlement_trigger_mode: settlementTriggerMode,
|
||||||
|
status,
|
||||||
|
}),
|
||||||
|
[query, regionId, settlementMode, settlementTriggerMode, status],
|
||||||
|
);
|
||||||
|
const result = usePaginatedQuery({
|
||||||
|
errorMessage: "加载工资政策失败",
|
||||||
|
fetcher: listHostAgencyPolicies,
|
||||||
|
filters,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
// queryKey 带上规整后的分页参数,筛选项变化时能稳定触发重新请求。
|
||||||
|
queryKey: ["host-agency-policy", toPageQuery({ ...filters, page, pageSize })],
|
||||||
|
});
|
||||||
|
const regionLabels = useMemo(
|
||||||
|
() =>
|
||||||
|
regionOptions.reduce((labels, item) => {
|
||||||
|
// 后端只返回 region_id,页面展示名称依赖区域配置接口做本地映射。
|
||||||
|
labels[Number(item.regionId)] = item.label;
|
||||||
|
return labels;
|
||||||
|
}, {}),
|
||||||
|
[regionOptions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingPolicy(null);
|
||||||
|
setForm(emptyForm());
|
||||||
|
setDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (policy) => {
|
||||||
|
setEditingPolicy(policy);
|
||||||
|
setForm(emptyForm(policy));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDrawer = () => {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
setEditingPolicy(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitPolicy = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const editing = Boolean(editingPolicy?.id);
|
||||||
|
await runAction(
|
||||||
|
editing ? "policy-update" : "policy-create",
|
||||||
|
editing ? "工资政策已更新" : "工资政策已创建",
|
||||||
|
async () => {
|
||||||
|
// 保存前先跑前端 schema,尽早拦截等级递增和金额格式问题;后端仍保留同款强校验。
|
||||||
|
const payload = buildPayload(parseForm(hostAgencyPolicyFormSchema, form));
|
||||||
|
if (editing) {
|
||||||
|
await updateHostAgencyPolicy(editingPolicy.id, payload);
|
||||||
|
} else {
|
||||||
|
await createHostAgencyPolicy(payload);
|
||||||
|
}
|
||||||
|
closeDrawer();
|
||||||
|
setForm(emptyForm());
|
||||||
|
await result.reload();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePolicy = async (policy) => {
|
||||||
|
if (!policy?.id || !abilities.canDelete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction(`policy-delete-${policy.id}`, "工资政策已删除", async () => {
|
||||||
|
await deleteHostAgencyPolicy(policy.id);
|
||||||
|
await result.reload();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishPolicy = async (policy) => {
|
||||||
|
if (!policy?.id || !abilities.canPublish) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction(`policy-publish-${policy.id}`, "工资政策已发布到结算运行表", async () => {
|
||||||
|
// 发布是后台草稿配置到 wallet 运行快照的唯一入口,结算任务只读取发布后的运行表。
|
||||||
|
await publishHostAgencyPolicy(policy.id);
|
||||||
|
await result.reload();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePolicyStatus = async (policy, nextEnabled = policy.status !== "active") => {
|
||||||
|
if (!policy?.id || !abilities.canUpdate || (policy.status === "active") === nextEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction(`policy-status-${policy.id}`, nextEnabled ? "工资政策已启用" : "工资政策已停用", async () => {
|
||||||
|
// 启停只改变 status,其余字段走完整 payload,确保后端 PUT 整包替换时不会丢等级配置。
|
||||||
|
await updateHostAgencyPolicy(policy.id, buildPayload({ ...emptyForm(policy), enabled: nextEnabled }));
|
||||||
|
await result.reload();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setQuery("");
|
||||||
|
setRegionId("");
|
||||||
|
setStatus("");
|
||||||
|
setSettlementMode("");
|
||||||
|
setSettlementTriggerMode("");
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLevel = (index, patch) => {
|
||||||
|
// 等级表使用受控数组,单格编辑只更新当前行,保存时再统一排序和校验。
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
levels: current.levels.map((level, levelIndex) => (levelIndex === index ? { ...level, ...patch } : level)),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addLevel = () => {
|
||||||
|
setForm((current) => {
|
||||||
|
const last = current.levels[current.levels.length - 1];
|
||||||
|
const nextLevel = Number(last?.level || current.levels.length) + 1;
|
||||||
|
// 新增等级沿用上一行金额作为起点,运营只需要调整差异字段;最终递增规则仍由 schema/backend 保底。
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
levels: [
|
||||||
|
...current.levels,
|
||||||
|
{
|
||||||
|
agencySalaryUsd: last?.agencySalaryUsd || "0",
|
||||||
|
hostCoinReward: last?.hostCoinReward || "0",
|
||||||
|
hostSalaryUsd: last?.hostSalaryUsd || "0",
|
||||||
|
level: String(nextLevel),
|
||||||
|
requiredDiamonds: "",
|
||||||
|
sortOrder: String(nextLevel),
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLevel = (index) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
levels: current.levels.filter((_, levelIndex) => levelIndex !== index),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAction = async (action, successMessage, submitter) => {
|
||||||
|
// loadingAction 带资源 ID,删除/启停这类行内操作不会把整页按钮全部锁死。
|
||||||
|
setLoadingAction(action);
|
||||||
|
try {
|
||||||
|
await submitter();
|
||||||
|
showToast(successMessage, "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "操作失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
abilities,
|
||||||
|
addLevel,
|
||||||
|
changeQuery: (value) => {
|
||||||
|
setQuery(value);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
changeRegionId: (value) => {
|
||||||
|
setRegionId(value);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
changeSettlementMode: (value) => {
|
||||||
|
setSettlementMode(value);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
changeSettlementTriggerMode: (value) => {
|
||||||
|
setSettlementTriggerMode(value);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
changeStatus: (value) => {
|
||||||
|
setStatus(value);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
closeDrawer,
|
||||||
|
data: result.data,
|
||||||
|
drawerOpen,
|
||||||
|
editingPolicy,
|
||||||
|
error: result.error,
|
||||||
|
form,
|
||||||
|
loading: result.loading,
|
||||||
|
loadingAction,
|
||||||
|
loadingRegions,
|
||||||
|
openCreate,
|
||||||
|
openEdit,
|
||||||
|
page,
|
||||||
|
publishPolicy,
|
||||||
|
query,
|
||||||
|
regionId,
|
||||||
|
regionLabels,
|
||||||
|
regionOptions,
|
||||||
|
reload: result.reload,
|
||||||
|
removeLevel,
|
||||||
|
removePolicy,
|
||||||
|
resetFilters,
|
||||||
|
setForm,
|
||||||
|
setPage,
|
||||||
|
settlementMode,
|
||||||
|
settlementTriggerMode,
|
||||||
|
status,
|
||||||
|
submitPolicy,
|
||||||
|
togglePolicyStatus,
|
||||||
|
updateLevel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelToForm(level) {
|
||||||
|
// 接口层已经把 snake_case 转成 camelCase,这里只负责把数值转成输入框友好的字符串。
|
||||||
|
return {
|
||||||
|
agencySalaryUsd: level.agencySalaryUsd || "0",
|
||||||
|
hostCoinReward: String(level.hostCoinReward || 0),
|
||||||
|
hostSalaryUsd: level.hostSalaryUsd || "0",
|
||||||
|
level: String(level.level || ""),
|
||||||
|
requiredDiamonds: String(level.requiredDiamonds || ""),
|
||||||
|
sortOrder: String(level.sortOrder || level.level || ""),
|
||||||
|
status: level.status || "active",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultLevelToForm([level, requiredDiamonds, hostSalaryUsd, hostCoinReward, agencySalaryUsd]) {
|
||||||
|
// 默认模板中的工资和奖励都是累计值,不是本等级增量;实际差额由后续结算任务计算。
|
||||||
|
return {
|
||||||
|
agencySalaryUsd,
|
||||||
|
hostCoinReward: String(hostCoinReward),
|
||||||
|
hostSalaryUsd,
|
||||||
|
level: String(level),
|
||||||
|
requiredDiamonds: String(requiredDiamonds),
|
||||||
|
sortOrder: String(level),
|
||||||
|
status: "active",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(form) {
|
||||||
|
// 后端接口使用 snake_case;这里是唯一的表单 -> API payload 转换边界。
|
||||||
|
return {
|
||||||
|
effective_from_ms: timeValueToMs(form.effectiveFrom),
|
||||||
|
effective_to_ms: timeValueToMs(form.effectiveTo),
|
||||||
|
gift_coin_to_diamond_ratio: String(form.giftCoinToDiamondRatio || "1").trim(),
|
||||||
|
levels: form.levels
|
||||||
|
.map((level) => ({
|
||||||
|
// 保存等级时统一转 number,避免字符串数字进入后端后造成弱类型歧义。
|
||||||
|
agency_salary_usd: String(level.agencySalaryUsd || "0").trim(),
|
||||||
|
host_coin_reward: Number(level.hostCoinReward || 0),
|
||||||
|
host_salary_usd: String(level.hostSalaryUsd || "0").trim(),
|
||||||
|
level: Number(level.level || 0),
|
||||||
|
required_diamonds: Number(level.requiredDiamonds || 0),
|
||||||
|
sort_order: Number(level.sortOrder || level.level || 0),
|
||||||
|
status: level.status || "active",
|
||||||
|
}))
|
||||||
|
// 后端也会排序校验,前端先排序可以让保存后的返回和当前表单顺序更一致。
|
||||||
|
.sort((left, right) => left.level - right.level),
|
||||||
|
name: String(form.name || "").trim(),
|
||||||
|
region_id: Number(form.regionId || 0),
|
||||||
|
residual_diamond_to_usd_rate: String(form.residualDiamondToUsdRate || "0").trim(),
|
||||||
|
settlement_mode: form.settlementMode || "daily",
|
||||||
|
// 触发方式会被发布到 wallet 运行表;manual 表示后续只能从工资结算页人工触发。
|
||||||
|
settlement_trigger_mode: form.settlementTriggerMode || "automatic",
|
||||||
|
status: form.enabled ? "active" : "disabled",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTimeMs(value) {
|
||||||
|
const ms = Number(value || 0);
|
||||||
|
return Number.isFinite(ms) && ms > 0 ? ms : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeValueToMs(value) {
|
||||||
|
if (value === "" || value === null || value === undefined) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const number = Number(value);
|
||||||
|
if (Number.isFinite(number) && number > 0) {
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
321
src/features/host-agency-policy/hooks/useTeamSalaryPolicyPage.js
Normal file
321
src/features/host-agency-policy/hooks/useTeamSalaryPolicyPage.js
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { toPageQuery } from "@/shared/api/query";
|
||||||
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
|
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||||
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
import {
|
||||||
|
createTeamSalaryPolicy,
|
||||||
|
deleteTeamSalaryPolicy,
|
||||||
|
listTeamSalaryPolicies,
|
||||||
|
updateTeamSalaryPolicy,
|
||||||
|
} from "@/features/host-agency-policy/api";
|
||||||
|
import { useHostAgencyPolicyAbilities } from "@/features/host-agency-policy/permissions.js";
|
||||||
|
import { teamSalaryPolicyFormSchema } from "@/features/host-agency-policy/schema";
|
||||||
|
|
||||||
|
const pageSize = 50;
|
||||||
|
|
||||||
|
const defaultLevelsByType = {
|
||||||
|
// BD 门槛来自下属 Agency 管理主播的 Host Salary 合计,工资字段是达到该门槛后的 BD 累计应得。
|
||||||
|
bd: [
|
||||||
|
[1, "100", "8", "8"],
|
||||||
|
[2, "200", "8", "16"],
|
||||||
|
[3, "500", "8", "40"],
|
||||||
|
[4, "1200", "8", "96"],
|
||||||
|
[5, "2000", "8", "160"],
|
||||||
|
[6, "5000", "9", "450"],
|
||||||
|
[7, "10000", "10", "1000"],
|
||||||
|
],
|
||||||
|
// Admin 门槛来自下属 BD 的 BD Salary 合计,工资字段是达到该门槛后的 Admin 累计应得。
|
||||||
|
admin: [
|
||||||
|
[1, "500", "20", "100"],
|
||||||
|
[2, "1000", "20", "200"],
|
||||||
|
[3, "3000", "20", "600"],
|
||||||
|
[4, "5000", "20", "1000"],
|
||||||
|
[5, "10000", "20", "2000"],
|
||||||
|
[6, "20000", "20", "4000"],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyForm = (policyType, policy = null) => ({
|
||||||
|
effectiveFrom: normalizeTimeMs(policy?.effectiveFromMs),
|
||||||
|
effectiveTo: normalizeTimeMs(policy?.effectiveToMs),
|
||||||
|
enabled: policy ? policy.status === "active" : false,
|
||||||
|
levels: policy?.levels?.length
|
||||||
|
? policy.levels.map(levelToForm)
|
||||||
|
: defaultLevelsByType[policyType].map(defaultLevelToForm),
|
||||||
|
name: policy?.name || defaultPolicyName(policyType),
|
||||||
|
policyType,
|
||||||
|
regionId: policy?.regionId ? String(policy.regionId) : "",
|
||||||
|
settlementTriggerMode: policy?.settlementTriggerMode || "automatic",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useTeamSalaryPolicyPage(policyType) {
|
||||||
|
const abilities = useHostAgencyPolicyAbilities();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [regionId, setRegionId] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [settlementTriggerMode, setSettlementTriggerMode] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [form, setForm] = useState(() => emptyForm(policyType));
|
||||||
|
const [editingPolicy, setEditingPolicy] = useState(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
|
const filters = useMemo(
|
||||||
|
() => ({
|
||||||
|
keyword: query,
|
||||||
|
policy_type: policyType,
|
||||||
|
region_id: regionId,
|
||||||
|
settlement_trigger_mode: settlementTriggerMode,
|
||||||
|
status,
|
||||||
|
}),
|
||||||
|
[policyType, query, regionId, settlementTriggerMode, status],
|
||||||
|
);
|
||||||
|
const result = usePaginatedQuery({
|
||||||
|
errorMessage: policyType === "bd" ? "加载 BD 工资政策失败" : "加载 Admin 工资政策失败",
|
||||||
|
fetcher: listTeamSalaryPolicies,
|
||||||
|
filters,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
queryKey: ["team-salary-policy", policyType, toPageQuery({ ...filters, page, pageSize })],
|
||||||
|
});
|
||||||
|
const regionLabels = useMemo(
|
||||||
|
() =>
|
||||||
|
regionOptions.reduce((labels, item) => {
|
||||||
|
labels[Number(item.regionId)] = item.label;
|
||||||
|
return labels;
|
||||||
|
}, {}),
|
||||||
|
[regionOptions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingPolicy(null);
|
||||||
|
setForm(emptyForm(policyType));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (policy) => {
|
||||||
|
setEditingPolicy(policy);
|
||||||
|
setForm(emptyForm(policyType, policy));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDrawer = () => {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
setEditingPolicy(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitPolicy = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const editing = Boolean(editingPolicy?.id);
|
||||||
|
await runAction(
|
||||||
|
editing ? "team-policy-update" : "team-policy-create",
|
||||||
|
editing ? "工资政策已更新" : "工资政策已创建",
|
||||||
|
async () => {
|
||||||
|
const payload = buildPayload(parseForm(teamSalaryPolicyFormSchema, form));
|
||||||
|
if (editing) {
|
||||||
|
await updateTeamSalaryPolicy(editingPolicy.id, payload);
|
||||||
|
} else {
|
||||||
|
await createTeamSalaryPolicy(payload);
|
||||||
|
}
|
||||||
|
closeDrawer();
|
||||||
|
setForm(emptyForm(policyType));
|
||||||
|
await result.reload();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePolicy = async (policy) => {
|
||||||
|
if (!policy?.id || !abilities.canTeamDelete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction(`team-policy-delete-${policy.id}`, "工资政策已删除", async () => {
|
||||||
|
await deleteTeamSalaryPolicy(policyType, policy.id);
|
||||||
|
await result.reload();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePolicyStatus = async (policy, nextEnabled = policy.status !== "active") => {
|
||||||
|
if (!policy?.id || !abilities.canTeamUpdate || (policy.status === "active") === nextEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction(
|
||||||
|
`team-policy-status-${policy.id}`,
|
||||||
|
nextEnabled ? "工资政策已启用" : "工资政策已停用",
|
||||||
|
async () => {
|
||||||
|
await updateTeamSalaryPolicy(
|
||||||
|
policy.id,
|
||||||
|
buildPayload({ ...emptyForm(policyType, policy), enabled: nextEnabled }),
|
||||||
|
);
|
||||||
|
await result.reload();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setQuery("");
|
||||||
|
setRegionId("");
|
||||||
|
setStatus("");
|
||||||
|
setSettlementTriggerMode("");
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLevel = (index, patch) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
levels: current.levels.map((level, levelIndex) => (levelIndex === index ? { ...level, ...patch } : level)),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addLevel = () => {
|
||||||
|
setForm((current) => {
|
||||||
|
const last = current.levels[current.levels.length - 1];
|
||||||
|
const nextLevel = Number(last?.level || current.levels.length) + 1;
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
levels: [
|
||||||
|
...current.levels,
|
||||||
|
{
|
||||||
|
level: String(nextLevel),
|
||||||
|
ratePercent: last?.ratePercent || "0",
|
||||||
|
salaryUsd: last?.salaryUsd || "0",
|
||||||
|
sortOrder: String(nextLevel),
|
||||||
|
status: "active",
|
||||||
|
thresholdUsd: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLevel = (index) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
levels: current.levels.filter((_, levelIndex) => levelIndex !== index),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAction = async (action, successMessage, submitter) => {
|
||||||
|
setLoadingAction(action);
|
||||||
|
try {
|
||||||
|
await submitter();
|
||||||
|
showToast(successMessage, "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "操作失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
abilities,
|
||||||
|
addLevel,
|
||||||
|
changeQuery: (value) => {
|
||||||
|
setQuery(value);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
changeRegionId: (value) => {
|
||||||
|
setRegionId(value);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
changeSettlementTriggerMode: (value) => {
|
||||||
|
setSettlementTriggerMode(value);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
changeStatus: (value) => {
|
||||||
|
setStatus(value);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
closeDrawer,
|
||||||
|
data: result.data,
|
||||||
|
drawerOpen,
|
||||||
|
editingPolicy,
|
||||||
|
error: result.error,
|
||||||
|
form,
|
||||||
|
loading: result.loading,
|
||||||
|
loadingAction,
|
||||||
|
loadingRegions,
|
||||||
|
openCreate,
|
||||||
|
openEdit,
|
||||||
|
page,
|
||||||
|
policyType,
|
||||||
|
query,
|
||||||
|
regionId,
|
||||||
|
regionLabels,
|
||||||
|
regionOptions,
|
||||||
|
reload: result.reload,
|
||||||
|
removeLevel,
|
||||||
|
removePolicy,
|
||||||
|
resetFilters,
|
||||||
|
setForm,
|
||||||
|
setPage,
|
||||||
|
settlementTriggerMode,
|
||||||
|
status,
|
||||||
|
submitPolicy,
|
||||||
|
togglePolicyStatus,
|
||||||
|
updateLevel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelToForm(level) {
|
||||||
|
return {
|
||||||
|
level: String(level.level || ""),
|
||||||
|
ratePercent: level.ratePercent || "0",
|
||||||
|
salaryUsd: level.salaryUsd || "0",
|
||||||
|
sortOrder: String(level.sortOrder || level.level || ""),
|
||||||
|
status: level.status || "active",
|
||||||
|
thresholdUsd: level.thresholdUsd || "0",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultLevelToForm([level, thresholdUsd, ratePercent, salaryUsd]) {
|
||||||
|
return {
|
||||||
|
level: String(level),
|
||||||
|
ratePercent,
|
||||||
|
salaryUsd,
|
||||||
|
sortOrder: String(level),
|
||||||
|
status: "active",
|
||||||
|
thresholdUsd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(form) {
|
||||||
|
return {
|
||||||
|
effective_from_ms: timeValueToMs(form.effectiveFrom),
|
||||||
|
effective_to_ms: timeValueToMs(form.effectiveTo),
|
||||||
|
levels: form.levels
|
||||||
|
.map((level) => ({
|
||||||
|
level: Number(level.level || 0),
|
||||||
|
rate_percent: String(level.ratePercent || "0").trim(),
|
||||||
|
salary_usd: String(level.salaryUsd || "0").trim(),
|
||||||
|
sort_order: Number(level.sortOrder || level.level || 0),
|
||||||
|
status: level.status || "active",
|
||||||
|
threshold_usd: String(level.thresholdUsd || "0").trim(),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.level - right.level),
|
||||||
|
name: String(form.name || "").trim(),
|
||||||
|
policy_type: form.policyType,
|
||||||
|
region_id: Number(form.regionId || 0),
|
||||||
|
settlement_trigger_mode: form.settlementTriggerMode || "automatic",
|
||||||
|
status: form.enabled ? "active" : "disabled",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultPolicyName(policyType) {
|
||||||
|
return policyType === "bd" ? "Lalu BD Salary Policy" : "Lalu Admin Salary Policy";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTimeMs(value) {
|
||||||
|
const ms = Number(value || 0);
|
||||||
|
return Number.isFinite(ms) && ms > 0 ? ms : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeValueToMs(value) {
|
||||||
|
if (value === "" || value === null || value === undefined) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) && number > 0 ? number : 0;
|
||||||
|
}
|
||||||
296
src/features/host-agency-policy/host-agency-policy.module.css
Normal file
296
src/features/host-agency-policy/host-agency-policy.module.css
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 650;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarFilters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inlineFilter {
|
||||||
|
display: inline-flex;
|
||||||
|
height: var(--control-height);
|
||||||
|
min-width: 132px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inlineFilter input {
|
||||||
|
width: 96px;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inlineFilter input::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.policyTabsBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 2px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.policyTab {
|
||||||
|
min-height: 40px !important;
|
||||||
|
padding-right: 18px !important;
|
||||||
|
padding-left: 18px !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
text-transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-card-strong);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identityText {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryText {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amountPositive {
|
||||||
|
color: var(--success);
|
||||||
|
font-weight: 760;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policyDrawer {
|
||||||
|
width: min(720px, calc(100vw - 40px)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawerBody {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.baseSection {
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldWide {
|
||||||
|
grid-column: span 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldCompact {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enabledField {
|
||||||
|
display: flex;
|
||||||
|
min-height: var(--control-height);
|
||||||
|
grid-column: span 2;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--bg-input);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleLabel {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeRangeField {
|
||||||
|
grid-column: span 4;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 720;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelSection {
|
||||||
|
min-width: 0;
|
||||||
|
padding-top: 16px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addLevelButton {
|
||||||
|
height: 34px !important;
|
||||||
|
min-height: 34px !important;
|
||||||
|
padding-right: 10px !important;
|
||||||
|
padding-left: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelTable {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelHeader,
|
||||||
|
.levelRow {
|
||||||
|
display: grid;
|
||||||
|
min-width: 620px;
|
||||||
|
grid-template-columns: 54px minmax(108px, 1fr) 82px minmax(104px, 1fr) 82px 64px 34px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teamLevelHeader,
|
||||||
|
.teamLevelRow {
|
||||||
|
display: grid;
|
||||||
|
min-width: 580px;
|
||||||
|
grid-template-columns: 54px minmax(130px, 1fr) 86px minmax(108px, 1fr) 64px 34px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelHeader {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teamLevelHeader {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelRow :global(.MuiTextField-root),
|
||||||
|
.levelRow :global(.MuiFormControl-root),
|
||||||
|
.teamLevelRow :global(.MuiTextField-root),
|
||||||
|
.teamLevelRow :global(.MuiFormControl-root) {
|
||||||
|
--admin-control-height: 34px;
|
||||||
|
--admin-control-font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.formGrid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldWide,
|
||||||
|
.fieldCompact,
|
||||||
|
.timeRangeField,
|
||||||
|
.enabledField {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelHeader,
|
||||||
|
.teamLevelHeader {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelRow,
|
||||||
|
.teamLevelRow {
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)) 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelRow > :nth-child(2),
|
||||||
|
.levelRow > :nth-child(4),
|
||||||
|
.teamLevelRow > :nth-child(2),
|
||||||
|
.teamLevelRow > :nth-child(4) {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
360
src/features/host-agency-policy/pages/HostAgencyPolicyPage.jsx
Normal file
360
src/features/host-agency-policy/pages/HostAgencyPolicyPage.jsx
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Add from "@mui/icons-material/Add";
|
||||||
|
import CloudUploadOutlined from "@mui/icons-material/CloudUploadOutlined";
|
||||||
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
|
import Tab from "@mui/material/Tab";
|
||||||
|
import Tabs from "@mui/material/Tabs";
|
||||||
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import {
|
||||||
|
AdminActionIconButton,
|
||||||
|
AdminFilterResetButton,
|
||||||
|
AdminFilterSelect,
|
||||||
|
AdminListBody,
|
||||||
|
AdminListPage,
|
||||||
|
AdminListToolbar,
|
||||||
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
import { HostAgencyPolicyDrawer } from "@/features/host-agency-policy/components/HostAgencyPolicyDrawer.jsx";
|
||||||
|
import { useHostAgencyPolicyPage } from "@/features/host-agency-policy/hooks/useHostAgencyPolicyPage.js";
|
||||||
|
import { TeamSalaryPolicyPanel } from "@/features/host-agency-policy/pages/TeamSalaryPolicyPanel.jsx";
|
||||||
|
import styles from "@/features/host-agency-policy/host-agency-policy.module.css";
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
["", "全部状态"],
|
||||||
|
["active", "启用"],
|
||||||
|
["disabled", "停用"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const settlementModeOptions = [
|
||||||
|
["", "全部结算"],
|
||||||
|
["daily", "日结"],
|
||||||
|
["half_month", "半月结算"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const settlementTriggerOptions = [
|
||||||
|
["", "全部触发"],
|
||||||
|
["automatic", "自动结算"],
|
||||||
|
["manual", "手动结算"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const columnsBase = [
|
||||||
|
{
|
||||||
|
key: "policy",
|
||||||
|
label: "政策",
|
||||||
|
width: "minmax(260px, 1.2fr)",
|
||||||
|
// DataTable 的 render 签名是 (item, rowIndex, context),这里显式保留 rowIndex 占位,避免把行号误当上下文。
|
||||||
|
render: (item, _rowIndex, context) => <PolicyIdentity item={item} regionLabels={context.regionLabels} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "settlement",
|
||||||
|
label: "结算与比例",
|
||||||
|
width: "minmax(190px, 0.9fr)",
|
||||||
|
render: (item) => <SettlementSummary item={item} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "levels",
|
||||||
|
label: "等级",
|
||||||
|
width: "minmax(180px, 0.8fr)",
|
||||||
|
render: (item) => <LevelSummary item={item} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "effective",
|
||||||
|
label: "生效时间",
|
||||||
|
width: "minmax(220px, 1fr)",
|
||||||
|
render: (item) => <EffectiveTime item={item} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "publish",
|
||||||
|
label: "发布",
|
||||||
|
width: "minmax(180px, 0.8fr)",
|
||||||
|
render: (item) => <PublishSummary item={item} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态",
|
||||||
|
width: "minmax(110px, 0.5fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updatedAt",
|
||||||
|
label: "更新时间",
|
||||||
|
width: "minmax(170px, 0.75fr)",
|
||||||
|
render: (item) => formatMillis(item.updatedAtMs || item.createdAtMs),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "操作",
|
||||||
|
width: "156px",
|
||||||
|
resizable: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function HostAgencyPolicyPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState("host");
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<div className={styles.policyTabsBar}>
|
||||||
|
<Tabs value={activeTab} onChange={(_, value) => setActiveTab(value)}>
|
||||||
|
<Tab className={styles.policyTab} label="Host 政策" value="host" />
|
||||||
|
<Tab className={styles.policyTab} label="BD 政策" value="bd" />
|
||||||
|
<Tab className={styles.policyTab} label="Admin 政策" value="admin" />
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
{activeTab === "host" ? <HostPolicyPanel /> : <TeamSalaryPolicyPanel policyType={activeTab} />}
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostPolicyPanel() {
|
||||||
|
const page = useHostAgencyPolicyPage();
|
||||||
|
const items = page.data.items || [];
|
||||||
|
const total = page.data.total || 0;
|
||||||
|
// 列表筛选仍是单区域筛选,因为当前阶段后端政策模型只绑定一个 region_id。
|
||||||
|
const regionFilterOptions = [["", "全部区域"], ...page.regionOptions.map((region) => [region.value, region.label])];
|
||||||
|
const columns = columnsBase.map((column) => {
|
||||||
|
// 过滤控件挂在列定义里,保持 DataTable 的列宽、筛选和渲染配置集中声明。
|
||||||
|
if (column.key === "policy") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createTextColumnFilter({
|
||||||
|
placeholder: "搜索政策名称",
|
||||||
|
value: page.query,
|
||||||
|
onChange: page.changeQuery,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (column.key === "settlement") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createOptionsColumnFilter({
|
||||||
|
// 同一列同时承载节奏和触发方式;列头筛选默认筛结算节奏,页面工具栏提供触发方式筛选。
|
||||||
|
options: settlementModeOptions,
|
||||||
|
placeholder: "搜索结算",
|
||||||
|
value: page.settlementMode,
|
||||||
|
onChange: page.changeSettlementMode,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (column.key === "status") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createOptionsColumnFilter({
|
||||||
|
options: statusOptions,
|
||||||
|
placeholder: "搜索状态",
|
||||||
|
value: page.status,
|
||||||
|
onChange: page.changeStatus,
|
||||||
|
}),
|
||||||
|
render: (item) => <PolicyStatusSwitch item={item} page={page} />,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (column.key === "actions") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
render: (item) => <PolicyActions item={item} page={page} />,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return column;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminListToolbar
|
||||||
|
actions={
|
||||||
|
page.abilities.canCreate ? (
|
||||||
|
<AdminActionIconButton label="新增工资政策" primary onClick={page.openCreate}>
|
||||||
|
<Add fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
filters={
|
||||||
|
<div className={styles.toolbarFilters}>
|
||||||
|
<AdminFilterSelect
|
||||||
|
label="区域"
|
||||||
|
options={regionFilterOptions}
|
||||||
|
value={page.regionId}
|
||||||
|
onChange={page.changeRegionId}
|
||||||
|
/>
|
||||||
|
<AdminFilterSelect
|
||||||
|
label="触发方式"
|
||||||
|
options={settlementTriggerOptions}
|
||||||
|
value={page.settlementTriggerMode}
|
||||||
|
onChange={page.changeSettlementTriggerMode}
|
||||||
|
/>
|
||||||
|
<AdminFilterResetButton
|
||||||
|
disabled={
|
||||||
|
!page.query &&
|
||||||
|
!page.regionId &&
|
||||||
|
!page.status &&
|
||||||
|
!page.settlementMode &&
|
||||||
|
!page.settlementTriggerMode
|
||||||
|
}
|
||||||
|
onClick={page.resetFilters}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
context={{ regionLabels: page.regionLabels }}
|
||||||
|
items={items}
|
||||||
|
minWidth="1420px"
|
||||||
|
pagination={
|
||||||
|
total > 0
|
||||||
|
? {
|
||||||
|
page: page.page,
|
||||||
|
pageSize: page.data.pageSize || 50,
|
||||||
|
total,
|
||||||
|
onPageChange: page.setPage,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
rowKey={(item) => item.id}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
<HostAgencyPolicyDrawer page={page} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolicyIdentity({ item, regionLabels }) {
|
||||||
|
// regionLabels 来自区域配置接口,后端政策列表只返回 region_id,避免列表接口重复拼区域名称。
|
||||||
|
const labels = regionLabels || {};
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span className={styles.name}>{item.name}</span>
|
||||||
|
<span className={styles.meta}>{labels[item.regionId] || `区域 ${item.regionId}`}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettlementSummary({ item }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{settlementLabel(item.settlementMode)}</span>
|
||||||
|
<span className={styles.meta}>{settlementTriggerLabel(item.settlementTriggerMode)}</span>
|
||||||
|
<span className={styles.meta}>金币转钻石 {item.giftCoinToDiamondRatio || "1"}</span>
|
||||||
|
<span className={styles.meta}>剩余钻石转美元 {item.residualDiamondToUsdRate || "0"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LevelSummary({ item }) {
|
||||||
|
const first = item.levels?.[0];
|
||||||
|
const last = item.levels?.[item.levels.length - 1];
|
||||||
|
// 等级摘要展示门槛范围和最高累计主播工资,帮助运营快速识别政策梯度是否录错。
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{item.levels?.length || 0} 个等级</span>
|
||||||
|
<span className={styles.meta}>
|
||||||
|
{first && last
|
||||||
|
? `${formatNumber(first.requiredDiamonds)} - ${formatNumber(last.requiredDiamonds)}`
|
||||||
|
: "-"}
|
||||||
|
</span>
|
||||||
|
<span className={styles.meta}>{last ? `最高工资 $${last.hostSalaryUsd}` : ""}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EffectiveTime({ item }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{item.effectiveFromMs ? formatMillis(item.effectiveFromMs) : "立即生效"}</span>
|
||||||
|
<span className={styles.meta}>{item.effectiveToMs ? formatMillis(item.effectiveToMs) : "长期有效"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PublishSummary({ item }) {
|
||||||
|
const status = item.publishStatus || "draft";
|
||||||
|
// publish_error 只在失败态显示,避免成功发布后的旧错误文案干扰运营判断。
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span className={`status-badge status-badge--${publishTone(status)}`}>{publishLabel(status)}</span>
|
||||||
|
<span className={styles.meta}>{item.publishedAtMs ? formatMillis(item.publishedAtMs) : "未发布"}</span>
|
||||||
|
{status === "failed" && item.publishError ? <span className={styles.meta}>{item.publishError}</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolicyStatusSwitch({ item, page }) {
|
||||||
|
const disabled = !page.abilities.canUpdate || page.loadingAction === `policy-status-${item.id}`;
|
||||||
|
// 行内启停最终仍走 update 接口,后端会重新执行同区域时间段冲突校验。
|
||||||
|
return (
|
||||||
|
<AdminSwitch
|
||||||
|
checked={item.status === "active"}
|
||||||
|
checkedLabel="启用"
|
||||||
|
disabled={disabled}
|
||||||
|
label="切换政策状态"
|
||||||
|
uncheckedLabel="停用"
|
||||||
|
onChange={(event) => page.togglePolicyStatus(item, event.target.checked)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolicyActions({ item, page }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.rowActions}>
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={!page.abilities.canPublish || page.loadingAction === `policy-publish-${item.id}`}
|
||||||
|
label="发布工资政策"
|
||||||
|
onClick={() => page.publishPolicy(item)}
|
||||||
|
>
|
||||||
|
<CloudUploadOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={!page.abilities.canUpdate}
|
||||||
|
label="编辑工资政策"
|
||||||
|
onClick={() => page.openEdit(item)}
|
||||||
|
>
|
||||||
|
<EditOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={!page.abilities.canDelete || page.loadingAction === `policy-delete-${item.id}`}
|
||||||
|
label="删除工资政策"
|
||||||
|
onClick={() => page.removePolicy(item)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function settlementLabel(value) {
|
||||||
|
return settlementModeOptions.find(([mode]) => mode === value)?.[1] || value || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function settlementTriggerLabel(value) {
|
||||||
|
return settlementTriggerOptions.find(([mode]) => mode === value)?.[1] || value || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishLabel(value) {
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
draft: "草稿",
|
||||||
|
failed: "发布失败",
|
||||||
|
published: "已发布",
|
||||||
|
}[value] ||
|
||||||
|
value ||
|
||||||
|
"-"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishTone(value) {
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
draft: "warning",
|
||||||
|
failed: "danger",
|
||||||
|
published: "running",
|
||||||
|
}[value] || "stopped"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||||
|
}
|
||||||
@ -0,0 +1,881 @@
|
|||||||
|
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
|
||||||
|
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
||||||
|
import Checkbox from "@mui/material/Checkbox";
|
||||||
|
import Tab from "@mui/material/Tab";
|
||||||
|
import Tabs from "@mui/material/Tabs";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
listHostSalarySettlements,
|
||||||
|
listTeamSalarySettlementPending,
|
||||||
|
listTeamSalarySettlementRecords,
|
||||||
|
settleTeamSalary,
|
||||||
|
} from "@/features/host-agency-policy/api";
|
||||||
|
import { useHostAgencyPolicyAbilities } from "@/features/host-agency-policy/permissions.js";
|
||||||
|
import {
|
||||||
|
AdminFilterResetButton,
|
||||||
|
AdminFilterSelect,
|
||||||
|
AdminListBody,
|
||||||
|
AdminListPage,
|
||||||
|
AdminListToolbar,
|
||||||
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
import styles from "@/features/host-agency-policy/host-agency-policy.module.css";
|
||||||
|
|
||||||
|
const pageSize = 50;
|
||||||
|
|
||||||
|
const settlementTypeOptions = [
|
||||||
|
["", "全部类型"],
|
||||||
|
["daily", "日结"],
|
||||||
|
["half_month", "半月结算"],
|
||||||
|
["month_end", "月底清算"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
["", "全部状态"],
|
||||||
|
["succeeded", "成功"],
|
||||||
|
["skipped", "跳过"],
|
||||||
|
["failed", "失败"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const policyTypeOptions = [
|
||||||
|
["bd", "BD"],
|
||||||
|
["admin", "Admin"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const policyTypeAllOptions = [
|
||||||
|
["", "全部角色"],
|
||||||
|
...policyTypeOptions,
|
||||||
|
];
|
||||||
|
|
||||||
|
const triggerOptions = [
|
||||||
|
["automatic", "自动政策"],
|
||||||
|
["manual", "手动政策"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const triggerAllOptions = [
|
||||||
|
["", "全部触发"],
|
||||||
|
...triggerOptions,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function HostSalarySettlementPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState("team-pending");
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<div className={styles.policyTabsBar}>
|
||||||
|
<Tabs value={activeTab} onChange={(_, value) => setActiveTab(value)}>
|
||||||
|
<Tab className={styles.policyTab} label="待结算" value="team-pending" />
|
||||||
|
<Tab className={styles.policyTab} label="BD/Admin 记录" value="team-records" />
|
||||||
|
<Tab className={styles.policyTab} label="Host/Agency 记录" value="host-records" />
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
{activeTab === "team-pending" ? (
|
||||||
|
<TeamPendingPanel />
|
||||||
|
) : activeTab === "team-records" ? (
|
||||||
|
<TeamRecordsPanel />
|
||||||
|
) : (
|
||||||
|
<HostRecordsPanel />
|
||||||
|
)}
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostRecordsPanel 保留原 Host/Agency 结算记录查询,避免新增 BD/Admin tab 后丢失主播工资查账入口。
|
||||||
|
function HostRecordsPanel() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [cycleKey, setCycleKey] = useState("");
|
||||||
|
const [userId, setUserId] = useState("");
|
||||||
|
const [agencyOwnerUserId, setAgencyOwnerUserId] = useState("");
|
||||||
|
const [settlementType, setSettlementType] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
||||||
|
|
||||||
|
// filters 使用后端 snake_case 字段,直接进入 apiRequest query,减少额外字段映射层。
|
||||||
|
const filters = useMemo(
|
||||||
|
() => ({
|
||||||
|
agency_owner_user_id: agencyOwnerUserId,
|
||||||
|
cycle_key: cycleKey,
|
||||||
|
end_at_ms: timeRange.endMs || "",
|
||||||
|
settlement_type: settlementType,
|
||||||
|
start_at_ms: timeRange.startMs || "",
|
||||||
|
status,
|
||||||
|
user_id: userId,
|
||||||
|
}),
|
||||||
|
[agencyOwnerUserId, cycleKey, settlementType, status, timeRange.endMs, timeRange.startMs, userId],
|
||||||
|
);
|
||||||
|
const query = usePaginatedQuery({
|
||||||
|
errorMessage: "获取工资结算记录失败",
|
||||||
|
fetcher: listHostSalarySettlements,
|
||||||
|
filters,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
queryKey: ["host-salary-settlements", filters, page],
|
||||||
|
});
|
||||||
|
const data = query.data || { items: [], page, pageSize, total: 0 };
|
||||||
|
const items = data.items || [];
|
||||||
|
const total = data.total || 0;
|
||||||
|
|
||||||
|
const changeFilter = (setter) => (value) => {
|
||||||
|
// 任一筛选变化都回到第一页,避免翻页状态导致新条件下看起来“没有数据”。
|
||||||
|
setter(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const changeTimeRange = (nextRange) => {
|
||||||
|
setTimeRange(nextRange);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const resetFilters = () => {
|
||||||
|
setAgencyOwnerUserId("");
|
||||||
|
setCycleKey("");
|
||||||
|
setSettlementType("");
|
||||||
|
setStatus("");
|
||||||
|
setTimeRange({ endMs: "", startMs: "" });
|
||||||
|
setUserId("");
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const tableColumns = hostRecordColumns.map((column) => {
|
||||||
|
// 列筛选挂在 DataTable 列定义上,保持筛选 UI 与列语义一致。
|
||||||
|
if (column.key === "host") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createTextColumnFilter({
|
||||||
|
placeholder: "主播用户 ID",
|
||||||
|
value: userId,
|
||||||
|
onChange: changeFilter(setUserId),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (column.key === "agency") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createTextColumnFilter({
|
||||||
|
placeholder: "代理用户 ID",
|
||||||
|
value: agencyOwnerUserId,
|
||||||
|
onChange: changeFilter(setAgencyOwnerUserId),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (column.key === "cycle") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createTextColumnFilter({
|
||||||
|
placeholder: "周期 key",
|
||||||
|
value: cycleKey,
|
||||||
|
onChange: changeFilter(setCycleKey),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (column.key === "status") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createOptionsColumnFilter({
|
||||||
|
options: statusOptions,
|
||||||
|
value: status,
|
||||||
|
onChange: changeFilter(setStatus),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return column;
|
||||||
|
});
|
||||||
|
const hasFilters =
|
||||||
|
agencyOwnerUserId ||
|
||||||
|
cycleKey ||
|
||||||
|
settlementType ||
|
||||||
|
status ||
|
||||||
|
timeRange.startMs ||
|
||||||
|
timeRange.endMs ||
|
||||||
|
userId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminListToolbar
|
||||||
|
filters={
|
||||||
|
<div className={styles.toolbarFilters}>
|
||||||
|
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
|
||||||
|
<AdminFilterSelect
|
||||||
|
label="结算类型"
|
||||||
|
options={settlementTypeOptions}
|
||||||
|
value={settlementType}
|
||||||
|
onChange={changeFilter(setSettlementType)}
|
||||||
|
/>
|
||||||
|
<AdminFilterResetButton disabled={!hasFilters} onClick={resetFilters} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable
|
||||||
|
columns={tableColumns}
|
||||||
|
items={items}
|
||||||
|
minWidth="1420px"
|
||||||
|
pagination={paginationProps(data, page, total, setPage)}
|
||||||
|
rowKey={(item) => item.settlementId}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TeamPendingPanel 是人工结算入口:列表金额来自后端候选计算,批量按钮只提交当前勾选用户。
|
||||||
|
function TeamPendingPanel() {
|
||||||
|
const abilities = useHostAgencyPolicyAbilities();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [policyType, setPolicyType] = useState("bd");
|
||||||
|
const [triggerMode, setTriggerMode] = useState("automatic");
|
||||||
|
const [cycleKey, setCycleKey] = useState(defaultCycleKey);
|
||||||
|
const [countryCode, setCountryCode] = useState("");
|
||||||
|
const [regionId, setRegionId] = useState("");
|
||||||
|
const [selected, setSelected] = useState(() => new Set());
|
||||||
|
const [settling, setSettling] = useState(false);
|
||||||
|
|
||||||
|
// BD/Admin 默认查看 automatic 上月周期;手动政策也可切换 triggerMode 后人工补发。
|
||||||
|
const filters = useMemo(
|
||||||
|
() => ({
|
||||||
|
country_code: countryCode,
|
||||||
|
cycle_key: cycleKey,
|
||||||
|
policy_type: policyType,
|
||||||
|
region_id: regionId,
|
||||||
|
trigger_mode: triggerMode,
|
||||||
|
}),
|
||||||
|
[countryCode, cycleKey, policyType, regionId, triggerMode],
|
||||||
|
);
|
||||||
|
const query = usePaginatedQuery({
|
||||||
|
errorMessage: "获取 BD/Admin 待结算列表失败",
|
||||||
|
fetcher: listTeamSalarySettlementPending,
|
||||||
|
filters,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
queryKey: ["team-salary-pending", filters, page],
|
||||||
|
});
|
||||||
|
const data = query.data || { items: [], page, pageSize, total: 0 };
|
||||||
|
const items = data.items || [];
|
||||||
|
const total = data.total || 0;
|
||||||
|
const allVisibleSelected = items.length > 0 && items.every((item) => selected.has(teamPendingKey(item)));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 筛选条件变化后清空勾选,避免把上一批用户带入当前国家或周期的结算请求。
|
||||||
|
setSelected(new Set());
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
const changeFilter = (setter) => (value) => {
|
||||||
|
// 国家/区域/周期变化后必须重算候选,不保留旧页码。
|
||||||
|
setter(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const resetFilters = () => {
|
||||||
|
setPolicyType("bd");
|
||||||
|
setTriggerMode("automatic");
|
||||||
|
setCycleKey(defaultCycleKey());
|
||||||
|
setCountryCode("");
|
||||||
|
setRegionId("");
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const toggleOne = (item) => {
|
||||||
|
setSelected((current) => {
|
||||||
|
// 选择 key 包含角色、周期、区域和用户,防止同一用户跨区域或跨周期时互相污染。
|
||||||
|
const next = new Set(current);
|
||||||
|
const key = teamPendingKey(item);
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const toggleVisible = () => {
|
||||||
|
setSelected((current) => {
|
||||||
|
// 表头复选框只操作当前页,避免用户在未看见的分页数据上误触批量结算。
|
||||||
|
const next = new Set(current);
|
||||||
|
items.forEach((item) => {
|
||||||
|
const key = teamPendingKey(item);
|
||||||
|
if (allVisibleSelected) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const settleSelected = async () => {
|
||||||
|
// 只从当前页 items 中提取已勾选用户,服务端仍会再次按 user_ids 白名单过滤。
|
||||||
|
const userIds = items
|
||||||
|
.filter((item) => selected.has(teamPendingKey(item)))
|
||||||
|
.map((item) => Number(item.userId))
|
||||||
|
.filter((id) => Number.isFinite(id) && id > 0);
|
||||||
|
if (!userIds.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSettling(true);
|
||||||
|
try {
|
||||||
|
// trigger_mode 随页面筛选提交,保证“手动政策”和“自动政策”不会交叉结算。
|
||||||
|
const result = await settleTeamSalary({
|
||||||
|
country_code: countryCode || undefined,
|
||||||
|
cycle_key: cycleKey,
|
||||||
|
policy_type: policyType,
|
||||||
|
region_id: Number(regionId || 0) || undefined,
|
||||||
|
trigger_mode: triggerMode,
|
||||||
|
user_ids: userIds,
|
||||||
|
});
|
||||||
|
showToast({
|
||||||
|
message: `结算完成:成功 ${result.successCount},跳过 ${result.skippedCount},失败 ${result.failureCount}`,
|
||||||
|
severity: result.failureCount > 0 ? "warning" : "success",
|
||||||
|
});
|
||||||
|
setSelected(new Set());
|
||||||
|
await query.reload();
|
||||||
|
} catch (error) {
|
||||||
|
showToast({ message: error?.message || "工资结算失败", severity: "error" });
|
||||||
|
} finally {
|
||||||
|
setSettling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableColumns = [
|
||||||
|
{
|
||||||
|
key: "select",
|
||||||
|
label: (
|
||||||
|
<Checkbox
|
||||||
|
checked={allVisibleSelected}
|
||||||
|
disabled={!items.length}
|
||||||
|
indeterminate={selected.size > 0 && !allVisibleSelected}
|
||||||
|
size="small"
|
||||||
|
onChange={toggleVisible}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
width: "64px",
|
||||||
|
resizable: false,
|
||||||
|
render: (item) => (
|
||||||
|
<Checkbox checked={selected.has(teamPendingKey(item))} size="small" onChange={() => toggleOne(item)} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...teamPendingColumns,
|
||||||
|
];
|
||||||
|
// 默认值也纳入 hasFilters,这样运营能一键回到“BD + 自动政策 + 上月周期”的标准入口。
|
||||||
|
const hasFilters = policyType !== "bd" || triggerMode !== "automatic" || cycleKey !== defaultCycleKey() || countryCode || regionId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminListToolbar
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
disabled={!abilities.canSettleSalary || settling || selected.size === 0}
|
||||||
|
startIcon={<PlayArrowOutlined fontSize="small" />}
|
||||||
|
variant="primary"
|
||||||
|
onClick={settleSelected}
|
||||||
|
>
|
||||||
|
批量结算
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
filters={
|
||||||
|
<div className={styles.toolbarFilters}>
|
||||||
|
<AdminFilterSelect label="角色" options={policyTypeOptions} value={policyType} onChange={changeFilter(setPolicyType)} />
|
||||||
|
<AdminFilterSelect label="政策触发" options={triggerOptions} value={triggerMode} onChange={changeFilter(setTriggerMode)} />
|
||||||
|
<InlineFilter label="周期" placeholder="YYYY-MM" value={cycleKey} onChange={changeFilter(setCycleKey)} />
|
||||||
|
<InlineFilter label="国家" placeholder="国家代码" value={countryCode} onChange={changeFilter(setCountryCode)} />
|
||||||
|
<InlineFilter label="区域" placeholder="区域 ID" value={regionId} onChange={changeFilter(setRegionId)} />
|
||||||
|
<AdminFilterResetButton disabled={!hasFilters} onClick={resetFilters} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable
|
||||||
|
columns={tableColumns}
|
||||||
|
items={items}
|
||||||
|
minWidth="1280px"
|
||||||
|
pagination={paginationProps(data, page, total, setPage)}
|
||||||
|
rowKey={teamPendingKey}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TeamRecordsPanel 查询 BD/Admin 结算记录,和待结算页分开,避免查账筛选影响待结算批量操作。
|
||||||
|
function TeamRecordsPanel() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [policyType, setPolicyType] = useState("");
|
||||||
|
const [triggerMode, setTriggerMode] = useState("");
|
||||||
|
const [cycleKey, setCycleKey] = useState("");
|
||||||
|
const [countryCode, setCountryCode] = useState("");
|
||||||
|
const [userId, setUserId] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
||||||
|
|
||||||
|
// 记录查询支持国家代码筛选;后端会转换为 region_id 集合过滤结算记录。
|
||||||
|
const filters = useMemo(
|
||||||
|
() => ({
|
||||||
|
country_code: countryCode,
|
||||||
|
cycle_key: cycleKey,
|
||||||
|
end_at_ms: timeRange.endMs || "",
|
||||||
|
policy_type: policyType,
|
||||||
|
start_at_ms: timeRange.startMs || "",
|
||||||
|
status,
|
||||||
|
trigger_mode: triggerMode,
|
||||||
|
user_id: userId,
|
||||||
|
}),
|
||||||
|
[countryCode, cycleKey, policyType, status, timeRange.endMs, timeRange.startMs, triggerMode, userId],
|
||||||
|
);
|
||||||
|
const query = usePaginatedQuery({
|
||||||
|
errorMessage: "获取 BD/Admin 结算记录失败",
|
||||||
|
fetcher: listTeamSalarySettlementRecords,
|
||||||
|
filters,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
queryKey: ["team-salary-records", filters, page],
|
||||||
|
});
|
||||||
|
const data = query.data || { items: [], page, pageSize, total: 0 };
|
||||||
|
const items = data.items || [];
|
||||||
|
const total = data.total || 0;
|
||||||
|
|
||||||
|
const changeFilter = (setter) => (value) => {
|
||||||
|
// 记录页所有筛选统一重置到第一页,和其它后台列表交互保持一致。
|
||||||
|
setter(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const resetFilters = () => {
|
||||||
|
setPolicyType("");
|
||||||
|
setTriggerMode("");
|
||||||
|
setCycleKey("");
|
||||||
|
setCountryCode("");
|
||||||
|
setStatus("");
|
||||||
|
setTimeRange({ endMs: "", startMs: "" });
|
||||||
|
setUserId("");
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const tableColumns = teamRecordColumns.map((column) => {
|
||||||
|
// 用户、周期、状态保留列内筛选;角色/触发方式/国家放顶部,便于组合查询。
|
||||||
|
if (column.key === "user") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createTextColumnFilter({
|
||||||
|
placeholder: "用户 ID",
|
||||||
|
value: userId,
|
||||||
|
onChange: changeFilter(setUserId),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (column.key === "cycle") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createTextColumnFilter({
|
||||||
|
placeholder: "周期 key",
|
||||||
|
value: cycleKey,
|
||||||
|
onChange: changeFilter(setCycleKey),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (column.key === "status") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createOptionsColumnFilter({
|
||||||
|
options: statusOptions,
|
||||||
|
value: status,
|
||||||
|
onChange: changeFilter(setStatus),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return column;
|
||||||
|
});
|
||||||
|
const hasFilters =
|
||||||
|
policyType ||
|
||||||
|
triggerMode ||
|
||||||
|
cycleKey ||
|
||||||
|
countryCode ||
|
||||||
|
status ||
|
||||||
|
timeRange.startMs ||
|
||||||
|
timeRange.endMs ||
|
||||||
|
userId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminListToolbar
|
||||||
|
filters={
|
||||||
|
<div className={styles.toolbarFilters}>
|
||||||
|
<TimeRangeFilter value={timeRange} onChange={(value) => { setTimeRange(value); setPage(1); }} />
|
||||||
|
<AdminFilterSelect label="角色" options={policyTypeAllOptions} value={policyType} onChange={changeFilter(setPolicyType)} />
|
||||||
|
<AdminFilterSelect label="触发" options={triggerAllOptions} value={triggerMode} onChange={changeFilter(setTriggerMode)} />
|
||||||
|
<InlineFilter label="国家" placeholder="国家代码" value={countryCode} onChange={changeFilter(setCountryCode)} />
|
||||||
|
<AdminFilterResetButton disabled={!hasFilters} onClick={resetFilters} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable
|
||||||
|
columns={tableColumns}
|
||||||
|
items={items}
|
||||||
|
minWidth="1280px"
|
||||||
|
pagination={paginationProps(data, page, total, setPage)}
|
||||||
|
rowKey={(item) => item.settlementId}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostRecordColumns 展示 Host/Agency 的原始结算事实,包括主播工资、金币奖励、代理工资和剩余折美元。
|
||||||
|
const hostRecordColumns = [
|
||||||
|
{
|
||||||
|
key: "host",
|
||||||
|
label: "主播",
|
||||||
|
width: "minmax(240px, 1.1fr)",
|
||||||
|
render: (item) => <UserCell fallbackId={item.userId} user={item.user} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "agency",
|
||||||
|
label: "代理",
|
||||||
|
width: "minmax(220px, 1fr)",
|
||||||
|
render: (item) => <UserCell fallbackId={item.agencyOwnerUserId} user={item.agencyOwner} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cycle",
|
||||||
|
label: "周期",
|
||||||
|
width: "minmax(170px, 0.8fr)",
|
||||||
|
render: (item) => <CycleCell item={item} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "level",
|
||||||
|
label: "等级/钻石",
|
||||||
|
width: "minmax(150px, 0.7fr)",
|
||||||
|
render: (item) => <HostLevelCell item={item} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hostReward",
|
||||||
|
label: "主播奖励",
|
||||||
|
width: "minmax(170px, 0.8fr)",
|
||||||
|
render: (item) => <HostRewardCell item={item} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "agencyReward",
|
||||||
|
label: "代理/剩余",
|
||||||
|
width: "minmax(170px, 0.8fr)",
|
||||||
|
render: (item) => <AgencyRewardCell item={item} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态",
|
||||||
|
width: "minmax(120px, 0.55fr)",
|
||||||
|
render: (item) => <StatusCell item={item} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdAtMs",
|
||||||
|
label: "时间",
|
||||||
|
width: "minmax(170px, 0.8fr)",
|
||||||
|
render: (item) => formatMillis(item.createdAtMs),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// teamPendingColumns 展示 BD/Admin 的“现在会发多少钱”和“此前已发到哪一档”。
|
||||||
|
const teamPendingColumns = [
|
||||||
|
{
|
||||||
|
key: "user",
|
||||||
|
label: "用户",
|
||||||
|
width: "minmax(230px, 1fr)",
|
||||||
|
render: (item) => <UserCell fallbackId={item.userId} user={item.user} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "scope",
|
||||||
|
label: "周期/区域",
|
||||||
|
width: "minmax(180px, 0.8fr)",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{roleLabel(item.policyType)} · {item.cycleKey || "-"}</span>
|
||||||
|
<span className={styles.meta}>{item.regionName || `区域 ${item.regionId || "-"}`}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "income",
|
||||||
|
label: "收入基数",
|
||||||
|
width: "minmax(150px, 0.7fr)",
|
||||||
|
render: (item) => <MoneyStack primary={item.incomeUsd} secondary="累计收入" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "level",
|
||||||
|
label: "政策/档位",
|
||||||
|
width: "minmax(210px, 0.9fr)",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{item.levelNo > 0 ? `等级 ${item.levelNo}` : "未达等级"}</span>
|
||||||
|
<span className={styles.meta}>{item.policyName || `政策 ${item.policyId || "-"}`}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "salary",
|
||||||
|
label: "待发工资",
|
||||||
|
width: "minmax(170px, 0.8fr)",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span className={styles.amountPositive}>{formatMoneyDelta(item.salaryUsdDelta)}</span>
|
||||||
|
<span className={styles.meta}>累计目标 ${item.salaryUsdTarget || "0"}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "progress",
|
||||||
|
label: "已结进度",
|
||||||
|
width: "minmax(180px, 0.8fr)",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>${item.settledSalaryUsd || "0"}</span>
|
||||||
|
<span className={styles.meta}>已结等级 {item.settledLevelNo || 0}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// teamRecordColumns 展示已落库的 BD/Admin 结算记录;settlementId 通过 rowKey 保持唯一。
|
||||||
|
const teamRecordColumns = [
|
||||||
|
{
|
||||||
|
key: "user",
|
||||||
|
label: "用户",
|
||||||
|
width: "minmax(230px, 1fr)",
|
||||||
|
render: (item) => <UserCell fallbackId={item.userId} user={item.user} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cycle",
|
||||||
|
label: "周期/角色",
|
||||||
|
width: "minmax(170px, 0.8fr)",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{item.cycleKey || "-"}</span>
|
||||||
|
<span className={styles.meta}>{roleLabel(item.policyType)} · {triggerLabel(item.triggerMode)}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "income",
|
||||||
|
label: "收入基数",
|
||||||
|
width: "minmax(150px, 0.7fr)",
|
||||||
|
render: (item) => <MoneyStack primary={item.incomeUsd} secondary={`等级 ${item.levelNo || 0}`} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "salary",
|
||||||
|
label: "发放工资",
|
||||||
|
width: "minmax(160px, 0.75fr)",
|
||||||
|
render: (item) => <span className={styles.amountPositive}>{formatMoneyDelta(item.salaryUsdDelta)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "policy",
|
||||||
|
label: "政策",
|
||||||
|
width: "minmax(130px, 0.55fr)",
|
||||||
|
render: (item) => `政策 ${item.policyId || "-"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态",
|
||||||
|
width: "minmax(120px, 0.55fr)",
|
||||||
|
render: (item) => <StatusCell item={item} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdAtMs",
|
||||||
|
label: "时间",
|
||||||
|
width: "minmax(170px, 0.8fr)",
|
||||||
|
render: (item) => formatMillis(item.createdAtMs),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// InlineFilter 是本页面的轻量文本筛选控件,用于周期、国家代码、区域 ID 这类短字段。
|
||||||
|
function InlineFilter({ label, onChange, placeholder, value }) {
|
||||||
|
return (
|
||||||
|
<label className={styles.inlineFilter}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<input placeholder={placeholder} value={value} onChange={(event) => onChange(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserCell 统一展示用户资料;没有资料时仍显示 user_id,确保查账可定位。
|
||||||
|
function UserCell({ fallbackId, user }) {
|
||||||
|
const idText = userIdText(user, fallbackId);
|
||||||
|
if (!idText) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={styles.identity}>
|
||||||
|
<span className={styles.avatar}>
|
||||||
|
{user?.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
|
||||||
|
</span>
|
||||||
|
<div className={styles.identityText}>
|
||||||
|
<span className={styles.primaryText}>{user?.username || "-"}</span>
|
||||||
|
<span className={styles.meta}>{idText}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CycleCell 展示 Host/Agency 周期和结算类型。
|
||||||
|
function CycleCell({ item }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{item.cycleKey || "-"}</span>
|
||||||
|
<span className={styles.meta}>{settlementTypeLabel(item.settlementType)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostLevelCell 展示主播档位和钻石累计,方便对照 Host 政策表。
|
||||||
|
function HostLevelCell({ item }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{item.levelNo > 0 ? `等级 ${item.levelNo}` : "未达等级"}</span>
|
||||||
|
<span className={styles.meta}>{formatNumber(item.totalDiamonds)} 钻石</span>
|
||||||
|
<span className={styles.meta}>政策 {item.policyId || "-"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostRewardCell 展示主播侧本次增量奖励。
|
||||||
|
function HostRewardCell({ item }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span className={styles.amountPositive}>{formatMoneyDelta(item.hostSalaryUsdDelta)}</span>
|
||||||
|
<span className={styles.meta}>金币 {formatSignedInteger(item.hostCoinRewardDelta)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgencyRewardCell 展示代理工资和月底剩余钻石折美元。
|
||||||
|
function AgencyRewardCell({ item }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span className={styles.amountPositive}>{formatMoneyDelta(item.agencySalaryUsdDelta)}</span>
|
||||||
|
<span className={styles.meta}>剩余折美元 {formatMoneyDelta(item.residualUsdDelta)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoneyStack 固定两行金额展示,避免表格列因文案变化跳动。
|
||||||
|
function MoneyStack({ primary, secondary }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>${primary || "0"}</span>
|
||||||
|
<span className={styles.meta}>{secondary}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusCell 统一状态徽标,失败或跳过原因放在第二行。
|
||||||
|
function StatusCell({ item }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span className={`status-badge status-badge--${statusTone(item.status)}`}>{statusLabel(item.status)}</span>
|
||||||
|
{item.reason ? <span className={styles.meta}>{item.reason}</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// paginationProps 只在 total > 0 时渲染分页,空表保持更干净的空状态。
|
||||||
|
function paginationProps(data, page, total, onPageChange) {
|
||||||
|
return total > 0
|
||||||
|
? {
|
||||||
|
page,
|
||||||
|
pageSize: data.pageSize || pageSize,
|
||||||
|
total,
|
||||||
|
onPageChange,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// teamPendingKey 绑定角色、周期、区域和用户,保证多维候选在前端选择集合里唯一。
|
||||||
|
function teamPendingKey(item) {
|
||||||
|
return `${item.policyType}:${item.cycleKey}:${item.regionId}:${item.userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// userIdText 展示短 ID / 长 ID;筛选仍然用长 user_id。
|
||||||
|
function userIdText(user, fallbackId) {
|
||||||
|
const id = String(user?.userId || fallbackId || "").trim();
|
||||||
|
if (!id || id === "0") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const display = String(user?.displayUserId || "").trim();
|
||||||
|
return display ? `${display} / ${id}` : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultCycleKey 计算上一个 UTC 自然月,和后端 previousMonthCycleKey 保持同一周期口径。
|
||||||
|
function defaultCycleKey() {
|
||||||
|
const now = new Date();
|
||||||
|
const firstOfMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||||
|
firstOfMonth.setUTCMonth(firstOfMonth.getUTCMonth() - 1);
|
||||||
|
return `${firstOfMonth.getUTCFullYear()}-${String(firstOfMonth.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// settlementTypeLabel 把 Host/Agency 结算类型枚举转成运营文案。
|
||||||
|
function settlementTypeLabel(value) {
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
daily: "日结",
|
||||||
|
half_month: "半月结算",
|
||||||
|
month_end: "月底清算",
|
||||||
|
}[value] || value || "-"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusLabel 把通用结算状态枚举转成运营文案。
|
||||||
|
function statusLabel(value) {
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
failed: "失败",
|
||||||
|
skipped: "跳过",
|
||||||
|
succeeded: "成功",
|
||||||
|
}[value] || value || "-"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusTone 把业务状态映射为现有 status-badge 视觉语义。
|
||||||
|
function statusTone(value) {
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
failed: "danger",
|
||||||
|
skipped: "warning",
|
||||||
|
succeeded: "running",
|
||||||
|
}[value] || "stopped"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// roleLabel 把 policy_type 展示为工资角色名称。
|
||||||
|
function roleLabel(value) {
|
||||||
|
return value === "admin" ? "Admin" : value === "bd" ? "BD" : value || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
// triggerLabel 把触发方式展示为自动/手动。
|
||||||
|
function triggerLabel(value) {
|
||||||
|
return value === "automatic" ? "自动" : value === "manual" ? "手动" : value || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatMoneyDelta 专门展示“本次增量”,正数带 +,0 保持中性。
|
||||||
|
function formatMoneyDelta(value) {
|
||||||
|
const number = Number(value || 0);
|
||||||
|
if (!Number.isFinite(number) || number === 0) {
|
||||||
|
return "$0.00";
|
||||||
|
}
|
||||||
|
const prefix = number > 0 ? "+$" : "-$";
|
||||||
|
return `${prefix}${Math.abs(number).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatSignedInteger 展示金币增量,正数带 +。
|
||||||
|
function formatSignedInteger(value) {
|
||||||
|
const number = Number(value || 0);
|
||||||
|
if (!Number.isFinite(number) || number === 0) {
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
return `${number > 0 ? "+" : ""}${formatNumber(number)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatNumber 统一数字千分位,表格内不做本地状态计算。
|
||||||
|
function formatNumber(value) {
|
||||||
|
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||||
|
}
|
||||||
271
src/features/host-agency-policy/pages/TeamSalaryPolicyPanel.jsx
Normal file
271
src/features/host-agency-policy/pages/TeamSalaryPolicyPanel.jsx
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
import Add from "@mui/icons-material/Add";
|
||||||
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import {
|
||||||
|
AdminActionIconButton,
|
||||||
|
AdminFilterResetButton,
|
||||||
|
AdminFilterSelect,
|
||||||
|
AdminListBody,
|
||||||
|
AdminListToolbar,
|
||||||
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
import { TeamSalaryPolicyDrawer } from "@/features/host-agency-policy/components/TeamSalaryPolicyDrawer.jsx";
|
||||||
|
import { useTeamSalaryPolicyPage } from "@/features/host-agency-policy/hooks/useTeamSalaryPolicyPage.js";
|
||||||
|
import styles from "@/features/host-agency-policy/host-agency-policy.module.css";
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
["", "全部状态"],
|
||||||
|
["active", "启用"],
|
||||||
|
["disabled", "停用"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const settlementTriggerOptions = [
|
||||||
|
["", "全部触发"],
|
||||||
|
["automatic", "自动结算"],
|
||||||
|
["manual", "手动结算"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const columnsBase = [
|
||||||
|
{
|
||||||
|
key: "policy",
|
||||||
|
label: "政策",
|
||||||
|
width: "minmax(260px, 1.2fr)",
|
||||||
|
render: (item, _rowIndex, context) => (
|
||||||
|
<PolicyIdentity item={item} policyType={context.policyType} regionLabels={context.regionLabels} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "settlement",
|
||||||
|
label: "触发与周期",
|
||||||
|
width: "minmax(180px, 0.8fr)",
|
||||||
|
render: (item) => <SettlementSummary item={item} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "levels",
|
||||||
|
label: "等级",
|
||||||
|
width: "minmax(220px, 0.95fr)",
|
||||||
|
render: (item, _rowIndex, context) => <LevelSummary item={item} policyType={context.policyType} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "effective",
|
||||||
|
label: "生效时间",
|
||||||
|
width: "minmax(220px, 1fr)",
|
||||||
|
render: (item) => <EffectiveTime item={item} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态",
|
||||||
|
width: "minmax(110px, 0.5fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updatedAt",
|
||||||
|
label: "更新时间",
|
||||||
|
width: "minmax(170px, 0.75fr)",
|
||||||
|
render: (item) => formatMillis(item.updatedAtMs || item.createdAtMs),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "操作",
|
||||||
|
width: "112px",
|
||||||
|
resizable: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TeamSalaryPolicyPanel({ policyType }) {
|
||||||
|
const page = useTeamSalaryPolicyPage(policyType);
|
||||||
|
const items = page.data.items || [];
|
||||||
|
const total = page.data.total || 0;
|
||||||
|
const regionFilterOptions = [["", "全部区域"], ...page.regionOptions.map((region) => [region.value, region.label])];
|
||||||
|
const columns = columnsBase.map((column) => {
|
||||||
|
if (column.key === "policy") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createTextColumnFilter({
|
||||||
|
placeholder: "搜索政策名称",
|
||||||
|
value: page.query,
|
||||||
|
onChange: page.changeQuery,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (column.key === "settlement") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createOptionsColumnFilter({
|
||||||
|
options: settlementTriggerOptions,
|
||||||
|
placeholder: "搜索触发方式",
|
||||||
|
value: page.settlementTriggerMode,
|
||||||
|
onChange: page.changeSettlementTriggerMode,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (column.key === "status") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createOptionsColumnFilter({
|
||||||
|
options: statusOptions,
|
||||||
|
placeholder: "搜索状态",
|
||||||
|
value: page.status,
|
||||||
|
onChange: page.changeStatus,
|
||||||
|
}),
|
||||||
|
render: (item) => <PolicyStatusSwitch item={item} page={page} />,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (column.key === "actions") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
render: (item) => <PolicyActions item={item} page={page} />,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return column;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminListToolbar
|
||||||
|
actions={
|
||||||
|
page.abilities.canTeamCreate ? (
|
||||||
|
<AdminActionIconButton
|
||||||
|
label={`新增${policyTitle(policyType)}政策`}
|
||||||
|
primary
|
||||||
|
onClick={page.openCreate}
|
||||||
|
>
|
||||||
|
<Add fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
filters={
|
||||||
|
<div className={styles.toolbarFilters}>
|
||||||
|
<AdminFilterSelect
|
||||||
|
label="区域"
|
||||||
|
options={regionFilterOptions}
|
||||||
|
value={page.regionId}
|
||||||
|
onChange={page.changeRegionId}
|
||||||
|
/>
|
||||||
|
<AdminFilterResetButton
|
||||||
|
disabled={!page.query && !page.regionId && !page.status && !page.settlementTriggerMode}
|
||||||
|
onClick={page.resetFilters}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
context={{ policyType, regionLabels: page.regionLabels }}
|
||||||
|
items={items}
|
||||||
|
minWidth="1240px"
|
||||||
|
pagination={
|
||||||
|
total > 0
|
||||||
|
? {
|
||||||
|
page: page.page,
|
||||||
|
pageSize: page.data.pageSize || 50,
|
||||||
|
total,
|
||||||
|
onPageChange: page.setPage,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
rowKey={(item) => item.id}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
<TeamSalaryPolicyDrawer page={page} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolicyIdentity({ item, policyType, regionLabels }) {
|
||||||
|
const labels = regionLabels || {};
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span className={styles.name}>{item.name}</span>
|
||||||
|
<span className={styles.meta}>
|
||||||
|
{policyTitle(policyType)} · {labels[item.regionId] || `区域 ${item.regionId}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettlementSummary({ item }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{settlementTriggerLabel(item.settlementTriggerMode)}</span>
|
||||||
|
<span className={styles.meta}>完整自然月</span>
|
||||||
|
<span className={styles.meta}>次月 5 日可结算</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LevelSummary({ item, policyType }) {
|
||||||
|
const first = item.levels?.[0];
|
||||||
|
const last = item.levels?.[item.levels.length - 1];
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{item.levels?.length || 0} 个等级</span>
|
||||||
|
<span className={styles.meta}>
|
||||||
|
{first && last ? `$${first.thresholdUsd} - $${last.thresholdUsd} ${thresholdUnit(policyType)}` : "-"}
|
||||||
|
</span>
|
||||||
|
<span className={styles.meta}>{last ? `最高工资 $${last.salaryUsd}` : ""}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EffectiveTime({ item }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{item.effectiveFromMs ? formatMillis(item.effectiveFromMs) : "立即生效"}</span>
|
||||||
|
<span className={styles.meta}>{item.effectiveToMs ? formatMillis(item.effectiveToMs) : "长期有效"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolicyStatusSwitch({ item, page }) {
|
||||||
|
const disabled = !page.abilities.canTeamUpdate || page.loadingAction === `team-policy-status-${item.id}`;
|
||||||
|
return (
|
||||||
|
<AdminSwitch
|
||||||
|
checked={item.status === "active"}
|
||||||
|
checkedLabel="启用"
|
||||||
|
disabled={disabled}
|
||||||
|
label="切换政策状态"
|
||||||
|
uncheckedLabel="停用"
|
||||||
|
onChange={(event) => page.togglePolicyStatus(item, event.target.checked)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolicyActions({ item, page }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.rowActions}>
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={!page.abilities.canTeamUpdate}
|
||||||
|
label="编辑工资政策"
|
||||||
|
onClick={() => page.openEdit(item)}
|
||||||
|
>
|
||||||
|
<EditOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={!page.abilities.canTeamDelete || page.loadingAction === `team-policy-delete-${item.id}`}
|
||||||
|
label="删除工资政策"
|
||||||
|
onClick={() => page.removePolicy(item)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function policyTitle(policyType) {
|
||||||
|
return policyType === "bd" ? "BD" : "Admin";
|
||||||
|
}
|
||||||
|
|
||||||
|
function thresholdUnit(policyType) {
|
||||||
|
return policyType === "bd" ? "主播工资" : "BD工资";
|
||||||
|
}
|
||||||
|
|
||||||
|
function settlementTriggerLabel(value) {
|
||||||
|
return settlementTriggerOptions.find(([mode]) => mode === value)?.[1] || value || "-";
|
||||||
|
}
|
||||||
20
src/features/host-agency-policy/permissions.js
Normal file
20
src/features/host-agency-policy/permissions.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export function useHostAgencyPolicyAbilities() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
canCreate: can(PERMISSIONS.hostAgencyPolicyCreate),
|
||||||
|
canDelete: can(PERMISSIONS.hostAgencyPolicyDelete),
|
||||||
|
canPublish: can(PERMISSIONS.hostAgencyPolicyPublish),
|
||||||
|
canTeamCreate: can(PERMISSIONS.teamSalaryPolicyCreate),
|
||||||
|
canTeamDelete: can(PERMISSIONS.teamSalaryPolicyDelete),
|
||||||
|
canTeamUpdate: can(PERMISSIONS.teamSalaryPolicyUpdate),
|
||||||
|
canTeamView: can(PERMISSIONS.teamSalaryPolicyView),
|
||||||
|
canSettleSalary: can(PERMISSIONS.hostSalarySettlementSettle),
|
||||||
|
canViewSettlements: can(PERMISSIONS.hostSalarySettlementView),
|
||||||
|
canUpdate: can(PERMISSIONS.hostAgencyPolicyUpdate),
|
||||||
|
canView: can(PERMISSIONS.hostAgencyPolicyView),
|
||||||
|
};
|
||||||
|
}
|
||||||
20
src/features/host-agency-policy/routes.js
Normal file
20
src/features/host-agency-policy/routes.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export const hostAgencyPolicyRoutes = [
|
||||||
|
{
|
||||||
|
label: "工资政策",
|
||||||
|
loader: () => import("./pages/HostAgencyPolicyPage.jsx").then((module) => module.HostAgencyPolicyPage),
|
||||||
|
menuCode: MENU_CODES.hostAgencyPolicy,
|
||||||
|
pageKey: "host-agency-policy",
|
||||||
|
path: "/host/salary-policies",
|
||||||
|
permission: PERMISSIONS.hostAgencyPolicyView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "工资结算",
|
||||||
|
loader: () => import("./pages/HostSalarySettlementPage.jsx").then((module) => module.HostSalarySettlementPage),
|
||||||
|
menuCode: MENU_CODES.hostSalarySettlement,
|
||||||
|
pageKey: "host-salary-settlement",
|
||||||
|
path: "/host/salary-settlements",
|
||||||
|
permission: PERMISSIONS.hostSalarySettlementView,
|
||||||
|
},
|
||||||
|
];
|
||||||
230
src/features/host-agency-policy/schema.ts
Normal file
230
src/features/host-agency-policy/schema.ts
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const statusValues = ["active", "disabled"] as const;
|
||||||
|
const settlementModes = ["daily", "half_month"] as const;
|
||||||
|
const settlementTriggerModes = ["automatic", "manual"] as const;
|
||||||
|
const teamPolicyTypes = ["bd", "admin"] as const;
|
||||||
|
|
||||||
|
const levelSchema = z.object({
|
||||||
|
// 输入框值可能是 string,复制/恢复表单时也可能是 number;统一在 superRefine 中转数值校验。
|
||||||
|
agencySalaryUsd: z.union([z.string(), z.number()]),
|
||||||
|
hostCoinReward: z.union([z.string(), z.number()]),
|
||||||
|
hostSalaryUsd: z.union([z.string(), z.number()]),
|
||||||
|
level: z.union([z.string(), z.number()]),
|
||||||
|
requiredDiamonds: z.union([z.string(), z.number()]),
|
||||||
|
sortOrder: z.union([z.string(), z.number()]).optional(),
|
||||||
|
status: z.enum(statusValues),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const hostAgencyPolicyFormSchema = z
|
||||||
|
.object({
|
||||||
|
effectiveFrom: z.union([z.string(), z.number()]).optional(),
|
||||||
|
effectiveTo: z.union([z.string(), z.number()]).optional(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
giftCoinToDiamondRatio: z.union([z.string(), z.number()]),
|
||||||
|
levels: z.array(levelSchema).min(1, "请至少配置一个等级"),
|
||||||
|
name: z.string().trim().min(1, "请输入政策名称").max(120, "政策名称不能超过 120 个字符"),
|
||||||
|
regionId: z.union([z.string(), z.number()]),
|
||||||
|
residualDiamondToUsdRate: z.union([z.string(), z.number()]),
|
||||||
|
settlementMode: z.enum(settlementModes),
|
||||||
|
settlementTriggerMode: z.enum(settlementTriggerModes),
|
||||||
|
})
|
||||||
|
.superRefine((value, context) => {
|
||||||
|
// 前端先做完整业务校验,降低运营误填成本;后端仍是最终可信校验边界。
|
||||||
|
const regionId = Number(value.regionId);
|
||||||
|
if (!Number.isInteger(regionId) || regionId <= 0) {
|
||||||
|
context.addIssue({ code: "custom", message: "请选择适用区域", path: ["regionId"] });
|
||||||
|
}
|
||||||
|
if (!isFixedDecimal(value.giftCoinToDiamondRatio, 6, false)) {
|
||||||
|
context.addIssue({ code: "custom", message: "金币转钻石比例不正确", path: ["giftCoinToDiamondRatio"] });
|
||||||
|
}
|
||||||
|
if (!isFixedDecimal(value.residualDiamondToUsdRate, 12, true)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "剩余钻石转美元比例不正确",
|
||||||
|
path: ["residualDiamondToUsdRate"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const effectiveFromMs = timeValueToMs(value.effectiveFrom);
|
||||||
|
const effectiveToMs = timeValueToMs(value.effectiveTo);
|
||||||
|
if (effectiveFromMs < 0 || effectiveToMs < 0 || (effectiveToMs > 0 && effectiveToMs <= effectiveFromMs)) {
|
||||||
|
context.addIssue({ code: "custom", message: "生效时间范围不正确", path: ["effectiveTo"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLevels = [...value.levels].sort((left, right) => Number(left.level) - Number(right.level));
|
||||||
|
const seenLevels = new Set<number>();
|
||||||
|
let previousRequiredDiamonds = 0;
|
||||||
|
let previousHostSalary = 0;
|
||||||
|
let previousHostCoinReward = 0;
|
||||||
|
let previousAgencySalary = 0;
|
||||||
|
normalizedLevels.forEach((level, index) => {
|
||||||
|
// 等级号、钻石门槛和三类累计权益必须单调,保证后续差额结算不会出现负值或多重命中。
|
||||||
|
const levelNo = Number(level.level);
|
||||||
|
if (!Number.isInteger(levelNo) || levelNo <= 0 || seenLevels.has(levelNo)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "等级必须为不重复的正整数",
|
||||||
|
path: ["levels", index, "level"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
seenLevels.add(levelNo);
|
||||||
|
const requiredDiamonds = Number(level.requiredDiamonds);
|
||||||
|
if (
|
||||||
|
!Number.isInteger(requiredDiamonds) ||
|
||||||
|
requiredDiamonds <= 0 ||
|
||||||
|
requiredDiamonds <= previousRequiredDiamonds
|
||||||
|
) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "钻石门槛必须逐级递增",
|
||||||
|
path: ["levels", index, "requiredDiamonds"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const hostSalary = decimalToScaled(level.hostSalaryUsd, 2);
|
||||||
|
if (hostSalary < 0 || hostSalary < previousHostSalary) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "主播工资不能小于上一等级",
|
||||||
|
path: ["levels", index, "hostSalaryUsd"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const hostCoinReward = Number(level.hostCoinReward);
|
||||||
|
if (!Number.isInteger(hostCoinReward) || hostCoinReward < 0 || hostCoinReward < previousHostCoinReward) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "金币奖励不能小于上一等级",
|
||||||
|
path: ["levels", index, "hostCoinReward"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const agencySalary = decimalToScaled(level.agencySalaryUsd, 2);
|
||||||
|
if (agencySalary < 0 || agencySalary < previousAgencySalary) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "代理工资不能小于上一等级",
|
||||||
|
path: ["levels", index, "agencySalaryUsd"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
previousRequiredDiamonds = requiredDiamonds;
|
||||||
|
previousHostSalary = hostSalary;
|
||||||
|
previousHostCoinReward = hostCoinReward;
|
||||||
|
previousAgencySalary = agencySalary;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamLevelSchema = z.object({
|
||||||
|
level: z.union([z.string(), z.number()]),
|
||||||
|
ratePercent: z.union([z.string(), z.number()]),
|
||||||
|
salaryUsd: z.union([z.string(), z.number()]),
|
||||||
|
sortOrder: z.union([z.string(), z.number()]).optional(),
|
||||||
|
status: z.enum(statusValues),
|
||||||
|
thresholdUsd: z.union([z.string(), z.number()]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const teamSalaryPolicyFormSchema = z
|
||||||
|
.object({
|
||||||
|
effectiveFrom: z.union([z.string(), z.number()]).optional(),
|
||||||
|
effectiveTo: z.union([z.string(), z.number()]).optional(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
levels: z.array(teamLevelSchema).min(1, "请至少配置一个等级"),
|
||||||
|
name: z.string().trim().min(1, "请输入政策名称").max(120, "政策名称不能超过 120 个字符"),
|
||||||
|
policyType: z.enum(teamPolicyTypes),
|
||||||
|
regionId: z.union([z.string(), z.number()]),
|
||||||
|
settlementTriggerMode: z.enum(settlementTriggerModes),
|
||||||
|
})
|
||||||
|
.superRefine((value, context) => {
|
||||||
|
const regionId = Number(value.regionId);
|
||||||
|
if (!Number.isInteger(regionId) || regionId <= 0) {
|
||||||
|
context.addIssue({ code: "custom", message: "请选择适用区域", path: ["regionId"] });
|
||||||
|
}
|
||||||
|
const effectiveFromMs = timeValueToMs(value.effectiveFrom);
|
||||||
|
const effectiveToMs = timeValueToMs(value.effectiveTo);
|
||||||
|
if (effectiveFromMs < 0 || effectiveToMs < 0 || (effectiveToMs > 0 && effectiveToMs <= effectiveFromMs)) {
|
||||||
|
context.addIssue({ code: "custom", message: "生效时间范围不正确", path: ["effectiveTo"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLevels = [...value.levels].sort((left, right) => Number(left.level) - Number(right.level));
|
||||||
|
const seenLevels = new Set<number>();
|
||||||
|
let previousThreshold = 0;
|
||||||
|
let previousSalary = 0;
|
||||||
|
normalizedLevels.forEach((level, index) => {
|
||||||
|
// BD/Admin 等级表保存累计门槛和累计工资,递增校验保证后续差额结算不会倒扣。
|
||||||
|
const levelNo = Number(level.level);
|
||||||
|
if (!Number.isInteger(levelNo) || levelNo <= 0 || seenLevels.has(levelNo)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "等级必须为不重复的正整数",
|
||||||
|
path: ["levels", index, "level"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
seenLevels.add(levelNo);
|
||||||
|
const threshold = decimalToScaled(level.thresholdUsd, 2, false);
|
||||||
|
if (threshold <= 0 || threshold <= previousThreshold) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "收入门槛必须逐级递增",
|
||||||
|
path: ["levels", index, "thresholdUsd"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const rate = decimalToScaled(level.ratePercent, 4, false);
|
||||||
|
if (rate <= 0) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "提成比例必须大于 0",
|
||||||
|
path: ["levels", index, "ratePercent"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const salary = decimalToScaled(level.salaryUsd, 2);
|
||||||
|
if (salary < 0 || salary < previousSalary) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "累计工资不能小于上一等级",
|
||||||
|
path: ["levels", index, "salaryUsd"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
previousThreshold = threshold;
|
||||||
|
previousSalary = salary;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function isFixedDecimal(value: unknown, scale: number, allowZero: boolean) {
|
||||||
|
return decimalToScaled(value, scale, allowZero) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decimalToScaled(value: unknown, scale: number, allowZero = true) {
|
||||||
|
// 小数校验转换成整数刻度比较,避免 JS 浮点数直接参与金额大小判断。
|
||||||
|
const raw = String(value ?? "").trim();
|
||||||
|
if (!raw || raw.startsWith("-")) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
const pattern = new RegExp(`^\\d+(\\.\\d{1,${scale}})?$`);
|
||||||
|
if (!pattern.test(raw)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
const [whole, fraction = ""] = raw.split(".");
|
||||||
|
const scaled = Number(whole) * 10 ** scale + Number(fraction.padEnd(scale, "0"));
|
||||||
|
if (!Number.isSafeInteger(scaled) || (!allowZero && scaled <= 0)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return scaled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeValueToMs(value: unknown) {
|
||||||
|
// 空值表示长期/立即生效;非法日期返回 -1,让上层统一提示时间范围错误。
|
||||||
|
if (value === "" || value === null || value === undefined) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return Number.isFinite(value) && value >= 0 ? value : -1;
|
||||||
|
}
|
||||||
|
const text = String(value).trim();
|
||||||
|
if (!text) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const number = Number(value);
|
||||||
|
if (/^\d+$/.test(text)) {
|
||||||
|
return Number.isFinite(number) ? number : -1;
|
||||||
|
}
|
||||||
|
const date = new Date(text);
|
||||||
|
const ms = date.getTime();
|
||||||
|
return Number.isFinite(ms) ? ms : -1;
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import type {
|
|||||||
CoinLedgerEntryDto,
|
CoinLedgerEntryDto,
|
||||||
CoinLedgerUserDto,
|
CoinLedgerUserDto,
|
||||||
PageQuery,
|
PageQuery,
|
||||||
|
ReportDto,
|
||||||
} from "@/shared/api/types";
|
} from "@/shared/api/types";
|
||||||
|
|
||||||
export function listCoinLedger(query: PageQuery = {}): Promise<ApiPage<CoinLedgerEntryDto>> {
|
export function listCoinLedger(query: PageQuery = {}): Promise<ApiPage<CoinLedgerEntryDto>> {
|
||||||
@ -26,6 +27,14 @@ export function listCoinAdjustments(query: PageQuery = {}): Promise<ApiPage<Coin
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listReports(query: PageQuery = {}): Promise<ApiPage<ReportDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listReports;
|
||||||
|
return apiRequest<ApiPage<ReportDto>>(apiEndpointPath(API_OPERATIONS.listReports), {
|
||||||
|
method: endpoint.method,
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function lookupCoinAdjustmentTarget(userId: string): Promise<CoinLedgerUserDto> {
|
export function lookupCoinAdjustmentTarget(userId: string): Promise<CoinLedgerUserDto> {
|
||||||
const endpoint = API_ENDPOINTS.lookupCoinAdjustmentTarget;
|
const endpoint = API_ENDPOINTS.lookupCoinAdjustmentTarget;
|
||||||
return apiRequest<CoinLedgerUserDto>(apiEndpointPath(API_OPERATIONS.lookupCoinAdjustmentTarget), {
|
return apiRequest<CoinLedgerUserDto>(apiEndpointPath(API_OPERATIONS.lookupCoinAdjustmentTarget), {
|
||||||
|
|||||||
@ -185,6 +185,56 @@
|
|||||||
background: var(--bg-card-strong);
|
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) {
|
@media (max-width: 640px) {
|
||||||
.detailGrid {
|
.detailGrid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
260
src/features/operations/pages/ReportListPage.jsx
Normal file
260
src/features/operations/pages/ReportListPage.jsx
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined";
|
||||||
|
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { listReports } from "@/features/operations/api";
|
||||||
|
import styles from "@/features/operations/operations.module.css";
|
||||||
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
|
import {
|
||||||
|
AdminFilterResetButton,
|
||||||
|
AdminListBody,
|
||||||
|
AdminListPage,
|
||||||
|
AdminListToolbar,
|
||||||
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
|
||||||
|
const pageSize = 50;
|
||||||
|
|
||||||
|
const targetTypeOptions = [
|
||||||
|
{ label: "全部目标", value: "" },
|
||||||
|
{ label: "用户", value: "user" },
|
||||||
|
{ label: "房间", value: "room" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const reportTypeOptions = [
|
||||||
|
{ label: "全部类型", value: "" },
|
||||||
|
{ label: "政治", value: "politics" },
|
||||||
|
{ label: "色情", value: "pornography" },
|
||||||
|
{ label: "血腥暴力", value: "graphic_violence" },
|
||||||
|
{ label: "言语辱骂", value: "verbal_abuse" },
|
||||||
|
{ label: "欺诈", value: "fraud" },
|
||||||
|
{ label: "违法内容", value: "illegal_content" },
|
||||||
|
{ label: "未成年人", value: "minors" },
|
||||||
|
{ label: "线下交易", value: "in_person_transactions" },
|
||||||
|
{ label: "其他", value: "other" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: "targetType",
|
||||||
|
label: "举报目标",
|
||||||
|
render: (report) => <TargetTypeLabel report={report} />,
|
||||||
|
width: "minmax(120px, 0.55fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "target",
|
||||||
|
label: "举报信息",
|
||||||
|
render: (report) => <TargetCell report={report} />,
|
||||||
|
width: "minmax(300px, 1.3fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reportType",
|
||||||
|
label: "举报类型",
|
||||||
|
render: (report) => reportTypeLabel(report.reportType),
|
||||||
|
width: "minmax(150px, 0.7fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reason",
|
||||||
|
label: "举报理由",
|
||||||
|
render: (report) => <span className={styles.reportReason}>{report.reason || "-"}</span>,
|
||||||
|
width: "minmax(220px, 1fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "imageUrls",
|
||||||
|
label: "举报图片",
|
||||||
|
render: (report) => <EvidenceImages imageUrls={report.imageUrls} />,
|
||||||
|
width: "minmax(180px, 0.8fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdAtMs",
|
||||||
|
label: "提交时间",
|
||||||
|
render: (report) => formatMillis(report.createdAtMs),
|
||||||
|
width: "minmax(170px, 0.75fr)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ReportListPage() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [targetType, setTargetType] = useState("");
|
||||||
|
const [reportType, setReportType] = useState("");
|
||||||
|
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
||||||
|
|
||||||
|
const filters = useMemo(
|
||||||
|
() => ({
|
||||||
|
end_at_ms: timeRange.endMs || "",
|
||||||
|
keyword,
|
||||||
|
report_type: reportType,
|
||||||
|
start_at_ms: timeRange.startMs || "",
|
||||||
|
target_type: targetType,
|
||||||
|
}),
|
||||||
|
[keyword, reportType, targetType, timeRange.endMs, timeRange.startMs],
|
||||||
|
);
|
||||||
|
const query = usePaginatedQuery({
|
||||||
|
errorMessage: "获取举报列表失败",
|
||||||
|
fetcher: listReports,
|
||||||
|
filters,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
queryKey: ["reports", filters, page],
|
||||||
|
});
|
||||||
|
const data = query.data || { items: [], total: 0, page, pageSize };
|
||||||
|
const items = data.items || [];
|
||||||
|
const total = data.total || 0;
|
||||||
|
|
||||||
|
const changeFilter = (setter) => (value) => {
|
||||||
|
setter(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const changeTimeRange = (nextRange) => {
|
||||||
|
setTimeRange(nextRange);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const resetFilters = () => {
|
||||||
|
setKeyword("");
|
||||||
|
setTargetType("");
|
||||||
|
setReportType("");
|
||||||
|
setTimeRange({ endMs: "", startMs: "" });
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableColumns = columns.map((column) => {
|
||||||
|
if (column.key === "target") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createTextColumnFilter({
|
||||||
|
placeholder: "长 ID / 短 ID / 昵称",
|
||||||
|
value: keyword,
|
||||||
|
onChange: changeFilter(setKeyword),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (column.key === "targetType") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createOptionsColumnFilter({
|
||||||
|
options: targetTypeOptions,
|
||||||
|
value: targetType,
|
||||||
|
onChange: changeFilter(setTargetType),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (column.key === "reportType") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createOptionsColumnFilter({
|
||||||
|
options: reportTypeOptions,
|
||||||
|
value: reportType,
|
||||||
|
onChange: changeFilter(setReportType),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return column;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<AdminListToolbar
|
||||||
|
filters={
|
||||||
|
<>
|
||||||
|
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
|
||||||
|
<AdminFilterResetButton
|
||||||
|
disabled={!keyword && !targetType && !reportType && !timeRange.startMs && !timeRange.endMs}
|
||||||
|
onClick={resetFilters}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable
|
||||||
|
columns={tableColumns}
|
||||||
|
items={items}
|
||||||
|
minWidth="1140px"
|
||||||
|
pagination={
|
||||||
|
total > 0
|
||||||
|
? {
|
||||||
|
page,
|
||||||
|
pageSize: data.pageSize || pageSize,
|
||||||
|
total,
|
||||||
|
onPageChange: setPage,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
rowKey={(report) => report.reportId}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TargetTypeLabel({ report }) {
|
||||||
|
const isRoom = report.targetType === "room";
|
||||||
|
return (
|
||||||
|
<span className={styles.reportTargetType}>
|
||||||
|
{isRoom ? <MeetingRoomOutlined fontSize="small" /> : <PersonOutlineOutlined fontSize="small" />}
|
||||||
|
{isRoom ? "房间" : "用户"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TargetCell({ report }) {
|
||||||
|
const target = report.target || {};
|
||||||
|
if (report.targetType === "room") {
|
||||||
|
const room = target.room || {};
|
||||||
|
return (
|
||||||
|
<div className={styles.identity}>
|
||||||
|
<span className={styles.avatar}>
|
||||||
|
{room.coverUrl ? <img alt="" src={room.coverUrl} /> : <MeetingRoomOutlined fontSize="small" />}
|
||||||
|
</span>
|
||||||
|
<span className={styles.identityText}>
|
||||||
|
<span className={styles.primaryText}>{room.title || "-"}</span>
|
||||||
|
<span className={styles.meta}>{targetMeta(room.roomShortId, room.roomId || report.roomId)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const user = target.user || {};
|
||||||
|
return (
|
||||||
|
<div className={styles.identity}>
|
||||||
|
<span className={styles.avatar}>
|
||||||
|
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
|
||||||
|
</span>
|
||||||
|
<span className={styles.identityText}>
|
||||||
|
<span className={styles.primaryText}>{user.username || "-"}</span>
|
||||||
|
<span className={styles.meta}>
|
||||||
|
{targetMeta(user.displayUserId || user.defaultDisplayUserId, user.userId || report.userId)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EvidenceImages({ imageUrls = [] }) {
|
||||||
|
const urls = Array.isArray(imageUrls) ? imageUrls.filter(Boolean) : [];
|
||||||
|
if (!urls.length) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={styles.reportImages}>
|
||||||
|
{urls.slice(0, 4).map((url) => (
|
||||||
|
<a className={styles.reportImage} href={url} key={url} rel="noreferrer" target="_blank">
|
||||||
|
<img alt="" src={url} />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
{urls.length > 4 ? <span className={styles.reportImageCount}>+{urls.length - 4}</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetMeta(shortId, longId) {
|
||||||
|
return `短ID ${shortId || "-"} · 长ID ${longId || "-"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportTypeLabel(value) {
|
||||||
|
return reportTypeOptions.find((item) => item.value === value)?.label || value || "-";
|
||||||
|
}
|
||||||
@ -17,4 +17,12 @@ export const operationsRoutes = [
|
|||||||
path: "/operations/coin-adjustments",
|
path: "/operations/coin-adjustments",
|
||||||
permission: PERMISSIONS.coinAdjustmentView,
|
permission: PERMISSIONS.coinAdjustmentView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "举报列表",
|
||||||
|
loader: () => import("./pages/ReportListPage.jsx").then((module) => module.ReportListPage),
|
||||||
|
menuCode: MENU_CODES.operationReports,
|
||||||
|
pageKey: "operation-reports",
|
||||||
|
path: "/operations/reports",
|
||||||
|
permission: PERMISSIONS.reportView,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export interface RedPacketConfigDto {
|
|||||||
delayedOpenSeconds: number;
|
delayedOpenSeconds: number;
|
||||||
expireSeconds: number;
|
expireSeconds: number;
|
||||||
dailySendLimit: number;
|
dailySendLimit: number;
|
||||||
|
ruleUrl?: string;
|
||||||
updatedByAdminId?: number;
|
updatedByAdminId?: number;
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
@ -20,8 +21,9 @@ export interface RedPacketConfigPayload {
|
|||||||
count_tiers: number[];
|
count_tiers: number[];
|
||||||
amount_tiers: number[];
|
amount_tiers: number[];
|
||||||
delayed_open_seconds: number;
|
delayed_open_seconds: number;
|
||||||
expire_seconds: number;
|
expire_seconds?: number;
|
||||||
daily_send_limit: number;
|
daily_send_limit: number;
|
||||||
|
rule_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RedPacketClaimDto {
|
export interface RedPacketClaimDto {
|
||||||
@ -54,15 +56,20 @@ export interface RedPacketDto {
|
|||||||
expiresAtMs: number;
|
expiresAtMs: number;
|
||||||
createdAtMs: number;
|
createdAtMs: number;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
|
refundAmount?: number;
|
||||||
|
refundStatus?: string;
|
||||||
|
refundedAtMs?: number;
|
||||||
claims?: RedPacketClaimDto[];
|
claims?: RedPacketClaimDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RedPacketQuery extends PageQuery {
|
export interface RedPacketQuery extends PageQuery {
|
||||||
room_id?: string;
|
roomId?: string;
|
||||||
sender_user_id?: string;
|
senderUserId?: string;
|
||||||
region_id?: string;
|
region_id?: string;
|
||||||
packet_type?: string;
|
packetMode?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RawConfig = RedPacketConfigDto & Record<string, unknown>;
|
type RawConfig = RedPacketConfigDto & Record<string, unknown>;
|
||||||
@ -102,6 +109,14 @@ export function getRedPacket(packetId: string): Promise<RedPacketDto> {
|
|||||||
}).then(normalizePacket);
|
}).then(normalizePacket);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function retryRedPacketRefund(packetId: string): Promise<unknown> {
|
||||||
|
const endpoint = API_ENDPOINTS.retryRedPacketRefund;
|
||||||
|
return apiRequest(apiEndpointPath(API_OPERATIONS.retryRedPacketRefund), {
|
||||||
|
body: { packetNo: packetId },
|
||||||
|
method: endpoint.method,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeConfig(item: RawConfig): RedPacketConfigDto {
|
function normalizeConfig(item: RawConfig): RedPacketConfigDto {
|
||||||
return {
|
return {
|
||||||
appCode: stringValue(item.appCode ?? item.app_code),
|
appCode: stringValue(item.appCode ?? item.app_code),
|
||||||
@ -111,6 +126,7 @@ function normalizeConfig(item: RawConfig): RedPacketConfigDto {
|
|||||||
delayedOpenSeconds: numberValue(item.delayedOpenSeconds ?? item.delayed_open_seconds),
|
delayedOpenSeconds: numberValue(item.delayedOpenSeconds ?? item.delayed_open_seconds),
|
||||||
expireSeconds: numberValue(item.expireSeconds ?? item.expire_seconds),
|
expireSeconds: numberValue(item.expireSeconds ?? item.expire_seconds),
|
||||||
dailySendLimit: numberValue(item.dailySendLimit ?? item.daily_send_limit),
|
dailySendLimit: numberValue(item.dailySendLimit ?? item.daily_send_limit),
|
||||||
|
ruleUrl: stringValue(item.ruleUrl ?? item.rule_url),
|
||||||
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
|
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
|
||||||
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_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),
|
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
|
||||||
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_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),
|
claims: rawClaims.map(normalizeClaim),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,30 +61,38 @@ export function RedPacketConfigDrawer({
|
|||||||
<div className="form-drawer__grid">
|
<div className="form-drawer__grid">
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
inputProps={{ min: 0 }}
|
|
||||||
label="延迟红包秒数"
|
label="延迟红包秒数"
|
||||||
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
type="number"
|
type="number"
|
||||||
value={form.delayedOpenSeconds}
|
value={form.delayedOpenSeconds}
|
||||||
onChange={(event) => updateValue("delayedOpenSeconds", event.target.value)}
|
onChange={(event) => updateValue("delayedOpenSeconds", event.target.value)}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled
|
||||||
inputProps={{ min: 1 }}
|
label="过期退款秒数(固定)"
|
||||||
label="过期退款秒数"
|
slotProps={{ htmlInput: { min: 1 } }}
|
||||||
type="number"
|
type="number"
|
||||||
value={form.expireSeconds}
|
value={form.expireSeconds}
|
||||||
onChange={(event) => updateValue("expireSeconds", event.target.value)}
|
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
inputProps={{ min: 1 }}
|
|
||||||
label="每用户每日最多发送"
|
label="每用户每日最多发送"
|
||||||
|
slotProps={{ htmlInput: { min: 1 } }}
|
||||||
type="number"
|
type="number"
|
||||||
value={form.dailySendLimit}
|
value={form.dailySendLimit}
|
||||||
onChange={(event) => updateValue("dailySendLimit", event.target.value)}
|
onChange={(event) => updateValue("dailySendLimit", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section className="form-drawer__section">
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
disabled={disabled}
|
||||||
|
label="规则说明 URL"
|
||||||
|
value={form.ruleUrl}
|
||||||
|
onChange={(event) => updateValue("ruleUrl", event.target.value)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
<TierSection
|
<TierSection
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="红包数量档位"
|
label="红包数量档位"
|
||||||
@ -140,8 +148,8 @@ function TierSection({ disabled, label, onAdd, onRemove, onUpdate, tierKey, valu
|
|||||||
<div className={styles.tierGrid} key={`${tierKey}-${index}`}>
|
<div className={styles.tierGrid} key={`${tierKey}-${index}`}>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
inputProps={{ min: 1 }}
|
|
||||||
label={`${label} ${index + 1}`}
|
label={`${label} ${index + 1}`}
|
||||||
|
slotProps={{ htmlInput: { min: 1 } }}
|
||||||
type="number"
|
type="number"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onUpdate(tierKey, index, event.target.value)}
|
onChange={(event) => onUpdate(tierKey, index, event.target.value)}
|
||||||
|
|||||||
@ -20,9 +20,10 @@ export function RedPacketConfigSummary({ canUpdate, config, configLoading, onEdi
|
|||||||
<SummaryItem label="数量档位">{formatTiers(config?.countTiers)}</SummaryItem>
|
<SummaryItem label="数量档位">{formatTiers(config?.countTiers)}</SummaryItem>
|
||||||
<SummaryItem label="金额档位">{formatTiers(config?.amountTiers)}</SummaryItem>
|
<SummaryItem label="金额档位">{formatTiers(config?.amountTiers)}</SummaryItem>
|
||||||
<SummaryItem label="延迟/过期">
|
<SummaryItem label="延迟/过期">
|
||||||
{Number(config?.delayedOpenSeconds || 0)}s / {Number(config?.expireSeconds || 0)}s
|
{Number(config?.delayedOpenSeconds || 0)}s / 24h
|
||||||
</SummaryItem>
|
</SummaryItem>
|
||||||
<SummaryItem label="每日上限">{Number(config?.dailySendLimit || 0)} 次</SummaryItem>
|
<SummaryItem label="每日上限">{Number(config?.dailySendLimit || 0)} 次</SummaryItem>
|
||||||
|
<SummaryItem label="规则 URL">{config?.ruleUrl || "-"}</SummaryItem>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.summaryActions}>
|
<div className={styles.summaryActions}>
|
||||||
<Button disabled={configLoading} startIcon={<RefreshOutlined fontSize="small" />} onClick={onRefresh}>
|
<Button disabled={configLoading} startIcon={<RefreshOutlined fontSize="small" />} onClick={onRefresh}>
|
||||||
|
|||||||
@ -53,6 +53,20 @@ export function RedPacketDetailDrawer({ loading, onClose, open, packet }) {
|
|||||||
<InfoRow label="状态" value={packetStatusLabel(packet.status)} />
|
<InfoRow label="状态" value={packetStatusLabel(packet.status)} />
|
||||||
<InfoRow label="金额" value={`${formatNumber(packet.totalAmount)} 金币`} />
|
<InfoRow label="金额" value={`${formatNumber(packet.totalAmount)} 金币`} />
|
||||||
<InfoRow label="剩余" value={`${formatNumber(packet.remainingAmount)} / ${packet.remainingCount} 个`} />
|
<InfoRow label="剩余" value={`${formatNumber(packet.remainingAmount)} / ${packet.remainingCount} 个`} />
|
||||||
|
<InfoRow
|
||||||
|
label="已领"
|
||||||
|
value={`${formatNumber(packet.totalAmount - packet.remainingAmount)} / ${
|
||||||
|
packet.packetCount - packet.remainingCount
|
||||||
|
} 个`}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label="退款"
|
||||||
|
value={
|
||||||
|
packet.refundStatus
|
||||||
|
? `${packetStatusLabel(packet.refundStatus)} ${formatNumber(packet.refundAmount)} 金币`
|
||||||
|
: "-"
|
||||||
|
}
|
||||||
|
/>
|
||||||
<InfoRow label="开启时间" value={formatMillis(packet.openAtMs)} />
|
<InfoRow label="开启时间" value={formatMillis(packet.openAtMs)} />
|
||||||
<InfoRow label="过期时间" value={formatMillis(packet.expiresAtMs)} />
|
<InfoRow label="过期时间" value={formatMillis(packet.expiresAtMs)} />
|
||||||
<DataTable
|
<DataTable
|
||||||
@ -81,7 +95,7 @@ function InfoRow({ label, value }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function packetTypeLabel(type) {
|
function packetTypeLabel(type) {
|
||||||
return type === "delayed" ? "延迟红包" : "普通红包";
|
return String(type || "").toLowerCase() === "delayed" ? "延时红包" : "即时红包";
|
||||||
}
|
}
|
||||||
|
|
||||||
function packetStatusLabel(status) {
|
function packetStatusLabel(status) {
|
||||||
@ -91,7 +105,7 @@ function packetStatusLabel(status) {
|
|||||||
refunded: "已退款",
|
refunded: "已退款",
|
||||||
waiting_open: "待开启",
|
waiting_open: "待开启",
|
||||||
};
|
};
|
||||||
return labels[status] || status || "-";
|
return labels[String(status || "").toLowerCase()] || status || "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
function claimStatusLabel(status) {
|
function claimStatusLabel(status) {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
getRedPacket,
|
getRedPacket,
|
||||||
getRedPacketConfig,
|
getRedPacketConfig,
|
||||||
listRedPackets,
|
listRedPackets,
|
||||||
|
retryRedPacketRefund,
|
||||||
updateRedPacketConfig,
|
updateRedPacketConfig,
|
||||||
} from "@/features/red-packets/api";
|
} from "@/features/red-packets/api";
|
||||||
import { useRedPacketAbilities } from "@/features/red-packets/permissions.js";
|
import { useRedPacketAbilities } from "@/features/red-packets/permissions.js";
|
||||||
@ -17,8 +18,9 @@ const emptyForm = {
|
|||||||
countTiers: [""],
|
countTiers: [""],
|
||||||
amountTiers: [""],
|
amountTiers: [""],
|
||||||
delayedOpenSeconds: "0",
|
delayedOpenSeconds: "0",
|
||||||
expireSeconds: "300",
|
expireSeconds: "86400",
|
||||||
dailySendLimit: "5",
|
dailySendLimit: "5",
|
||||||
|
ruleUrl: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useRedPacketPage() {
|
export function useRedPacketPage() {
|
||||||
@ -40,9 +42,9 @@ export function useRedPacketPage() {
|
|||||||
|
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
room_id: roomId.trim() || undefined,
|
roomId: roomId.trim() || undefined,
|
||||||
sender_user_id: senderUserId.trim() || undefined,
|
senderUserId: senderUserId.trim() || undefined,
|
||||||
packet_type: packetType || undefined,
|
packetMode: packetType || undefined,
|
||||||
status: status || undefined,
|
status: status || undefined,
|
||||||
}),
|
}),
|
||||||
[packetType, roomId, senderUserId, status],
|
[packetType, roomId, senderUserId, status],
|
||||||
@ -124,6 +126,22 @@ export function useRedPacketPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const retryRefund = async (packetId) => {
|
||||||
|
if (!packetId || !abilities.canUpdate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await retryRedPacketRefund(packetId);
|
||||||
|
showToast("退款重试已提交", "success");
|
||||||
|
await reloadPackets();
|
||||||
|
if (selectedPacket?.packetId === packetId) {
|
||||||
|
await openPacketDetail(packetId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "退款重试失败", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const closePacketDetail = () => {
|
const closePacketDetail = () => {
|
||||||
setDetailOpen(false);
|
setDetailOpen(false);
|
||||||
setSelectedPacket(null);
|
setSelectedPacket(null);
|
||||||
@ -163,6 +181,7 @@ export function useRedPacketPage() {
|
|||||||
reloadConfig,
|
reloadConfig,
|
||||||
reloadPackets,
|
reloadPackets,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
|
retryRefund,
|
||||||
roomId,
|
roomId,
|
||||||
selectedPacket,
|
selectedPacket,
|
||||||
senderUserId,
|
senderUserId,
|
||||||
@ -183,8 +202,9 @@ function formFromConfig(config) {
|
|||||||
countTiers: valuesToForm(config.countTiers),
|
countTiers: valuesToForm(config.countTiers),
|
||||||
amountTiers: valuesToForm(config.amountTiers),
|
amountTiers: valuesToForm(config.amountTiers),
|
||||||
delayedOpenSeconds: String(config.delayedOpenSeconds || 0),
|
delayedOpenSeconds: String(config.delayedOpenSeconds || 0),
|
||||||
expireSeconds: String(config.expireSeconds || 300),
|
expireSeconds: "86400",
|
||||||
dailySendLimit: String(config.dailySendLimit || 0),
|
dailySendLimit: String(config.dailySendLimit || 0),
|
||||||
|
ruleUrl: config.ruleUrl || "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,7 +216,7 @@ function payloadFromForm(form) {
|
|||||||
const countTiers = normalizePositiveIntegers(form.countTiers);
|
const countTiers = normalizePositiveIntegers(form.countTiers);
|
||||||
const amountTiers = normalizePositiveIntegers(form.amountTiers);
|
const amountTiers = normalizePositiveIntegers(form.amountTiers);
|
||||||
const delayedOpenSeconds = Number(form.delayedOpenSeconds || 0);
|
const delayedOpenSeconds = Number(form.delayedOpenSeconds || 0);
|
||||||
const expireSeconds = Number(form.expireSeconds || 0);
|
const expireSeconds = 86400;
|
||||||
const dailySendLimit = Number(form.dailySendLimit || 0);
|
const dailySendLimit = Number(form.dailySendLimit || 0);
|
||||||
if (!countTiers.length) {
|
if (!countTiers.length) {
|
||||||
throw new Error("红包数量档位不能为空");
|
throw new Error("红包数量档位不能为空");
|
||||||
@ -220,6 +240,7 @@ function payloadFromForm(form) {
|
|||||||
delayed_open_seconds: delayedOpenSeconds,
|
delayed_open_seconds: delayedOpenSeconds,
|
||||||
expire_seconds: expireSeconds,
|
expire_seconds: expireSeconds,
|
||||||
daily_send_limit: dailySendLimit,
|
daily_send_limit: dailySendLimit,
|
||||||
|
rule_url: String(form.ruleUrl || "").trim(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined";
|
import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined";
|
||||||
|
import ReplayOutlined from "@mui/icons-material/ReplayOutlined";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { RedPacketConfigDrawer } from "@/features/red-packets/components/RedPacketConfigDrawer.jsx";
|
import { RedPacketConfigDrawer } from "@/features/red-packets/components/RedPacketConfigDrawer.jsx";
|
||||||
@ -20,8 +21,8 @@ import { formatMillis } from "@/shared/utils/time.js";
|
|||||||
|
|
||||||
const packetTypeOptions = [
|
const packetTypeOptions = [
|
||||||
["", "全部类型"],
|
["", "全部类型"],
|
||||||
["normal", "普通红包"],
|
["IMMEDIATE", "即时红包"],
|
||||||
["delayed", "延迟红包"],
|
["DELAYED", "延时红包"],
|
||||||
];
|
];
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
@ -83,6 +84,17 @@ export function RedPacketPage() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "claimed",
|
||||||
|
label: "已领",
|
||||||
|
width: "minmax(145px, 0.6fr)",
|
||||||
|
render: (packet) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{formatNumber(packet.totalAmount - packet.remainingAmount)} 金币</span>
|
||||||
|
<span className={styles.meta}>{packet.packetCount - packet.remainingCount} 个</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
label: "状态",
|
label: "状态",
|
||||||
@ -101,12 +113,26 @@ export function RedPacketPage() {
|
|||||||
width: "minmax(180px, 0.75fr)",
|
width: "minmax(180px, 0.75fr)",
|
||||||
render: (packet) => formatMillis(packet.expiresAtMs),
|
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",
|
key: "actions",
|
||||||
label: "操作",
|
label: "操作",
|
||||||
width: "88px",
|
width: "128px",
|
||||||
render: (packet) => (
|
render: (packet) => (
|
||||||
<AdminRowActions>
|
<AdminRowActions>
|
||||||
|
{page.abilities.canUpdate && canRetryRefund(packet) ? (
|
||||||
|
<AdminActionIconButton label="重试退款" onClick={() => page.retryRefund(packet.packetId)}>
|
||||||
|
<ReplayOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null}
|
||||||
<AdminActionIconButton label="查看详情" onClick={() => page.openPacketDetail(packet.packetId)}>
|
<AdminActionIconButton label="查看详情" onClick={() => page.openPacketDetail(packet.packetId)}>
|
||||||
<VisibilityOutlined fontSize="small" />
|
<VisibilityOutlined fontSize="small" />
|
||||||
</AdminActionIconButton>
|
</AdminActionIconButton>
|
||||||
@ -210,13 +236,20 @@ export function RedPacketPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function packetTypeLabel(type) {
|
function packetTypeLabel(type) {
|
||||||
return type === "delayed" ? "延迟红包" : "普通红包";
|
const normalized = String(type || "").toLowerCase();
|
||||||
|
return normalized === "delayed" ? "延时红包" : "即时红包";
|
||||||
}
|
}
|
||||||
|
|
||||||
function packetStatusLabel(status) {
|
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) {
|
function formatNumber(value) {
|
||||||
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canRetryRefund(packet) {
|
||||||
|
const status = String(packet.status || "").toLowerCase();
|
||||||
|
return status === "active" || status === "waiting_open";
|
||||||
|
}
|
||||||
|
|||||||
@ -40,6 +40,7 @@ export const API_OPERATIONS = {
|
|||||||
createExploreTab: "createExploreTab",
|
createExploreTab: "createExploreTab",
|
||||||
createGift: "createGift",
|
createGift: "createGift",
|
||||||
createH5Link: "createH5Link",
|
createH5Link: "createH5Link",
|
||||||
|
createHostAgencyPolicy: "createHostAgencyPolicy",
|
||||||
createMenu: "createMenu",
|
createMenu: "createMenu",
|
||||||
createPermission: "createPermission",
|
createPermission: "createPermission",
|
||||||
createPlatform: "createPlatform",
|
createPlatform: "createPlatform",
|
||||||
@ -50,6 +51,7 @@ export const API_OPERATIONS = {
|
|||||||
createRole: "createRole",
|
createRole: "createRole",
|
||||||
createRoomPin: "createRoomPin",
|
createRoomPin: "createRoomPin",
|
||||||
createTaskDefinition: "createTaskDefinition",
|
createTaskDefinition: "createTaskDefinition",
|
||||||
|
createTeamSalaryPolicy: "createTeamSalaryPolicy",
|
||||||
createTier: "createTier",
|
createTier: "createTier",
|
||||||
createUser: "createUser",
|
createUser: "createUser",
|
||||||
creditCoinSellerStock: "creditCoinSellerStock",
|
creditCoinSellerStock: "creditCoinSellerStock",
|
||||||
@ -61,11 +63,13 @@ export const API_OPERATIONS = {
|
|||||||
deleteCountry: "deleteCountry",
|
deleteCountry: "deleteCountry",
|
||||||
deleteExploreTab: "deleteExploreTab",
|
deleteExploreTab: "deleteExploreTab",
|
||||||
deleteH5Link: "deleteH5Link",
|
deleteH5Link: "deleteH5Link",
|
||||||
|
deleteHostAgencyPolicy: "deleteHostAgencyPolicy",
|
||||||
deleteMenu: "deleteMenu",
|
deleteMenu: "deleteMenu",
|
||||||
deletePermission: "deletePermission",
|
deletePermission: "deletePermission",
|
||||||
deleteRechargeProduct: "deleteRechargeProduct",
|
deleteRechargeProduct: "deleteRechargeProduct",
|
||||||
deleteRole: "deleteRole",
|
deleteRole: "deleteRole",
|
||||||
deleteRoom: "deleteRoom",
|
deleteRoom: "deleteRoom",
|
||||||
|
deleteTeamSalaryPolicy: "deleteTeamSalaryPolicy",
|
||||||
disableCountry: "disableCountry",
|
disableCountry: "disableCountry",
|
||||||
disableGift: "disableGift",
|
disableGift: "disableGift",
|
||||||
disableRegion: "disableRegion",
|
disableRegion: "disableRegion",
|
||||||
@ -117,7 +121,9 @@ export const API_OPERATIONS = {
|
|||||||
listGifts: "listGifts",
|
listGifts: "listGifts",
|
||||||
listGiftTypes: "listGiftTypes",
|
listGiftTypes: "listGiftTypes",
|
||||||
listH5Links: "listH5Links",
|
listH5Links: "listH5Links",
|
||||||
|
listHostAgencyPolicies: "listHostAgencyPolicies",
|
||||||
listHosts: "listHosts",
|
listHosts: "listHosts",
|
||||||
|
listHostSalarySettlements: "listHostSalarySettlements",
|
||||||
listLevelConfig: "listLevelConfig",
|
listLevelConfig: "listLevelConfig",
|
||||||
listLoginLogs: "listLoginLogs",
|
listLoginLogs: "listLoginLogs",
|
||||||
listLuckyGiftConfigs: "listLuckyGiftConfigs",
|
listLuckyGiftConfigs: "listLuckyGiftConfigs",
|
||||||
@ -131,6 +137,7 @@ export const API_OPERATIONS = {
|
|||||||
listRegionBlocks: "listRegionBlocks",
|
listRegionBlocks: "listRegionBlocks",
|
||||||
listRegions: "listRegions",
|
listRegions: "listRegions",
|
||||||
listRegistrationRewardClaims: "listRegistrationRewardClaims",
|
listRegistrationRewardClaims: "listRegistrationRewardClaims",
|
||||||
|
listReports: "listReports",
|
||||||
listResourceGrants: "listResourceGrants",
|
listResourceGrants: "listResourceGrants",
|
||||||
listResourceGroups: "listResourceGroups",
|
listResourceGroups: "listResourceGroups",
|
||||||
listResources: "listResources",
|
listResources: "listResources",
|
||||||
@ -141,6 +148,7 @@ export const API_OPERATIONS = {
|
|||||||
listSevenDayCheckInClaims: "listSevenDayCheckInClaims",
|
listSevenDayCheckInClaims: "listSevenDayCheckInClaims",
|
||||||
listSystemMenus: "listSystemMenus",
|
listSystemMenus: "listSystemMenus",
|
||||||
listTaskDefinitions: "listTaskDefinitions",
|
listTaskDefinitions: "listTaskDefinitions",
|
||||||
|
listTeamSalaryPolicies: "listTeamSalaryPolicies",
|
||||||
listUserLeaderboards: "listUserLeaderboards",
|
listUserLeaderboards: "listUserLeaderboards",
|
||||||
listUsers: "listUsers",
|
listUsers: "listUsers",
|
||||||
login: "login",
|
login: "login",
|
||||||
@ -148,12 +156,14 @@ export const API_OPERATIONS = {
|
|||||||
lookupCoinAdjustmentTarget: "lookupCoinAdjustmentTarget",
|
lookupCoinAdjustmentTarget: "lookupCoinAdjustmentTarget",
|
||||||
me: "me",
|
me: "me",
|
||||||
navigationMenus: "navigationMenus",
|
navigationMenus: "navigationMenus",
|
||||||
|
publishHostAgencyPolicy: "publishHostAgencyPolicy",
|
||||||
refresh: "refresh",
|
refresh: "refresh",
|
||||||
replaceRegionBlocks: "replaceRegionBlocks",
|
replaceRegionBlocks: "replaceRegionBlocks",
|
||||||
replaceRegionCountries: "replaceRegionCountries",
|
replaceRegionCountries: "replaceRegionCountries",
|
||||||
replaceRoleDataScopes: "replaceRoleDataScopes",
|
replaceRoleDataScopes: "replaceRoleDataScopes",
|
||||||
replaceRolePermissions: "replaceRolePermissions",
|
replaceRolePermissions: "replaceRolePermissions",
|
||||||
resetUserPassword: "resetUserPassword",
|
resetUserPassword: "resetUserPassword",
|
||||||
|
retryRedPacketRefund: "retryRedPacketRefund",
|
||||||
search: "search",
|
search: "search",
|
||||||
setAgencyJoinEnabled: "setAgencyJoinEnabled",
|
setAgencyJoinEnabled: "setAgencyJoinEnabled",
|
||||||
setBDLeaderStatus: "setBDLeaderStatus",
|
setBDLeaderStatus: "setBDLeaderStatus",
|
||||||
@ -175,6 +185,7 @@ export const API_OPERATIONS = {
|
|||||||
updateGiftTypes: "updateGiftTypes",
|
updateGiftTypes: "updateGiftTypes",
|
||||||
updateH5Link: "updateH5Link",
|
updateH5Link: "updateH5Link",
|
||||||
updateH5Links: "updateH5Links",
|
updateH5Links: "updateH5Links",
|
||||||
|
updateHostAgencyPolicy: "updateHostAgencyPolicy",
|
||||||
updateMenu: "updateMenu",
|
updateMenu: "updateMenu",
|
||||||
updateMenuVisible: "updateMenuVisible",
|
updateMenuVisible: "updateMenuVisible",
|
||||||
updatePermission: "updatePermission",
|
updatePermission: "updatePermission",
|
||||||
@ -192,6 +203,7 @@ export const API_OPERATIONS = {
|
|||||||
updateRoomTreasureConfig: "updateRoomTreasureConfig",
|
updateRoomTreasureConfig: "updateRoomTreasureConfig",
|
||||||
updateSevenDayCheckInConfig: "updateSevenDayCheckInConfig",
|
updateSevenDayCheckInConfig: "updateSevenDayCheckInConfig",
|
||||||
updateTaskDefinition: "updateTaskDefinition",
|
updateTaskDefinition: "updateTaskDefinition",
|
||||||
|
updateTeamSalaryPolicy: "updateTeamSalaryPolicy",
|
||||||
updateTier: "updateTier",
|
updateTier: "updateTier",
|
||||||
updateTrack: "updateTrack",
|
updateTrack: "updateTrack",
|
||||||
updateUser: "updateUser",
|
updateUser: "updateUser",
|
||||||
@ -393,6 +405,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "app-config:update",
|
permission: "app-config:update",
|
||||||
permissions: ["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: {
|
createMenu: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.createMenu,
|
operationId: API_OPERATIONS.createMenu,
|
||||||
@ -463,6 +482,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "daily-task:create",
|
permission: "daily-task:create",
|
||||||
permissions: ["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: {
|
createTier: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.createTier,
|
operationId: API_OPERATIONS.createTier,
|
||||||
@ -540,6 +566,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "app-config:update",
|
permission: "app-config:update",
|
||||||
permissions: ["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: {
|
deleteMenu: {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
operationId: API_OPERATIONS.deleteMenu,
|
operationId: API_OPERATIONS.deleteMenu,
|
||||||
@ -575,6 +608,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "room:delete",
|
permission: "room:delete",
|
||||||
permissions: ["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: {
|
disableCountry: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.disableCountry,
|
operationId: API_OPERATIONS.disableCountry,
|
||||||
@ -711,14 +751,14 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
getRedPacket: {
|
getRedPacket: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.getRedPacket,
|
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",
|
permission: "red-packet:view",
|
||||||
permissions: ["red-packet:view"]
|
permissions: ["red-packet:view"]
|
||||||
},
|
},
|
||||||
getRedPacketConfig: {
|
getRedPacketConfig: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.getRedPacketConfig,
|
operationId: API_OPERATIONS.getRedPacketConfig,
|
||||||
path: "/v1/admin/activity/red-packets/config",
|
path: "/v1/resident-activity/voice-room-red-packet/config",
|
||||||
permission: "red-packet:view",
|
permission: "red-packet:view",
|
||||||
permissions: ["red-packet:view"]
|
permissions: ["red-packet:view"]
|
||||||
},
|
},
|
||||||
@ -930,6 +970,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "app-config:view",
|
permission: "app-config:view",
|
||||||
permissions: ["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: {
|
listHosts: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listHosts,
|
operationId: API_OPERATIONS.listHosts,
|
||||||
@ -937,6 +984,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "host:view",
|
permission: "host:view",
|
||||||
permissions: ["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: {
|
listLevelConfig: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listLevelConfig,
|
operationId: API_OPERATIONS.listLevelConfig,
|
||||||
@ -1003,7 +1057,7 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
listRedPackets: {
|
listRedPackets: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listRedPackets,
|
operationId: API_OPERATIONS.listRedPackets,
|
||||||
path: "/v1/admin/activity/red-packets",
|
path: "/v1/resident-activity/voice-room-red-packet/records",
|
||||||
permission: "red-packet:view",
|
permission: "red-packet:view",
|
||||||
permissions: ["red-packet:view"]
|
permissions: ["red-packet:view"]
|
||||||
},
|
},
|
||||||
@ -1028,6 +1082,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "registration-reward:view",
|
permission: "registration-reward:view",
|
||||||
permissions: ["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: {
|
listResourceGrants: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listResourceGrants,
|
operationId: API_OPERATIONS.listResourceGrants,
|
||||||
@ -1098,6 +1159,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "daily-task:view",
|
permission: "daily-task:view",
|
||||||
permissions: ["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: {
|
listUserLeaderboards: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listUserLeaderboards,
|
operationId: API_OPERATIONS.listUserLeaderboards,
|
||||||
@ -1139,6 +1207,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
operationId: API_OPERATIONS.navigationMenus,
|
operationId: API_OPERATIONS.navigationMenus,
|
||||||
path: "/v1/navigation/menus"
|
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: {
|
refresh: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.refresh,
|
operationId: API_OPERATIONS.refresh,
|
||||||
@ -1179,6 +1254,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "user:reset-password",
|
permission: "user:reset-password",
|
||||||
permissions: ["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: {
|
search: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.search,
|
operationId: API_OPERATIONS.search,
|
||||||
@ -1324,6 +1406,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "app-config:update",
|
permission: "app-config:update",
|
||||||
permissions: ["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: {
|
updateMenu: {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
operationId: API_OPERATIONS.updateMenu,
|
operationId: API_OPERATIONS.updateMenu,
|
||||||
@ -1360,9 +1449,9 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permissions: ["payment-product:update"]
|
permissions: ["payment-product:update"]
|
||||||
},
|
},
|
||||||
updateRedPacketConfig: {
|
updateRedPacketConfig: {
|
||||||
method: "PUT",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.updateRedPacketConfig,
|
operationId: API_OPERATIONS.updateRedPacketConfig,
|
||||||
path: "/v1/admin/activity/red-packets/config",
|
path: "/v1/resident-activity/voice-room-red-packet/config",
|
||||||
permission: "red-packet:update",
|
permission: "red-packet:update",
|
||||||
permissions: ["red-packet:update"]
|
permissions: ["red-packet:update"]
|
||||||
},
|
},
|
||||||
@ -1443,6 +1532,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "daily-task:update",
|
permission: "daily-task:update",
|
||||||
permissions: ["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: {
|
updateTier: {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
operationId: API_OPERATIONS.updateTier,
|
operationId: API_OPERATIONS.updateTier,
|
||||||
|
|||||||
292
src/shared/api/generated/schema.d.ts
vendored
292
src/shared/api/generated/schema.d.ts
vendored
@ -100,7 +100,7 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/admin/activity/red-packets": {
|
"/resident-activity/voice-room-red-packet/records": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
@ -116,7 +116,7 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/admin/activity/red-packets/{packet_id}": {
|
"/resident-activity/voice-room-red-packet/records/{packet_id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
@ -132,7 +132,7 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/admin/activity/red-packets/config": {
|
"/resident-activity/voice-room-red-packet/config": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
@ -140,8 +140,24 @@ export interface paths {
|
|||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
get: operations["getRedPacketConfig"];
|
get: operations["getRedPacketConfig"];
|
||||||
put: operations["updateRedPacketConfig"];
|
put?: never;
|
||||||
post?: 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;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
@ -964,6 +980,102 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/admin/operations/coin-adjustments": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -1012,6 +1124,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/admin/payment/recharge-bills": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2718,6 +2846,18 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
retryRedPacketRefund: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
getLuckyGiftConfig: {
|
getLuckyGiftConfig: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -3737,6 +3877,136 @@ export interface operations {
|
|||||||
200: components["responses"]["HostProfilePageResponse"];
|
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: {
|
listCoinAdjustments: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -3785,6 +4055,18 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listReports: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listRechargeBills: {
|
listRechargeBills: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@ -245,6 +245,43 @@ export interface CoinAdjustmentCreateDto {
|
|||||||
user: CoinLedgerUserDto;
|
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 {
|
export interface RechargeBillDto {
|
||||||
appCode?: string;
|
appCode?: string;
|
||||||
coinAmount: number;
|
coinAmount: number;
|
||||||
|
|||||||
@ -29,3 +29,11 @@
|
|||||||
.MuiTooltip-popper :is(h1, h2, h3, h4, h5, h6, p, span, small, strong, em, label, button, input, textarea, select, option, a, li, dt, dd, th, td, div, pre, code) {
|
.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);
|
font-size: var(--admin-font-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:is(#app, .MuiAutocomplete-popper, .MuiDialog-root, .MuiMenu-root, .MuiModal-root, .MuiPopover-root, .MuiPopper-root, .MuiSnackbar-root, .MuiTooltip-popper)
|
||||||
|
:is(button, input, textarea, select, option, .MuiAutocomplete-option, .MuiInputBase-root, .MuiInputBase-input, .MuiInputLabel-root, .MuiFormLabel-root, .MuiSelect-select, .MuiButton-root, .MuiMenuItem-root),
|
||||||
|
:is(#app, .MuiAutocomplete-popper, .MuiDialog-root, .MuiMenu-root, .MuiModal-root, .MuiPopover-root, .MuiPopper-root, .MuiSnackbar-root, .MuiTooltip-popper)
|
||||||
|
button
|
||||||
|
:is(span, small, strong, em) {
|
||||||
|
font-size: var(--control-font-size);
|
||||||
|
}
|
||||||
|
|||||||
@ -31,14 +31,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
padding: 0 var(--space-3);
|
padding: 0 var(--control-padding-x);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-control);
|
border-radius: var(--radius-control);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: var(--admin-font-size);
|
font-size: var(--control-font-size);
|
||||||
line-height: var(--admin-line-height);
|
line-height: var(--control-line-height);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition:
|
transition:
|
||||||
background var(--motion-fast) var(--ease-standard),
|
background var(--motion-fast) var(--ease-standard),
|
||||||
@ -84,10 +84,10 @@
|
|||||||
.button.MuiButton-root {
|
.button.MuiButton-root {
|
||||||
height: var(--control-height);
|
height: var(--control-height);
|
||||||
min-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);
|
border-radius: var(--radius-control);
|
||||||
font-size: var(--admin-font-size);
|
font-size: var(--control-font-size);
|
||||||
line-height: var(--admin-line-height);
|
line-height: var(--control-line-height);
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,8 +106,8 @@
|
|||||||
.MuiTextField-root,
|
.MuiTextField-root,
|
||||||
.MuiFormControl-root {
|
.MuiFormControl-root {
|
||||||
--admin-control-height: var(--control-height);
|
--admin-control-height: var(--control-height);
|
||||||
--admin-control-font-size: var(--admin-font-size);
|
--admin-control-font-size: var(--control-font-size);
|
||||||
--admin-control-line-height: var(--admin-line-height);
|
--admin-control-line-height: var(--control-line-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
.region-select.MuiTextField-root,
|
.region-select.MuiTextField-root,
|
||||||
@ -146,13 +146,13 @@
|
|||||||
height: var(--control-height);
|
height: var(--control-height);
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
font-size: var(--admin-font-size);
|
font-size: var(--control-font-size);
|
||||||
line-height: var(--admin-line-height);
|
line-height: var(--control-line-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
.MuiInputBase-root .MuiInputBase-input::placeholder {
|
.MuiInputBase-root .MuiInputBase-input::placeholder {
|
||||||
font-size: var(--admin-font-size);
|
font-size: var(--control-font-size);
|
||||||
line-height: var(--admin-line-height);
|
line-height: var(--control-line-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
.MuiInputBase-input:focus {
|
.MuiInputBase-input:focus {
|
||||||
@ -200,7 +200,7 @@
|
|||||||
.MuiFormControl-root .MuiOutlinedInput-input {
|
.MuiFormControl-root .MuiOutlinedInput-input {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
height: var(--admin-control-height);
|
height: var(--admin-control-height);
|
||||||
padding: 0 var(--space-3);
|
padding: 0 var(--control-padding-x);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: var(--admin-control-font-size);
|
font-size: var(--admin-control-font-size);
|
||||||
line-height: var(--admin-control-line-height);
|
line-height: var(--admin-control-line-height);
|
||||||
@ -219,12 +219,12 @@
|
|||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: var(--admin-control-font-size);
|
font-size: var(--admin-control-font-size);
|
||||||
line-height: var(--admin-control-line-height);
|
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,
|
.MuiTextField-root .MuiInputLabel-outlined.MuiInputLabel-shrink,
|
||||||
.MuiFormControl-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,
|
.MuiTextField-root .MuiFormLabel-root.Mui-focused,
|
||||||
@ -239,7 +239,7 @@
|
|||||||
height: var(--admin-control-height);
|
height: var(--admin-control-height);
|
||||||
min-height: var(--admin-control-height) !important;
|
min-height: var(--admin-control-height) !important;
|
||||||
align-items: center;
|
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);
|
font-size: var(--admin-control-font-size);
|
||||||
line-height: var(--admin-control-line-height);
|
line-height: var(--admin-control-line-height);
|
||||||
}
|
}
|
||||||
@ -252,7 +252,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.MuiAutocomplete-root {
|
.MuiAutocomplete-root {
|
||||||
--admin-autocomplete-input-height: 24px;
|
--admin-autocomplete-input-height: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.MuiAutocomplete-root .MuiOutlinedInput-root.MuiAutocomplete-inputRoot:not(.MuiInputBase-multiline) {
|
.MuiAutocomplete-root .MuiOutlinedInput-root.MuiAutocomplete-inputRoot:not(.MuiInputBase-multiline) {
|
||||||
@ -261,7 +261,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--space-1);
|
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 {
|
.MuiAutocomplete-root .MuiOutlinedInput-root.MuiAutocomplete-inputRoot .MuiAutocomplete-input {
|
||||||
@ -855,11 +855,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.side-drawer {
|
.side-drawer {
|
||||||
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
width: min(420px, 100vw);
|
width: min(420px, 100vw);
|
||||||
min-height: 100%;
|
height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
|
overflow: hidden;
|
||||||
padding: var(--space-6);
|
padding: var(--space-6);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
}
|
}
|
||||||
@ -870,6 +873,7 @@
|
|||||||
|
|
||||||
.side-drawer__header {
|
.side-drawer__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
min-height: var(--control-height);
|
min-height: var(--control-height);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@ -885,15 +889,21 @@
|
|||||||
|
|
||||||
.side-drawer__body {
|
.side-drawer__body {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-drawer__actions {
|
.side-drawer__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
margin-top: auto;
|
padding-top: var(--space-3);
|
||||||
padding-top: var(--space-2);
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-section-title {
|
.form-section-title {
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
:root {
|
:root {
|
||||||
--admin-font-size: 14px;
|
--admin-font-size: 14px;
|
||||||
--admin-line-height: 20px;
|
--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;
|
--status-height: 24px;
|
||||||
--admin-tag-height: var(--status-height);
|
--admin-tag-height: var(--status-height);
|
||||||
--admin-tag-radius: var(--radius-pill);
|
--admin-tag-radius: var(--radius-pill);
|
||||||
|
|||||||
35
src/theme.js
35
src/theme.js
@ -47,15 +47,17 @@ export const theme = createTheme({
|
|||||||
components: {
|
components: {
|
||||||
MuiButton: {
|
MuiButton: {
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
disableRipple: false
|
disableRipple: false,
|
||||||
|
size: "small"
|
||||||
},
|
},
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
root: {
|
root: {
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
height: token("control-height"),
|
height: token("control-height"),
|
||||||
borderRadius: token("radius-control"),
|
borderRadius: token("radius-control"),
|
||||||
padding: `0 ${token("space-3")}`,
|
padding: `0 ${token("control-padding-x")}`,
|
||||||
fontSize: 14,
|
fontSize: token("control-font-size"),
|
||||||
|
lineHeight: token("control-line-height"),
|
||||||
transition:
|
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)"
|
"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: {
|
MuiIconButton: {
|
||||||
|
defaultProps: {
|
||||||
|
size: "small"
|
||||||
|
},
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
root: {
|
root: {
|
||||||
borderRadius: token("radius-control"),
|
borderRadius: token("radius-control"),
|
||||||
@ -126,10 +131,11 @@ export const theme = createTheme({
|
|||||||
MuiInputBase: {
|
MuiInputBase: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
root: {
|
root: {
|
||||||
fontSize: 14
|
fontSize: token("control-font-size")
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
fontSize: 14,
|
fontSize: token("control-font-size"),
|
||||||
|
lineHeight: token("control-line-height"),
|
||||||
"&[type='number']": {
|
"&[type='number']": {
|
||||||
MozAppearance: "textfield"
|
MozAppearance: "textfield"
|
||||||
},
|
},
|
||||||
@ -158,7 +164,7 @@ export const theme = createTheme({
|
|||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
root: {
|
root: {
|
||||||
color: token("text-secondary"),
|
color: token("text-secondary"),
|
||||||
fontSize: 14
|
fontSize: token("control-font-size")
|
||||||
},
|
},
|
||||||
icon: {
|
icon: {
|
||||||
color: token("text-secondary")
|
color: token("text-secondary")
|
||||||
@ -243,7 +249,8 @@ export const theme = createTheme({
|
|||||||
MuiFormLabel: {
|
MuiFormLabel: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
root: {
|
root: {
|
||||||
fontSize: 14
|
fontSize: token("control-font-size"),
|
||||||
|
lineHeight: token("control-line-height")
|
||||||
},
|
},
|
||||||
asterisk: {
|
asterisk: {
|
||||||
color: token("danger"),
|
color: token("danger"),
|
||||||
@ -254,16 +261,26 @@ export const theme = createTheme({
|
|||||||
MuiFormHelperText: {
|
MuiFormHelperText: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
root: {
|
root: {
|
||||||
fontSize: 14
|
fontSize: token("control-font-size")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
MuiMenuItem: {
|
MuiMenuItem: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
root: {
|
root: {
|
||||||
fontSize: 14
|
fontSize: token("control-font-size")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
MuiTextField: {
|
||||||
|
defaultProps: {
|
||||||
|
size: "small"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiFormControl: {
|
||||||
|
defaultProps: {
|
||||||
|
size: "small"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user