大量优化修改扣积分规则

This commit is contained in:
Azmat@qq.com
2026-09-04 17:20:22 +08:00
parent 3e904479d9
commit 15718a60bf
41 changed files with 1590 additions and 429 deletions
+212 -14
View File
@@ -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>
+17 -2
View File
@@ -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]) {
+5 -4
View File
@@ -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 确认)
-5
View File
@@ -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>
+17 -17
View File
@@ -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">
+31 -2
View File
@@ -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)}
/>
+44 -24
View File
@@ -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="后续生成视频将使用此设置">
+14 -8
View File
@@ -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>
+6 -6
View File
@@ -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";
}
}
+3 -3
View File
@@ -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 {