bi报表和财务系统
This commit is contained in:
parent
f059b0c60c
commit
3e8d4b4aea
@ -2729,6 +2729,30 @@
|
||||
"x-permissions": ["payment-product:delete"]
|
||||
}
|
||||
},
|
||||
"/admin/money/scope": {
|
||||
"get": {
|
||||
"operationId": "getMoneyScope",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "money:view",
|
||||
"x-permissions": ["money:view"]
|
||||
}
|
||||
},
|
||||
"/admin/money/performance": {
|
||||
"get": {
|
||||
"operationId": "getMoneyPerformance",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "money:view",
|
||||
"x-permissions": ["money:view"]
|
||||
}
|
||||
},
|
||||
"/admin/payment/temporary-links": {
|
||||
"get": {
|
||||
"operationId": "listTemporaryPaymentLinks",
|
||||
@ -2738,7 +2762,20 @@
|
||||
}
|
||||
},
|
||||
"x-permission": "payment-temporary-link:view",
|
||||
"x-permissions": ["payment-temporary-link:view"]
|
||||
"x-permissions": ["payment-temporary-link:view", "money:view"]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createTemporaryPaymentLink",
|
||||
"requestBody": {
|
||||
"$ref": "#/components/requestBodies/TemporaryPaymentLinkRequest"
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "money:payment-link:create",
|
||||
"x-permissions": ["money:payment-link:create"]
|
||||
}
|
||||
},
|
||||
"/admin/payment/temporary-links/{order_id}": {
|
||||
@ -2760,7 +2797,7 @@
|
||||
}
|
||||
],
|
||||
"x-permission": "payment-temporary-link:view",
|
||||
"x-permissions": ["payment-temporary-link:view"]
|
||||
"x-permissions": ["payment-temporary-link:view", "money:view"]
|
||||
}
|
||||
},
|
||||
"/admin/payment/third-party-channels": {
|
||||
@ -2772,7 +2809,7 @@
|
||||
}
|
||||
},
|
||||
"x-permission": "payment-third-party:view",
|
||||
"x-permissions": ["payment-third-party:view"]
|
||||
"x-permissions": ["payment-third-party:view", "money:view"]
|
||||
}
|
||||
},
|
||||
"/admin/payment/third-party-methods/{method_id}/status": {
|
||||
@ -4518,6 +4555,41 @@
|
||||
"x-permissions": ["user:update"]
|
||||
}
|
||||
},
|
||||
"/users/{id}/money-scopes": {
|
||||
"get": {
|
||||
"operationId": "listUserMoneyScopes",
|
||||
"x-permission": "user:view",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/Id"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permissions": ["user:view"]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "replaceUserMoneyScopes",
|
||||
"x-permission": "user:update",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/Id"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"$ref": "#/components/requestBodies/MoneyScopeRequest"
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permissions": ["user:update"]
|
||||
}
|
||||
},
|
||||
"/users/{id}/reset-password": {
|
||||
"post": {
|
||||
"operationId": "resetUserPassword",
|
||||
@ -4822,6 +4894,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"MoneyScopeRequest": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["scopes"],
|
||||
"properties": {
|
||||
"scopes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/UserMoneyScope"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"MenuRequest": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
@ -4924,6 +5014,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TemporaryPaymentLinkRequest": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["appCode", "regionId", "usdMinorAmount", "providerCode", "paymentMethodId"],
|
||||
"properties": {
|
||||
"appCode": {
|
||||
"type": "string"
|
||||
},
|
||||
"language": {
|
||||
"type": "string"
|
||||
},
|
||||
"paymentMethodId": {
|
||||
"type": "integer"
|
||||
},
|
||||
"providerCode": {
|
||||
"type": "string"
|
||||
},
|
||||
"regionId": {
|
||||
"type": "integer"
|
||||
},
|
||||
"returnUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"usdMinorAmount": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"UserRequest": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
@ -5971,6 +6094,18 @@
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"additionalProperties": true
|
||||
},
|
||||
"UserMoneyScope": {
|
||||
"type": "object",
|
||||
"required": ["appCode", "regionId"],
|
||||
"properties": {
|
||||
"appCode": {
|
||||
"type": "string"
|
||||
},
|
||||
"regionId": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,26 +8,32 @@ import { OperatorPerformancePanel } from "./components/OperatorPerformancePanel.
|
||||
import { PaymentLinkPanel } from "./components/PaymentLinkPanel.jsx";
|
||||
import { PerformanceTable } from "./components/PerformanceTable.jsx";
|
||||
import {
|
||||
createPaymentLinkRecord,
|
||||
filterMoneyModules,
|
||||
initialPaymentLinks,
|
||||
moneyAppOptions,
|
||||
moneyCountries,
|
||||
moneyOperators,
|
||||
moneyScopeOptions,
|
||||
summarizeMoneyModules
|
||||
} from "./data.js";
|
||||
import {
|
||||
appOptionsFromMasterData,
|
||||
countryOptionsFromMasterData,
|
||||
fetchMoneyMasterData,
|
||||
regionOptionsFromMasterData
|
||||
} from "./masterDataApi.js";
|
||||
|
||||
const emptyMasterData = { apps: [], countries: [], error: "", loading: false, modules: [], performanceSummary: {}, regions: [], scopes: [] };
|
||||
|
||||
export function MoneyApp() {
|
||||
const [sessionState, setSessionState] = useState({ error: "", loading: true, session: null });
|
||||
const [masterDataState, setMasterDataState] = useState(emptyMasterData);
|
||||
const [activeView, setActiveView] = useState("overview");
|
||||
const [mode, setMode] = useState("finance");
|
||||
const [activeOperatorId, setActiveOperatorId] = useState("sarah-ali");
|
||||
const [appCode, setAppCode] = useState("");
|
||||
const [countryCode, setCountryCode] = useState("");
|
||||
const [scope, setScope] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [operatorKeyword, setOperatorKeyword] = useState("");
|
||||
const [links, setLinks] = useState(() => initialPaymentLinks);
|
||||
const [links, setLinks] = useState([]);
|
||||
const [toast, setToast] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@ -48,6 +54,53 @@ export function MoneyApp() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionState.session?.canViewMoney) {
|
||||
return undefined;
|
||||
}
|
||||
let mounted = true;
|
||||
setMasterDataState((current) => ({ ...current, error: "", loading: true }));
|
||||
fetchMoneyMasterData()
|
||||
.then((data) => {
|
||||
if (mounted) {
|
||||
setMasterDataState({ ...data, loading: false });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (mounted) {
|
||||
setMasterDataState({ ...emptyMasterData, error: err.message || "主数据加载失败", loading: false });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [sessionState.session?.canViewMoney]);
|
||||
|
||||
const appOptions = useMemo(() => appOptionsFromMasterData(masterDataState.apps, moneyAppOptions), [masterDataState.apps]);
|
||||
const countryOptions = useMemo(
|
||||
() => countryOptionsFromMasterData(masterDataState.countries, moneyCountries),
|
||||
[masterDataState.countries]
|
||||
);
|
||||
const regionOptions = useMemo(() => regionOptionsFromMasterData(masterDataState.regions), [masterDataState.regions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (appCode && !hasOption(appOptions, appCode)) {
|
||||
setAppCode("");
|
||||
}
|
||||
}, [appCode, appOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (countryCode && !hasOption(countryOptions, countryCode)) {
|
||||
setCountryCode("");
|
||||
}
|
||||
}, [countryCode, countryOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (regionId && !hasOption(regionOptions, regionId)) {
|
||||
setRegionId("");
|
||||
}
|
||||
}, [regionId, regionOptions]);
|
||||
|
||||
const filteredModules = useMemo(
|
||||
() =>
|
||||
filterMoneyModules({
|
||||
@ -55,21 +108,24 @@ export function MoneyApp() {
|
||||
appCode,
|
||||
countryCode,
|
||||
mode,
|
||||
modules: masterDataState.modules,
|
||||
operatorKeyword: mode === "finance" ? operatorKeyword : "",
|
||||
scope
|
||||
regionId,
|
||||
regions: masterDataState.regions
|
||||
}),
|
||||
[activeOperatorId, appCode, countryCode, mode, operatorKeyword, scope]
|
||||
[activeOperatorId, appCode, countryCode, masterDataState.modules, masterDataState.regions, mode, operatorKeyword, regionId]
|
||||
);
|
||||
const summary = useMemo(() => summarizeMoneyModules(filteredModules), [filteredModules]);
|
||||
const visibleModuleIds = useMemo(() => new Set(filteredModules.map((module) => module.id)), [filteredModules]);
|
||||
const visibleLinks = useMemo(() => links.filter((link) => visibleModuleIds.has(link.moduleId)), [links, visibleModuleIds]);
|
||||
const activeOperator = moneyOperators.find((operator) => operator.id === activeOperatorId) || moneyOperators[0];
|
||||
const operators = useMemo(() => operatorsFromModules(masterDataState.modules), [masterDataState.modules]);
|
||||
const activeOperator = operators.find((operator) => operator.id === activeOperatorId) || operators[0] || moneyOperators[0];
|
||||
const view = MONEY_VIEWS.find((item) => item.id === activeView) || MONEY_VIEWS[0];
|
||||
|
||||
const resetFilters = () => {
|
||||
setAppCode("");
|
||||
setCountryCode("");
|
||||
setScope("");
|
||||
setRegionId("");
|
||||
setOperatorKeyword("");
|
||||
};
|
||||
|
||||
@ -78,20 +134,16 @@ export function MoneyApp() {
|
||||
window.setTimeout(() => setToast(""), 1600);
|
||||
};
|
||||
|
||||
const createLink = (payload) => {
|
||||
const nextLink = createPaymentLinkRecord({
|
||||
...payload,
|
||||
operatorName: mode === "operator" ? activeOperator.label : undefined
|
||||
});
|
||||
setLinks((current) => [nextLink, ...current]);
|
||||
showToast("支付链接已创建");
|
||||
};
|
||||
|
||||
const copyLink = async (value) => {
|
||||
await copyText(value);
|
||||
showToast("链接已复制");
|
||||
};
|
||||
|
||||
const addPaymentLink = (link) => {
|
||||
setLinks((current) => [link, ...current.filter((item) => (item.orderId || item.id) !== (link.orderId || link.id))]);
|
||||
showToast("支付链接已创建");
|
||||
};
|
||||
|
||||
const exportReport = () => {
|
||||
downloadTextFile(createCsv(filteredModules), `hyapp-money-${Date.now()}.csv`);
|
||||
showToast("财务报表已导出");
|
||||
@ -124,33 +176,45 @@ export function MoneyApp() {
|
||||
<MoneyHeader
|
||||
activeOperatorId={activeOperatorId}
|
||||
appCode={appCode}
|
||||
appOptions={moneyAppOptions}
|
||||
appOptions={appOptions}
|
||||
countryCode={countryCode}
|
||||
countryOptions={[["", "全部国家"], ...moneyCountries.map((country) => [country.code, country.label])]}
|
||||
countryOptions={countryOptions}
|
||||
masterDataError={masterDataState.error}
|
||||
masterDataLoading={masterDataState.loading}
|
||||
mode={mode}
|
||||
onActiveOperatorChange={setActiveOperatorId}
|
||||
onAppChange={setAppCode}
|
||||
onCountryChange={setCountryCode}
|
||||
onExport={exportReport}
|
||||
onOperatorKeywordChange={setOperatorKeyword}
|
||||
onRegionChange={setRegionId}
|
||||
onReset={resetFilters}
|
||||
onScopeChange={setScope}
|
||||
operatorKeyword={operatorKeyword}
|
||||
operators={moneyOperators}
|
||||
scope={scope}
|
||||
scopeOptions={moneyScopeOptions}
|
||||
operators={operators}
|
||||
regionId={regionId}
|
||||
regionOptions={regionOptions}
|
||||
view={view}
|
||||
/>
|
||||
<div className="money-content">{renderContent(activeView, { copyLink, createLink, filteredModules, summary, visibleLinks })}</div>
|
||||
<div className="money-content">
|
||||
{renderContent(activeView, {
|
||||
copyLink,
|
||||
countries: masterDataState.countries,
|
||||
filteredModules,
|
||||
onPaymentLinkCreated: addPaymentLink,
|
||||
regions: masterDataState.regions,
|
||||
summary,
|
||||
visibleLinks
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
{toast ? <div className="money-toast">{toast}</div> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function renderContent(activeView, { copyLink, createLink, filteredModules, summary, visibleLinks }) {
|
||||
function renderContent(activeView, { copyLink, countries, filteredModules, onPaymentLinkCreated, regions, summary, visibleLinks }) {
|
||||
if (activeView === "modules") {
|
||||
return <PerformanceTable meta={`${filteredModules.length} modules`} modules={filteredModules} summary={summary} title="模块绩效" />;
|
||||
return <PerformanceTable meta={`${filteredModules.length} 个区域`} modules={filteredModules} summary={summary} title="区域绩效" />;
|
||||
}
|
||||
|
||||
if (activeView === "operators") {
|
||||
@ -158,16 +222,20 @@ function renderContent(activeView, { copyLink, createLink, filteredModules, summ
|
||||
}
|
||||
|
||||
if (activeView === "payments") {
|
||||
return <PaymentLinkPanel links={visibleLinks} modules={filteredModules} onCopy={copyLink} onCreateLink={createLink} />;
|
||||
return <PaymentLinkPanel links={visibleLinks} modules={filteredModules} onCopy={copyLink} onCreated={onPaymentLinkCreated} />;
|
||||
}
|
||||
|
||||
if (activeView === "countries") {
|
||||
return <CountryChannelPanel links={visibleLinks} modules={filteredModules} />;
|
||||
return <CountryChannelPanel countries={countries} links={visibleLinks} modules={filteredModules} regions={regions} />;
|
||||
}
|
||||
|
||||
return <MoneyOverview links={visibleLinks} modules={filteredModules} summary={summary} />;
|
||||
}
|
||||
|
||||
function hasOption(options, value) {
|
||||
return options.some(([optionValue]) => String(optionValue) === String(value));
|
||||
}
|
||||
|
||||
function StateScreen({ detail = "", title }) {
|
||||
return (
|
||||
<main className="money-shell money-shell--state">
|
||||
@ -180,7 +248,7 @@ function StateScreen({ detail = "", title }) {
|
||||
}
|
||||
|
||||
function createCsv(modules) {
|
||||
const header = ["模块", "负责人", "国家", "充值USD", "流水USD", "存量工资USD", "新增", "日活", "当日目标USD", "当月目标USD"];
|
||||
const header = ["区域", "负责人", "国家", "充值USD", "流水USD", "存量工资USD", "新增", "日活", "当日目标USD", "当月目标USD"];
|
||||
const rows = modules.map((module) => [
|
||||
module.moduleName,
|
||||
module.operatorName,
|
||||
@ -196,6 +264,23 @@ function createCsv(modules) {
|
||||
return [header, ...rows].map((row) => row.map(csvCell).join(",")).join("\n");
|
||||
}
|
||||
|
||||
function operatorsFromModules(modules) {
|
||||
const seen = new Set();
|
||||
return modules
|
||||
.map((module) => ({
|
||||
department: module.department || "",
|
||||
id: module.operatorId,
|
||||
label: module.operatorName || module.operatorId
|
||||
}))
|
||||
.filter((operator) => {
|
||||
if (!operator.id || seen.has(operator.id)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(operator.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function csvCell(value) {
|
||||
const text = String(value ?? "");
|
||||
return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
||||
|
||||
@ -1,36 +1,73 @@
|
||||
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";
|
||||
const MONEY_PERMISSION = "money:view";
|
||||
const DEFAULT_APP_CODE = "lalu";
|
||||
|
||||
export async function fetchMoneySession() {
|
||||
const response = await fetch(`${API_BASE_URL}/v1/auth/me`, {
|
||||
credentials: "include",
|
||||
headers: requestHeaders()
|
||||
});
|
||||
const payload = await readJSON(response);
|
||||
if (response.status === 401 || Number(payload?.code) === 40100) {
|
||||
redirectToLogin();
|
||||
throw new Error("登录已失效");
|
||||
}
|
||||
if (!response.ok || payload.code !== 0) {
|
||||
throw new Error(payload.message || response.statusText || "会话校验失败");
|
||||
}
|
||||
const session = payload.data || {};
|
||||
const session = await adminApiGet("/v1/auth/me");
|
||||
return {
|
||||
...session,
|
||||
canViewMoney: Array.isArray(session.permissions) && session.permissions.includes(MONEY_PERMISSION)
|
||||
};
|
||||
}
|
||||
|
||||
function requestHeaders() {
|
||||
const headers = {};
|
||||
export function adminApiGet(path, options = {}) {
|
||||
return adminApiRequest(path, { ...options, method: "GET" });
|
||||
}
|
||||
|
||||
export async function adminApiRequest(path, options = {}) {
|
||||
const response = await fetch(adminApiUrl(path, options.query), {
|
||||
body: requestBody(options.body),
|
||||
credentials: "include",
|
||||
headers: requestHeaders(options),
|
||||
method: options.method || (options.body === undefined ? "GET" : "POST")
|
||||
});
|
||||
const payload = await readJSON(response);
|
||||
if (response.status === 401 || Number(payload?.code) === 40100) {
|
||||
redirectToLogin();
|
||||
throw new Error("登录已失效");
|
||||
}
|
||||
if (!response.ok || isApiFailure(payload)) {
|
||||
throw new Error(payload.message || response.statusText || "会话校验失败");
|
||||
}
|
||||
return Object.prototype.hasOwnProperty.call(payload, "data") ? payload.data : payload;
|
||||
}
|
||||
|
||||
function requestHeaders(options = {}) {
|
||||
const headers = { ...(options.headers || {}) };
|
||||
const token = window.localStorage.getItem(TOKEN_KEY);
|
||||
const appCode = window.localStorage.getItem(APP_CODE_KEY) || DEFAULT_APP_CODE;
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
if (appCode && !headers[APP_CODE_HEADER]) {
|
||||
headers[APP_CODE_HEADER] = appCode;
|
||||
}
|
||||
if (options.body !== undefined && !(options.body instanceof FormData) && !headers["Content-Type"]) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function requestBody(body) {
|
||||
if (body === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return body instanceof FormData ? body : JSON.stringify(body);
|
||||
}
|
||||
|
||||
function adminApiUrl(path, query) {
|
||||
const url = new URL(`${API_BASE_URL}${path}`, window.location.origin);
|
||||
Object.entries(query || {}).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function readJSON(response) {
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
@ -43,6 +80,17 @@ async function readJSON(response) {
|
||||
}
|
||||
}
|
||||
|
||||
function isApiFailure(payload) {
|
||||
const code = payload?.code;
|
||||
const errorCode = payload?.errorCode;
|
||||
return (
|
||||
(typeof code === "number" && code !== 0) ||
|
||||
(typeof code === "string" && code !== "" && code !== "0" && code !== "OK") ||
|
||||
(typeof errorCode === "number" && errorCode !== 0) ||
|
||||
(typeof errorCode === "string" && errorCode !== "" && errorCode !== "0" && errorCode !== "OK")
|
||||
);
|
||||
}
|
||||
|
||||
function redirectToLogin() {
|
||||
window.localStorage.removeItem(TOKEN_KEY);
|
||||
if (window.location.pathname !== "/login") {
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { availableProvidersForCountry, moneyCountries, summarizeMoneyModules } from "../data.js";
|
||||
import { formatNumber, formatPercent, formatUsd } from "../format.js";
|
||||
|
||||
export function CountryChannelPanel({ links, modules }) {
|
||||
const rows = moneyCountries
|
||||
export function CountryChannelPanel({ countries = [], links, modules, regions = [] }) {
|
||||
const rows = countryRows(countries, modules, links, regions)
|
||||
.map((country) => {
|
||||
const countryModules = modules.filter((module) => module.countryCode === country.code);
|
||||
const countryLinks = links.filter((link) => link.countryCode === country.code);
|
||||
@ -33,7 +33,7 @@ export function CountryChannelPanel({ links, modules }) {
|
||||
<div className="money-country-metrics">
|
||||
<Metric label="充值" value={formatUsd(country.summary.rechargeUsd)} tone="success" />
|
||||
<Metric label="链接转化" value={formatPercent(ratio(country.paidLinkCount, country.linkCount))} tone="primary" />
|
||||
<Metric label="模块" value={formatNumber(country.modules.length)} tone="neutral" />
|
||||
<Metric label="区域" value={formatNumber(country.modules.length)} tone="neutral" />
|
||||
</div>
|
||||
<div className="money-provider-list">
|
||||
{country.providers.map((provider) => (
|
||||
@ -50,6 +50,40 @@ export function CountryChannelPanel({ links, modules }) {
|
||||
);
|
||||
}
|
||||
|
||||
function countryRows(countries, modules, links, regions) {
|
||||
const fallbackCountries = new Map(moneyCountries.map((country) => [country.code, country]));
|
||||
const realCountries = new Map(countries.map((country) => [country.countryCode, country]));
|
||||
const codes = new Set([
|
||||
...moneyCountries.map((country) => country.code),
|
||||
...modules.map((module) => module.countryCode),
|
||||
...links.map((link) => link.countryCode)
|
||||
]);
|
||||
|
||||
return [...codes]
|
||||
.filter((code) => code && code !== "ALL")
|
||||
.map((code) => {
|
||||
const fallback = fallbackCountries.get(code) || {};
|
||||
const real = realCountries.get(code) || {};
|
||||
return {
|
||||
code,
|
||||
currency: fallback.currency || real.currencyCode || "-",
|
||||
label: real.countryDisplayName || real.countryName || fallback.label || code,
|
||||
region: regionNameForCountry(code, regions) || fallback.region || "-"
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function regionNameForCountry(countryCode, regions) {
|
||||
const normalizedCountryCode = String(countryCode || "").toUpperCase();
|
||||
const region = regions.find((item) =>
|
||||
(item.countries || []).some((code) => String(code || "").toUpperCase() === normalizedCountryCode)
|
||||
);
|
||||
if (!region) {
|
||||
return "";
|
||||
}
|
||||
return [region.name, region.regionCode].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function Metric({ label, tone, value }) {
|
||||
return (
|
||||
<div className={`money-country-metric money-country-metric--${tone}`}>
|
||||
|
||||
@ -6,18 +6,20 @@ export function MoneyHeader({
|
||||
appOptions,
|
||||
countryCode,
|
||||
countryOptions,
|
||||
masterDataError,
|
||||
masterDataLoading,
|
||||
mode,
|
||||
onActiveOperatorChange,
|
||||
onAppChange,
|
||||
onCountryChange,
|
||||
onExport,
|
||||
onOperatorKeywordChange,
|
||||
onRegionChange,
|
||||
onReset,
|
||||
onScopeChange,
|
||||
operatorKeyword,
|
||||
operators,
|
||||
scope,
|
||||
scopeOptions,
|
||||
regionId,
|
||||
regionOptions,
|
||||
view
|
||||
}) {
|
||||
return (
|
||||
@ -27,9 +29,21 @@ export function MoneyHeader({
|
||||
</div>
|
||||
<div className="money-header-tools">
|
||||
<div className="money-filter-group">
|
||||
<FilterSelect label="App" options={appOptions} value={appCode} onChange={onAppChange} />
|
||||
<FilterSelect label="国家" options={countryOptions} value={countryCode} onChange={onCountryChange} />
|
||||
<FilterSelect label="模块" options={scopeOptions} value={scope} onChange={onScopeChange} />
|
||||
<FilterSelect disabled={masterDataLoading && appOptions.length <= 1} label="App" options={appOptions} value={appCode} onChange={onAppChange} />
|
||||
<FilterSelect
|
||||
disabled={masterDataLoading && countryOptions.length <= 1}
|
||||
label="国家"
|
||||
options={countryOptions}
|
||||
value={countryCode}
|
||||
onChange={onCountryChange}
|
||||
/>
|
||||
<FilterSelect
|
||||
disabled={masterDataLoading && regionOptions.length <= 1}
|
||||
label="区域"
|
||||
options={regionOptions}
|
||||
value={regionId}
|
||||
onChange={onRegionChange}
|
||||
/>
|
||||
{mode === "finance" ? (
|
||||
<label className="money-filter money-filter--search">
|
||||
<span>运营人员</span>
|
||||
@ -43,6 +57,7 @@ export function MoneyHeader({
|
||||
onChange={onActiveOperatorChange}
|
||||
/>
|
||||
)}
|
||||
{masterDataError ? <span className="money-filter-status">主数据加载失败</span> : null}
|
||||
</div>
|
||||
<div className="money-actions">
|
||||
<button className="money-button" onClick={onReset} type="button">
|
||||
@ -59,11 +74,11 @@ export function MoneyHeader({
|
||||
);
|
||||
}
|
||||
|
||||
function FilterSelect({ label, onChange, options, value }) {
|
||||
function FilterSelect({ disabled = false, label, onChange, options, value }) {
|
||||
return (
|
||||
<label className="money-filter">
|
||||
<span>{label}</span>
|
||||
<select value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
<select disabled={disabled} value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
{options.map(([optionValue, optionLabel]) => (
|
||||
<option key={optionValue || "all"} value={optionValue}>
|
||||
{optionLabel}
|
||||
|
||||
@ -15,7 +15,7 @@ export function MoneyOverview({ links, modules, summary }) {
|
||||
<article className="money-panel money-focus-panel">
|
||||
<div className="money-panel-head">
|
||||
<div>
|
||||
<h2>高价值模块</h2>
|
||||
<h2>高价值区域</h2>
|
||||
<span>Top 4</span>
|
||||
</div>
|
||||
<strong>{formatUsd(summary.grossProfitUsd)}</strong>
|
||||
|
||||
@ -16,8 +16,8 @@ export const MONEY_VIEWS = [
|
||||
},
|
||||
{
|
||||
id: "modules",
|
||||
label: "模块绩效",
|
||||
title: "模块绩效",
|
||||
label: "区域绩效",
|
||||
title: "区域绩效",
|
||||
Icon: TableRowsOutlined
|
||||
},
|
||||
{
|
||||
|
||||
@ -1,16 +1,8 @@
|
||||
import { moneyOperators, summarizeMoneyModules } from "../data.js";
|
||||
import { summarizeMoneyModules } from "../data.js";
|
||||
import { formatNumber, formatPercent, formatUsd } from "../format.js";
|
||||
|
||||
export function OperatorPerformancePanel({ modules }) {
|
||||
const rows = moneyOperators
|
||||
.map((operator) => {
|
||||
const ownedModules = modules.filter((module) => module.operatorId === operator.id);
|
||||
return {
|
||||
...operator,
|
||||
modules: ownedModules,
|
||||
summary: summarizeMoneyModules(ownedModules)
|
||||
};
|
||||
})
|
||||
const rows = moneyOperatorsFromModules(modules)
|
||||
.filter((operator) => operator.modules.length > 0)
|
||||
.sort((a, b) => b.summary.rechargeUsd - a.summary.rechargeUsd);
|
||||
|
||||
@ -24,7 +16,7 @@ export function OperatorPerformancePanel({ modules }) {
|
||||
<h2>{operator.label}</h2>
|
||||
<strong>{formatUsd(operator.summary.rechargeUsd)}</strong>
|
||||
<div className="money-operator-meta">
|
||||
<small>{operator.modules.length} modules</small>
|
||||
<small>{operator.modules.length} 个区域</small>
|
||||
<small>M {formatPercent(operator.summary.monthProgress)}</small>
|
||||
</div>
|
||||
</article>
|
||||
@ -44,7 +36,7 @@ export function OperatorPerformancePanel({ modules }) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>运营</th>
|
||||
<th>负责模块</th>
|
||||
<th>负责区域</th>
|
||||
<th>充值</th>
|
||||
<th>流水</th>
|
||||
<th>存量工资</th>
|
||||
@ -82,6 +74,26 @@ export function OperatorPerformancePanel({ modules }) {
|
||||
);
|
||||
}
|
||||
|
||||
function moneyOperatorsFromModules(modules) {
|
||||
const byOperator = new Map();
|
||||
modules.forEach((module) => {
|
||||
const id = module.operatorId || "0";
|
||||
if (!byOperator.has(id)) {
|
||||
byOperator.set(id, {
|
||||
department: module.department || "",
|
||||
id,
|
||||
label: module.operatorName || id,
|
||||
modules: []
|
||||
});
|
||||
}
|
||||
byOperator.get(id).modules.push(module);
|
||||
});
|
||||
return [...byOperator.values()].map((operator) => ({
|
||||
...operator,
|
||||
summary: summarizeMoneyModules(operator.modules)
|
||||
}));
|
||||
}
|
||||
|
||||
function Stack({ primary, secondary }) {
|
||||
return (
|
||||
<span className="money-stack">
|
||||
|
||||
@ -1,105 +1,558 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import AddLinkOutlined from "@mui/icons-material/AddLinkOutlined";
|
||||
import CloseOutlined from "@mui/icons-material/CloseOutlined";
|
||||
import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined";
|
||||
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
|
||||
import { availableCountriesForModule, availableProvidersForCountry, paymentStatusLabel } from "../data.js";
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import SearchOutlined from "@mui/icons-material/SearchOutlined";
|
||||
import { formatDateTime, formatUsd } from "../format.js";
|
||||
import {
|
||||
createAdminTemporaryPaymentLink,
|
||||
listAdminPaymentMethods,
|
||||
listAdminTemporaryPaymentLinks
|
||||
} from "../paymentLinksApi.js";
|
||||
import {
|
||||
buildTemporaryCountries,
|
||||
defaultReturnUrl,
|
||||
dollarsToMinor,
|
||||
temporaryCountryCode,
|
||||
temporaryCountryLabel,
|
||||
temporaryLogoText,
|
||||
temporaryMethodLabel
|
||||
} from "../temporaryPaymentApi.js";
|
||||
import { PanelHead } from "./MoneyTrendPanel.jsx";
|
||||
|
||||
export function PaymentLinkPanel({ links, modules, onCopy, onCreateLink }) {
|
||||
const [moduleId, setModuleId] = useState(() => modules[0]?.id || "");
|
||||
const countries = useMemo(() => availableCountriesForModule(moduleId), [moduleId]);
|
||||
const [countryCode, setCountryCode] = useState(() => countries[0]?.code || "");
|
||||
const providers = useMemo(() => availableProvidersForCountry(countryCode), [countryCode]);
|
||||
const [providerCode, setProviderCode] = useState(() => providers[0]?.code || "");
|
||||
const [amountUsd, setAmountUsd] = useState("99");
|
||||
const canSubmit = Boolean(moduleId && countryCode && providerCode && Number(amountUsd) > 0);
|
||||
const statusOptions = [
|
||||
["", "全部状态"],
|
||||
["pending", "待支付"],
|
||||
["redirected", "已生成"],
|
||||
["paid", "已支付"],
|
||||
["failed", "失败"],
|
||||
["credited", "已入账"]
|
||||
];
|
||||
|
||||
export function PaymentLinkPanel({ links = [], modules = [], onCopy, onCreated }) {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [query, setQuery] = useState({ keyword: "", status: "" });
|
||||
const [state, setState] = useState({ error: "", items: [], loading: true });
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [methods, setMethods] = useState([]);
|
||||
const [methodsState, setMethodsState] = useState({ error: "", loading: false });
|
||||
const [createState, setCreateState] = useState({ error: "", loading: false });
|
||||
const [form, setForm] = useState(() => ({
|
||||
appCode: "",
|
||||
countryCode: "",
|
||||
methodId: "",
|
||||
moduleId: "",
|
||||
usdAmount: "99"
|
||||
}));
|
||||
|
||||
const appOptions = useMemo(() => appOptionsFromModules(modules), [modules]);
|
||||
const appCodes = useMemo(() => appOptions.map(([appCode]) => appCode), [appOptions]);
|
||||
const appCodesKey = appCodes.join("|");
|
||||
|
||||
useEffect(() => {
|
||||
if (!modules.some((module) => module.id === moduleId)) {
|
||||
setModuleId(modules[0]?.id || "");
|
||||
}
|
||||
}, [moduleId, modules]);
|
||||
setForm((current) => {
|
||||
if (current.appCode && appCodes.includes(current.appCode)) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
appCode: appCodes[0] || "",
|
||||
countryCode: "",
|
||||
methodId: "",
|
||||
moduleId: ""
|
||||
};
|
||||
});
|
||||
}, [appCodes, appCodesKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!countries.some((country) => country.code === countryCode)) {
|
||||
setCountryCode(countries[0]?.code || "");
|
||||
let mounted = true;
|
||||
if (!appCodes.length) {
|
||||
setState({ error: "", items: [], loading: false });
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}
|
||||
}, [countries, countryCode]);
|
||||
setState((current) => ({ ...current, error: "", loading: true }));
|
||||
Promise.all(appCodes.map((appCode) => listAdminTemporaryPaymentLinks({ ...query, appCode })))
|
||||
.then((pages) => {
|
||||
if (mounted) {
|
||||
setState({ error: "", items: mergeLinks(pages.flatMap((page) => normalizeLinks(page, modules))), loading: false });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (mounted) {
|
||||
setState({ error: err.message || "加载支付链接失败", items: [], loading: false });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [appCodes, appCodesKey, modules, query]);
|
||||
|
||||
const countries = useMemo(() => buildTemporaryCountries(methods), [methods]);
|
||||
const methodsForCountry = useMemo(
|
||||
() =>
|
||||
methods
|
||||
.filter((method) => temporaryCountryCode(method) === form.countryCode)
|
||||
.sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0) || temporaryMethodLabel(a).localeCompare(temporaryMethodLabel(b))),
|
||||
[form.countryCode, methods]
|
||||
);
|
||||
const regionOptions = useMemo(() => regionsForCountry(modules, form.appCode, form.countryCode), [form.appCode, form.countryCode, modules]);
|
||||
const selectedMethod = methodsForCountry.find((method) => String(method.method_id) === String(form.methodId));
|
||||
const selectedRegion = regionOptions.find((option) => option.value === form.moduleId);
|
||||
const selectedModule = selectedRegion?.module;
|
||||
const rows = useMemo(() => mergeLinks([...state.items, ...links.map((link) => normalizeLegacyLink(link, modules))]), [links, modules, state.items]);
|
||||
const canCreate = Boolean(form.appCode && form.countryCode && form.moduleId && selectedMethod && selectedModule && dollarsToMinor(form.usdAmount) > 0 && !createState.loading);
|
||||
|
||||
useEffect(() => {
|
||||
if (!providers.some((provider) => provider.code === providerCode)) {
|
||||
setProviderCode(providers[0]?.code || "");
|
||||
if (!dialogOpen || !form.appCode) {
|
||||
setMethods([]);
|
||||
setMethodsState({ error: "", loading: false });
|
||||
return;
|
||||
}
|
||||
}, [providerCode, providers]);
|
||||
let mounted = true;
|
||||
setMethodsState({ error: "", loading: true });
|
||||
listAdminPaymentMethods(form.appCode)
|
||||
.then((nextMethods) => {
|
||||
if (mounted) {
|
||||
const activeMethods = nextMethods.filter((method) => !method.status || method.status === "active");
|
||||
setMethods(activeMethods);
|
||||
setMethodsState({ error: activeMethods.length ? "" : "当前无数据", loading: false });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (mounted) {
|
||||
setMethods([]);
|
||||
setMethodsState({ error: err.message || "支付方式加载失败", loading: false });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [dialogOpen, form.appCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!countries.length) {
|
||||
setForm((current) => (current.countryCode ? { ...current, countryCode: "", methodId: "", moduleId: "" } : current));
|
||||
return;
|
||||
}
|
||||
setForm((current) => (countries.some((country) => country.code === current.countryCode) ? current : { ...current, countryCode: countries[0].code, methodId: "", moduleId: "" }));
|
||||
}, [countries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!methodsForCountry.length) {
|
||||
setForm((current) => (current.methodId ? { ...current, methodId: "" } : current));
|
||||
return;
|
||||
}
|
||||
setForm((current) =>
|
||||
methodsForCountry.some((method) => String(method.method_id) === String(current.methodId))
|
||||
? current
|
||||
: { ...current, methodId: String(methodsForCountry[0].method_id) }
|
||||
);
|
||||
}, [methodsForCountry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!regionOptions.length) {
|
||||
setForm((current) => (current.moduleId ? { ...current, moduleId: "" } : current));
|
||||
return;
|
||||
}
|
||||
setForm((current) => (regionOptions.some((option) => option.value === current.moduleId) ? current : { ...current, moduleId: regionOptions[0].value }));
|
||||
}, [regionOptions]);
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) {
|
||||
setQuery({ keyword: keyword.trim(), status });
|
||||
};
|
||||
|
||||
const updateForm = (key) => (value) => {
|
||||
setForm((current) => {
|
||||
const next = { ...current, [key]: value };
|
||||
if (key === "appCode") {
|
||||
return { ...next, countryCode: "", methodId: "", moduleId: "" };
|
||||
}
|
||||
if (key === "countryCode") {
|
||||
return { ...next, methodId: "", moduleId: "" };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const submitCreate = async (event) => {
|
||||
event.preventDefault();
|
||||
const usdMinorAmount = dollarsToMinor(form.usdAmount);
|
||||
if (!canCreate || !selectedMethod || !selectedModule || usdMinorAmount <= 0) {
|
||||
setCreateState({ error: "参数不完整", loading: false });
|
||||
return;
|
||||
}
|
||||
onCreateLink({ amountUsd, countryCode, moduleId, providerCode });
|
||||
setAmountUsd("99");
|
||||
setCreateState({ error: "", loading: true });
|
||||
try {
|
||||
const data = await createAdminTemporaryPaymentLink({
|
||||
appCode: form.appCode,
|
||||
paymentMethodId: Number(selectedMethod.method_id),
|
||||
providerCode: selectedMethod.provider_code,
|
||||
regionId: Number(selectedModule.regionId),
|
||||
returnUrl: defaultReturnUrl(form.appCode),
|
||||
usdMinorAmount
|
||||
});
|
||||
const link = normalizeCreatedLink(data, {
|
||||
method: selectedMethod,
|
||||
module: selectedModule
|
||||
});
|
||||
setState((current) => ({ ...current, items: mergeLinks([link, ...current.items]) }));
|
||||
onCreated?.(link);
|
||||
setDialogOpen(false);
|
||||
setCreateState({ error: "", loading: false });
|
||||
setQuery({ keyword: keyword.trim(), status });
|
||||
} catch (err) {
|
||||
setCreateState({ error: err.message || "创建失败", loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="money-panel money-link-panel">
|
||||
<PanelHead label="国家支付链接" meta={`${modules.length} modules`} value={String(links.length)} />
|
||||
<PanelHead
|
||||
label="支付链接"
|
||||
meta={state.error || `${modules.length} 个区域`}
|
||||
value={state.loading ? "..." : String(rows.length)}
|
||||
/>
|
||||
<form className="money-link-form" onSubmit={submit}>
|
||||
<Select label="负责模块" value={moduleId} onChange={setModuleId} options={modules.map((module) => [module.id, module.moduleName])} />
|
||||
<Select label="国家" value={countryCode} onChange={setCountryCode} options={countries.map((country) => [country.code, `${country.label} / ${country.currency}`])} />
|
||||
<Select label="渠道" value={providerCode} onChange={setProviderCode} options={providers.map((provider) => [provider.code, provider.label])} />
|
||||
<label className="money-field">
|
||||
<span>金额 USD</span>
|
||||
<input min="1" step="0.01" type="number" value={amountUsd} onChange={(event) => setAmountUsd(event.target.value)} />
|
||||
<label className="money-field money-filter--search">
|
||||
<span>搜索</span>
|
||||
<input placeholder="订单 / 用户 / 国家" value={keyword} onChange={(event) => setKeyword(event.target.value)} />
|
||||
</label>
|
||||
<button className="money-button money-button--primary" disabled={!canSubmit} type="submit">
|
||||
<Select label="状态" value={status} onChange={setStatus} options={statusOptions} />
|
||||
<button className="money-button" disabled={state.loading} type="submit">
|
||||
<SearchOutlined fontSize="small" />
|
||||
查询
|
||||
</button>
|
||||
<button className="money-button" disabled={state.loading} onClick={() => setQuery({ keyword: keyword.trim(), status })} type="button">
|
||||
<RefreshOutlined fontSize="small" />
|
||||
刷新
|
||||
</button>
|
||||
<button className="money-button money-button--primary" disabled={!appOptions.length} onClick={() => setDialogOpen(true)} type="button">
|
||||
<AddLinkOutlined fontSize="small" />
|
||||
创建链接
|
||||
创建支付链接
|
||||
</button>
|
||||
</form>
|
||||
<div className="money-link-list">
|
||||
{links.slice(0, 5).map((link) => (
|
||||
{rows.map((link) => (
|
||||
<article className="money-link-row" key={link.id}>
|
||||
<div>
|
||||
<strong>{link.moduleName}</strong>
|
||||
<span>{`${link.countryName} / ${link.providerName} / ${formatUsd(link.amountUsd)}`}</span>
|
||||
<code title={link.payUrl}>{link.payUrl}</code>
|
||||
<code title={link.payUrl}>{link.payUrl || "-"}</code>
|
||||
</div>
|
||||
<aside>
|
||||
<span className={`money-status ${link.status === "paid" ? "money-status--success" : ""}`}>{paymentStatusLabel(link.status)}</span>
|
||||
<span className={`money-status ${link.status === "paid" || link.status === "credited" ? "money-status--success" : ""}`}>
|
||||
{paymentStatusLabel(link.status)}
|
||||
</span>
|
||||
<small>{formatDateTime(link.createdAtMs)}</small>
|
||||
</aside>
|
||||
<nav>
|
||||
<button className="money-icon-button" onClick={() => onCopy(link.payUrl)} type="button" aria-label="复制链接">
|
||||
<button className="money-icon-button" disabled={!link.payUrl} onClick={() => onCopy(link.payUrl)} type="button" aria-label="复制链接">
|
||||
<ContentCopyOutlined fontSize="small" />
|
||||
</button>
|
||||
<button className="money-icon-button" onClick={() => openLink(link.payUrl)} type="button" aria-label="打开链接">
|
||||
<button className="money-icon-button" disabled={!link.payUrl} onClick={() => openLink(link.payUrl)} type="button" aria-label="打开链接">
|
||||
<OpenInNewOutlined fontSize="small" />
|
||||
</button>
|
||||
</nav>
|
||||
</article>
|
||||
))}
|
||||
{!rows.length ? <div className="money-payment-empty">当前无数据</div> : null}
|
||||
</div>
|
||||
{dialogOpen ? (
|
||||
<CreatePaymentDialog
|
||||
appOptions={appOptions}
|
||||
canCreate={canCreate}
|
||||
countries={countries}
|
||||
createState={createState}
|
||||
form={form}
|
||||
methodsForCountry={methodsForCountry}
|
||||
methodsState={methodsState}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onSubmit={submitCreate}
|
||||
onUpdate={updateForm}
|
||||
regionOptions={regionOptions}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CreatePaymentDialog({
|
||||
appOptions,
|
||||
canCreate,
|
||||
countries,
|
||||
createState,
|
||||
form,
|
||||
methodsForCountry,
|
||||
methodsState,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onUpdate,
|
||||
regionOptions
|
||||
}) {
|
||||
return (
|
||||
<div className="money-modal-backdrop" role="presentation">
|
||||
<section className="money-payment-dialog" role="dialog" aria-modal="true" aria-label="创建支付链接">
|
||||
<header>
|
||||
<div>
|
||||
<h2>创建支付链接</h2>
|
||||
<span>{methodsState.loading ? "加载支付方式" : `${countries.length} 个国家`}</span>
|
||||
</div>
|
||||
<button className="money-icon-button" onClick={onClose} type="button" aria-label="关闭">
|
||||
<CloseOutlined fontSize="small" />
|
||||
</button>
|
||||
</header>
|
||||
<form className="money-create-form" onSubmit={onSubmit}>
|
||||
<div className="money-create-grid">
|
||||
<Select label="App" value={form.appCode} onChange={onUpdate("appCode")} options={appOptions} />
|
||||
<Select label="国家" value={form.countryCode} onChange={onUpdate("countryCode")} options={countries.map((country) => [country.code, temporaryCountryLabel(country)])} />
|
||||
<Select label="区域" value={form.moduleId} onChange={onUpdate("moduleId")} options={regionOptions.map((option) => [option.value, option.label])} />
|
||||
<label className="money-field">
|
||||
<span>金额 USD</span>
|
||||
<input inputMode="decimal" placeholder="99.00" value={form.usdAmount} onChange={(event) => onUpdate("usdAmount")(event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<section className="money-method-select">
|
||||
<div className="money-method-select-head">
|
||||
<strong>支付方式</strong>
|
||||
<span>{methodsForCountry.length}</span>
|
||||
</div>
|
||||
<div className="money-method-options money-method-options--dialog">
|
||||
{methodsForCountry.length ? (
|
||||
methodsForCountry.map((method) => (
|
||||
<MethodButton
|
||||
active={String(method.method_id) === String(form.methodId)}
|
||||
key={method.method_id}
|
||||
method={method}
|
||||
onClick={() => onUpdate("methodId")(String(method.method_id))}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="money-payment-empty">{methodsState.loading ? "加载中" : methodsState.error || "当前无数据"}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<footer>
|
||||
{createState.error ? <span className="money-payment-message money-payment-message--error">{createState.error}</span> : <span />}
|
||||
<button className="money-button money-button--primary" disabled={!canCreate} type="submit">
|
||||
<AddLinkOutlined fontSize="small" />
|
||||
{createState.loading ? "创建中" : "创建链接"}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Select({ label, onChange, options, value }) {
|
||||
return (
|
||||
<label className="money-field">
|
||||
<span>{label}</span>
|
||||
<select value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
{options.map(([optionValue, optionLabel]) => (
|
||||
<option key={optionValue} value={optionValue}>
|
||||
{options.length ? options.map(([optionValue, optionLabel]) => (
|
||||
<option key={optionValue || "all"} value={optionValue}>
|
||||
{optionLabel}
|
||||
</option>
|
||||
))}
|
||||
)) : <option value="">当前无数据</option>}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function MethodButton({ active, method, onClick }) {
|
||||
return (
|
||||
<button className={active ? "money-method-option is-active" : "money-method-option"} onClick={onClick} type="button">
|
||||
<span className="money-method-logo">{method.logo_url ? <img alt={method.method_name || method.pay_type || "pay"} src={method.logo_url} /> : temporaryLogoText(method)}</span>
|
||||
<span className="money-method-copy">
|
||||
<strong>{method.method_name || method.pay_type || method.pay_way || "-"}</strong>
|
||||
<small>{[method.provider_name || method.provider_code, method.pay_way, method.pay_type].filter(Boolean).join(" / ")}</small>
|
||||
</span>
|
||||
<span className="money-method-tags">
|
||||
<b>{method.currency_code || "-"}</b>
|
||||
<em>ID {method.method_id}</em>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeLinks(data, modules) {
|
||||
const items = Array.isArray(data?.items) ? data.items : Array.isArray(data) ? data : [];
|
||||
return items.map((item) => normalizeAdminLink(item, modules)).filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeAdminLink(item, modules = []) {
|
||||
const order = item.order || item;
|
||||
const id = stringValue(order.orderId ?? order.order_id ?? order.id);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const appCode = stringValue(order.appCode ?? order.app_code);
|
||||
const regionId = numberValue(order.regionId ?? order.region_id);
|
||||
const ownerUserId = stringValue(order.ownerUserId ?? order.owner_user_id);
|
||||
const module = moduleForLink(modules, appCode, regionId, ownerUserId);
|
||||
const usdMinorAmount = order.usdMinorAmount ?? order.usd_minor_amount;
|
||||
return {
|
||||
amountUsd: usdMinorAmount === 0 || usdMinorAmount ? Number(usdMinorAmount) / 100 : Number(order.amountUsd ?? order.amount_usd ?? 0),
|
||||
appCode,
|
||||
countryCode: stringValue(order.countryCode ?? order.country_code),
|
||||
countryName: stringValue(order.countryName ?? order.country_name ?? order.countryCode ?? order.country_code) || "-",
|
||||
createdAtMs: order.createdAtMs ?? order.created_at_ms ?? order.createdAt ?? order.created_at,
|
||||
id,
|
||||
moduleId: module?.id || scopeKey(appCode, regionId),
|
||||
moduleName: module?.moduleName || [appCode, regionId ? `区域 ${regionId}` : ""].filter(Boolean).join(" / ") || "Temporary Pay",
|
||||
ownerUserId,
|
||||
payUrl: stringValue(item.paymentLink ?? item.payment_link ?? order.payUrl ?? order.pay_url),
|
||||
providerName: stringValue(order.providerName ?? order.provider_name ?? order.providerCode ?? order.provider_code) || "-",
|
||||
region: module?.region || (regionId ? String(regionId) : ""),
|
||||
regionId,
|
||||
status: stringValue(order.status)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLegacyLink(link, modules) {
|
||||
if (!link) {
|
||||
return null;
|
||||
}
|
||||
const module = moduleForLink(modules, link.appCode, link.regionId, link.ownerUserId) || modules.find((item) => item.id === link.moduleId);
|
||||
return {
|
||||
amountUsd: Number(link.amountUsd || 0),
|
||||
appCode: link.appCode || module?.appCode || "",
|
||||
countryCode: link.countryCode,
|
||||
countryName: link.countryName || link.countryCode || "-",
|
||||
createdAtMs: link.createdAtMs,
|
||||
id: link.id,
|
||||
moduleId: link.moduleId || module?.id || "",
|
||||
moduleName: link.moduleName || module?.moduleName || "-",
|
||||
ownerUserId: link.ownerUserId,
|
||||
payUrl: link.payUrl || "",
|
||||
providerName: link.providerName || "-",
|
||||
region: link.region || module?.region || "",
|
||||
regionId: link.regionId || module?.regionId || 0,
|
||||
status: link.status || ""
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCreatedLink(data, { method, module }) {
|
||||
return normalizeAdminLink(data, [module]) || {
|
||||
amountUsd: dollarsToMinor("0") / 100,
|
||||
appCode: module.appCode,
|
||||
countryCode: temporaryCountryCode(method),
|
||||
countryName: method.country_name || temporaryCountryCode(method),
|
||||
createdAtMs: Date.now(),
|
||||
id: `${module.appCode}:${module.regionId}:${Date.now()}`,
|
||||
moduleId: module.id,
|
||||
moduleName: module.moduleName,
|
||||
payUrl: "",
|
||||
providerName: stringValue(method.provider_name ?? method.provider_code) || "-",
|
||||
region: module.region,
|
||||
regionId: module.regionId,
|
||||
status: "redirected"
|
||||
};
|
||||
}
|
||||
|
||||
function regionsForCountry(modules, appCode, countryCode) {
|
||||
const normalizedAppCode = stringValue(appCode);
|
||||
const normalizedCountryCode = stringValue(countryCode).toUpperCase();
|
||||
const byScope = new Map();
|
||||
modules.forEach((module) => {
|
||||
if (module.appCode !== normalizedAppCode || Number(module.regionId) <= 0 || !moduleCoversCountry(module, normalizedCountryCode)) {
|
||||
return;
|
||||
}
|
||||
const key = scopeKey(module.appCode, module.regionId);
|
||||
if (!byScope.has(key)) {
|
||||
byScope.set(key, {
|
||||
label: `${module.moduleName} · ${module.region}`,
|
||||
module,
|
||||
value: key
|
||||
});
|
||||
}
|
||||
});
|
||||
return [...byScope.values()].sort((a, b) => a.label.localeCompare(b.label, "zh-Hans-CN"));
|
||||
}
|
||||
|
||||
function moduleCoversCountry(module, countryCode) {
|
||||
if (!countryCode) {
|
||||
return true;
|
||||
}
|
||||
const codes = Array.isArray(module.countryCodes) ? module.countryCodes : [];
|
||||
return module.countryCode === "ALL" || module.countryCode === countryCode || codes.includes(countryCode);
|
||||
}
|
||||
|
||||
function moduleForLink(modules, appCode, regionId, ownerUserId) {
|
||||
const normalizedOwner = stringValue(ownerUserId);
|
||||
return (
|
||||
modules.find(
|
||||
(module) =>
|
||||
module.appCode === appCode &&
|
||||
Number(module.regionId) === Number(regionId) &&
|
||||
normalizedOwner &&
|
||||
String(module.operatorId) === normalizedOwner
|
||||
) ||
|
||||
modules.find((module) => module.appCode === appCode && Number(module.regionId) === Number(regionId)) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function appOptionsFromModules(modules) {
|
||||
const byCode = new Map();
|
||||
modules.forEach((module) => {
|
||||
const appCode = stringValue(module.appCode);
|
||||
if (appCode && !byCode.has(appCode)) {
|
||||
byCode.set(appCode, module.appName || appCode);
|
||||
}
|
||||
});
|
||||
return [...byCode.entries()].sort((a, b) => String(a[1]).localeCompare(String(b[1]), "zh-Hans-CN"));
|
||||
}
|
||||
|
||||
function mergeLinks(items) {
|
||||
const byId = new Map();
|
||||
items.filter(Boolean).forEach((item) => {
|
||||
if (!item.id || byId.has(item.id)) {
|
||||
return;
|
||||
}
|
||||
byId.set(item.id, item);
|
||||
});
|
||||
return [...byId.values()].sort((a, b) => Number(b.createdAtMs || 0) - Number(a.createdAtMs || 0));
|
||||
}
|
||||
|
||||
function paymentStatusLabel(status) {
|
||||
if (status === "paid") {
|
||||
return "已支付";
|
||||
}
|
||||
if (status === "redirected") {
|
||||
return "已生成";
|
||||
}
|
||||
if (status === "pending") {
|
||||
return "待支付";
|
||||
}
|
||||
if (status === "failed") {
|
||||
return "失败";
|
||||
}
|
||||
if (status === "credited") {
|
||||
return "已入账";
|
||||
}
|
||||
return status || "-";
|
||||
}
|
||||
|
||||
function scopeKey(appCode, regionId) {
|
||||
return `${appCode}:${Number(regionId || 0)}`;
|
||||
}
|
||||
|
||||
function numberValue(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
|
||||
function stringValue(value) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function openLink(value) {
|
||||
if (value) {
|
||||
window.open(value, "_blank", "noopener,noreferrer");
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { formatNumber, formatPercent, formatUsd } from "../format.js";
|
||||
|
||||
export function PerformanceTable({ meta = "", modules, summary, title = "运营模块绩效" }) {
|
||||
export function PerformanceTable({ meta = "", modules, summary, title = "区域绩效" }) {
|
||||
const rows = [createTotalRow(summary), ...modules];
|
||||
return (
|
||||
<section className="money-panel money-table-panel">
|
||||
<div className="money-table-head">
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<span>{meta || `${modules.length} modules`}</span>
|
||||
<span>{meta || `${modules.length} 个区域`}</span>
|
||||
</div>
|
||||
<strong>{modules.length}</strong>
|
||||
</div>
|
||||
@ -15,9 +15,9 @@ export function PerformanceTable({ meta = "", modules, summary, title = "运营
|
||||
<table className="money-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>负责模块</th>
|
||||
<th>负责区域</th>
|
||||
<th>负责人</th>
|
||||
<th>模块类型</th>
|
||||
<th>区域类型</th>
|
||||
<th>国家 / 地区</th>
|
||||
<th>充值</th>
|
||||
<th>App 流水</th>
|
||||
@ -83,7 +83,7 @@ function Stack({ primary, secondary }) {
|
||||
}
|
||||
|
||||
function Scope({ scope }) {
|
||||
const label = scope === "app" ? "App" : scope === "region" ? "地区" : "汇总";
|
||||
const label = scope === "app" ? "全站" : scope === "region" ? "区域" : "汇总";
|
||||
return <span className={`money-scope money-scope--${scope}`}>{label}</span>;
|
||||
}
|
||||
|
||||
|
||||
@ -6,10 +6,10 @@ export {
|
||||
initialPaymentLinks,
|
||||
moneyAppOptions,
|
||||
moneyCountries,
|
||||
moneyModuleMatchesRegion,
|
||||
moneyModules,
|
||||
moneyOperators,
|
||||
moneyProviderOptions,
|
||||
moneyScopeOptions,
|
||||
paymentStatusLabel,
|
||||
summarizeMoneyModules,
|
||||
trendLabels
|
||||
|
||||
306
money/src/masterDataApi.js
Normal file
306
money/src/masterDataApi.js
Normal file
@ -0,0 +1,306 @@
|
||||
import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||
import { adminApiGet } from "./api.js";
|
||||
|
||||
export async function fetchMoneyMasterData() {
|
||||
const [scopeResult, performanceResult] = await Promise.allSettled([fetchMoneyScope(), fetchMoneyPerformance()]);
|
||||
const errors = [scopeResult, performanceResult]
|
||||
.filter((result) => result.status === "rejected")
|
||||
.map((result) => result.reason?.message || "主数据加载失败");
|
||||
const scope = scopeResult.status === "fulfilled" ? scopeResult.value : emptyScope();
|
||||
const performance = performanceResult.status === "fulfilled" ? performanceResult.value : { items: [], summary: {} };
|
||||
const modules = buildMoneyModules(scope, performance.items || []);
|
||||
|
||||
return {
|
||||
apps: scope.apps,
|
||||
countries: scope.countries,
|
||||
error: errors.join(";"),
|
||||
modules,
|
||||
performanceSummary: performance.summary || {},
|
||||
regions: scope.regions,
|
||||
scopes: scope.scopes
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchMoneyScope() {
|
||||
const data = await adminApiGet(apiEndpointPath(API_OPERATIONS.getMoneyScope));
|
||||
return {
|
||||
all: Boolean(data?.all),
|
||||
apps: normalizeAdminApps(data?.apps || []),
|
||||
countries: normalizeCountries(data?.countries || []),
|
||||
regions: normalizeRegions(data?.regions || []),
|
||||
scopes: normalizeScopes(data?.scopes || [])
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchMoneyPerformance(query = {}) {
|
||||
const data = await adminApiGet(apiEndpointPath(API_OPERATIONS.getMoneyPerformance), { query });
|
||||
return {
|
||||
items: Array.isArray(data?.items) ? data.items : [],
|
||||
summary: data?.summary || {}
|
||||
};
|
||||
}
|
||||
|
||||
export async function listMoneyApps() {
|
||||
return normalizeAdminApps(await adminApiGet(apiEndpointPath(API_OPERATIONS.listApps)));
|
||||
}
|
||||
|
||||
export async function listMoneyCountries(query = {}) {
|
||||
return normalizeCountries(await adminApiGet(apiEndpointPath(API_OPERATIONS.listCountries), { query }));
|
||||
}
|
||||
|
||||
export async function listMoneyRegions(query = {}) {
|
||||
return normalizeRegions(await adminApiGet(apiEndpointPath(API_OPERATIONS.listRegions), { query }));
|
||||
}
|
||||
|
||||
export function normalizeAdminApps(data) {
|
||||
return listItems(data)
|
||||
.map((item) => {
|
||||
const appCode = stringValue(item.appCode ?? item.app_code ?? item.code);
|
||||
if (!appCode) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
appCode,
|
||||
appId: item.appId ?? item.app_id ?? item.id,
|
||||
appName: stringValue(item.appName ?? item.app_name ?? item.name) || appCode,
|
||||
packageName: stringValue(item.packageName ?? item.package_name),
|
||||
platform: stringValue(item.platform),
|
||||
status: item.status
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function normalizeCountries(data) {
|
||||
return listItems(data)
|
||||
.map((item) => {
|
||||
const countryCode = stringValue(item.countryCode ?? item.country_code ?? item.code).toUpperCase();
|
||||
if (!countryCode) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
countryCode,
|
||||
countryDisplayName: stringValue(item.countryDisplayName ?? item.country_display_name ?? item.displayName ?? item.display_name),
|
||||
countryId: item.countryId ?? item.country_id ?? item.id,
|
||||
countryName: stringValue(item.countryName ?? item.country_name ?? item.name),
|
||||
enabled: item.enabled,
|
||||
flag: stringValue(item.flag),
|
||||
appCode: stringValue(item.appCode ?? item.app_code),
|
||||
isoAlpha3: stringValue(item.isoAlpha3 ?? item.iso_alpha3),
|
||||
isoNumeric: stringValue(item.isoNumeric ?? item.iso_numeric),
|
||||
phoneCountryCode: stringValue(item.phoneCountryCode ?? item.phone_country_code),
|
||||
regionId: numberValue(item.regionId ?? item.region_id),
|
||||
sortOrder: numberValue(item.sortOrder ?? item.sort_order)
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || countryLabel(a).localeCompare(countryLabel(b), "zh-Hans-CN"));
|
||||
}
|
||||
|
||||
export function normalizeRegions(data) {
|
||||
return listItems(data)
|
||||
.map((item) => {
|
||||
const regionId = item.regionId ?? item.region_id ?? item.id;
|
||||
const regionCode = stringValue(item.regionCode ?? item.region_code ?? item.code).toUpperCase();
|
||||
if (!(regionId === 0 || regionId) || regionCode === "GLOBAL") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
appCode: stringValue(item.appCode ?? item.app_code),
|
||||
countries: normalizeCountryCodes(item.countries ?? item.countryCodes ?? item.country_codes),
|
||||
name: stringValue(item.name ?? item.regionName ?? item.region_name) || regionCode || `区域 ${regionId}`,
|
||||
regionCode,
|
||||
regionId,
|
||||
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
|
||||
status: item.status
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || String(a.name).localeCompare(String(b.name), "zh-Hans-CN"));
|
||||
}
|
||||
|
||||
export function appOptionsFromMasterData(apps, fallbackOptions) {
|
||||
const options = uniqueOptions(apps.map((app) => [app.appCode, app.appName || app.appCode]));
|
||||
return options.length ? [["", "全部 App"], ...options] : fallbackOptions;
|
||||
}
|
||||
|
||||
export function countryOptionsFromMasterData(countries, fallbackCountries) {
|
||||
const options = uniqueOptions(countries.map((country) => [country.countryCode, countryLabel(country)]));
|
||||
return options.length ? [["", "全部国家"], ...options] : [["", "全部国家"], ...fallbackCountries.map((country) => [country.code, country.label])];
|
||||
}
|
||||
|
||||
export function regionOptionsFromMasterData(regions) {
|
||||
return [
|
||||
["", "全部区域"],
|
||||
...uniqueOptions(
|
||||
regions.map((region) => [
|
||||
String(region.regionId),
|
||||
[region.name, region.regionCode, region.regionId].filter(Boolean).join(" · ")
|
||||
])
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
export function countryLabel(country) {
|
||||
return [country.flag, country.countryDisplayName || country.countryName, country.countryCode].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
export function buildMoneyModules(scope, performanceItems = []) {
|
||||
const apps = scope?.apps || [];
|
||||
const regions = scope?.regions || [];
|
||||
const countries = scope?.countries || [];
|
||||
const performanceByRegion = new Map();
|
||||
performanceItems.forEach((item) => {
|
||||
const key = `${stringValue(item.appCode ?? item.app_code)}:${Number(item.regionId ?? item.region_id ?? 0)}:${Number(item.operatorUserId ?? item.operator_user_id ?? 0)}`;
|
||||
performanceByRegion.set(key, item);
|
||||
});
|
||||
|
||||
const modules = [];
|
||||
performanceByRegion.forEach((item) => {
|
||||
modules.push(moduleFromPerformance(item, apps, regions, countries));
|
||||
});
|
||||
|
||||
scope.scopes
|
||||
?.filter((scopeItem) => Number(scopeItem.regionId) >= 0)
|
||||
.forEach((scopeItem) => {
|
||||
if (Number(scopeItem.regionId) === 0) {
|
||||
const appRegions = regions.filter((region) => region.appCode === scopeItem.appCode);
|
||||
if (appRegions.length) {
|
||||
appRegions.forEach((region) => {
|
||||
const hasRegionModule = modules.some(
|
||||
(module) => module.appCode === scopeItem.appCode && Number(module.regionId) === Number(region.regionId)
|
||||
);
|
||||
if (!hasRegionModule) {
|
||||
modules.push(emptyModuleFromScope({ appCode: scopeItem.appCode, regionId: region.regionId }, apps, regions, countries));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const hasModule = modules.some(
|
||||
(module) => module.appCode === scopeItem.appCode && Number(module.regionId) === Number(scopeItem.regionId)
|
||||
);
|
||||
if (!hasModule) {
|
||||
modules.push(emptyModuleFromScope(scopeItem, apps, regions, countries));
|
||||
}
|
||||
});
|
||||
|
||||
if (!modules.length && scope.all) {
|
||||
regions.forEach((region) => modules.push(emptyModuleFromScope({ appCode: region.appCode, regionId: region.regionId }, apps, regions, countries)));
|
||||
}
|
||||
|
||||
return modules.sort((a, b) => b.rechargeUsd - a.rechargeUsd || a.moduleName.localeCompare(b.moduleName, "zh-Hans-CN"));
|
||||
}
|
||||
|
||||
function moduleFromPerformance(item, apps, regions, countries) {
|
||||
const appCode = stringValue(item.appCode ?? item.app_code);
|
||||
const regionId = Number(item.regionId ?? item.region_id ?? 0);
|
||||
const app = apps.find((nextApp) => nextApp.appCode === appCode) || {};
|
||||
const region = regions.find((nextRegion) => nextRegion.appCode === appCode && Number(nextRegion.regionId) === regionId) || {};
|
||||
const regionCountries = countries.filter((country) => country.appCode === appCode && Number(country.regionId) === regionId);
|
||||
const paidUsd = Number(item.paidUsdMinor ?? item.paid_usd_minor ?? 0) / 100;
|
||||
const paidLinkCount = Number(item.paidLinkCount ?? item.paid_link_count ?? 0);
|
||||
const operatorId = String(item.operatorUserId ?? item.operator_user_id ?? "0");
|
||||
const operatorName = stringValue(item.operatorName ?? item.operator_name ?? item.operatorAccount ?? item.operator_account) || "未归属";
|
||||
return {
|
||||
appCode,
|
||||
appName: app.appName || appCode,
|
||||
coinIssued: 0,
|
||||
countryCode: regionCountries[0]?.countryCode || "ALL",
|
||||
countryCodes: regionCountries.map((country) => country.countryCode),
|
||||
countryName: regionCountries.length ? regionCountries.map(countryLabel).join(" / ") : "全部国家",
|
||||
dau: 0,
|
||||
dayTargetUsd: 0,
|
||||
department: stringValue(item.operatorAccount ?? item.operator_account),
|
||||
id: `${appCode}:${regionId}:${operatorId}`,
|
||||
linkCount: paidLinkCount,
|
||||
moduleName: `${app.appName || appCode} / ${region.name || (regionId === 0 ? "全部区域" : `区域 ${regionId}`)}`,
|
||||
monthActualUsd: paidUsd,
|
||||
monthTargetUsd: 0,
|
||||
newUsers: 0,
|
||||
operatorId,
|
||||
operatorName,
|
||||
paidLinkCount,
|
||||
paidUsers: paidLinkCount,
|
||||
rechargeTrend: [0, 0, 0, 0, 0, 0, paidUsd],
|
||||
rechargeUsd: paidUsd,
|
||||
region: [region.name, region.regionCode].filter(Boolean).join(" · ") || (regionId === 0 ? "全部区域" : String(regionId)),
|
||||
regionId,
|
||||
retention1: 0,
|
||||
retention7: 0,
|
||||
retention30: 0,
|
||||
revenueTrend: [0, 0, 0, 0, 0, 0, paidUsd],
|
||||
revenueUsd: paidUsd,
|
||||
salaryUsd: 0,
|
||||
scope: regionId === 0 ? "app" : "region"
|
||||
};
|
||||
}
|
||||
|
||||
function emptyModuleFromScope(scopeItem, apps, regions, countries) {
|
||||
return moduleFromPerformance(
|
||||
{
|
||||
appCode: scopeItem.appCode,
|
||||
operatorName: "-",
|
||||
paidLinkCount: 0,
|
||||
paidUsdMinor: 0,
|
||||
regionId: scopeItem.regionId
|
||||
},
|
||||
apps,
|
||||
regions,
|
||||
countries
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeScopes(scopes) {
|
||||
return (Array.isArray(scopes) ? scopes : [])
|
||||
.map((scope) => ({
|
||||
appCode: stringValue(scope.appCode ?? scope.app_code),
|
||||
regionId: numberValue(scope.regionId ?? scope.region_id)
|
||||
}))
|
||||
.filter((scope) => scope.appCode && scope.regionId >= 0);
|
||||
}
|
||||
|
||||
function emptyScope() {
|
||||
return { all: false, apps: [], countries: [], regions: [], scopes: [] };
|
||||
}
|
||||
|
||||
function listItems(data) {
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
if (Array.isArray(data?.items)) {
|
||||
return data.items;
|
||||
}
|
||||
if (Array.isArray(data?.list)) {
|
||||
return data.list;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeCountryCodes(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return [...new Set(value.map((item) => stringValue(item).toUpperCase()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function uniqueOptions(options) {
|
||||
const seen = new Set();
|
||||
return options.filter(([value]) => {
|
||||
const key = String(value || "");
|
||||
if (!key || seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function stringValue(value) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function numberValue(value) {
|
||||
const nextValue = Number(value);
|
||||
return Number.isFinite(nextValue) ? nextValue : 0;
|
||||
}
|
||||
112
money/src/masterDataApi.test.js
Normal file
112
money/src/masterDataApi.test.js
Normal file
@ -0,0 +1,112 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import {
|
||||
countryOptionsFromMasterData,
|
||||
fetchMoneyMasterData,
|
||||
normalizeAdminApps,
|
||||
normalizeCountries,
|
||||
normalizeRegions,
|
||||
regionOptionsFromMasterData
|
||||
} from "./masterDataApi.js";
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("normalizes app country and region master data for money filters", () => {
|
||||
expect(normalizeAdminApps({ items: [{ app_code: "lalu", name: "Lalu" }] })).toEqual([
|
||||
{ appCode: "lalu", appId: undefined, appName: "Lalu", packageName: "", platform: "", status: undefined }
|
||||
]);
|
||||
expect(normalizeCountries({ items: [{ country_code: "sa", country_display_name: "沙特", sort_order: 2 }] })).toEqual([
|
||||
{
|
||||
appCode: "",
|
||||
countryCode: "SA",
|
||||
countryDisplayName: "沙特",
|
||||
countryId: undefined,
|
||||
countryName: "",
|
||||
enabled: undefined,
|
||||
flag: "",
|
||||
isoAlpha3: "",
|
||||
isoNumeric: "",
|
||||
phoneCountryCode: "",
|
||||
regionId: 0,
|
||||
sortOrder: 2
|
||||
}
|
||||
]);
|
||||
expect(normalizeRegions({ items: [{ countries: ["sa", "AE"], name: "中东", region_code: "me", region_id: 7 }] })).toEqual([
|
||||
{
|
||||
appCode: "",
|
||||
countries: ["SA", "AE"],
|
||||
name: "中东",
|
||||
regionCode: "ME",
|
||||
regionId: 7,
|
||||
sortOrder: 0,
|
||||
status: undefined
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds select options from real money master data", () => {
|
||||
const countries = normalizeCountries({ items: [{ countryCode: "BR", countryDisplayName: "巴西" }] });
|
||||
const regions = normalizeRegions({ items: [{ countries: ["BR"], name: "拉美", regionCode: "LATAM", regionId: 3 }] });
|
||||
|
||||
expect(countryOptionsFromMasterData(countries, [])).toEqual([
|
||||
["", "全部国家"],
|
||||
["BR", "巴西 · BR"]
|
||||
]);
|
||||
expect(regionOptionsFromMasterData(regions)).toEqual([
|
||||
["", "全部区域"],
|
||||
["3", "拉美 · LATAM · 3"]
|
||||
]);
|
||||
});
|
||||
|
||||
test("fetches money master data from real admin API paths", async () => {
|
||||
const calls = [];
|
||||
window.localStorage.setItem("hyapp-admin.access-token", "token-1");
|
||||
window.localStorage.setItem("hyapp-admin.app-code", "yumi");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input, init) => {
|
||||
calls.push({ init, url: String(input) });
|
||||
const url = String(input);
|
||||
if (url.includes("/v1/admin/money/scope")) {
|
||||
return jsonResponse({
|
||||
all: false,
|
||||
apps: [{ appCode: "lalu", appName: "Lalu" }],
|
||||
countries: [{ appCode: "lalu", countryCode: "SA", countryDisplayName: "沙特", regionId: 2 }],
|
||||
regions: [{ appCode: "lalu", countries: ["SA"], name: "中东", regionCode: "ME", regionId: 2 }],
|
||||
scopes: [{ appCode: "lalu", regionId: 2 }]
|
||||
});
|
||||
}
|
||||
if (url.includes("/v1/admin/money/performance")) {
|
||||
return jsonResponse({
|
||||
items: [{ appCode: "lalu", operatorName: "Admin", operatorUserId: 1, paidLinkCount: 1, paidUsdMinor: 1299, regionId: 2 }],
|
||||
summary: { paidLinkCount: 1, paidUsdMinor: 1299 }
|
||||
});
|
||||
}
|
||||
return jsonResponse(null, 404);
|
||||
})
|
||||
);
|
||||
|
||||
const data = await fetchMoneyMasterData();
|
||||
|
||||
expect(data.error).toBe("");
|
||||
expect(data.apps.map((app) => app.appCode)).toEqual(["lalu"]);
|
||||
expect(data.countries.map((country) => country.countryCode)).toEqual(["SA"]);
|
||||
expect(data.regions.map((region) => region.regionId)).toEqual([2]);
|
||||
expect(data.modules).toHaveLength(1);
|
||||
expect(data.modules[0].rechargeUsd).toBe(12.99);
|
||||
expect(calls.map((call) => new URL(call.url).pathname).sort()).toEqual([
|
||||
"/api/v1/admin/money/performance",
|
||||
"/api/v1/admin/money/scope"
|
||||
]);
|
||||
expect(calls[0].init.headers.Authorization).toBe("Bearer token-1");
|
||||
expect(calls[0].init.headers["X-App-Code"]).toBe("yumi");
|
||||
});
|
||||
|
||||
function jsonResponse(data, status = 200) {
|
||||
return new Response(JSON.stringify({ code: status === 200 ? 0 : status, data, message: status === 200 ? "" : "error" }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status
|
||||
});
|
||||
}
|
||||
@ -22,12 +22,6 @@ export const moneyAppOptions = [
|
||||
["spark-chat", "Spark Chat"],
|
||||
];
|
||||
|
||||
export const moneyScopeOptions = [
|
||||
["", "全部模块"],
|
||||
["app", "App 模块"],
|
||||
["region", "App 地区模块"],
|
||||
];
|
||||
|
||||
export const moneyProviderOptions = [
|
||||
{ code: "mifapay", label: "MiFaPay", countries: ["AE", "SA", "TR"] },
|
||||
{ code: "v5pay", label: "V5Pay", countries: ["BR", "MX", "ID"] },
|
||||
@ -256,25 +250,53 @@ export function filterMoneyModules({
|
||||
mode = "finance",
|
||||
modules = moneyModules,
|
||||
operatorKeyword = "",
|
||||
scope = "",
|
||||
regionId = "",
|
||||
regions = [],
|
||||
} = {}) {
|
||||
const normalizedKeyword = operatorKeyword.trim().toLowerCase();
|
||||
|
||||
return modules.filter((module) => {
|
||||
const matchesRole = mode !== "operator" || module.operatorId === activeOperatorId;
|
||||
const matchesApp = !appCode || module.appCode === appCode;
|
||||
const matchesScope = !scope || module.scope === scope;
|
||||
const matchesCountry =
|
||||
!countryCode || module.countryCode === countryCode || (module.scope === "app" && module.countryCode === "ALL");
|
||||
const matchesRegion = !regionId || moneyModuleMatchesRegion(module, regionId, regions);
|
||||
const matchesOperator =
|
||||
!normalizedKeyword ||
|
||||
module.operatorName.toLowerCase().includes(normalizedKeyword) ||
|
||||
module.department.toLowerCase().includes(normalizedKeyword);
|
||||
|
||||
return matchesRole && matchesApp && matchesScope && matchesCountry && matchesOperator;
|
||||
return matchesRole && matchesApp && matchesCountry && matchesRegion && matchesOperator;
|
||||
});
|
||||
}
|
||||
|
||||
export function moneyModuleMatchesRegion(module, regionId, regions = []) {
|
||||
if (module.scope === "app" && module.countryCode === "ALL") {
|
||||
return true;
|
||||
}
|
||||
const normalizedRegionId = String(regionId || "");
|
||||
if (!normalizedRegionId) {
|
||||
return true;
|
||||
}
|
||||
if ((module.regionId === 0 || module.regionId) && String(module.regionId) === normalizedRegionId) {
|
||||
return true;
|
||||
}
|
||||
const region = regions.find((item) => String(item.regionId ?? item.value ?? item.id) === normalizedRegionId);
|
||||
if (!region) {
|
||||
return false;
|
||||
}
|
||||
const countryCodes = new Set(
|
||||
(region.countries || region.countryCodes || region.country_codes || [])
|
||||
.map((countryCode) =>
|
||||
String(countryCode || "")
|
||||
.trim()
|
||||
.toUpperCase(),
|
||||
)
|
||||
.filter(Boolean),
|
||||
);
|
||||
return countryCodes.has(String(module.countryCode || "").toUpperCase());
|
||||
}
|
||||
|
||||
export function summarizeMoneyModules(modules = []) {
|
||||
const rechargeUsd = sumBy(modules, "rechargeUsd");
|
||||
const revenueUsd = sumBy(modules, "revenueUsd");
|
||||
|
||||
@ -21,6 +21,16 @@ test("keeps app-level modules visible when filtering by country", () => {
|
||||
expect(modules.map((module) => module.id)).toEqual(["hy-live-app", "social-pro-brazil"]);
|
||||
});
|
||||
|
||||
test("filters region modules from real region country mappings", () => {
|
||||
const modules = filterMoneyModules({
|
||||
mode: "finance",
|
||||
regionId: "2",
|
||||
regions: [{ countries: ["SA", "AE"], name: "中东", regionCode: "ME", regionId: 2 }],
|
||||
});
|
||||
|
||||
expect(modules.map((module) => module.id)).toEqual(["hy-live-app", "voice-party-ksa", "voice-party-uae"]);
|
||||
});
|
||||
|
||||
test("summarizes visible modules for KPI cards and progress bars", () => {
|
||||
const summary = summarizeMoneyModules(filterMoneyModules({ activeOperatorId: "leo-wang", mode: "operator" }));
|
||||
|
||||
|
||||
55
money/src/paymentLinksApi.js
Normal file
55
money/src/paymentLinksApi.js
Normal file
@ -0,0 +1,55 @@
|
||||
import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||
import { adminApiGet, adminApiRequest } from "./api.js";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export function listAdminTemporaryPaymentLinks({ appCode = "", keyword = "", operatorUserId = "", providerCode = "", regionId = "", status = "" } = {}) {
|
||||
return adminApiGet(apiEndpointPath(API_OPERATIONS.listTemporaryPaymentLinks), {
|
||||
query: {
|
||||
app_code: appCode,
|
||||
keyword,
|
||||
operator_user_id: operatorUserId,
|
||||
page: 1,
|
||||
page_size: PAGE_SIZE,
|
||||
provider_code: providerCode,
|
||||
region_id: regionId,
|
||||
status
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getAdminTemporaryPaymentLink(orderId) {
|
||||
return adminApiGet(apiEndpointPath(API_OPERATIONS.getTemporaryPaymentLink, { order_id: orderId }));
|
||||
}
|
||||
|
||||
export function createAdminTemporaryPaymentLink(payload) {
|
||||
return adminApiRequest(apiEndpointPath(API_OPERATIONS.createTemporaryPaymentLink), {
|
||||
body: payload,
|
||||
method: "POST"
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAdminPaymentMethods(appCode) {
|
||||
const data = await adminApiGet(apiEndpointPath(API_OPERATIONS.listThirdPartyPaymentChannels), {
|
||||
headers: appCode ? { "X-App-Code": appCode } : undefined
|
||||
});
|
||||
const channels = Array.isArray(data?.items) ? data.items : [];
|
||||
return channels.flatMap((channel) =>
|
||||
(Array.isArray(channel.methods) ? channel.methods : []).map((method) => ({
|
||||
...method,
|
||||
app_code: method.appCode ?? method.app_code ?? channel.appCode ?? channel.app_code,
|
||||
country_code: method.countryCode ?? method.country_code,
|
||||
country_name: method.countryName ?? method.country_name,
|
||||
currency_code: method.currencyCode ?? method.currency_code,
|
||||
logo_url: method.logoUrl ?? method.logo_url,
|
||||
method_id: method.methodId ?? method.method_id,
|
||||
method_name: method.methodName ?? method.method_name,
|
||||
pay_type: method.payType ?? method.pay_type,
|
||||
pay_way: method.payWay ?? method.pay_way,
|
||||
provider_code: method.providerCode ?? method.provider_code ?? channel.providerCode ?? channel.provider_code,
|
||||
provider_name: method.providerName ?? method.provider_name ?? channel.providerName ?? channel.provider_name,
|
||||
sort_order: method.sortOrder ?? method.sort_order,
|
||||
status: method.status
|
||||
}))
|
||||
);
|
||||
}
|
||||
@ -372,6 +372,26 @@ button {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.money-filter select:disabled,
|
||||
.money-field select:disabled {
|
||||
cursor: not-allowed;
|
||||
background: #f8fafc;
|
||||
color: #98a2b3;
|
||||
}
|
||||
|
||||
.money-filter-status {
|
||||
display: inline-flex;
|
||||
min-height: 28px;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(183, 121, 31, 0.25);
|
||||
border-radius: 999px;
|
||||
background: rgba(183, 121, 31, 0.08);
|
||||
padding: 0 10px;
|
||||
color: var(--money-warning);
|
||||
font-size: 12px;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.money-filter select:hover,
|
||||
.money-filter input:hover,
|
||||
.money-field select:hover,
|
||||
@ -728,6 +748,317 @@ button {
|
||||
color: var(--money-accent);
|
||||
}
|
||||
|
||||
.money-temp-grid {
|
||||
display: grid;
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(360px, 0.78fr);
|
||||
}
|
||||
|
||||
.money-temp-form-panel,
|
||||
.money-result-panel,
|
||||
.money-temp-side {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.money-temp-form,
|
||||
.money-temp-side,
|
||||
.money-order-result {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.money-temp-row {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.money-temp-row--wide {
|
||||
grid-template-columns: minmax(180px, 0.8fr) minmax(260px, 1.2fr);
|
||||
}
|
||||
|
||||
.money-field--wide {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.money-segmented-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.money-segmented-field > span {
|
||||
color: var(--money-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 780;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.money-segmented {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
min-height: 38px;
|
||||
padding: 4px;
|
||||
border: 1px solid #cfd9e5;
|
||||
border-radius: 10px;
|
||||
background: #f7f9fc;
|
||||
}
|
||||
|
||||
.money-segmented button {
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #526070;
|
||||
font-size: 12px;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.money-segmented button.is-active,
|
||||
.money-segmented button:hover {
|
||||
background: #ffffff;
|
||||
color: var(--money-ink);
|
||||
box-shadow: 0 4px 14px rgba(16, 24, 40, 0.08);
|
||||
}
|
||||
|
||||
.money-temp-picker {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.money-temp-picker-head {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.money-temp-picker-head strong {
|
||||
color: var(--money-ink);
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.money-temp-picker-head > span {
|
||||
color: var(--money-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.money-temp-picker-head label {
|
||||
display: flex;
|
||||
width: min(280px, 100%);
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border: 1px solid #cfd9e5;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
padding: 0 10px;
|
||||
color: var(--money-muted);
|
||||
}
|
||||
|
||||
.money-temp-picker-head input {
|
||||
height: 34px;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.money-country-options,
|
||||
.money-method-options {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 312px;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.money-country-options {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.money-country-options button,
|
||||
.money-method-option {
|
||||
min-width: 0;
|
||||
border: 1px solid #e3eaf2;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
color: var(--money-ink);
|
||||
text-align: left;
|
||||
transition: background-color 150ms ease, border-color 150ms ease, box-shadow 150ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.money-country-options button {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.money-country-options button:hover,
|
||||
.money-country-options button.is-active,
|
||||
.money-method-option:hover,
|
||||
.money-method-option.is-active {
|
||||
border-color: rgba(47, 109, 246, 0.44);
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.money-country-options button.is-active,
|
||||
.money-method-option.is-active {
|
||||
box-shadow: inset 3px 0 0 var(--money-accent);
|
||||
}
|
||||
|
||||
.money-country-options strong {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 820;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.money-country-options span {
|
||||
color: var(--money-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.money-method-option {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||
padding: 11px;
|
||||
}
|
||||
|
||||
.money-method-logo {
|
||||
display: grid;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dce5ef;
|
||||
border-radius: 12px;
|
||||
background: #f7f9fc;
|
||||
color: var(--money-accent);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.money-method-logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.money-method-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.money-method-copy strong,
|
||||
.money-method-copy small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.money-method-copy strong {
|
||||
color: var(--money-ink);
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.money-method-copy small {
|
||||
color: var(--money-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.money-method-tags {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.money-method-tags b,
|
||||
.money-method-tags em {
|
||||
display: inline-flex;
|
||||
min-height: 24px;
|
||||
align-items: center;
|
||||
border: 1px solid #dce5ef;
|
||||
border-radius: 999px;
|
||||
padding: 0 8px;
|
||||
color: #526070;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.money-method-tags b {
|
||||
border-color: rgba(47, 109, 246, 0.24);
|
||||
background: rgba(47, 109, 246, 0.1);
|
||||
color: var(--money-accent);
|
||||
}
|
||||
|
||||
.money-payment-actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.money-payment-message {
|
||||
color: var(--money-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.money-payment-message--success {
|
||||
color: var(--money-success);
|
||||
}
|
||||
|
||||
.money-payment-message--error {
|
||||
color: var(--money-warning);
|
||||
}
|
||||
|
||||
.money-payment-empty {
|
||||
min-height: 46px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px dashed #cfd9e5;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
color: var(--money-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.money-result-row {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.money-result-row > strong {
|
||||
color: var(--money-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 830;
|
||||
}
|
||||
|
||||
.money-result-row > div {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
border: 1px solid #e3eaf2;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
padding: 10px 11px;
|
||||
color: var(--money-ink);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.money-link-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
@ -801,6 +1132,12 @@ button {
|
||||
color: var(--money-success);
|
||||
}
|
||||
|
||||
.money-status--danger {
|
||||
border-color: rgba(196, 47, 47, 0.26);
|
||||
background: rgba(196, 47, 47, 0.08);
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.money-table-panel {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
@ -830,6 +1167,10 @@ button {
|
||||
min-width: 1120px;
|
||||
}
|
||||
|
||||
.money-table--payments {
|
||||
min-width: 1380px;
|
||||
}
|
||||
|
||||
.money-table th,
|
||||
.money-table td {
|
||||
height: 52px;
|
||||
@ -875,6 +1216,177 @@ button {
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.money-link-table-head {
|
||||
min-height: 68px;
|
||||
}
|
||||
|
||||
.money-link-toolbar {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.money-link-toolbar .money-field {
|
||||
min-width: 128px;
|
||||
}
|
||||
|
||||
.money-table-message,
|
||||
.money-link-footer {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid #e3eaf2;
|
||||
background: #fff8ed;
|
||||
color: var(--money-warning);
|
||||
font-size: 12px;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.money-link-footer {
|
||||
justify-content: flex-start;
|
||||
border-top: 1px solid #e3eaf2;
|
||||
border-bottom: 0;
|
||||
background: #f8fafc;
|
||||
color: var(--money-muted);
|
||||
}
|
||||
|
||||
.money-row-actions,
|
||||
.money-copyable,
|
||||
.money-payment-url {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.money-row-actions {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.money-copyable > span,
|
||||
.money-payment-url code {
|
||||
overflow: hidden;
|
||||
max-width: 260px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.money-payment-url code {
|
||||
display: inline-block;
|
||||
color: #475467;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.money-empty-cell {
|
||||
height: 120px;
|
||||
color: var(--money-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.money-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(16, 24, 40, 0.38);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.money-payment-dialog {
|
||||
width: min(920px, calc(100vw - 40px));
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(207, 217, 229, 0.96);
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 28px 80px rgba(16, 24, 40, 0.26);
|
||||
}
|
||||
|
||||
.money-payment-dialog > header,
|
||||
.money-create-form > footer {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.money-payment-dialog > header {
|
||||
border-bottom: 1px solid #e3eaf2;
|
||||
}
|
||||
|
||||
.money-payment-dialog h2 {
|
||||
margin: 0 0 3px;
|
||||
font-size: 18px;
|
||||
font-weight: 880;
|
||||
}
|
||||
|
||||
.money-payment-dialog header span {
|
||||
color: var(--money-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.money-create-form {
|
||||
display: grid;
|
||||
max-height: calc(100vh - 138px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.money-create-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.money-method-select {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.money-method-select-head {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.money-method-select-head strong {
|
||||
color: var(--money-ink);
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.money-method-select-head span {
|
||||
color: var(--money-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.money-method-options--dialog {
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.money-create-form > footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
border-top: 1px solid #e3eaf2;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.money-scope--app {
|
||||
border-color: rgba(47, 109, 246, 0.34);
|
||||
background: rgba(47, 109, 246, 0.1);
|
||||
@ -1030,9 +1542,12 @@ button {
|
||||
}
|
||||
|
||||
.money-analysis,
|
||||
.money-temp-grid,
|
||||
.money-create-grid,
|
||||
.money-link-form,
|
||||
.money-workbench-grid,
|
||||
.money-country-grid {
|
||||
.money-country-grid,
|
||||
.money-temp-row--wide {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@ -1069,7 +1584,8 @@ button {
|
||||
|
||||
.money-header-tools,
|
||||
.money-filter-group,
|
||||
.money-actions {
|
||||
.money-actions,
|
||||
.money-link-toolbar {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
@ -1088,6 +1604,10 @@ button {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.money-country-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.money-link-row aside {
|
||||
justify-items: start;
|
||||
}
|
||||
@ -1110,10 +1630,20 @@ button {
|
||||
.money-retention,
|
||||
.money-signal-grid,
|
||||
.money-operator-cards,
|
||||
.money-country-metrics {
|
||||
.money-country-metrics,
|
||||
.money-temp-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.money-method-option {
|
||||
grid-template-columns: 38px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.money-method-tags {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.money-header-title h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
@ -1145,6 +1675,14 @@ button {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.money-payment-dialog {
|
||||
width: calc(100vw - 24px);
|
||||
}
|
||||
|
||||
.money-modal-backdrop {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.money-trend-chart {
|
||||
height: 238px;
|
||||
}
|
||||
|
||||
117
money/src/temporaryPaymentApi.js
Normal file
117
money/src/temporaryPaymentApi.js
Normal file
@ -0,0 +1,117 @@
|
||||
const RETURN_URL_BASE = "https://h5.global-interaction.com/recharge/index.html";
|
||||
|
||||
export const TEMPORARY_RETURN_APPS = ["lalu", "yumi", "aslan"];
|
||||
|
||||
export function defaultReturnUrl(appCode = "lalu") {
|
||||
const url = new URL(RETURN_URL_BASE);
|
||||
url.searchParams.set("app_code", String(appCode || "lalu").trim() || "lalu");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function isKnownDefaultReturnUrl(value) {
|
||||
const text = String(value || "").trim();
|
||||
return TEMPORARY_RETURN_APPS.some((appCode) => text === defaultReturnUrl(appCode));
|
||||
}
|
||||
|
||||
export function dollarsToMinor(value) {
|
||||
const text = String(value || "").trim();
|
||||
if (!/^\d+(\.\d{1,2})?$/.test(text)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round(Number(text) * 100);
|
||||
}
|
||||
|
||||
export function extractOrderId(value) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const url = new URL(text, window.location.href);
|
||||
const queryId = url.searchParams.get("order_id") || url.searchParams.get("orderId");
|
||||
if (queryId) {
|
||||
return queryId.trim();
|
||||
}
|
||||
const match = url.pathname.match(/temporary-pay\/([^/]+)\.html$/);
|
||||
if (match) {
|
||||
return decodeURIComponent(match[1]);
|
||||
}
|
||||
} catch {
|
||||
// Plain order ids are valid input.
|
||||
}
|
||||
return text.replace(/^order_id=/, "").trim();
|
||||
}
|
||||
|
||||
export function formatMinorUsd(value) {
|
||||
if (!(value === 0 || value)) {
|
||||
return "-";
|
||||
}
|
||||
return `USD ${(Number(value) / 100).toLocaleString("zh-CN", {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 2
|
||||
})}`;
|
||||
}
|
||||
|
||||
export function buildTemporaryCountries(sourceMethods) {
|
||||
const byCode = {};
|
||||
sourceMethods.forEach((method, index) => {
|
||||
const code = temporaryCountryCode(method);
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
if (!byCode[code]) {
|
||||
byCode[code] = {
|
||||
code,
|
||||
count: 0,
|
||||
currency: method.currency_code || "",
|
||||
firstSort: Number(method.sort_order || 0) || index,
|
||||
name: method.country_name || code
|
||||
};
|
||||
}
|
||||
byCode[code].count += 1;
|
||||
});
|
||||
return Object.keys(byCode)
|
||||
.map((code) => byCode[code])
|
||||
.sort((a, b) => a.firstSort - b.firstSort);
|
||||
}
|
||||
|
||||
export function temporaryCountryCode(method) {
|
||||
return String(method?.country_code || "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
export function temporaryCountryLabel(country) {
|
||||
if (!country) {
|
||||
return "";
|
||||
}
|
||||
return country.name && country.name !== country.code ? `${country.name} · ${country.code}` : country.code;
|
||||
}
|
||||
|
||||
export function temporaryMethodLabel(method) {
|
||||
return [method?.method_name, method?.pay_way, method?.pay_type, method?.provider_name].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function temporaryLogoText(method) {
|
||||
const value = method?.method_name || method?.pay_type || method?.provider_name || method?.provider_code || "Pay";
|
||||
return String(value).replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function temporaryStatusLabel(status) {
|
||||
if (status === "paid") {
|
||||
return "已支付";
|
||||
}
|
||||
if (status === "redirected") {
|
||||
return "已生成";
|
||||
}
|
||||
if (status === "pending") {
|
||||
return "待支付";
|
||||
}
|
||||
if (status === "failed") {
|
||||
return "失败";
|
||||
}
|
||||
if (status === "credited") {
|
||||
return "已入账";
|
||||
}
|
||||
return status || "-";
|
||||
}
|
||||
35
money/src/temporaryPaymentApi.test.js
Normal file
35
money/src/temporaryPaymentApi.test.js
Normal file
@ -0,0 +1,35 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { buildTemporaryCountries, defaultReturnUrl, dollarsToMinor, extractOrderId, temporaryStatusLabel } from "./temporaryPaymentApi.js";
|
||||
|
||||
test("converts usd text to minor amount with strict two-decimal validation", () => {
|
||||
expect(dollarsToMinor("300")).toBe(30000);
|
||||
expect(dollarsToMinor("9.99")).toBe(999);
|
||||
expect(dollarsToMinor("9.999")).toBe(0);
|
||||
expect(dollarsToMinor("abc")).toBe(0);
|
||||
});
|
||||
|
||||
test("extracts temporary order id from plain text, query, and wrapped html url", () => {
|
||||
expect(extractOrderId("ORDER-1")).toBe("ORDER-1");
|
||||
expect(extractOrderId("order_id=ORDER-2")).toBe("ORDER-2");
|
||||
expect(extractOrderId("https://pay.example/pay?order_id=ORDER-3")).toBe("ORDER-3");
|
||||
expect(extractOrderId("https://cos.example/temporary-pay/ORDER-4.html")).toBe("ORDER-4");
|
||||
});
|
||||
|
||||
test("builds country rows from temporary payment methods by first sort order", () => {
|
||||
const countries = buildTemporaryCountries([
|
||||
{ country_code: "BR", country_name: "Brazil", currency_code: "BRL", method_id: 2, sort_order: 20 },
|
||||
{ country_code: "SA", country_name: "Saudi", currency_code: "SAR", method_id: 1, sort_order: 10 },
|
||||
{ country_code: "BR", country_name: "Brazil", currency_code: "BRL", method_id: 3, sort_order: 30 }
|
||||
]);
|
||||
|
||||
expect(countries).toEqual([
|
||||
{ code: "SA", count: 1, currency: "SAR", firstSort: 10, name: "Saudi" },
|
||||
{ code: "BR", count: 2, currency: "BRL", firstSort: 20, name: "Brazil" }
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps h5 return app separate from payment app code", () => {
|
||||
expect(defaultReturnUrl("yumi")).toBe("https://h5.global-interaction.com/recharge/index.html?app_code=yumi");
|
||||
expect(defaultReturnUrl("unknown")).toBe("https://h5.global-interaction.com/recharge/index.html?app_code=unknown");
|
||||
expect(temporaryStatusLabel("credited")).toBe("已入账");
|
||||
});
|
||||
@ -39,6 +39,7 @@ export const PERMISSIONS = {
|
||||
coinSellerStockCredit: "coin-seller:stock-credit",
|
||||
coinSellerExchangeRate: "coin-seller:exchange-rate",
|
||||
moneyView: "money:view",
|
||||
moneyPaymentLinkCreate: "money:payment-link:create",
|
||||
coinLedgerView: "coin-ledger:view",
|
||||
coinSellerLedgerView: "coin-seller-ledger:view",
|
||||
coinAdjustmentView: "coin-adjustment:view",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { setAccessToken } from "@/shared/api/request";
|
||||
import { listUsers, updateUserStatus } from "./api";
|
||||
import { getMoneyScopeCatalog, listUserMoneyScopes, listUsers, replaceUserMoneyScopes, updateUserStatus } from "./api";
|
||||
|
||||
afterEach(() => {
|
||||
setAccessToken("");
|
||||
@ -37,3 +37,23 @@ test("updateUserStatus sends generated endpoint path and payload", async () => {
|
||||
expect(init?.method).toBe("PATCH");
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ status: "locked" });
|
||||
});
|
||||
|
||||
test("money scope APIs use generated endpoint paths", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { apps: [], items: [], total: 0 } })))
|
||||
);
|
||||
|
||||
await listUserMoneyScopes(9);
|
||||
await replaceUserMoneyScopes(9, [{ appCode: "lalu", regionId: 2 }]);
|
||||
await getMoneyScopeCatalog();
|
||||
|
||||
const calls = vi.mocked(fetch).mock.calls;
|
||||
expect(String(calls[0][0])).toContain("/api/v1/users/9/money-scopes");
|
||||
expect(calls[0][1]?.method).toBe("GET");
|
||||
expect(String(calls[1][0])).toContain("/api/v1/users/9/money-scopes");
|
||||
expect(calls[1][1]?.method).toBe("PUT");
|
||||
expect(JSON.parse(String(calls[1][1]?.body))).toEqual({ scopes: [{ appCode: "lalu", regionId: 2 }] });
|
||||
expect(String(calls[2][0])).toContain("/api/v1/admin/money/scope");
|
||||
expect(calls[2][1]?.method).toBe("GET");
|
||||
});
|
||||
|
||||
@ -8,6 +8,7 @@ import type {
|
||||
PageQuery,
|
||||
ResetPasswordResultDto,
|
||||
UserFormPayload,
|
||||
UserMoneyScopeDto,
|
||||
UserStatus,
|
||||
UserUpdatePayload
|
||||
} from "@/shared/api/types";
|
||||
@ -63,3 +64,26 @@ export function batchUpdateUserStatus(ids: EntityId[], status: UserStatus): Prom
|
||||
export function exportUsers(query?: PageQuery): Promise<Response> {
|
||||
return apiRequest(apiEndpointPath(API_OPERATIONS.exportUsers), { query, raw: true });
|
||||
}
|
||||
|
||||
export interface MoneyScopeCatalogDto {
|
||||
all?: boolean;
|
||||
apps?: Array<{ appCode: string; appName?: string }>;
|
||||
regions?: Array<{ appCode: string; countries?: string[]; name?: string; regionCode?: string; regionId: number }>;
|
||||
countries?: Array<{ appCode: string; countryCode: string; countryDisplayName?: string; countryName?: string; regionId?: number }>;
|
||||
scopes?: UserMoneyScopeDto[];
|
||||
}
|
||||
|
||||
export function listUserMoneyScopes(id: EntityId): Promise<{ items: UserMoneyScopeDto[]; total: number }> {
|
||||
return apiRequest<{ items: UserMoneyScopeDto[]; total: number }>(apiEndpointPath(API_OPERATIONS.listUserMoneyScopes, { id }));
|
||||
}
|
||||
|
||||
export function replaceUserMoneyScopes(id: EntityId, scopes: UserMoneyScopeDto[]): Promise<{ items: UserMoneyScopeDto[]; total: number }> {
|
||||
return apiRequest<{ items: UserMoneyScopeDto[]; total: number }, { scopes: UserMoneyScopeDto[] }>(apiEndpointPath(API_OPERATIONS.replaceUserMoneyScopes, { id }), {
|
||||
body: { scopes },
|
||||
method: "PUT"
|
||||
});
|
||||
}
|
||||
|
||||
export function getMoneyScopeCatalog(): Promise<MoneyScopeCatalogDto> {
|
||||
return apiRequest<MoneyScopeCatalogDto>(apiEndpointPath(API_OPERATIONS.getMoneyScope));
|
||||
}
|
||||
|
||||
@ -6,7 +6,9 @@ import { Button } from "@/shared/ui/Button.jsx";
|
||||
export function UserFormDrawer({
|
||||
editingUser,
|
||||
form,
|
||||
moneyScopeCatalog,
|
||||
onClose,
|
||||
onMoneyScopeChecked,
|
||||
onRoleChecked,
|
||||
onSubmit,
|
||||
open,
|
||||
@ -34,6 +36,24 @@ export function UserFormDrawer({
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">财务范围</div>
|
||||
<div className="permission-grid permission-grid--money">
|
||||
{moneyScopeCatalog?.loading ? <span className="permission-check">加载中</span> : null}
|
||||
{moneyScopeCatalog?.error ? <span className="permission-check">{moneyScopeCatalog.error}</span> : null}
|
||||
{!moneyScopeCatalog?.loading && !moneyScopeCatalog?.error
|
||||
? (moneyScopeCatalog?.apps || []).map((app) => (
|
||||
<MoneyScopeGroup
|
||||
app={app}
|
||||
checkedScopes={form.moneyScopes || []}
|
||||
key={app.appCode}
|
||||
onChecked={onMoneyScopeChecked}
|
||||
regions={(moneyScopeCatalog?.regions || []).filter((region) => region.appCode === app.appCode)}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">角色</div>
|
||||
<div className="permission-grid">
|
||||
@ -67,3 +87,29 @@ export function UserFormDrawer({
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function MoneyScopeGroup({ app, checkedScopes, onChecked, regions }) {
|
||||
const appCode = app.appCode;
|
||||
const isChecked = (regionId) =>
|
||||
checkedScopes.some((scope) => scope.appCode === appCode && Number(scope.regionId) === Number(regionId));
|
||||
|
||||
return (
|
||||
<div className="permission-scope-group">
|
||||
<strong>{app.appName || appCode}</strong>
|
||||
<label className="permission-check" key={`${appCode}:0`}>
|
||||
<input checked={isChecked(0)} onChange={(event) => onChecked(appCode, 0, event.target.checked)} type="checkbox" />
|
||||
<span>全部区域</span>
|
||||
</label>
|
||||
{regions.map((region) => (
|
||||
<label className="permission-check" key={`${appCode}:${region.regionId}`}>
|
||||
<input
|
||||
checked={isChecked(region.regionId)}
|
||||
onChange={(event) => onChecked(appCode, region.regionId, event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{[region.name, region.regionCode].filter(Boolean).join(" · ")}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -12,7 +12,10 @@ import {
|
||||
batchUpdateUserStatus,
|
||||
createUser,
|
||||
exportUsers,
|
||||
getMoneyScopeCatalog,
|
||||
listUserMoneyScopes,
|
||||
listUsers,
|
||||
replaceUserMoneyScopes,
|
||||
resetUserPassword,
|
||||
updateUser,
|
||||
updateUserStatus,
|
||||
@ -29,6 +32,7 @@ const emptyForm = {
|
||||
name: "",
|
||||
password: "",
|
||||
roleIds: [],
|
||||
moneyScopes: [],
|
||||
status: "active",
|
||||
team: "",
|
||||
username: "",
|
||||
@ -44,6 +48,7 @@ export function useUsersPage() {
|
||||
const [editingUser, setEditingUser] = useState(null);
|
||||
const [profileUser, setProfileUser] = useState(null);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [moneyScopeCatalog, setMoneyScopeCatalog] = useState({ apps: [], countries: [], error: "", loading: false, regions: [] });
|
||||
const [mergedUsers, setMergedUsers] = useState(emptyUsers);
|
||||
const abilities = useUserAbilities();
|
||||
const confirm = useConfirm();
|
||||
@ -133,10 +138,27 @@ export function useUsersPage() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const loadMoneyScopeCatalog = useCallback(async () => {
|
||||
setMoneyScopeCatalog((current) => ({ ...current, error: "", loading: true }));
|
||||
try {
|
||||
const catalog = await getMoneyScopeCatalog();
|
||||
setMoneyScopeCatalog({
|
||||
apps: Array.isArray(catalog?.apps) ? catalog.apps : [],
|
||||
countries: Array.isArray(catalog?.countries) ? catalog.countries : [],
|
||||
error: "",
|
||||
loading: false,
|
||||
regions: Array.isArray(catalog?.regions) ? catalog.regions : [],
|
||||
});
|
||||
} catch (err) {
|
||||
setMoneyScopeCatalog({ apps: [], countries: [], error: err.message || "加载财务范围失败", loading: false, regions: [] });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingUser(null);
|
||||
setForm(emptyForm);
|
||||
setFormOpen(true);
|
||||
loadMoneyScopeCatalog();
|
||||
};
|
||||
|
||||
const openEdit = (user) => {
|
||||
@ -146,11 +168,23 @@ export function useUsersPage() {
|
||||
name: user.name,
|
||||
password: "",
|
||||
roleIds: user.roleIds || [],
|
||||
moneyScopes: [],
|
||||
status: user.status,
|
||||
team: user.team || "",
|
||||
username: user.account,
|
||||
});
|
||||
setFormOpen(true);
|
||||
loadMoneyScopeCatalog();
|
||||
listUserMoneyScopes(user.id)
|
||||
.then((data) => {
|
||||
const scopes = Array.isArray(data?.items) ? data.items : [];
|
||||
setForm((current) =>
|
||||
editingUser?.id === user.id || current.username === user.account
|
||||
? { ...current, moneyScopes: normalizeMoneyScopes(scopes) }
|
||||
: current,
|
||||
);
|
||||
})
|
||||
.catch((err) => showToast(err.message || "加载财务范围失败", "error"));
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
@ -171,10 +205,14 @@ export function useUsersPage() {
|
||||
if (editingUser) {
|
||||
const payload = parseForm(userUpdateFormSchema, form);
|
||||
await updateUser(editingUser.id, payload);
|
||||
await replaceUserMoneyScopes(editingUser.id, normalizeMoneyScopes(form.moneyScopes));
|
||||
showToast("用户已更新", "success");
|
||||
} else {
|
||||
const payload = parseForm(userCreateFormSchema, form);
|
||||
const result = await createUser(payload);
|
||||
if (result?.user?.id) {
|
||||
await replaceUserMoneyScopes(result.user.id, normalizeMoneyScopes(form.moneyScopes));
|
||||
}
|
||||
showToast(`用户已创建,初始密码:${result.initialPassword}`, "success");
|
||||
}
|
||||
setFormOpen(false);
|
||||
@ -263,6 +301,28 @@ export function useUsersPage() {
|
||||
}));
|
||||
};
|
||||
|
||||
const setMoneyScopeChecked = (appCode, regionId, checked) => {
|
||||
const normalizedAppCode = String(appCode || "").trim();
|
||||
const normalizedRegionId = Number(regionId || 0);
|
||||
if (!normalizedAppCode || normalizedRegionId < 0) {
|
||||
return;
|
||||
}
|
||||
setForm((current) => {
|
||||
const scopes = normalizeMoneyScopes(current.moneyScopes);
|
||||
const exists = scopes.some((scope) => scope.appCode === normalizedAppCode && Number(scope.regionId) === normalizedRegionId);
|
||||
if (checked && !exists) {
|
||||
return { ...current, moneyScopes: [...scopes, { appCode: normalizedAppCode, regionId: normalizedRegionId }] };
|
||||
}
|
||||
if (!checked && exists) {
|
||||
return {
|
||||
...current,
|
||||
moneyScopes: scopes.filter((scope) => !(scope.appCode === normalizedAppCode && Number(scope.regionId) === normalizedRegionId)),
|
||||
};
|
||||
}
|
||||
return current;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
batchStatus,
|
||||
@ -277,6 +337,7 @@ export function useUsersPage() {
|
||||
form,
|
||||
formOpen,
|
||||
loading,
|
||||
moneyScopeCatalog,
|
||||
navigate,
|
||||
openCreate,
|
||||
openEdit,
|
||||
@ -292,6 +353,7 @@ export function useUsersPage() {
|
||||
roles,
|
||||
selectedIds,
|
||||
setForm,
|
||||
setMoneyScopeChecked,
|
||||
setPage,
|
||||
setRoleChecked,
|
||||
setSelectedIds,
|
||||
@ -303,3 +365,23 @@ export function useUsersPage() {
|
||||
users,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMoneyScopes(scopes = []) {
|
||||
const seen = new Set();
|
||||
return (Array.isArray(scopes) ? scopes : [])
|
||||
.map((scope) => ({
|
||||
appCode: String(scope.appCode || scope.app_code || "").trim(),
|
||||
regionId: Number(scope.regionId ?? scope.region_id ?? 0),
|
||||
}))
|
||||
.filter((scope) => {
|
||||
if (!scope.appCode || scope.regionId < 0) {
|
||||
return false;
|
||||
}
|
||||
const key = `${scope.appCode}:${scope.regionId}`;
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@ -94,7 +94,9 @@ export function UserManagementPage() {
|
||||
<UserFormDrawer
|
||||
editingUser={page.editingUser}
|
||||
form={page.form}
|
||||
moneyScopeCatalog={page.moneyScopeCatalog}
|
||||
onClose={page.closeForm}
|
||||
onMoneyScopeChecked={page.setMoneyScopeChecked}
|
||||
onRoleChecked={page.setRoleChecked}
|
||||
onSubmit={page.submitUser}
|
||||
open={page.formOpen}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
138
src/shared/api/generated/schema.d.ts
vendored
138
src/shared/api/generated/schema.d.ts
vendored
@ -1860,6 +1860,38 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/money/scope": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getMoneyScope"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/money/performance": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getMoneyPerformance"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/payment/temporary-links": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -1869,7 +1901,7 @@ export interface paths {
|
||||
};
|
||||
get: operations["listTemporaryPaymentLinks"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
post: operations["createTemporaryPaymentLink"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
@ -3156,6 +3188,22 @@ export interface paths {
|
||||
patch: operations["updateUser"];
|
||||
trace?: never;
|
||||
};
|
||||
"/users/{id}/money-scopes": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listUserMoneyScopes"];
|
||||
put: operations["replaceUserMoneyScopes"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/users/{id}/reset-password": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -3557,6 +3605,10 @@ export interface components {
|
||||
UserInput: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
UserMoneyScope: {
|
||||
appCode: string;
|
||||
regionId: number;
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
@ -3785,6 +3837,13 @@ export interface components {
|
||||
};
|
||||
};
|
||||
};
|
||||
MoneyScopeRequest: {
|
||||
content: {
|
||||
"application/json": {
|
||||
scopes: components["schemas"]["UserMoneyScope"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
MenuRequest: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["MenuInput"];
|
||||
@ -3831,6 +3890,19 @@ export interface components {
|
||||
};
|
||||
};
|
||||
};
|
||||
TemporaryPaymentLinkRequest: {
|
||||
content: {
|
||||
"application/json": {
|
||||
appCode: string;
|
||||
language?: string;
|
||||
paymentMethodId: number;
|
||||
providerCode: string;
|
||||
regionId: number;
|
||||
returnUrl?: string;
|
||||
usdMinorAmount: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
UserRequest: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UserInput"];
|
||||
@ -6078,6 +6150,30 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
getMoneyScope: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
getMoneyPerformance: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listTemporaryPaymentLinks: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -6090,6 +6186,18 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
createTemporaryPaymentLink: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: components["requestBodies"]["TemporaryPaymentLinkRequest"];
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
getTemporaryPaymentLink: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -7526,6 +7634,34 @@ export interface operations {
|
||||
200: components["responses"]["UserResponse"];
|
||||
};
|
||||
};
|
||||
listUserMoneyScopes: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: components["parameters"]["Id"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
replaceUserMoneyScopes: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: components["parameters"]["Id"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: components["requestBodies"]["MoneyScopeRequest"];
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
resetUserPassword: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@ -62,6 +62,12 @@ export interface AdminUserDto {
|
||||
team?: string;
|
||||
}
|
||||
|
||||
export interface UserMoneyScopeDto {
|
||||
appCode: string;
|
||||
regionId: number;
|
||||
userId?: EntityId;
|
||||
}
|
||||
|
||||
export interface UserFormPayload {
|
||||
mfaEnabled?: boolean;
|
||||
name: string;
|
||||
@ -80,6 +86,7 @@ export interface ResetPasswordResultDto {
|
||||
|
||||
export interface CreateUserResultDto {
|
||||
initialPassword?: string;
|
||||
user?: AdminUserDto;
|
||||
}
|
||||
|
||||
export interface RoleDto {
|
||||
|
||||
@ -1322,6 +1322,24 @@
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.permission-grid--money {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.permission-scope-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-2);
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.permission-scope-group > strong {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--admin-font-size);
|
||||
}
|
||||
|
||||
.permission-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@ -71,6 +71,16 @@ export default defineConfig({
|
||||
"/api": {
|
||||
target: "http://localhost:13100",
|
||||
changeOrigin: true
|
||||
},
|
||||
"/__api_test__": {
|
||||
target: "https://api-test.global-interaction.com",
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/__api_test__/, "")
|
||||
},
|
||||
"/__api_local__": {
|
||||
target: "http://127.0.0.1:13000",
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/__api_local__/, "")
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user