320 lines
10 KiB
JavaScript
320 lines
10 KiB
JavaScript
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();
|
||
}
|