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:
47 files changed
+3033
-501
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user