Merge branch 'main' into test

This commit is contained in:
zhx 2026-06-02 15:54:57 +08:00
commit 1a1c6ecc88
74 changed files with 8003 additions and 94 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ node_modules/
dist/
coverage/
*.log
.waylog/

View File

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

View File

@ -188,7 +188,7 @@
"x-permissions": ["red-packet:update"]
}
},
"/resident-activity/voice-room-red-packet/refund/retry": {
"/admin/activity/red-packets/refund/retry": {
"post": {
"operationId": "retryRedPacketRefund",
"responses": {
@ -1437,6 +1437,168 @@
"x-permissions": ["host:view"]
}
},
"/admin/host-agency-policies": {
"get": {
"operationId": "listHostAgencyPolicies",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "host-agency-policy:view",
"x-permissions": ["host-agency-policy:view"]
},
"post": {
"operationId": "createHostAgencyPolicy",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "host-agency-policy:create",
"x-permissions": ["host-agency-policy:create"]
}
},
"/admin/host-agency-policies/{policy_id}": {
"put": {
"operationId": "updateHostAgencyPolicy",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "policy_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "host-agency-policy:update",
"x-permissions": ["host-agency-policy:update"]
},
"delete": {
"operationId": "deleteHostAgencyPolicy",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "policy_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "host-agency-policy:delete",
"x-permissions": ["host-agency-policy:delete"]
}
},
"/admin/host-agency-policies/{policy_id}/publish": {
"post": {
"operationId": "publishHostAgencyPolicy",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "policy_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "host-agency-policy:publish",
"x-permissions": ["host-agency-policy:publish"]
}
},
"/admin/host-salary-settlements": {
"get": {
"operationId": "listHostSalarySettlements",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "host-salary-settlement:view",
"x-permissions": ["host-salary-settlement:view"]
}
},
"/admin/team-salary-policies": {
"get": {
"operationId": "listTeamSalaryPolicies",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "team-salary-policy:view",
"x-permissions": ["team-salary-policy:view"]
},
"post": {
"operationId": "createTeamSalaryPolicy",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "team-salary-policy:create",
"x-permissions": ["team-salary-policy:create"]
}
},
"/admin/team-salary-policies/{policy_id}": {
"put": {
"operationId": "updateTeamSalaryPolicy",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "policy_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "team-salary-policy:update",
"x-permissions": ["team-salary-policy:update"]
},
"delete": {
"operationId": "deleteTeamSalaryPolicy",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "policy_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "team-salary-policy:delete",
"x-permissions": ["team-salary-policy:delete"]
}
},
"/admin/operations/coin-adjustments": {
"get": {
"operationId": "listCoinAdjustments",

12
databi/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HYApp 数据大屏</title>
</head>
<body>
<div id="databi-root"></div>
<script type="module" src="/databi/src/main.jsx"></script>
</body>
</html>

94
databi/src/DatabiApp.jsx Normal file
View File

@ -0,0 +1,94 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchStatisticsOverview, getCurrentAppCode } from "./api.js";
import { CountryPerformanceTable } from "./components/CountryPerformanceTable.jsx";
import { DatabiHeader } from "./components/DatabiHeader.jsx";
import { FunnelPanel } from "./components/FunnelPanel.jsx";
import { GlobalOverviewPanel } from "./components/GlobalOverviewPanel.jsx";
import { MetricCard } from "./components/MetricCard.jsx";
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
import { createDashboardModel } from "./data/createDashboardModel.js";
import { sampleOverview } from "./data/sampleOverview.js";
import { endOfDay, lastDaysRange, startOfDay } from "./utils/time.js";
export function DatabiApp() {
const initialRange = useMemo(() => lastDaysRange(7), []);
const [range, setRange] = useState(initialRange);
const [countryId, setCountryId] = useState(0);
const [overview, setOverview] = useState(sampleOverview);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [preview, setPreview] = useState(true);
const appCode = getCurrentAppCode();
const loadOverview = useCallback(async () => {
setLoading(true);
setError("");
try {
const data = await fetchStatisticsOverview({
appCode,
countryId,
endMs: endOfDay(range.end),
startMs: startOfDay(range.start)
});
setOverview(data || sampleOverview);
setPreview(false);
} catch (err) {
setOverview(sampleOverview);
setPreview(true);
setError(err.message || "请求失败");
} finally {
setLoading(false);
}
}, [appCode, countryId, range.end, range.start]);
useEffect(() => {
void loadOverview();
const timer = window.setInterval(() => void loadOverview(), 60_000);
return () => window.clearInterval(timer);
}, [loadOverview]);
const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, preview }), [appCode, countryId, overview, preview]);
return (
<main className="databi-shell">
<section className="databi-screen">
<DatabiHeader
appCode={appCode}
countryId={countryId}
error={error}
loading={loading}
onCountryChange={setCountryId}
onRefresh={loadOverview}
onRangeChange={setRange}
preview={preview}
range={range}
updatedAt={model.updatedAt}
/>
<section className="metric-grid" aria-label="核心指标">
{model.kpis.map((item) => (
<MetricCard key={item.label} item={item} />
))}
</section>
<section className="business-metric-grid" aria-label="业务指标">
{model.businessKpis.map((item) => (
<MetricCard key={item.label} item={item} />
))}
</section>
<section className="databi-grid databi-grid--top">
<GlobalOverviewPanel countryBreakdown={model.countryBreakdown} topCountries={model.topCountries} />
<RevenueTrendPanel revenueSeries={model.revenueSeries} />
<FunnelPanel funnel={model.funnel} sideMetrics={model.sideMetrics} />
</section>
</section>
<CountryPerformanceTable gameRanking={model.gameRanking} giftRanking={model.giftRanking} items={model.countryBreakdown} />
<footer className="databi-footer">
<span>所有数据按 UTC 展示实时更新</span>
<strong>数据最多可能延迟 60 </strong>
</footer>
</main>
);
}

61
databi/src/api.js Normal file
View File

@ -0,0 +1,61 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "/api";
const TOKEN_KEY = "hyapp-admin.access-token";
const APP_CODE_KEY = "hyapp-admin.app-code";
const APP_CODE_HEADER = "X-App-Code";
export function getCurrentAppCode() {
return normalizeAppCode(window.localStorage.getItem(APP_CODE_KEY) || "lalu");
}
export async function fetchStatisticsOverview({ appCode, countryId, endMs, startMs }) {
const query = new URLSearchParams();
query.set("app_code", normalizeAppCode(appCode) || "lalu");
if (startMs) {
query.set("start_ms", String(startMs));
}
if (endMs) {
query.set("end_ms", String(endMs));
}
if (countryId) {
query.set("country_id", String(countryId));
}
const response = await fetch(`${API_BASE_URL}/v1/statistics/overview?${query.toString()}`, {
credentials: "include",
headers: requestHeaders(appCode)
});
const payload = await readJSON(response);
if (!response.ok || payload.code !== 0) {
throw new Error(payload.message || response.statusText || "请求失败");
}
return payload.data;
}
function requestHeaders(appCode) {
const headers = {};
const token = window.localStorage.getItem(TOKEN_KEY);
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const normalized = normalizeAppCode(appCode);
if (normalized) {
headers[APP_CODE_HEADER] = normalized;
}
return headers;
}
async function readJSON(response) {
const text = await response.text();
if (!text) {
return {};
}
try {
return JSON.parse(text);
} catch {
return { code: response.ok ? 0 : response.status, message: text };
}
}
function normalizeAppCode(value) {
return String(value || "").trim().toLowerCase();
}

View File

@ -0,0 +1,45 @@
import { useEffect, useRef } from "react";
import { BarChart, EffectScatterChart, FunnelChart, LineChart, LinesChart, MapChart, PieChart, ScatterChart } from "echarts/charts";
import { GeoComponent, GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
import * as echarts from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import worldMap from "../data/world.json";
echarts.use([BarChart, EffectScatterChart, FunnelChart, GeoComponent, GridComponent, LegendComponent, LineChart, LinesChart, MapChart, PieChart, ScatterChart, TooltipComponent, CanvasRenderer]);
echarts.registerMap("databi-world", worldMap);
export function EChart({ className = "", option }) {
const elementRef = useRef(null);
const chartRef = useRef(null);
const optionRef = useRef(option);
useEffect(() => {
optionRef.current = option;
chartRef.current?.setOption(option, true);
}, [option]);
useEffect(() => {
const element = elementRef.current;
if (!element) {
return undefined;
}
const chart = echarts.init(element, null, { renderer: "canvas" });
chartRef.current = chart;
chart.setOption(optionRef.current, true);
const resize = () => chart.resize();
const observer = typeof ResizeObserver === "function" ? new ResizeObserver(resize) : null;
observer?.observe(element);
window.addEventListener("resize", resize);
return () => {
observer?.disconnect();
window.removeEventListener("resize", resize);
chart.dispose();
chartRef.current = null;
};
}, []);
return <div className={["databi-chart", className].filter(Boolean).join(" ")} ref={elementRef} />;
}

View File

@ -0,0 +1,23 @@
import { formatCoin } from "../../utils/format.js";
export function createDonutOption(items) {
return {
animationDuration: 650,
color: ["#2dd7ef", "#1dc9aa", "#367be8", "#9a9cfb"],
series: [
{
data: items,
itemStyle: { borderColor: "#062039", borderWidth: 2 },
label: { show: false },
radius: ["56%", "82%"],
type: "pie"
}
],
tooltip: {
backgroundColor: "#08243a",
borderColor: "#1a5f82",
formatter: ({ name, percent, value }) => `${name}<br/>${formatCoin(value)} · ${percent}%`,
textStyle: { color: "#e9f6ff" }
}
};
}

View File

@ -0,0 +1,24 @@
export function createFunnelOption(items) {
return {
animationDuration: 650,
color: ["#277be8", "#10a6c4", "#13b5b0", "#17c99d"],
series: [
{
bottom: 6,
data: items.map((item) => ({ name: item.name, value: Math.max(item.rate * 100, 8) })),
gap: 9,
itemStyle: { borderColor: "rgba(220, 250, 255, 0.92)", borderWidth: 1.2, shadowBlur: 14, shadowColor: "rgba(39, 228, 245, 0.16)" },
label: { color: "#e8f6ff", distance: 16, formatter: "{b}", fontSize: 12, lineHeight: 16, position: "right" },
labelLine: { show: false },
left: "5%",
maxSize: "88%",
minSize: "42%",
sort: "descending",
top: 4,
type: "funnel",
width: "58%"
}
],
tooltip: { backgroundColor: "#08243a", borderColor: "#1a5f82", textStyle: { color: "#e9f6ff" } }
};
}

View File

@ -0,0 +1,53 @@
import { formatMoney } from "../../utils/format.js";
export function createGeoOption(items) {
const points = items
.filter((item) => Array.isArray(item.geo_point) && item.geo_point.length === 2)
.map((item) => [...item.geo_point, item.recharge_usd_minor, item.country]);
const max = Math.max(...points.map((item) => item[2]), 1);
return {
animationDuration: 650,
geo: {
center: [38, 12],
emphasis: { disabled: true },
itemStyle: {
areaColor: "#18354e",
borderColor: "rgba(62, 139, 184, 0.52)",
borderWidth: 0.65
},
label: { show: false },
layoutCenter: ["50%", "51%"],
layoutSize: "112%",
map: "databi-world",
roam: false,
silent: true,
zoom: 1.04
},
series: [
{
coordinateSystem: "geo",
data: points,
itemStyle: { color: "rgba(40, 228, 245, 0.18)", shadowBlur: 26, shadowColor: "#23e4ff" },
silent: true,
symbolSize: (value) => 14 + (value[2] / max) * 32,
type: "scatter",
z: 2
},
{
coordinateSystem: "geo",
data: points,
itemStyle: { borderColor: "#9ffcff", borderWidth: 1.5, color: "#28e4f5", shadowBlur: 22, shadowColor: "#23e4ff" },
rippleEffect: { brushType: "stroke", number: 3, scale: 3.6 },
symbolSize: (value) => 6 + (value[2] / max) * 15,
type: "effectScatter",
z: 3
}
],
tooltip: {
backgroundColor: "#08243a",
borderColor: "#1a5f82",
formatter: ({ value }) => `${value[3]}<br/>${formatMoney(value[2])}`,
textStyle: { color: "#e9f6ff" }
}
};
}

View File

@ -0,0 +1,50 @@
import { compactMoneyAxis, moneyMajor } from "../../utils/format.js";
export function createRevenueOption(series) {
const labels = series.map((item) => item.label);
const leftMax = Math.max(2_000_000, ...series.map((item) => moneyMajor(item.google) + moneyMajor(item.mifapay) + moneyMajor(item.coin_seller)));
const rightMax = Math.max(8_000_000, ...series.map((item) => moneyMajor(item.lineTotal ?? item.total)));
return {
animationDuration: 650,
color: ["#1bd8f2", "#20c9aa", "#4f8cff", "#f2f6ff"],
grid: { bottom: 30, left: 54, right: 54, top: 44 },
legend: [
{ data: ["Google", "MifaPay", "金币商"], icon: "roundRect", itemGap: 16, itemHeight: 8, itemWidth: 12, left: 22, textStyle: { color: "#a8bdd8", fontSize: 12 }, top: 1 },
{ data: ["总计 (USD)"], itemGap: 8, itemHeight: 8, itemWidth: 18, right: 18, textStyle: { color: "#d8e8f8", fontSize: 12 }, top: 1 }
],
tooltip: { backgroundColor: "#08243a", borderColor: "#1a5f82", extraCssText: "box-shadow: 0 10px 26px rgba(0,0,0,.32);", textStyle: { color: "#e9f6ff" }, trigger: "axis" },
xAxis: { axisLabel: { color: "#90a8c4", margin: 10 }, axisLine: { lineStyle: { color: "#1a3a56" } }, axisTick: { show: false }, boundaryGap: true, data: labels, type: "category" },
yAxis: [
{ axisLabel: { color: "#90a8c4", formatter: compactMoneyAxis }, axisTick: { show: false }, interval: leftMax <= 2_000_000 ? 500_000 : undefined, max: roundAxisMax(leftMax), splitLine: { lineStyle: { color: "rgba(111, 166, 212, 0.16)", type: "dashed" } }, type: "value" },
{ axisLabel: { color: "#90a8c4", formatter: compactMoneyAxis }, axisTick: { show: false }, interval: rightMax <= 8_000_000 ? 2_000_000 : undefined, max: roundAxisMax(rightMax), splitLine: { show: false }, type: "value" }
],
series: [
{ barGap: "-100%", barWidth: 28, data: series.map((item) => moneyMajor(item.google)), emphasis: { focus: "series" }, name: "Google", stack: "recharge", type: "bar" },
{ barWidth: 28, data: series.map((item) => moneyMajor(item.mifapay)), emphasis: { focus: "series" }, name: "MifaPay", stack: "recharge", type: "bar" },
{ barWidth: 28, data: series.map((item) => moneyMajor(item.coin_seller)), emphasis: { focus: "series" }, name: "金币商", stack: "recharge", type: "bar" },
{
data: series.map((item) => moneyMajor(item.lineTotal ?? item.total)),
lineStyle: { color: "#f2f6ff", shadowBlur: 10, shadowColor: "rgba(242,246,255,.42)", width: 3 },
name: "总计 (USD)",
smooth: 0.42,
symbol: "circle",
symbolSize: 7,
itemStyle: { borderColor: "#f2f6ff", borderWidth: 2, color: "#f2f6ff" },
type: "line",
yAxisIndex: 1,
z: 4
}
]
};
}
function roundAxisMax(value) {
if (value <= 2_000_000) {
return 2_000_000;
}
if (value <= 8_000_000) {
return 8_000_000;
}
const step = value > 10_000_000 ? 5_000_000 : 1_000_000;
return Math.ceil(value / step) * step;
}

View File

@ -0,0 +1,136 @@
import { useState } from "react";
import { formatCoin, formatMoney, formatMoneyFull, formatNumber, formatPercent } from "../utils/format.js";
import { Panel } from "./Panel.jsx";
const tabs = [
{ key: "country", label: "国家表现" },
{ key: "gift", label: "礼物排行" },
{ key: "game", label: "游戏排行" }
];
export function CountryPerformanceTable({ gameRanking, giftRanking, items }) {
const [activeTab, setActiveTab] = useState("country");
return (
<Panel className="panel--table" title={<TableTabs activeTab={activeTab} onChange={setActiveTab} />}>
<div className="country-table-wrap">
{activeTab === "country" ? <CountryTable items={items} /> : null}
{activeTab === "gift" ? <GiftTable items={giftRanking} /> : null}
{activeTab === "game" ? <GameTable items={gameRanking} /> : null}
</div>
</Panel>
);
}
function TableTabs({ activeTab, onChange }) {
return (
<div className="table-tabs">
{tabs.map((tab) => (
<button className={activeTab === tab.key ? "is-active" : ""} key={tab.key} type="button" onClick={() => onChange(tab.key)}>
{tab.label}
</button>
))}
</div>
);
}
function CountryTable({ items }) {
return (
<table className="country-table country-table--wide">
<thead>
<tr>
<th>#</th>
<th>国家</th>
<th>访客</th>
<th>活跃用户</th>
<th>付费用户</th>
<th>充值用户</th>
<th>总充值 (USD)</th>
<th>新用户充值 (USD)</th>
<th>ARPU (USD)</th>
<th>ARPPU (USD)</th>
<th>付费转化率</th>
<th>充值转化率</th>
<th>平均充值 (USD)</th>
<th> 7 </th>
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<tr key={item.country}>
<td>{index + 1}</td>
<td>{item.flag ? <i className="table-flag">{item.flag}</i> : null}{item.country}</td>
<td>{formatNumber(item.visitors)}</td>
<td>{formatNumber(item.active_users)}</td>
<td>{formatNumber(item.paid_users)}</td>
<td>{formatNumber(item.recharge_users)}</td>
<td>{formatMoneyFull(item.recharge_usd_minor)}</td>
<td>{formatMoneyFull(item.new_user_recharge_usd_minor)}</td>
<td>{formatMoney(item.arpu_usd_minor)}</td>
<td>{formatMoney(item.arppu_usd_minor)}</td>
<td>{formatPercent(item.payer_rate)}</td>
<td>{formatPercent(item.recharge_conversion_rate)}</td>
<td>{formatMoney(item.avg_recharge_usd_minor)}</td>
<td className="positive">+{formatPercent(item.trend_rate)}</td>
</tr>
))}
</tbody>
</table>
);
}
function GiftTable({ items }) {
const total = items.reduce((sum, item) => sum + item.value, 0) || 1;
return (
<table className="country-table">
<thead>
<tr>
<th>#</th>
<th>礼物</th>
<th>金币消费</th>
<th>占比</th>
<th> 7 </th>
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<tr key={item.name}>
<td>{index + 1}</td>
<td>{item.name}</td>
<td>{formatCoin(item.value)}</td>
<td>{formatPercent(item.value / total)}</td>
<td className="positive">+{formatPercent(0.168 - index * 0.017)}</td>
</tr>
))}
</tbody>
</table>
);
}
function GameTable({ items }) {
const total = items.reduce((sum, item) => sum + item.turnover_coin, 0) || 1;
return (
<table className="country-table">
<thead>
<tr>
<th>#</th>
<th>游戏</th>
<th>流水 (USD)</th>
<th>利润率</th>
<th>占比</th>
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<tr key={`${item.platform_code || "game"}-${item.game_id}`}>
<td>{index + 1}</td>
<td>{item.game_id}</td>
<td>{formatCoin(item.turnover_coin)}</td>
<td className="positive">{formatPercent(item.profit_rate)}</td>
<td>{formatPercent(item.turnover_coin / total)}</td>
</tr>
))}
</tbody>
</table>
);
}

View File

@ -0,0 +1,35 @@
import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined";
import PublicOutlined from "@mui/icons-material/PublicOutlined";
import { countryOptions } from "../config/options.js";
export function DatabiHeader({ countryId, onCountryChange, onRangeChange, range }) {
return (
<header className="databi-header">
<div className="brand-lockup">
<div className="brand-emblem" aria-hidden="true" />
<div>
<h1>全球运营指挥中心</h1>
</div>
</div>
<div className="header-controls">
<div className="range-control">
<input aria-label="开始日期" type="text" value={range.start} onChange={(event) => onRangeChange({ ...range, start: event.target.value })} />
<span>~</span>
<input aria-label="结束日期" type="text" value={range.end} onChange={(event) => onRangeChange({ ...range, end: event.target.value })} />
<CalendarMonthOutlined fontSize="small" />
</div>
<label className="region-control">
<PublicOutlined fontSize="small" />
<select aria-label="国家区域" value={countryId} onChange={(event) => onCountryChange(Number(event.target.value))}>
{countryOptions.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
</label>
<span className="live-dot">实时</span>
</div>
</header>
);
}

View File

@ -0,0 +1,28 @@
import { formatNumber, formatPercent } from "../utils/format.js";
import { MiniStat } from "./MiniStat.jsx";
import { Panel } from "./Panel.jsx";
export function FunnelPanel({ funnel, sideMetrics }) {
return (
<Panel title="ARPU 转化漏斗">
<div className="funnel-layout">
<div className="funnel-stack">
{funnel.map((item, index) => (
<div className="funnel-stage-row" key={item.name}>
<div className={`funnel-stage funnel-stage--${index + 1}`}>
<span>{item.name}</span>
<strong>{formatNumber(item.value)}</strong>
</div>
<b>{formatPercent(item.rate)}</b>
</div>
))}
</div>
<div className="side-metrics">
{sideMetrics.map((item) => (
<MiniStat key={item.label} item={item} />
))}
</div>
</div>
</Panel>
);
}

View File

@ -0,0 +1,37 @@
import { formatCoin, formatPercent } from "../utils/format.js";
import { FourMetricRow } from "./MiniStat.jsx";
import { Panel } from "./Panel.jsx";
export function GameEconomyPanel({ gameMetrics, gameRanking }) {
return (
<Panel title="游戏经济">
<FourMetricRow items={gameMetrics} />
<GameRankTable items={gameRanking} />
</Panel>
);
}
function GameRankTable({ items }) {
const max = Math.max(...items.map((item) => item.turnover_coin), 1);
const total = items.reduce((sum, item) => sum + item.turnover_coin, 0) || 1;
return (
<div className="game-table">
<div className="game-table-head">
<span>游戏流水排行</span>
<span>流水 (USD)</span>
<span>占比</span>
</div>
{items.map((item, index) => (
<div className="game-row" key={`${item.platform_code || "game"}-${item.game_id}`}>
<span>{index + 1}</span>
<em>{item.game_id || "-"}</em>
<div className="bar-track">
<i style={{ width: `${Math.max(5, (item.turnover_coin / max) * 100)}%` }} />
</div>
<b>{formatCoin(item.turnover_coin)}</b>
<strong>{formatPercent(item.turnover_coin / total)}</strong>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,34 @@
import { EChart } from "../charts/EChart.jsx";
import { createGeoOption } from "../charts/options/createGeoOption.js";
import { formatMoney, moneyMajor } from "../utils/format.js";
import { Panel } from "./Panel.jsx";
import { RankList } from "./RankList.jsx";
export function GlobalOverviewPanel({ countryBreakdown, topCountries }) {
return (
<Panel className="panel--map" title="全球概览">
<div className="geo-layout">
<div className="geo-map">
<EChart className="geo-chart" option={createGeoOption(countryBreakdown)} />
<div className="geo-scale">
<span></span>
<i />
<span></span>
</div>
</div>
<div className="rank-block">
<RankList items={topCountries} title="充值国家排行" valueFormatter={formatRankMoney} />
<button className="all-countries-button" type="button">查看全部国家</button>
</div>
</div>
</Panel>
);
}
function formatRankMoney(value) {
const amount = moneyMajor(value);
if (Math.abs(amount) >= 100_000) {
return `$${(amount / 1_000_000).toFixed(2)}M`;
}
return formatMoney(value);
}

View File

@ -0,0 +1,471 @@
import { useEffect, useMemo, useRef, useState } from "react";
import worldMap from "../data/world.json";
import { formatMoney, formatNumber } from "../utils/format.js";
const DEGREE = Math.PI / 180;
const INITIAL_ROTATION = { lat: 8, lon: 65 };
const MAX_LATITUDE = 58;
const LAND_RINGS = extractRings(worldMap.features);
export function GlobeMap({ items }) {
const canvasRef = useRef(null);
const wrapRef = useRef(null);
const dragRef = useRef(null);
const [dragging, setDragging] = useState(false);
const [hovered, setHovered] = useState(null);
const [rotation, setRotation] = useState(INITIAL_ROTATION);
const [size, setSize] = useState({ height: 0, width: 0 });
const points = useMemo(() => normalizePoints(items), [items]);
useEffect(() => {
const element = wrapRef.current;
if (!element) {
return undefined;
}
const syncSize = () => {
const rect = element.getBoundingClientRect();
setSize({ height: Math.max(1, Math.round(rect.height)), width: Math.max(1, Math.round(rect.width)) });
};
syncSize();
const observer = typeof ResizeObserver === "function" ? new ResizeObserver(syncSize) : null;
observer?.observe(element);
window.addEventListener("resize", syncSize);
return () => {
observer?.disconnect();
window.removeEventListener("resize", syncSize);
};
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !size.width || !size.height) {
return;
}
drawGlobe(canvas, size, rotation, points);
}, [points, rotation, size]);
const onPointerDown = (event) => {
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
lat: rotation.lat,
lon: rotation.lon,
x: event.clientX,
y: event.clientY
};
setHovered(null);
setDragging(true);
};
const onPointerMove = (event) => {
const drag = dragRef.current;
if (!drag) {
setHovered(findHoveredPoint(event, wrapRef.current, size, rotation, points));
return;
}
const dx = event.clientX - drag.x;
const dy = event.clientY - drag.y;
setRotation({
lat: clamp(drag.lat + dy * 0.24, -MAX_LATITUDE, MAX_LATITUDE),
lon: normalizeLongitude(drag.lon - dx * 0.34)
});
};
const endDrag = (event) => {
if (dragRef.current) {
event.currentTarget.releasePointerCapture?.(event.pointerId);
}
dragRef.current = null;
setDragging(false);
setHovered(findHoveredPoint(event, wrapRef.current, size, rotation, points));
};
return (
<div
aria-label="可旋转全球充值分布"
className={["globe-map", dragging ? "is-dragging" : ""].filter(Boolean).join(" ")}
onPointerCancel={endDrag}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerLeave={() => setHovered(null)}
ref={wrapRef}
role="img"
>
<canvas className="globe-canvas" ref={canvasRef} />
{hovered ? (
<div className="globe-tooltip" style={{ left: hovered.x, top: hovered.y }}>
<strong>{hovered.item.country}</strong>
<span>充值 {formatMoney(hovered.item.value)}</span>
<span>活跃 {formatNumber(hovered.item.activeUsers)}</span>
<span>付费 {formatNumber(hovered.item.paidUsers)}</span>
</div>
) : null}
</div>
);
}
function drawGlobe(canvas, size, rotation, points) {
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(size.width * dpr);
canvas.height = Math.round(size.height * dpr);
canvas.style.width = `${size.width}px`;
canvas.style.height = `${size.height}px`;
const context = canvas.getContext("2d");
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, size.width, size.height);
const cx = size.width / 2;
const cy = size.height / 2;
const radius = Math.max(1, Math.min(size.width * 0.48, size.height * 0.47));
drawAtmosphere(context, cx, cy, radius);
context.save();
context.beginPath();
context.arc(cx, cy, radius, 0, Math.PI * 2);
context.clip();
drawGraticule(context, rotation, cx, cy, radius);
drawLand(context, rotation, cx, cy, radius);
drawRoutes(context, points, rotation, cx, cy, radius);
drawPoints(context, points, rotation, cx, cy, radius);
drawRankMarkers(context, points, rotation, cx, cy, radius);
context.restore();
drawRim(context, cx, cy, radius);
}
function drawAtmosphere(context, cx, cy, radius) {
context.save();
context.shadowBlur = 34;
context.shadowColor = "rgba(35, 228, 255, 0.22)";
context.fillStyle = "rgba(5, 31, 52, 0.92)";
context.beginPath();
context.arc(cx, cy, radius + 1, 0, Math.PI * 2);
context.fill();
context.restore();
const ocean = context.createRadialGradient(cx - radius * 0.42, cy - radius * 0.48, radius * 0.08, cx, cy, radius);
ocean.addColorStop(0, "#144766");
ocean.addColorStop(0.46, "#082c49");
ocean.addColorStop(0.76, "#061f36");
ocean.addColorStop(1, "#04182d");
context.fillStyle = ocean;
context.beginPath();
context.arc(cx, cy, radius, 0, Math.PI * 2);
context.fill();
}
function drawGraticule(context, rotation, cx, cy, radius) {
context.save();
context.lineWidth = 0.65;
context.strokeStyle = "rgba(102, 184, 222, 0.16)";
for (let lat = -60; lat <= 60; lat += 30) {
drawProjectedLine(
context,
Array.from({ length: 91 }, (_, index) => [-180 + index * 4, lat]),
rotation,
cx,
cy,
radius
);
}
for (let lon = -150; lon <= 180; lon += 30) {
drawProjectedLine(
context,
Array.from({ length: 41 }, (_, index) => [lon, -80 + index * 4]),
rotation,
cx,
cy,
radius
);
}
context.restore();
}
function drawLand(context, rotation, cx, cy, radius) {
context.save();
context.fillStyle = "rgba(25, 63, 91, 0.9)";
context.strokeStyle = "rgba(75, 148, 190, 0.58)";
context.lineWidth = 0.62;
for (const ring of LAND_RINGS) {
drawRing(context, ring, rotation, cx, cy, radius);
}
context.restore();
}
function drawRoutes(context, points, rotation, cx, cy, radius) {
if (points.length < 2) {
return;
}
const hub = points[0];
const hubPoint = project(hub.point, rotation, cx, cy, radius);
if (!hubPoint) {
return;
}
context.save();
context.lineWidth = 0.8;
context.strokeStyle = "rgba(43, 230, 246, 0.22)";
for (const target of points.slice(1, 8)) {
const targetPoint = project(target.point, rotation, cx, cy, radius);
if (!targetPoint) {
continue;
}
const midX = (hubPoint.x + targetPoint.x) / 2;
const midY = (hubPoint.y + targetPoint.y) / 2;
const lift = Math.min(36, Math.hypot(targetPoint.x - hubPoint.x, targetPoint.y - hubPoint.y) * 0.22);
context.beginPath();
context.moveTo(hubPoint.x, hubPoint.y);
context.quadraticCurveTo(midX, midY - lift, targetPoint.x, targetPoint.y);
context.stroke();
}
context.restore();
}
function drawPoints(context, points, rotation, cx, cy, radius) {
const max = Math.max(...points.map((item) => item.value), 1);
for (const item of points) {
const point = project(item.point, rotation, cx, cy, radius);
if (!point) {
continue;
}
const strength = Math.sqrt(item.value / max);
const halo = 10 + strength * 24;
const core = 3.2 + strength * 5.4;
const glow = context.createRadialGradient(point.x, point.y, 1, point.x, point.y, halo);
glow.addColorStop(0, "rgba(129, 252, 255, 0.86)");
glow.addColorStop(0.2, "rgba(39, 228, 245, 0.42)");
glow.addColorStop(1, "rgba(39, 228, 245, 0)");
context.fillStyle = glow;
context.beginPath();
context.arc(point.x, point.y, halo, 0, Math.PI * 2);
context.fill();
context.strokeStyle = "rgba(126, 252, 255, 0.42)";
context.lineWidth = 1;
context.beginPath();
context.arc(point.x, point.y, core * 2.2, 0, Math.PI * 2);
context.stroke();
context.fillStyle = "#35f0f3";
context.shadowBlur = 16;
context.shadowColor = "rgba(53, 240, 243, 0.78)";
context.beginPath();
context.arc(point.x, point.y, core, 0, Math.PI * 2);
context.fill();
context.shadowBlur = 0;
}
}
function drawRankMarkers(context, points, rotation, cx, cy, radius) {
const visible = points
.map((item, index) => ({ index, item, point: project(item.point, rotation, cx, cy, radius) }))
.filter((item) => item.point)
.filter((item) => item.index < 5);
if (!visible.length) {
return;
}
context.save();
context.textAlign = "center";
context.textBaseline = "middle";
context.font = "800 11px Inter, Arial, sans-serif";
for (const marker of visible) {
const rank = marker.index + 1;
const markerRadius = rank <= 2 ? 10.5 : 9.5;
context.shadowBlur = 16;
context.shadowColor = "rgba(47, 232, 243, 0.55)";
context.fillStyle = "rgba(4, 31, 50, 0.92)";
context.beginPath();
context.arc(marker.point.x, marker.point.y, markerRadius, 0, Math.PI * 2);
context.fill();
context.shadowBlur = 0;
context.lineWidth = 1.5;
context.strokeStyle = rank <= 2 ? "rgba(143, 255, 255, 0.92)" : "rgba(76, 224, 241, 0.72)";
context.stroke();
context.fillStyle = rank <= 2 ? "#f0fcff" : "#bdf9ff";
context.fillText(String(rank), marker.point.x, marker.point.y + 0.5);
}
context.restore();
}
function drawRim(context, cx, cy, radius) {
context.save();
const rim = context.createRadialGradient(cx, cy, radius * 0.7, cx, cy, radius * 1.04);
rim.addColorStop(0, "rgba(37, 228, 245, 0)");
rim.addColorStop(0.78, "rgba(37, 228, 245, 0.02)");
rim.addColorStop(1, "rgba(37, 228, 245, 0.34)");
context.fillStyle = rim;
context.beginPath();
context.arc(cx, cy, radius * 1.04, 0, Math.PI * 2);
context.fill();
context.lineWidth = 1.1;
context.strokeStyle = "rgba(86, 186, 226, 0.42)";
context.beginPath();
context.arc(cx, cy, radius, 0, Math.PI * 2);
context.stroke();
context.restore();
}
function drawRing(context, ring, rotation, cx, cy, radius) {
context.beginPath();
let started = false;
let hasPoint = false;
for (const coordinate of ring) {
const point = project(coordinate, rotation, cx, cy, radius);
if (!point) {
started = false;
continue;
}
if (!started) {
context.moveTo(point.x, point.y);
started = true;
} else {
context.lineTo(point.x, point.y);
}
hasPoint = true;
}
if (hasPoint) {
context.fill();
context.stroke();
}
}
function drawProjectedLine(context, coordinates, rotation, cx, cy, radius) {
context.beginPath();
let started = false;
for (const coordinate of coordinates) {
const point = project(coordinate, rotation, cx, cy, radius);
if (!point) {
started = false;
continue;
}
if (!started) {
context.moveTo(point.x, point.y);
started = true;
} else {
context.lineTo(point.x, point.y);
}
}
context.stroke();
}
function project(coordinate, rotation, cx, cy, radius) {
const lon = coordinate[0] * DEGREE;
const lat = coordinate[1] * DEGREE;
const centerLon = rotation.lon * DEGREE;
const centerLat = rotation.lat * DEGREE;
const deltaLon = lon - centerLon;
const cosc = Math.sin(centerLat) * Math.sin(lat) + Math.cos(centerLat) * Math.cos(lat) * Math.cos(deltaLon);
if (cosc <= 0.02) {
return null;
}
return {
x: cx + radius * Math.cos(lat) * Math.sin(deltaLon),
y: cy - radius * (Math.cos(centerLat) * Math.sin(lat) - Math.sin(centerLat) * Math.cos(lat) * Math.cos(deltaLon))
};
}
function normalizePoints(items = []) {
return items
.filter((item) => Array.isArray(item.geo_point) && item.geo_point.length === 2)
.map((item) => ({
activeUsers: Number(item.active_users) || 0,
country: item.country,
paidUsers: Number(item.paid_users) || 0,
point: item.geo_point,
value: Number(item.recharge_usd_minor) || 0
}))
.sort((left, right) => right.value - left.value);
}
function findHoveredPoint(event, element, size, rotation, points) {
if (!element || !size.width || !size.height || !points.length) {
return null;
}
const rect = element.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const cx = size.width / 2;
const cy = size.height / 2;
const radius = Math.max(1, Math.min(size.width * 0.48, size.height * 0.47));
let nearest = null;
for (const item of points) {
const projected = project(item.point, rotation, cx, cy, radius);
if (!projected) {
continue;
}
const distance = Math.hypot(projected.x - x, projected.y - y);
if (distance <= 18 && (!nearest || distance < nearest.distance)) {
nearest = { distance, item, x: projected.x, y: projected.y };
}
}
if (!nearest) {
return null;
}
return {
item: nearest.item,
x: clamp(nearest.x + 14, 8, size.width - 136),
y: clamp(nearest.y - 76, 8, size.height - 96)
};
}
function extractRings(features = []) {
const rings = [];
for (const feature of features) {
const geometry = feature.geometry;
if (!geometry) {
continue;
}
if (geometry.type === "Polygon") {
rings.push(...geometry.coordinates);
}
if (geometry.type === "MultiPolygon") {
for (const polygon of geometry.coordinates) {
rings.push(...polygon);
}
}
}
return rings;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function normalizeLongitude(value) {
return ((((value + 180) % 360) + 360) % 360) - 180;
}

View File

@ -0,0 +1,20 @@
import { formatCoin } from "../utils/format.js";
export function HorizontalBars({ items }) {
const max = Math.max(...items.map((item) => item.value), 1);
return (
<div className="bar-list">
<div className="section-label">礼物消费排行</div>
{items.map((item, index) => (
<div className="bar-row" key={item.name}>
<span>{index + 1}</span>
<em>{item.name}</em>
<div className="bar-track">
<i style={{ width: `${Math.max(4, (item.value / max) * 100)}%` }} />
</div>
<b>{formatCoin(item.value)}</b>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,43 @@
import { EChart } from "../charts/EChart.jsx";
import { createDonutOption } from "../charts/options/createDonutOption.js";
import { formatCoin, formatPercent, formatWholeMoney } from "../utils/format.js";
import { FiveMetricRow } from "./MiniStat.jsx";
import { Panel } from "./Panel.jsx";
export function LuckyGiftPanel({ luckyMetrics, payoutDistribution }) {
return (
<Panel title="幸运礼物返奖">
<FiveMetricRow items={luckyMetrics} />
<div className="donut-layout">
<div className="donut-wrap">
<EChart className="donut-chart" option={createDonutOption(payoutDistribution)} />
<div className="donut-center">
<strong>{formatWholeMoney(totalValue(payoutDistribution))}</strong>
<span>返奖总额</span>
</div>
</div>
<DistributionList items={payoutDistribution} />
</div>
</Panel>
);
}
function totalValue(items) {
return items.reduce((sum, item) => sum + item.value, 0);
}
function DistributionList({ items }) {
const total = items.reduce((sum, item) => sum + item.value, 0) || 1;
return (
<div className="distribution-list">
{items.map((item) => (
<div className="distribution-row" key={item.name}>
<span />
<em>{item.name}</em>
<strong>{formatPercent(item.value / total)}</strong>
<b>{formatCoin(item.value)}</b>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,16 @@
export function MetricCard({ item }) {
const Icon = item.icon;
return (
<article className={["metric-card", Icon ? "" : "metric-card--no-icon"].filter(Boolean).join(" ")}>
{Icon ? <div className="metric-icon"><Icon /></div> : null}
<div className="metric-content">
<span>{item.label}</span>
<strong>{item.value}</strong>
<div className={["metric-delta", item.deltaTone === "down" ? "metric-delta--down" : ""].join(" ")}>
<span>{item.caption}</span>
<b>{item.delta ? `${item.deltaTone === "down" ? "↓" : "↑"} ${item.delta.replace(/^[-+]/, "")}` : ""}</b>
</div>
</div>
</article>
);
}

View File

@ -0,0 +1,48 @@
export function CoinIcon() {
return (
<svg viewBox="0 0 28 28" aria-hidden="true">
<path d="M6 8c0-2.2 3.6-4 8-4s8 1.8 8 4-3.6 4-8 4-8-1.8-8-4Zm0 5c0 2.2 3.6 4 8 4s8-1.8 8-4M6 18c0 2.2 3.6 4 8 4s8-1.8 8-4" />
<path d="M22 13v5M6 8v10" />
</svg>
);
}
export function UserPlusIcon() {
return (
<svg viewBox="0 0 28 28" aria-hidden="true">
<path d="M11 14a5 5 0 1 0 0-10 5 5 0 0 0 0 10ZM3 24c1-4.2 4-6.5 8-6.5s7 2.3 8 6.5M22 8v8M18 12h8" />
</svg>
);
}
export function ActiveUsersIcon() {
return (
<svg viewBox="0 0 28 28" aria-hidden="true">
<path d="M9 14a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9ZM19 14a3.8 3.8 0 1 0 0-7.6 3.8 3.8 0 0 0 0 7.6ZM2.8 24c.8-4 3.2-6 6.2-6s5.4 2 6.2 6M15.5 18.5c2.8.2 5 2 5.7 5.5" />
</svg>
);
}
export function CrownIcon() {
return (
<svg viewBox="0 0 28 28" aria-hidden="true">
<path d="m4 9 5.5 4.5L14 6l4.5 7.5L24 9l-2.2 12H6.2L4 9ZM7 24h14" />
</svg>
);
}
export function TrendIcon() {
return (
<svg viewBox="0 0 28 28" aria-hidden="true">
<path d="M4 23h20M7 19v-5M13 19V9M19 19V5M6 11l6-5 5 4 6-8" />
</svg>
);
}
export function StarUserIcon() {
return (
<svg viewBox="0 0 28 28" aria-hidden="true">
<path d="M10 13a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9ZM3 24c.8-4.4 3.5-7 7-7 1.4 0 2.7.4 3.8 1.2M21 15l1.5 3 3.3.5-2.4 2.3.6 3.2-3-1.6-3 1.6.6-3.2-2.4-2.3 3.3-.5L21 15Z" />
</svg>
);
}

View File

@ -0,0 +1,53 @@
export function MiniStat({ item }) {
const caption = parseCaption(item.caption);
const deltaTone = item.deltaTone || (caption.delta.startsWith("-") ? "down" : "up");
return (
<article className="mini-stat">
<span>{item.label}</span>
<strong>{item.value}</strong>
<small className={`mini-stat-delta mini-stat-delta--${deltaTone}`}>
<span>{caption.label}</span>
{caption.delta ? <b>{`${deltaTone === "down" ? "↓" : "↑"} ${caption.delta.replace(/^[-+]/, "")}`}</b> : null}
</small>
</article>
);
}
function parseCaption(caption = "") {
const normalized = String(caption).trim().replace(/\s+/g, " ");
const match = normalized.match(/^(.*)\s([+-][\d.]+(?:%|pp)?)$/);
if (!match) {
return { delta: "", label: normalized };
}
return { delta: match[2], label: match[1] };
}
export function TwoMetricRow({ items }) {
return (
<div className="two-metric-row">
{items.map((item) => (
<MiniStat item={item} key={item.label} />
))}
</div>
);
}
export function FourMetricRow({ items }) {
return (
<div className="four-metric-row">
{items.map((item) => (
<MiniStat item={item} key={item.label} />
))}
</div>
);
}
export function FiveMetricRow({ items }) {
return (
<div className="five-metric-row">
{items.map((item) => (
<MiniStat item={item} key={item.label} />
))}
</div>
);
}

View File

@ -0,0 +1,11 @@
export function Panel({ children, className = "", title }) {
return (
<section className={["databi-panel", className].filter(Boolean).join(" ")}>
<div className="panel-head">
<div className="panel-title">{title}</div>
<span className="panel-info">i</span>
</div>
{children}
</section>
);
}

View File

@ -0,0 +1,15 @@
export function RankList({ items, title, valueFormatter }) {
return (
<div className="rank-list">
<div className="rank-title">{title}</div>
{items.map((item, index) => (
<div className="rank-row" key={item.country}>
<span>{index + 1}</span>
{item.flag ? <i className="rank-flag">{item.flag}</i> : null}
<strong>{item.country}</strong>
<b>{valueFormatter(item.recharge_usd_minor)}</b>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,11 @@
import { EChart } from "../charts/EChart.jsx";
import { createRevenueOption } from "../charts/options/createRevenueOption.js";
import { Panel } from "./Panel.jsx";
export function RevenueTrendPanel({ revenueSeries }) {
return (
<Panel title="充值趋势">
<EChart className="trend-chart" option={createRevenueOption(revenueSeries)} />
</Panel>
);
}

View File

@ -0,0 +1,12 @@
import { HorizontalBars } from "./HorizontalBars.jsx";
import { TwoMetricRow } from "./MiniStat.jsx";
import { Panel } from "./Panel.jsx";
export function RoomGiftPanel({ giftRanking, roomMetrics }) {
return (
<Panel title="房间与礼物">
<TwoMetricRow items={roomMetrics} />
<HorizontalBars items={giftRanking} />
</Panel>
);
}

View File

@ -0,0 +1,8 @@
export const countryOptions = [
{ id: 0, label: "全部区域" },
{ id: 101, label: "印度尼西亚" },
{ id: 102, label: "印度" },
{ id: 103, label: "美国" },
{ id: 104, label: "巴西" },
{ id: 105, label: "土耳其" }
];

View File

@ -0,0 +1,42 @@
const countryMeta = [
{ code: "BR", label: "巴西", names: ["Brazil", "巴西"], point: [-51.93, -14.24] },
{ code: "EG", label: "埃及", names: ["Egypt", "埃及"], point: [30.8, 26.82] },
{ code: "ID", label: "印度尼西亚", names: ["Indonesia", "印度尼西亚"], point: [113.92, -0.79] },
{ code: "IN", label: "印度", names: ["India", "印度"], point: [78.96, 20.59] },
{ code: "TH", label: "泰国", names: ["Thailand", "泰国"], point: [100.99, 15.87] },
{ code: "TR", label: "土耳其", names: ["Turkey", "土耳其"], point: [35.24, 38.96] },
{ code: "US", label: "美国", names: ["United States", "USA", "United States of America", "美国"], point: [-95.71, 37.09] },
{ code: "VN", label: "越南", names: ["Vietnam", "Viet Nam", "越南"], point: [108.28, 14.06] }
];
const metaByName = new Map();
const metaByCode = new Map();
for (const item of countryMeta) {
metaByCode.set(item.code, item);
for (const name of item.names) {
metaByName.set(normalizeKey(name), item);
}
}
export function resolveCountryMeta({ code, name }) {
const fromCode = code ? metaByCode.get(String(code).trim().toUpperCase()) : null;
if (fromCode) {
return fromCode;
}
return metaByName.get(normalizeKey(name)) || null;
}
export function countryFlag(code) {
const normalized = String(code || "").trim().toUpperCase();
if (!/^[A-Z]{2}$/.test(normalized)) {
return "";
}
return Array.from(normalized)
.map((char) => String.fromCodePoint(127397 + char.charCodeAt(0)))
.join("");
}
function normalizeKey(value) {
return String(value || "").trim().toLowerCase();
}

View File

@ -0,0 +1,182 @@
import { CoinIcon, ActiveUsersIcon, CrownIcon, StarUserIcon, TrendIcon, UserPlusIcon } from "../components/MetricIcons.jsx";
import { countryFlag, resolveCountryMeta } from "./countryMeta.js";
import { formatMoney, formatMoneyFull, formatNumber, formatPercent, formatWholeMoney } from "../utils/format.js";
import { numberValue, ratio } from "../utils/number.js";
import { formatDateTime } from "../utils/time.js";
import { sampleOverview } from "./sampleOverview.js";
export function createDashboardModel(overview, { appCode, countryId, preview }) {
const source = overview || sampleOverview;
const recharge = numberValue(source.recharge_usd_minor);
const activeUsers = numberValue(source.active_users);
const paidUsers = numberValue(source.paid_users);
const newUsers = numberValue(source.new_users);
const countryBreakdown = normalizeCountries(source, countryId);
const revenueSeries = normalizeRevenueSeries(source);
const payoutDistribution = normalizeDistribution(source);
const gameRanking = normalizeGameRanking(source);
return {
appCode,
businessKpis: [
{ caption: "近 7 日", delta: preview ? "+16.80%" : "", label: "礼物消费", value: formatWholeMoney(source.gift_coin_spent) },
{ caption: "近 7 日", delta: preview ? "+12.93%" : "", label: "幸运礼物流水", value: formatWholeMoney(source.room_lucky_gift_turnover ?? source.lucky_gift_turnover) },
{ caption: "近 7 日", delta: preview ? "+8.15%" : "", label: "幸运礼物返奖", value: formatWholeMoney(source.lucky_gift_payout) },
{ caption: "近 7 日", delta: preview ? "+4.66%" : "", label: "幸运礼物利润", value: formatWholeMoney(source.lucky_gift_profit) },
{ caption: "近 7 日", delta: preview ? "+14.20%" : "", label: "游戏流水", value: formatWholeMoney(source.game_turnover) },
{ caption: "近 7 日", delta: preview ? "+18.18%" : "", label: "游戏利润", value: formatWholeMoney(source.game_profit) }
],
countryBreakdown,
funnel: [
{ name: "访客", rate: 1, value: newUsers },
{ name: "活跃用户", rate: ratio(activeUsers, newUsers), value: activeUsers },
{ name: "付费用户", rate: ratio(paidUsers, activeUsers), value: paidUsers },
{ name: "充值用户", rate: ratio(numberValue(source.lucky_gift_payers), activeUsers), value: numberValue(source.lucky_gift_payers) }
],
gameMetrics: [
{ caption: "近 7 日 +14.20%", label: "流水", value: formatWholeMoney(source.game_turnover) },
{ caption: "近 7 日 +9.63%", label: "派奖", value: formatWholeMoney(source.game_payout) },
{ caption: "近 7 日 -3.58%", label: "退款", value: formatWholeMoney(source.game_refund) },
{ caption: "近 7 日 +18.18%", label: "利润", value: formatWholeMoney(source.game_profit) }
],
gameRanking,
giftRanking: normalizeGiftRanking(source),
kpis: [
{ caption: "近 7 日", delta: preview ? "+18.10%" : "", icon: CoinIcon, label: "总充值", value: formatMoneyFull(recharge) },
{ caption: "近 7 日", delta: preview ? "+16.80%" : "", icon: UserPlusIcon, label: "新用户充值", value: formatMoneyFull(source.new_user_recharge_usd_minor) },
{ caption: "近 7 日", delta: preview ? "+10.77%" : "", icon: ActiveUsersIcon, label: "活跃用户", value: formatNumber(activeUsers) },
{ caption: "近 7 日", delta: preview ? "+12.92%" : "", icon: CrownIcon, label: "付费用户", value: formatNumber(paidUsers) },
{ caption: "近 7 日", delta: preview ? "-0.80%" : "", deltaTone: "down", icon: TrendIcon, label: "ARPU", value: formatMoney(source.arpu_usd_minor) },
{ caption: "近 7 日", delta: preview ? "+3.06%" : "", icon: StarUserIcon, label: "ARPPU", value: formatMoney(source.arppu_usd_minor) }
],
luckyMetrics: [
{ caption: "近 7 日 +10.62%", label: "流水", value: formatWholeMoney(source.lucky_gift_turnover) },
{ caption: "近 7 日 +8.15%", label: "返奖", value: formatWholeMoney(source.lucky_gift_payout) },
{ caption: "近 7 日 +4.66%", label: "利润", value: formatWholeMoney(source.lucky_gift_profit) },
{ caption: "近 7 日 -2.01pp", label: "返奖率", value: formatPercent(source.lucky_gift_payout_rate) },
{ caption: "近 7 日 +0.76pp", label: "利润率", value: formatPercent(source.lucky_gift_profit_rate) }
],
payoutDistribution,
revenueSeries,
roomMetrics: [
{ caption: "近 7 日 +16.80%", label: "礼物消费(充值)", value: formatWholeMoney(source.gift_coin_spent) },
{ caption: "近 7 日 +12.93%", label: "幸运礼物流水", value: formatWholeMoney(source.room_lucky_gift_turnover ?? source.lucky_gift_turnover) }
],
sideMetrics: [
{ caption: "近 7 日 -0.80%", deltaTone: "down", label: "ARPU", value: formatMoney(source.arpu_usd_minor) },
{ caption: "近 7 日 +0.18pp", label: "付费转化率", value: formatPercent(ratio(paidUsers, newUsers)) },
{ caption: "近 7 日 +3.06%", label: "ARPPU", value: formatMoney(source.arppu_usd_minor) }
],
topCountries: countryBreakdown.slice(0, 5),
updatedAt: formatDateTime(source.updated_at_ms || source.updatedAtMs || Date.now())
};
}
function normalizeRevenueSeries(source) {
if (Array.isArray(source.daily_series) && source.daily_series.length) {
return source.daily_series.map((item) => ({
coin_seller: numberValue(item.coin_seller ?? item.coinSeller),
google: numberValue(item.google),
label: localizeSeriesLabel(item.label || item.stat_day || item.day || "-"),
lineTotal: numberValue(item.line_total ?? item.lineTotal ?? item.trend_total ?? item.trendTotal ?? item.total ?? item.recharge_usd_minor),
mifapay: numberValue(item.mifapay ?? item.mifa_pay),
total: numberValue(item.total ?? item.recharge_usd_minor)
}));
}
return [
{
coin_seller: numberValue(source.coin_seller_recharge_usd_minor),
google: numberValue(source.google_recharge_usd_minor),
label: "当前",
lineTotal: numberValue(source.recharge_usd_minor),
mifapay: numberValue(source.mifapay_recharge_usd_minor),
total: numberValue(source.recharge_usd_minor)
}
];
}
function normalizeCountries(source, countryId) {
const raw = Array.isArray(source.country_breakdown) && source.country_breakdown.length ? source.country_breakdown : null;
const rows =
raw ||
[
{
active_users: source.active_users,
arppu_usd_minor: source.arppu_usd_minor,
country: countryId ? `国家 ${countryId}` : "全部区域",
paid_users: source.paid_users,
recharge_usd_minor: source.recharge_usd_minor,
x: 56,
y: 52
}
];
const totalRecharge = rows.reduce((sum, item) => sum + numberValue(item.recharge_usd_minor), 0) || 1;
return rows
.map((item, index) => ({
...normalizeCountryRow(item, index, totalRecharge)
}))
.sort((left, right) => right.recharge_usd_minor - left.recharge_usd_minor);
}
function normalizeCountryRow(item, index, totalRecharge) {
const code = item.country_code || item.countryCode || item.iso_code || item.isoCode;
const country = item.country || item.country_name || code || `国家 ${index + 1}`;
const meta = resolveCountryMeta({ code, name: country });
const recharge = numberValue(item.recharge_usd_minor);
return {
active_users: numberValue(item.active_users),
arpu_usd_minor: numberValue(item.arpu_usd_minor ?? item.arpuUsdMinor),
arppu_usd_minor: numberValue(item.arppu_usd_minor),
avg_recharge_usd_minor: numberValue(item.avg_recharge_usd_minor ?? item.avgRechargeUsdMinor),
country: meta?.label || country,
country_code: code || meta?.code || "",
flag: countryFlag(code || meta?.code),
geo_point: Array.isArray(item.geo_point) ? item.geo_point : meta?.point,
new_user_recharge_usd_minor: numberValue(item.new_user_recharge_usd_minor ?? item.newUserRechargeUsdMinor),
paid_users: numberValue(item.paid_users),
payer_rate: ratio(item.paid_users, item.active_users),
recharge_usd_minor: recharge,
recharge_conversion_rate: numberValue(item.recharge_conversion_rate ?? item.rechargeConversionRate),
recharge_users: numberValue(item.recharge_users ?? item.rechargeUsers),
share: recharge / totalRecharge,
trend_rate: numberValue(item.trend_rate ?? item.trendRate),
visitors: numberValue(item.visitors ?? item.new_users ?? item.newUsers)
};
}
function normalizeGiftRanking(source) {
if (Array.isArray(source.gift_ranking) && source.gift_ranking.length) {
return source.gift_ranking.map((item) => ({ name: item.name || item.gift_name || item.gift_id || "-", value: numberValue(item.value ?? item.amount ?? item.coin) }));
}
return [{ name: "礼物消费", value: numberValue(source.gift_coin_spent) }];
}
function normalizeDistribution(source) {
if (Array.isArray(source.payout_distribution) && source.payout_distribution.length) {
return source.payout_distribution.map((item) => ({ name: item.name || "-", value: numberValue(item.value) }));
}
return [
{ name: "幸运礼物返奖", value: numberValue(source.lucky_gift_payout) },
{ name: "幸运礼物利润", value: Math.max(numberValue(source.lucky_gift_profit), 0) }
];
}
function normalizeGameRanking(source) {
if (Array.isArray(source.game_ranking) && source.game_ranking.length) {
return source.game_ranking.map((item) => ({
game_id: item.game_id || item.gameId || "-",
platform_code: item.platform_code || item.platformCode || "",
profit_rate: numberValue(item.profit_rate ?? item.profitRate),
turnover_coin: numberValue(item.turnover_coin ?? item.turnoverCoin)
}));
}
return [{ game_id: "全部游戏", platform_code: "", profit_rate: numberValue(source.game_profit_rate), turnover_coin: numberValue(source.game_turnover) }];
}
function localizeSeriesLabel(label) {
const match = String(label).match(/^May\s+(\d{1,2})$/i);
if (match) {
return `5月${match[1]}`;
}
return label;
}

View File

@ -0,0 +1,67 @@
export const sampleOverview = {
active_users: 221188,
arppu_usd_minor: 306,
arpu_usd_minor: 5,
coin_seller_recharge_usd_minor: 196046500,
country_breakdown: [
{ active_users: 81560, arpu_usd_minor: 7, arppu_usd_minor: 334, avg_recharge_usd_minor: 2520, country: "印度尼西亚", new_user_recharge_usd_minor: 142140000, paid_users: 7008, recharge_conversion_rate: 0.0183, recharge_users: 5687, recharge_usd_minor: 235490000, trend_rate: 0.2043, visitors: 310200 },
{ active_users: 58190, arpu_usd_minor: 7, arppu_usd_minor: 303, avg_recharge_usd_minor: 2373, country: "印度", new_user_recharge_usd_minor: 94280000, paid_users: 4850, recharge_conversion_rate: 0.0179, recharge_users: 3985, recharge_usd_minor: 158732000, trend_rate: 0.1795, visitors: 222150 },
{ active_users: 24350, arpu_usd_minor: 7, arppu_usd_minor: 318, avg_recharge_usd_minor: 2485, country: "美国", new_user_recharge_usd_minor: 38165000, paid_users: 1980, recharge_conversion_rate: 0.0214, recharge_users: 1620, recharge_usd_minor: 62876000, trend_rate: 0.1611, visitors: 112700 },
{ active_users: 19840, arpu_usd_minor: 5, arppu_usd_minor: 297, avg_recharge_usd_minor: 2310, country: "巴西", new_user_recharge_usd_minor: 25041000, paid_users: 1360, recharge_conversion_rate: 0.0188, recharge_users: 1120, recharge_usd_minor: 40368000, trend_rate: 0.1238, visitors: 89350 },
{ active_users: 16720, arpu_usd_minor: 5, arppu_usd_minor: 290, avg_recharge_usd_minor: 2026, country: "土耳其", new_user_recharge_usd_minor: 15362000, paid_users: 1150, recharge_conversion_rate: 0.0188, recharge_users: 945, recharge_usd_minor: 24456000, trend_rate: 0.1081, visitors: 74880 },
{ active_users: 11220, arpu_usd_minor: 5, arppu_usd_minor: 284, avg_recharge_usd_minor: 2083, country: "埃及", new_user_recharge_usd_minor: 8941000, paid_users: 780, recharge_conversion_rate: 0.0188, recharge_users: 610, recharge_usd_minor: 15894000, trend_rate: 0.0942, visitors: 48660 },
{ active_users: 9880, arpu_usd_minor: 4, arppu_usd_minor: 270, avg_recharge_usd_minor: 2011, country: "泰国", new_user_recharge_usd_minor: 7403000, paid_users: 650, recharge_conversion_rate: 0.0171, recharge_users: 520, recharge_usd_minor: 13287000, trend_rate: 0.0725, visitors: 45210 },
{ active_users: 8860, arpu_usd_minor: 4, arppu_usd_minor: 251, avg_recharge_usd_minor: 1973, country: "越南", new_user_recharge_usd_minor: 6224000, paid_users: 580, recharge_conversion_rate: 0.0171, recharge_users: 470, recharge_usd_minor: 11643000, trend_rate: 0.0618, visitors: 42120 }
],
daily_series: [
{ coin_seller: 38000000, google: 42000000, label: "5月25日", line_total: 470000000, mifapay: 36000000, total: 116000000 },
{ coin_seller: 44000000, google: 47000000, label: "5月26日", line_total: 510000000, mifapay: 39000000, total: 130000000 },
{ coin_seller: 48000000, google: 52000000, label: "5月27日", line_total: 640000000, mifapay: 61000000, total: 161000000 },
{ coin_seller: 39000000, google: 39000000, label: "5月28日", line_total: 455000000, mifapay: 34000000, total: 112000000 },
{ coin_seller: 42000000, google: 44000000, label: "5月29日", line_total: 515000000, mifapay: 41000000, total: 127000000 },
{ coin_seller: 52000000, google: 61000000, label: "5月30日", line_total: 635000000, mifapay: 43000000, total: 156000000 },
{ coin_seller: 56000000, google: 53000000, label: "5月31日", line_total: 710000000, mifapay: 65000000, total: 174000000 }
],
game_payout: 343560,
game_players: 12932,
game_profit: 436248,
game_profit_rate: 0.5405,
game_ranking: [
{ game_id: "龙腾财富", payout_coin: 64020, player_count: 2038, profit_coin: 146410, profit_rate: 0.6958, turnover_coin: 210430 },
{ game_id: "海洋宝藏", payout_coin: 53260, player_count: 1820, profit_coin: 113660, profit_rate: 0.6809, turnover_coin: 166920 },
{ game_id: "幸运转盘", payout_coin: 43980, player_count: 1460, profit_coin: 84690, profit_rate: 0.6582, turnover_coin: 128670 },
{ game_id: "黄金帝国", payout_coin: 33400, player_count: 1024, profit_coin: 54060, profit_rate: 0.6254, turnover_coin: 87460 },
{ game_id: "探险之旅", payout_coin: 21980, player_count: 760, profit_coin: 38340, profit_rate: 0.6356, turnover_coin: 60320 }
],
game_refund: 27200,
game_turnover: 807008,
gift_coin_spent: 4277860,
gift_ranking: [
{ name: "飞梦", value: 568200 },
{ name: "银河飞船", value: 412450 },
{ name: "爱心城堡", value: 329180 },
{ name: "玫瑰花园", value: 265770 },
{ name: "水晶之心", value: 198260 }
],
google_recharge_usd_minor: 223200000,
lucky_gift_payout: 2573750,
lucky_gift_payout_rate: 6.3815,
lucky_gift_payers: 12932,
lucky_gift_profit: -2171086,
lucky_gift_profit_rate: -5.3815,
lucky_gift_turnover: 402664,
mifapay_recharge_usd_minor: 318500000,
new_user_recharge_usd_minor: 481316000,
new_users: 1012000,
paid_users: 16170,
payout_distribution: [
{ name: "用户幸运礼物返奖", value: 1616840 },
{ name: "幸运盒子返奖", value: 518420 },
{ name: "活动返奖", value: 269040 },
{ name: "其他", value: 169450 }
],
recharge_usd_minor: 737746500,
retention: { day1_rate: 0.6136, day7_rate: 0.181, day30_rate: 0.1077 },
room_lucky_gift_turnover: 635150,
updated_at_ms: Date.now()
};

File diff suppressed because one or more lines are too long

10
databi/src/main.jsx Normal file
View File

@ -0,0 +1,10 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { DatabiApp } from "./DatabiApp.jsx";
import "./styles/index.css";
createRoot(document.getElementById("databi-root")).render(
<React.StrictMode>
<DatabiApp />
</React.StrictMode>
);

View File

@ -0,0 +1,33 @@
* {
box-sizing: border-box;
}
body {
min-width: 1280px;
min-height: 720px;
margin: 0;
overflow: auto;
background:
linear-gradient(rgba(29, 216, 242, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(29, 216, 242, 0.04) 1px, transparent 1px),
radial-gradient(circle at 18% 24%, rgba(28, 216, 242, 0.12), transparent 28%),
radial-gradient(circle at 82% 12%, rgba(31, 201, 170, 0.1), transparent 26%),
#031322;
background-size:
24px 24px,
24px 24px,
auto,
auto,
auto;
color: #e9f6ff;
}
button,
input,
select {
font: inherit;
}
button {
cursor: pointer;
}

187
databi/src/styles/cards.css Normal file
View File

@ -0,0 +1,187 @@
.metric-card,
.databi-panel,
.mini-stat {
border: 1px solid rgba(35, 128, 180, 0.46);
background:
linear-gradient(180deg, rgba(9, 43, 70, 0.95), rgba(5, 27, 47, 0.92)),
rgba(8, 34, 56, 0.92);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.metric-card {
grid-column: span 2;
display: grid;
grid-template-columns: 44px 1fr;
height: 128px;
align-items: center;
gap: 15px;
padding: 14px 18px;
border-radius: 8px;
}
.metric-icon {
display: grid;
width: 40px;
height: 40px;
place-items: center;
color: #27e4f5;
}
.metric-icon svg {
width: 38px;
height: 38px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.9;
}
.metric-content {
min-width: 0;
}
.metric-content > span,
.mini-stat > span,
.section-label,
.rank-title {
color: #98afc9;
font-size: 12px;
}
.metric-content strong {
display: block;
margin-top: 6px;
overflow: hidden;
color: #f6fbff;
font-size: clamp(24px, 1.5vw, 30px);
font-weight: 780;
line-height: 1.1;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-delta {
display: flex;
justify-content: space-between;
gap: 8px;
margin-top: 9px;
padding-top: 8px;
border-top: 1px solid rgba(120, 169, 205, 0.16);
color: #6f8aa4;
font-size: 12px;
}
.metric-delta b,
.positive {
color: #24d79f;
font-weight: 760;
}
.metric-delta--down b {
color: #f5a623;
}
.side-metrics {
display: grid;
gap: 8px;
}
.funnel-layout .side-metrics {
grid-template-columns: 1fr;
align-content: start;
gap: 9px;
}
.funnel-layout .mini-stat {
height: 74px;
padding: 10px 11px;
}
.mini-stat {
min-width: 0;
padding: 10px 11px;
border-radius: 8px;
}
.mini-stat strong {
display: block;
margin-top: 4px;
overflow: hidden;
color: #f6fbff;
font-size: 20px;
font-weight: 780;
line-height: 1.15;
text-overflow: ellipsis;
white-space: nowrap;
}
.side-metrics .mini-stat strong {
font-size: 21px;
}
.mini-stat small {
display: flex;
justify-content: space-between;
gap: 6px;
margin-top: 3px;
overflow: hidden;
color: #6f8aa4;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.four-metric-row .mini-stat small,
.five-metric-row .mini-stat small {
gap: 2px;
font-size: 8px;
}
.mini-stat small span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.mini-stat small b {
flex: 0 0 auto;
color: #24d79f;
font-weight: 760;
}
.mini-stat-delta--down b {
color: #f5a623;
}
.two-metric-row,
.four-metric-row,
.five-metric-row {
display: grid;
gap: 8px;
margin-bottom: 10px;
}
.two-metric-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.four-metric-row {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.five-metric-row {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.five-metric-row .mini-stat strong {
font-size: clamp(10px, 0.72vw, 16px);
}
.four-metric-row .mini-stat strong {
font-size: clamp(13px, 0.8vw, 16px);
}
.five-metric-row .mini-stat {
padding-inline: 6px;
}

View File

@ -0,0 +1,177 @@
.databi-chart {
width: 100%;
min-width: 0;
}
.geo-chart {
position: relative;
z-index: 1;
height: 232px;
}
.databi-grid--top .geo-chart {
height: 100%;
}
.trend-chart,
.funnel-chart {
height: 232px;
}
.databi-grid--top .trend-chart {
flex: 1;
height: auto;
min-height: 0;
}
.donut-chart {
height: 182px;
}
.bar-list {
display: grid;
gap: 8px;
}
.databi-grid--middle .bar-list {
gap: 15px;
margin-top: 12px;
}
.bar-row {
display: grid;
grid-template-columns: 20px minmax(86px, 112px) minmax(0, 1fr) 86px;
align-items: center;
gap: 9px;
min-height: 24px;
}
.bar-track {
position: relative;
height: 9px;
overflow: hidden;
border-radius: 0;
background: rgba(95, 151, 193, 0.18);
}
.bar-track i {
position: absolute;
inset: 0 auto 0 0;
background: linear-gradient(90deg, #25d8f5, #1aa7ef);
box-shadow: 0 0 12px rgba(37, 216, 245, 0.42);
}
.donut-layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
align-items: center;
gap: 18px;
}
.databi-grid--middle .donut-layout {
flex: 1;
min-height: 0;
}
.donut-wrap {
position: relative;
}
.distribution-list {
display: grid;
gap: 10px;
}
.distribution-row {
display: grid;
grid-template-columns: 8px minmax(150px, 1fr) 58px 86px;
align-items: center;
gap: 8px;
min-width: 0;
}
.distribution-row span {
width: 8px;
height: 8px;
border-radius: 50%;
background: #25d8f5;
}
.distribution-row:nth-child(2) span {
background: #1dc9aa;
}
.distribution-row:nth-child(3) span {
background: #367be8;
}
.distribution-row:nth-child(4) span {
background: #9a9cfb;
}
.distribution-row em {
min-width: 0;
overflow: hidden;
color: #cfe3f7;
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.distribution-row strong {
color: #d9ebff;
font-weight: 680;
}
.game-table {
display: grid;
gap: 8px;
}
.databi-grid--middle .game-table {
gap: 13px;
margin-top: 12px;
}
.game-table-head {
display: grid;
grid-template-columns: 1fr 86px 64px;
gap: 10px;
color: #9eb7ce;
font-size: 12px;
}
.game-row {
display: grid;
grid-template-columns: 20px minmax(82px, 126px) minmax(0, 1fr) 86px 64px;
align-items: center;
gap: 9px;
min-height: 24px;
}
.game-row strong {
color: #24d79f;
font-weight: 720;
}
.donut-center {
position: absolute;
top: 50%;
left: 50%;
display: grid;
width: 82px;
transform: translate(-50%, -50%);
text-align: center;
pointer-events: none;
}
.donut-center strong {
color: #f6fbff;
font-size: 18px;
font-weight: 820;
}
.donut-center span {
color: #9eb7ce;
font-size: 11px;
}

View File

@ -0,0 +1,8 @@
@import "./tokens.css";
@import "./base.css";
@import "./layout.css";
@import "./cards.css";
@import "./panels.css";
@import "./charts.css";
@import "./tables.css";
@import "./responsive.css";

View File

@ -0,0 +1,272 @@
.databi-shell {
width: 100vw;
min-width: 1280px;
min-height: 720px;
padding: 0 24px 24px;
}
.databi-screen {
--analysis-row-height: 320px;
--business-metric-row-height: 88px;
display: grid;
grid-template-rows: 72px 128px var(--business-metric-row-height) var(--analysis-row-height);
gap: 16px;
min-height: 0;
}
.databi-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
height: 72px;
margin: 0 -24px;
padding: 0 24px;
border-bottom: 1px solid rgba(34, 121, 170, 0.46);
background: rgba(2, 15, 31, 0.72);
}
.brand-lockup {
display: flex;
min-width: 0;
align-items: center;
gap: 14px;
}
.brand-emblem {
display: grid;
position: relative;
width: 40px;
height: 40px;
place-items: center;
border: 1px solid rgba(39, 228, 245, 0.7);
border-radius: 50%;
background: rgba(11, 45, 70, 0.82);
box-shadow: inset 0 0 12px rgba(39, 228, 245, 0.16);
}
.brand-emblem::before {
width: 19px;
height: 19px;
border: 2px solid #27e4f5;
border-radius: 50%;
background:
linear-gradient(90deg, transparent 45%, #27e4f5 45% 55%, transparent 55%),
linear-gradient(transparent 45%, #27e4f5 45% 55%, transparent 55%);
box-shadow: 0 0 12px rgba(39, 228, 245, 0.55);
content: "";
}
.brand-emblem::after {
position: absolute;
inset: 7px;
border: 1px solid rgba(39, 228, 245, 0.55);
border-radius: 50%;
content: "";
}
.brand-lockup h1 {
margin: 0;
font-size: 26px;
font-weight: 780;
letter-spacing: 0;
}
.header-meta {
display: flex;
gap: 12px;
margin-top: 2px;
color: #7fa1bf;
font-size: 12px;
}
.header-controls {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 10px;
}
.range-control,
.region-control {
display: inline-flex;
height: 44px;
align-items: center;
gap: 10px;
border: 1px solid rgba(64, 148, 197, 0.42);
border-radius: 8px;
background: rgba(7, 29, 48, 0.86);
color: #d8ebff;
}
.range-control {
width: 378px;
padding: 0 16px;
}
.range-control input,
.region-control select {
min-width: 0;
border: 0;
background: transparent;
color: #d8ebff;
font: inherit;
outline: 0;
}
.range-control input {
width: 132px;
color-scheme: dark;
}
.range-control input::-webkit-calendar-picker-indicator {
display: none;
}
.range-control svg,
.region-control svg {
color: #ccd9e8;
}
.region-control {
width: 280px;
padding: 0 16px;
}
.region-control select {
flex: 1;
}
.range-control:focus-within,
.region-control:focus-within {
border-color: rgba(39, 228, 245, 0.86);
box-shadow: 0 0 0 3px rgba(39, 228, 245, 0.1);
}
.live-dot {
display: inline-flex;
align-items: center;
gap: 7px;
color: #20e7d6;
font-size: 13px;
font-weight: 720;
}
.live-dot::before {
width: 7px;
height: 7px;
border-radius: 50%;
background: #20e7d6;
box-shadow: 0 0 14px rgba(32, 231, 214, 0.72);
content: "";
}
.live-dot--warn {
color: #f7a33a;
}
.live-dot--warn::before {
background: #f7a33a;
box-shadow: 0 0 14px rgba(247, 163, 58, 0.72);
}
.metric-grid,
.business-metric-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 16px;
}
.business-metric-grid .metric-card {
height: var(--business-metric-row-height);
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
padding: 10px 16px;
}
.business-metric-grid .metric-content {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
grid-template-rows: auto 1fr;
column-gap: 12px;
align-items: center;
}
.business-metric-grid .metric-content > span {
grid-column: 1;
grid-row: 1;
}
.business-metric-grid .metric-content strong {
grid-column: 1;
grid-row: 2;
margin-top: 3px;
font-size: clamp(18px, 1vw, 20px);
line-height: 1.25;
}
.business-metric-grid .metric-delta {
grid-column: 2;
grid-row: 1 / 3;
display: grid;
gap: 3px;
align-self: center;
justify-self: end;
margin-top: 0;
padding-top: 0;
border-top: 0;
font-size: 11px;
text-align: right;
white-space: nowrap;
}
.business-metric-grid .metric-delta span {
color: #7f9bb7;
}
.databi-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 16px;
}
.databi-grid--top {
height: var(--analysis-row-height);
min-height: 0;
}
.databi-grid--top > .databi-panel,
.databi-grid--middle > .databi-panel {
grid-column: span 4;
}
.databi-grid--top .databi-panel {
height: var(--analysis-row-height);
min-height: 0;
}
.databi-grid--middle .databi-panel {
height: 100%;
min-height: 0;
}
.databi-screen .databi-grid--top .databi-panel,
.databi-screen .databi-grid--middle .databi-panel {
display: flex;
flex-direction: column;
}
.databi-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 34px;
margin-top: 16px;
color: #7f91a8;
font-size: 12px;
}
.databi-footer strong {
color: #f7a33a;
font-weight: 760;
}

View File

@ -0,0 +1,347 @@
.databi-panel {
min-width: 0;
min-height: 236px;
padding: 16px 20px;
border-radius: 8px;
}
.panel--table {
min-height: 190px;
margin-top: 16px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 28px;
margin-bottom: 12px;
}
.panel-title {
margin: 0;
color: #f3f9ff;
font-size: 18px;
font-weight: 780;
}
.panel-info {
display: grid;
width: 22px;
height: 22px;
place-items: center;
border: 1px solid rgba(157, 199, 232, 0.72);
border-radius: 50%;
color: #9dc7e8;
font-size: 13px;
}
.geo-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(218px, 0.72fr);
align-items: start;
gap: 16px;
height: 100%;
min-height: 0;
}
.databi-grid--top .geo-layout,
.databi-grid--top .funnel-layout {
flex: 1;
height: auto;
min-height: 0;
}
.databi-grid--top .geo-map {
aspect-ratio: 1.66;
height: auto;
max-height: 210px;
}
.geo-map {
position: relative;
min-width: 0;
overflow: hidden;
border-radius: 8px;
background:
radial-gradient(circle at 50% 50%, rgba(39, 228, 245, 0.1), transparent 48%),
radial-gradient(ellipse at 48% 52%, rgba(36, 116, 154, 0.2), transparent 68%),
linear-gradient(180deg, rgba(11, 48, 74, 0.62), rgba(5, 25, 43, 0.24));
}
.geo-map::before {
position: absolute;
inset: 26px 18px;
z-index: 0;
border: 0;
border-radius: 0;
background:
linear-gradient(90deg, rgba(39, 228, 245, 0.055) 1px, transparent 1px) 0 0 / 25% 100%,
linear-gradient(rgba(39, 228, 245, 0.05) 1px, transparent 1px) 0 0 / 100% 33%;
content: "";
pointer-events: none;
}
.geo-map::after {
position: absolute;
inset: 28px 28px;
z-index: 1;
border-top: 1px solid rgba(39, 228, 245, 0.06);
border-bottom: 1px solid rgba(39, 228, 245, 0.06);
background:
radial-gradient(ellipse at 24% 50%, rgba(37, 228, 245, 0.09), transparent 43%),
radial-gradient(ellipse at 74% 52%, rgba(37, 228, 245, 0.08), transparent 48%);
content: "";
pointer-events: none;
}
.rank-block {
display: grid;
align-content: start;
gap: 8px;
min-width: 0;
}
.globe-map {
position: relative;
z-index: 2;
width: 100%;
height: 100%;
cursor: grab;
touch-action: none;
user-select: none;
}
.globe-map.is-dragging {
cursor: grabbing;
}
.globe-canvas {
display: block;
width: 100%;
height: 100%;
}
.globe-tooltip {
position: absolute;
z-index: 5;
display: grid;
gap: 3px;
min-width: 128px;
padding: 8px 10px;
border: 1px solid rgba(52, 199, 229, 0.48);
border-radius: 4px;
background: rgba(4, 24, 41, 0.94);
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.32), 0 0 18px rgba(37, 228, 245, 0.2);
color: #cfe3f7;
font-size: 11px;
line-height: 1.2;
pointer-events: none;
}
.globe-tooltip strong {
color: #f2fbff;
font-size: 12px;
font-weight: 760;
}
.globe-tooltip span {
color: #9eb7ce;
font-variant-numeric: tabular-nums;
}
.geo-scale {
position: absolute;
bottom: 16px;
left: 16px;
z-index: 4;
display: grid;
gap: 5px;
color: #cdeeff;
font-size: 12px;
line-height: 1;
pointer-events: none;
}
.geo-scale i {
width: 13px;
height: 58px;
border: 1px solid rgba(43, 229, 245, 0.36);
border-radius: 2px;
background: linear-gradient(180deg, #2ff4e9 0%, #1fadc3 52%, #16759a 100%);
box-shadow: 0 0 14px rgba(37, 228, 245, 0.24);
}
.rank-list {
display: grid;
align-content: start;
gap: 8px;
min-width: 0;
}
.rank-row {
display: grid;
grid-template-columns: 20px 20px minmax(0, 1fr) 62px;
align-items: center;
gap: 8px;
min-width: 0;
min-height: 36px;
padding: 0 10px;
border: 1px solid rgba(81, 154, 203, 0.2);
border-radius: 8px;
background: rgba(6, 27, 46, 0.72);
}
.rank-flag {
display: inline-grid;
min-width: 18px;
place-items: center;
font-style: normal;
line-height: 1;
}
.rank-row span,
.bar-row span,
.game-row span {
color: #9eb7ce;
font-variant-numeric: tabular-nums;
}
.rank-row strong,
.bar-row em,
.game-row em {
min-width: 0;
overflow: hidden;
color: #e9f6ff;
font-style: normal;
font-weight: 640;
text-overflow: ellipsis;
white-space: nowrap;
}
.rank-row strong,
.rank-row b {
font-size: 14px;
}
.all-countries-button {
height: 34px;
border: 1px solid rgba(39, 148, 203, 0.5);
border-radius: 8px;
background: rgba(7, 35, 58, 0.72);
color: #2fe8f3;
font-size: 14px;
}
.rank-row b,
.bar-row b,
.game-row b,
.country-table td {
color: #cfe3f7;
font-weight: 620;
font-variant-numeric: tabular-nums;
}
.funnel-layout {
display: grid;
grid-template-columns: 1fr;
align-content: center;
gap: 8px;
flex: 1;
min-height: 0;
}
.funnel-stack {
display: grid;
width: min(420px, 88%);
justify-self: center;
gap: 5px;
padding: 2px 0 0;
}
.funnel-stage-row {
display: grid;
grid-template-columns: minmax(210px, 1fr);
align-items: center;
}
.funnel-stage-row > b {
display: none;
color: #9eb7ce;
font-size: 12px;
font-weight: 700;
text-align: right;
font-variant-numeric: tabular-nums;
}
.funnel-stage {
display: flex;
align-items: center;
justify-content: center;
gap: 18px;
height: 37px;
padding: 0 26px;
clip-path: polygon(4% 0, 96% 0, 88% 100%, 12% 100%);
border: 1px solid rgba(196, 241, 255, 0.65);
background: linear-gradient(180deg, rgba(54, 138, 234, 0.94), rgba(30, 106, 196, 0.92));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18);
color: #edf8ff;
}
.funnel-stage span {
font-size: 14px;
font-weight: 720;
line-height: 1.1;
white-space: nowrap;
}
.funnel-stage strong {
color: #f6fbff;
font-size: 19px;
font-weight: 820;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.funnel-layout .side-metrics {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.funnel-layout .mini-stat {
height: 72px;
overflow: hidden;
padding: 6px 10px;
}
.funnel-layout .mini-stat > span {
font-size: 12px;
}
.funnel-layout .mini-stat strong {
margin-top: 2px;
font-size: 18px;
line-height: 1.2;
}
.funnel-layout .mini-stat small {
margin-top: 3px;
font-size: 9px;
line-height: 1.15;
}
.funnel-stage--2 {
margin-inline: 18px;
background: linear-gradient(180deg, rgba(20, 174, 194, 0.94), rgba(16, 137, 173, 0.92));
}
.funnel-stage--3 {
margin-inline: 36px;
background: linear-gradient(180deg, rgba(31, 190, 177, 0.94), rgba(19, 155, 157, 0.92));
}
.funnel-stage--4 {
margin-inline: 54px;
background: linear-gradient(180deg, rgba(32, 203, 161, 0.94), rgba(18, 168, 137, 0.92));
}

View File

@ -0,0 +1,199 @@
@media (max-width: 1440px) {
.metric-card {
grid-template-columns: 34px 1fr;
gap: 10px;
padding: 12px;
}
.metric-content strong {
font-size: 20px;
}
.funnel-layout {
grid-template-columns: 1fr;
gap: 8px;
}
.funnel-stack {
width: min(330px, 100%);
gap: 5px;
padding-left: 0;
}
.funnel-stage-row {
grid-template-columns: minmax(0, 1fr);
}
.funnel-stage {
height: 37px;
padding: 0 18px;
}
.funnel-stage span {
font-size: 12px;
}
.funnel-stage strong {
font-size: 16px;
}
.funnel-stage--2 {
margin-inline: 8px;
}
.funnel-stage--3 {
margin-inline: 14px;
}
.funnel-stage--4 {
margin-inline: 20px;
}
.funnel-layout .mini-stat {
height: 72px;
padding: 6px 10px;
}
.funnel-layout .mini-stat strong {
font-size: 16px;
}
.funnel-layout .mini-stat small {
font-size: 8px;
}
.donut-layout {
grid-template-columns: 128px minmax(0, 1fr);
gap: 12px;
}
.donut-chart {
height: 132px;
}
.distribution-row {
grid-template-columns: 8px minmax(80px, 1fr) 42px 56px;
gap: 5px;
}
.distribution-row em {
font-size: 11px;
}
.distribution-row strong,
.distribution-row b {
font-size: 11px;
}
.five-metric-row {
gap: 7px;
}
.five-metric-row .mini-stat {
padding: 8px 4px;
}
.five-metric-row .mini-stat strong {
font-size: 10px;
}
.five-metric-row .mini-stat small {
font-size: 7px;
}
}
@media (max-width: 1180px) {
body,
.databi-shell {
min-width: 0;
}
.databi-header,
.header-controls {
align-items: flex-start;
flex-wrap: wrap;
}
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.databi-grid--top,
.databi-grid--middle {
grid-template-columns: 1fr;
}
.databi-grid--top .databi-panel:last-child {
grid-column: auto;
}
}
@media (max-width: 1300px) {
.databi-shell {
min-width: 1280px;
}
.metric-card {
grid-template-columns: 36px 1fr;
padding-inline: 12px;
}
.metric-content strong {
font-size: 20px;
}
.metric-delta {
font-size: 11px;
}
.metric-delta span {
white-space: nowrap;
}
.funnel-layout {
grid-template-columns: 1fr;
gap: 8px;
}
.funnel-stage-row {
grid-template-columns: minmax(0, 1fr);
}
.funnel-stage {
padding: 0 12px;
}
.funnel-stage strong {
font-size: 14px;
}
.funnel-stage--2 {
margin-inline: 5px;
}
.funnel-stage--3 {
margin-inline: 9px;
}
.funnel-stage--4 {
margin-inline: 13px;
}
.funnel-layout .mini-stat {
padding-inline: 8px;
}
.funnel-layout .mini-stat strong {
font-size: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 1ms !important;
transition-duration: 1ms !important;
}
}

View File

@ -0,0 +1,75 @@
.country-table-wrap {
overflow-x: auto;
}
.country-table {
width: 100%;
min-width: 720px;
border-collapse: collapse;
}
.country-table--wide {
min-width: 1680px;
}
.country-table th,
.country-table td {
height: 25px;
padding: 0 12px;
border-bottom: 1px solid rgba(108, 157, 196, 0.22);
border-right: 1px solid rgba(108, 157, 196, 0.15);
text-align: right;
white-space: nowrap;
}
.country-table th {
background: rgba(128, 171, 211, 0.08);
color: #9eb7ce;
font-size: 12px;
font-weight: 720;
}
.country-table th:nth-child(2),
.country-table td:nth-child(2) {
text-align: left;
}
.country-table th:first-child,
.country-table td:first-child {
width: 42px;
text-align: center;
}
.table-flag {
display: inline-block;
width: 24px;
margin-right: 8px;
font-style: normal;
text-align: center;
}
.table-tabs {
display: inline-flex;
gap: 3px;
padding: 2px;
border: 1px solid rgba(61, 141, 190, 0.42);
border-radius: 4px;
background: rgba(7, 28, 47, 0.72);
}
.table-tabs button {
height: 24px;
padding: 0 12px;
border: 0;
border-radius: 3px;
background: transparent;
color: #9eb7ce;
font-size: 12px;
font-weight: 720;
}
.table-tabs button.is-active {
background: rgba(37, 216, 245, 0.18);
color: #e9f6ff;
box-shadow: inset 0 0 0 1px rgba(37, 216, 245, 0.38);
}

View File

@ -0,0 +1,7 @@
:root {
color-scheme: dark;
font-family: Inter, "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
font-size: 14px;
line-height: 1.45;
text-rendering: geometricPrecision;
}

View File

@ -0,0 +1,56 @@
import { numberValue } from "./number.js";
export function formatMoney(value) {
const amount = moneyMajor(value);
if (Math.abs(amount) >= 1_000_000) {
return `$${(amount / 1_000_000).toFixed(2)}M`;
}
if (Math.abs(amount) >= 10_000) {
return `$${Math.round(amount).toLocaleString("en-US")}`;
}
return `$${amount.toLocaleString("en-US", { maximumFractionDigits: 2, minimumFractionDigits: amount < 10 ? 2 : 0 })}`;
}
export function formatMoneyFull(value) {
return formatSignedDollar(Math.round(moneyMajor(value)));
}
export function formatWholeMoney(value) {
return formatSignedDollar(numberValue(value));
}
export function formatCoin(value) {
return numberValue(value).toLocaleString("en-US");
}
export function formatNumber(value) {
return numberValue(value).toLocaleString("en-US");
}
export function formatPercent(value) {
return `${(numberValue(value) * 100).toFixed(2)}%`;
}
export function formatCompactPercent(value) {
return `${(numberValue(value) * 100).toFixed(1)}%`;
}
export function compactMoneyAxis(value) {
if (Math.abs(value) >= 1_000_000) {
return `${(value / 1_000_000).toFixed(1)}M`;
}
if (Math.abs(value) >= 1_000) {
return `${Math.round(value / 1_000)}K`;
}
return value;
}
export function moneyMajor(value) {
return numberValue(value) / 100;
}
function formatSignedDollar(value) {
const amount = Number(value) || 0;
const sign = amount < 0 ? "-" : "";
return `${sign}$${Math.abs(amount).toLocaleString("en-US")}`;
}

View File

@ -0,0 +1,9 @@
export function numberValue(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 0;
}
export function ratio(numerator, denominator) {
const den = numberValue(denominator);
return den > 0 ? numberValue(numerator) / den : 0;
}

27
databi/src/utils/time.js Normal file
View File

@ -0,0 +1,27 @@
export function lastDaysRange(days) {
const end = new Date();
const start = new Date(end);
start.setDate(end.getDate() - days + 1);
return { end: dateInputValue(end), start: dateInputValue(start) };
}
export function startOfDay(value) {
return value ? new Date(`${value}T00:00:00`).getTime() : "";
}
export function endOfDay(value) {
return value ? new Date(`${value}T23:59:59`).getTime() : "";
}
export function formatDateTime(value) {
const date = new Date(Number(value) || Date.now());
return `${dateInputValue(date)} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
}
function dateInputValue(date) {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
function pad2(value) {
return String(value).padStart(2, "0");
}

View File

@ -201,6 +201,8 @@ export const fallbackNavigation = [
routeNavItem("host-org-bd-leaders", { icon: ShieldOutlined }),
routeNavItem("host-org-bds", { icon: GroupsOutlined }),
routeNavItem("host-org-hosts", { icon: ManageAccountsOutlined }),
routeNavItem("host-agency-policy", { icon: WalletOutlined }),
routeNavItem("host-salary-settlement", { icon: ReceiptLongOutlined }),
routeNavItem("host-org-coin-sellers", { icon: WalletOutlined }),
],
},

View File

@ -15,6 +15,17 @@ export const PERMISSIONS = {
regionUpdate: "region:update",
regionStatus: "region:status",
hostView: "host:view",
hostAgencyPolicyView: "host-agency-policy:view",
hostAgencyPolicyCreate: "host-agency-policy:create",
hostAgencyPolicyUpdate: "host-agency-policy:update",
hostAgencyPolicyDelete: "host-agency-policy:delete",
hostAgencyPolicyPublish: "host-agency-policy:publish",
teamSalaryPolicyView: "team-salary-policy:view",
teamSalaryPolicyCreate: "team-salary-policy:create",
teamSalaryPolicyUpdate: "team-salary-policy:update",
teamSalaryPolicyDelete: "team-salary-policy:delete",
hostSalarySettlementView: "host-salary-settlement:view",
hostSalarySettlementSettle: "host-salary-settlement:settle",
agencyView: "agency:view",
agencyCreate: "agency:create",
agencyStatus: "agency:status",
@ -182,6 +193,8 @@ export const MENU_CODES = {
hostOrgBdLeaders: "host-org-bd-leaders",
hostOrgBds: "host-org-bds",
hostOrgHosts: "host-org-hosts",
hostAgencyPolicy: "host-agency-policy",
hostSalarySettlement: "host-salary-settlement",
hostOrgCoinSellers: "host-org-coin-sellers",
payment: "payment",
paymentBillList: "payment-bill-list",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,5 @@
import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined";
import ReplayOutlined from "@mui/icons-material/ReplayOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { RedPacketConfigDrawer } from "@/features/red-packets/components/RedPacketConfigDrawer.jsx";
import { RedPacketConfigSummary } from "@/features/red-packets/components/RedPacketConfigSummary.jsx";
import { RedPacketDetailDrawer } from "@/features/red-packets/components/RedPacketDetailDrawer.jsx";
@ -9,14 +7,13 @@ import { useRedPacketPage } from "@/features/red-packets/hooks/useRedPacketPage.
import styles from "@/features/red-packets/red-packets.module.css";
import {
AdminActionIconButton,
AdminFilterResetButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
const packetTypeOptions = [
@ -52,6 +49,11 @@ export function RedPacketPage() {
key: "room",
label: "房间/区域",
width: "minmax(180px, 0.75fr)",
filter: createTextColumnFilter({
placeholder: "搜索房间 ID",
value: page.roomId,
onChange: page.setRoomId,
}),
render: (packet) => (
<div className={styles.stack}>
<span>{packet.roomId || "-"}</span>
@ -63,12 +65,23 @@ export function RedPacketPage() {
key: "sender",
label: "发送人",
width: "minmax(130px, 0.55fr)",
filter: createTextColumnFilter({
placeholder: "搜索发送人 ID",
value: page.senderUserId,
onChange: page.setSenderUserId,
}),
render: (packet) => packet.senderUserId || "-",
},
{
key: "type",
label: "类型",
width: "minmax(110px, 0.5fr)",
filter: createOptionsColumnFilter({
options: packetTypeOptions,
placeholder: "搜索类型",
value: page.packetType,
onChange: page.setPacketType,
}),
render: (packet) => packetTypeLabel(packet.packetType),
},
{
@ -99,6 +112,12 @@ export function RedPacketPage() {
key: "status",
label: "状态",
width: "minmax(110px, 0.5fr)",
filter: createOptionsColumnFilter({
options: statusOptions,
placeholder: "搜索状态",
value: page.status,
onChange: page.setStatus,
}),
render: (packet) => packetStatusLabel(packet.status),
},
{
@ -150,51 +169,6 @@ export function RedPacketPage() {
onEdit={page.openConfigDrawer}
onRefresh={page.reloadConfig}
/>
<AdminListToolbar
actions={<AdminFilterResetButton label="重置筛选" onClick={page.resetFilters} />}
filters={
<>
<TextField
label="房间 ID"
size="small"
value={page.roomId}
onChange={(event) => page.setRoomId(event.target.value)}
/>
<TextField
label="发送人 ID"
size="small"
value={page.senderUserId}
onChange={(event) => page.setSenderUserId(event.target.value)}
/>
<TextField
select
label="类型"
size="small"
value={page.packetType}
onChange={(event) => page.setPacketType(event.target.value)}
>
{packetTypeOptions.map(([value, label]) => (
<MenuItem key={value || "all"} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
select
label="状态"
size="small"
value={page.status}
onChange={(event) => page.setStatus(event.target.value)}
>
{statusOptions.map(([value, label]) => (
<MenuItem key={value || "all"} value={value}>
{label}
</MenuItem>
))}
</TextField>
</>
}
/>
<DataState error={page.packetsError} loading={page.packetsLoading} onRetry={page.reloadPackets}>
<AdminListBody>
<DataTable

View File

@ -0,0 +1,138 @@
export const resourceBatchUploadSize = 10;
const resourceTypeAliases = new Map([
["头像框", "avatar_frame"],
["坐骑", "vehicle"],
["座驾", "vehicle"],
["气泡", "chat_bubble"],
["勋章", "badge"],
["徽章", "badge"],
["飘窗", "floating_screen"],
["飘屏", "floating_screen"],
["mic声波", "mic_seat_animation"],
["麦位动效", "mic_seat_animation"],
["背景卡", "profile_card"],
["资料卡", "profile_card"],
]);
const pricedResourceTypes = new Set(["avatar_frame", "vehicle"]);
export function parseResourceFolderFiles(fileList, now = Date.now()) {
const files = Array.from(fileList || []).filter((file) => file?.name && !file.name.startsWith("."));
const groups = new Map();
files.forEach((file) => {
const parsed = parseResourceFilename(file);
if (!parsed.ok) {
return;
}
const key = [parsed.resourceType, parsed.name, parsed.price, parsed.badgeForm].join("|");
const current = groups.get(key) || {
badgeForm: parsed.badgeForm,
coverFile: null,
animationFile: null,
name: parsed.name,
price: parsed.price,
resourceType: parsed.resourceType,
};
const fileKey = parsed.role === "cover" ? "coverFile" : "animationFile";
if (!current[fileKey]) {
current[fileKey] = file;
}
groups.set(key, current);
});
const resources = Array.from(groups.values())
.filter((item) => item.coverFile && item.animationFile)
.map((item, index) => ({
...item,
resourceCode: resourceCodeFor(item, index, now),
}));
return {
errors: [],
resources,
};
}
export function resourcePlanToPayload(item) {
const coinPrice = pricedResourceTypes.has(item.resourceType) ? Number(item.price || 0) : 0;
return {
amount: 0,
animationUrl: item.animationUrl,
assetUrl: item.animationUrl,
badgeForm: item.resourceType === "badge" ? item.badgeForm || "tile" : undefined,
coinPrice,
name: item.name,
previewUrl: item.coverUrl,
priceType: coinPrice > 0 ? "coin" : "free",
resourceCode: item.resourceCode,
resourceType: item.resourceType,
sortOrder: 0,
status: "active",
};
}
function parseResourceFilename(file) {
const baseName = stripExtension(file.name);
const parts = baseName
.split("_")
.map((part) => part.trim())
.filter(Boolean);
if (parts.length < 3) {
return { ok: false };
}
const role = normalizeRole(parts[parts.length - 1]);
if (!role) {
return { ok: false };
}
const resourceType = resourceTypeAliases.get(parts[0].toLowerCase()) || resourceTypeAliases.get(parts[0]);
if (!resourceType) {
return { ok: false };
}
const name = parts[1];
if (!name) {
return { ok: false };
}
let price = 0;
let badgeForm = "tile";
if (pricedResourceTypes.has(resourceType)) {
price = Number(parts[2]);
if (!Number.isInteger(price) || price <= 0) {
return { ok: false };
}
} else if (resourceType === "badge") {
const formToken = parts.length >= 4 ? parts[2] : "";
badgeForm = formToken === "长" || formToken === "long" || formToken === "strip" ? "strip" : "tile";
}
return { ok: true, badgeForm, name, price, resourceType, role };
}
function normalizeRole(value) {
const role = String(value || "")
.trim()
.toLowerCase();
if (role === "cover" || role === "封面") {
return "cover";
}
if (role === "animation" || role === "anim" || role === "动效") {
return "animation";
}
return "";
}
function resourceCodeFor(item, index, now) {
const suffix = Number(now || Date.now()).toString(36);
return `batch_${item.resourceType}_${suffix}_${String(index + 1).padStart(2, "0")}`;
}
function stripExtension(filename) {
return String(filename || "").replace(/\.[^.]+$/, "");
}

View File

@ -0,0 +1,55 @@
import { expect, test } from "vitest";
import { parseResourceFolderFiles, resourcePlanToPayload } from "./batchUpload.js";
test("parses folder resource filenames into resource plans", () => {
const files = [
file("头像框_星光_99_7_cover.png"),
file("头像框_星光_99_7_animation.svga"),
file("勋章_守护_长_cover.png"),
file("勋章_守护_长_animation.pag"),
file("背景卡_夜色_cover.png"),
file("背景卡_夜色_animation.svga"),
file("mic声波_律动_cover.png"),
file("mic声波_律动_animation.svga"),
];
const plan = parseResourceFolderFiles(files, 1770000000000);
expect(plan.errors).toEqual([]);
expect(plan.resources.map((item) => [item.name, item.resourceType, item.price, item.badgeForm])).toEqual([
["星光", "avatar_frame", 99, "tile"],
["守护", "badge", 0, "strip"],
["夜色", "profile_card", 0, "tile"],
["律动", "mic_seat_animation", 0, "tile"],
]);
expect(resourcePlanToPayload({ ...plan.resources[0], coverUrl: "cover", animationUrl: "animation" })).toMatchObject(
{
animationUrl: "animation",
coinPrice: 99,
previewUrl: "cover",
priceType: "coin",
resourceType: "avatar_frame",
},
);
});
test("silently ignores invalid and unpaired material", () => {
const plan = parseResourceFolderFiles(
[
file("frame/vip_TOPO2.png"),
file("气泡_清新_cover.png"),
file("头像框_星光_99_7_cover.png"),
file("头像框_星光_99_7_animation.svga"),
],
1770000000000,
);
expect(plan.errors).toEqual([]);
expect(plan.resources).toHaveLength(1);
expect(plan.resources[0]).toMatchObject({ name: "星光", resourceType: "avatar_frame" });
});
function file(name) {
return new File(["payload"], name, { type: "image/png" });
}

View File

@ -55,15 +55,20 @@ export const badgeFormOptions = [
export const badgeFormLabels = Object.fromEntries(badgeFormOptions);
export const resourceBatchUploadRoleLabels = {
animation: "动效素材",
cover: "封面图",
};
export const resourceTypeFilters = [
["", "全部类型"],
["avatar_frame", "头像框"],
["profile_card", "资料卡"],
["profile_card", "背景卡"],
["coin", "金币"],
["vehicle", "座驾"],
["chat_bubble", "气泡"],
["badge", "徽章"],
["floating_screen", "飘"],
["floating_screen", "飘"],
["gift", "礼物"],
["mic_seat_icon", "麦位图标"],
["mic_seat_animation", "麦位动效"],

View File

@ -1,9 +1,14 @@
import Add from "@mui/icons-material/Add";
import EditOutlined from "@mui/icons-material/EditOutlined";
import FileUploadOutlined from "@mui/icons-material/FileUploadOutlined";
import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import CircularProgress from "@mui/material/CircularProgress";
import MenuItem from "@mui/material/MenuItem";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import { useRef, useState } from "react";
import {
AdminFormDialog,
AdminFormFieldGrid,
@ -25,13 +30,22 @@ import { formatMillis } from "@/shared/utils/time.js";
import {
badgeFormLabels,
badgeFormOptions,
resourceBatchUploadRoleLabels,
resourceStatusFilters,
resourcePriceTypeLabels,
resourcePriceTypeOptions,
resourceTypeFilters,
resourceTypeLabels,
} from "@/features/resources/constants.js";
import { createResource } from "@/features/resources/api";
import {
parseResourceFolderFiles,
resourceBatchUploadSize,
resourcePlanToPayload,
} from "@/features/resources/batchUpload.js";
import { useResourceListPage } from "@/features/resources/hooks/useResourcePages.js";
import { uploadImagesBatch } from "@/shared/api/upload";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import styles from "@/features/resources/resources.module.css";
const resourceTypeOptions = resourceTypeFilters.filter(([value]) => value && value !== "emoji_pack");
@ -81,6 +95,11 @@ const baseColumns = [
export function ResourceListPage() {
const page = useResourceListPage();
const { showToast } = useToast();
const folderInputRef = useRef(null);
const [batchOpen, setBatchOpen] = useState(false);
const [batchPlan, setBatchPlan] = useState({ errors: [], resources: [] });
const [batchProgress, setBatchProgress] = useState({ label: "", running: false });
const items = page.data.items || [];
const total = page.data.total || 0;
const createDisabled = !page.abilities.canCreate || !page.abilities.canUpload;
@ -123,14 +142,85 @@ export function ResourceListPage() {
: column,
);
const openBatchDialog = () => {
setBatchPlan({ errors: [], resources: [] });
setBatchProgress({ label: "", running: false });
setBatchOpen(true);
};
const closeBatchDialog = () => {
if (!batchProgress.running) {
setBatchOpen(false);
}
};
const handleFolderChange = (event) => {
const plan = parseResourceFolderFiles(event.target.files);
event.target.value = "";
setBatchPlan(plan);
setBatchProgress({ label: "", running: false });
};
const submitBatchUpload = async (event) => {
event.preventDefault();
if (!batchPlan.resources.length || batchProgress.running) {
return;
}
const resources = batchPlan.resources.map((item) => ({ ...item }));
const uploadEntries = resources.flatMap((resource, resourceIndex) => [
{ file: resource.coverFile, resourceIndex, role: "cover" },
{ file: resource.animationFile, resourceIndex, role: "animation" },
]);
setBatchProgress({ label: "上传素材 0/" + uploadEntries.length, running: true });
try {
for (let index = 0; index < uploadEntries.length; index += resourceBatchUploadSize) {
const chunk = uploadEntries.slice(index, index + resourceBatchUploadSize);
setBatchProgress({
label: `上传素材 ${index + 1}-${index + chunk.length}/${uploadEntries.length}`,
running: true,
});
const results = await uploadImagesBatch(chunk.map((entry) => entry.file));
results.forEach((result, resultIndex) => {
const entry = chunk[resultIndex];
resources[entry.resourceIndex][entry.role === "cover" ? "coverUrl" : "animationUrl"] = result.url;
});
}
for (let index = 0; index < resources.length; index += 1) {
setBatchProgress({ label: `创建资源 ${index + 1}/${resources.length}`, running: true });
await createResource(resourcePlanToPayload(resources[index]));
}
showToast(`批量上传完成,共创建 ${resources.length} 个资源`, "success");
setBatchOpen(false);
setBatchPlan({ errors: [], resources: [] });
await page.reload();
} catch (err) {
showToast(err.message || "批量上传失败", "error");
} finally {
setBatchProgress({ label: "", running: false });
}
};
return (
<AdminListPage>
<AdminListToolbar
actions={
page.abilities.canCreate ? (
<AdminActionIconButton label="添加资源" primary onClick={page.openCreateResource}>
<Add fontSize="small" />
</AdminActionIconButton>
<>
<AdminActionIconButton
disabled={createDisabled}
label="批量上传资源"
onClick={openBatchDialog}
>
<FileUploadOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton label="添加资源" primary onClick={page.openCreateResource}>
<Add fontSize="small" />
</AdminActionIconButton>
</>
) : null
}
/>
@ -169,10 +259,120 @@ export function ResourceListPage() {
onClose={page.closeAction}
onSubmit={page.submitResource}
/>
<ResourceBatchUploadDialog
disabled={createDisabled}
fileInputRef={folderInputRef}
open={batchOpen}
plan={batchPlan}
progress={batchProgress}
onClose={closeBatchDialog}
onFileChange={handleFolderChange}
onSubmit={submitBatchUpload}
/>
</AdminListPage>
);
}
function ResourceBatchUploadDialog({ disabled, fileInputRef, onClose, onFileChange, onSubmit, open, plan, progress }) {
const submitDisabled = disabled || progress.running || !plan.resources.length;
return (
<AdminFormDialog
loading={progress.running}
open={open}
size="wide"
submitDisabled={submitDisabled}
submitLabel="开始上传"
title="批量上传资源"
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormSection
actions={
<div className={styles.batchActions}>
<Tooltip arrow placement="top" title={<ResourceBatchRules />}>
<span className={styles.batchHelp} aria-label="文件规则" role="button" tabIndex={0}>
<HelpOutlineOutlined fontSize="small" />
</span>
</Tooltip>
<button
className={styles.batchPickButton}
disabled={disabled || progress.running}
type="button"
onClick={() => fileInputRef.current?.click()}
>
<FileUploadOutlined fontSize="small" />
选择文件夹
</button>
</div>
}
title="文件夹资源"
>
<input
ref={fileInputRef}
className={styles.batchInput}
directory=""
multiple
type="file"
webkitdirectory=""
onChange={onFileChange}
/>
{progress.running ? (
<div className={styles.batchProgress}>
<CircularProgress color="inherit" size={16} />
{progress.label}
</div>
) : null}
<ResourceBatchPreview resources={plan.resources} />
</AdminFormSection>
</AdminFormDialog>
);
}
function ResourceBatchRules() {
return (
<div className={styles.batchRules}>
<div>只读取符合命名规则且同时存在 cover/animation 的资源其他文件会被忽略</div>
<div>头像框_名称_价格_天数_cover / animation</div>
<div>坐骑_名称_价格_天数_cover / animation</div>
<div>气泡_名称_cover / animation</div>
<div>勋章_名称_长_cover / animation</div>
<div>飘窗_名称_cover / animation</div>
<div>mic声波_名称_cover / animation</div>
<div>背景卡_名称_cover / animation</div>
<div>天数字段会忽略勋章不写时按短徽章处理</div>
<div>服务端单批最多上传 10 个素材前端会自动分批串行上传</div>
</div>
);
}
function ResourceBatchPreview({ resources }) {
if (!resources.length) {
return <div className={styles.batchEmpty}>尚未选择资源文件夹</div>;
}
return (
<div className={styles.batchTable}>
<div className={styles.batchTableHead}>
<span>资源</span>
<span>类型</span>
<span>价格</span>
<span>素材</span>
</div>
{resources.map((resource) => (
<div className={styles.batchTableRow} key={resource.resourceCode}>
<span>{resource.name}</span>
<span>{resourceTypeLabels[resource.resourceType] || resource.resourceType}</span>
<span>{resource.price > 0 ? `金币 ${resource.price}` : "免费"}</span>
<span>
{resourceBatchUploadRoleLabels.cover} / {resourceBatchUploadRoleLabels.animation}
</span>
</div>
))}
</div>
);
}
function ResourceRowActions({ page, resource }) {
return (
<AdminRowActions>
@ -378,7 +578,9 @@ function badgeFormFromMetadata(metadataJson) {
}
try {
const metadata = JSON.parse(metadataJson);
const badgeForm = String(metadata?.badge_form || "").trim().toLowerCase();
const badgeForm = String(metadata?.badge_form || "")
.trim()
.toLowerCase();
return badgeForm === "strip" || badgeForm === "tile" ? badgeForm : "";
} catch {
return "";

View File

@ -195,10 +195,123 @@
color: var(--text-tertiary);
}
.batchInput {
display: none;
}
.batchActions {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.batchHelp {
display: inline-flex;
width: var(--control-height);
height: var(--control-height);
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card);
color: var(--text-secondary);
cursor: help;
}
.batchHelp:hover {
border-color: var(--primary-border);
background: var(--primary-surface);
color: var(--primary);
}
.batchRules {
display: grid;
max-width: 460px;
gap: 6px;
color: inherit;
font-size: 13px;
line-height: 1.5;
}
.batchPickButton {
display: inline-flex;
height: var(--control-height);
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: 0 var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card);
color: var(--text-primary);
cursor: pointer;
font: inherit;
font-weight: 650;
}
.batchPickButton:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.batchHint {
color: var(--text-secondary);
line-height: 1.6;
}
.batchProgress,
.batchEmpty {
display: flex;
min-height: 44px;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-3);
border: 1px solid var(--border-muted);
border-radius: var(--radius-sm);
background: var(--bg-card-strong);
color: var(--text-secondary);
}
.batchTable {
display: grid;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.batchTableHead,
.batchTableRow {
display: grid;
min-height: 42px;
align-items: center;
gap: var(--space-3);
grid-template-columns: minmax(180px, 1.2fr) minmax(110px, 0.7fr) minmax(100px, 0.55fr) minmax(150px, 0.9fr);
padding: 0 var(--space-3);
}
.batchTableHead {
border-bottom: 1px solid var(--border);
background: var(--bg-card-strong);
color: var(--text-secondary);
font-size: var(--admin-font-size);
font-weight: 700;
}
.batchTableRow {
border-bottom: 1px solid var(--border-muted);
color: var(--text-primary);
}
.batchTableRow:last-child {
border-bottom: 0;
}
@media (max-width: 860px) {
.shopDrawerTools,
.shopSelectionRow,
.shopSelectionControls {
.shopSelectionControls,
.batchTableHead,
.batchTableRow {
grid-template-columns: 1fr;
}

View File

@ -40,6 +40,7 @@ export const API_OPERATIONS = {
createExploreTab: "createExploreTab",
createGift: "createGift",
createH5Link: "createH5Link",
createHostAgencyPolicy: "createHostAgencyPolicy",
createMenu: "createMenu",
createPermission: "createPermission",
createPlatform: "createPlatform",
@ -50,6 +51,7 @@ export const API_OPERATIONS = {
createRole: "createRole",
createRoomPin: "createRoomPin",
createTaskDefinition: "createTaskDefinition",
createTeamSalaryPolicy: "createTeamSalaryPolicy",
createTier: "createTier",
createUser: "createUser",
creditCoinSellerStock: "creditCoinSellerStock",
@ -61,11 +63,13 @@ export const API_OPERATIONS = {
deleteCountry: "deleteCountry",
deleteExploreTab: "deleteExploreTab",
deleteH5Link: "deleteH5Link",
deleteHostAgencyPolicy: "deleteHostAgencyPolicy",
deleteMenu: "deleteMenu",
deletePermission: "deletePermission",
deleteRechargeProduct: "deleteRechargeProduct",
deleteRole: "deleteRole",
deleteRoom: "deleteRoom",
deleteTeamSalaryPolicy: "deleteTeamSalaryPolicy",
disableCountry: "disableCountry",
disableGift: "disableGift",
disableRegion: "disableRegion",
@ -117,7 +121,9 @@ export const API_OPERATIONS = {
listGifts: "listGifts",
listGiftTypes: "listGiftTypes",
listH5Links: "listH5Links",
listHostAgencyPolicies: "listHostAgencyPolicies",
listHosts: "listHosts",
listHostSalarySettlements: "listHostSalarySettlements",
listLevelConfig: "listLevelConfig",
listLoginLogs: "listLoginLogs",
listLuckyGiftConfigs: "listLuckyGiftConfigs",
@ -142,6 +148,7 @@ export const API_OPERATIONS = {
listSevenDayCheckInClaims: "listSevenDayCheckInClaims",
listSystemMenus: "listSystemMenus",
listTaskDefinitions: "listTaskDefinitions",
listTeamSalaryPolicies: "listTeamSalaryPolicies",
listUserLeaderboards: "listUserLeaderboards",
listUsers: "listUsers",
login: "login",
@ -149,6 +156,7 @@ export const API_OPERATIONS = {
lookupCoinAdjustmentTarget: "lookupCoinAdjustmentTarget",
me: "me",
navigationMenus: "navigationMenus",
publishHostAgencyPolicy: "publishHostAgencyPolicy",
refresh: "refresh",
replaceRegionBlocks: "replaceRegionBlocks",
replaceRegionCountries: "replaceRegionCountries",
@ -177,6 +185,7 @@ export const API_OPERATIONS = {
updateGiftTypes: "updateGiftTypes",
updateH5Link: "updateH5Link",
updateH5Links: "updateH5Links",
updateHostAgencyPolicy: "updateHostAgencyPolicy",
updateMenu: "updateMenu",
updateMenuVisible: "updateMenuVisible",
updatePermission: "updatePermission",
@ -194,6 +203,7 @@ export const API_OPERATIONS = {
updateRoomTreasureConfig: "updateRoomTreasureConfig",
updateSevenDayCheckInConfig: "updateSevenDayCheckInConfig",
updateTaskDefinition: "updateTaskDefinition",
updateTeamSalaryPolicy: "updateTeamSalaryPolicy",
updateTier: "updateTier",
updateTrack: "updateTrack",
updateUser: "updateUser",
@ -395,6 +405,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "app-config:update",
permissions: ["app-config:update"]
},
createHostAgencyPolicy: {
method: "POST",
operationId: API_OPERATIONS.createHostAgencyPolicy,
path: "/v1/admin/host-agency-policies",
permission: "host-agency-policy:create",
permissions: ["host-agency-policy:create"]
},
createMenu: {
method: "POST",
operationId: API_OPERATIONS.createMenu,
@ -465,6 +482,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "daily-task:create",
permissions: ["daily-task:create"]
},
createTeamSalaryPolicy: {
method: "POST",
operationId: API_OPERATIONS.createTeamSalaryPolicy,
path: "/v1/admin/team-salary-policies",
permission: "team-salary-policy:create",
permissions: ["team-salary-policy:create"]
},
createTier: {
method: "POST",
operationId: API_OPERATIONS.createTier,
@ -542,6 +566,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "app-config:update",
permissions: ["app-config:update"]
},
deleteHostAgencyPolicy: {
method: "DELETE",
operationId: API_OPERATIONS.deleteHostAgencyPolicy,
path: "/v1/admin/host-agency-policies/{policy_id}",
permission: "host-agency-policy:delete",
permissions: ["host-agency-policy:delete"]
},
deleteMenu: {
method: "DELETE",
operationId: API_OPERATIONS.deleteMenu,
@ -577,6 +608,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "room:delete",
permissions: ["room:delete"]
},
deleteTeamSalaryPolicy: {
method: "DELETE",
operationId: API_OPERATIONS.deleteTeamSalaryPolicy,
path: "/v1/admin/team-salary-policies/{policy_id}",
permission: "team-salary-policy:delete",
permissions: ["team-salary-policy:delete"]
},
disableCountry: {
method: "POST",
operationId: API_OPERATIONS.disableCountry,
@ -932,6 +970,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "app-config:view",
permissions: ["app-config:view"]
},
listHostAgencyPolicies: {
method: "GET",
operationId: API_OPERATIONS.listHostAgencyPolicies,
path: "/v1/admin/host-agency-policies",
permission: "host-agency-policy:view",
permissions: ["host-agency-policy:view"]
},
listHosts: {
method: "GET",
operationId: API_OPERATIONS.listHosts,
@ -939,6 +984,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "host:view",
permissions: ["host:view"]
},
listHostSalarySettlements: {
method: "GET",
operationId: API_OPERATIONS.listHostSalarySettlements,
path: "/v1/admin/host-salary-settlements",
permission: "host-salary-settlement:view",
permissions: ["host-salary-settlement:view"]
},
listLevelConfig: {
method: "GET",
operationId: API_OPERATIONS.listLevelConfig,
@ -1107,6 +1159,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "daily-task:view",
permissions: ["daily-task:view"]
},
listTeamSalaryPolicies: {
method: "GET",
operationId: API_OPERATIONS.listTeamSalaryPolicies,
path: "/v1/admin/team-salary-policies",
permission: "team-salary-policy:view",
permissions: ["team-salary-policy:view"]
},
listUserLeaderboards: {
method: "GET",
operationId: API_OPERATIONS.listUserLeaderboards,
@ -1148,6 +1207,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
operationId: API_OPERATIONS.navigationMenus,
path: "/v1/navigation/menus"
},
publishHostAgencyPolicy: {
method: "POST",
operationId: API_OPERATIONS.publishHostAgencyPolicy,
path: "/v1/admin/host-agency-policies/{policy_id}/publish",
permission: "host-agency-policy:publish",
permissions: ["host-agency-policy:publish"]
},
refresh: {
method: "POST",
operationId: API_OPERATIONS.refresh,
@ -1191,7 +1257,7 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
retryRedPacketRefund: {
method: "POST",
operationId: API_OPERATIONS.retryRedPacketRefund,
path: "/v1/resident-activity/voice-room-red-packet/refund/retry",
path: "/v1/admin/activity/red-packets/refund/retry",
permission: "red-packet:update",
permissions: ["red-packet:update"]
},
@ -1340,6 +1406,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "app-config:update",
permissions: ["app-config:update"]
},
updateHostAgencyPolicy: {
method: "PUT",
operationId: API_OPERATIONS.updateHostAgencyPolicy,
path: "/v1/admin/host-agency-policies/{policy_id}",
permission: "host-agency-policy:update",
permissions: ["host-agency-policy:update"]
},
updateMenu: {
method: "PATCH",
operationId: API_OPERATIONS.updateMenu,
@ -1459,6 +1532,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "daily-task:update",
permissions: ["daily-task:update"]
},
updateTeamSalaryPolicy: {
method: "PUT",
operationId: API_OPERATIONS.updateTeamSalaryPolicy,
path: "/v1/admin/team-salary-policies/{policy_id}",
permission: "team-salary-policy:update",
permissions: ["team-salary-policy:update"]
},
updateTier: {
method: "PUT",
operationId: API_OPERATIONS.updateTier,

View File

@ -164,6 +164,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/activity/red-packets/refund/retry": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["retryRedPacketRefund"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/activity/lucky-gifts/v2/config": {
parameters: {
query?: never;
@ -980,6 +996,102 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/host-agency-policies": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listHostAgencyPolicies"];
put?: never;
post: operations["createHostAgencyPolicy"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/host-agency-policies/{policy_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["updateHostAgencyPolicy"];
post?: never;
delete: operations["deleteHostAgencyPolicy"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/host-agency-policies/{policy_id}/publish": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["publishHostAgencyPolicy"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/host-salary-settlements": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listHostSalarySettlements"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/team-salary-policies": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listTeamSalaryPolicies"];
put?: never;
post: operations["createTeamSalaryPolicy"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/team-salary-policies/{policy_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["updateTeamSalaryPolicy"];
post?: never;
delete: operations["deleteTeamSalaryPolicy"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/operations/coin-adjustments": {
parameters: {
query?: never;
@ -3781,6 +3893,136 @@ export interface operations {
200: components["responses"]["HostProfilePageResponse"];
};
};
listHostAgencyPolicies: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
createHostAgencyPolicy: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
updateHostAgencyPolicy: {
parameters: {
query?: never;
header?: never;
path: {
policy_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
deleteHostAgencyPolicy: {
parameters: {
query?: never;
header?: never;
path: {
policy_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
publishHostAgencyPolicy: {
parameters: {
query?: never;
header?: never;
path: {
policy_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listHostSalarySettlements: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listTeamSalaryPolicies: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
createTeamSalaryPolicy: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
updateTeamSalaryPolicy: {
parameters: {
query?: never;
header?: never;
path: {
policy_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
deleteTeamSalaryPolicy: {
parameters: {
query?: never;
header?: never;
path: {
policy_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listCoinAdjustments: {
parameters: {
query?: never;

View File

@ -1,6 +1,6 @@
import { afterEach, expect, test, vi } from "vitest";
import { setAccessToken } from "@/shared/api/request";
import { uploadFile, uploadImage } from "@/shared/api/upload";
import { uploadFile, uploadImage, uploadImagesBatch } from "@/shared/api/upload";
afterEach(() => {
setAccessToken("");
@ -10,7 +10,12 @@ afterEach(() => {
test("uploadImage sends multipart data to generated image endpoint", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { url: "https://media.haiyihy.com/admin/images/a.png" } })))
vi.fn(
async () =>
new Response(
JSON.stringify({ code: 0, data: { url: "https://media.haiyihy.com/admin/images/a.png" } }),
),
),
);
const result = await uploadImage(new File(["image"], "avatar.png", { type: "image/png" }));
@ -26,7 +31,10 @@ test("uploadImage sends multipart data to generated image endpoint", async () =>
test("uploadFile sends multipart data to generated file endpoint", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { url: "https://media.haiyihy.com/admin/files/a.pdf" } })))
vi.fn(
async () =>
new Response(JSON.stringify({ code: 0, data: { url: "https://media.haiyihy.com/admin/files/a.pdf" } })),
),
);
await uploadFile(new File(["file"], "doc.pdf", { type: "application/pdf" }));
@ -37,3 +45,26 @@ test("uploadFile sends multipart data to generated file endpoint", async () => {
expect(init?.body).toBeInstanceOf(FormData);
expect(init?.headers).not.toHaveProperty("Content-Type");
});
test("uploadImagesBatch sends multipart data to admin batch endpoint", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: [{ url: "https://media.haiyihy.com/admin/images/cover.png" }],
}),
),
),
);
await uploadImagesBatch([new File(["image"], "cover.png", { type: "image/png" })]);
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/admin/files/image/batch-upload");
expect(init?.method).toBe("POST");
expect(init?.body).toBeInstanceOf(FormData);
expect(init?.headers).not.toHaveProperty("Content-Type");
});

View File

@ -9,7 +9,7 @@ export function uploadFile(file: File): Promise<UploadResultDto> {
return apiRequest<UploadResultDto, FormData>(apiEndpointPath(API_OPERATIONS.uploadFile), {
body: formData,
method: endpoint.method
method: endpoint.method,
});
}
@ -20,6 +20,18 @@ export function uploadImage(file: File): Promise<UploadResultDto> {
return apiRequest<UploadResultDto, FormData>(apiEndpointPath(API_OPERATIONS.uploadImage), {
body: formData,
method: endpoint.method
method: endpoint.method,
});
}
export function uploadImagesBatch(files: File[]): Promise<UploadResultDto[]> {
const formData = new FormData();
files.forEach((file) => {
formData.append("files", file);
});
return apiRequest<UploadResultDto[], FormData>("/v1/admin/files/image/batch-upload", {
body: formData,
method: "POST",
});
}

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,14 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
build: {
rollupOptions: {
input: {
admin: new URL("./index.html", import.meta.url).pathname,
databi: new URL("./databi/index.html", import.meta.url).pathname
}
}
},
css: {
modules: {
generateScopedName: "hy-[name]__[local]__[hash:base64:5]",