diff --git a/src/App.tsx b/src/App.tsx index 9e13d34..bf77b6d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,7 @@ const CharacterMixPage = lazy(() => import("./features/character-mix/CharacterMi const CommunityPage = lazy(() => import("./features/community/CommunityPage")); const CommunityCaseAddPage = lazy(() => import("./features/community-review/CommunityCaseAddPage")); const CommunityReviewPage = lazy(() => import("./features/community-review/CommunityReviewPage")); +const BetaApplicationsPage = lazy(() => import("./features/beta-applications/BetaApplicationsPage")); const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarConsolePage")); const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage")); const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage")); @@ -109,6 +110,7 @@ const VIEW_KEYS = new Set([ "more", "communityReview", "communityCaseAdd", + "betaApplications", "report", "providerHealth", "userAgreement", @@ -124,6 +126,7 @@ const LEGACY_PAGE_STYLE_VIEWS = new Set([ "community", "communityReview", "communityCaseAdd", + "betaApplications", "assets", "ecommerce", "ecommerceHub", @@ -157,6 +160,8 @@ function normalizeViewKey(rawView: string): WebViewKey { ? "communityReview" : rawView === "community-case-add" ? "communityCaseAdd" + : rawView === "beta-applications" || rawView === "beta-application-review" + ? "betaApplications" : rawView; return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "not-found"; } @@ -1303,6 +1308,8 @@ function App() { onOpenReview={() => handleSetView("communityReview")} /> ); + case "betaApplications": + return ; case "workbench": return ( typeof item === "string") : []; +} + +function normalizeStatus(value: unknown): BetaApplicationStatus { + return value === "approved" || value === "rejected" ? value : "pending"; +} + +function normalizeApplication(raw: unknown): BetaApplicationItem { + const item = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : {}; + return { + id: Number(item.id) || 0, + userId: readNumberOrNull(item.userId), + username: readNullableString(item.username), + name: readString(item.name), + phone: readString(item.phone), + wechat: readString(item.wechat), + industry: readString(item.industry), + company: readString(item.company), + city: readString(item.city), + aiTools: readString(item.aiTools), + aiDuration: readString(item.aiDuration), + aiTrack: readString(item.aiTrack), + aiDirection: readStringArray(item.aiDirection), + weeklyUsage: readString(item.weeklyUsage), + feedbackWilling: readString(item.feedbackWilling), + wantFeature: readStringArray(item.wantFeature), + selfStatement: readString(item.selfStatement), + signature: readString(item.signature), + agreeRules: item.agreeRules === true, + status: normalizeStatus(item.status), + inviteCode: readNullableString(item.inviteCode), + reviewNote: readNullableString(item.reviewNote), + reviewedBy: readNumberOrNull(item.reviewedBy), + reviewerUsername: readNullableString(item.reviewerUsername), + reviewedAt: readNullableString(item.reviewedAt), + ipAddress: readNullableString(item.ipAddress), + userAgent: readNullableString(item.userAgent), + createdAt: readString(item.createdAt), + updatedAt: readString(item.updatedAt), + }; +} + +export const betaApplicationClient = { + async submit(input: BetaApplicationInput): Promise { + const payload = await serverRequest<{ application: BetaApplicationSubmitResult }>("beta-applications", { + method: "POST", + body: input, + maxRetries: 0, + fallbackMessage: "提交内测申请失败", + }); + return payload.application; + }, + + async listAdminApplications(status?: BetaApplicationStatus | ""): Promise { + const query = status ? `?status=${encodeURIComponent(status)}` : ""; + const payload = await serverRequest<{ applications?: unknown[] }>(`admin/beta-applications${query}`, { + fallbackMessage: "读取内测申请失败", + }); + return Array.isArray(payload.applications) ? payload.applications.map(normalizeApplication) : []; + }, + + async reviewApplication( + id: number, + action: "approve" | "reject", + reviewNote?: string, + ): Promise { + const payload = await serverRequest<{ application: unknown }>(`admin/beta-applications/${id}`, { + method: "PATCH", + body: { action, reviewNote }, + maxRetries: 0, + fallbackMessage: "审核内测申请失败", + }); + return normalizeApplication(payload.application); + }, +}; diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index bc32b75..a130343 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -64,6 +64,12 @@ function formatBalance(cents: number): string { return `${value.toFixed(2)} 积分`; } +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 AppShell({ activeView, navItems, @@ -249,6 +255,7 @@ function AppShell({ const displayedBalanceLabel = session ? formatBalance(displayedBalanceCents) : "0 积分"; const showCommunityReview = canReviewCommunity(session); const showCommunityCaseAdd = canManageCommunityCases(session); + const showBetaApplicationReview = canReviewBetaApplications(session); return (
) : null} + {showBetaApplicationReview ? ( + + ) : null} {showCommunityCaseAdd ? ( <>
- @@ -268,11 +323,16 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => { {/* ── Footer ── */}
- -
diff --git a/src/features/beta-applications/BetaApplicationsPage.tsx b/src/features/beta-applications/BetaApplicationsPage.tsx new file mode 100644 index 0000000..c6a0cf2 --- /dev/null +++ b/src/features/beta-applications/BetaApplicationsPage.tsx @@ -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 = { + 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 ( +
+ {label} + {value} +
+ ); +} + +export default function BetaApplicationsPage({ session, onOpenLogin }: BetaApplicationsPageProps) { + const allowed = canReviewBetaApplications(session); + const [status, setStatus] = useState("pending"); + const [applications, setApplications] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [reviewNote, setReviewNote] = useState(""); + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + +
+ +

请登录审核账号

+

内测申请审核仅开放给管理员和 xqy1912。

+ +
+
+ ); + } + + if (!allowed) { + return ( + +
+ +

当前账号没有审核权限

+

请切换到 admin 或 xqy1912 后再进入内测审核台。

+
+
+ ); + } + + return ( + +
+
+
+ 内部审核台 +

内测申请表

+

查看用户提交的完整申请资料,通过后发放内测码,驳回后向用户发送未通过通知。

+
+ +
+ +
+ {STATUS_OPTIONS.map((option) => ( + + ))} +
+ + {error ?

{error}

: null} + +
+ + + {selectedApplication ? ( +
+
+
+ {STATUS_LABEL[selectedApplication.status]} +

{selectedApplication.name || "未填写姓名"}

+

{selectedApplication.selfStatement || "申请人未填写自述。"}

+
+ {selectedApplication.inviteCode ? ( + 内测码:{selectedApplication.inviteCode} + ) : null} +
+ +
+

一、个人基础信息

+
+ + + + + + + + +
+
+ +
+

二、AI 从业与使用经历

+
+ + + + +
+
+ +
+

三、内测使用意向调研

+
+ + + +
+
+ +
+

四、申请自述与确认

+

{selectedApplication.selfStatement || "未填写"}

+
+ + + + +
+
+ + {selectedApplication.status !== "pending" ? ( +
+

审核结果

+
+ + + +
+
+ ) : ( +
+