feat(finance): 新增财务分组与充值套餐管理页

套餐(面额/赠送/折扣/上架)写入 system_configs.recharge_packages,支付服务约60秒缓存生效

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-30 22:32:49 +08:00
parent 0369cbb146
commit f5ffb19604
3 changed files with 141 additions and 0 deletions
+2
View File
@@ -21,6 +21,7 @@ import TaskEditor from "@/pages/tasks/editor";
import TaskDetail from "@/pages/tasks/detail";
import ProvidersPage from "@/pages/providers";
import ConfigPage from "@/pages/config";
import RechargePackagesPage from "@/pages/finance/packages";
import ArticleEditorPage from "@/pages/article-editor";
import ParkLayout from "@/pages/park";
import CompanyEditPage from "@/pages/park/company-edit";
@@ -83,6 +84,7 @@ export default function App() {
<Route path="/tasks/:id/edit" element={<TaskEditor />} />
<Route path="/providers" element={<ProvidersPage />} />
<Route path="/config" element={<ConfigPage />} />
<Route path="/finance/packages" element={<RechargePackagesPage />} />
<Route path="/article-editor" element={<ArticleEditorPage />} />
<Route path="/park-screens" element={<ParkScreensPage />} />
<Route path="/settings" element={<SettingsPage />} />
+7
View File
@@ -1,5 +1,6 @@
import type { LucideIcon } from "lucide-react";
import {
Wallet,
Users,
GraduationCap,
Calendar,
@@ -72,6 +73,12 @@ export const NAV: NavGroup[] = [
{ to: "/plans", label: "流程启动", icon: Flag, roles: ["operator"], perm: "menu:admin_plans" },
],
},
{
title: "财务",
items: [
{ to: "/finance/packages", label: "充值套餐", icon: Wallet, roles: ["operator"] },
],
},
{
title: "业务运营",
items: [
+132
View File
@@ -0,0 +1,132 @@
import * as React from "react";
import { PageHeader } from "@/components/generic/PageHeader";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { configApi } from "@/api/resources";
/** 充值套餐项(system_configs.recharge_packages JSON)。
* amount=面额(元)、bonus=赠送(元)、discount=折扣%(100=原价)、enabled=上架开关 */
interface Pkg {
id: string;
amount: number;
bonus: number;
discount: number;
label: string;
enabled: boolean;
}
const CONFIG_KEY = "recharge_packages";
const EMPTY: Pkg = { id: "", amount: 10, bonus: 0, discount: 100, label: "", enabled: true };
export default function RechargePackagesPage() {
const [items, setItems] = React.useState<Pkg[]>([]);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const load = React.useCallback(async () => {
setLoading(true);
try {
const rows = await configApi.list();
const row = (rows as { key: string; value: string }[]).find((r) => r.key === CONFIG_KEY);
const parsed = row?.value ? JSON.parse(row.value) : [];
setItems(
(Array.isArray(parsed) ? parsed : []).map((p: Partial<Pkg>, i: number) => ({
id: p.id || `p${i + 1}`,
amount: Number(p.amount ?? 0),
bonus: Number(p.bonus ?? 0),
discount: Number(p.discount ?? 100),
label: p.label || "",
enabled: p.enabled !== false,
})),
);
} catch (e) {
window.alert((e as Error).message || "加载套餐配置失败");
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => { load(); }, [load]);
const patch = (i: number, fields: Partial<Pkg>) =>
setItems((arr) => arr.map((it, k) => (k === i ? { ...it, ...fields } : it)));
const save = async () => {
// 校验:面额>0、折扣 1~100、id 唯一
const ids = new Set<string>();
for (const [i, it] of items.entries()) {
if (!(it.amount > 0)) { window.alert(`${i + 1} 行:面额必须大于 0`); return; }
if (it.discount < 1 || it.discount > 100) { window.alert(`${i + 1} 行:折扣需在 1~100 之间`); return; }
const id = it.id.trim() || `p${i + 1}`;
if (ids.has(id)) { window.alert(`套餐 ID 重复:${id}`); return; }
ids.add(id);
}
setSaving(true);
try {
await configApi.set(
CONFIG_KEY,
JSON.stringify(items.map((it, i) => ({ ...it, id: it.id.trim() || `p${i + 1}` }))),
"算力充值套餐(app/pay 支付子应用读取)",
);
window.alert("套餐已保存,最长 60 秒内生效");
load();
} catch (e) {
window.alert((e as Error).message || "保存失败");
} finally {
setSaving(false);
}
};
return (
<div>
<PageHeader
title="充值套餐"
description="算力充值套餐配置:面额、赠送、折扣与上架开关;保存后由支付服务读取(约 60 秒缓存)"
actions={
<div className="flex gap-2">
<Button variant="outline" onClick={load}></Button>
<Button variant="outline" onClick={() => setItems([...items, { ...EMPTY }])}></Button>
<Button onClick={save} disabled={saving || loading}></Button>
</div>
}
/>
<Card>
<CardContent className="pt-2">
{loading ? (
<p className="py-4 text-sm text-muted-foreground"></p>
) : items.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">使</p>
) : (
<div className="space-y-2 py-2">
<div className="grid grid-cols-[1fr_100px_100px_100px_1fr_90px_60px] items-center gap-3 px-1 text-xs text-muted-foreground">
<span> ID</span><span>()</span><span>()</span><span>(%)</span><span></span><span></span><span />
</div>
{items.map((it, i) => {
const pay = Math.round((it.amount * it.discount) / 100);
return (
<div key={i} className="grid grid-cols-[1fr_100px_100px_100px_1fr_90px_60px] items-center gap-3">
<Input value={it.id} onChange={(e) => patch(i, { id: e.target.value })} placeholder="p10" />
<Input type="number" min={1} value={it.amount} onChange={(e) => patch(i, { amount: Number(e.target.value) })} />
<Input type="number" min={0} value={it.bonus} onChange={(e) => patch(i, { bonus: Number(e.target.value) })} />
<Input type="number" min={1} max={100} value={it.discount} onChange={(e) => patch(i, { discount: Number(e.target.value) })} />
<Input value={it.label} onChange={(e) => patch(i, { label: e.target.value })} placeholder="前端显示名(留空自动)" />
<div className="flex items-center gap-2">
<input type="checkbox" checked={it.enabled} onChange={(e) => patch(i, { enabled: e.target.checked })} />
<span className="text-xs text-muted-foreground">¥{pay}</span>
</div>
<Button variant="ghost" size="sm" className="text-destructive" onClick={() => setItems(items.filter((_, k) => k !== i))}></Button>
</div>
);
})}
<p className="pt-2 text-xs text-muted-foreground">
= × % ¥ = + 100
</p>
</div>
)}
</CardContent>
</Card>
</div>
);
}