Compare commits

...

20 Commits

Author SHA1 Message Date
stringadmin 2574dfe3d7 fix: harden generation task recovery 2026-06-08 20:57:40 +08:00
stringadmin 136fb15397 docs: update legal compliance pages 2026-06-08 20:57:40 +08:00
stringadmin 4e97e706fd Add beta application email fields 2026-06-08 18:30:05 +08:00
stringadmin 30536ad15f Fix wan2.7 image quality selection 2026-06-08 18:26:44 +08:00
stringadmin e78cc05299 Merge remote-tracking branch 'origin/master' into codex/generation-task-reliability 2026-06-08 17:39:11 +08:00
stringadmin b88be66e7f Merge pull request 'feat: Workbench SaaS视觉升级与视图重置机制' (#26) from feat/workbench-saas-polish-and-reset into master
Reviewed-on: #26
2026-06-08 09:31:55 +00:00
stringadmin 1a9196a63a Merge branch 'master' into feat/workbench-saas-polish-and-reset 2026-06-08 09:31:49 +00:00
ludan 4dfcb6fc8a feat: Workbench SaaS视觉升级与视图重置机制
本次提交包含以下改进:

## 1. Workbench视图重置机制 (App.tsx + WorkbenchPage.tsx)
- 在App.tsx中新增workbenchResetToken状态,每次导航到workbench页面且存在session时递增token
- WorkbenchPage新增resetToken属性,检测token变化后自动调用handleNewConversation()重置工作台状态
- 重置时清空消息列表和活跃会话ID,确保每次进入工作台都是全新状态

## 2. 滚动操作提示系统 (WorkbenchPage.tsx)
- 新增scrollActionHint状态和hideScrollActionHint/showScrollActionHint方法
- 用户滚动离开消息区域时自动显示滚动方向提示(顶部/底部按钮)
- 1.4秒后自动隐藏提示,优化交互体验
- 手动点击滚动按钮后立即隐藏提示
- 为滚动按钮添加--top/--bottom标识类名,支持独立定位

## 3. Prompt案例弹窗自适应布局 (WorkbenchPage.tsx)
- renderPromptCaseOverlay重构为动态计算moda l类名
- 根据图片实测宽高比(is-tall-media/is-portrait-media)和文案长度(is-long-copy)动态调整布局
- 添加handlePromptCaseImageLoad回调在图片加载后测量尺寸

## 4. Workbench SaaS视觉美化 (workbench.css)
- 全新SaaS风格设计变量(--wb-panel, --wb-line, --wb-shadow等)
- 首页区域:标题样式、Composer输入框圆角/阴影/聚焦态、发送按钮渐变样式
- 模式选择/芯片组件:下拉菜单、悬停态优化、选中态高亮
- 聊天消息区:气泡圆角、头像样式、消息间距、空状态引导
- 图片/视频结果卡片:边框、阴影、标签徽章、视频PLAY标识
- 生成中卡片:停止按钮样式
- 会话侧边栏:折叠态浮动按钮定位、展开态面板样式、选中项左侧指示条
- 滚动快捷键:固定定位圆形按钮、显示/隐藏过渡动画
- Prompt案例弹窗:桌面端毛玻璃双栏布局、移动端底部面板布局
- @media适配:560px/720px/900px/980px四个断点全覆盖

## 5. 全局移动端布局变量 (dark-green.css)
- 新增--dg-mobile-nav-height/gap/space CSS变量,统一移动端底部导航高度计算
- 优化Topbar z-index层级
- 非特殊页面自动添加顶部padding避让移动导航
- Profile弹窗fixed定位及安全区域适配
2026-06-08 17:30:21 +08:00
stringadmin e351e93200 Center beta application review layout 2026-06-08 16:35:32 +08:00
stringadmin 117b9354eb Restore moderation page styles 2026-06-08 16:32:16 +08:00
stringadmin 446514dd06 Fix beta application review page scrolling 2026-06-08 16:26:38 +08:00
stringadmin 85a174bcb5 Avoid clearing sessions on permission errors 2026-06-08 16:20:52 +08:00
stringadmin 560a7baddc Restore image generation estimate to 20 credits 2026-06-08 16:07:04 +08:00
stringadmin 4f7f67a278 Scale generation billing estimates to 1-to-100 credits 2026-06-08 16:03:52 +08:00
stringadmin 3963d9ae2f Show billing estimate and clarify session replacement 2026-06-08 15:55:50 +08:00
stringadmin 60d5cd2edf Merge pull request 'Codex/generation task reliability' (#25) from codex/generation-task-reliability into master
Reviewed-on: #25
2026-06-08 07:49:24 +00:00
stringadmin 2afa73ac18 Align visible credit pricing to 1-to-100 2026-06-08 15:46:31 +08:00
stringadmin 3a1bc0241e feat: add beta application review flow 2026-06-08 15:23:13 +08:00
stringadmin 33723d00f0 Merge remote-tracking branch 'origin/master' into codex/generation-task-reliability 2026-06-08 15:08:26 +08:00
stringadmin fe5a839b37 fix: harden generation task polling fallback 2026-06-08 14:47:27 +08:00
31 changed files with 3426 additions and 140 deletions
+19 -1
View File
@@ -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<WebViewKey>([
"more",
"communityReview",
"communityCaseAdd",
"betaApplications",
"report",
"providerHealth",
"userAgreement",
@@ -123,6 +126,7 @@ const LEGACY_PAGE_STYLE_VIEWS = new Set<WebViewKey>([
"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";
}
@@ -368,6 +374,7 @@ function App() {
})));
const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false);
const [workbenchResetToken, setWorkbenchResetToken] = useState(0);
const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub";
useEffect(() => {
if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true);
@@ -453,6 +460,9 @@ function App() {
);
const handleSetView = useCallback((view: WebViewKey) => {
if (view === "workbench" && Boolean(session)) {
setWorkbenchResetToken((token) => token + 1);
}
window.location.hash = `/${view}`;
setView(view);
if (view !== "login") {
@@ -461,11 +471,12 @@ function App() {
if (isWorkspaceView(view)) {
setWorkspaceExpanded(true);
}
}, [setView, setWorkspaceExpanded]);
}, [session, setView, setWorkspaceExpanded]);
const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => {
clearAllUserStorage();
clearSessionState();
setUserMaxConcurrency(null);
setProjects([]);
setProjectsLoaded(true);
setUsage(emptyUsageSummary);
@@ -574,6 +585,7 @@ function App() {
const nextSession = await keyServerClient.getCurrentSession();
if (cancelled) return;
setSession(nextSession);
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
await hydrateAccountData(nextSession);
};
@@ -606,6 +618,7 @@ function App() {
if (cancelled) return;
if (nextSession) {
setSession(nextSession);
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
} else {
clearAuthenticatedState({ resetView: true });
}
@@ -943,6 +956,7 @@ function App() {
async (nextSession: WebUserSession) => {
hideSessionReplaced();
setSession(nextSession);
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
await hydrateAccountData(nextSession);
if (nextSession.user.email && !nextSession.user.emailVerified) {
@@ -1298,14 +1312,18 @@ function App() {
onOpenReview={() => handleSetView("communityReview")}
/>
);
case "betaApplications":
return <BetaApplicationsPage session={session} onOpenLogin={handleOpenLogin} />;
case "workbench":
return (
<WorkbenchPage
key={`workbench-${workbenchResetToken}`}
isAuthenticated={Boolean(session)}
session={session}
onRequireLogin={handleRequireTaskLogin}
onOpenResultInCanvas={handleOpenResultInCanvas}
onRefreshUsage={refreshUsage}
resetToken={workbenchResetToken}
/>
);
case "home":
+72 -1
View File
@@ -4,6 +4,7 @@ import {
isRecord,
readJsonResponse,
serverRequest,
isServerRequestError,
throwResponseError,
} from "./serverConnection";
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
@@ -247,6 +248,46 @@ let taskHistoryRouteMissing = false;
const TASK_SUBMIT_TIMEOUT_MS = 90_000;
const TASK_STATUS_TIMEOUT_MS = 20_000;
const NON_RETRYING_REQUEST = { maxRetries: 0 };
const PENDING_CANCEL_TASKS_KEY = "omniai:pending-task-cancellations";
function readPendingCancelTaskIds(): string[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(PENDING_CANCEL_TASKS_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string" && id.trim().length > 0) : [];
} catch {
return [];
}
}
function writePendingCancelTaskIds(taskIds: string[]): void {
if (typeof window === "undefined") return;
try {
const uniqueIds = Array.from(new Set(taskIds.filter(Boolean)));
if (uniqueIds.length) {
window.localStorage.setItem(PENDING_CANCEL_TASKS_KEY, JSON.stringify(uniqueIds));
} else {
window.localStorage.removeItem(PENDING_CANCEL_TASKS_KEY);
}
} catch {
// Pending cancellation recovery is best-effort.
}
}
function markTaskCancelPending(taskId: string): void {
writePendingCancelTaskIds([...readPendingCancelTaskIds(), taskId]);
}
function clearPendingTaskCancel(taskId: string): void {
writePendingCancelTaskIds(readPendingCancelTaskIds().filter((id) => id !== taskId));
}
function shouldRetryTaskCancel(error: unknown): boolean {
if (!isServerRequestError(error)) return true;
const status = error.status;
return status === 429 || status === undefined || status >= 500;
}
export const aiGenerationClient = {
async createImageTask(input: ImageGenInput): Promise<ImageTaskCreateResponse> {
@@ -335,18 +376,48 @@ export const aiGenerationClient = {
},
async cancelTask(taskId: string): Promise<void> {
markTaskCancelPending(taskId);
try {
await serverRequest<void>(`ai/tasks/${taskId}/cancel`, {
method: "PATCH",
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Task cancel failed",
});
clearPendingTaskCancel(taskId);
} catch (error) {
if (isOptionalApiRouteMissing(error)) return;
if (isOptionalApiRouteMissing(error) || !shouldRetryTaskCancel(error)) {
clearPendingTaskCancel(taskId);
return;
}
throw error;
}
},
cancelTaskOnUnload(taskId: string): void {
markTaskCancelPending(taskId);
const url = buildApiUrl(`ai/tasks/${encodeURIComponent(taskId)}/cancel`);
const headers = buildAuthHeaders();
const body = JSON.stringify({ reason: "page_unload" });
try {
void fetch(url, {
method: "PATCH",
headers,
body,
credentials: "include",
keepalive: true,
});
} catch {
// Page unload cancellation is best-effort.
}
},
flushPendingTaskCancellations(): void {
readPendingCancelTaskIds().forEach((taskId) => {
this.cancelTask(taskId).catch(() => {});
});
},
async getTaskStatus(taskId: string): Promise<AiTaskStatus> {
return serverRequest<AiTaskStatus>(`ai/tasks/${taskId}`, {
timeoutMs: TASK_STATUS_TIMEOUT_MS,
+139
View File
@@ -0,0 +1,139 @@
import { serverRequest } from "./serverConnection";
export interface BetaApplicationInput {
name: string;
email: 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;
applicationDate: 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),
email: readString(item.email),
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),
applicationDate: readString(item.applicationDate),
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);
},
};
+18 -3
View File
@@ -7,10 +7,24 @@ 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<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 getEffectiveGenerationLimit(): number {
return getEffectiveLimit();
}
export function getGenerationUserKey(userId?: string | number | null): string {
return userId === undefined || userId === null || userId === "" ? "anonymous" : String(userId);
}
@@ -39,8 +53,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)}`;
+1
View File
@@ -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),
};
}
+6 -1
View File
@@ -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,
+12
View File
@@ -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;
+1 -3
View File
@@ -44,7 +44,6 @@ export function waitForTask(
let settled = false;
let cleanup: (() => void) | null = null;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let sseConnected = false;
let fallbackTimerId: ReturnType<typeof setTimeout> | 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);
+20
View File
@@ -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 (
<div
@@ -486,6 +493,19 @@ function AppShell({
</button>
</>
) : null}
{showBetaApplicationReview ? (
<button
type="button"
className="profile-popover__review-btn"
onClick={() => {
setProfileOpen(false);
onSelectView("betaApplications");
}}
>
<ShellIcon name="check-circle" />
</button>
) : null}
{showCommunityCaseAdd ? (
<>
<button
+78 -12
View File
@@ -1,5 +1,6 @@
import { CloseOutlined, ExperimentOutlined } from "@ant-design/icons";
import { useState } from "react";
import { betaApplicationClient } from "../api/betaApplicationClient";
interface BetaApplicationModalProps {
open: boolean;
@@ -9,6 +10,7 @@ interface BetaApplicationModalProps {
/* ── Form state ── */
interface BetaFormData {
name: string;
email: string;
phone: string;
wechat: string;
industry: string;
@@ -23,11 +25,13 @@ interface BetaFormData {
wantFeature: string[];
selfStatement: string;
signature: string;
applicationDate: string;
agreeRules: boolean;
}
const INITIAL_FORM: BetaFormData = {
name: "",
email: "",
phone: "",
wechat: "",
industry: "",
@@ -42,6 +46,7 @@ const INITIAL_FORM: BetaFormData = {
wantFeature: [],
selfStatement: "",
signature: "",
applicationDate: "",
agreeRules: false,
};
@@ -140,16 +145,74 @@ function TextField({
const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
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]) => {
setForm((prev) => ({ ...prev, [key]: value }));
setMessage(null);
};
const close = () => {
if (submitting) return;
onClose();
};
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;
};
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(),
email: form.email.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(),
applicationDate: form.applicationDate.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;
return (
<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">
{/* ── Header ── */}
@@ -158,10 +221,10 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
<ExperimentOutlined className="beta-modal-header__icon" />
<div>
<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>
<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 />
</button>
</header>
@@ -174,6 +237,7 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
<h3 className="beta-doc-section__title"></h3>
<div className="beta-doc-grid">
<TextField label="姓名 / 常用昵称" value={form.name} onChange={(v) => update("name", v)} />
<TextField label="接收内测码邮箱" value={form.email} onChange={(v) => update("email", v)} placeholder="审核通过后内测码将发送到此邮箱" />
<TextField label="联系手机号码" value={form.phone} onChange={(v) => update("phone", v)} />
<TextField label="微信账号" value={form.wechat} onChange={(v) => update("wechat", v)} />
<TextField label="所在行业 / 职业" value={form.industry} onChange={(v) => update("industry", v)} />
@@ -239,10 +303,10 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
<h3 className="beta-doc-section__title"></h3>
<ol className="beta-rules-list">
<li> <strong>30 </strong> + </li>
<li> <strong>500 </strong> 使</li>
<li> <strong>500 50,000 </strong>使</li>
<li>线</li>
<li></li>
<li> <strong>48 </strong> </li>
<li> <strong>48 </strong> </li>
<li>线</li>
</ol>
@@ -257,10 +321,7 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
<div className="beta-doc-grid beta-doc-grid--two">
<TextField label="申请人确认签字" value={form.signature} onChange={(v) => update("signature", v)} placeholder="请签署姓名" />
<div className="beta-text-field">
<span className="beta-text-field__label"></span>
<input type="text" className="beta-text-field__input" value="2026年 月 日" readOnly />
</div>
<TextField label="申请填写日期" value={form.applicationDate} onChange={(v) => update("applicationDate", v)} placeholder="例如:2026年6月8日" />
</div>
</section>
@@ -268,11 +329,16 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
{/* ── 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 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>
</footer>
</section>
@@ -29,7 +29,7 @@ const membershipPlans: MembershipPlan[] = [
subtitle: "Pro",
period: "月付",
price: "299 元 / 月",
grant: "每月赠送 10000 积分,30 天有效",
grant: "每月赠送 29900 积分,30 天有效",
comparisonLabel: "专业版基础权益",
icon: <CrownOutlined />,
benefits: ["通用大模型全解锁", "积分与 API 消耗 9 折", "并发提升到 3 个", "去水印、插队加速、专属客服"],
@@ -41,7 +41,7 @@ const membershipPlans: MembershipPlan[] = [
subtitle: "Pro",
period: "季付",
price: "897 元 / 季",
grant: "连续 3 个月按月发放 Pro 积分",
grant: "季度合计 89700 积分,默认按月分摊",
comparisonLabel: "相比月付新增",
badge: "季度",
icon: <CrownOutlined />,
@@ -54,7 +54,7 @@ const membershipPlans: MembershipPlan[] = [
subtitle: "Pro",
period: "年付",
price: "1990 元 / 年",
grant: "全年合计 140000 积分,默认按月分摊",
grant: "全年合计 199000 积分,默认按月分摊",
comparisonLabel: "相比季付新增",
badge: "年费优惠",
icon: <CrownOutlined />,
@@ -67,7 +67,7 @@ const membershipPlans: MembershipPlan[] = [
subtitle: "Enterprise",
period: "月付",
price: "499 元 / 月",
grant: "每月赠送 2000 积分,30 天有效",
grant: "每月赠送 49900 积分,30 天有效",
comparisonLabel: "企业版基础权益",
icon: <RocketOutlined />,
benefits: ["企业私有模型与高性能模型", "默认 10 并发,可申请提升", "积分与 API 消耗 8 折", "用量报表与正式 API 权限"],
@@ -79,7 +79,7 @@ const membershipPlans: MembershipPlan[] = [
subtitle: "Enterprise",
period: "季付",
price: "1497 元 / 季",
grant: "连续 3 个月按月发放企业版积分",
grant: "季度合计 149700 积分,默认按月分摊",
comparisonLabel: "相比月付新增",
badge: "季度",
icon: <RocketOutlined />,
@@ -92,7 +92,7 @@ const membershipPlans: MembershipPlan[] = [
subtitle: "Enterprise",
period: "年付",
price: "4990 元 / 年",
grant: "全年合计 340000 积分,默认按月分摊",
grant: "全年合计 499000 积分,默认按月分摊",
comparisonLabel: "相比季付新增",
badge: "企业年费",
icon: <RocketOutlined />,
@@ -0,0 +1,298 @@
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.email)} />
<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={valueOrEmpty(selectedApplication.applicationDate)} />
<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>
);
}
+12 -8
View File
@@ -396,7 +396,6 @@ function CanvasPage({
const canvasUploadInputRef = useRef<HTMLInputElement>(null);
const imageNodeInputRef = useRef<HTMLInputElement>(null);
const canvasRef = useRef<HTMLElement>(null);
const videoGenerationInFlightRef = useRef(new Set<string>());
const canvasReferenceUploadPromisesRef = useRef(new Map<string, Promise<string | null>>());
const canvasDragCounterRef = useRef(0);
const [isCanvasDragging, setIsCanvasDragging] = useState(false);
@@ -417,7 +416,7 @@ function CanvasPage({
const {
textGenerationState, imageGenerationState, videoGenerationState,
generationToast, setGenerationToast,
imageGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef,
imageGenerationInFlightRef, videoGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef,
canvasGenKeepaliveRestoredRef,
setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus,
restoreKeepaliveTasks, resetGenerationState,
@@ -1887,13 +1886,14 @@ function CanvasPage({
setVideoGenerationStatus(nodeId, { status: "submitting", message: "正在提交视频生成", progress: 8 });
setGenerationToast("视频正在生成");
let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
try {
const referenceUrls = await resolveConnectedImageReferenceUrls("video", nodeId);
if (videoNode.videoMode === "img2video" && referenceUrls.length === 0) {
throw new Error("图生视频需要先连接至少一个可用的图片节点");
}
let requestModel = resolveVideoRequestModel({ model, referenceUrls });
const task = await onCreateTask({
task = await onCreateTask({
title: videoNode.title || "视频节点生成",
type: "video",
prompt: prompt || "根据参考图片生成视频",
@@ -1916,10 +1916,12 @@ function CanvasPage({
if (task.status === "completed" && !task.outputUrl) {
throw new Error("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试");
}
const taskId = task.id;
addCanvasGenKeepalive(taskId, nodeId, "video", projectId || "");
setVideoGenerationStatus(nodeId, { status: "running", message: "视频生成中", progress: Math.max(18, Number(task.progress || 0)) });
const outputUrl =
task.outputUrl ||
(await waitForImageTaskResult(task.id, (status) => {
(await waitForVideoTaskResult(taskId, (status) => {
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
const statusLabel =
status.status === "pending"
@@ -1932,11 +1934,12 @@ function CanvasPage({
setVideoGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
}));
setVideoGenerationStatus(nodeId, { status: "success", message: "视频生成完成", progress: 100 });
removeCanvasGenKeepalive(taskId);
const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
url: outputUrl,
mediaType: "video/mp4",
resultType: "video",
taskId: task.id,
taskId,
originalUrl: outputUrl,
});
setVideoNodes((currentNodes) =>
@@ -1947,7 +1950,7 @@ function CanvasPage({
videoUrl: outputUrl,
assetRef: immediateAssetRef,
taskRef: {
taskId: task.id,
taskId,
status: "completed",
resultUrl: outputUrl,
updatedAt: new Date().toISOString(),
@@ -1961,7 +1964,7 @@ function CanvasPage({
url: outputUrl,
mediaType: "video/mp4",
resultType: "video",
taskId: task.id,
taskId,
originalUrl: outputUrl,
});
await delay(420);
@@ -1974,7 +1977,7 @@ function CanvasPage({
videoUrl: assetRef.url,
assetRef,
taskRef: {
taskId: task.id,
taskId,
status: "completed",
resultUrl: assetRef.url,
updatedAt: new Date().toISOString(),
@@ -1991,6 +1994,7 @@ function CanvasPage({
});
} finally {
videoGenerationInFlightRef.current.delete(nodeId);
if (task?.id) removeCanvasGenKeepalive(task.id);
}
};
+37 -2
View File
@@ -6,6 +6,7 @@ import type {
CanvasVideoGenerationState,
CanvasVideoNode,
} from "./canvasTypes";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { createCanvasAssetRefFromGeneratedResult, persistCanvasGeneratedResultAsset } from "./canvasAssetPersistence";
import { waitForImageTaskResult, waitForVideoTaskResult } from "./canvasUtils";
@@ -41,6 +42,13 @@ export function removeCanvasGenKeepalive(taskId: string): void {
saveCanvasGenKeepalive(loadCanvasGenKeepalive().filter((e) => e.taskId !== taskId));
}
export function cancelCanvasGenKeepaliveOnUnload(): void {
const entries = loadCanvasGenKeepalive();
if (!entries.length) return;
entries.forEach((entry) => aiGenerationClient.cancelTaskOnUnload(entry.taskId));
saveCanvasGenKeepalive([]);
}
export interface UseCanvasGenerationParams {
setImageNodes: Dispatch<SetStateAction<CanvasImageNode[]>>;
setVideoNodes: Dispatch<SetStateAction<CanvasVideoNode[]>>;
@@ -55,6 +63,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
const [generationToast, setGenerationToast] = useState<string | null>(null);
const imageGenerationInFlightRef = useRef(new Set<string>());
const videoGenerationInFlightRef = useRef(new Set<string>());
const textGenerationInFlightRef = useRef(new Set<string>());
const textGenerationAbortControllersRef = useRef(new Map<string, AbortController>());
const canvasGenKeepaliveRestoredRef = useRef(false);
@@ -125,7 +134,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
imageGenerationInFlightRef.current.delete(entry.nodeId);
});
} else if (entry.nodeKind === "video") {
imageGenerationInFlightRef.current.add(entry.nodeId);
videoGenerationInFlightRef.current.add(entry.nodeId);
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复视频生成", progress: 20 });
void waitForVideoTaskResult(entry.taskId, (status) => {
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
@@ -154,7 +163,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
removeCanvasGenKeepalive(entry.taskId);
setVideoGenerationStatus(entry.nodeId, { status: "error", message: "视频生成失败" });
}).finally(() => {
imageGenerationInFlightRef.current.delete(entry.nodeId);
videoGenerationInFlightRef.current.delete(entry.nodeId);
});
}
}
@@ -165,11 +174,36 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
textGenerationAbortControllersRef.current.clear();
textGenerationInFlightRef.current.clear();
imageGenerationInFlightRef.current.clear();
videoGenerationInFlightRef.current.clear();
setTextGenerationState({});
setImageGenerationState({});
setVideoGenerationState({});
};
useEffect(() => {
const handlePageHide = () => {
cancelCanvasGenKeepaliveOnUnload();
textGenerationAbortControllersRef.current.forEach((controller) => controller.abort());
textGenerationAbortControllersRef.current.clear();
textGenerationInFlightRef.current.clear();
imageGenerationInFlightRef.current.clear();
videoGenerationInFlightRef.current.clear();
setTextGenerationState({});
setImageGenerationState({});
setVideoGenerationState({});
};
const handleOnline = () => {
aiGenerationClient.flushPendingTaskCancellations();
};
window.addEventListener("pagehide", handlePageHide);
window.addEventListener("online", handleOnline);
aiGenerationClient.flushPendingTaskCancellations();
return () => {
window.removeEventListener("pagehide", handlePageHide);
window.removeEventListener("online", handleOnline);
};
}, []);
return {
textGenerationState,
imageGenerationState,
@@ -177,6 +211,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
generationToast,
setGenerationToast,
imageGenerationInFlightRef,
videoGenerationInFlightRef,
textGenerationInFlightRef,
textGenerationAbortControllersRef,
canvasGenKeepaliveRestoredRef,
@@ -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";
@@ -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";
+211 -60
View File
@@ -7,61 +7,213 @@ interface CompliancePageProps {
kind: ComplianceKind;
}
const companyName = "OmniAI";
const companyName = "南京万物可爱文化传媒有限公司";
const platformName = "Omniai平台";
const contactEmail = "system@omniai.net.cn";
const contactPhone = "15155073618";
const address = "江苏省南京市江北新区扬子江数字视听产业园9栋A楼501";
const address = "江苏省南京市江北新区扬子江数字视听产业园9栋A楼9A-501";
const icpRecord = "苏ICP备2026021747号-1";
const effectiveDate = "2026年06月8日";
const agreementSections = [
{
title: "服务范围",
body: "平台提供 AI 图片、视频、脚本、数字人及相关创作辅助服务。具体功能、模型能力、消耗规则以页面展示和平台公告为准。",
},
{
title: "账号与使用",
body: "用户应保证注册信息真实有效,妥善保管账号与登录凭证,不得出租、转让账号或以自动化方式恶意占用平台资源。",
},
{
title: "内容合规",
body: "用户不得上传、生成、发布违法违规、侵权、涉政敏感、暴恐、色情、赌博、诈骗或侵犯他人合法权益的内容。平台有权对违规内容采取删除、限制功能、封禁账号等措施。",
},
{
title: "积分与付费",
body: "积分仅限平台内消费,不支持提现、转让或折现。充值、套餐、赠送积分的有效期、消耗顺序和退费规则以充值页面展示为准。",
},
{
title: "责任限制",
body: "AI 生成结果可能存在偏差,用户应自行审核输出内容并承担使用后果。因不可抗力、第三方服务异常、网络故障造成的服务中断,平台将在合理范围内修复。",
},
];
const privacyPolicyText = `
隐私政策
更新日期:2026年06月8日
生效日期:2026年06月8日
欢迎您使用本平台服务!
南京万物可爱文化传媒有限公司(以下简称“我们”或“平台”)是Omniai平台的运营者。我们非常重视用户的隐私和个人信息保护。您在使用我们的产品与/或服务时,我们可能会收集和使用您的相关信息。我们希望通过本《隐私政策》(以下简称“本政策”)向您说明,在您使用我们的产品与/或服务时,我们如何收集、使用、保存、共享和转让您的个人信息,以及您所享有的个人信息权利。
本政策与您所使用的我们的产品与/或服务息息相关,请您务必仔细阅读并确认您已经充分理解本政策的内容。一旦您开始使用我们的产品与/或服务,即表示您已充分理解并同意本政策。
本政策将帮助您了解以下内容:
一、本政策的适用范围
二、我们如何收集和使用您的个人信息
三、我们如何使用Cookie和同类技术
四、我们如何共享、转让、公开披露您的个人信息
五、我们如何保护和存储您的个人信息
六、您如何管理您的个人信息
七、我们如何保护未成年人的个人信息
八、本政策如何更新
九、其他声明与责任限制
十、如何联系我们
一、本政策的适用范围
1. 本政策适用于您通过我们的网站、客户端应用程序、小程序以及其他技术形态(包括但不限于SDK、API等方式)访问和使用我们的产品与/或服务。
2. 本政策不适用于以下情况:
(1)我们的产品与/或服务中包含的或链接至第三方提供的信息与/或服务(包括任何第三方应用、网站、产品、服务等)。这些服务由第三方负责运营,您使用该服务适用第三方另行向您说明的个人信息处理规则。
(2)其他非我们向您提供的产品与/或服务。
(3)特别提示:用户间交易。您通过平台社区与其他用户进行作品、素材、版权等交易时,您主动向交易对方提供的个人信息(如联系方式、交付地址等)不适用本政策。我们建议您在交易过程中谨慎保护您的个人信息,并充分了解交易对方的信用状况。
二、我们如何收集和使用您的个人信息
我们会遵循合法、正当、必要、诚信的原则,基于本政策所述的目的,收集和使用您的个人信息。如果我们将您的个人信息用于本政策未载明的其他用途,我们将以合理方式另行向您告知并征得您的同意。
(一)注册、登录与认证
1. 必要信息:当您注册平台账号时,您需要提供您的手机号码及验证码/登录密码。手机号码是履行国家法律法规对网络实名制(真实身份信息认证)要求所需的必要信息,若您不提供,您将无法完成注册,仅能使用浏览、搜索等基本功能。
2. 企业用户:如您以企业身份注册,您还需提供企业名称、营业执照、法定代表人/联系人信息等,以便我们核验您的企业身份,为您提供企业级服务。
3. 第三方账号登录:您可以使用我们认可的第三方账号(如微信、QQ等)进行登录。我们会收集您在该第三方账号下的唯一标识、昵称、头像等信息,用于创建您在本平台的账号。
4. 账号信息完善:您可以选择填写或修改您的昵称、头像、个人简介、所在地区等信息。此类信息非必要,但有助于提升您的社区交互体验。
(二)浏览、搜索与内容发布
1. 内容浏览:当您浏览平台上的模型、素材、作品、文章等内容时,我们会收集您的设备信息、日志信息,包括IP地址、浏览器类型、操作系统版本、访问时间、点击记录、浏览记录、下载记录等。此类信息用于为您提供内容展示、优化推荐算法以及保障服务安全稳定运行。
2. 搜索功能:当您使用搜索服务时,我们会收集您的搜索关键字信息,用于向您展示搜索结果。
3. 内容发布与社区交互:当您在平台社区上传模型、发布作品、发表评论、点赞、收藏、分享时,我们会收集您主动发布的内容(包括但不限于文字、图片、视频、模型文件等)。您发布的内容中会展示您的昵称、头像等信息。
(三)SaaS软件服务
1. 使用SaaS工具:当您使用我们提供的SaaS软件工具(如图片编辑、模型训练、设计工具等)时,您主动上传、输入或导入的内容及指令将被收集,以便为您提供服务。
2. 生成内容:您通过SaaS工具生成的内容,其所有权归您所有。我们会保存该生成内容的记录,以便您进行查看、下载、管理或再次编辑。
3. 模型优化:在经过安全加密技术处理、严格去标识化且无法重新识别特定个人的前提下,我们可能会将您使用SaaS软件过程中产生的脱敏数据用于模型优化和改进服务。您可以通过平台设置选择是否允许我们将您的内容用于此目的。
(四)付费订阅与交易服务
1. 会员订阅:当您购买会员服务(软件订阅费)或充值积分时,我们会收集您的订单信息(包括商品/服务名称、金额、交易时间)和支付信息。支付信息(如银行卡号、第三方支付账号)由第三方支付机构直接收集,我们不会获取您完整的支付敏感信息。
2. 积分消耗:当您使用积分消耗大模型算力或兑换服务时,我们会记录您的积分变动情况。
3. 用户间交易:
(1)当您作为卖方(发布作品/素材进行售卖)时,我们需要收集您的实名认证信息(个人或企业)、收款账户信息(银行卡号或第三方支付账号),以便向您结算交易款项。
(2)当您作为买方(购买作品/素材)时,我们需要收集您的订单信息,并可能向卖方提供您的平台账号信息,以便卖方完成交付。您的真实联系方式(如手机号、地址)不会直接提供给卖方,除非您主动通过平台沟通工具披露。
(五)运营与安全保障
为了维护相关产品或服务的正常稳定运行,保护您或其他用户或公众的安全及合法利益,我们会收集如下必要信息:
1. 设备信息:包括设备型号、操作系统版本、唯一设备标识符、IP地址、MAC地址、WLAN接入点、蓝牙、基站、软件版本号、网络接入方式/类型/状态等。
2. 日志信息:包括您的操作日志、服务日志,例如您对平台功能的点击、使用情况、崩溃数据、异常信息等。
3. 应用信息:用于预防恶意程序、保障运营质量,我们会收集安装的应用列表、软件列表或正在运行的进程信息。
4. IP归属地:根据相关法律法规要求,我们可能会在您的个人主页、作品发布等页面展示您的IP地址归属地信息。
(六)系统权限
为实现特定功能,我们可能会向您申请相机/摄像头、相册/存储、麦克风、通知、剪贴板等系统权限。您可以在设备设置中自主选择开启或关闭这些权限。关闭权限将可能导致对应功能无法使用,但不影响其他功能。
(七)征得授权同意的例外
根据相关法律法规,以下情形中收集您的个人信息无需征得您的授权同意:为订立、履行您作为一方当事人的合同所必需;为履行法定职责或者法定义务所必需;为应对突发公共卫生事件,或者紧急情况下为保护自然人的生命健康和财产安全所必需;为公共利益实施新闻报道、舆论监督等行为,在合理的范围内处理个人信息;依照法律规定在合理的范围内处理您自行公开或者其他已经合法公开的个人信息;法律、行政法规规定的其他情形。
三、我们如何使用Cookie和同类技术
1. Cookie:为使您获得更轻松的访问体验,我们可能会使用Cookie技术收集和存储您的登录状态、浏览偏好、使用习惯等信息。您可以根据自己的偏好管理或删除Cookie,但请注意,禁用Cookie可能会导致您无法正常使用平台的某些功能。
2. 同类技术:我们可能会使用网站信标、像素标签等其他同类技术,用于分析您对我们服务的使用情况、评估广告效果等。
四、我们如何共享、转让、公开披露您的个人信息
(一)共享
我们不会与任何公司、组织和个人共享您的个人信息,但获得您的明确同意、履行法定义务、与授权合作伙伴共享、用户间交易、关联公司共享等情况除外。授权合作伙伴包括支付服务提供商、实名认证服务商、云服务提供商、推送服务提供商、安全与风控服务商等,我们仅共享实现目的所必要的信息。
(二)转让
我们不会将您的个人信息转让给任何公司、组织和个人,但获得您的明确同意后,或在涉及合并、收购、破产清算等情形时除外。如涉及个人信息转让,我们会要求新的持有您个人信息的公司、组织继续受本政策约束,否则将要求其重新向您征求授权同意。
(三)公开披露
我们不会公开披露您的个人信息,但获得您的明确同意后、基于法律司法或行政程序要求、对违规账号或侵权行为进行必要公示等情况除外。
(四)共享、转让、公开披露的例外
在为订立或履行合同所必需、履行法定义务所必需、应对紧急情况、公共利益新闻报道或舆论监督、处理您自行公开或其他已合法公开信息、法律行政法规规定的其他情形中,共享、转让、公开披露您的个人信息无需事先征得您的授权同意。
五、我们如何保护和存储您的个人信息
1. 存储地点:我们在中华人民共和国境内运营中收集和产生的个人信息,将存储在中华人民共和国境内。我们不会将您的个人信息传输至境外。
2. 存储期限:我们仅在为实现本政策所述目的所必需的最短期限内保留您的个人信息,除非法律法规有更强的存留要求。超出存储期限后,我们将对您的个人信息进行删除或匿名化处理。
3. 保护措施:我们采用SSL加密、数据脱敏、访问控制、入侵检测等符合行业标准的安全技术措施,保护您的个人信息免遭泄露、篡改、毁损或未经授权的访问。我们建立数据安全管理制度,与员工签署保密协议,定期开展安全审计。一旦发生个人信息安全事件,我们将依法及时告知您,并向监管部门报告。
六、您如何管理您的个人信息
您可以通过平台账号功能或联系我们,访问、更正、补充、删除您的个人信息,撤回同意或申请注销账号。账号注销后,我们将删除或匿名化处理您的个人信息(法律法规另有规定的除外),您将无法再使用该账号登录平台。对于合理的请求,我们原则上不收取费用;对于无端重复、超出合理限度的请求,我们可能会拒绝或收取合理成本费用。
七、我们如何保护未成年人的个人信息
1. 平台主要面向成年人。如您为18周岁以下的未成年人,应在您的父母或其他监护人监护、指导下共同阅读并同意本政策。
2. 如您为14周岁以下的儿童,请务必在监护人明确同意的前提下使用我们的服务。我们将只会在法律法规允许、监护人明确同意或保护儿童所必要的情况下收集、使用儿童的个人信息。
3. 如我们发现未经监护人同意收集了儿童个人信息,我们将尽快删除相关数据。
八、本政策如何更新
我们可能会根据法律法规变化、产品功能调整或业务发展需要适时修订本政策。本政策更新后,我们会在平台显著位置发布更新版本,并在生效前通过公告、站内信、弹窗等方式通知您。如您继续使用我们的服务,即表示您同意受修订后的本政策约束。
九、其他声明与责任限制
为提供更好的服务,我们的产品中可能包含第三方SDK。第三方SDK可能会收集您的设备信息、网络信息等,具体请查阅我们在平台公示的《第三方信息共享清单》。本政策适用中华人民共和国大陆地区法律。因本政策引起的任何争议,双方应友好协商解决;协商不成的,任何一方均有权将争议提交至本平台运营主体所在地有管辖权的人民法院诉讼解决。
十、如何联系我们
如您对本政策有任何疑问、意见或建议,或需要进行投诉、举报,您可以通过以下方式与我们联系:
客服邮箱:system@omniai.net.cn
客服电话:15155073618
邮寄地址:江苏省南京市江北新区扬子江数字视听产业园9栋A楼9A-501
我们将在15个工作日内回复您的请求。
`.trim();
const privacySections = [
{
title: "收集的信息",
body: "我们会收集账号信息、登录状态、联系方式、创作输入、生成结果、用量记录、设备与网络日志,用于提供服务、安全审计和问题排查。",
},
{
title: "Cookie 与本地存储",
body: "我们使用 Cookie、localStorage 和 sessionStorage 保存登录状态、偏好设置、Cookie 同意状态、创作草稿和断点续传数据。",
},
{
title: "信息使用",
body: "信息用于身份验证、生成任务处理、资产管理、积分计费、客服支持、风控合规、服务优化和法律法规要求的备案审计。",
},
{
title: "第三方处理",
body: "为完成 AI 生成、对象存储、短信邮件、支付或错误监控,我们可能向必要的第三方服务提供最小范围数据,并要求其按约定保护数据安全。",
},
{
title: "用户权利",
body: "你可以通过平台账号功能或联系方式申请访问、更正、删除个人信息,或撤回非必要授权。法律法规另有要求的记录可能需按规定保留。",
},
];
const agreementText = `
Omniai平台用户协议
更新日期:2026年06月8日
生效日期:2026年06月8日
欢迎您使用本平台服务。
本平台向您提供SaaS软件服务、社区交流、素材及作品交易等相关服务。欢迎您与南京万物可爱文化传媒有限公司(以下简称“我们”或“平台”)共同签署本《用户服务协议》(下称“本协议”),并使用Omniai平台服务!
在您注册成为平台用户前,请您务必审慎阅读、充分理解本协议的全部内容,特别是免除或限制责任、法律适用和争议解决条款。如您对本协议内容有任何疑问,可通过平台客服进行咨询。如您未满18周岁,请在法定监护人陪同下仔细阅读并充分理解本协议,并征得监护人的同意后使用本平台服务。
当您点击“同意”本协议、完成注册程序,或实际开始使用平台服务时,即表示您已充分阅读、理解并接受本协议的全部内容,并与我们达成一致,本协议即对您产生法律约束力。如您不同意本协议的任何条款,请立即停止注册或使用行为。
我们可能不时修改本协议及相关平台规则,并通过网站公告、站内信等方式进行通知。若您在本协议修改后继续使用服务,即视为您已接受修改后的协议。
第一章 定义
1. 本平台:指由南京万物可爱文化传媒有限公司拥有并运营的,向用户提供SaaS软件工具、社区互动、内容上传、展示、分享、付费下载及交易等功能的网站、客户端应用程序及其他技术服务平台。
2. SaaS服务:指我们基于软件即服务模式,向您提供的在线软件工具及相关技术服务,您可能需要支付软件订阅费(会员费)后方可使用全部或部分功能。
3. 社区服务:指我们在平台上为用户提供的,用于发布、展示、交流、分享及交易作品、素材、版权等内容的空间与功能。
4. 平台规则:指我们已经发布或后续可能发布、修改的与本平台相关的所有协议、政策、活动规则、公告、说明、站内信通知等,以及《隐私政策》等,均构成本协议不可分割的组成部分。
5. 用户:指使用本平台服务的任何自然人或组织,包括企业用户与个人用户,合称“您”。
6. 内容:指用户通过本平台上传、发布、生成、展示、交易的全部信息,包括但不限于模型、软件、素材、图片、视频、音频、文字、代码、设计图、评论等。
第二章 账号的注册、使用与管理
1. 账号注册
(1)您在使用本平台服务前,需通过实名手机号或我们认可的第三方账号进行注册。企业用户还应提供真实、有效的企业营业执照等信息。您在注册或使用Omniai平台服务时可能需要提供一些必要的信息,为保证您享用的平台服务安全有效且不断优化,您同意授权我们对您的必要个人信息进行验证和合理使用。您须保证所填写及提供的资料真实、准确、完整、合法有效。
(2)您注册成功的账号仅限您本人/本企业自身正当使用,禁止以任何形式赠与、借用、出租、转让、售卖或授权他人使用该账号。
(3)特别提示:禁止恶意注册。您不得恶意批量注册账号,不得利用多个账号或其他技术手段实施干扰平台运营、规避平台规则、获取不当利益的行为。一经发现,我们有权立即冻结或收回相关账号,并追究您的违约责任。
(4)如您提供任何违法、不实或我们认为不适合的资料,或我们有理由怀疑您的行为属于恶意操作,我们有权暂停或终止您的账号。
(5)我们及Omniai平台无须对任何用户的任何登记资料承担任何责任,包括但不限于鉴别、核实任何登记资料的真实性、正确性、完整性、适用性及是否为最新资料的责任。
2. 账号安全
(1)您应对账号及密码/验证码的安全性负完全责任,并对该账号下进行的所有活动承担责任。您应高度重视对账号与验证码的保密,在任何情况下不向他人透露账号及验证码。您的账号遭到未获授权的使用,或者发生其它任何安全问题时,您应立即通知我们。
(2)我们发现或合理认为使用者并非账号初始注册人,为保障账号安全,有权立即暂停或终止提供服务,并永久禁用该账号。
3. 账号注销与回收
(1)您可以按照平台公示的方式申请注销账号。账号注销后,我们将对账号内信息进行删除或匿名化处理,法律法规另有规定的除外。
(2)如您的账号连续六个月以上未登录,我们有权冻结、收回或者注销该账号。
第三章 平台服务与使用规范
1. 服务内容
(1)SaaS软件服务:我们向您提供在线SaaS软件工具,您可按需选择免费版或付费版(会员费)。使用部分高级功能或消耗大模型算力时,可能需要消耗积分,积分可通过充值等方式获得。
(2)社区服务:平台提供作品、素材、版权的上传、展示、付费下载、互动交流等服务。用户间可通过平台进行相关版权或素材的交易。
(3)交易手续费:为维持平台运营,对于用户间通过平台达成的交易,我们可能会向卖方或买方收取一定比例的交易手续费,具体费率以平台页面公示为准。
2. 用户行为规范:严禁侵权与违法内容
(1)您承诺,您在使用平台SaaS软件创作、或上传、发布、交易的所有内容,均由您原创或已获得合法、完整的授权,不存在侵犯任何第三方知识产权、肖像权、名誉权、隐私权等合法权益的情形。
(2)高风险警示:您不得使用本平台创作、上传、发布、传播任何违反中华人民共和国法律法规、社会主义制度、国家利益、社会公序良俗或包含淫秽、色情、暴力、赌博、恐怖、侮辱、诽谤、虚假信息、扰乱社会秩序等任何不良内容的信息或作品。
(3)您不得利用本平台进行任何危害网络安全的行为,包括但不限于使用插件、外挂、爬虫、病毒、反向工程、干扰系统正常运行等。
(4)您不得利用本平台实施洗钱、套现、诈骗、赌博等违法活动。
3. 禁止恶意“薅羊毛”与不正当竞争
您不得通过批量注册账号、使用外挂程序、虚构交易、虚假评价、套取平台补贴或奖励等方式,获取不当商业利益或损害平台及其他用户的权益。一经发现,平台有权扣划不当得利、限制或封禁账号、追究违约责任。
AI内容生成特别提示:您理解并同意,本平台提供的SaaS软件中可能包含基于人工智能技术的内容生成功能。鉴于AI技术的局限性,生成内容可能存在不确定性或不准确性。您应自行对生成内容进行审核和判断,并对其使用承担全部责任。我们不对生成内容的合法性、准确性、完整性、不侵权性作任何保证。
第四章 知识产权
1. 平台的知识产权
本平台软件、代码、界面设计、商标、标识等知识产权归我们所有,未经书面许可,您不得进行复制、修改、反向编译或用于任何商业目的。
2. 您的内容的知识产权
(1)权利归属:您通过本平台上传、发布的内容及使用SaaS软件独立创作生成的内容,其知识产权归您或原权利人所有。您对您的内容承担全部责任。
(2)授权许可:为了使您的内容能够在平台上进行展示、传播、交易,并为了我们能够持续改进SaaS软件和社区服务,您授予我们一项全球范围内、免费的、非排他性的、可分许可的权利,允许我们在平台及相关业务中使用、复制、修改、改编、分发您公开发布的内容。如果您不希望我们使用您的内容用于模型训练或改进,您可以通过平台设置或联系客服关闭此选项。对于您设置为非公开或仅用于交易的内容,我们将采取更严格的保护措施。
(3)交易授权:您通过平台社区进行作品、素材、版权的付费下载或交易,即授予购买方一项按照交易约定范围使用该内容的许可。您作为卖方,应清晰标识授权范围,并对授权内容的真实性和合法性承担全部责任。
3. 侵权内容处理
我们尊重他人知识产权,并已建立侵权投诉处理机制。任何第三方认为平台上的内容侵犯其合法权益的,可按照平台公示的投诉渠道提交书面通知及初步侵权证据。我们将在收到合格通知后,依法采取删除、屏蔽、断开链接等必要措施,并通知相关用户。对于重复侵权、恶意侵权或情节严重的用户,我们有权直接终止向其提供服务,并永久封禁其账号。
第五章 社区交易与责任限制
1. 交易主体与责任承担
(1)您充分理解并同意,本平台仅为用户提供作品、素材、版权的信息发布、展示、下载、支付结算等交易技术支持服务。平台不是交易合同的任何一方当事人,也并非作品的卖方或买方。
(2)核心免责声明:平台对于用户发布、展示、交易的内容的合法性、真实性、准确性、完整性、安全性、质量、是否侵权以及交易的履行等,不承担任何事先审查或担保责任。您与其他用户之间因内容交易产生的任何纠纷,均由交易双方自行协商解决,或通过司法、行政途径解决。
(3)如我们收到司法机关或行政机关的有效法律文书,或认为确有必要时,我们可以采取临时性措施以维护各方权益。
2. 社区内容的管理权利
我们有权依据法律法规、本协议及平台规则,对平台社区内的内容进行主动巡查。如发现或收到举报证明内容涉嫌侵权、违法或违反本协议,我们有权在不事先通知的情况下,直接删除、屏蔽相关内容,并对相关用户采取警示、限制功能、暂停服务或封禁账号等措施。
第六章 费用与支付
1. 软件订阅费(会员费):您购买会员服务时,应按照平台页面公示的价格和期限支付费用。会员服务为虚拟产品,一经开通,除法律另有规定或平台规则另有说明外,原则上不予退款。
2. 积分充值:积分用于消耗平台算力或兑换特定服务。充值后积分有效期以平台公示为准。平台有权根据运营情况调整积分获取或消耗规则,但会提前公告。
3. 交易手续费:平台有权就用户间通过平台达成的交易收取手续费,费率在交易前明确告知。平台有权根据市场情况调整手续费率,通过公告方式通知后生效。
4. 税费:您使用本平台所获得的收入(如售卖素材所得),根据中国法律规定,您应自行申报并缴纳相关税费。平台按照法律规定履行代扣代缴义务(如适用)。
第七章 违约与处理
1. 违约认定:发生违反本协议或平台规则、侵犯第三方权益、恶意批量注册、虚假交易、套取利益或其他违反法律法规并造成损害的行为,视为您违约。
2. 处理措施:我们有权独立判断并采取警告、拒绝发布、删除内容、限制使用功能、暂停服务、扣划不当得利、冻结或永久封禁账号、追究赔偿等一项或多项措施。
3. 赔偿责任:如因您的行为导致我们或第三方遭受任何损失,您应足额赔偿。
第八章 免责声明与责任限制
1. 服务现状:我们的服务按“现状”和“可得到”的状态提供,我们不作出任何明示或暗示的保证,包括但不限于服务无间断、无错误、安全可靠、适用于特定目的等。
2. AI生成内容免责:鉴于人工智能技术的局限性,我们无法保证通过SaaS软件生成内容的真实性、准确性、独创性及不侵权性。您应当对生成内容自行加以判断和验证,并承担使用该等内容所产生的全部风险与责任。
3. 不可抗力及第三方原因:因自然灾害、战争、政府行为、电力或通讯故障、第三方攻击等不可抗力或非我们故意或重大过失的原因导致的损失,我们不承担责任。
4. 服务中断与变更:我们可能会对平台进行升级、维护或修改,由此导致的服务中断或功能变更,我们将提前通过合理方式通知您。但我们不对因上述必要维护导致的任何损失承担责任,法律另有强制性规定的除外。
5. 第三方链接与内容:平台可能包含指向第三方网站或资源的链接。我们不对这些第三方网站或资源的可用性、内容、产品或服务承担任何责任。您访问第三方网站或使用第三方服务所产生的一切风险由您自行承担。
6. 责任上限:在法律允许的最大范围内,我们对于您因使用平台服务而遭受的任何间接、附带、特殊、惩罚性损失,即使已被告知可能发生该等损失,也不承担任何责任。我们的全部赔偿责任总额,不超过您在过去十二个月内向平台支付的费用总额。
第九章 个人信息保护
1. 我们非常重视您的个人信息保护。我们将按照平台公布的《隐私政策》收集、使用、存储、共享和保护您的个人信息。请您务必仔细阅读《隐私政策》。
2. 您同意我们根据《隐私政策》以及相关法律法规的要求处理您的个人信息。
3. 您知悉并同意,为方便您使用Omniai相关服务,我们有权在遵守法律法规的前提下,以明示的方式获取、使用、储存和分享您的个人信息。我们不会在未经您授权时,公开、编辑或透露您的个人信息及您保存在Omniai的非公开内容。
4. 您知悉并同意,我们有权通过cookie等技术收集您的产品或服务使用、行为数据,并在经过数据脱敏使之不再指向或关联到您个人身份信息时,自由使用脱敏后的纯商业数据。
第十章 协议的修改与终止
1. 协议的修改:我们有权根据法律法规变化或业务需要修改本协议。修改后的协议将在平台公示或以其他方式通知您。如您不接受修改后的协议,您应停止使用平台服务;如您继续使用,则视为同意接受修改后的协议。
2. 协议的终止:您可以申请注销账号终止本协议。我们有权在您违约或出现其他法定、约定情形时终止本协议。协议终止后,您仍需对协议终止前的行为承担责任。
第十一章 法律适用与争议解决
1. 本协议的订立、生效、履行、解释及争议解决,均适用中华人民共和国大陆地区法律。
2. 凡因本协议引起的或与本协议有关的任何争议,双方应首先友好协商解决;协商不成的,任何一方均有权将争议提交至本平台运营主体所在地南京市浦口区有管辖权的人民法院通过诉讼解决。
第十二章 其他
1. 本协议条款标题仅为方便阅读而设,不影响条款含义的解释。
2. 本协议任何条款被认定为无效或不可执行,不影响其他条款的效力。
3. 如您对本协议有任何疑问、意见或建议,或需要投诉举报,您可以通过system@omniai.net.cn与我们联系。
4. 兜底免责声明:在法律法规允许的最大范围内,本协议未明确列明的、或因超出我们合理预见或控制范围所产生的任何直接或间接损失、责任或风险,我们均不承担责任,除非相关法律另有强制性规定。您理解并同意,使用本平台服务的风险由您自行承担,我们仅以普通注意义务对平台进行管理,不对任何未明确约定的商业成果或安全性作出保证。
(以下无正文)
`.trim();
function getDocumentLines(text: string) {
return text.split(/\n+/).map((line) => line.trim()).filter(Boolean);
}
function getLineClassName(line: string, index: number) {
if (index === 0) return "compliance-document__title";
if (/^(第[一二三四五六七八九十]+章|[一二三四五六七八九十]+、)/.test(line)) return "compliance-document__heading";
if (/^[一二三四五六七八九十]+/.test(line)) return "compliance-document__subheading";
if (/^[0-9]+\./.test(line) || /^[0-9]+/.test(line) || /^·/.test(line)) return "compliance-document__clause";
return "compliance-document__paragraph";
}
export default function CompliancePage({ kind }: CompliancePageProps) {
const isPrivacy = kind === "privacy";
const sections = isPrivacy ? privacySections : agreementSections;
const title = isPrivacy ? "隐私政策" : "用户协议";
const Icon = isPrivacy ? SafetyOutlined : FileTextOutlined;
const lines = getDocumentLines(isPrivacy ? privacyPolicyText : agreementText);
return (
<section className="compliance-page">
@@ -71,27 +223,26 @@ export default function CompliancePage({ kind }: CompliancePageProps) {
<div>
<span className="compliance-hero__eyebrow"></span>
<h1>{title}</h1>
<p>{companyName} 2026 6 3 </p>
<p>{companyName}{platformName}{effectiveDate}</p>
</div>
</header>
<div className="compliance-card">
{sections.map((section, index) => (
<article key={section.title} className="compliance-section">
<span>{String(index + 1).padStart(2, "0")}</span>
<div>
<h2>{section.title}</h2>
<p>{section.body}</p>
</div>
</article>
))}
</div>
<article className="compliance-card compliance-document">
{lines.map((line, index) => {
const className = getLineClassName(line, index);
if (className === "compliance-document__title") return <h2 key={`${index}-${line}`} className={className}>{line}</h2>;
if (className === "compliance-document__heading") return <h3 key={`${index}-${line}`} className={className}>{line}</h3>;
if (className === "compliance-document__subheading") return <h4 key={`${index}-${line}`} className={className}>{line}</h4>;
return <p key={`${index}-${line}`} className={className}>{line}</p>;
})}
</article>
<footer className="compliance-contact">
<strong></strong>
<span>{contactEmail}</span>
<span>{address}</span>
<span>{contactPhone}</span>
<span>ICP备2026021747号-1</span>
<span>{icpRecord}</span>
</footer>
</div>
</section>
+1
View File
@@ -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";
+259 -34
View File
@@ -37,7 +37,7 @@ import {
import "../../styles/pages/workbench.css";
import type { WebGenerationPreviewTask, WebUserSession } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { claimGenerationSlot, getActiveGenerationTaskCount, getGenerationUserKey, releaseGenerationSlot } from "../../api/generationConcurrency";
import { claimGenerationSlot, getActiveGenerationTaskCount, getEffectiveGenerationLimit, getGenerationUserKey, releaseGenerationSlot } from "../../api/generationConcurrency";
import { preUploadReference, resolvePreUploadedUrl } from "../../api/referenceUploadService";
import { assetClient } from "../../api/assetClient";
import { communityClient } from "../../api/communityClient";
@@ -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,
@@ -201,6 +202,7 @@ interface WorkbenchPageProps {
onRequireLogin: (input: CreatePreviewTaskInput) => void;
onOpenResultInCanvas?: (payload: import("./workbenchConstants").WorkbenchResultActionPayload) => void;
onRefreshUsage?: () => void;
resetToken?: number;
}
// ─── Component ───────────────────────────────────────────────────────────
@@ -220,12 +222,19 @@ const MODE_ICONS: Record<WorkbenchMode, ReactNode> = {
video: <VideoCameraOutlined />,
};
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,
onRequireLogin,
onOpenResultInCanvas,
onRefreshUsage,
resetToken,
}: WorkbenchPageProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const referenceInputRef = useRef<HTMLInputElement | null>(null);
@@ -244,10 +253,11 @@ function WorkbenchPage({
const activeConversationIdRef = useRef<number | null>(null);
const messagesRef = useRef<ChatMessage[]>([]);
const conversationMessagesCacheRef = useRef<Map<number, ChatMessage[]>>(new Map());
const skipConversationAutoSelectRef = useRef(false);
const skipConversationAutoSelectRef = useRef(Boolean(resetToken));
const keepaliveTasksRef = useRef<Record<string, WorkbenchKeepaliveTask>>(readStoredKeepaliveTasks());
const taskAbortControllersRef = useRef<Map<string, AbortController>>(new Map());
const lastScrollTopRef = useRef(0);
const scrollActionHintTimerRef = useRef<number | null>(null);
const shouldFollowNewMessagesRef = useRef(true);
const pendingScrollToLatestRef = useRef(true);
const genTracker = useGenerationTasks({ sourceView: "workbench" });
@@ -256,7 +266,7 @@ function WorkbenchPage({
const [activeMode, setActiveMode] = useState<WorkbenchMode>("video");
const [inputValue, setInputValue] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>(() => readStoredMessages());
const [messages, setMessages] = useState<ChatMessage[]>(() => (resetToken ? [] : readStoredMessages()));
const [promptHistory, setPromptHistory] = useState<string[]>(() => readStoredPromptHistory());
const [toolbarMenuId, setToolbarMenuId] = useState<ToolbarMenuId>(null);
const [referenceItems, setReferenceItems] = useState<ReferenceItem[]>([]);
@@ -279,7 +289,7 @@ function WorkbenchPage({
const [projectError, setProjectError] = useState<string | null>(null);
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [activeConversationId, setActiveConversationId] = useState<number | null>(() =>
readStoredActiveConversationId(readStoredMessages()),
resetToken ? null : readStoredActiveConversationId(readStoredMessages()),
);
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
const [deleteDialog, setDeleteDialog] = useState<DeleteDialogState | null>(null);
@@ -289,7 +299,9 @@ function WorkbenchPage({
const [promptSelectionRange, setPromptSelectionRange] = useState({ start: 0, end: 0 });
const [mentionActiveIndex, setMentionActiveIndex] = useState(0);
const [composerHidden, setComposerHidden] = useState(false);
const [scrollActionHint, setScrollActionHint] = useState<"top" | "bottom" | null>(null);
const [workspaceStarted, setWorkspaceStarted] = useState(false);
const lastResetTokenRef = useRef(resetToken);
useEffect(() => {
activeConversationIdRef.current = activeConversationId;
@@ -415,7 +427,6 @@ function WorkbenchPage({
const toolTheme = MODE_META[activeMode];
const workbenchAccent = "#00ff88";
const hasConversationRecords = activeConversationId !== null || messages.length > 0;
const hasActivatedWorkspace = workspaceStarted || isGenerating || hasConversationRecords;
const referenceCount = referenceItems.length;
const activeVideoModelValue = toHappyHorseDisplayModel(videoModel);
const activeModelValue =
@@ -443,6 +454,7 @@ function WorkbenchPage({
[conversations],
);
const hasSidebarRecords = conversationRecords.length > 0;
const hasActivatedWorkspace = workspaceStarted || isGenerating || hasConversationRecords;
const activeConversationTitle = useMemo(() => {
if (!activeConversationId) return "";
@@ -459,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";
@@ -496,6 +569,31 @@ function WorkbenchPage({
});
}, []);
const hideScrollActionHint = useCallback(() => {
if (scrollActionHintTimerRef.current !== null) {
window.clearTimeout(scrollActionHintTimerRef.current);
scrollActionHintTimerRef.current = null;
}
setScrollActionHint(null);
}, []);
const showScrollActionHint = useCallback((direction: "top" | "bottom") => {
if (scrollActionHintTimerRef.current !== null) {
window.clearTimeout(scrollActionHintTimerRef.current);
}
setScrollActionHint(direction);
scrollActionHintTimerRef.current = window.setTimeout(() => {
setScrollActionHint(null);
scrollActionHintTimerRef.current = null;
}, 1400);
}, []);
useEffect(() => () => {
if (scrollActionHintTimerRef.current !== null) {
window.clearTimeout(scrollActionHintTimerRef.current);
}
}, []);
const imageSettingGroups = useMemo<WorkbenchFieldGroup[]>(
() => [
{
@@ -890,6 +988,54 @@ function WorkbenchPage({
persistKeepaliveTasks(rest);
};
const releaseKeepaliveTaskLocally = useCallback((taskId: string, options?: { cancelServer?: boolean }) => {
const task = keepaliveTasksRef.current[taskId];
taskAbortControllersRef.current.get(taskId)?.abort();
taskAbortControllersRef.current.delete(taskId);
removeKeepaliveTask(taskId);
if (task && options?.cancelServer) {
aiGenerationClient.cancelTask(task.taskId).catch(() => {});
}
syncActiveGenerationUi();
}, [syncActiveGenerationUi]);
const releaseKeepaliveTaskAfterNetworkLoss = useCallback((task: WorkbenchKeepaliveTask, progress: number) => {
const latestTask = {
...task,
progress,
statusLabel: "网络中断,已释放提交按钮",
};
void patchConversationMessage(task.conversationId, task.assistantMessageId, {
status: "failed",
taskProgress: Math.max(progress, 100),
taskStatusLabel: "网络中断",
body: "网络中断,当前任务已停止等待并释放提交按钮。请确认网络恢复后重新提交任务。",
});
upsertKeepaliveTask(latestTask);
releaseKeepaliveTaskLocally(task.taskId, { cancelServer: true });
if (activeConversationIdRef.current === task.conversationId) {
setIsGenerating(false);
setGenerationStatus("网络中断,已释放提交按钮");
setGenerationProgress(0);
}
}, [patchConversationMessage, releaseKeepaliveTaskLocally]);
const cancelActiveKeepaliveTasksOnPageExit = useCallback(() => {
const tasks = Object.values(keepaliveTasksRef.current);
if (!tasks.length) return;
tasks.forEach((task) => {
taskAbortControllersRef.current.get(task.taskId)?.abort();
taskAbortControllersRef.current.delete(task.taskId);
releaseGenerationSlot(task.concurrencySlotId);
aiGenerationClient.cancelTaskOnUnload(task.taskId);
});
keepaliveTasksRef.current = {};
persistKeepaliveTasks({});
setIsGenerating(false);
setGenerationStatus("已释放未完成任务");
setGenerationProgress(0);
}, []);
const runKeepalivePoll = useCallback(
(task: WorkbenchKeepaliveTask) => {
if (taskAbortControllersRef.current.has(task.taskId)) return;
@@ -916,6 +1062,10 @@ function WorkbenchPage({
if (abortController.signal.aborted) return;
if (attempt > 0) await sleep(3000);
if (abortController.signal.aborted) return;
if (typeof navigator !== "undefined" && navigator.onLine === false) {
releaseKeepaliveTaskAfterNetworkLoss(task, lastKnownProgress);
return;
}
let status;
try {
@@ -929,7 +1079,8 @@ function WorkbenchPage({
taskProgress: 100,
taskStatusLabel: "任务异常",
});
removeKeepaliveTask(task.taskId);
releaseKeepaliveTaskLocally(task.taskId, { cancelServer: true });
onRefreshUsage?.();
return;
}
continue;
@@ -1128,9 +1279,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;
@@ -1151,6 +1308,24 @@ function WorkbenchPage({
};
}, [runKeepalivePoll]);
useEffect(() => {
const handlePageHide = () => {
cancelActiveKeepaliveTasksOnPageExit();
};
const handleOnline = () => {
Object.values(keepaliveTasksRef.current).forEach((task) => runKeepalivePoll(task));
syncActiveGenerationUi();
};
window.addEventListener("pagehide", handlePageHide);
window.addEventListener("online", handleOnline);
aiGenerationClient.flushPendingTaskCancellations();
return () => {
window.removeEventListener("pagehide", handlePageHide);
window.removeEventListener("online", handleOnline);
};
}, [cancelActiveKeepaliveTasksOnPageExit, runKeepalivePoll, syncActiveGenerationUi]);
useEffect(() => {
persistPromptHistory(promptHistory);
}, [promptHistory]);
@@ -1266,6 +1441,12 @@ function WorkbenchPage({
activeConversationIdRef.current = null;
}, [syncActiveGenerationUi]);
useEffect(() => {
if (resetToken === undefined || lastResetTokenRef.current === resetToken) return;
lastResetTokenRef.current = resetToken;
handleNewConversation();
}, [handleNewConversation, resetToken]);
const handleSelectProject = useCallback((id: string) => {
if (!id) {
handleNewConversation();
@@ -1420,6 +1601,7 @@ function WorkbenchPage({
const atBottom = top + surface.clientHeight >= surface.scrollHeight - edgeThreshold;
shouldFollowNewMessagesRef.current = atBottom;
setComposerHidden(!(atTop || atBottom));
hideScrollActionHint();
lastScrollTopRef.current = top;
};
@@ -1434,24 +1616,27 @@ function WorkbenchPage({
shouldFollowNewMessagesRef.current = atBottom;
if (atTop || atBottom) {
setComposerHidden(false);
hideScrollActionHint();
} else if (Math.abs(delta) > scrollDeltaThreshold) {
setComposerHidden(true);
showScrollActionHint(delta < 0 ? "top" : "bottom");
}
lastScrollTopRef.current = top;
};
surface.addEventListener("scroll", handleScroll, { passive: true });
return () => surface.removeEventListener("scroll", handleScroll);
}, [hasActivatedWorkspace]);
}, [hasActivatedWorkspace, hideScrollActionHint, showScrollActionHint]);
const scrollMessagesSurface = useCallback((direction: "top" | "bottom") => {
const surface = messagesSurfaceRef.current;
if (!surface) return;
const top = direction === "top" ? 0 : surface.scrollHeight;
hideScrollActionHint();
setComposerHidden(false);
surface.scrollTo({ top, behavior: "smooth" });
}, []);
}, [hideScrollActionHint]);
const closeToolbarMenus = () => setToolbarMenuId(null);
const toggleToolbarMenu = (menuId: Exclude<ToolbarMenuId, null>) => {
@@ -1771,7 +1956,7 @@ function WorkbenchPage({
const trimmedPrompt = (promptOverride ?? inputValue).trim();
if (!trimmedPrompt) return;
const userKey = getGenerationUserKey(session?.user.id);
if (activeMode !== "chat" && getActiveGenerationTaskCount(userKey) >= 3) return;
if (activeMode !== "chat" && getActiveGenerationTaskCount(userKey) >= getEffectiveGenerationLimit()) return;
setReferencePreviewOpen(false);
let conversationId = activeConversationIdRef.current ?? activeConversationId;
@@ -2250,8 +2435,11 @@ function WorkbenchPage({
setProjectError("仅支持对视频结果进行超分");
return;
}
if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) {
setProjectError(`当前已有 ${getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id))} 个任务进行中(上限3个),请等待任一任务完成后再提交新任务`);
const userKey = getGenerationUserKey(session?.user.id);
const activeCount = getActiveGenerationTaskCount(userKey);
const limit = getEffectiveGenerationLimit();
if (activeCount >= limit) {
setProjectError(`当前已有 ${activeCount} 个任务进行中(上限${limit}个),请等待任一任务完成后再提交新任务`);
return;
}
if (!isAuthenticated) {
@@ -2372,8 +2560,11 @@ function WorkbenchPage({
setProjectError("仅支持对图片结果进行超分");
return;
}
if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) {
setProjectError(`当前已有 ${getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id))} 个任务进行中(上限3个),请等待任一任务完成后再提交新任务`);
const userKey = getGenerationUserKey(session?.user.id);
const activeCount = getActiveGenerationTaskCount(userKey);
const limit = getEffectiveGenerationLimit();
if (activeCount >= limit) {
setProjectError(`当前已有 ${activeCount} 个任务进行中(上限${limit}个),请等待任一任务完成后再提交新任务`);
return;
}
if (!isAuthenticated) {
@@ -2545,7 +2736,16 @@ function WorkbenchPage({
}
};
const sendDisabled = !inputValue.trim() || (activeMode !== "chat" && getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3);
const activeGenerationCount = getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id));
const activeGenerationLimit = getEffectiveGenerationLimit();
const generationLimitReached = activeMode !== "chat" && activeGenerationCount >= activeGenerationLimit;
const promptIsEmpty = !inputValue.trim();
const sendDisabled = promptIsEmpty || generationLimitReached;
const sendButtonTitle = promptIsEmpty
? "输入内容后可发送"
: generationLimitReached
? `当前已有 ${activeGenerationCount} 个任务进行中(上限 ${activeGenerationLimit} 个),请等待任一任务完成`
: billingEstimate.title;
const suggestedPrompts = [
{ text: "画一个赛博朋克风格的城市夜景", mode: "image" as WorkbenchMode },
@@ -2810,7 +3010,7 @@ function WorkbenchPage({
<SelectChip
chipId="image-grid-mode"
value={imageGridMode}
options={GRID_MODE_OPTIONS}
options={imageGridModeOptions}
disabled={disabled}
isOpen={toolbarMenuId === "image-grid-mode"}
onToggle={() => toggleToolbarMenu("image-grid-mode")}
@@ -2882,10 +3082,15 @@ function WorkbenchPage({
)}
</div>
<div className="wb-composer__toolbar-right">
<span className="wb-composer__billing-estimate" title={billingEstimate.title}>
{billingEstimate.label}
</span>
<button
type="button"
className={`wb-composer__send-primary${isGenerating ? " is-loading" : ""}`}
disabled={sendDisabled || isGenerating}
title={isGenerating ? "任务处理中" : sendButtonTitle}
aria-label={isGenerating ? "任务处理中" : sendButtonTitle}
onClick={() => {
if (getCachedRole() === "admin") console.log("[ai/workbench-send-click]", {
mode: activeMode,
@@ -2933,9 +3138,29 @@ function WorkbenchPage({
</div>
) : null;
const renderPromptCaseOverlay = () =>
selectedPromptCase ? (
<div className="wb-prompt-case-modal" role="dialog" aria-modal="true" aria-labelledby="wb-prompt-case-title">
const renderPromptCaseOverlay = () => {
if (!selectedPromptCase) return null;
const measuredRatio = promptCaseMeasuredRatios[selectedPromptCase.id];
const ratioParts = selectedPromptCase.ratio.replace(/\s+/g, "").split(":").map(Number);
const declaredRatio =
ratioParts.length === 2 && ratioParts[0] > 0 && ratioParts[1] > 0
? ratioParts[0] / ratioParts[1]
: null;
const caseRatio =
typeof measuredRatio === "number" && Number.isFinite(measuredRatio) && measuredRatio > 0
? measuredRatio
: declaredRatio;
const copyLength = `${selectedPromptCase.summary} ${selectedPromptCase.prompt}`.length;
const modalClassName = [
"wb-prompt-case-modal",
caseRatio && caseRatio < 0.72 ? "is-tall-media" : "",
caseRatio && caseRatio >= 0.72 && caseRatio < 1 ? "is-portrait-media" : "",
copyLength > 260 ? "is-long-copy" : "",
].filter(Boolean).join(" ");
return (
<div className={modalClassName} role="dialog" aria-modal="true" aria-labelledby="wb-prompt-case-title">
<button
type="button"
className="wb-prompt-case-modal__backdrop"
@@ -2944,7 +3169,11 @@ function WorkbenchPage({
/>
<section className="wb-prompt-case-modal__panel">
<div className="wb-prompt-case-modal__media">
<img src={selectedPromptCase.imageUrl} alt={selectedPromptCase.title} />
<img
src={selectedPromptCase.imageUrl}
alt={selectedPromptCase.title}
onLoad={(event) => handlePromptCaseImageLoad(selectedPromptCase.id, event)}
/>
</div>
<aside className="wb-prompt-case-modal__sidebar">
<button
@@ -2984,7 +3213,8 @@ function WorkbenchPage({
</aside>
</section>
</div>
) : null;
);
};
if (!hasActivatedWorkspace) {
return (
@@ -3079,8 +3309,8 @@ function WorkbenchPage({
</div>
</div>
{renderConversationSidebar()}
</div>
{renderConversationSidebar()}
{renderMessagePreviewOverlay()}
{renderPromptCaseOverlay()}
{renderDeleteDialog()}
@@ -3166,11 +3396,6 @@ function WorkbenchPage({
<span>{message.taskStatusLabel || generationStatus}</span>
</div>
)}
{message.role === "assistant" && message.mode === "chat" && message.status === "completed" && (
<div className="ai-chat-task-billing-note">
{formatTextTokenUsage(message.taskUsage)}
</div>
)}
{(message.resultUrl || (message.result && message.status !== "thinking")) && (
<ResultCard
message={message}
@@ -3227,10 +3452,10 @@ function WorkbenchPage({
{renderComposerToolbar(false, isGenerating)}
</div>
</section>
<div className="wb-chat-scroll-actions" aria-label="聊天滚动">
<div className={`wb-chat-scroll-actions${scrollActionHint ? ` is-showing-${scrollActionHint}` : ""}`} aria-label="聊天滚动">
<button
type="button"
className="wb-chat-scroll-actions__button"
className="wb-chat-scroll-actions__button wb-chat-scroll-actions__button--top"
title="返回聊天顶部"
aria-label="返回聊天顶部"
onClick={() => scrollMessagesSurface("top")}
@@ -3239,7 +3464,7 @@ function WorkbenchPage({
</button>
<button
type="button"
className="wb-chat-scroll-actions__button"
className="wb-chat-scroll-actions__button wb-chat-scroll-actions__button--bottom"
title="到达聊天底部"
aria-label="到达聊天底部"
onClick={() => scrollMessagesSurface("bottom")}
+1 -1
View File
@@ -231,7 +231,7 @@ export const MODE_OPTIONS: WorkbenchOption[] = (Object.keys(MODE_META) as Workbe
}));
export const IMAGE_MODEL_OPTIONS: WorkbenchOption[] = [
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro 4K" },
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro" },
{ value: "wan2.7-image", label: "wan 2.7" },
{ value: "gpt-image-2", label: "omni-GPT" },
{ value: "gpt-image-2-vip", label: "omni-GPT VIP" },
+2 -2
View File
@@ -33,7 +33,7 @@ const initialState: SessionState = {
loginPromptOpen: false,
pendingAction: null,
sessionReplacedOpen: false,
sessionReplacedMessage: '您的账号已在其他设备登录,此设备的登录状态已失效。',
sessionReplacedMessage: '当前账号已在其他设备登录,此设备的登录状态已失效。',
};
export const useSessionStore = create<SessionState & SessionActions>((set) => ({
@@ -55,7 +55,7 @@ export const useSessionStore = create<SessionState & SessionActions>((set) => ({
showSessionReplaced: (message) => set({
sessionReplacedOpen: true,
sessionReplacedMessage: message || '您的账号已在其他设备登录(最多同时 2 台设备),此设备的登录状态已失效。',
sessionReplacedMessage: message || '当前账号已在其他设备登录,此设备的登录状态已失效。',
}),
hideSessionReplaced: () => set({ sessionReplacedOpen: false }),
@@ -107,6 +107,12 @@
color: #1e1e1e;
}
.beta-modal-header__close:disabled,
.beta-modal-footer__btn:disabled {
opacity: 0.58;
cursor: wait;
}
/* ── Scrollable body ── */
.beta-modal-body {
flex: 1;
@@ -412,6 +418,22 @@
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 {
display: inline-flex;
align-items: center;
+421
View File
@@ -0,0 +1,421 @@
.beta-admin-page__inner {
display: flex;
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 {
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;
flex: 1;
min-height: 0;
align-items: start;
}
.beta-admin-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 100%;
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;
max-height: 100%;
min-height: 0;
overflow: auto;
padding-right: 4px;
}
.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;
overflow: auto;
}
.beta-admin-toolbar,
.beta-admin-detail__header {
flex-direction: column;
}
.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) {
.beta-admin-field-grid {
grid-template-columns: 1fr;
}
.beta-admin-actions {
flex-direction: column;
}
}
+67
View File
@@ -788,6 +788,65 @@
overflow: hidden;
}
.compliance-document {
gap: 0;
padding: 30px 34px 34px;
overflow: visible;
}
.compliance-document__title,
.compliance-document__heading,
.compliance-document__subheading,
.compliance-document__paragraph,
.compliance-document__clause {
margin: 0;
max-width: 100%;
color: var(--fg-body);
letter-spacing: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.compliance-document__title {
margin-bottom: 18px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-subtle);
font-size: clamp(24px, 3vw, 32px);
line-height: 1.22;
}
.compliance-document__heading {
margin: 26px 0 12px;
padding-top: 4px;
color: var(--accent);
font-size: 20px;
line-height: 1.35;
}
.compliance-document__subheading {
margin: 18px 0 8px;
font-size: 16px;
line-height: 1.5;
}
.compliance-document__paragraph,
.compliance-document__clause {
color: var(--fg-muted);
font-size: 14px;
line-height: 1.85;
}
.compliance-document__paragraph + .compliance-document__paragraph,
.compliance-document__clause + .compliance-document__clause,
.compliance-document__paragraph + .compliance-document__clause,
.compliance-document__clause + .compliance-document__paragraph {
margin-top: 8px;
}
.compliance-document__clause {
padding-left: 12px;
}
.compliance-section {
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
@@ -892,4 +951,12 @@
.compliance-section {
grid-template-columns: 1fr;
}
.compliance-document {
padding: 22px 18px 26px;
}
.compliance-document__clause {
padding-left: 0;
}
}
+15
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+44
View File
@@ -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);
@@ -10837,8 +10845,44 @@
}
@media (max-width: 900px) {
.web-shell[data-ui-theme="dark-green"] {
--dg-mobile-nav-height: 58px;
--dg-mobile-nav-gap: 12px;
--dg-mobile-nav-space: calc(var(--dg-mobile-nav-height) + var(--dg-mobile-nav-gap));
}
.web-shell[data-ui-theme="dark-green"] .web-topbar {
z-index: 72;
}
.web-shell[data-ui-theme="dark-green"] .web-shell__content,
.web-shell[data-ui-theme="dark-green"] .web-shell__page {
min-height: 0;
padding-bottom: 0;
}
.web-shell[data-ui-theme="dark-green"] .web-shell__page {
overflow: auto;
}
.web-shell[data-ui-theme="dark-green"]:not([data-view="home"]):not([data-view="login"]):not([data-view="workbench"]):not([data-view="agent"]):not([data-view="avatarConsole"]) .web-shell__page {
padding-top: var(--dg-mobile-nav-space);
}
.web-shell[data-ui-theme="dark-green"] .profile-popover {
position: fixed;
top: calc(56px + var(--dg-mobile-nav-space) + env(safe-area-inset-top, 0px));
right: 12px;
z-index: 120;
width: min(288px, calc(100vw - 24px));
max-height: calc(100svh - 56px - var(--dg-mobile-nav-space) - 24px);
overflow-y: auto;
transform-origin: top right;
}
.web-shell[data-ui-theme="dark-green"] .floating-nav {
top: calc(56px + env(safe-area-inset-top, 0px));
z-index: 50;
right: 12px;
bottom: auto;
left: 12px;
+2
View File
@@ -25,6 +25,7 @@ export type WebViewKey =
| "dialogGenerator"
| "communityReview"
| "communityCaseAdd"
| "betaApplications"
| "report"
| "providerHealth"
| "userAgreement"
@@ -73,6 +74,7 @@ export interface WebUserSession extends WebApiResultMeta {
enterpriseAdminUserId?: number | string | null;
balanceCents?: number;
enterpriseBalanceCents?: number;
maxConcurrency?: number;
activePackages?: Array<{
name: string;
expiresAt: string;
+4 -3
View File
@@ -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));
}
+19
View File
@@ -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 {
+5 -3
View File
@@ -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));