Codex/generation task reliability #25
+12
@@ -8,6 +8,7 @@ import ToastContainer from "./components/toast/ToastContainer";
|
|||||||
import { toast } from "./components/toast/toastStore";
|
import { toast } from "./components/toast/toastStore";
|
||||||
import { aiGenerationClient } from "./api/aiGenerationClient";
|
import { aiGenerationClient } from "./api/aiGenerationClient";
|
||||||
import { keyServerClient } from "./api/keyServerClient";
|
import { keyServerClient } from "./api/keyServerClient";
|
||||||
|
import { setUserMaxConcurrency } from "./api/generationConcurrency";
|
||||||
import { notificationClient } from "./api/notificationClient";
|
import { notificationClient } from "./api/notificationClient";
|
||||||
import {
|
import {
|
||||||
SERVER_SESSION_REPLACED_EVENT,
|
SERVER_SESSION_REPLACED_EVENT,
|
||||||
@@ -32,6 +33,7 @@ const CharacterMixPage = lazy(() => import("./features/character-mix/CharacterMi
|
|||||||
const CommunityPage = lazy(() => import("./features/community/CommunityPage"));
|
const CommunityPage = lazy(() => import("./features/community/CommunityPage"));
|
||||||
const CommunityCaseAddPage = lazy(() => import("./features/community-review/CommunityCaseAddPage"));
|
const CommunityCaseAddPage = lazy(() => import("./features/community-review/CommunityCaseAddPage"));
|
||||||
const CommunityReviewPage = lazy(() => import("./features/community-review/CommunityReviewPage"));
|
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 AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarConsolePage"));
|
||||||
const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage"));
|
const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage"));
|
||||||
const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage"));
|
const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage"));
|
||||||
@@ -108,6 +110,7 @@ const VIEW_KEYS = new Set<WebViewKey>([
|
|||||||
"more",
|
"more",
|
||||||
"communityReview",
|
"communityReview",
|
||||||
"communityCaseAdd",
|
"communityCaseAdd",
|
||||||
|
"betaApplications",
|
||||||
"report",
|
"report",
|
||||||
"providerHealth",
|
"providerHealth",
|
||||||
"userAgreement",
|
"userAgreement",
|
||||||
@@ -123,6 +126,7 @@ const LEGACY_PAGE_STYLE_VIEWS = new Set<WebViewKey>([
|
|||||||
"community",
|
"community",
|
||||||
"communityReview",
|
"communityReview",
|
||||||
"communityCaseAdd",
|
"communityCaseAdd",
|
||||||
|
"betaApplications",
|
||||||
"assets",
|
"assets",
|
||||||
"ecommerce",
|
"ecommerce",
|
||||||
"ecommerceHub",
|
"ecommerceHub",
|
||||||
@@ -156,6 +160,8 @@ function normalizeViewKey(rawView: string): WebViewKey {
|
|||||||
? "communityReview"
|
? "communityReview"
|
||||||
: rawView === "community-case-add"
|
: rawView === "community-case-add"
|
||||||
? "communityCaseAdd"
|
? "communityCaseAdd"
|
||||||
|
: rawView === "beta-applications" || rawView === "beta-application-review"
|
||||||
|
? "betaApplications"
|
||||||
: rawView;
|
: rawView;
|
||||||
return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "not-found";
|
return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "not-found";
|
||||||
}
|
}
|
||||||
@@ -466,6 +472,7 @@ function App() {
|
|||||||
const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => {
|
const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => {
|
||||||
clearAllUserStorage();
|
clearAllUserStorage();
|
||||||
clearSessionState();
|
clearSessionState();
|
||||||
|
setUserMaxConcurrency(null);
|
||||||
setProjects([]);
|
setProjects([]);
|
||||||
setProjectsLoaded(true);
|
setProjectsLoaded(true);
|
||||||
setUsage(emptyUsageSummary);
|
setUsage(emptyUsageSummary);
|
||||||
@@ -574,6 +581,7 @@ function App() {
|
|||||||
const nextSession = await keyServerClient.getCurrentSession();
|
const nextSession = await keyServerClient.getCurrentSession();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setSession(nextSession);
|
setSession(nextSession);
|
||||||
|
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
|
||||||
await hydrateAccountData(nextSession);
|
await hydrateAccountData(nextSession);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -606,6 +614,7 @@ function App() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (nextSession) {
|
if (nextSession) {
|
||||||
setSession(nextSession);
|
setSession(nextSession);
|
||||||
|
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
|
||||||
} else {
|
} else {
|
||||||
clearAuthenticatedState({ resetView: true });
|
clearAuthenticatedState({ resetView: true });
|
||||||
}
|
}
|
||||||
@@ -943,6 +952,7 @@ function App() {
|
|||||||
async (nextSession: WebUserSession) => {
|
async (nextSession: WebUserSession) => {
|
||||||
hideSessionReplaced();
|
hideSessionReplaced();
|
||||||
setSession(nextSession);
|
setSession(nextSession);
|
||||||
|
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
|
||||||
await hydrateAccountData(nextSession);
|
await hydrateAccountData(nextSession);
|
||||||
|
|
||||||
if (nextSession.user.email && !nextSession.user.emailVerified) {
|
if (nextSession.user.email && !nextSession.user.emailVerified) {
|
||||||
@@ -1298,6 +1308,8 @@ function App() {
|
|||||||
onOpenReview={() => handleSetView("communityReview")}
|
onOpenReview={() => handleSetView("communityReview")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "betaApplications":
|
||||||
|
return <BetaApplicationsPage session={session} onOpenLogin={handleOpenLogin} />;
|
||||||
case "workbench":
|
case "workbench":
|
||||||
return (
|
return (
|
||||||
<WorkbenchPage
|
<WorkbenchPage
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { serverRequest } from "./serverConnection";
|
||||||
|
|
||||||
|
export interface BetaApplicationInput {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
wechat: string;
|
||||||
|
industry: string;
|
||||||
|
company: string;
|
||||||
|
city: string;
|
||||||
|
aiTools: string;
|
||||||
|
aiDuration: string;
|
||||||
|
aiTrack: string;
|
||||||
|
aiDirection: string[];
|
||||||
|
weeklyUsage: string;
|
||||||
|
feedbackWilling: string;
|
||||||
|
wantFeature: string[];
|
||||||
|
selfStatement: string;
|
||||||
|
signature: string;
|
||||||
|
agreeRules: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BetaApplicationStatus = "pending" | "approved" | "rejected";
|
||||||
|
|
||||||
|
export interface BetaApplicationItem extends BetaApplicationInput {
|
||||||
|
id: number;
|
||||||
|
userId: number | null;
|
||||||
|
username: string | null;
|
||||||
|
status: BetaApplicationStatus;
|
||||||
|
inviteCode: string | null;
|
||||||
|
reviewNote: string | null;
|
||||||
|
reviewedBy: number | null;
|
||||||
|
reviewerUsername: string | null;
|
||||||
|
reviewedAt: string | null;
|
||||||
|
ipAddress: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BetaApplicationSubmitResult {
|
||||||
|
id: number;
|
||||||
|
status: BetaApplicationStatus;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNullableString(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumberOrNull(value: unknown): number | null {
|
||||||
|
if (value === null || value === undefined || value === "") return null;
|
||||||
|
const next = Number(value);
|
||||||
|
return Number.isFinite(next) ? next : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStringArray(value: unknown): string[] {
|
||||||
|
return Array.isArray(value) ? value.filter((item): item is string => 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<string, unknown>) : {};
|
||||||
|
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<BetaApplicationSubmitResult> {
|
||||||
|
const payload = await serverRequest<{ application: BetaApplicationSubmitResult }>("beta-applications", {
|
||||||
|
method: "POST",
|
||||||
|
body: input,
|
||||||
|
maxRetries: 0,
|
||||||
|
fallbackMessage: "提交内测申请失败",
|
||||||
|
});
|
||||||
|
return payload.application;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listAdminApplications(status?: BetaApplicationStatus | ""): Promise<BetaApplicationItem[]> {
|
||||||
|
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<BetaApplicationItem> {
|
||||||
|
const payload = await serverRequest<{ application: unknown }>(`admin/beta-applications/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: { action, reviewNote },
|
||||||
|
maxRetries: 0,
|
||||||
|
fallbackMessage: "审核内测申请失败",
|
||||||
|
});
|
||||||
|
return normalizeApplication(payload.application);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -7,10 +7,20 @@ interface GenerationSlot {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_ACTIVE_GENERATION_TASKS = 3;
|
const DEFAULT_MAX_ACTIVE_GENERATION_TASKS = 3;
|
||||||
const STALE_SLOT_MS = 6 * 60 * 60 * 1000;
|
const STALE_SLOT_MS = 6 * 60 * 60 * 1000;
|
||||||
const activeSlots = new Map<string, GenerationSlot>();
|
const activeSlots = new Map<string, GenerationSlot>();
|
||||||
|
|
||||||
|
let userMaxConcurrency: number | null = null;
|
||||||
|
|
||||||
|
export function setUserMaxConcurrency(limit: number | null | undefined): void {
|
||||||
|
userMaxConcurrency = typeof limit === "number" && limit > 0 ? limit : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEffectiveLimit(): number {
|
||||||
|
return userMaxConcurrency ?? DEFAULT_MAX_ACTIVE_GENERATION_TASKS;
|
||||||
|
}
|
||||||
|
|
||||||
export function getGenerationUserKey(userId?: string | number | null): string {
|
export function getGenerationUserKey(userId?: string | number | null): string {
|
||||||
return userId === undefined || userId === null || userId === "" ? "anonymous" : String(userId);
|
return userId === undefined || userId === null || userId === "" ? "anonymous" : String(userId);
|
||||||
}
|
}
|
||||||
@@ -39,8 +49,9 @@ export function claimGenerationSlot(input: {
|
|||||||
}): () => void {
|
}): () => void {
|
||||||
pruneStaleSlots();
|
pruneStaleSlots();
|
||||||
const activeCount = getActiveGenerationTaskCount(input.userKey);
|
const activeCount = getActiveGenerationTaskCount(input.userKey);
|
||||||
if (activeCount >= MAX_ACTIVE_GENERATION_TASKS) {
|
const effectiveLimit = getEffectiveLimit();
|
||||||
throw new Error("当前账号同时最多生成 3 个图片/视频任务,请等待已有任务完成后再提交。");
|
if (activeCount >= effectiveLimit) {
|
||||||
|
throw new Error(`当前账号同时最多生成 ${effectiveLimit} 个图片/视频任务,请等待已有任务完成后再提交。`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = input.id || `generation-slot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
const id = input.id || `generation-slot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|||||||
@@ -434,6 +434,7 @@ function normalizeUser(raw: unknown): WebUserSession["user"] | null {
|
|||||||
candidate.enterpriseBalance ??
|
candidate.enterpriseBalance ??
|
||||||
candidate.enterprise_balance,
|
candidate.enterprise_balance,
|
||||||
),
|
),
|
||||||
|
maxConcurrency: toNumber(candidate.maxConcurrency ?? candidate.max_concurrency),
|
||||||
activePackages: toActivePackages(candidate.activePackages ?? candidate.active_packages),
|
activePackages: toActivePackages(candidate.activePackages ?? candidate.active_packages),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export function waitForTask(
|
|||||||
let settled = false;
|
let settled = false;
|
||||||
let cleanup: (() => void) | null = null;
|
let cleanup: (() => void) | null = null;
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
let sseConnected = false;
|
|
||||||
let fallbackTimerId: ReturnType<typeof setTimeout> | null = null;
|
let fallbackTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||||
let lastProgress = 0;
|
let lastProgress = 0;
|
||||||
let lastProgressAt = startedAt;
|
let lastProgressAt = startedAt;
|
||||||
@@ -83,10 +82,9 @@ export function waitForTask(
|
|||||||
};
|
};
|
||||||
|
|
||||||
cleanup = aiGenerationClient.subscribeTaskStatus(taskId, handleUpdate);
|
cleanup = aiGenerationClient.subscribeTaskStatus(taskId, handleUpdate);
|
||||||
sseConnected = true;
|
|
||||||
|
|
||||||
fallbackTimerId = setTimeout(() => {
|
fallbackTimerId = setTimeout(() => {
|
||||||
if (settled || !sseConnected) return;
|
if (settled) return;
|
||||||
if (cleanup) cleanup();
|
if (cleanup) cleanup();
|
||||||
startPolling();
|
startPolling();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ function formatBalance(cents: number): string {
|
|||||||
return `${value.toFixed(2)} 积分`;
|
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({
|
function AppShell({
|
||||||
activeView,
|
activeView,
|
||||||
navItems,
|
navItems,
|
||||||
@@ -249,6 +255,7 @@ function AppShell({
|
|||||||
const displayedBalanceLabel = session ? formatBalance(displayedBalanceCents) : "0 积分";
|
const displayedBalanceLabel = session ? formatBalance(displayedBalanceCents) : "0 积分";
|
||||||
const showCommunityReview = canReviewCommunity(session);
|
const showCommunityReview = canReviewCommunity(session);
|
||||||
const showCommunityCaseAdd = canManageCommunityCases(session);
|
const showCommunityCaseAdd = canManageCommunityCases(session);
|
||||||
|
const showBetaApplicationReview = canReviewBetaApplications(session);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -486,6 +493,19 @@ function AppShell({
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
{showBetaApplicationReview ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="profile-popover__review-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setProfileOpen(false);
|
||||||
|
onSelectView("betaApplications");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShellIcon name="check-circle" />
|
||||||
|
内测申请审核
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
{showCommunityCaseAdd ? (
|
{showCommunityCaseAdd ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CloseOutlined, ExperimentOutlined } from "@ant-design/icons";
|
import { CloseOutlined, ExperimentOutlined } from "@ant-design/icons";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { betaApplicationClient } from "../api/betaApplicationClient";
|
||||||
|
|
||||||
interface BetaApplicationModalProps {
|
interface BetaApplicationModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -140,16 +141,70 @@ function TextField({
|
|||||||
|
|
||||||
const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
|
const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
|
||||||
const [form, setForm] = useState<BetaFormData>(INITIAL_FORM);
|
const [form, setForm] = useState<BetaFormData>(INITIAL_FORM);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ tone: "success" | "error"; text: string } | null>(null);
|
||||||
|
|
||||||
const update = <K extends keyof BetaFormData>(key: K, value: BetaFormData[K]) => {
|
const update = <K extends keyof BetaFormData>(key: K, value: BetaFormData[K]) => {
|
||||||
setForm((prev) => ({ ...prev, [key]: value }));
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setMessage(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
if (submitting) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
if (!form.name.trim()) return "请填写姓名 / 常用昵称";
|
||||||
|
if (!form.phone.trim()) return "请填写联系手机号码";
|
||||||
|
if (!form.wechat.trim()) return "请填写微信账号";
|
||||||
|
if (!form.selfStatement.trim()) return "请填写申请自述";
|
||||||
|
if (!form.signature.trim()) return "请填写申请人确认签字";
|
||||||
|
if (!form.agreeRules) return "请先阅读并同意内测规则";
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (submitting) return;
|
||||||
|
const validationError = validate();
|
||||||
|
if (validationError) {
|
||||||
|
setMessage({ tone: "error", text: validationError });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
await betaApplicationClient.submit({
|
||||||
|
...form,
|
||||||
|
name: form.name.trim(),
|
||||||
|
phone: form.phone.trim(),
|
||||||
|
wechat: form.wechat.trim(),
|
||||||
|
industry: form.industry.trim(),
|
||||||
|
company: form.company.trim(),
|
||||||
|
city: form.city.trim(),
|
||||||
|
aiTools: form.aiTools.trim(),
|
||||||
|
aiDuration: form.aiDuration.trim(),
|
||||||
|
aiTrack: form.aiTrack.trim(),
|
||||||
|
weeklyUsage: form.weeklyUsage.trim(),
|
||||||
|
feedbackWilling: form.feedbackWilling.trim(),
|
||||||
|
selfStatement: form.selfStatement.trim(),
|
||||||
|
signature: form.signature.trim(),
|
||||||
|
});
|
||||||
|
setForm(INITIAL_FORM);
|
||||||
|
setMessage({ tone: "success", text: "申请已提交,请留意站内通知。" });
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({ tone: "error", text: error instanceof Error ? error.message : "提交内测申请失败" });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="beta-application-modal" role="dialog" aria-modal="true" aria-labelledby="beta-modal-title">
|
<div className="beta-application-modal" role="dialog" aria-modal="true" aria-labelledby="beta-modal-title">
|
||||||
<button type="button" className="beta-application-modal__backdrop" onClick={onClose} aria-label="关闭内测申请弹窗" />
|
<button type="button" className="beta-application-modal__backdrop" onClick={close} aria-label="关闭内测申请弹窗" />
|
||||||
|
|
||||||
<section className="beta-application-modal__panel">
|
<section className="beta-application-modal__panel">
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
@@ -158,10 +213,10 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
|
|||||||
<ExperimentOutlined className="beta-modal-header__icon" />
|
<ExperimentOutlined className="beta-modal-header__icon" />
|
||||||
<div>
|
<div>
|
||||||
<h2 id="beta-modal-title">OmniAI 内测体验官申请表</h2>
|
<h2 id="beta-modal-title">OmniAI 内测体验官申请表</h2>
|
||||||
<p className="beta-modal-header__subtitle">封闭限量内测 · 仅限 <strong>30 人</strong> · 赠送 <strong>500 元</strong> 通用调用积分</p>
|
<p className="beta-modal-header__subtitle">封闭限量内测 · 仅限 <strong>30 人</strong> · 赠送 <strong>500 元等值 50,000 积分</strong></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="beta-modal-header__close" onClick={onClose} aria-label="关闭">
|
<button type="button" className="beta-modal-header__close" onClick={close} aria-label="关闭" disabled={submitting}>
|
||||||
<CloseOutlined />
|
<CloseOutlined />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
@@ -239,7 +294,7 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
|
|||||||
<h3 className="beta-doc-section__title">五、内测规则知情同意书</h3>
|
<h3 className="beta-doc-section__title">五、内测规则知情同意书</h3>
|
||||||
<ol className="beta-rules-list">
|
<ol className="beta-rules-list">
|
||||||
<li>本次为封闭限量内测,仅限 <strong>30 人</strong>,按照资质匹配度 + 申请顺序筛选;</li>
|
<li>本次为封闭限量内测,仅限 <strong>30 人</strong>,按照资质匹配度 + 申请顺序筛选;</li>
|
||||||
<li>内测赠送 <strong>500 元</strong> 通用调用积分,仅限内测期间使用,不可提现、不可转让、不可兑换现金;</li>
|
<li>内测赠送 <strong>500 元等值 50,000 积分</strong>,仅限内测期间使用,不可提现、不可转让、不可兑换现金;</li>
|
||||||
<li>内测版本含未上线测试功能,存在功能不稳定、界面调整、参数优化等情况,申请人自愿理解包容;</li>
|
<li>内测版本含未上线测试功能,存在功能不稳定、界面调整、参数优化等情况,申请人自愿理解包容;</li>
|
||||||
<li>严禁私自泄露内测专属工作流、内部功能、测试接口、未发布技术方案等内部资料;</li>
|
<li>严禁私自泄露内测专属工作流、内部功能、测试接口、未发布技术方案等内部资料;</li>
|
||||||
<li>审核通过后,官方将在 <strong>48 小时</strong> 内发放内测账号、登录权限及免费积分;</li>
|
<li>审核通过后,官方将在 <strong>48 小时</strong> 内发放内测账号、登录权限及免费积分;</li>
|
||||||
@@ -268,11 +323,16 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
|
|||||||
|
|
||||||
{/* ── Footer ── */}
|
{/* ── Footer ── */}
|
||||||
<footer className="beta-modal-footer">
|
<footer className="beta-modal-footer">
|
||||||
<button type="button" className="beta-modal-footer__btn beta-modal-footer__btn--secondary" onClick={onClose}>
|
{message ? (
|
||||||
|
<p className={`beta-modal-footer__message beta-modal-footer__message--${message.tone}`} role="status">
|
||||||
|
{message.text}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<button type="button" className="beta-modal-footer__btn beta-modal-footer__btn--secondary" onClick={close} disabled={submitting}>
|
||||||
关闭
|
关闭
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="beta-modal-footer__btn beta-modal-footer__btn--primary" onClick={onClose}>
|
<button type="button" className="beta-modal-footer__btn beta-modal-footer__btn--primary" onClick={() => void submit()} disabled={submitting}>
|
||||||
提交申请
|
{submitting ? "提交中..." : "提交申请"}
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const membershipPlans: MembershipPlan[] = [
|
|||||||
subtitle: "Pro",
|
subtitle: "Pro",
|
||||||
period: "月付",
|
period: "月付",
|
||||||
price: "299 元 / 月",
|
price: "299 元 / 月",
|
||||||
grant: "每月赠送 10000 积分,30 天有效",
|
grant: "每月赠送 29900 积分,30 天有效",
|
||||||
comparisonLabel: "专业版基础权益",
|
comparisonLabel: "专业版基础权益",
|
||||||
icon: <CrownOutlined />,
|
icon: <CrownOutlined />,
|
||||||
benefits: ["通用大模型全解锁", "积分与 API 消耗 9 折", "并发提升到 3 个", "去水印、插队加速、专属客服"],
|
benefits: ["通用大模型全解锁", "积分与 API 消耗 9 折", "并发提升到 3 个", "去水印、插队加速、专属客服"],
|
||||||
@@ -41,7 +41,7 @@ const membershipPlans: MembershipPlan[] = [
|
|||||||
subtitle: "Pro",
|
subtitle: "Pro",
|
||||||
period: "季付",
|
period: "季付",
|
||||||
price: "897 元 / 季",
|
price: "897 元 / 季",
|
||||||
grant: "连续 3 个月按月发放 Pro 积分",
|
grant: "季度合计 89700 积分,默认按月分摊",
|
||||||
comparisonLabel: "相比月付新增",
|
comparisonLabel: "相比月付新增",
|
||||||
badge: "季度",
|
badge: "季度",
|
||||||
icon: <CrownOutlined />,
|
icon: <CrownOutlined />,
|
||||||
@@ -54,7 +54,7 @@ const membershipPlans: MembershipPlan[] = [
|
|||||||
subtitle: "Pro",
|
subtitle: "Pro",
|
||||||
period: "年付",
|
period: "年付",
|
||||||
price: "1990 元 / 年",
|
price: "1990 元 / 年",
|
||||||
grant: "全年合计 140000 积分,默认按月分摊",
|
grant: "全年合计 199000 积分,默认按月分摊",
|
||||||
comparisonLabel: "相比季付新增",
|
comparisonLabel: "相比季付新增",
|
||||||
badge: "年费优惠",
|
badge: "年费优惠",
|
||||||
icon: <CrownOutlined />,
|
icon: <CrownOutlined />,
|
||||||
@@ -67,7 +67,7 @@ const membershipPlans: MembershipPlan[] = [
|
|||||||
subtitle: "Enterprise",
|
subtitle: "Enterprise",
|
||||||
period: "月付",
|
period: "月付",
|
||||||
price: "499 元 / 月",
|
price: "499 元 / 月",
|
||||||
grant: "每月赠送 2000 积分,30 天有效",
|
grant: "每月赠送 49900 积分,30 天有效",
|
||||||
comparisonLabel: "企业版基础权益",
|
comparisonLabel: "企业版基础权益",
|
||||||
icon: <RocketOutlined />,
|
icon: <RocketOutlined />,
|
||||||
benefits: ["企业私有模型与高性能模型", "默认 10 并发,可申请提升", "积分与 API 消耗 8 折", "用量报表与正式 API 权限"],
|
benefits: ["企业私有模型与高性能模型", "默认 10 并发,可申请提升", "积分与 API 消耗 8 折", "用量报表与正式 API 权限"],
|
||||||
@@ -79,7 +79,7 @@ const membershipPlans: MembershipPlan[] = [
|
|||||||
subtitle: "Enterprise",
|
subtitle: "Enterprise",
|
||||||
period: "季付",
|
period: "季付",
|
||||||
price: "1497 元 / 季",
|
price: "1497 元 / 季",
|
||||||
grant: "连续 3 个月按月发放企业版积分",
|
grant: "季度合计 149700 积分,默认按月分摊",
|
||||||
comparisonLabel: "相比月付新增",
|
comparisonLabel: "相比月付新增",
|
||||||
badge: "季度",
|
badge: "季度",
|
||||||
icon: <RocketOutlined />,
|
icon: <RocketOutlined />,
|
||||||
@@ -92,7 +92,7 @@ const membershipPlans: MembershipPlan[] = [
|
|||||||
subtitle: "Enterprise",
|
subtitle: "Enterprise",
|
||||||
period: "年付",
|
period: "年付",
|
||||||
price: "4990 元 / 年",
|
price: "4990 元 / 年",
|
||||||
grant: "全年合计 340000 积分,默认按月分摊",
|
grant: "全年合计 499000 积分,默认按月分摊",
|
||||||
comparisonLabel: "相比季付新增",
|
comparisonLabel: "相比季付新增",
|
||||||
badge: "企业年费",
|
badge: "企业年费",
|
||||||
icon: <RocketOutlined />,
|
icon: <RocketOutlined />,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -107,6 +107,12 @@
|
|||||||
color: #1e1e1e;
|
color: #1e1e1e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.beta-modal-header__close:disabled,
|
||||||
|
.beta-modal-footer__btn:disabled {
|
||||||
|
opacity: 0.58;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Scrollable body ── */
|
/* ── Scrollable body ── */
|
||||||
.beta-modal-body {
|
.beta-modal-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -412,6 +418,22 @@
|
|||||||
background: #f5f1ea;
|
background: #f5f1ea;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.beta-modal-footer__message {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-modal-footer__message--success {
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-modal-footer__message--error {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
.beta-modal-footer__btn {
|
.beta-modal-footer__btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
.beta-admin-page__inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
width: min(1180px, calc(100vw - 48px));
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-toolbar span {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-toolbar h1 {
|
||||||
|
margin: 4px 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-toolbar p {
|
||||||
|
max-width: 620px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-toolbar button,
|
||||||
|
.beta-admin-status-tabs button,
|
||||||
|
.beta-admin-actions button,
|
||||||
|
.beta-admin-access button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-toolbar button {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-toolbar button:disabled,
|
||||||
|
.beta-admin-actions button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-status-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-status-tabs button {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-status-tabs button.is-active {
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.45);
|
||||||
|
background: rgba(var(--accent-rgb), 0.14);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-error {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--error, #ef4444);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 300px minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: calc(100vh - 220px);
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-list__item {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-list__item.is-active {
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.52);
|
||||||
|
background: rgba(var(--accent-rgb), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-list__item strong {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 14px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-list__item small {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-list__empty,
|
||||||
|
.beta-admin-detail--empty {
|
||||||
|
display: grid;
|
||||||
|
min-height: 180px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px dashed var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-status {
|
||||||
|
width: fit-content;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-status--pending {
|
||||||
|
background: rgba(245, 158, 11, 0.16);
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-status--approved {
|
||||||
|
background: rgba(16, 185, 129, 0.16);
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-status--rejected {
|
||||||
|
background: rgba(239, 68, 68, 0.16);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-detail__header,
|
||||||
|
.beta-admin-form-card,
|
||||||
|
.beta-admin-review-box {
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-detail__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-detail__header span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-detail__header h2 {
|
||||||
|
margin: 5px 0 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-detail__header p {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-code {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid rgba(var(--accent-rgb), 0.35);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(var(--accent-rgb), 0.12);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-form-card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-form-card h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-field-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-field {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-field--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-field span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-field strong {
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-statement {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.8;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-review-box {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-review-box label {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-review-box label span {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-review-box textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 92px;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
outline: none;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-actions button {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-actions button:first-child {
|
||||||
|
border-color: rgba(239, 68, 68, 0.35);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-actions button:last-child {
|
||||||
|
border-color: rgba(16, 185, 129, 0.35);
|
||||||
|
background: rgba(16, 185, 129, 0.14);
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-access {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 420px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-access svg {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-access h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-access p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-access button {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.38);
|
||||||
|
background: rgba(var(--accent-rgb), 0.14);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.beta-admin-page__inner {
|
||||||
|
width: min(100%, calc(100vw - 24px));
|
||||||
|
padding: 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-toolbar,
|
||||||
|
.beta-admin-detail__header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-list {
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.beta-admin-field-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ export type WebViewKey =
|
|||||||
| "dialogGenerator"
|
| "dialogGenerator"
|
||||||
| "communityReview"
|
| "communityReview"
|
||||||
| "communityCaseAdd"
|
| "communityCaseAdd"
|
||||||
|
| "betaApplications"
|
||||||
| "report"
|
| "report"
|
||||||
| "providerHealth"
|
| "providerHealth"
|
||||||
| "userAgreement"
|
| "userAgreement"
|
||||||
@@ -73,6 +74,7 @@ export interface WebUserSession extends WebApiResultMeta {
|
|||||||
enterpriseAdminUserId?: number | string | null;
|
enterpriseAdminUserId?: number | string | null;
|
||||||
balanceCents?: number;
|
balanceCents?: number;
|
||||||
enterpriseBalanceCents?: number;
|
enterpriseBalanceCents?: number;
|
||||||
|
maxConcurrency?: number;
|
||||||
activePackages?: Array<{
|
activePackages?: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user