大量优化修改扣积分规则
This commit is contained in:
@@ -28,7 +28,6 @@ import {
|
||||
AccountPage,
|
||||
AssetFactoryPage,
|
||||
AuthScreen,
|
||||
Dashboard,
|
||||
FreeCreatePage,
|
||||
QuickCreatePage,
|
||||
OmniCreatePage,
|
||||
@@ -122,6 +121,12 @@ export function App() {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [projectTotal, setProjectTotal] = useState(0);
|
||||
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([]);
|
||||
|
||||
const refreshModelConfigs = useCallback(() => {
|
||||
void api.modelConfigs()
|
||||
.then((modelData) => setModelConfigs(modelData?.results || []))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
const [billing, setBilling] = useState<BillingSummary | null>(null);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
// YYX#row22:未读生成任务 —— 导航「图片生成」总数 + 每个商品的未读分数(商品角标)
|
||||
@@ -344,23 +349,39 @@ export function App() {
|
||||
if (user.is_platform_admin && !team && route.admin === undefined) {
|
||||
navigateAdmin("", { replace: true });
|
||||
} else if (!user.is_platform_admin && route.admin !== undefined) {
|
||||
navigate("dashboard", { replace: true });
|
||||
navigate("omniCreate", { replace: true });
|
||||
}
|
||||
// navigate/navigateAdmin 为组件内函数,故意不入依赖避免每次渲染重跑
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [booting, user, team, route.admin]);
|
||||
|
||||
// 子账号(非主账号)纠回:团队 / 消费 页仅主账号(owner=超管)可见,子账号直接拉回工作台(PMC#3)。
|
||||
// 子账号(非主账号)纠回:团队 / 消费 页仅主账号(owner=超管)可见,子账号直接拉回全能创作(PMC#3)。
|
||||
// role 为空时(身份尚未带回角色)不拦,避免误纠超管。
|
||||
useLayoutEffect(() => {
|
||||
if (booting || !user || !role) return;
|
||||
if (!isOwner && isOwnerOnlyPage(page)) {
|
||||
navigate("dashboard", { replace: true });
|
||||
navigate("omniCreate", { replace: true });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [booting, user, role, isOwner, page]);
|
||||
|
||||
// 工作台暂时隐藏:普通入口落到 dashboard 时改走全能创作。
|
||||
// /admin/* 故意用 page=dashboard + route.admin 占位,绝不能再踢去 omni,否则会和上面的超管 gating 对踢死循环。
|
||||
useLayoutEffect(() => {
|
||||
if (booting || !user || page !== "dashboard") return;
|
||||
if (route.admin !== undefined) return;
|
||||
navigate("omniCreate", { replace: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [booting, user, page, route.admin]);
|
||||
|
||||
// Load preferences + sessions when entering settings.
|
||||
// 进创作相关页时重拉模型目录,避免后台刚改能力/积分,前端还捧着首屏缓存
|
||||
useEffect(() => {
|
||||
if (!authed || !dataLoaded) return;
|
||||
if (!["freeCreate", "omniCreate", "omniSession", "omniHistory", "quickCreate", "videoReplace", "pipeline"].includes(page)) return;
|
||||
refreshModelConfigs();
|
||||
}, [authed, page, dataLoaded, refreshModelConfigs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authed || (page !== "settings" && page !== "settingsNotify")) return;
|
||||
loadSettingsData();
|
||||
@@ -856,7 +877,7 @@ export function App() {
|
||||
navigateAdmin("", { replace: true });
|
||||
return;
|
||||
}
|
||||
navigate("dashboard", { replace: true });
|
||||
navigate("omniCreate", { replace: true });
|
||||
// 先把工作台必需数据拉好,这期间「登录页」保持「登录成功,正在进入工作台…」提示(authed 仍 false → AuthScreen 不卸载),
|
||||
// 数据就绪后才揭开外壳直接进有数据的工作台 —— 避免中间闪一个空白「加载中…」页(PMC#7)。
|
||||
try {
|
||||
@@ -923,9 +944,10 @@ export function App() {
|
||||
const currentTeam: Team = team;
|
||||
|
||||
function renderPage() {
|
||||
switch (page) {
|
||||
switch (page) {
|
||||
case "dashboard":
|
||||
return <Dashboard products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} billing={billing} userName={currentUser.username} loading={!dataLoaded} navigate={navigate} onDelete={deleteProjectAction} />;
|
||||
// 工作台隐藏期间不渲染该页,上面的 effect 会改到全能创作
|
||||
return null;
|
||||
case "products":
|
||||
return (
|
||||
<ProductsPage
|
||||
@@ -1079,7 +1101,7 @@ export function App() {
|
||||
/>
|
||||
);
|
||||
case "omniCreate":
|
||||
return <OmniCreatePage navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} />;
|
||||
return <OmniCreatePage modelConfigs={modelConfigs} navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} />;
|
||||
case "omniHistory":
|
||||
return <OmniHistoryPage navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} />;
|
||||
case "omniSession":
|
||||
@@ -1089,6 +1111,7 @@ export function App() {
|
||||
firstMessage={route.firstMessage}
|
||||
firstRefs={route.firstRefs}
|
||||
firstUploads={route.firstUploads}
|
||||
modelConfigs={modelConfigs}
|
||||
navigate={navigate}
|
||||
onNotify={(type, text) => setNotice({ type, text })}
|
||||
/>
|
||||
@@ -1140,7 +1163,7 @@ export function App() {
|
||||
case "settingsNotify":
|
||||
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} onLogout={logout} />;
|
||||
default:
|
||||
return <Dashboard products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} billing={billing} userName={currentUser.username} loading={!dataLoaded} navigate={navigate} onDelete={deleteProjectAction} />;
|
||||
return <OmniCreatePage modelConfigs={modelConfigs} navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1182,6 +1205,7 @@ export function App() {
|
||||
scriptModelName={publicModelDisplayName(pipelineTextModel, "AI")}
|
||||
textModels={modelConfigs.filter((m) => m.capability === "text" && m.status === "active")}
|
||||
videoModels={modelConfigs.filter((m) => m.capability === "video" && m.status === "active")}
|
||||
imageModels={modelConfigs.filter((m) => m.capability === "image" && m.status === "active")}
|
||||
loading={loading}
|
||||
navigate={navigate}
|
||||
onBack={() => goBack("projects")}
|
||||
|
||||
@@ -1760,28 +1760,41 @@
|
||||
.image-workbench .ic-param.open .ic-param-btn svg { transform: rotate(180deg); }
|
||||
.image-workbench .ic-param-menu {
|
||||
position: absolute; bottom: calc(100% + 6px); left: -2px;
|
||||
min-width: 140px;
|
||||
min-width: max(100%, 180px);
|
||||
width: max-content;
|
||||
max-width: min(280px, calc(100vw - 24px));
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, .08);
|
||||
padding: 4px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12);
|
||||
padding: 6px;
|
||||
display: none;
|
||||
z-index: 30;
|
||||
z-index: 40;
|
||||
}
|
||||
.image-workbench .ic-param.open .ic-param-menu { display: block; }
|
||||
.image-workbench .ic-param.open .ic-param-menu { display: grid; gap: 3px; }
|
||||
.image-workbench .ic-param-menu .mi {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border: 0; border-radius: var(--r-sm);
|
||||
border: 0; border-radius: 8px;
|
||||
background: transparent;
|
||||
font-size: 13px; color: var(--accent-black);
|
||||
font-family: inherit; text-align: left; cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.image-workbench .ic-param-menu .mi:hover { background: var(--black-alpha-4); }
|
||||
.image-workbench .ic-param-menu .mi.selected {
|
||||
background: var(--heat-8);
|
||||
color: var(--heat);
|
||||
font-weight: 600;
|
||||
}
|
||||
.image-workbench .ic-param-menu .mi .mi-check {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
visibility: hidden;
|
||||
color: var(--heat);
|
||||
}
|
||||
.image-workbench .ic-param-menu .mi:hover { background: var(--background-lighter); }
|
||||
.image-workbench .ic-param-menu .mi.selected { color: var(--heat); font-weight: 600; }
|
||||
.image-workbench .ic-param-menu .mi .mi-check { margin-left: auto; visibility: hidden; color: var(--heat); }
|
||||
.image-workbench .ic-param-menu .mi.selected .mi-check { visibility: visible; }
|
||||
|
||||
/* ============================================================
|
||||
@@ -3040,7 +3053,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.yz-image .new-conversation {
|
||||
height: 46px;
|
||||
@@ -3070,24 +3085,38 @@
|
||||
gap: 4px;
|
||||
}
|
||||
.yz-image .history-item {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
.yz-image .history-item:hover { background: rgba(34, 42, 54, 0.045); }
|
||||
.yz-image .history-item.active { background: rgba(0, 47, 167, 0.075); }
|
||||
.yz-image .history-item .nm {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.yz-image .history-item .ic-conv-acts { display: flex; gap: 2px; }
|
||||
.yz-image .history-item.active .nm { color: var(--klein); font-weight: 600; }
|
||||
.yz-image .history-item .ic-conv-acts {
|
||||
flex: 0 0 auto;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.yz-image .history-item:hover .ic-conv-acts,
|
||||
.yz-image .history-item:focus-within .ic-conv-acts { display: inline-flex; }
|
||||
.yz-image .history-empty {
|
||||
margin: 6px 4px;
|
||||
color: var(--muted);
|
||||
@@ -3419,7 +3448,12 @@
|
||||
color: var(--klein);
|
||||
background: rgba(0, 47, 167, 0.075);
|
||||
}
|
||||
.yz-image .ic-param-menu .mi.selected { color: var(--klein); }
|
||||
.yz-image .ic-param-menu .mi.selected {
|
||||
background: rgba(0, 47, 167, 0.08);
|
||||
color: var(--klein);
|
||||
}
|
||||
.yz-image .ic-param-menu .mi.selected .mi-check { color: var(--klein); }
|
||||
|
||||
.yz-image .image-reference-thumbs button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -669,7 +669,7 @@ export const api = {
|
||||
// 独立实体提取(**异步**):只提交任务、秒回 task_id(慢活在 worker 跑,不再 502);已有在途则复用(防重复扣费)。
|
||||
// 进度/结果用 extractStatus 轮询。商品不提(参考图层用真实主图)。
|
||||
extractEntities(projectId: string) {
|
||||
return request<{ task_id: string; status: string }>(`/api/projects/${projectId}/extract-entities/`, { method: "POST" });
|
||||
return request<{ task_id: string; status: string; mode?: string; entities?: Array<{ id: string; type: "character" | "scene"; name: string; visual_prompt: string; ref_index: number }> }>(`/api/projects/${projectId}/extract-entities/`, { method: "POST" });
|
||||
},
|
||||
// 实体提取进度:running=有在途任务(刷新后据此重建 loading);否则读最近一次成败,成功回带 entities 供「提取+生成」继续出图。
|
||||
extractStatus(projectId: string) {
|
||||
@@ -947,7 +947,8 @@ export const api = {
|
||||
return request<BillingTrend>(`/api/billing/trend/${range ? `?range=${range}` : ""}`);
|
||||
},
|
||||
modelConfigs() {
|
||||
return request<Paginated<ModelConfig>>("/api/ai/models/");
|
||||
// 创作页下拉依赖完整 active 目录;加大 page_size 避免默认分页截断
|
||||
return request<Paginated<ModelConfig>>("/api/ai/models/?page_size=200");
|
||||
},
|
||||
aiTasks() {
|
||||
// 生图工作室的任务中心:只看生图任务(模特上身图/平台套图/图片创作 = person_image /
|
||||
@@ -1335,7 +1336,7 @@ export const adminApi = {
|
||||
const q = qs.toString();
|
||||
return request<AdminModel[]>(`/api/admin/models/${q ? `?${q}` : ""}`);
|
||||
},
|
||||
createModel(payload: { provider: string; name: string; display_name: string; capability: string; endpoint?: string; unit_price?: string; status?: string }) {
|
||||
createModel(payload: { provider: string; name: string; display_name: string; capability: string; endpoint?: string; unit_price?: string; status?: string; metadata?: Record<string, unknown> }) {
|
||||
return request<AdminModel>("/api/admin/models/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
updateModel(id: string, payload: Record<string, unknown>) {
|
||||
|
||||
@@ -11,7 +11,6 @@ const SIDEBAR_COLLAPSED_KEY = "airshelf:sidebar-collapsed";
|
||||
// 全局命令面板(Ctrl K / 点搜索框)—— 忠实搬设计稿 SHELL_COMMANDS,href 改成真路由导航
|
||||
type Command = { id: string; group: string; label: string; sub: string; page: Page; icon: string; key?: string };
|
||||
const SHELL_COMMANDS: Command[] = [
|
||||
{ id: "dashboard", group: "导航", label: "工作台", sub: "任务队列、今日消耗、项目进度", page: "dashboard", icon: "dashboard", key: "D" },
|
||||
{ id: "omni-create", group: "导航", label: "全能创作", sub: "预设工作流 + 对话式 Agent", page: "omniCreate", icon: "sparkles", key: "O" },
|
||||
{ id: "omni-history", group: "导航", label: "创作历史", sub: "查看与继续独立会话", page: "omniHistory", icon: "history", key: "H" },
|
||||
{ id: "products", group: "导航", label: "商品库", sub: "管理 SKU、商品图册、卖点信息", page: "products", icon: "package", key: "P" },
|
||||
@@ -225,7 +224,6 @@ export function AccountMenu({ open, anchorRect, onClose, navigate, logout, user,
|
||||
type NavDef = { id: string; page: Page; label: string; icon: string; badge?: number };
|
||||
|
||||
const NAV: NavDef[] = [
|
||||
{ id: "dashboard", page: "dashboard", label: "工作台", icon: "dashboard" },
|
||||
{ id: "products", page: "products", label: "商品库", icon: "package" },
|
||||
{ id: "models", page: "models", label: "模特库", icon: "model" },
|
||||
{ id: "projects", page: "projects", label: "视频创作", icon: "clapperboard" },
|
||||
@@ -281,7 +279,6 @@ export function topModuleForPage(page: Page): TopModule | null {
|
||||
}
|
||||
|
||||
const MODE_TABS: { id: TopModule; label: string; page: Page }[] = [
|
||||
{ id: "workbench", label: "工作台", page: "dashboard" },
|
||||
{ id: "omni", label: "全能创作", page: "omniCreate" },
|
||||
{ id: "image", label: "图片创作", page: "assetFactory" },
|
||||
{ id: "video", label: "视频创作", page: "projects" },
|
||||
@@ -365,10 +362,9 @@ export function ModeTabs({ active, navigate }: { active: TopModule | null; navig
|
||||
<svg className="mode-clip-defs" width="0" height="0" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<clipPath id="modeButtonMask" clipPathUnits="userSpaceOnUse">
|
||||
<rect rx="22" ry="22" x="0" y="0" width="104" height="44" />
|
||||
<rect rx="22" ry="22" x="120" y="0" width="110" height="44" />
|
||||
<rect rx="22" ry="22" x="246" y="0" width="110" height="44" />
|
||||
<rect rx="22" ry="22" x="372" y="0" width="110" height="44" />
|
||||
<rect rx="22" ry="22" x="0" y="0" width="110" height="44" />
|
||||
<rect rx="22" ry="22" x="126" y="0" width="110" height="44" />
|
||||
<rect rx="22" ry="22" x="252" y="0" width="110" height="44" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
@@ -567,7 +563,7 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
|
||||
{mobileNavOpen && <div className="mobile-nav-backdrop" onClick={() => setMobileNavOpen(false)} />}
|
||||
<aside className={`sidebar${mobileNavOpen ? " mobile-open" : ""}`}>
|
||||
<div className="sidebar-head">
|
||||
<a className="brand" href="/dashboard" aria-label="影擎工作台" onClick={(event) => { event.preventDefault(); navigate("dashboard"); }}>
|
||||
<a className="brand" href="/omni-create" aria-label="影擎全能创作" onClick={(event) => { event.preventDefault(); navigate("omniCreate"); }}>
|
||||
<span className="brand-clip"><img className="brand-logo" src="/assets/yz/logo-horizontal.png" alt="影擎" /></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,37 @@ export function modelDurations(config: ModelConfig | undefined): number[] {
|
||||
return list?.length ? list : [...FC_DURATIONS];
|
||||
}
|
||||
|
||||
/** 老板挂牌: metadata.points_pricing 每秒积分(视频)。无挂牌返回 null。 */
|
||||
export function pointsPerSecondFromCatalog(
|
||||
config: ModelConfig | undefined,
|
||||
resolution: string,
|
||||
hasVideoRef = false,
|
||||
): number | null {
|
||||
const pricing = (config?.metadata?.points_pricing || {}) as Record<string, unknown>;
|
||||
const tiers = Array.isArray(pricing.tiers) ? pricing.tiers : [];
|
||||
const res = (resolution || "").toLowerCase();
|
||||
const hit = tiers.find((row) => row && typeof row === "object" && String((row as { resolution?: string }).resolution || "").toLowerCase() === res) as
|
||||
| { points_per_second?: number; points_per_second_with_ref?: number }
|
||||
| undefined;
|
||||
if (!hit) return null;
|
||||
if (hasVideoRef && hit.points_per_second_with_ref != null) {
|
||||
const n = Number(hit.points_per_second_with_ref);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
const n = Number(hit.points_per_second);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export function pointsPerImageFromCatalog(config: ModelConfig | undefined): number | null {
|
||||
const pricing = (config?.metadata?.points_pricing || {}) as Record<string, unknown>;
|
||||
if (pricing.points_per_image != null) {
|
||||
const n = Number(pricing.points_per_image);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
const unit = Number(config?.unit_price);
|
||||
return Number.isFinite(unit) && unit > 0 ? unit : null;
|
||||
}
|
||||
|
||||
export const MODE_LABELS: Record<FreeMode, string> = { universal: "全能参考", keyframe: "首尾帧" };
|
||||
|
||||
// 上传素材限制(与后端 FreeVideoUploadView / jimeng inputBar 对齐)
|
||||
@@ -117,28 +148,27 @@ export function tokenPrice(config: ModelConfig | undefined, resolution: string,
|
||||
export type BillingRates = { margin: number; rate: number; multiplier: number };
|
||||
export const DEFAULT_BILLING_RATES: BillingRates = { margin: 1.5, rate: 10, multiplier: 1 };
|
||||
|
||||
export function hasVideoPointsPricing(config: ModelConfig | undefined): boolean {
|
||||
const pricing = (config?.metadata?.points_pricing || {}) as Record<string, unknown>;
|
||||
const tiers = Array.isArray(pricing.tiers) ? pricing.tiers : [];
|
||||
return tiers.some((row) => row && typeof row === "object" && Number((row as { points_per_second?: number }).points_per_second) >= 0);
|
||||
}
|
||||
|
||||
/** 视频预估积分。只认后台 metadata.points_pricing 挂牌;没填挂牌返回 0(不拿旧 token×毛利糊弄用户)。 */
|
||||
export function estimateCost(
|
||||
config: ModelConfig | undefined,
|
||||
params: { ratio: string; resolution: string; duration: number; refs: { type: string; duration?: number }[] },
|
||||
billing: BillingRates = DEFAULT_BILLING_RATES
|
||||
): { tokens: number; points: number } {
|
||||
const inputVideoSeconds = params.refs
|
||||
.filter((r) => r.type === "video")
|
||||
.reduce((sum, r) => sum + (r.duration || 0), 0);
|
||||
const tokens = estimateTokens(params.ratio, params.resolution, params.duration, inputVideoSeconds);
|
||||
): { tokens: number; points: number; listed: boolean } {
|
||||
const hasVideoRef = params.refs.some((r) => r.type === "video");
|
||||
const price = tokenPrice(config, params.resolution, hasVideoRef);
|
||||
const costYuan = Math.round(((tokens * price) / 1e6) * 100) / 100;
|
||||
if (costYuan <= 0) return { tokens, points: 0 };
|
||||
// 浮点乘积在 .5 边界可能落成 27.499999…,先吸附 6 位小数再 round,
|
||||
// 与后端 Decimal ROUND_HALF_UP 逐字对齐。只吸浮点噪声(1e-9 级),
|
||||
// 不能用 toFixed(2):非默认 margin 下 x.495 会被先四舍五入成 x.50 多显 1 积分(review 确认)
|
||||
const raw = Number((costYuan * billing.margin * billing.rate).toFixed(6));
|
||||
const listPoints = Math.max(1, Math.round(raw));
|
||||
// 团队价格系数(差异化调价):两步取整与后端 apply_team_price 逐字对齐(先挂牌取整,再乘系数取整)
|
||||
const multiplier = billing.multiplier || 1;
|
||||
const points = multiplier === 1 ? listPoints : Math.max(1, Math.round(Number((listPoints * multiplier).toFixed(6))));
|
||||
return { tokens, points };
|
||||
const pps = pointsPerSecondFromCatalog(config, params.resolution, hasVideoRef);
|
||||
if (pps != null && params.duration > 0) {
|
||||
const listPoints = Math.max(1, Math.round(pps * params.duration));
|
||||
const multiplier = billing.multiplier || 1;
|
||||
const points = multiplier === 1 ? listPoints : Math.max(1, Math.round(Number((listPoints * multiplier).toFixed(6))));
|
||||
return { tokens: 0, points, listed: true };
|
||||
}
|
||||
return { tokens: 0, points: 0, listed: false };
|
||||
}
|
||||
|
||||
export function modelLabel(name: string): string {
|
||||
|
||||
@@ -59,7 +59,7 @@ function KeyframeSlot({ role, item, onPickLibrary, onRemove }: {
|
||||
);
|
||||
}
|
||||
|
||||
export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onOpenPlatformLibrary, onClear, onSend }: {
|
||||
export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onOpenPlatformLibrary, onSend }: {
|
||||
mode: FreeMode;
|
||||
model: string;
|
||||
ratio: string;
|
||||
@@ -82,7 +82,6 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
|
||||
onSeedChange: (seed: number) => void;
|
||||
onOpenLibrary: (role?: "first_frame" | "last_frame") => void;
|
||||
onOpenPlatformLibrary: (role?: "first_frame" | "last_frame") => void;
|
||||
onClear: () => void;
|
||||
onSend: () => void;
|
||||
}) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
@@ -175,7 +174,6 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
|
||||
onResolutionChange={onResolutionChange}
|
||||
onDurationChange={onDurationChange}
|
||||
onSeedChange={onSeedChange}
|
||||
onClear={onClear}
|
||||
onSend={onSend}
|
||||
/>
|
||||
{dragOver && <div className="fc-drop-hint">松开上传到「{MODE_LABELS[mode]}」</div>}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
// 自由创作·输入条工具栏:模型/模式/比例/分辨率/时长/种子 下拉 + 预估消耗 + 清空 + 生成。
|
||||
// 自由创作·输入条工具栏:模型/模式/比例/分辨率/时长/种子 下拉 + 预估积分 + 生成。
|
||||
// 模型下拉列后端返回的全部视频模型(FC_MODELS 只提供好看的标签)。
|
||||
// 约束联动(与后端校验一致):分辨率与时长档位取自所选模型的 metadata.capabilities,
|
||||
// 换档时把超出新模型能力的选择夹回去。
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ArrowRight, ChevronDown } from "lucide-react";
|
||||
import { ArrowRight, ChevronDown, Coins } from "lucide-react";
|
||||
import type { ModelConfig } from "../../types";
|
||||
import {
|
||||
DEFAULT_BILLING_RATES,
|
||||
FC_MODELS,
|
||||
FC_RATIOS,
|
||||
FC_RESOLUTIONS,
|
||||
MODE_LABELS,
|
||||
estimateCost,
|
||||
modelDurations,
|
||||
@@ -77,7 +76,7 @@ function FcDropdown({ label, display, items, onSelect, disabled }: {
|
||||
);
|
||||
}
|
||||
|
||||
export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, hasPrompt, submitting, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onClear, onSend }: {
|
||||
export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, hasPrompt, submitting, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onSend }: {
|
||||
mode: FreeMode;
|
||||
model: string;
|
||||
ratio: string;
|
||||
@@ -95,14 +94,13 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
|
||||
onResolutionChange: (resolution: string) => void;
|
||||
onDurationChange: (duration: number) => void;
|
||||
onSeedChange: (seed: number) => void;
|
||||
onClear: () => void;
|
||||
onSend: () => void;
|
||||
}) {
|
||||
const config = videoConfigs.find((c) => c.name === model);
|
||||
// 可选的分辨率/时长按所选模型的能力来,不再写死 —— Seedance 2.5 能出 30 秒,2.0 只有 15。
|
||||
const allowedResolutions = modelResolutions(config);
|
||||
const allowedDurations = modelDurations(config);
|
||||
const { tokens, points } = estimateCost(config, { ratio, resolution, duration, refs }, billingRates || DEFAULT_BILLING_RATES);
|
||||
const { points } = estimateCost(config, { ratio, resolution, duration, refs }, billingRates || DEFAULT_BILLING_RATES);
|
||||
const [seedOpen, setSeedOpen] = useState(false);
|
||||
const seedRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
@@ -152,11 +150,9 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
|
||||
<FcDropdown
|
||||
label="分辨率"
|
||||
display={resolution.toUpperCase()}
|
||||
items={FC_RESOLUTIONS.map((r) => ({
|
||||
items={allowedResolutions.map((r) => ({
|
||||
value: r,
|
||||
label: r.toUpperCase(),
|
||||
disabled: !allowedResolutions.includes(r),
|
||||
hint: "当前模型不支持这一档"
|
||||
}))}
|
||||
onSelect={onResolutionChange}
|
||||
/>
|
||||
@@ -191,10 +187,12 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
|
||||
</div>
|
||||
</div>
|
||||
<div className="fc-toolbar-r">
|
||||
<span className="fc-estimate" title="预估消耗(实际按真实用量结算,多退少不补超)">
|
||||
≈ {tokens.toLocaleString()} tokens · {points.toLocaleString()} 积分
|
||||
</span>
|
||||
<button type="button" className="fc-clear" onClick={onClear}>清空</button>
|
||||
{points > 0 ? (
|
||||
<span className="fc-estimate" title="预估积分(实际按真实用量结算)">
|
||||
<Coins aria-hidden="true" />
|
||||
≈ {points.toLocaleString()} 积分
|
||||
</span>
|
||||
) : null}
|
||||
<button type="button" className="fc-gen" disabled={!canSend} onClick={onSend} title="Ctrl/Cmd + Enter">
|
||||
{submitting ? "提交中…" : uploading ? "素材上传中…" : "生成"}
|
||||
<ArrowRight />
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronDown, SlidersHorizontal, Sparkles } from "lucide-react";
|
||||
import { CustomSelect } from "./custom-select";
|
||||
import { modelDurations, modelResolutions } from "./free-create/constants";
|
||||
import type { ModelConfig } from "../types";
|
||||
|
||||
/** 无目录时的回落清单(展示名,与会话 params.model 历史值兼容)。 */
|
||||
export const OMNI_VIDEO_MODELS = ["Seedance 2.5", "Seedance 2.0", "Seedance 2.0 Fast", "Seedance 2.0 Mini"];
|
||||
export const OMNI_IMAGE_MODELS = ["Seedream5.0", "YQ image2"];
|
||||
export const OMNI_RESOLUTIONS = ["1080p", "720p", "480p"];
|
||||
export const OMNI_RATIOS = ["9:16", "16:9", "1:1", "4:3", "3:4"];
|
||||
export const OMNI_VIDEO_DURATIONS = [
|
||||
"4 秒", "5 秒", "6 秒", "7 秒", "8 秒", "9 秒", "10 秒",
|
||||
"智能时长", "4 秒", "5 秒", "6 秒", "7 秒", "8 秒", "9 秒", "10 秒",
|
||||
"11 秒", "12 秒", "13 秒", "14 秒", "15 秒", "30 秒",
|
||||
];
|
||||
export const OMNI_IMAGE_COUNTS = ["1 张", "2 张", "4 张", "8 张"];
|
||||
@@ -20,6 +23,59 @@ function withCurrent(values: string[], current: string) {
|
||||
return current && !values.includes(current) ? [current, ...values] : values;
|
||||
}
|
||||
|
||||
function modelOptionLabel(config: ModelConfig): string {
|
||||
return (config.display_name || config.name || "").trim();
|
||||
}
|
||||
|
||||
/** 忽略空格/横杠/大小写,兼容旧会话「Seedance 2.0 Mini」对上后台「Seedance-2.0-Mini」。 */
|
||||
function normalizeModelKey(value: string): string {
|
||||
return String(value || "").toLowerCase().replace(/[\s_\-·.]+/g, "");
|
||||
}
|
||||
|
||||
/** 会话里存的是展示名;用 display_name / name 都能对上目录项。 */
|
||||
export function findCatalogModel(
|
||||
configs: ModelConfig[] | undefined,
|
||||
label: string,
|
||||
capability: "video" | "image",
|
||||
): ModelConfig | undefined {
|
||||
const list = (configs || []).filter((c) => c.capability === capability && c.status !== "disabled");
|
||||
const key = (label || "").trim();
|
||||
if (!key) return list[0];
|
||||
const norm = normalizeModelKey(key);
|
||||
return (
|
||||
list.find((c) => modelOptionLabel(c) === key)
|
||||
|| list.find((c) => c.name === key)
|
||||
|| list.find((c) => normalizeModelKey(modelOptionLabel(c)) === norm)
|
||||
|| list.find((c) => normalizeModelKey(c.name) === norm)
|
||||
|| list.find((c) => {
|
||||
const dn = normalizeModelKey(c.display_name || "");
|
||||
const nm = normalizeModelKey(c.name || "");
|
||||
return (dn && (dn.includes(norm) || norm.includes(dn))) || (nm && (nm.includes(norm) || norm.includes(nm)));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function catalogModelLabels(
|
||||
configs: ModelConfig[] | undefined,
|
||||
capability: "video" | "image",
|
||||
fallback: string[],
|
||||
): string[] {
|
||||
const labels = (configs || [])
|
||||
.filter((c) => c.capability === capability)
|
||||
.map(modelOptionLabel)
|
||||
.filter(Boolean);
|
||||
const seen = new Set<string>();
|
||||
const unique = labels.filter((x) => (seen.has(x) ? false : (seen.add(x), true)));
|
||||
return unique.length ? unique : fallback;
|
||||
}
|
||||
|
||||
function durationLabelsForModel(config: ModelConfig | undefined, isVideo: boolean): string[] {
|
||||
if (!isVideo) return [...OMNI_IMAGE_COUNTS];
|
||||
const seconds = modelDurations(config);
|
||||
if (!seconds.length) return [...OMNI_VIDEO_DURATIONS];
|
||||
return ["智能时长", ...seconds.map((n) => `${n} 秒`)];
|
||||
}
|
||||
|
||||
export function OmniParamBar({
|
||||
isVideo,
|
||||
disabled,
|
||||
@@ -27,6 +83,7 @@ export function OmniParamBar({
|
||||
resolution,
|
||||
ratio,
|
||||
duration,
|
||||
catalogModels,
|
||||
onModel,
|
||||
onResolution,
|
||||
onRatio,
|
||||
@@ -38,6 +95,7 @@ export function OmniParamBar({
|
||||
resolution: string;
|
||||
ratio: string;
|
||||
duration: string;
|
||||
catalogModels?: ModelConfig[];
|
||||
onModel: (value: string) => void;
|
||||
onResolution: (value: string) => void;
|
||||
onRatio: (value: string) => void;
|
||||
@@ -47,6 +105,20 @@ export function OmniParamBar({
|
||||
const [customOn, setCustomOn] = useState(isVideo ? duration !== "智能时长" : true);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const capability = isVideo ? "video" as const : "image" as const;
|
||||
const selected = useMemo(
|
||||
() => findCatalogModel(catalogModels, model, capability),
|
||||
[catalogModels, model, capability],
|
||||
);
|
||||
|
||||
const models = withCurrent(
|
||||
catalogModelLabels(catalogModels, capability, isVideo ? OMNI_VIDEO_MODELS : OMNI_IMAGE_MODELS),
|
||||
model,
|
||||
);
|
||||
const allowedRes = isVideo ? modelResolutions(selected) : [];
|
||||
const resolutions = withCurrent(allowedRes.length ? allowedRes : OMNI_RESOLUTIONS, resolution);
|
||||
const durations = withCurrent(durationLabelsForModel(selected, isVideo), duration);
|
||||
|
||||
useEffect(() => {
|
||||
setCustomOn(isVideo ? duration !== "智能时长" : true);
|
||||
}, [isVideo, duration]);
|
||||
@@ -60,8 +132,18 @@ export function OmniParamBar({
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [menuOpen]);
|
||||
|
||||
const models = withCurrent(isVideo ? OMNI_VIDEO_MODELS : OMNI_IMAGE_MODELS, model);
|
||||
const durations = isVideo ? OMNI_VIDEO_DURATIONS : OMNI_IMAGE_COUNTS;
|
||||
useEffect(() => {
|
||||
if (!selected || !isVideo) return;
|
||||
const nextRes = modelResolutions(selected);
|
||||
if (nextRes.length && resolution && !nextRes.includes(resolution)) {
|
||||
onResolution(nextRes.includes("720p") ? "720p" : nextRes[0]);
|
||||
}
|
||||
const nextDur = durationLabelsForModel(selected, true);
|
||||
if (duration && duration !== "智能时长" && !nextDur.includes(duration)) {
|
||||
onDuration(nextDur.includes("8 秒") ? "8 秒" : (nextDur.find((x) => x !== "智能时长") || "智能时长"));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selected?.id, isVideo]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -84,7 +166,7 @@ export function OmniParamBar({
|
||||
disabled={disabled}
|
||||
value={resolution}
|
||||
onChange={onResolution}
|
||||
options={toOptions(withCurrent(OMNI_RESOLUTIONS, resolution))}
|
||||
options={toOptions(resolutions)}
|
||||
/>
|
||||
</label>
|
||||
<label className="omni-parameter">
|
||||
@@ -136,7 +218,7 @@ export function OmniParamBar({
|
||||
</div>
|
||||
) : null}
|
||||
<div className="omni-duration-values" hidden={isVideo && !customOn}>
|
||||
{durations.map((value) => (
|
||||
{(isVideo ? durations.filter((value) => value !== "智能时长") : durations).map((value) => (
|
||||
<button
|
||||
type="button"
|
||||
key={value}
|
||||
|
||||
@@ -1650,7 +1650,8 @@ select.duration-select:focus,
|
||||
max-width: none;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
z-index: calc(var(--z-overlay) + 40);
|
||||
/* 必须高于 .modal-bg(999) / .admin-drawer(1800),否则弹窗里点开下拉看不见、点不着 */
|
||||
z-index: 1900;
|
||||
}
|
||||
.rs-select-group {
|
||||
padding: 8px 10px 4px;
|
||||
|
||||
@@ -646,23 +646,21 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fc-page .fc-estimate {
|
||||
font-size: 11px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--fc-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.fc-page .fc-clear {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
.fc-page .fc-estimate svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
color: var(--fc-muted);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fc-page .fc-clear:hover { color: var(--accent-black); background: rgba(34, 42, 54, 0.06); }
|
||||
.fc-page .fc-gen {
|
||||
min-width: 100px;
|
||||
height: 42px;
|
||||
|
||||
@@ -1345,7 +1345,8 @@
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.omni-confirm-card button {
|
||||
/* 只染脚上的确认按钮。参数条里的 CustomSelect / 时长触发器不能吃到克莱因蓝。 */
|
||||
.omni-confirm-foot > button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -1358,12 +1359,12 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.omni-confirm-card button:disabled {
|
||||
.omni-confirm-foot > button:disabled {
|
||||
background: rgba(34, 42, 54, .18);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.omni-confirm-card button i {
|
||||
.omni-confirm-foot > button i {
|
||||
color: rgba(255, 255, 255, .72);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
|
||||
@@ -1018,7 +1018,7 @@
|
||||
.as-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-end;
|
||||
gap: 18px;
|
||||
padding: 0 20px;
|
||||
border-top: 1px solid rgba(34, 42, 54, 0.09);
|
||||
@@ -1490,6 +1490,7 @@
|
||||
.setup-card .tpl-expect { font-size: 12px; line-height: 1.6; color: var(--black-alpha-56); background: var(--heat-8); border-radius: var(--r-sm); padding: 8px 10px; margin-bottom: 8px; }
|
||||
.setup-card .tpl-expect .mono { font-family: var(--font-mono); font-size: 10px; color: var(--heat); letter-spacing: .04em; margin-right: 6px; }
|
||||
.setup-card .setup-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
||||
.setup-card .setup-cost-hint { color: var(--pl-muted); font-size: 10px; white-space: nowrap; margin-right: 2px; }
|
||||
|
||||
/* ── 行34 · 添加分镜:本地草稿可编辑卡片 ── */
|
||||
.draft-shot-card { border-style: dashed; }
|
||||
|
||||
@@ -7,6 +7,18 @@ import { CustomSelect } from "../../components/custom-select";
|
||||
import type { AdminModel, AdminProvider } from "../../types";
|
||||
import { pts } from "../stage-config";
|
||||
|
||||
|
||||
function sortAdminModels(list: AdminModel[]): AdminModel[] {
|
||||
// 默认最前,启用次之,停用靠后;同档按能力、供应商
|
||||
return [...list].sort((a, b) => {
|
||||
if (Boolean(a.is_default) !== Boolean(b.is_default)) return a.is_default ? -1 : 1;
|
||||
if ((a.status === "active") !== (b.status === "active")) return a.status === "active" ? -1 : 1;
|
||||
const cap = String(a.capability || "").localeCompare(String(b.capability || ""));
|
||||
if (cap !== 0) return cap;
|
||||
return String(a.provider_name || a.provider || "").localeCompare(String(b.provider_name || b.provider || ""));
|
||||
});
|
||||
}
|
||||
|
||||
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
@@ -20,7 +32,100 @@ function statusPill(active: boolean) {
|
||||
}
|
||||
|
||||
const EMPTY_PROVIDER = { id: "", name: "", display_name: "", base_url: "", api_key: "", status: "active" };
|
||||
const EMPTY_MODEL = { id: "", provider: "", name: "", display_name: "", capability: "text", endpoint: "", unit_price: "", status: "active" };
|
||||
const VIDEO_RES_OPTIONS = ["480p", "720p", "1080p", "4k"];
|
||||
|
||||
type VideoTier = { resolution: string; points_per_second: string; points_per_second_with_ref: string };
|
||||
|
||||
type ModelForm = {
|
||||
id: string;
|
||||
provider: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
capability: string;
|
||||
endpoint: string;
|
||||
unit_price: string;
|
||||
status: string;
|
||||
durationsText: string;
|
||||
videoTiers: VideoTier[];
|
||||
};
|
||||
|
||||
const EMPTY_MODEL: ModelForm = {
|
||||
id: "", provider: "", name: "", display_name: "", capability: "text", endpoint: "",
|
||||
unit_price: "", status: "active", durationsText: "4,5,6,8,10,12,15",
|
||||
videoTiers: [{ resolution: "480p", points_per_second: "20", points_per_second_with_ref: "" }],
|
||||
};
|
||||
|
||||
function readModelForm(m: AdminModel): ModelForm {
|
||||
const meta = (m.metadata || {}) as Record<string, unknown>;
|
||||
const caps = (meta.capabilities || {}) as Record<string, unknown>;
|
||||
const pricing = (meta.points_pricing || {}) as Record<string, unknown>;
|
||||
const durations = Array.isArray(caps.durations) ? caps.durations.map(String) : [];
|
||||
const tiersRaw = Array.isArray(pricing.tiers) ? pricing.tiers : [];
|
||||
const videoTiers: VideoTier[] = tiersRaw
|
||||
.filter((row): row is Record<string, unknown> => !!row && typeof row === "object")
|
||||
.map((row) => ({
|
||||
resolution: String(row.resolution || ""),
|
||||
points_per_second: String(row.points_per_second ?? ""),
|
||||
points_per_second_with_ref: String(row.points_per_second_with_ref ?? ""),
|
||||
}));
|
||||
let unit = m.unit_price || "";
|
||||
if (m.capability === "image" && pricing.points_per_image != null) unit = String(pricing.points_per_image);
|
||||
if ((m.capability === "text" || m.capability === "vision") && pricing.points_per_call != null) {
|
||||
unit = String(pricing.points_per_call);
|
||||
}
|
||||
return {
|
||||
id: m.id,
|
||||
provider: m.provider,
|
||||
name: m.name,
|
||||
display_name: m.display_name,
|
||||
capability: m.capability,
|
||||
endpoint: m.endpoint,
|
||||
unit_price: unit,
|
||||
status: m.status,
|
||||
durationsText: durations.length ? durations.join(",") : "4,5,6,8,10,12,15",
|
||||
videoTiers: videoTiers.length
|
||||
? videoTiers
|
||||
: [{ resolution: "480p", points_per_second: "", points_per_second_with_ref: "" }],
|
||||
};
|
||||
}
|
||||
|
||||
function buildMetadata(form: ModelForm): Record<string, unknown> {
|
||||
if (form.capability === "image") {
|
||||
const pts = Number(form.unit_price || 0);
|
||||
return { points_pricing: { mode: "per_image", points_per_image: Number.isFinite(pts) ? pts : 0 } };
|
||||
}
|
||||
if (form.capability === "text" || form.capability === "vision") {
|
||||
const pts = Number(form.unit_price || 0);
|
||||
return { points_pricing: { mode: "per_call", points_per_call: Number.isFinite(pts) ? pts : 0 } };
|
||||
}
|
||||
if (form.capability === "video") {
|
||||
const durations = form.durationsText
|
||||
.split(/[,,\s]+/)
|
||||
.map((x) => Number(x))
|
||||
.filter((n) => Number.isFinite(n) && n > 0);
|
||||
const tiers = form.videoTiers
|
||||
.filter((row) => row.resolution && row.points_per_second !== "")
|
||||
.map((row) => {
|
||||
const item: Record<string, unknown> = {
|
||||
resolution: row.resolution,
|
||||
points_per_second: Number(row.points_per_second),
|
||||
};
|
||||
if (row.points_per_second_with_ref !== "") {
|
||||
item.points_per_second_with_ref = Number(row.points_per_second_with_ref);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
return {
|
||||
capabilities: {
|
||||
resolutions: tiers.map((t) => String(t.resolution)),
|
||||
durations,
|
||||
operations: ["video_generate"],
|
||||
},
|
||||
points_pricing: { mode: "per_second", tiers },
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function AdminModelsPage({ notify }: { notify: Notify }) {
|
||||
const [providers, setProviders] = useState<AdminProvider[]>([]);
|
||||
@@ -28,7 +133,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
|
||||
const [modelPage, setModelPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [provModal, setProvModal] = useState<typeof EMPTY_PROVIDER | null>(null);
|
||||
const [modelModal, setModelModal] = useState<typeof EMPTY_MODEL | null>(null);
|
||||
const [modelModal, setModelModal] = useState<ModelForm | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -36,7 +141,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
|
||||
try {
|
||||
const [ps, ms] = await Promise.all([adminApi.providers(), adminApi.models()]);
|
||||
setProviders(ps);
|
||||
setModels(ms);
|
||||
setModels(sortAdminModels(ms));
|
||||
} catch {
|
||||
notify("error", "加载模型供应商失败");
|
||||
} finally {
|
||||
@@ -101,12 +206,32 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
|
||||
async function saveModel() {
|
||||
if (!modelModal || saving) return;
|
||||
if (!modelModal.provider || !modelModal.name.trim()) { notify("error", "请选择供应商并填写模型名"); return; }
|
||||
if (modelModal.capability === "video") {
|
||||
const ok = modelModal.videoTiers.some((row) => row.resolution && row.points_per_second !== "");
|
||||
if (!ok) { notify("error", "视频模型请至少添加一档分辨率和每秒积分"); return; }
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const metadata = buildMetadata(modelModal);
|
||||
if (modelModal.id) {
|
||||
await adminApi.updateModel(modelModal.id, { display_name: modelModal.display_name, endpoint: modelModal.endpoint, unit_price: modelModal.unit_price || "0", status: modelModal.status });
|
||||
await adminApi.updateModel(modelModal.id, {
|
||||
display_name: modelModal.display_name,
|
||||
endpoint: modelModal.endpoint,
|
||||
unit_price: modelModal.unit_price || "0",
|
||||
status: modelModal.status,
|
||||
metadata,
|
||||
});
|
||||
} else {
|
||||
await adminApi.createModel({ provider: modelModal.provider, name: modelModal.name, display_name: modelModal.display_name || modelModal.name, capability: modelModal.capability, endpoint: modelModal.endpoint, unit_price: modelModal.unit_price || "0", status: modelModal.status });
|
||||
await adminApi.createModel({
|
||||
provider: modelModal.provider,
|
||||
name: modelModal.name,
|
||||
display_name: modelModal.display_name || modelModal.name,
|
||||
capability: modelModal.capability,
|
||||
endpoint: modelModal.endpoint,
|
||||
unit_price: modelModal.unit_price || "0",
|
||||
status: modelModal.status,
|
||||
metadata,
|
||||
} as Parameters<typeof adminApi.createModel>[0] & { metadata: Record<string, unknown> });
|
||||
}
|
||||
notify("success", "模型已保存");
|
||||
setModelModal(null);
|
||||
@@ -170,7 +295,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
|
||||
<td>{m.is_default ? <span className="pill info"><span className="dot" />默认</span> : <span className="muted">—</span>}</td>
|
||||
<td className="col-actions">
|
||||
{!m.is_default && <button className="btn btn-sm btn-ghost" type="button" onClick={() => setDefault(m)}>设默认</button>}
|
||||
<button className="btn btn-sm btn-ghost" type="button" onClick={() => setModelModal({ id: m.id, provider: m.provider, name: m.name, display_name: m.display_name, capability: m.capability, endpoint: m.endpoint, unit_price: m.unit_price, status: m.status })}>编辑</button>
|
||||
<button className="btn btn-sm btn-ghost" type="button" onClick={() => setModelModal(readModelForm(m))}>编辑</button>
|
||||
<button className={`btn btn-sm btn-ghost${m.status === "active" ? " danger" : ""}`} type="button" onClick={() => toggleModel(m)}>{m.status === "active" ? "停用" : "启用"}</button>
|
||||
<button className="btn btn-sm btn-ghost danger" type="button" onClick={() => delModel(m)}>删除</button>
|
||||
</td>
|
||||
@@ -184,7 +309,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
|
||||
)}
|
||||
|
||||
{provModal && (
|
||||
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) setProvModal(null); }}>
|
||||
<div className="modal-bg show">
|
||||
<div className="modal" role="dialog" aria-modal="true" aria-label="供应商">
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h">
|
||||
@@ -219,7 +344,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
|
||||
)}
|
||||
|
||||
{modelModal && (
|
||||
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) setModelModal(null); }}>
|
||||
<div className="modal-bg show">
|
||||
<div className="modal" role="dialog" aria-modal="true" aria-label="模型">
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h">
|
||||
@@ -259,16 +384,89 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
|
||||
<label className="field-label">显示名</label>
|
||||
<input className="input" type="text" value={modelModal.display_name} onChange={(e) => setModelModal((m) => m && ({ ...m, display_name: e.target.value }))} />
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label className="field-label">endpoint</label>
|
||||
<input className="input" type="text" value={modelModal.endpoint} onChange={(e) => setModelModal((m) => m && ({ ...m, endpoint: e.target.value }))} />
|
||||
</div>
|
||||
{modelModal.capability === "image" ? (
|
||||
<div className="field">
|
||||
<label className="field-label">积分规则 · 每张</label>
|
||||
<input className="input" type="number" min={0} step={1} placeholder="例如 10" value={modelModal.unit_price} onChange={(e) => setModelModal((m) => m && ({ ...m, unit_price: e.target.value }))} />
|
||||
<div className="field-hint">老板填写:用该模型出 1 张图扣多少积分</div>
|
||||
</div>
|
||||
) : null}
|
||||
{modelModal.capability === "text" || modelModal.capability === "vision" ? (
|
||||
<div className="field">
|
||||
<label className="field-label">积分规则 · 每次</label>
|
||||
<input className="input" type="number" min={0} step={1} placeholder="例如 4" value={modelModal.unit_price} onChange={(e) => setModelModal((m) => m && ({ ...m, unit_price: e.target.value }))} />
|
||||
<div className="field-hint">老板填写:调用 1 次扣多少积分</div>
|
||||
</div>
|
||||
) : null}
|
||||
{modelModal.capability === "video" ? (
|
||||
<>
|
||||
<div className="field">
|
||||
<label className="field-label">可用时长(秒,逗号分隔)</label>
|
||||
<input className="input" type="text" value={modelModal.durationsText} onChange={(e) => setModelModal((m) => m && ({ ...m, durationsText: e.target.value }))} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">分辨率积分(每秒)</label>
|
||||
<div className="field-hint">每个分辨率一档;含视频参考可另填更高积分,不填则同普通价</div>
|
||||
<div style={{ display: "grid", gap: 8, marginTop: 8 }}>
|
||||
{modelModal.videoTiers.map((tier, index) => (
|
||||
<div key={index} className="field-row" style={{ alignItems: "end" }}>
|
||||
<div className="field">
|
||||
<label className="field-label">分辨率</label>
|
||||
<CustomSelect
|
||||
fill
|
||||
value={tier.resolution}
|
||||
onChange={(next) => setModelModal((m) => {
|
||||
if (!m) return m;
|
||||
const videoTiers = m.videoTiers.map((row, i) => i === index ? { ...row, resolution: next } : row);
|
||||
return { ...m, videoTiers };
|
||||
})}
|
||||
options={VIDEO_RES_OPTIONS.map((r) => ({ value: r, label: r }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">积分/秒</label>
|
||||
<input className="input" type="number" min={0} step={1} value={tier.points_per_second} onChange={(e) => setModelModal((m) => {
|
||||
if (!m) return m;
|
||||
const videoTiers = m.videoTiers.map((row, i) => i === index ? { ...row, points_per_second: e.target.value } : row);
|
||||
return { ...m, videoTiers };
|
||||
})} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">含视频参考·积分/秒</label>
|
||||
<input className="input" type="number" min={0} step={1} placeholder="可选" value={tier.points_per_second_with_ref} onChange={(e) => setModelModal((m) => {
|
||||
if (!m) return m;
|
||||
const videoTiers = m.videoTiers.map((row, i) => i === index ? { ...row, points_per_second_with_ref: e.target.value } : row);
|
||||
return { ...m, videoTiers };
|
||||
})} />
|
||||
</div>
|
||||
<button className="btn btn-sm btn-ghost" type="button" onClick={() => setModelModal((m) => m && ({ ...m, videoTiers: m.videoTiers.filter((_, i) => i !== index) }))}>删除</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
type="button"
|
||||
style={{ marginTop: 8 }}
|
||||
onClick={() => setModelModal((m) => m && ({
|
||||
...m,
|
||||
videoTiers: [...m.videoTiers, { resolution: "720p", points_per_second: "", points_per_second_with_ref: "" }],
|
||||
}))}
|
||||
>
|
||||
+ 添加分辨率
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{!["image", "text", "vision", "video"].includes(modelModal.capability) ? (
|
||||
<div className="field">
|
||||
<label className="field-label">单价(积分/次)</label>
|
||||
<input className="input" type="text" placeholder="0" value={modelModal.unit_price} onChange={(e) => setModelModal((m) => m && ({ ...m, unit_price: e.target.value }))} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">endpoint</label>
|
||||
<input className="input" type="text" value={modelModal.endpoint} onChange={(e) => setModelModal((m) => m && ({ ...m, endpoint: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="modal-f">
|
||||
<button className="btn" type="button" onClick={() => setModelModal(null)}>取消</button>
|
||||
|
||||
@@ -14,6 +14,7 @@ const PAGE_SIZE = 10;
|
||||
|
||||
const TABS = [
|
||||
{ key: "", label: "全部" },
|
||||
{ key: "generating", label: "生成中" },
|
||||
{ key: "failed", label: "失败" },
|
||||
{ key: "succeeded", label: "成功" }
|
||||
];
|
||||
@@ -25,11 +26,25 @@ function fmtDate(iso: string) {
|
||||
return `${d.getFullYear()}/${p(d.getMonth() + 1)}/${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
created: "已创建",
|
||||
reserved: "已预留",
|
||||
submitted: "已提交",
|
||||
polling: "生成中",
|
||||
postprocessing: "后处理",
|
||||
succeeded: "成功",
|
||||
failed: "失败",
|
||||
cancelled: "已取消",
|
||||
compensating: "补偿中",
|
||||
};
|
||||
|
||||
function statusPill(status: string) {
|
||||
if (status === "succeeded") return <span className="pill ok"><span className="dot" />成功</span>;
|
||||
if (status === "failed") return <span className="pill err"><span className="dot" />失败</span>;
|
||||
if (["cancelled", "compensating"].includes(status)) return <span className="pill neutral"><span className="dot" />{status}</span>;
|
||||
return <span className="pill info"><span className="dot" />进行中</span>;
|
||||
if (["cancelled", "compensating"].includes(status)) {
|
||||
return <span className="pill neutral"><span className="dot" />{STATUS_LABEL[status] || status}</span>;
|
||||
}
|
||||
return <span className="pill info"><span className="dot" />{STATUS_LABEL[status] || "生成中"}</span>;
|
||||
}
|
||||
|
||||
function attemptKind(attempt: AdminTaskDetail["attempts"][number]) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
X
|
||||
} from "lucide-react";
|
||||
import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types";
|
||||
import { pointsPerImageFromCatalog } from "../components/free-create/constants";
|
||||
import { api } from "../api";
|
||||
import { useFileDrop } from "../components/use-file-drop";
|
||||
import { imageModelPickerOptions } from "../model-display";
|
||||
@@ -727,13 +728,13 @@ export function ImageWorkbenchPage({
|
||||
useEffect(() => {
|
||||
void api.billingConfig().then((cfg) => setPriceMultiplier(Number(cfg.team_price_multiplier) || 1)).catch(() => undefined);
|
||||
}, []);
|
||||
// 每张图实扣单价:取「当前选的生图模型」的 unit_price(火山/gpt-image),没匹配上用第一个图像模型,
|
||||
// 再兜底 20 积分(后端 quote_flat=unit_price 积分/张,默认模型 gpt-image-2=20 积分)。前端预估据此算,和后端实扣一致(PMC#20)。
|
||||
// 每张图实扣单价:优先后台挂牌 points_pricing.points_per_image,否则 unit_price,再兜底 20。
|
||||
// 与后端 quote_flat / image_points_per_unit 同口径(PMC#20)。
|
||||
const perImagePrice = (() => {
|
||||
const want = genModel === "gpt-image" ? "gpt-image" : genModel === "volcano" ? "seedream" : genModel;
|
||||
const m = imageModels.find((x) => x.name.toLowerCase().includes(want)) || imageModels[0];
|
||||
const p = Number(m?.unit_price);
|
||||
return Number.isFinite(p) && p > 0 ? p : 20;
|
||||
const listed = pointsPerImageFromCatalog(m);
|
||||
return listed != null && listed > 0 ? listed : 20;
|
||||
})();
|
||||
// 与后端逐张对齐:每张 = 挂牌单价取整 × 系数 → HALF_UP 最低 1,总价 = 张数 × 单张
|
||||
// (不能先乘张数再取整:0.85 系数 × 3 张会比后端逐张各取整少 1-2 积分,review 确认)
|
||||
|
||||
@@ -567,10 +567,6 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
|
||||
window.setTimeout(() => promptRef.current?.insertMention({ label, thumb: ref.thumb_url || (ref.type === "image" ? ref.url : "") }), 0);
|
||||
}, [mode, refs, libraryTargetRole, notify]);
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
promptRef.current?.clear();
|
||||
setRefs([]);
|
||||
}, []);
|
||||
|
||||
const filterCounts = useMemo(() => ({
|
||||
all: tasks.length,
|
||||
@@ -701,7 +697,6 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
|
||||
onSeedChange={setSeed}
|
||||
onOpenLibrary={openLibrary}
|
||||
onOpenPlatformLibrary={openPlatformLibrary}
|
||||
onClear={clearInput}
|
||||
onSend={() => void handleSend()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -18,9 +18,10 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import { OmniParamBar } from "../components/omni-param-bar";
|
||||
import { findCatalogModel } from "../components/omni-param-bar";
|
||||
import { modelResolutions } from "../components/free-create/constants";
|
||||
import { ConfirmModal } from "../components/overlays";
|
||||
import type { CreationConversation, CreationRef } from "../types";
|
||||
import type { CreationConversation, CreationRef, ModelConfig } from "../types";
|
||||
import type { NavigateFn } from "./route-config";
|
||||
|
||||
type OutputMode = "video" | "image";
|
||||
@@ -102,9 +103,11 @@ function formatRelativeTime(iso: string) {
|
||||
}
|
||||
|
||||
export function OmniCreatePage({
|
||||
modelConfigs,
|
||||
navigate,
|
||||
onNotify,
|
||||
}: {
|
||||
modelConfigs?: ModelConfig[];
|
||||
navigate: NavigateFn;
|
||||
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
||||
}) {
|
||||
@@ -132,19 +135,23 @@ export function OmniCreatePage({
|
||||
|
||||
useEffect(() => {
|
||||
if (outputMode === "image") {
|
||||
setModel("Seedream5.0");
|
||||
const img = findCatalogModel(modelConfigs, "", "image");
|
||||
setModel(img ? (img.display_name || img.name) : "Seedream5.0");
|
||||
setResolution("模型默认");
|
||||
setRatio("1:1");
|
||||
setDuration("1 张");
|
||||
} else {
|
||||
setModel("Seedance 2.5");
|
||||
setResolution("1080p");
|
||||
const vid = findCatalogModel(modelConfigs, "", "video");
|
||||
const label = vid ? (vid.display_name || vid.name) : "Seedance 2.5";
|
||||
const resList = modelResolutions(vid);
|
||||
setModel(label);
|
||||
setResolution(resList.includes("720p") ? "720p" : (resList[0] || "1080p"));
|
||||
setRatio("9:16");
|
||||
setDuration("智能时长");
|
||||
}
|
||||
setActiveCategory("all");
|
||||
setSelectedCase((prev) => (prev && prev.mode !== outputMode ? null : prev));
|
||||
}, [outputMode]);
|
||||
}, [outputMode, modelConfigs]);
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (event: MouseEvent) => {
|
||||
@@ -385,17 +392,6 @@ export function OmniCreatePage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OmniParamBar
|
||||
isVideo={outputMode === "video"}
|
||||
model={model}
|
||||
resolution={resolution}
|
||||
ratio={ratio}
|
||||
duration={duration}
|
||||
onModel={setModel}
|
||||
onResolution={setResolution}
|
||||
onRatio={setRatio}
|
||||
onDuration={setDuration}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -595,6 +591,10 @@ export function OmniHistoryPage({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="omni-history-new" onClick={() => navigate("omniCreate")}>
|
||||
<Plus />
|
||||
新建会话
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="omni-history-list">
|
||||
|
||||
@@ -17,13 +17,15 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import { OmniParamBar } from "../components/omni-param-bar";
|
||||
import { findCatalogModel, OmniParamBar } from "../components/omni-param-bar";
|
||||
import { estimateCost, pointsPerImageFromCatalog } from "../components/free-create/constants";
|
||||
import { MediaLightbox } from "../components/overlays";
|
||||
import type {
|
||||
CreationConversationDetail,
|
||||
CreationField,
|
||||
CreationMessage,
|
||||
CreationRef,
|
||||
ModelConfig,
|
||||
} from "../types";
|
||||
import type { NavigateFn } from "./route-config";
|
||||
|
||||
@@ -421,17 +423,19 @@ function ConfirmCard({
|
||||
message,
|
||||
sessionParams,
|
||||
isVideo,
|
||||
catalogModels,
|
||||
disabled,
|
||||
onConfirm,
|
||||
}: {
|
||||
message: CreationMessage;
|
||||
sessionParams: Record<string, string>;
|
||||
isVideo: boolean;
|
||||
catalogModels?: ModelConfig[];
|
||||
disabled: boolean;
|
||||
onConfirm: (params: Record<string, string>) => void;
|
||||
}) {
|
||||
const submitted = Boolean(message.payload.submitted);
|
||||
const credits = Number(message.payload.estimated_credits || 0);
|
||||
const payloadCredits = Number(message.payload.estimated_credits || 0);
|
||||
const payloadParams = asStringMap(message.payload.params);
|
||||
const snapshot = { ...sessionParams, ...payloadParams };
|
||||
const [draft, setDraft] = useState(snapshot);
|
||||
@@ -440,6 +444,27 @@ function ConfirmCard({
|
||||
cardIsVideo && Boolean(draft.duration) && Boolean(snapshot.duration) && draft.duration !== snapshot.duration;
|
||||
const setField = (key: string, value: string) => setDraft((prev) => ({ ...prev, [key]: value }));
|
||||
const summary = paramLine(draft, cardIsVideo) || "当前参数";
|
||||
// 确认卡积分:改模型/分辨率/时长/张数时按后台挂牌实时重算(与后端 quote_* 同口径;标准团队系数=1)
|
||||
const credits = (() => {
|
||||
if (cardIsVideo) {
|
||||
const model = findCatalogModel(catalogModels, draft.model || "", "video");
|
||||
const resolution = (draft.resolution || "720p").toLowerCase();
|
||||
const raw = String(draft.duration || "");
|
||||
const digits = raw.replace(/\D/g, "");
|
||||
// 与后端 video_duration 一致:「智能时长」/解析不出 → 15 秒
|
||||
const duration = digits ? Math.max(4, Math.min(Number(digits), 30)) : 15;
|
||||
const est = estimateCost(model, { ratio: draft.ratio || "9:16", resolution, duration, refs: [] });
|
||||
if (est.listed && est.points > 0) return est.points;
|
||||
// 挂牌缺失时仍展示后端下发的预估(若有)
|
||||
return payloadCredits;
|
||||
}
|
||||
const model = findCatalogModel(catalogModels, draft.model || "", "image");
|
||||
const unit = pointsPerImageFromCatalog(model);
|
||||
if (unit == null || unit <= 0) return payloadCredits;
|
||||
const countLabel = String(draft.count || draft.duration || "1");
|
||||
const count = Math.max(1, Math.min(8, parseInt(countLabel, 10) || 1));
|
||||
return unit * count;
|
||||
})();
|
||||
|
||||
return (
|
||||
<section className="omni-confirm-card">
|
||||
@@ -452,6 +477,7 @@ function ConfirmCard({
|
||||
<OmniParamBar
|
||||
isVideo={cardIsVideo}
|
||||
disabled={disabled}
|
||||
catalogModels={catalogModels}
|
||||
model={draft.model || ""}
|
||||
resolution={draft.resolution || ""}
|
||||
ratio={draft.ratio || ""}
|
||||
@@ -636,6 +662,7 @@ export function OmniSessionPage({
|
||||
firstMessage,
|
||||
firstRefs,
|
||||
firstUploads,
|
||||
modelConfigs,
|
||||
navigate,
|
||||
onNotify,
|
||||
}: {
|
||||
@@ -644,6 +671,7 @@ export function OmniSessionPage({
|
||||
firstMessage?: string;
|
||||
firstRefs?: CreationRef[];
|
||||
firstUploads?: CreationRef[];
|
||||
modelConfigs?: ModelConfig[];
|
||||
navigate: NavigateFn;
|
||||
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
||||
}) {
|
||||
@@ -1041,6 +1069,7 @@ export function OmniSessionPage({
|
||||
message={message}
|
||||
sessionParams={params}
|
||||
isVideo={isVideo}
|
||||
catalogModels={modelConfigs}
|
||||
disabled={streaming || confirming}
|
||||
onConfirm={(nextParams) => void handleConfirm(message, nextParams)}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Fragment, memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ChangeEvent, CSSProperties, PointerEvent as ReactPointerEvent } from "react";
|
||||
import { ArrowLeft, ArrowRight, Check, ChevronRight, Image, Info, LayoutList, Play, RefreshCw, Route, Sparkles, Upload, UsersRound, X } from "lucide-react";
|
||||
import { ArrowLeft, ArrowRight, Check, ChevronRight, Image, LayoutList, Play, RefreshCw, Route, Sparkles, Upload, UsersRound, X } from "lucide-react";
|
||||
import { api, ApiError } from "../api";
|
||||
import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, Team, TimelineSavePayload, User } from "../types";
|
||||
import { isHiddenFromScriptPicker, publicModelDisplayName } from "../model-display";
|
||||
@@ -10,7 +10,7 @@ import type { Notice, Page } from "./route-config";
|
||||
import { stageOrder, statusPill } from "./stage-config";
|
||||
import { ConfirmModal, MediaLightbox, TeamModal, useBodyScrollLock } from "../components/overlays";
|
||||
import { CustomSelect } from "../components/custom-select";
|
||||
import { DEFAULT_BILLING_RATES, estimateCost, FC_MODELS, modelLabel } from "../components/free-create/constants";
|
||||
import { DEFAULT_BILLING_RATES, estimateCost, FC_MODELS, modelLabel, pointsPerImageFromCatalog } from "../components/free-create/constants";
|
||||
import { ModelLibrary } from "../components/model-library";
|
||||
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
|
||||
import {
|
||||
@@ -537,6 +537,7 @@ export function PipelinePage(props: {
|
||||
scriptModelName: string;
|
||||
textModels?: ModelConfig[];
|
||||
videoModels?: ModelConfig[];
|
||||
imageModels?: ModelConfig[];
|
||||
onAdoptScript: (scriptId: string) => void | Promise<unknown>;
|
||||
onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise<unknown>;
|
||||
onAddShot: (afterSegmentId: string, content?: { narration?: string; visual_prompt?: string }) => Promise<unknown>;
|
||||
@@ -575,7 +576,7 @@ export function PipelinePage(props: {
|
||||
}) {
|
||||
const {
|
||||
project, loading, navigate, products, assets, onNotify,
|
||||
textModels, videoModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
|
||||
textModels, videoModels, imageModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
|
||||
onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateModel, onUploadModel, onGenerateTriview, onRenameModel,
|
||||
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject, onRefreshBilling,
|
||||
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
|
||||
@@ -971,14 +972,24 @@ export function PipelinePage(props: {
|
||||
if (extractState === "running") return;
|
||||
setExtractErr("");
|
||||
setExtractState("running");
|
||||
setExtractMsg("正在从剧本认出角色 / 场景…");
|
||||
setExtractMsg("正在整理角色 / 场景…");
|
||||
try {
|
||||
await api.extractEntities(project.id); // 异步提交(已有在途则后端复用,不重复扣费),慢活在 worker 跑
|
||||
pollExtractUntilDone(mode); // 轮询直到 worker 跑完
|
||||
// 后端本地拆实体:秒回 succeeded(不调模型、不扣费);兼容旧异步任务仍可轮询
|
||||
const res = await api.extractEntities(project.id) as { status?: string; entities?: ExtractEntity[] };
|
||||
if (res?.status === "succeeded") {
|
||||
await onRefreshProject();
|
||||
if ((mode === "gen" || mode === "full") && (res.entities?.length ?? 0) > 0) {
|
||||
await runGenForEntities(res.entities as ExtractEntity[], mode);
|
||||
}
|
||||
setExtractState("idle");
|
||||
setExtractMsg("");
|
||||
return;
|
||||
}
|
||||
pollExtractUntilDone(mode);
|
||||
} catch (err) {
|
||||
setExtractState("idle");
|
||||
setExtractMsg("");
|
||||
setExtractErr(err instanceof Error ? err.message : "提取失败,请重试");
|
||||
setExtractErr(err instanceof Error ? err.message : "整理失败,请重试");
|
||||
}
|
||||
}
|
||||
// ── 流程步骤3 · 兜底弹窗拦截:生成视频前,查被引用的角色/场景是否都有「按名字采用」的参考图 ──
|
||||
@@ -1126,6 +1137,13 @@ export function PipelinePage(props: {
|
||||
const videoAnyStarted = segments.some((s) => ["running", "succeeded", "queued", "completed", "done"].includes(s.status));
|
||||
const [chargeConfirm, setChargeConfirm] = useState<"video" | null>(null);
|
||||
const videoConfigs = videoModels ?? [];
|
||||
// 基础资产生成用默认图像模型挂牌(与后端 get_default_model(IMAGE)+quote_flat 同口径)
|
||||
const imageUnitPoints = (() => {
|
||||
const configs = imageModels ?? [];
|
||||
const preferred = configs.find((m) => m.status === "active") || configs[0];
|
||||
const listed = pointsPerImageFromCatalog(preferred);
|
||||
return listed != null && listed > 0 ? listed : 20;
|
||||
})();
|
||||
const defaultVideoModel = videoConfigs.find((m) => m.status === "active" && m.name === DEFAULT_VIDEO_MODEL_NAME)
|
||||
|| videoConfigs.find((m) => m.status === "active")
|
||||
|| videoConfigs[0];
|
||||
@@ -1209,10 +1227,10 @@ export function PipelinePage(props: {
|
||||
size="sm"
|
||||
value={outputResolution}
|
||||
onChange={(next) => changeOutputSpec({ resolution: next })}
|
||||
options={OUTPUT_RESOLUTIONS.map((option) => ({
|
||||
...option,
|
||||
disabled: Boolean(supportedResolutions.length && !supportedResolutions.includes(option.value)),
|
||||
}))}
|
||||
options={(supportedResolutions.length
|
||||
? OUTPUT_RESOLUTIONS.filter((option) => supportedResolutions.includes(option.value))
|
||||
: OUTPUT_RESOLUTIONS
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<label className="as-spec-field">
|
||||
@@ -3289,6 +3307,7 @@ export function PipelinePage(props: {
|
||||
setSetupPersona(recommended.persona);
|
||||
setSetupDuration(recommended.duration);
|
||||
}}>重新推荐</button>
|
||||
<span className="mono setup-cost-hint" title="按文本模型挂牌计价,失败不扣">预计 {pts(100)} 积分/次 · 失败不扣</span>
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={loading || scriptFileBusy || videoDigestBusy || ((setupSource === "manual" || setupSource === "video") && !chatText.trim())} onClick={() => void runScriptWithSetup()}>确定</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3407,17 +3426,20 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 存模板 / 重生成 / 确认下一步:有镜头脚本后才显示;积分提示已挪到右侧「确定」旁 */}
|
||||
{currentScript ? (
|
||||
<div className="stage-foot">
|
||||
<div className="info pl-tip">脚本生成预计消耗 {pts(10)} 积分 / 次,失败不扣除</div>
|
||||
<div className="info pl-tip">重新生成预计消耗 {pts(100)} 积分 / 次,失败不扣除</div>
|
||||
<div className="hstack">
|
||||
<button className="pl-ghost" type="button" disabled={loading || !currentScript} onClick={openSaveTemplate}>存为模板</button>
|
||||
<button className="pl-ghost" type="button" disabled={loading} onClick={openSaveTemplate}>存为模板</button>
|
||||
<button className="pl-ghost" type="button" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑", "重新生成全部", undefined, "auto")}>重新生成脚本</button>
|
||||
<button className="pl-next" type="button" disabled={loading || !currentScript} onClick={confirmScript}>
|
||||
<button className="pl-next" type="button" disabled={loading} onClick={confirmScript}>
|
||||
<span>{scriptAdopted ? "进入下一步" : "确认脚本,进入下一步"}</span>
|
||||
<ArrowRight />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* ============= STAGE 2 · 基础资产(真实 base_asset_groups,按 kind 分组)============= */}
|
||||
@@ -3439,8 +3461,7 @@ export function PipelinePage(props: {
|
||||
|| assetUrl(productCover); // 商品卡显示商品主图,三视图在右侧面板
|
||||
const triViewGen = `${productName} 商品三视图,从左到右:正面 / 侧面 / 背面,统一光照,白色背景,16:9`;
|
||||
const runProductTri = () => { setTriPanelOpen(true); setTriPreviewId(null); void genBaseAsset("product", triViewGen, undefined, "tri:product"); };
|
||||
// 实体提取闸门:没走过正式提取步(entities_extracted)且没生成任何基础资产 → 盖蒙版 + 三按钮,不自动花钱。
|
||||
// 用 entities_extracted 标记而非 script_entities 存在性:脚本生成期也可能吐过不稳的 entities,那不算正式提取。
|
||||
// 实体整理闸门:定稿时一般已本地落 entities_extracted;未落且无资产时盖蒙版。整理免费、不调模型。
|
||||
const entitiesExtracted = project.metadata?.entities_extracted === true;
|
||||
const hasAnyAsset = KIND_ORDER.some((k) => groupsByKind(k).length > 0);
|
||||
const gateVisible = extractState === "running" || (!entitiesExtracted && !hasAnyAsset);
|
||||
@@ -3476,16 +3497,16 @@ export function PipelinePage(props: {
|
||||
<div className="sgv-card">
|
||||
<span className="sgv-ico"><span className="spinner"></span></span>
|
||||
<div className="sgv-body">
|
||||
<div className="sgv-title">{extractMsg || "正在提取角色 / 场景"}<span className="sgv-dots"><i></i><i></i><i></i></span></div>
|
||||
<div className="sgv-title">{extractMsg || "正在整理角色 / 场景"}<span className="sgv-dots"><i></i><i></i><i></i></span></div>
|
||||
<div className="sgv-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="eg-title">先从剧本认出角色 / 场景</div>
|
||||
<div className="mono eg-sub">AI 认出角色 / 场景并出提示词,稍后你再逐个生成</div>
|
||||
<button type="button" className="as-ai-btn as-ai-btn-lg eg-extract-btn" onClick={() => void runExtract("only")}>提取角色 / 场景</button>
|
||||
<div className="mono eg-cost-hint">{pts(10)} 积分/次 · 失败不扣</div>
|
||||
<div className="eg-title">从剧本整理角色 / 场景</div>
|
||||
<div className="mono eg-sub">直接读取脚本里的角色与场景,不调用模型、不扣积分</div>
|
||||
<button type="button" className="as-ai-btn as-ai-btn-lg eg-extract-btn" onClick={() => void runExtract("only")}>整理角色 / 场景</button>
|
||||
<div className="mono eg-cost-hint">免费 · 本地整理</div>
|
||||
{extractErr && <div className="eg-err">{extractErr}</div>}
|
||||
</>
|
||||
)}
|
||||
@@ -3501,7 +3522,7 @@ export function PipelinePage(props: {
|
||||
<div className="as-head-side">
|
||||
{(entitiesExtracted || hasAnyAsset) ? (
|
||||
<button type="button" className="as-ghost-btn" disabled={extractState === "running"} data-stop onClick={() => void runExtract("only")}>
|
||||
{extractState === "running" ? "提取中…" : "重新提取角色 / 场景"}
|
||||
{extractState === "running" ? "提取中…" : "重新整理角色 / 场景"}
|
||||
</button>
|
||||
) : null}
|
||||
<div className="as-completion"><span>资产完成度</span><strong>{assetDone} / {assetTotal}</strong></div>
|
||||
@@ -3578,7 +3599,7 @@ export function PipelinePage(props: {
|
||||
<button className="as-ghost-btn as-ghost-btn-sm prod-preview-adopt" type="button" disabled={previewTriAsset === adoptedTriAsset || loading} title={previewTriAsset === adoptedTriAsset ? "此版本已采用" : "将此版本设为商品采用版本"} onClick={() => { if (productGroup && previewTriAsset !== adoptedTriAsset) void onAdoptBaseAsset(productGroup.id, previewTriAsset); }}>
|
||||
{previewTriAsset === adoptedTriAsset ? "已采用" : "采用此版本"}
|
||||
</button>
|
||||
<span className="as-cost">{pts(20)} 积分 / 次</span>
|
||||
<span className="as-cost">{pts(imageUnitPoints)} 积分 / 次</span>
|
||||
</div>
|
||||
) : null}
|
||||
{!isLocalProduct ? (
|
||||
@@ -3780,7 +3801,6 @@ export function PipelinePage(props: {
|
||||
})}
|
||||
</div>
|
||||
<footer className="as-action-bar">
|
||||
<span><Info />确认后将用以上资产直接生成视频,后续仍可替换。提取 {pts(10)} / 人物 {pts(20)} / 场景 {pts(20)} · 失败不扣</span>
|
||||
<div>
|
||||
{videoAnyStarted ? (
|
||||
<span className="pill pill-l2 neutral" title="后续生成视频将使用此设置">
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
estimateCost,
|
||||
FC_MODELS,
|
||||
modelLabel,
|
||||
pointsPerImageFromCatalog,
|
||||
type BillingRates,
|
||||
} from "../components/free-create/constants";
|
||||
import type { NavigateFn } from "./route-config";
|
||||
@@ -571,9 +572,14 @@ export function QuickCreatePage({
|
||||
{ ratio: aspectRatio, resolution, duration: totalDuration, refs: [] },
|
||||
billingRates,
|
||||
);
|
||||
// 商品理解和基础资产在脚本生成前无法精确报价;按每场约40积分给出透明预估,最终按成功任务结算。
|
||||
// 故事板下线后每场少一次 image-2 出图,预估相应下调。
|
||||
const estimatedPoints = videoEstimate.points > 0 ? videoEstimate.points + sceneCount * 40 : sceneCount * 200;
|
||||
// 基础资产按图像挂牌预估;视频按挂牌秒价。最终按成功任务实扣。
|
||||
const imageUnit = (() => {
|
||||
const img = modelConfigs.find((m) => m.capability === "image" && m.status === "active")
|
||||
|| modelConfigs.find((m) => m.capability === "image");
|
||||
const listed = pointsPerImageFromCatalog(img);
|
||||
return listed != null && listed > 0 ? listed : 20;
|
||||
})();
|
||||
const estimatedPoints = videoEstimate.listed ? videoEstimate.points + sceneCount * imageUnit : 0;
|
||||
const shellClass = [
|
||||
"quick-create-shell",
|
||||
restoring ? "is-restoring" : "",
|
||||
@@ -688,10 +694,10 @@ export function QuickCreatePage({
|
||||
value={resolution}
|
||||
onChange={setResolution}
|
||||
disabled={isGenerating}
|
||||
options={QUICK_RESOLUTIONS.map((option) => ({
|
||||
...option,
|
||||
disabled: Boolean(supportedResolutions.length && !supportedResolutions.includes(option.value)),
|
||||
}))}
|
||||
options={(supportedResolutions.length
|
||||
? QUICK_RESOLUTIONS.filter((option) => supportedResolutions.includes(option.value))
|
||||
: QUICK_RESOLUTIONS
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<label className="quick-parameter-field">
|
||||
@@ -727,7 +733,7 @@ export function QuickCreatePage({
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="quick-generate-button" onClick={() => void startGeneration()} disabled={!canStart}>
|
||||
<WandSparkles /><span>{`立即生成视频 · 消耗 ${estimatedPoints} 积分`}</span>
|
||||
<WandSparkles /><span>{estimatedPoints > 0 ? `立即生成视频 · 消耗 ${estimatedPoints} 积分` : "立即生成视频"}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Film,
|
||||
FolderKanban,
|
||||
Home,
|
||||
ImageIcon,
|
||||
Library,
|
||||
MessageSquare,
|
||||
@@ -90,7 +89,6 @@ export function isOwnerOnlyPage(page: Page) {
|
||||
}
|
||||
|
||||
export const mainNav: NavItem[] = [
|
||||
{ page: "dashboard", label: "工作台", icon: Home },
|
||||
{ page: "products", label: "商品库", icon: Package },
|
||||
{ page: "models", label: "模特库", icon: UserRound },
|
||||
{ page: "projects", label: "视频创作", icon: FolderKanban },
|
||||
@@ -169,7 +167,8 @@ export function resolveRoute(): ResolvedRoute {
|
||||
return { page: "dashboard", authMode: "login", admin: path.slice("/admin/".length), hash };
|
||||
}
|
||||
if (path === "/" && isPage(hash)) return { page: hash, authMode: "login", hash };
|
||||
if (path === "/" || path === "/dashboard") return { page: "dashboard", authMode: "login", hash };
|
||||
// 工作台暂时隐藏:根路径与 /dashboard 都进全能创作
|
||||
if (path === "/" || path === "/dashboard") return { page: "omniCreate", authMode: "login", hash };
|
||||
if (path === "/products") return { page: "products", authMode: "login", hash };
|
||||
if (path === "/products/new") return { page: "productCreateUpload", authMode: "login", hash };
|
||||
if (path.startsWith("/products/")) {
|
||||
@@ -210,13 +209,14 @@ export function resolveRoute(): ResolvedRoute {
|
||||
if (path === "/settings/notify") return { page: "settingsNotify", authMode: "login", hash };
|
||||
if (path === "/settings") return { page: "settings", authMode: "login", hash };
|
||||
if (path === "/trash") return { page: "trash", authMode: "login", hash };
|
||||
return { page: "dashboard", authMode: "login", hash };
|
||||
return { page: "omniCreate", authMode: "login", hash };
|
||||
}
|
||||
|
||||
export function pathForPage(page: Page, options: NavigateOptions = {}) {
|
||||
switch (page) {
|
||||
case "dashboard":
|
||||
return "/dashboard";
|
||||
// 工作台隐藏期间,旧入口也落到全能创作
|
||||
return "/omni-create";
|
||||
case "products":
|
||||
return "/products";
|
||||
case "productCreateUpload":
|
||||
@@ -272,6 +272,6 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) {
|
||||
case "trash":
|
||||
return "/trash";
|
||||
default:
|
||||
return "/dashboard";
|
||||
return "/omni-create";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,7 +441,7 @@ export function VideoReplacePage({
|
||||
).map(() => ({ type: "image" }))),
|
||||
],
|
||||
}, billingRates);
|
||||
const points = estimated.points || 220;
|
||||
const points = estimated.points;
|
||||
// 上传参考视频不算「正在复刻」:右侧面板要保持待命,只在上传区自己显示进度。
|
||||
// 生成按钮不会因此被误点 —— videoReady 要等 asset_id 回来才为真。
|
||||
const generating = Boolean(job && isInFlight(job.status)) || submitting;
|
||||
@@ -464,8 +464,8 @@ export function VideoReplacePage({
|
||||
const generateLabel = generating
|
||||
? (digesting ? "正在提炼参考视频…" : reviewing ? "正在审核素材…" : (shotTotal > 1 && shotIndex > 0 ? `正在复刻 ${shotIndex}/${shotTotal} 镜…` : "正在复刻…"))
|
||||
: hasResult
|
||||
? `再次${copy.modeLabel} · 消耗 ${points} 积分`
|
||||
: `开始${copy.modeLabel} · 消耗 ${points} 积分`;
|
||||
? (points > 0 ? `再次${copy.modeLabel} · 消耗 ${points} 积分` : `再次${copy.modeLabel}`)
|
||||
: (points > 0 ? `开始${copy.modeLabel} · 消耗 ${points} 积分` : `开始${copy.modeLabel}`);
|
||||
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user