Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1049fa3218 | |||
| 6f54ad92c0 | |||
| 9b7e708f85 | |||
| 4e97e706fd | |||
| 30536ad15f | |||
| e78cc05299 | |||
| b88be66e7f | |||
| 1a9196a63a | |||
| 4dfcb6fc8a | |||
| e351e93200 | |||
| 117b9354eb | |||
| 446514dd06 | |||
| 85a174bcb5 | |||
| 560a7baddc | |||
| 4f7f67a278 | |||
| 3963d9ae2f | |||
| 60d5cd2edf | |||
| 2afa73ac18 | |||
| 3a1bc0241e | |||
| 33723d00f0 | |||
| fe5a839b37 |
+19
-1
@@ -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":
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -7,10 +7,20 @@ interface GenerationSlot {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const MAX_ACTIVE_GENERATION_TASKS = 3;
|
||||
const DEFAULT_MAX_ACTIVE_GENERATION_TASKS = 3;
|
||||
const STALE_SLOT_MS = 6 * 60 * 60 * 1000;
|
||||
const activeSlots = new Map<string, GenerationSlot>();
|
||||
|
||||
let userMaxConcurrency: number | null = null;
|
||||
|
||||
export function setUserMaxConcurrency(limit: number | null | undefined): void {
|
||||
userMaxConcurrency = typeof limit === "number" && limit > 0 ? limit : null;
|
||||
}
|
||||
|
||||
function getEffectiveLimit(): number {
|
||||
return userMaxConcurrency ?? DEFAULT_MAX_ACTIVE_GENERATION_TASKS;
|
||||
}
|
||||
|
||||
export function getGenerationUserKey(userId?: string | number | null): string {
|
||||
return userId === undefined || userId === null || userId === "" ? "anonymous" : String(userId);
|
||||
}
|
||||
@@ -39,8 +49,9 @@ export function claimGenerationSlot(input: {
|
||||
}): () => void {
|
||||
pruneStaleSlots();
|
||||
const activeCount = getActiveGenerationTaskCount(input.userKey);
|
||||
if (activeCount >= MAX_ACTIVE_GENERATION_TASKS) {
|
||||
throw new Error("当前账号同时最多生成 3 个图片/视频任务,请等待已有任务完成后再提交。");
|
||||
const effectiveLimit = getEffectiveLimit();
|
||||
if (activeCount >= effectiveLimit) {
|
||||
throw new Error(`当前账号同时最多生成 ${effectiveLimit} 个图片/视频任务,请等待已有任务完成后再提交。`);
|
||||
}
|
||||
|
||||
const id = input.id || `generation-slot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
+171
-129
@@ -37,117 +37,153 @@ interface MoreTool {
|
||||
imageTool?: WebImageWorkbenchTool;
|
||||
ready: boolean;
|
||||
badge?: string;
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
type CompareScene =
|
||||
| "workbench"
|
||||
| "inpaint"
|
||||
| "camera"
|
||||
| "upscale"
|
||||
| "watermark"
|
||||
| "dialog"
|
||||
| "subtitle"
|
||||
| "digital-human"
|
||||
| "character"
|
||||
| "avatar";
|
||||
|
||||
const toolCompareScenes: Record<string, CompareScene> = {
|
||||
workbench: "workbench",
|
||||
inpaint: "inpaint",
|
||||
camera: "camera",
|
||||
upscale: "upscale",
|
||||
watermarkRemoval: "watermark",
|
||||
dialogGenerator: "dialog",
|
||||
subtitleRemoval: "subtitle",
|
||||
digitalHuman: "digital-human",
|
||||
characterMix: "character",
|
||||
avatarConsole: "avatar",
|
||||
const toolPreviewImages: Record<string, string> = {
|
||||
inpaint: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%B1%80%E9%83%A8%E9%87%8D%E7%BB%98.PNG",
|
||||
camera: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E9%95%9C%E5%A4%B4%E5%AE%9E%E9%AA%8C%E5%AE%A4.PNG",
|
||||
upscale: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%88%86%E8%BE%A8%E7%8E%87%E6%8F%90%E5%8D%87.PNG",
|
||||
watermarkRemoval: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%8E%BB%E6%B0%B4%E5%8D%B0.PNG",
|
||||
dialogGenerator: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E4%BA%A4%E4%BA%92%E5%BC%8F%E5%AF%B9%E8%AF%9D%E6%A1%86%E7%94%9F%E6%88%90%E5%99%A8.PNG",
|
||||
subtitleRemoval: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%AD%97%E5%B9%95%E5%8E%BB%E9%99%A4.PNG",
|
||||
characterMix: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E8%A7%92%E8%89%B2%E8%BF%81%E7%A7%BB.PNG",
|
||||
avatarConsole: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E6%95%B0%E5%AD%97%E4%BA%BA%E6%8E%A7%E5%88%B6%E5%8F%B0.PNG",
|
||||
};
|
||||
|
||||
function ToolComparePanel({ scene }: { scene: CompareScene }) {
|
||||
function ToolPreviewPanel({ toolId }: { toolId: string }) {
|
||||
const imageUrl = toolPreviewImages[toolId];
|
||||
if (!imageUrl) return null;
|
||||
|
||||
return (
|
||||
<span className={`more-card__compare more-card__compare--${scene}`} aria-hidden="true">
|
||||
<span className="more-card__compare-labels">
|
||||
<span>Before</span>
|
||||
<span>After</span>
|
||||
</span>
|
||||
<span className="more-card__compare-stage">
|
||||
<span className="more-card__compare-side more-card__compare-side--before">
|
||||
<span className="more-card__scene-subject" />
|
||||
<span className="more-card__scene-detail" />
|
||||
<span className="more-card__scene-overlay" />
|
||||
</span>
|
||||
<span className="more-card__compare-divider">
|
||||
<span />
|
||||
</span>
|
||||
<span className="more-card__compare-side more-card__compare-side--after">
|
||||
<span className="more-card__scene-subject" />
|
||||
<span className="more-card__scene-detail" />
|
||||
<span className="more-card__scene-overlay" />
|
||||
</span>
|
||||
<span className="more-card__preview" aria-hidden="true">
|
||||
<span className="more-card__preview-frame">
|
||||
<img src={imageUrl} alt="" loading="lazy" decoding="async" />
|
||||
</span>
|
||||
<img className="more-card__preview-popover" src={imageUrl} alt="" loading="lazy" decoding="async" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const tools: MoreTool[] = [
|
||||
{ id: "workbench", title: "图片工作台", text: "融合、修复、局部增强", useCase: "适合商品图精修、创意合成和局部画面重做", tags: ["热门", "一站式", "商品图"], icon: <EditOutlined />, category: "image", imageTool: "workbench", ready: true, featured: true },
|
||||
{ id: "inpaint", title: "局部重绘", text: "修掉瑕疵、替换物体、重做局部画面", useCase: "适合快速处理商品瑕疵、人物细节和背景杂物", tags: ["新手推荐", "精修"], icon: <HighlightOutlined />, category: "image", imageTool: "inpaint", ready: true },
|
||||
{ id: "camera", title: "镜头实验室", text: "快速生成俯拍、特写、广角等商业镜头", useCase: "适合做产品主图、种草图和不同机位方案", tags: ["电商常用", "镜头"], icon: <CameraOutlined />, category: "image", imageTool: "camera", ready: true },
|
||||
{ id: "upscale", title: "分辨率提升", text: "把低清图片或视频提升到可交付质感", useCase: "适合修复旧素材、放大商品图和增强短视频清晰度", tags: ["高清", "交付前"], icon: <ColumnWidthOutlined />, category: "image", target: "resolutionUpscale", ready: true },
|
||||
{ id: "watermarkRemoval", title: "去水印", text: "智能去除图片水印、文字和遮挡元素", useCase: "适合整理素材、清理参考图和恢复画面干净度", tags: ["素材清理", "高频"], icon: <DeleteOutlined />, category: "image", target: "watermarkRemoval", ready: true },
|
||||
{ id: "dialogGenerator", title: "交互式对话框生成器", text: "上传背景图,快速制作可拖拽编辑的对话框", useCase: "适合剧情海报、社媒截图和角色对白设计", tags: ["内容创作", "可编辑"], icon: <MessageOutlined />, category: "image", target: "dialogGenerator", ready: true },
|
||||
{ id: "subtitleRemoval", title: "字幕去除", text: "擦除视频字幕,让画面重新变干净", useCase: "适合二创前素材整理、短视频重剪和画面修复", tags: ["视频增强", "素材清理"], icon: <DeleteOutlined />, category: "video", target: "subtitleRemoval", ready: true },
|
||||
{ id: "digitalHuman", title: "数字人", text: "用一张人像和音频生成口播视频", useCase: "适合品牌讲解、课程口播和带货短视频", tags: ["热门", "口播", "视频"], icon: <CustomerServiceOutlined />, category: "video", target: "digitalHuman", ready: true, featured: true },
|
||||
{ id: "characterMix", title: "角色迁移", text: "把人物图迁移到参考视频的动作里", useCase: "适合角色短片、动作复刻和虚拟人内容生产", tags: ["角色视频", "动作"], icon: <SwapOutlined />, category: "video", target: "characterMix", ready: true },
|
||||
{ id: "avatarConsole", title: "数字人控制台", text: "管理形象、播报、互动与接入配置", useCase: "适合持续运营数字人、配置品牌形象和复用口播模板", tags: ["运营台", "企业"], icon: <DashboardOutlined />, category: "video", target: "avatarConsole", ready: true },
|
||||
];
|
||||
|
||||
interface FeaturedTool {
|
||||
id: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
kicker: string;
|
||||
steps: string[];
|
||||
outcome: string;
|
||||
icon: ReactNode;
|
||||
imageTool?: WebImageWorkbenchTool;
|
||||
target?: WebViewKey;
|
||||
category: ToolCategory;
|
||||
gradient: string;
|
||||
function getPreviewClassName(toolId: string) {
|
||||
return toolPreviewImages[toolId] ? " more-card--has-preview" : " more-card--no-preview";
|
||||
}
|
||||
|
||||
const featuredTools: FeaturedTool[] = [
|
||||
const tools: MoreTool[] = [
|
||||
{
|
||||
id: "workbench",
|
||||
title: "图片工作台",
|
||||
desc: "从一张素材开始,完成精修、合成和二次创作。",
|
||||
kicker: "图片精修工作流",
|
||||
steps: ["上传素材", "局部修复", "高清导出"],
|
||||
outcome: "适合商品图、海报图和创意视觉",
|
||||
text: "融合、修复、局部增强",
|
||||
useCase: "适合商品图精修、创意合成和局部画面重做",
|
||||
tags: ["热门", "一站式", "商品图"],
|
||||
icon: <EditOutlined />,
|
||||
imageTool: "workbench",
|
||||
category: "image",
|
||||
gradient: "linear-gradient(135deg, rgba(99, 102, 241, 0.12), rgba(139, 92, 246, 0.06))",
|
||||
imageTool: "workbench",
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
id: "inpaint",
|
||||
title: "局部重绘",
|
||||
text: "修掉瑕疵、替换物体、重做局部画面",
|
||||
useCase: "适合快速处理商品瑕疵、人物细节和背景杂物",
|
||||
tags: ["新手推荐", "精修"],
|
||||
icon: <HighlightOutlined />,
|
||||
category: "image",
|
||||
imageTool: "inpaint",
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
id: "camera",
|
||||
title: "镜头实验室",
|
||||
text: "快速生成俯拍、特写、广角等商业镜头",
|
||||
useCase: "适合做产品主图、种草图和不同机位方案",
|
||||
tags: ["电商常用", "镜头"],
|
||||
icon: <CameraOutlined />,
|
||||
category: "image",
|
||||
imageTool: "camera",
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
id: "upscale",
|
||||
title: "分辨率提升",
|
||||
text: "把低清图片或视频提升到可交付质感",
|
||||
useCase: "适合修复旧素材、放大商品图和增强短视频清晰度",
|
||||
tags: ["高清", "交付前"],
|
||||
icon: <ColumnWidthOutlined />,
|
||||
category: "image",
|
||||
target: "resolutionUpscale",
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
id: "watermarkRemoval",
|
||||
title: "去水印",
|
||||
text: "智能去除图片水印、文字和遮挡元素",
|
||||
useCase: "适合整理素材、清理参考图和恢复画面干净度",
|
||||
tags: ["素材清理", "高频"],
|
||||
icon: <DeleteOutlined />,
|
||||
category: "image",
|
||||
target: "watermarkRemoval",
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
id: "dialogGenerator",
|
||||
title: "交互式对话框生成器",
|
||||
text: "上传背景图,快速制作可拖拽编辑的对话框",
|
||||
useCase: "适合剧情海报、社媒截图和角色对白设计",
|
||||
tags: ["内容创作", "可编辑"],
|
||||
icon: <MessageOutlined />,
|
||||
category: "image",
|
||||
target: "dialogGenerator",
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
id: "subtitleRemoval",
|
||||
title: "字幕去除",
|
||||
text: "擦除视频字幕,让画面重新变干净",
|
||||
useCase: "适合二创前素材整理、短视频重剪和画面修复",
|
||||
tags: ["视频增强", "素材清理"],
|
||||
icon: <DeleteOutlined />,
|
||||
category: "video",
|
||||
target: "subtitleRemoval",
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
id: "digitalHuman",
|
||||
title: "数字人",
|
||||
desc: "用参考人像和音频,快速生成可交付口播视频。",
|
||||
kicker: "口播视频工作流",
|
||||
steps: ["选择人像", "上传音频", "生成视频"],
|
||||
outcome: "适合品牌讲解、课程和带货短视频",
|
||||
text: "用一张人像和音频生成口播视频",
|
||||
useCase: "适合品牌讲解、课程口播和带货短视频",
|
||||
tags: ["热门", "口播", "视频"],
|
||||
icon: <CustomerServiceOutlined />,
|
||||
target: "digitalHuman",
|
||||
category: "video",
|
||||
gradient: "linear-gradient(135deg, rgba(13, 148, 136, 0.12), rgba(6, 182, 212, 0.06))",
|
||||
target: "digitalHuman",
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
id: "characterMix",
|
||||
title: "角色迁移",
|
||||
text: "把人物图迁移到参考视频的动作里",
|
||||
useCase: "适合角色短片、动作复刻和虚拟人内容生产",
|
||||
tags: ["角色视频", "动作"],
|
||||
icon: <SwapOutlined />,
|
||||
category: "video",
|
||||
target: "characterMix",
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
id: "avatarConsole",
|
||||
title: "数字人控制台",
|
||||
text: "管理形象、播报、互动与接入配置",
|
||||
useCase: "适合持续运营数字人、配置品牌形象和复用口播模板",
|
||||
tags: ["运营台", "企业"],
|
||||
icon: <DashboardOutlined />,
|
||||
category: "video",
|
||||
target: "avatarConsole",
|
||||
ready: true,
|
||||
},
|
||||
];
|
||||
|
||||
const categoryLabels: Record<ToolCategory, string> = {
|
||||
image: "图像创作",
|
||||
video: "视频生成",
|
||||
video: "视频创作",
|
||||
};
|
||||
|
||||
const categoryIcons: Record<ToolCategory, ReactNode> = {
|
||||
@@ -162,6 +198,20 @@ const filters: { key: FilterKey; label: string }[] = [
|
||||
{ key: "upcoming", label: "即将上线" },
|
||||
];
|
||||
|
||||
const coreToolIds = new Set(["workbench", "inpaint", "watermarkRemoval"]);
|
||||
|
||||
const coreToolGradients: Record<string, string> = {
|
||||
workbench: "linear-gradient(135deg, rgba(99, 102, 241, 0.12), rgba(139, 92, 246, 0.06))",
|
||||
inpaint: "linear-gradient(135deg, rgba(var(--accent-rgb), 0.16), rgba(var(--accent-rgb), 0.055))",
|
||||
watermarkRemoval: "linear-gradient(135deg, rgba(16, 185, 129, 0.13), rgba(var(--accent-rgb), 0.055))",
|
||||
};
|
||||
|
||||
const coreToolSteps: Record<string, string[]> = {
|
||||
workbench: ["上传素材", "局部修复", "高清导出"],
|
||||
inpaint: ["选定区域", "描述修改", "生成结果"],
|
||||
watermarkRemoval: ["上传素材", "智能识别", "干净导出"],
|
||||
};
|
||||
|
||||
const RECENT_STORAGE_KEY = "omniai:more-recent-tools";
|
||||
const MAX_RECENT = 4;
|
||||
|
||||
@@ -169,7 +219,9 @@ function getRecentToolIds(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(RECENT_STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch { return []; }
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function pushRecentToolId(id: string) {
|
||||
@@ -199,39 +251,29 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
|
||||
}
|
||||
}, [onOpenImageTool, onSelectView]);
|
||||
|
||||
const openFeaturedTool = useCallback((tool: FeaturedTool) => {
|
||||
pushRecentToolId(tool.id);
|
||||
setRecentIds(getRecentToolIds());
|
||||
if (tool.imageTool && onOpenImageTool) {
|
||||
onOpenImageTool(tool.imageTool);
|
||||
return;
|
||||
}
|
||||
if (tool.target && onSelectView) {
|
||||
onSelectView(tool.target);
|
||||
}
|
||||
}, [onOpenImageTool, onSelectView]);
|
||||
|
||||
const filteredTools = tools.filter((t) => {
|
||||
if (t.featured) return false;
|
||||
const filteredTools = tools.filter((tool) => {
|
||||
if (coreToolIds.has(tool.id)) return false;
|
||||
if (filter === "all") return true;
|
||||
if (filter === "upcoming") return !t.ready;
|
||||
return t.category === filter;
|
||||
if (filter === "upcoming") return !tool.ready;
|
||||
return tool.category === filter;
|
||||
});
|
||||
|
||||
const filterCounts: Record<FilterKey, number> = {
|
||||
all: tools.filter((t) => !t.featured).length,
|
||||
image: tools.filter((t) => !t.featured && t.category === "image").length,
|
||||
video: tools.filter((t) => !t.featured && t.category === "video").length,
|
||||
upcoming: tools.filter((t) => !t.featured && !t.ready).length,
|
||||
all: tools.filter((tool) => !coreToolIds.has(tool.id)).length,
|
||||
image: tools.filter((tool) => !coreToolIds.has(tool.id) && tool.category === "image").length,
|
||||
video: tools.filter((tool) => !coreToolIds.has(tool.id) && tool.category === "video").length,
|
||||
upcoming: tools.filter((tool) => !coreToolIds.has(tool.id) && !tool.ready).length,
|
||||
};
|
||||
|
||||
const recentTools = recentIds
|
||||
.map((id) => tools.find((t) => t.id === id))
|
||||
.filter((t): t is MoreTool => Boolean(t) && (t?.ready ?? false));
|
||||
.map((id) => tools.find((tool) => tool.id === id))
|
||||
.filter((tool): tool is MoreTool => Boolean(tool) && (tool?.ready ?? false));
|
||||
|
||||
const groupedTools = filteredTools.reduce<Record<ToolCategory, MoreTool[]>>((acc, t) => {
|
||||
if (!acc[t.category]) acc[t.category] = [];
|
||||
acc[t.category].push(t);
|
||||
const coreTools = tools.filter((tool) => coreToolIds.has(tool.id));
|
||||
|
||||
const groupedTools = filteredTools.reduce<Record<ToolCategory, MoreTool[]>>((acc, tool) => {
|
||||
if (!acc[tool.category]) acc[tool.category] = [];
|
||||
acc[tool.category].push(tool);
|
||||
return acc;
|
||||
}, {} as Record<ToolCategory, MoreTool[]>);
|
||||
|
||||
@@ -247,19 +289,19 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
|
||||
</div>
|
||||
<div className="more-page-v2__header-meta" aria-label="工具盒概览">
|
||||
<span>{tools.filter((tool) => tool.ready).length} 个可用工具</span>
|
||||
<span>{featuredTools.length} 个核心入口</span>
|
||||
<span>{coreTools.length} 个核心入口</span>
|
||||
</div>
|
||||
<nav className="more-page-v2__filters" aria-label="工具分类筛选">
|
||||
{filters.map((f) => (
|
||||
{filters.map((item) => (
|
||||
<button
|
||||
key={f.key}
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={filter === f.key ? "is-active" : ""}
|
||||
aria-pressed={filter === f.key}
|
||||
onClick={() => setFilter(f.key)}
|
||||
className={filter === item.key ? "is-active" : ""}
|
||||
aria-pressed={filter === item.key}
|
||||
onClick={() => setFilter(item.key)}
|
||||
>
|
||||
<span>{f.label}</span>
|
||||
<em>{filterCounts[f.key]}</em>
|
||||
<span>{item.label}</span>
|
||||
<em>{filterCounts[item.key]}</em>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
@@ -298,27 +340,27 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
|
||||
<ThunderboltOutlined /> 核心工具
|
||||
</h2>
|
||||
<div className="more-page-v2__featured-grid">
|
||||
{featuredTools.map((tool) => (
|
||||
{coreTools.map((tool) => (
|
||||
<button
|
||||
key={tool.id}
|
||||
type="button"
|
||||
className="more-card more-card--featured"
|
||||
style={{ "--card-gradient": tool.gradient } as CSSProperties}
|
||||
aria-label={`打开核心工具:${tool.title},${tool.desc}`}
|
||||
onClick={() => openFeaturedTool(tool)}
|
||||
className={`more-card more-card--featured${getPreviewClassName(tool.id)}`}
|
||||
style={{ "--card-gradient": coreToolGradients[tool.id] ?? "linear-gradient(135deg, rgba(var(--accent-rgb), 0.12), rgba(var(--accent-rgb), 0.04))" } as CSSProperties}
|
||||
aria-label={`打开核心工具:${tool.title},${tool.text}`}
|
||||
onClick={() => openTool(tool)}
|
||||
>
|
||||
<span className="more-card__featured-icon">{tool.icon}</span>
|
||||
<div className="more-card__featured-body">
|
||||
<span className="more-card__featured-kicker">{tool.kicker}</span>
|
||||
<span className="more-card__featured-kicker">{categoryLabels[tool.category]}</span>
|
||||
<strong>{tool.title}</strong>
|
||||
<ToolComparePanel scene={toolCompareScenes[tool.id]} />
|
||||
<span className="more-card__featured-desc">{tool.desc}</span>
|
||||
<ToolPreviewPanel toolId={tool.id} />
|
||||
<span className="more-card__featured-desc">{tool.text}</span>
|
||||
<span className="more-card__steps" aria-hidden="true">
|
||||
{tool.steps.map((step) => (
|
||||
{(coreToolSteps[tool.id] ?? tool.tags.slice(0, 3)).map((step) => (
|
||||
<span key={step}>{step}</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="more-card__outcome">{tool.outcome}</span>
|
||||
<span className="more-card__outcome">{tool.useCase}</span>
|
||||
<span className="more-card__cta">开始使用 →</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -341,7 +383,7 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
|
||||
<button
|
||||
key={tool.id}
|
||||
type="button"
|
||||
className={`more-card${tool.ready ? " more-card--ready" : " more-card--pending"}`}
|
||||
className={`more-card${tool.ready ? " more-card--ready" : " more-card--pending"}${getPreviewClassName(tool.id)}`}
|
||||
aria-label={tool.ready ? `打开工具:${tool.title},${tool.text}` : `${tool.title}暂未开放`}
|
||||
onClick={() => openTool(tool)}
|
||||
disabled={!tool.ready}
|
||||
@@ -353,7 +395,7 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
|
||||
))}
|
||||
</span>
|
||||
<strong>{tool.title}</strong>
|
||||
<ToolComparePanel scene={toolCompareScenes[tool.id]} />
|
||||
<ToolPreviewPanel toolId={tool.id} />
|
||||
<span className="more-card__desc">{tool.text}</span>
|
||||
<span className="more-card__use-case">{tool.useCase}</span>
|
||||
<span className="more-card__action">打开工具 →</span>
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -1128,9 +1226,15 @@ function WorkbenchPage({
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageQualityOptions.some((option) => option.value === imageQuality)) {
|
||||
setImageQuality(getDefaultImageQuality(imageModel));
|
||||
setImageQuality(getDefaultImageQualityForContext(imageModel, imageQualityContext));
|
||||
}
|
||||
}, [imageModel, imageQuality, imageQualityOptions]);
|
||||
}, [imageModel, imageQuality, imageQualityContext, imageQualityOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageGridModeOptions.some((option) => option.value === imageGridMode)) {
|
||||
setImageGridMode("single");
|
||||
}
|
||||
}, [imageGridMode, imageGridModeOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeMode !== "video" || videoFrameMode !== "start-end" || referenceItems.length <= 2) return;
|
||||
@@ -1266,6 +1370,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 +1530,7 @@ function WorkbenchPage({
|
||||
const atBottom = top + surface.clientHeight >= surface.scrollHeight - edgeThreshold;
|
||||
shouldFollowNewMessagesRef.current = atBottom;
|
||||
setComposerHidden(!(atTop || atBottom));
|
||||
hideScrollActionHint();
|
||||
lastScrollTopRef.current = top;
|
||||
};
|
||||
|
||||
@@ -1434,24 +1545,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>) => {
|
||||
@@ -2545,7 +2659,15 @@ function WorkbenchPage({
|
||||
}
|
||||
};
|
||||
|
||||
const sendDisabled = !inputValue.trim() || (activeMode !== "chat" && getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3);
|
||||
const activeGenerationCount = getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id));
|
||||
const generationLimitReached = activeMode !== "chat" && activeGenerationCount >= 3;
|
||||
const promptIsEmpty = !inputValue.trim();
|
||||
const sendDisabled = promptIsEmpty || generationLimitReached;
|
||||
const sendButtonTitle = promptIsEmpty
|
||||
? "输入内容后可发送"
|
||||
: generationLimitReached
|
||||
? `当前已有 ${activeGenerationCount} 个任务进行中,请等待任一任务完成`
|
||||
: billingEstimate.title;
|
||||
|
||||
const suggestedPrompts = [
|
||||
{ text: "画一个赛博朋克风格的城市夜景", mode: "image" as WorkbenchMode },
|
||||
@@ -2810,7 +2932,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 +3004,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 +3060,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 +3091,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 +3135,8 @@ function WorkbenchPage({
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
) : null;
|
||||
);
|
||||
};
|
||||
|
||||
if (!hasActivatedWorkspace) {
|
||||
return (
|
||||
@@ -3079,8 +3231,8 @@ function WorkbenchPage({
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{renderConversationSidebar()}
|
||||
</div>
|
||||
{renderConversationSidebar()}
|
||||
{renderMessagePreviewOverlay()}
|
||||
{renderPromptCaseOverlay()}
|
||||
{renderDeleteDialog()}
|
||||
@@ -3166,11 +3318,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 +3374,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 +3386,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")}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+314
-99
@@ -1,6 +1,11 @@
|
||||
.more-page-v2 {
|
||||
--more-card-shadow: 0 18px 48px rgba(0, 0, 0, 0.24);
|
||||
--more-card-glow: 0 0 0 1px rgba(255, 255, 255, 0.025), 0 18px 38px rgba(0, 0, 0, 0.16);
|
||||
--more-card-shadow: 0 22px 54px rgba(0, 0, 0, 0.3);
|
||||
--more-card-glow: 0 0 0 1px rgba(255, 255, 255, 0.035), 0 16px 34px rgba(0, 0, 0, 0.18);
|
||||
--more-card-surface: rgba(19, 23, 24, 0.86);
|
||||
--more-card-surface-strong: rgba(22, 27, 28, 0.94);
|
||||
--more-card-border: rgba(255, 255, 255, 0.105);
|
||||
--more-card-border-strong: rgba(var(--accent-rgb), 0.3);
|
||||
--more-page-pad-x: clamp(18px, 2.3vw, 32px);
|
||||
|
||||
position: relative;
|
||||
display: grid;
|
||||
@@ -158,24 +163,24 @@
|
||||
|
||||
.more-page-v2__scroll {
|
||||
overflow-y: auto;
|
||||
padding: 26px 28px 68px;
|
||||
padding: 28px var(--more-page-pad-x) 72px;
|
||||
scrollbar-color: rgba(var(--accent-rgb), 0.26) transparent;
|
||||
}
|
||||
|
||||
.more-page-v2__section {
|
||||
margin-bottom: 30px;
|
||||
margin-bottom: 34px;
|
||||
}
|
||||
|
||||
.more-page-v2__section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 15px;
|
||||
color: var(--fg-muted);
|
||||
margin: 0 0 14px;
|
||||
color: color-mix(in srgb, var(--fg-muted) 86%, var(--fg-body));
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
font-weight: 850;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0.055em;
|
||||
}
|
||||
|
||||
.more-page-v2__section-title .anticon {
|
||||
@@ -199,27 +204,31 @@
|
||||
|
||||
.more-page-v2__recent-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.more-page-v2__featured-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 18px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.more-card--featured {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
display: grid;
|
||||
grid-template-columns: 54px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
justify-items: stretch;
|
||||
gap: 16px;
|
||||
min-height: 336px;
|
||||
padding: 20px;
|
||||
border-color: rgba(var(--accent-rgb), 0.18);
|
||||
border-color: rgba(var(--accent-rgb), 0.2);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background:
|
||||
var(--card-gradient),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.012)),
|
||||
var(--bg-panel);
|
||||
radial-gradient(circle at 14% 4%, rgba(var(--accent-rgb), 0.12), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.016)),
|
||||
var(--more-card-surface-strong);
|
||||
box-shadow: var(--more-card-glow);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -230,27 +239,27 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.035), transparent),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.045), transparent 34%);
|
||||
opacity: 0.5;
|
||||
linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.038), transparent),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 34%);
|
||||
opacity: 0.62;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.more-card--featured:hover {
|
||||
border-color: rgba(var(--accent-rgb), 0.45);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--more-card-shadow), 0 0 0 1px rgba(var(--accent-rgb), 0.1);
|
||||
border-color: rgba(var(--accent-rgb), 0.46);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--more-card-shadow), 0 0 0 1px rgba(var(--accent-rgb), 0.12);
|
||||
}
|
||||
|
||||
.more-card__featured-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.22);
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.24);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(var(--accent-rgb), 0.16), rgba(var(--accent-rgb), 0.08)),
|
||||
linear-gradient(180deg, rgba(var(--accent-rgb), 0.18), rgba(var(--accent-rgb), 0.08)),
|
||||
var(--bg-inset);
|
||||
color: var(--accent);
|
||||
font-size: 24px;
|
||||
@@ -261,11 +270,32 @@
|
||||
.more-card__featured-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
gap: 10px;
|
||||
justify-self: stretch;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.more-card--featured .more-card__preview {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.more-card--featured.more-card--no-preview {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.more-card--featured.more-card--no-preview .more-card__featured-body {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.more-card--featured.more-card--no-preview .more-card__outcome {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.more-card__featured-kicker {
|
||||
width: fit-content;
|
||||
color: var(--accent);
|
||||
@@ -277,14 +307,14 @@
|
||||
|
||||
.more-card__featured-body strong {
|
||||
color: var(--fg-body);
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
font-size: 20px;
|
||||
font-weight: 850;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.more-card__featured-desc {
|
||||
font-size: 13px;
|
||||
color: var(--fg-muted);
|
||||
color: color-mix(in srgb, var(--fg-muted) 88%, var(--fg-body));
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -327,20 +357,23 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
min-height: 28px;
|
||||
margin-top: 0;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.28);
|
||||
min-height: 32px;
|
||||
margin-top: auto;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.34);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background: rgba(var(--accent-rgb), 0.08);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(var(--accent-rgb), 0.16), rgba(var(--accent-rgb), 0.08)),
|
||||
rgba(var(--accent-rgb), 0.06);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
font-weight: 850;
|
||||
color: var(--accent) !important;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.more-page-v2__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(236px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@@ -350,18 +383,22 @@
|
||||
align-content: start;
|
||||
justify-items: start;
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
min-height: 392px;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--border-weak);
|
||||
border: 1px solid var(--more-card-border);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.032), transparent 42%),
|
||||
var(--bg-panel);
|
||||
radial-gradient(circle at 12% 0%, rgba(var(--accent-rgb), 0.055), transparent 34%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 42%),
|
||||
var(--more-card-surface);
|
||||
color: var(--fg-body);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.035),
|
||||
0 1px 0 rgba(255, 255, 255, 0.02);
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background 160ms ease,
|
||||
@@ -370,12 +407,19 @@
|
||||
}
|
||||
|
||||
.more-card:hover {
|
||||
border-color: rgba(var(--accent-rgb), 0.38);
|
||||
border-color: var(--more-card-border-strong);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 46%),
|
||||
var(--bg-hover, rgba(255, 255, 255, 0.03));
|
||||
radial-gradient(circle at 12% 0%, rgba(var(--accent-rgb), 0.085), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.052), transparent 46%),
|
||||
rgba(24, 29, 30, 0.94);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--more-card-glow), 0 10px 26px rgba(0, 0, 0, 0.16);
|
||||
box-shadow: var(--more-card-glow), 0 14px 30px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.more-card:active,
|
||||
.more-page-v2__filters button:active,
|
||||
.more-page-v2__empty-action:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.more-card--pending {
|
||||
@@ -395,17 +439,20 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 150px;
|
||||
min-height: 54px;
|
||||
padding: 10px 14px;
|
||||
border-color: rgba(var(--accent-rgb), 0.14);
|
||||
min-width: 164px;
|
||||
min-height: 58px;
|
||||
padding: 11px 14px;
|
||||
border-color: rgba(var(--accent-rgb), 0.16);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.038), rgba(255, 255, 255, 0.016)),
|
||||
rgba(18, 23, 24, 0.88);
|
||||
}
|
||||
|
||||
.more-card__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.16);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background:
|
||||
@@ -437,15 +484,15 @@
|
||||
.more-card strong {
|
||||
max-width: 100%;
|
||||
color: var(--fg-body);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
font-size: 16px;
|
||||
font-weight: 850;
|
||||
line-height: 1.28;
|
||||
}
|
||||
|
||||
.more-card__topline {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
@@ -472,9 +519,9 @@
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 92px;
|
||||
min-height: 104px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.28);
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.24);
|
||||
border-radius: 10px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(var(--accent-rgb), 0.1), transparent 34%),
|
||||
@@ -483,8 +530,7 @@
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.34),
|
||||
0 0 20px rgba(var(--accent-rgb), 0.08);
|
||||
clip-path: polygon(0 10px, 10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%);
|
||||
0 0 18px rgba(var(--accent-rgb), 0.07);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
@@ -506,7 +552,6 @@
|
||||
inset: 5px;
|
||||
z-index: 3;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.16);
|
||||
clip-path: polygon(0 8px, 8px 0, 100% 0, 100% calc(100% - 8px), calc(100% - 8px) 100%, 0 100%);
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -880,15 +925,102 @@
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.more-card__preview {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1.42 / 1;
|
||||
overflow: visible;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.more-card__preview-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.22);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background:
|
||||
radial-gradient(circle at 50% 42%, rgba(var(--accent-rgb), 0.12), transparent 56%),
|
||||
linear-gradient(135deg, rgba(var(--accent-rgb), 0.08), transparent 34%),
|
||||
var(--bg-inset);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.07),
|
||||
0 0 18px rgba(var(--accent-rgb), 0.06);
|
||||
}
|
||||
|
||||
.more-card__preview-frame::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 34%, rgba(0, 0, 0, 0.18)),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.045), transparent 38%, rgba(255, 255, 255, 0.025));
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.more-card__preview-frame img,
|
||||
.more-card__preview-popover {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
padding: 6px;
|
||||
transform: none;
|
||||
transition:
|
||||
filter 220ms ease;
|
||||
}
|
||||
|
||||
.more-card:hover .more-card__preview-frame img {
|
||||
filter: saturate(1.05) contrast(1.02);
|
||||
}
|
||||
|
||||
.more-card__preview-popover {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 12px);
|
||||
z-index: 20;
|
||||
width: min(420px, calc(100vw - 48px));
|
||||
height: auto;
|
||||
max-height: min(360px, 58vh);
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.34);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background:
|
||||
radial-gradient(circle at 50% 20%, rgba(var(--accent-rgb), 0.12), transparent 52%),
|
||||
rgba(10, 14, 14, 0.96);
|
||||
box-shadow:
|
||||
0 28px 68px rgba(0, 0, 0, 0.46),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 8px) scale(0.96);
|
||||
transform-origin: 50% 100%;
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.more-card__preview:hover .more-card__preview-popover {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
}
|
||||
|
||||
.more-card--featured .more-card__preview-popover {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.more-card__desc {
|
||||
color: var(--fg-muted);
|
||||
color: color-mix(in srgb, var(--fg-muted) 88%, var(--fg-body));
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.more-card__use-case {
|
||||
display: block;
|
||||
min-height: 38px;
|
||||
min-height: 50px;
|
||||
color: color-mix(in srgb, var(--fg-muted) 78%, var(--fg-body));
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
@@ -898,15 +1030,15 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
min-height: 26px;
|
||||
margin-top: 2px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
min-height: 30px;
|
||||
margin-top: auto;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
color: var(--fg-body);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
font-weight: 850;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background 160ms ease,
|
||||
@@ -915,8 +1047,8 @@
|
||||
}
|
||||
|
||||
.more-card:hover .more-card__action {
|
||||
border-color: rgba(var(--accent-rgb), 0.28);
|
||||
background: rgba(var(--accent-rgb), 0.08);
|
||||
border-color: rgba(var(--accent-rgb), 0.32);
|
||||
background: rgba(var(--accent-rgb), 0.1);
|
||||
color: var(--accent);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
@@ -936,14 +1068,15 @@
|
||||
.more-page-v2__empty {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 10px;
|
||||
min-height: 220px;
|
||||
padding: 34px 20px;
|
||||
border: 1px solid var(--border-weak);
|
||||
gap: 12px;
|
||||
min-height: 238px;
|
||||
padding: 38px 22px;
|
||||
border: 1px solid var(--more-card-border);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(var(--accent-rgb), 0.065), transparent 64%),
|
||||
var(--bg-panel);
|
||||
radial-gradient(circle at 50% 0%, rgba(var(--accent-rgb), 0.1), transparent 42%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 64%),
|
||||
var(--more-card-surface);
|
||||
color: var(--fg-muted);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -951,11 +1084,13 @@
|
||||
.more-page-v2__empty-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.22);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background: rgba(var(--accent-rgb), 0.1);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(var(--accent-rgb), 0.16), rgba(var(--accent-rgb), 0.08)),
|
||||
rgba(var(--accent-rgb), 0.08);
|
||||
color: var(--accent);
|
||||
font-size: 20px;
|
||||
}
|
||||
@@ -978,12 +1113,14 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 34px;
|
||||
min-height: 36px;
|
||||
margin-top: 4px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.32);
|
||||
padding: 0 14px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.36);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background: rgba(var(--accent-rgb), 0.08);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(var(--accent-rgb), 0.14), rgba(var(--accent-rgb), 0.08)),
|
||||
rgba(var(--accent-rgb), 0.06);
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
@@ -1013,6 +1150,7 @@
|
||||
|
||||
.more-page-v2__header {
|
||||
grid-template-columns: minmax(180px, auto) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.more-page-v2__filters {
|
||||
@@ -1023,15 +1161,21 @@
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.more-page-v2 {
|
||||
--more-page-pad-x: 16px;
|
||||
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.more-page-v2__header {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding: 14px 16px 12px;
|
||||
padding: 16px 16px 14px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.more-page-v2__header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.more-page-v2__header-meta {
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -1047,13 +1191,22 @@
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.more-page-v2__filters button {
|
||||
min-height: 31px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.more-page-v2__scroll {
|
||||
padding: 16px 16px 48px;
|
||||
padding: 18px 16px 52px;
|
||||
}
|
||||
|
||||
.more-page-v2__section {
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.more-page-v2__grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(172px, 1fr));
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.more-page-v2__recent-row {
|
||||
@@ -1063,11 +1216,13 @@
|
||||
}
|
||||
|
||||
.more-page-v2__featured-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.more-card--featured {
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -1079,7 +1234,12 @@
|
||||
}
|
||||
|
||||
.more-card__featured-body strong {
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.more-card--featured .more-card__preview {
|
||||
width: 100%;
|
||||
min-height: 176px;
|
||||
}
|
||||
|
||||
.more-card__featured-kicker,
|
||||
@@ -1097,8 +1257,13 @@
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.more-card__compare {
|
||||
min-height: 82px;
|
||||
.more-card__preview {
|
||||
min-height: 190px;
|
||||
}
|
||||
|
||||
.more-card {
|
||||
min-height: 394px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.more-card__topline {
|
||||
@@ -1108,24 +1273,74 @@
|
||||
}
|
||||
|
||||
.more-card__use-case {
|
||||
min-height: 54px;
|
||||
min-height: 46px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.more-page-v2__header {
|
||||
gap: 10px;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.more-page-v2__header-meta {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
margin-right: -16px;
|
||||
padding-right: 16px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.more-page-v2__header-meta::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.more-page-v2__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.more-card {
|
||||
gap: 9px;
|
||||
.more-page-v2__featured-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.more-card__compare {
|
||||
min-height: 94px;
|
||||
.more-page-v2__section-title {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.more-card--featured {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.more-card__featured-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.more-card {
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.more-card__preview {
|
||||
min-height: 190px;
|
||||
}
|
||||
|
||||
.more-card__use-case {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.more-card__action,
|
||||
.more-card__cta {
|
||||
min-height: 32px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.more-card__preview-popover {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user