2026-06-28 23:25:01 +08:00

320 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useState } from "react";
import { fetchMoneySession } from "./api.js";
import { CountryChannelPanel } from "./components/CountryChannelPanel.jsx";
import { MoneyHeader } from "./components/MoneyHeader.jsx";
import { MoneyOverview } from "./components/MoneyOverview.jsx";
import { MONEY_VIEWS, MoneySidebar } from "./components/MoneySidebar.jsx";
import { OperatorPerformancePanel } from "./components/OperatorPerformancePanel.jsx";
import { PaymentLinkPanel } from "./components/PaymentLinkPanel.jsx";
import { PerformanceTable } from "./components/PerformanceTable.jsx";
import {
filterMoneyModules,
moneyAppOptions,
moneyCountries,
moneyOperators,
summarizeMoneyModules
} from "./data.js";
import {
appOptionsFromMasterData,
countryOptionsFromMasterData,
fetchMoneyMasterData,
regionOptionsFromMasterData
} from "./masterDataApi.js";
const emptyMasterData = { apps: [], countries: [], error: "", loading: false, modules: [], performanceSummary: {}, regions: [], scopes: [] };
export function MoneyApp() {
const [sessionState, setSessionState] = useState({ error: "", loading: true, session: null });
const [masterDataState, setMasterDataState] = useState(emptyMasterData);
const [activeView, setActiveView] = useState("overview");
const [mode, setMode] = useState("finance");
const [activeOperatorId, setActiveOperatorId] = useState("sarah-ali");
const [appCode, setAppCode] = useState("");
const [countryCode, setCountryCode] = useState("");
const [regionId, setRegionId] = useState("");
const [operatorKeyword, setOperatorKeyword] = useState("");
const [links, setLinks] = useState([]);
const [toast, setToast] = useState("");
useEffect(() => {
let mounted = true;
fetchMoneySession()
.then((session) => {
if (mounted) {
setSessionState({ error: "", loading: false, session });
}
})
.catch((err) => {
if (mounted) {
setSessionState({ error: err.message || "会话校验失败", loading: false, session: null });
}
});
return () => {
mounted = false;
};
}, []);
useEffect(() => {
if (!sessionState.session?.canViewMoney) {
return undefined;
}
let mounted = true;
setMasterDataState((current) => ({ ...current, error: "", loading: true }));
fetchMoneyMasterData()
.then((data) => {
if (mounted) {
setMasterDataState({ ...data, loading: false });
}
})
.catch((err) => {
if (mounted) {
setMasterDataState({ ...emptyMasterData, error: err.message || "主数据加载失败", loading: false });
}
});
return () => {
mounted = false;
};
}, [sessionState.session?.canViewMoney]);
const appOptions = useMemo(() => appOptionsFromMasterData(masterDataState.apps, moneyAppOptions), [masterDataState.apps]);
const countryOptions = useMemo(
() => countryOptionsFromMasterData(masterDataState.countries, moneyCountries),
[masterDataState.countries]
);
const regionOptions = useMemo(() => regionOptionsFromMasterData(masterDataState.regions), [masterDataState.regions]);
useEffect(() => {
if (appCode && !hasOption(appOptions, appCode)) {
setAppCode("");
}
}, [appCode, appOptions]);
useEffect(() => {
if (countryCode && !hasOption(countryOptions, countryCode)) {
setCountryCode("");
}
}, [countryCode, countryOptions]);
useEffect(() => {
if (regionId && !hasOption(regionOptions, regionId)) {
setRegionId("");
}
}, [regionId, regionOptions]);
const filteredModules = useMemo(
() =>
filterMoneyModules({
activeOperatorId,
appCode,
countryCode,
mode,
modules: masterDataState.modules,
operatorKeyword: mode === "finance" ? operatorKeyword : "",
regionId,
regions: masterDataState.regions
}),
[activeOperatorId, appCode, countryCode, masterDataState.modules, masterDataState.regions, mode, operatorKeyword, regionId]
);
const summary = useMemo(() => summarizeMoneyModules(filteredModules), [filteredModules]);
const visibleModuleIds = useMemo(() => new Set(filteredModules.map((module) => module.id)), [filteredModules]);
const visibleLinks = useMemo(() => links.filter((link) => visibleModuleIds.has(link.moduleId)), [links, visibleModuleIds]);
const operators = useMemo(() => operatorsFromModules(masterDataState.modules), [masterDataState.modules]);
const activeOperator = operators.find((operator) => operator.id === activeOperatorId) || operators[0] || moneyOperators[0];
const view = MONEY_VIEWS.find((item) => item.id === activeView) || MONEY_VIEWS[0];
const resetFilters = () => {
setAppCode("");
setCountryCode("");
setRegionId("");
setOperatorKeyword("");
};
const showToast = (message) => {
setToast(message);
window.setTimeout(() => setToast(""), 1600);
};
const copyLink = async (value) => {
await copyText(value);
showToast("链接已复制");
};
const addPaymentLink = (link) => {
setLinks((current) => [link, ...current.filter((item) => (item.orderId || item.id) !== (link.orderId || link.id))]);
showToast("支付链接已创建");
};
const exportReport = () => {
downloadTextFile(createCsv(filteredModules), `hyapp-money-${Date.now()}.csv`);
showToast("财务报表已导出");
};
if (sessionState.loading) {
return <StateScreen title="正在进入财务系统" />;
}
if (sessionState.error) {
return <StateScreen title={sessionState.error} />;
}
if (!sessionState.session?.canViewMoney) {
return <StateScreen title="无 money:view" />;
}
return (
<main className="money-shell">
<MoneySidebar
activeOperator={activeOperator}
activeView={activeView}
mode={mode}
onModeChange={setMode}
onViewChange={setActiveView}
session={sessionState.session}
summary={summary}
/>
<section className="money-main">
<MoneyHeader
activeOperatorId={activeOperatorId}
appCode={appCode}
appOptions={appOptions}
countryCode={countryCode}
countryOptions={countryOptions}
masterDataError={masterDataState.error}
masterDataLoading={masterDataState.loading}
mode={mode}
onActiveOperatorChange={setActiveOperatorId}
onAppChange={setAppCode}
onCountryChange={setCountryCode}
onExport={exportReport}
onOperatorKeywordChange={setOperatorKeyword}
onRegionChange={setRegionId}
onReset={resetFilters}
operatorKeyword={operatorKeyword}
operators={operators}
regionId={regionId}
regionOptions={regionOptions}
view={view}
/>
<div className="money-content">
{renderContent(activeView, {
copyLink,
countries: masterDataState.countries,
filteredModules,
onPaymentLinkCreated: addPaymentLink,
regions: masterDataState.regions,
summary,
visibleLinks
})}
</div>
</section>
{toast ? <div className="money-toast">{toast}</div> : null}
</main>
);
}
function renderContent(activeView, { copyLink, countries, filteredModules, onPaymentLinkCreated, regions, summary, visibleLinks }) {
if (activeView === "modules") {
return <PerformanceTable meta={`${filteredModules.length} 个区域`} modules={filteredModules} summary={summary} title="区域绩效" />;
}
if (activeView === "operators") {
return <OperatorPerformancePanel modules={filteredModules} />;
}
if (activeView === "payments") {
return <PaymentLinkPanel links={visibleLinks} modules={filteredModules} onCopy={copyLink} onCreated={onPaymentLinkCreated} />;
}
if (activeView === "countries") {
return <CountryChannelPanel countries={countries} links={visibleLinks} modules={filteredModules} regions={regions} />;
}
return <MoneyOverview links={visibleLinks} modules={filteredModules} summary={summary} />;
}
function hasOption(options, value) {
return options.some(([optionValue]) => String(optionValue) === String(value));
}
function StateScreen({ detail = "", title }) {
return (
<main className="money-shell money-shell--state">
<section className="money-state">
<h1>{title}</h1>
{detail ? <p>{detail}</p> : null}
</section>
</main>
);
}
function createCsv(modules) {
const header = ["区域", "负责人", "国家", "充值USD", "流水USD", "存量工资USD", "新增", "日活", "当日目标USD", "当月目标USD"];
const rows = modules.map((module) => [
module.moduleName,
module.operatorName,
module.countryName,
module.rechargeUsd,
module.revenueUsd,
module.salaryUsd,
module.newUsers,
module.dau,
module.dayTargetUsd,
module.monthTargetUsd
]);
return [header, ...rows].map((row) => row.map(csvCell).join(",")).join("\n");
}
function operatorsFromModules(modules) {
const seen = new Set();
return modules
.map((module) => ({
department: module.department || "",
id: module.operatorId,
label: module.operatorName || module.operatorId
}))
.filter((operator) => {
if (!operator.id || seen.has(operator.id)) {
return false;
}
seen.add(operator.id);
return true;
});
}
function csvCell(value) {
const text = String(value ?? "");
return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
}
function downloadTextFile(content, filename) {
const blob = new Blob([content], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
async function copyText(value) {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
return;
} catch {
// 本地 HTTP 调试时浏览器可能拒绝 Clipboard API退回同步复制以保证财务能拿到刚生成的收款链接。
}
}
const input = document.createElement("textarea");
input.value = value;
input.setAttribute("readonly", "");
input.style.position = "fixed";
input.style.opacity = "0";
document.body.appendChild(input);
input.select();
document.execCommand("copy");
input.remove();
}