feat(payment): 新增微信支付宝积分充值与对账
This commit is contained in:
@@ -2,8 +2,8 @@ import type { Asset } from "@/stores/use-asset-store";
|
||||
|
||||
export const PLUGIN_API_VERSION = "yingce.plugin/v1" as const;
|
||||
|
||||
export type PluginContributionKind = "provider" | "workflow" | "canvas-node" | "transform" | "command" | "asset-source" | "usage-observer" | "ai-capability" | "agent" | "import-export";
|
||||
export type PluginSurface = "node" | "fullscreen" | "hybrid" | "asset-source" | "settings";
|
||||
export type PluginContributionKind = "provider" | "payment-provider" | "workflow" | "canvas-node" | "transform" | "command" | "asset-source" | "usage-observer" | "ai-capability" | "agent" | "import-export";
|
||||
export type PluginSurface = "node" | "fullscreen" | "hybrid" | "asset-source" | "settings" | "wallet";
|
||||
export type ProtocolCapability = "text" | "image" | "video" | "audio";
|
||||
export type ProtocolScope = "admin.system-channel" | "user.custom-channel" | "canvas" | "creation" | "agent" | string;
|
||||
export type PluginRuntime = "declarative" | "sandbox" | "worker" | "trusted-backend";
|
||||
@@ -60,6 +60,13 @@ export type PluginWorkflowContribution = {
|
||||
parameters: PluginParameter[];
|
||||
defaults?: Record<string, string | number | boolean>;
|
||||
};
|
||||
export type PluginPaymentProviderContribution = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
checkoutMode: "qr_code" | "redirect";
|
||||
expiryPolicy: { defaultMinutes: number; minMinutes: number; maxMinutes: number };
|
||||
};
|
||||
export type PluginCanvasNodeContribution = {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -76,6 +83,7 @@ export type PluginTransformContribution = {
|
||||
};
|
||||
export type PluginContributions = {
|
||||
providers?: PluginProviderContribution[];
|
||||
paymentProviders?: PluginPaymentProviderContribution[];
|
||||
workflows?: PluginWorkflowContribution[];
|
||||
canvasNodes?: PluginCanvasNodeContribution[];
|
||||
transforms?: PluginTransformContribution[];
|
||||
@@ -97,6 +105,10 @@ export type PluginPermission =
|
||||
| "ai.text"
|
||||
| "media.read"
|
||||
| "usage.read"
|
||||
| "payment.create"
|
||||
| "payment.query"
|
||||
| "payment.close"
|
||||
| "payment.reconcile"
|
||||
| "external.open";
|
||||
|
||||
export type PluginManifest = {
|
||||
@@ -124,9 +136,7 @@ export type PluginStorage = {
|
||||
remove(key: string): Promise<void>;
|
||||
};
|
||||
|
||||
export type PluginTextContentPart =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image_url"; image_url: { url: string } };
|
||||
export type PluginTextContentPart = { type: "text"; text: string } | { type: "image_url"; image_url: { url: string } };
|
||||
|
||||
export type PluginTextMessage = {
|
||||
role: "system" | "user" | "assistant";
|
||||
@@ -218,10 +228,7 @@ export type PromptOptimizationResult = {
|
||||
};
|
||||
|
||||
export type PromptOptimizerProvider = {
|
||||
optimize: (
|
||||
input: PromptOptimizationInput,
|
||||
options?: { signal?: AbortSignal; onDelta?: (text: string) => void },
|
||||
) => Promise<PromptOptimizationResult>;
|
||||
optimize: (input: PromptOptimizationInput, options?: { signal?: AbortSignal; onDelta?: (text: string) => void }) => Promise<PromptOptimizationResult>;
|
||||
};
|
||||
|
||||
export type AssetSourceQuery = {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ChevronDown,
|
||||
CloudUpload,
|
||||
Coins,
|
||||
CreditCard,
|
||||
Database,
|
||||
FileClock,
|
||||
HardDrive,
|
||||
@@ -71,6 +72,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
|
||||
label: "运营",
|
||||
items: [
|
||||
{ path: "/admin/announcements", label: "系统公告", description: "发布、关闭与历史公告", icon: <BellRing className="size-4" /> },
|
||||
{ path: "/admin/payments", label: "支付充值", description: "支付渠道、订单与对账", icon: <CreditCard className="size-4" /> },
|
||||
{ path: "/admin/credit-operations", label: "积分运营", description: "人工调账与异常计费", icon: <Coins className="size-4" /> },
|
||||
{ path: "/admin/redemption-codes", label: "兑换码", description: "生成与查看兑换码批次", icon: <TicketCheck className="size-4" /> },
|
||||
{ path: "/admin/logs", label: "请求明细", description: "上游调用与费用", icon: <FileClock className="size-4" /> },
|
||||
|
||||
17
web/src/pages/admin/payments/payments-page.css
Normal file
17
web/src/pages/admin/payments/payments-page.css
Normal file
@@ -0,0 +1,17 @@
|
||||
.admin-list-toolbar .ant-btn.admin-toolbar-primary-action {
|
||||
border-color: var(--btn-solid-bg) !important;
|
||||
background: var(--btn-solid-bg) !important;
|
||||
color: var(--btn-solid-fg) !important;
|
||||
}
|
||||
|
||||
.admin-list-toolbar .ant-btn.admin-toolbar-primary-action:not(:disabled):hover {
|
||||
border-color: var(--btn-solid-hover-bg) !important;
|
||||
background: var(--btn-solid-hover-bg) !important;
|
||||
color: var(--btn-solid-fg) !important;
|
||||
}
|
||||
|
||||
.admin-list-toolbar .ant-btn.admin-toolbar-primary-action:not(:disabled):active {
|
||||
border-color: var(--btn-solid-active-bg) !important;
|
||||
background: var(--btn-solid-active-bg) !important;
|
||||
color: var(--btn-solid-fg) !important;
|
||||
}
|
||||
747
web/src/pages/admin/payments/payments-page.tsx
Normal file
747
web/src/pages/admin/payments/payments-page.tsx
Normal file
@@ -0,0 +1,747 @@
|
||||
import { AlipayCircleFilled, WechatFilled } from "@ant-design/icons";
|
||||
import { Alert, App, Button, DatePicker, Drawer, Form, Input, InputNumber, Select, Switch, Tabs } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import { Eye, Plus, RefreshCw, Search, Settings2, XCircle } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { PaginationBar } from "@/components/layout/workspace-page";
|
||||
import { formatCredits } from "@/constant/credits";
|
||||
import {
|
||||
closeAdminPaymentOrder,
|
||||
createAdminTopupProduct,
|
||||
listAdminPaymentOrders,
|
||||
listAdminPaymentProviders,
|
||||
listAdminPaymentReconciliationItems,
|
||||
listAdminPaymentReconciliations,
|
||||
listAdminTopupProducts,
|
||||
queryAdminPaymentOrder,
|
||||
runAdminPaymentReconciliation,
|
||||
updateAdminPaymentProvider,
|
||||
updateAdminTopupProduct,
|
||||
type AdminPaymentProvider,
|
||||
type PaymentOrder,
|
||||
type PaymentReconciliationItem,
|
||||
type PaymentReconciliationRun,
|
||||
type TopupProduct,
|
||||
} from "@/services/api/payments";
|
||||
|
||||
import { AdminPageFrame } from "../components/admin-shell";
|
||||
import { AdminDataTable, AdminStatusBadge, AdminTableEmpty, configuredSecretText } from "../components/admin-ui";
|
||||
import "./payments-page.css";
|
||||
|
||||
type ProviderFormValues = {
|
||||
enabled: boolean;
|
||||
closeAfterMinutes: number;
|
||||
values: Record<string, string>;
|
||||
};
|
||||
|
||||
type ProductFormValues = {
|
||||
name: string;
|
||||
description?: string;
|
||||
amountYuan: number;
|
||||
credits: number;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
const paymentOrderStatus: Record<string, { label: string; tone: "neutral" | "success" | "warning" | "error" | "info" }> = {
|
||||
created: { label: "创建中", tone: "info" },
|
||||
pending: { label: "待支付", tone: "warning" },
|
||||
closing: { label: "关单中", tone: "warning" },
|
||||
closed: { label: "已关闭", tone: "neutral" },
|
||||
credited: { label: "已入账", tone: "success" },
|
||||
create_failed: { label: "下单失败", tone: "error" },
|
||||
};
|
||||
|
||||
const reconciliationResult: Record<string, { label: string; tone: "neutral" | "success" | "warning" | "error" | "info" }> = {
|
||||
matched: { label: "一致", tone: "success" },
|
||||
recovered: { label: "已自动补发", tone: "info" },
|
||||
local_order_not_found: { label: "本地订单缺失", tone: "error" },
|
||||
provider_record_missing: { label: "渠道记录缺失", tone: "error" },
|
||||
amount_mismatch: { label: "金额不一致", tone: "error" },
|
||||
trade_no_mismatch: { label: "交易号不一致", tone: "error" },
|
||||
credit_failed: { label: "补发失败", tone: "error" },
|
||||
};
|
||||
|
||||
export default function AdminPaymentsPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const [activeTab, setActiveTab] = useState("providers");
|
||||
const [providers, setProviders] = useState<AdminPaymentProvider[]>([]);
|
||||
const [products, setProducts] = useState<TopupProduct[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [providerDrawer, setProviderDrawer] = useState<AdminPaymentProvider>();
|
||||
const [providerSaving, setProviderSaving] = useState(false);
|
||||
const [providerForm] = Form.useForm<ProviderFormValues>();
|
||||
|
||||
const [productDrawer, setProductDrawer] = useState<TopupProduct | null | undefined>();
|
||||
const [productSaving, setProductSaving] = useState(false);
|
||||
const [productForm] = Form.useForm<ProductFormValues>();
|
||||
|
||||
const [orders, setOrders] = useState<PaymentOrder[]>([]);
|
||||
const [orderTotal, setOrderTotal] = useState(0);
|
||||
const [orderPage, setOrderPage] = useState(1);
|
||||
const [orderPageSize, setOrderPageSize] = useState(30);
|
||||
const [orderStatusFilter, setOrderStatusFilter] = useState("all");
|
||||
const [orderKeyword, setOrderKeyword] = useState("");
|
||||
const [ordersLoading, setOrdersLoading] = useState(false);
|
||||
const [orderActionId, setOrderActionId] = useState("");
|
||||
|
||||
const [runs, setRuns] = useState<PaymentReconciliationRun[]>([]);
|
||||
const [runTotal, setRunTotal] = useState(0);
|
||||
const [runPage, setRunPage] = useState(1);
|
||||
const [runPageSize, setRunPageSize] = useState(30);
|
||||
const [runsLoading, setRunsLoading] = useState(false);
|
||||
const [runProviderFilter, setRunProviderFilter] = useState("all");
|
||||
const [runningBill, setRunningBill] = useState(false);
|
||||
const [billProviderId, setBillProviderId] = useState("");
|
||||
const [billDate, setBillDate] = useState<Dayjs>(dayjs().subtract(1, "day"));
|
||||
const [detailRun, setDetailRun] = useState<PaymentReconciliationRun>();
|
||||
const [detailItems, setDetailItems] = useState<PaymentReconciliationItem[]>([]);
|
||||
const [detailTotal, setDetailTotal] = useState(0);
|
||||
const [detailPage, setDetailPage] = useState(1);
|
||||
const [detailPageSize, setDetailPageSize] = useState(50);
|
||||
const [detailResult, setDetailResult] = useState("all");
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
const loadBase = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [providerResult, productResult] = await Promise.all([listAdminPaymentProviders(), listAdminTopupProducts()]);
|
||||
setProviders(providerResult.providers);
|
||||
setProducts(productResult.products);
|
||||
setBillProviderId((current) => current || providerResult.providers.find((item) => item.configured)?.id || providerResult.providers[0]?.id || "");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取支付配置失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadOrders = async (page = orderPage, limit = orderPageSize) => {
|
||||
setOrdersLoading(true);
|
||||
try {
|
||||
const result = await listAdminPaymentOrders({ status: orderStatusFilter === "all" ? undefined : orderStatusFilter, keyword: orderKeyword.trim() || undefined, page, limit });
|
||||
setOrders(result.orders);
|
||||
setOrderTotal(result.total);
|
||||
setOrderPage(result.page);
|
||||
setOrderPageSize(result.limit);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取支付订单失败");
|
||||
} finally {
|
||||
setOrdersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRuns = async (page = runPage, limit = runPageSize) => {
|
||||
setRunsLoading(true);
|
||||
try {
|
||||
const result = await listAdminPaymentReconciliations({ providerId: runProviderFilter === "all" ? undefined : runProviderFilter, page, limit });
|
||||
setRuns(result.runs);
|
||||
setRunTotal(result.total);
|
||||
setRunPage(result.page);
|
||||
setRunPageSize(result.limit);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取对账记录失败");
|
||||
} finally {
|
||||
setRunsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([loadBase(), loadOrders(1, orderPageSize), loadRuns(1, runPageSize)]);
|
||||
}, []);
|
||||
|
||||
const refresh = async () => {
|
||||
if (activeTab === "orders") await loadOrders();
|
||||
else if (activeTab === "reconciliation") await loadRuns();
|
||||
else await loadBase();
|
||||
};
|
||||
|
||||
const openProvider = (provider: AdminPaymentProvider) => {
|
||||
providerForm.resetFields();
|
||||
const configValues = { ...(provider.values || {}) };
|
||||
for (const field of provider.configFields) {
|
||||
if (!configValues[field.name] && field.default !== undefined && field.default !== null) configValues[field.name] = String(field.default);
|
||||
}
|
||||
providerForm.setFieldsValue({ enabled: provider.configEnabled, closeAfterMinutes: provider.closeAfterMinutes || 30, values: configValues });
|
||||
setProviderDrawer(provider);
|
||||
};
|
||||
|
||||
const saveProvider = async () => {
|
||||
if (!providerDrawer) return;
|
||||
const values = await providerForm.validateFields();
|
||||
setProviderSaving(true);
|
||||
try {
|
||||
await updateAdminPaymentProvider(providerDrawer.id, {
|
||||
enabled: values.enabled,
|
||||
closeAfterMinutes: values.closeAfterMinutes,
|
||||
values: Object.fromEntries(Object.entries(values.values || {}).map(([key, value]) => [key, String(value || "")])),
|
||||
});
|
||||
message.success(`${providerDrawer.name}配置已保存为新版本`);
|
||||
setProviderDrawer(undefined);
|
||||
await loadBase();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "保存支付渠道失败");
|
||||
} finally {
|
||||
setProviderSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openProduct = (product?: TopupProduct) => {
|
||||
productForm.resetFields();
|
||||
productForm.setFieldsValue(
|
||||
product
|
||||
? {
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
amountYuan: product.amountFen / 100,
|
||||
credits: product.creditsMicrocredits / 1_000_000,
|
||||
enabled: product.enabled,
|
||||
sortOrder: product.sortOrder,
|
||||
}
|
||||
: { enabled: true, sortOrder: products.length * 10, amountYuan: 10, credits: 10 },
|
||||
);
|
||||
setProductDrawer(product || null);
|
||||
};
|
||||
|
||||
const saveProduct = async () => {
|
||||
if (productDrawer === undefined) return;
|
||||
const values = await productForm.validateFields();
|
||||
const input = {
|
||||
name: values.name.trim(),
|
||||
description: values.description?.trim(),
|
||||
amountFen: Math.round(values.amountYuan * 100),
|
||||
creditsMicrocredits: Math.round(values.credits * 1_000_000),
|
||||
enabled: values.enabled,
|
||||
sortOrder: values.sortOrder || 0,
|
||||
};
|
||||
setProductSaving(true);
|
||||
try {
|
||||
if (productDrawer) await updateAdminTopupProduct(productDrawer.id, input);
|
||||
else await createAdminTopupProduct(input);
|
||||
message.success(productDrawer ? "充值商品已更新" : "充值商品已创建");
|
||||
setProductDrawer(undefined);
|
||||
await loadBase();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "保存充值商品失败");
|
||||
} finally {
|
||||
setProductSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const queryOrder = async (order: PaymentOrder) => {
|
||||
setOrderActionId(order.id);
|
||||
try {
|
||||
await queryAdminPaymentOrder(order.id);
|
||||
message.success("已向支付渠道查单");
|
||||
await loadOrders();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "查单失败");
|
||||
} finally {
|
||||
setOrderActionId("");
|
||||
}
|
||||
};
|
||||
|
||||
const closeOrder = (order: PaymentOrder) => {
|
||||
modal.confirm({
|
||||
title: "关闭未支付订单?",
|
||||
content: "系统会先向支付渠道查单;若渠道已支付则立即入账,否则执行关单。",
|
||||
okText: "查单并关单",
|
||||
cancelText: "取消",
|
||||
onOk: async () => {
|
||||
setOrderActionId(order.id);
|
||||
try {
|
||||
await closeAdminPaymentOrder(order.id);
|
||||
message.success("订单状态已更新");
|
||||
await loadOrders();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "关单失败");
|
||||
throw error;
|
||||
} finally {
|
||||
setOrderActionId("");
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const runReconciliation = async () => {
|
||||
if (!billProviderId || !billDate) return;
|
||||
setRunningBill(true);
|
||||
try {
|
||||
const result = await runAdminPaymentReconciliation({ providerId: billProviderId, billDate: billDate.format("YYYY-MM-DD") });
|
||||
if (result.run.status === "running") {
|
||||
message.info("该渠道与账单日期的对账正在执行,请稍后刷新查看结果");
|
||||
} else {
|
||||
message.success(result.run.recoveredItems ? `对账完成,自动补发 ${result.run.recoveredItems} 笔` : "对账完成");
|
||||
}
|
||||
await loadRuns(1, runPageSize);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "执行对账失败");
|
||||
await loadRuns(1, runPageSize);
|
||||
} finally {
|
||||
setRunningBill(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openRunDetails = async (run: PaymentReconciliationRun, page = 1, limit = detailPageSize, result = detailResult) => {
|
||||
setDetailRun(run);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const response = await listAdminPaymentReconciliationItems(run.id, { result: result === "all" ? undefined : result, page, limit });
|
||||
setDetailRun(response.run);
|
||||
setDetailItems(response.items);
|
||||
setDetailTotal(response.total);
|
||||
setDetailPage(response.page);
|
||||
setDetailPageSize(response.limit);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取对账明细失败");
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const providerNames = useMemo(() => Object.fromEntries(providers.map((item) => [item.id, item.name])), [providers]);
|
||||
|
||||
const providerColumns: ColumnsType<AdminPaymentProvider> = [
|
||||
{
|
||||
title: "支付渠道",
|
||||
key: "provider",
|
||||
render: (_, provider) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<PaymentBrandIcon providerId={provider.id} />
|
||||
<div>
|
||||
<div className="font-medium">{provider.name}</div>
|
||||
<div className="mt-0.5 font-mono text-xs text-foreground/45">{provider.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: "支付方式", dataIndex: "checkoutMode", width: 120, align: "center", render: (value) => (value === "qr_code" ? "扫码支付" : "网站跳转") },
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 180,
|
||||
align: "center",
|
||||
render: (_, provider) => (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<AdminStatusBadge label={provider.enabled ? "可用" : "不可用"} tone={provider.enabled ? "success" : "neutral"} />
|
||||
<span className="text-xs text-foreground/45">
|
||||
插件{provider.pluginEnabled ? "已开放" : "已停用"} · 配置{provider.configEnabled ? "已启用" : "已停用"}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: "未支付自动关闭", dataIndex: "closeAfterMinutes", width: 150, align: "center", render: (value) => `${value || 30} 分钟` },
|
||||
{ title: "配置版本", dataIndex: "version", width: 105, align: "center", render: (value) => (value ? `v${value}` : "未配置") },
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (_, provider) => (
|
||||
<Button size="small" icon={<Settings2 className="size-3.5" />} onClick={() => openProvider(provider)}>
|
||||
配置
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const productColumns: ColumnsType<TopupProduct> = [
|
||||
{
|
||||
title: "商品",
|
||||
key: "name",
|
||||
render: (_, product) => (
|
||||
<div>
|
||||
<div className="font-medium">{product.name}</div>
|
||||
<div className="mt-0.5 text-xs text-foreground/45">{product.description || "无说明"}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: "售价", dataIndex: "amountFen", width: 130, align: "right", render: (value) => <span className="font-medium tabular-nums">¥ {(value / 100).toFixed(2)}</span> },
|
||||
{ title: "到账积分", dataIndex: "creditsMicrocredits", width: 150, align: "right", render: (value) => <span className="tabular-nums">{formatCredits(value)}</span> },
|
||||
{ title: "排序", dataIndex: "sortOrder", width: 90, align: "center" },
|
||||
{ title: "状态", dataIndex: "enabled", width: 100, align: "center", render: (value) => <AdminStatusBadge label={value ? "销售中" : "已停用"} tone={value ? "success" : "neutral"} /> },
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 90,
|
||||
align: "center",
|
||||
render: (_, product) => (
|
||||
<Button size="small" onClick={() => openProduct(product)}>
|
||||
编辑
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const orderColumns: ColumnsType<PaymentOrder> = [
|
||||
{
|
||||
title: "订单",
|
||||
key: "order",
|
||||
width: 280,
|
||||
render: (_, order) => (
|
||||
<div>
|
||||
<div className="font-mono text-xs">{order.merchantOrderNo}</div>
|
||||
<div className="mt-1 truncate text-xs text-foreground/45">
|
||||
用户 {order.userId || "--"} · {order.productName}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "渠道",
|
||||
dataIndex: "providerId",
|
||||
width: 160,
|
||||
render: (value) => (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<PaymentBrandIcon providerId={value} compact />
|
||||
{providerNames[value] || value}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "金额 / 积分",
|
||||
key: "amount",
|
||||
width: 150,
|
||||
align: "right",
|
||||
render: (_, order) => (
|
||||
<div>
|
||||
<div className="font-medium tabular-nums">¥ {(order.amountFen / 100).toFixed(2)}</div>
|
||||
<div className="text-xs text-foreground/45">{formatCredits(order.creditsMicrocredits)} 积分</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: "状态", dataIndex: "status", width: 110, align: "center", render: (value) => <AdminStatusBadge {...(paymentOrderStatus[value] || { label: value, tone: "neutral" as const })} /> },
|
||||
{ title: "创建时间", dataIndex: "createdAt", width: 170, render: (value) => formatDateTime(value) },
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 150,
|
||||
align: "center",
|
||||
render: (_, order) => (
|
||||
<div className="flex justify-center gap-1">
|
||||
<Button type="text" size="small" loading={orderActionId === order.id} disabled={order.status === "credited" || order.status === "closed"} onClick={() => void queryOrder(order)}>
|
||||
查单
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<XCircle className="size-3.5" />}
|
||||
loading={orderActionId === order.id}
|
||||
disabled={!["created", "pending", "create_failed", "closing"].includes(order.status)}
|
||||
onClick={() => closeOrder(order)}
|
||||
>
|
||||
关单
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const runColumns: ColumnsType<PaymentReconciliationRun> = [
|
||||
{ title: "账单日期", dataIndex: "billDate", width: 120 },
|
||||
{
|
||||
title: "渠道",
|
||||
dataIndex: "providerId",
|
||||
width: 190,
|
||||
render: (value) => (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<PaymentBrandIcon providerId={value} compact />
|
||||
{providerNames[value] || value}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
width: 110,
|
||||
align: "center",
|
||||
render: (value) => <AdminStatusBadge label={value === "completed" ? "已完成" : value === "running" ? "执行中" : "失败"} tone={value === "completed" ? "success" : value === "running" ? "info" : "error"} />,
|
||||
},
|
||||
{ title: "一致", dataIndex: "matchItems", width: 80, align: "right" },
|
||||
{ title: "自动补发", dataIndex: "recoveredItems", width: 100, align: "right", render: (value) => <span className={value ? "font-medium text-status-success" : ""}>{value}</span> },
|
||||
{ title: "异常", dataIndex: "errorItems", width: 80, align: "right", render: (value) => <span className={value ? "font-medium text-status-error" : ""}>{value}</span> },
|
||||
{ title: "完成时间", dataIndex: "completedAt", width: 170, render: (value) => (value ? formatDateTime(value) : "--") },
|
||||
{
|
||||
title: "明细",
|
||||
key: "actions",
|
||||
width: 90,
|
||||
align: "center",
|
||||
render: (_, run) => (
|
||||
<Button type="text" size="small" icon={<Eye className="size-3.5" />} onClick={() => void openRunDetails(run)}>
|
||||
查看
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const detailColumns: ColumnsType<PaymentReconciliationItem> = [
|
||||
{ title: "结果", dataIndex: "result", width: 135, render: (value) => <AdminStatusBadge {...(reconciliationResult[value] || { label: value, tone: "neutral" as const })} /> },
|
||||
{ title: "商户订单号", dataIndex: "merchantOrderNo", width: 255, render: (value) => <span className="font-mono text-xs">{value}</span> },
|
||||
{ title: "渠道交易号", dataIndex: "providerTradeNo", width: 220, render: (value) => (value ? <span className="font-mono text-xs">{value}</span> : "--") },
|
||||
{ title: "金额", dataIndex: "amountFen", width: 110, align: "right", render: (value, item) => `${item.currency} ${(value / 100).toFixed(2)}` },
|
||||
{ title: "说明", dataIndex: "detail", render: (value) => value || "账单与本地订单一致" },
|
||||
];
|
||||
|
||||
return (
|
||||
<AdminPageFrame
|
||||
title="支付充值"
|
||||
description="管理系统支付适配器、充值商品、支付订单与 T+1 对账"
|
||||
actions={
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={loading || ordersLoading || runsLoading} onClick={() => void refresh()}>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
scroll
|
||||
>
|
||||
<Alert className="my-4" type="info" showIcon message="平台不提供支付退款" description="管理端仅提供查单、关单和对账。关单前始终先向渠道查单;对账发现已支付未入账订单时会幂等补发积分。" />
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{
|
||||
key: "providers",
|
||||
label: "支付渠道",
|
||||
children: <AdminDataTable table={{ rowKey: "id", loading, columns: providerColumns, dataSource: providers, pagination: false, scroll: { x: 980 } }} empty={<AdminTableEmpty title="没有发现支付渠道插件" />} />,
|
||||
},
|
||||
{
|
||||
key: "products",
|
||||
label: "充值商品",
|
||||
children: (
|
||||
<AdminDataTable
|
||||
toolbar={<span />}
|
||||
trailing={
|
||||
<Button type="primary" className="admin-toolbar-primary-action" icon={<Plus className="size-4" />} onClick={() => openProduct()}>
|
||||
新增商品
|
||||
</Button>
|
||||
}
|
||||
table={{ rowKey: "id", loading, columns: productColumns, dataSource: products, pagination: false, scroll: { x: 820 } }}
|
||||
empty={<AdminTableEmpty title="还没有充值商品" />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "orders",
|
||||
label: "支付订单",
|
||||
children: (
|
||||
<AdminDataTable
|
||||
toolbar={
|
||||
<Input
|
||||
className="app-list-search"
|
||||
allowClear
|
||||
prefix={<Search className="size-4 text-foreground/40" />}
|
||||
value={orderKeyword}
|
||||
placeholder="订单号、渠道交易号或用户 ID"
|
||||
onChange={(event) => setOrderKeyword(event.target.value)}
|
||||
onPressEnter={() => void loadOrders(1)}
|
||||
/>
|
||||
}
|
||||
toolbarFilters={
|
||||
<Select
|
||||
className="w-36"
|
||||
value={orderStatusFilter}
|
||||
onChange={setOrderStatusFilter}
|
||||
options={[{ value: "all", label: "全部状态" }, ...Object.entries(paymentOrderStatus).map(([value, item]) => ({ value, label: item.label }))]}
|
||||
/>
|
||||
}
|
||||
trailing={<Button onClick={() => void loadOrders(1)}>查询</Button>}
|
||||
table={{ rowKey: "id", loading: ordersLoading, columns: orderColumns, dataSource: orders, pagination: false, scroll: { x: 1180 } }}
|
||||
empty={<AdminTableEmpty filtered={Boolean(orderKeyword || orderStatusFilter !== "all")} title="没有支付订单" />}
|
||||
footer={<PaginationBar alwaysShow current={orderPage} pageSize={orderPageSize} total={orderTotal} onChange={(page, size) => void loadOrders(size !== orderPageSize ? 1 : page, size)} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "reconciliation",
|
||||
label: "支付对账",
|
||||
children: (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border/70 bg-card p-3">
|
||||
<Select
|
||||
className="min-w-52"
|
||||
value={billProviderId || undefined}
|
||||
placeholder="选择支付渠道"
|
||||
onChange={setBillProviderId}
|
||||
options={providers.map((provider) => ({ value: provider.id, label: provider.name, disabled: !provider.configured }))}
|
||||
/>
|
||||
<DatePicker value={billDate} allowClear={false} disabledDate={(date) => !date.isBefore(dayjs(), "day") || date.isBefore(dayjs().subtract(3, "month"), "day")} onChange={(date) => date && setBillDate(date)} />
|
||||
<Button type="primary" loading={runningBill} disabled={!billProviderId} onClick={() => void runReconciliation()}>
|
||||
执行对账
|
||||
</Button>
|
||||
<span className="text-xs text-foreground/45">系统每天 10:15 后自动对账昨日账单;也可在此手动重跑最近三个月账单。</span>
|
||||
</div>
|
||||
<AdminDataTable
|
||||
toolbar={
|
||||
<Select
|
||||
className="w-52"
|
||||
value={runProviderFilter}
|
||||
onChange={(value) => {
|
||||
setRunProviderFilter(value);
|
||||
setRunPage(1);
|
||||
}}
|
||||
options={[{ value: "all", label: "全部支付渠道" }, ...providers.map((provider) => ({ value: provider.id, label: provider.name }))]}
|
||||
/>
|
||||
}
|
||||
trailing={<Button onClick={() => void loadRuns(1)}>筛选</Button>}
|
||||
table={{ rowKey: "id", loading: runsLoading, columns: runColumns, dataSource: runs, pagination: false, scroll: { x: 1000 } }}
|
||||
empty={<AdminTableEmpty title="还没有对账记录" />}
|
||||
footer={<PaginationBar alwaysShow current={runPage} pageSize={runPageSize} total={runTotal} onChange={(page, size) => void loadRuns(size !== runPageSize ? 1 : page, size)} />}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title={providerDrawer ? `配置 ${providerDrawer.name}` : "配置支付渠道"}
|
||||
width={620}
|
||||
open={Boolean(providerDrawer)}
|
||||
destroyOnHidden
|
||||
onClose={() => setProviderDrawer(undefined)}
|
||||
extra={
|
||||
<Button type="primary" loading={providerSaving} onClick={() => void saveProvider()}>
|
||||
保存新版本
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{providerDrawer ? (
|
||||
<Form form={providerForm} layout="vertical" requiredMark="optional">
|
||||
<Alert
|
||||
className="mb-4"
|
||||
type={providerDrawer.pluginEnabled ? "info" : "warning"}
|
||||
showIcon
|
||||
message={providerDrawer.pluginEnabled ? "密钥会加密保存,历史订单固定使用创建时的配置版本。" : "该宿主插件当前已在插件管理中停用;保存配置后仍需开放插件才能接受新订单。"}
|
||||
/>
|
||||
<Form.Item name="enabled" label="渠道配置启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="closeAfterMinutes" label="未支付订单自动关闭时间(分钟)" rules={[{ required: true }, { type: "number", min: 5, max: 1440 }]}>
|
||||
<InputNumber min={5} max={1440} precision={0} className="w-full" />
|
||||
</Form.Item>
|
||||
{providerDrawer.configFields.map((field) => {
|
||||
const secretReady = Boolean(providerDrawer.secretConfigured[field.name]);
|
||||
const rules = field.required && !secretReady ? [{ required: true, message: `请输入${field.label || field.name}` }] : undefined;
|
||||
const placeholder = field.secret && secretReady ? configuredSecretText : field.description || undefined;
|
||||
const input =
|
||||
field.type === "textarea" ? (
|
||||
<Input.TextArea autoSize={{ minRows: 4, maxRows: 10 }} placeholder={placeholder} autoComplete="off" />
|
||||
) : field.type === "password" || field.secret ? (
|
||||
<Input.Password placeholder={placeholder} autoComplete="new-password" />
|
||||
) : (
|
||||
<Input placeholder={placeholder} />
|
||||
);
|
||||
return (
|
||||
<Form.Item key={field.name} name={["values", field.name]} label={field.label || field.name} extra={field.description} rules={rules}>
|
||||
{input}
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
</Form>
|
||||
) : null}
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
title={productDrawer ? "编辑充值商品" : "新增充值商品"}
|
||||
width={520}
|
||||
open={productDrawer !== undefined}
|
||||
destroyOnHidden
|
||||
onClose={() => setProductDrawer(undefined)}
|
||||
extra={
|
||||
<Button type="primary" loading={productSaving} onClick={() => void saveProduct()}>
|
||||
保存
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form form={productForm} layout="vertical" requiredMark="optional">
|
||||
<Form.Item name="name" label="商品名称" rules={[{ required: true, max: 120 }]}>
|
||||
<Input placeholder="例如:100 积分" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="商品说明" rules={[{ max: 500 }]}>
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Form.Item name="amountYuan" label="售价(元)" rules={[{ required: true }, { type: "number", min: 0.01, max: 1_000_000 }]}>
|
||||
<InputNumber min={0.01} max={1_000_000} precision={2} className="w-full" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="credits"
|
||||
label="到账积分"
|
||||
rules={[
|
||||
{ required: true },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
const credits = Number(value);
|
||||
const microcredits = Math.round(credits * 1_000_000);
|
||||
return Number.isFinite(credits) && credits > 0 && credits <= 1_000_000_000 && Number.isSafeInteger(microcredits) ? Promise.resolve() : Promise.reject(new Error("请输入 0.000001 至 10 亿之间且可安全处理的积分"));
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0.000001} max={1_000_000_000} precision={6} className="w-full" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="sortOrder" label="排序" rules={[{ required: true }]}>
|
||||
<InputNumber precision={0} className="w-full" />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="上架销售" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
title={detailRun ? `${providerNames[detailRun.providerId] || detailRun.providerId} · ${detailRun.billDate} 对账明细` : "对账明细"}
|
||||
width="min(1080px, 94vw)"
|
||||
open={Boolean(detailRun)}
|
||||
destroyOnHidden
|
||||
onClose={() => {
|
||||
setDetailRun(undefined);
|
||||
setDetailItems([]);
|
||||
}}
|
||||
>
|
||||
{detailRun?.error ? <Alert className="mb-4" type="error" showIcon message="对账执行失败" description={detailRun.error} /> : null}
|
||||
<AdminDataTable
|
||||
toolbar={
|
||||
<Select
|
||||
className="w-44"
|
||||
value={detailResult}
|
||||
onChange={(value) => {
|
||||
setDetailResult(value);
|
||||
if (detailRun) void openRunDetails(detailRun, 1, detailPageSize, value);
|
||||
}}
|
||||
options={[{ value: "all", label: "全部结果" }, ...Object.entries(reconciliationResult).map(([value, item]) => ({ value, label: item.label }))]}
|
||||
/>
|
||||
}
|
||||
table={{ rowKey: "id", loading: detailLoading, columns: detailColumns, dataSource: detailItems, pagination: false, scroll: { x: 950 } }}
|
||||
empty={<AdminTableEmpty title={detailRun?.status === "failed" ? "本次对账未生成明细" : "账单没有交易记录"} />}
|
||||
footer={<PaginationBar alwaysShow current={detailPage} pageSize={detailPageSize} total={detailTotal} onChange={(page, size) => detailRun && void openRunDetails(detailRun, size !== detailPageSize ? 1 : page, size, detailResult)} />}
|
||||
/>
|
||||
</Drawer>
|
||||
</AdminPageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentBrandIcon({ providerId, compact = false }: { providerId: string; compact?: boolean }) {
|
||||
const size = compact ? "size-6" : "size-10";
|
||||
if (providerId === "wechat-native")
|
||||
return (
|
||||
<span className={`grid ${size} shrink-0 place-items-center rounded-lg bg-[#07c160]/10 text-[#07c160]`}>
|
||||
<WechatFilled className={compact ? "text-sm" : "text-xl"} aria-hidden />
|
||||
</span>
|
||||
);
|
||||
if (providerId === "alipay-page-pay")
|
||||
return (
|
||||
<span className={`grid ${size} shrink-0 place-items-center rounded-lg bg-[#1677ff]/10 text-[#1677ff]`}>
|
||||
<AlipayCircleFilled className={compact ? "text-sm" : "text-xl"} aria-hidden />
|
||||
</span>
|
||||
);
|
||||
return <span className={`grid ${size} shrink-0 place-items-center rounded-lg bg-muted text-xs`}>PAY</span>;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return dayjs(value).format("YYYY-MM-DD HH:mm:ss");
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { App, Button, Input, Select, Switch } from "antd";
|
||||
import { AlipayCircleFilled, WechatFilled } from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { CloudUpload, PlugZap, RefreshCw, Search, Trash2, UsersRound } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -34,7 +35,7 @@ export default function AdminPluginsPage() {
|
||||
const [savingId, setSavingId] = useState("");
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [kind, setKind] = useState<"all" | "application" | "protocol" | "uploaded">("all");
|
||||
const [kind, setKind] = useState<"all" | "application" | "protocol" | "payment" | "uploaded">("all");
|
||||
const [availability, setAvailability] = useState<"all" | "available" | "unavailable">("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
@@ -120,6 +121,7 @@ export default function AdminPluginsPage() {
|
||||
|
||||
const applicationCount = items.filter((item) => item.management.kind === "application").length;
|
||||
const protocolCount = items.filter((item) => item.management.kind === "protocol").length;
|
||||
const paymentCount = items.filter((item) => item.management.kind === "payment").length;
|
||||
const unavailableCount = items.filter((item) => !(states[item.manifest.id]?.platformAvailable ?? item.status === "enabled")).length;
|
||||
const hasFilters = Boolean(search.trim() || kind !== "all" || availability !== "all");
|
||||
const paginated = filtered.slice((page - 1) * pageSize, page * pageSize);
|
||||
@@ -131,18 +133,22 @@ export default function AdminPluginsPage() {
|
||||
width: 410,
|
||||
render: (_, item) => (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-muted text-foreground/65">
|
||||
<PlugZap className="size-4" aria-hidden="true" />
|
||||
</span>
|
||||
<PluginBrandIcon pluginId={item.manifest.id} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="truncate font-medium text-foreground">{item.manifest.name}</span>
|
||||
<span className="shrink-0 text-xs text-foreground/42">v{item.manifest.version}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-xs">
|
||||
<span className="max-w-44 shrink-0 truncate font-mono text-[11px] text-foreground/42" title={item.manifest.id}>{item.manifest.id}</span>
|
||||
<span className="text-foreground/20" aria-hidden="true">·</span>
|
||||
<span className={`truncate ${item.error ? "text-status-error" : "text-foreground/52"}`} title={item.error || item.manifest.description || "未提供插件说明"}>{item.error || item.manifest.description || "未提供插件说明"}</span>
|
||||
<span className="max-w-44 shrink-0 truncate font-mono text-[11px] text-foreground/42" title={item.manifest.id}>
|
||||
{item.manifest.id}
|
||||
</span>
|
||||
<span className="text-foreground/20" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<span className={`truncate ${item.error ? "text-status-error" : "text-foreground/52"}`} title={item.error || item.manifest.description || "未提供插件说明"}>
|
||||
{item.error || item.manifest.description || "未提供插件说明"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,7 +162,11 @@ export default function AdminPluginsPage() {
|
||||
render: (_, item) => (
|
||||
<div>
|
||||
<AdminStatusBadge label={managementLabel(item.management)} tone={item.management.origin === "uploaded" ? "warning" : item.management.kind === "application" ? "info" : "neutral"} />
|
||||
{!item.manifest.trusted ? <div className="mt-1.5"><AdminStatusBadge label="来源未验证" tone="warning" /></div> : null}
|
||||
{!item.manifest.trusted ? (
|
||||
<div className="mt-1.5">
|
||||
<AdminStatusBadge label="来源未验证" tone="warning" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -165,20 +175,21 @@ export default function AdminPluginsPage() {
|
||||
key: "scope",
|
||||
width: 180,
|
||||
align: "center",
|
||||
render: (_, item) => item.management.activationScope === "user" ? (
|
||||
<div>
|
||||
<div className="font-medium text-foreground/78">用户自主启用</div>
|
||||
<div className="mt-1 inline-flex items-center gap-1 text-xs text-foreground/48">
|
||||
<UsersRound className="size-3.5" aria-hidden="true" />
|
||||
{states[item.manifest.id]?.enabledUserCount || 0} 位已启用
|
||||
render: (_, item) =>
|
||||
item.management.activationScope === "user" ? (
|
||||
<div>
|
||||
<div className="font-medium text-foreground/78">用户自主启用</div>
|
||||
<div className="mt-1 inline-flex items-center gap-1 text-xs text-foreground/48">
|
||||
<UsersRound className="size-3.5" aria-hidden="true" />
|
||||
{states[item.manifest.id]?.enabledUserCount || 0} 位已启用
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="font-medium text-foreground/78">全局生效</div>
|
||||
<div className="mt-1 text-xs text-foreground/48">管理员统一控制</div>
|
||||
</div>
|
||||
),
|
||||
) : (
|
||||
<div>
|
||||
<div className="font-medium text-foreground/78">全局生效</div>
|
||||
<div className="mt-1 text-xs text-foreground/48">管理员统一控制</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "平台状态",
|
||||
@@ -190,7 +201,13 @@ export default function AdminPluginsPage() {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<AdminStatusBadge label={available ? "已开放" : "已停用"} tone={available ? "success" : "neutral"} />
|
||||
<Switch className="plugin-state-switch" loading={savingId === item.manifest.id} checked={available} aria-label={`${item.manifest.name},当前${available ? "平台开放,点击停用" : "平台停用,点击开放"}`} onChange={(checked) => void changeAvailability(item, checked)} />
|
||||
<Switch
|
||||
className="plugin-state-switch"
|
||||
loading={savingId === item.manifest.id}
|
||||
checked={available}
|
||||
aria-label={`${item.manifest.name},当前${available ? "平台开放,点击停用" : "平台停用,点击开放"}`}
|
||||
onChange={(checked) => void changeAvailability(item, checked)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -200,13 +217,14 @@ export default function AdminPluginsPage() {
|
||||
key: "actions",
|
||||
width: 72,
|
||||
align: "center",
|
||||
render: (_, item) => item.management.origin === "uploaded" ? (
|
||||
<Button danger type="text" size="small" aria-label={`卸载 ${item.manifest.name}`} icon={<Trash2 className="size-3.5" aria-hidden="true" />} onClick={() => remove(item)} />
|
||||
) : (
|
||||
<span title="内置插件不可卸载">
|
||||
<Button type="text" size="small" disabled aria-label={`${item.manifest.name}为内置插件,不可卸载`} icon={<Trash2 className="size-3.5" aria-hidden="true" />} />
|
||||
</span>
|
||||
),
|
||||
render: (_, item) =>
|
||||
item.management.origin === "uploaded" ? (
|
||||
<Button danger type="text" size="small" aria-label={`卸载 ${item.manifest.name}`} icon={<Trash2 className="size-3.5" aria-hidden="true" />} onClick={() => remove(item)} />
|
||||
) : (
|
||||
<span title="内置插件不可卸载">
|
||||
<Button type="text" size="small" disabled aria-label={`${item.manifest.name}为内置插件,不可卸载`} icon={<Trash2 className="size-3.5" aria-hidden="true" />} />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -225,21 +243,84 @@ export default function AdminPluginsPage() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="my-4 grid min-h-16 grid-cols-4 divide-x divide-border/70 overflow-hidden rounded-lg border border-border/70 bg-card">
|
||||
<div className="my-4 grid min-h-16 grid-cols-2 divide-x divide-border/70 overflow-hidden rounded-lg border border-border/70 bg-card sm:grid-cols-5">
|
||||
<OverviewItem label="全部插件" value={items.length} />
|
||||
<OverviewItem label="官方应用" value={applicationCount} />
|
||||
<OverviewItem label="协议与自定义" value={protocolCount} />
|
||||
<OverviewItem label="系统协议" value={protocolCount} />
|
||||
<OverviewItem label="支付协议" value={paymentCount} />
|
||||
<OverviewItem label="平台已停用" value={unavailableCount} tone={unavailableCount ? "warning" : "default"} />
|
||||
</div>
|
||||
<AdminDataTable
|
||||
toolbar={<Input className="app-list-search" allowClear prefix={<Search className="size-4 text-foreground/40" />} value={search} aria-label="搜索插件" placeholder="搜索插件名称、ID 或作者" onChange={(event) => { setSearch(event.target.value); setPage(1); }} />}
|
||||
toolbarFilters={<><Select aria-label="筛选插件类型" className="w-44" value={kind} onChange={(value) => { setKind(value); setPage(1); }} options={[{ value: "all", label: "全部类型" }, { value: "application", label: "官方应用插件" }, { value: "protocol", label: "系统协议插件" }, { value: "uploaded", label: "上传的自定义插件" }]} /><Select aria-label="筛选插件状态" className="w-32" value={availability} onChange={(value) => { setAvailability(value); setPage(1); }} options={[{ value: "all", label: "全部状态" }, { value: "available", label: "平台开放" }, { value: "unavailable", label: "平台停用" }]} /></>}
|
||||
toolbar={
|
||||
<Input
|
||||
className="app-list-search"
|
||||
allowClear
|
||||
prefix={<Search className="size-4 text-foreground/40" />}
|
||||
value={search}
|
||||
aria-label="搜索插件"
|
||||
placeholder="搜索插件名称、ID 或作者"
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
toolbarFilters={
|
||||
<>
|
||||
<Select
|
||||
aria-label="筛选插件类型"
|
||||
className="w-44"
|
||||
value={kind}
|
||||
onChange={(value) => {
|
||||
setKind(value);
|
||||
setPage(1);
|
||||
}}
|
||||
options={[
|
||||
{ value: "all", label: "全部类型" },
|
||||
{ value: "application", label: "官方应用插件" },
|
||||
{ value: "protocol", label: "系统协议插件" },
|
||||
{ value: "payment", label: "支付协议插件" },
|
||||
{ value: "uploaded", label: "上传的自定义插件" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
aria-label="筛选插件状态"
|
||||
className="w-32"
|
||||
value={availability}
|
||||
onChange={(value) => {
|
||||
setAvailability(value);
|
||||
setPage(1);
|
||||
}}
|
||||
options={[
|
||||
{ value: "all", label: "全部状态" },
|
||||
{ value: "available", label: "平台开放" },
|
||||
{ value: "unavailable", label: "平台停用" },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
toolbarActive={hasFilters}
|
||||
onReset={() => { setSearch(""); setKind("all"); setAvailability("all"); setPage(1); }}
|
||||
onReset={() => {
|
||||
setSearch("");
|
||||
setKind("all");
|
||||
setAvailability("all");
|
||||
setPage(1);
|
||||
}}
|
||||
skeletonColumns={5}
|
||||
table={{ className: "app-data-table", size: "small", sticky: true, rowKey: (item) => item.manifest.id, loading, columns, dataSource: paginated, pagination: false, scroll: { x: 1120 } }}
|
||||
empty={<AdminTableEmpty filtered={hasFilters} title={hasFilters ? undefined : "还没有可管理的插件"} />}
|
||||
footer={<PaginationBar alwaysShow current={page} pageSize={pageSize} total={filtered.length} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />}
|
||||
footer={
|
||||
<PaginationBar
|
||||
alwaysShow
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={filtered.length}
|
||||
onChange={(nextPage, nextPageSize) => {
|
||||
setPage(nextPageSize !== pageSize ? 1 : nextPage);
|
||||
setPageSize(nextPageSize);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<UploadPluginModal open={uploadOpen} onClose={() => setUploadOpen(false)} onUpload={(file) => void upload(file)} />
|
||||
</AdminPageFrame>
|
||||
@@ -255,6 +336,28 @@ function OverviewItem({ label, value, tone = "default" }: { label: string; value
|
||||
);
|
||||
}
|
||||
|
||||
function PluginBrandIcon({ pluginId }: { pluginId: string }) {
|
||||
if (pluginId === "official-payment-wechat-native") {
|
||||
return (
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-[#07c160]/10 text-[#07c160]">
|
||||
<WechatFilled className="text-lg" aria-hidden />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (pluginId === "official-payment-alipay-page") {
|
||||
return (
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-[#1677ff]/10 text-[#1677ff]">
|
||||
<AlipayCircleFilled className="text-lg" aria-hidden />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-muted text-foreground/65">
|
||||
<PlugZap className="size-4" aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function mergePlugins(remote: BackendPlugin[]): AdminPluginItem[] {
|
||||
const byId = new Map<string, AdminPluginItem>();
|
||||
for (const plugin of listRegisteredPlugins()) {
|
||||
@@ -276,11 +379,13 @@ function mergePlugins(remote: BackendPlugin[]): AdminPluginItem[] {
|
||||
|
||||
function managementOrder(value: PluginManagement) {
|
||||
if (value.kind === "application") return 0;
|
||||
if (value.origin === "official") return 1;
|
||||
return 2;
|
||||
if (value.kind === "payment") return 1;
|
||||
if (value.origin === "official") return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
function managementLabel(value: PluginManagement) {
|
||||
if (value.origin === "uploaded") return "自定义插件";
|
||||
return value.kind === "application" ? "官方应用" : "系统协议";
|
||||
if (value.kind === "application") return "官方应用";
|
||||
return value.kind === "payment" ? "系统支付协议" : "系统协议";
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { App, Button, Input, Modal, Select, Switch, Typography } from "antd";
|
||||
import { AudioLines, CalendarDays, CheckCircle2, Clock3, ExternalLink, Film, FolderOpen, Image as ImageIcon, MessageSquareText, PlugZap, RefreshCw, Search, Settings2, ShieldCheck, SlidersHorizontal } from "lucide-react";
|
||||
import { AudioLines, CalendarDays, CheckCircle2, Clock3, CreditCard, ExternalLink, Film, FolderOpen, Image as ImageIcon, MessageSquareText, PlugZap, RefreshCw, Search, Settings2, ShieldCheck, SlidersHorizontal } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
@@ -19,6 +19,7 @@ import "./plugins.css";
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
provider: "模型渠道",
|
||||
"payment-provider": "支付协议",
|
||||
"canvas-node": "画布节点",
|
||||
workflow: "工作流",
|
||||
transform: "媒体转换",
|
||||
@@ -55,6 +56,7 @@ const protocolSectionMeta = [
|
||||
{ key: "image", label: "图片协议", description: "生成、编辑与参考图", icon: ImageIcon },
|
||||
{ key: "video", label: "视频协议", description: "创建任务、轮询与媒体结果", icon: Film },
|
||||
{ key: "audio", label: "音频协议", description: "语音与异步音频任务", icon: AudioLines },
|
||||
{ key: "payment", label: "支付协议", description: "支付、查单、关单与对账", icon: CreditCard },
|
||||
] as const;
|
||||
|
||||
export default function PluginsPage() {
|
||||
@@ -141,12 +143,7 @@ export default function PluginsPage() {
|
||||
const contributionKinds = contributionKindsFor(manifest);
|
||||
const searchableText = [manifest.name, manifest.description, manifest.author, manifest.id, ...contributionKinds.map((kind) => categoryLabels[kind] || kind)].filter(Boolean).join(" ").toLocaleLowerCase();
|
||||
if (normalizedSearch && !searchableText.includes(normalizedSearch)) return false;
|
||||
if (categoryFilter !== "all") {
|
||||
const providerCapabilities = providerCapabilitiesFor(manifest);
|
||||
const matchesCapability = providerCapabilities.includes(categoryFilter as "text" | "image" | "video" | "audio");
|
||||
const matchesApp = categoryFilter === "other" && contributionKinds.length > 0 && providerCapabilities.length === 0;
|
||||
if (!matchesCapability && !matchesApp) return false;
|
||||
}
|
||||
if (categoryFilter !== "all" && !pluginMatchesCategory(manifest, categoryFilter)) return false;
|
||||
if (trustFilter === "trusted" && !manifest.trusted) return false;
|
||||
if (statusFilter === "enabled" && !enabled) return false;
|
||||
if (statusFilter === "disabled" && enabled) return false;
|
||||
@@ -156,8 +153,8 @@ export default function PluginsPage() {
|
||||
|
||||
const pluginSections = useMemo(
|
||||
() => [
|
||||
...protocolSectionMeta.map((section) => ({ ...section, plugins: filteredPlugins.filter((plugin) => providerCapabilitiesFor(plugin.manifest).includes(section.key)) })),
|
||||
{ key: "other", label: "应用插件", description: "画布、素材与工作流扩展", icon: PlugZap, plugins: filteredPlugins.filter((plugin) => !providerCapabilitiesFor(plugin.manifest).length) },
|
||||
...protocolSectionMeta.map((section) => ({ ...section, plugins: filteredPlugins.filter((plugin) => pluginMatchesCategory(plugin.manifest, section.key)) })),
|
||||
{ key: "other", label: "应用插件", description: "画布、素材与工作流扩展", icon: PlugZap, plugins: filteredPlugins.filter((plugin) => pluginMatchesCategory(plugin.manifest, "other")) },
|
||||
],
|
||||
[filteredPlugins],
|
||||
);
|
||||
@@ -182,13 +179,12 @@ export default function PluginsPage() {
|
||||
|
||||
const categoryCounts = useMemo(() => {
|
||||
const visiblePlugins = registeredPlugins.filter((plugin) => user?.role === "admin" || features.systemPluginsVisibleToUsers || isOfficialApplicationPlugin(plugin.manifest.id));
|
||||
const counts: Record<string, number> = { all: visiblePlugins.length, text: 0, image: 0, video: 0, audio: 0, other: 0 };
|
||||
const counts: Record<string, number> = { all: visiblePlugins.length, text: 0, image: 0, video: 0, audio: 0, payment: 0, other: 0 };
|
||||
for (const plugin of visiblePlugins) {
|
||||
const capabilities = providerCapabilitiesFor(plugin.manifest);
|
||||
if (!capabilities.length) counts.other += 1;
|
||||
for (const capability of capabilities) {
|
||||
counts[capability] = (counts[capability] || 0) + 1;
|
||||
for (const section of protocolSectionMeta) {
|
||||
if (pluginMatchesCategory(plugin.manifest, section.key)) counts[section.key] += 1;
|
||||
}
|
||||
if (pluginMatchesCategory(plugin.manifest, "other")) counts.other += 1;
|
||||
}
|
||||
return counts;
|
||||
}, [features.systemPluginsVisibleToUsers, registeredPlugins, user?.role]);
|
||||
@@ -201,8 +197,7 @@ export default function PluginsPage() {
|
||||
const hasPluginConfiguration = (plugin: RegisteredPlugin) => Boolean(plugin.manifest.configuration?.fields?.length);
|
||||
const canConfigurePlugin = (plugin: RegisteredPlugin) => Boolean(pluginStates[plugin.manifest.id]?.canConfigure) && (hasPluginConfiguration(plugin) || plugin.manifest.id === RUNNINGHUB_PLUGIN_ID || plugin.manifest.id === COMFYUI_PLUGIN_ID);
|
||||
|
||||
const isPluginEnabled = (plugin: RegisteredPlugin, installation = installations.find((item) => item.manifest.id === plugin.manifest.id)) =>
|
||||
pluginStates[plugin.manifest.id]?.effectiveEnabled ?? Boolean(installation?.enabled);
|
||||
const isPluginEnabled = (plugin: RegisteredPlugin, installation = installations.find((item) => item.manifest.id === plugin.manifest.id)) => pluginStates[plugin.manifest.id]?.effectiveEnabled ?? Boolean(installation?.enabled);
|
||||
|
||||
const togglePlugin = async (plugin: RegisteredPlugin, enabled: boolean) => {
|
||||
try {
|
||||
@@ -325,7 +320,11 @@ export default function PluginsPage() {
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={backendPluginsLoading} onClick={() => void reloadBackendPlugins()}>
|
||||
刷新插件
|
||||
</Button>
|
||||
{user?.role === "admin" ? <Button type="primary" onClick={() => navigate("/admin/plugins")}>管理员插件管理</Button> : null}
|
||||
{user?.role === "admin" ? (
|
||||
<Button type="primary" onClick={() => navigate("/admin/plugins")}>
|
||||
管理员插件管理
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -385,16 +384,18 @@ export default function PluginsPage() {
|
||||
<span className="plugin-version">v{plugin.manifest.version}</span>
|
||||
</div>
|
||||
<div className="plugin-card-labels">
|
||||
<span className={`plugin-source-label${sourceLabel === "系统插件" ? " is-system" : ""}`}>
|
||||
{sourceLabel}
|
||||
</span>
|
||||
<span className={`plugin-source-label${sourceLabel === "系统插件" ? " is-system" : ""}`}>{sourceLabel}</span>
|
||||
{trusted ? (
|
||||
<span className="plugin-trust-label">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
可信插件
|
||||
</span>
|
||||
) : null}
|
||||
<span className="plugin-category-label">{contributionKindsFor(plugin.manifest).map((kind) => categoryLabels[kind] ?? kind).join(" · ")}</span>
|
||||
<span className="plugin-category-label">
|
||||
{contributionKindsFor(plugin.manifest)
|
||||
.map((kind) => categoryLabels[kind] ?? kind)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -429,7 +430,14 @@ export default function PluginsPage() {
|
||||
<i aria-hidden="true" />
|
||||
{!state?.platformAvailable && state?.blockedReason ? state.blockedReason : enabled ? "已启用" : "已停用"}
|
||||
</span>
|
||||
<Switch className="plugin-state-switch" disabled={!state?.canToggle} checked={enabled} aria-label={`${plugin.manifest.name},当前${enabled ? "已启用,点击停用" : "已停用,点击启用"}`} title={state?.blockedReason} onChange={(checked) => void togglePlugin(plugin, checked)} />
|
||||
<Switch
|
||||
className="plugin-state-switch"
|
||||
disabled={!state?.canToggle}
|
||||
checked={enabled}
|
||||
aria-label={`${plugin.manifest.name},当前${enabled ? "已启用,点击停用" : "已停用,点击启用"}`}
|
||||
title={state?.blockedReason}
|
||||
onChange={(checked) => void togglePlugin(plugin, checked)}
|
||||
/>
|
||||
{canConfigure ? (
|
||||
<Button
|
||||
className="plugin-settings-button"
|
||||
@@ -569,7 +577,11 @@ export default function PluginsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="plugin-settings-empty">
|
||||
{`贡献能力:${contributionKindsFor(settingsPlugin.manifest).map((kind) => categoryLabels[kind] || kind).join("、") || "未声明"}。当前接入位置和权限会根据插件清单自动生效。`}
|
||||
{`贡献能力:${
|
||||
contributionKindsFor(settingsPlugin.manifest)
|
||||
.map((kind) => categoryLabels[kind] || kind)
|
||||
.join("、") || "未声明"
|
||||
}。当前接入位置和权限会根据插件清单自动生效。`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -610,6 +622,7 @@ function isOfficialApplicationPlugin(pluginId: string) {
|
||||
|
||||
function pluginSourceLabel(plugin: RegisteredPlugin, state?: PluginState) {
|
||||
if (plugin.source === "uploaded") return "自定义插件";
|
||||
if (plugin.source === "system") return "系统插件";
|
||||
if (state?.canToggle || isOfficialApplicationPlugin(plugin.manifest.id)) return "官方插件";
|
||||
return "系统插件";
|
||||
}
|
||||
@@ -618,6 +631,7 @@ function contributionKindsFor(manifest: PluginManifest): string[] {
|
||||
const contributions = manifest.contributes;
|
||||
const kinds: string[] = [];
|
||||
if (contributions.providers?.length) kinds.push("provider");
|
||||
if (contributions.paymentProviders?.length) kinds.push("payment-provider");
|
||||
if (contributions.workflows?.length) kinds.push("workflow");
|
||||
if (contributions.canvasNodes?.length) kinds.push("canvas-node");
|
||||
if (contributions.transforms?.length) kinds.push("transform");
|
||||
@@ -633,6 +647,14 @@ function providerCapabilitiesFor(manifest: PluginManifest) {
|
||||
return [...new Set((manifest.contributes.providers || []).flatMap((provider) => provider.capabilities))];
|
||||
}
|
||||
|
||||
function pluginMatchesCategory(manifest: PluginManifest, category: string) {
|
||||
const providerCapabilities = providerCapabilitiesFor(manifest);
|
||||
const isPaymentProtocol = Boolean(manifest.contributes.paymentProviders?.length);
|
||||
if (category === "payment") return isPaymentProtocol;
|
||||
if (category === "other") return !isPaymentProtocol && providerCapabilities.length === 0;
|
||||
return providerCapabilities.includes(category as "text" | "image" | "video" | "audio");
|
||||
}
|
||||
|
||||
function capabilityLabel(value: string) {
|
||||
return ({ text: "文本", image: "图片", video: "视频", audio: "音频" } as Record<string, string>)[value] || value;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { App, Button, Grid, Input, Segmented, Table, Tag } from "antd";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { AlipayCircleFilled, WechatFilled } from "@ant-design/icons";
|
||||
import { App, Button, Grid, Input, Modal, QRCode, Segmented, Skeleton, Table, Tag } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { ArrowDownLeft, ArrowUpRight, CalendarCheck, Coins, RefreshCw, RotateCcw, ShieldCheck, SlidersHorizontal, Sparkles, TicketCheck } from "lucide-react";
|
||||
import { ArrowDownLeft, ArrowUpRight, CalendarCheck, Coins, CreditCard, RefreshCw, RotateCcw, ShieldCheck, SlidersHorizontal, Sparkles, TicketCheck } from "lucide-react";
|
||||
|
||||
import { formatCredits } from "@/constant/credits";
|
||||
import { PaginationBar, TableSurface } from "@/components/layout/workspace-page";
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { checkinCredits, getWallet, redeemCredits, type CreditLedgerEntry, type WalletSummary } from "@/services/api/wallet";
|
||||
import { closePaymentOrder, createPaymentOrder, getPaymentOrder, listPaymentProviders, listTopupProducts, queryPaymentOrder, refreshPaymentCheckout, type PaymentOrder, type PaymentProvider, type TopupProduct } from "@/services/api/payments";
|
||||
import { modelDisplayName, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
type LedgerFilter = "all" | "income" | "consume" | "refund";
|
||||
@@ -33,7 +35,22 @@ export default function WalletPage() {
|
||||
const [checkingIn, setCheckingIn] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [paymentProducts, setPaymentProducts] = useState<TopupProduct[]>([]);
|
||||
const [paymentProviders, setPaymentProviders] = useState<PaymentProvider[]>([]);
|
||||
const [paymentsLoading, setPaymentsLoading] = useState(true);
|
||||
const [selectedProductId, setSelectedProductId] = useState("");
|
||||
const [selectedProviderId, setSelectedProviderId] = useState("");
|
||||
const [paymentCreating, setPaymentCreating] = useState(false);
|
||||
const [paymentOrder, setPaymentOrder] = useState<PaymentOrder | null>(null);
|
||||
const [paymentModalOpen, setPaymentModalOpen] = useState(false);
|
||||
const [paymentQuerying, setPaymentQuerying] = useState(false);
|
||||
const [clock, setClock] = useState(Date.now());
|
||||
const requestSequence = useRef(0);
|
||||
const paymentPollSequence = useRef(0);
|
||||
const paymentRefreshInFlight = useRef(false);
|
||||
const nextPaymentRefreshAt = useRef(0);
|
||||
const paymentIdempotencyKey = useRef("");
|
||||
const completedPaymentOrderId = useRef("");
|
||||
|
||||
const reload = async (targetPage = page, targetPageSize = pageSize) => {
|
||||
const sequence = ++requestSequence.current;
|
||||
@@ -52,6 +69,82 @@ export default function WalletPage() {
|
||||
void reload(page, pageSize);
|
||||
}, [filter, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setPaymentsLoading(true);
|
||||
Promise.all([listTopupProducts(), listPaymentProviders()])
|
||||
.then(([productsResult, providersResult]) => {
|
||||
if (!active) return;
|
||||
setPaymentProducts(productsResult.products);
|
||||
setPaymentProviders(providersResult.providers);
|
||||
setSelectedProductId((current) => current || productsResult.products[0]?.id || "");
|
||||
setSelectedProviderId((current) => current || providersResult.providers[0]?.id || "");
|
||||
})
|
||||
.catch((error) => {
|
||||
if (active) message.error(error instanceof Error ? error.message : "读取在线充值配置失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setPaymentsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const returnedOrderId = new URLSearchParams(window.location.search).get("paymentOrder");
|
||||
if (!returnedOrderId) return;
|
||||
getPaymentOrder(returnedOrderId)
|
||||
.then(({ order }) => {
|
||||
setPaymentOrder(order);
|
||||
nextPaymentRefreshAt.current = 0;
|
||||
setPaymentModalOpen(true);
|
||||
if (order.status === "pending") void confirmPayment(order.id);
|
||||
})
|
||||
.catch((error) => message.error(error instanceof Error ? error.message : "读取支付结果失败"));
|
||||
window.history.replaceState(null, "", window.location.pathname);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!paymentModalOpen || !paymentOrder || !["created", "pending", "closing"].includes(paymentOrder.status)) return;
|
||||
const interval = window.setInterval(() => {
|
||||
setClock(Date.now());
|
||||
const sequence = ++paymentPollSequence.current;
|
||||
void getPaymentOrder(paymentOrder.id)
|
||||
.then(({ order }) => {
|
||||
if (sequence !== paymentPollSequence.current) return;
|
||||
setPaymentOrder(order);
|
||||
if (order.status === "credited") {
|
||||
void paymentCompleted(order.id);
|
||||
return;
|
||||
}
|
||||
const checkoutExpired = order.checkout.mode === "qr_code" && order.checkout.expiresAt && new Date(order.checkout.expiresAt).getTime() <= Date.now();
|
||||
if (checkoutExpired && new Date(order.expiresAt).getTime() > Date.now() && !paymentRefreshInFlight.current && Date.now() >= nextPaymentRefreshAt.current) {
|
||||
paymentRefreshInFlight.current = true;
|
||||
nextPaymentRefreshAt.current = Date.now() + 15 * 60_000;
|
||||
void refreshPaymentCheckout(order.id)
|
||||
.then(async (result) => {
|
||||
setPaymentOrder(result.order);
|
||||
if (result.order.status === "credited") await paymentCompleted(result.order.id);
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
paymentRefreshInFlight.current = false;
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, 2_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [paymentModalOpen, paymentOrder?.id, paymentOrder?.status]);
|
||||
|
||||
const selectedProduct = useMemo(() => paymentProducts.find((item) => item.id === selectedProductId), [paymentProducts, selectedProductId]);
|
||||
const selectedProvider = useMemo(() => paymentProviders.find((item) => item.id === selectedProviderId), [paymentProviders, selectedProviderId]);
|
||||
|
||||
useEffect(() => {
|
||||
paymentIdempotencyKey.current = "";
|
||||
}, [selectedProductId, selectedProviderId]);
|
||||
|
||||
const redeem = async () => {
|
||||
const normalized = code.trim().toLowerCase();
|
||||
if (normalized.length !== 32) {
|
||||
@@ -87,6 +180,94 @@ export default function WalletPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const startPayment = async () => {
|
||||
if (!selectedProduct || !selectedProvider) {
|
||||
message.error("请选择充值商品和支付方式");
|
||||
return;
|
||||
}
|
||||
setPaymentCreating(true);
|
||||
try {
|
||||
if (!paymentIdempotencyKey.current) paymentIdempotencyKey.current = crypto.randomUUID();
|
||||
const result = await createPaymentOrder({ productId: selectedProduct.id, providerId: selectedProvider.id, idempotencyKey: paymentIdempotencyKey.current });
|
||||
paymentIdempotencyKey.current = "";
|
||||
setPaymentOrder(result.order);
|
||||
nextPaymentRefreshAt.current = 0;
|
||||
if (result.order.status === "credited") {
|
||||
setPaymentModalOpen(true);
|
||||
await paymentCompleted(result.order.id);
|
||||
return;
|
||||
}
|
||||
if (result.order.checkout.mode === "redirect" && result.order.checkout.url) {
|
||||
window.location.assign(result.order.checkout.url);
|
||||
return;
|
||||
}
|
||||
setPaymentModalOpen(true);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "创建支付订单失败");
|
||||
} finally {
|
||||
setPaymentCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
async function confirmPayment(orderId = paymentOrder?.id) {
|
||||
if (!orderId) return;
|
||||
setPaymentQuerying(true);
|
||||
try {
|
||||
const result = await queryPaymentOrder(orderId);
|
||||
setPaymentOrder(result.order);
|
||||
if (result.order.status === "credited") await paymentCompleted(result.order.id);
|
||||
else if (result.order.status === "closed") message.warning("订单已关闭,未产生积分充值");
|
||||
else message.info("渠道尚未确认支付,请稍后再试");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "查询支付结果失败");
|
||||
} finally {
|
||||
setPaymentQuerying(false);
|
||||
}
|
||||
}
|
||||
|
||||
const cancelPayment = async () => {
|
||||
if (!paymentOrder) return;
|
||||
setPaymentQuerying(true);
|
||||
try {
|
||||
const result = await closePaymentOrder(paymentOrder.id);
|
||||
setPaymentOrder(result.order);
|
||||
if (result.order.status === "credited") await paymentCompleted(result.order.id);
|
||||
else message.success("未支付订单已关闭");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "关闭订单失败");
|
||||
} finally {
|
||||
setPaymentQuerying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const retryPaymentCheckout = async () => {
|
||||
if (!paymentOrder) return;
|
||||
setPaymentQuerying(true);
|
||||
try {
|
||||
const result = await refreshPaymentCheckout(paymentOrder.id);
|
||||
setPaymentOrder(result.order);
|
||||
if (result.order.status === "credited") await paymentCompleted(result.order.id);
|
||||
else if (result.order.status === "closed") message.warning("订单已关闭,未产生积分充值");
|
||||
else if (result.order.checkout.mode === "redirect" && result.order.checkout.url) {
|
||||
window.location.assign(result.order.checkout.url);
|
||||
return;
|
||||
} else message.success("支付二维码已重新生成");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "重新生成支付收银台失败");
|
||||
} finally {
|
||||
setPaymentQuerying(false);
|
||||
}
|
||||
};
|
||||
|
||||
async function paymentCompleted(orderId: string) {
|
||||
if (completedPaymentOrderId.current === orderId) return;
|
||||
completedPaymentOrderId.current = orderId;
|
||||
await reload(1, pageSize);
|
||||
setPage(1);
|
||||
window.dispatchEvent(new CustomEvent("wallet:updated"));
|
||||
message.success("支付成功,积分已到账");
|
||||
}
|
||||
|
||||
const entries = wallet?.entries || [];
|
||||
const account = wallet?.account;
|
||||
const totalMicrocredits = (account?.availableMicrocredits || 0) + (account?.reservedMicrocredits || 0);
|
||||
@@ -119,7 +300,12 @@ export default function WalletPage() {
|
||||
<main className="app-user-content app-workspace-scroll library-page wallet-library-page relative h-full overflow-y-auto text-foreground">
|
||||
<div className="relative w-full px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="studio-band">
|
||||
<motion.header initial={reducedMotion ? false : { opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="app-page-header flex flex-wrap items-start justify-between gap-4">
|
||||
<motion.header
|
||||
initial={reducedMotion ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }}
|
||||
className="app-page-header flex flex-wrap items-start justify-between gap-4"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[var(--fs-heading-lg)] font-semibold leading-7">积分中心</h1>
|
||||
@@ -131,7 +317,14 @@ export default function WalletPage() {
|
||||
<Coins className="size-3" />
|
||||
可用 {formatCredits(account?.availableMicrocredits || 0, 6)}
|
||||
</span>
|
||||
<Button className="library-primary-action" icon={<CalendarCheck className="size-4" />} type={wallet?.policy.checkedInToday ? "default" : "primary"} loading={checkingIn} disabled={wallet?.policy.checkedInToday} onClick={() => void checkin()}>
|
||||
<Button
|
||||
className="library-primary-action"
|
||||
icon={<CalendarCheck className="size-4" />}
|
||||
type={wallet?.policy.checkedInToday ? "default" : "primary"}
|
||||
loading={checkingIn}
|
||||
disabled={wallet?.policy.checkedInToday}
|
||||
onClick={() => void checkin()}
|
||||
>
|
||||
{wallet?.policy.checkedInToday ? "今日已签到" : `签到 +${formatCredits(wallet?.policy.checkinBonusMicrocredits || 0)}`}
|
||||
</Button>
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void reload()}>
|
||||
@@ -146,8 +339,13 @@ export default function WalletPage() {
|
||||
<div className="wallet-balance-inner">
|
||||
<div className="wallet-balance-primary">
|
||||
<div className="wallet-balance-heading">
|
||||
<span className="library-icon-tile wallet-balance-icon"><Coins /></span>
|
||||
<div><strong>可用创作积分</strong><span>最近更新 {formatTime(account?.updatedAt)}</span></div>
|
||||
<span className="library-icon-tile wallet-balance-icon">
|
||||
<Coins />
|
||||
</span>
|
||||
<div>
|
||||
<strong>可用创作积分</strong>
|
||||
<span>最近更新 {formatTime(account?.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wallet-balance-number">
|
||||
<strong>{formatCredits(account?.availableMicrocredits || 0, 6)}</strong>
|
||||
@@ -155,14 +353,22 @@ export default function WalletPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="wallet-balance-details">
|
||||
<span className="wallet-account-status"><ShieldCheck />账户正常</span>
|
||||
<span className="wallet-account-status">
|
||||
<ShieldCheck />
|
||||
账户正常
|
||||
</span>
|
||||
<BalanceMetric label="冻结积分" description="调用中或待核对" value={account?.reservedMicrocredits || 0} icon={<TicketCheck className="size-4" />} />
|
||||
<BalanceMetric label="账户总额" description="可用与冻结合计" value={totalMicrocredits} icon={<Coins className="size-4" />} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<motion.div initial={reducedMotion ? false : { opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="wallet-redeem-panel app-workspace-surface flex flex-col rounded-lg p-5 backdrop-blur-xl sm:p-6">
|
||||
<motion.div
|
||||
initial={reducedMotion ? false : { opacity: 0, x: 12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }}
|
||||
className="wallet-redeem-panel app-workspace-surface flex flex-col rounded-lg p-5 backdrop-blur-xl sm:p-6"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="wallet-redeem-icon grid size-9 shrink-0 place-items-center rounded-lg">
|
||||
<TicketCheck className="size-4" />
|
||||
@@ -174,7 +380,17 @@ export default function WalletPage() {
|
||||
</div>
|
||||
<label className="mt-6 block">
|
||||
<span className="text-xs font-medium text-foreground/70">兑换码</span>
|
||||
<Input className="mt-2 font-mono" size="large" value={code} maxLength={32} spellCheck={false} autoComplete="off" onChange={(event) => setCode(event.target.value.replace(/[-\s]/g, ""))} placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" onPressEnter={() => void redeem()} />
|
||||
<Input
|
||||
className="mt-2 font-mono"
|
||||
size="large"
|
||||
value={code}
|
||||
maxLength={32}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
onChange={(event) => setCode(event.target.value.replace(/[-\s]/g, ""))}
|
||||
placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
onPressEnter={() => void redeem()}
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-foreground/45">
|
||||
<span>兑换成功后立即到账</span>
|
||||
@@ -186,6 +402,73 @@ export default function WalletPage() {
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
<section className="app-workspace-surface mt-6 rounded-lg p-5 sm:p-6">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-foreground/6 text-foreground/70">
|
||||
<CreditCard className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">在线充值</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-foreground/55">支付成功后自动充值积分。平台不提供退款,请确认商品和金额后付款。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedProduct ? (
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-foreground/45">应付金额</div>
|
||||
<div className="mt-1 text-2xl font-semibold tabular-nums">¥ {(selectedProduct.amountFen / 100).toFixed(2)}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{paymentsLoading ? (
|
||||
<Skeleton className="mt-6" active paragraph={{ rows: 2 }} />
|
||||
) : paymentProducts.length && paymentProviders.length ? (
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-medium text-foreground/65">选择充值商品</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{paymentProducts.map((product) => (
|
||||
<button
|
||||
key={product.id}
|
||||
type="button"
|
||||
className={`rounded-lg border px-4 py-3 text-left transition-colors ${selectedProductId === product.id ? "border-primary bg-primary/5" : "border-border/70 bg-background/35 hover:border-foreground/25"}`}
|
||||
onClick={() => setSelectedProductId(product.id)}
|
||||
>
|
||||
<div className="font-medium">{product.name}</div>
|
||||
<div className="mt-1 text-xs text-foreground/48">
|
||||
{formatCredits(product.creditsMicrocredits, 6)} 积分 · ¥ {(product.amountFen / 100).toFixed(2)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-medium text-foreground/65">选择支付方式</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{paymentProviders.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-3 text-left transition-colors ${selectedProviderId === provider.id ? "border-primary bg-primary/5" : "border-border/70 bg-background/35 hover:border-foreground/25"}`}
|
||||
onClick={() => setSelectedProviderId(provider.id)}
|
||||
>
|
||||
<PaymentBrandIcon providerId={provider.id} />
|
||||
<span className="min-w-0 truncate text-sm font-medium">{provider.id === "wechat-native" ? "微信支付" : "支付宝"}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button className="mt-3" type="primary" size="large" block loading={paymentCreating} disabled={!selectedProduct || !selectedProvider} onClick={() => void startPayment()}>
|
||||
{selectedProvider?.checkoutMode === "qr_code" ? "生成支付二维码" : "前往支付宝支付"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<WorkspaceState compact icon="wallet" title="在线充值暂未开放" description="管理员配置充值商品和支付渠道后即可使用。" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="wallet-ledger-panel app-workspace-surface mt-9 rounded-lg p-4 backdrop-blur-xl sm:p-5">
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
@@ -208,7 +491,13 @@ export default function WalletPage() {
|
||||
<Table className="app-data-table wallet-ledger-table" rowKey="id" size="middle" loading={loading} columns={columns} dataSource={entries} pagination={false} tableLayout="fixed" scroll={{ x: 990 }} />
|
||||
</TableSurface>
|
||||
) : (
|
||||
<div className="grid gap-1 overflow-hidden rounded-md bg-transparent">{entries.length ? entries.map((entry) => <LedgerMobileRow key={entry.id} config={config} entry={entry} />) : <WorkspaceState compact icon="wallet" title="没有匹配的积分记录" description="切换流水类型,或完成一次生成后再回来查看。" />}</div>
|
||||
<div className="grid gap-1 overflow-hidden rounded-md bg-transparent">
|
||||
{entries.length ? (
|
||||
entries.map((entry) => <LedgerMobileRow key={entry.id} config={config} entry={entry} />)
|
||||
) : (
|
||||
<WorkspaceState compact icon="wallet" title="没有匹配的积分记录" description="切换流水类型,或完成一次生成后再回来查看。" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<PaginationBar
|
||||
current={page}
|
||||
@@ -222,6 +511,58 @@ export default function WalletPage() {
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={paymentModalOpen}
|
||||
title={paymentOrder?.status === "credited" ? "充值完成" : paymentOrder?.checkout.mode === "qr_code" ? "微信扫码支付" : "确认支付结果"}
|
||||
onCancel={() => setPaymentModalOpen(false)}
|
||||
footer={
|
||||
paymentOrder?.status === "pending"
|
||||
? [
|
||||
<Button key="close" danger disabled={paymentQuerying} onClick={() => void cancelPayment()}>
|
||||
关闭订单
|
||||
</Button>,
|
||||
<Button key="query" type="primary" loading={paymentQuerying} onClick={() => void confirmPayment()}>
|
||||
我已完成支付
|
||||
</Button>,
|
||||
]
|
||||
: paymentOrder?.status === "create_failed"
|
||||
? [
|
||||
<Button key="done" onClick={() => setPaymentModalOpen(false)}>
|
||||
稍后处理
|
||||
</Button>,
|
||||
<Button key="retry" type="primary" loading={paymentQuerying} onClick={() => void retryPaymentCheckout()}>
|
||||
{paymentOrder.checkout.mode === "redirect" ? "重新打开支付宝" : "重新生成二维码"}
|
||||
</Button>,
|
||||
]
|
||||
: [
|
||||
<Button key="done" type="primary" onClick={() => setPaymentModalOpen(false)}>
|
||||
完成
|
||||
</Button>,
|
||||
]
|
||||
}
|
||||
destroyOnHidden
|
||||
>
|
||||
{paymentOrder ? (
|
||||
<div className="py-2 text-center">
|
||||
<div className="mx-auto grid size-12 place-items-center rounded-xl bg-foreground/5">
|
||||
<PaymentBrandIcon providerId={paymentOrder.providerId} size="large" />
|
||||
</div>
|
||||
<div className="mt-3 text-lg font-semibold">¥ {(paymentOrder.amountFen / 100).toFixed(2)}</div>
|
||||
<div className="mt-1 text-xs text-foreground/48">
|
||||
{paymentOrder.productName} · {formatCredits(paymentOrder.creditsMicrocredits, 6)} 积分
|
||||
</div>
|
||||
{paymentOrder.status === "pending" && paymentOrder.checkout.mode === "qr_code" && paymentOrder.checkout.value ? (
|
||||
<div className="mt-5 flex flex-col items-center">
|
||||
<QRCode value={paymentOrder.checkout.value} size={208} bordered={false} />
|
||||
<p className="mt-3 text-sm font-medium">请打开微信扫一扫完成支付</p>
|
||||
<p className="mt-1 text-xs text-foreground/45">请勿保存二维码后长按识别</p>
|
||||
</div>
|
||||
) : null}
|
||||
<PaymentOrderStatus order={paymentOrder} now={clock} />
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -230,7 +571,11 @@ function BalanceMetric({ label, description, value, icon }: { label: string; des
|
||||
return (
|
||||
<div className="wallet-balance-metric">
|
||||
<span className="wallet-balance-metric-icon">{icon}</span>
|
||||
<div><span>{label}</span><strong>{formatCredits(value, 6)}</strong><small>{description}</small></div>
|
||||
<div>
|
||||
<span>{label}</span>
|
||||
<strong>{formatCredits(value, 6)}</strong>
|
||||
<small>{description}</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -276,6 +621,7 @@ function LedgerTypeTag({ type }: { type: CreditLedgerEntry["type"] }) {
|
||||
function ledgerTypeMeta(type: CreditLedgerEntry["type"]) {
|
||||
const values = {
|
||||
redeem: { label: "兑换充值", tagColor: "default", icon: <ArrowDownLeft className="size-4" />, iconClass: "bg-foreground/8 text-foreground/70" },
|
||||
payment_topup: { label: "在线充值", tagColor: "success", icon: <CreditCard className="size-4" />, iconClass: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-300" },
|
||||
admin_grant: { label: "管理员充值", tagColor: "default", icon: <ArrowDownLeft className="size-4" />, iconClass: "bg-foreground/8 text-foreground/70" },
|
||||
consume: { label: "模型消费", tagColor: "error", icon: <Sparkles className="size-4" />, iconClass: "bg-rose-500/10 text-rose-600 dark:text-rose-300" },
|
||||
reserve: { label: "积分冻结", tagColor: "warning", icon: <ArrowUpRight className="size-4" />, iconClass: "bg-amber-500/10 text-amber-600 dark:text-amber-300" },
|
||||
@@ -289,6 +635,7 @@ function ledgerTypeMeta(type: CreditLedgerEntry["type"]) {
|
||||
|
||||
function ledgerTitle(entry: CreditLedgerEntry) {
|
||||
if (entry.type === "redeem") return "兑换码充值";
|
||||
if (entry.type === "payment_topup") return "在线支付充值";
|
||||
if (entry.type === "refund") return "模型消费退款";
|
||||
if (entry.type === "consume") return "模型调用";
|
||||
if (entry.type === "signup_bonus") return "新用户注册奖励";
|
||||
@@ -296,6 +643,30 @@ function ledgerTitle(entry: CreditLedgerEntry) {
|
||||
return entry.note || "积分调整";
|
||||
}
|
||||
|
||||
function PaymentBrandIcon({ providerId, size = "normal" }: { providerId: string; size?: "normal" | "large" }) {
|
||||
const className = size === "large" ? "text-3xl" : "text-xl";
|
||||
if (providerId === "wechat-native") return <WechatFilled className={`${className} text-[#07C160]`} aria-label="微信支付" />;
|
||||
return <AlipayCircleFilled className={`${className} text-[#1677FF]`} aria-label="支付宝" />;
|
||||
}
|
||||
|
||||
function PaymentOrderStatus({ order, now }: { order: PaymentOrder; now: number }) {
|
||||
const remainingSeconds = Math.max(0, Math.floor((new Date(order.expiresAt).getTime() - now) / 1000));
|
||||
if (order.status === "credited") return <div className="mt-5 rounded-lg bg-emerald-500/8 px-4 py-3 text-sm font-medium text-emerald-600 dark:text-emerald-300">支付已确认,积分已经到账</div>;
|
||||
if (order.status === "closed") return <div className="mt-5 rounded-lg bg-foreground/5 px-4 py-3 text-sm text-foreground/60">订单已关闭,未产生积分充值</div>;
|
||||
if (order.status === "create_failed") {
|
||||
const action = order.checkout.mode === "redirect" ? "重新打开支付宝收银台" : "重新生成支付二维码";
|
||||
return <div className="mt-5 rounded-lg bg-rose-500/8 px-4 py-3 text-sm text-rose-600 dark:text-rose-300">创建渠道订单失败,可使用下方按钮{action}</div>;
|
||||
}
|
||||
return <div className="mt-5 text-xs text-foreground/48">订单剩余支付时间 {formatCountdown(remainingSeconds)},页面将自动确认支付结果</div>;
|
||||
}
|
||||
|
||||
function formatCountdown(seconds: number) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const rest = seconds % 60;
|
||||
return [hours, minutes, rest].map((value) => String(value).padStart(2, "0")).join(":");
|
||||
}
|
||||
|
||||
function ledgerModelName(config: AiConfig, entry: CreditLedgerEntry) {
|
||||
return entry.model ? modelDisplayName(config, entry.model) : ledgerTitle(entry);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ const FeatureAvailabilityPage = lazy(() => import("@/pages/admin/admin-route-pag
|
||||
const ChannelsPage = lazy(() => import("@/pages/admin/channels/channels-page"));
|
||||
const LogicalModelsPage = lazy(() => import("@/pages/admin/logical-models/logical-models-page"));
|
||||
const AdminPluginsPage = lazy(() => import("@/pages/admin/plugins/plugins-page"));
|
||||
const AdminPaymentsPage = lazy(() => import("@/pages/admin/payments/payments-page"));
|
||||
const LogsPage = lazy(() => import("@/pages/admin/logs/logs-page"));
|
||||
const RedemptionCodesPage = lazy(() => import("@/pages/admin/redemption-codes/redemption-codes-page"));
|
||||
const RuntimePolicySettingsPage = lazy(() => import("@/pages/admin/settings/runtime-policy-settings-page"));
|
||||
@@ -184,6 +185,7 @@ export const router = createBrowserRouter([
|
||||
{ path: "channels", element: deferred(<ChannelsPage />) },
|
||||
{ path: "models", element: <RequireFeature feature="frontendModelsEnabled">{deferred(<LogicalModelsPage />)}</RequireFeature> },
|
||||
{ path: "plugins", element: deferred(<AdminPluginsPage />) },
|
||||
{ path: "payments", element: deferred(<AdminPaymentsPage />) },
|
||||
{ path: "prompt-templates", element: deferred(<StoryboardPromptsPage />) },
|
||||
{ path: "storyboard-prompts", element: <Navigate to="/admin/prompt-templates" replace /> },
|
||||
{ path: "announcements", element: deferred(<AnnouncementsPage />) },
|
||||
|
||||
187
web/src/services/api/payments.ts
Normal file
187
web/src/services/api/payments.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { apiClient, request } from "@/services/api/request";
|
||||
|
||||
export type PaymentProvider = {
|
||||
id: "wechat-native" | "alipay-page-pay" | string;
|
||||
pluginId: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
checkoutMode: "qr_code" | "redirect";
|
||||
enabled: boolean;
|
||||
pluginEnabled: boolean;
|
||||
configured: boolean;
|
||||
closeAfterMinutes: number;
|
||||
};
|
||||
|
||||
export type TopupProduct = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
amountFen: number;
|
||||
creditsMicrocredits: number;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
createdBy: string;
|
||||
updatedBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PaymentOrderStatus = "created" | "pending" | "closing" | "closed" | "credited" | "create_failed";
|
||||
|
||||
export type PaymentOrder = {
|
||||
id: string;
|
||||
userId?: string;
|
||||
merchantOrderNo: string;
|
||||
productId: string;
|
||||
productName: string;
|
||||
providerId: string;
|
||||
amountFen: number;
|
||||
currency: "CNY" | string;
|
||||
creditsMicrocredits: number;
|
||||
status: PaymentOrderStatus;
|
||||
providerStatus?: string;
|
||||
providerTradeNo?: string;
|
||||
checkout: {
|
||||
mode: "qr_code" | "redirect" | "";
|
||||
value?: string;
|
||||
url?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
expiresAt: string;
|
||||
providerPaidAt?: string;
|
||||
creditedAt?: string;
|
||||
closedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PaymentConfigField = {
|
||||
name: string;
|
||||
type: "string" | "password" | "textarea" | "url" | string;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
secret?: boolean;
|
||||
default?: unknown;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type AdminPaymentProvider = PaymentProvider & {
|
||||
configId?: string;
|
||||
configEnabled: boolean;
|
||||
version: number;
|
||||
values: Record<string, string>;
|
||||
secretConfigured: Record<string, boolean>;
|
||||
configFields: PaymentConfigField[];
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export function listPaymentProviders() {
|
||||
return request<{ providers: PaymentProvider[] }>(apiClient.get("/payments/providers"));
|
||||
}
|
||||
|
||||
export function listTopupProducts() {
|
||||
return request<{ products: TopupProduct[] }>(apiClient.get("/payments/products"));
|
||||
}
|
||||
|
||||
export function createPaymentOrder(input: { productId: string; providerId: string; idempotencyKey: string }) {
|
||||
return request<{ order: PaymentOrder }>(apiClient.post("/payments/orders", input));
|
||||
}
|
||||
|
||||
export function getPaymentOrder(id: string) {
|
||||
return request<{ order: PaymentOrder }>(apiClient.get(`/payments/orders/${encodeURIComponent(id)}`));
|
||||
}
|
||||
|
||||
export function queryPaymentOrder(id: string) {
|
||||
return request<{ order: PaymentOrder }>(apiClient.post(`/payments/orders/${encodeURIComponent(id)}/query`));
|
||||
}
|
||||
|
||||
export function closePaymentOrder(id: string) {
|
||||
return request<{ order: PaymentOrder }>(apiClient.post(`/payments/orders/${encodeURIComponent(id)}/close`));
|
||||
}
|
||||
|
||||
export function refreshPaymentCheckout(id: string) {
|
||||
return request<{ order: PaymentOrder }>(apiClient.post(`/payments/orders/${encodeURIComponent(id)}/checkout/refresh`));
|
||||
}
|
||||
|
||||
export function listAdminPaymentProviders() {
|
||||
return request<{ providers: AdminPaymentProvider[] }>(apiClient.get("/admin/payments/providers"));
|
||||
}
|
||||
|
||||
export function updateAdminPaymentProvider(id: string, input: { enabled: boolean; closeAfterMinutes: number; values: Record<string, string> }) {
|
||||
return request<{ provider: AdminPaymentProvider }>(apiClient.put(`/admin/payments/providers/${encodeURIComponent(id)}/config`, input));
|
||||
}
|
||||
|
||||
export function listAdminTopupProducts() {
|
||||
return request<{ products: TopupProduct[] }>(apiClient.get("/admin/payments/products"));
|
||||
}
|
||||
|
||||
export type TopupProductInput = Pick<TopupProduct, "name" | "amountFen" | "creditsMicrocredits" | "enabled" | "sortOrder"> & { description?: string };
|
||||
|
||||
export function createAdminTopupProduct(input: TopupProductInput) {
|
||||
return request<{ product: TopupProduct }>(apiClient.post("/admin/payments/products", input));
|
||||
}
|
||||
|
||||
export function updateAdminTopupProduct(id: string, input: TopupProductInput) {
|
||||
return request<{ product: TopupProduct }>(apiClient.put(`/admin/payments/products/${encodeURIComponent(id)}`, input));
|
||||
}
|
||||
|
||||
export function listAdminPaymentOrders(params: { status?: string; keyword?: string; page?: number; limit?: number } = {}) {
|
||||
return request<{ orders: PaymentOrder[]; total: number; page: number; limit: number }>(apiClient.get("/admin/payments/orders", { params }));
|
||||
}
|
||||
|
||||
export function queryAdminPaymentOrder(id: string) {
|
||||
return request<{ order: PaymentOrder }>(apiClient.post(`/admin/payments/orders/${encodeURIComponent(id)}/query`));
|
||||
}
|
||||
|
||||
export function closeAdminPaymentOrder(id: string) {
|
||||
return request<{ order: PaymentOrder }>(apiClient.post(`/admin/payments/orders/${encodeURIComponent(id)}/close`));
|
||||
}
|
||||
|
||||
export type PaymentReconciliationStatus = "running" | "completed" | "failed";
|
||||
|
||||
export type PaymentReconciliationResult = "matched" | "recovered" | "local_order_not_found" | "provider_record_missing" | "amount_mismatch" | "trade_no_mismatch" | "credit_failed";
|
||||
|
||||
export type PaymentReconciliationRun = {
|
||||
id: string;
|
||||
providerId: string;
|
||||
configId: string;
|
||||
billDate: string;
|
||||
status: PaymentReconciliationStatus;
|
||||
totalItems: number;
|
||||
matchItems: number;
|
||||
recoveredItems: number;
|
||||
errorItems: number;
|
||||
error?: string;
|
||||
startedBy?: string;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PaymentReconciliationItem = {
|
||||
id: string;
|
||||
runId: string;
|
||||
providerId: string;
|
||||
paymentOrderId?: string;
|
||||
merchantOrderNo: string;
|
||||
providerTradeNo?: string;
|
||||
amountFen: number;
|
||||
currency: string;
|
||||
result: PaymentReconciliationResult;
|
||||
resolved: boolean;
|
||||
detail?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export function runAdminPaymentReconciliation(input: { providerId: string; billDate: string }) {
|
||||
return request<{ run: PaymentReconciliationRun }>(apiClient.post("/admin/payments/reconciliations", input));
|
||||
}
|
||||
|
||||
export function listAdminPaymentReconciliations(params: { providerId?: string; status?: string; page?: number; limit?: number } = {}) {
|
||||
return request<{ runs: PaymentReconciliationRun[]; total: number; page: number; limit: number }>(apiClient.get("/admin/payments/reconciliations", { params }));
|
||||
}
|
||||
|
||||
export function listAdminPaymentReconciliationItems(id: string, params: { result?: string; page?: number; limit?: number } = {}) {
|
||||
return request<{ run: PaymentReconciliationRun; items: PaymentReconciliationItem[]; total: number; page: number; limit: number }>(apiClient.get(`/admin/payments/reconciliations/${encodeURIComponent(id)}/items`, { params }));
|
||||
}
|
||||
@@ -17,8 +17,8 @@ export type BackendPlugin = {
|
||||
export type WorkflowPluginStatus = "enabled" | "disabled" | "invalid" | string;
|
||||
|
||||
export type PluginManagement = {
|
||||
origin: "official" | "uploaded";
|
||||
kind: "protocol" | "application";
|
||||
origin: "official" | "system" | "uploaded";
|
||||
kind: "protocol" | "application" | "payment";
|
||||
activationScope: "system" | "user";
|
||||
configurationScope: "none" | "system" | "user";
|
||||
};
|
||||
|
||||
@@ -14,11 +14,12 @@ export type CreditAccount = {
|
||||
export type CreditLedgerEntry = {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: "redeem" | "admin_grant" | "consume" | "refund" | "admin_adjustment" | "signup_bonus" | "checkin_bonus";
|
||||
type: "redeem" | "payment_topup" | "admin_grant" | "consume" | "refund" | "admin_adjustment" | "signup_bonus" | "checkin_bonus";
|
||||
amountMicrocredits: number;
|
||||
availableAfterMicrocredits: number;
|
||||
reservedAfterMicrocredits: number;
|
||||
billingOrderId?: string;
|
||||
paymentOrderId?: string;
|
||||
model?: string;
|
||||
channelId?: string;
|
||||
scene?: string;
|
||||
@@ -65,7 +66,7 @@ export type ChannelModel = {
|
||||
priceVersion: number;
|
||||
capabilityVersion?: number;
|
||||
capabilityConfig?: import("@/lib/model-capabilities").ModelCapabilityConfig;
|
||||
priceTiers: ChannelModelPriceTier[];
|
||||
priceTiers: ChannelModelPriceTier[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
@@ -73,8 +74,8 @@ export type ChannelModel = {
|
||||
export type ChannelModelPriceTier = {
|
||||
id: string;
|
||||
channelModelId: string;
|
||||
selector: Record<string, string>;
|
||||
selectorKey: string;
|
||||
selector: Record<string, string>;
|
||||
selectorKey: string;
|
||||
resolution: string;
|
||||
videoSeconds: number;
|
||||
providerModelKey: string;
|
||||
@@ -100,7 +101,7 @@ export type ChannelModelMutation = {
|
||||
protocol?: ChannelModel["protocol"];
|
||||
enabled?: boolean;
|
||||
capabilityConfig?: ChannelModel["capabilityConfig"];
|
||||
priceTiers?: Array<Omit<ChannelModelPriceTier, "id" | "channelModelId" | "selectorKey" | "priceVersion" | "createdAt" | "updatedAt">>;
|
||||
priceTiers?: Array<Omit<ChannelModelPriceTier, "id" | "channelModelId" | "selectorKey" | "priceVersion" | "createdAt" | "updatedAt">>;
|
||||
billingMode?: ChannelModel["billingMode"];
|
||||
unitPriceMicrocredits?: number;
|
||||
inputTokenPriceMicrocredits?: number;
|
||||
@@ -285,7 +286,7 @@ export function updateAdminChannelModel(channelId: string, id: string, input: Ch
|
||||
}
|
||||
|
||||
export function deleteAdminChannelModel(channelId: string, id: string) {
|
||||
return request<{ ok: boolean }>(api.delete(`/admin/channels/${encodeURIComponent(channelId)}/models/${encodeURIComponent(id)}`));
|
||||
return request<{ ok: boolean }>(api.delete(`/admin/channels/${encodeURIComponent(channelId)}/models/${encodeURIComponent(id)}`));
|
||||
}
|
||||
|
||||
export type AdminFinanceListParams = { keyword?: string; status?: string; validity?: string; page?: number; limit?: number };
|
||||
|
||||
Reference in New Issue
Block a user