Merge GitHub upstream into Gitea primary

This commit is contained in:
Jesse_Chen
2026-08-05 11:13:34 +08:00
105 changed files with 3612 additions and 886 deletions
+48 -25
View File
@@ -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>
);
}
-6
View File
@@ -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
+2 -2
View File
@@ -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));
+3 -3
View File
@@ -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(