Merge branch 'main' into test
This commit is contained in:
commit
1a1c6ecc88
1
.gitignore
vendored
1
.gitignore
vendored
@ -4,3 +4,4 @@ node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
.waylog/
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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
12
databi/index.html
Normal 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
94
databi/src/DatabiApp.jsx
Normal 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
61
databi/src/api.js
Normal 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();
|
||||
}
|
||||
45
databi/src/charts/EChart.jsx
Normal file
45
databi/src/charts/EChart.jsx
Normal 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} />;
|
||||
}
|
||||
23
databi/src/charts/options/createDonutOption.js
Normal file
23
databi/src/charts/options/createDonutOption.js
Normal 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" }
|
||||
}
|
||||
};
|
||||
}
|
||||
24
databi/src/charts/options/createFunnelOption.js
Normal file
24
databi/src/charts/options/createFunnelOption.js
Normal 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" } }
|
||||
};
|
||||
}
|
||||
53
databi/src/charts/options/createGeoOption.js
Normal file
53
databi/src/charts/options/createGeoOption.js
Normal 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" }
|
||||
}
|
||||
};
|
||||
}
|
||||
50
databi/src/charts/options/createRevenueOption.js
Normal file
50
databi/src/charts/options/createRevenueOption.js
Normal 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;
|
||||
}
|
||||
136
databi/src/components/CountryPerformanceTable.jsx
Normal file
136
databi/src/components/CountryPerformanceTable.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
databi/src/components/DatabiHeader.jsx
Normal file
35
databi/src/components/DatabiHeader.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
databi/src/components/FunnelPanel.jsx
Normal file
28
databi/src/components/FunnelPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
databi/src/components/GameEconomyPanel.jsx
Normal file
37
databi/src/components/GameEconomyPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
databi/src/components/GlobalOverviewPanel.jsx
Normal file
34
databi/src/components/GlobalOverviewPanel.jsx
Normal 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);
|
||||
}
|
||||
471
databi/src/components/GlobeMap.jsx
Normal file
471
databi/src/components/GlobeMap.jsx
Normal 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;
|
||||
}
|
||||
20
databi/src/components/HorizontalBars.jsx
Normal file
20
databi/src/components/HorizontalBars.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
databi/src/components/LuckyGiftPanel.jsx
Normal file
43
databi/src/components/LuckyGiftPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
databi/src/components/MetricCard.jsx
Normal file
16
databi/src/components/MetricCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
databi/src/components/MetricIcons.jsx
Normal file
48
databi/src/components/MetricIcons.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
databi/src/components/MiniStat.jsx
Normal file
53
databi/src/components/MiniStat.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
databi/src/components/Panel.jsx
Normal file
11
databi/src/components/Panel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
databi/src/components/RankList.jsx
Normal file
15
databi/src/components/RankList.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
databi/src/components/RevenueTrendPanel.jsx
Normal file
11
databi/src/components/RevenueTrendPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
databi/src/components/RoomGiftPanel.jsx
Normal file
12
databi/src/components/RoomGiftPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
databi/src/config/options.js
Normal file
8
databi/src/config/options.js
Normal 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: "土耳其" }
|
||||
];
|
||||
42
databi/src/data/countryMeta.js
Normal file
42
databi/src/data/countryMeta.js
Normal 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();
|
||||
}
|
||||
182
databi/src/data/createDashboardModel.js
Normal file
182
databi/src/data/createDashboardModel.js
Normal 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;
|
||||
}
|
||||
67
databi/src/data/sampleOverview.js
Normal file
67
databi/src/data/sampleOverview.js
Normal 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()
|
||||
};
|
||||
1
databi/src/data/world.json
Normal file
1
databi/src/data/world.json
Normal file
File diff suppressed because one or more lines are too long
10
databi/src/main.jsx
Normal file
10
databi/src/main.jsx
Normal 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>
|
||||
);
|
||||
33
databi/src/styles/base.css
Normal file
33
databi/src/styles/base.css
Normal 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
187
databi/src/styles/cards.css
Normal 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;
|
||||
}
|
||||
177
databi/src/styles/charts.css
Normal file
177
databi/src/styles/charts.css
Normal 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;
|
||||
}
|
||||
8
databi/src/styles/index.css
Normal file
8
databi/src/styles/index.css
Normal 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";
|
||||
272
databi/src/styles/layout.css
Normal file
272
databi/src/styles/layout.css
Normal 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;
|
||||
}
|
||||
347
databi/src/styles/panels.css
Normal file
347
databi/src/styles/panels.css
Normal 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));
|
||||
}
|
||||
199
databi/src/styles/responsive.css
Normal file
199
databi/src/styles/responsive.css
Normal 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;
|
||||
}
|
||||
}
|
||||
75
databi/src/styles/tables.css
Normal file
75
databi/src/styles/tables.css
Normal 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);
|
||||
}
|
||||
7
databi/src/styles/tokens.css
Normal file
7
databi/src/styles/tokens.css
Normal 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;
|
||||
}
|
||||
56
databi/src/utils/format.js
Normal file
56
databi/src/utils/format.js
Normal 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")}`;
|
||||
}
|
||||
9
databi/src/utils/number.js
Normal file
9
databi/src/utils/number.js
Normal 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
27
databi/src/utils/time.js
Normal 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");
|
||||
}
|
||||
@ -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 }),
|
||||
],
|
||||
},
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -6,6 +6,7 @@ import { dashboardRoutes } from "@/features/dashboard/routes.js";
|
||||
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
|
||||
import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/routes.js";
|
||||
import { gameRoutes } from "@/features/games/routes.js";
|
||||
import { hostAgencyPolicyRoutes } from "@/features/host-agency-policy/routes.js";
|
||||
import { hostOrgRoutes } from "@/features/host-org/routes.js";
|
||||
import { levelConfigRoutes } from "@/features/level-config/routes.js";
|
||||
import { logsRoutes } from "@/features/logs/routes.js";
|
||||
@ -51,6 +52,7 @@ export const adminRoutes: AdminRoute[] = [
|
||||
...paymentRoutes,
|
||||
...gameRoutes,
|
||||
...usersRoutes,
|
||||
...hostAgencyPolicyRoutes,
|
||||
...hostOrgRoutes,
|
||||
...rolesRoutes,
|
||||
...menusRoutes,
|
||||
|
||||
560
src/features/host-agency-policy/api.ts
Normal file
560
src/features/host-agency-policy/api.ts
Normal file
@ -0,0 +1,560 @@
|
||||
import { apiRequest } from "@/shared/api/request";
|
||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||
import type { ApiPage, EntityId, PageQuery } from "@/shared/api/types";
|
||||
|
||||
export interface HostAgencyPolicyLevelDto {
|
||||
id?: number;
|
||||
policyId?: number;
|
||||
level: number;
|
||||
// 后端保存的是达到该等级后的累计权益,列表里展示的最高工资也基于累计值。
|
||||
requiredDiamonds: number;
|
||||
hostSalaryUsd: string;
|
||||
hostCoinReward: number;
|
||||
agencySalaryUsd: string;
|
||||
totalSalaryUsd?: string;
|
||||
status: string;
|
||||
sortOrder?: number;
|
||||
createdAtMs?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface HostAgencyPolicyDto {
|
||||
id: number;
|
||||
appCode?: string;
|
||||
name: string;
|
||||
regionId: number;
|
||||
status: string;
|
||||
settlementMode: string;
|
||||
settlementTriggerMode: string;
|
||||
giftCoinToDiamondRatio: string;
|
||||
residualDiamondToUsdRate: string;
|
||||
effectiveFromMs?: number;
|
||||
effectiveToMs?: number;
|
||||
createdByAdminId?: number;
|
||||
updatedByAdminId?: number;
|
||||
publishStatus?: string;
|
||||
publishError?: string;
|
||||
publishedAtMs?: number;
|
||||
publishedByAdminId?: number;
|
||||
createdAtMs?: number;
|
||||
updatedAtMs?: number;
|
||||
levels: HostAgencyPolicyLevelDto[];
|
||||
}
|
||||
|
||||
export interface HostAgencyPolicyPayload {
|
||||
name: string;
|
||||
// 第一阶段按单区域配置;同一区域同一生效时间只能启用一条政策。
|
||||
region_id: number;
|
||||
status: string;
|
||||
settlement_mode: string;
|
||||
settlement_trigger_mode: string;
|
||||
gift_coin_to_diamond_ratio: string;
|
||||
residual_diamond_to_usd_rate: string;
|
||||
effective_from_ms: number;
|
||||
effective_to_ms: number;
|
||||
levels: Array<{
|
||||
level: number;
|
||||
required_diamonds: number;
|
||||
host_salary_usd: string;
|
||||
host_coin_reward: number;
|
||||
agency_salary_usd: string;
|
||||
status: string;
|
||||
sort_order: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
type RawPolicy = HostAgencyPolicyDto & Record<string, unknown>;
|
||||
type RawLevel = HostAgencyPolicyLevelDto & Record<string, unknown>;
|
||||
|
||||
export function listHostAgencyPolicies(query: PageQuery = {}): Promise<ApiPage<HostAgencyPolicyDto>> {
|
||||
const endpoint = API_ENDPOINTS.listHostAgencyPolicies;
|
||||
// 统一走 OpenAPI 生成的 operation path,避免手写路径和后端契约漂移。
|
||||
return apiRequest<ApiPage<RawPolicy>>(apiEndpointPath(API_OPERATIONS.listHostAgencyPolicies), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
}).then((page) => ({
|
||||
...page,
|
||||
items: (page.items || []).map(normalizePolicy),
|
||||
}));
|
||||
}
|
||||
|
||||
export function createHostAgencyPolicy(payload: HostAgencyPolicyPayload): Promise<HostAgencyPolicyDto> {
|
||||
const endpoint = API_ENDPOINTS.createHostAgencyPolicy;
|
||||
return apiRequest<RawPolicy, HostAgencyPolicyPayload>(apiEndpointPath(API_OPERATIONS.createHostAgencyPolicy), {
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
}).then(normalizePolicy);
|
||||
}
|
||||
|
||||
export function updateHostAgencyPolicy(
|
||||
policyId: EntityId,
|
||||
payload: HostAgencyPolicyPayload,
|
||||
): Promise<HostAgencyPolicyDto> {
|
||||
const endpoint = API_ENDPOINTS.updateHostAgencyPolicy;
|
||||
return apiRequest<RawPolicy, HostAgencyPolicyPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.updateHostAgencyPolicy, { policy_id: policyId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
).then(normalizePolicy);
|
||||
}
|
||||
|
||||
export function deleteHostAgencyPolicy(policyId: EntityId): Promise<unknown> {
|
||||
const endpoint = API_ENDPOINTS.deleteHostAgencyPolicy;
|
||||
return apiRequest(apiEndpointPath(API_OPERATIONS.deleteHostAgencyPolicy, { policy_id: policyId }), {
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function publishHostAgencyPolicy(policyId: EntityId): Promise<HostAgencyPolicyDto> {
|
||||
const endpoint = API_ENDPOINTS.publishHostAgencyPolicy;
|
||||
return apiRequest<RawPolicy>(apiEndpointPath(API_OPERATIONS.publishHostAgencyPolicy, { policy_id: policyId }), {
|
||||
method: endpoint.method,
|
||||
}).then(normalizePolicy);
|
||||
}
|
||||
|
||||
export interface TeamSalaryPolicyLevelDto {
|
||||
id?: number;
|
||||
policyId?: number;
|
||||
level: number;
|
||||
thresholdUsd: string;
|
||||
ratePercent: string;
|
||||
salaryUsd: string;
|
||||
status: string;
|
||||
sortOrder?: number;
|
||||
createdAtMs?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface TeamSalaryPolicyDto {
|
||||
id: number;
|
||||
appCode?: string;
|
||||
policyType: "bd" | "admin";
|
||||
name: string;
|
||||
regionId: number;
|
||||
status: string;
|
||||
settlementTriggerMode: string;
|
||||
effectiveFromMs?: number;
|
||||
effectiveToMs?: number;
|
||||
createdByAdminId?: number;
|
||||
updatedByAdminId?: number;
|
||||
createdAtMs?: number;
|
||||
updatedAtMs?: number;
|
||||
levels: TeamSalaryPolicyLevelDto[];
|
||||
}
|
||||
|
||||
export interface TeamSalaryPolicyPayload {
|
||||
policy_type: "bd" | "admin";
|
||||
name: string;
|
||||
region_id: number;
|
||||
status: string;
|
||||
settlement_trigger_mode: string;
|
||||
effective_from_ms: number;
|
||||
effective_to_ms: number;
|
||||
description?: string;
|
||||
levels: Array<{
|
||||
level: number;
|
||||
threshold_usd: string;
|
||||
rate_percent: string;
|
||||
salary_usd: string;
|
||||
status: string;
|
||||
sort_order: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
type RawTeamPolicy = TeamSalaryPolicyDto & Record<string, unknown>;
|
||||
type RawTeamLevel = TeamSalaryPolicyLevelDto & Record<string, unknown>;
|
||||
|
||||
export function listTeamSalaryPolicies(query: PageQuery = {}): Promise<ApiPage<TeamSalaryPolicyDto>> {
|
||||
const endpoint = API_ENDPOINTS.listTeamSalaryPolicies;
|
||||
return apiRequest<ApiPage<RawTeamPolicy>>(apiEndpointPath(API_OPERATIONS.listTeamSalaryPolicies), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
}).then((page) => ({
|
||||
...page,
|
||||
items: (page.items || []).map(normalizeTeamPolicy),
|
||||
}));
|
||||
}
|
||||
|
||||
export function createTeamSalaryPolicy(payload: TeamSalaryPolicyPayload): Promise<TeamSalaryPolicyDto> {
|
||||
const endpoint = API_ENDPOINTS.createTeamSalaryPolicy;
|
||||
return apiRequest<RawTeamPolicy, TeamSalaryPolicyPayload>(apiEndpointPath(API_OPERATIONS.createTeamSalaryPolicy), {
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
}).then(normalizeTeamPolicy);
|
||||
}
|
||||
|
||||
export function updateTeamSalaryPolicy(
|
||||
policyId: EntityId,
|
||||
payload: TeamSalaryPolicyPayload,
|
||||
): Promise<TeamSalaryPolicyDto> {
|
||||
const endpoint = API_ENDPOINTS.updateTeamSalaryPolicy;
|
||||
return apiRequest<RawTeamPolicy, TeamSalaryPolicyPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.updateTeamSalaryPolicy, { policy_id: policyId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
).then(normalizeTeamPolicy);
|
||||
}
|
||||
|
||||
export function deleteTeamSalaryPolicy(policyType: string, policyId: EntityId): Promise<unknown> {
|
||||
const endpoint = API_ENDPOINTS.deleteTeamSalaryPolicy;
|
||||
return apiRequest(apiEndpointPath(API_OPERATIONS.deleteTeamSalaryPolicy, { policy_id: policyId }), {
|
||||
method: endpoint.method,
|
||||
query: { policy_type: policyType },
|
||||
});
|
||||
}
|
||||
|
||||
export interface HostSalarySettlementUserDto {
|
||||
userId: string;
|
||||
displayUserId?: string;
|
||||
username?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface HostSalarySettlementDto {
|
||||
settlementId: string;
|
||||
commandId: string;
|
||||
transactionId: string;
|
||||
settlementType: string;
|
||||
userId: string;
|
||||
user: HostSalarySettlementUserDto;
|
||||
agencyOwnerUserId: string;
|
||||
agencyOwner: HostSalarySettlementUserDto;
|
||||
cycleKey: string;
|
||||
policyId: number;
|
||||
levelNo: number;
|
||||
totalDiamonds: number;
|
||||
hostSalaryUsdMinorDelta: number;
|
||||
hostSalaryUsdDelta: string;
|
||||
hostCoinRewardDelta: number;
|
||||
agencySalaryUsdMinorDelta: number;
|
||||
agencySalaryUsdDelta: string;
|
||||
residualUsdMinorDelta: number;
|
||||
residualUsdDelta: string;
|
||||
status: string;
|
||||
reason: string;
|
||||
createdAtMs: number;
|
||||
}
|
||||
|
||||
type RawSettlement = HostSalarySettlementDto & Record<string, unknown>;
|
||||
|
||||
export function listHostSalarySettlements(query: PageQuery = {}): Promise<ApiPage<HostSalarySettlementDto>> {
|
||||
const endpoint = API_ENDPOINTS.listHostSalarySettlements;
|
||||
return apiRequest<ApiPage<RawSettlement>>(apiEndpointPath(API_OPERATIONS.listHostSalarySettlements), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
}).then((page) => ({
|
||||
...page,
|
||||
items: (page.items || []).map(normalizeSettlement),
|
||||
}));
|
||||
}
|
||||
|
||||
export interface TeamSalarySettlementPendingDto {
|
||||
policyType: "bd" | "admin";
|
||||
userId: string;
|
||||
user: HostSalarySettlementUserDto;
|
||||
cycleKey: string;
|
||||
regionId: number;
|
||||
regionName?: string;
|
||||
policyId: number;
|
||||
policyName: string;
|
||||
levelNo: number;
|
||||
incomeUsdMinor: number;
|
||||
incomeUsd: string;
|
||||
salaryUsdMinorTarget: number;
|
||||
salaryUsdTarget: string;
|
||||
salaryUsdMinorDelta: number;
|
||||
salaryUsdDelta: string;
|
||||
settledLevelNo: number;
|
||||
settledSalaryUsdMinor: number;
|
||||
settledSalaryUsd: string;
|
||||
lastSettledIncomeUsdMinor: number;
|
||||
lastSettledIncomeUsd: string;
|
||||
monthClosedAtMs: number;
|
||||
}
|
||||
|
||||
export interface TeamSalarySettlementRecordDto {
|
||||
settlementId: string;
|
||||
commandId: string;
|
||||
transactionId: string;
|
||||
policyType: "bd" | "admin";
|
||||
triggerMode: string;
|
||||
userId: string;
|
||||
user: HostSalarySettlementUserDto;
|
||||
cycleKey: string;
|
||||
regionId: number;
|
||||
policyId: number;
|
||||
levelNo: number;
|
||||
incomeUsdMinor: number;
|
||||
incomeUsd: string;
|
||||
salaryUsdMinorDelta: number;
|
||||
salaryUsdDelta: string;
|
||||
status: string;
|
||||
reason: string;
|
||||
createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface TeamSalarySettlePayload {
|
||||
policy_type: "bd" | "admin";
|
||||
trigger_mode: "manual" | "automatic";
|
||||
cycle_key: string;
|
||||
region_id?: number;
|
||||
country_code?: string;
|
||||
user_ids?: number[];
|
||||
}
|
||||
|
||||
export interface TeamSalarySettleResult {
|
||||
policyType: "bd" | "admin";
|
||||
cycleKey: string;
|
||||
requestedCount: number;
|
||||
processedCount: number;
|
||||
successCount: number;
|
||||
skippedCount: number;
|
||||
failureCount: number;
|
||||
records: TeamSalarySettlementRecordDto[];
|
||||
}
|
||||
|
||||
type RawTeamPending = TeamSalarySettlementPendingDto & Record<string, unknown>;
|
||||
type RawTeamRecord = TeamSalarySettlementRecordDto & Record<string, unknown>;
|
||||
type RawTeamSettleResult = TeamSalarySettleResult & Record<string, unknown>;
|
||||
|
||||
export function listTeamSalarySettlementPending(
|
||||
query: PageQuery = {},
|
||||
): Promise<ApiPage<TeamSalarySettlementPendingDto>> {
|
||||
// 新增结算接口尚未进入生成端点,先集中在本 feature API 封装手写路径,避免页面散落裸字符串。
|
||||
return apiRequest<ApiPage<RawTeamPending>>("/v1/admin/team-salary-settlements/pending", {
|
||||
method: "GET",
|
||||
query,
|
||||
}).then((page) => ({
|
||||
...page,
|
||||
items: (page.items || []).map(normalizeTeamPending),
|
||||
}));
|
||||
}
|
||||
|
||||
export function settleTeamSalary(payload: TeamSalarySettlePayload): Promise<TeamSalarySettleResult> {
|
||||
// 批量结算是写钱包动作;payload 保持后端 snake_case,减少序列化时的字段转换风险。
|
||||
return apiRequest<RawTeamSettleResult, TeamSalarySettlePayload>("/v1/admin/team-salary-settlements/settle", {
|
||||
body: payload,
|
||||
method: "POST",
|
||||
}).then(normalizeTeamSettleResult);
|
||||
}
|
||||
|
||||
export function listTeamSalarySettlementRecords(
|
||||
query: PageQuery = {},
|
||||
): Promise<ApiPage<TeamSalarySettlementRecordDto>> {
|
||||
// 记录查询与待结算分开,避免运营查历史记录时触发候选重算。
|
||||
return apiRequest<ApiPage<RawTeamRecord>>("/v1/admin/team-salary-settlements/records", {
|
||||
method: "GET",
|
||||
query,
|
||||
}).then((page) => ({
|
||||
...page,
|
||||
items: (page.items || []).map(normalizeTeamRecord),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizePolicy(item: RawPolicy): HostAgencyPolicyDto {
|
||||
// 响应兼容 snake_case/camelCase,是为了后端 DTO 和前端内部模型之间只有这一处转换。
|
||||
return {
|
||||
id: numberValue(item.id),
|
||||
appCode: stringValue(item.appCode ?? item.app_code),
|
||||
name: stringValue(item.name),
|
||||
regionId: numberValue(item.regionId ?? item.region_id),
|
||||
status: stringValue(item.status) || "disabled",
|
||||
settlementMode: stringValue(item.settlementMode ?? item.settlement_mode) || "daily",
|
||||
settlementTriggerMode: stringValue(item.settlementTriggerMode ?? item.settlement_trigger_mode) || "automatic",
|
||||
giftCoinToDiamondRatio: stringValue(item.giftCoinToDiamondRatio ?? item.gift_coin_to_diamond_ratio) || "1",
|
||||
residualDiamondToUsdRate:
|
||||
stringValue(item.residualDiamondToUsdRate ?? item.residual_diamond_to_usd_rate) || "0",
|
||||
effectiveFromMs: numberValue(item.effectiveFromMs ?? item.effective_from_ms),
|
||||
effectiveToMs: numberValue(item.effectiveToMs ?? item.effective_to_ms),
|
||||
createdByAdminId: numberValue(item.createdByAdminId ?? item.created_by_admin_id),
|
||||
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
|
||||
publishStatus: stringValue(item.publishStatus ?? item.publish_status) || "draft",
|
||||
publishError: stringValue(item.publishError ?? item.publish_error),
|
||||
publishedAtMs: numberValue(item.publishedAtMs ?? item.published_at_ms),
|
||||
publishedByAdminId: numberValue(item.publishedByAdminId ?? item.published_by_admin_id),
|
||||
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||
levels: arrayValue(item.levels).map(normalizeLevel),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSettlement(raw: RawSettlement): HostSalarySettlementDto {
|
||||
const user = asRecord(raw.user);
|
||||
const agencyOwner = asRecord(raw.agencyOwner ?? raw.agency_owner);
|
||||
return {
|
||||
settlementId: stringValue(raw.settlementId ?? raw.settlement_id),
|
||||
commandId: stringValue(raw.commandId ?? raw.command_id),
|
||||
transactionId: stringValue(raw.transactionId ?? raw.transaction_id),
|
||||
settlementType: stringValue(raw.settlementType ?? raw.settlement_type),
|
||||
userId: stringValue(raw.userId ?? raw.user_id),
|
||||
user: normalizeSettlementUser(user),
|
||||
agencyOwnerUserId: stringValue(raw.agencyOwnerUserId ?? raw.agency_owner_user_id),
|
||||
agencyOwner: normalizeSettlementUser(agencyOwner),
|
||||
cycleKey: stringValue(raw.cycleKey ?? raw.cycle_key),
|
||||
policyId: numberValue(raw.policyId ?? raw.policy_id),
|
||||
levelNo: numberValue(raw.levelNo ?? raw.level_no),
|
||||
totalDiamonds: numberValue(raw.totalDiamonds ?? raw.total_diamonds),
|
||||
hostSalaryUsdMinorDelta: numberValue(raw.hostSalaryUsdMinorDelta ?? raw.host_salary_usd_minor_delta),
|
||||
hostSalaryUsdDelta: stringValue(raw.hostSalaryUsdDelta ?? raw.host_salary_usd_delta),
|
||||
hostCoinRewardDelta: numberValue(raw.hostCoinRewardDelta ?? raw.host_coin_reward_delta),
|
||||
agencySalaryUsdMinorDelta: numberValue(raw.agencySalaryUsdMinorDelta ?? raw.agency_salary_usd_minor_delta),
|
||||
agencySalaryUsdDelta: stringValue(raw.agencySalaryUsdDelta ?? raw.agency_salary_usd_delta),
|
||||
residualUsdMinorDelta: numberValue(raw.residualUsdMinorDelta ?? raw.residual_usd_minor_delta),
|
||||
residualUsdDelta: stringValue(raw.residualUsdDelta ?? raw.residual_usd_delta),
|
||||
status: stringValue(raw.status) || "succeeded",
|
||||
reason: stringValue(raw.reason),
|
||||
createdAtMs: numberValue(raw.createdAtMs ?? raw.created_at_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSettlementUser(raw: Record<string, unknown>): HostSalarySettlementUserDto {
|
||||
return {
|
||||
userId: stringValue(raw.userId ?? raw.user_id),
|
||||
displayUserId: stringValue(raw.displayUserId ?? raw.display_user_id),
|
||||
username: stringValue(raw.username),
|
||||
avatar: stringValue(raw.avatar),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTeamPending(raw: RawTeamPending): TeamSalarySettlementPendingDto {
|
||||
const user = asRecord(raw.user);
|
||||
// 待结算 DTO 兼容 snake_case/camelCase,方便后端 Go JSON tag 与前端内部模型独立演进。
|
||||
return {
|
||||
policyType: (stringValue(raw.policyType ?? raw.policy_type) || "bd") as "bd" | "admin",
|
||||
userId: stringValue(raw.userId ?? raw.user_id),
|
||||
user: normalizeSettlementUser(user),
|
||||
cycleKey: stringValue(raw.cycleKey ?? raw.cycle_key),
|
||||
regionId: numberValue(raw.regionId ?? raw.region_id),
|
||||
regionName: stringValue(raw.regionName ?? raw.region_name),
|
||||
policyId: numberValue(raw.policyId ?? raw.policy_id),
|
||||
policyName: stringValue(raw.policyName ?? raw.policy_name),
|
||||
levelNo: numberValue(raw.levelNo ?? raw.level_no),
|
||||
incomeUsdMinor: numberValue(raw.incomeUsdMinor ?? raw.income_usd_minor),
|
||||
incomeUsd: stringValue(raw.incomeUsd ?? raw.income_usd),
|
||||
salaryUsdMinorTarget: numberValue(raw.salaryUsdMinorTarget ?? raw.salary_usd_minor_target),
|
||||
salaryUsdTarget: stringValue(raw.salaryUsdTarget ?? raw.salary_usd_target),
|
||||
salaryUsdMinorDelta: numberValue(raw.salaryUsdMinorDelta ?? raw.salary_usd_minor_delta),
|
||||
salaryUsdDelta: stringValue(raw.salaryUsdDelta ?? raw.salary_usd_delta),
|
||||
settledLevelNo: numberValue(raw.settledLevelNo ?? raw.settled_level_no),
|
||||
settledSalaryUsdMinor: numberValue(raw.settledSalaryUsdMinor ?? raw.settled_salary_usd_minor),
|
||||
settledSalaryUsd: stringValue(raw.settledSalaryUsd ?? raw.settled_salary_usd),
|
||||
lastSettledIncomeUsdMinor: numberValue(raw.lastSettledIncomeUsdMinor ?? raw.last_settled_income_usd_minor),
|
||||
lastSettledIncomeUsd: stringValue(raw.lastSettledIncomeUsd ?? raw.last_settled_income_usd),
|
||||
monthClosedAtMs: numberValue(raw.monthClosedAtMs ?? raw.month_closed_at_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTeamRecord(raw: RawTeamRecord): TeamSalarySettlementRecordDto {
|
||||
const user = asRecord(raw.user);
|
||||
// 金额字段同时保留 minor 和格式化字符串;表格展示用字符串,后续导出/统计可用 minor。
|
||||
return {
|
||||
settlementId: stringValue(raw.settlementId ?? raw.settlement_id),
|
||||
commandId: stringValue(raw.commandId ?? raw.command_id),
|
||||
transactionId: stringValue(raw.transactionId ?? raw.transaction_id),
|
||||
policyType: (stringValue(raw.policyType ?? raw.policy_type) || "bd") as "bd" | "admin",
|
||||
triggerMode: stringValue(raw.triggerMode ?? raw.trigger_mode),
|
||||
userId: stringValue(raw.userId ?? raw.user_id),
|
||||
user: normalizeSettlementUser(user),
|
||||
cycleKey: stringValue(raw.cycleKey ?? raw.cycle_key),
|
||||
regionId: numberValue(raw.regionId ?? raw.region_id),
|
||||
policyId: numberValue(raw.policyId ?? raw.policy_id),
|
||||
levelNo: numberValue(raw.levelNo ?? raw.level_no),
|
||||
incomeUsdMinor: numberValue(raw.incomeUsdMinor ?? raw.income_usd_minor),
|
||||
incomeUsd: stringValue(raw.incomeUsd ?? raw.income_usd),
|
||||
salaryUsdMinorDelta: numberValue(raw.salaryUsdMinorDelta ?? raw.salary_usd_minor_delta),
|
||||
salaryUsdDelta: stringValue(raw.salaryUsdDelta ?? raw.salary_usd_delta),
|
||||
status: stringValue(raw.status) || "succeeded",
|
||||
reason: stringValue(raw.reason),
|
||||
createdAtMs: numberValue(raw.createdAtMs ?? raw.created_at_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTeamSettleResult(raw: RawTeamSettleResult): TeamSalarySettleResult {
|
||||
// 批量结算结果保留 records,结算后需要立即展示或定位 settlement_id 时无需再次查列表。
|
||||
return {
|
||||
policyType: (stringValue(raw.policyType ?? raw.policy_type) || "bd") as "bd" | "admin",
|
||||
cycleKey: stringValue(raw.cycleKey ?? raw.cycle_key),
|
||||
requestedCount: numberValue(raw.requestedCount ?? raw.requested_count),
|
||||
processedCount: numberValue(raw.processedCount ?? raw.processed_count),
|
||||
successCount: numberValue(raw.successCount ?? raw.success_count),
|
||||
skippedCount: numberValue(raw.skippedCount ?? raw.skipped_count),
|
||||
failureCount: numberValue(raw.failureCount ?? raw.failure_count),
|
||||
records: arrayValue(raw.records).map((item) => normalizeTeamRecord(asRecord(item) as RawTeamRecord)),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTeamPolicy(item: RawTeamPolicy): TeamSalaryPolicyDto {
|
||||
return {
|
||||
id: numberValue(item.id),
|
||||
appCode: stringValue(item.appCode ?? item.app_code),
|
||||
policyType: (stringValue(item.policyType ?? item.policy_type) || "bd") as "bd" | "admin",
|
||||
name: stringValue(item.name),
|
||||
regionId: numberValue(item.regionId ?? item.region_id),
|
||||
status: stringValue(item.status) || "disabled",
|
||||
settlementTriggerMode: stringValue(item.settlementTriggerMode ?? item.settlement_trigger_mode) || "automatic",
|
||||
effectiveFromMs: numberValue(item.effectiveFromMs ?? item.effective_from_ms),
|
||||
effectiveToMs: numberValue(item.effectiveToMs ?? item.effective_to_ms),
|
||||
createdByAdminId: numberValue(item.createdByAdminId ?? item.created_by_admin_id),
|
||||
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
|
||||
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||
levels: arrayValue(item.levels).map(normalizeTeamLevel),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTeamLevel(raw: unknown): TeamSalaryPolicyLevelDto {
|
||||
const item = asRecord(raw) as RawTeamLevel;
|
||||
return {
|
||||
id: numberValue(item.id),
|
||||
policyId: numberValue(item.policyId ?? item.policy_id),
|
||||
level: numberValue(item.level),
|
||||
thresholdUsd: stringValue(item.thresholdUsd ?? item.threshold_usd),
|
||||
ratePercent: stringValue(item.ratePercent ?? item.rate_percent),
|
||||
salaryUsd: stringValue(item.salaryUsd ?? item.salary_usd),
|
||||
status: stringValue(item.status) || "active",
|
||||
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
|
||||
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLevel(raw: unknown): HostAgencyPolicyLevelDto {
|
||||
const item = asRecord(raw) as RawLevel;
|
||||
// 金额字段保持字符串,避免前端 Number 格式化吞掉小数位或引入精度问题。
|
||||
return {
|
||||
id: numberValue(item.id),
|
||||
policyId: numberValue(item.policyId ?? item.policy_id),
|
||||
level: numberValue(item.level),
|
||||
requiredDiamonds: numberValue(item.requiredDiamonds ?? item.required_diamonds),
|
||||
hostSalaryUsd: stringValue(item.hostSalaryUsd ?? item.host_salary_usd),
|
||||
hostCoinReward: numberValue(item.hostCoinReward ?? item.host_coin_reward),
|
||||
agencySalaryUsd: stringValue(item.agencySalaryUsd ?? item.agency_salary_usd),
|
||||
totalSalaryUsd: stringValue(item.totalSalaryUsd ?? item.total_salary_usd),
|
||||
status: stringValue(item.status) || "active",
|
||||
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
|
||||
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function arrayValue(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown) {
|
||||
// 列表展示允许缺省值兜底为 0;真正的必填和正数校验在表单 schema 与后端 service 完成。
|
||||
const number = Number(value || 0);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
@ -0,0 +1,253 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import styles from "@/features/host-agency-policy/host-agency-policy.module.css";
|
||||
|
||||
const settlementModeOptions = [
|
||||
["daily", "日结"],
|
||||
["half_month", "半月结算"],
|
||||
];
|
||||
|
||||
const settlementTriggerOptions = [
|
||||
["automatic", "自动结算"],
|
||||
["manual", "手动结算"],
|
||||
];
|
||||
|
||||
export function HostAgencyPolicyDrawer({ page }) {
|
||||
const saving = page.loadingAction === "policy-create" || page.loadingAction === "policy-update";
|
||||
const disabled = saving || (!page.editingPolicy ? !page.abilities.canCreate : !page.abilities.canUpdate);
|
||||
return (
|
||||
<SideDrawer
|
||||
actions={
|
||||
<>
|
||||
<Button disabled={saving} onClick={page.closeDrawer}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={disabled || page.loadingRegions} type="submit" variant="primary">
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
as="form"
|
||||
className={styles.policyDrawer}
|
||||
contentClassName={styles.drawerBody}
|
||||
open={page.drawerOpen}
|
||||
title={page.editingPolicy ? "编辑工资政策" : "新增工资政策"}
|
||||
width="wide"
|
||||
onClose={saving ? undefined : page.closeDrawer}
|
||||
onSubmit={page.submitPolicy}
|
||||
>
|
||||
<PolicyBaseFields disabled={disabled} page={page} />
|
||||
<PolicyLevelEditor disabled={disabled} page={page} />
|
||||
</SideDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyBaseFields({ disabled, page }) {
|
||||
const form = page.form;
|
||||
const setForm = (patch) => page.setForm((current) => ({ ...current, ...patch }));
|
||||
return (
|
||||
<section className={[styles.formSection, styles.baseSection].join(" ")}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h3>基础配置</h3>
|
||||
</div>
|
||||
<div className={styles.formGrid}>
|
||||
<TextField
|
||||
required
|
||||
className={styles.fieldWide}
|
||||
disabled={disabled}
|
||||
label="政策名称"
|
||||
size="small"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm({ name: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
select
|
||||
className={styles.fieldWide}
|
||||
disabled={disabled || page.loadingRegions}
|
||||
label="适用区域"
|
||||
size="small"
|
||||
value={form.regionId}
|
||||
onChange={(event) => setForm({ regionId: event.target.value })}
|
||||
>
|
||||
{page.regionOptions.map((region) => (
|
||||
<MenuItem key={region.value} value={region.value}>
|
||||
{region.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
required
|
||||
select
|
||||
className={styles.fieldCompact}
|
||||
disabled={disabled}
|
||||
label="结算方式"
|
||||
size="small"
|
||||
value={form.settlementMode}
|
||||
onChange={(event) => setForm({ settlementMode: event.target.value })}
|
||||
>
|
||||
{settlementModeOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
required
|
||||
select
|
||||
className={styles.fieldCompact}
|
||||
disabled={disabled}
|
||||
label="触发方式"
|
||||
size="small"
|
||||
value={form.settlementTriggerMode}
|
||||
onChange={(event) => setForm({ settlementTriggerMode: event.target.value })}
|
||||
>
|
||||
{settlementTriggerOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
required
|
||||
className={styles.fieldCompact}
|
||||
disabled={disabled}
|
||||
label="金币转钻石比例"
|
||||
size="small"
|
||||
value={form.giftCoinToDiamondRatio}
|
||||
onChange={(event) => setForm({ giftCoinToDiamondRatio: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
className={styles.fieldCompact}
|
||||
disabled={disabled}
|
||||
label="剩余钻石转美元比例"
|
||||
size="small"
|
||||
value={form.residualDiamondToUsdRate}
|
||||
onChange={(event) => setForm({ residualDiamondToUsdRate: event.target.value })}
|
||||
/>
|
||||
<TimeRangeFilter
|
||||
className={styles.timeRangeField}
|
||||
disabled={disabled}
|
||||
label="生效时间"
|
||||
value={{ endMs: form.effectiveTo, startMs: form.effectiveFrom }}
|
||||
onChange={(range) => setForm({ effectiveFrom: range.startMs, effectiveTo: range.endMs })}
|
||||
/>
|
||||
<div className={styles.enabledField}>
|
||||
<span className={styles.toggleLabel}>政策状态</span>
|
||||
<AdminSwitch
|
||||
checked={form.enabled}
|
||||
checkedLabel="启用"
|
||||
disabled={disabled}
|
||||
label="政策状态"
|
||||
uncheckedLabel="停用"
|
||||
onChange={(event) => setForm({ enabled: event.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyLevelEditor({ disabled, page }) {
|
||||
return (
|
||||
<section className={[styles.formSection, styles.levelSection].join(" ")}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h3>等级配置</h3>
|
||||
<Button
|
||||
className={styles.addLevelButton}
|
||||
disabled={disabled}
|
||||
startIcon={<Add fontSize="small" />}
|
||||
onClick={page.addLevel}
|
||||
>
|
||||
添加等级
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.levelTable}>
|
||||
<div className={styles.levelHeader}>
|
||||
<span>等级</span>
|
||||
<span>所需钻石</span>
|
||||
<span>主播美元</span>
|
||||
<span>金币奖励</span>
|
||||
<span>代理美元</span>
|
||||
<span>状态</span>
|
||||
<span />
|
||||
</div>
|
||||
<div className={styles.levelList}>
|
||||
{page.form.levels.map((level, index) => (
|
||||
<div className={styles.levelRow} key={`${level.level}-${index}`}>
|
||||
<TextField
|
||||
required
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
type="number"
|
||||
value={level.level}
|
||||
onChange={(event) =>
|
||||
page.updateLevel(index, {
|
||||
level: event.target.value,
|
||||
sortOrder: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
type="number"
|
||||
value={level.requiredDiamonds}
|
||||
onChange={(event) => page.updateLevel(index, { requiredDiamonds: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
value={level.hostSalaryUsd}
|
||||
onChange={(event) => page.updateLevel(index, { hostSalaryUsd: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
type="number"
|
||||
value={level.hostCoinReward}
|
||||
onChange={(event) => page.updateLevel(index, { hostCoinReward: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
value={level.agencySalaryUsd}
|
||||
onChange={(event) => page.updateLevel(index, { agencySalaryUsd: event.target.value })}
|
||||
/>
|
||||
<AdminSwitch
|
||||
checked={level.status === "active"}
|
||||
checkedLabel="启用"
|
||||
disabled={disabled}
|
||||
label={`等级 ${level.level || index + 1} 状态`}
|
||||
uncheckedLabel="停用"
|
||||
onChange={(event) =>
|
||||
page.updateLevel(index, { status: event.target.checked ? "active" : "disabled" })
|
||||
}
|
||||
/>
|
||||
<IconButton
|
||||
disabled={disabled || page.form.levels.length <= 1}
|
||||
label="删除等级"
|
||||
sx={{ height: 34, minHeight: 34, width: 34 }}
|
||||
onClick={() => page.removeLevel(index)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,216 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import styles from "@/features/host-agency-policy/host-agency-policy.module.css";
|
||||
|
||||
const settlementTriggerOptions = [
|
||||
["automatic", "自动结算"],
|
||||
["manual", "手动结算"],
|
||||
];
|
||||
|
||||
export function TeamSalaryPolicyDrawer({ page }) {
|
||||
const saving = page.loadingAction === "team-policy-create" || page.loadingAction === "team-policy-update";
|
||||
const disabled = saving || (!page.editingPolicy ? !page.abilities.canTeamCreate : !page.abilities.canTeamUpdate);
|
||||
return (
|
||||
<SideDrawer
|
||||
actions={
|
||||
<>
|
||||
<Button disabled={saving} onClick={page.closeDrawer}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={disabled || page.loadingRegions} type="submit" variant="primary">
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
as="form"
|
||||
className={styles.policyDrawer}
|
||||
contentClassName={styles.drawerBody}
|
||||
open={page.drawerOpen}
|
||||
title={
|
||||
page.editingPolicy
|
||||
? `编辑${policyTitle(page.policyType)}政策`
|
||||
: `新增${policyTitle(page.policyType)}政策`
|
||||
}
|
||||
width="wide"
|
||||
onClose={saving ? undefined : page.closeDrawer}
|
||||
onSubmit={page.submitPolicy}
|
||||
>
|
||||
<TeamPolicyBaseFields disabled={disabled} page={page} />
|
||||
<TeamPolicyLevelEditor disabled={disabled} page={page} />
|
||||
</SideDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamPolicyBaseFields({ disabled, page }) {
|
||||
const form = page.form;
|
||||
const setForm = (patch) => page.setForm((current) => ({ ...current, ...patch }));
|
||||
return (
|
||||
<section className={[styles.formSection, styles.baseSection].join(" ")}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h3>基础配置</h3>
|
||||
</div>
|
||||
<div className={styles.formGrid}>
|
||||
<TextField
|
||||
required
|
||||
className={styles.fieldWide}
|
||||
disabled={disabled}
|
||||
label="政策名称"
|
||||
size="small"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm({ name: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
select
|
||||
className={styles.fieldWide}
|
||||
disabled={disabled || page.loadingRegions}
|
||||
label="适用区域"
|
||||
size="small"
|
||||
value={form.regionId}
|
||||
onChange={(event) => setForm({ regionId: event.target.value })}
|
||||
>
|
||||
{page.regionOptions.map((region) => (
|
||||
<MenuItem key={region.value} value={region.value}>
|
||||
{region.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
required
|
||||
select
|
||||
className={styles.fieldCompact}
|
||||
disabled={disabled}
|
||||
label="触发方式"
|
||||
size="small"
|
||||
value={form.settlementTriggerMode}
|
||||
onChange={(event) => setForm({ settlementTriggerMode: event.target.value })}
|
||||
>
|
||||
{settlementTriggerOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TimeRangeFilter
|
||||
className={styles.timeRangeField}
|
||||
disabled={disabled}
|
||||
label="生效时间"
|
||||
value={{ endMs: form.effectiveTo, startMs: form.effectiveFrom }}
|
||||
onChange={(range) => setForm({ effectiveFrom: range.startMs, effectiveTo: range.endMs })}
|
||||
/>
|
||||
<div className={styles.enabledField}>
|
||||
<span className={styles.toggleLabel}>政策状态</span>
|
||||
<AdminSwitch
|
||||
checked={form.enabled}
|
||||
checkedLabel="启用"
|
||||
disabled={disabled}
|
||||
label="政策状态"
|
||||
uncheckedLabel="停用"
|
||||
onChange={(event) => setForm({ enabled: event.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamPolicyLevelEditor({ disabled, page }) {
|
||||
return (
|
||||
<section className={[styles.formSection, styles.levelSection].join(" ")}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h3>等级配置</h3>
|
||||
<Button
|
||||
className={styles.addLevelButton}
|
||||
disabled={disabled}
|
||||
startIcon={<Add fontSize="small" />}
|
||||
onClick={page.addLevel}
|
||||
>
|
||||
添加等级
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.levelTable}>
|
||||
<div className={styles.teamLevelHeader}>
|
||||
<span>等级</span>
|
||||
<span>{thresholdLabel(page.policyType)}</span>
|
||||
<span>比例%</span>
|
||||
<span>{policyTitle(page.policyType)}美元</span>
|
||||
<span>状态</span>
|
||||
<span />
|
||||
</div>
|
||||
<div className={styles.levelList}>
|
||||
{page.form.levels.map((level, index) => (
|
||||
<div className={styles.teamLevelRow} key={`${level.level}-${index}`}>
|
||||
<TextField
|
||||
required
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
type="number"
|
||||
value={level.level}
|
||||
onChange={(event) =>
|
||||
page.updateLevel(index, {
|
||||
level: event.target.value,
|
||||
sortOrder: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
value={level.thresholdUsd}
|
||||
onChange={(event) => page.updateLevel(index, { thresholdUsd: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
value={level.ratePercent}
|
||||
onChange={(event) => page.updateLevel(index, { ratePercent: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
value={level.salaryUsd}
|
||||
onChange={(event) => page.updateLevel(index, { salaryUsd: event.target.value })}
|
||||
/>
|
||||
<AdminSwitch
|
||||
checked={level.status === "active"}
|
||||
checkedLabel="启用"
|
||||
disabled={disabled}
|
||||
label={`等级 ${level.level || index + 1} 状态`}
|
||||
uncheckedLabel="停用"
|
||||
onChange={(event) =>
|
||||
page.updateLevel(index, { status: event.target.checked ? "active" : "disabled" })
|
||||
}
|
||||
/>
|
||||
<IconButton
|
||||
disabled={disabled || page.form.levels.length <= 1}
|
||||
label="删除等级"
|
||||
sx={{ height: 34, minHeight: 34, width: 34 }}
|
||||
onClick={() => page.removeLevel(index)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function policyTitle(policyType) {
|
||||
return policyType === "bd" ? "BD" : "Admin";
|
||||
}
|
||||
|
||||
function thresholdLabel(policyType) {
|
||||
return policyType === "bd" ? "下属主播工资" : "BD工资";
|
||||
}
|
||||
362
src/features/host-agency-policy/hooks/useHostAgencyPolicyPage.js
Normal file
362
src/features/host-agency-policy/hooks/useHostAgencyPolicyPage.js
Normal file
@ -0,0 +1,362 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { toPageQuery } from "@/shared/api/query";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import {
|
||||
createHostAgencyPolicy,
|
||||
deleteHostAgencyPolicy,
|
||||
listHostAgencyPolicies,
|
||||
publishHostAgencyPolicy,
|
||||
updateHostAgencyPolicy,
|
||||
} from "@/features/host-agency-policy/api";
|
||||
import { useHostAgencyPolicyAbilities } from "@/features/host-agency-policy/permissions.js";
|
||||
import { hostAgencyPolicyFormSchema } from "@/features/host-agency-policy/schema";
|
||||
|
||||
const pageSize = 50;
|
||||
|
||||
// 产品文档给出的 Lalu 首版等级表:每项分别是等级、钻石门槛、主播累计美元、主播累计金币奖励、代理累计美元。
|
||||
// 这些值只用于“新建政策”的表单预填,编辑已有政策时始终以后端返回的配置为准。
|
||||
const defaultLevels = [
|
||||
[1, 350000, "1.5", 90000, "0.5"],
|
||||
[2, 700000, "3", 198000, "0.8"],
|
||||
[3, 1180000, "4", 350000, "1.3"],
|
||||
[4, 1770000, "6", 531000, "1.6"],
|
||||
[5, 2660000, "9", 800000, "2.5"],
|
||||
[6, 4000000, "13", 1200000, "4"],
|
||||
[7, 6000000, "20", 1800000, "6"],
|
||||
[8, 9000000, "30", 2700000, "9"],
|
||||
[9, 13500000, "45", 4050000, "13"],
|
||||
[10, 20260000, "67", 6078000, "19"],
|
||||
[11, 30390000, "100", 9200000, "29"],
|
||||
[12, 45590000, "152", 13600000, "43"],
|
||||
[13, 68380000, "182", 25000000, "65"],
|
||||
[14, 102580000, "342", 30700000, "97"],
|
||||
[15, 153870000, "513", 46000000, "146"],
|
||||
[16, 230800000, "768", 69200000, "219"],
|
||||
[17, 346200000, "1151", 103800000, "328"],
|
||||
[18, 519310000, "1727", 155700000, "493"],
|
||||
[19, 778970000, "2589", 233700000, "739"],
|
||||
[20, 1168450000, "3884", 350500000, "1108"],
|
||||
[21, 1752680000, "5826", 525800000, "1165"],
|
||||
[22, 2629030000, "8739", 788700000, "2494"],
|
||||
[23, 3943540000, "13109", 1183000000, "3741"],
|
||||
[24, 5915310000, "19664", 1774500000, "5611"],
|
||||
];
|
||||
|
||||
const emptyForm = (policy = null) => ({
|
||||
// 后端用 epoch ms 保存生效范围;表单里用空字符串表示“未设置”,方便时间控件展示占位。
|
||||
effectiveFrom: normalizeTimeMs(policy?.effectiveFromMs),
|
||||
effectiveTo: normalizeTimeMs(policy?.effectiveToMs),
|
||||
// 新建默认停用,运营保存草稿后再手动启用,避免半配置状态影响结算匹配。
|
||||
enabled: policy ? policy.status === "active" : false,
|
||||
giftCoinToDiamondRatio: policy?.giftCoinToDiamondRatio || "1",
|
||||
// 编辑时保留后端等级;新建时一次铺满 24 个默认等级,减少运营逐级录入成本。
|
||||
levels: policy?.levels?.length ? policy.levels.map(levelToForm) : defaultLevels.map(defaultLevelToForm),
|
||||
name: policy?.name || "Lalu Host & Agency Salary Policy",
|
||||
regionId: policy?.regionId ? String(policy.regionId) : "",
|
||||
residualDiamondToUsdRate: policy?.residualDiamondToUsdRate || "0",
|
||||
settlementMode: policy?.settlementMode || "daily",
|
||||
settlementTriggerMode: policy?.settlementTriggerMode || "automatic",
|
||||
});
|
||||
|
||||
export function useHostAgencyPolicyPage() {
|
||||
const abilities = useHostAgencyPolicyAbilities();
|
||||
const { showToast } = useToast();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [query, setQuery] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [settlementMode, setSettlementMode] = useState("");
|
||||
const [settlementTriggerMode, setSettlementTriggerMode] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [form, setForm] = useState(() => emptyForm());
|
||||
const [editingPolicy, setEditingPolicy] = useState(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
// 列表筛选和表单状态分开维护,避免打开抽屉编辑时污染当前列表查询条件。
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
keyword: query,
|
||||
region_id: regionId,
|
||||
settlement_mode: settlementMode,
|
||||
settlement_trigger_mode: settlementTriggerMode,
|
||||
status,
|
||||
}),
|
||||
[query, regionId, settlementMode, settlementTriggerMode, status],
|
||||
);
|
||||
const result = usePaginatedQuery({
|
||||
errorMessage: "加载工资政策失败",
|
||||
fetcher: listHostAgencyPolicies,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
// queryKey 带上规整后的分页参数,筛选项变化时能稳定触发重新请求。
|
||||
queryKey: ["host-agency-policy", toPageQuery({ ...filters, page, pageSize })],
|
||||
});
|
||||
const regionLabels = useMemo(
|
||||
() =>
|
||||
regionOptions.reduce((labels, item) => {
|
||||
// 后端只返回 region_id,页面展示名称依赖区域配置接口做本地映射。
|
||||
labels[Number(item.regionId)] = item.label;
|
||||
return labels;
|
||||
}, {}),
|
||||
[regionOptions],
|
||||
);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingPolicy(null);
|
||||
setForm(emptyForm());
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (policy) => {
|
||||
setEditingPolicy(policy);
|
||||
setForm(emptyForm(policy));
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const closeDrawer = () => {
|
||||
setDrawerOpen(false);
|
||||
setEditingPolicy(null);
|
||||
};
|
||||
|
||||
const submitPolicy = async (event) => {
|
||||
event.preventDefault();
|
||||
const editing = Boolean(editingPolicy?.id);
|
||||
await runAction(
|
||||
editing ? "policy-update" : "policy-create",
|
||||
editing ? "工资政策已更新" : "工资政策已创建",
|
||||
async () => {
|
||||
// 保存前先跑前端 schema,尽早拦截等级递增和金额格式问题;后端仍保留同款强校验。
|
||||
const payload = buildPayload(parseForm(hostAgencyPolicyFormSchema, form));
|
||||
if (editing) {
|
||||
await updateHostAgencyPolicy(editingPolicy.id, payload);
|
||||
} else {
|
||||
await createHostAgencyPolicy(payload);
|
||||
}
|
||||
closeDrawer();
|
||||
setForm(emptyForm());
|
||||
await result.reload();
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const removePolicy = async (policy) => {
|
||||
if (!policy?.id || !abilities.canDelete) {
|
||||
return;
|
||||
}
|
||||
await runAction(`policy-delete-${policy.id}`, "工资政策已删除", async () => {
|
||||
await deleteHostAgencyPolicy(policy.id);
|
||||
await result.reload();
|
||||
});
|
||||
};
|
||||
|
||||
const publishPolicy = async (policy) => {
|
||||
if (!policy?.id || !abilities.canPublish) {
|
||||
return;
|
||||
}
|
||||
await runAction(`policy-publish-${policy.id}`, "工资政策已发布到结算运行表", async () => {
|
||||
// 发布是后台草稿配置到 wallet 运行快照的唯一入口,结算任务只读取发布后的运行表。
|
||||
await publishHostAgencyPolicy(policy.id);
|
||||
await result.reload();
|
||||
});
|
||||
};
|
||||
|
||||
const togglePolicyStatus = async (policy, nextEnabled = policy.status !== "active") => {
|
||||
if (!policy?.id || !abilities.canUpdate || (policy.status === "active") === nextEnabled) {
|
||||
return;
|
||||
}
|
||||
await runAction(`policy-status-${policy.id}`, nextEnabled ? "工资政策已启用" : "工资政策已停用", async () => {
|
||||
// 启停只改变 status,其余字段走完整 payload,确保后端 PUT 整包替换时不会丢等级配置。
|
||||
await updateHostAgencyPolicy(policy.id, buildPayload({ ...emptyForm(policy), enabled: nextEnabled }));
|
||||
await result.reload();
|
||||
});
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setRegionId("");
|
||||
setStatus("");
|
||||
setSettlementMode("");
|
||||
setSettlementTriggerMode("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const updateLevel = (index, patch) => {
|
||||
// 等级表使用受控数组,单格编辑只更新当前行,保存时再统一排序和校验。
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
levels: current.levels.map((level, levelIndex) => (levelIndex === index ? { ...level, ...patch } : level)),
|
||||
}));
|
||||
};
|
||||
|
||||
const addLevel = () => {
|
||||
setForm((current) => {
|
||||
const last = current.levels[current.levels.length - 1];
|
||||
const nextLevel = Number(last?.level || current.levels.length) + 1;
|
||||
// 新增等级沿用上一行金额作为起点,运营只需要调整差异字段;最终递增规则仍由 schema/backend 保底。
|
||||
return {
|
||||
...current,
|
||||
levels: [
|
||||
...current.levels,
|
||||
{
|
||||
agencySalaryUsd: last?.agencySalaryUsd || "0",
|
||||
hostCoinReward: last?.hostCoinReward || "0",
|
||||
hostSalaryUsd: last?.hostSalaryUsd || "0",
|
||||
level: String(nextLevel),
|
||||
requiredDiamonds: "",
|
||||
sortOrder: String(nextLevel),
|
||||
status: "active",
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const removeLevel = (index) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
levels: current.levels.filter((_, levelIndex) => levelIndex !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
// loadingAction 带资源 ID,删除/启停这类行内操作不会把整页按钮全部锁死。
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await submitter();
|
||||
showToast(successMessage, "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
addLevel,
|
||||
changeQuery: (value) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
},
|
||||
changeRegionId: (value) => {
|
||||
setRegionId(value);
|
||||
setPage(1);
|
||||
},
|
||||
changeSettlementMode: (value) => {
|
||||
setSettlementMode(value);
|
||||
setPage(1);
|
||||
},
|
||||
changeSettlementTriggerMode: (value) => {
|
||||
setSettlementTriggerMode(value);
|
||||
setPage(1);
|
||||
},
|
||||
changeStatus: (value) => {
|
||||
setStatus(value);
|
||||
setPage(1);
|
||||
},
|
||||
closeDrawer,
|
||||
data: result.data,
|
||||
drawerOpen,
|
||||
editingPolicy,
|
||||
error: result.error,
|
||||
form,
|
||||
loading: result.loading,
|
||||
loadingAction,
|
||||
loadingRegions,
|
||||
openCreate,
|
||||
openEdit,
|
||||
page,
|
||||
publishPolicy,
|
||||
query,
|
||||
regionId,
|
||||
regionLabels,
|
||||
regionOptions,
|
||||
reload: result.reload,
|
||||
removeLevel,
|
||||
removePolicy,
|
||||
resetFilters,
|
||||
setForm,
|
||||
setPage,
|
||||
settlementMode,
|
||||
settlementTriggerMode,
|
||||
status,
|
||||
submitPolicy,
|
||||
togglePolicyStatus,
|
||||
updateLevel,
|
||||
};
|
||||
}
|
||||
|
||||
function levelToForm(level) {
|
||||
// 接口层已经把 snake_case 转成 camelCase,这里只负责把数值转成输入框友好的字符串。
|
||||
return {
|
||||
agencySalaryUsd: level.agencySalaryUsd || "0",
|
||||
hostCoinReward: String(level.hostCoinReward || 0),
|
||||
hostSalaryUsd: level.hostSalaryUsd || "0",
|
||||
level: String(level.level || ""),
|
||||
requiredDiamonds: String(level.requiredDiamonds || ""),
|
||||
sortOrder: String(level.sortOrder || level.level || ""),
|
||||
status: level.status || "active",
|
||||
};
|
||||
}
|
||||
|
||||
function defaultLevelToForm([level, requiredDiamonds, hostSalaryUsd, hostCoinReward, agencySalaryUsd]) {
|
||||
// 默认模板中的工资和奖励都是累计值,不是本等级增量;实际差额由后续结算任务计算。
|
||||
return {
|
||||
agencySalaryUsd,
|
||||
hostCoinReward: String(hostCoinReward),
|
||||
hostSalaryUsd,
|
||||
level: String(level),
|
||||
requiredDiamonds: String(requiredDiamonds),
|
||||
sortOrder: String(level),
|
||||
status: "active",
|
||||
};
|
||||
}
|
||||
|
||||
function buildPayload(form) {
|
||||
// 后端接口使用 snake_case;这里是唯一的表单 -> API payload 转换边界。
|
||||
return {
|
||||
effective_from_ms: timeValueToMs(form.effectiveFrom),
|
||||
effective_to_ms: timeValueToMs(form.effectiveTo),
|
||||
gift_coin_to_diamond_ratio: String(form.giftCoinToDiamondRatio || "1").trim(),
|
||||
levels: form.levels
|
||||
.map((level) => ({
|
||||
// 保存等级时统一转 number,避免字符串数字进入后端后造成弱类型歧义。
|
||||
agency_salary_usd: String(level.agencySalaryUsd || "0").trim(),
|
||||
host_coin_reward: Number(level.hostCoinReward || 0),
|
||||
host_salary_usd: String(level.hostSalaryUsd || "0").trim(),
|
||||
level: Number(level.level || 0),
|
||||
required_diamonds: Number(level.requiredDiamonds || 0),
|
||||
sort_order: Number(level.sortOrder || level.level || 0),
|
||||
status: level.status || "active",
|
||||
}))
|
||||
// 后端也会排序校验,前端先排序可以让保存后的返回和当前表单顺序更一致。
|
||||
.sort((left, right) => left.level - right.level),
|
||||
name: String(form.name || "").trim(),
|
||||
region_id: Number(form.regionId || 0),
|
||||
residual_diamond_to_usd_rate: String(form.residualDiamondToUsdRate || "0").trim(),
|
||||
settlement_mode: form.settlementMode || "daily",
|
||||
// 触发方式会被发布到 wallet 运行表;manual 表示后续只能从工资结算页人工触发。
|
||||
settlement_trigger_mode: form.settlementTriggerMode || "automatic",
|
||||
status: form.enabled ? "active" : "disabled",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTimeMs(value) {
|
||||
const ms = Number(value || 0);
|
||||
return Number.isFinite(ms) && ms > 0 ? ms : "";
|
||||
}
|
||||
|
||||
function timeValueToMs(value) {
|
||||
if (value === "" || value === null || value === undefined) {
|
||||
return 0;
|
||||
}
|
||||
const number = Number(value);
|
||||
if (Number.isFinite(number) && number > 0) {
|
||||
return number;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
321
src/features/host-agency-policy/hooks/useTeamSalaryPolicyPage.js
Normal file
321
src/features/host-agency-policy/hooks/useTeamSalaryPolicyPage.js
Normal file
@ -0,0 +1,321 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { toPageQuery } from "@/shared/api/query";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import {
|
||||
createTeamSalaryPolicy,
|
||||
deleteTeamSalaryPolicy,
|
||||
listTeamSalaryPolicies,
|
||||
updateTeamSalaryPolicy,
|
||||
} from "@/features/host-agency-policy/api";
|
||||
import { useHostAgencyPolicyAbilities } from "@/features/host-agency-policy/permissions.js";
|
||||
import { teamSalaryPolicyFormSchema } from "@/features/host-agency-policy/schema";
|
||||
|
||||
const pageSize = 50;
|
||||
|
||||
const defaultLevelsByType = {
|
||||
// BD 门槛来自下属 Agency 管理主播的 Host Salary 合计,工资字段是达到该门槛后的 BD 累计应得。
|
||||
bd: [
|
||||
[1, "100", "8", "8"],
|
||||
[2, "200", "8", "16"],
|
||||
[3, "500", "8", "40"],
|
||||
[4, "1200", "8", "96"],
|
||||
[5, "2000", "8", "160"],
|
||||
[6, "5000", "9", "450"],
|
||||
[7, "10000", "10", "1000"],
|
||||
],
|
||||
// Admin 门槛来自下属 BD 的 BD Salary 合计,工资字段是达到该门槛后的 Admin 累计应得。
|
||||
admin: [
|
||||
[1, "500", "20", "100"],
|
||||
[2, "1000", "20", "200"],
|
||||
[3, "3000", "20", "600"],
|
||||
[4, "5000", "20", "1000"],
|
||||
[5, "10000", "20", "2000"],
|
||||
[6, "20000", "20", "4000"],
|
||||
],
|
||||
};
|
||||
|
||||
const emptyForm = (policyType, policy = null) => ({
|
||||
effectiveFrom: normalizeTimeMs(policy?.effectiveFromMs),
|
||||
effectiveTo: normalizeTimeMs(policy?.effectiveToMs),
|
||||
enabled: policy ? policy.status === "active" : false,
|
||||
levels: policy?.levels?.length
|
||||
? policy.levels.map(levelToForm)
|
||||
: defaultLevelsByType[policyType].map(defaultLevelToForm),
|
||||
name: policy?.name || defaultPolicyName(policyType),
|
||||
policyType,
|
||||
regionId: policy?.regionId ? String(policy.regionId) : "",
|
||||
settlementTriggerMode: policy?.settlementTriggerMode || "automatic",
|
||||
});
|
||||
|
||||
export function useTeamSalaryPolicyPage(policyType) {
|
||||
const abilities = useHostAgencyPolicyAbilities();
|
||||
const { showToast } = useToast();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [query, setQuery] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [settlementTriggerMode, setSettlementTriggerMode] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [form, setForm] = useState(() => emptyForm(policyType));
|
||||
const [editingPolicy, setEditingPolicy] = useState(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
keyword: query,
|
||||
policy_type: policyType,
|
||||
region_id: regionId,
|
||||
settlement_trigger_mode: settlementTriggerMode,
|
||||
status,
|
||||
}),
|
||||
[policyType, query, regionId, settlementTriggerMode, status],
|
||||
);
|
||||
const result = usePaginatedQuery({
|
||||
errorMessage: policyType === "bd" ? "加载 BD 工资政策失败" : "加载 Admin 工资政策失败",
|
||||
fetcher: listTeamSalaryPolicies,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["team-salary-policy", policyType, toPageQuery({ ...filters, page, pageSize })],
|
||||
});
|
||||
const regionLabels = useMemo(
|
||||
() =>
|
||||
regionOptions.reduce((labels, item) => {
|
||||
labels[Number(item.regionId)] = item.label;
|
||||
return labels;
|
||||
}, {}),
|
||||
[regionOptions],
|
||||
);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingPolicy(null);
|
||||
setForm(emptyForm(policyType));
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (policy) => {
|
||||
setEditingPolicy(policy);
|
||||
setForm(emptyForm(policyType, policy));
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const closeDrawer = () => {
|
||||
setDrawerOpen(false);
|
||||
setEditingPolicy(null);
|
||||
};
|
||||
|
||||
const submitPolicy = async (event) => {
|
||||
event.preventDefault();
|
||||
const editing = Boolean(editingPolicy?.id);
|
||||
await runAction(
|
||||
editing ? "team-policy-update" : "team-policy-create",
|
||||
editing ? "工资政策已更新" : "工资政策已创建",
|
||||
async () => {
|
||||
const payload = buildPayload(parseForm(teamSalaryPolicyFormSchema, form));
|
||||
if (editing) {
|
||||
await updateTeamSalaryPolicy(editingPolicy.id, payload);
|
||||
} else {
|
||||
await createTeamSalaryPolicy(payload);
|
||||
}
|
||||
closeDrawer();
|
||||
setForm(emptyForm(policyType));
|
||||
await result.reload();
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const removePolicy = async (policy) => {
|
||||
if (!policy?.id || !abilities.canTeamDelete) {
|
||||
return;
|
||||
}
|
||||
await runAction(`team-policy-delete-${policy.id}`, "工资政策已删除", async () => {
|
||||
await deleteTeamSalaryPolicy(policyType, policy.id);
|
||||
await result.reload();
|
||||
});
|
||||
};
|
||||
|
||||
const togglePolicyStatus = async (policy, nextEnabled = policy.status !== "active") => {
|
||||
if (!policy?.id || !abilities.canTeamUpdate || (policy.status === "active") === nextEnabled) {
|
||||
return;
|
||||
}
|
||||
await runAction(
|
||||
`team-policy-status-${policy.id}`,
|
||||
nextEnabled ? "工资政策已启用" : "工资政策已停用",
|
||||
async () => {
|
||||
await updateTeamSalaryPolicy(
|
||||
policy.id,
|
||||
buildPayload({ ...emptyForm(policyType, policy), enabled: nextEnabled }),
|
||||
);
|
||||
await result.reload();
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setRegionId("");
|
||||
setStatus("");
|
||||
setSettlementTriggerMode("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const updateLevel = (index, patch) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
levels: current.levels.map((level, levelIndex) => (levelIndex === index ? { ...level, ...patch } : level)),
|
||||
}));
|
||||
};
|
||||
|
||||
const addLevel = () => {
|
||||
setForm((current) => {
|
||||
const last = current.levels[current.levels.length - 1];
|
||||
const nextLevel = Number(last?.level || current.levels.length) + 1;
|
||||
return {
|
||||
...current,
|
||||
levels: [
|
||||
...current.levels,
|
||||
{
|
||||
level: String(nextLevel),
|
||||
ratePercent: last?.ratePercent || "0",
|
||||
salaryUsd: last?.salaryUsd || "0",
|
||||
sortOrder: String(nextLevel),
|
||||
status: "active",
|
||||
thresholdUsd: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const removeLevel = (index) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
levels: current.levels.filter((_, levelIndex) => levelIndex !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await submitter();
|
||||
showToast(successMessage, "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
addLevel,
|
||||
changeQuery: (value) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
},
|
||||
changeRegionId: (value) => {
|
||||
setRegionId(value);
|
||||
setPage(1);
|
||||
},
|
||||
changeSettlementTriggerMode: (value) => {
|
||||
setSettlementTriggerMode(value);
|
||||
setPage(1);
|
||||
},
|
||||
changeStatus: (value) => {
|
||||
setStatus(value);
|
||||
setPage(1);
|
||||
},
|
||||
closeDrawer,
|
||||
data: result.data,
|
||||
drawerOpen,
|
||||
editingPolicy,
|
||||
error: result.error,
|
||||
form,
|
||||
loading: result.loading,
|
||||
loadingAction,
|
||||
loadingRegions,
|
||||
openCreate,
|
||||
openEdit,
|
||||
page,
|
||||
policyType,
|
||||
query,
|
||||
regionId,
|
||||
regionLabels,
|
||||
regionOptions,
|
||||
reload: result.reload,
|
||||
removeLevel,
|
||||
removePolicy,
|
||||
resetFilters,
|
||||
setForm,
|
||||
setPage,
|
||||
settlementTriggerMode,
|
||||
status,
|
||||
submitPolicy,
|
||||
togglePolicyStatus,
|
||||
updateLevel,
|
||||
};
|
||||
}
|
||||
|
||||
function levelToForm(level) {
|
||||
return {
|
||||
level: String(level.level || ""),
|
||||
ratePercent: level.ratePercent || "0",
|
||||
salaryUsd: level.salaryUsd || "0",
|
||||
sortOrder: String(level.sortOrder || level.level || ""),
|
||||
status: level.status || "active",
|
||||
thresholdUsd: level.thresholdUsd || "0",
|
||||
};
|
||||
}
|
||||
|
||||
function defaultLevelToForm([level, thresholdUsd, ratePercent, salaryUsd]) {
|
||||
return {
|
||||
level: String(level),
|
||||
ratePercent,
|
||||
salaryUsd,
|
||||
sortOrder: String(level),
|
||||
status: "active",
|
||||
thresholdUsd,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPayload(form) {
|
||||
return {
|
||||
effective_from_ms: timeValueToMs(form.effectiveFrom),
|
||||
effective_to_ms: timeValueToMs(form.effectiveTo),
|
||||
levels: form.levels
|
||||
.map((level) => ({
|
||||
level: Number(level.level || 0),
|
||||
rate_percent: String(level.ratePercent || "0").trim(),
|
||||
salary_usd: String(level.salaryUsd || "0").trim(),
|
||||
sort_order: Number(level.sortOrder || level.level || 0),
|
||||
status: level.status || "active",
|
||||
threshold_usd: String(level.thresholdUsd || "0").trim(),
|
||||
}))
|
||||
.sort((left, right) => left.level - right.level),
|
||||
name: String(form.name || "").trim(),
|
||||
policy_type: form.policyType,
|
||||
region_id: Number(form.regionId || 0),
|
||||
settlement_trigger_mode: form.settlementTriggerMode || "automatic",
|
||||
status: form.enabled ? "active" : "disabled",
|
||||
};
|
||||
}
|
||||
|
||||
function defaultPolicyName(policyType) {
|
||||
return policyType === "bd" ? "Lalu BD Salary Policy" : "Lalu Admin Salary Policy";
|
||||
}
|
||||
|
||||
function normalizeTimeMs(value) {
|
||||
const ms = Number(value || 0);
|
||||
return Number.isFinite(ms) && ms > 0 ? ms : "";
|
||||
}
|
||||
|
||||
function timeValueToMs(value) {
|
||||
if (value === "" || value === null || value === undefined) {
|
||||
return 0;
|
||||
}
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number > 0 ? number : 0;
|
||||
}
|
||||
296
src/features/host-agency-policy/host-agency-policy.module.css
Normal file
296
src/features/host-agency-policy/host-agency-policy.module.css
Normal file
@ -0,0 +1,296 @@
|
||||
.stack {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta {
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbarFilters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.inlineFilter {
|
||||
display: inline-flex;
|
||||
height: var(--control-height);
|
||||
min-width: 132px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.inlineFilter input {
|
||||
width: 96px;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.inlineFilter input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.policyTabsBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0 2px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.policyTab {
|
||||
min-height: 40px !important;
|
||||
padding-right: 18px !important;
|
||||
padding-left: 18px !important;
|
||||
font-weight: 700 !important;
|
||||
text-transform: none !important;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: inline-flex;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card-strong);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.identityText {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.primaryText {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.amountPositive {
|
||||
color: var(--success);
|
||||
font-weight: 760;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.policyDrawer {
|
||||
width: min(720px, calc(100vw - 40px)) !important;
|
||||
}
|
||||
|
||||
.drawerBody {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.formSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.baseSection {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.formGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.fieldWide {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
.fieldCompact {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.enabledField {
|
||||
display: flex;
|
||||
min-height: var(--control-height);
|
||||
grid-column: span 2;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.toggleLabel {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeRangeField {
|
||||
grid-column: span 4;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sectionHeader h3 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.levelSection {
|
||||
min-width: 0;
|
||||
padding-top: 16px;
|
||||
padding-bottom: 2px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.addLevelButton {
|
||||
height: 34px !important;
|
||||
min-height: 34px !important;
|
||||
padding-right: 10px !important;
|
||||
padding-left: 10px !important;
|
||||
}
|
||||
|
||||
.levelTable {
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.levelHeader,
|
||||
.levelRow {
|
||||
display: grid;
|
||||
min-width: 620px;
|
||||
grid-template-columns: 54px minmax(108px, 1fr) 82px minmax(104px, 1fr) 82px 64px 34px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.teamLevelHeader,
|
||||
.teamLevelRow {
|
||||
display: grid;
|
||||
min-width: 580px;
|
||||
grid-template-columns: 54px minmax(130px, 1fr) 86px minmax(108px, 1fr) 64px 34px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.levelHeader {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.teamLevelHeader {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.levelList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.levelRow :global(.MuiTextField-root),
|
||||
.levelRow :global(.MuiFormControl-root),
|
||||
.teamLevelRow :global(.MuiTextField-root),
|
||||
.teamLevelRow :global(.MuiFormControl-root) {
|
||||
--admin-control-height: 34px;
|
||||
--admin-control-font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.formGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.fieldWide,
|
||||
.fieldCompact,
|
||||
.timeRangeField,
|
||||
.enabledField {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.levelHeader,
|
||||
.teamLevelHeader {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.levelRow,
|
||||
.teamLevelRow {
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr)) 44px;
|
||||
}
|
||||
|
||||
.levelRow > :nth-child(2),
|
||||
.levelRow > :nth-child(4),
|
||||
.teamLevelRow > :nth-child(2),
|
||||
.teamLevelRow > :nth-child(4) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
360
src/features/host-agency-policy/pages/HostAgencyPolicyPage.jsx
Normal file
360
src/features/host-agency-policy/pages/HostAgencyPolicyPage.jsx
Normal file
@ -0,0 +1,360 @@
|
||||
import { useState } from "react";
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import CloudUploadOutlined from "@mui/icons-material/CloudUploadOutlined";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import Tab from "@mui/material/Tab";
|
||||
import Tabs from "@mui/material/Tabs";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterResetButton,
|
||||
AdminFilterSelect,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { HostAgencyPolicyDrawer } from "@/features/host-agency-policy/components/HostAgencyPolicyDrawer.jsx";
|
||||
import { useHostAgencyPolicyPage } from "@/features/host-agency-policy/hooks/useHostAgencyPolicyPage.js";
|
||||
import { TeamSalaryPolicyPanel } from "@/features/host-agency-policy/pages/TeamSalaryPolicyPanel.jsx";
|
||||
import styles from "@/features/host-agency-policy/host-agency-policy.module.css";
|
||||
|
||||
const statusOptions = [
|
||||
["", "全部状态"],
|
||||
["active", "启用"],
|
||||
["disabled", "停用"],
|
||||
];
|
||||
|
||||
const settlementModeOptions = [
|
||||
["", "全部结算"],
|
||||
["daily", "日结"],
|
||||
["half_month", "半月结算"],
|
||||
];
|
||||
|
||||
const settlementTriggerOptions = [
|
||||
["", "全部触发"],
|
||||
["automatic", "自动结算"],
|
||||
["manual", "手动结算"],
|
||||
];
|
||||
|
||||
const columnsBase = [
|
||||
{
|
||||
key: "policy",
|
||||
label: "政策",
|
||||
width: "minmax(260px, 1.2fr)",
|
||||
// DataTable 的 render 签名是 (item, rowIndex, context),这里显式保留 rowIndex 占位,避免把行号误当上下文。
|
||||
render: (item, _rowIndex, context) => <PolicyIdentity item={item} regionLabels={context.regionLabels} />,
|
||||
},
|
||||
{
|
||||
key: "settlement",
|
||||
label: "结算与比例",
|
||||
width: "minmax(190px, 0.9fr)",
|
||||
render: (item) => <SettlementSummary item={item} />,
|
||||
},
|
||||
{
|
||||
key: "levels",
|
||||
label: "等级",
|
||||
width: "minmax(180px, 0.8fr)",
|
||||
render: (item) => <LevelSummary item={item} />,
|
||||
},
|
||||
{
|
||||
key: "effective",
|
||||
label: "生效时间",
|
||||
width: "minmax(220px, 1fr)",
|
||||
render: (item) => <EffectiveTime item={item} />,
|
||||
},
|
||||
{
|
||||
key: "publish",
|
||||
label: "发布",
|
||||
width: "minmax(180px, 0.8fr)",
|
||||
render: (item) => <PublishSummary item={item} />,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(110px, 0.5fr)",
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
label: "更新时间",
|
||||
width: "minmax(170px, 0.75fr)",
|
||||
render: (item) => formatMillis(item.updatedAtMs || item.createdAtMs),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "156px",
|
||||
resizable: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function HostAgencyPolicyPage() {
|
||||
const [activeTab, setActiveTab] = useState("host");
|
||||
return (
|
||||
<AdminListPage>
|
||||
<div className={styles.policyTabsBar}>
|
||||
<Tabs value={activeTab} onChange={(_, value) => setActiveTab(value)}>
|
||||
<Tab className={styles.policyTab} label="Host 政策" value="host" />
|
||||
<Tab className={styles.policyTab} label="BD 政策" value="bd" />
|
||||
<Tab className={styles.policyTab} label="Admin 政策" value="admin" />
|
||||
</Tabs>
|
||||
</div>
|
||||
{activeTab === "host" ? <HostPolicyPanel /> : <TeamSalaryPolicyPanel policyType={activeTab} />}
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function HostPolicyPanel() {
|
||||
const page = useHostAgencyPolicyPage();
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
// 列表筛选仍是单区域筛选,因为当前阶段后端政策模型只绑定一个 region_id。
|
||||
const regionFilterOptions = [["", "全部区域"], ...page.regionOptions.map((region) => [region.value, region.label])];
|
||||
const columns = columnsBase.map((column) => {
|
||||
// 过滤控件挂在列定义里,保持 DataTable 的列宽、筛选和渲染配置集中声明。
|
||||
if (column.key === "policy") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索政策名称",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "settlement") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
// 同一列同时承载节奏和触发方式;列头筛选默认筛结算节奏,页面工具栏提供触发方式筛选。
|
||||
options: settlementModeOptions,
|
||||
placeholder: "搜索结算",
|
||||
value: page.settlementMode,
|
||||
onChange: page.changeSettlementMode,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "status") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: statusOptions,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
render: (item) => <PolicyStatusSwitch item={item} page={page} />,
|
||||
};
|
||||
}
|
||||
if (column.key === "actions") {
|
||||
return {
|
||||
...column,
|
||||
render: (item) => <PolicyActions item={item} page={page} />,
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
page.abilities.canCreate ? (
|
||||
<AdminActionIconButton label="新增工资政策" primary onClick={page.openCreate}>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null
|
||||
}
|
||||
filters={
|
||||
<div className={styles.toolbarFilters}>
|
||||
<AdminFilterSelect
|
||||
label="区域"
|
||||
options={regionFilterOptions}
|
||||
value={page.regionId}
|
||||
onChange={page.changeRegionId}
|
||||
/>
|
||||
<AdminFilterSelect
|
||||
label="触发方式"
|
||||
options={settlementTriggerOptions}
|
||||
value={page.settlementTriggerMode}
|
||||
onChange={page.changeSettlementTriggerMode}
|
||||
/>
|
||||
<AdminFilterResetButton
|
||||
disabled={
|
||||
!page.query &&
|
||||
!page.regionId &&
|
||||
!page.status &&
|
||||
!page.settlementMode &&
|
||||
!page.settlementTriggerMode
|
||||
}
|
||||
onClick={page.resetFilters}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
context={{ regionLabels: page.regionLabels }}
|
||||
items={items}
|
||||
minWidth="1420px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page: page.page,
|
||||
pageSize: page.data.pageSize || 50,
|
||||
total,
|
||||
onPageChange: page.setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(item) => item.id}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<HostAgencyPolicyDrawer page={page} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyIdentity({ item, regionLabels }) {
|
||||
// regionLabels 来自区域配置接口,后端政策列表只返回 region_id,避免列表接口重复拼区域名称。
|
||||
const labels = regionLabels || {};
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.name}>{item.name}</span>
|
||||
<span className={styles.meta}>{labels[item.regionId] || `区域 ${item.regionId}`}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettlementSummary({ item }) {
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span>{settlementLabel(item.settlementMode)}</span>
|
||||
<span className={styles.meta}>{settlementTriggerLabel(item.settlementTriggerMode)}</span>
|
||||
<span className={styles.meta}>金币转钻石 {item.giftCoinToDiamondRatio || "1"}</span>
|
||||
<span className={styles.meta}>剩余钻石转美元 {item.residualDiamondToUsdRate || "0"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelSummary({ item }) {
|
||||
const first = item.levels?.[0];
|
||||
const last = item.levels?.[item.levels.length - 1];
|
||||
// 等级摘要展示门槛范围和最高累计主播工资,帮助运营快速识别政策梯度是否录错。
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span>{item.levels?.length || 0} 个等级</span>
|
||||
<span className={styles.meta}>
|
||||
{first && last
|
||||
? `${formatNumber(first.requiredDiamonds)} - ${formatNumber(last.requiredDiamonds)}`
|
||||
: "-"}
|
||||
</span>
|
||||
<span className={styles.meta}>{last ? `最高工资 $${last.hostSalaryUsd}` : ""}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EffectiveTime({ item }) {
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span>{item.effectiveFromMs ? formatMillis(item.effectiveFromMs) : "立即生效"}</span>
|
||||
<span className={styles.meta}>{item.effectiveToMs ? formatMillis(item.effectiveToMs) : "长期有效"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PublishSummary({ item }) {
|
||||
const status = item.publishStatus || "draft";
|
||||
// publish_error 只在失败态显示,避免成功发布后的旧错误文案干扰运营判断。
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span className={`status-badge status-badge--${publishTone(status)}`}>{publishLabel(status)}</span>
|
||||
<span className={styles.meta}>{item.publishedAtMs ? formatMillis(item.publishedAtMs) : "未发布"}</span>
|
||||
{status === "failed" && item.publishError ? <span className={styles.meta}>{item.publishError}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyStatusSwitch({ item, page }) {
|
||||
const disabled = !page.abilities.canUpdate || page.loadingAction === `policy-status-${item.id}`;
|
||||
// 行内启停最终仍走 update 接口,后端会重新执行同区域时间段冲突校验。
|
||||
return (
|
||||
<AdminSwitch
|
||||
checked={item.status === "active"}
|
||||
checkedLabel="启用"
|
||||
disabled={disabled}
|
||||
label="切换政策状态"
|
||||
uncheckedLabel="停用"
|
||||
onChange={(event) => page.togglePolicyStatus(item, event.target.checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyActions({ item, page }) {
|
||||
return (
|
||||
<div className={styles.rowActions}>
|
||||
<AdminActionIconButton
|
||||
disabled={!page.abilities.canPublish || page.loadingAction === `policy-publish-${item.id}`}
|
||||
label="发布工资政策"
|
||||
onClick={() => page.publishPolicy(item)}
|
||||
>
|
||||
<CloudUploadOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="编辑工资政策"
|
||||
onClick={() => page.openEdit(item)}
|
||||
>
|
||||
<EditOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton
|
||||
disabled={!page.abilities.canDelete || page.loadingAction === `policy-delete-${item.id}`}
|
||||
label="删除工资政策"
|
||||
onClick={() => page.removePolicy(item)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function settlementLabel(value) {
|
||||
return settlementModeOptions.find(([mode]) => mode === value)?.[1] || value || "-";
|
||||
}
|
||||
|
||||
function settlementTriggerLabel(value) {
|
||||
return settlementTriggerOptions.find(([mode]) => mode === value)?.[1] || value || "-";
|
||||
}
|
||||
|
||||
function publishLabel(value) {
|
||||
return (
|
||||
{
|
||||
draft: "草稿",
|
||||
failed: "发布失败",
|
||||
published: "已发布",
|
||||
}[value] ||
|
||||
value ||
|
||||
"-"
|
||||
);
|
||||
}
|
||||
|
||||
function publishTone(value) {
|
||||
return (
|
||||
{
|
||||
draft: "warning",
|
||||
failed: "danger",
|
||||
published: "running",
|
||||
}[value] || "stopped"
|
||||
);
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||
}
|
||||
@ -0,0 +1,881 @@
|
||||
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
|
||||
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import Tab from "@mui/material/Tab";
|
||||
import Tabs from "@mui/material/Tabs";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
listHostSalarySettlements,
|
||||
listTeamSalarySettlementPending,
|
||||
listTeamSalarySettlementRecords,
|
||||
settleTeamSalary,
|
||||
} from "@/features/host-agency-policy/api";
|
||||
import { useHostAgencyPolicyAbilities } from "@/features/host-agency-policy/permissions.js";
|
||||
import {
|
||||
AdminFilterResetButton,
|
||||
AdminFilterSelect,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import styles from "@/features/host-agency-policy/host-agency-policy.module.css";
|
||||
|
||||
const pageSize = 50;
|
||||
|
||||
const settlementTypeOptions = [
|
||||
["", "全部类型"],
|
||||
["daily", "日结"],
|
||||
["half_month", "半月结算"],
|
||||
["month_end", "月底清算"],
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
["", "全部状态"],
|
||||
["succeeded", "成功"],
|
||||
["skipped", "跳过"],
|
||||
["failed", "失败"],
|
||||
];
|
||||
|
||||
const policyTypeOptions = [
|
||||
["bd", "BD"],
|
||||
["admin", "Admin"],
|
||||
];
|
||||
|
||||
const policyTypeAllOptions = [
|
||||
["", "全部角色"],
|
||||
...policyTypeOptions,
|
||||
];
|
||||
|
||||
const triggerOptions = [
|
||||
["automatic", "自动政策"],
|
||||
["manual", "手动政策"],
|
||||
];
|
||||
|
||||
const triggerAllOptions = [
|
||||
["", "全部触发"],
|
||||
...triggerOptions,
|
||||
];
|
||||
|
||||
export function HostSalarySettlementPage() {
|
||||
const [activeTab, setActiveTab] = useState("team-pending");
|
||||
return (
|
||||
<AdminListPage>
|
||||
<div className={styles.policyTabsBar}>
|
||||
<Tabs value={activeTab} onChange={(_, value) => setActiveTab(value)}>
|
||||
<Tab className={styles.policyTab} label="待结算" value="team-pending" />
|
||||
<Tab className={styles.policyTab} label="BD/Admin 记录" value="team-records" />
|
||||
<Tab className={styles.policyTab} label="Host/Agency 记录" value="host-records" />
|
||||
</Tabs>
|
||||
</div>
|
||||
{activeTab === "team-pending" ? (
|
||||
<TeamPendingPanel />
|
||||
) : activeTab === "team-records" ? (
|
||||
<TeamRecordsPanel />
|
||||
) : (
|
||||
<HostRecordsPanel />
|
||||
)}
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
// HostRecordsPanel 保留原 Host/Agency 结算记录查询,避免新增 BD/Admin tab 后丢失主播工资查账入口。
|
||||
function HostRecordsPanel() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [cycleKey, setCycleKey] = useState("");
|
||||
const [userId, setUserId] = useState("");
|
||||
const [agencyOwnerUserId, setAgencyOwnerUserId] = useState("");
|
||||
const [settlementType, setSettlementType] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
||||
|
||||
// filters 使用后端 snake_case 字段,直接进入 apiRequest query,减少额外字段映射层。
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
agency_owner_user_id: agencyOwnerUserId,
|
||||
cycle_key: cycleKey,
|
||||
end_at_ms: timeRange.endMs || "",
|
||||
settlement_type: settlementType,
|
||||
start_at_ms: timeRange.startMs || "",
|
||||
status,
|
||||
user_id: userId,
|
||||
}),
|
||||
[agencyOwnerUserId, cycleKey, settlementType, status, timeRange.endMs, timeRange.startMs, userId],
|
||||
);
|
||||
const query = usePaginatedQuery({
|
||||
errorMessage: "获取工资结算记录失败",
|
||||
fetcher: listHostSalarySettlements,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["host-salary-settlements", filters, page],
|
||||
});
|
||||
const data = query.data || { items: [], page, pageSize, total: 0 };
|
||||
const items = data.items || [];
|
||||
const total = data.total || 0;
|
||||
|
||||
const changeFilter = (setter) => (value) => {
|
||||
// 任一筛选变化都回到第一页,避免翻页状态导致新条件下看起来“没有数据”。
|
||||
setter(value);
|
||||
setPage(1);
|
||||
};
|
||||
const changeTimeRange = (nextRange) => {
|
||||
setTimeRange(nextRange);
|
||||
setPage(1);
|
||||
};
|
||||
const resetFilters = () => {
|
||||
setAgencyOwnerUserId("");
|
||||
setCycleKey("");
|
||||
setSettlementType("");
|
||||
setStatus("");
|
||||
setTimeRange({ endMs: "", startMs: "" });
|
||||
setUserId("");
|
||||
setPage(1);
|
||||
};
|
||||
const tableColumns = hostRecordColumns.map((column) => {
|
||||
// 列筛选挂在 DataTable 列定义上,保持筛选 UI 与列语义一致。
|
||||
if (column.key === "host") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "主播用户 ID",
|
||||
value: userId,
|
||||
onChange: changeFilter(setUserId),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "agency") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "代理用户 ID",
|
||||
value: agencyOwnerUserId,
|
||||
onChange: changeFilter(setAgencyOwnerUserId),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "cycle") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "周期 key",
|
||||
value: cycleKey,
|
||||
onChange: changeFilter(setCycleKey),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "status") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: statusOptions,
|
||||
value: status,
|
||||
onChange: changeFilter(setStatus),
|
||||
}),
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
const hasFilters =
|
||||
agencyOwnerUserId ||
|
||||
cycleKey ||
|
||||
settlementType ||
|
||||
status ||
|
||||
timeRange.startMs ||
|
||||
timeRange.endMs ||
|
||||
userId;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<div className={styles.toolbarFilters}>
|
||||
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
|
||||
<AdminFilterSelect
|
||||
label="结算类型"
|
||||
options={settlementTypeOptions}
|
||||
value={settlementType}
|
||||
onChange={changeFilter(setSettlementType)}
|
||||
/>
|
||||
<AdminFilterResetButton disabled={!hasFilters} onClick={resetFilters} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
items={items}
|
||||
minWidth="1420px"
|
||||
pagination={paginationProps(data, page, total, setPage)}
|
||||
rowKey={(item) => item.settlementId}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// TeamPendingPanel 是人工结算入口:列表金额来自后端候选计算,批量按钮只提交当前勾选用户。
|
||||
function TeamPendingPanel() {
|
||||
const abilities = useHostAgencyPolicyAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [page, setPage] = useState(1);
|
||||
const [policyType, setPolicyType] = useState("bd");
|
||||
const [triggerMode, setTriggerMode] = useState("automatic");
|
||||
const [cycleKey, setCycleKey] = useState(defaultCycleKey);
|
||||
const [countryCode, setCountryCode] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [selected, setSelected] = useState(() => new Set());
|
||||
const [settling, setSettling] = useState(false);
|
||||
|
||||
// BD/Admin 默认查看 automatic 上月周期;手动政策也可切换 triggerMode 后人工补发。
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
country_code: countryCode,
|
||||
cycle_key: cycleKey,
|
||||
policy_type: policyType,
|
||||
region_id: regionId,
|
||||
trigger_mode: triggerMode,
|
||||
}),
|
||||
[countryCode, cycleKey, policyType, regionId, triggerMode],
|
||||
);
|
||||
const query = usePaginatedQuery({
|
||||
errorMessage: "获取 BD/Admin 待结算列表失败",
|
||||
fetcher: listTeamSalarySettlementPending,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["team-salary-pending", filters, page],
|
||||
});
|
||||
const data = query.data || { items: [], page, pageSize, total: 0 };
|
||||
const items = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const allVisibleSelected = items.length > 0 && items.every((item) => selected.has(teamPendingKey(item)));
|
||||
|
||||
useEffect(() => {
|
||||
// 筛选条件变化后清空勾选,避免把上一批用户带入当前国家或周期的结算请求。
|
||||
setSelected(new Set());
|
||||
}, [filters]);
|
||||
|
||||
const changeFilter = (setter) => (value) => {
|
||||
// 国家/区域/周期变化后必须重算候选,不保留旧页码。
|
||||
setter(value);
|
||||
setPage(1);
|
||||
};
|
||||
const resetFilters = () => {
|
||||
setPolicyType("bd");
|
||||
setTriggerMode("automatic");
|
||||
setCycleKey(defaultCycleKey());
|
||||
setCountryCode("");
|
||||
setRegionId("");
|
||||
setPage(1);
|
||||
};
|
||||
const toggleOne = (item) => {
|
||||
setSelected((current) => {
|
||||
// 选择 key 包含角色、周期、区域和用户,防止同一用户跨区域或跨周期时互相污染。
|
||||
const next = new Set(current);
|
||||
const key = teamPendingKey(item);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const toggleVisible = () => {
|
||||
setSelected((current) => {
|
||||
// 表头复选框只操作当前页,避免用户在未看见的分页数据上误触批量结算。
|
||||
const next = new Set(current);
|
||||
items.forEach((item) => {
|
||||
const key = teamPendingKey(item);
|
||||
if (allVisibleSelected) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const settleSelected = async () => {
|
||||
// 只从当前页 items 中提取已勾选用户,服务端仍会再次按 user_ids 白名单过滤。
|
||||
const userIds = items
|
||||
.filter((item) => selected.has(teamPendingKey(item)))
|
||||
.map((item) => Number(item.userId))
|
||||
.filter((id) => Number.isFinite(id) && id > 0);
|
||||
if (!userIds.length) {
|
||||
return;
|
||||
}
|
||||
setSettling(true);
|
||||
try {
|
||||
// trigger_mode 随页面筛选提交,保证“手动政策”和“自动政策”不会交叉结算。
|
||||
const result = await settleTeamSalary({
|
||||
country_code: countryCode || undefined,
|
||||
cycle_key: cycleKey,
|
||||
policy_type: policyType,
|
||||
region_id: Number(regionId || 0) || undefined,
|
||||
trigger_mode: triggerMode,
|
||||
user_ids: userIds,
|
||||
});
|
||||
showToast({
|
||||
message: `结算完成:成功 ${result.successCount},跳过 ${result.skippedCount},失败 ${result.failureCount}`,
|
||||
severity: result.failureCount > 0 ? "warning" : "success",
|
||||
});
|
||||
setSelected(new Set());
|
||||
await query.reload();
|
||||
} catch (error) {
|
||||
showToast({ message: error?.message || "工资结算失败", severity: "error" });
|
||||
} finally {
|
||||
setSettling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const tableColumns = [
|
||||
{
|
||||
key: "select",
|
||||
label: (
|
||||
<Checkbox
|
||||
checked={allVisibleSelected}
|
||||
disabled={!items.length}
|
||||
indeterminate={selected.size > 0 && !allVisibleSelected}
|
||||
size="small"
|
||||
onChange={toggleVisible}
|
||||
/>
|
||||
),
|
||||
width: "64px",
|
||||
resizable: false,
|
||||
render: (item) => (
|
||||
<Checkbox checked={selected.has(teamPendingKey(item))} size="small" onChange={() => toggleOne(item)} />
|
||||
),
|
||||
},
|
||||
...teamPendingColumns,
|
||||
];
|
||||
// 默认值也纳入 hasFilters,这样运营能一键回到“BD + 自动政策 + 上月周期”的标准入口。
|
||||
const hasFilters = policyType !== "bd" || triggerMode !== "automatic" || cycleKey !== defaultCycleKey() || countryCode || regionId;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
<Button
|
||||
disabled={!abilities.canSettleSalary || settling || selected.size === 0}
|
||||
startIcon={<PlayArrowOutlined fontSize="small" />}
|
||||
variant="primary"
|
||||
onClick={settleSelected}
|
||||
>
|
||||
批量结算
|
||||
</Button>
|
||||
}
|
||||
filters={
|
||||
<div className={styles.toolbarFilters}>
|
||||
<AdminFilterSelect label="角色" options={policyTypeOptions} value={policyType} onChange={changeFilter(setPolicyType)} />
|
||||
<AdminFilterSelect label="政策触发" options={triggerOptions} value={triggerMode} onChange={changeFilter(setTriggerMode)} />
|
||||
<InlineFilter label="周期" placeholder="YYYY-MM" value={cycleKey} onChange={changeFilter(setCycleKey)} />
|
||||
<InlineFilter label="国家" placeholder="国家代码" value={countryCode} onChange={changeFilter(setCountryCode)} />
|
||||
<InlineFilter label="区域" placeholder="区域 ID" value={regionId} onChange={changeFilter(setRegionId)} />
|
||||
<AdminFilterResetButton disabled={!hasFilters} onClick={resetFilters} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
items={items}
|
||||
minWidth="1280px"
|
||||
pagination={paginationProps(data, page, total, setPage)}
|
||||
rowKey={teamPendingKey}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// TeamRecordsPanel 查询 BD/Admin 结算记录,和待结算页分开,避免查账筛选影响待结算批量操作。
|
||||
function TeamRecordsPanel() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [policyType, setPolicyType] = useState("");
|
||||
const [triggerMode, setTriggerMode] = useState("");
|
||||
const [cycleKey, setCycleKey] = useState("");
|
||||
const [countryCode, setCountryCode] = useState("");
|
||||
const [userId, setUserId] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
||||
|
||||
// 记录查询支持国家代码筛选;后端会转换为 region_id 集合过滤结算记录。
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
country_code: countryCode,
|
||||
cycle_key: cycleKey,
|
||||
end_at_ms: timeRange.endMs || "",
|
||||
policy_type: policyType,
|
||||
start_at_ms: timeRange.startMs || "",
|
||||
status,
|
||||
trigger_mode: triggerMode,
|
||||
user_id: userId,
|
||||
}),
|
||||
[countryCode, cycleKey, policyType, status, timeRange.endMs, timeRange.startMs, triggerMode, userId],
|
||||
);
|
||||
const query = usePaginatedQuery({
|
||||
errorMessage: "获取 BD/Admin 结算记录失败",
|
||||
fetcher: listTeamSalarySettlementRecords,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["team-salary-records", filters, page],
|
||||
});
|
||||
const data = query.data || { items: [], page, pageSize, total: 0 };
|
||||
const items = data.items || [];
|
||||
const total = data.total || 0;
|
||||
|
||||
const changeFilter = (setter) => (value) => {
|
||||
// 记录页所有筛选统一重置到第一页,和其它后台列表交互保持一致。
|
||||
setter(value);
|
||||
setPage(1);
|
||||
};
|
||||
const resetFilters = () => {
|
||||
setPolicyType("");
|
||||
setTriggerMode("");
|
||||
setCycleKey("");
|
||||
setCountryCode("");
|
||||
setStatus("");
|
||||
setTimeRange({ endMs: "", startMs: "" });
|
||||
setUserId("");
|
||||
setPage(1);
|
||||
};
|
||||
const tableColumns = teamRecordColumns.map((column) => {
|
||||
// 用户、周期、状态保留列内筛选;角色/触发方式/国家放顶部,便于组合查询。
|
||||
if (column.key === "user") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "用户 ID",
|
||||
value: userId,
|
||||
onChange: changeFilter(setUserId),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "cycle") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "周期 key",
|
||||
value: cycleKey,
|
||||
onChange: changeFilter(setCycleKey),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "status") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: statusOptions,
|
||||
value: status,
|
||||
onChange: changeFilter(setStatus),
|
||||
}),
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
const hasFilters =
|
||||
policyType ||
|
||||
triggerMode ||
|
||||
cycleKey ||
|
||||
countryCode ||
|
||||
status ||
|
||||
timeRange.startMs ||
|
||||
timeRange.endMs ||
|
||||
userId;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<div className={styles.toolbarFilters}>
|
||||
<TimeRangeFilter value={timeRange} onChange={(value) => { setTimeRange(value); setPage(1); }} />
|
||||
<AdminFilterSelect label="角色" options={policyTypeAllOptions} value={policyType} onChange={changeFilter(setPolicyType)} />
|
||||
<AdminFilterSelect label="触发" options={triggerAllOptions} value={triggerMode} onChange={changeFilter(setTriggerMode)} />
|
||||
<InlineFilter label="国家" placeholder="国家代码" value={countryCode} onChange={changeFilter(setCountryCode)} />
|
||||
<AdminFilterResetButton disabled={!hasFilters} onClick={resetFilters} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
items={items}
|
||||
minWidth="1280px"
|
||||
pagination={paginationProps(data, page, total, setPage)}
|
||||
rowKey={(item) => item.settlementId}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// hostRecordColumns 展示 Host/Agency 的原始结算事实,包括主播工资、金币奖励、代理工资和剩余折美元。
|
||||
const hostRecordColumns = [
|
||||
{
|
||||
key: "host",
|
||||
label: "主播",
|
||||
width: "minmax(240px, 1.1fr)",
|
||||
render: (item) => <UserCell fallbackId={item.userId} user={item.user} />,
|
||||
},
|
||||
{
|
||||
key: "agency",
|
||||
label: "代理",
|
||||
width: "minmax(220px, 1fr)",
|
||||
render: (item) => <UserCell fallbackId={item.agencyOwnerUserId} user={item.agencyOwner} />,
|
||||
},
|
||||
{
|
||||
key: "cycle",
|
||||
label: "周期",
|
||||
width: "minmax(170px, 0.8fr)",
|
||||
render: (item) => <CycleCell item={item} />,
|
||||
},
|
||||
{
|
||||
key: "level",
|
||||
label: "等级/钻石",
|
||||
width: "minmax(150px, 0.7fr)",
|
||||
render: (item) => <HostLevelCell item={item} />,
|
||||
},
|
||||
{
|
||||
key: "hostReward",
|
||||
label: "主播奖励",
|
||||
width: "minmax(170px, 0.8fr)",
|
||||
render: (item) => <HostRewardCell item={item} />,
|
||||
},
|
||||
{
|
||||
key: "agencyReward",
|
||||
label: "代理/剩余",
|
||||
width: "minmax(170px, 0.8fr)",
|
||||
render: (item) => <AgencyRewardCell item={item} />,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(120px, 0.55fr)",
|
||||
render: (item) => <StatusCell item={item} />,
|
||||
},
|
||||
{
|
||||
key: "createdAtMs",
|
||||
label: "时间",
|
||||
width: "minmax(170px, 0.8fr)",
|
||||
render: (item) => formatMillis(item.createdAtMs),
|
||||
},
|
||||
];
|
||||
|
||||
// teamPendingColumns 展示 BD/Admin 的“现在会发多少钱”和“此前已发到哪一档”。
|
||||
const teamPendingColumns = [
|
||||
{
|
||||
key: "user",
|
||||
label: "用户",
|
||||
width: "minmax(230px, 1fr)",
|
||||
render: (item) => <UserCell fallbackId={item.userId} user={item.user} />,
|
||||
},
|
||||
{
|
||||
key: "scope",
|
||||
label: "周期/区域",
|
||||
width: "minmax(180px, 0.8fr)",
|
||||
render: (item) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{roleLabel(item.policyType)} · {item.cycleKey || "-"}</span>
|
||||
<span className={styles.meta}>{item.regionName || `区域 ${item.regionId || "-"}`}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "income",
|
||||
label: "收入基数",
|
||||
width: "minmax(150px, 0.7fr)",
|
||||
render: (item) => <MoneyStack primary={item.incomeUsd} secondary="累计收入" />,
|
||||
},
|
||||
{
|
||||
key: "level",
|
||||
label: "政策/档位",
|
||||
width: "minmax(210px, 0.9fr)",
|
||||
render: (item) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{item.levelNo > 0 ? `等级 ${item.levelNo}` : "未达等级"}</span>
|
||||
<span className={styles.meta}>{item.policyName || `政策 ${item.policyId || "-"}`}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "salary",
|
||||
label: "待发工资",
|
||||
width: "minmax(170px, 0.8fr)",
|
||||
render: (item) => (
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.amountPositive}>{formatMoneyDelta(item.salaryUsdDelta)}</span>
|
||||
<span className={styles.meta}>累计目标 ${item.salaryUsdTarget || "0"}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "progress",
|
||||
label: "已结进度",
|
||||
width: "minmax(180px, 0.8fr)",
|
||||
render: (item) => (
|
||||
<div className={styles.stack}>
|
||||
<span>${item.settledSalaryUsd || "0"}</span>
|
||||
<span className={styles.meta}>已结等级 {item.settledLevelNo || 0}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// teamRecordColumns 展示已落库的 BD/Admin 结算记录;settlementId 通过 rowKey 保持唯一。
|
||||
const teamRecordColumns = [
|
||||
{
|
||||
key: "user",
|
||||
label: "用户",
|
||||
width: "minmax(230px, 1fr)",
|
||||
render: (item) => <UserCell fallbackId={item.userId} user={item.user} />,
|
||||
},
|
||||
{
|
||||
key: "cycle",
|
||||
label: "周期/角色",
|
||||
width: "minmax(170px, 0.8fr)",
|
||||
render: (item) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{item.cycleKey || "-"}</span>
|
||||
<span className={styles.meta}>{roleLabel(item.policyType)} · {triggerLabel(item.triggerMode)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "income",
|
||||
label: "收入基数",
|
||||
width: "minmax(150px, 0.7fr)",
|
||||
render: (item) => <MoneyStack primary={item.incomeUsd} secondary={`等级 ${item.levelNo || 0}`} />,
|
||||
},
|
||||
{
|
||||
key: "salary",
|
||||
label: "发放工资",
|
||||
width: "minmax(160px, 0.75fr)",
|
||||
render: (item) => <span className={styles.amountPositive}>{formatMoneyDelta(item.salaryUsdDelta)}</span>,
|
||||
},
|
||||
{
|
||||
key: "policy",
|
||||
label: "政策",
|
||||
width: "minmax(130px, 0.55fr)",
|
||||
render: (item) => `政策 ${item.policyId || "-"}`,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(120px, 0.55fr)",
|
||||
render: (item) => <StatusCell item={item} />,
|
||||
},
|
||||
{
|
||||
key: "createdAtMs",
|
||||
label: "时间",
|
||||
width: "minmax(170px, 0.8fr)",
|
||||
render: (item) => formatMillis(item.createdAtMs),
|
||||
},
|
||||
];
|
||||
|
||||
// InlineFilter 是本页面的轻量文本筛选控件,用于周期、国家代码、区域 ID 这类短字段。
|
||||
function InlineFilter({ label, onChange, placeholder, value }) {
|
||||
return (
|
||||
<label className={styles.inlineFilter}>
|
||||
<span>{label}</span>
|
||||
<input placeholder={placeholder} value={value} onChange={(event) => onChange(event.target.value)} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// UserCell 统一展示用户资料;没有资料时仍显示 user_id,确保查账可定位。
|
||||
function UserCell({ fallbackId, user }) {
|
||||
const idText = userIdText(user, fallbackId);
|
||||
if (!idText) {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.avatar}>
|
||||
{user?.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
|
||||
</span>
|
||||
<div className={styles.identityText}>
|
||||
<span className={styles.primaryText}>{user?.username || "-"}</span>
|
||||
<span className={styles.meta}>{idText}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// CycleCell 展示 Host/Agency 周期和结算类型。
|
||||
function CycleCell({ item }) {
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span>{item.cycleKey || "-"}</span>
|
||||
<span className={styles.meta}>{settlementTypeLabel(item.settlementType)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// HostLevelCell 展示主播档位和钻石累计,方便对照 Host 政策表。
|
||||
function HostLevelCell({ item }) {
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span>{item.levelNo > 0 ? `等级 ${item.levelNo}` : "未达等级"}</span>
|
||||
<span className={styles.meta}>{formatNumber(item.totalDiamonds)} 钻石</span>
|
||||
<span className={styles.meta}>政策 {item.policyId || "-"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// HostRewardCell 展示主播侧本次增量奖励。
|
||||
function HostRewardCell({ item }) {
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.amountPositive}>{formatMoneyDelta(item.hostSalaryUsdDelta)}</span>
|
||||
<span className={styles.meta}>金币 {formatSignedInteger(item.hostCoinRewardDelta)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// AgencyRewardCell 展示代理工资和月底剩余钻石折美元。
|
||||
function AgencyRewardCell({ item }) {
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.amountPositive}>{formatMoneyDelta(item.agencySalaryUsdDelta)}</span>
|
||||
<span className={styles.meta}>剩余折美元 {formatMoneyDelta(item.residualUsdDelta)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// MoneyStack 固定两行金额展示,避免表格列因文案变化跳动。
|
||||
function MoneyStack({ primary, secondary }) {
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span>${primary || "0"}</span>
|
||||
<span className={styles.meta}>{secondary}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// StatusCell 统一状态徽标,失败或跳过原因放在第二行。
|
||||
function StatusCell({ item }) {
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span className={`status-badge status-badge--${statusTone(item.status)}`}>{statusLabel(item.status)}</span>
|
||||
{item.reason ? <span className={styles.meta}>{item.reason}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// paginationProps 只在 total > 0 时渲染分页,空表保持更干净的空状态。
|
||||
function paginationProps(data, page, total, onPageChange) {
|
||||
return total > 0
|
||||
? {
|
||||
page,
|
||||
pageSize: data.pageSize || pageSize,
|
||||
total,
|
||||
onPageChange,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// teamPendingKey 绑定角色、周期、区域和用户,保证多维候选在前端选择集合里唯一。
|
||||
function teamPendingKey(item) {
|
||||
return `${item.policyType}:${item.cycleKey}:${item.regionId}:${item.userId}`;
|
||||
}
|
||||
|
||||
// userIdText 展示短 ID / 长 ID;筛选仍然用长 user_id。
|
||||
function userIdText(user, fallbackId) {
|
||||
const id = String(user?.userId || fallbackId || "").trim();
|
||||
if (!id || id === "0") {
|
||||
return "";
|
||||
}
|
||||
const display = String(user?.displayUserId || "").trim();
|
||||
return display ? `${display} / ${id}` : id;
|
||||
}
|
||||
|
||||
// defaultCycleKey 计算上一个 UTC 自然月,和后端 previousMonthCycleKey 保持同一周期口径。
|
||||
function defaultCycleKey() {
|
||||
const now = new Date();
|
||||
const firstOfMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
firstOfMonth.setUTCMonth(firstOfMonth.getUTCMonth() - 1);
|
||||
return `${firstOfMonth.getUTCFullYear()}-${String(firstOfMonth.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// settlementTypeLabel 把 Host/Agency 结算类型枚举转成运营文案。
|
||||
function settlementTypeLabel(value) {
|
||||
return (
|
||||
{
|
||||
daily: "日结",
|
||||
half_month: "半月结算",
|
||||
month_end: "月底清算",
|
||||
}[value] || value || "-"
|
||||
);
|
||||
}
|
||||
|
||||
// statusLabel 把通用结算状态枚举转成运营文案。
|
||||
function statusLabel(value) {
|
||||
return (
|
||||
{
|
||||
failed: "失败",
|
||||
skipped: "跳过",
|
||||
succeeded: "成功",
|
||||
}[value] || value || "-"
|
||||
);
|
||||
}
|
||||
|
||||
// statusTone 把业务状态映射为现有 status-badge 视觉语义。
|
||||
function statusTone(value) {
|
||||
return (
|
||||
{
|
||||
failed: "danger",
|
||||
skipped: "warning",
|
||||
succeeded: "running",
|
||||
}[value] || "stopped"
|
||||
);
|
||||
}
|
||||
|
||||
// roleLabel 把 policy_type 展示为工资角色名称。
|
||||
function roleLabel(value) {
|
||||
return value === "admin" ? "Admin" : value === "bd" ? "BD" : value || "-";
|
||||
}
|
||||
|
||||
// triggerLabel 把触发方式展示为自动/手动。
|
||||
function triggerLabel(value) {
|
||||
return value === "automatic" ? "自动" : value === "manual" ? "手动" : value || "-";
|
||||
}
|
||||
|
||||
// formatMoneyDelta 专门展示“本次增量”,正数带 +,0 保持中性。
|
||||
function formatMoneyDelta(value) {
|
||||
const number = Number(value || 0);
|
||||
if (!Number.isFinite(number) || number === 0) {
|
||||
return "$0.00";
|
||||
}
|
||||
const prefix = number > 0 ? "+$" : "-$";
|
||||
return `${prefix}${Math.abs(number).toFixed(2)}`;
|
||||
}
|
||||
|
||||
// formatSignedInteger 展示金币增量,正数带 +。
|
||||
function formatSignedInteger(value) {
|
||||
const number = Number(value || 0);
|
||||
if (!Number.isFinite(number) || number === 0) {
|
||||
return "0";
|
||||
}
|
||||
return `${number > 0 ? "+" : ""}${formatNumber(number)}`;
|
||||
}
|
||||
|
||||
// formatNumber 统一数字千分位,表格内不做本地状态计算。
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||
}
|
||||
271
src/features/host-agency-policy/pages/TeamSalaryPolicyPanel.jsx
Normal file
271
src/features/host-agency-policy/pages/TeamSalaryPolicyPanel.jsx
Normal file
@ -0,0 +1,271 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterResetButton,
|
||||
AdminFilterSelect,
|
||||
AdminListBody,
|
||||
AdminListToolbar,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { TeamSalaryPolicyDrawer } from "@/features/host-agency-policy/components/TeamSalaryPolicyDrawer.jsx";
|
||||
import { useTeamSalaryPolicyPage } from "@/features/host-agency-policy/hooks/useTeamSalaryPolicyPage.js";
|
||||
import styles from "@/features/host-agency-policy/host-agency-policy.module.css";
|
||||
|
||||
const statusOptions = [
|
||||
["", "全部状态"],
|
||||
["active", "启用"],
|
||||
["disabled", "停用"],
|
||||
];
|
||||
|
||||
const settlementTriggerOptions = [
|
||||
["", "全部触发"],
|
||||
["automatic", "自动结算"],
|
||||
["manual", "手动结算"],
|
||||
];
|
||||
|
||||
const columnsBase = [
|
||||
{
|
||||
key: "policy",
|
||||
label: "政策",
|
||||
width: "minmax(260px, 1.2fr)",
|
||||
render: (item, _rowIndex, context) => (
|
||||
<PolicyIdentity item={item} policyType={context.policyType} regionLabels={context.regionLabels} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "settlement",
|
||||
label: "触发与周期",
|
||||
width: "minmax(180px, 0.8fr)",
|
||||
render: (item) => <SettlementSummary item={item} />,
|
||||
},
|
||||
{
|
||||
key: "levels",
|
||||
label: "等级",
|
||||
width: "minmax(220px, 0.95fr)",
|
||||
render: (item, _rowIndex, context) => <LevelSummary item={item} policyType={context.policyType} />,
|
||||
},
|
||||
{
|
||||
key: "effective",
|
||||
label: "生效时间",
|
||||
width: "minmax(220px, 1fr)",
|
||||
render: (item) => <EffectiveTime item={item} />,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(110px, 0.5fr)",
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
label: "更新时间",
|
||||
width: "minmax(170px, 0.75fr)",
|
||||
render: (item) => formatMillis(item.updatedAtMs || item.createdAtMs),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "112px",
|
||||
resizable: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function TeamSalaryPolicyPanel({ policyType }) {
|
||||
const page = useTeamSalaryPolicyPage(policyType);
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const regionFilterOptions = [["", "全部区域"], ...page.regionOptions.map((region) => [region.value, region.label])];
|
||||
const columns = columnsBase.map((column) => {
|
||||
if (column.key === "policy") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索政策名称",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "settlement") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: settlementTriggerOptions,
|
||||
placeholder: "搜索触发方式",
|
||||
value: page.settlementTriggerMode,
|
||||
onChange: page.changeSettlementTriggerMode,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "status") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: statusOptions,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
render: (item) => <PolicyStatusSwitch item={item} page={page} />,
|
||||
};
|
||||
}
|
||||
if (column.key === "actions") {
|
||||
return {
|
||||
...column,
|
||||
render: (item) => <PolicyActions item={item} page={page} />,
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
page.abilities.canTeamCreate ? (
|
||||
<AdminActionIconButton
|
||||
label={`新增${policyTitle(policyType)}政策`}
|
||||
primary
|
||||
onClick={page.openCreate}
|
||||
>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null
|
||||
}
|
||||
filters={
|
||||
<div className={styles.toolbarFilters}>
|
||||
<AdminFilterSelect
|
||||
label="区域"
|
||||
options={regionFilterOptions}
|
||||
value={page.regionId}
|
||||
onChange={page.changeRegionId}
|
||||
/>
|
||||
<AdminFilterResetButton
|
||||
disabled={!page.query && !page.regionId && !page.status && !page.settlementTriggerMode}
|
||||
onClick={page.resetFilters}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
context={{ policyType, regionLabels: page.regionLabels }}
|
||||
items={items}
|
||||
minWidth="1240px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page: page.page,
|
||||
pageSize: page.data.pageSize || 50,
|
||||
total,
|
||||
onPageChange: page.setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(item) => item.id}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<TeamSalaryPolicyDrawer page={page} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyIdentity({ item, policyType, regionLabels }) {
|
||||
const labels = regionLabels || {};
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.name}>{item.name}</span>
|
||||
<span className={styles.meta}>
|
||||
{policyTitle(policyType)} · {labels[item.regionId] || `区域 ${item.regionId}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettlementSummary({ item }) {
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span>{settlementTriggerLabel(item.settlementTriggerMode)}</span>
|
||||
<span className={styles.meta}>完整自然月</span>
|
||||
<span className={styles.meta}>次月 5 日可结算</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelSummary({ item, policyType }) {
|
||||
const first = item.levels?.[0];
|
||||
const last = item.levels?.[item.levels.length - 1];
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span>{item.levels?.length || 0} 个等级</span>
|
||||
<span className={styles.meta}>
|
||||
{first && last ? `$${first.thresholdUsd} - $${last.thresholdUsd} ${thresholdUnit(policyType)}` : "-"}
|
||||
</span>
|
||||
<span className={styles.meta}>{last ? `最高工资 $${last.salaryUsd}` : ""}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EffectiveTime({ item }) {
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span>{item.effectiveFromMs ? formatMillis(item.effectiveFromMs) : "立即生效"}</span>
|
||||
<span className={styles.meta}>{item.effectiveToMs ? formatMillis(item.effectiveToMs) : "长期有效"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyStatusSwitch({ item, page }) {
|
||||
const disabled = !page.abilities.canTeamUpdate || page.loadingAction === `team-policy-status-${item.id}`;
|
||||
return (
|
||||
<AdminSwitch
|
||||
checked={item.status === "active"}
|
||||
checkedLabel="启用"
|
||||
disabled={disabled}
|
||||
label="切换政策状态"
|
||||
uncheckedLabel="停用"
|
||||
onChange={(event) => page.togglePolicyStatus(item, event.target.checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyActions({ item, page }) {
|
||||
return (
|
||||
<div className={styles.rowActions}>
|
||||
<AdminActionIconButton
|
||||
disabled={!page.abilities.canTeamUpdate}
|
||||
label="编辑工资政策"
|
||||
onClick={() => page.openEdit(item)}
|
||||
>
|
||||
<EditOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton
|
||||
disabled={!page.abilities.canTeamDelete || page.loadingAction === `team-policy-delete-${item.id}`}
|
||||
label="删除工资政策"
|
||||
onClick={() => page.removePolicy(item)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function policyTitle(policyType) {
|
||||
return policyType === "bd" ? "BD" : "Admin";
|
||||
}
|
||||
|
||||
function thresholdUnit(policyType) {
|
||||
return policyType === "bd" ? "主播工资" : "BD工资";
|
||||
}
|
||||
|
||||
function settlementTriggerLabel(value) {
|
||||
return settlementTriggerOptions.find(([mode]) => mode === value)?.[1] || value || "-";
|
||||
}
|
||||
20
src/features/host-agency-policy/permissions.js
Normal file
20
src/features/host-agency-policy/permissions.js
Normal file
@ -0,0 +1,20 @@
|
||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export function useHostAgencyPolicyAbilities() {
|
||||
const { can } = useAuth();
|
||||
|
||||
return {
|
||||
canCreate: can(PERMISSIONS.hostAgencyPolicyCreate),
|
||||
canDelete: can(PERMISSIONS.hostAgencyPolicyDelete),
|
||||
canPublish: can(PERMISSIONS.hostAgencyPolicyPublish),
|
||||
canTeamCreate: can(PERMISSIONS.teamSalaryPolicyCreate),
|
||||
canTeamDelete: can(PERMISSIONS.teamSalaryPolicyDelete),
|
||||
canTeamUpdate: can(PERMISSIONS.teamSalaryPolicyUpdate),
|
||||
canTeamView: can(PERMISSIONS.teamSalaryPolicyView),
|
||||
canSettleSalary: can(PERMISSIONS.hostSalarySettlementSettle),
|
||||
canViewSettlements: can(PERMISSIONS.hostSalarySettlementView),
|
||||
canUpdate: can(PERMISSIONS.hostAgencyPolicyUpdate),
|
||||
canView: can(PERMISSIONS.hostAgencyPolicyView),
|
||||
};
|
||||
}
|
||||
20
src/features/host-agency-policy/routes.js
Normal file
20
src/features/host-agency-policy/routes.js
Normal file
@ -0,0 +1,20 @@
|
||||
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export const hostAgencyPolicyRoutes = [
|
||||
{
|
||||
label: "工资政策",
|
||||
loader: () => import("./pages/HostAgencyPolicyPage.jsx").then((module) => module.HostAgencyPolicyPage),
|
||||
menuCode: MENU_CODES.hostAgencyPolicy,
|
||||
pageKey: "host-agency-policy",
|
||||
path: "/host/salary-policies",
|
||||
permission: PERMISSIONS.hostAgencyPolicyView,
|
||||
},
|
||||
{
|
||||
label: "工资结算",
|
||||
loader: () => import("./pages/HostSalarySettlementPage.jsx").then((module) => module.HostSalarySettlementPage),
|
||||
menuCode: MENU_CODES.hostSalarySettlement,
|
||||
pageKey: "host-salary-settlement",
|
||||
path: "/host/salary-settlements",
|
||||
permission: PERMISSIONS.hostSalarySettlementView,
|
||||
},
|
||||
];
|
||||
230
src/features/host-agency-policy/schema.ts
Normal file
230
src/features/host-agency-policy/schema.ts
Normal file
@ -0,0 +1,230 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const statusValues = ["active", "disabled"] as const;
|
||||
const settlementModes = ["daily", "half_month"] as const;
|
||||
const settlementTriggerModes = ["automatic", "manual"] as const;
|
||||
const teamPolicyTypes = ["bd", "admin"] as const;
|
||||
|
||||
const levelSchema = z.object({
|
||||
// 输入框值可能是 string,复制/恢复表单时也可能是 number;统一在 superRefine 中转数值校验。
|
||||
agencySalaryUsd: z.union([z.string(), z.number()]),
|
||||
hostCoinReward: z.union([z.string(), z.number()]),
|
||||
hostSalaryUsd: z.union([z.string(), z.number()]),
|
||||
level: z.union([z.string(), z.number()]),
|
||||
requiredDiamonds: z.union([z.string(), z.number()]),
|
||||
sortOrder: z.union([z.string(), z.number()]).optional(),
|
||||
status: z.enum(statusValues),
|
||||
});
|
||||
|
||||
export const hostAgencyPolicyFormSchema = z
|
||||
.object({
|
||||
effectiveFrom: z.union([z.string(), z.number()]).optional(),
|
||||
effectiveTo: z.union([z.string(), z.number()]).optional(),
|
||||
enabled: z.boolean(),
|
||||
giftCoinToDiamondRatio: z.union([z.string(), z.number()]),
|
||||
levels: z.array(levelSchema).min(1, "请至少配置一个等级"),
|
||||
name: z.string().trim().min(1, "请输入政策名称").max(120, "政策名称不能超过 120 个字符"),
|
||||
regionId: z.union([z.string(), z.number()]),
|
||||
residualDiamondToUsdRate: z.union([z.string(), z.number()]),
|
||||
settlementMode: z.enum(settlementModes),
|
||||
settlementTriggerMode: z.enum(settlementTriggerModes),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
// 前端先做完整业务校验,降低运营误填成本;后端仍是最终可信校验边界。
|
||||
const regionId = Number(value.regionId);
|
||||
if (!Number.isInteger(regionId) || regionId <= 0) {
|
||||
context.addIssue({ code: "custom", message: "请选择适用区域", path: ["regionId"] });
|
||||
}
|
||||
if (!isFixedDecimal(value.giftCoinToDiamondRatio, 6, false)) {
|
||||
context.addIssue({ code: "custom", message: "金币转钻石比例不正确", path: ["giftCoinToDiamondRatio"] });
|
||||
}
|
||||
if (!isFixedDecimal(value.residualDiamondToUsdRate, 12, true)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "剩余钻石转美元比例不正确",
|
||||
path: ["residualDiamondToUsdRate"],
|
||||
});
|
||||
}
|
||||
const effectiveFromMs = timeValueToMs(value.effectiveFrom);
|
||||
const effectiveToMs = timeValueToMs(value.effectiveTo);
|
||||
if (effectiveFromMs < 0 || effectiveToMs < 0 || (effectiveToMs > 0 && effectiveToMs <= effectiveFromMs)) {
|
||||
context.addIssue({ code: "custom", message: "生效时间范围不正确", path: ["effectiveTo"] });
|
||||
}
|
||||
|
||||
const normalizedLevels = [...value.levels].sort((left, right) => Number(left.level) - Number(right.level));
|
||||
const seenLevels = new Set<number>();
|
||||
let previousRequiredDiamonds = 0;
|
||||
let previousHostSalary = 0;
|
||||
let previousHostCoinReward = 0;
|
||||
let previousAgencySalary = 0;
|
||||
normalizedLevels.forEach((level, index) => {
|
||||
// 等级号、钻石门槛和三类累计权益必须单调,保证后续差额结算不会出现负值或多重命中。
|
||||
const levelNo = Number(level.level);
|
||||
if (!Number.isInteger(levelNo) || levelNo <= 0 || seenLevels.has(levelNo)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "等级必须为不重复的正整数",
|
||||
path: ["levels", index, "level"],
|
||||
});
|
||||
}
|
||||
seenLevels.add(levelNo);
|
||||
const requiredDiamonds = Number(level.requiredDiamonds);
|
||||
if (
|
||||
!Number.isInteger(requiredDiamonds) ||
|
||||
requiredDiamonds <= 0 ||
|
||||
requiredDiamonds <= previousRequiredDiamonds
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "钻石门槛必须逐级递增",
|
||||
path: ["levels", index, "requiredDiamonds"],
|
||||
});
|
||||
}
|
||||
const hostSalary = decimalToScaled(level.hostSalaryUsd, 2);
|
||||
if (hostSalary < 0 || hostSalary < previousHostSalary) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "主播工资不能小于上一等级",
|
||||
path: ["levels", index, "hostSalaryUsd"],
|
||||
});
|
||||
}
|
||||
const hostCoinReward = Number(level.hostCoinReward);
|
||||
if (!Number.isInteger(hostCoinReward) || hostCoinReward < 0 || hostCoinReward < previousHostCoinReward) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "金币奖励不能小于上一等级",
|
||||
path: ["levels", index, "hostCoinReward"],
|
||||
});
|
||||
}
|
||||
const agencySalary = decimalToScaled(level.agencySalaryUsd, 2);
|
||||
if (agencySalary < 0 || agencySalary < previousAgencySalary) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "代理工资不能小于上一等级",
|
||||
path: ["levels", index, "agencySalaryUsd"],
|
||||
});
|
||||
}
|
||||
previousRequiredDiamonds = requiredDiamonds;
|
||||
previousHostSalary = hostSalary;
|
||||
previousHostCoinReward = hostCoinReward;
|
||||
previousAgencySalary = agencySalary;
|
||||
});
|
||||
});
|
||||
|
||||
const teamLevelSchema = z.object({
|
||||
level: z.union([z.string(), z.number()]),
|
||||
ratePercent: z.union([z.string(), z.number()]),
|
||||
salaryUsd: z.union([z.string(), z.number()]),
|
||||
sortOrder: z.union([z.string(), z.number()]).optional(),
|
||||
status: z.enum(statusValues),
|
||||
thresholdUsd: z.union([z.string(), z.number()]),
|
||||
});
|
||||
|
||||
export const teamSalaryPolicyFormSchema = z
|
||||
.object({
|
||||
effectiveFrom: z.union([z.string(), z.number()]).optional(),
|
||||
effectiveTo: z.union([z.string(), z.number()]).optional(),
|
||||
enabled: z.boolean(),
|
||||
levels: z.array(teamLevelSchema).min(1, "请至少配置一个等级"),
|
||||
name: z.string().trim().min(1, "请输入政策名称").max(120, "政策名称不能超过 120 个字符"),
|
||||
policyType: z.enum(teamPolicyTypes),
|
||||
regionId: z.union([z.string(), z.number()]),
|
||||
settlementTriggerMode: z.enum(settlementTriggerModes),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
const regionId = Number(value.regionId);
|
||||
if (!Number.isInteger(regionId) || regionId <= 0) {
|
||||
context.addIssue({ code: "custom", message: "请选择适用区域", path: ["regionId"] });
|
||||
}
|
||||
const effectiveFromMs = timeValueToMs(value.effectiveFrom);
|
||||
const effectiveToMs = timeValueToMs(value.effectiveTo);
|
||||
if (effectiveFromMs < 0 || effectiveToMs < 0 || (effectiveToMs > 0 && effectiveToMs <= effectiveFromMs)) {
|
||||
context.addIssue({ code: "custom", message: "生效时间范围不正确", path: ["effectiveTo"] });
|
||||
}
|
||||
|
||||
const normalizedLevels = [...value.levels].sort((left, right) => Number(left.level) - Number(right.level));
|
||||
const seenLevels = new Set<number>();
|
||||
let previousThreshold = 0;
|
||||
let previousSalary = 0;
|
||||
normalizedLevels.forEach((level, index) => {
|
||||
// BD/Admin 等级表保存累计门槛和累计工资,递增校验保证后续差额结算不会倒扣。
|
||||
const levelNo = Number(level.level);
|
||||
if (!Number.isInteger(levelNo) || levelNo <= 0 || seenLevels.has(levelNo)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "等级必须为不重复的正整数",
|
||||
path: ["levels", index, "level"],
|
||||
});
|
||||
}
|
||||
seenLevels.add(levelNo);
|
||||
const threshold = decimalToScaled(level.thresholdUsd, 2, false);
|
||||
if (threshold <= 0 || threshold <= previousThreshold) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "收入门槛必须逐级递增",
|
||||
path: ["levels", index, "thresholdUsd"],
|
||||
});
|
||||
}
|
||||
const rate = decimalToScaled(level.ratePercent, 4, false);
|
||||
if (rate <= 0) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "提成比例必须大于 0",
|
||||
path: ["levels", index, "ratePercent"],
|
||||
});
|
||||
}
|
||||
const salary = decimalToScaled(level.salaryUsd, 2);
|
||||
if (salary < 0 || salary < previousSalary) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "累计工资不能小于上一等级",
|
||||
path: ["levels", index, "salaryUsd"],
|
||||
});
|
||||
}
|
||||
previousThreshold = threshold;
|
||||
previousSalary = salary;
|
||||
});
|
||||
});
|
||||
|
||||
function isFixedDecimal(value: unknown, scale: number, allowZero: boolean) {
|
||||
return decimalToScaled(value, scale, allowZero) >= 0;
|
||||
}
|
||||
|
||||
function decimalToScaled(value: unknown, scale: number, allowZero = true) {
|
||||
// 小数校验转换成整数刻度比较,避免 JS 浮点数直接参与金额大小判断。
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw || raw.startsWith("-")) {
|
||||
return -1;
|
||||
}
|
||||
const pattern = new RegExp(`^\\d+(\\.\\d{1,${scale}})?$`);
|
||||
if (!pattern.test(raw)) {
|
||||
return -1;
|
||||
}
|
||||
const [whole, fraction = ""] = raw.split(".");
|
||||
const scaled = Number(whole) * 10 ** scale + Number(fraction.padEnd(scale, "0"));
|
||||
if (!Number.isSafeInteger(scaled) || (!allowZero && scaled <= 0)) {
|
||||
return -1;
|
||||
}
|
||||
return scaled;
|
||||
}
|
||||
|
||||
function timeValueToMs(value: unknown) {
|
||||
// 空值表示长期/立即生效;非法日期返回 -1,让上层统一提示时间范围错误。
|
||||
if (value === "" || value === null || value === undefined) {
|
||||
return 0;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) && value >= 0 ? value : -1;
|
||||
}
|
||||
const text = String(value).trim();
|
||||
if (!text) {
|
||||
return 0;
|
||||
}
|
||||
const number = Number(value);
|
||||
if (/^\d+$/.test(text)) {
|
||||
return Number.isFinite(number) ? number : -1;
|
||||
}
|
||||
const date = new Date(text);
|
||||
const ms = date.getTime();
|
||||
return Number.isFinite(ms) ? ms : -1;
|
||||
}
|
||||
@ -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
|
||||
|
||||
138
src/features/resources/batchUpload.js
Normal file
138
src/features/resources/batchUpload.js
Normal 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(/\.[^.]+$/, "");
|
||||
}
|
||||
55
src/features/resources/batchUpload.test.js
Normal file
55
src/features/resources/batchUpload.test.js
Normal 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" });
|
||||
}
|
||||
@ -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", "麦位动效"],
|
||||
|
||||
@ -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 "";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
242
src/shared/api/generated/schema.d.ts
vendored
242
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@ -29,3 +29,11 @@
|
||||
.MuiTooltip-popper :is(h1, h2, h3, h4, h5, h6, p, span, small, strong, em, label, button, input, textarea, select, option, a, li, dt, dd, th, td, div, pre, code) {
|
||||
font-size: var(--admin-font-size);
|
||||
}
|
||||
|
||||
:is(#app, .MuiAutocomplete-popper, .MuiDialog-root, .MuiMenu-root, .MuiModal-root, .MuiPopover-root, .MuiPopper-root, .MuiSnackbar-root, .MuiTooltip-popper)
|
||||
:is(button, input, textarea, select, option, .MuiAutocomplete-option, .MuiInputBase-root, .MuiInputBase-input, .MuiInputLabel-root, .MuiFormLabel-root, .MuiSelect-select, .MuiButton-root, .MuiMenuItem-root),
|
||||
:is(#app, .MuiAutocomplete-popper, .MuiDialog-root, .MuiMenu-root, .MuiModal-root, .MuiPopover-root, .MuiPopper-root, .MuiSnackbar-root, .MuiTooltip-popper)
|
||||
button
|
||||
:is(span, small, strong, em) {
|
||||
font-size: var(--control-font-size);
|
||||
}
|
||||
|
||||
@ -31,14 +31,14 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-3);
|
||||
padding: 0 var(--control-padding-x);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: var(--admin-font-size);
|
||||
line-height: var(--admin-line-height);
|
||||
font-size: var(--control-font-size);
|
||||
line-height: var(--control-line-height);
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background var(--motion-fast) var(--ease-standard),
|
||||
@ -84,10 +84,10 @@
|
||||
.button.MuiButton-root {
|
||||
height: var(--control-height);
|
||||
min-height: var(--control-height);
|
||||
padding: 0 var(--space-3);
|
||||
padding: 0 var(--control-padding-x);
|
||||
border-radius: var(--radius-control);
|
||||
font-size: var(--admin-font-size);
|
||||
line-height: var(--admin-line-height);
|
||||
font-size: var(--control-font-size);
|
||||
line-height: var(--control-line-height);
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
@ -106,8 +106,8 @@
|
||||
.MuiTextField-root,
|
||||
.MuiFormControl-root {
|
||||
--admin-control-height: var(--control-height);
|
||||
--admin-control-font-size: var(--admin-font-size);
|
||||
--admin-control-line-height: var(--admin-line-height);
|
||||
--admin-control-font-size: var(--control-font-size);
|
||||
--admin-control-line-height: var(--control-line-height);
|
||||
}
|
||||
|
||||
.region-select.MuiTextField-root,
|
||||
@ -146,13 +146,13 @@
|
||||
height: var(--control-height);
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
font-size: var(--admin-font-size);
|
||||
line-height: var(--admin-line-height);
|
||||
font-size: var(--control-font-size);
|
||||
line-height: var(--control-line-height);
|
||||
}
|
||||
|
||||
.MuiInputBase-root .MuiInputBase-input::placeholder {
|
||||
font-size: var(--admin-font-size);
|
||||
line-height: var(--admin-line-height);
|
||||
font-size: var(--control-font-size);
|
||||
line-height: var(--control-line-height);
|
||||
}
|
||||
|
||||
.MuiInputBase-input:focus {
|
||||
@ -200,7 +200,7 @@
|
||||
.MuiFormControl-root .MuiOutlinedInput-input {
|
||||
box-sizing: border-box;
|
||||
height: var(--admin-control-height);
|
||||
padding: 0 var(--space-3);
|
||||
padding: 0 var(--control-padding-x);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--admin-control-font-size);
|
||||
line-height: var(--admin-control-line-height);
|
||||
@ -219,12 +219,12 @@
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-control-font-size);
|
||||
line-height: var(--admin-control-line-height);
|
||||
transform: translate(14px, 8px) scale(1);
|
||||
transform: translate(12px, 6px) scale(1);
|
||||
}
|
||||
|
||||
.MuiTextField-root .MuiInputLabel-outlined.MuiInputLabel-shrink,
|
||||
.MuiFormControl-root .MuiInputLabel-outlined.MuiInputLabel-shrink {
|
||||
transform: translate(14px, -8px) scale(0.75);
|
||||
transform: translate(12px, -7px) scale(0.75);
|
||||
}
|
||||
|
||||
.MuiTextField-root .MuiFormLabel-root.Mui-focused,
|
||||
@ -239,7 +239,7 @@
|
||||
height: var(--admin-control-height);
|
||||
min-height: var(--admin-control-height) !important;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-8) 0 var(--space-3) !important;
|
||||
padding: 0 var(--space-8) 0 var(--control-padding-x) !important;
|
||||
font-size: var(--admin-control-font-size);
|
||||
line-height: var(--admin-control-line-height);
|
||||
}
|
||||
@ -252,7 +252,7 @@
|
||||
}
|
||||
|
||||
.MuiAutocomplete-root {
|
||||
--admin-autocomplete-input-height: 24px;
|
||||
--admin-autocomplete-input-height: 22px;
|
||||
}
|
||||
|
||||
.MuiAutocomplete-root .MuiOutlinedInput-root.MuiAutocomplete-inputRoot:not(.MuiInputBase-multiline) {
|
||||
@ -261,7 +261,7 @@
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
padding: 4px 40px 4px var(--space-2);
|
||||
padding: 3px 36px 3px var(--space-2);
|
||||
}
|
||||
|
||||
.MuiAutocomplete-root .MuiOutlinedInput-root.MuiAutocomplete-inputRoot .MuiAutocomplete-input {
|
||||
@ -855,11 +855,14 @@
|
||||
}
|
||||
|
||||
.side-drawer {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
width: min(420px, 100vw);
|
||||
min-height: 100%;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
overflow: hidden;
|
||||
padding: var(--space-6);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
@ -870,6 +873,7 @@
|
||||
|
||||
.side-drawer__header {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
min-height: var(--control-height);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@ -885,15 +889,21 @@
|
||||
|
||||
.side-drawer__body {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
gap: var(--space-4);
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.side-drawer__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
margin-top: auto;
|
||||
padding-top: var(--space-2);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
:root {
|
||||
--admin-font-size: 14px;
|
||||
--admin-line-height: 20px;
|
||||
--control-height: 36px;
|
||||
--control-font-size: 13px;
|
||||
--control-line-height: 18px;
|
||||
--control-height: 32px;
|
||||
--control-padding-x: 10px;
|
||||
--status-height: 24px;
|
||||
--admin-tag-height: var(--status-height);
|
||||
--admin-tag-radius: var(--radius-pill);
|
||||
|
||||
35
src/theme.js
35
src/theme.js
@ -47,15 +47,17 @@ export const theme = createTheme({
|
||||
components: {
|
||||
MuiButton: {
|
||||
defaultProps: {
|
||||
disableRipple: false
|
||||
disableRipple: false,
|
||||
size: "small"
|
||||
},
|
||||
styleOverrides: {
|
||||
root: {
|
||||
minWidth: 0,
|
||||
height: token("control-height"),
|
||||
borderRadius: token("radius-control"),
|
||||
padding: `0 ${token("space-3")}`,
|
||||
fontSize: 14,
|
||||
padding: `0 ${token("control-padding-x")}`,
|
||||
fontSize: token("control-font-size"),
|
||||
lineHeight: token("control-line-height"),
|
||||
transition:
|
||||
"background-color 150ms cubic-bezier(0.2, 0, 0, 1), border-color 150ms cubic-bezier(0.2, 0, 0, 1), box-shadow 150ms cubic-bezier(0.2, 0, 0, 1), transform 220ms cubic-bezier(0.16, 1, 0.3, 1)"
|
||||
},
|
||||
@ -114,6 +116,9 @@ export const theme = createTheme({
|
||||
}
|
||||
},
|
||||
MuiIconButton: {
|
||||
defaultProps: {
|
||||
size: "small"
|
||||
},
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: token("radius-control"),
|
||||
@ -126,10 +131,11 @@ export const theme = createTheme({
|
||||
MuiInputBase: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
fontSize: 14
|
||||
fontSize: token("control-font-size")
|
||||
},
|
||||
input: {
|
||||
fontSize: 14,
|
||||
fontSize: token("control-font-size"),
|
||||
lineHeight: token("control-line-height"),
|
||||
"&[type='number']": {
|
||||
MozAppearance: "textfield"
|
||||
},
|
||||
@ -158,7 +164,7 @@ export const theme = createTheme({
|
||||
styleOverrides: {
|
||||
root: {
|
||||
color: token("text-secondary"),
|
||||
fontSize: 14
|
||||
fontSize: token("control-font-size")
|
||||
},
|
||||
icon: {
|
||||
color: token("text-secondary")
|
||||
@ -243,7 +249,8 @@ export const theme = createTheme({
|
||||
MuiFormLabel: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
fontSize: 14
|
||||
fontSize: token("control-font-size"),
|
||||
lineHeight: token("control-line-height")
|
||||
},
|
||||
asterisk: {
|
||||
color: token("danger"),
|
||||
@ -254,16 +261,26 @@ export const theme = createTheme({
|
||||
MuiFormHelperText: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
fontSize: 14
|
||||
fontSize: token("control-font-size")
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiMenuItem: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
fontSize: 14
|
||||
fontSize: token("control-font-size")
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiTextField: {
|
||||
defaultProps: {
|
||||
size: "small"
|
||||
}
|
||||
},
|
||||
MuiFormControl: {
|
||||
defaultProps: {
|
||||
size: "small"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -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]",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user