大量优化修改扣积分规则

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
+4 -8
View File
@@ -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}