diff --git a/src/api/betaApplicationClient.ts b/src/api/betaApplicationClient.ts index f0ad467..39921b2 100644 --- a/src/api/betaApplicationClient.ts +++ b/src/api/betaApplicationClient.ts @@ -2,6 +2,7 @@ import { serverRequest } from "./serverConnection"; export interface BetaApplicationInput { name: string; + email: string; phone: string; wechat: string; industry: string; @@ -16,6 +17,7 @@ export interface BetaApplicationInput { wantFeature: string[]; selfStatement: string; signature: string; + applicationDate: string; agreeRules: boolean; } @@ -72,6 +74,7 @@ function normalizeApplication(raw: unknown): BetaApplicationItem { userId: readNumberOrNull(item.userId), username: readNullableString(item.username), name: readString(item.name), + email: readString(item.email), phone: readString(item.phone), wechat: readString(item.wechat), industry: readString(item.industry), @@ -86,6 +89,7 @@ function normalizeApplication(raw: unknown): BetaApplicationItem { wantFeature: readStringArray(item.wantFeature), selfStatement: readString(item.selfStatement), signature: readString(item.signature), + applicationDate: readString(item.applicationDate), agreeRules: item.agreeRules === true, status: normalizeStatus(item.status), inviteCode: readNullableString(item.inviteCode), diff --git a/src/api/modelCapabilitiesClient.ts b/src/api/modelCapabilitiesClient.ts index 7d284d0..22614bf 100644 --- a/src/api/modelCapabilitiesClient.ts +++ b/src/api/modelCapabilitiesClient.ts @@ -38,9 +38,14 @@ function normalizeModelOption(raw: unknown): ModelCapabilityOption | null { const enabled = raw.enabled === undefined ? status !== "maintenance" && status !== "disabled" : Boolean(raw.enabled); if (!enabled) return null; + const label = toStringValue(raw.label ?? raw.displayName ?? raw.display_name ?? raw.name, value); + return { value, - label: toStringValue(raw.label ?? raw.displayName ?? raw.display_name ?? raw.name, value), + label: + value === "wan2.7-image-pro" + ? label.replace(/\s*4k\b/i, "").trim() || "wan 2.7 Pro" + : label, description: toStringValue(raw.description) || undefined, badge: toStringValue(raw.badge) || undefined, enabled, diff --git a/src/api/serverConnection.ts b/src/api/serverConnection.ts index 3c2a499..4327da1 100644 --- a/src/api/serverConnection.ts +++ b/src/api/serverConnection.ts @@ -248,6 +248,17 @@ function isNonAuthErrorCode(code: string | undefined): boolean { ].includes(code); } +function isAuthFailureResponse(status: number, payload: unknown): boolean { + if (status === 401) return true; + if (status !== 403) return false; + + const code = getPayloadCode(payload); + if (code === "SESSION_REPLACED" || code === "TOKEN_EXPIRED" || code === "ACCOUNT_DISABLED") return true; + + const message = getPayloadMessage(payload) || ""; + return /账号已禁用|登录已过期|登录状态|session|token|企业信息不存在/i.test(message); +} + function notifySessionExpired(status: number, response: Response, payload: unknown): void { if (status !== 401 && status !== 403) return; if (typeof window === "undefined") return; @@ -263,6 +274,7 @@ function notifySessionExpired(status: number, response: Response, payload: unkno // Non-auth 403 errors (enterprise model access, insufficient balance) must // not trigger session expiry. if (status === 403 && isNonAuthErrorCode(getPayloadCode(payload))) return; + if (!isAuthFailureResponse(status, payload)) return; const now = Date.now(); if (now - lastSessionExpiredEventAt < 1500) return; diff --git a/src/components/BetaApplicationModal.tsx b/src/components/BetaApplicationModal.tsx index 850dff2..619082a 100644 --- a/src/components/BetaApplicationModal.tsx +++ b/src/components/BetaApplicationModal.tsx @@ -10,6 +10,7 @@ interface BetaApplicationModalProps { /* ── Form state ── */ interface BetaFormData { name: string; + email: string; phone: string; wechat: string; industry: string; @@ -24,11 +25,13 @@ interface BetaFormData { wantFeature: string[]; selfStatement: string; signature: string; + applicationDate: string; agreeRules: boolean; } const INITIAL_FORM: BetaFormData = { name: "", + email: "", phone: "", wechat: "", industry: "", @@ -43,6 +46,7 @@ const INITIAL_FORM: BetaFormData = { wantFeature: [], selfStatement: "", signature: "", + applicationDate: "", agreeRules: false, }; @@ -156,10 +160,12 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => { const validate = () => { if (!form.name.trim()) return "请填写姓名 / 常用昵称"; + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) return "请填写用于接收内测码的有效邮箱"; if (!form.phone.trim()) return "请填写联系手机号码"; if (!form.wechat.trim()) return "请填写微信账号"; if (!form.selfStatement.trim()) return "请填写申请自述"; if (!form.signature.trim()) return "请填写申请人确认签字"; + if (!form.applicationDate.trim()) return "请填写申请日期"; if (!form.agreeRules) return "请先阅读并同意内测规则"; return null; }; @@ -178,6 +184,7 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => { await betaApplicationClient.submit({ ...form, name: form.name.trim(), + email: form.email.trim(), phone: form.phone.trim(), wechat: form.wechat.trim(), industry: form.industry.trim(), @@ -190,9 +197,10 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => { feedbackWilling: form.feedbackWilling.trim(), selfStatement: form.selfStatement.trim(), signature: form.signature.trim(), + applicationDate: form.applicationDate.trim(), }); setForm(INITIAL_FORM); - setMessage({ tone: "success", text: "申请已提交,请留意站内通知。" }); + setMessage({ tone: "success", text: "申请已提交,请留意预留邮箱中的审核结果。" }); } catch (error) { setMessage({ tone: "error", text: error instanceof Error ? error.message : "提交内测申请失败" }); } finally { @@ -229,6 +237,7 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {

一、个人基础信息

update("name", v)} /> + update("email", v)} placeholder="审核通过后内测码将发送到此邮箱" /> update("phone", v)} /> update("wechat", v)} /> update("industry", v)} /> @@ -297,7 +306,7 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
  • 内测赠送 500 元等值 50,000 积分,仅限内测期间使用,不可提现、不可转让、不可兑换现金;
  • 内测版本含未上线测试功能,存在功能不稳定、界面调整、参数优化等情况,申请人自愿理解包容;
  • 严禁私自泄露内测专属工作流、内部功能、测试接口、未发布技术方案等内部资料;
  • -
  • 审核通过后,官方将在 48 小时 内发放内测账号、登录权限及免费积分;
  • +
  • 审核通过后,官方将在 48 小时 内通过预留邮箱发放内测码、登录权限及免费积分;
  • 正式版上线后,优质内测体验官可享受专属永久优惠权限与平台荣誉称号。
  • @@ -312,10 +321,7 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
    update("signature", v)} placeholder="请签署姓名" /> -
    - 申请填写日期 - -
    + update("applicationDate", v)} placeholder="例如:2026年6月8日" />
    diff --git a/src/features/beta-applications/BetaApplicationsPage.tsx b/src/features/beta-applications/BetaApplicationsPage.tsx index c6a0cf2..e4efccb 100644 --- a/src/features/beta-applications/BetaApplicationsPage.tsx +++ b/src/features/beta-applications/BetaApplicationsPage.tsx @@ -214,6 +214,7 @@ export default function BetaApplicationsPage({ session, onOpenLogin }: BetaAppli

    一、个人基础信息

    + @@ -248,6 +249,7 @@ export default function BetaApplicationsPage({ session, onOpenLogin }: BetaAppli

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

    + diff --git a/src/features/community-review/CommunityCaseAddPage.tsx b/src/features/community-review/CommunityCaseAddPage.tsx index 8aff1a4..b17055e 100644 --- a/src/features/community-review/CommunityCaseAddPage.tsx +++ b/src/features/community-review/CommunityCaseAddPage.tsx @@ -10,6 +10,7 @@ import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { aiGenerationClient } from "../../api/aiGenerationClient"; import { communityClient } from "../../api/communityClient"; import WorkspacePageShell from "../../components/WorkspacePageShell"; +import "../../styles/pages/compliance.css"; import type { WebCanvasWorkflow, WebUserSession } from "../../types"; import { getWorkflowCoverUrl, isCanvasWorkflow } from "../community/communityCaseUtils"; import { canManageCommunityCases } from "./communityPermissions"; diff --git a/src/features/community-review/CommunityReviewPage.tsx b/src/features/community-review/CommunityReviewPage.tsx index 87c9fdd..d316472 100644 --- a/src/features/community-review/CommunityReviewPage.tsx +++ b/src/features/community-review/CommunityReviewPage.tsx @@ -11,6 +11,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { communityClient, type ServerCommunityCase } from "../../api/communityClient"; import { reportClient, type AdminReportItem } from "../../api/reportClient"; import WorkspacePageShell from "../../components/WorkspacePageShell"; +import "../../styles/pages/compliance.css"; import type { WebUserSession } from "../../types"; import { canManageCommunityCases, canReviewCommunity } from "./communityPermissions"; diff --git a/src/features/report/ReportPage.tsx b/src/features/report/ReportPage.tsx index 5a88fd3..f6886f7 100644 --- a/src/features/report/ReportPage.tsx +++ b/src/features/report/ReportPage.tsx @@ -2,6 +2,7 @@ import { CheckCircleOutlined, FlagOutlined, MailOutlined, PhoneOutlined } from " import { useEffect, useState, type FormEvent } from "react"; import { publicConfigClient, type WebPublicConfig } from "../../api/publicConfigClient"; import { reportClient, type ReportInput } from "../../api/reportClient"; +import "../../styles/pages/compliance.css"; type SubmitState = "idle" | "loading" | "success" | "error"; diff --git a/src/features/workbench/WorkbenchPage.tsx b/src/features/workbench/WorkbenchPage.tsx index af93be9..47b0e1c 100644 --- a/src/features/workbench/WorkbenchPage.tsx +++ b/src/features/workbench/WorkbenchPage.tsx @@ -67,7 +67,6 @@ import { downloadResultAsset } from "./workbenchDownload"; import { translateTaskError } from "../../utils/translateTaskError"; import { buildLocalTimeoutMessage, - formatTextTokenUsage, getTaskTimeoutPolicy, isTaskLocallyTimedOut, } from "../../utils/taskLifecycle"; @@ -79,10 +78,12 @@ import { import { isViduModel } from "../../utils/viduRouting"; import { isPixverseModel } from "../../utils/pixverseRouting"; import { resolveVideoRequestModel } from "../../utils/resolveVideoModel"; -import { ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy"; +import { calculateEnterpriseVideoCredits, ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy"; import { getImageQualityOptions, + getImageQualityOptionsForContext, getDefaultImageQuality, + getDefaultImageQualityForContext, getVideoQualityOptions, getDefaultVideoQuality, getVideoQualityLabel, @@ -221,6 +222,12 @@ const MODE_ICONS: Record = { video: , }; +function formatCreditValue(value: number): string { + if (!Number.isFinite(value)) return "-"; + if (value >= 100) return Math.round(value).toLocaleString("zh-CN"); + return Number(value.toFixed(2)).toString(); +} + function WorkbenchPage({ isAuthenticated, session, @@ -464,11 +471,72 @@ function WorkbenchPage({ setSidebarCollapsed(!hasSidebarRecords); }, [hasSidebarRecords]); - const imageQualityOptions = useMemo(() => getImageQualityOptions(imageModel), [imageModel]); + const hasImageReferences = activeMode === "image" && referenceItems.some((item) => item.kind === "image"); + const isImageGridMode = activeMode === "image" && imageGridMode !== "single"; + const imageQualityContext = useMemo( + () => ({ + hasReferenceImages: hasImageReferences, + isGridMode: isImageGridMode, + }), + [hasImageReferences, isImageGridMode], + ); + const imageQualityOptions = useMemo( + () => getImageQualityOptionsForContext(imageModel, imageQualityContext), + [imageModel, imageQualityContext], + ); + const imageGridModeOptions = useMemo( + () => + String(imageModel || "").toLowerCase().startsWith("wan2.7-") + ? GRID_MODE_OPTIONS.filter((option) => option.value !== "grid-25") + : GRID_MODE_OPTIONS, + [imageModel], + ); const videoQualityOptions = getVideoQualityOptions(videoModel); const videoQualityLabel = getVideoQualityLabel(videoModel, videoQuality); const imageSettingsSummary = `${imageRatio} / ${imageQuality}`; + const billingEstimate = useMemo(() => { + if (activeMode === "image") { + return { + label: "预计 20 积分", + title: `图片生成按任务计费:${activeModel},${imageSettingsSummary},预计 20 积分`, + }; + } + if (activeMode === "video") { + try { + const durationSeconds = Math.max(1, Math.ceil(Number(videoDuration) || 1)); + const credits = calculateEnterpriseVideoCredits({ + model: activeModelValue, + resolution: videoQuality, + durationSeconds, + muted: false, + hasReferenceVideo: referenceItems.some((item) => item.kind === "video"), + }); + return { + label: `预计 ${formatCreditValue(credits)} 积分`, + title: `${activeModel},${videoQualityLabel},${durationSeconds} 秒,预计 ${formatCreditValue(credits)} 积分`, + }; + } catch { + return { + label: "计费以提交后为准", + title: "当前模型的预估计费暂不可用,实际扣费以服务端结算为准", + }; + } + } + return { + label: "按 Token 结算", + title: "文本对话按输入、输出 Token 实际用量结算,完成后显示本次积分", + }; + }, [ + activeMode, + activeModel, + activeModelValue, + imageSettingsSummary, + referenceItems, + videoDuration, + videoQuality, + videoQualityLabel, + ]); const composerPlaceholder = referenceItems.length > 0 ? `${toolTheme.placeholder},可输入 @ 引用参考内容` : toolTheme.placeholder; const dropdownDirection = hasActivatedWorkspace ? "up" : "down"; @@ -1158,9 +1226,15 @@ function WorkbenchPage({ useEffect(() => { if (!imageQualityOptions.some((option) => option.value === imageQuality)) { - setImageQuality(getDefaultImageQuality(imageModel)); + setImageQuality(getDefaultImageQualityForContext(imageModel, imageQualityContext)); } - }, [imageModel, imageQuality, imageQualityOptions]); + }, [imageModel, imageQuality, imageQualityContext, imageQualityOptions]); + + useEffect(() => { + if (!imageGridModeOptions.some((option) => option.value === imageGridMode)) { + setImageGridMode("single"); + } + }, [imageGridMode, imageGridModeOptions]); useEffect(() => { if (activeMode !== "video" || videoFrameMode !== "start-end" || referenceItems.length <= 2) return; @@ -2585,7 +2659,15 @@ function WorkbenchPage({ } }; - const sendDisabled = !inputValue.trim() || (activeMode !== "chat" && getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3); + const activeGenerationCount = getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)); + const generationLimitReached = activeMode !== "chat" && activeGenerationCount >= 3; + const promptIsEmpty = !inputValue.trim(); + const sendDisabled = promptIsEmpty || generationLimitReached; + const sendButtonTitle = promptIsEmpty + ? "输入内容后可发送" + : generationLimitReached + ? `当前已有 ${activeGenerationCount} 个任务进行中,请等待任一任务完成` + : billingEstimate.title; const suggestedPrompts = [ { text: "画一个赛博朋克风格的城市夜景", mode: "image" as WorkbenchMode }, @@ -2850,7 +2932,7 @@ function WorkbenchPage({ toggleToolbarMenu("image-grid-mode")} @@ -2922,10 +3004,15 @@ function WorkbenchPage({ )}
    + + {billingEstimate.label} +
    )} - {message.role === "assistant" && message.mode === "chat" && message.status === "completed" && ( -
    - {formatTextTokenUsage(message.taskUsage)} -
    - )} {(message.resultUrl || (message.result && message.status !== "thinking")) && ( ((set) => ({ @@ -55,7 +55,7 @@ export const useSessionStore = create((set) => ({ showSessionReplaced: (message) => set({ sessionReplacedOpen: true, - sessionReplacedMessage: message || '您的账号已在其他设备登录(最多同时 2 台设备),此设备的登录状态已失效。', + sessionReplacedMessage: message || '当前账号已在其他设备登录,此设备的登录状态已失效。', }), hideSessionReplaced: () => set({ sessionReplacedOpen: false }), diff --git a/src/styles/pages/beta-applications.css b/src/styles/pages/beta-applications.css index 47d9952..2cebadb 100644 --- a/src/styles/pages/beta-applications.css +++ b/src/styles/pages/beta-applications.css @@ -3,7 +3,11 @@ flex-direction: column; gap: 18px; width: min(1180px, calc(100vw - 48px)); + margin: 0 auto; + height: 100%; + min-height: 0; padding: 24px; + overflow: hidden; } .beta-admin-toolbar { @@ -90,6 +94,8 @@ display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 16px; + flex: 1; + min-height: 0; align-items: start; } @@ -97,7 +103,7 @@ display: flex; flex-direction: column; gap: 8px; - max-height: calc(100vh - 220px); + max-height: 100%; overflow: auto; padding-right: 4px; } @@ -174,6 +180,10 @@ flex-direction: column; gap: 14px; min-width: 0; + max-height: 100%; + min-height: 0; + overflow: auto; + padding-right: 4px; } .beta-admin-detail__header, @@ -376,6 +386,7 @@ .beta-admin-page__inner { width: min(100%, calc(100vw - 24px)); padding: 16px 12px; + overflow: auto; } .beta-admin-toolbar, @@ -385,11 +396,18 @@ .beta-admin-layout { grid-template-columns: 1fr; + overflow: visible; } .beta-admin-list { max-height: none; } + + .beta-admin-detail { + max-height: none; + overflow: visible; + padding-right: 0; + } } @media (max-width: 640px) { diff --git a/src/styles/pages/legacy-pages.css b/src/styles/pages/legacy-pages.css index 07c635e..75597b8 100644 --- a/src/styles/pages/legacy-pages.css +++ b/src/styles/pages/legacy-pages.css @@ -11915,6 +11915,21 @@ gap: 6px; } +.wb-composer__billing-estimate { + max-width: 138px; + padding: 6px 9px; + border: 2px solid #111; + background: #fffbe8; + color: #111; + font-size: 12px; + font-weight: 800; + line-height: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + box-shadow: 2px 2px 0 #111; +} + .wb-composer__send-primary { display: inline-flex; align-items: center; diff --git a/src/styles/themes/dark-green.css b/src/styles/themes/dark-green.css index 7a39fc2..eb58736 100644 --- a/src/styles/themes/dark-green.css +++ b/src/styles/themes/dark-green.css @@ -1794,6 +1794,14 @@ box-shadow: none; } +.web-shell[data-ui-theme="dark-green"] .wb-composer__billing-estimate { + border: 1px solid var(--border-default); + border-radius: 999px; + background: var(--bg-elevated); + color: var(--fg-body); + box-shadow: none; +} + .web-shell[data-ui-theme="dark-green"] .wb-composer__send-primary:hover:not(:disabled) { background: var(--accent-hover); color: var(--dg-button-text); diff --git a/src/utils/enterpriseVideoPolicy.ts b/src/utils/enterpriseVideoPolicy.ts index 2240ea2..359aa31 100644 --- a/src/utils/enterpriseVideoPolicy.ts +++ b/src/utils/enterpriseVideoPolicy.ts @@ -40,6 +40,7 @@ export const ENTERPRISE_VIDEO_RESOLUTION_OPTIONS = [ export const ENTERPRISE_DEFAULT_VIDEO_MODEL = HAPPY_HORSE_UI_MODEL; export const ENTERPRISE_DEFAULT_VIDEO_RESOLUTION = "1080P"; +const CREDITS_PER_CNY = 100; export interface EnterpriseVideoPricingInput { model: string; @@ -74,11 +75,11 @@ export function getEnterpriseVideoCreditRate(input: EnterpriseVideoPricingInput) } if (model.includes("vidu")) { - return resolution === "720P" ? 0.4 : 0.8; + return resolution === "720P" ? 0.6 : 1.0; } if (model.includes("pixverse")) { - return resolution === "720P" ? 0.4 : 0.8; + return resolution === "720P" ? 0.6 : 1.0; } if (model.includes("kling")) { @@ -94,5 +95,5 @@ export function getEnterpriseVideoCreditRate(input: EnterpriseVideoPricingInput) export function calculateEnterpriseVideoCredits(input: EnterpriseVideoPricingInput): number { const duration = Math.max(1, Math.ceil(Number(input.durationSeconds) || 1)); - return Number((getEnterpriseVideoCreditRate(input) * duration).toFixed(2)); + return Number((getEnterpriseVideoCreditRate(input) * duration * CREDITS_PER_CNY).toFixed(2)); } diff --git a/src/utils/modelOptions.ts b/src/utils/modelOptions.ts index 9b452d9..63d6371 100644 --- a/src/utils/modelOptions.ts +++ b/src/utils/modelOptions.ts @@ -25,11 +25,30 @@ export function getImageQualityOptions(model: string): CanvasOption[] { : imageQualityOptions.filter((option) => option.value !== "4K"); } +export function getImageQualityOptionsForContext( + model: string, + context?: { hasReferenceImages?: boolean; isGridMode?: boolean }, +): CanvasOption[] { + const options = getImageQualityOptions(model); + const shouldLimitTo2K = + String(model || "").toLowerCase() === "wan2.7-image-pro" && + (context?.hasReferenceImages || context?.isGridMode); + return shouldLimitTo2K ? options.filter((option) => option.value !== "4K") : options; +} + export function getDefaultImageQuality(model: string): string { const options = getImageQualityOptions(model); return options.some((option) => option.value === "2K") ? "2K" : options[0]?.value || "1K"; } +export function getDefaultImageQualityForContext( + model: string, + context?: { hasReferenceImages?: boolean; isGridMode?: boolean }, +): string { + const options = getImageQualityOptionsForContext(model, context); + return options.some((option) => option.value === "2K") ? "2K" : options[0]?.value || "1K"; +} + // ─── Video quality ──────────────────────────────────────────────────────────── function normalizeVideoModel(model: string): string { diff --git a/src/utils/taskLifecycle.ts b/src/utils/taskLifecycle.ts index ae3a850..757c4b2 100644 --- a/src/utils/taskLifecycle.ts +++ b/src/utils/taskLifecycle.ts @@ -32,8 +32,10 @@ export interface TextTokenUsage { totalTokens?: number; } -export const TEXT_INPUT_CREDITS_PER_MILLION = 2; -export const TEXT_OUTPUT_CREDITS_PER_MILLION = 5; +const CREDITS_PER_CNY = 100; + +export const TEXT_INPUT_CREDITS_PER_MILLION = 2 * CREDITS_PER_CNY; +export const TEXT_OUTPUT_CREDITS_PER_MILLION = 5 * CREDITS_PER_CNY; const IMAGE_TIMEOUT_POLICY: TaskTimeoutPolicy = { submitTimeoutMs: 90_000, @@ -151,7 +153,7 @@ export function estimateTextTokenCredits(usage: TextTokenUsage): number { } export function formatTextTokenUsage(usage?: TextTokenUsage | null): string { - const rule = "文本计费规则:输入 Token 每百万 2 积分,输出 Token 每百万 5 积分,实际以服务端结算为准。"; + const rule = "文本计费规则:输入 Token 每百万 200 积分,输出 Token 每百万 500 积分,实际以服务端结算为准。"; if (!usage) return rule; const promptTokens = Math.max(0, Number(usage.promptTokens || 0)); const completionTokens = Math.max(0, Number(usage.completionTokens || 0));