feat(finance): 新增财务分组与充值套餐管理页
套餐(面额/赠送/折扣/上架)写入 system_configs.recharge_packages,支付服务约60秒缓存生效 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import TaskEditor from "@/pages/tasks/editor";
|
|||||||
import TaskDetail from "@/pages/tasks/detail";
|
import TaskDetail from "@/pages/tasks/detail";
|
||||||
import ProvidersPage from "@/pages/providers";
|
import ProvidersPage from "@/pages/providers";
|
||||||
import ConfigPage from "@/pages/config";
|
import ConfigPage from "@/pages/config";
|
||||||
|
import RechargePackagesPage from "@/pages/finance/packages";
|
||||||
import ArticleEditorPage from "@/pages/article-editor";
|
import ArticleEditorPage from "@/pages/article-editor";
|
||||||
import ParkLayout from "@/pages/park";
|
import ParkLayout from "@/pages/park";
|
||||||
import CompanyEditPage from "@/pages/park/company-edit";
|
import CompanyEditPage from "@/pages/park/company-edit";
|
||||||
@@ -83,6 +84,7 @@ export default function App() {
|
|||||||
<Route path="/tasks/:id/edit" element={<TaskEditor />} />
|
<Route path="/tasks/:id/edit" element={<TaskEditor />} />
|
||||||
<Route path="/providers" element={<ProvidersPage />} />
|
<Route path="/providers" element={<ProvidersPage />} />
|
||||||
<Route path="/config" element={<ConfigPage />} />
|
<Route path="/config" element={<ConfigPage />} />
|
||||||
|
<Route path="/finance/packages" element={<RechargePackagesPage />} />
|
||||||
<Route path="/article-editor" element={<ArticleEditorPage />} />
|
<Route path="/article-editor" element={<ArticleEditorPage />} />
|
||||||
<Route path="/park-screens" element={<ParkScreensPage />} />
|
<Route path="/park-screens" element={<ParkScreensPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
Wallet,
|
||||||
Users,
|
Users,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -72,6 +73,12 @@ export const NAV: NavGroup[] = [
|
|||||||
{ to: "/plans", label: "流程启动", icon: Flag, roles: ["operator"], perm: "menu:admin_plans" },
|
{ to: "/plans", label: "流程启动", icon: Flag, roles: ["operator"], perm: "menu:admin_plans" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "财务",
|
||||||
|
items: [
|
||||||
|
{ to: "/finance/packages", label: "充值套餐", icon: Wallet, roles: ["operator"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "业务运营",
|
title: "业务运营",
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user