diff --git a/src/App.tsx b/src/App.tsx index dcb03c7..bf77b6d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import ToastContainer from "./components/toast/ToastContainer"; import { toast } from "./components/toast/toastStore"; import { aiGenerationClient } from "./api/aiGenerationClient"; import { keyServerClient } from "./api/keyServerClient"; +import { setUserMaxConcurrency } from "./api/generationConcurrency"; import { notificationClient } from "./api/notificationClient"; import { SERVER_SESSION_REPLACED_EVENT, @@ -32,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")); @@ -108,6 +110,7 @@ const VIEW_KEYS = new Set([ "more", "communityReview", "communityCaseAdd", + "betaApplications", "report", "providerHealth", "userAgreement", @@ -123,6 +126,7 @@ const LEGACY_PAGE_STYLE_VIEWS = new Set([ "community", "communityReview", "communityCaseAdd", + "betaApplications", "assets", "ecommerce", "ecommerceHub", @@ -156,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"; } @@ -466,6 +472,7 @@ function App() { const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => { clearAllUserStorage(); clearSessionState(); + setUserMaxConcurrency(null); setProjects([]); setProjectsLoaded(true); setUsage(emptyUsageSummary); @@ -574,6 +581,7 @@ function App() { const nextSession = await keyServerClient.getCurrentSession(); if (cancelled) return; setSession(nextSession); + setUserMaxConcurrency(nextSession?.user?.maxConcurrency); await hydrateAccountData(nextSession); }; @@ -606,6 +614,7 @@ function App() { if (cancelled) return; if (nextSession) { setSession(nextSession); + setUserMaxConcurrency(nextSession?.user?.maxConcurrency); } else { clearAuthenticatedState({ resetView: true }); } @@ -943,6 +952,7 @@ function App() { async (nextSession: WebUserSession) => { hideSessionReplaced(); setSession(nextSession); + setUserMaxConcurrency(nextSession?.user?.maxConcurrency); await hydrateAccountData(nextSession); if (nextSession.user.email && !nextSession.user.emailVerified) { @@ -1298,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/api/generationConcurrency.ts b/src/api/generationConcurrency.ts index ed9df37..a4e3b56 100644 --- a/src/api/generationConcurrency.ts +++ b/src/api/generationConcurrency.ts @@ -7,10 +7,20 @@ interface GenerationSlot { createdAt: number; } -const MAX_ACTIVE_GENERATION_TASKS = 3; +const DEFAULT_MAX_ACTIVE_GENERATION_TASKS = 3; const STALE_SLOT_MS = 6 * 60 * 60 * 1000; const activeSlots = new Map(); +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 { return userId === undefined || userId === null || userId === "" ? "anonymous" : String(userId); } @@ -39,8 +49,9 @@ export function claimGenerationSlot(input: { }): () => void { pruneStaleSlots(); const activeCount = getActiveGenerationTaskCount(input.userKey); - if (activeCount >= MAX_ACTIVE_GENERATION_TASKS) { - throw new Error("当前账号同时最多生成 3 个图片/视频任务,请等待已有任务完成后再提交。"); + const effectiveLimit = getEffectiveLimit(); + if (activeCount >= effectiveLimit) { + throw new Error(`当前账号同时最多生成 ${effectiveLimit} 个图片/视频任务,请等待已有任务完成后再提交。`); } const id = input.id || `generation-slot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; diff --git a/src/api/keyServerClient.ts b/src/api/keyServerClient.ts index c7fd90d..d86109a 100644 --- a/src/api/keyServerClient.ts +++ b/src/api/keyServerClient.ts @@ -434,6 +434,7 @@ function normalizeUser(raw: unknown): WebUserSession["user"] | null { candidate.enterpriseBalance ?? candidate.enterprise_balance, ), + maxConcurrency: toNumber(candidate.maxConcurrency ?? candidate.max_concurrency), activePackages: toActivePackages(candidate.activePackages ?? candidate.active_packages), }; } diff --git a/src/api/taskSubscription.ts b/src/api/taskSubscription.ts index ae702ec..3084581 100644 --- a/src/api/taskSubscription.ts +++ b/src/api/taskSubscription.ts @@ -44,7 +44,6 @@ export function waitForTask( let settled = false; let cleanup: (() => void) | null = null; let timeoutId: ReturnType | null = null; - let sseConnected = false; let fallbackTimerId: ReturnType | null = null; let lastProgress = 0; let lastProgressAt = startedAt; @@ -83,10 +82,9 @@ export function waitForTask( }; cleanup = aiGenerationClient.subscribeTaskStatus(taskId, handleUpdate); - sseConnected = true; fallbackTimerId = setTimeout(() => { - if (settled || !sseConnected) return; + if (settled) return; if (cleanup) cleanup(); startPolling(); }, 5000); 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 ? ( <>
- @@ -239,7 +294,7 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {

五、内测规则知情同意书

  1. 本次为封闭限量内测,仅限 30 人,按照资质匹配度 + 申请顺序筛选;
  2. -
  3. 内测赠送 500 元 通用调用积分,仅限内测期间使用,不可提现、不可转让、不可兑换现金;
  4. +
  5. 内测赠送 500 元等值 50,000 积分,仅限内测期间使用,不可提现、不可转让、不可兑换现金;
  6. 内测版本含未上线测试功能,存在功能不稳定、界面调整、参数优化等情况,申请人自愿理解包容;
  7. 严禁私自泄露内测专属工作流、内部功能、测试接口、未发布技术方案等内部资料;
  8. 审核通过后,官方将在 48 小时 内发放内测账号、登录权限及免费积分;
  9. @@ -268,11 +323,16 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => { {/* ── Footer ── */}
    - -
    diff --git a/src/components/RechargeModal/RechargeModal.tsx b/src/components/RechargeModal/RechargeModal.tsx index 49e01da..5cd0242 100644 --- a/src/components/RechargeModal/RechargeModal.tsx +++ b/src/components/RechargeModal/RechargeModal.tsx @@ -29,7 +29,7 @@ const membershipPlans: MembershipPlan[] = [ subtitle: "Pro", period: "月付", price: "299 元 / 月", - grant: "每月赠送 10000 积分,30 天有效", + grant: "每月赠送 29900 积分,30 天有效", comparisonLabel: "专业版基础权益", icon: , benefits: ["通用大模型全解锁", "积分与 API 消耗 9 折", "并发提升到 3 个", "去水印、插队加速、专属客服"], @@ -41,7 +41,7 @@ const membershipPlans: MembershipPlan[] = [ subtitle: "Pro", period: "季付", price: "897 元 / 季", - grant: "连续 3 个月按月发放 Pro 积分", + grant: "季度合计 89700 积分,默认按月分摊", comparisonLabel: "相比月付新增", badge: "季度", icon: , @@ -54,7 +54,7 @@ const membershipPlans: MembershipPlan[] = [ subtitle: "Pro", period: "年付", price: "1990 元 / 年", - grant: "全年合计 140000 积分,默认按月分摊", + grant: "全年合计 199000 积分,默认按月分摊", comparisonLabel: "相比季付新增", badge: "年费优惠", icon: , @@ -67,7 +67,7 @@ const membershipPlans: MembershipPlan[] = [ subtitle: "Enterprise", period: "月付", price: "499 元 / 月", - grant: "每月赠送 2000 积分,30 天有效", + grant: "每月赠送 49900 积分,30 天有效", comparisonLabel: "企业版基础权益", icon: , benefits: ["企业私有模型与高性能模型", "默认 10 并发,可申请提升", "积分与 API 消耗 8 折", "用量报表与正式 API 权限"], @@ -79,7 +79,7 @@ const membershipPlans: MembershipPlan[] = [ subtitle: "Enterprise", period: "季付", price: "1497 元 / 季", - grant: "连续 3 个月按月发放企业版积分", + grant: "季度合计 149700 积分,默认按月分摊", comparisonLabel: "相比月付新增", badge: "季度", icon: , @@ -92,7 +92,7 @@ const membershipPlans: MembershipPlan[] = [ subtitle: "Enterprise", period: "年付", price: "4990 元 / 年", - grant: "全年合计 340000 积分,默认按月分摊", + grant: "全年合计 499000 积分,默认按月分摊", comparisonLabel: "相比季付新增", badge: "企业年费", icon: , 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" ? ( +
    +

    审核结果

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