大量优化修改扣积分规则

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
+27 -3
View File
@@ -4,6 +4,7 @@ from rest_framework import serializers
from apps.accounts.models import Team, TeamMember, User from apps.accounts.models import Team, TeamMember, User
from apps.ai.model_routing import model_metadata_errors, provider_metadata_errors from apps.ai.model_routing import model_metadata_errors, provider_metadata_errors
from apps.billing.points_rules import normalize_points_pricing_for_save, points_pricing_errors
from apps.ai.models import AIModelAttempt, AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord from apps.ai.models import AIModelAttempt, AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord
from apps.assets.models import Asset from apps.assets.models import Asset
from apps.billing.models import CreditLedger, QuotaPolicy from apps.billing.models import CreditLedger, QuotaPolicy
@@ -247,12 +248,35 @@ class AdminModelConfigSerializer(serializers.ModelSerializer):
def validate(self, attrs): def validate(self, attrs):
attrs = super().validate(attrs) attrs = super().validate(attrs)
if self.instance is None or "metadata" in attrs or "capability" in attrs: if self.instance is None or "metadata" in attrs or "capability" in attrs or "unit_price" in attrs:
capability = attrs.get("capability", getattr(self.instance, "capability", "")) capability = attrs.get("capability", getattr(self.instance, "capability", ""))
metadata = attrs.get("metadata", getattr(self.instance, "metadata", {})) metadata = attrs.get("metadata", getattr(self.instance, "metadata", {}))
errors = model_metadata_errors(capability, metadata) if not isinstance(metadata, dict):
metadata = {}
metadata = normalize_points_pricing_for_save(capability, metadata)
# 图片/文本:表单 unit_price 与挂牌积分同步
if "unit_price" in attrs and capability in {"image", "text", "vision"}:
from decimal import Decimal
try:
pts = int(Decimal(str(attrs.get("unit_price") or 0)))
except Exception:
pts = 0
pricing = dict(metadata.get("points_pricing") or {})
if capability == "image":
pricing.update({"mode": "per_image", "points_per_image": max(pts, 0)})
else:
pricing.update({"mode": "per_call", "points_per_call": max(pts, 0)})
metadata["points_pricing"] = pricing
attrs["metadata"] = metadata
# 视频有挂牌秒价时,把展示用 unit_price 写成最低档秒积分,列表更好读
if capability == "video":
tiers = (metadata.get("points_pricing") or {}).get("tiers") or []
secs = [t.get("points_per_second") for t in tiers if isinstance(t, dict) and t.get("points_per_second") is not None]
if secs:
attrs["unit_price"] = min(int(x) for x in secs)
errors = list(model_metadata_errors(capability, metadata)) + list(points_pricing_errors(capability, metadata))
if errors: if errors:
raise serializers.ValidationError({"metadata": list(errors)}) raise serializers.ValidationError({"metadata": errors})
return attrs return attrs
+18 -2
View File
@@ -13,6 +13,7 @@ from apps.accounts.audit import log_admin_action
from apps.accounts.models import Invitation, Team, User from apps.accounts.models import Invitation, Team, User
from apps.accounts.permissions import IsPlatformAdmin from apps.accounts.permissions import IsPlatformAdmin
from apps.accounts.serializers import InvitationSerializer from apps.accounts.serializers import InvitationSerializer
from apps.ai.model_catalog import invalidate_model_catalog_cache
from apps.ai.models import AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord from apps.ai.models import AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord
from apps.assets.models import Asset from apps.assets.models import Asset
from apps.assets.review import poll_asset_review, submit_asset_for_review from apps.assets.review import poll_asset_review, submit_asset_for_review
@@ -45,6 +46,14 @@ logger = logging.getLogger(__name__)
# 后台「刷新状态」只向供应商拉取已提交的异步视频任务;单次上限避免请求拖死。 # 后台「刷新状态」只向供应商拉取已提交的异步视频任务;单次上限避免请求拖死。
_ADMIN_TASK_POLL_LIMIT = 20 _ADMIN_TASK_POLL_LIMIT = 20
_ADMIN_TASK_POLL_STATUSES = (AITask.Status.SUBMITTED, AITask.Status.POLLING) _ADMIN_TASK_POLL_STATUSES = (AITask.Status.SUBMITTED, AITask.Status.POLLING)
# 任务监控「生成中」Tab:含已创建/已预留/已提交/轮询/后处理(与自由创作 IN_FLIGHT 对齐)
_ADMIN_TASK_INFLIGHT_STATUSES = (
AITask.Status.CREATED,
AITask.Status.RESERVED,
AITask.Status.SUBMITTED,
AITask.Status.POLLING,
AITask.Status.POSTPROCESSING,
)
def _team_qs(): def _team_qs():
@@ -463,7 +472,9 @@ def admin_tasks(request):
.order_by("-created_at") .order_by("-created_at")
) )
st = request.query_params.get("status") st = request.query_params.get("status")
if st in dict(AITask.Status.choices): if st in {"generating", "running", "in_flight"}:
qs = qs.filter(status__in=_ADMIN_TASK_INFLIGHT_STATUSES)
elif st in dict(AITask.Status.choices):
qs = qs.filter(status=st) qs = qs.filter(status=st)
tt = request.query_params.get("task_type") tt = request.query_params.get("task_type")
if tt in dict(AITask.Type.choices): if tt in dict(AITask.Type.choices):
@@ -772,11 +783,12 @@ def admin_provider_detail(request, provider_id):
return Response(AdminModelProviderSerializer(ModelProvider.objects.annotate(model_count_anno=Count("models", distinct=True)).get(id=obj.id)).data) return Response(AdminModelProviderSerializer(ModelProvider.objects.annotate(model_count_anno=Count("models", distinct=True)).get(id=obj.id)).data)
@api_view(["GET", "POST"]) @api_view(["GET", "POST"])
@permission_classes([IsPlatformAdmin]) @permission_classes([IsPlatformAdmin])
def admin_models(request): def admin_models(request):
if request.method == "GET": if request.method == "GET":
qs = ModelConfig.objects.select_related("provider").order_by("provider__name", "capability", "created_at") qs = ModelConfig.objects.select_related("provider").order_by("-is_default", "status", "capability", "provider__name", "created_at")
prov = request.query_params.get("provider") prov = request.query_params.get("provider")
if prov: if prov:
qs = qs.filter(provider_id=prov) qs = qs.filter(provider_id=prov)
@@ -787,6 +799,7 @@ def admin_models(request):
serializer = AdminModelConfigSerializer(data=request.data) serializer = AdminModelConfigSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
obj = serializer.save() obj = serializer.save()
invalidate_model_catalog_cache()
log_admin_action(request, "model.create", target_type="model_config", target_id=obj.id, target_name=f"{obj.provider_id}:{obj.name}") log_admin_action(request, "model.create", target_type="model_config", target_id=obj.id, target_name=f"{obj.provider_id}:{obj.name}")
return Response(AdminModelConfigSerializer(obj).data, status=status.HTTP_201_CREATED) return Response(AdminModelConfigSerializer(obj).data, status=status.HTTP_201_CREATED)
@@ -800,10 +813,12 @@ def admin_model_detail(request, model_id):
if request.method == "DELETE": if request.method == "DELETE":
log_admin_action(request, "model.delete", target_type="model_config", target_id=obj.id, target_name=obj.name) log_admin_action(request, "model.delete", target_type="model_config", target_id=obj.id, target_name=obj.name)
obj.delete() obj.delete()
invalidate_model_catalog_cache()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
serializer = AdminModelConfigSerializer(obj, data=request.data, partial=True) serializer = AdminModelConfigSerializer(obj, data=request.data, partial=True)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
serializer.save() serializer.save()
invalidate_model_catalog_cache()
log_admin_action(request, "model.update", target_type="model_config", target_id=obj.id, target_name=obj.name) log_admin_action(request, "model.update", target_type="model_config", target_id=obj.id, target_name=obj.name)
return Response(AdminModelConfigSerializer(ModelConfig.objects.select_related("provider").get(id=obj.id)).data) return Response(AdminModelConfigSerializer(ModelConfig.objects.select_related("provider").get(id=obj.id)).data)
@@ -821,6 +836,7 @@ def admin_model_set_default(request, model_id):
ModelConfig.objects.filter(capability=obj.capability).exclude(id=obj.id).update(is_default=False) ModelConfig.objects.filter(capability=obj.capability).exclude(id=obj.id).update(is_default=False)
obj.is_default = True obj.is_default = True
obj.save(update_fields=["is_default", "updated_at"]) obj.save(update_fields=["is_default", "updated_at"])
invalidate_model_catalog_cache()
log_admin_action(request, "model.set_default", target_type="model_config", target_id=obj.id, target_name=f"{obj.capability}:{obj.name}") log_admin_action(request, "model.set_default", target_type="model_config", target_id=obj.id, target_name=f"{obj.capability}:{obj.name}")
return Response(AdminModelConfigSerializer(ModelConfig.objects.select_related("provider").get(id=obj.id)).data) return Response(AdminModelConfigSerializer(ModelConfig.objects.select_related("provider").get(id=obj.id)).data)
+136 -11
View File
@@ -66,6 +66,32 @@ IMAGE_MODEL_BY_LABEL = {
# 「智能时长」= 交给我们定,取一个口播讲得完又不烧钱的中间值 # 「智能时长」= 交给我们定,取一个口播讲得完又不烧钱的中间值
SMART_DURATION = 15 SMART_DURATION = 15
# 全能创作 video_prompt 写作规范:从专业创作 / 一键成片(口播脚本)抽硬规则,
# 适配「整段出片指令」而不是 ScriptDraft JSON。只要求口径接近,不改工具形态。
_OMNI_VIDEO_PROMPT_RULES = """
【出片脚本写法 · 对齐专业口播】
表现形式默认**口播带货**,像真人在讲刚遇到的一件事,不要详情页朗读或主播念稿。
video_prompt 必须能被出片模型直接执行,按时间轴写秒级分镜,建议结构:
- 片头写清:总时长、画幅、整体光线/色调、口播语气(口语、有停顿与立场)
- 然后按「0-3s / 3-8s / …」逐段写,每段同时写清这五项(不能省):
1. 景别(大特写/特写/近景/中景/全景;一条片里至少切两次景别)
2. 机位(平视/俯拍/仰拍/过肩/桌面视角)
3. 运镜(手持跟拍/推近/拉远/横摇/环绕/固定 —— 每段至少一个运镜词)
4. 动作(谁、哪只手、对什么、做什么;要连贯可拍,禁止「展示质感」等抽象词)
5. 信息变化(这几秒画面上多了/变了什么)
- 口播原文单独写清(可用「口播:…」),字数贴近会话时长:大约 5.0–5.7 字/秒,
15 秒约 75–85 字;太短撑不满,太长会赶。
- 钩子段画面不要「对着镜头说话」静态开场;前 15 个口播字禁止「大家好/今天分享/给你们推荐」。
- 全片只围绕一个具体情境推进一个主卖点;卖点要有看得见的证据(质地/前后变化/用法结果)。
- CTA 像跟朋友说话;禁止小黄车/立即购买/闭眼入等平台指令腔。
- **不要写字幕/花字/标题贴片/弹幕/角标/水印/购物浮层**,也不要写「无字幕」
(否定说法也容易把字画上屏)。口播只存在于声音;包装上原有印刷字除外。
- 已 @ 的角色/商品/场景参考图会自动附上,不要在 prompt 里重描长相;以图锁定性别年龄服装外形。
- 同一场戏保持地点、光线、服装连续;要换环境就明确写下一时间段切换。
"""
class AgentError(Exception): class AgentError(Exception):
"""Agent 循环里的业务错误,已经是可以直接给用户看的中文。""" """Agent 循环里的业务错误,已经是可以直接给用户看的中文。"""
@@ -353,8 +379,9 @@ def tool_schemas(context: AgentContext) -> list[dict]:
"name": "write_plan", "name": "write_plan",
"description": ( "description": (
"写「视频最终方案」卡并请用户确认。**这是出片前的最后一步**,调完会等用户点确认," "写「视频最终方案」卡并请用户确认。**这是出片前的最后一步**,调完会等用户点确认,"
"确认后平台直接按 video_prompt 出片,你不会再有插话机会 —— 所以 video_prompt " "确认后平台直接按 video_prompt 出片,你不会再有插话机会"
"必须是完整、可独立执行的成片指令。先调 write_strategy 再调它" "video_prompt 按系统里的「出片脚本写法」写成口播秒级分镜(专业创作同口径),不要只写大纲"
"先调 write_strategy 再调它。"
), ),
"parameters": { "parameters": {
"type": "object", "type": "object",
@@ -383,9 +410,11 @@ def tool_schemas(context: AgentContext) -> list[dict]:
"video_prompt": { "video_prompt": {
"type": "string", "type": "string",
"description": ( "description": (
"交给出片模型的完整指令:秒级分镜(每镜画面/动作/机位/光线)、口播原文、" "交给出片模型的完整口播带货指令(对齐专业创作口径)。"
"风格锚点、一致性要求。已 @ 的素材会自动作为参考图附上," "必须含:总时长与画幅、整体光线色调、按 0-Ns 分段的秒级分镜"
"不要在这里重复描述它们的长相。**不要写字幕相关要求。**" "(每段写清景别/机位/运镜/具体动作/信息变化)、口播原文、一个主卖点与可见证据、口语 CTA。"
"禁止字幕/花字/贴片及「无字幕」字样;禁止详情页腔与「大家好」开场。"
"已 @ 素材会自动作参考图,勿重描长相。"
), ),
}, },
}, },
@@ -464,9 +493,68 @@ def _coerce_fields(raw) -> list[dict]:
return fields return fields
def _normalize_model_label(value: str) -> str:
return "".join(ch for ch in str(value or "").lower() if ch.isalnum())
def image_model_name(params: dict) -> str | None:
"""出图模型 label → 供应商模型名;目录优先,认不出返回 None 让下游用默认。"""
from django.db.models import Q
from .models import ModelConfig
label = str(params.get("model") or "").strip()
if not label:
return None
mapped = IMAGE_MODEL_BY_LABEL.get(label)
if mapped:
return mapped
hit = (
ModelConfig.objects.filter(capability=ModelConfig.Capability.IMAGE, status=ModelConfig.Status.ACTIVE)
.filter(Q(display_name=label) | Q(name=label))
.order_by("created_at")
.first()
)
return hit.name if hit else None
def video_model_name(params: dict) -> str: def video_model_name(params: dict) -> str:
"""会话参数里的模型 label → 火山模型名。认不出就回落 2.5(最长 30 秒那档)。""" """会话参数里的模型 label → 供应商模型名。
return VIDEO_MODEL_BY_LABEL.get(str(params.get("model") or ""), DEFAULT_VIDEO_MODEL)
先认历史写死映射,再按 ModelConfig.display_name / name 查目录 —— 后台新加模型不用改代码。
"""
from django.db.models import Q
from .models import ModelConfig
label = str(params.get("model") or "").strip()
if not label:
return DEFAULT_VIDEO_MODEL
mapped = VIDEO_MODEL_BY_LABEL.get(label)
if not mapped:
norm = _normalize_model_label(label)
for k, v in VIDEO_MODEL_BY_LABEL.items():
if _normalize_model_label(k) == norm:
mapped = v
break
if mapped:
return mapped
hit = (
ModelConfig.objects.filter(capability=ModelConfig.Capability.VIDEO, status=ModelConfig.Status.ACTIVE)
.filter(Q(display_name=label) | Q(name=label))
.order_by("created_at")
.first()
)
if hit is None:
hit = (
ModelConfig.objects.filter(capability=ModelConfig.Capability.VIDEO, status=ModelConfig.Status.ACTIVE)
.filter(Q(display_name__icontains=label) | Q(name__icontains=label))
.order_by("created_at")
.first()
)
return hit.name if hit else DEFAULT_VIDEO_MODEL
def video_duration(params: dict) -> int: def video_duration(params: dict) -> int:
@@ -531,8 +619,9 @@ def _run_generate_image(context: AgentContext, args: dict) -> tuple[dict, list]:
mode="image", mode="image",
count=count, count=count,
ratio=params.get("ratio") or None, ratio=params.get("ratio") or None,
image_model=IMAGE_MODEL_BY_LABEL.get(str(params.get("model") or ""), params.get("model") or None), image_model=image_model_name(params) or params.get("model") or None,
reference_image_ids=reference_image_ids or None, reference_image_ids=reference_image_ids or None,
feature="omni_create",
) )
context.generations_used += 1 context.generations_used += 1
return ( return (
@@ -587,6 +676,27 @@ def estimate_video_credits(context: AgentContext) -> int:
return 0 return 0
def estimate_image_credits(context: AgentContext) -> int:
"""出图确认卡预计积分:挂牌单价(含团队系数)逐张取整后再 × 张数,与 enqueue 逐任务预留同口径。"""
from apps.billing.pricing import quote_flat
from apps.ai.services import resolve_image_model, get_default_model
params = context.conversation.params or {}
model_name = image_model_name(params) or str(params.get("model") or "").strip() or None
model_config = resolve_image_model(model_name) if model_name else None
if model_config is None:
model_config = get_default_model(ModelConfig.Capability.IMAGE)
if model_config is None:
return 0
count = _image_count(params, None)
try:
per = quote_flat(model_config, units=1, team=context.team)
return int(per.points) * count
except Exception: # noqa: BLE001
logger.warning("omni create: image estimate failed", exc_info=True)
return 0
def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_message: CreationMessage): def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_message: CreationMessage):
"""用户点了确认 → 直接按方案卡里存好的 video_prompt 出片。 """用户点了确认 → 直接按方案卡里存好的 video_prompt 出片。
@@ -764,7 +874,18 @@ def build_system_prompt(context: AgentContext) -> str:
"- 用户说改时长/模型/比例/分辨率:立刻 ask_user,type=single 给出选项;选完后旧方案作废,必须按新参数重新 write_plan。不要让用户去点底部菜单。", "- 用户说改时长/模型/比例/分辨率:立刻 ask_user,type=single 给出选项;选完后旧方案作废,必须按新参数重新 write_plan。不要让用户去点底部菜单。",
"- 光线、构图、镜头这些专业判断是你的活,不要反过来问用户。", "- 光线、构图、镜头这些专业判断是你的活,不要反过来问用户。",
] ]
if not context.is_video: if context.is_video:
lines.extend(["", _OMNI_VIDEO_PROMPT_RULES.strip()])
# 按会话时长给出口播字数锚点(与专业创作 narration_limit 同口径)
try:
dur = video_duration(params)
except Exception: # noqa: BLE001
dur = SMART_DURATION
lo = max(1, int(dur * 5.0))
hi = max(lo, min(85, int(dur * 5.7)))
lines.append(f"- 当前按约 {dur} 秒出片,口播建议 {lo}{hi} 字;write_plan 的 voice_chars 填这个区间。")
lines.append("- 写方案时必须调用 write_plan;video_prompt 按上面的秒级分镜规范写满,不要只给大纲。")
else:
lines.extend([ lines.extend([
"", "",
"【出图】", "【出图】",
@@ -1106,20 +1227,24 @@ def _dispatch_tool(context: AgentContext, name: str, args: dict) -> tuple[dict,
if not prompt: if not prompt:
return {"payload": {"error": "生成失败:模型没有给出画面描述"}}, False return {"payload": {"error": "生成失败:模型没有给出画面描述"}}, False
# 出图也走确认卡:用户先看当前模型/比例/张数,点了才提交。 # 出图也走确认卡:用户先看当前模型/比例/张数,点了才提交。
credits = estimate_image_credits(context)
confirm = append_message( confirm = append_message(
context.conversation, role="assistant", context.conversation, role="assistant",
kind=CreationMessage.Kind.CONFIRM, kind=CreationMessage.Kind.CONFIRM,
payload={ payload={
"kind": "image", "kind": "image",
"label": "开始生成", "label": "开始生成",
"estimated_credits": 0, "estimated_credits": credits,
"prompt": prompt, "prompt": prompt,
"submitted": False, "submitted": False,
"params": snapshot_session_params(context.conversation), "params": snapshot_session_params(context.conversation),
"param_options": confirm_param_options(False), "param_options": confirm_param_options(False),
}, },
) )
events = [{"type": "message", "message": _message_payload(confirm)}] events = [
{"type": "message", "message": _message_payload(confirm)},
{"type": "credits", "estimated": credits},
]
return {"payload": {"awaiting_confirmation": True}, "_events": events}, True return {"payload": {"awaiting_confirmation": True}, "_events": events}, True
return {"payload": {"error": f"未知工具 {name}"}}, False return {"payload": {"error": f"未知工具 {name}"}}, False
+158
View File
@@ -0,0 +1,158 @@
"""从已落库脚本本地拆出角色/场景 —— 不调模型、不扣积分。
脚本生成时已要求结构化 entities;这里负责:
1. ScriptVersion.metadata / content 里的 entities
2. 只保留 character / scene(商品不抽)
3. 至少保证 1 角色 + 1 场景(缺则按分镜补默认)
4. 回填 project.metadata + 每镜 entity_refs + entities_extracted
"""
from __future__ import annotations
import json
import re
from typing import Any
def _as_list(value: Any) -> list:
return value if isinstance(value, list) else []
def _load_draft_entities(script) -> list[dict]:
meta = script.metadata if isinstance(script.metadata, dict) else {}
ents = _as_list(meta.get("entities"))
if ents:
return [e for e in ents if isinstance(e, dict)]
raw = (script.content or "").strip()
if not raw:
return []
try:
data = json.loads(raw)
except (TypeError, ValueError, json.JSONDecodeError):
return []
if not isinstance(data, dict):
return []
return [e for e in _as_list(data.get("entities")) if isinstance(e, dict)]
def _normalize_entities(raw: list[dict]) -> list[dict]:
out: list[dict] = []
seen: set[str] = set()
for i, ent in enumerate(raw):
etype = str(ent.get("type") or "").strip().lower()
if etype in {"person", "cast", "role"}:
etype = "character"
if etype in {"location", "bg", "background"}:
etype = "scene"
if etype not in {"character", "scene"}:
continue
eid = str(ent.get("id") or f"{'c' if etype == 'character' else 's'}{i + 1}").strip()
while eid in seen:
eid = f"{eid}_{i}"
seen.add(eid)
name = str(ent.get("name") or eid).strip() or eid
visual = str(ent.get("visual_prompt") or ent.get("prompt") or "").strip()
if not visual:
visual = (
f"{name},全身出镜,自然光,9:16竖屏"
if etype == "character"
else f"{name},环境空镜,干净构图,16:9横屏"
)
out.append(
{
"id": eid,
"type": etype,
"name": name,
"visual_prompt": visual,
"ref_index": len(out) + 1,
}
)
return out
def _default_character(segments) -> dict:
# 优先用第一镜画面里像「人」的描述,否则通用主角
hint = ""
for seg in segments:
text = f"{getattr(seg, 'visual_prompt', '') or ''} {getattr(seg, 'narration', '') or ''}"
if re.search(r"女|男|人|主播|模特|客户|宝妈|姐姐|哥哥", text):
hint = (getattr(seg, "visual_prompt", None) or text).strip()
break
visual = hint[:80] if hint else "电商短视频出镜主角,全身,自然妆容,干净背景,9:16竖屏"
return {
"id": "c1",
"type": "character",
"name": "主角",
"visual_prompt": visual,
"ref_index": 1,
}
def _default_scene(segments) -> dict:
hint = ""
for seg in segments:
text = (getattr(seg, "visual_prompt", None) or "").strip()
if text:
hint = text
break
visual = hint[:80] if hint else "干净室内场景,柔和光线,适合电商口播,16:9横屏"
return {
"id": "s1",
"type": "scene",
"name": "主场景",
"visual_prompt": visual,
"ref_index": 1,
}
def _ensure_min_entities(entities: list[dict], segments) -> list[dict]:
has_char = any(e["type"] == "character" for e in entities)
has_scene = any(e["type"] == "scene" for e in entities)
if not has_char:
entities.append(_default_character(segments))
if not has_scene:
entities.append(_default_scene(segments))
# 重排 ref_index
for i, e in enumerate(entities):
e["ref_index"] = i + 1
return entities
def _backfill_segment_refs(segments, entities: list[dict]) -> None:
valid = {e["id"] for e in entities}
char_ids = [e["id"] for e in entities if e["type"] == "character"]
scene_ids = [e["id"] for e in entities if e["type"] == "scene"]
default_refs = (char_ids[:1] + scene_ids[:1]) or list(valid)[:2]
for seg in segments:
refs = [r for r in (seg.entity_refs or []) if r in valid]
speaker = (seg.speaker or "").strip()
if speaker and speaker in valid and speaker not in refs:
refs.append(speaker)
if not refs:
refs = list(default_refs)
if refs != (seg.entity_refs or []):
seg.entity_refs = refs
seg.save(update_fields=["entity_refs", "updated_at"])
def materialize_script_entities(*, project, script) -> list[dict]:
"""本地拆角色/场景并落库。返回规范化后的 entities 列表。"""
from django.db import transaction
from apps.ai.script_agent import _map_entities_to_project_metadata
segments = list(script.segments.order_by("sort_order"))
entities = _ensure_min_entities(_normalize_entities(_load_draft_entities(script)), segments)
with transaction.atomic():
_map_entities_to_project_metadata(project, entities)
md = dict(project.metadata or {})
md["entities_extracted"] = True
md["entities_extract_mode"] = "local"
project.metadata = md
project.save(update_fields=["metadata", "updated_at"])
_backfill_segment_refs(segments, entities)
# 同步写回脚本 metadata.entities,方便下次读
sm = dict(script.metadata or {})
sm["entities"] = entities
script.metadata = sm
script.save(update_fields=["metadata", "updated_at"])
return entities
+12 -18
View File
@@ -25,7 +25,7 @@ from django.utils import timezone
from apps.assets.models import Asset, AssetFile, FreeAsset, FreeAssetGroup from apps.assets.models import Asset, AssetFile, FreeAsset, FreeAssetGroup
from apps.assets.storage import TosStorage from apps.assets.storage import TosStorage
from apps.billing.pricing import quote_video_actual, quote_video_estimate, video_reserve_amount from apps.billing.pricing import settle_video_from_payload, quote_video_estimate, video_quote_payload, video_reserve_amount
from apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit from apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit
from .models import AITask, ModelConfig from .models import AITask, ModelConfig
@@ -530,7 +530,7 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
references=built["snapshots"], references=built["snapshots"],
team=team, team=team,
) )
reserve_amount = video_reserve_amount(quote.points) reserve_amount = video_reserve_amount(quote.points, rule=quote.meta.get("rule"))
request_payload = { request_payload = {
"feature": feature, "feature": feature,
@@ -546,9 +546,8 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
"generate_audio": generate_audio, "generate_audio": generate_audio,
"search_mode": search_mode, "search_mode": search_mode,
"estimated_tokens": tokens, "estimated_tokens": tokens,
# 团队价格系数快照:按实结算用它,中途改价不影响在途任务 # 计价快照:挂牌秒价/团队系数下单时钉死,结算只认这些
"price_multiplier": quote.meta.get("price_multiplier", "1"), **video_quote_payload(quote),
"points_per_yuan_snapshot": quote.meta.get("rate", ""),
"references": built["snapshots"], "references": built["snapshots"],
"model_routing_v1": True, "model_routing_v1": True,
} }
@@ -646,7 +645,7 @@ def start_pending_free_video(task: AITask) -> AITask:
references=built["snapshots"], references=built["snapshots"],
team=task.team, team=task.team,
) )
reserve_amount = video_reserve_amount(quote.points) reserve_amount = video_reserve_amount(quote.points, rule=quote.meta.get("rule"))
with transaction.atomic(): with transaction.atomic():
locked = ( locked = (
@@ -671,6 +670,7 @@ def start_pending_free_video(task: AITask) -> AITask:
next_payload["references"] = built["snapshots"] next_payload["references"] = built["snapshots"]
next_payload["estimated_tokens"] = tokens next_payload["estimated_tokens"] = tokens
next_payload["review_pending"] = False next_payload["review_pending"] = False
next_payload.update(video_quote_payload(quote))
locked.request_payload = next_payload locked.request_payload = next_payload
locked.estimated_cost = quote.points locked.estimated_cost = quote.points
locked.status = AITask.Status.RESERVED locked.status = AITask.Status.RESERVED
@@ -991,21 +991,15 @@ def finalize_free_video(*, task: AITask) -> AITask:
total_tokens = int(usage.get("total_tokens") or 0) total_tokens = int(usage.get("total_tokens") or 0)
except (TypeError, ValueError): except (TypeError, ValueError):
total_tokens = 0 total_tokens = 0
with_video_ref = any((r or {}).get("type") == "video" for r in payload.get("references") or []) settle = settle_video_from_payload(actual_model, payload=payload, tokens=total_tokens)
resolution = payload.get("resolution") or "720p" if settle.meta.get("rule") == "missing_usage":
if total_tokens > 0: actual, base_cost = locked.estimated_cost, locked.base_cost
from decimal import Decimal else:
settle = quote_video_actual(
actual_model, tokens=total_tokens, with_video_ref=with_video_ref, resolution=resolution,
multiplier=Decimal(str(payload.get("price_multiplier") or "1")),
)
actual, base_cost = settle.points, settle.base_cost_yuan actual, base_cost = settle.points, settle.base_cost_yuan
payload["actual_tokens"] = total_tokens if total_tokens > 0:
payload["actual_tokens"] = total_tokens
if settle.meta.get("rate"): if settle.meta.get("rate"):
payload["points_per_yuan_snapshot"] = settle.meta["rate"] payload["points_per_yuan_snapshot"] = settle.meta["rate"]
else:
actual, base_cost = locked.estimated_cost, locked.base_cost
seed_out = response.get("seed") seed_out = response.get("seed")
if seed_out is not None: if seed_out is not None:
payload["seed_used"] = seed_out payload["seed_used"] = seed_out
+14
View File
@@ -0,0 +1,14 @@
"""公开模型目录缓存(前端下拉 /api/ai/models/)。
后台增删改模型或设默认时失效,避免停用模型仍出现在创作页
"""
from __future__ import annotations
from django.core.cache import cache
MODEL_CATALOG_CACHE_KEY = "ai_models_catalog_v1"
MODEL_CATALOG_CACHE_TTL = 60 # 秒;失效以写路径为准,TTL 兜底
def invalidate_model_catalog_cache() -> None:
cache.delete(MODEL_CATALOG_CACHE_KEY)
+11 -1
View File
@@ -1612,7 +1612,17 @@ def persist_script_draft(*, project, user, task, draft: dict, source: str):
dialogue=seg.get("dialogue") or [], dialogue=seg.get("dialogue") or [],
product_points=[], product_points=[],
) )
_map_entities_to_project_metadata(project, draft.get("entities", [])) ents = draft.get("entities") or []
_map_entities_to_project_metadata(project, ents)
# 出稿已带角色+场景时,直接视为已提取(资产页免再点付费提取)
if any((e or {}).get("type") == "character" for e in ents if isinstance(e, dict)) and any(
(e or {}).get("type") == "scene" for e in ents if isinstance(e, dict)
):
md = dict(project.metadata or {})
md["entities_extracted"] = True
md["entities_extract_mode"] = "from_script"
project.metadata = md
project.save(update_fields=["metadata", "updated_at"])
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT) stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
stage.status = ProjectStage.Status.NEEDS_REVIEW stage.status = ProjectStage.Status.NEEDS_REVIEW
stage.save(update_fields=["status", "updated_at"]) stage.save(update_fields=["status", "updated_at"])
+50 -73
View File
@@ -1146,16 +1146,13 @@ def get_inflight_extraction(project):
def submit_extract_entities(*, project, user) -> AITask: def submit_extract_entities(*, project, user) -> AITask:
"""提交独立实体提取步(**异步**)。读已定稿(或最新)脚本 → 校验 + 建 RESERVED 任务 + 预留额度(秒级), """从已定稿脚本**本地**拆出角色/场景(不调模型、不扣积分)。
慢活(豆包思考模型流式抽取,可达数十秒)交给 Celery worker(run_extract_entities_task)
这样 Web (gunicorn/nginx)不被数十秒的模型请求占住 不再 502 脚本生成时已带结构化 entities;这里只做规范化 + 最少 1 角色/1 场景兜底 + 落库
**防重复扣费**:本项目已有在途提取任务时直接复用它,不新建/不二次预扣 仍返回一条 SUCCEEDED ENTITY_EXTRACTION 任务,方便前端轮询/审计口径不变
(用户刷新后手痒重点或并发点击都安全 提取是实体唯一权威来源,本就只需跑一次) """
校验失败(无脚本/无分镜/无模型/额度不足) ValueError 由端点转 400;运行期失败由 worker 记进 from apps.ai.entity_local import materialize_script_entities
task.error_message,前端轮询 extract-status 读取返回 AITask"""
from apps.projects.models import ScriptVersion from apps.projects.models import ScriptVersion
from apps.ai.tasks import extract_entities_task
script = ( script = (
ScriptVersion.objects.filter(project=project, is_adopted=True).order_by("-created_at").first() ScriptVersion.objects.filter(project=project, is_adopted=True).order_by("-created_at").first()
@@ -1163,56 +1160,34 @@ def submit_extract_entities(*, project, user) -> AITask:
) )
if script is None: if script is None:
raise ValueError("请先生成并定稿脚本,再提取角色 / 场景") raise ValueError("请先生成并定稿脚本,再提取角色 / 场景")
segments = list(script.segments.order_by("sort_order")) if not script.segments.exists():
if not segments:
raise ValueError("脚本没有分镜,无法提取") raise ValueError("脚本没有分镜,无法提取")
inflight = get_inflight_extraction(project) entities = materialize_script_entities(project=project, script=script)
if inflight is not None:
return inflight # 已有提取在跑:复用,绝不二次预扣 / 重复出活
model_config = _resolve_extract_model_config() model_config = _resolve_extract_model_config()
if model_config is None: if model_config is None:
# AITask.model_config 非空;本地提取不调模型,但仍需一条配置挂审计任务
raise ValueError("没有可用的文本模型") raise ValueError("没有可用的文本模型")
task = AITask.objects.create(
product = project.product team=project.team,
if product is not None: created_by=user,
sp = "".join([p.title for p in product.selling_points.all()[:5]]) project=project,
prod_line = f"名称:{product.title}\n品类:{product.category or '未填'}\n卖点:{sp or '未填'}" task_type=AITask.Type.ENTITY_EXTRACTION,
else: status=AITask.Status.SUCCEEDED,
prod_line = "(无商品信息)" model_config=model_config,
seg_lines = [ idempotency_key=f"entity_extraction:local:{project.id}:{uuid.uuid4()}",
f"{i} role={s.role or ''} narration={(s.narration or '').strip()} visual={(s.visual_prompt or '').strip()}" request_payload={
for i, s in enumerate(segments) "mode": "local",
] "script_id": str(script.id),
user_msg = ( "entity_count": len(entities),
f"商品信息:\n{prod_line}\n\n分镜脚本(共 {len(segments)} 镜,index 从 0 开始):\n" + "\n".join(seg_lines) },
response_payload={"entities": entities, "mode": "local"},
estimated_cost=Decimal("0"),
actual_cost=Decimal("0"),
base_cost=Decimal("0"),
completed_at=timezone.now(),
) )
# skill 正文(领域功力)+ 写死的输出契约兜底(保证「只输出 JSON」永远在,即便 skill 丢失)
system = _load_skill_system_prompt("ecommerce-entity-extract") + _EXTRACT_OUTPUT_CONTRACT
messages = [{"role": "system", "content": system}, {"role": "user", "content": user_msg}]
try:
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.ENTITY_EXTRACTION,
model_config=model_config,
request_payload={
"model": model_config.name,
"endpoint": model_config.endpoint,
"messages": messages,
"script_id": str(script.id),
"model_routing_v1": True,
},
)
except Exception as exc: # 余额不足等预扣失败
raise ValueError("额度不足,无法提取(请先充值)") from exc
# 真实平台成本由每条 AIModelAttempt 按实际模型累加,避免 Fallback 后仍记主模型旧成本。
task.base_cost = Decimal("0")
task.save(update_fields=["base_cost", "updated_at"])
extract_entities_task.delay(str(task.id))
return task return task
@@ -3181,7 +3156,7 @@ def submit_video_segment(
# 视频段 token 计量计价(与自由创作同一成本表+同一毛利):按用户选定的比例/清晰度/目标时长预估, # 视频段 token 计量计价(与自由创作同一成本表+同一毛利):按用户选定的比例/清晰度/目标时长预估,
# 预留=积分×buffer,终态按火山真实 usage.total_tokens 结算(poll_video_segment true-up)。 # 预留=积分×buffer,终态按火山真实 usage.total_tokens 结算(poll_video_segment true-up)。
# 这里终结了「视频 ¥1/段、成本 ¥15」的倒贴定价。 # 这里终结了「视频 ¥1/段、成本 ¥15」的倒贴定价。
from apps.billing.pricing import quote_video_estimate, video_reserve_amount from apps.billing.pricing import quote_video_estimate, settle_video_from_payload, video_quote_payload, video_reserve_amount
est_tokens, quote = quote_video_estimate( est_tokens, quote = quote_video_estimate(
model_config, model_config,
@@ -3204,7 +3179,7 @@ def submit_video_segment(
task_type=AITask.Type.VIDEO_SEGMENT, task_type=AITask.Type.VIDEO_SEGMENT,
model_config=model_config, model_config=model_config,
quote=quote, quote=quote,
reserve_amount=video_reserve_amount(quote.points), reserve_amount=video_reserve_amount(quote.points, rule=quote.meta.get("rule")),
request_payload={ request_payload={
"model": model_config.name, "model": model_config.name,
"endpoint": model_config.endpoint, "endpoint": model_config.endpoint,
@@ -3213,8 +3188,7 @@ def submit_video_segment(
"ratio": aspect_ratio, "ratio": aspect_ratio,
"resolution": resolution, "resolution": resolution,
"estimated_tokens": est_tokens, "estimated_tokens": est_tokens,
# 团队价格系数快照:按实结算用它,中途改价不影响在途任务(jimeng 同款纪律) **video_quote_payload(quote),
"price_multiplier": quote.meta.get("price_multiplier", "1"),
"video_segment_id": str(video_segment.id), "video_segment_id": str(video_segment.id),
"reference_images": reference_images, "reference_images": reference_images,
"model_routing_v1": True, "model_routing_v1": True,
@@ -3375,34 +3349,26 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
# 按火山真实 usage.total_tokens 结算(true-up,与自由创作同口径): # 按火山真实 usage.total_tokens 结算(true-up,与自由创作同口径):
# 多退(charge 差额自动 RELEASE)/超预留 clamp(ledger 禁超扣,差额平台承担并告警)。 # 多退(charge 差额自动 RELEASE)/超预留 clamp(ledger 禁超扣,差额平台承担并告警)。
# usage 缺失(异常响应)回落预估价,不阻断出片。 # usage 缺失(异常响应)回落预估价,不阻断出片。
from apps.billing.pricing import quote_video_actual
reservation = locked_task.credit_reservation reservation = locked_task.credit_reservation
payload = locked_task.request_payload or {} payload = dict(locked_task.request_payload or {})
try: try:
usage_tokens = int((response.get("usage") or {}).get("total_tokens") or 0) usage_tokens = int((response.get("usage") or {}).get("total_tokens") or 0)
except (TypeError, ValueError): except (TypeError, ValueError):
usage_tokens = 0 usage_tokens = 0
if usage_tokens > 0: settle = settle_video_from_payload(actual_model, payload=payload, tokens=usage_tokens)
settle = quote_video_actual( if settle.meta.get("rule") == "missing_usage":
actual_model, actual_points, base_cost = locked_task.estimated_cost, locked_task.base_cost
tokens=usage_tokens, else:
with_video_ref=False,
resolution=str(payload.get("resolution") or "720p"),
multiplier=Decimal(str(payload.get("price_multiplier") or "1")),
)
actual_points, base_cost = settle.points, settle.base_cost_yuan actual_points, base_cost = settle.points, settle.base_cost_yuan
if settle.meta.get("rate"):
payload["points_per_yuan_snapshot"] = settle.meta["rate"]
locked_task.request_payload = payload
if actual_points > reservation.amount: if actual_points > reservation.amount:
logger.warning( logger.warning(
"video segment task %s actual %s exceeds reserved %s, clamped", "video segment task %s actual %s exceeds reserved %s, clamped",
locked_task.id, actual_points, reservation.amount, locked_task.id, actual_points, reservation.amount,
) )
actual_points = reservation.amount actual_points = reservation.amount
else:
actual_points, base_cost = locked_task.estimated_cost, locked_task.base_cost
if usage_tokens > 0 and settle.meta.get("rate"):
payload["points_per_yuan_snapshot"] = settle.meta["rate"]
locked_task.request_payload = payload
locked_task.status = AITask.Status.SUCCEEDED locked_task.status = AITask.Status.SUCCEEDED
locked_task.response_payload = response locked_task.response_payload = response
locked_task.actual_cost = actual_points locked_task.actual_cost = actual_points
@@ -3527,7 +3493,7 @@ def _reap_stale_standalone_image_tasks(*, team) -> None:
continue continue
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None, image_model: str | None = None, conversation=None, reference_image_ids: list[str] | None = None, platform_id: str | None = None, batch_id: str | None = None, retry_of_task_id: str | None = None, tryon_prompt_v2_override: bool = False, tryon_ab: dict | None = None, dispatch: bool = True) -> list[AITask]: def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None, image_model: str | None = None, conversation=None, reference_image_ids: list[str] | None = None, platform_id: str | None = None, batch_id: str | None = None, retry_of_task_id: str | None = None, tryon_prompt_v2_override: bool = False, tryon_ab: dict | None = None, feature: str | None = None, dispatch: bool = True) -> list[AITask]:
"""独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 + """独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 +
预留额度这种秒级的活,真正 ~30s ARK 出图交给 Celery worker(generate_standalone_image_task) 预留额度这种秒级的活,真正 ~30s ARK 出图交给 Celery worker(generate_standalone_image_task)
@@ -3641,6 +3607,8 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
for index in range(count): for index in range(count):
quote = quote_flat(model_config, team=team) quote = quote_flat(model_config, team=team)
request_payload = {"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product), "model_id": str(model_id) if model_id else None, "model_entity_id": str(model_entity_id) if model_entity_id else None, "batch_id": batch_id, "ratio": str(ratio) if ratio else None, "reference_image_ids": ref_ids, "platform_id": platform_key or None, "platform_name": platform_name or None} request_payload = {"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product), "model_id": str(model_id) if model_id else None, "model_entity_id": str(model_entity_id) if model_entity_id else None, "batch_id": batch_id, "ratio": str(ratio) if ratio else None, "reference_image_ids": ref_ids, "platform_id": platform_key or None, "platform_name": platform_name or None}
if feature:
request_payload["feature"] = str(feature)
if use_model_routing: if use_model_routing:
request_payload["model_routing_v1"] = True request_payload["model_routing_v1"] = True
if tryon_classification is not None: if tryon_classification is not None:
@@ -3899,13 +3867,22 @@ def run_standalone_image_task(*, task_id: str) -> None:
asset_meta["batch_id"] = str(payload["batch_id"]) asset_meta["batch_id"] = str(payload["batch_id"])
if payload.get("model_entity_id"): if payload.get("model_entity_id"):
asset_meta["model_entity_id"] = str(payload["model_entity_id"]) asset_meta["model_entity_id"] = str(payload["model_entity_id"])
# 全能创作出图只留在会话里,不进图片创作最近列表 / 资产库。
is_omni = str(payload.get("feature") or "") == "omni_create"
if is_omni:
asset_meta["feature"] = "omni_create"
asset = Asset.objects.create( asset = Asset.objects.create(
id=asset_id, team=team, created_by=user, name=f"AI 生成 · {asset_label} · {index + 1}", id=asset_id, team=team, created_by=user, name=f"AI 生成 · {asset_label} · {index + 1}",
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=asset_category, origin_task=task, asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=asset_category, origin_task=task,
metadata=asset_meta, metadata=asset_meta,
# 模特候选与项目角色均是功能性资料:候选保存后进入模特库,不进入 /library; # 模特候选与项目角色均是功能性资料:候选保存后进入模特库,不进入 /library;
# 商品/创作等图片趴成图保持原有自动入库行为。 # 商品/创作等图片趴成图保持原有自动入库行为。
in_library=asset_category not in (Asset.Category.PERSON, Asset.Category.MODEL_PORTRAIT), # 全能创作同视频:不自动入库。
in_library=(
False
if is_omni
else asset_category not in (Asset.Category.PERSON, Asset.Category.MODEL_PORTRAIT)
),
) )
AssetFile.objects.create(asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True) AssetFile.objects.create(asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True)
except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费) except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
@@ -273,6 +273,7 @@ class GenerateImageTests(CreationAgentBaseTests):
self.assertEqual(kwargs["reference_image_ids"], [str(self.product_asset.id)]) self.assertEqual(kwargs["reference_image_ids"], [str(self.product_asset.id)])
self.assertEqual(kwargs["ratio"], "1:1") # 会话级参数直接用,不再问用户 self.assertEqual(kwargs["ratio"], "1:1") # 会话级参数直接用,不再问用户
self.assertEqual(kwargs["count"], 1) self.assertEqual(kwargs["count"], 1)
self.assertEqual(kwargs["feature"], "omni_create")
def test_session_image_count_overrides_model_count(self): def test_session_image_count_overrides_model_count(self):
self.conversation.params = {"ratio": "1:1", "count": "2 张"} self.conversation.params = {"ratio": "1:1", "count": "2 张"}
+14 -15
View File
@@ -23,7 +23,7 @@ from django.utils import timezone
from apps.assets.models import Asset, Model from apps.assets.models import Asset, Model
from apps.billing.models import CreditAccount from apps.billing.models import CreditAccount
from apps.billing.pricing import quote_video_estimate, video_reserve_amount from apps.billing.pricing import quote_video_estimate, settle_video_from_payload, video_quote_payload, video_reserve_amount
from apps.products.models import Product from apps.products.models import Product
from .free_video import ( from .free_video import (
@@ -1053,7 +1053,7 @@ def _legacy_start_pending_replace_shots(task):
references=built["snapshots"], references=built["snapshots"],
team=task.team, team=task.team,
) )
reserve_amount = video_reserve_amount(quote.points) reserve_amount = video_reserve_amount(quote.points, rule=quote.meta.get("rule"))
with transaction.atomic(): with transaction.atomic():
locked = ( locked = (
@@ -1083,6 +1083,7 @@ def _legacy_start_pending_replace_shots(task):
next_payload["estimated_tokens"] = tokens next_payload["estimated_tokens"] = tokens
next_payload["review_pending"] = False next_payload["review_pending"] = False
next_payload["duration"] = billed_duration next_payload["duration"] = billed_duration
next_payload.update(video_quote_payload(quote))
locked.request_payload = next_payload locked.request_payload = next_payload
locked.estimated_cost = quote.points locked.estimated_cost = quote.points
locked.status = AITask.Status.RESERVED locked.status = AITask.Status.RESERVED
@@ -1262,7 +1263,6 @@ def complete_replace_shots(task):
"""全部镜头出完:下载 → ffmpeg 拼接 → 转存 TOS → 按 tokens 合计结算。""" """全部镜头出完:下载 → ffmpeg 拼接 → 转存 TOS → 按 tokens 合计结算。"""
from decimal import Decimal from decimal import Decimal
from apps.billing.pricing import quote_video_actual
from apps.billing.services.ledger import charge_reserved_credit, release_credit from apps.billing.services.ledger import charge_reserved_credit, release_credit
from .free_video import _notify_failure, _store_free_video_media from .free_video import _notify_failure, _store_free_video_media
@@ -1289,18 +1289,18 @@ def complete_replace_shots(task):
total_tokens += int(item.get("tokens") or 0) total_tokens += int(item.get("tokens") or 0)
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
resolution = payload.get("resolution") or "720p" # 挂牌秒价按快照扣;无挂牌才按 tokens true-up
if total_tokens > 0: if "with_ref_video" not in payload:
settle = quote_video_actual( payload["with_ref_video"] = True # 替换片默认带视频参考
locked.model_config, tokens=total_tokens, with_video_ref=True, settle = settle_video_from_payload(locked.model_config, payload=payload, tokens=total_tokens)
resolution=resolution, multiplier=Decimal(str(payload.get("price_multiplier") or "1")), if settle.meta.get("rule") == "missing_usage":
) actual, base_cost = locked.estimated_cost, locked.base_cost
else:
actual, base_cost = settle.points, settle.base_cost_yuan actual, base_cost = settle.points, settle.base_cost_yuan
payload["actual_tokens"] = total_tokens if total_tokens > 0:
payload["actual_tokens"] = total_tokens
if settle.meta.get("rate"): if settle.meta.get("rate"):
payload["points_per_yuan_snapshot"] = settle.meta["rate"] payload["points_per_yuan_snapshot"] = settle.meta["rate"]
else:
actual, base_cost = locked.estimated_cost, locked.base_cost
with transaction.atomic(): with transaction.atomic():
locked = AITask.objects.select_for_update().get(id=locked.id) locked = AITask.objects.select_for_update().get(id=locked.id)
if locked.status != AITask.Status.POSTPROCESSING: if locked.status != AITask.Status.POSTPROCESSING:
@@ -1584,7 +1584,7 @@ def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = F
references=references, references=references,
team=team, team=team,
) )
reserve_amount = video_reserve_amount(quote.points) reserve_amount = video_reserve_amount(quote.points, rule=quote.meta.get("rule"))
account = CreditAccount.objects.filter(team=team).first() account = CreditAccount.objects.filter(team=team).first()
available = (account.balance - account.reserved_balance) if account else Decimal("0") available = (account.balance - account.reserved_balance) if account else Decimal("0")
if available < reserve_amount: if available < reserve_amount:
@@ -1610,8 +1610,7 @@ def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = F
"generate_audio": True, "generate_audio": True,
"search_mode": "off", "search_mode": "off",
"estimated_tokens": tokens, "estimated_tokens": tokens,
"price_multiplier": quote.meta.get("price_multiplier", "1"), **video_quote_payload(quote),
"points_per_yuan_snapshot": quote.meta.get("rate", ""),
"references": references, "references": references,
"model_routing_v1": True, "model_routing_v1": True,
"review_pending": True, "review_pending": True,
+6 -10
View File
@@ -14,7 +14,7 @@ from django.utils import timezone
from apps.ai.models import AITask from apps.ai.models import AITask
from apps.assets.models import Asset from apps.assets.models import Asset
from apps.billing.models import CreditReservation from apps.billing.models import CreditReservation
from apps.billing.pricing import quote_video_actual from apps.billing.pricing import settle_video_from_payload
from apps.projects.models import VideoSegment, VideoSegmentVersion from apps.projects.models import VideoSegment, VideoSegmentVersion
@@ -108,16 +108,12 @@ def _platform_cost(response: dict, *, task: AITask, actual_model, with_video_ref
total_tokens = int((response.get("usage") or {}).get("total_tokens") or 0) total_tokens = int((response.get("usage") or {}).get("total_tokens") or 0)
except (TypeError, ValueError): except (TypeError, ValueError):
total_tokens = 0 total_tokens = 0
if total_tokens <= 0: payload = dict(task.request_payload or {})
if "with_ref_video" not in payload:
payload["with_ref_video"] = with_video_ref
settle = settle_video_from_payload(actual_model, payload=payload, tokens=total_tokens)
if settle.meta.get("rule") == "missing_usage":
return 0, task.base_cost return 0, task.base_cost
payload = task.request_payload or {}
settle = quote_video_actual(
actual_model,
tokens=total_tokens,
with_video_ref=with_video_ref,
resolution=str(payload.get("resolution") or "720p"),
multiplier=Decimal(str(payload.get("price_multiplier") or "1")),
)
return total_tokens, settle.base_cost_yuan return total_tokens, settle.base_cost_yuan
+25
View File
@@ -1248,6 +1248,7 @@ class FreeVideoUploadView(APIView):
class ModelConfigViewSet(ReadOnlyModelViewSet): class ModelConfigViewSet(ReadOnlyModelViewSet):
# 按创建序固定排序:最早创建的 active 模型排第一 = 前端选择器默认项,与 get_default_model 口径一致。 # 按创建序固定排序:最早创建的 active 模型排第一 = 前端选择器默认项,与 get_default_model 口径一致。
# DeepSeek 已停用,下拉里不再出现。 # DeepSeek 已停用,下拉里不再出现。
# 列表短且高频(创作页下拉),整页结果缓存;后台改模型走 invalidate_model_catalog_cache。
queryset = ( queryset = (
ModelConfig.objects.select_related("provider") ModelConfig.objects.select_related("provider")
.filter(status=ModelConfig.Status.ACTIVE) .filter(status=ModelConfig.Status.ACTIVE)
@@ -1259,6 +1260,30 @@ class ModelConfigViewSet(ReadOnlyModelViewSet):
search_fields = ["name", "display_name", "capability"] search_fields = ["name", "display_name", "capability"]
ordering_fields = ["created_at", "display_name"] ordering_fields = ["created_at", "display_name"]
def list(self, request, *args, **kwargs):
from django.core.cache import cache
from apps.ai.model_catalog import MODEL_CATALOG_CACHE_KEY, MODEL_CATALOG_CACHE_TTL
# 缓存「无 search/ordering/capability、首页」目录。page_size=200 是创作页常规用法。
qp = request.query_params
page = str(qp.get("page") or "1")
page_size = str(qp.get("page_size") or "")
is_plain = (
not any(qp.get(k) for k in ("search", "ordering", "capability"))
and page in {"1", ""}
and page_size in {"", "200"}
)
cache_key = MODEL_CATALOG_CACHE_KEY if page_size in {"", "200"} else f"{MODEL_CATALOG_CACHE_KEY}:{page_size}"
if is_plain:
cached = cache.get(cache_key)
if cached is not None:
return Response(cached)
response = super().list(request, *args, **kwargs)
if is_plain and response.status_code == 200:
cache.set(cache_key, response.data, MODEL_CATALOG_CACHE_TTL)
return response
class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
+207
View File
@@ -0,0 +1,207 @@
"""老板挂牌积分规则(不谈供应商成本)。
存在 ModelConfig.metadata["points_pricing"]:
· image mode=per_image, points_per_image
· text mode=per_call, points_per_call
· video mode=per_second, tiers=[{resolution, points_per_second, points_per_second_with_ref?}]
能力(可选分辨率/时长) metadata["capabilities"]
未配置 points_pricing 时由 pricing.quote_* 回落旧口径(unit_price / token 价表)
"""
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from typing import Any
def _dict(value: Any) -> dict:
return value if isinstance(value, dict) else {}
def _int_points(value: Any) -> int | None:
if value is None or value is False:
return None
try:
n = int(Decimal(str(value)).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
except Exception: # noqa: BLE001
return None
return n if n >= 0 else None
def points_pricing_of(model_config) -> dict:
return _dict(_dict(getattr(model_config, "metadata", None)).get("points_pricing"))
def capabilities_of(model_config) -> dict:
return _dict(_dict(getattr(model_config, "metadata", None)).get("capabilities"))
def has_points_pricing(model_config) -> bool:
pricing = points_pricing_of(model_config)
mode = str(pricing.get("mode") or "").strip()
if mode == "per_image":
return _int_points(pricing.get("points_per_image")) is not None
if mode == "per_call":
return _int_points(pricing.get("points_per_call")) is not None
if mode == "per_second":
tiers = pricing.get("tiers")
return isinstance(tiers, list) and any(isinstance(t, dict) for t in tiers)
# 兼容:只填了数字字段
return (
_int_points(pricing.get("points_per_image")) is not None
or _int_points(pricing.get("points_per_call")) is not None
)
def image_points_per_unit(model_config) -> int | None:
pricing = points_pricing_of(model_config)
n = _int_points(pricing.get("points_per_image"))
if n is not None:
return n
# 回落 unit_price(积分/张)
return _int_points(getattr(model_config, "unit_price", None))
def text_points_per_call(model_config) -> int | None:
pricing = points_pricing_of(model_config)
n = _int_points(pricing.get("points_per_call"))
if n is not None:
return n
return _int_points(getattr(model_config, "unit_price", None))
def video_tier(model_config, *, resolution: str, with_ref_video: bool = False) -> dict | None:
pricing = points_pricing_of(model_config)
tiers = pricing.get("tiers")
if not isinstance(tiers, list):
return None
resolution = str(resolution or "").strip().lower()
hit = None
for raw in tiers:
if not isinstance(raw, dict):
continue
if str(raw.get("resolution") or "").strip().lower() == resolution:
hit = raw
break
return hit
def video_points_per_second(model_config, *, resolution: str, with_ref_video: bool = False) -> int | None:
tier = video_tier(model_config, resolution=resolution, with_ref_video=with_ref_video)
if not tier:
return None
if with_ref_video:
n = _int_points(tier.get("points_per_second_with_ref"))
if n is not None:
return n
return _int_points(tier.get("points_per_second"))
def quote_points_per_second(*, points_per_second: int, duration: int) -> Decimal:
seconds = max(int(duration or 0), 0)
pts = max(int(points_per_second) * seconds, 1) if seconds > 0 else 0
return Decimal(pts)
def quote_points_units(*, points_per_unit: int, units: int) -> Decimal:
count = max(int(units or 0), 0)
if count <= 0:
return Decimal("0")
return Decimal(max(int(points_per_unit) * count, 1))
def normalize_points_pricing_for_save(capability: str, metadata: dict | None) -> dict:
"""整理 metadata:补齐 points_pricing.mode,并让 unit_price 与挂牌积分对齐(兼容旧读法)。"""
root = dict(metadata or {})
pricing = dict(_dict(root.get("points_pricing")))
caps = dict(_dict(root.get("capabilities")))
capability = str(capability or "")
if capability == "image":
pts = _int_points(pricing.get("points_per_image"))
if pts is not None:
pricing["mode"] = "per_image"
pricing["points_per_image"] = pts
root["points_pricing"] = pricing
return root
if capability in {"text", "vision"}:
pts = _int_points(pricing.get("points_per_call"))
if pts is not None:
pricing["mode"] = "per_call"
pricing["points_per_call"] = pts
root["points_pricing"] = pricing
return root
if capability == "video":
tiers_in = pricing.get("tiers") if isinstance(pricing.get("tiers"), list) else []
tiers: list[dict] = []
resolutions: list[str] = []
for raw in tiers_in:
if not isinstance(raw, dict):
continue
res = str(raw.get("resolution") or "").strip()
pps = _int_points(raw.get("points_per_second"))
if not res or pps is None:
continue
row = {"resolution": res, "points_per_second": pps}
with_ref = _int_points(raw.get("points_per_second_with_ref"))
if with_ref is not None:
row["points_per_second_with_ref"] = with_ref
tiers.append(row)
if res not in resolutions:
resolutions.append(res)
if tiers:
pricing["mode"] = "per_second"
pricing["tiers"] = tiers
# 能力分辨率与价表对齐,前端下拉只出有价的档
if not caps.get("resolutions"):
caps["resolutions"] = resolutions
else:
# 保留已配能力,但确保价表分辨率都在里面
existing = [str(x) for x in (caps.get("resolutions") or [])]
for res in resolutions:
if res not in existing:
existing.append(res)
caps["resolutions"] = existing
root["points_pricing"] = pricing
root["capabilities"] = caps
return root
root["points_pricing"] = pricing
return root
def points_pricing_errors(capability: str, metadata: Any) -> tuple[str, ...]:
"""可选校验:填了 points_pricing 就要完整。"""
root = _dict(metadata)
pricing = _dict(root.get("points_pricing"))
if not pricing:
return ()
errors: list[str] = []
mode = str(pricing.get("mode") or "").strip()
if capability == "image":
if _int_points(pricing.get("points_per_image")) is None and mode in {"", "per_image"}:
if "points_per_image" in pricing:
errors.append("points_pricing.points_per_image 必须是 ≥0 的整数积分")
elif capability in {"text", "vision"}:
if "points_per_call" in pricing and _int_points(pricing.get("points_per_call")) is None:
errors.append("points_pricing.points_per_call 必须是 ≥0 的整数积分")
elif capability == "video":
tiers = pricing.get("tiers")
if tiers is None:
return tuple(errors)
if not isinstance(tiers, list) or not tiers:
errors.append("视频 points_pricing.tiers 至少配置一档分辨率积分")
else:
for i, raw in enumerate(tiers):
if not isinstance(raw, dict):
errors.append(f"tiers[{i}] 必须是对象")
continue
if not str(raw.get("resolution") or "").strip():
errors.append(f"tiers[{i}].resolution 不能为空")
if _int_points(raw.get("points_per_second")) is None:
errors.append(f"tiers[{i}].points_per_second 必须是 ≥0 的整数积分")
if "points_per_second_with_ref" in raw and _int_points(raw.get("points_per_second_with_ref")) is None:
errors.append(f"tiers[{i}].points_per_second_with_ref 必须是 ≥0 的整数积分")
return tuple(errors)
+150 -14
View File
@@ -10,8 +10,9 @@
平台成本配在 metadata.pricing.base_cost_yuan(未配=0,毛利报表按未知处理) 平台成本配在 metadata.pricing.base_cost_yuan(未配=0,毛利报表按未知处理)
· 配音:metadata.pricing = {"mode":"per_chars","chars_per_unit":500,"points_per_unit":10, · 配音:metadata.pricing = {"mode":"per_chars","chars_per_unit":500,"points_per_unit":10,
"min_units":1,"base_cost_yuan_per_unit":...},按字符数阶梯 "min_units":1,"base_cost_yuan_per_unit":...},按字符数阶梯
· 视频:metadata.pricing 是火山**成本价表**(/百万tokens, apps/ai/video_pricing.py), · 视频:优先 metadata.points_pricing 挂牌(分辨率积分/ × 时长;可配含视频参考价);
用户价 = ¥成本 × video_margin_multiplier 积分按真实 usage.total_tokens 结算 未挂牌才走 metadata.pricing 火山成本 × 毛利 积分,并按 usage.total_tokens true-up
挂牌任务预留=精确积分结算认下单快照;token 任务预留仍 × video_reserve_buffer
取整(前后端必须逐字一致,前端镜像在 frontend/src/components/free-create/constants.ts): 取整(前后端必须逐字一致,前端镜像在 frontend/src/components/free-create/constants.ts):
¥成本先 quantize 0.01(video_pricing.calculate_cost 现状) ×毛利 ×汇率 ROUND_HALF_UP ¥成本先 quantize 0.01(video_pricing.calculate_cost 现状) ×毛利 ×汇率 ROUND_HALF_UP
@@ -100,19 +101,38 @@ def _pricing_meta(model_config) -> dict:
def quote_flat(model_config, *, units: int = 1, team=None) -> Quote: def quote_flat(model_config, *, units: int = 1, team=None) -> Quote:
"""文本(次)/ 图像(张)。unit_price = 积分/单位;≤0 回落 10 积分。team 传入则乘团队价格系数。""" """文本(次)/ 图像(张)。优先 metadata.points_pricing 挂牌;否则 unit_price;≤0 回落默认积分。team 传入则乘团队价格系数。"""
unit_points = Decimal(str(getattr(model_config, "unit_price", 0) or 0)) from apps.billing.points_rules import image_points_per_unit, text_points_per_call
capability = str(getattr(model_config, "capability", "") or "")
listed = None
if capability == "image":
listed = image_points_per_unit(model_config)
elif capability in {"text", "vision"}:
listed = text_points_per_call(model_config)
listed_from_hang = listed is not None and listed > 0
if listed_from_hang:
unit_points = Decimal(listed)
else:
unit_points = Decimal(str(getattr(model_config, "unit_price", 0) or 0))
if unit_points <= 0: if unit_points <= 0:
is_image = str(getattr(model_config, "capability", "")) == "image" is_image = capability == "image"
unit_points = FLAT_FALLBACK_POINTS_IMAGE if is_image else FLAT_FALLBACK_POINTS unit_points = FLAT_FALLBACK_POINTS_IMAGE if is_image else FLAT_FALLBACK_POINTS
unit_points = unit_points.quantize(Decimal("1"), rounding=ROUND_HALF_UP) unit_points = unit_points.quantize(Decimal("1"), rounding=ROUND_HALF_UP)
base_per_unit = Decimal(str(_pricing_meta(model_config).get("base_cost_yuan") or 0)) base_per_unit = Decimal(str(_pricing_meta(model_config).get("base_cost_yuan") or 0))
multiplier = team_price_multiplier(team) multiplier = team_price_multiplier(team)
points = apply_team_price(max(unit_points * units, Decimal("1")), multiplier) points = apply_team_price(max(unit_points * units, Decimal("1")), multiplier)
# 挂牌:image=per_image / text=per_call;未挂牌仍记 flat(unit_price 或默认)
if listed_from_hang and capability == "image":
rule = "per_image"
elif listed_from_hang and capability in {"text", "vision"}:
rule = "per_call"
else:
rule = "flat"
return Quote( return Quote(
points=points, points=points,
base_cost_yuan=base_per_unit * units, base_cost_yuan=base_per_unit * units,
meta={"rule": "flat", "units": units, "unit_points": str(unit_points), "price_multiplier": str(multiplier), "rate": str(get_billing_config().points_per_yuan)}, meta={"rule": rule, "units": units, "unit_points": str(unit_points), "price_multiplier": str(multiplier), "rate": str(get_billing_config().points_per_yuan)},
) )
@@ -176,25 +196,141 @@ def quote_video_from_cost(cost_yuan: Decimal, *, tokens: int = 0, multiplier: De
def quote_video_estimate(model_config, *, aspect_ratio: str, resolution: str, duration: int, references: list, team=None) -> tuple[int, Quote]: def quote_video_estimate(model_config, *, aspect_ratio: str, resolution: str, duration: int, references: list, team=None) -> tuple[int, Quote]:
"""预估:apps/ai/video_pricing 的 ¥成本 × 毛利 → 积分 → ×团队当前系数。返回 (tokens, Quote)。""" """预估视频积分。优先老板挂牌(分辨率×秒);未配置则回落 token×成本×毛利。返回 (tokens占位, Quote)。"""
from apps.ai.video_pricing import estimate_video_cost from apps.ai.video_pricing import estimate_video_cost, has_video_reference
from apps.billing.points_rules import quote_points_per_second, video_points_per_second
with_ref = has_video_reference(references)
pps = video_points_per_second(model_config, resolution=resolution, with_ref_video=with_ref)
multiplier = team_price_multiplier(team)
if pps is not None:
list_points = quote_points_per_second(points_per_second=pps, duration=duration)
points = apply_team_price(list_points, multiplier)
return 0, Quote(
points=points,
base_cost_yuan=Decimal("0"),
meta={
"rule": "points_per_second",
"resolution": resolution,
"duration": duration,
"with_ref_video": with_ref,
"points_per_second": pps,
"price_multiplier": str(multiplier),
"rate": str(get_billing_config().points_per_yuan),
},
)
tokens, cost_yuan = estimate_video_cost( tokens, cost_yuan = estimate_video_cost(
model_config, aspect_ratio=aspect_ratio, resolution=resolution, duration=duration, references=references model_config, aspect_ratio=aspect_ratio, resolution=resolution, duration=duration, references=references
) )
return tokens, quote_video_from_cost(cost_yuan, tokens=tokens, multiplier=team_price_multiplier(team)) return tokens, quote_video_from_cost(cost_yuan, tokens=tokens, multiplier=multiplier)
def quote_video_actual(model_config, *, tokens: int, with_video_ref: bool, resolution: str, multiplier: Decimal | None = None) -> Quote: def quote_video_actual(
"""按真实 usage.total_tokens 结算(与预估同一张成本表+同一毛利)。 model_config,
multiplier 必须传**下单时的快照系数**(request_payload.price_multiplier),不读团队当前值""" *,
tokens: int,
with_video_ref: bool,
resolution: str,
multiplier: Decimal | None = None,
duration: int | None = None,
listed_points_per_second: int | None = None,
) -> Quote:
"""结算视频积分。优先老板挂牌秒价(与预估同口径);未挂牌才按 tokens×成本表。
multiplier / listed_points_per_second 必须用**下单快照**,中途改价不影响在途任务"""
from apps.ai.video_pricing import tokens_to_cost from apps.ai.video_pricing import tokens_to_cost
from apps.billing.points_rules import quote_points_per_second, video_points_per_second
multiplier = multiplier if multiplier is not None else Decimal("1")
pps = listed_points_per_second
if pps is None:
pps = video_points_per_second(model_config, resolution=resolution, with_ref_video=with_video_ref)
if pps is not None and duration is not None and int(duration) > 0:
list_points = quote_points_per_second(points_per_second=int(pps), duration=int(duration))
points = apply_team_price(list_points, multiplier)
return Quote(
points=points,
base_cost_yuan=Decimal("0"),
meta={
"rule": "points_per_second",
"resolution": resolution,
"duration": int(duration),
"with_ref_video": with_video_ref,
"points_per_second": int(pps),
"price_multiplier": str(multiplier),
"rate": str(get_billing_config().points_per_yuan),
},
)
cost_yuan = tokens_to_cost(model_config, tokens, with_video_ref=with_video_ref, resolution=resolution) cost_yuan = tokens_to_cost(model_config, tokens, with_video_ref=with_video_ref, resolution=resolution)
return quote_video_from_cost(cost_yuan, tokens=tokens, multiplier=multiplier) return quote_video_from_cost(cost_yuan, tokens=tokens, multiplier=multiplier)
def video_reserve_amount(points: Decimal) -> Decimal: def video_reserve_amount(points: Decimal, *, rule: str | None = None) -> Decimal:
"""视频预留额 = 预估积分 × buffer(真实 tokens 可能略超预估;ledger 禁超预留扣费)。""" """视频预留额。挂牌秒价=精确积分(无 buffer);token 估价才 × buffer(真实 tokens 可能略超预估)。"""
points = Decimal(str(points)).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
if rule == "points_per_second":
return max(points, Decimal("1")) if points > 0 else Decimal("0")
buffer = get_billing_config().video_reserve_buffer buffer = get_billing_config().video_reserve_buffer
return (points * buffer).quantize(Decimal("1"), rounding=ROUND_HALF_UP) return (points * buffer).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
def video_quote_payload(quote: Quote) -> dict:
"""下单时写入 request_payload 的计价快照,结算只认这些字段。"""
meta = quote.meta or {}
out = {
"pricing_rule": meta.get("rule") or "",
"price_multiplier": meta.get("price_multiplier", "1"),
"points_per_yuan_snapshot": meta.get("rate", ""),
}
if meta.get("rule") == "points_per_second":
out["listed_points_per_second"] = int(meta.get("points_per_second") or 0)
out["with_ref_video"] = bool(meta.get("with_ref_video"))
if meta.get("duration") is not None:
out["billing_duration"] = int(meta["duration"])
return out
def settle_video_from_payload(model_config, *, payload: dict, tokens: int = 0) -> Quote:
"""统一视频结算:挂牌任务按快照秒价×时长扣;token 任务按 usage true-up。
挂牌不依赖 tokens用量缺失也能按后台填的积分扣到位"""
payload = payload or {}
multiplier = Decimal(str(payload.get("price_multiplier") or "1"))
resolution = str(payload.get("resolution") or "720p")
duration = payload.get("billing_duration")
if duration in (None, ""):
duration = payload.get("duration")
try:
duration_i = int(duration or 0) or None
except (TypeError, ValueError):
duration_i = None
with_ref = payload.get("with_ref_video")
if with_ref is None:
with_ref = any((r or {}).get("type") == "video" for r in (payload.get("references") or []))
listed = payload.get("listed_points_per_second")
try:
listed_i = int(listed) if listed not in (None, "") else None
except (TypeError, ValueError):
listed_i = None
rule = str(payload.get("pricing_rule") or "")
# 挂牌快照在:即使 usage.tokens=0 也按秒价结算(与预估同数)
if rule == "points_per_second" or listed_i is not None:
return quote_video_actual(
model_config,
tokens=int(tokens or 0),
with_video_ref=bool(with_ref),
resolution=resolution,
multiplier=multiplier,
duration=duration_i,
listed_points_per_second=listed_i,
)
if int(tokens or 0) <= 0:
# 无 usage 且无挂牌:由调用方回落 estimated_cost
return Quote(points=Decimal("0"), base_cost_yuan=Decimal("0"), meta={"rule": "missing_usage"})
# 无挂牌快照 → 纯 token 结算(即便模型后来配了挂牌也不改在途口径)
from apps.ai.video_pricing import tokens_to_cost
cost_yuan = tokens_to_cost(
model_config, int(tokens), with_video_ref=bool(with_ref), resolution=resolution,
)
return quote_video_from_cost(cost_yuan, tokens=int(tokens), multiplier=multiplier)
+93
View File
@@ -487,3 +487,96 @@ class TeamPriceMultiplierTests(TestCase):
client = APIClient() client = APIClient()
client.force_authenticate(self.user) client.force_authenticate(self.user)
self.assertEqual(client.get("/api/billing/config/").json()["team_price_multiplier"], "0.80") self.assertEqual(client.get("/api/billing/config/").json()["team_price_multiplier"], "0.80")
class ListedPointsPricingTests(TestCase):
"""老板挂牌秒价:预留精确、结算认快照,不跟 tokens/中途改价跑偏。"""
def setUp(self):
from apps.billing.pricing import invalidate_billing_config_cache
invalidate_billing_config_cache()
self.user = User.objects.create_user(username="listed-pts", password="p")
self.team = Team.objects.create(name="listed", owner=self.user, price_multiplier=Decimal("1.00"))
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
CreditAccount.objects.create(team=self.team, balance=Decimal("10000"), reserved_balance=Decimal("0"))
provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "V"})
self.model = ModelConfig.objects.create(
provider=provider,
name="doubao-seedance-listed",
display_name="Listed Mini",
capability=ModelConfig.Capability.VIDEO,
unit_price=Decimal("0"),
status=ModelConfig.Status.ACTIVE,
metadata={
"capabilities": {"resolutions": ["480p", "720p"], "durations": [4, 5]},
"points_pricing": {
"mode": "per_second",
"tiers": [
{"resolution": "720p", "points_per_second": 10, "points_per_second_with_ref": 15},
{"resolution": "480p", "points_per_second": 6},
],
},
},
)
def test_estimate_and_exact_reserve(self):
from apps.billing.pricing import quote_video_estimate, video_reserve_amount
_tokens, quote = quote_video_estimate(
self.model, aspect_ratio="16:9", resolution="720p", duration=5, references=[], team=self.team,
)
self.assertEqual(quote.meta["rule"], "points_per_second")
self.assertEqual(quote.points, Decimal("50")) # 10 * 5
self.assertEqual(video_reserve_amount(quote.points, rule=quote.meta["rule"]), Decimal("50"))
# token 路径仍乘 buffer(默认 1.10)
self.assertEqual(video_reserve_amount(Decimal("50"), rule="video_tokens"), Decimal("55"))
def test_settle_snapshot_ignores_model_price_change_and_tokens(self):
from apps.billing.pricing import quote_video_estimate, settle_video_from_payload, video_quote_payload
_tokens, quote = quote_video_estimate(
self.model,
aspect_ratio="16:9",
resolution="720p",
duration=5,
references=[{"type": "video", "url": "http://x"}],
team=self.team,
)
# 含视频参考 → 15 * 5 = 75
self.assertEqual(quote.points, Decimal("75"))
payload = {
"resolution": "720p",
"duration": 5,
**video_quote_payload(quote),
"references": [{"type": "video"}],
}
# 中途把挂牌改成天价
meta = dict(self.model.metadata)
meta["points_pricing"] = {
"mode": "per_second",
"tiers": [{"resolution": "720p", "points_per_second": 999, "points_per_second_with_ref": 999}],
}
self.model.metadata = meta
self.model.save(update_fields=["metadata"])
settle = settle_video_from_payload(self.model, payload=payload, tokens=9_999_999)
self.assertEqual(settle.points, Decimal("75"))
self.assertEqual(settle.meta["rule"], "points_per_second")
settle0 = settle_video_from_payload(self.model, payload=payload, tokens=0)
self.assertEqual(settle0.points, Decimal("75"))
def test_flat_image_uses_points_per_image(self):
from apps.billing.pricing import quote_flat
provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "V"})
image = ModelConfig.objects.create(
provider=provider,
name="seedream-listed",
capability=ModelConfig.Capability.IMAGE,
unit_price=Decimal("0"),
status=ModelConfig.Status.ACTIVE,
metadata={"points_pricing": {"mode": "per_image", "points_per_image": 33}},
)
quote = quote_flat(image, units=2, team=self.team)
self.assertEqual(quote.points, Decimal("66"))
self.assertEqual(quote.meta.get("rule"), "per_image")
@@ -106,6 +106,15 @@ def adopt_script_version(project: Project, script: ScriptVersion) -> None:
if not script.is_adopted: if not script.is_adopted:
script.is_adopted = True script.is_adopted = True
script.save(update_fields=["is_adopted", "updated_at"]) script.save(update_fields=["is_adopted", "updated_at"])
# 定稿即本地拆角色/场景(不调模型、不扣费),资产页无需再付费「提取」
try:
from apps.ai.entity_local import materialize_script_entities
materialize_script_entities(project=project, script=script)
except Exception: # noqa: BLE001 — 拆实体失败不挡定稿进资产;用户仍可点「提取」重试
import logging
logging.getLogger(__name__).exception("local entity materialize on adopt failed for project %s", project.id)
sync_video_segments_to_script(project, script) sync_video_segments_to_script(project, script)
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT) stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
stage.status = ProjectStage.Status.SUCCEEDED stage.status = ProjectStage.Status.SUCCEEDED
@@ -1362,3 +1362,5 @@ class QuickCreateCoordinatorTests(TestCase):
+41 -93
View File
@@ -198,123 +198,71 @@ class ProjectApiTests(TestCase):
) )
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
@patch("apps.ai.services.VolcanoArkProvider")
def test_extract_entities_streams_and_persists(self, provider_cls):
"""提取走流式通道(豆包 2.0 Pro 思考模型):思考事件丢弃、只收正文 JSON →
落库 cast/scenes + 每镜 entity_refs + entities_extracted 标记,计费一次"""
import json as _json
extracted = {
"entities": [
{"id": "c1", "type": "character", "name": "女主", "visual_prompt": "26岁都市女性,利落短发"},
{"id": "s1", "type": "scene", "name": "出租屋客厅", "visual_prompt": "ins风小客厅,暖白自然光"},
],
"segments": [
{"index": 0, "entity_refs": ["c1", "s1"]},
{"index": 1, "entity_refs": ["c1", "s1"]},
],
}
provider = provider_cls.return_value
provider.chat_completion_stream.return_value = iter([
{"type": "reasoning", "text": "先通读分镜"}, # 思考事件:必须被丢弃,不进正文
{"type": "delta", "text": "```json\n" + _json.dumps(extracted, ensure_ascii=False)},
{"type": "delta", "text": "\n```"},
{"type": "done"},
])
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P") def test_extract_entities_local_from_script_metadata(self):
"""本地提取:读脚本 metadata.entities,不调模型、不扣积分,落 cast/scenes + entities_extracted。"""
from apps.billing.models import CreditLedger
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P-local")
script = ScriptVersion.objects.create( script = ScriptVersion.objects.create(
project=project, title="脚本", content="x", is_adopted=True, metadata={"entities": []}, project=project,
title="脚本",
content="{}",
is_adopted=True,
metadata={
"entities": [
{"id": "c1", "type": "character", "name": "女主", "visual_prompt": "都市女性"},
{"id": "s1", "type": "scene", "name": "出租屋客厅", "visual_prompt": "小客厅"},
]
},
) )
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0") ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0", entity_refs=[])
ScriptSegment.objects.create(script_version=script, sort_order=1, narration="旧1", visual_prompt="画1") ScriptSegment.objects.create(script_version=script, sort_order=1, narration="旧1", visual_prompt="画1", entity_refs=[])
response = self.client.post(f"/api/projects/{project.id}/extract-entities/", {}, format="json") response = self.client.post(f"/api/projects/{project.id}/extract-entities/", {}, format="json")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# 锁定豆包 2.0 Pro,且走的是流式通道(而非非流式 chat_completion) self.assertEqual(response.data["status"], "succeeded")
self.assertEqual(provider.chat_completion_stream.call_args.kwargs["model"], "doubao-seed-2-0-pro-260215") self.assertEqual(response.data["mode"], "local")
provider.chat_completion.assert_not_called()
# 实体落库:cast/scenes + entities_extracted 标记 + 每镜 entity_refs 回填
project.refresh_from_db() project.refresh_from_db()
self.assertIn("女主", project.metadata.get("cast", [])) self.assertIn("女主", project.metadata.get("cast", []))
self.assertIn("出租屋客厅", project.metadata.get("scenes", [])) self.assertIn("出租屋客厅", project.metadata.get("scenes", []))
self.assertTrue(project.metadata.get("entities_extracted")) self.assertTrue(project.metadata.get("entities_extracted"))
self.assertEqual(project.metadata.get("entities_extract_mode"), "local")
segs = list(script.segments.order_by("sort_order")) segs = list(script.segments.order_by("sort_order"))
self.assertEqual(segs[0].entity_refs, ["c1", "s1"]) self.assertEqual(segs[0].entity_refs, ["c1", "s1"])
self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 1) self.assertEqual(CreditLedger.objects.filter(team=self.team).count(), 0)
@patch("apps.ai.services.VolcanoArkProvider") def test_extract_entities_local_fills_defaults_when_missing(self):
def test_extract_entities_recovers_when_content_empty_uses_reasoning(self, provider_cls): """脚本没带 entities 时,本地补最少 1 角色 + 1 场景。"""
"""极端兜底:思考模型整轮只发了 reasoning、正文 content 为空 → 回退用 reasoning 里的 JSON, project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P-default")
不再像旧非流式那样拿到空串报未返回有效 JSON"""
import json as _json
extracted = {
"entities": [{"id": "c1", "type": "character", "name": "男主", "visual_prompt": "28岁居家男性"}],
"segments": [{"index": 0, "entity_refs": ["c1"]}],
}
provider = provider_cls.return_value
provider.chat_completion_stream.return_value = iter([
{"type": "reasoning", "text": _json.dumps(extracted, ensure_ascii=False)},
{"type": "done"},
])
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P2")
script = ScriptVersion.objects.create( script = ScriptVersion.objects.create(
project=project, title="脚本", content="x", is_adopted=True, metadata={"entities": []}, project=project, title="脚本", content="{}", is_adopted=True, metadata={"entities": []},
)
ScriptSegment.objects.create(
script_version=script, sort_order=0, narration="女主介绍产品", visual_prompt="明亮客厅口播",
) )
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0")
response = self.client.post(f"/api/projects/{project.id}/extract-entities/", {}, format="json") response = self.client.post(f"/api/projects/{project.id}/extract-entities/", {}, format="json")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
project.refresh_from_db() project.refresh_from_db()
self.assertIn("男主", project.metadata.get("cast", [])) self.assertTrue(project.metadata.get("cast"))
self.assertTrue(project.metadata.get("scenes"))
self.assertTrue(project.metadata.get("entities_extracted"))
@patch("apps.ai.services.VolcanoArkProvider") def test_extract_status_after_local_success(self):
def test_extract_entities_inflight_guard_reuses_task(self, provider_cls): project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P-status")
"""已有在途提取任务时再次提交 → 复用同一任务,不新建、不二次预扣、不重跑模型
(用户刷新后手痒重点 / 并发点击都不会重复扣费)"""
from apps.ai.services import submit_extract_entities
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P3")
script = ScriptVersion.objects.create( script = ScriptVersion.objects.create(
project=project, title="脚本", content="x", is_adopted=True, metadata={"entities": []}, project=project, title="脚本", content="{}", is_adopted=True,
) metadata={"entities": [{"id": "c1", "type": "character", "name": "男主", "visual_prompt": "x"},
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0") {"id": "s1", "type": "scene", "name": "厨房", "visual_prompt": "y"}]},
inflight = AITask.objects.create(
team=self.team, created_by=self.user, project=project, model_config=self.model,
task_type=AITask.Type.ENTITY_EXTRACTION, status=AITask.Status.SUBMITTED,
idempotency_key="entity_extraction:inflight:1",
)
before = AITask.objects.filter(project=project, task_type=AITask.Type.ENTITY_EXTRACTION).count()
task = submit_extract_entities(project=project, user=self.user)
self.assertEqual(str(task.id), str(inflight.id)) # 复用在途任务
self.assertEqual( # 没新建任务
AITask.objects.filter(project=project, task_type=AITask.Type.ENTITY_EXTRACTION).count(), before
)
provider_cls.return_value.chat_completion_stream.assert_not_called() # 没重跑模型
# 没二次预扣额度(复用直接 return,根本没走到 create_ai_task)
self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.RESERVE).count(), 0)
def test_extract_status_reports_running_then_safe_failure(self):
"""extract-status:在途→running=True;失败→安全错误对象,原始任务错误不透给前端。"""
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P4")
t = AITask.objects.create(
team=self.team, created_by=self.user, project=project, model_config=self.model,
task_type=AITask.Type.ENTITY_EXTRACTION, status=AITask.Status.SUBMITTED,
idempotency_key="entity_extraction:status:1",
) )
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="a", visual_prompt="b")
self.client.post(f"/api/projects/{project.id}/extract-entities/", {}, format="json")
res = self.client.get(f"/api/projects/{project.id}/extract-status/") res = self.client.get(f"/api/projects/{project.id}/extract-status/")
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertTrue(res.data["running"])
t.status = AITask.Status.FAILED
t.error_message = "提取结果解析失败(模型未返回有效 JSON),请重试"
t.save(update_fields=["status", "error_message"])
res = self.client.get(f"/api/projects/{project.id}/extract-status/")
self.assertFalse(res.data["running"]) self.assertFalse(res.data["running"])
self.assertEqual(res.data["status"], "failed") self.assertEqual(res.data["status"], "succeeded")
self.assertEqual(res.data["error"]["code"], "unknown") self.assertTrue(any(e.get("name") == "男主" for e in res.data["entities"]))
self.assertNotIn("有效 JSON", res.data["error_message"])
@patch("apps.ai.services._store_generated_media") @patch("apps.ai.services._store_generated_media")
@patch("apps.ai.services.get_image_provider") @patch("apps.ai.services.get_image_provider")
+13 -14
View File
@@ -928,29 +928,28 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
@action(detail=True, methods=["post"], url_path="extract-entities") @action(detail=True, methods=["post"], url_path="extract-entities")
def extract_entities_action(self, request, pk=None): def extract_entities_action(self, request, pk=None):
"""从已定稿脚本提取角色 / 场景实体(独立步,进资产阶段时调)——**异步**:豆包思考模型流式抽取较慢, """从已定稿脚本**本地**拆出角色 / 场景(不调模型、不扣积分)。
Web 层只建任务秒回(不再 502),慢活交 worker(run_extract_entities_task)前端拿 task_id 轮询 读脚本自带 entities,缺则按分镜补最少 1 角色 + 1 场景;商品不提"""
extract-status 取结果已有在途提取则复用(防刷新后重点重复扣费)落库覆盖 project.metadata +
每镜 entity_refs;**商品不提**(参考图层用真实主图)"""
require_worker() # 异步提取依赖 worker 兜底执行,没 worker 直接拒绝(否则任务永远 RESERVED)
project = self.get_object() project = self.get_object()
try: try:
task = submit_extract_entities(project=project, user=request.user) task = submit_extract_entities(project=project, user=request.user)
except ValueError as exc: # 无脚本 / 无分镜 / 无模型 / 额度不足,立即反馈 except ValueError as exc:
message = str(exc)
internal_kind = (
"user_credit_insufficient" if "额度不足" in message
else "model_unavailable" if "模型" in message
else "invalid_input"
)
public_error = classify_generation_error( public_error = classify_generation_error(
exc, operation="entity_extract", internal_kind=internal_kind exc, operation="entity_extract", internal_kind="invalid_input"
) )
return Response( return Response(
{"detail": public_error.fallback_message, "error": public_error.as_dict()}, {"detail": public_error.fallback_message, "error": public_error.as_dict()},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
return Response({"task_id": str(task.id), "status": task.status}) project.refresh_from_db()
entities = (task.response_payload or {}).get("entities") or (project.metadata or {}).get("script_entities") or []
return Response({
"task_id": str(task.id),
"status": task.status,
"mode": "local",
"entities": entities,
})
@action(detail=True, methods=["get"], url_path="extract-status") @action(detail=True, methods=["get"], url_path="extract-status")
def extract_status_action(self, request, pk=None): def extract_status_action(self, request, pk=None):
+33 -9
View File
@@ -28,7 +28,6 @@ import {
AccountPage, AccountPage,
AssetFactoryPage, AssetFactoryPage,
AuthScreen, AuthScreen,
Dashboard,
FreeCreatePage, FreeCreatePage,
QuickCreatePage, QuickCreatePage,
OmniCreatePage, OmniCreatePage,
@@ -122,6 +121,12 @@ export function App() {
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [projectTotal, setProjectTotal] = useState(0); const [projectTotal, setProjectTotal] = useState(0);
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([]); 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 [billing, setBilling] = useState<BillingSummary | null>(null);
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
// YYX#row22:未读生成任务 —— 导航「图片生成」总数 + 每个商品的未读分数(商品角标) // YYX#row22:未读生成任务 —— 导航「图片生成」总数 + 每个商品的未读分数(商品角标)
@@ -344,23 +349,39 @@ export function App() {
if (user.is_platform_admin && !team && route.admin === undefined) { if (user.is_platform_admin && !team && route.admin === undefined) {
navigateAdmin("", { replace: true }); navigateAdmin("", { replace: true });
} else if (!user.is_platform_admin && route.admin !== undefined) { } else if (!user.is_platform_admin && route.admin !== undefined) {
navigate("dashboard", { replace: true }); navigate("omniCreate", { replace: true });
} }
// navigate/navigateAdmin 为组件内函数,故意不入依赖避免每次渲染重跑 // navigate/navigateAdmin 为组件内函数,故意不入依赖避免每次渲染重跑
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [booting, user, team, route.admin]); }, [booting, user, team, route.admin]);
// 子账号(非主账号)纠回:团队 / 消费 页仅主账号(owner=超管)可见,子账号直接拉回工作台(PMC#3)。 // 子账号(非主账号)纠回:团队 / 消费 页仅主账号(owner=超管)可见,子账号直接拉回全能创作(PMC#3)。
// role 为空时(身份尚未带回角色)不拦,避免误纠超管。 // role 为空时(身份尚未带回角色)不拦,避免误纠超管。
useLayoutEffect(() => { useLayoutEffect(() => {
if (booting || !user || !role) return; if (booting || !user || !role) return;
if (!isOwner && isOwnerOnlyPage(page)) { if (!isOwner && isOwnerOnlyPage(page)) {
navigate("dashboard", { replace: true }); navigate("omniCreate", { replace: true });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [booting, user, role, isOwner, page]); }, [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. // 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(() => { useEffect(() => {
if (!authed || (page !== "settings" && page !== "settingsNotify")) return; if (!authed || (page !== "settings" && page !== "settingsNotify")) return;
loadSettingsData(); loadSettingsData();
@@ -856,7 +877,7 @@ export function App() {
navigateAdmin("", { replace: true }); navigateAdmin("", { replace: true });
return; return;
} }
navigate("dashboard", { replace: true }); navigate("omniCreate", { replace: true });
// 先把工作台必需数据拉好,这期间「登录页」保持「登录成功,正在进入工作台…」提示(authed 仍 false → AuthScreen 不卸载), // 先把工作台必需数据拉好,这期间「登录页」保持「登录成功,正在进入工作台…」提示(authed 仍 false → AuthScreen 不卸载),
// 数据就绪后才揭开外壳直接进有数据的工作台 —— 避免中间闪一个空白「加载中…」页(PMC#7)。 // 数据就绪后才揭开外壳直接进有数据的工作台 —— 避免中间闪一个空白「加载中…」页(PMC#7)。
try { try {
@@ -923,9 +944,10 @@ export function App() {
const currentTeam: Team = team; const currentTeam: Team = team;
function renderPage() { function renderPage() {
switch (page) { switch (page) {
case "dashboard": 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": case "products":
return ( return (
<ProductsPage <ProductsPage
@@ -1079,7 +1101,7 @@ export function App() {
/> />
); );
case "omniCreate": 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": case "omniHistory":
return <OmniHistoryPage navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} />; return <OmniHistoryPage navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} />;
case "omniSession": case "omniSession":
@@ -1089,6 +1111,7 @@ export function App() {
firstMessage={route.firstMessage} firstMessage={route.firstMessage}
firstRefs={route.firstRefs} firstRefs={route.firstRefs}
firstUploads={route.firstUploads} firstUploads={route.firstUploads}
modelConfigs={modelConfigs}
navigate={navigate} navigate={navigate}
onNotify={(type, text) => setNotice({ type, text })} onNotify={(type, text) => setNotice({ type, text })}
/> />
@@ -1140,7 +1163,7 @@ export function App() {
case "settingsNotify": 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} />; 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: 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")} scriptModelName={publicModelDisplayName(pipelineTextModel, "AI")}
textModels={modelConfigs.filter((m) => m.capability === "text" && m.status === "active")} textModels={modelConfigs.filter((m) => m.capability === "text" && m.status === "active")}
videoModels={modelConfigs.filter((m) => m.capability === "video" && 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} loading={loading}
navigate={navigate} navigate={navigate}
onBack={() => goBack("projects")} onBack={() => goBack("projects")}
+49 -15
View File
@@ -1760,28 +1760,41 @@
.image-workbench .ic-param.open .ic-param-btn svg { transform: rotate(180deg); } .image-workbench .ic-param.open .ic-param-btn svg { transform: rotate(180deg); }
.image-workbench .ic-param-menu { .image-workbench .ic-param-menu {
position: absolute; bottom: calc(100% + 6px); left: -2px; 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); background: var(--surface);
border: 1px solid var(--border-faint); border: 1px solid rgba(34, 42, 54, 0.10);
border-radius: var(--r-md); border-radius: 10px;
box-shadow: 0 6px 24px rgba(0, 0, 0, .08); box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12);
padding: 4px; padding: 6px;
display: none; 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 { .image-workbench .ic-param-menu .mi {
width: 100%; width: 100%;
min-height: 36px;
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 7px 10px; padding: 7px 10px;
border: 0; border-radius: var(--r-sm); border: 0; border-radius: 8px;
background: transparent; background: transparent;
font-size: 13px; color: var(--accent-black); font-size: 13px; color: var(--accent-black);
font-family: inherit; text-align: left; cursor: pointer; 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; } .image-workbench .ic-param-menu .mi.selected .mi-check { visibility: visible; }
/* ============================================================ /* ============================================================
@@ -3040,7 +3053,9 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 14px; padding: 14px;
min-width: 0;
min-height: 0; min-height: 0;
overflow: hidden;
} }
.yz-image .new-conversation { .yz-image .new-conversation {
height: 46px; height: 46px;
@@ -3070,24 +3085,38 @@
gap: 4px; gap: 4px;
} }
.yz-image .history-item { .yz-image .history-item {
min-width: 0;
width: 100%;
box-sizing: border-box;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 10px; padding: 10px 12px;
border-radius: 10px; border-radius: 10px;
color: var(--text); color: var(--text);
cursor: pointer; 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.active { background: rgba(0, 47, 167, 0.075); }
.yz-image .history-item .nm { .yz-image .history-item .nm {
min-width: 0; min-width: 0;
flex: 1; flex: 1 1 auto;
overflow: hidden; overflow: hidden;
font-size: 13px; font-size: 13px;
line-height: 1.35;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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 { .yz-image .history-empty {
margin: 6px 4px; margin: 6px 4px;
color: var(--muted); color: var(--muted);
@@ -3419,7 +3448,12 @@
color: var(--klein); color: var(--klein);
background: rgba(0, 47, 167, 0.075); 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 { .yz-image .image-reference-thumbs button {
position: absolute; position: absolute;
top: 0; top: 0;
+4 -3
View File
@@ -669,7 +669,7 @@ export const api = {
// 独立实体提取(**异步**):只提交任务、秒回 task_id(慢活在 worker 跑,不再 502);已有在途则复用(防重复扣费)。 // 独立实体提取(**异步**):只提交任务、秒回 task_id(慢活在 worker 跑,不再 502);已有在途则复用(防重复扣费)。
// 进度/结果用 extractStatus 轮询。商品不提(参考图层用真实主图)。 // 进度/结果用 extractStatus 轮询。商品不提(参考图层用真实主图)。
extractEntities(projectId: string) { 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 供「提取+生成」继续出图。 // 实体提取进度:running=有在途任务(刷新后据此重建 loading);否则读最近一次成败,成功回带 entities 供「提取+生成」继续出图。
extractStatus(projectId: string) { extractStatus(projectId: string) {
@@ -947,7 +947,8 @@ export const api = {
return request<BillingTrend>(`/api/billing/trend/${range ? `?range=${range}` : ""}`); return request<BillingTrend>(`/api/billing/trend/${range ? `?range=${range}` : ""}`);
}, },
modelConfigs() { modelConfigs() {
return request<Paginated<ModelConfig>>("/api/ai/models/"); // 创作页下拉依赖完整 active 目录;加大 page_size 避免默认分页截断
return request<Paginated<ModelConfig>>("/api/ai/models/?page_size=200");
}, },
aiTasks() { aiTasks() {
// 生图工作室的任务中心:只看生图任务(模特上身图/平台套图/图片创作 = person_image / // 生图工作室的任务中心:只看生图任务(模特上身图/平台套图/图片创作 = person_image /
@@ -1335,7 +1336,7 @@ export const adminApi = {
const q = qs.toString(); const q = qs.toString();
return request<AdminModel[]>(`/api/admin/models/${q ? `?${q}` : ""}`); 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) }); return request<AdminModel>("/api/admin/models/", { method: "POST", body: JSON.stringify(payload) });
}, },
updateModel(id: string, payload: Record<string, unknown>) { updateModel(id: string, payload: Record<string, unknown>) {
+4 -8
View File
@@ -11,7 +11,6 @@ const SIDEBAR_COLLAPSED_KEY = "airshelf:sidebar-collapsed";
// 全局命令面板(Ctrl K / 点搜索框)—— 忠实搬设计稿 SHELL_COMMANDS,href 改成真路由导航 // 全局命令面板(Ctrl K / 点搜索框)—— 忠实搬设计稿 SHELL_COMMANDS,href 改成真路由导航
type Command = { id: string; group: string; label: string; sub: string; page: Page; icon: string; key?: string }; type Command = { id: string; group: string; label: string; sub: string; page: Page; icon: string; key?: string };
const SHELL_COMMANDS: Command[] = [ 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-create", group: "导航", label: "全能创作", sub: "预设工作流 + 对话式 Agent", page: "omniCreate", icon: "sparkles", key: "O" },
{ id: "omni-history", group: "导航", label: "创作历史", sub: "查看与继续独立会话", page: "omniHistory", icon: "history", key: "H" }, { id: "omni-history", group: "导航", label: "创作历史", sub: "查看与继续独立会话", page: "omniHistory", icon: "history", key: "H" },
{ id: "products", group: "导航", label: "商品库", sub: "管理 SKU、商品图册、卖点信息", page: "products", icon: "package", key: "P" }, { 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 }; type NavDef = { id: string; page: Page; label: string; icon: string; badge?: number };
const NAV: NavDef[] = [ const NAV: NavDef[] = [
{ id: "dashboard", page: "dashboard", label: "工作台", icon: "dashboard" },
{ id: "products", page: "products", label: "商品库", icon: "package" }, { id: "products", page: "products", label: "商品库", icon: "package" },
{ id: "models", page: "models", label: "模特库", icon: "model" }, { id: "models", page: "models", label: "模特库", icon: "model" },
{ id: "projects", page: "projects", label: "视频创作", icon: "clapperboard" }, { 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 }[] = [ const MODE_TABS: { id: TopModule; label: string; page: Page }[] = [
{ id: "workbench", label: "工作台", page: "dashboard" },
{ id: "omni", label: "全能创作", page: "omniCreate" }, { id: "omni", label: "全能创作", page: "omniCreate" },
{ id: "image", label: "图片创作", page: "assetFactory" }, { id: "image", label: "图片创作", page: "assetFactory" },
{ id: "video", label: "视频创作", page: "projects" }, { 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"> <svg className="mode-clip-defs" width="0" height="0" aria-hidden="true" focusable="false">
<defs> <defs>
<clipPath id="modeButtonMask" clipPathUnits="userSpaceOnUse"> <clipPath id="modeButtonMask" clipPathUnits="userSpaceOnUse">
<rect rx="22" ry="22" x="0" y="0" width="104" height="44" /> <rect rx="22" ry="22" x="0" y="0" width="110" height="44" />
<rect rx="22" ry="22" x="120" 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="246" y="0" width="110" height="44" /> <rect rx="22" ry="22" x="252" y="0" width="110" height="44" />
<rect rx="22" ry="22" x="372" y="0" width="110" height="44" />
</clipPath> </clipPath>
</defs> </defs>
</svg> </svg>
@@ -567,7 +563,7 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
{mobileNavOpen && <div className="mobile-nav-backdrop" onClick={() => setMobileNavOpen(false)} />} {mobileNavOpen && <div className="mobile-nav-backdrop" onClick={() => setMobileNavOpen(false)} />}
<aside className={`sidebar${mobileNavOpen ? " mobile-open" : ""}`}> <aside className={`sidebar${mobileNavOpen ? " mobile-open" : ""}`}>
<div className="sidebar-head"> <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> <span className="brand-clip"><img className="brand-logo" src="/assets/yz/logo-horizontal.png" alt="影擎" /></span>
</a> </a>
</div> </div>
@@ -47,6 +47,37 @@ export function modelDurations(config: ModelConfig | undefined): number[] {
return list?.length ? list : [...FC_DURATIONS]; 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: "首尾帧" }; export const MODE_LABELS: Record<FreeMode, string> = { universal: "全能参考", keyframe: "首尾帧" };
// 上传素材限制(与后端 FreeVideoUploadView / jimeng inputBar 对齐) // 上传素材限制(与后端 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 type BillingRates = { margin: number; rate: number; multiplier: number };
export const DEFAULT_BILLING_RATES: BillingRates = { margin: 1.5, rate: 10, multiplier: 1 }; 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( export function estimateCost(
config: ModelConfig | undefined, config: ModelConfig | undefined,
params: { ratio: string; resolution: string; duration: number; refs: { type: string; duration?: number }[] }, params: { ratio: string; resolution: string; duration: number; refs: { type: string; duration?: number }[] },
billing: BillingRates = DEFAULT_BILLING_RATES billing: BillingRates = DEFAULT_BILLING_RATES
): { tokens: number; points: number } { ): { tokens: number; points: number; listed: boolean } {
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);
const hasVideoRef = params.refs.some((r) => r.type === "video"); const hasVideoRef = params.refs.some((r) => r.type === "video");
const price = tokenPrice(config, params.resolution, hasVideoRef); const pps = pointsPerSecondFromCatalog(config, params.resolution, hasVideoRef);
const costYuan = Math.round(((tokens * price) / 1e6) * 100) / 100; if (pps != null && params.duration > 0) {
if (costYuan <= 0) return { tokens, points: 0 }; const listPoints = Math.max(1, Math.round(pps * params.duration));
// 浮点乘积在 .5 边界可能落成 27.499999…,先吸附 6 位小数再 round, const multiplier = billing.multiplier || 1;
// 与后端 Decimal ROUND_HALF_UP 逐字对齐。只吸浮点噪声(1e-9 级), const points = multiplier === 1 ? listPoints : Math.max(1, Math.round(Number((listPoints * multiplier).toFixed(6))));
// 不能用 toFixed(2):非默认 margin 下 x.495 会被先四舍五入成 x.50 多显 1 积分(review 确认) return { tokens: 0, points, listed: true };
const raw = Number((costYuan * billing.margin * billing.rate).toFixed(6)); }
const listPoints = Math.max(1, Math.round(raw)); return { tokens: 0, points: 0, listed: false };
// 团队价格系数(差异化调价):两步取整与后端 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 };
} }
export function modelLabel(name: string): string { 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; mode: FreeMode;
model: string; model: string;
ratio: string; ratio: string;
@@ -82,7 +82,6 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
onSeedChange: (seed: number) => void; onSeedChange: (seed: number) => void;
onOpenLibrary: (role?: "first_frame" | "last_frame") => void; onOpenLibrary: (role?: "first_frame" | "last_frame") => void;
onOpenPlatformLibrary: (role?: "first_frame" | "last_frame") => void; onOpenPlatformLibrary: (role?: "first_frame" | "last_frame") => void;
onClear: () => void;
onSend: () => void; onSend: () => void;
}) { }) {
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
@@ -175,7 +174,6 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
onResolutionChange={onResolutionChange} onResolutionChange={onResolutionChange}
onDurationChange={onDurationChange} onDurationChange={onDurationChange}
onSeedChange={onSeedChange} onSeedChange={onSeedChange}
onClear={onClear}
onSend={onSend} onSend={onSend}
/> />
{dragOver && <div className="fc-drop-hint">{MODE_LABELS[mode]}</div>} {dragOver && <div className="fc-drop-hint">{MODE_LABELS[mode]}</div>}
@@ -1,15 +1,14 @@
// 自由创作·输入条工具栏:模型/模式/比例/分辨率/时长/种子 下拉 + 预估消耗 + 清空 + 生成。 // 自由创作·输入条工具栏:模型/模式/比例/分辨率/时长/种子 下拉 + 预估积分 + 生成。
// 模型下拉列后端返回的全部视频模型(FC_MODELS 只提供好看的标签)。 // 模型下拉列后端返回的全部视频模型(FC_MODELS 只提供好看的标签)。
// 约束联动(与后端校验一致):分辨率与时长档位取自所选模型的 metadata.capabilities, // 约束联动(与后端校验一致):分辨率与时长档位取自所选模型的 metadata.capabilities,
// 换档时把超出新模型能力的选择夹回去。 // 换档时把超出新模型能力的选择夹回去。
import { useEffect, useRef, useState } from "react"; 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 type { ModelConfig } from "../../types";
import { import {
DEFAULT_BILLING_RATES, DEFAULT_BILLING_RATES,
FC_MODELS, FC_MODELS,
FC_RATIOS, FC_RATIOS,
FC_RESOLUTIONS,
MODE_LABELS, MODE_LABELS,
estimateCost, estimateCost,
modelDurations, 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; mode: FreeMode;
model: string; model: string;
ratio: string; ratio: string;
@@ -95,14 +94,13 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
onResolutionChange: (resolution: string) => void; onResolutionChange: (resolution: string) => void;
onDurationChange: (duration: number) => void; onDurationChange: (duration: number) => void;
onSeedChange: (seed: number) => void; onSeedChange: (seed: number) => void;
onClear: () => void;
onSend: () => void; onSend: () => void;
}) { }) {
const config = videoConfigs.find((c) => c.name === model); const config = videoConfigs.find((c) => c.name === model);
// 可选的分辨率/时长按所选模型的能力来,不再写死 —— Seedance 2.5 能出 30 秒,2.0 只有 15。 // 可选的分辨率/时长按所选模型的能力来,不再写死 —— Seedance 2.5 能出 30 秒,2.0 只有 15。
const allowedResolutions = modelResolutions(config); const allowedResolutions = modelResolutions(config);
const allowedDurations = modelDurations(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 [seedOpen, setSeedOpen] = useState(false);
const seedRef = useRef<HTMLDivElement>(null); const seedRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -152,11 +150,9 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
<FcDropdown <FcDropdown
label="分辨率" label="分辨率"
display={resolution.toUpperCase()} display={resolution.toUpperCase()}
items={FC_RESOLUTIONS.map((r) => ({ items={allowedResolutions.map((r) => ({
value: r, value: r,
label: r.toUpperCase(), label: r.toUpperCase(),
disabled: !allowedResolutions.includes(r),
hint: "当前模型不支持这一档"
}))} }))}
onSelect={onResolutionChange} onSelect={onResolutionChange}
/> />
@@ -191,10 +187,12 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
</div> </div>
</div> </div>
<div className="fc-toolbar-r"> <div className="fc-toolbar-r">
<span className="fc-estimate" title="预估消耗(实际按真实用量结算,多退少不补超)"> {points > 0 ? (
{tokens.toLocaleString()} tokens · {points.toLocaleString()} <span className="fc-estimate" title="预估积分(实际按真实用量结算)">
</span> <Coins aria-hidden="true" />
<button type="button" className="fc-clear" onClick={onClear}></button> {points.toLocaleString()}
</span>
) : null}
<button type="button" className="fc-gen" disabled={!canSend} onClick={onSend} title="Ctrl/Cmd + Enter"> <button type="button" className="fc-gen" disabled={!canSend} onClick={onSend} title="Ctrl/Cmd + Enter">
{submitting ? "提交中…" : uploading ? "素材上传中…" : "生成"} {submitting ? "提交中…" : uploading ? "素材上传中…" : "生成"}
<ArrowRight /> <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 { ChevronDown, SlidersHorizontal, Sparkles } from "lucide-react";
import { CustomSelect } from "./custom-select"; 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_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_IMAGE_MODELS = ["Seedream5.0", "YQ image2"];
export const OMNI_RESOLUTIONS = ["1080p", "720p", "480p"]; export const OMNI_RESOLUTIONS = ["1080p", "720p", "480p"];
export const OMNI_RATIOS = ["9:16", "16:9", "1:1", "4:3", "3:4"]; export const OMNI_RATIOS = ["9:16", "16:9", "1:1", "4:3", "3:4"];
export const OMNI_VIDEO_DURATIONS = [ 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 秒", "11 秒", "12 秒", "13 秒", "14 秒", "15 秒", "30 秒",
]; ];
export const OMNI_IMAGE_COUNTS = ["1 张", "2 张", "4 张", "8 张"]; 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; 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({ export function OmniParamBar({
isVideo, isVideo,
disabled, disabled,
@@ -27,6 +83,7 @@ export function OmniParamBar({
resolution, resolution,
ratio, ratio,
duration, duration,
catalogModels,
onModel, onModel,
onResolution, onResolution,
onRatio, onRatio,
@@ -38,6 +95,7 @@ export function OmniParamBar({
resolution: string; resolution: string;
ratio: string; ratio: string;
duration: string; duration: string;
catalogModels?: ModelConfig[];
onModel: (value: string) => void; onModel: (value: string) => void;
onResolution: (value: string) => void; onResolution: (value: string) => void;
onRatio: (value: string) => void; onRatio: (value: string) => void;
@@ -47,6 +105,20 @@ export function OmniParamBar({
const [customOn, setCustomOn] = useState(isVideo ? duration !== "智能时长" : true); const [customOn, setCustomOn] = useState(isVideo ? duration !== "智能时长" : true);
const wrapRef = useRef<HTMLDivElement>(null); 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(() => { useEffect(() => {
setCustomOn(isVideo ? duration !== "智能时长" : true); setCustomOn(isVideo ? duration !== "智能时长" : true);
}, [isVideo, duration]); }, [isVideo, duration]);
@@ -60,8 +132,18 @@ export function OmniParamBar({
return () => document.removeEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown);
}, [menuOpen]); }, [menuOpen]);
const models = withCurrent(isVideo ? OMNI_VIDEO_MODELS : OMNI_IMAGE_MODELS, model); useEffect(() => {
const durations = isVideo ? OMNI_VIDEO_DURATIONS : OMNI_IMAGE_COUNTS; 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 ( return (
<> <>
@@ -84,7 +166,7 @@ export function OmniParamBar({
disabled={disabled} disabled={disabled}
value={resolution} value={resolution}
onChange={onResolution} onChange={onResolution}
options={toOptions(withCurrent(OMNI_RESOLUTIONS, resolution))} options={toOptions(resolutions)}
/> />
</label> </label>
<label className="omni-parameter"> <label className="omni-parameter">
@@ -136,7 +218,7 @@ export function OmniParamBar({
</div> </div>
) : null} ) : null}
<div className="omni-duration-values" hidden={isVideo && !customOn}> <div className="omni-duration-values" hidden={isVideo && !customOn}>
{durations.map((value) => ( {(isVideo ? durations.filter((value) => value !== "智能时长") : durations).map((value) => (
<button <button
type="button" type="button"
key={value} key={value}
+2 -1
View File
@@ -1650,7 +1650,8 @@ select.duration-select:focus,
max-width: none; max-width: none;
display: grid; display: grid;
gap: 3px; gap: 3px;
z-index: calc(var(--z-overlay) + 40); /* 必须高于 .modal-bg(999) / .admin-drawer(1800),否则弹窗里点开下拉看不见、点不着 */
z-index: 1900;
} }
.rs-select-group { .rs-select-group {
padding: 8px 10px 4px; padding: 8px 10px 4px;
+9 -11
View File
@@ -646,23 +646,21 @@
flex-shrink: 0; flex-shrink: 0;
} }
.fc-page .fc-estimate { .fc-page .fc-estimate {
font-size: 11px; display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--fc-muted); color: var(--fc-muted);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: nowrap; white-space: nowrap;
margin-right: 2px;
} }
.fc-page .fc-clear { .fc-page .fc-estimate svg {
height: 32px; width: 14px;
padding: 0 10px; height: 14px;
border: 0; flex-shrink: 0;
border-radius: 8px;
color: var(--fc-muted); 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 { .fc-page .fc-gen {
min-width: 100px; min-width: 100px;
height: 42px; height: 42px;
+4 -3
View File
@@ -1345,7 +1345,8 @@
gap: 14px; gap: 14px;
} }
.omni-confirm-card button { /* 只染脚上的确认按钮。参数条里的 CustomSelect / 时长触发器不能吃到克莱因蓝。 */
.omni-confirm-foot > button {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
@@ -1358,12 +1359,12 @@
cursor: pointer; cursor: pointer;
} }
.omni-confirm-card button:disabled { .omni-confirm-foot > button:disabled {
background: rgba(34, 42, 54, .18); background: rgba(34, 42, 54, .18);
cursor: not-allowed; cursor: not-allowed;
} }
.omni-confirm-card button i { .omni-confirm-foot > button i {
color: rgba(255, 255, 255, .72); color: rgba(255, 255, 255, .72);
font-size: 12px; font-size: 12px;
font-style: normal; font-style: normal;
+2 -1
View File
@@ -1018,7 +1018,7 @@
.as-action-bar { .as-action-bar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-end;
gap: 18px; gap: 18px;
padding: 0 20px; padding: 0 20px;
border-top: 1px solid rgba(34, 42, 54, 0.09); 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 { 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 .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-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 · 添加分镜:本地草稿可编辑卡片 ── */ /* ── 行34 · 添加分镜:本地草稿可编辑卡片 ── */
.draft-shot-card { border-style: dashed; } .draft-shot-card { border-style: dashed; }
+212 -14
View File
@@ -7,6 +7,18 @@ import { CustomSelect } from "../../components/custom-select";
import type { AdminModel, AdminProvider } from "../../types"; import type { AdminModel, AdminProvider } from "../../types";
import { pts } from "../stage-config"; 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; type Notify = (type: "success" | "error" | "info", text: string) => void;
const PAGE_SIZE = 10; 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_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 }) { export function AdminModelsPage({ notify }: { notify: Notify }) {
const [providers, setProviders] = useState<AdminProvider[]>([]); const [providers, setProviders] = useState<AdminProvider[]>([]);
@@ -28,7 +133,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
const [modelPage, setModelPage] = useState(1); const [modelPage, setModelPage] = useState(1);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [provModal, setProvModal] = useState<typeof EMPTY_PROVIDER | null>(null); 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 [saving, setSaving] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -36,7 +141,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
try { try {
const [ps, ms] = await Promise.all([adminApi.providers(), adminApi.models()]); const [ps, ms] = await Promise.all([adminApi.providers(), adminApi.models()]);
setProviders(ps); setProviders(ps);
setModels(ms); setModels(sortAdminModels(ms));
} catch { } catch {
notify("error", "加载模型供应商失败"); notify("error", "加载模型供应商失败");
} finally { } finally {
@@ -101,12 +206,32 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
async function saveModel() { async function saveModel() {
if (!modelModal || saving) return; if (!modelModal || saving) return;
if (!modelModal.provider || !modelModal.name.trim()) { notify("error", "请选择供应商并填写模型名"); 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); setSaving(true);
try { try {
const metadata = buildMetadata(modelModal);
if (modelModal.id) { 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 { } 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", "模型已保存"); notify("success", "模型已保存");
setModelModal(null); 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>{m.is_default ? <span className="pill info"><span className="dot" /></span> : <span className="muted"></span>}</td>
<td className="col-actions"> <td className="col-actions">
{!m.is_default && <button className="btn btn-sm btn-ghost" type="button" onClick={() => setDefault(m)}></button>} {!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${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> <button className="btn btn-sm btn-ghost danger" type="button" onClick={() => delModel(m)}></button>
</td> </td>
@@ -184,7 +309,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
)} )}
{provModal && ( {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="供应商"> <div className="modal" role="dialog" aria-modal="true" aria-label="供应商">
<span className="corner-tr">+</span><span className="corner-bl">+</span> <span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h"> <div className="modal-h">
@@ -219,7 +344,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
)} )}
{modelModal && ( {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="模型"> <div className="modal" role="dialog" aria-modal="true" aria-label="模型">
<span className="corner-tr">+</span><span className="corner-bl">+</span> <span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h"> <div className="modal-h">
@@ -259,16 +384,89 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
<label className="field-label"></label> <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 }))} /> <input className="input" type="text" value={modelModal.display_name} onChange={(e) => setModelModal((m) => m && ({ ...m, display_name: e.target.value }))} />
</div> </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"> <div className="field">
<label className="field-label">(/)</label> <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 }))} /> <input className="input" type="text" placeholder="0" value={modelModal.unit_price} onChange={(e) => setModelModal((m) => m && ({ ...m, unit_price: e.target.value }))} />
</div> </div>
<div className="field"> ) : null}
<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>
</div> </div>
<div className="modal-f"> <div className="modal-f">
<button className="btn" type="button" onClick={() => setModelModal(null)}></button> <button className="btn" type="button" onClick={() => setModelModal(null)}></button>
+17 -2
View File
@@ -14,6 +14,7 @@ const PAGE_SIZE = 10;
const TABS = [ const TABS = [
{ key: "", label: "全部" }, { key: "", label: "全部" },
{ key: "generating", label: "生成中" },
{ key: "failed", label: "失败" }, { key: "failed", label: "失败" },
{ key: "succeeded", 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())}`; 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) { function statusPill(status: string) {
if (status === "succeeded") return <span className="pill ok"><span className="dot" /></span>; 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 (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>; if (["cancelled", "compensating"].includes(status)) {
return <span className="pill info"><span className="dot" /></span>; 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]) { function attemptKind(attempt: AdminTaskDetail["attempts"][number]) {
+5 -4
View File
@@ -28,6 +28,7 @@ import {
X X
} from "lucide-react"; } from "lucide-react";
import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types"; import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types";
import { pointsPerImageFromCatalog } from "../components/free-create/constants";
import { api } from "../api"; import { api } from "../api";
import { useFileDrop } from "../components/use-file-drop"; import { useFileDrop } from "../components/use-file-drop";
import { imageModelPickerOptions } from "../model-display"; import { imageModelPickerOptions } from "../model-display";
@@ -727,13 +728,13 @@ export function ImageWorkbenchPage({
useEffect(() => { useEffect(() => {
void api.billingConfig().then((cfg) => setPriceMultiplier(Number(cfg.team_price_multiplier) || 1)).catch(() => undefined); void api.billingConfig().then((cfg) => setPriceMultiplier(Number(cfg.team_price_multiplier) || 1)).catch(() => undefined);
}, []); }, []);
// 每张图实扣单价:取「当前选的生图模型」的 unit_price(火山/gpt-image),没匹配上用第一个图像模型, // 每张图实扣单价:优先后台挂牌 points_pricing.points_per_image,否则 unit_price,再兜底 20。
// 再兜底 20 积分(后端 quote_flat=unit_price 积分/张,默认模型 gpt-image-2=20 积分)。前端预估据此算,和后端实扣一致(PMC#20)。 // 后端 quote_flat / image_points_per_unit 同口径(PMC#20)。
const perImagePrice = (() => { const perImagePrice = (() => {
const want = genModel === "gpt-image" ? "gpt-image" : genModel === "volcano" ? "seedream" : genModel; const want = genModel === "gpt-image" ? "gpt-image" : genModel === "volcano" ? "seedream" : genModel;
const m = imageModels.find((x) => x.name.toLowerCase().includes(want)) || imageModels[0]; const m = imageModels.find((x) => x.name.toLowerCase().includes(want)) || imageModels[0];
const p = Number(m?.unit_price); const listed = pointsPerImageFromCatalog(m);
return Number.isFinite(p) && p > 0 ? p : 20; return listed != null && listed > 0 ? listed : 20;
})(); })();
// 与后端逐张对齐:每张 = 挂牌单价取整 × 系数 → HALF_UP 最低 1,总价 = 张数 × 单张 // 与后端逐张对齐:每张 = 挂牌单价取整 × 系数 → HALF_UP 最低 1,总价 = 张数 × 单张
// (不能先乘张数再取整:0.85 系数 × 3 张会比后端逐张各取整少 1-2 积分,review 确认) // (不能先乘张数再取整: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); window.setTimeout(() => promptRef.current?.insertMention({ label, thumb: ref.thumb_url || (ref.type === "image" ? ref.url : "") }), 0);
}, [mode, refs, libraryTargetRole, notify]); }, [mode, refs, libraryTargetRole, notify]);
const clearInput = useCallback(() => {
promptRef.current?.clear();
setRefs([]);
}, []);
const filterCounts = useMemo(() => ({ const filterCounts = useMemo(() => ({
all: tasks.length, all: tasks.length,
@@ -701,7 +697,6 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
onSeedChange={setSeed} onSeedChange={setSeed}
onOpenLibrary={openLibrary} onOpenLibrary={openLibrary}
onOpenPlatformLibrary={openPlatformLibrary} onOpenPlatformLibrary={openPlatformLibrary}
onClear={clearInput}
onSend={() => void handleSend()} onSend={() => void handleSend()}
/> />
</div> </div>
+17 -17
View File
@@ -18,9 +18,10 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { api } from "../api"; 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 { ConfirmModal } from "../components/overlays";
import type { CreationConversation, CreationRef } from "../types"; import type { CreationConversation, CreationRef, ModelConfig } from "../types";
import type { NavigateFn } from "./route-config"; import type { NavigateFn } from "./route-config";
type OutputMode = "video" | "image"; type OutputMode = "video" | "image";
@@ -102,9 +103,11 @@ function formatRelativeTime(iso: string) {
} }
export function OmniCreatePage({ export function OmniCreatePage({
modelConfigs,
navigate, navigate,
onNotify, onNotify,
}: { }: {
modelConfigs?: ModelConfig[];
navigate: NavigateFn; navigate: NavigateFn;
onNotify?: (type: "success" | "error" | "info", text: string) => void; onNotify?: (type: "success" | "error" | "info", text: string) => void;
}) { }) {
@@ -132,19 +135,23 @@ export function OmniCreatePage({
useEffect(() => { useEffect(() => {
if (outputMode === "image") { if (outputMode === "image") {
setModel("Seedream5.0"); const img = findCatalogModel(modelConfigs, "", "image");
setModel(img ? (img.display_name || img.name) : "Seedream5.0");
setResolution("模型默认"); setResolution("模型默认");
setRatio("1:1"); setRatio("1:1");
setDuration("1 张"); setDuration("1 张");
} else { } else {
setModel("Seedance 2.5"); const vid = findCatalogModel(modelConfigs, "", "video");
setResolution("1080p"); 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"); setRatio("9:16");
setDuration("智能时长"); setDuration("智能时长");
} }
setActiveCategory("all"); setActiveCategory("all");
setSelectedCase((prev) => (prev && prev.mode !== outputMode ? null : prev)); setSelectedCase((prev) => (prev && prev.mode !== outputMode ? null : prev));
}, [outputMode]); }, [outputMode, modelConfigs]);
useEffect(() => { useEffect(() => {
const onDown = (event: MouseEvent) => { const onDown = (event: MouseEvent) => {
@@ -385,17 +392,6 @@ export function OmniCreatePage({
</div> </div>
</div> </div>
<OmniParamBar
isVideo={outputMode === "video"}
model={model}
resolution={resolution}
ratio={ratio}
duration={duration}
onModel={setModel}
onResolution={setResolution}
onRatio={setRatio}
onDuration={setDuration}
/>
</div> </div>
<button <button
type="button" type="button"
@@ -595,6 +591,10 @@ export function OmniHistoryPage({
</button> </button>
))} ))}
</div> </div>
<button type="button" className="omni-history-new" onClick={() => navigate("omniCreate")}>
<Plus />
</button>
</div> </div>
</header> </header>
<div className="omni-history-list"> <div className="omni-history-list">
+31 -2
View File
@@ -17,13 +17,15 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { api } from "../api"; 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 { MediaLightbox } from "../components/overlays";
import type { import type {
CreationConversationDetail, CreationConversationDetail,
CreationField, CreationField,
CreationMessage, CreationMessage,
CreationRef, CreationRef,
ModelConfig,
} from "../types"; } from "../types";
import type { NavigateFn } from "./route-config"; import type { NavigateFn } from "./route-config";
@@ -421,17 +423,19 @@ function ConfirmCard({
message, message,
sessionParams, sessionParams,
isVideo, isVideo,
catalogModels,
disabled, disabled,
onConfirm, onConfirm,
}: { }: {
message: CreationMessage; message: CreationMessage;
sessionParams: Record<string, string>; sessionParams: Record<string, string>;
isVideo: boolean; isVideo: boolean;
catalogModels?: ModelConfig[];
disabled: boolean; disabled: boolean;
onConfirm: (params: Record<string, string>) => void; onConfirm: (params: Record<string, string>) => void;
}) { }) {
const submitted = Boolean(message.payload.submitted); 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 payloadParams = asStringMap(message.payload.params);
const snapshot = { ...sessionParams, ...payloadParams }; const snapshot = { ...sessionParams, ...payloadParams };
const [draft, setDraft] = useState(snapshot); const [draft, setDraft] = useState(snapshot);
@@ -440,6 +444,27 @@ function ConfirmCard({
cardIsVideo && Boolean(draft.duration) && Boolean(snapshot.duration) && draft.duration !== snapshot.duration; cardIsVideo && Boolean(draft.duration) && Boolean(snapshot.duration) && draft.duration !== snapshot.duration;
const setField = (key: string, value: string) => setDraft((prev) => ({ ...prev, [key]: value })); const setField = (key: string, value: string) => setDraft((prev) => ({ ...prev, [key]: value }));
const summary = paramLine(draft, cardIsVideo) || "当前参数"; 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 ( return (
<section className="omni-confirm-card"> <section className="omni-confirm-card">
@@ -452,6 +477,7 @@ function ConfirmCard({
<OmniParamBar <OmniParamBar
isVideo={cardIsVideo} isVideo={cardIsVideo}
disabled={disabled} disabled={disabled}
catalogModels={catalogModels}
model={draft.model || ""} model={draft.model || ""}
resolution={draft.resolution || ""} resolution={draft.resolution || ""}
ratio={draft.ratio || ""} ratio={draft.ratio || ""}
@@ -636,6 +662,7 @@ export function OmniSessionPage({
firstMessage, firstMessage,
firstRefs, firstRefs,
firstUploads, firstUploads,
modelConfigs,
navigate, navigate,
onNotify, onNotify,
}: { }: {
@@ -644,6 +671,7 @@ export function OmniSessionPage({
firstMessage?: string; firstMessage?: string;
firstRefs?: CreationRef[]; firstRefs?: CreationRef[];
firstUploads?: CreationRef[]; firstUploads?: CreationRef[];
modelConfigs?: ModelConfig[];
navigate: NavigateFn; navigate: NavigateFn;
onNotify?: (type: "success" | "error" | "info", text: string) => void; onNotify?: (type: "success" | "error" | "info", text: string) => void;
}) { }) {
@@ -1041,6 +1069,7 @@ export function OmniSessionPage({
message={message} message={message}
sessionParams={params} sessionParams={params}
isVideo={isVideo} isVideo={isVideo}
catalogModels={modelConfigs}
disabled={streaming || confirming} disabled={streaming || confirming}
onConfirm={(nextParams) => void handleConfirm(message, nextParams)} 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 { Fragment, memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import type { ChangeEvent, CSSProperties, PointerEvent as ReactPointerEvent } from "react"; 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 { api, ApiError } from "../api";
import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, Team, TimelineSavePayload, User } from "../types"; import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, Team, TimelineSavePayload, User } from "../types";
import { isHiddenFromScriptPicker, publicModelDisplayName } from "../model-display"; import { isHiddenFromScriptPicker, publicModelDisplayName } from "../model-display";
@@ -10,7 +10,7 @@ import type { Notice, Page } from "./route-config";
import { stageOrder, statusPill } from "./stage-config"; import { stageOrder, statusPill } from "./stage-config";
import { ConfirmModal, MediaLightbox, TeamModal, useBodyScrollLock } from "../components/overlays"; import { ConfirmModal, MediaLightbox, TeamModal, useBodyScrollLock } from "../components/overlays";
import { CustomSelect } from "../components/custom-select"; 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 { ModelLibrary } from "../components/model-library";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge"; import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import { import {
@@ -537,6 +537,7 @@ export function PipelinePage(props: {
scriptModelName: string; scriptModelName: string;
textModels?: ModelConfig[]; textModels?: ModelConfig[];
videoModels?: ModelConfig[]; videoModels?: ModelConfig[];
imageModels?: ModelConfig[];
onAdoptScript: (scriptId: string) => void | Promise<unknown>; onAdoptScript: (scriptId: string) => void | Promise<unknown>;
onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => 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>; onAddShot: (afterSegmentId: string, content?: { narration?: string; visual_prompt?: string }) => Promise<unknown>;
@@ -575,7 +576,7 @@ export function PipelinePage(props: {
}) { }) {
const { const {
project, loading, navigate, products, assets, onNotify, 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, onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateModel, onUploadModel, onGenerateTriview, onRenameModel,
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject, onRefreshBilling, onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject, onRefreshBilling,
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
@@ -971,14 +972,24 @@ export function PipelinePage(props: {
if (extractState === "running") return; if (extractState === "running") return;
setExtractErr(""); setExtractErr("");
setExtractState("running"); setExtractState("running");
setExtractMsg("正在从剧本认出角色 / 场景…"); setExtractMsg("正在整理角色 / 场景…");
try { try {
await api.extractEntities(project.id); // 异步提交(已有在途则后端复用,不重复扣费),慢活在 worker 跑 // 后端本地拆实体:秒回 succeeded(不调模型、不扣费);兼容旧异步任务仍可轮询
pollExtractUntilDone(mode); // 轮询直到 worker 跑完 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) { } catch (err) {
setExtractState("idle"); setExtractState("idle");
setExtractMsg(""); setExtractMsg("");
setExtractErr(err instanceof Error ? err.message : "提取失败,请重试"); setExtractErr(err instanceof Error ? err.message : "整理失败,请重试");
} }
} }
// ── 流程步骤3 · 兜底弹窗拦截:生成视频前,查被引用的角色/场景是否都有「按名字采用」的参考图 ── // ── 流程步骤3 · 兜底弹窗拦截:生成视频前,查被引用的角色/场景是否都有「按名字采用」的参考图 ──
@@ -1126,6 +1137,13 @@ export function PipelinePage(props: {
const videoAnyStarted = segments.some((s) => ["running", "succeeded", "queued", "completed", "done"].includes(s.status)); const videoAnyStarted = segments.some((s) => ["running", "succeeded", "queued", "completed", "done"].includes(s.status));
const [chargeConfirm, setChargeConfirm] = useState<"video" | null>(null); const [chargeConfirm, setChargeConfirm] = useState<"video" | null>(null);
const videoConfigs = videoModels ?? []; 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) const defaultVideoModel = videoConfigs.find((m) => m.status === "active" && m.name === DEFAULT_VIDEO_MODEL_NAME)
|| videoConfigs.find((m) => m.status === "active") || videoConfigs.find((m) => m.status === "active")
|| videoConfigs[0]; || videoConfigs[0];
@@ -1209,10 +1227,10 @@ export function PipelinePage(props: {
size="sm" size="sm"
value={outputResolution} value={outputResolution}
onChange={(next) => changeOutputSpec({ resolution: next })} onChange={(next) => changeOutputSpec({ resolution: next })}
options={OUTPUT_RESOLUTIONS.map((option) => ({ options={(supportedResolutions.length
...option, ? OUTPUT_RESOLUTIONS.filter((option) => supportedResolutions.includes(option.value))
disabled: Boolean(supportedResolutions.length && !supportedResolutions.includes(option.value)), : OUTPUT_RESOLUTIONS
}))} )}
/> />
</label> </label>
<label className="as-spec-field"> <label className="as-spec-field">
@@ -3289,6 +3307,7 @@ export function PipelinePage(props: {
setSetupPersona(recommended.persona); setSetupPersona(recommended.persona);
setSetupDuration(recommended.duration); setSetupDuration(recommended.duration);
}}></button> }}></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> <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>
</div> </div>
@@ -3407,17 +3426,20 @@ export function PipelinePage(props: {
</div> </div>
</div> </div>
{/* 存模板 / 重生成 / 确认下一步:有镜头脚本后才显示;积分提示已挪到右侧「确定」旁 */}
{currentScript ? (
<div className="stage-foot"> <div className="stage-foot">
<div className="info pl-tip"> {pts(10)} / </div> <div className="info pl-tip"> {pts(100)} / </div>
<div className="hstack"> <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-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> <span>{scriptAdopted ? "进入下一步" : "确认脚本,进入下一步"}</span>
<ArrowRight /> <ArrowRight />
</button> </button>
</div> </div>
</div> </div>
) : null}
</section> </section>
{/* ============= STAGE 2 · 基础资产(真实 base_asset_groups,按 kind 分组)============= */} {/* ============= STAGE 2 · 基础资产(真实 base_asset_groups,按 kind 分组)============= */}
@@ -3439,8 +3461,7 @@ export function PipelinePage(props: {
|| assetUrl(productCover); // 商品卡显示商品主图,三视图在右侧面板 || assetUrl(productCover); // 商品卡显示商品主图,三视图在右侧面板
const triViewGen = `${productName} 商品三视图,从左到右:正面 / 侧面 / 背面,统一光照,白色背景,16:9`; const triViewGen = `${productName} 商品三视图,从左到右:正面 / 侧面 / 背面,统一光照,白色背景,16:9`;
const runProductTri = () => { setTriPanelOpen(true); setTriPreviewId(null); void genBaseAsset("product", triViewGen, undefined, "tri:product"); }; const runProductTri = () => { setTriPanelOpen(true); setTriPreviewId(null); void genBaseAsset("product", triViewGen, undefined, "tri:product"); };
// 实体提取闸门:没走过正式提取步(entities_extracted)且没生成任何基础资产 → 盖蒙版 + 三按钮,不自动花钱 // 实体整理闸门:定稿时一般已本地落 entities_extracted;未落且无资产时盖蒙版。整理免费、不调模型
// 用 entities_extracted 标记而非 script_entities 存在性:脚本生成期也可能吐过不稳的 entities,那不算正式提取。
const entitiesExtracted = project.metadata?.entities_extracted === true; const entitiesExtracted = project.metadata?.entities_extracted === true;
const hasAnyAsset = KIND_ORDER.some((k) => groupsByKind(k).length > 0); const hasAnyAsset = KIND_ORDER.some((k) => groupsByKind(k).length > 0);
const gateVisible = extractState === "running" || (!entitiesExtracted && !hasAnyAsset); const gateVisible = extractState === "running" || (!entitiesExtracted && !hasAnyAsset);
@@ -3476,16 +3497,16 @@ export function PipelinePage(props: {
<div className="sgv-card"> <div className="sgv-card">
<span className="sgv-ico"><span className="spinner"></span></span> <span className="sgv-ico"><span className="spinner"></span></span>
<div className="sgv-body"> <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 className="sgv-bar"></div>
</div> </div>
</div> </div>
) : ( ) : (
<> <>
<div className="eg-title"> / </div> <div className="eg-title"> / </div>
<div className="mono eg-sub">AI / ,</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> <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="mono eg-cost-hint"> · </div>
{extractErr && <div className="eg-err">{extractErr}</div>} {extractErr && <div className="eg-err">{extractErr}</div>}
</> </>
)} )}
@@ -3501,7 +3522,7 @@ export function PipelinePage(props: {
<div className="as-head-side"> <div className="as-head-side">
{(entitiesExtracted || hasAnyAsset) ? ( {(entitiesExtracted || hasAnyAsset) ? (
<button type="button" className="as-ghost-btn" disabled={extractState === "running"} data-stop onClick={() => void runExtract("only")}> <button type="button" className="as-ghost-btn" disabled={extractState === "running"} data-stop onClick={() => void runExtract("only")}>
{extractState === "running" ? "提取中…" : "重新提取角色 / 场景"} {extractState === "running" ? "提取中…" : "重新整理角色 / 场景"}
</button> </button>
) : null} ) : null}
<div className="as-completion"><span></span><strong>{assetDone} / {assetTotal}</strong></div> <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); }}> <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 ? "已采用" : "采用此版本"} {previewTriAsset === adoptedTriAsset ? "已采用" : "采用此版本"}
</button> </button>
<span className="as-cost">{pts(20)} / </span> <span className="as-cost">{pts(imageUnitPoints)} / </span>
</div> </div>
) : null} ) : null}
{!isLocalProduct ? ( {!isLocalProduct ? (
@@ -3780,7 +3801,6 @@ export function PipelinePage(props: {
})} })}
</div> </div>
<footer className="as-action-bar"> <footer className="as-action-bar">
<span><Info /> {pts(10)} / {pts(20)} / {pts(20)} · </span>
<div> <div>
{videoAnyStarted ? ( {videoAnyStarted ? (
<span className="pill pill-l2 neutral" title="后续生成视频将使用此设置"> <span className="pill pill-l2 neutral" title="后续生成视频将使用此设置">
+14 -8
View File
@@ -29,6 +29,7 @@ import {
estimateCost, estimateCost,
FC_MODELS, FC_MODELS,
modelLabel, modelLabel,
pointsPerImageFromCatalog,
type BillingRates, type BillingRates,
} from "../components/free-create/constants"; } from "../components/free-create/constants";
import type { NavigateFn } from "./route-config"; import type { NavigateFn } from "./route-config";
@@ -571,9 +572,14 @@ export function QuickCreatePage({
{ ratio: aspectRatio, resolution, duration: totalDuration, refs: [] }, { ratio: aspectRatio, resolution, duration: totalDuration, refs: [] },
billingRates, billingRates,
); );
// 商品理解和基础资产在脚本生成前无法精确报价;按每场约40积分给出透明预估,最终按成功任务结算 // 基础资产按图像挂牌预估;视频按挂牌秒价。最终按成功任务实扣
// 故事板下线后每场少一次 image-2 出图,预估相应下调。 const imageUnit = (() => {
const estimatedPoints = videoEstimate.points > 0 ? videoEstimate.points + sceneCount * 40 : sceneCount * 200; 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 = [ const shellClass = [
"quick-create-shell", "quick-create-shell",
restoring ? "is-restoring" : "", restoring ? "is-restoring" : "",
@@ -688,10 +694,10 @@ export function QuickCreatePage({
value={resolution} value={resolution}
onChange={setResolution} onChange={setResolution}
disabled={isGenerating} disabled={isGenerating}
options={QUICK_RESOLUTIONS.map((option) => ({ options={(supportedResolutions.length
...option, ? QUICK_RESOLUTIONS.filter((option) => supportedResolutions.includes(option.value))
disabled: Boolean(supportedResolutions.length && !supportedResolutions.includes(option.value)), : QUICK_RESOLUTIONS
}))} )}
/> />
</label> </label>
<label className="quick-parameter-field"> <label className="quick-parameter-field">
@@ -727,7 +733,7 @@ export function QuickCreatePage({
</button> </button>
) : ( ) : (
<button type="button" className="quick-generate-button" onClick={() => void startGeneration()} disabled={!canStart}> <button type="button" className="quick-generate-button" onClick={() => void startGeneration()} disabled={!canStart}>
<WandSparkles /><span>{`立即生成视频 · 消耗 ${estimatedPoints} 积分`}</span> <WandSparkles /><span>{estimatedPoints > 0 ? `立即生成视频 · 消耗 ${estimatedPoints} 积分` : "立即生成视频"}</span>
</button> </button>
)} )}
</div> </div>
+6 -6
View File
@@ -1,7 +1,6 @@
import { import {
Film, Film,
FolderKanban, FolderKanban,
Home,
ImageIcon, ImageIcon,
Library, Library,
MessageSquare, MessageSquare,
@@ -90,7 +89,6 @@ export function isOwnerOnlyPage(page: Page) {
} }
export const mainNav: NavItem[] = [ export const mainNav: NavItem[] = [
{ page: "dashboard", label: "工作台", icon: Home },
{ page: "products", label: "商品库", icon: Package }, { page: "products", label: "商品库", icon: Package },
{ page: "models", label: "模特库", icon: UserRound }, { page: "models", label: "模特库", icon: UserRound },
{ page: "projects", label: "视频创作", icon: FolderKanban }, { page: "projects", label: "视频创作", icon: FolderKanban },
@@ -169,7 +167,8 @@ export function resolveRoute(): ResolvedRoute {
return { page: "dashboard", authMode: "login", admin: path.slice("/admin/".length), hash }; return { page: "dashboard", authMode: "login", admin: path.slice("/admin/".length), hash };
} }
if (path === "/" && isPage(hash)) return { page: hash, authMode: "login", 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") return { page: "products", authMode: "login", hash };
if (path === "/products/new") return { page: "productCreateUpload", authMode: "login", hash }; if (path === "/products/new") return { page: "productCreateUpload", authMode: "login", hash };
if (path.startsWith("/products/")) { 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/notify") return { page: "settingsNotify", authMode: "login", hash };
if (path === "/settings") return { page: "settings", authMode: "login", hash }; if (path === "/settings") return { page: "settings", authMode: "login", hash };
if (path === "/trash") return { page: "trash", 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 = {}) { export function pathForPage(page: Page, options: NavigateOptions = {}) {
switch (page) { switch (page) {
case "dashboard": case "dashboard":
return "/dashboard"; // 工作台隐藏期间,旧入口也落到全能创作
return "/omni-create";
case "products": case "products":
return "/products"; return "/products";
case "productCreateUpload": case "productCreateUpload":
@@ -272,6 +272,6 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) {
case "trash": case "trash":
return "/trash"; return "/trash";
default: default:
return "/dashboard"; return "/omni-create";
} }
} }
+3 -3
View File
@@ -441,7 +441,7 @@ export function VideoReplacePage({
).map(() => ({ type: "image" }))), ).map(() => ({ type: "image" }))),
], ],
}, billingRates); }, billingRates);
const points = estimated.points || 220; const points = estimated.points;
// 上传参考视频不算「正在复刻」:右侧面板要保持待命,只在上传区自己显示进度。 // 上传参考视频不算「正在复刻」:右侧面板要保持待命,只在上传区自己显示进度。
// 生成按钮不会因此被误点 —— videoReady 要等 asset_id 回来才为真。 // 生成按钮不会因此被误点 —— videoReady 要等 asset_id 回来才为真。
const generating = Boolean(job && isInFlight(job.status)) || submitting; const generating = Boolean(job && isInFlight(job.status)) || submitting;
@@ -464,8 +464,8 @@ export function VideoReplacePage({
const generateLabel = generating const generateLabel = generating
? (digesting ? "正在提炼参考视频…" : reviewing ? "正在审核素材…" : (shotTotal > 1 && shotIndex > 0 ? `正在复刻 ${shotIndex}/${shotTotal} 镜…` : "正在复刻…")) ? (digesting ? "正在提炼参考视频…" : reviewing ? "正在审核素材…" : (shotTotal > 1 && shotIndex > 0 ? `正在复刻 ${shotIndex}/${shotTotal} 镜…` : "正在复刻…"))
: hasResult : hasResult
? `再次${copy.modeLabel} · 消耗 ${points} 积分` ? (points > 0 ? `再次${copy.modeLabel} · 消耗 ${points} 积分` : `再次${copy.modeLabel}`)
: `开始${copy.modeLabel} · 消耗 ${points} 积分`; : (points > 0 ? `开始${copy.modeLabel} · 消耗 ${points} 积分` : `开始${copy.modeLabel}`);
const loadHistory = async () => { const loadHistory = async () => {
try { try {