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