bi报表和财务系统

This commit is contained in:
local 2026-06-28 23:25:01 +08:00
parent f059b0c60c
commit 3e8d4b4aea
30 changed files with 4807 additions and 2444 deletions

View File

@ -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"
}
}
}
}
}

View File

@ -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;

View File

@ -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") {

View File

@ -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}`}>

View File

@ -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}

View File

@ -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>

View File

@ -16,8 +16,8 @@ export const MONEY_VIEWS = [
},
{
id: "modules",
label: "模块绩效",
title: "模块绩效",
label: "区域绩效",
title: "区域绩效",
Icon: TableRowsOutlined
},
{

View File

@ -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">

View File

@ -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");

View File

@ -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>;
}

View File

@ -6,10 +6,10 @@ export {
initialPaymentLinks,
moneyAppOptions,
moneyCountries,
moneyModuleMatchesRegion,
moneyModules,
moneyOperators,
moneyProviderOptions,
moneyScopeOptions,
paymentStatusLabel,
summarizeMoneyModules,
trendLabels

306
money/src/masterDataApi.js Normal file
View 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;
}

View 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
});
}

View File

@ -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");

View File

@ -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" }));

View 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
}))
);
}

View File

@ -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;
}

View 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 || "-";
}

View 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("已入账");
});

View File

@ -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",

View File

@ -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");
});

View File

@ -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));
}

View File

@ -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>
);
}

View File

@ -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;
});
}

View File

@ -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

View File

@ -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;

View File

@ -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 {

View File

@ -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;

View File

@ -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__/, "")
}
}
},