feat: add card rotation, customer lookup, and concurrent eligibility checks

Add encrypted card pool management, persisted card and outbound rotation, and per-card decline cooldowns. Improve customer and redemption-code lookup, recovery controls, and filtering; rebuild frontend assets and refresh mock screenshots.

Give read-only eligibility checks four independent slots so payment processing no longer blocks them. Cover concurrency, slot cleanup, card rotation, and lookup behavior with tests.
This commit is contained in:
mizorewww committed 2026-10-04 11:44:38 +08:00
1 parent 92f09423c3
commit 83ac3f00ae
47 files changed
+3033 -501

No files matched your search

+8 -1
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { Alert, Box, Button, Stack, Typography } from "@mui/material";
import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded";
import CheckRounded from "@mui/icons-material/CheckRounded";
@@ -6,6 +6,12 @@ import CheckRounded from "@mui/icons-material/CheckRounded";
export function CopyableCodes({ codes }: { codes: string[] }) {
const [copied, setCopied] = useState<number | "all" | null>(null);
const [error, setError] = useState(false);
// The ✓ marks a recent copy; clear it so a later visit does not read as fresh.
useEffect(() => {
if (copied === null) return;
const timer = setTimeout(() => setCopied(null), 5000);
return () => clearTimeout(timer);
}, [copied]);
async function copy(index: number | "all") {
try {
await navigator.clipboard.writeText(
@@ -43,6 +49,7 @@ export function CopyableCodes({ codes }: { codes: string[] }) {
<Box
component="ul"
aria-label="新生成的兑换码"
tabIndex={0}
sx={{
m: 0,
p: 0,
+44 -27
View File
@@ -13,6 +13,8 @@ import {
Typography,
} from "@mui/material";
import { adminApi } from "./adminApi";
import { codeStatus } from "./codeStatus";
import PersonSearchRounded from "@mui/icons-material/PersonSearchRounded";
type Detail = {
order: {
@@ -81,9 +83,9 @@ export function CustomerPanel({
async function copy() {
try {
await navigator.clipboard.writeText(detail?.code || "");
setNotice("完整卡密已复制。");
setNotice("完整兑换码已复制。");
} catch {
setNotice("复制失败,请选中下方完整卡密手动复制。");
setNotice("复制失败,请选中下方完整兑换码手动复制。");
}
}
function prepare(mode: "links" | "pay") {
@@ -94,10 +96,22 @@ export function CustomerPanel({
}
return (
<>
<Paper variant="outlined" sx={{ p: { xs: 2, sm: 3 }, mb: 3 }}>
<Typography variant="h2" sx={{ fontSize: 21 }}>
按客户查卡密 / 单独补单
</Typography>
<Paper
variant="outlined"
component="section"
aria-labelledby="customer-panel-title"
sx={{ p: { xs: 2, sm: 3 }, height: "100%" }}
>
<Stack direction="row" spacing={1} alignItems="center">
<PersonSearchRounded color="primary" aria-hidden="true" />
<Typography
id="customer-panel-title"
variant="h2"
sx={{ fontSize: 21 }}
>
按客户查询
</Typography>
</Stack>
<Box
component="form"
onSubmit={(e) => {
@@ -115,6 +129,7 @@ export function CustomerPanel({
size="small"
fullWidth
autoComplete="off"
slotProps={{ htmlInput: { maxLength: 16, spellCheck: false } }}
/>
<Button
type="submit"
@@ -127,7 +142,7 @@ export function CustomerPanel({
</Stack>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
直接查看客户对应的完整卡密和付款链接,无需按批次翻页。
直接查看客户对应的完整兑换码和付款链接,无需按批次翻页。
</Typography>
</Paper>
<Dialog
@@ -136,34 +151,35 @@ export function CustomerPanel({
fullWidth
maxWidth="sm"
aria-labelledby="customer-title"
aria-describedby="customer-dialog-description"
>
<DialogTitle id="customer-title">
{detail ? `@${detail.order.username} 的订单` : "客户订单详情"}
{detail
? detail.order.username
? `@${detail.order.username} 的订单`
: "兑换码详情"
: error
? "查询结果"
: "客户订单详情"}
</DialogTitle>
<DialogContent>
<DialogContent id="customer-dialog-description">
{busy && <Typography>正在读取订单…</Typography>}
{error && <Alert severity="error">{error}</Alert>}
{error && <Alert severity="error" role="alert">{error}</Alert>}
{detail && (
<Stack spacing={2}>
<Typography>
{detail.order.months} 个月 · {detail.order.batch || "未分类"} ·{" "}
{(
{
review: "待处理",
succeeded: "已完成",
processing: "处理中",
active: "未使用",
revoked: "已停用",
} as Record<string, string>
)[detail.order.status] || detail.order.status}
</Typography>
<Typography variant="body2" color="text.secondary">
{detail.order.message}
{codeStatus[detail.order.status]?.label || detail.order.status}
</Typography>
{detail.order.message && (
<Typography variant="body2" color="text.secondary">
{detail.order.message}
</Typography>
)}
{detail.code ? (
<>
<TextField
label="客户对应的完整卡密"
label="客户对应的完整兑换码"
multiline
value={detail.code}
slotProps={{ input: { readOnly: true } }}
@@ -175,15 +191,16 @@ export function CustomerPanel({
}}
/>
<Button variant="outlined" onClick={() => void copy()}>
复制完整卡密
复制完整兑换码
</Button>
</>
) : (
<Alert severity="info">
此历史卡密仅保存了校验值,无法还原。仍可按此客户订单单独补单。
此历史兑换码仅保存了校验值,无法还原完整内容。
{detail.can_recover && "仍可按此客户订单单独补单。"}
</Alert>
)}
{notice && <Alert severity="info">{notice}</Alert>}
{notice && <Alert severity="info" role="status">{notice}</Alert>}
{detail.checkout_url && (
<>
<TextField
@@ -210,7 +227,7 @@ export function CustomerPanel({
)}
{detail.can_recover && (
<Alert severity="info">
“仅生成链接”不会付款;“单独补单”会先展示这一笔订单,确认后才尝试付款。
「仅生成补单链接」不会付款;「单独补单」会先展示这一笔订单,确认后才尝试付款。
</Alert>
)}
</Stack>
+169
View File
@@ -0,0 +1,169 @@
import { useRef, useState } from "react";
import {
Alert,
Box,
Button,
CircularProgress,
InputAdornment,
Paper,
Stack,
TextField,
Typography,
} from "@mui/material";
import FactCheckRounded from "@mui/icons-material/FactCheckRounded";
import { request } from "./shared";
// Standalone eligibility probe next to the redemption card: a visitor can
// verify an X account before involving any redemption code. Advisory only.
export function EligibilityCard() {
const [username, setUsername] = useState("");
const [checking, setChecking] = useState(false);
const [invalid, setInvalid] = useState(false);
const [result, setResult] = useState<{
severity: "success" | "warning" | "info";
message: string;
} | null>(null);
const field = useRef<HTMLInputElement>(null);
const root = useRef<HTMLDivElement>(null);
const alertRef = useRef<HTMLDivElement>(null);
const cleanUser = username.trim().replace(/^@/, "").toLowerCase();
const userValid = /^[a-z0-9_]{1,15}$/.test(cleanUser);
async function run() {
setInvalid(!userValid);
if (!userValid || checking) {
if (!userValid) field.current?.focus();
return;
}
// 检测期间输入框与按钮禁用,焦点会跌落;完成后泊放到结果提示。
const refocus = root.current?.contains(document.activeElement) ?? false;
setChecking(true);
setResult(null);
try {
const { ok, data } = await request<{
eligible?: boolean;
message?: string;
}>("/api/check", { username: cleanUser }, AbortSignal.timeout(45000));
if (ok && typeof data.eligible === "boolean") {
setResult(
data.eligible
? { severity: "success", message: "该账号当前可以接收赠送。" }
: {
severity: "warning",
message: `该账号当前无法接收赠送:${data.message || "原因未知。"}`,
},
);
} else {
setResult({
severity: "info",
message: data.message || "暂时无法检测,请稍后再试。",
});
}
} catch {
setResult({ severity: "info", message: "暂时无法检测,请稍后再试。" });
} finally {
setChecking(false);
// 仅当焦点确已跌落 body 时泊放;用户等待期间移走焦点不抢夺。
if (refocus)
requestAnimationFrame(() => {
if (document.activeElement === document.body)
alertRef.current?.focus();
});
}
}
return (
<Paper
ref={root}
variant="outlined"
component="section"
aria-labelledby="eligibility-title"
sx={{
p: { xs: 2.5, sm: 4 },
borderRadius: "28px",
display: "flex",
flexDirection: "column",
}}
>
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 1 }}>
<FactCheckRounded color="primary" aria-hidden="true" />
<Typography id="eligibility-title" component="h2" variant="h3">
检测赠送资格
</Typography>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
兑换前先确认 X 账号当前能否接收 Premium 赠送。检测不消耗兑换码。
</Typography>
<Stack
component="form"
spacing={2.5}
noValidate
sx={{ flexGrow: 1 }}
onSubmit={(event) => {
event.preventDefault();
void run();
}}
>
<TextField
label="X 用户名"
value={username}
onChange={(event) => {
setUsername(event.target.value);
setResult(null);
if (invalid) setInvalid(false);
}}
disabled={checking}
autoComplete="off"
placeholder="your_username"
error={invalid}
helperText={
invalid
? "请输入 1–15 位英文字母、数字或下划线,不是显示名称。"
: "填写 @ 后的用户名。"
}
slotProps={{
htmlInput: {
maxLength: 16,
spellCheck: false,
autoCapitalize: "none",
ref: field,
},
input: {
startAdornment: (
<InputAdornment position="start">@</InputAdornment>
),
},
}}
/>
<Button
type="submit"
variant="outlined"
size="large"
disabled={checking}
startIcon={
checking ? (
<CircularProgress size={18} aria-hidden="true" />
) : undefined
}
>
{checking ? "正在检测…" : "开始检测"}
</Button>
{result && (
<Alert
ref={alertRef}
tabIndex={-1}
severity={result.severity}
role="status"
aria-live="polite"
>
{result.message}
</Alert>
)}
<Box sx={{ flexGrow: 1 }} aria-hidden="true" />
<Typography variant="caption" color="text.secondary" component="p">
结果仅供参考,提交兑换时系统会向 X 再次核实。
</Typography>
</Stack>
</Paper>
);
}
+30 -28
View File
@@ -152,7 +152,7 @@ function conditionLabel(token: Extract<Token, { kind: "condition" }>): string {
const op = token.negate ? "≠" : ":";
switch (token.field) {
case "folder":
return token.value === "-"
return token.value === "-" && !token.literal
? `批次${op}未分类`
: `批次${op}${token.value}`;
case "status":
@@ -160,7 +160,7 @@ function conditionLabel(token: Extract<Token, { kind: "condition" }>): string {
case "months":
return `时长${op}${token.value} 个月`;
case "username":
return token.value === "-"
return token.value === "-" && !token.literal
? `账号${op}未绑定`
: `账号${op}${token.value}`;
}
@@ -183,8 +183,15 @@ function tokenLabel(token: Token): string {
}
}
function quoteValue(name: string) {
return /[\s()":=!≠]/.test(name) ? `"${name}"` : name;
function quoteValue(name: string, literal = false) {
// 批次名/账号可含任意字符;保留字、引号与反斜杠特殊处理,保证序列化结果
// 可被 tokenizer 还原。哨兵值 "-"(未分类/未绑定)保持裸写;仅当名称本身
// 就叫 "-" 时才加引号(literal,与 parse 的哨兵分支互逆)。
if (/^(and|or|not|非|&&|\|\|)$/i.test(name) || (name === "-" && literal))
return `"${name}"`;
return /[\s()":=!≠\\:()!="“”]/.test(name)
? `"${name.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/["“”]/g, "\\$&")}"`
: name;
}
function serializeTokens(tokens: Token[]): string {
@@ -196,8 +203,8 @@ function serializeTokens(tokens: Token[]): string {
case "condition": {
const op = token.negate ? "!=" : ":";
text =
token.field === "folder"
? `folder${op}${quoteValue(token.value)}`
token.field === "folder" || token.field === "username"
? `${token.field}${op}${quoteValue(token.value, token.literal)}`
: `${token.field}${op}${token.value}`;
break;
}
@@ -297,7 +304,10 @@ export function FilterBar({
}: Props) {
const [tokens, setTokens] = useState<Token[]>([]);
const [mode, setMode] = useState<"palette" | "text">("palette");
const [announce, setAnnounce] = useState("");
// seq 递增保证相同文案重复播报时 live region 仍会重建触发朗读。
const [announce, setAnnounce] = useState({ seq: 0, text: "" });
const speak = (text: string) =>
setAnnounce((current) => ({ seq: current.seq + 1, text }));
const [desyncWarning, setDesyncWarning] = useState("");
const [username, setUsername] = useState("");
const canvasRef = useRef<HTMLDivElement>(null);
@@ -312,7 +322,7 @@ export function FilterBar({
// 文本无法解析:恢复为调色板内容并提示,保证画布与表达式永不脱节。
const note = "无法解析文本表达式,已恢复为调色板中的条件。";
setDesyncWarning(note);
setAnnounce(note);
speak(note);
onChange(serializeTokens(tokens));
}
// tokens 刻意不作为依赖:只在 value / mode 变化时同步。
@@ -328,7 +338,7 @@ export function FilterBar({
useEffect(() => {
if (applySeq > lastApplySeq.current) {
lastApplySeq.current = applySeq;
setAnnounce(
speak(
resultCount == null
? "筛选已应用"
: `筛选已应用,共 ${resultCount} 条结果`,
@@ -340,14 +350,14 @@ export function FilterBar({
lastNoticeSeq.current = clearNotice.seq;
note = clearNotice.text;
}
setAnnounce(note);
speak(note);
}
wasApplied.current = applied;
}, [applied, applySeq, resultCount, clearNotice]);
function commit(next: Token[], note: string) {
setTokens(next);
setAnnounce(note);
speak(note);
setDesyncWarning("");
onChange(serializeTokens(next));
// 激活的调色板按钮可能在提交后被禁用,导致焦点跌回 <body>;
@@ -402,17 +412,6 @@ export function FilterBar({
commit(next, `已切换为 ${tokenLabel(updated)}`);
}
// 条件 chip:Enter/Space(与点击一致)切换 = / ≠。
function conditionChipKeyDown(index: number) {
return (event: KeyboardEvent) => {
if (disabled) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggleNegate(index);
}
};
}
// 其他词元 chip 暴露 role="button",Enter/Space 与 Delete/Backspace 一样触发删除。
function chipKeyDown(index: number) {
return (event: KeyboardEvent) => {
@@ -493,7 +492,7 @@ export function FilterBar({
sx={{ mb: 0.5, flexWrap: "wrap", rowGap: 0.5 }}
>
<FilterAltOutlined color="primary" sx={{ flexShrink: 0 }} />
<Typography variant="h3" sx={{ whiteSpace: "nowrap", flexShrink: 0 }}>
<Typography variant="h2" sx={{ whiteSpace: "nowrap", flexShrink: 0, fontSize: 21 }}>
高级筛选
</Typography>
{applied && (
@@ -511,7 +510,6 @@ export function FilterBar({
variant="text"
size="small"
startIcon={mode === "palette" ? <CodeOutlined /> : <PaletteOutlined />}
aria-pressed={mode === "text"}
aria-label={
mode === "palette" ? "切换到文本模式" : "切换到调色板模式"
}
@@ -567,7 +565,7 @@ export function FilterBar({
event.preventDefault();
if (!applyDisabled) onApply();
// 表达式无效时 Enter 不再静默,播报当前不可应用的原因。
else if (invalidReason) setAnnounce(invalidReason);
else if (invalidReason) speak(invalidReason);
}
}}
sx={(theme) => ({
@@ -617,7 +615,6 @@ export function FilterBar({
aria-label={`${tokenLabel(token)},按 Enter 或点击切换 = / ≠,按 Delete 或 Backspace 删除`}
onClick={disabled ? undefined : () => toggleNegate(index)}
onDelete={disabled ? undefined : () => removeAt(index)}
onKeyDown={conditionChipKeyDown(index)}
sx={[
chipSizeSx,
conditionTint(tokenTone(token)),
@@ -727,6 +724,8 @@ export function FilterBar({
field: "folder",
value: folder.name,
negate: false,
// 批次名恰为哨兵值 "-" 时保持字法语义。
literal: folder.name === "-" ? true : undefined,
})
}
ariaLabel={`添加批次条件 ${folder.name}`}
@@ -805,7 +804,9 @@ export function FilterBar({
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
addUsername();
if (username.trim() && !usernameValid)
speak("账号名需为字母、数字或下划线。");
else addUsername();
}
}}
slotProps={{
@@ -984,11 +985,12 @@ export function FilterBar({
</Stack>
<Box
component="span"
key={announce.seq}
role="status"
aria-live="polite"
sx={visuallyHidden}
>
{announce}
{announce.text}
</Box>
</Paper>
);
+11 -9
View File
@@ -22,17 +22,15 @@ type Props = {
stats?: AdminStats;
filter: string;
disabled: boolean;
muted?: boolean;
onSelect: (id: string) => void;
onBusyChange: (value: boolean) => void;
onChanged: (deleted?: string) => Promise<void>;
onChanged: () => Promise<void>;
};
export function FolderPanel({
folders,
stats,
filter,
disabled,
muted = false,
onSelect,
onBusyChange,
onChanged,
@@ -84,12 +82,10 @@ export function FolderPanel({
useFlexGap
gap={1}
sx={{
height: 80,
minHeight: 80,
overflowX: "auto",
alignItems: "center",
pb: 1,
opacity: muted ? 0.55 : 1,
transition: "opacity 160ms ease",
}}
role="region"
aria-label="批次文件夹,可横向滚动"
@@ -164,28 +160,34 @@ export function FolderPanel({
fullWidth
maxWidth="xs"
aria-labelledby="batch-rename-title"
aria-describedby="batch-rename-content"
>
<Box
component="form"
noValidate
onSubmit={(e) => {
e.preventDefault();
void save();
}}
>
<DialogTitle id="batch-rename-title">重命名批次</DialogTitle>
<DialogContent>
<DialogContent id="batch-rename-content">
<TextField
autoFocus
label="批次名称"
value={name}
onChange={(e) => setName(e.target.value)}
onChange={(e) => {
setName(e.target.value);
if (error) setError("");
}}
disabled={pending}
required
error={!!error}
sx={{ mt: 1 }}
helperText="该批次内的兑换码会同步更新名称"
/>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
<Alert severity="error" role="alert" sx={{ mt: 2 }}>
{error}
</Alert>
)}
+281
View File
@@ -0,0 +1,281 @@
import { useEffect, useRef, useState } from "react";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Paper,
Stack,
TextField,
Typography,
} from "@mui/material";
import ManageSearchRounded from "@mui/icons-material/ManageSearchRounded";
import { adminApi, formatTime, type AdminCode } from "./adminApi";
import { codeStatus } from "./codeStatus";
// Direct lookup by full redemption code: status, batch and recipient without
// paging through folders. Actions delegate to the parent's existing flows.
export function LookupPanel({
disabled,
refreshSignal,
onCopy,
onViewCustomer,
onRevoke,
}: {
disabled: boolean;
refreshSignal: number;
onCopy: (code: AdminCode) => void;
onViewCustomer: (id: string) => void;
onRevoke: (code: AdminCode) => void;
}) {
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const [invalid, setInvalid] = useState(false);
const [error, setError] = useState("");
const [result, setResult] = useState<AdminCode | null>(null);
const queried = useRef("");
const sequence = useRef(0);
const field = useRef<HTMLInputElement>(null);
const clean = input.replace(/\s+/g, "").toUpperCase();
const valid = /^XG-[A-F0-9]{48}$/.test(clean);
async function run(code: string) {
const seq = ++sequence.current;
setBusy(true);
setError("");
try {
const data = await adminApi<AdminCode>("/api/admin/lookup", { code });
if (seq === sequence.current) {
setResult(data);
queried.current = code;
}
} catch (e) {
if (seq === sequence.current) {
setResult(null);
setError((e as Error).message);
}
} finally {
if (seq === sequence.current) setBusy(false);
}
}
function submit() {
setInvalid(!valid);
if (!valid || busy || disabled) {
if (!valid) field.current?.focus();
return;
}
void run(clean);
}
// Keep a visible result in sync after mutations (revoke, move, folder ops).
// Only while the shown result still matches the input: once the admin edits
// the field, the old result is gone and must not reappear.
useEffect(() => {
if (refreshSignal > 0 && result && queried.current === clean)
void run(queried.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshSignal]);
const rows = result
? [
["接收账号", result.username ? `@${result.username}` : "—"],
["批次", result.batch || "未分类"],
[
"生成时间",
formatTime(result.created),
],
...(result.status === "processing" || result.status === "review"
? [["订单进度", `${Math.min(100, Math.max(0, result.progress ?? 0))}%`]]
: []),
]
: [];
return (
<Paper
variant="outlined"
component="section"
aria-labelledby="lookup-title"
sx={{ p: { xs: 2, sm: 3 }, height: "100%" }}
>
<Stack direction="row" spacing={1} alignItems="center">
<ManageSearchRounded color="primary" aria-hidden="true" />
<Typography id="lookup-title" variant="h2" sx={{ fontSize: 21 }}>
按兑换码查询
</Typography>
</Stack>
<Box
component="form"
noValidate
onSubmit={(event) => {
event.preventDefault();
submit();
}}
sx={{ mt: 2 }}
>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
<TextField
label="完整兑换码"
placeholder="XG-…"
value={input}
onChange={(event) => {
// 编辑即作废在途响应:旧结果不得在输入变化后重新出现。
sequence.current++;
setInput(event.target.value);
setResult(null);
setError("");
if (invalid) setInvalid(false);
}}
size="small"
fullWidth
autoComplete="off"
error={invalid}
helperText={
invalid
? "请输入 XG- 开头、后接 48 位十六进制字符(0-9、A-F)的完整兑换码。"
: " "
}
slotProps={{
htmlInput: { maxLength: 80, spellCheck: false, ref: field },
input: { sx: { fontFamily: "monospace", fontSize: 16 } },
}}
/>
<Button
type="submit"
variant="outlined"
disabled={busy || disabled || !input.trim()}
startIcon={
busy ? <CircularProgress size={16} aria-hidden="true" /> : undefined
}
sx={{ whiteSpace: "nowrap", alignSelf: { sm: "flex-start" } }}
>
{busy ? "正在查询…" : "查询兑换码"}
</Button>
</Stack>
</Box>
<Typography variant="body2" color="text.secondary">
粘贴客户提供的完整兑换码,直接查看状态、批次和接收账号,无需按批次翻页。
</Typography>
{error && (
<Alert severity="error" role="alert" sx={{ mt: 2 }}>
{error}
</Alert>
)}
<Box aria-live="polite">
{result && (
<Box
sx={{
mt: 2,
p: 2,
border: 1,
borderColor: "divider",
borderRadius: 2,
bgcolor: "background.default",
}}
>
<Stack
direction="row"
spacing={1}
alignItems="center"
flexWrap="wrap"
useFlexGap
>
<Chip
size="small"
variant="outlined"
color={codeStatus[result.status]?.color ?? "default"}
label={codeStatus[result.status]?.label ?? result.status}
/>
<Typography variant="body2" fontWeight={600}>
{result.months} 个月
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ fontFamily: "monospace" }}
>
尾号 …{result.hint}
</Typography>
</Stack>
<Box
component="dl"
sx={{
display: "grid",
gridTemplateColumns: "auto 1fr",
columnGap: 2,
rowGap: 0.5,
m: 0,
mt: 1.5,
}}
>
{rows.map(([label, value]) => (
<Box component="div" key={label} sx={{ display: "contents" }}>
<Typography
component="dt"
variant="body2"
color="text.secondary"
sx={{ whiteSpace: "nowrap" }}
>
{label}
</Typography>
<Typography
component="dd"
variant="body2"
sx={{ m: 0, overflowWrap: "anywhere" }}
>
{value}
</Typography>
</Box>
))}
</Box>
{result.message && (
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 1, overflowWrap: "anywhere" }}
>
{result.message}
</Typography>
)}
<Stack
direction="row"
spacing={1}
flexWrap="wrap"
useFlexGap
sx={{ mt: 1.5 }}
>
<Button
size="small"
variant="outlined"
disabled={disabled}
onClick={() => onViewCustomer(result.id)}
>
查看兑换码 / 补单
</Button>
{result.copyable && (
<Button
size="small"
variant="outlined"
disabled={disabled}
onClick={() => onCopy(result)}
>
复制完整兑换码
</Button>
)}
{result.status === "active" && (
<Button
size="small"
variant="outlined"
color="error"
disabled={disabled}
onClick={() => onRevoke(result)}
>
停用
</Button>
)}
</Stack>
</Box>
)}
</Box>
</Paper>
);
}
+32 -17
View File
@@ -24,7 +24,7 @@ import {
Typography,
} from "@mui/material";
import ExpandMoreRounded from "@mui/icons-material/ExpandMoreRounded";
import { adminApi } from "./adminApi";
import { adminApi, formatTime } from "./adminApi";
import type { RecoverySelection } from "./CustomerPanel";
type Item = {
@@ -47,6 +47,7 @@ type Batch = {
created: number;
updated?: number;
last4: string;
cards?: number;
paused: boolean;
message: string;
items: Item[];
@@ -83,13 +84,13 @@ function resultSummary(items: Item[]) {
return [`共 ${items.length} 笔`, ...parts.filter(([, n]) => n > 0).map(([label, n]) => `${label} ${n} 笔`)].join(" · ");
}
function taskTime(seconds?: number) {
return seconds ? new Date(seconds * 1000).toLocaleString("zh-CN", { hour12: false }) : "时间未记录";
return seconds ? formatTime(seconds) : "时间未记录";
}
function BatchDetails({ batch }: { batch: Batch }) {
return <>
<Stack direction="row" flexWrap="wrap" gap={1} alignItems="center">
<Chip size="small" label={labels[batch.state] || batch.state} />
<Typography variant="body2">{batch.mode === "links" ? "仅生成链接" : `付款卡尾号 ${batch.last4}`} · {resultSummary(batch.items)}</Typography>
<Typography variant="body2">{batch.mode === "links" ? "仅生成补单链接" : batch.cards && batch.cards > 1 ? `付款卡 ${batch.cards} 张随机轮换(当前尾号 ${batch.last4})` : `付款卡尾号 ${batch.last4}`} · {resultSummary(batch.items)}</Typography>
</Stack>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 1 }}>
创建于 {taskTime(batch.created)} · 更新于 {taskTime(batch.updated || batch.created)}
@@ -123,7 +124,7 @@ function Orders({ items }: { items: Item[] }) {
fontWeight={600}
sx={{ overflowWrap: "anywhere" }}
>
@{item.username} · 卡密尾号 {item.hint}
@{item.username} · 兑换码尾号 {item.hint}
</Typography>
{item.payment_node && (
<Typography variant="caption">付款节点 {item.payment_node}</Typography>
@@ -157,6 +158,9 @@ function Orders({ items }: { items: Item[] }) {
))}
</Stack>
<TableContainer
tabIndex={0}
role="region"
aria-label="补单订单清单,可滚动查看"
sx={{ maxHeight: 380, display: { xs: "none", sm: "block" } }}
>
<Table size="small" stickyHeader aria-label="补单订单清单">
@@ -173,7 +177,7 @@ function Orders({ items }: { items: Item[] }) {
<TableCell sx={{ verticalAlign: "top", minWidth: 140 }}>
<Typography variant="body2">@{item.username}</Typography>
<Typography variant="caption" display="block">
卡密尾号 {item.hint}
兑换码尾号 {item.hint}
{item.payment_node && ` · 付款节点 ${item.payment_node}`}
</Typography>
<Typography variant="caption" color="text.secondary">
@@ -232,6 +236,8 @@ export function RecoveryPanel({
const linksOnly = batch?.mode === "links";
const mutating = useRef(false);
const sequence = useRef(0);
// 上一个补单操作在途时到达的 selection 先暂存,完成后补发,避免"点了没反应"。
const pendingSelection = useRef<RecoverySelection | null>(null);
const active = batch?.state === "running" || batch?.state === "stopping";
const pending =
batch?.items.filter((item) => item.state === "pending").length || 0;
@@ -264,7 +270,10 @@ export function RecoveryPanel({
};
}, []);
useEffect(() => {
if (selection) void act("preview", selection);
if (selection) {
if (mutating.current) pendingSelection.current = selection;
else void act("preview", selection);
}
}, [selection]);
async function act(
action: "preview" | "start" | "stop",
@@ -303,6 +312,11 @@ export function RecoveryPanel({
mutating.current = false;
sequence.current++;
setBusy(false);
const queued = pendingSelection.current;
if (queued) {
pendingSelection.current = null;
void act("preview", queued);
}
}
}
return (
@@ -341,7 +355,7 @@ export function RecoveryPanel({
disabled={busy || active}
onClick={() => void act("preview", { mode: "links" })}
>
仅生成链接
仅生成补单链接
</Button>
<Button
variant="contained"
@@ -363,20 +377,20 @@ export function RecoveryPanel({
</Stack>
</Stack>
{error && !open && (
<Alert severity="error" sx={{ mt: 2 }}>
<Alert severity="error" role="alert" sx={{ mt: 2 }}>
{error}
</Alert>
)}
{statusError && <Alert severity="error" sx={{ mt: 2 }}>当前状态读取失败:{statusError}</Alert>}
<Box sx={{ mt: 2 }} aria-live="polite">
<Typography variant="body2" fontWeight={600}>
{statusError && <Alert severity="error" role="alert" sx={{ mt: 2 }}>当前状态读取失败:{statusError}</Alert>}
<Box sx={{ mt: 2 }}>
<Typography variant="body2" fontWeight={600} aria-live="polite">
{statusError ? "当前状态暂不可用" : active ? "当前有补单任务运行中" : summary ? "当前没有运行中的补单任务" : "正在读取当前状态…"}
</Typography>
{summary && <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
当前订单:待核实 {summary.review} 笔 · 充值处理中 {summary.processing} 笔
</Typography>}
</Box>
{batch && active && (
{batch && active && !statusError && (
<Box sx={{ mt: 2 }}>
<BatchDetails batch={batch} />
</Box>
@@ -393,7 +407,7 @@ export function RecoveryPanel({
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<AccordionDetails id="recovery-history-content">
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
以下为这次任务保存的处理结果。当前订单数量显示在上方;刷新页面不会重新执行此任务。
</Typography>
@@ -417,13 +431,14 @@ export function RecoveryPanel({
},
}}
aria-labelledby="recovery-title"
aria-describedby="recovery-dialog-description"
>
<DialogTitle id="recovery-title">
{linksOnly ? "确认生成补单链接(不付款)" : "确认手动补单"}
</DialogTitle>
<DialogContent>
<DialogContent id="recovery-dialog-description">
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
<Alert severity="error" role="alert" sx={{ mb: 2 }}>
{error}
</Alert>
)}
@@ -432,7 +447,7 @@ export function RecoveryPanel({
"只生成或更新付款链接,不提交银行卡付款;核验并处理"
) : (
<>
使用银行卡尾号 <strong>{batch?.last4}</strong>,核验并处理
{batch?.cards && batch.cards > 1 ? <>使用 <strong>{batch.cards}</strong> 张银行卡随机轮换(当前尾号 <strong>{batch?.last4}</strong>)</> : <>使用银行卡尾号 <strong>{batch?.last4}</strong></>},核验并处理
</>
)}{" "}
<strong>{pending}</strong> 笔订单,账单总额{" "}
@@ -478,7 +493,7 @@ export function RecoveryPanel({
}
label={
linksOnly
? "我已核对客户和套餐,确认仅生成链接,不付款。"
? "我已核对客户和套餐,确认仅生成补单链接,不付款。"
: "我已核对客户、套餐、账单金额和银行卡,确认启动本批次付款。"
}
/>
+15 -10
View File
@@ -18,10 +18,10 @@ import { stats as fetchStats, type AdminStatsDetail } from "./adminApi";
const visuallyHidden = {
position: "absolute",
width: 1,
height: 1,
width: "1px",
height: "1px",
p: 0,
m: -1,
m: "-1px",
overflow: "hidden",
clip: "rect(0 0 0 0)",
whiteSpace: "nowrap",
@@ -241,7 +241,8 @@ export function StatsPanel({ refreshSignal }: { refreshSignal: number }) {
1,
...(data?.review_stages ?? []).map((stage) => stage.count),
);
const remainder = theme.vars.palette.primary.light;
// Neutral track color that stays calm on dark surfaces (primary.light does not).
const remainder = theme.vars.palette.divider;
return (
<Paper variant="outlined" sx={{ mb: 3, overflow: "hidden" }}>
<Button
@@ -282,8 +283,12 @@ export function StatsPanel({ refreshSignal }: { refreshSignal: number }) {
aria-label="统计概览"
sx={{ px: { xs: 2.5, sm: 3 }, pb: 3, pt: 1 }}
>
{/* 折叠按钮内的标题是 span,补一个隐藏的 h2 让小节 h3 有父级。 */}
<Typography variant="h2" sx={visuallyHidden}>
统计概览
</Typography>
{loading && (
<Stack spacing={2} aria-label="正在加载统计数据">
<Stack spacing={2} role="status" aria-label="正在加载统计数据">
<Box
sx={{
display: "grid",
@@ -302,7 +307,7 @@ export function StatsPanel({ refreshSignal }: { refreshSignal: number }) {
</Stack>
)}
{!loading && error && (
<Alert severity="error">
<Alert severity="error" role="alert">
{error}
{data && " 以下保留上次加载的数据。"}
<Button onClick={() => void load()}>重新加载</Button>
@@ -336,9 +341,9 @@ export function StatsPanel({ refreshSignal }: { refreshSignal: number }) {
value={String(data.codes.succeeded)}
hint={`成功率 ${successRate}`}
/>
<StatCard label="待处理" value={String(data.codes.processing)} />
<StatCard label="待审核" value={String(data.codes.review)} />
<StatCard label="已撤销" value={String(data.codes.revoked)} />
<StatCard label="处理中" value={String(data.codes.processing)} />
<StatCard label="待核实" value={String(data.codes.review)} />
<StatCard label="已停用" value={String(data.codes.revoked)} />
</Box>
{data.daily.length > 0 && (
<Box component="section" aria-labelledby="stats-daily-title">
@@ -445,7 +450,7 @@ export function StatsPanel({ refreshSignal }: { refreshSignal: number }) {
{data.review_stages.length > 0 && (
<Box component="section" aria-labelledby="stats-stages-title">
<Typography id="stats-stages-title" variant="h3" sx={{ mb: 1.5 }}>
待审核阶段分布
待核实阶段分布
</Typography>
<Stack spacing={1.5}>
{[...data.review_stages]
+102 -62
View File
@@ -36,26 +36,23 @@ import ConfirmationNumberOutlined from "@mui/icons-material/ConfirmationNumberOu
import { mount, Shell } from "./shared";
import { AppearanceMenu } from "./AppearanceMenu";
import { CopyableCodes } from "./CopyableCodes";
import { adminApi as api, type AdminStats, type Folder } from "./adminApi";
import {
adminApi as api,
formatTime,
type AdminCode,
type AdminStats,
type Folder,
} from "./adminApi";
import { codeStatus as statuses } from "./codeStatus";
import { FolderPanel } from "./FolderPanel";
import { FilterBar } from "./FilterBar";
import { parseFilter } from "./filter";
import { CustomerPanel, type RecoverySelection } from "./CustomerPanel";
import { LookupPanel } from "./LookupPanel";
import { RecoveryPanel } from "./RecoveryPanel";
import { StatsPanel } from "./StatsPanel";
type Code = {
copyable: boolean;
folder: string;
id: string;
hint: string;
batch: string;
months: number;
status: string;
username: string;
message: string;
created: number;
};
type Code = AdminCode;
type Listing = {
folder: string;
folders: Folder[];
@@ -72,16 +69,6 @@ type Generated = {
folder: string;
};
type Confirmation = { kind: "revoke"; code: Code } | null;
const statuses: Record<
string,
{ label: string; color: "default" | "primary" | "success" | "warning" }
> = {
active: { label: "可使用", color: "primary" },
processing: { label: "处理中", color: "primary" },
succeeded: { label: "已完成", color: "success" },
review: { label: "待核实", color: "warning" },
revoked: { label: "已停用", color: "default" },
};
function Admin() {
const [customerSelection, setCustomerSelection] = useState<{
id: string;
@@ -97,6 +84,8 @@ function Admin() {
const [count, setCount] = useState("10");
const [batch, setBatch] = useState("");
const [copyNotice, setCopyNotice] = useState("");
// 连续复制时重置 Snackbar 的自动隐藏计时,避免第二条提示一闪而过。
const [copySeq, setCopySeq] = useState(0);
const copying = useRef(false);
const [selectedIDs, setSelectedIDs] = useState<string[]>([]);
const [moveOpen, setMoveOpen] = useState(false);
@@ -121,7 +110,7 @@ function Admin() {
const [confirmation, setConfirmation] = useState<Confirmation>(null);
const [batchError, setBatchError] = useState(false);
const [countError, setCountError] = useState(false);
const [focusTarget, setFocusTarget] = useState<"form" | "list" | null>(null);
const [focusTarget, setFocusTarget] = useState<"list" | null>(null);
const [statsSignal, setStatsSignal] = useState(0);
const listSequence = useRef(0);
const mutation = useRef(false);
@@ -131,6 +120,8 @@ function Admin() {
const refresh = useCallback(
async (page: number, folder = filterRef.current) => {
const sequence = ++listSequence.current;
// 跨页翻页保留选择;只有切换批次时才清空(筛选模式的进出单独管理)。
const switchingFolder = folder !== filterRef.current;
setLoading(true);
setListError("");
try {
@@ -140,7 +131,7 @@ function Admin() {
if (sequence === listSequence.current) {
setListing(data);
filterRef.current = data.folder;
setSelectedIDs([]);
if (switchingFolder) setSelectedIDs([]);
// The panel fetches on mount; only nudge it for later refreshes.
if (sequence > 1) setStatsSignal((value) => value + 1);
}
@@ -160,7 +151,7 @@ function Admin() {
// Filter mode: parse the expression, fetch every page client-side, then
// evaluate the predicate locally. Shares listSequence with refresh() so
// overlapping loads from either mode cancel each other.
const applyFilter = useCallback(async (source: string) => {
const applyFilter = useCallback(async (source: string, keepPage = false) => {
let match: (code: Code) => boolean;
try {
match = parseFilter(source);
@@ -185,7 +176,7 @@ function Admin() {
setAllCodes(codes);
setActiveFilter({ source, match });
setApplySeq((value) => value + 1);
setFilterPage(0);
if (!keepPage) setFilterPage(0);
setSelectedIDs([]);
} catch (error) {
if (sequence === listSequence.current)
@@ -202,12 +193,18 @@ function Admin() {
setFilterError("");
setFilterPage(0);
setSelectedIDs([]);
if (reload) void refresh(0);
if (reload) void refresh(0, "");
}
// After any mutation, reload the current view and keep stats/lookup panels
// in sync (applyFilter does not touch statsSignal on its own; refresh does).
function reloadCurrent() {
if (activeFilter) void applyFilter(activeFilter.source);
else void refresh(listing?.page ?? 0);
if (activeFilter) {
void applyFilter(activeFilter.source, true);
setStatsSignal((value) => value + 1);
} else {
void refresh(listing?.page ?? 0);
}
}
useEffect(() => {
@@ -215,7 +212,6 @@ function Admin() {
// Restore to a surviving control after the dialog's exit transition.
const timer = setTimeout(() => {
if (focusTarget === "list") refreshButton.current?.focus();
else form.current?.querySelector<HTMLElement>("[role=combobox]")?.focus();
setFocusTarget(null);
}, 250);
return () => clearTimeout(timer);
@@ -224,6 +220,7 @@ function Admin() {
async function copyRow(code: Code) {
if (!code.copyable) {
setCopyNotice("历史兑换码未保存完整内容,请使用原先下载的 TXT。");
setCopySeq((value) => value + 1);
return;
}
if (copying.current) return;
@@ -248,6 +245,7 @@ function Admin() {
} catch {
setCopyNotice("复制失败,请检查剪贴板权限或稍后重试。");
} finally {
setCopySeq((value) => value + 1);
copying.current = false;
}
}
@@ -290,6 +288,7 @@ function Admin() {
try {
await api("/api/admin/revoke", { id: confirmation.code.id });
setNotice({ text: "兑换码已停用。", error: false });
setSelectedIDs([]);
setFocusTarget("list");
setConfirmation(null);
reloadCurrent();
@@ -315,6 +314,7 @@ function Admin() {
text: `已移动 ${selectedIDs.length} 枚兑换码。`,
error: false,
});
setSelectedIDs([]);
setMoveOpen(false);
setFocusTarget("list");
reloadCurrent();
@@ -351,6 +351,8 @@ function Admin() {
const visibleCodes = matched
? matched.slice(safeFilterPage * 100, safeFilterPage * 100 + 100)
: (listing?.codes ?? []);
const pageIDs = visibleCodes.map((code) => code.id);
const pageSelected = pageIDs.filter((id) => selectedIDs.includes(id));
return (
<Shell admin>
@@ -387,12 +389,32 @@ function Admin() {
<AppearanceMenu />
</Stack>
</Stack>
<CustomerPanel
selected={customerSelection}
onPrepare={(id, mode) =>
setRecoverySelection({ id, mode, seq: Date.now() })
}
/>
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
md: "minmax(0, 1fr) minmax(0, 1fr)",
},
gap: 3,
mb: 3,
alignItems: "stretch",
}}
>
<CustomerPanel
selected={customerSelection}
onPrepare={(id, mode) =>
setRecoverySelection({ id, mode, seq: Date.now() })
}
/>
<LookupPanel
disabled={busy || loading}
refreshSignal={statsSignal}
onCopy={(code) => void copyRow(code)}
onViewCustomer={(id) => setCustomerSelection({ id, seq: Date.now() })}
onRevoke={(code) => setConfirmation({ kind: "revoke", code })}
/>
</Box>
<RecoveryPanel selection={recoverySelection} />
<StatsPanel refreshSignal={statsSignal} />
<FolderPanel
@@ -400,7 +422,6 @@ function Admin() {
stats={listing?.stats}
filter={listing?.folder ?? ""}
disabled={busy || loading}
muted={!!activeFilter}
onSelect={(folder) => {
// Clicking a folder chip while the expression filter is active
// exits filter mode and returns to normal folder filtering.
@@ -425,8 +446,7 @@ function Admin() {
mutation.current = value;
setBusy(value);
}}
onChanged={async (deleted) => {
if (deleted === filterRef.current) filterRef.current = "";
onChanged={async () => {
if (activeFilter) reloadCurrent();
else await refresh(0);
}}
@@ -519,7 +539,10 @@ function Admin() {
type="number"
required
value={count}
onChange={(event) => setCount(event.target.value)}
onChange={(event) => {
setCount(event.target.value);
if (countError) setCountError(false);
}}
disabled={busy}
error={countError}
helperText={
@@ -531,7 +554,10 @@ function Admin() {
label="批次名称(可选)"
placeholder="例如:十月赠礼"
value={batch}
onChange={(event) => setBatch(event.target.value)}
onChange={(event) => {
setBatch(event.target.value);
if (batchError) setBatchError(false);
}}
disabled={busy}
error={batchError}
helperText={
@@ -556,6 +582,8 @@ function Admin() {
<Alert
severity={notice.error ? "error" : "success"}
role="status"
closeText="关闭"
onClose={notice.error ? undefined : () => setNotice(null)}
sx={{ mb: 3 }}
>
{notice.text}
@@ -637,7 +665,7 @@ function Admin() {
</Stack>
{loading && <LinearProgress aria-label="正在加载兑换码" />}
{listError && (
<Alert severity="error" sx={{ m: 2 }}>
<Alert severity="error" role="alert" sx={{ m: 2 }}>
{listError}
{(listing || allCodes) && " 以下保留上次加载的数据。"}
<Button onClick={() => reloadCurrent()}>重新加载</Button>
@@ -693,18 +721,17 @@ function Admin() {
busy || loading || !!listError || !visibleCodes.length
}
checked={
!!visibleCodes.length &&
selectedIDs.length === visibleCodes.length
!!pageIDs.length && pageSelected.length === pageIDs.length
}
indeterminate={
selectedIDs.length > 0 &&
selectedIDs.length < visibleCodes.length
pageSelected.length > 0 &&
pageSelected.length < pageIDs.length
}
onChange={(event) =>
setSelectedIDs(
setSelectedIDs((ids) =>
event.target.checked
? visibleCodes.map((code) => code.id)
: [],
? [...new Set([...ids, ...pageIDs])]
: ids.filter((id) => !pageIDs.includes(id)),
)
}
/>
@@ -806,14 +833,10 @@ function Admin() {
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ whiteSpace: "nowrap" }}>
{new Date(code.created * 1000).toLocaleDateString(
"zh-CN",
)}
{formatTime(code.created).slice(0, 10)}
</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(code.created * 1000).toLocaleTimeString(
"zh-CN",
)}
{formatTime(code.created).slice(11)}
</Typography>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
@@ -825,8 +848,9 @@ function Admin() {
onClick={() =>
setCustomerSelection({ id: code.id, seq: Date.now() })
}
sx={{ whiteSpace: "nowrap", flexShrink: 0 }}
>
查看卡密 / 补单
查看兑换码 / 补单
</Button>
<Tooltip
describeChild
@@ -955,9 +979,16 @@ function Admin() {
) : (
<>
<Typography variant="body2" color="text.secondary">
{listing?.folder
? `第 ${listing.page + 1} 页`
: `共 ${listing?.stats.total ?? 0} 条 · 第 ${(listing?.page ?? 0) + 1} / ${Math.max(1, Math.ceil((listing?.stats.total ?? 0) / 100))} 页`}
{(() => {
const total = listing?.folder
? listing.folder === "unfiled"
? (listing?.stats.unfiled ?? 0)
: (listing?.folders.find(
(folder) => folder.id === listing.folder,
)?.count ?? 0)
: (listing?.stats.total ?? 0);
return `共 ${total} 条 · 第 ${(listing?.page ?? 0) + 1} / ${Math.max(1, Math.ceil(total / 100))} 页`;
})()}
</Typography>
<Stack direction="row" spacing={1}>
<Button
@@ -988,6 +1019,7 @@ function Admin() {
fullWidth
maxWidth="xs"
aria-labelledby="move-dialog-title"
aria-describedby="move-dialog-description"
>
<DialogTitle id="move-dialog-title">
移动 {selectedIDs.length} 枚兑换码
@@ -999,6 +1031,10 @@ function Admin() {
value={moveTarget}
disabled={busy}
onChange={(event) => setMoveTarget(event.target.value)}
slotProps={{
select: { displayEmpty: true },
inputLabel: { shrink: true },
}}
sx={{ mt: 1 }}
>
<MenuItem value="">未分类</MenuItem>
@@ -1012,7 +1048,7 @@ function Admin() {
</MenuItem>
))}
</TextField>
<DialogContentText sx={{ mt: 2 }}>
<DialogContentText id="move-dialog-description" sx={{ mt: 2 }}>
只修改归属分类,不改变兑换码、账号和订单状态。
</DialogContentText>
</DialogContent>
@@ -1035,13 +1071,15 @@ function Admin() {
onClose={() => {
if (!busy) setConfirmation(null);
}}
role="alertdialog"
aria-labelledby="admin-dialog-title"
aria-describedby="admin-dialog-description"
maxWidth="xs"
fullWidth
>
<DialogTitle id="admin-dialog-title">停用这枚兑换码?</DialogTitle>
<DialogContent>
<DialogContentText>
<DialogContentText id="admin-dialog-description">
{`尾号 ${confirmation?.code.hint ?? ""} 的兑换码将无法使用。此操作不可撤销。`}
</DialogContentText>
</DialogContent>
@@ -1064,10 +1102,12 @@ function Admin() {
</DialogActions>
</Dialog>
<Snackbar
key={copySeq}
open={!!copyNotice}
autoHideDuration={4000}
onClose={() => setCopyNotice("")}
message={copyNotice}
slotProps={{ content: { role: "status" } }}
/>
</Shell>
);
+20
View File
@@ -24,3 +24,23 @@ export type AdminStatsDetail = {
export function stats() {
return adminApi<AdminStatsDetail>("/api/admin/stats");
}
export type AdminCode = {
copyable: boolean;
folder: string;
id: string;
hint: string;
batch: string;
months: number;
status: string;
username: string;
message: string;
created: number;
updated?: number;
progress?: number;
};
// 全站统一的时间呈现:YYYY-MM-DD HH:mm:ss(与统计表日期风格一致)。
export function formatTime(seconds: number): string {
const d = new Date(seconds * 1000);
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
+92 -96
View File
@@ -6,7 +6,6 @@ import {
Alert,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
@@ -26,11 +25,11 @@ import {
import ArrowForwardRounded from "@mui/icons-material/ArrowForwardRounded";
import ArrowOutwardRounded from "@mui/icons-material/ArrowOutwardRounded";
import ExpandMoreRounded from "@mui/icons-material/ExpandMoreRounded";
import FactCheckRounded from "@mui/icons-material/FactCheckRounded";
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
import HistoryRounded from "@mui/icons-material/HistoryRounded";
import { mount, request, Shell } from "./shared";
import { AppearanceMenu } from "./AppearanceMenu";
import { EligibilityCard } from "./EligibilityCard";
type Result = {
status?: string;
@@ -60,19 +59,18 @@ function App() {
const [locked, setLocked] = useState(false);
const [exhausted, setExhausted] = useState(false);
const [validation, setValidation] = useState(false);
const [checking, setChecking] = useState(false);
const [checkResult, setCheckResult] = useState<{
severity: "success" | "warning" | "info";
message: string;
} | null>(null);
// 本次会话的进度来自「兑换」还是「查询」,驱动进度区标题与区域命名。
const fromRedeem = useRef(false);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const controller = useRef<AbortController | null>(null);
const inFlight = useRef(false);
const progressPanel = useRef<HTMLDivElement>(null);
const progressHeading = useRef<HTMLParagraphElement>(null);
const resultAlert = useRef<HTMLDivElement>(null);
const form = useRef<HTMLFormElement>(null);
const current = useRef<Input>({ code: "", username: "" });
const attempt = useRef(0);
const cleanCode = code.trim().toUpperCase();
const cleanCode = code.replace(/\s+/g, "").toUpperCase();
const cleanUser = username.trim().replace(/^@/, "").toLowerCase();
const codeValid = /^XG-[A-F0-9]{48}$/.test(cleanCode);
const userValid = /^[a-z0-9_]{1,15}$/.test(cleanUser);
@@ -126,6 +124,10 @@ function App() {
}
function apply(ok: boolean, data: Result) {
if (data.status === "paused") setService("paused");
// If focus sits in the progress panel and this update unmounts it
// (terminal states), hand focus to the result alert instead of <body>.
const refocus =
progressPanel.current?.contains(document.activeElement) ?? false;
setResult({
...data,
message:
@@ -141,9 +143,11 @@ function App() {
? "error"
: data.status === "succeeded"
? "success"
: data.status === "review" || data.status === "revoked"
? "warning"
: "info",
: data.status === "revoked"
? "error"
: data.status === "review"
? "warning"
: "info",
);
if (data.payment_declined) {
setProgress(null);
@@ -162,6 +166,11 @@ function App() {
setProgress(null);
if (ok && data.status === "active") setLocked(false);
}
if (refocus)
requestAnimationFrame(() => {
if (!progressPanel.current?.contains(document.activeElement))
resultAlert.current?.focus();
});
}
async function send(path: string) {
controller.current = new AbortController();
@@ -214,7 +223,7 @@ function App() {
}
return form.current!.reportValidity();
}
function start() {
function start(keepProgress = false) {
if (inFlight.current) return false;
clearTimeout(timer.current);
current.current = { code: cleanCode, username: cleanUser };
@@ -223,13 +232,18 @@ function App() {
setBusy(true);
setExhausted(false);
setSeverity("info");
setProgress(5);
// 继续查询保留已显示的进度,避免进度条倒退(apply 维持单调不降)。
if (!keepProgress) setProgress(5);
scroll();
// The submit/query buttons disable themselves; park focus on the progress
// heading so keyboard and screen-reader users are not dropped to <body>.
requestAnimationFrame(() => progressHeading.current?.focus());
return true;
}
async function redeem() {
setConfirmation(false);
if (service !== "enabled" || result?.payment_declined) return;
fromRedeem.current = true;
if (!start()) return;
setResult({ status: "processing", message: "正在核实 X 账号和赠送资格…" });
try {
@@ -252,53 +266,37 @@ function App() {
}
function edit(field: "code" | "username", value: string) {
if (field === "code") setCode(value);
else {
setUsername(value);
setCheckResult(null);
}
else setUsername(value);
// 与已提交的键值对比较(同一套清洗规则):只在真正变化时解锁并清空
// 结果;空白位置等外观编辑保留当前展示(锁定也继续生效)。
const differs =
field === "code"
? value.replace(/\s+/g, "").toUpperCase() !== current.current.code
: value.trim().replace(/^@/, "").toLowerCase() !==
current.current.username;
if (!differs) return;
if (locked) setLocked(false);
setResult(null);
setProgress(null);
setExhausted(false);
// A lost response stays query-only until the server returns a known state.
}
// Advisory only: the result never blocks or alters the redemption flow.
async function runCheck() {
if (checking || busy || !userValid) return;
setChecking(true);
setCheckResult(null);
try {
const { ok, data } = await request<{
eligible?: boolean;
message?: string;
}>("/api/check", { username: cleanUser }, AbortSignal.timeout(45000));
if (ok && typeof data.eligible === "boolean") {
setCheckResult(
data.eligible
? { severity: "success", message: "该账号当前可以接收赠送。" }
: {
severity: "warning",
message: `该账号当前无法接收赠送:${data.message || "原因未知。"}`,
},
);
} else {
setCheckResult({
severity: "info",
message: data.message || "暂时无法检测,请稍后再试。",
});
}
} catch {
setCheckResult({ severity: "info", message: "暂时无法检测,请稍后再试。" });
} finally {
setChecking(false);
}
}
return (
<Shell>
<Paper
variant="outlined"
sx={{ p: { xs: 2.5, sm: 4 }, borderRadius: "28px" }}
<Shell maxWidth="md">
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
md: "minmax(0, 7fr) minmax(0, 5fr)",
},
gap: 3,
}}
>
<Paper
variant="outlined"
sx={{ p: { xs: 2.5, sm: 4 }, borderRadius: "28px" }}
>
<Stack
direction="row"
alignItems="center"
@@ -329,7 +327,7 @@ function App() {
ref={form}
onSubmit={(event) => {
event.preventDefault();
if (service === "enabled" && !result?.payment_declined && valid() && !locked && !busy)
if (service === "enabled" && !result?.payment_declined && result?.status !== "revoked" && valid() && !locked && !busy)
setConfirmation(true);
}}
noValidate
@@ -348,12 +346,13 @@ function App() {
error={validation && !codeValid}
helperText={
validation && !codeValid
? "请输入 XG- 开头、后接 48 位字母或数字的完整兑换码。"
? "请输入 XG- 开头、后接 48 位十六进制字符(0-9、A-F)的完整兑换码。"
: "请保留兑换码,后续查询仍需使用。"
}
slotProps={{
htmlInput: { maxLength: 80, spellCheck: false },
input: { sx: { fontFamily: "monospace", fontSize: 14 } },
input: { sx: { fontFamily: "monospace", fontSize: 16 } },
inputLabel: { shrink: true },
}}
/>
<TextField
@@ -383,44 +382,23 @@ function App() {
},
}}
/>
<Box>
<Button
variant="text"
size="small"
disabled={!userValid || busy || checking}
onClick={() => void runCheck()}
startIcon={
checking ? (
<CircularProgress size={16} aria-hidden="true" />
) : (
<FactCheckRounded />
)
}
>
{checking ? "正在检测…" : "检测可否接收赠送"}
</Button>
{checkResult && (
<Alert
severity={checkResult.severity}
role="status"
aria-live="polite"
sx={{ mt: 1 }}
>
{checkResult.message}
</Alert>
)}
</Box>
<Button
type="submit"
variant="contained"
size="large"
disabled={busy || locked || service !== "enabled" || result?.payment_declined}
disabled={busy || locked || service !== "enabled" || result?.payment_declined || result?.status === "revoked"}
endIcon={<ArrowForwardRounded />}
>
{service === "paused"
{service === "loading"
? "正在检查服务状态…"
: service === "unknown"
? "服务状态未知,请稍后再试"
: service === "paused"
? "充值已暂停"
: result?.payment_declined
? "付款被拒,请联系管理员"
: result?.status === "revoked"
? "兑换码已停用"
: busy
? "正在处理,请稍候"
: locked
@@ -435,6 +413,7 @@ function App() {
disabled={busy}
startIcon={<HistoryRounded />}
onClick={() => {
fromRedeem.current = false;
if (valid() && start()) {
setResult({ message: "正在查询原订单,不会再次扣款…" });
void query();
@@ -449,7 +428,7 @@ function App() {
{progress !== null && (
<Box
component="section"
aria-label="兑换进度"
aria-labelledby="progress-heading"
sx={{
mt: 3,
p: 2,
@@ -463,10 +442,16 @@ function App() {
spacing={1}
sx={{ mb: 1.5 }}
>
<Typography variant="body2" fontWeight={600}>
<Typography
ref={progressHeading}
id="progress-heading"
tabIndex={-1}
variant="body2"
fontWeight={600}
>
{result?.status === "succeeded"
? "兑换完成"
: busy
: fromRedeem.current
? "兑换进度"
: "原订单进度"}
</Typography>
@@ -477,7 +462,7 @@ function App() {
<LinearProgress
variant="determinate"
value={progress}
aria-label="兑换阶段进度"
aria-label="流程阶段进度"
color={result?.status === "succeeded" ? "success" : "primary"}
sx={{ height: 6, borderRadius: 3 }}
/>
@@ -510,12 +495,18 @@ function App() {
component="p"
sx={{ mt: 2 }}
>
百分比表示流程阶段,不是预计耗时。请勿重复提交。
{result?.status === "succeeded"
? "百分比表示流程阶段,不是预计耗时。请保留兑换码备查。"
: result?.status === "review"
? "百分比表示流程阶段,不是预计耗时。"
: "百分比表示流程阶段,不是预计耗时。请勿重复提交。"}
</Typography>
</Box>
)}
{result && (
<Alert
ref={resultAlert}
tabIndex={-1}
severity={severity}
role="status"
aria-live="polite"
@@ -532,7 +523,8 @@ function App() {
sx={{ mt: 2 }}
onClick={() => {
// Resume status polling only; never re-submits the redemption.
if (valid() && start()) {
fromRedeem.current = false;
if (valid() && start(true)) {
setResult({
status: result.status,
message: "正在继续查询原订单,不会再次扣款…",
@@ -546,9 +538,10 @@ function App() {
)}
{result?.status === "succeeded" && (
<Button
href="https://x.com/"
href="https://x.com/i/premium"
target="_blank"
rel="noopener noreferrer"
variant="contained"
endIcon={<ArrowOutwardRounded />}
fullWidth
sx={{ mt: 2 }}
@@ -566,6 +559,8 @@ function App() {
</Typography>
</Stack>
</Paper>
<EligibilityCard />
</Box>
<Box component="section" aria-labelledby="faq-title" sx={{ mt: 4 }}>
<Typography id="faq-title" variant="h3" component="h2" sx={{ mb: 1 }}>
常见问题
@@ -577,7 +572,7 @@ function App() {
],
[
"账号暂时无法接收赠送怎么办?",
"X 会根据账号情况决定是否允许接收 Premium 赠送。兑换前可先点击用户名旁的「检测可否接收赠送」确认当前资格。首次建单前资格未通过,兑换码不会使用;已有待核实订单时,请使用原兑换码和账号重新检查,符合条件且尚未付款时会继续兑换。",
"X 会根据账号情况决定是否允许接收 Premium 赠送。兑换前可先用本页的「检测赠送资格」确认账号当前状态。首次建单前资格未通过,兑换码不会使用;已有待核实订单时,请使用原兑换码和账号重新检查,符合条件且尚未付款时会继续兑换。",
],
[
"等待较久或关闭页面后,如何查询?",
@@ -602,7 +597,7 @@ function App() {
>
<Typography fontWeight={500}>{title}</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 0, pb: 3 }}>
<AccordionDetails id={`faq-content-${index}`} sx={{ px: 0, pb: 3 }}>
<Typography variant="body2" color="text.secondary">
{text}
</Typography>
@@ -640,6 +635,7 @@ function App() {
open={confirmation}
onClose={() => setConfirmation(false)}
aria-labelledby="redeem-dialog-title"
aria-describedby="redeem-dialog-description"
fullWidth
maxWidth="xs"
>
@@ -649,7 +645,7 @@ function App() {
: "确认接收 Premium 的账号"}
</DialogTitle>
<DialogContent>
<DialogContentText>
<DialogContentText id="redeem-dialog-description">
接收账号为 <strong>@{cleanUser}</strong>。
{result?.status === "review"
? "将重新核对账号资格和原订单;符合条件且尚未付款时继续付款,已提交过付款的订单只核实结果。"
@@ -665,7 +661,7 @@ function App() {
disabled={service !== "enabled"}
onClick={() => void redeem()}
>
确认兑换
{result?.status === "review" ? "继续兑换" : "确认兑换"}
</Button>
</DialogActions>
</Dialog>
+10
View File
@@ -0,0 +1,10 @@
export const codeStatus: Record<
string,
{ label: string; color: "default" | "primary" | "success" | "warning" }
> = {
active: { label: "可使用", color: "primary" },
processing: { label: "处理中", color: "primary" },
succeeded: { label: "已完成", color: "success" },
review: { label: "待核实", color: "warning" },
revoked: { label: "已停用", color: "default" },
};
+86 -19
View File
@@ -31,13 +31,42 @@ type Token =
function tokenize(src: string): Token[] {
// 中文输入法容错:全角括号、冒号、引号、叹号、等号统一归一为半角。
src = src
.replace(/(/g, "(")
.replace(/)/g, ")")
.replace(/:/g, ":")
.replace(/!/g, "!")
.replace(/=/g, "=")
.replace(/["“”]/g, '"');
// 引号内的字面内容保持原样(批次名可能本身含全角标点)。
let normalized = "";
let inString = false;
let escaped = false;
for (const raw of src) {
if (inString) {
if (escaped) {
normalized += raw;
escaped = false;
continue;
}
if (raw === "\\") {
normalized += raw;
escaped = true;
continue;
}
// 未转义的全角引号在字符串内视为闭合(中文输入法自动配对场景);
// 名称字面的全角引号由 quoteValue 转义为 \” 绕过。
if (raw === '"' || /["“”]/.test(raw)) {
normalized += '"';
inString = false;
continue;
}
normalized += raw;
continue;
}
const char = /["“”]/.test(raw) ? '"' : raw;
if (char === '"') inString = true;
normalized += char
.replace(/(/g, "(")
.replace(/)/g, ")")
.replace(/:/g, ":")
.replace(/!/g, "!")
.replace(/=/g, "=");
}
src = normalized;
const tokens: Token[] = [];
let index = 0;
while (index < src.length) {
@@ -82,10 +111,22 @@ function tokenize(src: string): Token[] {
continue;
}
if (char === '"') {
const end = src.indexOf('"', index + 1);
if (end === -1)
throw new Error("引号不匹配:批次名称的英文双引号没有闭合。");
tokens.push({ kind: "string", text: src.slice(index + 1, end) });
// 支持 \" 与 \\ 转义,与 quoteValue 的序列化互逆。
let end = index + 1;
let text = "";
for (;;) {
if (end >= src.length)
throw new Error("引号不匹配:批次名称的英文双引号没有闭合。");
if (src[end] === "\\" && end + 1 < src.length) {
text += src[end + 1];
end += 2;
continue;
}
if (src[end] === '"') break;
text += src[end];
end++;
}
tokens.push({ kind: "string", text });
index = end + 1;
continue;
}
@@ -108,11 +149,13 @@ type Node =
| { type: "or"; left: Node; right: Node }
| { type: "and"; left: Node; right: Node }
| { type: "not"; operand: Node }
| { type: "condition"; field: Field; value: string; negate: boolean };
// literal: 值来自引号串,哨兵值("-")按字面名称处理。
| { type: "condition"; field: Field; value: string; negate: boolean; literal?: boolean };
// 调色板模式使用的扁平词元,按表达式中的出现顺序记录。
// condition.literal: 值来自引号串(序列化时保持引号,哨兵 "-" 按字面名称处理)。
export type ExpressionToken =
| { kind: "condition"; field: Field; value: string; negate: boolean }
| { kind: "condition"; field: Field; value: string; negate: boolean; literal?: boolean }
| { kind: "not" }
| { kind: "and" }
| { kind: "or" }
@@ -193,8 +236,16 @@ function parse(src: string): { root: Node; flat: ExpressionToken[] } {
throw new Error(`「${field.text}」后缺少 : 或 !=。`);
next();
const value = peek();
if (!value || (value.kind !== "word" && value.kind !== "string"))
if (!value || (value.kind !== "word" && value.kind !== "string")) {
if (
value &&
(value.kind === "and" || value.kind === "or" || value.kind === "not")
)
throw new Error(
`「${field.text}:」后遇到逻辑运算符;如果名称本身含 and / or / not / 非,请用英文引号包裹,如 ${field.text}:"非"。`,
);
throw new Error(`「${field.text}:」后缺少值。`);
}
next();
let normalized = value.text;
if (name === "status") {
@@ -214,14 +265,30 @@ function parse(src: string): { root: Node; flat: ExpressionToken[] } {
} else if (!value.text) {
throw new Error("「username:」后需要账号名;未绑定请使用 username:-。");
}
flat.push({ kind: "condition", field: name, value: normalized, negate });
return { type: "condition", field: name, value: normalized, negate };
flat.push({
kind: "condition",
field: name,
value: normalized,
negate,
literal: value.kind === "string",
});
return {
type: "condition",
field: name,
value: normalized,
negate,
literal: value.kind === "string",
};
}
const root = parseExpr();
if (position < tokens.length) {
const rest = tokens[position];
if (rest.kind === "rparen") throw new Error("括号不匹配:多余的右括号。");
if (rest.kind === "colon" || rest.kind === "eq" || rest.kind === "neq")
throw new Error(
'值中包含 : 或 = 等特殊字符;请用英文引号包裹名称,如 folder:"国庆:活动"。',
);
if (rest.kind === "lparen")
throw new Error("「(」前缺少逻辑运算符(and / or)。");
if (rest.kind === "word" || rest.kind === "string") {
@@ -259,7 +326,7 @@ export function parseFilter(src: string): (code: CodeRow) => boolean {
break;
case "folder":
matched =
node.value === "-"
node.value === "-" && !node.literal
? !code.batch
: code.batch.toLowerCase() === node.value.toLowerCase();
break;
@@ -268,7 +335,7 @@ export function parseFilter(src: string): (code: CodeRow) => boolean {
break;
case "username":
matched =
node.value === "-"
node.value === "-" && !node.literal
? !code.username
: (code.username ?? "").toLowerCase() ===
node.value.toLowerCase();
@@ -287,4 +354,4 @@ export function parseExpressionTokens(src: string): ExpressionToken[] {
}
export const FILTER_HINT =
"条件:folder:批次名、status:状态、months:时长、username:账号;逻辑:and、or、not、括号;!= 取反;folder:- 未分类、username:- 未绑定。";
"条件:folder:批次名、status:状态、months:时长、username:账号;逻辑:and、or、not、括号;!= 取反;folder:- 未分类、username:- 未绑定;名称含空格、冒号或保留字时用英文引号包裹,如 folder:\"and\"。";
+43 -11
View File
@@ -46,17 +46,21 @@ class ErrorBoundary extends Component<
return { failed: true };
}
render() {
if (this.state.failed)
if (this.state.failed) {
const admin = window.location.pathname.startsWith("/admin");
return (
<Container sx={{ py: 8 }}>
<Alert severity="error">
页面暂时无法显示。若已提交兑换,请保留兑换码,重新打开页面后查询原订单;不要重复兑换。
{admin
? "管理页暂时无法显示。进行中的兑换不受影响,请重新打开后继续核实。"
: "页面暂时无法显示。若已提交兑换,请保留兑换码,重新打开页面后查询原订单;不要重复兑换。"}
</Alert>
<Button href="/" sx={{ mt: 2 }}>
重新打开兑换页
<Button href={admin ? "/admin" : "/"} sx={{ mt: 2 }}>
{admin ? "重新打开管理页" : "重新打开兑换页"}
</Button>
</Container>
);
}
return this.props.children;
}
}
@@ -84,18 +88,45 @@ export function mount(node: ReactNode) {
export function Shell({
admin = false,
maxWidth,
children,
}: {
admin?: boolean;
maxWidth?: "xs" | "sm" | "md" | "lg";
children: ReactNode;
}) {
return (
<Container
id="main"
component="main"
maxWidth={admin ? "lg" : "sm"}
sx={{ py: { xs: 3, sm: 6 } }}
>
<>
<Link
href="#main"
sx={{
position: "absolute",
left: 16,
top: -64,
zIndex: (theme) => theme.zIndex.tooltip,
px: 2,
py: 1,
borderRadius: 1,
bgcolor: "background.paper",
color: "text.primary",
fontWeight: 600,
transition: "top 160ms ease",
"&:focus-visible": { top: 16 },
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
}}
>
跳转到主要内容
</Link>
<Container
id="main"
component="main"
tabIndex={-1}
maxWidth={maxWidth ?? (admin ? "lg" : "sm")}
sx={{
py: { xs: 3, sm: 6 },
"&:focus-visible": { outline: "none" },
}}
>
{children}
<Box
component="footer"
@@ -144,6 +175,7 @@ export function Shell({
GitHub
</Link>
</Box>
</Container>
</Container>
</>
);
}