feat: add beta application review flow
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ExperimentOutlined,
|
||||
FileSearchOutlined,
|
||||
LoginOutlined,
|
||||
ReloadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { betaApplicationClient, type BetaApplicationItem, type BetaApplicationStatus } from "../../api/betaApplicationClient";
|
||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||
import type { WebUserSession } from "../../types";
|
||||
import "../../styles/pages/beta-applications.css";
|
||||
|
||||
interface BetaApplicationsPageProps {
|
||||
session: WebUserSession | null;
|
||||
onOpenLogin: () => void;
|
||||
}
|
||||
|
||||
type StatusFilter = BetaApplicationStatus | "";
|
||||
|
||||
const STATUS_OPTIONS: Array<{ value: StatusFilter; label: string }> = [
|
||||
{ value: "pending", label: "待审核" },
|
||||
{ value: "approved", label: "已通过" },
|
||||
{ value: "rejected", label: "已驳回" },
|
||||
{ value: "", label: "全部" },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<BetaApplicationStatus, string> = {
|
||||
pending: "待审核",
|
||||
approved: "已通过",
|
||||
rejected: "已驳回",
|
||||
};
|
||||
|
||||
function canReviewBetaApplications(session: WebUserSession | null): boolean {
|
||||
const role = String(session?.user.role || "").trim().toLowerCase();
|
||||
const username = String(session?.user.username || "").trim().toLowerCase();
|
||||
return role === "admin" || username === "xqy1912";
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
if (!value) return "暂无时间";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function valueOrEmpty(value?: string | null): string {
|
||||
return value?.trim() || "未填写";
|
||||
}
|
||||
|
||||
function joinValues(values: string[]): string {
|
||||
return values.length ? values.join("、") : "未选择";
|
||||
}
|
||||
|
||||
function DetailField({ label, value, wide }: { label: string; value: string; wide?: boolean }) {
|
||||
return (
|
||||
<div className={`beta-admin-field${wide ? " beta-admin-field--wide" : ""}`}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BetaApplicationsPage({ session, onOpenLogin }: BetaApplicationsPageProps) {
|
||||
const allowed = canReviewBetaApplications(session);
|
||||
const [status, setStatus] = useState<StatusFilter>("pending");
|
||||
const [applications, setApplications] = useState<BetaApplicationItem[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [reviewNote, setReviewNote] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectedApplication = useMemo(
|
||||
() => applications.find((item) => item.id === selectedId) ?? applications[0] ?? null,
|
||||
[applications, selectedId],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!allowed) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const items = await betaApplicationClient.listAdminApplications(status);
|
||||
setApplications(items);
|
||||
setSelectedId((current) =>
|
||||
current && items.some((item) => item.id === current) ? current : (items[0]?.id ?? null),
|
||||
);
|
||||
} catch (loadError) {
|
||||
setApplications([]);
|
||||
setError(loadError instanceof Error ? loadError.message : "内测申请列表加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [allowed, status]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const handleDecision = async (action: "approve" | "reject") => {
|
||||
if (!selectedApplication || selectedApplication.status !== "pending" || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await betaApplicationClient.reviewApplication(selectedApplication.id, action, reviewNote.trim());
|
||||
setReviewNote("");
|
||||
await load();
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "审核操作失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<WorkspacePageShell title="内测申请审核" fullWidth className="beta-admin-page page-motion">
|
||||
<section className="beta-admin-access">
|
||||
<LoginOutlined />
|
||||
<h1>请登录审核账号</h1>
|
||||
<p>内测申请审核仅开放给管理员和 xqy1912。</p>
|
||||
<button type="button" onClick={onOpenLogin}>登录 / 注册</button>
|
||||
</section>
|
||||
</WorkspacePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
return (
|
||||
<WorkspacePageShell title="内测申请审核" fullWidth className="beta-admin-page page-motion">
|
||||
<section className="beta-admin-access">
|
||||
<FileSearchOutlined />
|
||||
<h1>当前账号没有审核权限</h1>
|
||||
<p>请切换到 admin 或 xqy1912 后再进入内测审核台。</p>
|
||||
</section>
|
||||
</WorkspacePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspacePageShell title="内测申请审核" fullWidth className="beta-admin-page page-motion">
|
||||
<div className="beta-admin-page__inner">
|
||||
<section className="beta-admin-toolbar">
|
||||
<div>
|
||||
<span>内部审核台</span>
|
||||
<h1>内测申请表</h1>
|
||||
<p>查看用户提交的完整申请资料,通过后发放内测码,驳回后向用户发送未通过通知。</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => void load()} disabled={loading}>
|
||||
<ReloadOutlined />
|
||||
刷新
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="beta-admin-status-tabs" role="tablist" aria-label="内测申请状态">
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value || "all"}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={status === option.value}
|
||||
className={status === option.value ? "is-active" : ""}
|
||||
onClick={() => setStatus(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? <p className="beta-admin-error">{error}</p> : null}
|
||||
|
||||
<section className="beta-admin-layout">
|
||||
<aside className="beta-admin-list" aria-label="内测申请列表">
|
||||
{loading ? <div className="beta-admin-list__empty">正在加载申请...</div> : null}
|
||||
{!loading && applications.length === 0 ? (
|
||||
<div className="beta-admin-list__empty">暂无需要显示的申请</div>
|
||||
) : null}
|
||||
{applications.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`beta-admin-list__item${item.id === selectedApplication?.id ? " is-active" : ""}`}
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
>
|
||||
<span className={`beta-admin-status beta-admin-status--${item.status}`}>{STATUS_LABEL[item.status]}</span>
|
||||
<strong>{item.name || item.username || `申请 #${item.id}`}</strong>
|
||||
<small>{item.industry || "未填写行业"} · {formatDate(item.createdAt)}</small>
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
{selectedApplication ? (
|
||||
<article className="beta-admin-detail">
|
||||
<header className="beta-admin-detail__header">
|
||||
<div>
|
||||
<span><ExperimentOutlined /> {STATUS_LABEL[selectedApplication.status]}</span>
|
||||
<h2>{selectedApplication.name || "未填写姓名"}</h2>
|
||||
<p>{selectedApplication.selfStatement || "申请人未填写自述。"}</p>
|
||||
</div>
|
||||
{selectedApplication.inviteCode ? (
|
||||
<strong className="beta-admin-code">内测码:{selectedApplication.inviteCode}</strong>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<section className="beta-admin-form-card">
|
||||
<h3>一、个人基础信息</h3>
|
||||
<div className="beta-admin-field-grid">
|
||||
<DetailField label="姓名 / 常用昵称" value={valueOrEmpty(selectedApplication.name)} />
|
||||
<DetailField label="联系手机号码" value={valueOrEmpty(selectedApplication.phone)} />
|
||||
<DetailField label="微信账号" value={valueOrEmpty(selectedApplication.wechat)} />
|
||||
<DetailField label="所在行业 / 职业" value={valueOrEmpty(selectedApplication.industry)} />
|
||||
<DetailField label="所属公司 / 机构" value={valueOrEmpty(selectedApplication.company)} />
|
||||
<DetailField label="所在城市" value={valueOrEmpty(selectedApplication.city)} />
|
||||
<DetailField label="关联账号" value={selectedApplication.username || `UID ${selectedApplication.userId ?? "未登录提交"}`} />
|
||||
<DetailField label="提交时间" value={formatDate(selectedApplication.createdAt)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="beta-admin-form-card">
|
||||
<h3>二、AI 从业与使用经历</h3>
|
||||
<div className="beta-admin-field-grid">
|
||||
<DetailField label="常用 AI 创作工具" value={valueOrEmpty(selectedApplication.aiTools)} wide />
|
||||
<DetailField label="AI 内容创作从业时长" value={valueOrEmpty(selectedApplication.aiDuration)} />
|
||||
<DetailField label="是否深耕相关赛道" value={valueOrEmpty(selectedApplication.aiTrack)} />
|
||||
<DetailField label="日常主要创作方向" value={joinValues(selectedApplication.aiDirection)} wide />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="beta-admin-form-card">
|
||||
<h3>三、内测使用意向调研</h3>
|
||||
<div className="beta-admin-field-grid">
|
||||
<DetailField label="每周稳定使用次数" value={valueOrEmpty(selectedApplication.weeklyUsage)} />
|
||||
<DetailField label="反馈意愿" value={valueOrEmpty(selectedApplication.feedbackWilling)} />
|
||||
<DetailField label="最想体验功能" value={joinValues(selectedApplication.wantFeature)} wide />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="beta-admin-form-card">
|
||||
<h3>四、申请自述与确认</h3>
|
||||
<p className="beta-admin-statement">{selectedApplication.selfStatement || "未填写"}</p>
|
||||
<div className="beta-admin-field-grid">
|
||||
<DetailField label="申请人确认签字" value={valueOrEmpty(selectedApplication.signature)} />
|
||||
<DetailField label="同意规则" value={selectedApplication.agreeRules ? "已同意" : "未同意"} />
|
||||
<DetailField label="IP" value={valueOrEmpty(selectedApplication.ipAddress)} />
|
||||
<DetailField label="客户端" value={valueOrEmpty(selectedApplication.userAgent)} wide />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{selectedApplication.status !== "pending" ? (
|
||||
<section className="beta-admin-form-card">
|
||||
<h3>审核结果</h3>
|
||||
<div className="beta-admin-field-grid">
|
||||
<DetailField label="审核人" value={selectedApplication.reviewerUsername || `UID ${selectedApplication.reviewedBy ?? "-"}`} />
|
||||
<DetailField label="审核时间" value={formatDate(selectedApplication.reviewedAt)} />
|
||||
<DetailField label="审核备注" value={valueOrEmpty(selectedApplication.reviewNote)} wide />
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="beta-admin-review-box">
|
||||
<label>
|
||||
<span>审核备注</span>
|
||||
<textarea
|
||||
value={reviewNote}
|
||||
onChange={(event) => setReviewNote(event.target.value)}
|
||||
placeholder="填写通过说明或驳回原因;驳回时该备注会作为用户通知内容。"
|
||||
/>
|
||||
</label>
|
||||
<div className="beta-admin-actions">
|
||||
<button type="button" disabled={submitting} onClick={() => void handleDecision("reject")}>
|
||||
<CloseCircleOutlined />
|
||||
驳回并通知
|
||||
</button>
|
||||
<button type="button" disabled={submitting} onClick={() => void handleDecision("approve")}>
|
||||
<CheckCircleOutlined />
|
||||
通过并发放内测码
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</article>
|
||||
) : (
|
||||
<div className="beta-admin-detail beta-admin-detail--empty">选择左侧申请查看表单详情</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</WorkspacePageShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user