Merge GitHub upstream into Gitea primary
This commit is contained in:
@@ -1,16 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
AuditOutlined,
|
||||
CreditCardOutlined,
|
||||
GiftOutlined,
|
||||
ShoppingOutlined,
|
||||
MessageOutlined,
|
||||
TeamOutlined,
|
||||
TransactionOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Authenticated, Refine } from "@refinedev/core";
|
||||
import { ErrorComponent, ThemedLayout, useNotificationProvider } from "@refinedev/antd";
|
||||
import { ErrorComponent, ThemedLayout, ThemedSider, useNotificationProvider } from "@refinedev/antd";
|
||||
import routerProvider from "@refinedev/nextjs-router";
|
||||
import { App as AntdApp, ConfigProvider, Spin, theme } from "antd";
|
||||
import { App as AntdApp, ConfigProvider, Menu, Spin, theme } from "antd";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
@@ -19,39 +23,58 @@ import {
|
||||
adminDataProvider,
|
||||
} from "@/lib/admin/providers";
|
||||
|
||||
function AdminSider() {
|
||||
return (
|
||||
<ThemedSider
|
||||
render={({ items, collapsed }) => (
|
||||
<>
|
||||
{items}
|
||||
<Menu.Item key="return-to-chat" icon={<ArrowLeftOutlined />} title="返回对话">
|
||||
<Link href="/" aria-label="返回对话">{collapsed ? null : "返回对话"}</Link>
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminApp({ children }: { children: ReactNode }) {
|
||||
const notificationProvider = useNotificationProvider();
|
||||
return (
|
||||
<ConfigProvider theme={{ algorithm: theme.darkAlgorithm, token: { colorPrimary: "#c8a96b" } }}>
|
||||
<AntdApp>
|
||||
<Refine
|
||||
routerProvider={routerProvider}
|
||||
dataProvider={adminDataProvider}
|
||||
authProvider={adminAuthProvider}
|
||||
accessControlProvider={adminAccessControlProvider}
|
||||
notificationProvider={notificationProvider}
|
||||
resources={[
|
||||
<div className="admin-app-shell">
|
||||
<ConfigProvider theme={{ algorithm: theme.darkAlgorithm, token: { colorPrimary: "#c8a96b" } }}>
|
||||
<AntdApp>
|
||||
<Refine
|
||||
routerProvider={routerProvider}
|
||||
dataProvider={adminDataProvider}
|
||||
authProvider={adminAuthProvider}
|
||||
accessControlProvider={adminAccessControlProvider}
|
||||
notificationProvider={notificationProvider}
|
||||
resources={[
|
||||
{ name: "codes", list: "/admin/codes", meta: { label: "兑换码", icon: <GiftOutlined /> } },
|
||||
{ name: "payments", list: "/admin/payments", meta: { label: "支付管理", icon: <CreditCardOutlined /> } },
|
||||
{ name: "packages", list: "/admin/packages", meta: { label: "套餐管理", icon: <ShoppingOutlined /> } },
|
||||
{ name: "users", list: "/admin/codes?resource=users", meta: { label: "用户资料", icon: <TeamOutlined /> } },
|
||||
{ name: "credit-transactions", list: "/admin/codes?resource=credit-transactions", meta: { label: "积分流水", icon: <TransactionOutlined /> } },
|
||||
{ name: "consultations", list: "/admin/codes?resource=consultations", meta: { label: "咨询请求", icon: <MessageOutlined /> } },
|
||||
{ name: "audit-logs", list: "/admin/codes?resource=audit-logs", meta: { label: "审计日志", icon: <AuditOutlined /> } },
|
||||
]}
|
||||
options={{
|
||||
syncWithLocation: true,
|
||||
warnWhenUnsavedChanges: true,
|
||||
title: { text: "Jyotisha 后台" },
|
||||
}}
|
||||
>
|
||||
<Authenticated
|
||||
key="admin-authenticated"
|
||||
loading={<div className="admin-loading"><Spin size="large" /><span>正在验证后台权限</span></div>}
|
||||
options={{
|
||||
syncWithLocation: true,
|
||||
warnWhenUnsavedChanges: true,
|
||||
title: { text: "Jyotisha 后台" },
|
||||
}}
|
||||
>
|
||||
<ThemedLayout>{children}</ThemedLayout>
|
||||
</Authenticated>
|
||||
</Refine>
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
<Authenticated
|
||||
key="admin-authenticated"
|
||||
loading={<div className="admin-loading"><Spin size="large" /><span>正在验证后台权限</span></div>}
|
||||
>
|
||||
<ThemedLayout Sider={AdminSider}>{children}</ThemedLayout>
|
||||
</Authenticated>
|
||||
</Refine>
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ const statusColors: Record<CodeRecord["status"], string> = {
|
||||
};
|
||||
|
||||
export default function CodesPage() {
|
||||
const { data: role } = usePermissions<"admin" | "viewer">({});
|
||||
const { data: role } = usePermissions<"admin">({});
|
||||
const { data: identity } = useGetIdentity<AdminIdentity>();
|
||||
const { mutate: createCodes, mutation: createMutation } = useCreate<{ id: string; generated: CodeRecord[] }>();
|
||||
const { mutate: updateCode, mutation: updateMutation } = useUpdate<CodeRecord>();
|
||||
@@ -137,7 +137,7 @@ export default function CodesPage() {
|
||||
{ label: "已兑换", value: "redeemed" },
|
||||
{ label: "已撤销", value: "revoked" },
|
||||
]}
|
||||
extra={writable ? <Button type="primary" onClick={() => setCreateOpen(true)}>批量生成</Button> : <Tag>viewer 只读</Tag>}
|
||||
extra={writable ? <Button type="primary" onClick={() => setCreateOpen(true)}>批量生成</Button> : null}
|
||||
/>
|
||||
|
||||
<Modal title="批量生成兑换码" open={createOpen} onCancel={() => setCreateOpen(false)} footer={null} destroyOnHidden>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { List } from "@refinedev/antd";
|
||||
import { Alert, App, Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Row, Col, Space, Switch, Table, Tag, Typography, type TableColumnsType } from "antd";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type PaymentPackage = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
priceCents: number;
|
||||
credits: number;
|
||||
sortOrder: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type PackageFormValues = Omit<PaymentPackage, "id" | "priceCents"> & { priceYuan: number };
|
||||
|
||||
function formatMoney(cents: number) {
|
||||
return `¥${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
async function responsePayload(response: Response) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || "请求失败");
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function PackageManagement() {
|
||||
const { message } = App.useApp();
|
||||
const [packageForm] = Form.useForm<PackageFormValues>();
|
||||
const [packages, setPackages] = useState<PaymentPackage[]>([]);
|
||||
const [packagesLoading, setPackagesLoading] = useState(true);
|
||||
const [packagesError, setPackagesError] = useState("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingPackage, setEditingPackage] = useState<PaymentPackage | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [disablingId, setDisablingId] = useState<string | null>(null);
|
||||
|
||||
const loadPackages = useCallback(async () => {
|
||||
setPackagesLoading(true);
|
||||
setPackagesError("");
|
||||
try {
|
||||
const payload = await responsePayload(await fetch("/api/admin/packages", { cache: "no-store" }));
|
||||
setPackages(payload.packages);
|
||||
} catch (error) {
|
||||
setPackagesError(error instanceof Error ? error.message : "读取套餐失败");
|
||||
} finally {
|
||||
setPackagesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => void loadPackages(), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [loadPackages]);
|
||||
|
||||
function openCreateModal() {
|
||||
setEditingPackage(null);
|
||||
packageForm.setFieldsValue({ name: "", description: "", priceYuan: 1, credits: 10, sortOrder: 0, enabled: true });
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEditModal(item: PaymentPackage) {
|
||||
setEditingPackage(item);
|
||||
packageForm.setFieldsValue({
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
priceYuan: item.priceCents / 100,
|
||||
credits: item.credits,
|
||||
sortOrder: item.sortOrder,
|
||||
enabled: item.enabled,
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
if (saving) return;
|
||||
setModalOpen(false);
|
||||
setEditingPackage(null);
|
||||
packageForm.resetFields();
|
||||
}
|
||||
|
||||
async function savePackage(values: PackageFormValues) {
|
||||
setSaving(true);
|
||||
try {
|
||||
await responsePayload(await fetch("/api/admin/packages", {
|
||||
method: editingPackage ? "PATCH" : "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...(editingPackage ? { id: editingPackage.id } : {}),
|
||||
name: values.name.trim(),
|
||||
description: values.description?.trim() ?? "",
|
||||
priceCents: Math.round(values.priceYuan * 100),
|
||||
credits: values.credits,
|
||||
sortOrder: values.sortOrder,
|
||||
enabled: values.enabled,
|
||||
}),
|
||||
}));
|
||||
message.success(editingPackage ? "套餐已更新" : "套餐已添加");
|
||||
setModalOpen(false);
|
||||
setEditingPackage(null);
|
||||
packageForm.resetFields();
|
||||
await loadPackages();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "保存套餐失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function disablePackage(id: string) {
|
||||
setDisablingId(id);
|
||||
try {
|
||||
await responsePayload(await fetch("/api/admin/packages", {
|
||||
method: "DELETE",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
}));
|
||||
message.success("套餐已停用");
|
||||
await loadPackages();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "停用套餐失败");
|
||||
} finally {
|
||||
setDisablingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<PaymentPackage> = [
|
||||
{ title: "名称", dataIndex: "name", render: (_, item) => <Space direction="vertical" size={0}><Text strong>{item.name}</Text><Text type="secondary">{item.description || "暂无描述"}</Text></Space> },
|
||||
{ title: "价格", dataIndex: "priceCents", align: "right", render: formatMoney },
|
||||
{ title: "点数", dataIndex: "credits", align: "right" },
|
||||
{ title: "排序", dataIndex: "sortOrder", align: "right" },
|
||||
{ title: "状态", dataIndex: "enabled", render: (enabled) => <Tag color={enabled ? "green" : "default"}>{enabled ? "启用" : "停用"}</Tag> },
|
||||
{ title: "操作", key: "actions", fixed: "right", render: (_, item) => <Space><Button type="link" onClick={() => openEditModal(item)}>编辑</Button>{item.enabled && <Popconfirm title="停用此套餐?" description="停用后用户将无法继续购买。" okText="停用" cancelText="取消" onConfirm={() => disablePackage(item.id)}><Button type="link" danger loading={disablingId === item.id}>停用</Button></Popconfirm>}</Space> },
|
||||
];
|
||||
|
||||
return (
|
||||
<List title="套餐管理">
|
||||
<Card title="套餐设置" extra={<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>添加套餐</Button>}>
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
{packagesError && <Alert type="error" showIcon message="套餐列表读取失败" description={packagesError} action={<Button size="small" onClick={() => void loadPackages()}>重试</Button>} />}
|
||||
<Table<PaymentPackage> rowKey="id" columns={columns} dataSource={packages} loading={packagesLoading} pagination={false} scroll={{ x: "max-content" }} />
|
||||
</Space>
|
||||
</Card>
|
||||
<Modal title={editingPackage ? "编辑套餐" : "添加套餐"} open={modalOpen} okText={editingPackage ? "保存修改" : "添加套餐"} cancelText="取消" confirmLoading={saving} onOk={() => packageForm.submit()} onCancel={closeModal} destroyOnHidden afterClose={() => packageForm.resetFields()} maskClosable={!saving} keyboard={!saving}>
|
||||
<Form<PackageFormValues> form={packageForm} layout="vertical" onFinish={savePackage} requiredMark="optional" initialValues={{ priceYuan: 1, credits: 10, sortOrder: 0, enabled: true }}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: "请输入套餐名称" }, { max: 80 }]}><Input autoFocus /></Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}><Form.Item name="priceYuan" label="价格(元)" extra="保存时转换为 API 的 priceCents" rules={[{ required: true, message: "请输入价格" }]}><InputNumber min={0.01} max={1_000_000} precision={2} step={1} style={{ width: "100%" }} prefix="¥" /></Form.Item></Col>
|
||||
<Col span={12}><Form.Item name="credits" label="点数" rules={[{ required: true, message: "请输入点数" }]}><InputNumber min={1} max={10_000_000} precision={0} style={{ width: "100%" }} /></Form.Item></Col>
|
||||
</Row>
|
||||
<Form.Item name="sortOrder" label="排序" rules={[{ required: true, message: "请输入排序" }]}><InputNumber min={-100_000} max={100_000} precision={0} style={{ width: "100%" }} /></Form.Item>
|
||||
<Form.Item name="description" label="描述" rules={[{ max: 500 }]}><Input.TextArea rows={3} showCount maxLength={500} /></Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch checkedChildren="启用" unCheckedChildren="停用" /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</List>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
|
||||
import { List } from "@refinedev/antd";
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Collapse,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
type TableColumnsType,
|
||||
} from "antd";
|
||||
import type { Dayjs } from "dayjs";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type Order = {
|
||||
orderNo: string;
|
||||
userEmail: string | null;
|
||||
packageName: string | null;
|
||||
moneyCents: number;
|
||||
credits: number;
|
||||
status: string;
|
||||
epayTradeNo: string | null;
|
||||
createdAt: string;
|
||||
paidAt: string | null;
|
||||
};
|
||||
|
||||
type PaymentStats = {
|
||||
totalOrders: number;
|
||||
paidOrders: number;
|
||||
pendingOrders: number;
|
||||
failedExpiredOrders: number;
|
||||
paidAmountCents: number;
|
||||
grantedCredits: number;
|
||||
};
|
||||
|
||||
type PaymentFilters = { status?: string; dates?: [Dayjs, Dayjs] };
|
||||
type EpaySettings = {
|
||||
gatewayUrl: string;
|
||||
pid: string;
|
||||
notifyUrl: string;
|
||||
returnUrl: string;
|
||||
siteName: string;
|
||||
chatEnabled: boolean;
|
||||
keyConfigured: boolean;
|
||||
complete: boolean;
|
||||
source: "database" | "environment" | "unconfigured";
|
||||
};
|
||||
type EpaySettingsForm = Pick<EpaySettings, "gatewayUrl" | "pid" | "notifyUrl" | "returnUrl" | "siteName" | "chatEnabled"> & { newKey?: string };
|
||||
|
||||
const initialStats: PaymentStats = {
|
||||
totalOrders: 0,
|
||||
paidOrders: 0,
|
||||
pendingOrders: 0,
|
||||
failedExpiredOrders: 0,
|
||||
paidAmountCents: 0,
|
||||
grantedCredits: 0,
|
||||
};
|
||||
const statusLabels: Record<string, string> = { pending: "待支付", paid: "已支付", failed: "失败", expired: "已过期" };
|
||||
const statusColors: Record<string, string> = { pending: "gold", paid: "green", failed: "red", expired: "default" };
|
||||
const dateFormatter = new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short", timeZone: "Asia/Shanghai" });
|
||||
const pageSize = 20;
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
return value ? dateFormatter.format(new Date(value)) : "—";
|
||||
}
|
||||
|
||||
function formatMoney(cents: number) {
|
||||
return `¥${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
async function responsePayload(response: Response) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || "请求失败");
|
||||
return payload;
|
||||
}
|
||||
|
||||
export default function PaymentManagement() {
|
||||
const { message } = App.useApp();
|
||||
const [filterForm] = Form.useForm<PaymentFilters>();
|
||||
const [epayForm] = Form.useForm<EpaySettingsForm>();
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [stats, setStats] = useState(initialStats);
|
||||
const [paymentLoading, setPaymentLoading] = useState(true);
|
||||
const [paymentError, setPaymentError] = useState("");
|
||||
const [filters, setFilters] = useState<PaymentFilters>({});
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [epaySettings, setEpaySettings] = useState<EpaySettings | null>(null);
|
||||
const [epayLoading, setEpayLoading] = useState(true);
|
||||
const [epaySaving, setEpaySaving] = useState(false);
|
||||
const [epayTesting, setEpayTesting] = useState(false);
|
||||
const [epayError, setEpayError] = useState("");
|
||||
|
||||
const loadPayments = useCallback(async () => {
|
||||
setPaymentLoading(true);
|
||||
setPaymentError("");
|
||||
const params = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.dates?.[0]) params.set("from", filters.dates[0].startOf("day").toISOString());
|
||||
if (filters.dates?.[1]) params.set("to", filters.dates[1].endOf("day").toISOString());
|
||||
try {
|
||||
const payload = await responsePayload(await fetch(`/api/admin/payments?${params}`, { cache: "no-store" }));
|
||||
setOrders(payload.orders);
|
||||
setStats(payload.stats);
|
||||
setTotal(payload.pagination.total);
|
||||
} catch (error) {
|
||||
setPaymentError(error instanceof Error ? error.message : "读取支付记录失败");
|
||||
} finally {
|
||||
setPaymentLoading(false);
|
||||
}
|
||||
}, [filters, offset]);
|
||||
|
||||
const loadEpaySettings = useCallback(async () => {
|
||||
setEpayLoading(true);
|
||||
setEpayError("");
|
||||
try {
|
||||
const payload: EpaySettings = await responsePayload(await fetch("/api/admin/epay-settings", { cache: "no-store" }));
|
||||
setEpaySettings(payload);
|
||||
epayForm.setFieldsValue({
|
||||
gatewayUrl: payload.gatewayUrl,
|
||||
pid: payload.pid,
|
||||
notifyUrl: payload.notifyUrl,
|
||||
returnUrl: payload.returnUrl,
|
||||
siteName: payload.siteName,
|
||||
chatEnabled: payload.chatEnabled,
|
||||
newKey: "",
|
||||
});
|
||||
} catch (error) {
|
||||
setEpayError(error instanceof Error ? error.message : "读取易支付配置失败");
|
||||
} finally {
|
||||
setEpayLoading(false);
|
||||
}
|
||||
}, [epayForm]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => void loadPayments(), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [loadPayments]);
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => void loadEpaySettings(), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [loadEpaySettings]);
|
||||
|
||||
async function testEpayAvailability() {
|
||||
setEpayTesting(true);
|
||||
try {
|
||||
const payload = await responsePayload(await fetch("/api/admin/epay-settings/test", { method: "POST" }));
|
||||
if (payload.available) message.success(`${payload.message}(${payload.status},${payload.latencyMs}ms)`);
|
||||
else message.error(payload.message || "当前已保存的易支付配置暂不可用");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "当前已保存的易支付配置暂不可用");
|
||||
} finally {
|
||||
setEpayTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEpaySettings(values: EpaySettingsForm) {
|
||||
setEpaySaving(true);
|
||||
try {
|
||||
await responsePayload(await fetch("/api/admin/epay-settings", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...values, newKey: values.newKey || undefined }),
|
||||
}));
|
||||
epayForm.setFieldValue("newKey", "");
|
||||
message.success("Z-Pay(易支付)配置已保存");
|
||||
await loadEpaySettings();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "保存易支付配置失败");
|
||||
} finally {
|
||||
setEpaySaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const orderColumns: TableColumnsType<Order> = [
|
||||
{ title: "订单号", dataIndex: "orderNo", render: (value) => <Text code copyable>{value}</Text> },
|
||||
{ title: "用户邮箱", dataIndex: "userEmail", render: (value) => value || "—" },
|
||||
{ title: "套餐", dataIndex: "packageName", render: (value) => value || "—" },
|
||||
{ title: "金额", dataIndex: "moneyCents", align: "right", render: formatMoney },
|
||||
{ title: "点数", dataIndex: "credits", align: "right" },
|
||||
{ title: "状态", dataIndex: "status", render: (value) => <Tag color={statusColors[value]}>{statusLabels[value] || value}</Tag> },
|
||||
{ title: "易支付交易号", dataIndex: "epayTradeNo", render: (value) => value || "—" },
|
||||
{ title: "创建时间", dataIndex: "createdAt", render: formatDate },
|
||||
{ title: "支付时间", dataIndex: "paidAt", render: formatDate },
|
||||
];
|
||||
return (
|
||||
<List title="支付管理">
|
||||
<Space direction="vertical" size="large" style={{ width: "100%" }}>
|
||||
<Card title="支付概览">
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} md={8} xl={4}><Statistic title="总订单" value={stats.totalOrders} /></Col>
|
||||
<Col xs={12} md={8} xl={4}><Statistic title="已支付" value={stats.paidOrders} /></Col>
|
||||
<Col xs={12} md={8} xl={4}><Statistic title="待支付" value={stats.pendingOrders} /></Col>
|
||||
<Col xs={12} md={8} xl={4}><Statistic title="失败 / 过期" value={stats.failedExpiredOrders} /></Col>
|
||||
<Col xs={12} md={8} xl={4}><Statistic title="已支付金额" value={stats.paidAmountCents / 100} precision={2} prefix="¥" /></Col>
|
||||
<Col xs={12} md={8} xl={4}><Statistic title="已赠送点数" value={stats.grantedCredits} /></Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Collapse
|
||||
defaultActiveKey={[]}
|
||||
items={[{
|
||||
key: "epay-settings",
|
||||
label: "Z-Pay(易支付)渠道配置",
|
||||
children: (
|
||||
<Card
|
||||
loading={epayLoading}
|
||||
extra={<Space><Button disabled={!epaySettings?.keyConfigured || !epaySettings.complete} loading={epayTesting} onClick={() => void testEpayAvailability()}>测试可用性(当前已保存配置)</Button><Button type="primary" loading={epaySaving} onClick={() => epayForm.submit()}>保存配置</Button></Space>}
|
||||
>
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
<Text type="secondary">配置兼容标准 Z-Pay / 易支付协议的支付网关、商户凭据、回调地址与对话页开关。</Text>
|
||||
{epaySettings && <Space wrap><Tag color={epaySettings.source === "database" ? "blue" : "default"}>来源:{{ database: "数据库", environment: "环境变量", unconfigured: "未配置" }[epaySettings.source]}</Tag><Tag color={epaySettings.complete ? "green" : "orange"}>{epaySettings.complete ? "配置完整" : "配置不完整"}</Tag><Tag color={epaySettings.keyConfigured ? "green" : "orange"}>{epaySettings.keyConfigured ? "密钥已配置" : "密钥未配置"}</Tag><Tag color={epaySettings.chatEnabled ? "green" : "default"}>{epaySettings.chatEnabled ? "对话支付开放" : "对话支付关闭"}</Tag></Space>}
|
||||
{epayError && <Alert type="error" showIcon message="易支付配置读取失败" description={epayError} action={<Button size="small" onClick={() => void loadEpaySettings()}>重试</Button>} />}
|
||||
<Alert type="info" showIcon message="商户密钥不会回显" description="密钥输入框始终为空;更新现有数据库配置时留空会保留原密钥。首次从环境变量迁移到数据库时必须重新输入密钥。" />
|
||||
<Form<EpaySettingsForm> form={epayForm} layout="vertical" onFinish={saveEpaySettings} requiredMark="optional">
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={12}><Form.Item name="gatewayUrl" label="网关地址" rules={[{ required: true, message: "请输入网关地址" }, { type: "url", message: "请输入有效 URL" }]}><Input placeholder="https://pay.example.com" /></Form.Item></Col>
|
||||
<Col xs={24} lg={12}><Form.Item name="pid" label="商户 ID" rules={[{ required: true, message: "请输入商户 ID" }, { max: 200 }]}><Input /></Form.Item></Col>
|
||||
</Row>
|
||||
<Form.Item name="newKey" label="商户密钥" extra="留空保持当前数据库密钥;系统绝不预填或回显密钥。"><Input.Password autoComplete="new-password" placeholder="留空保持原密钥" /></Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={12}><Form.Item name="notifyUrl" label="异步通知地址" rules={[{ required: true, message: "请输入异步通知地址" }, { type: "url", message: "请输入有效 URL" }]}><Input /></Form.Item></Col>
|
||||
<Col xs={24} lg={12}><Form.Item name="returnUrl" label="支付完成返回地址" rules={[{ required: true, message: "请输入支付完成返回地址" }, { type: "url", message: "请输入有效 URL" }]}><Input /></Form.Item></Col>
|
||||
</Row>
|
||||
<Form.Item name="siteName" label="网站名称" rules={[{ required: true, message: "请输入网站名称" }, { max: 100 }]}><Input /></Form.Item>
|
||||
<Form.Item name="chatEnabled" label="在对话页开放支付" valuePropName="checked"><Switch checkedChildren="开放" unCheckedChildren="关闭" /></Form.Item>
|
||||
</Form>
|
||||
</Space>
|
||||
</Card>
|
||||
),
|
||||
}]}
|
||||
/>
|
||||
|
||||
<Card title="支付记录" extra={<Text type="secondary">共 {total} 条平台订单</Text>}>
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
<Form form={filterForm} layout="inline" onValuesChange={(_, values) => { setOffset(0); setFilters(values); }}>
|
||||
<Form.Item name="dates" label="创建日期"><DatePicker.RangePicker allowClear /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear placeholder="全部状态" style={{ minWidth: 140 }} options={Object.entries(statusLabels).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Button onClick={() => { filterForm.resetFields(); setOffset(0); setFilters({}); }}>重置</Button>
|
||||
</Form>
|
||||
{paymentError && <Alert type="error" showIcon message="支付记录读取失败" description={paymentError} action={<Button size="small" onClick={() => void loadPayments()}>重试</Button>} />}
|
||||
<Table<Order>
|
||||
rowKey="orderNo"
|
||||
columns={orderColumns}
|
||||
dataSource={orders}
|
||||
loading={paymentLoading}
|
||||
scroll={{ x: "max-content" }}
|
||||
pagination={{ current: Math.floor(offset / pageSize) + 1, pageSize, total, showSizeChanger: false, showTotal: (count, range) => `第 ${range[0]}–${range[1]} 条,共 ${count} 条`, onChange: (page) => setOffset((page - 1) * pageSize) }}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
</List>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Menu } from "@base-ui/react/menu";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ChevronRight,
|
||||
Gift,
|
||||
KeyRound,
|
||||
LogOut,
|
||||
MessageSquareText,
|
||||
Plus,
|
||||
@@ -37,7 +35,6 @@ export type SidebarAccount = {
|
||||
name: string;
|
||||
email: string;
|
||||
credits: number;
|
||||
isAdmin: boolean;
|
||||
initial: string;
|
||||
};
|
||||
|
||||
@@ -208,9 +205,6 @@ export function AppSidebar({
|
||||
<Menu.Item className="account-menu-item" onClick={onOpenRedeem}>
|
||||
<Gift aria-hidden="true" /><span>兑换点数</span><small>{account.credits} 点</small>
|
||||
</Menu.Item>
|
||||
{account.isAdmin && <Menu.LinkItem className="account-menu-item" render={<Link href="/admin/codes" />} closeOnClick>
|
||||
<KeyRound aria-hidden="true" /><span>管理兑换码</span><ChevronRight aria-hidden="true" />
|
||||
</Menu.LinkItem>}
|
||||
<Menu.Separator className="account-menu-separator" />
|
||||
<Menu.Item className="account-menu-item account-menu-danger" onClick={onOpenLogout}>
|
||||
<LogOut aria-hidden="true" /><span>退出登录</span>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { zhCN as dateFnsZhCN } from "date-fns/locale";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { zhCN } from "react-day-picker/locale";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
@@ -17,8 +16,6 @@ type BirthDatePickerProps = {
|
||||
readonly onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const emptyDefaultMonth = new Date(1997, 0, 1);
|
||||
|
||||
export function BirthDatePicker({ value, disabled, onChange }: BirthDatePickerProps) {
|
||||
const labelId = useId();
|
||||
const valueId = useId();
|
||||
@@ -26,6 +23,7 @@ export function BirthDatePicker({ value, disabled, onChange }: BirthDatePickerPr
|
||||
const selected = parseBirthDate(value);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const defaultMonth = new Date(1997, today.getMonth(), 1);
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
@@ -45,18 +43,19 @@ export function BirthDatePicker({ value, disabled, onChange }: BirthDatePickerPr
|
||||
<span id={valueId}>
|
||||
{selected === undefined
|
||||
? "选择出生日期"
|
||||
: format(selected, "PPP", { locale: dateFnsZhCN })}
|
||||
: format(selected, "PPP", { locale: zhCN })}
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-auto p-0" aria-label="选择出生日期">
|
||||
<PopoverContent align="start" className="w-auto p-0">
|
||||
<Calendar
|
||||
key={value || "empty"}
|
||||
mode="single"
|
||||
className="[--cell-size:2.75rem] [&_button[data-selected-single=true]]:text-primary-foreground!"
|
||||
locale={zhCN}
|
||||
selected={selected}
|
||||
defaultMonth={selected ?? emptyDefaultMonth}
|
||||
defaultMonth={selected ?? defaultMonth}
|
||||
captionLayout="dropdown"
|
||||
navLayout="after"
|
||||
startMonth={new Date(1900, 0)}
|
||||
endMonth={today}
|
||||
reverseYears
|
||||
|
||||
@@ -132,7 +132,7 @@ export function EmailOtpLogin({
|
||||
});
|
||||
if (otpError) throw otpError;
|
||||
}
|
||||
window.location.assign(window.location.hostname.startsWith("admin.") && window.location.hostname.includes("staging") ? "/admin" : "/");
|
||||
window.location.assign("/");
|
||||
} catch (caught) {
|
||||
if (!(caught instanceof Error)) throw caught;
|
||||
setError(authMessage(caught));
|
||||
@@ -149,7 +149,7 @@ export function EmailOtpLogin({
|
||||
setNotice("");
|
||||
try {
|
||||
await selfHostedAuthActions.signInWithPassword(email, password);
|
||||
window.location.assign(window.location.hostname.startsWith("admin.") && window.location.hostname.includes("staging") ? "/admin" : "/");
|
||||
window.location.assign("/");
|
||||
} catch (caught) {
|
||||
if (!(caught instanceof Error)) throw caught;
|
||||
setError(authMessage(caught));
|
||||
|
||||
@@ -51,17 +51,17 @@ function Calendar({
|
||||
),
|
||||
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||
"pointer-events-none absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||
"pointer-events-auto size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||
"pointer-events-auto size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
|
||||
Reference in New Issue
Block a user