Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89e9e24a5e | |||
| c243c4dd58 | |||
| a314a42405 | |||
| 2d83b9385c |
@@ -1,133 +0,0 @@
|
||||
"""add membership & points system
|
||||
|
||||
Revision ID: 076_membership_points
|
||||
Revises: 075_add_sentence_timings
|
||||
Create Date: 2026-09-15
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "076_membership_points"
|
||||
down_revision = "075_add_sentence_timings"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. users 表新增字段
|
||||
with op.batch_alter_table("users") as batch:
|
||||
batch.add_column(
|
||||
sa.Column("is_member", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column("member_type", sa.String(20), nullable=True),
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column("member_expires_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column("points_balance", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
)
|
||||
|
||||
# 2. points_accounts 积分账户表
|
||||
op.create_table(
|
||||
"points_accounts",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False, unique=True, index=True),
|
||||
sa.Column("balance", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("total_earned", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("total_spent", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.text("NOW()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.text("NOW()"),
|
||||
),
|
||||
)
|
||||
|
||||
# 3. points_transactions 积分流水表
|
||||
op.create_table(
|
||||
"points_transactions",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False, index=True),
|
||||
sa.Column("account_id", sa.String(36), nullable=False, index=True),
|
||||
sa.Column("type", sa.String(20), nullable=False, index=True),
|
||||
sa.Column("source", sa.String(50), nullable=False, index=True),
|
||||
sa.Column("amount", sa.Integer(), nullable=False),
|
||||
sa.Column("balance_after", sa.Integer(), nullable=False),
|
||||
sa.Column("description", sa.String(255), nullable=False, server_default=""),
|
||||
sa.Column("ref_id", sa.String(100), nullable=False, server_default=""),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.text("NOW()"),
|
||||
),
|
||||
)
|
||||
|
||||
# 4. points_orders 积分/会员订单表
|
||||
op.create_table(
|
||||
"points_orders",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False, index=True),
|
||||
sa.Column("order_type", sa.String(20), nullable=False),
|
||||
sa.Column("product_code", sa.String(50), nullable=False),
|
||||
sa.Column("amount_cents", sa.Integer(), nullable=False),
|
||||
sa.Column("original_amount_cents", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("discount", sa.Float(), nullable=False, server_default=sa.text("1.0")),
|
||||
sa.Column("points_amount", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
|
||||
sa.Column("payment_method", sa.String(50), nullable=True),
|
||||
sa.Column("payment_id", sa.String(100), nullable=True),
|
||||
sa.Column("paid_at", sa.DateTime(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.text("NOW()"),
|
||||
),
|
||||
)
|
||||
|
||||
# 5. daily_usage_records 每日使用记录表
|
||||
op.create_table(
|
||||
"daily_usage_records",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False, index=True),
|
||||
sa.Column("usage_date", sa.DateTime(), nullable=False),
|
||||
sa.Column("usage_type", sa.String(50), nullable=False, server_default="free_clip"),
|
||||
sa.Column("count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.text("NOW()"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"user_id",
|
||||
"usage_date",
|
||||
"usage_type",
|
||||
name="uq_daily_usage_user_date_type",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("daily_usage_records")
|
||||
op.drop_table("points_orders")
|
||||
op.drop_table("points_transactions")
|
||||
op.drop_table("points_accounts")
|
||||
|
||||
with op.batch_alter_table("users") as batch:
|
||||
batch.drop_column("points_balance")
|
||||
batch.drop_column("member_expires_at")
|
||||
batch.drop_column("member_type")
|
||||
batch.drop_column("is_member")
|
||||
@@ -18,10 +18,8 @@ from app.api.routes.health import router as health_check_router
|
||||
from app.api.routes.ingest_jobs import router as ingest_jobs_router
|
||||
from app.api.routes.internal_render import router as internal_render_router
|
||||
from app.api.routes.lipsync import router as lipsync_router
|
||||
from app.api.routes.points import points_router, usage_router
|
||||
from app.api.routes.projects import router as projects_router
|
||||
from app.api.routes.scripts import router as scripts_router
|
||||
from app.api.routes.scripts_ai import router as scripts_ai_router
|
||||
from app.api.routes.share import router as share_router
|
||||
from app.api.routes.subscription import router as subscription_router
|
||||
from app.api.routes.tags import router as tags_router
|
||||
@@ -191,23 +189,8 @@ api_router.include_router(
|
||||
prefix="/scripts",
|
||||
tags=["ScriptLibrary"],
|
||||
)
|
||||
api_router.include_router(
|
||||
scripts_ai_router,
|
||||
prefix="/scripts",
|
||||
tags=["ScriptLibrary AI"],
|
||||
)
|
||||
api_router.include_router(
|
||||
ai_avatar_render_router,
|
||||
prefix="/ai-avatar/render",
|
||||
tags=["AI Avatar Render"],
|
||||
)
|
||||
api_router.include_router(
|
||||
points_router,
|
||||
prefix="/points",
|
||||
tags=["Points"],
|
||||
)
|
||||
api_router.include_router(
|
||||
usage_router,
|
||||
prefix="/usage",
|
||||
tags=["Usage"],
|
||||
)
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
"""默认模板兜底共享逻辑(P0 #1922).
|
||||
|
||||
提供 get_or_create_default_template_id(db, user_id) 共享函数,
|
||||
供 templates.py 列表查询、clips_standalone.py 独立端点、dependencies.py
|
||||
resolve_draft_plan_id 三处复用,避免三处各写一套兜底逻辑产生分叉。
|
||||
|
||||
根因:PR#1918 清理模板管理 API 时误删了 GET /templates 自动创建默认模板
|
||||
兜底,前端 PR#1913 去掉空 tid 拦截后首次进入生成页拼出
|
||||
/templates//editor/clips/from-assets(双斜杠)→ FastAPI 404,阻断新用户首次
|
||||
生成。
|
||||
"""
|
||||
"""P0 fix: 共享的默认模板自动兜底逻辑。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,24 +11,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_or_create_default_template_id(db: Session, user_id: str) -> Optional[str]:
|
||||
"""获取或自动创建默认配音模板的 id。
|
||||
|
||||
判定逻辑(不做异常降级,只有确实创建失败时才回滚重查):
|
||||
1. 查用户名下 is_active=True 且有 TemplateClipConfig 的模板 → 返回其 id;
|
||||
2. 无则调用 CreateTemplateUseCase 创建一条默认 voice_over 模板;
|
||||
3. 创建异常时 rollback 再重查一次(防并发唯一键冲突),重查仍无返回 None。
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
TemplateClipConfigModel,
|
||||
TemplateModel,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
from packages.application.template.commands import (
|
||||
CreateTemplateCommand,
|
||||
SegmentCommand,
|
||||
)
|
||||
"""为用户查找一个有效模板;若不存在则自动创建默认配音模板。"""
|
||||
from packages.adapters.sqlalchemy_impl.models import TemplateClipConfigModel, TemplateModel
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository
|
||||
from packages.application.template.commands import CreateTemplateCommand, SegmentCommand
|
||||
from packages.application.template.use_cases import CreateTemplateUseCase
|
||||
|
||||
existing = (
|
||||
@@ -70,22 +46,21 @@ def get_or_create_default_template_id(db: Session, user_id: str) -> Optional[str
|
||||
)
|
||||
tpl = CreateTemplateUseCase(repo).execute(cmd)
|
||||
db.commit()
|
||||
logger.info("auto-created default voice_over template: id=%s user=%s", tpl.id, user_id)
|
||||
logger.info("[default-template] 自动创建默认模板: user=%s tpl=%s", user_id, tpl.id)
|
||||
return tpl.id
|
||||
except Exception:
|
||||
db.rollback()
|
||||
# 重查:可能并发请求已建好
|
||||
existing = (
|
||||
existing2 = (
|
||||
db.query(TemplateModel)
|
||||
.filter(TemplateModel.user_id == user_id, TemplateModel.is_active.is_(True))
|
||||
.order_by(TemplateModel.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
has_seg = (
|
||||
db.query(TemplateClipConfigModel.id).filter(TemplateClipConfigModel.template_id == existing.id).first()
|
||||
if existing2 is not None:
|
||||
has_seg2 = (
|
||||
db.query(TemplateClipConfigModel.id).filter(TemplateClipConfigModel.template_id == existing2.id).first()
|
||||
)
|
||||
if has_seg:
|
||||
return existing.id
|
||||
logger.exception("failed to auto-create default template user=%s", user_id)
|
||||
if has_seg2:
|
||||
return existing2.id
|
||||
logger.exception("[default-template] 自动创建默认模板失败: user=%s", user_id)
|
||||
return None
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""路由层共享辅助函数 — 消除跨文件重复定义。"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
@@ -138,4 +138,4 @@ def format_utc_datetime(dt: datetime | None) -> str:
|
||||
return dt
|
||||
if dt.tzinfo is None:
|
||||
return dt.isoformat() + "Z"
|
||||
return dt.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from typing import List, Literal
|
||||
|
||||
from app.services.ai_service import TITLE_STYLES, generate_smart_titles, semantic_match_assets
|
||||
from fastapi import APIRouter
|
||||
@@ -31,7 +31,7 @@ class GenerateTitlesRequest(BaseModel):
|
||||
class GenerateTitlesResponse(BaseModel):
|
||||
"""智能标题生成响应."""
|
||||
|
||||
titles: list[str] = Field(..., description="生成的标题列表")
|
||||
titles: List[str] = Field(..., description="生成的标题列表")
|
||||
style: str = Field(..., description="实际使用的风格")
|
||||
source: str = Field(..., description="来源:doubao 或 fallback")
|
||||
description: str = Field(..., description="原始描述")
|
||||
@@ -53,7 +53,7 @@ class AssetMatchItem(BaseModel):
|
||||
|
||||
id: str = Field(..., description="素材ID")
|
||||
name: str = Field(default="", description="素材名称")
|
||||
tags: list[str] = Field(default_factory=list, description="标签列表")
|
||||
tags: List[str] = Field(default_factory=list, description="标签列表")
|
||||
description: str = Field(default="", description="素材描述")
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ class SemanticMatchRequest(BaseModel):
|
||||
"""语义匹配请求."""
|
||||
|
||||
description: str = Field(..., min_length=1, max_length=500, description="目标视频内容描述")
|
||||
assets: list[AssetMatchItem] = Field(..., min_length=1, max_length=100, description="待匹配素材列表")
|
||||
assets: List[AssetMatchItem] = Field(..., min_length=1, max_length=100, description="待匹配素材列表")
|
||||
top_k: int = Field(default=0, ge=0, le=100, description="返回前K个,0返回全部")
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ class SemanticMatchResultItem(AssetMatchItem):
|
||||
class SemanticMatchResponse(BaseModel):
|
||||
"""语义匹配响应."""
|
||||
|
||||
matches: list[SemanticMatchResultItem] = Field(..., description="按匹配度降序排列的素材列表")
|
||||
matches: List[SemanticMatchResultItem] = Field(..., description="按匹配度降序排列的素材列表")
|
||||
source: str = Field(..., description="来源:doubao / fallback")
|
||||
description: str = Field(..., description="原始描述")
|
||||
total: int = Field(..., description="输入素材总数")
|
||||
@@ -99,7 +99,7 @@ def generate_titles(request: GenerateTitlesRequest):
|
||||
return GenerateTitlesResponse(**result)
|
||||
|
||||
|
||||
@router.get("/titles/styles", response_model=list[TitleStyleInfo])
|
||||
@router.get("/titles/styles", response_model=List[TitleStyleInfo])
|
||||
def list_title_styles():
|
||||
"""获取支持的标题风格列表."""
|
||||
return [
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
@@ -82,7 +82,7 @@ def create_render_job(
|
||||
logger.exception("Celery 任务投递失败(创建): job_id=%s err=%s", job.id, exc)
|
||||
job.status = "failed"
|
||||
job.error_message = f"任务提交失败:{exc}"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
svc.db.commit()
|
||||
svc.db.refresh(job)
|
||||
return AiAvatarRenderJobResponse.model_validate(job)
|
||||
@@ -183,7 +183,7 @@ def retry_render_job(
|
||||
logger.exception("Celery 任务投递失败(重试): job_id=%s err=%s", job.id, exc)
|
||||
job.status = "failed"
|
||||
job.error_message = f"任务提交失败:{exc}"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
svc.db.commit()
|
||||
svc.db.refresh(job)
|
||||
return AiAvatarRenderJobResponse.model_validate(job)
|
||||
@@ -246,7 +246,7 @@ def generate_render_smart_cover(
|
||||
"url": cover_url,
|
||||
}
|
||||
job.output_cover_url = cover_url
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
logger.warning("更新 render_job 封面字段失败(不影响返回): job_id=%s err=%s", job_id, exc)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.api.routes._helpers import check_project_access, format_utc_datetime
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
@@ -390,7 +390,7 @@ def update_asset_review_status(
|
||||
return _to_asset_response(updated)
|
||||
|
||||
|
||||
@router.post("/batch", response_model=list[AssetResponse])
|
||||
@router.post("/batch", response_model=List[AssetResponse])
|
||||
def batch_get_assets(
|
||||
request: BatchGetRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
|
||||
@@ -8,7 +8,7 @@ import json
|
||||
import logging
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
@@ -156,7 +156,7 @@ def _cleanup_expired_uploads() -> int:
|
||||
if not CHUNK_STORAGE_ROOT.exists():
|
||||
return 0
|
||||
|
||||
now = datetime.now(UTC)
|
||||
now = datetime.now(timezone.utc)
|
||||
cleaned = 0
|
||||
|
||||
for meta_file in CHUNK_STORAGE_ROOT.glob("*.meta.json"):
|
||||
@@ -166,7 +166,7 @@ def _cleanup_expired_uploads() -> int:
|
||||
|
||||
expires_at = datetime.fromisoformat(meta["expires_at"])
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Only cleanup uploads that are not actively being uploaded
|
||||
if expires_at < now and meta.get("status") != "uploading":
|
||||
@@ -226,7 +226,7 @@ async def init_chunked_upload(
|
||||
|
||||
# Generate upload ID
|
||||
upload_id = uuid4().hex
|
||||
now = datetime.now(UTC)
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(hours=CHUNK_EXPIRY_HOURS)
|
||||
|
||||
# Create chunk directory
|
||||
@@ -421,9 +421,9 @@ async def upload_chunk(
|
||||
# Check expiry
|
||||
expires_at = datetime.fromisoformat(meta["expires_at"])
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
if expires_at < datetime.now(UTC):
|
||||
if expires_at < datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Upload has expired")
|
||||
|
||||
# Validate chunk index
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""独立的从素材创建片段端点(不依赖 template_id 路径参数).
|
||||
"""独立的从素材创建片段端点(不依赖template_id路径参数).
|
||||
|
||||
POST /api/v1/clips/from-assets
|
||||
- 与 /api/v1/templates/{template_id}/editor/clips/from-assets 功能一致
|
||||
- 区别:template_id 从 body 传入(可选),为空时后端自动创建/查找默认模板
|
||||
- 解决前端首次加载时 templateId 为空导致双斜杠 404 的问题(P0 #1922)
|
||||
- 解决前端首次加载时 templateId 为空导致双斜杠 404 的问题
|
||||
- 内部复用 resolve_draft_plan_id 和 create_clips_from_assets_editor 的核心逻辑
|
||||
"""
|
||||
|
||||
@@ -15,7 +15,7 @@ from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_asset_repository, get_db_session
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
@@ -57,8 +57,11 @@ def create_clips_from_assets(
|
||||
if not template_id:
|
||||
template_id = get_or_create_default_template_id(db, user_id)
|
||||
if not template_id:
|
||||
from fastapi import HTTPException
|
||||
from fastapi import status as http_status
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="无法自动创建默认模板,请刷新页面重试",
|
||||
)
|
||||
plan_id = resolve_draft_plan_id(
|
||||
|
||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
import ipaddress
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
from typing import Any, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
@@ -41,7 +41,7 @@ router = APIRouter(tags=["Generation"])
|
||||
class GenerateCoverRequest(BaseModel):
|
||||
"""AI 封面生成请求体"""
|
||||
|
||||
asset_ids: list[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
|
||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
|
||||
cover_type: str = Field(
|
||||
default="ai_frame",
|
||||
description="封面类型: ai_frame / manual / upload / ai_regenerate",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import psycopg
|
||||
import redis
|
||||
@@ -13,7 +13,7 @@ router = APIRouter(tags=["Health"])
|
||||
async def health_check():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"version": settings.APP_VERSION,
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ async def startup_check():
|
||||
all_ready = all(check["status"] == "healthy" for check in checks.values())
|
||||
response = {
|
||||
"status": "started" if all_ready else "starting",
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"checks": checks,
|
||||
}
|
||||
if not all_ready:
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
@@ -197,8 +196,9 @@ def get_lipsync_job(
|
||||
# 三层防御 ①:如果距上次更新超过 30 秒,同步刷新一次(避免 background task
|
||||
# 静默失败导致前端永远看到 running);否则挂后台异步刷新(避免阻塞轮询)。
|
||||
from datetime import datetime as _dt
|
||||
from datetime import timezone as _tz
|
||||
|
||||
_now = _dt.now(UTC)
|
||||
_now = _dt.now(_tz.utc)
|
||||
_stale = job.updated_at is None or (_now - job.updated_at).total_seconds() > 30
|
||||
if _stale:
|
||||
try:
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
"""积分 & 会员 API 路由 (#1895)
|
||||
|
||||
导出两个 router:
|
||||
- points_router: 积分相关路由,前缀 /points
|
||||
- usage_router: 每日额度路由,前缀 /usage
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.points import (
|
||||
DailyUsageResponse,
|
||||
MembershipStatusResponse,
|
||||
PointRuleItem,
|
||||
PointsBalanceResponse,
|
||||
PointsCheckRequest,
|
||||
PointsCheckResponse,
|
||||
PointsDeductRequest,
|
||||
PointsOrderResponse,
|
||||
PointsPackageItem,
|
||||
PointsPackagesResponse,
|
||||
PointsRechargeRequest,
|
||||
PointsRefundRequest,
|
||||
PointsRulesResponse,
|
||||
PointsTransactionsResponse,
|
||||
SimpleMessageResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.points_rules import (
|
||||
FREE_USER_MULTIPLIER,
|
||||
MEMBER_DISCOUNT,
|
||||
POINTS_PACKAGES,
|
||||
POINTS_SCENES,
|
||||
calculate_points_cost,
|
||||
)
|
||||
from packages.domain.points_service import PointsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 两个 router ──
|
||||
points_router = APIRouter()
|
||||
usage_router = APIRouter()
|
||||
|
||||
|
||||
def _get_service() -> PointsService:
|
||||
return PointsService()
|
||||
|
||||
|
||||
def _is_member(user: AuthenticatedUser) -> bool:
|
||||
"""判断用户是否为付费会员。"""
|
||||
return getattr(user.user, "is_member", False)
|
||||
|
||||
|
||||
def _member_type(user: AuthenticatedUser) -> str | None:
|
||||
return getattr(user.user, "member_type", None)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# 积分相关路由 (prefix=/points)
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@points_router.get("/balance", response_model=PointsBalanceResponse)
|
||||
def get_balance(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""查询当前用户积分余额 + 会员状态。"""
|
||||
svc = _get_service()
|
||||
account = svc.get_or_create_account(current_user.user.id, db)
|
||||
return PointsBalanceResponse(
|
||||
balance=account["balance"],
|
||||
total_earned=account["total_earned"],
|
||||
total_spent=account["total_spent"],
|
||||
is_member=_is_member(current_user),
|
||||
member_type=_member_type(current_user),
|
||||
member_expires_at=getattr(current_user.user, "member_expires_at", None),
|
||||
)
|
||||
|
||||
|
||||
@points_router.get("/transactions", response_model=PointsTransactionsResponse)
|
||||
def get_transactions(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
type: Optional[str] = Query(None, description="筛选类型: add/deduct"),
|
||||
source: Optional[str] = Query(None, description="筛选来源场景"),
|
||||
start_date: Optional[datetime] = Query(None),
|
||||
end_date: Optional[datetime] = Query(None),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""查询积分流水(分页+筛选)。"""
|
||||
svc = _get_service()
|
||||
result = svc.get_transactions(
|
||||
user_id=current_user.user.id,
|
||||
db=db,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
type_filter=type,
|
||||
source_filter=source,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
return PointsTransactionsResponse(**result)
|
||||
|
||||
|
||||
@points_router.get("/rules", response_model=PointsRulesResponse)
|
||||
def get_rules(
|
||||
_current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""查询所有积分消耗规则。"""
|
||||
rules = []
|
||||
for scene_key, scene_data in POINTS_SCENES.items():
|
||||
rules.append(
|
||||
PointRuleItem(
|
||||
scene_key=scene_key,
|
||||
name=scene_data["name"],
|
||||
base_points=scene_data["base_points"],
|
||||
unit=scene_data["unit"],
|
||||
extra_per_30s=scene_data.get("extra_per_30s"),
|
||||
)
|
||||
)
|
||||
return PointsRulesResponse(
|
||||
rules=rules,
|
||||
free_user_multiplier=FREE_USER_MULTIPLIER,
|
||||
)
|
||||
|
||||
|
||||
@points_router.get("/packages", response_model=PointsPackagesResponse)
|
||||
def get_packages(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""查询可购买的积分包列表。"""
|
||||
packages = []
|
||||
for code, pkg in POINTS_PACKAGES.items():
|
||||
unit_price = f"¥{pkg['price_cents'] / 100 / pkg['points']:.3f}/积分"
|
||||
packages.append(
|
||||
PointsPackageItem(
|
||||
code=code,
|
||||
name=pkg["name"],
|
||||
points=pkg["points"],
|
||||
price_cents=pkg["price_cents"],
|
||||
unit_price=unit_price,
|
||||
)
|
||||
)
|
||||
mt = _member_type(current_user)
|
||||
discount = MEMBER_DISCOUNT.get(mt) if mt else None
|
||||
return PointsPackagesResponse(packages=packages, user_discount=discount)
|
||||
|
||||
|
||||
@points_router.post("/check", response_model=PointsCheckResponse)
|
||||
def check_points(
|
||||
body: PointsCheckRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""消费前检查余额是否足够。"""
|
||||
is_mem = _is_member(current_user)
|
||||
mt = _member_type(current_user)
|
||||
|
||||
# 混剪场景先检查免费额度
|
||||
is_free_quota = False
|
||||
if body.scene_key == "ai_video" and not is_mem:
|
||||
svc = _get_service()
|
||||
if svc.check_daily_free_clip(current_user.user.id, db):
|
||||
is_free_quota = True
|
||||
|
||||
required = calculate_points_cost(
|
||||
body.scene_key,
|
||||
is_mem,
|
||||
quantity=body.quantity or 1,
|
||||
duration_minutes=body.duration_minutes or 0,
|
||||
member_type=mt,
|
||||
)
|
||||
|
||||
svc = _get_service()
|
||||
account = svc.get_or_create_account(current_user.user.id, db)
|
||||
balance = account["balance"]
|
||||
|
||||
return PointsCheckResponse(
|
||||
allowed=is_free_quota or balance >= required,
|
||||
required_points=required,
|
||||
current_balance=balance,
|
||||
remaining_after=balance - required,
|
||||
is_free_quota=is_free_quota,
|
||||
)
|
||||
|
||||
|
||||
@points_router.post("/deduct", response_model=SimpleMessageResponse)
|
||||
def deduct_points(
|
||||
body: PointsDeductRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""积分扣减(内部服务调用)。"""
|
||||
svc = _get_service()
|
||||
result = svc.deduct_points(
|
||||
user_id=current_user.user.id,
|
||||
amount=body.amount,
|
||||
source=body.scene_key,
|
||||
db=db,
|
||||
description=body.description or "",
|
||||
ref_id=body.ref_id or "",
|
||||
)
|
||||
if not result["success"]:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {body.amount},余额 {result['balance']}",
|
||||
},
|
||||
)
|
||||
return SimpleMessageResponse(
|
||||
success=True,
|
||||
message=f"扣减 {body.amount} 积分成功",
|
||||
data={"transaction_id": result["transaction_id"], "balance": result["balance"]},
|
||||
)
|
||||
|
||||
|
||||
@points_router.post("/refund", response_model=SimpleMessageResponse)
|
||||
def refund_points(
|
||||
body: PointsRefundRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""积分退还(内部服务调用)。"""
|
||||
from packages.adapters.sqlalchemy_impl.models import PointsTransactionModel
|
||||
|
||||
txn = (
|
||||
db.query(PointsTransactionModel)
|
||||
.filter(PointsTransactionModel.id == body.transaction_id)
|
||||
.first()
|
||||
)
|
||||
if txn is None:
|
||||
raise HTTPException(status_code=404, detail="交易记录不存在")
|
||||
if txn.user_id != current_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="无权退还他人积分")
|
||||
|
||||
svc = _get_service()
|
||||
result = svc.refund_points(
|
||||
user_id=current_user.user.id,
|
||||
amount=txn.amount,
|
||||
source=txn.source,
|
||||
db=db,
|
||||
ref_id=body.transaction_id,
|
||||
description=body.reason or f"退还: {txn.description}",
|
||||
)
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=500, detail="退还失败")
|
||||
return SimpleMessageResponse(
|
||||
success=True,
|
||||
message=f"退还 {txn.amount} 积分成功",
|
||||
data={"transaction_id": result["transaction_id"], "balance": result["balance"]},
|
||||
)
|
||||
|
||||
|
||||
@points_router.post("/recharge", response_model=PointsOrderResponse)
|
||||
def create_recharge_order(
|
||||
body: PointsRechargeRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""创建积分充值订单。"""
|
||||
svc = _get_service()
|
||||
try:
|
||||
order = svc.create_order(
|
||||
user_id=current_user.user.id,
|
||||
order_type="points",
|
||||
product_code=body.package_id,
|
||||
db=db,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from None
|
||||
return PointsOrderResponse(**order)
|
||||
|
||||
|
||||
@points_router.get("/subscription/membership", response_model=MembershipStatusResponse)
|
||||
def get_membership_status(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""获取当前用户会员状态(聚合信息)。"""
|
||||
svc = _get_service()
|
||||
account = svc.get_or_create_account(current_user.user.id, db)
|
||||
is_mem = _is_member(current_user)
|
||||
max_resolution = "1080p" if is_mem else "720p"
|
||||
|
||||
return MembershipStatusResponse(
|
||||
is_member=is_mem,
|
||||
member_type=_member_type(current_user),
|
||||
member_expires_at=getattr(current_user.user, "member_expires_at", None),
|
||||
points_balance=account["balance"],
|
||||
max_resolution=max_resolution,
|
||||
)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# 每日额度路由 (prefix=/usage)
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@usage_router.get("/daily", response_model=DailyUsageResponse)
|
||||
def get_daily_usage(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""查询今日免费混剪额度使用情况。"""
|
||||
svc = _get_service()
|
||||
result = svc.get_daily_usage(current_user.user.id, db)
|
||||
return DailyUsageResponse(**result)
|
||||
|
||||
|
||||
# 为了向后兼容,也导出一个不带后缀的 router(方便旧引用)
|
||||
router = points_router
|
||||
@@ -1,234 +0,0 @@
|
||||
"""Scripts AI 能力路由 — Issue #1893.
|
||||
|
||||
三个 AI 工具接口(均挂载在 /api/v1/scripts 前缀下):
|
||||
- POST /extract-from-douyin 从抖音视频提取文案(yt-dlp 下载 + ASR 转写)
|
||||
- POST /ai-rewrite AI 文案改写(复用豆包 LLM)
|
||||
- POST /ai-generate-titles AI 标题生成(复用 generate_smart_titles)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.schemas.scripts_ai import (
|
||||
AiGenerateTitlesRequest,
|
||||
AiGenerateTitlesResponse,
|
||||
AiRewriteRequest,
|
||||
AiRewriteResponse,
|
||||
ExtractFromDouyinRequest,
|
||||
ExtractFromDouyinResponse,
|
||||
)
|
||||
from app.services.script_asr_service import (
|
||||
ASRNotConfiguredError,
|
||||
ASRTranscriptionError,
|
||||
transcribe_to_text,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from packages.shared.ai_client import get_doubao_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 抖音 URL 校验:支持短链 v.douyin.com 和长链 www.douyin.com/video/
|
||||
_DOUYIN_URL_RE = re.compile(
|
||||
r"^(https?://)?(v\.douyin\.com/\S+|www\.douyin\.com/video/\S+)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _validate_douyin_url(url: str) -> None:
|
||||
"""校验抖音 URL 格式,不合法时抛 HTTPException(400)."""
|
||||
if not url or not url.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="链接不能为空",
|
||||
)
|
||||
if not _DOUYIN_URL_RE.match(url.strip()):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无效的抖音链接,仅支持 v.douyin.com 短链或 www.douyin.com/video/ 长链",
|
||||
)
|
||||
|
||||
|
||||
# ── 1. 从抖音视频提取文案 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/extract-from-douyin",
|
||||
response_model=ExtractFromDouyinResponse,
|
||||
)
|
||||
def extract_from_douyin(
|
||||
request: ExtractFromDouyinRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> ExtractFromDouyinResponse:
|
||||
"""从抖音视频下载无水印视频并通过 ASR 提取文案."""
|
||||
source_url = request.url.strip()
|
||||
_validate_douyin_url(source_url)
|
||||
|
||||
# 确保 URL 有 scheme(yt-dlp 需要完整 URL)
|
||||
url_for_download = source_url
|
||||
if not re.match(r"^https?://", url_for_download, re.IGNORECASE):
|
||||
url_for_download = "https://" + url_for_download
|
||||
|
||||
# 使用临时目录下载视频,退出时自动清理
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="douyin_extract_") as temp_dir:
|
||||
import yt_dlp
|
||||
|
||||
ydl_opts = {
|
||||
"format": "best[ext=mp4]/best",
|
||||
"outtmpl": f"{temp_dir}/%(id)s.%(ext)s",
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"noplaylist": True,
|
||||
}
|
||||
|
||||
try:
|
||||
ydl = yt_dlp.YoutubeDL(ydl_opts)
|
||||
info = ydl.extract_info(url_for_download, download=True)
|
||||
except Exception as exc:
|
||||
logger.error("抖音视频下载失败: url=%s error=%s", source_url, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"视频下载失败: {exc}",
|
||||
) from exc
|
||||
|
||||
if info is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无法解析该抖音链接",
|
||||
)
|
||||
|
||||
video_path = ydl.prepare_filename(info)
|
||||
duration = float(info.get("duration") or 0)
|
||||
|
||||
# ASR 转写
|
||||
try:
|
||||
text = transcribe_to_text(video_path)
|
||||
except ASRNotConfiguredError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except ASRTranscriptionError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
return ExtractFromDouyinResponse(
|
||||
text=text,
|
||||
duration_seconds=duration,
|
||||
source_url=source_url,
|
||||
)
|
||||
|
||||
|
||||
# ── 2. AI 文案改写 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/ai-rewrite",
|
||||
response_model=AiRewriteResponse,
|
||||
)
|
||||
def ai_rewrite(
|
||||
request: AiRewriteRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> AiRewriteResponse:
|
||||
"""使用豆包大模型改写文案."""
|
||||
content = (request.content or "").strip()
|
||||
if not content:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="文案内容不能为空",
|
||||
)
|
||||
|
||||
style = request.style or "口语化"
|
||||
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="AI 服务不可用,请联系管理员配置豆包大模型 API Key",
|
||||
)
|
||||
|
||||
system_prompt = (
|
||||
"你是一个专业的短视频文案改写专家。请对以下文案进行改写,"
|
||||
"要求:保留原意、口语化、适合短视频口播、调整语序避免查重。"
|
||||
)
|
||||
if style:
|
||||
system_prompt += f"\n风格要求:{style}"
|
||||
|
||||
user_prompt = f"请改写以下文案:\n\n{content}"
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
|
||||
try:
|
||||
rewritten = client.chat_completion(
|
||||
messages=messages,
|
||||
temperature=0.8,
|
||||
max_tokens=2048,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("AI 改写调用失败: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"AI 改写失败: {exc}",
|
||||
) from exc
|
||||
|
||||
if not rewritten:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="AI 改写未返回有效结果",
|
||||
)
|
||||
|
||||
return AiRewriteResponse(
|
||||
original=content,
|
||||
rewritten=rewritten.strip(),
|
||||
style=style,
|
||||
)
|
||||
|
||||
|
||||
# ── 3. AI 标题生成 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/ai-generate-titles",
|
||||
response_model=AiGenerateTitlesResponse,
|
||||
)
|
||||
def ai_generate_titles(
|
||||
request: AiGenerateTitlesRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> AiGenerateTitlesResponse:
|
||||
"""使用现有 generate_smart_titles 生成标题."""
|
||||
content = (request.content or "").strip()
|
||||
if not content:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="文案内容不能为空",
|
||||
)
|
||||
|
||||
# count 限制在 1-5(Pydantic ge=1 le=5 已校验),但为兼容直接调用场景截断
|
||||
count = max(1, min(5, request.count))
|
||||
|
||||
from app.services.ai_service import generate_smart_titles
|
||||
|
||||
result = generate_smart_titles(
|
||||
description=content,
|
||||
style="viral",
|
||||
count=count,
|
||||
)
|
||||
|
||||
titles = result.get("titles", [])[:count]
|
||||
|
||||
return AiGenerateTitlesResponse(titles=titles)
|
||||
@@ -4,7 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_user_repository
|
||||
@@ -66,7 +67,7 @@ def _get_plan_price(plan_id: str, billing_cycle: str) -> float:
|
||||
|
||||
def _build_subscription_info(user: AuthenticatedUser) -> SubscriptionInfo:
|
||||
"""构建订阅信息响应"""
|
||||
now = datetime.now(UTC)
|
||||
now = datetime.now(timezone.utc)
|
||||
if user.user.subscription_expires_at:
|
||||
period_end = user.user.subscription_expires_at.isoformat()
|
||||
period_start = now.isoformat()
|
||||
@@ -99,10 +100,10 @@ async def get_current_subscription(
|
||||
return _build_subscription_info(current_user)
|
||||
|
||||
|
||||
@router.get("/billing-records", response_model=list[BillingRecord])
|
||||
@router.get("/billing-records", response_model=List[BillingRecord])
|
||||
async def get_billing_records(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> list[BillingRecord]:
|
||||
) -> List[BillingRecord]:
|
||||
"""获取账单记录列表"""
|
||||
from packages.adapters.sqlalchemy_impl.billing_repository import SQLAlchemyBillingRepository
|
||||
from packages.adapters.sqlalchemy_impl.session import SessionLocal
|
||||
@@ -250,7 +251,7 @@ async def payment_callback(
|
||||
|
||||
# 计算到期时间
|
||||
days = 365 if billing_cycle == "yearly" else 30
|
||||
expires_at = datetime.now(UTC) + timedelta(days=days)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=days)
|
||||
repo.update_subscription_on_payment(user_id, plan, expires_at)
|
||||
|
||||
return {"success": True, "message": "支付成功", "record_id": record_id}
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
保留:
|
||||
- GET /templates:列表查询(生成页使用)
|
||||
- 默认模板自动创建兜底逻辑(复用 _default_template.get_or_create_default_template_id)
|
||||
|
||||
其他模板 CRUD / 分类 / 标签 / 收藏 / 复制 / 校验 / 使用统计等 HTTP 端点
|
||||
已在 PR#1918 中删除(前端 PR#1911 已删除 my-templates / editing-planner /
|
||||
templates 管理页面)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,10 +11,7 @@ import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.template import (
|
||||
ListTemplatesResponse,
|
||||
TemplateResponse,
|
||||
)
|
||||
from app.schemas.template import ListTemplatesResponse, TemplateResponse
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -50,7 +43,7 @@ def list_templates(
|
||||
):
|
||||
"""获取用户可用的模板列表(仅返回 active 状态)。"""
|
||||
user_id = str(current_user.user.id)
|
||||
# P0 兜底:无有效模板时自动创建默认配音模板(解决新用户首次进入生成页 404)
|
||||
# P0 兜底:无有效模板时自动创建默认配音模板
|
||||
get_or_create_default_template_id(db, user_id)
|
||||
|
||||
list_uc = ListTemplatesUseCase(repo)
|
||||
@@ -59,7 +52,7 @@ def list_templates(
|
||||
category=category,
|
||||
tag=tag,
|
||||
mode=mode,
|
||||
valid_only=True, # 仅返回 active + 有片段配置
|
||||
valid_only=True,
|
||||
)
|
||||
skip = (page - 1) * page_size
|
||||
templates = list_uc.execute(user_id, skip=skip, limit=page_size, filter=filters)
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
|
||||
核心依赖:
|
||||
- get_editor_services: 获取模板+计划服务
|
||||
- get_draft_plan_id: Depends 形式的路径依赖(template_id 路径参数必填)
|
||||
- resolve_draft_plan_id: 纯函数版本,供 clips_standalone 等非路径参数场景复用
|
||||
(支持空 tid 时自动兜底创建默认模板)
|
||||
- get_draft_plan_id: 根据 template_id 获取或创建草稿,返回 plan_id
|
||||
- resolve_draft_plan_id: 纯函数版本(可在非依赖场景复用)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from app.api.routes._default_template import get_or_create_default_template_id
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
@@ -23,6 +22,8 @@ from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
|
||||
from .._default_template import get_or_create_default_template_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -34,55 +35,53 @@ def get_editor_services(
|
||||
|
||||
|
||||
def resolve_draft_plan_id(
|
||||
template_id: str,
|
||||
template_id: Optional[str],
|
||||
services: tuple[EditTemplateService, EditPlanService],
|
||||
current_user: AuthenticatedUser,
|
||||
db: Session,
|
||||
auto_create_default: bool = True,
|
||||
) -> str:
|
||||
"""根据 template_id 获取或创建草稿,返回 plan_id(纯函数,不带 Depends)。
|
||||
"""纯函数:根据 template_id 获取或创建草稿 plan_id(可在独立端点复用)。
|
||||
|
||||
当 auto_create_default=True 且 template_id 为空时,自动调用
|
||||
get_or_create_default_template_id 创建默认模板(用于 clips_standalone
|
||||
等非路径参数场景)。
|
||||
- template_id 为空且 auto_create_default=True 时,自动兜底创建/查找默认模板
|
||||
- 返回有效 plan_id;模板不存在/无权限时抛 404
|
||||
"""
|
||||
tpl_svc, plan_svc = services
|
||||
user_id = str(current_user.user.id)
|
||||
|
||||
# 0. 空 tid 兜底
|
||||
if not template_id:
|
||||
if auto_create_default:
|
||||
tid = get_or_create_default_template_id(db, user_id)
|
||||
if not tid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="无法自动创建默认模板,请刷新页面重试",
|
||||
)
|
||||
template_id = tid
|
||||
else:
|
||||
# P0 兜底:空 template_id 时自动创建/查找默认模板
|
||||
if not template_id and auto_create_default:
|
||||
template_id = get_or_create_default_template_id(db, user_id)
|
||||
if not template_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="template_id 不能为空",
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="无法自动创建默认模板,请刷新页面重试",
|
||||
)
|
||||
|
||||
# 1. 门禁:校验模板存在且可访问
|
||||
if not template_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="缺少 template_id 参数",
|
||||
)
|
||||
|
||||
# 0. 门禁:校验模板存在且可访问
|
||||
old_repo = SQLAlchemyTemplateRepository(db)
|
||||
old_template = old_repo.get_active(template_id, user_id)
|
||||
is_global_template = tpl_svc.get_template(template_id) is not None
|
||||
if old_template is None and not is_global_template:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
|
||||
|
||||
# 2. 草稿已存在 → 直接返回
|
||||
# 1. 草稿已存在 → 直接返回
|
||||
draft = tpl_svc.get_template_draft(template_id)
|
||||
if draft is not None:
|
||||
return draft.id
|
||||
|
||||
# 3. 全局模板(新系统)→ 用新服务创建草稿
|
||||
# 2. 全局模板(新系统)→ 用新服务创建草稿
|
||||
if is_global_template:
|
||||
draft = tpl_svc.create_template_draft(template_id, user_id=user_id)
|
||||
return draft.id
|
||||
|
||||
# 4. 旧模板(templates 表)→ 基于旧模板创建草稿计划
|
||||
# 3. 旧模板(templates 表)→ 基于旧模板创建草稿计划
|
||||
from app.services.plan_generator_service import PlanGeneratorService
|
||||
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
@@ -135,15 +134,11 @@ def get_draft_plan_id(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
) -> str:
|
||||
"""路径依赖:根据 template_id 获取或创建草稿,返回 plan_id.
|
||||
|
||||
Depends 版本:路径参数 template_id 由 FastAPI 保证非空,不自动兜底。
|
||||
兜底逻辑走 resolve_draft_plan_id(auto_create_default=False)。
|
||||
"""
|
||||
"""FastAPI 依赖:路径参数 {template_id} 下获取/创建草稿 plan_id。"""
|
||||
return resolve_draft_plan_id(
|
||||
template_id=template_id,
|
||||
services=services,
|
||||
current_user=current_user,
|
||||
db=db,
|
||||
auto_create_default=False,
|
||||
auto_create_default=False, # 路径参数路由不兜底(路径里本就应有值)
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re as _re
|
||||
from typing import Any, Optional
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator, validator
|
||||
|
||||
@@ -21,7 +21,7 @@ _EXPORT_VALID_FORMATS = {"mp4", "mov"}
|
||||
class AIRecommendRequest(BaseModel):
|
||||
"""AI 推荐片段方案请求体"""
|
||||
|
||||
asset_ids: list[str] = Field(default_factory=list, description="素材 ID 列表")
|
||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表")
|
||||
editing_mode: str = Field(default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip")
|
||||
target_duration: float = Field(default=30.0, ge=1.0, le=600.0, description="目标时长(秒)")
|
||||
|
||||
@@ -44,7 +44,7 @@ class AIRecommendResponse(BaseModel):
|
||||
"""AI 推荐片段方案响应体"""
|
||||
|
||||
plan_id: str = Field(..., description="剪辑计划 ID")
|
||||
clips: list[AIRecommendClipItem] = Field(..., description="推荐的片段列表")
|
||||
clips: List[AIRecommendClipItem] = Field(..., description="推荐的片段列表")
|
||||
config: dict[str, Any] = Field(..., description="推荐的 plan config(cover/title/subtitle/bgm)")
|
||||
total_duration: float = Field(..., ge=0.0, description="推荐方案总时长(秒)")
|
||||
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
|
||||
@@ -137,7 +137,7 @@ class ClipReorderItem(BaseModel):
|
||||
class ClipReorderRequest(BaseModel):
|
||||
"""片段重排序请求"""
|
||||
|
||||
items: list[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表")
|
||||
items: List[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表")
|
||||
|
||||
|
||||
class ClipReorderResponse(BaseModel):
|
||||
@@ -151,7 +151,7 @@ class ClipReorderResponse(BaseModel):
|
||||
class ClipBatchDeleteRequest(BaseModel):
|
||||
"""批量删除片段请求"""
|
||||
|
||||
clip_ids: list[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表")
|
||||
clip_ids: List[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表")
|
||||
|
||||
|
||||
class ClipBatchDeleteResponse(BaseModel):
|
||||
@@ -169,7 +169,7 @@ _UNSET = object()
|
||||
class ClipsFromAssetsRequest(BaseModel):
|
||||
"""从素材批量创建片段请求"""
|
||||
|
||||
asset_ids: list[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
|
||||
asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
|
||||
clip_type: str = Field(default="main", description="片段类型,默认 main")
|
||||
clip_count: Optional[int] = Field(
|
||||
default=None,
|
||||
@@ -218,7 +218,7 @@ class ClipsFromAssetsResponse(BaseModel):
|
||||
created_count: int
|
||||
plan_id: str = ""
|
||||
message: str = ""
|
||||
clip_ids: list[str] = Field(default_factory=list, description="创建的片段ID列表")
|
||||
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
|
||||
duplicate_warning: Optional[str] = Field(default=None, description="查重率超标警告")
|
||||
exhaustion_warning: Optional[str] = Field(default=None, description="素材耗尽警告")
|
||||
|
||||
@@ -302,7 +302,7 @@ class ExportPresetItem(BaseModel):
|
||||
class ExportPresetListResponse(BaseModel):
|
||||
"""导出预设列表响应"""
|
||||
|
||||
items: list[ExportPresetItem]
|
||||
items: List[ExportPresetItem]
|
||||
total: int
|
||||
|
||||
|
||||
@@ -316,7 +316,7 @@ class FilterPresetResponse(BaseModel):
|
||||
name: str
|
||||
category: str
|
||||
description: str
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FilterConfigResponse(BaseModel):
|
||||
@@ -346,7 +346,7 @@ class FilterUpdateRequest(BaseModel):
|
||||
class FilterPresetListResponse(BaseModel):
|
||||
"""滤镜预设列表响应"""
|
||||
|
||||
items: list[FilterPresetResponse]
|
||||
items: List[FilterPresetResponse]
|
||||
total: int
|
||||
|
||||
|
||||
@@ -360,7 +360,7 @@ class TransitionPresetResponse(BaseModel):
|
||||
name: str
|
||||
category: str
|
||||
description: str
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
default_duration: float
|
||||
min_duration: float
|
||||
max_duration: float
|
||||
@@ -402,7 +402,7 @@ class BatchTransitionResponse(BaseModel):
|
||||
class TransitionPresetListResponse(BaseModel):
|
||||
"""转场预设列表响应"""
|
||||
|
||||
items: list[TransitionPresetResponse]
|
||||
items: List[TransitionPresetResponse]
|
||||
total: int
|
||||
|
||||
|
||||
@@ -458,7 +458,7 @@ class EditorClipResponse(BaseModel):
|
||||
class EditorClipListResponse(BaseModel):
|
||||
"""片段列表响应"""
|
||||
|
||||
items: list[EditorClipResponse]
|
||||
items: List[EditorClipResponse]
|
||||
total: int
|
||||
|
||||
|
||||
@@ -496,7 +496,7 @@ class EditorClipBatchItem(BaseModel):
|
||||
class EditorClipBatchUpdateRequest(BaseModel):
|
||||
"""批量替换clips请求(全量覆盖)"""
|
||||
|
||||
clips: list[EditorClipBatchItem] = Field(default_factory=list)
|
||||
clips: List[EditorClipBatchItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EditorClipBatchUpdateResponse(BaseModel):
|
||||
@@ -584,4 +584,4 @@ class EditorTimelineResponse(BaseModel):
|
||||
|
||||
plan_id: str
|
||||
total_duration: float
|
||||
scenes: list[EditorTimelineSceneResponse]
|
||||
scenes: List[EditorTimelineSceneResponse]
|
||||
|
||||
@@ -287,22 +287,7 @@ def retry_voice_clone(
|
||||
return _to_response(profile)
|
||||
|
||||
|
||||
_ALLOWED_PREVIEW_EMOTIONS = {
|
||||
"",
|
||||
# 7 种标准英文枚举
|
||||
"neutral",
|
||||
"happy",
|
||||
"sad",
|
||||
"angry",
|
||||
"surprised",
|
||||
"fearful",
|
||||
"disgusted",
|
||||
# 旧英文 4 枚举兼容
|
||||
"natural",
|
||||
"excited",
|
||||
"calm",
|
||||
"friendly",
|
||||
}
|
||||
_ALLOWED_PREVIEW_EMOTIONS = {"", "natural", "excited", "calm", "friendly"}
|
||||
|
||||
|
||||
@router.get("/{clone_id}/preview", response_model=VoiceClonePreviewResponse)
|
||||
@@ -310,10 +295,7 @@ def get_voice_clone_preview(
|
||||
clone_id: str,
|
||||
text: str = Query("", description="自定义试听文本,为空则使用默认示例"),
|
||||
speed: float = Query(1.0, ge=0.5, le=2.0, description="语速,0.5-2.0,默认 1.0"),
|
||||
emotion: str = Query(
|
||||
"",
|
||||
description="情绪:neutral/happy/sad/angry/surprised/fearful/disgusted,兼容旧值 natural/excited/calm/friendly,空为默认自然",
|
||||
),
|
||||
emotion: str = Query("", description="情绪:natural/excited/calm/friendly,空字符串为默认自然"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
|
||||
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
@@ -329,7 +311,7 @@ def get_voice_clone_preview(
|
||||
if emotion not in _ALLOWED_PREVIEW_EMOTIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"不支持的 emotion 值: {emotion},可选: neutral/happy/sad/angry/surprised/fearful/disgusted(兼容 natural/excited/calm/friendly)或留空",
|
||||
detail=f"不支持的 emotion 值: {emotion},可选: natural/excited/calm/friendly 或留空",
|
||||
)
|
||||
|
||||
use_case = GetVoiceCloneUseCase(repository)
|
||||
|
||||
@@ -6,7 +6,7 @@ ensuring proper lifecycle management and testability.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import Generator
|
||||
|
||||
import redis
|
||||
from app.config import settings
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
@@ -10,7 +10,7 @@ Exposes:
|
||||
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Request, Response
|
||||
from prometheus_client import (
|
||||
|
||||
@@ -67,9 +67,7 @@ class CreateLipsyncJobRequest(BaseModel):
|
||||
voice_id: str = Field("", description="音色 ID(预置音色或克隆音色 profile UUID)")
|
||||
script_text: str = Field("", description="要合成的文案(直生模式必填,最长 5000 字符)")
|
||||
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0")
|
||||
emotion: str = Field(
|
||||
"", description="情绪(英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted,兼容中文/旧值;空为默认)"
|
||||
)
|
||||
emotion: str = Field("", description="情绪(中文/英文:自然/兴奋/沉稳/亲切/开心/悲伤/愤怒/惊讶/恐惧/厌恶 等)")
|
||||
|
||||
enable_video_loop: bool = Field(
|
||||
True, description="音频长于视频时是否循环画面(AI数字人默认开启,防止音频长于视频被截断)"
|
||||
@@ -122,11 +120,7 @@ class AiAvatarTtsPreviewRequest(BaseModel):
|
||||
voice_id: str = Field(..., min_length=1, max_length=128, description="音色 ID")
|
||||
script_text: str = Field(..., min_length=1, max_length=5000, description="要合成的文案")
|
||||
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0")
|
||||
emotion: str = Field(
|
||||
"neutral",
|
||||
max_length=32,
|
||||
description="情绪(英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted,或中文/旧值)",
|
||||
)
|
||||
emotion: str = Field("natural", max_length=32, description="情绪")
|
||||
|
||||
|
||||
class AiAvatarTtsPreviewResponse(BaseModel):
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
"""积分 & 会员相关 Pydantic Schema (#1895)"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ============ 余额 & 账户 ============
|
||||
|
||||
|
||||
class PointsBalanceResponse(BaseModel):
|
||||
"""积分余额 + 会员状态"""
|
||||
|
||||
balance: int = Field(..., description="当前积分余额")
|
||||
total_earned: int = Field(..., description="累计获得积分")
|
||||
total_spent: int = Field(..., description="累计消耗积分")
|
||||
is_member: bool = Field(default=False, description="是否付费会员")
|
||||
member_type: Optional[str] = Field(None, description="会员类型: monthly/quarterly/yearly")
|
||||
member_expires_at: Optional[datetime] = Field(None, description="会员到期时间")
|
||||
|
||||
|
||||
# ============ 流水 ============
|
||||
|
||||
|
||||
class PointsTransactionItem(BaseModel):
|
||||
"""单条积分流水"""
|
||||
|
||||
id: str
|
||||
type: str = Field(..., description="类型: add/deduct")
|
||||
source: str = Field(..., description="来源场景")
|
||||
amount: int
|
||||
balance_after: int
|
||||
description: str = ""
|
||||
ref_id: str = ""
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class PointsTransactionsResponse(BaseModel):
|
||||
"""积分流水分页响应"""
|
||||
|
||||
items: list[PointsTransactionItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
# ============ 规则 & 积分包 ============
|
||||
|
||||
|
||||
class PointRuleItem(BaseModel):
|
||||
"""单条积分规则"""
|
||||
|
||||
scene_key: str
|
||||
name: str
|
||||
base_points: int
|
||||
unit: str
|
||||
extra_per_30s: Optional[int] = None
|
||||
|
||||
|
||||
class PointsRulesResponse(BaseModel):
|
||||
"""所有积分消耗规则"""
|
||||
|
||||
rules: list[PointRuleItem]
|
||||
free_user_multiplier: float = Field(..., description="免费用户积分上浮系数")
|
||||
|
||||
|
||||
class PointsPackageItem(BaseModel):
|
||||
"""积分包信息"""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
points: int
|
||||
price_cents: int
|
||||
unit_price: str = Field("", description="单价描述,如 ¥0.099/积分")
|
||||
|
||||
|
||||
class PointsPackagesResponse(BaseModel):
|
||||
"""可购买的积分包列表"""
|
||||
|
||||
packages: list[PointsPackageItem]
|
||||
user_discount: Optional[float] = Field(None, description="当前用户折扣(会员)")
|
||||
|
||||
|
||||
# ============ 消费前检查 ============
|
||||
|
||||
|
||||
class PointsCheckRequest(BaseModel):
|
||||
"""消费前余额检查请求"""
|
||||
|
||||
scene_key: str
|
||||
duration_minutes: Optional[float] = None
|
||||
quantity: Optional[int] = 1
|
||||
|
||||
|
||||
class PointsCheckResponse(BaseModel):
|
||||
"""消费前余额检查响应"""
|
||||
|
||||
allowed: bool
|
||||
required_points: int
|
||||
current_balance: int
|
||||
remaining_after: int
|
||||
is_free_quota: bool = False
|
||||
|
||||
|
||||
# ============ 手动扣减 / 退还(内部接口) ============
|
||||
|
||||
|
||||
class PointsDeductRequest(BaseModel):
|
||||
"""积分扣减请求"""
|
||||
|
||||
scene_key: str
|
||||
amount: int
|
||||
description: Optional[str] = ""
|
||||
ref_id: Optional[str] = ""
|
||||
|
||||
|
||||
class PointsRefundRequest(BaseModel):
|
||||
"""积分退还请求"""
|
||||
|
||||
transaction_id: str
|
||||
reason: Optional[str] = ""
|
||||
|
||||
|
||||
class PointsRechargeRequest(BaseModel):
|
||||
"""积分充值请求"""
|
||||
|
||||
package_id: str = Field(..., description="积分包 code,如 starter_pack")
|
||||
|
||||
|
||||
# ============ 订单 ============
|
||||
|
||||
|
||||
class PointsOrderResponse(BaseModel):
|
||||
"""订单信息"""
|
||||
|
||||
id: str
|
||||
order_type: str
|
||||
product_code: str
|
||||
amount_cents: int
|
||||
status: str
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
# ============ 每日额度 ============
|
||||
|
||||
|
||||
class DailyUsageResponse(BaseModel):
|
||||
"""今日免费额度使用情况"""
|
||||
|
||||
free_clips_used: int
|
||||
free_clips_limit: int
|
||||
free_clips_remaining: int
|
||||
reset_at: str
|
||||
|
||||
|
||||
# ============ 会员状态(聚合) ============
|
||||
|
||||
|
||||
class MembershipStatusResponse(BaseModel):
|
||||
"""当前用户会员状态(聚合信息)"""
|
||||
|
||||
is_member: bool
|
||||
member_type: Optional[str] = None
|
||||
member_expires_at: Optional[datetime] = None
|
||||
points_balance: int
|
||||
max_resolution: str = Field(
|
||||
default="1080p",
|
||||
description="可用最高分辨率: 720p(free) / 1080p(paid)",
|
||||
)
|
||||
|
||||
|
||||
# ============ 通用响应 ============
|
||||
|
||||
|
||||
class SimpleMessageResponse(BaseModel):
|
||||
"""简单消息响应"""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
data: Optional[dict[str, Any]] = None
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -20,8 +20,8 @@ class ScriptResponse(BaseModel):
|
||||
user_id: str
|
||||
title: str
|
||||
content: str
|
||||
segments: list[ScriptSegment] = Field(default_factory=list)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
segments: List[ScriptSegment] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -34,12 +34,12 @@ class ScriptListResponse(BaseModel):
|
||||
class CreateScriptRequest(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
content: str = ""
|
||||
segments: list[ScriptSegment] = Field(default_factory=list)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
segments: List[ScriptSegment] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UpdateScriptRequest(BaseModel):
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
content: Optional[str] = None
|
||||
segments: Optional[list[ScriptSegment]] = None
|
||||
tags: Optional[list[str]] = None
|
||||
segments: Optional[List[ScriptSegment]] = None
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Scripts AI 能力 Pydantic schemas — Issue #1893.
|
||||
|
||||
抖音文案提取、AI 改写、AI 标题生成的请求/响应模型。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ── 抖音文案提取 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ExtractFromDouyinRequest(BaseModel):
|
||||
"""从抖音视频提取文案请求."""
|
||||
|
||||
url: str = Field(..., description="抖音视频链接(短链或长链)")
|
||||
|
||||
|
||||
class ExtractFromDouyinResponse(BaseModel):
|
||||
"""从抖音视频提取文案响应."""
|
||||
|
||||
text: str = Field(..., description="ASR 识别出的文案文本")
|
||||
duration_seconds: float = Field(..., description="视频时长(秒)")
|
||||
source_url: str = Field(..., description="原始视频链接")
|
||||
|
||||
|
||||
# ── AI 改写 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AiRewriteRequest(BaseModel):
|
||||
"""AI 文案改写请求."""
|
||||
|
||||
content: str = Field(..., description="原文内容")
|
||||
style: Optional[str] = Field("口语化", description="改写风格,如 口语化/正式/活泼")
|
||||
|
||||
|
||||
class AiRewriteResponse(BaseModel):
|
||||
"""AI 文案改写响应."""
|
||||
|
||||
original: str = Field(..., description="原文")
|
||||
rewritten: str = Field(..., description="改写后的文案")
|
||||
style: str = Field(..., description="使用的改写风格")
|
||||
|
||||
|
||||
# ── AI 标题生成 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AiGenerateTitlesRequest(BaseModel):
|
||||
"""AI 标题生成请求."""
|
||||
|
||||
content: str = Field(..., description="文案内容")
|
||||
count: int = Field(3, ge=1, le=5, description="生成标题数量(1-5,默认3)")
|
||||
|
||||
|
||||
class AiGenerateTitlesResponse(BaseModel):
|
||||
"""AI 标题生成响应."""
|
||||
|
||||
titles: List[str] = Field(..., description="生成的标题列表")
|
||||
@@ -8,7 +8,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -42,12 +42,12 @@ class TemplateResponse(BaseModel):
|
||||
name: str
|
||||
mode: str
|
||||
category: str = ""
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
title_config: dict[str, Any] = Field(default_factory=dict)
|
||||
subtitle_config: dict[str, Any] = Field(default_factory=dict)
|
||||
bgm_config: dict[str, Any] = Field(default_factory=dict)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
title_config: Dict[str, Any] = Field(default_factory=dict)
|
||||
subtitle_config: Dict[str, Any] = Field(default_factory=dict)
|
||||
bgm_config: Dict[str, Any] = Field(default_factory=dict)
|
||||
estimated_duration: float = 0.0
|
||||
segments: list[SegmentResponse] = Field(default_factory=list)
|
||||
segments: List[SegmentResponse] = Field(default_factory=list)
|
||||
is_active: bool = True
|
||||
is_favorite: bool = False
|
||||
usage_count: int = 0
|
||||
@@ -56,7 +56,7 @@ class TemplateResponse(BaseModel):
|
||||
|
||||
|
||||
class ListTemplatesResponse(BaseModel):
|
||||
items: list[TemplateResponse]
|
||||
items: List[TemplateResponse]
|
||||
total: int = 0
|
||||
|
||||
|
||||
@@ -67,12 +67,12 @@ class CreateTemplateRequest(BaseModel):
|
||||
name: str
|
||||
mode: str
|
||||
category: str = ""
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
title_config: dict[str, Any] = Field(default_factory=dict)
|
||||
subtitle_config: dict[str, Any] = Field(default_factory=dict)
|
||||
bgm_config: dict[str, Any] = Field(default_factory=dict)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
title_config: Dict[str, Any] = Field(default_factory=dict)
|
||||
subtitle_config: Dict[str, Any] = Field(default_factory=dict)
|
||||
bgm_config: Dict[str, Any] = Field(default_factory=dict)
|
||||
estimated_duration: float = 0.0
|
||||
segments: list[SegmentRequest] = Field(default_factory=list)
|
||||
segments: List[SegmentRequest] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GenerateWarningResponse(BaseModel):
|
||||
@@ -80,4 +80,4 @@ class GenerateWarningResponse(BaseModel):
|
||||
|
||||
code: str
|
||||
message: str
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
details: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -15,7 +15,7 @@ class TitleLibraryItemResponse(BaseModel):
|
||||
text: str
|
||||
category: str = "default"
|
||||
description: str = ""
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
usage_count: int = 0
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
@@ -32,7 +32,7 @@ class CreateTitleLibraryRequest(BaseModel):
|
||||
text: str = Field(..., min_length=1, max_length=500)
|
||||
category: str = "default"
|
||||
description: str = ""
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UpdateTitleLibraryRequest(BaseModel):
|
||||
@@ -40,4 +40,4 @@ class UpdateTitleLibraryRequest(BaseModel):
|
||||
text: Optional[str] = Field(None, min_length=1, max_length=500)
|
||||
category: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
tags: Optional[list[str]] = None
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -23,7 +23,7 @@ class TTSSynthesizeRequest(BaseModel):
|
||||
voice_model: str = Field("", description="语音模型名称")
|
||||
voice_clone_profile_id: str = Field("", description="关联的音色克隆档案 ID")
|
||||
format: str = Field("mp3", description="输出格式(mp3/wav/pcm)")
|
||||
metadata_: Optional[dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
|
||||
metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
@@ -49,7 +49,7 @@ class TTSJobResponse(BaseModel):
|
||||
error_message: str = ""
|
||||
retry_count: int = 0
|
||||
max_retries: int = 3
|
||||
metadata_: Optional[dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
|
||||
metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
@@ -83,7 +83,7 @@ class TTSSynthesizeResponse(BaseModel):
|
||||
class ListTTSJobResponse(BaseModel):
|
||||
"""TTS 任务列表响应。"""
|
||||
|
||||
items: list[TTSJobResponse]
|
||||
items: List[TTSJobResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -61,7 +61,7 @@ class ShareResponse(BaseModel):
|
||||
class ShareListResponse(BaseModel):
|
||||
"""分享列表响应."""
|
||||
|
||||
items: list[ShareResponse]
|
||||
items: List[ShareResponse]
|
||||
total: int = 0
|
||||
skip: int = 0
|
||||
limit: int = 20
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -56,7 +56,7 @@ class UnifiedVoiceItemResponse(BaseModel):
|
||||
status: str = "completed"
|
||||
"""状态"""
|
||||
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
"""标签列表"""
|
||||
|
||||
# 克隆音色特有字段
|
||||
@@ -113,7 +113,7 @@ class PresetVoiceItemResponse(BaseModel):
|
||||
preview_url: str = ""
|
||||
"""预览音频 URL"""
|
||||
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
"""标签列表"""
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -19,7 +19,7 @@ class CreateVoiceCloneRequest(BaseModel):
|
||||
language: str = Field("zh-CN", description="语言")
|
||||
gender: str = Field("unknown", description="性别")
|
||||
max_retries: int = Field(3, ge=1, le=10, description="最大重试次数")
|
||||
metadata_: Optional[dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
|
||||
metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
@@ -41,7 +41,7 @@ class VoiceCloneProfileResponse(BaseModel):
|
||||
error_message: str = ""
|
||||
retry_count: int = 0
|
||||
max_retries: int = 3
|
||||
metadata_: Optional[dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
|
||||
metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -62,7 +62,7 @@ class VoiceCloneStatusResponse(BaseModel):
|
||||
class ListVoiceCloneResponse(BaseModel):
|
||||
"""音色克隆列表响应。"""
|
||||
|
||||
items: list[VoiceCloneProfileResponse]
|
||||
items: List[VoiceCloneProfileResponse]
|
||||
total: int
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -21,7 +21,7 @@ class VoiceLibraryItemResponse(BaseModel):
|
||||
file_size: int = 0
|
||||
status: str = "completed"
|
||||
project_id: Optional[str] = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -42,7 +42,7 @@ class CreateVoiceLibraryRequest(BaseModel):
|
||||
file_size: int = 0
|
||||
status: str = "completed"
|
||||
project_id: Optional[str] = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UpdateVoiceLibraryRequest(BaseModel):
|
||||
@@ -55,4 +55,4 @@ class UpdateVoiceLibraryRequest(BaseModel):
|
||||
duration: Optional[float] = None
|
||||
file_size: Optional[int] = None
|
||||
status: Optional[str] = None
|
||||
tags: Optional[list[str]] = None
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
@@ -16,7 +16,7 @@ import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -117,7 +117,7 @@ class AiAvatarRenderService:
|
||||
self.db.add(job)
|
||||
self.db.flush()
|
||||
|
||||
job.submitted_at = datetime.now(UTC)
|
||||
job.submitted_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
@@ -164,7 +164,7 @@ class AiAvatarRenderService:
|
||||
return None
|
||||
if job.status in ("pending", "submitted"):
|
||||
job.status = "cancelled"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
@@ -186,7 +186,7 @@ class AiAvatarRenderService:
|
||||
job.output_duration = 0.0
|
||||
job.started_at = None
|
||||
job.completed_at = None
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
@@ -215,9 +215,9 @@ class AiAvatarRenderService:
|
||||
try:
|
||||
# 更新状态为 processing
|
||||
job.status = "processing"
|
||||
job.started_at = datetime.now(UTC)
|
||||
job.started_at = datetime.now(timezone.utc)
|
||||
job.progress = 5
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
|
||||
# 获取对口型任务信息
|
||||
@@ -394,8 +394,8 @@ class AiAvatarRenderService:
|
||||
# 6. 完成
|
||||
job.status = "completed"
|
||||
job.progress = 100
|
||||
job.completed_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
logger.info("渲染任务完成: %s", job_id)
|
||||
|
||||
@@ -406,14 +406,14 @@ class AiAvatarRenderService:
|
||||
except AiAvatarRenderError as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = str(exc)
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
logger.error("渲染任务失败 [%s]: %s", job_id, exc)
|
||||
raise
|
||||
except Exception as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = f"渲染异常: {str(exc)}"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
logger.exception("渲染任务异常 [%s]", job_id)
|
||||
raise
|
||||
@@ -494,7 +494,7 @@ class AiAvatarRenderService:
|
||||
# 同步更新 cover_config,保持 smart-cover 路径一致
|
||||
if isinstance(job.cover_config, dict):
|
||||
job.cover_config = {**job.cover_config, "mode": "auto_frame", "url": cover_url.strip()}
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
|
||||
return self._persist_to_library(job, cover_url=cover_url)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from packages.domain.ai_parsing import generate_titles_fallback as _generate_titles_fallback_base
|
||||
from packages.domain.ai_parsing import keyword_match_fallback as _semantic_match_fallback_base
|
||||
@@ -64,7 +64,7 @@ def _generate_titles_fallback(
|
||||
description: str,
|
||||
style: str = "viral",
|
||||
count: int = 5,
|
||||
) -> list[str]:
|
||||
) -> List[str]:
|
||||
"""本地降级:基于模板规则生成标题(薄包装,转发到 ai_parsing 模块)."""
|
||||
style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"])
|
||||
return _generate_titles_fallback_base(description, style_info, count)
|
||||
@@ -74,7 +74,7 @@ def generate_smart_titles(
|
||||
description: str,
|
||||
style: str = "viral",
|
||||
count: int = 5,
|
||||
) -> dict[str, Any]:
|
||||
) -> Dict[str, Any]:
|
||||
"""生成智能标题.
|
||||
|
||||
Args:
|
||||
@@ -164,16 +164,16 @@ def generate_smart_titles(
|
||||
|
||||
def _semantic_match_fallback(
|
||||
description: str,
|
||||
assets: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
assets: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""本地降级:基于关键词的简单匹配(薄包装,转发到 ai_parsing 模块)."""
|
||||
return _semantic_match_fallback_base(description, assets)
|
||||
|
||||
|
||||
def _parse_semantic_match_response(
|
||||
content: str,
|
||||
asset_ids: list[str],
|
||||
) -> Optional[dict[str, float]]:
|
||||
asset_ids: List[str],
|
||||
) -> Optional[Dict[str, float]]:
|
||||
"""从模型返回中解析素材匹配度(薄包装,转发到 ai_parsing 模块)."""
|
||||
result = _parse_semantic_match_base(content, asset_ids)
|
||||
if result is None:
|
||||
@@ -183,9 +183,9 @@ def _parse_semantic_match_response(
|
||||
|
||||
def semantic_match_assets(
|
||||
description: str,
|
||||
assets: list[dict[str, Any]],
|
||||
assets: List[Dict[str, Any]],
|
||||
top_k: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
) -> Dict[str, Any]:
|
||||
"""智能素材语义匹配.
|
||||
|
||||
根据用户描述,评估每个素材的语义匹配度并排序。
|
||||
@@ -336,13 +336,13 @@ class AIService:
|
||||
description: str,
|
||||
style: str = "viral",
|
||||
count: int = 5,
|
||||
) -> dict[str, Any]:
|
||||
) -> Dict[str, Any]:
|
||||
return generate_smart_titles(description, style, count)
|
||||
|
||||
def semantic_match(
|
||||
self,
|
||||
description: str,
|
||||
assets: list[dict[str, Any]],
|
||||
assets: List[Dict[str, Any]],
|
||||
top_k: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
) -> Dict[str, Any]:
|
||||
return semantic_match_assets(description, assets, top_k)
|
||||
|
||||
@@ -28,8 +28,8 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -57,7 +57,7 @@ _REUSE_OVERLAP_RATIO = 0.6
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _read_meta(model) -> dict:
|
||||
@@ -156,7 +156,7 @@ def record_used_segments(
|
||||
r["plan_id"] = plan_id
|
||||
meta[USED_RANGES_KEY] = ranges
|
||||
model.classification_result = json.dumps(meta, ensure_ascii=False)
|
||||
model.updated_at = datetime.now(UTC)
|
||||
model.updated_at = datetime.now(timezone.utc)
|
||||
return
|
||||
|
||||
ranges.append(
|
||||
@@ -171,7 +171,7 @@ def record_used_segments(
|
||||
)
|
||||
meta[USED_RANGES_KEY] = ranges
|
||||
model.classification_result = json.dumps(meta, ensure_ascii=False)
|
||||
model.updated_at = datetime.now(UTC)
|
||||
model.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def remove_used_segment(
|
||||
@@ -215,7 +215,7 @@ def remove_used_segment(
|
||||
if removed:
|
||||
meta[USED_RANGES_KEY] = remaining
|
||||
model.classification_result = json.dumps(meta, ensure_ascii=False)
|
||||
model.updated_at = datetime.now(UTC)
|
||||
model.updated_at = datetime.now(timezone.utc)
|
||||
return removed
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ def reset_used_segments(db: Session, asset_id: str) -> None:
|
||||
if meta.get(USED_RANGES_KEY):
|
||||
meta[USED_RANGES_KEY] = []
|
||||
model.classification_result = json.dumps(meta, ensure_ascii=False)
|
||||
model.updated_at = datetime.now(UTC)
|
||||
model.updated_at = datetime.now(timezone.utc)
|
||||
logger.info("[片段追踪] 素材区间记录手动清空: asset_id=%s", asset_id)
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app.services.asset_segment_tracker import (
|
||||
REUSE_RATIO_LIMIT,
|
||||
@@ -206,7 +206,7 @@ class EditPlanService:
|
||||
status: Optional[EditPlanClipStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> list[EditPlanClip]:
|
||||
) -> List[EditPlanClip]:
|
||||
"""列出计划的片段"""
|
||||
# 确保计划存在
|
||||
self.get_plan_or_raise(plan_id)
|
||||
@@ -503,7 +503,7 @@ class EditPlanService:
|
||||
source = self.get_plan_or_raise(source_plan_id)
|
||||
|
||||
# 分页读取源 plan 全部片段
|
||||
clips: list[EditPlanClip] = []
|
||||
clips: List[EditPlanClip] = []
|
||||
skip, page = 0, 500
|
||||
while True:
|
||||
batch = self._clip_repo.list_by_plan(source_plan_id, skip=skip, limit=page)
|
||||
@@ -665,7 +665,7 @@ class EditPlanService:
|
||||
source = self.get_plan_or_raise(source_plan_id)
|
||||
|
||||
# 分页读取源 plan 全部片段
|
||||
clips: list[EditPlanClip] = []
|
||||
clips: List[EditPlanClip] = []
|
||||
skip, page = 0, 500
|
||||
while True:
|
||||
batch = self._clip_repo.list_by_plan(source_plan_id, skip=skip, limit=page)
|
||||
@@ -797,7 +797,7 @@ class EditPlanService:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
clips: list[EditPlanClip] = []
|
||||
clips: List[EditPlanClip] = []
|
||||
skip, page = 0, 500
|
||||
while True:
|
||||
batch = self._clip_repo.list_by_plan(plan_id, skip=skip, limit=page)
|
||||
@@ -1063,7 +1063,7 @@ class EditPlanService:
|
||||
|
||||
# ── 片段分割与合并 ──────────────────────────────────────────────────────
|
||||
|
||||
def split_clip(self, clip_id: str, split_time: float) -> dict[str, Any]:
|
||||
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
|
||||
"""将一个片段从指定位置分割为两个片段
|
||||
|
||||
Args:
|
||||
@@ -1151,7 +1151,7 @@ class EditPlanService:
|
||||
"right_clip": created_right,
|
||||
}
|
||||
|
||||
def merge_clips(self, clip_ids: list[str]) -> EditPlanClip:
|
||||
def merge_clips(self, clip_ids: List[str]) -> EditPlanClip:
|
||||
"""合并多个连续片段为一个片段
|
||||
|
||||
Args:
|
||||
@@ -1217,7 +1217,7 @@ class EditPlanService:
|
||||
|
||||
# ── 渲染生成流程 ────────────────────────────────────────────────────────
|
||||
|
||||
def get_generation_status(self, plan_id: str) -> dict[str, Any]:
|
||||
def get_generation_status(self, plan_id: str) -> Dict[str, Any]:
|
||||
"""获取渲染进度状态
|
||||
|
||||
Returns:
|
||||
@@ -1364,7 +1364,7 @@ class EditPlanService:
|
||||
)
|
||||
return count
|
||||
|
||||
def update_plan_config(self, plan_id: str, config_updates: dict[str, Any]) -> EditPlan:
|
||||
def update_plan_config(self, plan_id: str, config_updates: Dict[str, Any]) -> EditPlan:
|
||||
"""更新计划配置(合并更新)
|
||||
|
||||
Args:
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -76,7 +76,7 @@ class EditTemplateService:
|
||||
active_only: bool = False,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[EditTemplate]:
|
||||
) -> List[EditTemplate]:
|
||||
"""列出模板
|
||||
|
||||
Args:
|
||||
@@ -227,7 +227,7 @@ class EditTemplateService:
|
||||
clip_type: Optional[ClipType] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> list[TemplateClipConfig]:
|
||||
) -> List[TemplateClipConfig]:
|
||||
"""列出模板的片段配置
|
||||
|
||||
注意:本方法要求模板存在于新表 ``edit_templates``(全局模板库),
|
||||
@@ -253,7 +253,7 @@ class EditTemplateService:
|
||||
clip_type: Optional[ClipType] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> list[TemplateClipConfig]:
|
||||
) -> List[TemplateClipConfig]:
|
||||
"""编辑器读取模板片段配置的单一数据源入口.
|
||||
|
||||
片段配置主表是 ``template_clip_configs``(直接读取,不抛异常、不降级)。
|
||||
@@ -404,8 +404,8 @@ class EditTemplateService:
|
||||
def reorder_clip_configs(
|
||||
self,
|
||||
template_id: str,
|
||||
config_ids: list[str],
|
||||
) -> list[TemplateClipConfig]:
|
||||
config_ids: List[str],
|
||||
) -> List[TemplateClipConfig]:
|
||||
"""重新排序片段配置
|
||||
|
||||
Args:
|
||||
@@ -560,7 +560,7 @@ class EditTemplateService:
|
||||
)
|
||||
|
||||
# 5. 转换每个片段为模板片段配置
|
||||
created_configs: list[TemplateClipConfig] = []
|
||||
created_configs: List[TemplateClipConfig] = []
|
||||
for clip_config_obj in clips_to_template_clip_configs(created_template.id, clips):
|
||||
created = self._clip_config_repo.create(clip_config_obj)
|
||||
created_configs.append(created)
|
||||
|
||||
@@ -17,7 +17,7 @@ from __future__ import annotations
|
||||
import io
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -229,7 +229,7 @@ class LipsyncService:
|
||||
)
|
||||
job.mediakit_task_id = result["task_id"]
|
||||
job.status = "submitted"
|
||||
job.submitted_at = datetime.now(UTC)
|
||||
job.submitted_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
logger.info(
|
||||
"[lipsync] 直传音频已提交 MediaKit: job_id=%s task_id=%s",
|
||||
@@ -338,7 +338,7 @@ class LipsyncService:
|
||||
job.status = "failed"
|
||||
job.error_message = f"Celery 任务投递失败: {exc}"
|
||||
job.error_code = "AsyncDispatchFailed"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
else:
|
||||
# 2b/2c. 直接音频 / 预合成音频:同步路径
|
||||
@@ -360,7 +360,7 @@ class LipsyncService:
|
||||
voice_id: str,
|
||||
script_text: str,
|
||||
speed: float = 1.0,
|
||||
emotion: str = "neutral",
|
||||
emotion: str = "natural",
|
||||
) -> dict:
|
||||
"""同步做 TTS 合成 + 下载 + ffprobe + 句子时间戳计算.
|
||||
|
||||
@@ -498,8 +498,8 @@ class LipsyncService:
|
||||
temp_url = result.get("video_url", "")
|
||||
job.output_video_url = temp_url
|
||||
job.output_duration = result.get("duration", 0.0)
|
||||
job.completed_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
# 异步转存自家 OSS
|
||||
try:
|
||||
@@ -517,12 +517,12 @@ class LipsyncService:
|
||||
job.status = "failed"
|
||||
job.error_message = error.get("message", "任务执行失败")
|
||||
job.error_code = error.get("code", "TaskFailed")
|
||||
job.completed_at = datetime.now(UTC)
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
# 中间状态(running/processing/queued 等)同步到 DB,避免前端永远卡在 submitted
|
||||
if isinstance(mk_status, str) and mk_status:
|
||||
job.status = mk_status
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
except Exception as exc: # noqa: BLE001 - DB 提交失败必须记录日志并重试,否则后台任务静默失败
|
||||
logger.error(
|
||||
@@ -589,7 +589,7 @@ class LipsyncService:
|
||||
|
||||
if job.status in ("pending", "tts_processing", "submitted"):
|
||||
job.status = "cancelled"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from typing import Any
|
||||
from typing import Any, List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -62,8 +62,8 @@ class PlanGeneratorService:
|
||||
def generate_from_template(
|
||||
self,
|
||||
template: EditTemplate,
|
||||
clip_configs: list[TemplateClipConfig],
|
||||
asset_ids: list[str],
|
||||
clip_configs: List[TemplateClipConfig],
|
||||
asset_ids: List[str],
|
||||
*,
|
||||
project_id: str = "",
|
||||
created_by_user_id: str = "",
|
||||
@@ -135,7 +135,7 @@ class PlanGeneratorService:
|
||||
)
|
||||
|
||||
# 5. 持久化所有 clips 并计算总时长
|
||||
created_clips: list[EditPlanClip] = []
|
||||
created_clips: List[EditPlanClip] = []
|
||||
total_duration = 0.0
|
||||
for clip in clips:
|
||||
saved = self._clip_repo.create(clip)
|
||||
@@ -184,15 +184,15 @@ class PlanGeneratorService:
|
||||
def _create_clips_from_configs(
|
||||
self,
|
||||
plan_id: str,
|
||||
clip_configs: list[TemplateClipConfig],
|
||||
) -> list[EditPlanClip]:
|
||||
clip_configs: List[TemplateClipConfig],
|
||||
) -> List[EditPlanClip]:
|
||||
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化).
|
||||
|
||||
委托给 plan_generator_utils.create_clips_from_configs 纯函数。
|
||||
"""
|
||||
return create_clips_from_configs(plan_id, clip_configs)
|
||||
|
||||
def _map_clip_types_for_mode(self, clips: list[EditPlanClip], editing_mode: str) -> None:
|
||||
def _map_clip_types_for_mode(self, clips: List[EditPlanClip], editing_mode: str) -> None:
|
||||
"""将 MAIN 类型片段按 editing_mode 映射为对应角色类型.
|
||||
|
||||
委托给 plan_generator_utils.map_clip_types_for_mode 纯函数。
|
||||
@@ -204,7 +204,7 @@ class PlanGeneratorService:
|
||||
plan_id: str,
|
||||
editing_mode: str,
|
||||
asset_count: int,
|
||||
) -> list[EditPlanClip]:
|
||||
) -> List[EditPlanClip]:
|
||||
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构.
|
||||
|
||||
委托给 plan_generator_utils.generate_default_clips 纯函数。
|
||||
@@ -213,8 +213,8 @@ class PlanGeneratorService:
|
||||
|
||||
def _distribute_assets(
|
||||
self,
|
||||
clips: list[EditPlanClip],
|
||||
asset_ids: list[str],
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
editing_mode: str,
|
||||
*,
|
||||
random_selection: bool = False,
|
||||
@@ -259,7 +259,7 @@ class PlanGeneratorService:
|
||||
external_used_segments=external_used_segments,
|
||||
)
|
||||
|
||||
def _fetch_asset_scene_points(self, asset_ids: list[str]) -> dict[str, list[float]]:
|
||||
def _fetch_asset_scene_points(self, asset_ids: List[str]) -> dict[str, list[float]]:
|
||||
"""从素材 metadata 读取场景切换点缓存(无缓存的素材不包含在结果中)。"""
|
||||
points_map: dict[str, list[float]] = {}
|
||||
if not self._asset_repo:
|
||||
@@ -272,7 +272,7 @@ class PlanGeneratorService:
|
||||
points_map[asset_id] = points
|
||||
return points_map
|
||||
|
||||
def _sort_assets_by_smart_score(self, asset_ids: list[str]) -> list[str]:
|
||||
def _sort_assets_by_smart_score(self, asset_ids: List[str]) -> List[str]:
|
||||
"""按 smart_match 综合评分降序排列素材 ID(注入随机噪声)。
|
||||
|
||||
评分高的素材(质量好、时长合适、新鲜、使用次数少)倾向排在前面;
|
||||
@@ -295,7 +295,7 @@ class PlanGeneratorService:
|
||||
)
|
||||
return [aid for aid, _ in scored]
|
||||
|
||||
def _fetch_asset_durations(self, asset_ids: list[str]) -> dict[str, float]:
|
||||
def _fetch_asset_durations(self, asset_ids: List[str]) -> dict[str, float]:
|
||||
"""从数据库获取素材时长信息.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"""文案提取 ASR 服务封装 — Issue #1893.
|
||||
|
||||
将已有的 ASR 服务工厂封装为面向文案提取场景的简单接口:
|
||||
- transcribe_to_text(video_path) -> str:将视频/音频转写为纯文本
|
||||
- 未配置 ASR 时抛 ASRNotConfiguredError(路由层映射为 503)
|
||||
- ASR 调用失败时抛 ASRTranscriptionError(路由层映射为 502)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from packages.ports.asr_service import ASRServiceError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ASRNotConfiguredError(Exception):
|
||||
"""ASR 服务未配置."""
|
||||
|
||||
|
||||
class ASRTranscriptionError(Exception):
|
||||
"""ASR 转写失败."""
|
||||
|
||||
|
||||
def transcribe_to_text(media_path: str | Path) -> str:
|
||||
"""将视频/音频文件转写为纯文本.
|
||||
|
||||
Args:
|
||||
media_path: 媒体文件路径
|
||||
|
||||
Returns:
|
||||
转写出的文本
|
||||
|
||||
Raises:
|
||||
ASRNotConfiguredError: ASR 服务未配置
|
||||
ASRTranscriptionError: ASR 调用失败
|
||||
"""
|
||||
# 延迟导入,避免循环依赖和启动时副作用
|
||||
from apps.worker.services.asr_service_factory import get_asr_service
|
||||
|
||||
asr = get_asr_service()
|
||||
if asr is None:
|
||||
raise ASRNotConfiguredError("ASR 服务未配置,请联系管理员配置火山 MediaKit 或阿里云 ASR 密钥")
|
||||
|
||||
try:
|
||||
timeline = asr.transcribe(Path(media_path))
|
||||
# 拼接所有分段的文本
|
||||
text = "".join(seg.text for seg in timeline.segments)
|
||||
return text.strip()
|
||||
except ASRNotConfiguredError:
|
||||
raise
|
||||
except ASRServiceError as exc:
|
||||
logger.error("ASR 转写失败: %s", exc)
|
||||
raise ASRTranscriptionError(f"语音识别失败: {exc}") from exc
|
||||
except Exception as exc:
|
||||
logger.error("ASR 转写异常: %s", exc)
|
||||
raise ASRTranscriptionError(f"语音识别失败: {exc}") from exc
|
||||
@@ -6,7 +6,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -93,7 +93,7 @@ class ScriptService:
|
||||
script.segments = segments
|
||||
if tags is not None:
|
||||
script.tags = tags
|
||||
script.updated_at = datetime.now(UTC)
|
||||
script.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(script)
|
||||
return script
|
||||
|
||||
@@ -20,7 +20,7 @@ _estimate_sentence_timings_by_chars 等内部函数已复用共享实现,避
|
||||
|
||||
import io
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from celery import shared_task
|
||||
@@ -167,7 +167,7 @@ def tts_synthesize_and_submit(
|
||||
job.status = "failed"
|
||||
job.error_message = f"TTS 合成失败: {exc}"
|
||||
job.error_code = "TTSSynthesisFailed"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return
|
||||
except ValueError as exc:
|
||||
@@ -175,7 +175,7 @@ def tts_synthesize_and_submit(
|
||||
job.status = "failed"
|
||||
job.error_message = f"TTS 参数错误: {exc}"
|
||||
job.error_code = "TTSInvalidParam"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return
|
||||
|
||||
@@ -185,7 +185,7 @@ def tts_synthesize_and_submit(
|
||||
job.status = "failed"
|
||||
job.error_message = "TTS 未返回音频 URL"
|
||||
job.error_code = "TTSNoAudio"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return
|
||||
|
||||
@@ -271,7 +271,7 @@ def tts_synthesize_and_submit(
|
||||
)
|
||||
job.mediakit_task_id = mk_result["task_id"]
|
||||
job.status = "submitted"
|
||||
job.submitted_at = datetime.now(UTC)
|
||||
job.submitted_at = datetime.now(timezone.utc)
|
||||
logger.info(
|
||||
"[lipsync_tts] 已提交 MediaKit: job_id=%s task_id=%s",
|
||||
job_id,
|
||||
@@ -305,7 +305,7 @@ def tts_synthesize_and_submit(
|
||||
job.status = "failed"
|
||||
job.error_message = "TTS 异步任务执行异常"
|
||||
job.error_code = "AsyncTaskError"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
except Exception:
|
||||
logger.exception("[lipsync_tts] 回写失败状态时异常: job_id=%s", job_id)
|
||||
@@ -374,8 +374,8 @@ def poll_mediakit_status(self, job_id: str, user_id: str):
|
||||
logger.warning("[lipsync_poll] 转存 OSS 失败,保留临时 URL: job_id=%s err=%s", job_id, exc)
|
||||
job.output_video_url = output_url
|
||||
job.output_duration = result.get("duration", 0.0)
|
||||
job.completed_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
logger.info("[lipsync_poll] 任务完成: job_id=%s", job_id)
|
||||
elif mk_status in ("failed", "error", _SF):
|
||||
@@ -383,13 +383,13 @@ def poll_mediakit_status(self, job_id: str, user_id: str):
|
||||
job.status = "failed"
|
||||
job.error_message = error.get("message", "任务执行失败")
|
||||
job.error_code = error.get("code", "TaskFailed")
|
||||
job.completed_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
logger.info("[lipsync_poll] 任务失败: job_id=%s err=%s", job_id, job.error_message)
|
||||
else:
|
||||
# 中间状态,更新时间戳,继续重试
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
if isinstance(mk_status, str) and mk_status:
|
||||
job.status = mk_status
|
||||
db.commit()
|
||||
@@ -446,7 +446,7 @@ def persist_output_video_task(job_id: str, user_id: str, temp_url: str):
|
||||
permanent_url = storage.upload_file(io.BytesIO(data), storage_key, content_type="video/mp4")
|
||||
final_url = _sign_media_url(permanent_url) if permanent_url else temp_url
|
||||
job.output_video_url = final_url
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
logger.info("[lipsync_tts.persist] 输出视频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -40,6 +40,7 @@ async function loginWithRetry(
|
||||
|
||||
type ProjectResponse = { id: string }
|
||||
type LibraryResponse = { id: string }
|
||||
type TemplateResponse = { id: string }
|
||||
type AssetListResponse = {
|
||||
items: Array<{
|
||||
id: string
|
||||
@@ -125,17 +126,28 @@ test.describe("Core generation flow", () => {
|
||||
)
|
||||
.toBe("ready")
|
||||
|
||||
// #1926 P0 fix: POST /templates CRUD endpoint removed; GET /templates
|
||||
// now auto-creates a default template for new users. Use the first one.
|
||||
const templatesResp = await request.get(`${apiBase}/templates`, { headers })
|
||||
expect(templatesResp.status(), await templatesResp.text()).toBe(200)
|
||||
const templatesData = (await templatesResp.json()) as {
|
||||
items: Array<{ id: string }>
|
||||
}
|
||||
expect(Array.isArray(templatesData.items)).toBe(true)
|
||||
expect(templatesData.items.length).toBeGreaterThan(0)
|
||||
const templateId = templatesData.items[0].id
|
||||
expect(templateId).toBeTruthy()
|
||||
// Create an editing template so the generate page has at least one template
|
||||
// (templates are now loaded from API; new users have none by default)
|
||||
const template = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `E2E 测试模板 ${suffix}`,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 30,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
tags: ["e2e"],
|
||||
},
|
||||
})
|
||||
expect(template.status(), await template.text()).toBe(201)
|
||||
const templateData = (await template.json()) as TemplateResponse
|
||||
expect(templateData.id).toBeTruthy()
|
||||
|
||||
// Set auth in localStorage
|
||||
await page.addInitScript(
|
||||
|
||||
@@ -1,477 +0,0 @@
|
||||
/**
|
||||
* 积分 & 会员 API 封装(v2 两档会员制)
|
||||
* 后端接口未就绪阶段使用 mock 数据;接口就绪后把 MOCK 开关关掉即可。
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
PointsBalance,
|
||||
PointsTransaction,
|
||||
PointsTransactionsParams,
|
||||
PointsTransactionsResponse,
|
||||
PointsPackagesResponse,
|
||||
PointsRechargeRequest,
|
||||
PointsOrder,
|
||||
PointsRulesResponse,
|
||||
PointsCheckRequest,
|
||||
PointsCheckResponse,
|
||||
SubscriptionCurrent,
|
||||
SubscribeRequest,
|
||||
SubscriptionPlan,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* 是否启用 mock 数据(后端 PR 合入前为 true;对接真实接口后改为 false)
|
||||
*/
|
||||
export const POINTS_API_MOCK = true
|
||||
|
||||
// ==================== Mock 数据 ====================
|
||||
const MOCK_BALANCE: PointsBalance = {
|
||||
balance: 580,
|
||||
total_earned: 1200,
|
||||
total_spent: 620,
|
||||
is_member: false,
|
||||
member_type: null,
|
||||
member_expires_at: null,
|
||||
free_clips_used: 1,
|
||||
free_clips_limit: 2,
|
||||
free_clips_remaining: 1,
|
||||
}
|
||||
|
||||
const MOCK_PACKAGES: PointsPackagesResponse = {
|
||||
packages: [
|
||||
{
|
||||
id: "starter_pack",
|
||||
name: "体验包",
|
||||
points: 100,
|
||||
price: 990,
|
||||
discounted_price_for_free: 990,
|
||||
discounted_price_for_monthly: 891,
|
||||
discounted_price_for_quarterly: 861,
|
||||
discounted_price_for_yearly: 792,
|
||||
},
|
||||
{
|
||||
id: "basic_pack",
|
||||
name: "基础包",
|
||||
points: 500,
|
||||
price: 3900,
|
||||
discounted_price_for_free: 3900,
|
||||
discounted_price_for_monthly: 3510,
|
||||
discounted_price_for_quarterly: 3393,
|
||||
discounted_price_for_yearly: 3120,
|
||||
},
|
||||
{
|
||||
id: "pro_pack",
|
||||
name: "专业包",
|
||||
points: 2000,
|
||||
price: 12900,
|
||||
discounted_price_for_free: 12900,
|
||||
discounted_price_for_monthly: 11610,
|
||||
discounted_price_for_quarterly: 11223,
|
||||
discounted_price_for_yearly: 10320,
|
||||
},
|
||||
],
|
||||
user_member_type: "free",
|
||||
unit_price_yuan: 0.1,
|
||||
}
|
||||
|
||||
const MOCK_RULES: PointsRulesResponse = {
|
||||
rules: [
|
||||
{
|
||||
scene_key: "ai_voice",
|
||||
scene_name: "AI 配音",
|
||||
points_per_use: 1,
|
||||
unit: "分钟",
|
||||
description: "每生成 1 分钟配音",
|
||||
},
|
||||
{
|
||||
scene_key: "ai_video",
|
||||
scene_name: "智能混剪",
|
||||
points_per_use: 3,
|
||||
unit: "条",
|
||||
extra_per_30s: 1,
|
||||
description: "每条 ≤30s 3 积分,每加 30s +1",
|
||||
},
|
||||
{
|
||||
scene_key: "ai_digital_human",
|
||||
scene_name: "AI 数字人",
|
||||
points_per_use: 15,
|
||||
unit: "分钟",
|
||||
description: "每生成 1 分钟口播",
|
||||
},
|
||||
{
|
||||
scene_key: "voice_clone_train",
|
||||
scene_name: "声音克隆训练",
|
||||
points_per_use: 0,
|
||||
unit: "次",
|
||||
description: "训练免费",
|
||||
},
|
||||
{ scene_key: "voice_clone_synth", scene_name: "声音克隆合成", points_per_use: 1, unit: "分钟" },
|
||||
{ scene_key: "douyin_extract", scene_name: "抖音链接提取", points_per_use: 1, unit: "次" },
|
||||
{ scene_key: "ai_rewrite", scene_name: "AI 改写文案", points_per_use: 1, unit: "次" },
|
||||
{ scene_key: "ai_title", scene_name: "AI 标题生成", points_per_use: 1, unit: "次" },
|
||||
{ scene_key: "ai_cover", scene_name: "AI 封面生成", points_per_use: 1, unit: "张" },
|
||||
],
|
||||
free_user_multiplier: 1.15,
|
||||
note: "免费用户消耗 = 会员消耗 × 1.15,向上取整",
|
||||
}
|
||||
|
||||
function genMockTransactions(): PointsTransactionsResponse {
|
||||
const now = new Date()
|
||||
const list = [
|
||||
{
|
||||
src: "ai_voice",
|
||||
name: "AI 配音",
|
||||
type: "spend" as const,
|
||||
amt: 1,
|
||||
desc: "生成配音 1 分钟",
|
||||
days: 0,
|
||||
hours: 0,
|
||||
mins: 30,
|
||||
},
|
||||
{
|
||||
src: "ai_video",
|
||||
name: "智能混剪",
|
||||
type: "spend" as const,
|
||||
amt: 5,
|
||||
desc: "生成 1 分钟视频(基础3+30s*2)",
|
||||
days: 0,
|
||||
hours: 1,
|
||||
mins: 15,
|
||||
},
|
||||
{
|
||||
src: "task_reward",
|
||||
name: "任务奖励",
|
||||
type: "earn" as const,
|
||||
amt: 20,
|
||||
desc: "首次生成视频奖励",
|
||||
days: 1,
|
||||
hours: 0,
|
||||
mins: 0,
|
||||
},
|
||||
{
|
||||
src: "recharge",
|
||||
name: "充值",
|
||||
type: "earn" as const,
|
||||
amt: 500,
|
||||
desc: "基础包充值",
|
||||
days: 15,
|
||||
hours: 0,
|
||||
mins: 0,
|
||||
},
|
||||
{
|
||||
src: "ai_rewrite",
|
||||
name: "AI 改写文案",
|
||||
type: "spend" as const,
|
||||
amt: 2,
|
||||
desc: "免费用户价(1×1.15 向上取整)",
|
||||
days: 16,
|
||||
hours: 2,
|
||||
mins: 10,
|
||||
},
|
||||
{
|
||||
src: "ai_title",
|
||||
name: "AI 标题生成",
|
||||
type: "spend" as const,
|
||||
amt: 2,
|
||||
desc: "免费用户价",
|
||||
days: 16,
|
||||
hours: 3,
|
||||
mins: 0,
|
||||
},
|
||||
{
|
||||
src: "douyin_extract",
|
||||
name: "抖音链接提取",
|
||||
type: "spend" as const,
|
||||
amt: 2,
|
||||
desc: "提取 3 分钟文案",
|
||||
days: 18,
|
||||
hours: 0,
|
||||
mins: 0,
|
||||
},
|
||||
{
|
||||
src: "ai_digital_human",
|
||||
name: "AI 数字人",
|
||||
type: "spend" as const,
|
||||
amt: 18,
|
||||
desc: "数字人口播 1 分钟(免费用户价)",
|
||||
days: 20,
|
||||
hours: 0,
|
||||
mins: 0,
|
||||
},
|
||||
{
|
||||
src: "task_reward",
|
||||
name: "任务奖励",
|
||||
type: "earn" as const,
|
||||
amt: 50,
|
||||
desc: "注册赠送",
|
||||
days: 30,
|
||||
hours: 0,
|
||||
mins: 0,
|
||||
},
|
||||
]
|
||||
let bal = MOCK_BALANCE.balance
|
||||
const items = list
|
||||
.map((t, i) => {
|
||||
const signed = t.type === "earn" ? t.amt : -t.amt
|
||||
const balance_after = bal // 按时间倒序:earliest 先算
|
||||
// adjust running bal
|
||||
bal = t.type === "earn" ? bal - t.amt : bal + t.amt
|
||||
const d = new Date(now)
|
||||
d.setDate(d.getDate() - t.days)
|
||||
d.setHours(d.getHours() - t.hours)
|
||||
d.setMinutes(d.getMinutes() - t.mins)
|
||||
return {
|
||||
id: `tx_${i + 1}`,
|
||||
type: t.type,
|
||||
source: t.src as PointsBalance extends never ? never : string,
|
||||
source_name: t.name,
|
||||
amount: t.amt,
|
||||
signed_amount: signed,
|
||||
balance_after,
|
||||
description: t.desc,
|
||||
ref_id: null,
|
||||
created_at: d.toISOString(),
|
||||
}
|
||||
})
|
||||
.reverse()
|
||||
// Rebuild balance_after going forward
|
||||
let running = 50 + 0 // after registration gift
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
const it = items[i] as PointsTransaction & { balance_after?: number }
|
||||
if (it.source === "task_reward" && it.description.includes("注册")) running = 50
|
||||
}
|
||||
running = 50
|
||||
const fwd = [...items].reverse() as Array<PointsTransaction & { balance_after?: number }>
|
||||
for (const it of fwd) {
|
||||
running += it.signed_amount
|
||||
it.balance_after = running
|
||||
}
|
||||
return { items: fwd, total: fwd.length, page: 1, page_size: 20 } as PointsTransactionsResponse
|
||||
}
|
||||
|
||||
// ==================== 真实 API ====================
|
||||
|
||||
/** 查询积分余额 + 会员状态 */
|
||||
export async function getPointsBalance(): Promise<PointsBalance> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) => setTimeout(() => r({ ...MOCK_BALANCE }), 180))
|
||||
}
|
||||
const res = await apiClient.get("/points/balance")
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 查询积分流水(分页) */
|
||||
export async function getPointsTransactions(
|
||||
params: PointsTransactionsParams = {},
|
||||
): Promise<PointsTransactionsResponse> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) => setTimeout(() => r(genMockTransactions()), 200))
|
||||
}
|
||||
const res = await apiClient.get("/points/transactions", { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 查询积分包列表 */
|
||||
export async function getPointsPackages(): Promise<PointsPackagesResponse> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) => setTimeout(() => r({ ...MOCK_PACKAGES }), 150))
|
||||
}
|
||||
const res = await apiClient.get("/points/packages")
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 创建积分充值订单(mock 阶段返回 "pending" 订单,前端弹"支付开发中") */
|
||||
export async function createPointsOrder(req: PointsRechargeRequest): Promise<PointsOrder> {
|
||||
if (POINTS_API_MOCK) {
|
||||
const pkg = MOCK_PACKAGES.packages.find((p) => p.id === req.package_id)
|
||||
const mt = MOCK_PACKAGES.user_member_type
|
||||
type DiscountKey =
|
||||
| "discounted_price_for_free"
|
||||
| "discounted_price_for_monthly"
|
||||
| "discounted_price_for_quarterly"
|
||||
| "discounted_price_for_yearly"
|
||||
const discountKey = `discounted_price_for_${mt}` as DiscountKey
|
||||
const price = pkg?.[discountKey] ?? pkg?.price ?? 0
|
||||
return new Promise((r) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
r({
|
||||
id: `mock_order_${Date.now()}`,
|
||||
package_id: req.package_id,
|
||||
package_name: pkg?.name ?? "",
|
||||
points_amount: pkg?.points ?? 0,
|
||||
price_cents: price,
|
||||
original_price_cents: pkg?.price ?? 0,
|
||||
discount: price / (pkg?.price || 1),
|
||||
currency: "CNY",
|
||||
status: "pending",
|
||||
payment_method: null,
|
||||
payment_id: null,
|
||||
paid_at: null,
|
||||
expire_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
}),
|
||||
300,
|
||||
),
|
||||
)
|
||||
}
|
||||
const res = await apiClient.post("/points/recharge", req)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 查询积分消耗规则 */
|
||||
export async function getPointsRules(): Promise<PointsRulesResponse> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) => setTimeout(() => r({ ...MOCK_RULES }), 120))
|
||||
}
|
||||
const res = await apiClient.get("/points/rules")
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 消费前余额预检查 */
|
||||
export async function checkPoints(req: PointsCheckRequest): Promise<PointsCheckResponse> {
|
||||
if (POINTS_API_MOCK) {
|
||||
const rule = MOCK_RULES.rules.find((r) => r.scene_key === req.scene_key)
|
||||
if (!rule) {
|
||||
return {
|
||||
allowed: false,
|
||||
required_points: 0,
|
||||
current_balance: MOCK_BALANCE.balance,
|
||||
remaining_after: MOCK_BALANCE.balance,
|
||||
is_free_quota: false,
|
||||
code: "SCENE_NOT_FOUND",
|
||||
message: "未知场景",
|
||||
recharge_url: "/app/points",
|
||||
}
|
||||
}
|
||||
const units = req.units ?? 1
|
||||
let base = rule.points_per_use * units
|
||||
if (rule.extra_per_30s && units > 1) {
|
||||
// ai_video extra_per_30s: base already covers first 30s, subtract
|
||||
base = rule.points_per_use + rule.extra_per_30s * (units - 1)
|
||||
}
|
||||
const isFree =
|
||||
MOCK_BALANCE.is_member === false &&
|
||||
req.scene_key === "ai_video" &&
|
||||
(MOCK_BALANCE.free_clips_remaining ?? 0) > 0
|
||||
const needed = isFree
|
||||
? 0
|
||||
: MOCK_BALANCE.is_member
|
||||
? base
|
||||
: Math.ceil(base * MOCK_RULES.free_user_multiplier)
|
||||
const allowed = isFree || MOCK_BALANCE.balance >= needed
|
||||
return {
|
||||
allowed,
|
||||
required_points: needed,
|
||||
current_balance: MOCK_BALANCE.balance,
|
||||
remaining_after: MOCK_BALANCE.balance - needed,
|
||||
is_free_quota: isFree,
|
||||
code: allowed ? undefined : "INSUFFICIENT_POINTS",
|
||||
message: allowed
|
||||
? undefined
|
||||
: `积分不足,需要 ${needed} 积分,当前余额 ${MOCK_BALANCE.balance}`,
|
||||
recharge_url: "/app/points",
|
||||
}
|
||||
}
|
||||
const res = await apiClient.post("/points/check", req)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ==================== 订阅相关 ====================
|
||||
|
||||
/** 订阅套餐(定价常量,前端硬编码;折扣由后端会员类型决定) */
|
||||
export const SUBSCRIPTION_PLANS: SubscriptionPlan[] = [
|
||||
{
|
||||
id: "monthly",
|
||||
name: "月卡",
|
||||
price_cents: 1990,
|
||||
price_yuan: 19.9,
|
||||
per_month_yuan: 19.9,
|
||||
billing_label: "/月",
|
||||
},
|
||||
{
|
||||
id: "quarterly",
|
||||
name: "季卡",
|
||||
price_cents: 3990,
|
||||
price_yuan: 39.9,
|
||||
per_month_yuan: 13.3,
|
||||
savings_percent: 33,
|
||||
recommended: true,
|
||||
billing_label: "/季",
|
||||
},
|
||||
{
|
||||
id: "yearly",
|
||||
name: "年卡",
|
||||
price_cents: 15900,
|
||||
price_yuan: 159,
|
||||
per_month_yuan: 13.25,
|
||||
savings_percent: 33,
|
||||
billing_label: "/年",
|
||||
},
|
||||
]
|
||||
|
||||
/** 查询当前订阅 */
|
||||
export async function getCurrentSubscription(): Promise<SubscriptionCurrent> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
r({
|
||||
is_member: MOCK_BALANCE.is_member,
|
||||
member_type: MOCK_BALANCE.member_type,
|
||||
member_type_name: MOCK_BALANCE.is_member ? "付费会员" : "免费会员",
|
||||
status: MOCK_BALANCE.is_member ? "active" : "none",
|
||||
current_period_start: null,
|
||||
current_period_end: MOCK_BALANCE.member_expires_at,
|
||||
auto_renew: false,
|
||||
points_discount: MOCK_BALANCE.is_member ? 0.9 : 1.0,
|
||||
}),
|
||||
150,
|
||||
),
|
||||
)
|
||||
}
|
||||
const res = await apiClient.get("/subscription/current")
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 开通/续费会员 */
|
||||
export async function subscribe(req: SubscribeRequest): Promise<PointsOrder> {
|
||||
if (POINTS_API_MOCK) {
|
||||
const plan = SUBSCRIPTION_PLANS.find((p) => p.id === req.member_type)!
|
||||
return new Promise((r) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
r({
|
||||
id: `mock_sub_${Date.now()}`,
|
||||
package_id: plan.id,
|
||||
package_name: plan.name,
|
||||
points_amount: 0,
|
||||
price_cents: plan.price_cents,
|
||||
original_price_cents: plan.price_cents,
|
||||
discount: 1,
|
||||
currency: "CNY",
|
||||
status: "pending",
|
||||
payment_method: null,
|
||||
payment_id: null,
|
||||
paid_at: null,
|
||||
expire_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
}),
|
||||
300,
|
||||
),
|
||||
)
|
||||
}
|
||||
const res = await apiClient.post("/subscription/subscribe", req)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 取消自动续费 */
|
||||
export async function cancelAutoRenew(): Promise<{ success: boolean; message: string }> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) =>
|
||||
setTimeout(() => r({ success: true, message: "已取消自动续费" }), 200),
|
||||
)
|
||||
}
|
||||
const res = await apiClient.post("/subscription/cancel")
|
||||
return res.data
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
/**
|
||||
* 积分 & 会员系统 API 类型定义(v2 两档会员制)
|
||||
* 接口契约对齐后端设计文档 membership-points-design-v2.md
|
||||
*/
|
||||
|
||||
/** 会员类型 */
|
||||
export type MemberType = "free" | "monthly" | "quarterly" | "yearly"
|
||||
|
||||
/** 积分流水类型 */
|
||||
export type PointsTxType = "earn" | "spend" | "refund"
|
||||
|
||||
/** 积分来源/消耗场景 */
|
||||
export type PointsSource =
|
||||
| "recharge" // 充值
|
||||
| "task_reward" // 任务奖励
|
||||
| "ai_voice" // AI 配音
|
||||
| "ai_digital_human" // AI 数字人
|
||||
| "ai_video" // 智能混剪
|
||||
| "voice_clone_train" // 声音克隆训练
|
||||
| "voice_clone_synth" // 声音克隆合成
|
||||
| "douyin_extract" // 抖音链接提取
|
||||
| "ai_rewrite" // AI 改写文案
|
||||
| "ai_title" // AI 标题生成
|
||||
| "ai_cover" // AI 封面生成
|
||||
| "subscription_bonus" // 会员赠送
|
||||
| "admin_adjust" // 管理员调整
|
||||
| "refund" // 失败退还
|
||||
|
||||
/** 会员 & 积分余额响应 */
|
||||
export interface PointsBalance {
|
||||
/** 当前可用积分 */
|
||||
balance: number
|
||||
/** 累计获得 */
|
||||
total_earned: number
|
||||
/** 累计消耗 */
|
||||
total_spent: number
|
||||
/** 是否付费会员(free 用户为 false) */
|
||||
is_member: boolean
|
||||
/** 会员类型:monthly / quarterly / yearly;free 用户为 null */
|
||||
member_type: Extract<MemberType, "monthly" | "quarterly" | "yearly"> | null
|
||||
/** 会员到期时间 ISO 字符串 */
|
||||
member_expires_at: string | null
|
||||
/** 今日免费混剪已用次数 */
|
||||
free_clips_used?: number
|
||||
/** 今日免费混剪额度上限 */
|
||||
free_clips_limit?: number
|
||||
/** 今日免费混剪剩余 */
|
||||
free_clips_remaining?: number
|
||||
}
|
||||
|
||||
/** 积分流水记录 */
|
||||
export interface PointsTransaction {
|
||||
id: string
|
||||
/** earn / spend / refund */
|
||||
type: PointsTxType
|
||||
/** 来源场景 */
|
||||
source: PointsSource
|
||||
/** 场景中文名称 */
|
||||
source_name: string
|
||||
/** 变动数量(正数) */
|
||||
amount: number
|
||||
/** 带符号的变动数(收入+,支出-) */
|
||||
signed_amount: number
|
||||
/** 变动后余额 */
|
||||
balance_after: number
|
||||
/** 备注描述 */
|
||||
description: string
|
||||
/** 关联业务 ID */
|
||||
ref_id: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 积分流水分页响应 */
|
||||
export interface PointsTransactionsResponse {
|
||||
items: PointsTransaction[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 积分流水查询参数 */
|
||||
export interface PointsTransactionsParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
type?: PointsTxType
|
||||
source?: PointsSource
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}
|
||||
|
||||
/** 积分包 */
|
||||
export interface PointsPackage {
|
||||
id: "starter_pack" | "basic_pack" | "pro_pack" | string
|
||||
/** 中文名称 */
|
||||
name: string
|
||||
/** 积分数量 */
|
||||
points: number
|
||||
/** 原价(分) */
|
||||
price: number
|
||||
/** 各会员类型折扣价(分) */
|
||||
discounted_price_for_free: number
|
||||
discounted_price_for_monthly: number
|
||||
discounted_price_for_quarterly: number
|
||||
discounted_price_for_yearly: number
|
||||
}
|
||||
|
||||
/** 积分包列表响应 */
|
||||
export interface PointsPackagesResponse {
|
||||
packages: PointsPackage[]
|
||||
/** 当前用户会员类型,用于前端计算折后价 */
|
||||
user_member_type: MemberType
|
||||
/** 积分单价(元/积分,按会员价计) */
|
||||
unit_price_yuan: number
|
||||
}
|
||||
|
||||
/** 创建充值订单请求 */
|
||||
export interface PointsRechargeRequest {
|
||||
package_id: string
|
||||
payment_method?: "wechat_pay" | "alipay"
|
||||
}
|
||||
|
||||
/** 订单状态 */
|
||||
export type OrderStatus = "pending" | "paid" | "failed" | "refunded" | "expired"
|
||||
|
||||
/** 充值订单响应 */
|
||||
export interface PointsOrder {
|
||||
id: string
|
||||
package_id: string
|
||||
package_name: string
|
||||
points_amount: number
|
||||
price_cents: number
|
||||
original_price_cents: number
|
||||
discount: number
|
||||
currency: "CNY"
|
||||
status: OrderStatus
|
||||
payment_method: string | null
|
||||
payment_id: string | null
|
||||
paid_at: string | null
|
||||
expire_at: string | null
|
||||
created_at: string
|
||||
/** 微信/支付宝支付参数(mock 阶段前端自行处理) */
|
||||
pay_params?: Record<string, string>
|
||||
}
|
||||
|
||||
/** 订阅套餐(月/季/年) */
|
||||
export interface SubscriptionPlan {
|
||||
id: "monthly" | "quarterly" | "yearly"
|
||||
name: string
|
||||
price_cents: number
|
||||
price_yuan: number
|
||||
per_month_yuan: number
|
||||
savings_percent?: number
|
||||
recommended?: boolean
|
||||
billing_label: string
|
||||
}
|
||||
|
||||
/** 当前订阅详情 */
|
||||
export interface SubscriptionCurrent {
|
||||
is_member: boolean
|
||||
member_type: Extract<MemberType, "monthly" | "quarterly" | "yearly"> | null
|
||||
member_type_name: string
|
||||
status: "active" | "expired" | "cancelled" | "none"
|
||||
current_period_start: string | null
|
||||
current_period_end: string | null
|
||||
auto_renew: boolean
|
||||
/** 订阅会员对应的积分折扣 */
|
||||
points_discount: number
|
||||
}
|
||||
|
||||
/** 开通/续费订阅请求 */
|
||||
export interface SubscribeRequest {
|
||||
member_type: "monthly" | "quarterly" | "yearly"
|
||||
payment_method?: "wechat_pay" | "alipay"
|
||||
}
|
||||
|
||||
/** 积分消耗规则 */
|
||||
export interface PointsRule {
|
||||
scene_key: PointsSource
|
||||
scene_name: string
|
||||
/** 每次消耗基础积分(会员价) */
|
||||
points_per_use: number
|
||||
/** 计量单位:条/分钟/次/张 */
|
||||
unit: string
|
||||
/** 额外每 30s 加积分(ai_video 用) */
|
||||
extra_per_30s?: number
|
||||
/** 说明文案 */
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface PointsRulesResponse {
|
||||
rules: PointsRule[]
|
||||
/** 免费用户消耗倍率 */
|
||||
free_user_multiplier: number
|
||||
note: string
|
||||
}
|
||||
|
||||
/** 消费前余额检查请求 */
|
||||
export interface PointsCheckRequest {
|
||||
scene_key: PointsSource
|
||||
/** 单位数量(时长/条数),默认 1 */
|
||||
units?: number
|
||||
}
|
||||
|
||||
/** 消费前余额检查响应 */
|
||||
export interface PointsCheckResponse {
|
||||
allowed: boolean
|
||||
required_points: number
|
||||
current_balance: number
|
||||
remaining_after: number
|
||||
/** 是否走免费额度(混剪场景) */
|
||||
is_free_quota: boolean
|
||||
/** 拒绝原因代码 */
|
||||
code?: "INSUFFICIENT_POINTS" | "FREE_QUOTA_EXCEEDED" | "SCENE_NOT_FOUND"
|
||||
message?: string
|
||||
/** 充值页跳转 URL */
|
||||
recharge_url?: string
|
||||
}
|
||||
@@ -1,195 +1,37 @@
|
||||
/**
|
||||
* 文案库 API(#1811 v2)
|
||||
* CRUD + 搜索/分类/分页;后端未就绪时使用 mock 数据(SCRIPTS_API_MOCK=true)
|
||||
* 文案库 API
|
||||
* 对接后端 /api/v1/scripts(CRUD + 列表解包)
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
ScriptItem,
|
||||
ScriptListParams,
|
||||
ScriptListResponse,
|
||||
ScriptUpsertRequest,
|
||||
ScriptCategory,
|
||||
CreateScriptRequest,
|
||||
UpdateScriptRequest,
|
||||
} from "./types"
|
||||
|
||||
/** 是否启用 mock(后端合入后改为 false) */
|
||||
export const SCRIPTS_API_MOCK = true
|
||||
|
||||
// ==================== Mock 数据 ====================
|
||||
|
||||
const MOCK_CATEGORIES: ScriptCategory[] = [
|
||||
"promo",
|
||||
"vlog",
|
||||
"knowledge",
|
||||
"story",
|
||||
"emotion",
|
||||
"other",
|
||||
]
|
||||
|
||||
const SAMPLE_TITLES: Record<ScriptCategory, string[]> = {
|
||||
promo: ["新品上市限时特惠", "618大促开场", "品牌故事宣传片"],
|
||||
vlog: ["周末citywalk记录", "打工人的一天", "探店vlog"],
|
||||
knowledge: ["3分钟学会XX", "冷知识科普", "行业深度解读"],
|
||||
story: ["励志小故事", "情感故事一则", "反转剧情"],
|
||||
emotion: ["深夜emo时刻", "治愈系文案", "朋友圈金句"],
|
||||
other: ["通用开场白", "节日祝福", "万能结尾"],
|
||||
}
|
||||
|
||||
const SAMPLE_TAGS = ["热门", "新品", "节日", "情感", "干货", "搞笑", "治愈", "励志"]
|
||||
|
||||
function genMockScripts(): ScriptItem[] {
|
||||
const items: ScriptItem[] = []
|
||||
const now = Date.now()
|
||||
let idx = 0
|
||||
for (const cat of MOCK_CATEGORIES) {
|
||||
const titles = SAMPLE_TITLES[cat]
|
||||
for (let i = 0; i < titles.length; i++) {
|
||||
idx++
|
||||
const title = titles[i]
|
||||
const content = `这是一条【${cat}】分类下的示例文案,标题为「${title}」。\n\n正文可以包含多段落,每段对应一个片段(segments)。\n\n此为 mock 数据,后端接口就绪后会自动切换为真实数据。`
|
||||
const segments = content.split(/\n\n+/).filter(Boolean)
|
||||
const tagCount = 1 + (idx % 3)
|
||||
const tags: string[] = []
|
||||
for (let t = 0; t < tagCount; t++) {
|
||||
tags.push(SAMPLE_TAGS[(idx + t) % SAMPLE_TAGS.length])
|
||||
}
|
||||
items.push({
|
||||
id: `mock_${idx}`,
|
||||
title,
|
||||
content,
|
||||
segments,
|
||||
tags,
|
||||
title_text: title,
|
||||
title_category: cat,
|
||||
title_config: {
|
||||
font: "default",
|
||||
color: "#ffffff",
|
||||
stroke: "#000000",
|
||||
position: (["top", "center", "bottom"] as const)[idx % 3],
|
||||
size: 48,
|
||||
bold: idx % 2 === 0,
|
||||
italic: false,
|
||||
},
|
||||
char_count: content.length,
|
||||
use_count: Math.floor(Math.random() * 50),
|
||||
created_at: new Date(now - idx * 86400_000 * 2).toISOString(),
|
||||
updated_at: new Date(now - idx * 86400_000).toISOString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
const MOCK_SCRIPTS = genMockScripts()
|
||||
|
||||
// ==================== 真实 API ====================
|
||||
|
||||
/** 获取文案列表(支持分页/搜索/分类) */
|
||||
export async function getScripts(params: ScriptListParams = {}): Promise<ScriptListResponse> {
|
||||
if (SCRIPTS_API_MOCK) {
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.page_size ?? 20
|
||||
let items = [...MOCK_SCRIPTS]
|
||||
if (params.keyword) {
|
||||
const kw = params.keyword.toLowerCase()
|
||||
items = items.filter(
|
||||
(s) => s.title.toLowerCase().includes(kw) || s.content.toLowerCase().includes(kw),
|
||||
)
|
||||
}
|
||||
if (params.category && params.category !== "all") {
|
||||
items = items.filter((s) => s.title_category === params.category)
|
||||
}
|
||||
if (params.tag) {
|
||||
items = items.filter((s) => s.tags?.includes(params.tag as string))
|
||||
}
|
||||
const total = items.length
|
||||
const start = (page - 1) * pageSize
|
||||
const pageItems = items.slice(start, start + pageSize)
|
||||
return new Promise((r) =>
|
||||
setTimeout(() => r({ items: pageItems, total, page, page_size: pageSize }), 200),
|
||||
)
|
||||
}
|
||||
const res = await apiClient.get<ScriptListResponse>("/scripts", { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 获取单条文案详情 */
|
||||
export async function getScript(id: string): Promise<ScriptItem> {
|
||||
if (SCRIPTS_API_MOCK) {
|
||||
const item = MOCK_SCRIPTS.find((s) => s.id === id)
|
||||
return new Promise((r) => setTimeout(() => r(item ?? MOCK_SCRIPTS[0]), 120))
|
||||
}
|
||||
const res = await apiClient.get<ScriptItem>(`/scripts/${id}`)
|
||||
return res.data
|
||||
/** 获取文案列表 — 必须解包 items(后端返回 {items,total})*/
|
||||
export const getScripts = async (): Promise<ScriptItem[]> => {
|
||||
const response = await apiClient.get<ScriptListResponse | ScriptItem[]>("/scripts")
|
||||
const data = response.data as unknown
|
||||
if (Array.isArray(data)) return data
|
||||
const items = (data as { items?: ScriptItem[] })?.items
|
||||
return Array.isArray(items) ? items : []
|
||||
}
|
||||
|
||||
/** 新建文案 */
|
||||
export async function createScript(data: ScriptUpsertRequest): Promise<ScriptItem> {
|
||||
if (SCRIPTS_API_MOCK) {
|
||||
const segments =
|
||||
data.segments && data.segments.length > 0
|
||||
? data.segments
|
||||
: data.content.split(/\n\n+/).filter(Boolean)
|
||||
const item: ScriptItem = {
|
||||
id: `mock_${Date.now()}`,
|
||||
...data,
|
||||
segments,
|
||||
char_count: data.content.length,
|
||||
use_count: 0,
|
||||
tags: data.tags ?? [],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
MOCK_SCRIPTS.unshift(item)
|
||||
return new Promise((r) => setTimeout(() => r(item), 200))
|
||||
}
|
||||
const res = await apiClient.post<ScriptItem>("/scripts", data)
|
||||
return res.data
|
||||
export const createScript = async (data: CreateScriptRequest): Promise<ScriptItem> => {
|
||||
const response = await apiClient.post<ScriptItem>("/scripts", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新文案 */
|
||||
export async function updateScript(id: string, data: ScriptUpsertRequest): Promise<ScriptItem> {
|
||||
if (SCRIPTS_API_MOCK) {
|
||||
const idx = MOCK_SCRIPTS.findIndex((s) => s.id === id)
|
||||
const segments =
|
||||
data.segments && data.segments.length > 0
|
||||
? data.segments
|
||||
: data.content.split(/\n\n+/).filter(Boolean)
|
||||
const updated: ScriptItem = {
|
||||
...MOCK_SCRIPTS[idx],
|
||||
...data,
|
||||
segments,
|
||||
char_count: data.content.length,
|
||||
tags: data.tags ?? MOCK_SCRIPTS[idx]?.tags ?? [],
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
if (idx >= 0) MOCK_SCRIPTS[idx] = updated
|
||||
return new Promise((r) => setTimeout(() => r(updated), 200))
|
||||
}
|
||||
const res = await apiClient.put<ScriptItem>(`/scripts/${id}`, data)
|
||||
return res.data
|
||||
export const updateScript = async (id: string, data: UpdateScriptRequest): Promise<ScriptItem> => {
|
||||
const response = await apiClient.put<ScriptItem>(`/scripts/${id}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除文案 */
|
||||
export async function deleteScript(id: string): Promise<void> {
|
||||
if (SCRIPTS_API_MOCK) {
|
||||
const idx = MOCK_SCRIPTS.findIndex((s) => s.id === id)
|
||||
if (idx >= 0) MOCK_SCRIPTS.splice(idx, 1)
|
||||
return new Promise((r) => setTimeout(r, 150))
|
||||
}
|
||||
export const deleteScript = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/scripts/${id}`)
|
||||
}
|
||||
|
||||
/** 复制文案(返回新副本) */
|
||||
export async function duplicateScript(id: string): Promise<ScriptItem> {
|
||||
const orig = await getScript(id)
|
||||
const copy = await createScript({
|
||||
title: `${orig.title}(副本)`,
|
||||
content: orig.content,
|
||||
segments: orig.segments,
|
||||
tags: orig.tags,
|
||||
title_text: orig.title_text,
|
||||
title_category: orig.title_category,
|
||||
title_config: orig.title_config,
|
||||
})
|
||||
return copy
|
||||
}
|
||||
|
||||
@@ -1,97 +1,24 @@
|
||||
/**
|
||||
* 文案库 API — 类型定义(#1811 v2 完整字段版)
|
||||
* 字段对齐后端契约:title / content / segments / tags / title_text / title_category / title_config
|
||||
* 同时保留 char_count / use_count / timestamps 等展示字段
|
||||
* 文案库 API — 类型定义
|
||||
* 对接后端 /api/v1/scripts
|
||||
*/
|
||||
|
||||
/** 标题配置(字体、颜色、位置、字号) */
|
||||
export interface ScriptTitleConfig {
|
||||
/** 字体预设 key,如 "default" / "bold" / "handwritten" */
|
||||
font?: string
|
||||
/** 文字颜色(CSS color) */
|
||||
color?: string
|
||||
/** 描边色 */
|
||||
stroke?: string
|
||||
/** 位置:top / center / bottom */
|
||||
position?: "top" | "center" | "bottom"
|
||||
/** 字号(px) */
|
||||
size?: number
|
||||
/** 是否加粗 */
|
||||
bold?: boolean
|
||||
/** 是否斜体 */
|
||||
italic?: boolean
|
||||
}
|
||||
|
||||
/** 文案分类(可枚举,也支持自定义) */
|
||||
export type ScriptCategory =
|
||||
| "promo" // 营销推广
|
||||
| "vlog" // Vlog/日常
|
||||
| "knowledge" // 知识科普
|
||||
| "story" // 故事剧情
|
||||
| "emotion" // 情感语录
|
||||
| "other" // 其他
|
||||
|
||||
export const SCRIPT_CATEGORY_LABEL: Record<ScriptCategory, string> = {
|
||||
promo: "营销推广",
|
||||
vlog: "Vlog 日常",
|
||||
knowledge: "知识科普",
|
||||
story: "故事剧情",
|
||||
emotion: "情感语录",
|
||||
other: "其他",
|
||||
}
|
||||
|
||||
/** 文案条目 */
|
||||
export interface ScriptItem {
|
||||
id: string
|
||||
/** 名称(标题) */
|
||||
title: string
|
||||
/** 正文 */
|
||||
content: string
|
||||
/** 分段(按段落切分,供后端/生成步骤逐段使用) */
|
||||
segments?: string[]
|
||||
/** 标签(逗号分隔或数组,列表展示用 Tag) */
|
||||
tags?: string[]
|
||||
/** 配套标题文本(选填,"使用"跳创作页时会预填到标题) */
|
||||
title_text?: string
|
||||
/** 分类 */
|
||||
title_category?: ScriptCategory
|
||||
/** 标题样式配置(字体/颜色/位置/字号) */
|
||||
title_config?: ScriptTitleConfig
|
||||
/** 正文字符数(后端返回,前端用于展示) */
|
||||
char_count?: number
|
||||
/** 使用次数(后端返回) */
|
||||
use_count?: number
|
||||
char_count: number
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 列表查询参数(支持搜索/分类/分页) */
|
||||
export interface ScriptListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
/** 标题/正文模糊搜索 */
|
||||
keyword?: string
|
||||
/** 分类筛选 */
|
||||
category?: ScriptCategory | "all"
|
||||
/** 标签筛选 */
|
||||
tag?: string
|
||||
}
|
||||
|
||||
/** 列表响应 */
|
||||
export interface ScriptListResponse {
|
||||
items: ScriptItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 创建/编辑请求 */
|
||||
export interface ScriptUpsertRequest {
|
||||
export interface CreateScriptRequest {
|
||||
title: string
|
||||
content: string
|
||||
segments?: string[]
|
||||
tags?: string[]
|
||||
title_text?: string
|
||||
title_category?: ScriptCategory
|
||||
title_config?: ScriptTitleConfig
|
||||
}
|
||||
|
||||
export type UpdateScriptRequest = Partial<CreateScriptRequest>
|
||||
|
||||
@@ -85,13 +85,9 @@ export async function batchDeleteEditPlanClips(
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 从素材批量创建片段(追加到时间线末尾)。
|
||||
* #1921 修复:templateId 为空时调用新端点 POST /clips/from-assets,避免拼出双斜杠
|
||||
* `/templates//editor/clips/from-assets` 导致 404;有 templateId 时保持原路径向后兼容。
|
||||
*/
|
||||
/** 从素材批量创建片段(追加到时间线末尾) */
|
||||
export async function createClipsFromAssets(
|
||||
templateId: string | undefined | null,
|
||||
templateId: string,
|
||||
assetIds: string[],
|
||||
clipType = "main",
|
||||
requiredClipsCount?: number,
|
||||
@@ -104,15 +100,16 @@ export async function createClipsFromAssets(
|
||||
if (requiredClipsCount !== undefined) {
|
||||
body.required_clips_count = requiredClipsCount
|
||||
}
|
||||
// 新端点(#1921):templateId 为空时,body 不传 template_id,由后端兜底创建默认模板
|
||||
const hasTid = !!templateId
|
||||
const url = hasTid ? `/templates/${templateId}/editor/clips/from-assets` : "/clips/from-assets"
|
||||
// from-assets 后端会调用 MediaKit 智能选片(最长 60s),单独延长超时
|
||||
const response = await apiClient.post<ClipsFromAssetsResponse>(url, body, {
|
||||
timeout: 60000,
|
||||
signal: opts?.signal,
|
||||
// _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast,#1777)
|
||||
...(opts?.silentErrorToast ? ({ _silentErrorToast: true } as Record<string, unknown>) : {}),
|
||||
})
|
||||
const response = await apiClient.post<ClipsFromAssetsResponse>(
|
||||
`/templates/${templateId}/editor/clips/from-assets`,
|
||||
body,
|
||||
{
|
||||
timeout: 60000,
|
||||
signal: opts?.signal,
|
||||
// _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast,#1777)
|
||||
...(opts?.silentErrorToast ? ({ _silentErrorToast: true } as Record<string, unknown>) : {}),
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
/* Header 积分徽章 & Popover 样式 */
|
||||
.xx-points-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
background: var(--bg-tertiary, #fafafa);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
line-height: 1;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.xx-points-badge:hover {
|
||||
border-color: var(--primary-color, #6366f1);
|
||||
color: var(--primary-color, #6366f1);
|
||||
background: var(--primary-soft, #eef2ff);
|
||||
}
|
||||
|
||||
.xx-points-badge.zero {
|
||||
color: #faad14;
|
||||
border-color: #ffe58f;
|
||||
background: #fffbe6;
|
||||
}
|
||||
|
||||
.xx-points-badge.low {
|
||||
color: #fa8c16;
|
||||
border-color: #ffd591;
|
||||
background: #fff7e6;
|
||||
}
|
||||
|
||||
.xx-points-badge-icon {
|
||||
font-size: 14px;
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.xx-points-badge.zero .xx-points-badge-icon,
|
||||
.xx-points-badge.low .xx-points-badge-icon {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.xx-points-badge-val {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Popover */
|
||||
.xx-points-popover {
|
||||
width: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-points-popover-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.xx-points-popover-balance {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.xx-points-icon {
|
||||
color: #8b5cf6;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.xx-points-value {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary, #111827);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.xx-points-unit {
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.xx-points-warn {
|
||||
margin: 0 !important;
|
||||
padding: 8px 10px;
|
||||
background: #fffbe6;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.xx-points-expire {
|
||||
font-size: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xx-points-popover-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
background: var(--bg-tertiary, #fafafa);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
}
|
||||
|
||||
.xx-points-stat-val {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #111827);
|
||||
margin-top: 2px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* Header 右上角的积分徽章(💎 580)
|
||||
* - 余额 <10 时橙色告警
|
||||
* - 点击弹出 Popover:余额、会员信息、充值入口、积分明细入口
|
||||
*/
|
||||
import React, { useEffect } from "react"
|
||||
import { Popover, Button, Tag, Space, Typography, Badge } from "antd"
|
||||
import {
|
||||
ThunderboltOutlined,
|
||||
CrownOutlined,
|
||||
RightOutlined,
|
||||
WarningOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import "./PointsBadge.css"
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
|
||||
const PointsBadge: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { balance, init, loading } = usePointsStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!balance) init()
|
||||
}, [balance, init])
|
||||
|
||||
const bal = balance?.balance ?? 0
|
||||
const lowBalance = bal > 0 && bal < 10
|
||||
const zero = bal === 0
|
||||
const isMember = !!balance?.is_member
|
||||
const memberLabel = isMember
|
||||
? balance?.member_type === "yearly"
|
||||
? "年卡会员"
|
||||
: balance?.member_type === "quarterly"
|
||||
? "季卡会员"
|
||||
: balance?.member_type === "monthly"
|
||||
? "月卡会员"
|
||||
: "付费会员"
|
||||
: "免费会员"
|
||||
|
||||
const popContent = (
|
||||
<div className="xx-points-popover">
|
||||
<div className="xx-points-popover-top">
|
||||
<div className="xx-points-popover-balance">
|
||||
<ThunderboltOutlined className="xx-points-icon" />
|
||||
<span className="xx-points-value">{loading ? "…" : bal}</span>
|
||||
<span className="xx-points-unit">积分</span>
|
||||
</div>
|
||||
<Tag color={isMember ? "gold" : "default"} icon={<CrownOutlined />}>
|
||||
{memberLabel}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{(zero || lowBalance) && (
|
||||
<Paragraph type="warning" className="xx-points-warn">
|
||||
<WarningOutlined /> 积分不足,充值后可继续使用 AI 功能
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
{balance?.member_expires_at && (
|
||||
<Text type="secondary" className="xx-points-expire">
|
||||
会员到期:{new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<div className="xx-points-popover-stats">
|
||||
<div>
|
||||
<Text type="secondary">累计获得</Text>
|
||||
<div className="xx-points-stat-val">+{balance?.total_earned ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">累计消耗</Text>
|
||||
<div className="xx-points-stat-val">-{balance?.total_spent ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Space direction="vertical" style={{ width: "100%" }} size={8}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
block
|
||||
onClick={() => navigate("/app/points")}
|
||||
>
|
||||
充值积分
|
||||
</Button>
|
||||
<Button block onClick={() => navigate("/app/points/transactions")}>
|
||||
积分明细
|
||||
<RightOutlined />
|
||||
</Button>
|
||||
{!isMember && (
|
||||
<Button block type="link" onClick={() => navigate("/app/subscription")}>
|
||||
<CrownOutlined /> 升级会员享折扣
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover content={popContent} placement="bottomRight" trigger="click" arrow={false}>
|
||||
<button
|
||||
type="button"
|
||||
className={`xx-points-badge ${zero ? "zero" : lowBalance ? "low" : ""}`}
|
||||
>
|
||||
<Badge dot={zero} color={zero ? "#faad14" : undefined}>
|
||||
<ThunderboltOutlined className="xx-points-badge-icon" />
|
||||
</Badge>
|
||||
<span className="xx-points-badge-val">{loading ? "…" : bal}</span>
|
||||
</button>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default PointsBadge
|
||||
@@ -1,67 +0,0 @@
|
||||
/* 功能按钮旁的积分消耗提示 */
|
||||
.xx-points-cost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-points-cost.free,
|
||||
.xx-points-cost.free-quota {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.xx-points-cost.insufficient {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.xx-points-gem {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.xx-points-num {
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.xx-points-unit-label {
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
}
|
||||
|
||||
.xx-points-tag-free {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #d1fae5;
|
||||
color: #059669;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.xx-points-free-user-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.xx-points-desc {
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.xx-points-warn-icon {
|
||||
color: #ef4444;
|
||||
margin-left: 2px;
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* 功能操作按钮旁的"消耗积分"提示
|
||||
* 例:[生成配音] 💎 -1 积分
|
||||
* - 根据 scene_key 自动读取规则
|
||||
* - 免费用户自动计算 ×1.15 向上取整
|
||||
* - 余额不足时显示红色告警 + 充值提示
|
||||
*
|
||||
* 使用:<PointsCost scene="ai_voice" units={1} />
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { Tooltip } from "antd"
|
||||
import { WarningOutlined } from "@ant-design/icons"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import type { PointsSource } from "@/api/points/types"
|
||||
import { getPointsRules } from "@/api/points"
|
||||
import "./PointsCost.css"
|
||||
|
||||
interface Props {
|
||||
/** 消耗场景 key */
|
||||
scene: PointsSource
|
||||
/** 单位数(分钟数/条数/张数),默认 1 */
|
||||
units?: number
|
||||
/** 是否显示为紧凑模式(仅图标+数字,不显示单位文字) */
|
||||
compact?: boolean
|
||||
/** 余额不足时,是否显示充值提示 */
|
||||
showRechargeHint?: boolean
|
||||
/** 自定义 class */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** 单位中文 */
|
||||
const UNIT_LABEL: Record<string, string> = {
|
||||
分钟: "分钟",
|
||||
条: "条",
|
||||
次: "次",
|
||||
张: "张",
|
||||
}
|
||||
|
||||
const PointsCost: React.FC<Props> = ({
|
||||
scene,
|
||||
units = 1,
|
||||
compact = false,
|
||||
showRechargeHint = true,
|
||||
className = "",
|
||||
}) => {
|
||||
const { balance, init } = usePointsStore()
|
||||
const [rules, setRules] = useState<Awaited<ReturnType<typeof getPointsRules>> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!balance) init()
|
||||
if (!rules) {
|
||||
getPointsRules()
|
||||
.then(setRules)
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [balance, init, rules])
|
||||
|
||||
const { cost, isFreeQuota, rule, isFreeUser, insufficient } = useMemo(() => {
|
||||
if (!rules || !balance) {
|
||||
return {
|
||||
cost: 0,
|
||||
isFreeQuota: false,
|
||||
rule: null,
|
||||
isFreeUser: !balance?.is_member,
|
||||
insufficient: false,
|
||||
}
|
||||
}
|
||||
const rule = rules.rules.find((r) => r.scene_key === scene)
|
||||
if (!rule)
|
||||
return {
|
||||
cost: 0,
|
||||
isFreeQuota: false,
|
||||
rule: null,
|
||||
isFreeUser: !balance.is_member,
|
||||
insufficient: false,
|
||||
}
|
||||
// 免费训练不扣费
|
||||
if (rule.points_per_use === 0) {
|
||||
return {
|
||||
cost: 0,
|
||||
isFreeQuota: false,
|
||||
rule,
|
||||
isFreeUser: !balance.is_member,
|
||||
insufficient: false,
|
||||
}
|
||||
}
|
||||
|
||||
// 智能混剪:首条30s=3分,每+30s +1
|
||||
let baseCost: number
|
||||
if (scene === "ai_video" && rule.extra_per_30s) {
|
||||
// units 当作"30s 段数"更简单;按分钟算:minutes 个 30s 段 - 1
|
||||
const segments = Math.max(1, Math.ceil(units * 2)) // 1min = 2 segments
|
||||
baseCost = rule.points_per_use + rule.extra_per_30s * (segments - 1)
|
||||
} else {
|
||||
baseCost = rule.points_per_use * Math.max(1, units)
|
||||
}
|
||||
|
||||
// 混剪 + 免费用户 + 今日有免费额度 → 免费
|
||||
const isFree =
|
||||
scene === "ai_video" && !balance.is_member && (balance.free_clips_remaining ?? 0) > 0
|
||||
|
||||
const isFreeUser = !balance.is_member
|
||||
const cost = isFree
|
||||
? 0
|
||||
: isFreeUser
|
||||
? Math.ceil(baseCost * rules.free_user_multiplier)
|
||||
: baseCost
|
||||
const insufficient = !isFree && balance.balance < cost
|
||||
return { cost, isFreeQuota: isFree, rule, isFreeUser, insufficient }
|
||||
}, [rules, balance, scene, units])
|
||||
|
||||
if (!rule || !balance) {
|
||||
return <span className={`xx-points-cost ${className}`} />
|
||||
}
|
||||
|
||||
if (rule.points_per_use === 0) {
|
||||
return (
|
||||
<span className={`xx-points-cost free ${className}`}>
|
||||
<span className="xx-points-tag-free">免费</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (isFreeQuota) {
|
||||
return (
|
||||
<Tooltip title={`今日免费额度剩余 ${balance.free_clips_remaining} 条,不扣积分`}>
|
||||
<span className={`xx-points-cost free-quota ${className}`}>
|
||||
<span className="xx-points-tag-free">免费</span>
|
||||
{!compact && (
|
||||
<span className="xx-points-desc">(今日剩余 {balance.free_clips_remaining} 条)</span>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const unitLabel = compact
|
||||
? ""
|
||||
: ` /${units > 1 ? `${units}${UNIT_LABEL[rule.unit] ?? rule.unit}` : rule.unit}`
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`xx-points-cost ${insufficient ? "insufficient" : ""} ${isFreeUser ? "free-user" : ""} ${className}`}
|
||||
>
|
||||
<span className="xx-points-gem">💎</span>
|
||||
<span className="xx-points-num">-{cost}</span>
|
||||
{unitLabel && <span className="xx-points-unit-label">积分{unitLabel}</span>}
|
||||
{isFreeUser && !compact && (
|
||||
<Tooltip title={`免费用户消耗 ×${rules?.free_user_multiplier ?? 1.15},开通会员享标准价`}>
|
||||
<span className="xx-points-free-user-tag">非会员</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{insufficient && showRechargeHint && (
|
||||
<Tooltip title={`积分不足,需 ${cost} 积分,当前余额 ${balance.balance}`}>
|
||||
<WarningOutlined className="xx-points-warn-icon" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default PointsCost
|
||||
@@ -1,5 +1,4 @@
|
||||
/* Phase 1 Header 样式 + 手机端适配
|
||||
v2: 增加积分徽章、升级按钮、会员金冠 */
|
||||
/* Phase 1 Header 样式 + 手机端适配 */
|
||||
.xx-top-nav {
|
||||
height: 68px;
|
||||
position: sticky;
|
||||
@@ -78,40 +77,10 @@
|
||||
.xx-right-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* v2: 顶部积分徽章 */
|
||||
.xx-header-points {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* v2: 升级会员按钮 */
|
||||
.xx-upgrade-btn {
|
||||
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||
border: none;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.xx-upgrade-btn:hover {
|
||||
background: linear-gradient(135deg, #d97706, #dc2626) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* v2: 会员金冠按钮 */
|
||||
.xx-crown-btn {
|
||||
font-weight: 700;
|
||||
color: #d97706;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.xx-crown-btn:hover {
|
||||
background: rgba(245, 158, 11, 0.1) !important;
|
||||
color: #b45309 !important;
|
||||
}
|
||||
|
||||
.xx-user-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -146,29 +115,6 @@
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* v2: 手机端积分/会员行 */
|
||||
.xx-mobile-points-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 4px 16px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.xx-mobile-member-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #d97706;
|
||||
background: #fef3c7;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.xx-mobile-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -211,7 +157,6 @@
|
||||
|
||||
.xx-top-nav-inner {
|
||||
padding: 0 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.xx-brand-text {
|
||||
@@ -232,19 +177,6 @@
|
||||
.xx-username {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 手机端:缩小升级按钮文字 */
|
||||
.xx-upgrade-btn {
|
||||
padding: 0 8px !important;
|
||||
font-size: 12px !important;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.xx-crown-btn {
|
||||
padding: 0 4px !important;
|
||||
font-size: 12px !important;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏幕平板:导航文字缩小 */
|
||||
@@ -256,14 +188,4 @@
|
||||
.xx-nav-links button {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.xx-header-points {
|
||||
transform: scale(0.9);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.xx-upgrade-btn {
|
||||
font-size: 12px !important;
|
||||
padding: 0 10px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
/**
|
||||
* Phase 1 Header 重构
|
||||
* 扁平化导航菜单 + 手机端汉堡菜单
|
||||
* v2: 增加积分徽章、升级会员入口、我的积分入口
|
||||
*/
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { Avatar, Badge, Button, Dropdown, Drawer, Space, Tooltip } from "antd"
|
||||
import {
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
MenuOutlined,
|
||||
CrownFilled,
|
||||
ThunderboltOutlined,
|
||||
HistoryOutlined,
|
||||
WalletOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import React, { useState } from "react"
|
||||
import { Avatar, Dropdown, Drawer, Space } from "antd"
|
||||
import { LogoutOutlined, SettingOutlined, UserOutlined, MenuOutlined } from "@ant-design/icons"
|
||||
import { useLocation, useNavigate } from "react-router-dom"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { useLogout } from "@/hooks/useAuth"
|
||||
import type { MenuProps } from "antd"
|
||||
import { NAV_ITEMS } from "@/config/navigation"
|
||||
import PointsBadge from "@/components/common/PointsBadge"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import "./Header.css"
|
||||
|
||||
const Header: React.FC = () => {
|
||||
@@ -31,18 +19,6 @@ const Header: React.FC = () => {
|
||||
const logoutMutation = useLogout()
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
// 积分与会员状态
|
||||
const { balance, init } = usePointsStore()
|
||||
useEffect(() => {
|
||||
init()
|
||||
// 每 60s 刷新一次(余额可能变化)
|
||||
const t = setInterval(() => init(), 60_000)
|
||||
return () => clearInterval(t)
|
||||
}, [init])
|
||||
|
||||
const isMember = !!balance?.is_member
|
||||
const memberType = balance?.member_type ?? null
|
||||
|
||||
/** 用户下拉菜单 */
|
||||
const menuItems: MenuProps["items"] = [
|
||||
{
|
||||
@@ -57,30 +33,6 @@ const Header: React.FC = () => {
|
||||
label: "订阅管理",
|
||||
onClick: () => navigate("/app/subscription"),
|
||||
},
|
||||
// v2: 我的积分入口
|
||||
{
|
||||
key: "points-center",
|
||||
icon: <ThunderboltOutlined />,
|
||||
label: (
|
||||
<Space>
|
||||
我的积分
|
||||
{balance && <span style={{ color: "#8b5cf6", fontWeight: 700 }}>{balance.balance}</span>}
|
||||
</Space>
|
||||
),
|
||||
onClick: () => navigate("/app/points"),
|
||||
},
|
||||
{
|
||||
key: "points-history",
|
||||
icon: <HistoryOutlined />,
|
||||
label: "积分明细",
|
||||
onClick: () => navigate("/app/points/transactions"),
|
||||
},
|
||||
{
|
||||
key: "recharge",
|
||||
icon: <WalletOutlined />,
|
||||
label: "充值积分",
|
||||
onClick: () => navigate("/app/points/recharge"),
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "logout",
|
||||
@@ -92,6 +44,7 @@ const Header: React.FC = () => {
|
||||
|
||||
/** 判断导航项是否激活 */
|
||||
const isActive = (path: string) => {
|
||||
// 首页特殊处理:/ 和 /app/dashboard 都算激活
|
||||
if (path === "/app/dashboard") {
|
||||
return (
|
||||
location.pathname === "/" ||
|
||||
@@ -125,47 +78,6 @@ const Header: React.FC = () => {
|
||||
</nav>
|
||||
|
||||
<div className="xx-right-section">
|
||||
{/* v2: 积分徽章(顶部显示) */}
|
||||
<PointsBadge />
|
||||
|
||||
{/* v2: 升级会员入口(仅免费用户显示) */}
|
||||
{!isMember && (
|
||||
<Tooltip title="升级会员解锁无限混剪、批量导出,积分 8 折起">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<CrownFilled />}
|
||||
className="xx-upgrade-btn"
|
||||
onClick={() => navigate("/app/subscription")}
|
||||
>
|
||||
升级会员
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* v2: 付费会员显示金冠标识 */}
|
||||
{isMember && (
|
||||
<Tooltip
|
||||
title={`${memberType === "yearly" ? "年卡" : memberType === "quarterly" ? "季卡" : "月卡"}会员${
|
||||
balance?.member_expires_at
|
||||
? ` · 到期 ${new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}`
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Badge dot color="gold">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CrownFilled style={{ color: "#f59e0b" }} />}
|
||||
className="xx-crown-btn"
|
||||
onClick={() => navigate("/app/subscription")}
|
||||
>
|
||||
{memberType === "yearly" ? "年卡" : memberType === "quarterly" ? "季卡" : "会员"}
|
||||
</Button>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 手机端汉堡菜单按钮 */}
|
||||
<button className="xx-hamburger" type="button" onClick={() => setMobileMenuOpen(true)}>
|
||||
<MenuOutlined />
|
||||
@@ -191,31 +103,6 @@ const Header: React.FC = () => {
|
||||
width={260}
|
||||
className="xx-mobile-drawer"
|
||||
>
|
||||
{/* 手机端积分/会员入口 */}
|
||||
{balance && (
|
||||
<div className="xx-mobile-points-row">
|
||||
<PointsBadge />
|
||||
{!isMember ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<CrownFilled />}
|
||||
onClick={() => {
|
||||
navigate("/app/subscription")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
升级
|
||||
</Button>
|
||||
) : (
|
||||
<span className="xx-mobile-member-tag">
|
||||
<CrownFilled style={{ color: "#f59e0b" }} />{" "}
|
||||
{memberType === "yearly" ? "年卡" : memberType === "quarterly" ? "季卡" : "月卡"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="xx-mobile-nav">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<button
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
ScanOutlined,
|
||||
ControlOutlined,
|
||||
CrownOutlined,
|
||||
ThunderboltOutlined,
|
||||
UnorderedListOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons"
|
||||
@@ -106,18 +105,6 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/tasks",
|
||||
icon: React.createElement(UnorderedListOutlined),
|
||||
},
|
||||
{
|
||||
key: "subscription",
|
||||
label: "会员订阅",
|
||||
path: "/app/subscription",
|
||||
icon: React.createElement(CrownOutlined),
|
||||
},
|
||||
{
|
||||
key: "points",
|
||||
label: "积分中心",
|
||||
path: "/app/points",
|
||||
icon: React.createElement(ThunderboltOutlined),
|
||||
},
|
||||
]
|
||||
|
||||
/** 侧边栏导航分组(Sidebar 分组列表使用) */
|
||||
@@ -209,16 +196,10 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
},
|
||||
{
|
||||
key: "subscription",
|
||||
label: "会员订阅",
|
||||
label: "订阅管理",
|
||||
path: "/app/subscription",
|
||||
icon: React.createElement(CrownOutlined),
|
||||
},
|
||||
{
|
||||
key: "points",
|
||||
label: "积分中心",
|
||||
path: "/app/points",
|
||||
icon: React.createElement(ThunderboltOutlined),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -28,21 +28,13 @@ import { useVariantVoicePreview } from "./hooks/useVariantVoicePreview"
|
||||
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import { previewTts } from "@/api/tts"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import { hasEnoughPoints } from "./hooks/pointsCost"
|
||||
import "./generate.css"
|
||||
import "./generate-points.css"
|
||||
|
||||
const GeneratePage: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
/* ── 表单状态 ── */
|
||||
const formState = useGenerateFormState()
|
||||
/* ── 积分状态 ── */
|
||||
const { balance, init: initPoints } = usePointsStore()
|
||||
useEffect(() => {
|
||||
initPoints()
|
||||
}, [initPoints])
|
||||
const {
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
@@ -356,13 +348,6 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
/* ── 步骤3「确认生成视频」:校验通过 → 创建正式生成任务 → 跳步骤4看实时进展 ── */
|
||||
const handleConfirmGenerate = useCallback(async () => {
|
||||
// 积分预检查
|
||||
const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
|
||||
const check = hasEnoughPoints(balance ?? null, units)
|
||||
if (!check.sufficient) {
|
||||
message.error(check.reason ?? "积分不足,请充值")
|
||||
return
|
||||
}
|
||||
if (isBatch) {
|
||||
if (selectedVariantIds.length === 0) {
|
||||
message.warning("请至少勾选一个视频")
|
||||
@@ -396,7 +381,6 @@ const GeneratePage: React.FC = () => {
|
||||
previewReady,
|
||||
handleGenerate,
|
||||
setCurrentStep,
|
||||
balance,
|
||||
])
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
@@ -420,14 +404,6 @@ const GeneratePage: React.FC = () => {
|
||||
return "xx-generate-layout full-width"
|
||||
}, [currentStep])
|
||||
|
||||
/* ── 积分消耗估算(步骤3确认生成展示用) ── */
|
||||
const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
|
||||
const pointsEstimate = useMemo(
|
||||
() => hasEnoughPoints(balance ?? null, unitsForCost),
|
||||
[unitsForCost, balance],
|
||||
)
|
||||
const insufficientPoints = !pointsEstimate.sufficient
|
||||
|
||||
/* ================================================================
|
||||
渲染
|
||||
================================================================ */
|
||||
@@ -631,11 +607,6 @@ const GeneratePage: React.FC = () => {
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
selectedCount={isBatch ? selectedVariantIds.length : 1}
|
||||
estimatedCost={pointsEstimate.cost}
|
||||
freeClipsUsedThisTime={pointsEstimate.freeClipsUsed ?? 0}
|
||||
pointsInsufficient={insufficientPoints}
|
||||
insufficientReason={pointsEstimate.reason}
|
||||
onRecharge={() => navigate("/app/points/recharge")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,38 +1,24 @@
|
||||
/**
|
||||
* GeneratePage 步骤底部操作按钮(Issue #1677 修正:固定 6 步)
|
||||
* v2: 步骤4 按钮前显示本次积分消耗;积分不足时禁用按钮并提示充值
|
||||
* GeneratePage 步骤底部操作按钮(#1899 简化为 5 步)
|
||||
*
|
||||
* 步骤 1~3:上一步 / 下一步
|
||||
* 步骤 4(选择标题):「✨ 确认生成视频 / 确认生成 N 个视频」→ 创建正式生成任务,成功后跳步骤5
|
||||
* 步骤 5(确认生成):渲染进度页,全部完成后「下一步:选择封面」;仅上一步
|
||||
* 步骤 6(选择封面):仅上一步
|
||||
* 步骤 1~2:上一步 / 下一步
|
||||
* 步骤 3(选择标题):「✨ 确认生成视频 / 确认生成 N 个视频」→ 创建正式生成任务,成功后跳步骤4
|
||||
* 步骤 4(确认生成):渲染进度页,全部完成后「下一步:选择封面」;仅上一步
|
||||
* 步骤 5(选择封面):仅上一步
|
||||
*/
|
||||
import React from "react"
|
||||
import { Tooltip } from "antd"
|
||||
import { ThunderboltOutlined, ExclamationCircleOutlined } from "@ant-design/icons"
|
||||
|
||||
export interface GenerateStepActionsProps {
|
||||
currentStep: number
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
/** 步骤4:确认生成视频(校验 + 创建渲染任务) */
|
||||
/** 步骤3:确认生成视频(校验 + 创建渲染任务) */
|
||||
onConfirmGenerate: () => void | Promise<void>
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
/** 批量模式下勾选的视频数量(N=1 时为1) */
|
||||
selectedCount?: number
|
||||
// v2: 积分相关
|
||||
/** 本次预估消耗积分 */
|
||||
estimatedCost?: number
|
||||
/** 是否积分不足 */
|
||||
pointsInsufficient?: boolean
|
||||
/** 积分不足原因 */
|
||||
insufficientReason?: string
|
||||
/** 剩余免费混剪次数 */
|
||||
freeClipsUsedThisTime?: number
|
||||
/** 前往充值 */
|
||||
onRecharge?: () => void
|
||||
}
|
||||
|
||||
const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
@@ -44,15 +30,10 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
generated,
|
||||
generateError,
|
||||
selectedCount = 1,
|
||||
estimatedCost,
|
||||
pointsInsufficient,
|
||||
insufficientReason,
|
||||
freeClipsUsedThisTime,
|
||||
onRecharge,
|
||||
}) => {
|
||||
const renderPrimaryButton = () => {
|
||||
/* 步骤 1~3:上一步 / 下一步 */
|
||||
if (currentStep < 4) {
|
||||
/* 步骤 1~2:上一步 / 下一步 */
|
||||
if (currentStep < 3) {
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onNext}>
|
||||
下一步 →
|
||||
@@ -60,20 +41,8 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
/* 步骤 4:选择标题 — 确认生成 */
|
||||
if (currentStep === 4) {
|
||||
const costTag =
|
||||
typeof estimatedCost === "number" && estimatedCost > 0 ? (
|
||||
<span className="xx-step-cost-tag">
|
||||
<ThunderboltOutlined />
|
||||
{estimatedCost}
|
||||
</span>
|
||||
) : typeof estimatedCost === "number" &&
|
||||
estimatedCost === 0 &&
|
||||
typeof freeClipsUsedThisTime === "number" ? (
|
||||
<span className="xx-step-cost-tag free">使用 {freeClipsUsedThisTime} 次免费额度</span>
|
||||
) : null
|
||||
|
||||
/* 步骤 3:选择标题 — 确认生成 */
|
||||
if (currentStep === 3) {
|
||||
if (generating) {
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" disabled>
|
||||
@@ -85,41 +54,18 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onConfirmGenerate}>
|
||||
🔄 重新生成
|
||||
{costTag}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (pointsInsufficient) {
|
||||
return (
|
||||
<Tooltip title={insufficientReason ?? "积分不足,请充值"}>
|
||||
<span>
|
||||
<button className="xx-btn xx-btn-primary" disabled>
|
||||
<ExclamationCircleOutlined /> 积分不足
|
||||
{costTag}
|
||||
</button>
|
||||
{onRecharge && (
|
||||
<button
|
||||
className="xx-btn xx-btn-ghost"
|
||||
style={{ marginLeft: 8, color: "#7c3aed", borderColor: "#c4b5fd" }}
|
||||
onClick={onRecharge}
|
||||
>
|
||||
去充值 →
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onConfirmGenerate}>
|
||||
{selectedCount > 1 ? `✨ 确认生成 ${selectedCount} 个视频` : "✨ 确认生成视频"}
|
||||
{costTag}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* 步骤 5:确认生成进度页 — 全部完成后下一步进封面 */
|
||||
if (currentStep === 5) {
|
||||
/* 步骤 4:确认生成进度页 — 全部完成后下一步进封面 */
|
||||
if (currentStep === 4) {
|
||||
if (generated) {
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onNext}>
|
||||
@@ -134,7 +80,7 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
/* 步骤 6(封面,最后一步):无主按钮 */
|
||||
/* 步骤 5(封面,最后一步):无主按钮 */
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
/* Points cost badge inside step actions button */
|
||||
.xx-step-cost-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.xx-step-cost-tag.free {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: #d1fae5;
|
||||
}
|
||||
|
||||
.xx-step-cost-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 10px;
|
||||
color: #92400e;
|
||||
font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* 生成前积分消耗估算与余额校验
|
||||
* 用于步骤4「确认生成」按钮前展示本次消耗,积分不足时禁用并提示充值
|
||||
*/
|
||||
import type { PointsBalance } from "@/api/points/types"
|
||||
|
||||
/** 生成单条视频消耗积分(基准) */
|
||||
export const BASE_VIDEO_POINTS = 3
|
||||
|
||||
/**
|
||||
* 估算生成任务的积分消耗
|
||||
* @param videoCount 视频条数(批量模式)
|
||||
* @param memberMultiplier 会员倍率(免费用户 1.15)
|
||||
*/
|
||||
export function estimateGenerateCost(videoCount: number, memberMultiplier = 1): number {
|
||||
const raw = BASE_VIDEO_POINTS * videoCount * memberMultiplier
|
||||
// 向上取整,避免小数
|
||||
return Math.ceil(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断积分是否充足(含每日免费额度)
|
||||
* @returns sufficient=true 表示可以继续生成;false 需要提示充值
|
||||
*/
|
||||
export function hasEnoughPoints(
|
||||
balance: PointsBalance | null,
|
||||
videoCount: number,
|
||||
): {
|
||||
sufficient: boolean
|
||||
cost: number
|
||||
reason?: string
|
||||
freeClipsUsed?: number
|
||||
freeClipsRemaining?: number
|
||||
} {
|
||||
if (!balance) {
|
||||
// 未登录或未加载:不拦截,后端会校验
|
||||
return { sufficient: true, cost: estimateGenerateCost(videoCount) }
|
||||
}
|
||||
|
||||
const isMember = balance.is_member
|
||||
const multiplier = isMember ? 1 : 1.15
|
||||
const cost = estimateGenerateCost(videoCount, multiplier)
|
||||
|
||||
// 免费用户优先使用每日免费额度
|
||||
if (!isMember && balance.free_clips_remaining && balance.free_clips_remaining > 0) {
|
||||
const freeUsed = Math.min(balance.free_clips_remaining, videoCount)
|
||||
const remainingAfterFree = videoCount - freeUsed
|
||||
const paidCost = estimateGenerateCost(remainingAfterFree, multiplier)
|
||||
if (paidCost === 0) {
|
||||
// 完全用免费额度
|
||||
return {
|
||||
sufficient: true,
|
||||
cost: 0,
|
||||
freeClipsUsed: freeUsed,
|
||||
freeClipsRemaining: balance.free_clips_remaining - freeUsed,
|
||||
}
|
||||
}
|
||||
if (balance.balance >= paidCost) {
|
||||
return {
|
||||
sufficient: true,
|
||||
cost: paidCost,
|
||||
freeClipsUsed: freeUsed,
|
||||
freeClipsRemaining: balance.free_clips_remaining - freeUsed,
|
||||
}
|
||||
}
|
||||
return {
|
||||
sufficient: false,
|
||||
cost: paidCost,
|
||||
reason: `积分不足:本次需 ${paidCost} 积分(使用 ${freeUsed} 次免费额度后),当前余额 ${balance.balance},还差 ${paidCost - balance.balance} 积分`,
|
||||
freeClipsUsed: freeUsed,
|
||||
}
|
||||
}
|
||||
|
||||
// 付费会员或免费额度用完
|
||||
if (balance.balance >= cost) {
|
||||
return { sufficient: true, cost }
|
||||
}
|
||||
return {
|
||||
sufficient: false,
|
||||
cost,
|
||||
reason: `积分不足:本次需 ${cost} 积分,当前余额 ${balance.balance},还差 ${cost - balance.balance} 积分`,
|
||||
}
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
/**
|
||||
* 积分中心主页(/app/points)
|
||||
* 展示余额、会员信息、本月统计、快捷入口
|
||||
*/
|
||||
import React, { useEffect } from "react"
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
Row,
|
||||
Statistic,
|
||||
Button,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
Progress,
|
||||
List,
|
||||
Avatar,
|
||||
Empty,
|
||||
} from "antd"
|
||||
import {
|
||||
ThunderboltOutlined,
|
||||
CrownFilled,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
HistoryOutlined,
|
||||
WalletOutlined,
|
||||
FileTextOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import "./Points.css"
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const SOURCE_NAME: Record<string, string> = {
|
||||
recharge: "充值",
|
||||
task_reward: "任务奖励",
|
||||
ai_voice: "AI 配音",
|
||||
ai_digital_human: "AI 数字人",
|
||||
ai_video: "智能混剪",
|
||||
voice_clone_train: "声音克隆训练",
|
||||
voice_clone_synth: "声音克隆合成",
|
||||
douyin_extract: "抖音提取",
|
||||
ai_rewrite: "AI 改写",
|
||||
ai_title: "AI 标题",
|
||||
ai_cover: "AI 封面",
|
||||
subscription_bonus: "会员赠送",
|
||||
admin_adjust: "管理员调整",
|
||||
refund: "失败退还",
|
||||
}
|
||||
|
||||
const PointsCenter: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { balance, subscription, init, loading } = usePointsStore()
|
||||
|
||||
useEffect(() => {
|
||||
init()
|
||||
}, [init])
|
||||
|
||||
const bal = balance?.balance ?? 0
|
||||
const earned = balance?.total_earned ?? 0
|
||||
const spent = balance?.total_spent ?? 0
|
||||
const isMember = !!balance?.is_member
|
||||
const freeUsed = balance?.free_clips_used ?? 0
|
||||
const freeLimit = balance?.free_clips_limit ?? 2
|
||||
const freeRemain = balance?.free_clips_remaining ?? (isMember ? 0 : 2)
|
||||
|
||||
// 近 5 条流水 mock(实际从 transactions 页加载)
|
||||
const recentTx = [
|
||||
{ type: "spend", source: "ai_voice", amount: 1, time: "今天 10:30" },
|
||||
{ type: "spend", source: "ai_video", amount: 3, time: "今天 09:15" },
|
||||
{ type: "earn", source: "task_reward", amount: 20, time: "昨天" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="xx-points-page">
|
||||
<PageHead
|
||||
title="积分中心"
|
||||
description="管理积分余额、查看流水、充值使用"
|
||||
|
||||
actions={
|
||||
<Space>
|
||||
<Button icon={<FileTextOutlined />} onClick={() => navigate("/app/points/rules")}>
|
||||
积分规则
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<WalletOutlined />}
|
||||
onClick={() => navigate("/app/subscription")}
|
||||
>
|
||||
充值积分
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 顶部大卡 */}
|
||||
<Card className="xx-points-hero" bordered={false}>
|
||||
<Row gutter={24} align="middle">
|
||||
<Col xs={24} md={14}>
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<Text type="secondary" style={{ color: "rgba(255,255,255,0.8)" }}>
|
||||
当前可用积分
|
||||
</Text>
|
||||
<div className="xx-points-hero-balance">
|
||||
<ThunderboltOutlined />
|
||||
<span>{loading ? "…" : bal.toLocaleString()}</span>
|
||||
</div>
|
||||
<Space size={8} wrap>
|
||||
{isMember ? (
|
||||
<Tag color="gold" icon={<CrownFilled />} style={{ padding: "4px 10px" }}>
|
||||
{subscription?.member_type === "yearly"
|
||||
? "年卡"
|
||||
: subscription?.member_type === "quarterly"
|
||||
? "季卡"
|
||||
: "月卡"}
|
||||
会员
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
background: "rgba(255,255,255,0.2)",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
}}
|
||||
>
|
||||
免费会员
|
||||
</Tag>
|
||||
)}
|
||||
{balance?.member_expires_at && (
|
||||
<Text style={{ color: "rgba(255,255,255,0.85)", fontSize: 12 }}>
|
||||
到期 {new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
|
||||
</Text>
|
||||
)}
|
||||
{!isMember && (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => navigate("/app/subscription")}
|
||||
style={{ background: "#fff", color: "#7c3aed", borderColor: "#fff" }}
|
||||
>
|
||||
<CrownFilled /> 升级会员享 8 折
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col xs={24} md={10}>
|
||||
{/* 今日免费额度 */}
|
||||
{!isMember && freeLimit > 0 && (
|
||||
<div className="xx-points-free-quota">
|
||||
<div className="xx-points-free-title">
|
||||
<InfoCircleOutlined /> 今日免费混剪
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round((freeUsed / freeLimit) * 100)}
|
||||
strokeColor={{ "0%": "#f59e0b", "100%": "#ef4444" }}
|
||||
format={() => `${freeUsed}/${freeLimit} 条`}
|
||||
/>
|
||||
<Text style={{ color: "rgba(255,255,255,0.8)", fontSize: 12 }}>
|
||||
剩余 {freeRemain} 条免费混剪,超出部分消耗积分
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 统计 */}
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col xs={12} md={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="累计获得"
|
||||
value={earned}
|
||||
prefix={<ArrowUpOutlined style={{ color: "#10b981" }} />}
|
||||
valueStyle={{ color: "#10b981" }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="累计消耗"
|
||||
value={spent}
|
||||
prefix={<ArrowDownOutlined style={{ color: "#ef4444" }} />}
|
||||
valueStyle={{ color: "#ef4444" }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="可用积分"
|
||||
value={bal}
|
||||
prefix={<ThunderboltOutlined style={{ color: "#8b5cf6" }} />}
|
||||
valueStyle={{ color: "#8b5cf6" }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={isMember ? "会员等级" : "今日免费剩余"}
|
||||
value={isMember ? (subscription?.member_type_name ?? "付费会员") : `${freeRemain} 条`}
|
||||
prefix={<CrownFilled style={{ color: "#f59e0b" }} />}
|
||||
valueStyle={{ color: "#f59e0b" }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 快捷入口 & 最近流水 */}
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 8 }}>
|
||||
<Col xs={24} md={14}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<HistoryOutlined />
|
||||
最近流水
|
||||
</Space>
|
||||
}
|
||||
extra={<a onClick={() => navigate("/app/points/transactions")}>查看全部 →</a>}
|
||||
>
|
||||
{recentTx.length === 0 ? (
|
||||
<Empty description="暂无积分流水" />
|
||||
) : (
|
||||
<List
|
||||
dataSource={recentTx}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<Avatar
|
||||
size="small"
|
||||
style={{
|
||||
background: item.type === "earn" ? "#d1fae5" : "#fee2e2",
|
||||
color: item.type === "earn" ? "#059669" : "#dc2626",
|
||||
}}
|
||||
icon={item.type === "earn" ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
||||
/>
|
||||
}
|
||||
title={SOURCE_NAME[item.source] ?? item.source}
|
||||
description={item.time}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
color: item.type === "earn" ? "#10b981" : "#ef4444",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{item.type === "earn" ? "+" : "-"}
|
||||
{item.amount}
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={10}>
|
||||
<Card title="快捷操作">
|
||||
<Space direction="vertical" style={{ width: "100%" }} size={10}>
|
||||
<Button
|
||||
block
|
||||
size="large"
|
||||
type="primary"
|
||||
icon={<WalletOutlined />}
|
||||
onClick={() => navigate("/app/subscription")}
|
||||
>
|
||||
充值积分
|
||||
</Button>
|
||||
<Button
|
||||
block
|
||||
size="large"
|
||||
icon={<HistoryOutlined />}
|
||||
onClick={() => navigate("/app/points/transactions")}
|
||||
>
|
||||
积分明细
|
||||
</Button>
|
||||
<Button
|
||||
block
|
||||
size="large"
|
||||
icon={<CrownFilled />}
|
||||
onClick={() => navigate("/app/subscription")}
|
||||
>
|
||||
{isMember ? "续费/升级会员" : "升级付费会员"}
|
||||
</Button>
|
||||
<Button
|
||||
block
|
||||
size="large"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => navigate("/app/points/rules")}
|
||||
>
|
||||
积分消耗规则
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PointsCenter
|
||||
export const Component = PointsCenter
|
||||
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* 积分充值页(/app/points/recharge)
|
||||
* 单独展示积分包,供入口直接跳转使用
|
||||
* 主 Plans 页面也有充值区,这里提供独立 URL 方便从"积分不足"弹窗跳转
|
||||
*/
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { Card, Col, Row, Button, Tag, Typography, Space, Modal, message, Tooltip } from "antd"
|
||||
import { ThunderboltOutlined, SafetyCertificateOutlined, CrownFilled } from "@ant-design/icons"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import { createPointsOrder, getPointsPackages } from "@/api/points"
|
||||
import type { PointsPackage } from "@/api/points/types"
|
||||
import "./Points.css"
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
const PointsRecharge: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { balance, init } = usePointsStore()
|
||||
const [packages, setPackages] = useState<PointsPackage[]>([])
|
||||
const [buying, setBuying] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
init()
|
||||
getPointsPackages()
|
||||
.then((r) => setPackages(r.packages))
|
||||
.catch(() => {})
|
||||
}, [init])
|
||||
|
||||
const getPackPrice = (pkg: PointsPackage): number => {
|
||||
const mt = balance?.member_type ?? "free"
|
||||
type DiscountKey =
|
||||
| "discounted_price_for_free"
|
||||
| "discounted_price_for_monthly"
|
||||
| "discounted_price_for_quarterly"
|
||||
| "discounted_price_for_yearly"
|
||||
const key = `discounted_price_for_${mt}` as DiscountKey
|
||||
return pkg[key] ?? pkg.price
|
||||
}
|
||||
|
||||
const handleBuy = async (pkg: PointsPackage) => {
|
||||
try {
|
||||
setBuying(pkg.id)
|
||||
const order = await createPointsOrder({ package_id: pkg.id })
|
||||
Modal.info({
|
||||
title: "支付功能开发中",
|
||||
icon: <SafetyCertificateOutlined />,
|
||||
content: (
|
||||
<div>
|
||||
<Paragraph>
|
||||
订单已创建({order.id.slice(0, 16)}…),金额{" "}
|
||||
<b>¥{(order.price_cents / 100).toFixed(2).replace(/\.00$/, "")}</b>。
|
||||
微信/支付宝支付正在接入中。
|
||||
</Paragraph>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
现阶段所有功能免费体验,积分仅为演示数据。
|
||||
</Paragraph>
|
||||
</div>
|
||||
),
|
||||
okText: "知道了",
|
||||
})
|
||||
} catch (e) {
|
||||
const err = e as { message?: string }
|
||||
message.error(err?.message ?? "下单失败")
|
||||
} finally {
|
||||
setBuying(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-points-page">
|
||||
<PageHead
|
||||
title="积分充值"
|
||||
description="积分永久有效,可用于全部 AI 功能;付费会员享折扣"
|
||||
|
||||
actions={
|
||||
<Space>
|
||||
{!balance?.is_member && (
|
||||
<Button icon={<CrownFilled />} onClick={() => navigate("/app/subscription")}>
|
||||
升级会员 8 折起
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => navigate("/app/points/transactions")}>积分明细</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
{balance && (
|
||||
<Card
|
||||
bordered={false}
|
||||
style={{ marginBottom: 16, background: "linear-gradient(135deg,#ede9fe,#fce7f3)" }}
|
||||
>
|
||||
<Space size="large">
|
||||
<div>
|
||||
<Text type="secondary">当前可用积分</Text>
|
||||
<div style={{ fontSize: 28, fontWeight: 800, color: "#7c3aed" }}>
|
||||
<ThunderboltOutlined /> {balance.balance.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Title level={4}>选择积分包</Title>
|
||||
<Row gutter={[20, 20]}>
|
||||
{packages.map((pkg) => {
|
||||
const price = getPackPrice(pkg)
|
||||
const discount = price < pkg.price ? Math.round((1 - price / pkg.price) * 100) : 0
|
||||
const unit = price / pkg.points
|
||||
return (
|
||||
<Col xs={24} sm={12} md={8} key={pkg.id}>
|
||||
<Card
|
||||
className={`xx-pkg-card ${pkg.id === "basic_pack" ? "recommended" : ""} ${discount > 0 ? "has-discount" : ""}`}
|
||||
hoverable
|
||||
>
|
||||
{pkg.id === "basic_pack" && <div className="xx-pkg-badge">热门</div>}
|
||||
{discount > 0 && (
|
||||
<Tag color="gold" className="xx-pkg-discount">
|
||||
会员{10 - discount / 10}折
|
||||
</Tag>
|
||||
)}
|
||||
<div className="xx-pkg-name">{pkg.name}</div>
|
||||
<div className="xx-pkg-points">
|
||||
<ThunderboltOutlined /> {pkg.points.toLocaleString()} 积分
|
||||
</div>
|
||||
<div className="xx-pkg-price">
|
||||
<span className="currency">¥</span>
|
||||
<span className="amount">
|
||||
{(price / 100).toFixed(price % 100 === 0 ? 0 : 1).replace(/\.0$/, "")}
|
||||
</span>
|
||||
{discount > 0 && (
|
||||
<span className="xx-pkg-origin">¥{(pkg.price / 100).toFixed(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-pkg-unit">≈¥{unit.toFixed(3)}/积分 · 永久有效</div>
|
||||
<Button
|
||||
block
|
||||
type={pkg.id === "basic_pack" ? "primary" : "default"}
|
||||
loading={buying === pkg.id}
|
||||
onClick={() => handleBuy(pkg)}
|
||||
size="large"
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
立即购买
|
||||
</Button>
|
||||
</Card>
|
||||
</Col>
|
||||
)
|
||||
})}
|
||||
</Row>
|
||||
|
||||
<Card style={{ marginTop: 24 }}>
|
||||
<Title level={5}>积分消耗说明</Title>
|
||||
<ul style={{ paddingLeft: 20, color: "var(--text-secondary)", lineHeight: 2 }}>
|
||||
<li>智能混剪:3 积分/条(≤30s),每加 30s +1 积分</li>
|
||||
<li>AI 配音 / 声音克隆合成:1 积分/分钟</li>
|
||||
<li>AI 数字人:15 积分/分钟</li>
|
||||
<li>抖音链接提取 / AI 改写 / AI 标题 / AI 封面:1~2 积分/次</li>
|
||||
<li>声音克隆训练:免费</li>
|
||||
<li>免费用户每日 2 条混剪免费,其余 AI 功能消耗为会员价 ×1.15</li>
|
||||
</ul>
|
||||
<Tooltip title="具体规则以系统实际计算为准">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
<SafetyCertificateOutlined /> 所有规则以页面实际显示为准
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PointsRecharge
|
||||
export const Component = PointsRecharge
|
||||
@@ -1,88 +0,0 @@
|
||||
/* 积分中心 & 明细 & 规则页通用样式 */
|
||||
.xx-points-page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
/* Hero 渐变卡 */
|
||||
.xx-points-hero {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #ec4899 50%, #f59e0b 100%);
|
||||
border-radius: 16px;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xx-points-hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -60px;
|
||||
top: -60px;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.15) 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.xx-points-hero .ant-card-body {
|
||||
padding: 28px 32px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xx-points-hero-balance {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
font-size: 52px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.xx-points-hero-balance .anticon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.xx-points-free-quota {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.xx-points-free-title {
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.xx-points-free-quota .ant-progress-text {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* 列表筛选 */
|
||||
.xx-points-filters {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-tx-earn {
|
||||
color: #10b981;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.xx-tx-spend {
|
||||
color: #ef4444;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.xx-tx-refund {
|
||||
color: #3b82f6;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 规则页 */
|
||||
.xx-rules-table .ant-descriptions-item-label {
|
||||
width: 160px;
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* 积分规则页(/app/points/rules)
|
||||
* 静态展示积分获得、消耗、会员折扣等规则
|
||||
*/
|
||||
import React from "react"
|
||||
import { Card, Descriptions, Table, Tag, Typography, Divider, Space } from "antd"
|
||||
import { InfoCircleOutlined, CrownFilled, ThunderboltOutlined } from "@ant-design/icons"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import "./Points.css"
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
const costData = [
|
||||
{ key: "ai_video", scene: "智能混剪", unit: "条(≤30s)", cost: 3, remark: "每加 30s +1" },
|
||||
{ key: "ai_voice", scene: "AI 配音(含克隆合成)", unit: "分钟", cost: 1, remark: "" },
|
||||
{ key: "ai_digital_human", scene: "AI 数字人", unit: "分钟", cost: 15, remark: "" },
|
||||
{ key: "douyin_extract", scene: "抖音文案提取", unit: "次", cost: 1, remark: "" },
|
||||
{ key: "ai_rewrite", scene: "AI 文案改写", unit: "次", cost: 1, remark: "" },
|
||||
{ key: "ai_title", scene: "AI 标题生成", unit: "次", cost: 1, remark: "" },
|
||||
{ key: "ai_cover", scene: "AI 封面生成", unit: "次", cost: 2, remark: "" },
|
||||
{ key: "voice_clone_train", scene: "声音克隆训练", unit: "次", cost: 0, remark: "免费" },
|
||||
]
|
||||
|
||||
const earnData = [
|
||||
{ key: "new_user", channel: "注册奖励", points: "50~100", remark: "新人专享" },
|
||||
{ key: "recharge", channel: "充值购买", points: "100~2000/次", remark: "永久有效" },
|
||||
{ key: "subscribe", channel: "开通会员赠送", points: "见会员方案", remark: "季/年卡赠送更多" },
|
||||
{ key: "task", channel: "任务/活动奖励", points: "不定额", remark: "运营活动发放" },
|
||||
]
|
||||
|
||||
const PointsRules: React.FC = () => {
|
||||
return (
|
||||
<div className="xx-points-page">
|
||||
<PageHead title="积分规则" description="了解如何获得、使用与管理积分" />
|
||||
|
||||
<Card>
|
||||
<Title level={5}>
|
||||
<InfoCircleOutlined style={{ color: "#3b82f6" }} /> 什么是积分?
|
||||
</Title>
|
||||
<Paragraph>
|
||||
积分是小虾 SaaS 用于结算 AI 能力的虚拟货币。每次使用智能混剪、AI 配音、AI 数字人、AI
|
||||
改写等功能时,
|
||||
会根据使用量扣除相应积分。积分永久有效,可通过充值、任务奖励、注册礼包等方式获取。
|
||||
</Paragraph>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Title level={5}>
|
||||
<ThunderboltOutlined style={{ color: "#8b5cf6" }} /> 积分消耗规则
|
||||
</Title>
|
||||
<Table
|
||||
className="xx-rules-table"
|
||||
dataSource={costData}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: "功能场景", dataIndex: "scene", width: 200 },
|
||||
{ title: "计费单位", dataIndex: "unit", width: 140 },
|
||||
{
|
||||
title: "消耗积分",
|
||||
dataIndex: "cost",
|
||||
width: 110,
|
||||
render: (v: number) =>
|
||||
v === 0 ? (
|
||||
<Tag color="green">免费</Tag>
|
||||
) : (
|
||||
<Text strong style={{ color: "#ef4444" }}>
|
||||
{v}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{ title: "备注", dataIndex: "remark" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Paragraph type="secondary" style={{ marginTop: 12, fontSize: 12 }}>
|
||||
免费用户每日享有 2 条免费混剪额度,超出后按上述标准 ×1.15 扣除积分;
|
||||
付费会员按上述标准原价扣除积分。
|
||||
</Paragraph>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Title level={5} style={{ color: "#10b981" }}>
|
||||
↑ 积分获得方式
|
||||
</Title>
|
||||
<Table
|
||||
className="xx-rules-table"
|
||||
dataSource={earnData}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: "渠道", dataIndex: "channel", width: 200 },
|
||||
{ title: "积分数量", dataIndex: "points", width: 180 },
|
||||
{ title: "备注", dataIndex: "remark" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Title level={5}>
|
||||
<CrownFilled style={{ color: "#f59e0b" }} /> 会员折扣
|
||||
</Title>
|
||||
<Descriptions bordered column={1} size="small">
|
||||
<Descriptions.Item label="免费用户">
|
||||
所有 AI 功能按标准价 ×1.15 消耗积分;每日 2 条免费混剪
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="付费会员(月/季/年卡)">
|
||||
所有 AI 功能按标准价消耗;积分购买享 8~9 折;解锁批量导出、多平台发布等高级功能
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="年卡会员">
|
||||
积分购买最低 8 折;开通即赠送 2000 积分
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="季卡会员">
|
||||
积分购买 8.5 折;开通即赠送 400 积分
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Title level={5}>退款与有效期</Title>
|
||||
<ul style={{ paddingLeft: 20, lineHeight: 2 }}>
|
||||
<li>充值积分永久有效,不会过期</li>
|
||||
<li>AI 生成失败或服务异常时,已扣除的积分将自动退还</li>
|
||||
<li>会员为虚拟商品,一经开通不支持退款;到期后会员权益自动失效,积分余额不受影响</li>
|
||||
<li>如需发票请联系客服</li>
|
||||
</ul>
|
||||
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
规则更新时间:2026-09-15
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PointsRules
|
||||
export const Component = PointsRules
|
||||
@@ -1,252 +0,0 @@
|
||||
/**
|
||||
* 积分明细页(/app/points/transactions)
|
||||
* 分页展示积分流水,支持按类型/来源筛选
|
||||
*/
|
||||
import React, { useEffect, useState, useCallback } from "react"
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Tag,
|
||||
Select,
|
||||
DatePicker,
|
||||
Space,
|
||||
Typography,
|
||||
Empty,
|
||||
Input,
|
||||
Spin,
|
||||
Button,
|
||||
} from "antd"
|
||||
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons"
|
||||
import type { ColumnsType } from "antd/es/table"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import dayjs from "dayjs"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import { getPointsTransactions } from "@/api/points"
|
||||
import type { PointsTransaction, PointsTxType } from "@/api/points/types"
|
||||
import "./Points.css"
|
||||
|
||||
const { Text } = Typography
|
||||
const { RangePicker } = DatePicker
|
||||
|
||||
const TYPE_LABEL: Record<PointsTxType, { text: string; color: string }> = {
|
||||
earn: { text: "获得", color: "green" },
|
||||
spend: { text: "消耗", color: "red" },
|
||||
refund: { text: "退还", color: "blue" },
|
||||
}
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
recharge: "充值",
|
||||
task_reward: "任务奖励",
|
||||
ai_voice: "AI 配音",
|
||||
ai_digital_human: "AI 数字人",
|
||||
ai_video: "智能混剪",
|
||||
voice_clone_train: "声音克隆训练",
|
||||
voice_clone_synth: "克隆合成",
|
||||
douyin_extract: "抖音链接提取",
|
||||
ai_rewrite: "AI 文案改写",
|
||||
ai_title: "AI 标题生成",
|
||||
ai_cover: "AI 封面生成",
|
||||
subscription_bonus: "会员赠送",
|
||||
admin_adjust: "管理员调整",
|
||||
refund: "失败退还",
|
||||
}
|
||||
|
||||
const PointsTransactions: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [data, setData] = useState<PointsTransaction[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [type, setType] = useState<PointsTxType | "all">("all")
|
||||
const [source, setSource] = useState<string>("all")
|
||||
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null)
|
||||
const [keyword, setKeyword] = useState("")
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: Record<string, string | number> = { page, page_size: pageSize }
|
||||
if (type !== "all") params.type = type
|
||||
if (source !== "all") params.source = source
|
||||
if (dateRange && dateRange[0] && dateRange[1]) {
|
||||
params.start_date = dateRange[0].format("YYYY-MM-DD")
|
||||
params.end_date = dateRange[1].format("YYYY-MM-DD")
|
||||
}
|
||||
const res = await getPointsTransactions(params)
|
||||
let items = res.items
|
||||
if (keyword) {
|
||||
const k = keyword.toLowerCase()
|
||||
items = items.filter(
|
||||
(it) =>
|
||||
it.description.toLowerCase().includes(k) ||
|
||||
(SOURCE_LABEL[it.source] ?? it.source).includes(keyword),
|
||||
)
|
||||
}
|
||||
setData(items)
|
||||
setTotal(res.total)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize, type, source, dateRange, keyword])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const columns: ColumnsType<PointsTransaction> = [
|
||||
{
|
||||
title: "时间",
|
||||
dataIndex: "created_at",
|
||||
width: 180,
|
||||
render: (v: string) => dayjs(v).format("YYYY-MM-DD HH:mm"),
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "type",
|
||||
width: 90,
|
||||
render: (t: PointsTxType) => {
|
||||
const cfg = TYPE_LABEL[t]
|
||||
return <Tag color={cfg.color}>{cfg.text}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "来源/场景",
|
||||
dataIndex: "source",
|
||||
width: 150,
|
||||
render: (s: string, r: PointsTransaction) => (
|
||||
<Space>
|
||||
<Text>{r.source_name || SOURCE_LABEL[s] || s}</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "说明",
|
||||
dataIndex: "description",
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: "变动",
|
||||
dataIndex: "signed_amount",
|
||||
width: 110,
|
||||
align: "right",
|
||||
render: (v: number, r: PointsTransaction) => (
|
||||
<span
|
||||
className={
|
||||
r.type === "earn" ? "xx-tx-earn" : r.type === "refund" ? "xx-tx-refund" : "xx-tx-spend"
|
||||
}
|
||||
>
|
||||
{v > 0 ? "+" : ""}
|
||||
{v}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "余额",
|
||||
dataIndex: "balance_after",
|
||||
width: 110,
|
||||
align: "right",
|
||||
render: (v: number) => (
|
||||
<Text strong style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{v}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="xx-points-page">
|
||||
<PageHead
|
||||
title="积分明细"
|
||||
description="查看积分收入、消耗与退还记录"
|
||||
|
||||
actions={
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={load}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => navigate("/app/subscription")}>
|
||||
充值积分
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<div className="xx-points-filters">
|
||||
<Space wrap size={12}>
|
||||
<Select
|
||||
value={type}
|
||||
onChange={(v) => {
|
||||
setType(v)
|
||||
setPage(1)
|
||||
}}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部类型" },
|
||||
{ value: "earn", label: "获得" },
|
||||
{ value: "spend", label: "消耗" },
|
||||
{ value: "refund", label: "退还" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={source}
|
||||
onChange={(v) => {
|
||||
setSource(v)
|
||||
setPage(1)
|
||||
}}
|
||||
style={{ width: 160 }}
|
||||
showSearch
|
||||
options={[
|
||||
{ value: "all", label: "全部来源" },
|
||||
...Object.entries(SOURCE_LABEL).map(([k, v]) => ({ value: k, label: v })),
|
||||
]}
|
||||
/>
|
||||
<RangePicker
|
||||
value={dateRange ?? undefined}
|
||||
onChange={(dates) => {
|
||||
setDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null] | null)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
placeholder="搜索说明"
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
style={{ width: 200 }}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onPressEnter={() => {
|
||||
setPage(1)
|
||||
load()
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<Table<PointsTransaction>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
locale={{ emptyText: <Empty description="暂无积分流水,快去使用 AI 功能吧" /> }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p)
|
||||
setPageSize(ps)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PointsTransactions
|
||||
export const Component = PointsTransactions
|
||||
@@ -1,198 +1,117 @@
|
||||
/**
|
||||
* 文案库页面 — Issue #1811(v2 完整版)
|
||||
* 功能:
|
||||
* - 列表页:卡片列表,搜索(标题/正文)、分类标签筛选、分页
|
||||
* 每条卡片展示:title、content 前 100 字摘要、title_text、分类 Tag、tags、使用次数、时间
|
||||
* 操作:编辑 / 删除 / 复制 / 使用(跳创作页预填)
|
||||
* - 新建/编辑弹窗:title、content 多行、segments(按空行自动拆分+手动编辑)、title_text、title_category、
|
||||
* title_config(字体/颜色/位置/字号)、tags
|
||||
* - 删除确认(Popconfirm)
|
||||
* - 对接 api/scripts CRUD(mock 阶段 SCRIPTS_API_MOCK=true)
|
||||
*
|
||||
* 风格对齐标题库(.xx-scripts-* 命名,沿用 CSS 变量)
|
||||
* 文案库页面 — Issue #1811
|
||||
* 风格对齐标题库(同类资源管理页面统一风格),单列卡片列表
|
||||
* 功能:列表 / 新建 / 编辑 / 删除 / 按标题搜索 / 空状态
|
||||
* 对接后端 /api/v1/scripts CRUD
|
||||
*/
|
||||
import React, { useCallback, useEffect, useState } from "react"
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
message,
|
||||
} from "antd"
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
CopyOutlined,
|
||||
PlayCircleOutlined,
|
||||
SearchOutlined,
|
||||
TagsOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { Modal, message, Empty, Button, Input, Popconfirm } from "antd"
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined } from "@ant-design/icons"
|
||||
import {
|
||||
getScripts,
|
||||
createScript,
|
||||
updateScript,
|
||||
deleteScript,
|
||||
duplicateScript,
|
||||
type ScriptItem,
|
||||
} from "@/api/scripts"
|
||||
import type { ScriptItem, ScriptCategory, ScriptUpsertRequest } from "@/api/scripts"
|
||||
import { SCRIPT_CATEGORY_LABEL } from "@/api/scripts"
|
||||
import "./scripts.css"
|
||||
|
||||
const { TextArea } = Input
|
||||
|
||||
const PAGE_SIZE = 12
|
||||
const CATEGORY_OPTIONS: { value: ScriptCategory | "all"; label: string }[] = [
|
||||
{ value: "all", label: "全部分类" },
|
||||
...Object.entries(SCRIPT_CATEGORY_LABEL).map(([value, label]) => ({
|
||||
value: value as ScriptCategory,
|
||||
label,
|
||||
})),
|
||||
]
|
||||
|
||||
const FONT_OPTIONS = [
|
||||
{ value: "default", label: "默认" },
|
||||
{ value: "bold", label: "粗体" },
|
||||
{ value: "handwritten", label: "手写" },
|
||||
{ value: "serif", label: "衬线" },
|
||||
]
|
||||
|
||||
const POSITION_OPTIONS = [
|
||||
{ value: "top", label: "顶部" },
|
||||
{ value: "center", label: "居中" },
|
||||
{ value: "bottom", label: "底部" },
|
||||
] as const
|
||||
|
||||
const ScriptLibrary: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [items, setItems] = useState<ScriptItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [scripts, setScripts] = useState<ScriptItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [keyword, setKeyword] = useState("")
|
||||
const [category, setCategory] = useState<ScriptCategory | "all">("all")
|
||||
const [searchText, setSearchText] = useState("")
|
||||
|
||||
// 弹窗状态
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<ScriptItem | null>(null)
|
||||
const [formTitle, setFormTitle] = useState("")
|
||||
const [formContent, setFormContent] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [form] = Form.useForm<ScriptUpsertRequest & { tags_text?: string }>()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getScripts({
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
keyword: keyword.trim() || undefined,
|
||||
category,
|
||||
})
|
||||
// 兼容老接口返回数组的兜底
|
||||
if (Array.isArray(res)) {
|
||||
setItems(res)
|
||||
setTotal(res.length)
|
||||
} else {
|
||||
setItems(res.items ?? [])
|
||||
setTotal(res.total ?? 0)
|
||||
}
|
||||
const items = await getScripts()
|
||||
setScripts(items)
|
||||
} catch (err) {
|
||||
const e = err as { message?: string }
|
||||
message.error(e?.message ?? "加载文案列表失败")
|
||||
message.error(err instanceof Error ? err.message : "加载文案列表失败")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, keyword, category])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
}, [])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const kw = searchText.trim().toLowerCase()
|
||||
if (!kw) return scripts
|
||||
return scripts.filter((s) => s.title.toLowerCase().includes(kw))
|
||||
}, [scripts, searchText])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
title: "",
|
||||
content: "",
|
||||
segments: [],
|
||||
tags: [],
|
||||
title_text: "",
|
||||
title_category: "other",
|
||||
title_config: {
|
||||
font: "default",
|
||||
color: "#ffffff",
|
||||
stroke: "#000000",
|
||||
position: "center",
|
||||
size: 48,
|
||||
bold: true,
|
||||
italic: false,
|
||||
},
|
||||
})
|
||||
setModalOpen(true)
|
||||
setFormTitle("")
|
||||
setFormContent("")
|
||||
setCreateOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (item: ScriptItem) => {
|
||||
setEditing(item)
|
||||
form.setFieldsValue({
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
segments: item.segments ?? item.content.split(/\n\n+/).filter(Boolean),
|
||||
tags: item.tags ?? [],
|
||||
title_text: item.title_text ?? "",
|
||||
title_category: item.title_category ?? "other",
|
||||
title_config: item.title_config ?? {
|
||||
font: "default",
|
||||
color: "#ffffff",
|
||||
stroke: "#000000",
|
||||
position: "center",
|
||||
size: 48,
|
||||
},
|
||||
})
|
||||
setModalOpen(true)
|
||||
setFormTitle(item.title)
|
||||
setFormContent(item.content)
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false)
|
||||
const handleCloseCreate = () => {
|
||||
setCreateOpen(false)
|
||||
setFormTitle("")
|
||||
setFormContent("")
|
||||
}
|
||||
|
||||
const handleCloseEdit = () => {
|
||||
setEditing(null)
|
||||
setFormTitle("")
|
||||
setFormContent("")
|
||||
}
|
||||
|
||||
/** 提交新建/编辑 */
|
||||
const handleSubmit = async () => {
|
||||
const handleCreate = async () => {
|
||||
const title = formTitle.trim()
|
||||
const content = formContent.trim()
|
||||
if (!title || !content) {
|
||||
message.warning("请填写标题和正文")
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setSubmitting(true)
|
||||
const payload: ScriptUpsertRequest = {
|
||||
title: values.title.trim(),
|
||||
content: values.content,
|
||||
segments: values.segments?.filter(Boolean) ?? values.content.split(/\n\n+/).filter(Boolean),
|
||||
tags: values.tags ?? [],
|
||||
title_text: values.title_text?.trim() || undefined,
|
||||
title_category: values.title_category,
|
||||
title_config: values.title_config,
|
||||
}
|
||||
if (editing) {
|
||||
await updateScript(editing.id, payload)
|
||||
message.success("文案已更新")
|
||||
} else {
|
||||
await createScript(payload)
|
||||
message.success("文案已创建")
|
||||
}
|
||||
closeModal()
|
||||
await createScript({ title, content })
|
||||
message.success("文案已创建")
|
||||
handleCloseCreate()
|
||||
await load()
|
||||
} catch (err) {
|
||||
// form 校验失败不弹 message
|
||||
if ((err as { errorFields?: unknown })?.errorFields) return
|
||||
const e = err as { message?: string }
|
||||
message.error(e?.message ?? "保存失败")
|
||||
message.error(err instanceof Error ? err.message : "创建文案失败")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!editing) return
|
||||
const title = formTitle.trim()
|
||||
const content = formContent.trim()
|
||||
if (!title || !content) {
|
||||
message.warning("请填写标题和正文")
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await updateScript(editing.id, { title, content })
|
||||
message.success("文案已更新")
|
||||
handleCloseEdit()
|
||||
await load()
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "更新文案失败")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -202,85 +121,41 @@ const ScriptLibrary: React.FC = () => {
|
||||
try {
|
||||
await deleteScript(id)
|
||||
message.success("文案已删除")
|
||||
// 删除后若当前页空了,回退一页
|
||||
if (items.length === 1 && page > 1) {
|
||||
setPage(page - 1)
|
||||
} else {
|
||||
await load()
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as { message?: string }
|
||||
message.error(e?.message ?? "删除失败")
|
||||
}
|
||||
}
|
||||
|
||||
const handleDuplicate = async (id: string) => {
|
||||
try {
|
||||
await duplicateScript(id)
|
||||
message.success("已复制")
|
||||
setPage(1)
|
||||
await load()
|
||||
} catch (err) {
|
||||
const e = err as { message?: string }
|
||||
message.error(e?.message ?? "复制失败")
|
||||
message.error(err instanceof Error ? err.message : "删除文案失败")
|
||||
}
|
||||
}
|
||||
|
||||
/** 使用:跳创作页,query 带 scriptId 预填 */
|
||||
const handleUse = (item: ScriptItem) => {
|
||||
navigate(`/app/generate?scriptId=${encodeURIComponent(item.id)}`)
|
||||
}
|
||||
|
||||
const preview = (content: string) => {
|
||||
const text = content.replace(/\s+/g, " ").trim()
|
||||
if (text.length <= 100) return text || "(空)"
|
||||
return `${text.slice(0, 100)}…`
|
||||
return text.length > 120 ? `${text.slice(0, 120)}…` : text || "(空)"
|
||||
}
|
||||
|
||||
const formatTime = (iso?: string) => {
|
||||
if (!iso) return "-"
|
||||
const formatTime = (iso: string) => {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
const pad = (n: number) => String(n).padStart(2, "0")
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
const categoryColor: Record<ScriptCategory, string> = {
|
||||
promo: "red",
|
||||
vlog: "blue",
|
||||
knowledge: "green",
|
||||
story: "purple",
|
||||
emotion: "magenta",
|
||||
other: "default",
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
|
||||
d.getHours(),
|
||||
)}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-scripts-page">
|
||||
<div className="xx-scripts-layout">
|
||||
{/* 顶部筛选栏 */}
|
||||
{/* 顶部操作栏 */}
|
||||
<div className="xx-scripts-filters">
|
||||
<Space wrap size={12} className="xx-scripts-filters-left">
|
||||
<div className="xx-scripts-filters-left">
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索标题或正文"
|
||||
value={keyword}
|
||||
onChange={(e) => {
|
||||
setKeyword(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
placeholder="按标题搜索"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<Select
|
||||
value={category}
|
||||
onChange={(v) => {
|
||||
setCategory(v)
|
||||
setPage(1)
|
||||
}}
|
||||
style={{ width: 140 }}
|
||||
options={CATEGORY_OPTIONS}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
<div className="xx-scripts-filters-right">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建文案
|
||||
@@ -288,227 +163,108 @@ const ScriptLibrary: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
{/* 列表 / 空状态 */}
|
||||
{loading ? (
|
||||
<div className="xx-scripts-loading">加载中…</div>
|
||||
) : items.length === 0 ? (
|
||||
) : filtered.length === 0 ? (
|
||||
<Empty
|
||||
description={
|
||||
keyword || category !== "all"
|
||||
? "没有匹配的文案"
|
||||
: "暂无文案,点击右上角「新建文案」开始创作"
|
||||
}
|
||||
description={searchText ? "没有匹配的文案" : "暂无文案,点击右上角「新建文案」开始创作"}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="xx-scripts-list">
|
||||
{items.map((s) => (
|
||||
<Card key={s.id} className="xx-script-card" hoverable size="small">
|
||||
<div className="xx-script-card-header">
|
||||
<div className="xx-script-title-row">
|
||||
<span className="xx-script-title">{s.title}</span>
|
||||
{s.title_category && (
|
||||
<Tag color={categoryColor[s.title_category] ?? "default"}>
|
||||
{SCRIPT_CATEGORY_LABEL[s.title_category]}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
<Space size={4} className="xx-script-actions">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => handleUse(s)}
|
||||
>
|
||||
使用
|
||||
<div className="xx-scripts-list">
|
||||
{filtered.map((s) => (
|
||||
<div key={s.id} className="xx-script-card">
|
||||
<div className="xx-script-card-header">
|
||||
<div className="xx-script-title">{s.title}</div>
|
||||
<div className="xx-script-actions">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(s)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除此文案?"
|
||||
description="删除后不可恢复"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => handleDelete(s.id)}
|
||||
>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => handleDuplicate(s.id)}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(s)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除此文案?"
|
||||
description="删除后不可恢复"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => handleDelete(s.id)}
|
||||
>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
|
||||
<div className="xx-script-preview">{preview(s.content)}</div>
|
||||
|
||||
{s.title_text && (
|
||||
<div className="xx-script-title-text">
|
||||
<span className="xx-script-label">配套标题:</span>
|
||||
{s.title_text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{s.tags && s.tags.length > 0 && (
|
||||
<div className="xx-script-tags">
|
||||
<TagsOutlined
|
||||
style={{ color: "var(--text-tertiary, #999)", marginRight: 4 }}
|
||||
/>
|
||||
{s.tags.map((t, i) => (
|
||||
<Tag key={`${t}-${i}`} color="blue">
|
||||
{t}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="xx-script-meta">
|
||||
<span>{s.char_count ?? s.content.length} 字</span>
|
||||
<span className="xx-script-meta-sep">·</span>
|
||||
<span>使用 {s.use_count ?? 0} 次</span>
|
||||
<span className="xx-script-meta-sep">·</span>
|
||||
<span>{formatTime(s.updated_at ?? s.created_at)}</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="xx-scripts-pagination">
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={total}
|
||||
onChange={(p) => setPage(p)}
|
||||
showSizeChanger={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-script-preview">{preview(s.content)}</div>
|
||||
<div className="xx-script-meta">
|
||||
<span>{s.char_count ?? s.content.length} 字</span>
|
||||
<span>·</span>
|
||||
<span>{formatTime(s.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 新建/编辑弹窗 */}
|
||||
{/* 新建弹窗 */}
|
||||
<Modal
|
||||
title={editing ? "编辑文案" : "新建文案"}
|
||||
open={modalOpen}
|
||||
onCancel={closeModal}
|
||||
onOk={handleSubmit}
|
||||
title="新建文案"
|
||||
open={createOpen}
|
||||
onCancel={handleCloseCreate}
|
||||
onOk={handleCreate}
|
||||
confirmLoading={submitting}
|
||||
destroyOnClose
|
||||
okText={editing ? "保存" : "创建"}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
width={680}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
title_category: "other",
|
||||
title_config: {
|
||||
font: "default",
|
||||
color: "#ffffff",
|
||||
stroke: "#000000",
|
||||
position: "center",
|
||||
size: 48,
|
||||
bold: true,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="名称"
|
||||
rules={[{ required: true, message: "请填写文案名称" }, { max: 200 }]}
|
||||
>
|
||||
<Input placeholder="给这段文案起个名字" maxLength={200} />
|
||||
</Form.Item>
|
||||
<div className="xx-script-form">
|
||||
<Input
|
||||
placeholder="标题"
|
||||
value={formTitle}
|
||||
onChange={(e) => setFormTitle(e.target.value)}
|
||||
maxLength={200}
|
||||
/>
|
||||
<TextArea
|
||||
placeholder="正文"
|
||||
value={formContent}
|
||||
onChange={(e) => setFormContent(e.target.value)}
|
||||
rows={8}
|
||||
maxLength={5000}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="正文"
|
||||
rules={[{ required: true, message: "请填写正文" }]}
|
||||
extra="段落间用空行分隔,保存时会自动按空行切分为 segments。"
|
||||
>
|
||||
<TextArea placeholder="在这里输入文案正文…" rows={6} maxLength={10000} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="segments" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="title_text" label="配套标题(选填)" rules={[{ max: 200 }]}>
|
||||
<Input placeholder="使用此文案时自动带入的标题文本" maxLength={200} />
|
||||
</Form.Item>
|
||||
|
||||
<Space size={16} style={{ display: "flex" }}>
|
||||
<Form.Item name="title_category" label="分类" style={{ flex: 1, marginBottom: 0 }}>
|
||||
<Select options={CATEGORY_OPTIONS.filter((o) => o.value !== "all")} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["title_config", "position"]}
|
||||
label="标题位置"
|
||||
style={{ flex: 1, marginBottom: 0 }}
|
||||
>
|
||||
<Select options={POSITION_OPTIONS as unknown as { value: string; label: string }[]} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Space size={16} style={{ display: "flex", marginTop: 12 }}>
|
||||
<Form.Item
|
||||
name={["title_config", "font"]}
|
||||
label="字体"
|
||||
style={{ flex: 1, marginBottom: 0 }}
|
||||
>
|
||||
<Select options={FONT_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["title_config", "size"]}
|
||||
label="字号"
|
||||
style={{ flex: 1, marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber min={20} max={120} style={{ width: "100%" }} addonAfter="px" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Space size={16} style={{ display: "flex", marginTop: 12 }}>
|
||||
<Form.Item
|
||||
name={["title_config", "color"]}
|
||||
label="文字颜色"
|
||||
style={{ flex: 1, marginBottom: 0 }}
|
||||
>
|
||||
<Input type="color" style={{ width: "100%", height: 32, padding: 4 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["title_config", "stroke"]}
|
||||
label="描边色"
|
||||
style={{ flex: 1, marginBottom: 0 }}
|
||||
>
|
||||
<Input type="color" style={{ width: "100%", height: 32, padding: 4 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.Item name="tags" label="标签" style={{ marginTop: 12 }}>
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder="输入标签后回车添加"
|
||||
tokenSeparators={[",", ","]}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{/* 编辑弹窗 */}
|
||||
<Modal
|
||||
title="编辑文案"
|
||||
open={!!editing}
|
||||
onCancel={handleCloseEdit}
|
||||
onOk={handleUpdate}
|
||||
confirmLoading={submitting}
|
||||
destroyOnClose
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-script-form">
|
||||
<Input
|
||||
placeholder="标题"
|
||||
value={formTitle}
|
||||
onChange={(e) => setFormTitle(e.target.value)}
|
||||
maxLength={200}
|
||||
/>
|
||||
<TextArea
|
||||
placeholder="正文"
|
||||
value={formContent}
|
||||
onChange={(e) => setFormContent(e.target.value)}
|
||||
rows={8}
|
||||
maxLength={5000}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,168 +1,137 @@
|
||||
/**
|
||||
* 文案库页面样式(#1811 v2)
|
||||
* 风格对齐标题库,新增卡片 hover 阴影、标签/meta 行、筛选栏样式
|
||||
* 文案库页面 - V21 设计系统样式
|
||||
* 单列卡片列表,风格对齐标题库(xx-titles-page)
|
||||
*/
|
||||
@import "../../styles/global.css";
|
||||
|
||||
/* ============================================================
|
||||
页面容器
|
||||
============================================================ */
|
||||
.xx-scripts-page {
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
min-height: 100%;
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.xx-scripts-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: var(--space-lg);
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── 筛选栏 ── */
|
||||
/* ============================================================
|
||||
顶部筛选栏
|
||||
============================================================ */
|
||||
.xx-scripts-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-card, #fff);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color, #f0f0f0);
|
||||
}
|
||||
|
||||
.xx-scripts-filters-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-scripts-filters-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* ── 列表 ── */
|
||||
/* ============================================================
|
||||
列表
|
||||
============================================================ */
|
||||
.xx-scripts-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-scripts-loading {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: var(--space-xl);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
文案卡片(对齐标题卡片风格,单列)
|
||||
============================================================ */
|
||||
.xx-script-card {
|
||||
transition:
|
||||
box-shadow 0.2s,
|
||||
transform 0.2s;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md);
|
||||
transition: var(--transition-all);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.xx-script-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.xx-script-card .ant-card-body {
|
||||
padding: 16px;
|
||||
border-color: var(--primary-color);
|
||||
background: var(--bg-secondary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.xx-script-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.xx-script-title-row {
|
||||
|
||||
.xx-script-title {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.xx-script-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.xx-script-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1f1f1f);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.xx-script-actions {
|
||||
gap: var(--space-xxs);
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: var(--transition-opacity, opacity 0.2s);
|
||||
}
|
||||
.xx-script-actions .ant-btn {
|
||||
padding: 0 6px;
|
||||
|
||||
.xx-script-card:hover .xx-script-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.xx-script-preview {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #666);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-script-title-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #999);
|
||||
margin-bottom: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.xx-script-label {
|
||||
color: var(--text-tertiary, #999);
|
||||
}
|
||||
|
||||
.xx-script-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.xx-script-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #999);
|
||||
}
|
||||
.xx-script-meta-sep {
|
||||
opacity: 0.5;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--font-size-xs, 12px);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ── 分页 ── */
|
||||
.xx-scripts-pagination {
|
||||
/* ============================================================
|
||||
弹窗表单
|
||||
============================================================ */
|
||||
.xx-script-form {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-scripts-loading {
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: var(--text-tertiary, #999);
|
||||
}
|
||||
|
||||
/* ── 弹窗表单 ── */
|
||||
.xx-script-form .ant-form-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ── 响应式 ── */
|
||||
@media (max-width: 768px) {
|
||||
.xx-scripts-page {
|
||||
padding: 16px 12px;
|
||||
}
|
||||
.xx-scripts-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.xx-scripts-filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.xx-scripts-filters-left {
|
||||
width: 100%;
|
||||
}
|
||||
.xx-scripts-filters-left .ant-input,
|
||||
.xx-scripts-filters-left .ant-select {
|
||||
flex: 1;
|
||||
}
|
||||
.xx-script-form textarea.ant-input {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
@@ -1,286 +1,233 @@
|
||||
/* 会员订阅 & 积分充值页样式 v2 */
|
||||
/* V21 定价页面 - CSS变量体系 */
|
||||
@import "../../styles/global.css";
|
||||
|
||||
.xx-plans-page {
|
||||
max-width: 1100px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 0;
|
||||
padding: var(--space-3xl) var(--space-lg);
|
||||
}
|
||||
|
||||
/* 当前状态卡片 */
|
||||
.xx-current-status {
|
||||
background: linear-gradient(135deg, #ede9fe 0%, #fce7f3 100%);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-current-status .ant-card-body {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.xx-current-balance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.xx-current-balance-val {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 计费周期切换 */
|
||||
.xx-billing-switch {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xx-billing-btn {
|
||||
position: relative;
|
||||
border: 2px solid var(--border-color, #e5e7eb);
|
||||
background: var(--bg-primary, #fff);
|
||||
border-radius: 12px;
|
||||
padding: 16px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
/* 页面头部 */
|
||||
.xx-page-head {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-3xl);
|
||||
}
|
||||
|
||||
.xx-billing-btn:hover {
|
||||
border-color: var(--primary-soft, #c4b5fd);
|
||||
.xx-page-head h2 {
|
||||
font-size: var(--font-size-5xl);
|
||||
font-weight: var(--font-weight-extrabold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--space-md);
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
|
||||
.xx-billing-btn.active {
|
||||
border-color: #7c3aed;
|
||||
background: #f5f3ff;
|
||||
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.15);
|
||||
.xx-page-head p {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
max-width: 560px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.xx-billing-name {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary, #111827);
|
||||
/* 定价卡片网格 - 4列 */
|
||||
.xx-plans-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-lg);
|
||||
margin-top: var(--space-xl);
|
||||
}
|
||||
|
||||
.xx-billing-price {
|
||||
margin: 6px 0;
|
||||
@media (max-width: 1200px) {
|
||||
.xx-plans-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.xx-billing-yuan {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
@media (max-width: 768px) {
|
||||
.xx-plans-grid {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 400px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.xx-billing-amount {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary, #111827);
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.xx-billing-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
}
|
||||
|
||||
.xx-billing-save {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 方案卡片 */
|
||||
/* V21 定价卡片 */
|
||||
.xx-plan-card {
|
||||
border-radius: 14px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: var(--space-xl) var(--space-lg);
|
||||
position: relative;
|
||||
height: 100%;
|
||||
transition: var(--transition-base);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.xx-plan-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 32px 90px color-mix(in srgb, var(--primary-color) 15%, transparent);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.xx-plan-card.featured {
|
||||
border: 2px solid #7c3aed;
|
||||
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.12);
|
||||
border: 2px solid var(--primary-color);
|
||||
box-shadow:
|
||||
0 0 0 4px color-mix(in srgb, var(--primary-color) 10%, transparent),
|
||||
0 32px 90px color-mix(in srgb, var(--primary-color) 15%, transparent);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.xx-plan-recommend-badge {
|
||||
.xx-plan-card.featured:hover {
|
||||
transform: scale(1.02) translateY(-4px);
|
||||
}
|
||||
|
||||
/* 推荐标签 */
|
||||
.xx-badge {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
right: 20px;
|
||||
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||
color: #fff;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--gradient-primary);
|
||||
color: var(--text-inverse);
|
||||
padding: 6px 16px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-extrabold);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-plan-head {
|
||||
/* 企业版定制标签 */
|
||||
.xx-badge.enterprise {
|
||||
background: linear-gradient(135deg, var(--accent-color), var(--accent-dark));
|
||||
}
|
||||
|
||||
/* 套餐名称 */
|
||||
.xx-plan-card h3 {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-extrabold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--space-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 价格 */
|
||||
.xx-plan-price {
|
||||
margin: 12px 0 6px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.xx-plan-price .currency {
|
||||
font-size: 18px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-secondary);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.xx-plan-price .amount {
|
||||
font-size: 44px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary, #111827);
|
||||
font-size: var(--font-size-5xl);
|
||||
font-weight: var(--font-weight-extrabold);
|
||||
color: var(--primary-color);
|
||||
line-height: 1;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.xx-plan-card.featured .xx-plan-price .amount {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.xx-plan-price .period {
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: 14px;
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.xx-feature-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 20px 0;
|
||||
/* 企业版价格 */
|
||||
.xx-plan-price.custom .amount {
|
||||
font-size: var(--font-size-3xl);
|
||||
}
|
||||
|
||||
.xx-feature-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.xx-feature-list li.disabled {
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xx-feature-no {
|
||||
width: 16px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary, #d1d5db);
|
||||
}
|
||||
|
||||
.xx-plan-tip {
|
||||
/* 描述 */
|
||||
.xx-plan-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-base);
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
margin-top: 10px;
|
||||
margin-bottom: var(--space-lg);
|
||||
padding-bottom: var(--space-lg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* 积分包卡片 */
|
||||
.xx-pkg-card {
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
overflow: hidden;
|
||||
/* 功能列表 */
|
||||
.xx-features {
|
||||
margin-top: var(--space-sm);
|
||||
padding-top: var(--space-lg);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.xx-pkg-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 28px rgba(139, 92, 246, 0.18);
|
||||
}
|
||||
|
||||
.xx-pkg-card.recommended {
|
||||
border: 2px solid #8b5cf6;
|
||||
}
|
||||
|
||||
.xx-pkg-badge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
background: linear-gradient(135deg, #8b5cf6, #ec4899);
|
||||
color: #fff;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.xx-pkg-discount {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.xx-pkg-name {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #111827);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.xx-pkg-points {
|
||||
font-size: 16px;
|
||||
color: #8b5cf6;
|
||||
font-weight: 600;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.xx-pkg-price {
|
||||
.xx-feature {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-base);
|
||||
margin-bottom: var(--space-md);
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
.xx-pkg-price .currency {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
.xx-feature span:first-child {
|
||||
color: var(--secondary-color);
|
||||
font-weight: var(--font-weight-extrabold);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-pkg-price .amount {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary, #111827);
|
||||
line-height: 1;
|
||||
/* 订阅按钮 */
|
||||
.xx-subscribe-btn {
|
||||
width: 100%;
|
||||
margin-top: var(--space-lg);
|
||||
padding: var(--space-md) var(--space-lg) !important;
|
||||
font-size: var(--font-size-base) !important;
|
||||
font-weight: var(--font-weight-extrabold) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
transition: var(--transition-fast) !important;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.xx-pkg-card.recommended .xx-pkg-price .amount {
|
||||
color: #8b5cf6;
|
||||
.xx-subscribe-btn.primary {
|
||||
background: var(--gradient-primary) !important;
|
||||
color: var(--text-inverse) !important;
|
||||
border: none !important;
|
||||
box-shadow: var(--shadow-primary) !important;
|
||||
}
|
||||
|
||||
.xx-pkg-origin {
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
text-decoration: line-through;
|
||||
font-size: 13px;
|
||||
.xx-subscribe-btn.primary:hover {
|
||||
box-shadow: var(--shadow-hover) !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.xx-pkg-unit {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
margin-top: 4px;
|
||||
.xx-subscribe-btn.ghost {
|
||||
background: var(--bg-primary) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.xx-billing-switch {
|
||||
.xx-subscribe-btn.ghost:hover {
|
||||
border-color: var(--primary-color) !important;
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
/* 企业版按钮 */
|
||||
.xx-subscribe-btn.enterprise {
|
||||
background: linear-gradient(135deg, var(--accent-color), var(--accent-dark)) !important;
|
||||
color: var(--text-inverse) !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 14px 26px color-mix(in srgb, var(--accent-color) 22%, transparent) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.xx-plans-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.xx-plan-price .amount {
|
||||
font-size: 36px;
|
||||
|
||||
.xx-plan-card {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,422 +1,107 @@
|
||||
/**
|
||||
* 会员订阅 & 积分充值页
|
||||
* v2 两档会员制:免费 vs 付费
|
||||
* 付费三档:月¥19.9 / 季¥39.9(推荐)/ 年¥159
|
||||
* 积分包:100/¥9.9、500/¥39、2000/¥129
|
||||
* 定价页面 - V21 UI 四级定价
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Row,
|
||||
Tag,
|
||||
Divider,
|
||||
Space,
|
||||
Typography,
|
||||
Modal,
|
||||
message,
|
||||
Tooltip,
|
||||
Badge,
|
||||
} from "antd"
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
CrownFilled,
|
||||
ThunderboltOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
VideoCameraOutlined,
|
||||
StarFilled,
|
||||
} from "@ant-design/icons"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import React, { useState } from "react"
|
||||
import { message } from "antd"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import { SUBSCRIPTION_PLANS, createPointsOrder } from "@/api/points"
|
||||
import type { PointsPackage } from "@/api/points/types"
|
||||
import { getPointsPackages } from "@/api/points"
|
||||
import "./Plans.css"
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
/** 免费会员权益 */
|
||||
const FREE_FEATURES = [
|
||||
{ include: true, text: "每日 2 条免费混剪" },
|
||||
{ include: true, text: "720p 导出分辨率" },
|
||||
{ include: true, text: "AI 配音(×1.15 积分)" },
|
||||
{ include: true, text: "AI 数字人(×1.15 积分)" },
|
||||
{ include: true, text: "声音克隆训练免费" },
|
||||
{ include: false, text: "批量导出" },
|
||||
{ include: false, text: "多平台一键发布" },
|
||||
{ include: false, text: "去重检测报告" },
|
||||
const PLANS = [
|
||||
{
|
||||
id: "free",
|
||||
name: "体验版",
|
||||
price: 0,
|
||||
description: "适合个人体验",
|
||||
recommended: false,
|
||||
features: ["3个视频成片", "基础标题模板", "基础配音音色", "720p导出"],
|
||||
},
|
||||
{
|
||||
id: "standard",
|
||||
name: "标准版",
|
||||
price: 99,
|
||||
description: "适合内容创作者",
|
||||
recommended: false,
|
||||
features: ["无限视频成片", "高级标题模板", "全部配音音色", "1080p导出", "API接口"],
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "专业版",
|
||||
price: 299,
|
||||
description: "适合专业团队",
|
||||
recommended: true,
|
||||
features: ["无限视频成片", "自定义标题", "全部配音音色", "4K导出", "优先渲染队列", "专属客服"],
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
name: "企业版",
|
||||
price: null,
|
||||
description: "适合企业用户",
|
||||
recommended: false,
|
||||
features: ["私有化部署", "自定义品牌形象", " SLA服务保障", "技术支持", "团队协作", "数据报表"],
|
||||
},
|
||||
]
|
||||
|
||||
/** 付费会员权益 */
|
||||
const PAID_FEATURES = [
|
||||
{ include: true, text: "无限次智能混剪" },
|
||||
{ include: true, text: "最高 1080p 导出" },
|
||||
{ include: true, text: "全部 AI 功能(标准积分价)" },
|
||||
{ include: true, text: "声音克隆训练免费" },
|
||||
{ include: true, text: "积分购买最低 8 折" },
|
||||
{ include: true, text: "批量导出" },
|
||||
{ include: true, text: "多平台一键发布" },
|
||||
{ include: true, text: "去重检测报告" },
|
||||
]
|
||||
|
||||
const formatYuan = (cents: number) =>
|
||||
`¥${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 1).replace(/\.0$/, "")}`
|
||||
|
||||
const Plans: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { balance, init } = usePointsStore()
|
||||
const [selectedBilling, setSelectedBilling] = useState<"monthly" | "quarterly" | "yearly">(
|
||||
"quarterly",
|
||||
)
|
||||
const [packages, setPackages] = useState<PointsPackage[]>([])
|
||||
const [subscribing] = useState(false)
|
||||
const [buying, setBuying] = useState<string | null>(null)
|
||||
const [subscribing, setSubscribing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
init()
|
||||
getPointsPackages()
|
||||
.then((r) => setPackages(r.packages))
|
||||
.catch(() => {})
|
||||
}, [init])
|
||||
|
||||
const isMember = !!balance?.is_member
|
||||
const memberType = balance?.member_type ?? null
|
||||
|
||||
/** 根据会员等级计算积分包折后价(分) */
|
||||
const getPackPrice = (pkg: PointsPackage): number => {
|
||||
const mt = memberType ?? "free"
|
||||
type DiscountKey =
|
||||
| "discounted_price_for_free"
|
||||
| "discounted_price_for_monthly"
|
||||
| "discounted_price_for_quarterly"
|
||||
| "discounted_price_for_yearly"
|
||||
const key = `discounted_price_for_${mt}` as DiscountKey
|
||||
return pkg[key] ?? pkg.price
|
||||
}
|
||||
|
||||
const selectedPlan = useMemo(
|
||||
() => SUBSCRIPTION_PLANS.find((p) => p.id === selectedBilling)!,
|
||||
[selectedBilling],
|
||||
)
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
Modal.confirm({
|
||||
title: "支付功能开发中",
|
||||
icon: <SafetyCertificateOutlined />,
|
||||
content: "微信/支付宝支付正在接入中,完成后会第一时间通知。是否返回首页继续使用免费功能?",
|
||||
okText: "返回首页",
|
||||
cancelText: "留在此页",
|
||||
onOk: () => navigate("/app/dashboard"),
|
||||
})
|
||||
// 实际对接时:
|
||||
// try {
|
||||
// setSubscribing(true)
|
||||
// const order = await subscribe({ member_type: selectedBilling, payment_method: "wechat_pay" })
|
||||
// // 拉起支付...
|
||||
// } catch (e: any) {
|
||||
// message.error(e?.message ?? "订阅失败")
|
||||
// } finally {
|
||||
// setSubscribing(false)
|
||||
// }
|
||||
}
|
||||
|
||||
const handleBuyPoints = async (pkg: PointsPackage) => {
|
||||
const handleSubscribe = async (_planId: string) => {
|
||||
try {
|
||||
setBuying(pkg.id)
|
||||
const order = await createPointsOrder({ package_id: pkg.id })
|
||||
Modal.info({
|
||||
title: "支付功能开发中",
|
||||
icon: <ThunderboltOutlined />,
|
||||
content: (
|
||||
<div>
|
||||
<Paragraph>
|
||||
订单 <Text code>{order.id.slice(0, 16)}…</Text> 已创建,金额{" "}
|
||||
<b>{formatYuan(order.price_cents)}</b>,
|
||||
微信/支付宝支付正在接入中,正式上线后可直接付款。
|
||||
</Paragraph>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
现阶段所有功能均处于免费体验阶段,积分仅为演示数据。
|
||||
</Paragraph>
|
||||
</div>
|
||||
),
|
||||
okText: "知道了",
|
||||
})
|
||||
} catch (e) {
|
||||
const err = e as { message?: string }
|
||||
message.error(err?.message ?? "创建订单失败")
|
||||
setSubscribing(true)
|
||||
message.success("订阅成功")
|
||||
} catch (error: unknown) {
|
||||
message.error("订阅失败")
|
||||
} finally {
|
||||
setBuying(null)
|
||||
setSubscribing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-plans-page">
|
||||
<PageHead
|
||||
title="会员与积分"
|
||||
description="开通会员解锁全部功能,按需充值积分灵活使用 AI 能力"
|
||||
<PageHead title="选择适合您的方案" description="灵活定价,满足不同规模的创作需求" />
|
||||
|
||||
actions={
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => navigate("/app/points/transactions")}
|
||||
<div className="xx-plans-grid">
|
||||
{PLANS.map((plan) => (
|
||||
<div key={plan.id} className={`xx-plan-card ${plan.recommended ? "featured" : ""}`}>
|
||||
{plan.recommended && <div className="xx-badge">推荐</div>}
|
||||
{plan.id === "enterprise" && <div className="xx-badge enterprise">定制</div>}
|
||||
|
||||
<h3>{plan.name}</h3>
|
||||
|
||||
<div className={`xx-plan-price ${plan.price === null ? "custom" : ""}`}>
|
||||
{plan.price !== null ? (
|
||||
<>
|
||||
<span className="currency">¥</span>
|
||||
<span className="amount">{plan.price}</span>
|
||||
<span className="period">/月</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="amount">联系我们</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="xx-plan-description">{plan.description}</p>
|
||||
|
||||
<div className="xx-features">
|
||||
{plan.features.map((feature, idx) => (
|
||||
<div key={idx} className="xx-feature">
|
||||
<span>✓</span>
|
||||
<span>{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`xx-subscribe-btn ${
|
||||
plan.id === "enterprise" ? "enterprise" : plan.recommended ? "primary" : "ghost"
|
||||
}`}
|
||||
onClick={() => handleSubscribe(plan.id)}
|
||||
disabled={subscribing}
|
||||
>
|
||||
积分明细
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 当前状态卡片 */}
|
||||
{balance && (
|
||||
<Card className="xx-current-status" bordered={false}>
|
||||
<Row align="middle" gutter={24}>
|
||||
<Col flex="auto">
|
||||
<Space size="large" wrap>
|
||||
<div>
|
||||
<Text type="secondary">当前身份</Text>
|
||||
<div>
|
||||
{isMember ? (
|
||||
<Tag
|
||||
color="gold"
|
||||
icon={<CrownFilled />}
|
||||
style={{ marginTop: 4, fontSize: 14, padding: "4px 10px" }}
|
||||
>
|
||||
{memberType === "yearly"
|
||||
? "年卡"
|
||||
: memberType === "quarterly"
|
||||
? "季卡"
|
||||
: "月卡"}
|
||||
会员
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag style={{ marginTop: 4, fontSize: 14, padding: "4px 10px" }}>
|
||||
免费会员
|
||||
</Tag>
|
||||
)}
|
||||
{balance.member_expires_at && (
|
||||
<Text type="secondary" style={{ marginLeft: 8 }}>
|
||||
到期 {new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">可用积分</Text>
|
||||
<div className="xx-current-balance">
|
||||
<ThunderboltOutlined style={{ color: "#8b5cf6" }} />
|
||||
<span className="xx-current-balance-val">{balance.balance}</span>
|
||||
</div>
|
||||
</div>
|
||||
{balance.free_clips_limit ? (
|
||||
<div>
|
||||
<Text type="secondary">今日免费混剪</Text>
|
||||
<div>
|
||||
<Text strong>{balance.free_clips_used ?? 0}</Text>
|
||||
<Text type="secondary"> / {balance.free_clips_limit} 条</Text>
|
||||
{!isMember && (
|
||||
<Tag color="blue" style={{ marginLeft: 8 }}>
|
||||
剩余 {balance.free_clips_remaining ?? 0} 条
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => {
|
||||
const el = document.getElementById("points-packages")
|
||||
el?.scrollIntoView({ behavior: "smooth" })
|
||||
}}
|
||||
>
|
||||
充值积分
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 两档会员对比 */}
|
||||
<Title level={4} style={{ marginTop: 24 }}>
|
||||
<CrownFilled style={{ color: "#f59e0b", marginRight: 8 }} />
|
||||
选择适合您的方案
|
||||
</Title>
|
||||
|
||||
{/* 计费周期切换 */}
|
||||
<div className="xx-billing-switch">
|
||||
{SUBSCRIPTION_PLANS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className={`xx-billing-btn ${selectedBilling === p.id ? "active" : ""}`}
|
||||
onClick={() => setSelectedBilling(p.id)}
|
||||
>
|
||||
{p.recommended && (
|
||||
<Badge.Ribbon text="推荐" color="gold" style={{ top: -14, right: -22 }} />
|
||||
)}
|
||||
<div className="xx-billing-name">{p.name}</div>
|
||||
<div className="xx-billing-price">
|
||||
<span className="xx-billing-yuan">¥</span>
|
||||
<span className="xx-billing-amount">{p.price_yuan}</span>
|
||||
</div>
|
||||
<div className="xx-billing-sub">
|
||||
≈¥{p.per_month_yuan}/月
|
||||
{p.savings_percent ? (
|
||||
<span className="xx-billing-save">省{p.savings_percent}%</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
{subscribing ? "处理中..." : plan.price === null ? "联系我们" : "立即订阅"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Row gutter={[20, 20]} style={{ marginTop: 16 }}>
|
||||
{/* 免费会员 */}
|
||||
<Col xs={24} md={12}>
|
||||
<Card className="xx-plan-card" bordered>
|
||||
<div className="xx-plan-head">
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
免费会员
|
||||
</Title>
|
||||
<div className="xx-plan-price">
|
||||
<span className="currency">¥</span>
|
||||
<span className="amount">0</span>
|
||||
<span className="period">/永久</span>
|
||||
</div>
|
||||
<Text type="secondary">体验 AI 剪辑的基础能力</Text>
|
||||
</div>
|
||||
<Divider style={{ margin: "16px 0" }} />
|
||||
<ul className="xx-feature-list">
|
||||
{FREE_FEATURES.map((f, i) => (
|
||||
<li key={i} className={f.include ? "" : "disabled"}>
|
||||
{f.include ? (
|
||||
<CheckCircleFilled style={{ color: "#10b981" }} />
|
||||
) : (
|
||||
<span className="xx-feature-no">—</span>
|
||||
)}
|
||||
<span>{f.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button block size="large" disabled>
|
||||
当前方案
|
||||
</Button>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 付费会员 */}
|
||||
<Col xs={24} md={12}>
|
||||
<Card className="xx-plan-card featured" bordered>
|
||||
<div className="xx-plan-recommend-badge">
|
||||
<StarFilled /> 推荐
|
||||
</div>
|
||||
<div className="xx-plan-head">
|
||||
<Title level={4} style={{ margin: 0, color: "#7c3aed" }}>
|
||||
<CrownFilled style={{ color: "#f59e0b" }} /> 付费会员
|
||||
</Title>
|
||||
<div className="xx-plan-price">
|
||||
<span className="currency">¥</span>
|
||||
<span className="amount">{selectedPlan.price_yuan}</span>
|
||||
<span className="period">{selectedPlan.billing_label}</span>
|
||||
</div>
|
||||
<Text type="secondary">
|
||||
折合 ¥{selectedPlan.per_month_yuan}/月 · 解锁全部 AI 能力
|
||||
</Text>
|
||||
</div>
|
||||
<Divider style={{ margin: "16px 0" }} />
|
||||
<ul className="xx-feature-list">
|
||||
{PAID_FEATURES.map((f, i) => (
|
||||
<li key={i}>
|
||||
<CheckCircleFilled style={{ color: "#7c3aed" }} />
|
||||
<span>{f.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
block
|
||||
size="large"
|
||||
type="primary"
|
||||
loading={subscribing}
|
||||
disabled={isMember && memberType === selectedBilling}
|
||||
onClick={handleSubscribe}
|
||||
icon={<ThunderboltOutlined />}
|
||||
>
|
||||
{isMember && memberType === selectedBilling
|
||||
? "当前方案"
|
||||
: isMember
|
||||
? "续费/升级"
|
||||
: "立即开通"}
|
||||
</Button>
|
||||
<div className="xx-plan-tip">
|
||||
<VideoCameraOutlined /> 虚拟商品不支持退款 · 支付接入中先演示
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 积分充值 */}
|
||||
<div id="points-packages">
|
||||
<Title level={4} style={{ marginTop: 40 }}>
|
||||
<ThunderboltOutlined style={{ color: "#8b5cf6", marginRight: 8 }} />
|
||||
积分充值
|
||||
<Tooltip title="积分永久有效,可用于所有 AI 功能;付费会员享折扣">
|
||||
<Text type="secondary" style={{ fontSize: 13, marginLeft: 8, fontWeight: "normal" }}>
|
||||
(永久有效)
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Title>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{packages.map((pkg) => {
|
||||
const price = getPackPrice(pkg)
|
||||
const discount = price < pkg.price ? Math.round((1 - price / pkg.price) * 100) : 0
|
||||
const unit = price / pkg.points
|
||||
return (
|
||||
<Col xs={24} sm={8} key={pkg.id}>
|
||||
<Card
|
||||
className={`xx-pkg-card ${discount > 0 ? "has-discount" : ""} ${pkg.id === "basic_pack" ? "recommended" : ""}`}
|
||||
hoverable
|
||||
>
|
||||
{pkg.id === "basic_pack" && <div className="xx-pkg-badge">热门</div>}
|
||||
{discount > 0 && (
|
||||
<Tag color="gold" className="xx-pkg-discount">
|
||||
会员{10 - discount / 10}折
|
||||
</Tag>
|
||||
)}
|
||||
<div className="xx-pkg-name">{pkg.name}</div>
|
||||
<div className="xx-pkg-points">
|
||||
<ThunderboltOutlined /> {pkg.points.toLocaleString()} 积分
|
||||
</div>
|
||||
<div className="xx-pkg-price">
|
||||
<span className="currency">¥</span>
|
||||
<span className="amount">
|
||||
{(price / 100).toFixed(price % 100 === 0 ? 0 : 1).replace(/\.0$/, "")}
|
||||
</span>
|
||||
{discount > 0 && (
|
||||
<span className="xx-pkg-origin">¥{(pkg.price / 100).toFixed(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-pkg-unit">≈¥{unit.toFixed(3)}/积分</div>
|
||||
<Button
|
||||
block
|
||||
type={pkg.id === "basic_pack" ? "primary" : "default"}
|
||||
loading={buying === pkg.id}
|
||||
onClick={() => handleBuyPoints(pkg)}
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
立即购买
|
||||
</Button>
|
||||
</Card>
|
||||
</Col>
|
||||
)
|
||||
})}
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,22 +96,6 @@ const appChildren: RouteObject[] = [
|
||||
path: "subscription/billing",
|
||||
lazy: lazyRoute(() => import("@/pages/subscription/Billing")),
|
||||
},
|
||||
{
|
||||
path: "points",
|
||||
lazy: lazyRoute(() => import("@/pages/points/Center")),
|
||||
},
|
||||
{
|
||||
path: "points/transactions",
|
||||
lazy: lazyRoute(() => import("@/pages/points/Transactions")),
|
||||
},
|
||||
{
|
||||
path: "points/recharge",
|
||||
lazy: lazyRoute(() => import("@/pages/points/Packages")),
|
||||
},
|
||||
{
|
||||
path: "points/rules",
|
||||
lazy: lazyRoute(() => import("@/pages/points/Rules")),
|
||||
},
|
||||
{
|
||||
path: "profile",
|
||||
lazy: lazyRoute(() => import("@/pages/profile/Settings")),
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* 积分 & 会员状态管理(Zustand)
|
||||
* - 启动时拉取余额 & 订阅信息
|
||||
* - 提供刷新、余额扣减(乐观更新)等工具
|
||||
*/
|
||||
import { create } from "zustand"
|
||||
import type { PointsBalance, PointsRulesResponse, SubscriptionCurrent } from "@/api/points/types"
|
||||
import { getCurrentSubscription, getPointsBalance, getPointsRules } from "@/api/points"
|
||||
|
||||
interface PointsState {
|
||||
/** 积分余额 & 会员状态(来自 /points/balance) */
|
||||
balance: PointsBalance | null
|
||||
/** 订阅详情(来自 /subscription/current) */
|
||||
subscription: SubscriptionCurrent | null
|
||||
/** 积分消耗规则缓存 */
|
||||
rules: PointsRulesResponse | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
/** 初始化:拉取余额 + 订阅信息 + 规则 */
|
||||
init: () => Promise<void>
|
||||
/** 强制刷新余额 */
|
||||
refreshBalance: () => Promise<void>
|
||||
/** 乐观扣减:在支付/业务发起前调用,失败时用 refreshBalance 兜底 */
|
||||
optimisticDeduct: (points: number) => void
|
||||
/** 乐观增加(充值成功后调用) */
|
||||
optimisticAdd: (points: number) => void
|
||||
}
|
||||
|
||||
export const usePointsStore = create<PointsState>((set, get) => ({
|
||||
balance: null,
|
||||
subscription: null,
|
||||
rules: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
init: async () => {
|
||||
if (get().loading) return
|
||||
set({ loading: true, error: null })
|
||||
try {
|
||||
const [balance, sub, rules] = await Promise.all([
|
||||
getPointsBalance(),
|
||||
getCurrentSubscription(),
|
||||
getPointsRules(),
|
||||
])
|
||||
set({ balance, subscription: sub, rules, loading: false })
|
||||
} catch (e) {
|
||||
set({ error: (e as Error).message, loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
refreshBalance: async () => {
|
||||
try {
|
||||
const balance = await getPointsBalance()
|
||||
set({ balance })
|
||||
} catch (e) {
|
||||
set({ error: (e as Error).message })
|
||||
}
|
||||
},
|
||||
|
||||
optimisticDeduct: (points: number) => {
|
||||
const b = get().balance
|
||||
if (!b) return
|
||||
set({
|
||||
balance: {
|
||||
...b,
|
||||
balance: Math.max(0, b.balance - points),
|
||||
total_spent: b.total_spent + points,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
optimisticAdd: (points: number) => {
|
||||
const b = get().balance
|
||||
if (!b) return
|
||||
set({
|
||||
balance: {
|
||||
...b,
|
||||
balance: b.balance + points,
|
||||
total_earned: b.total_earned + points,
|
||||
},
|
||||
})
|
||||
},
|
||||
}))
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
import ffmpeg
|
||||
from shared.ffmpeg_utils import FFMPEG_ENCODE_CRF, FFMPEG_ENCODE_PRESET
|
||||
@@ -40,7 +41,7 @@ class VideoProcessor:
|
||||
|
||||
def concatenate_videos(
|
||||
self,
|
||||
input_paths: list[str],
|
||||
input_paths: List[str],
|
||||
output_path: str,
|
||||
resolution: tuple[int, int] = (1920, 1080),
|
||||
fps: int = 25,
|
||||
|
||||
@@ -18,10 +18,9 @@ from __future__ import annotations
|
||||
import logging
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from video_processing.oss_helpers import download_asset, upload_to_oss
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def mark_asset_used_for_generation(asset, times: int = 1) -> None:
|
||||
@@ -15,6 +15,6 @@ def mark_asset_used_for_generation(asset, times: int = 1) -> None:
|
||||
asset.metadata = {
|
||||
**asset.metadata,
|
||||
"generation_use_count": int(asset.metadata.get("generation_use_count") or 0) + times,
|
||||
"last_used_at": datetime.now(UTC).isoformat(),
|
||||
"last_used_at": datetime.now(timezone.utc).isoformat(),
|
||||
"review_status": asset.metadata.get("review_status") or "pending_review",
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import TitleLibraryModel
|
||||
|
||||
@@ -10,6 +10,6 @@ def mark_title_used_for_generation(db, task) -> None:
|
||||
if title is None:
|
||||
return
|
||||
title.usage_count = int(title.usage_count or 0) + 1
|
||||
title.updated_at = datetime.now(UTC)
|
||||
title.updated_at = datetime.now(timezone.utc)
|
||||
db.add(title)
|
||||
db.commit()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Worker 启动时的初始化任务 — 孤儿任务清理等."""
|
||||
|
||||
import logging
|
||||
from datetime import UTC
|
||||
|
||||
from celery.signals import worker_ready
|
||||
from worker_app.db import SessionLocal
|
||||
@@ -131,14 +130,14 @@ def cleanup_stale_jobs(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_MINUTES) -> in
|
||||
Returns:
|
||||
清理的任务数量
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import JobModel
|
||||
from packages.domain.job import JobStatus
|
||||
|
||||
try:
|
||||
session = SessionLocal()
|
||||
cutoff = datetime.now(UTC) - timedelta(minutes=timeout_minutes)
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes)
|
||||
stale_jobs = (
|
||||
session.query(JobModel)
|
||||
.filter(
|
||||
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
# ── 数据类 ───────────────────────────────────────────────────────────────────
|
||||
@@ -322,5 +322,5 @@ def build_error_info(error: Exception, stage: str = "render") -> dict[str, Any]:
|
||||
"message": str(error),
|
||||
"stack_trace": tb_summary,
|
||||
"stage": stage,
|
||||
"failed_at": datetime.now(UTC).isoformat(),
|
||||
"failed_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from celery.utils.log import get_task_logger
|
||||
@@ -447,7 +447,7 @@ def ingest_asset(job_id: str) -> dict:
|
||||
|
||||
# Update job status to PROCESSING
|
||||
job.status = IngestJobStatus.PROCESSING
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
job_repo.update(job)
|
||||
db.commit()
|
||||
|
||||
@@ -703,7 +703,7 @@ def ingest_asset(job_id: str) -> dict:
|
||||
if codec_val:
|
||||
asset.codec = str(codec_val)
|
||||
asset.status = AssetStatus.ERROR
|
||||
asset.updated_at = datetime.now(UTC)
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
asset_repo.update(asset)
|
||||
else:
|
||||
# 旧链路兜底:无占位记录(如历史 job 重跑)才新建
|
||||
@@ -729,7 +729,7 @@ def ingest_asset(job_id: str) -> dict:
|
||||
job.status = IngestJobStatus.FAILED
|
||||
job.error_message = f"Invalid media file: {error_reason}"
|
||||
job.result_asset_id = asset.id
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
job_repo.update(job)
|
||||
db.commit()
|
||||
|
||||
@@ -797,13 +797,13 @@ def ingest_asset(job_id: str) -> dict:
|
||||
pass
|
||||
asset.status = AssetStatus.READY
|
||||
asset.thumbnail_url = thumbnail_url
|
||||
asset.updated_at = datetime.now(UTC)
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
asset_repo.update(asset)
|
||||
|
||||
# Update job status to COMPLETED
|
||||
job.status = IngestJobStatus.COMPLETED
|
||||
job.result_asset_id = asset.id
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
job_repo.update(job)
|
||||
|
||||
db.commit()
|
||||
@@ -825,7 +825,7 @@ def ingest_asset(job_id: str) -> dict:
|
||||
if job:
|
||||
job.status = IngestJobStatus.FAILED
|
||||
job.error_message = str(e)
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
job_repo.update(job)
|
||||
|
||||
# 将上传时创建的占位 Asset(PROCESSING/UPLOADING)标记为 ERROR,
|
||||
@@ -857,7 +857,7 @@ def ingest_asset(job_id: str) -> dict:
|
||||
):
|
||||
existing.status = AssetStatus.ERROR
|
||||
existing.metadata = {**(existing.metadata or {}), "ingest_error": str(e)}
|
||||
existing.updated_at = datetime.now(UTC)
|
||||
existing.updated_at = datetime.now(timezone.utc)
|
||||
asset_repo.update(existing)
|
||||
logger.info(
|
||||
"Marked asset as ERROR due to ingest failure: asset_id=%s job_id=%s",
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
-- 会员 + 积分系统 (#1895)
|
||||
-- users 表新增字段 + 4 张新表
|
||||
-- 创建时间: 2026-09-11
|
||||
|
||||
-- 1. users 表新增字段
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_member BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS member_type VARCHAR(20);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS member_expires_at TIMESTAMP;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS points_balance INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- 2. points_accounts 积分账户表
|
||||
CREATE TABLE IF NOT EXISTS points_accounts (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
balance INTEGER NOT NULL DEFAULT 0,
|
||||
total_earned INTEGER NOT NULL DEFAULT 0,
|
||||
total_spent INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 3. points_transactions 积分流水表
|
||||
CREATE TABLE IF NOT EXISTS points_transactions (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
account_id VARCHAR(36) NOT NULL REFERENCES points_accounts(id) ON DELETE CASCADE,
|
||||
type VARCHAR(20) NOT NULL,
|
||||
source VARCHAR(50) NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
balance_after INTEGER NOT NULL,
|
||||
description VARCHAR(255) DEFAULT '',
|
||||
ref_id VARCHAR(100) DEFAULT '',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_points_tx_user ON points_transactions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_points_tx_type ON points_transactions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_points_tx_source ON points_transactions(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_points_tx_created ON points_transactions(created_at);
|
||||
|
||||
-- 4. points_orders 积分/会员订单表
|
||||
CREATE TABLE IF NOT EXISTS points_orders (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
order_type VARCHAR(20) NOT NULL,
|
||||
product_code VARCHAR(50) NOT NULL,
|
||||
amount_cents INTEGER NOT NULL,
|
||||
original_amount_cents INTEGER NOT NULL DEFAULT 0,
|
||||
discount REAL NOT NULL DEFAULT 1.0,
|
||||
points_amount INTEGER NOT NULL DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
payment_method VARCHAR(50),
|
||||
payment_id VARCHAR(100),
|
||||
paid_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_points_orders_user ON points_orders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_points_orders_status ON points_orders(status);
|
||||
|
||||
-- 5. daily_usage_records 每日免费混剪计数
|
||||
CREATE TABLE IF NOT EXISTS daily_usage_records (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
usage_date DATE NOT NULL,
|
||||
usage_type VARCHAR(50) NOT NULL DEFAULT 'free_clip',
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(user_id, usage_date, usage_type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_usage_user_date ON daily_usage_records(user_id, usage_date);
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Asset InMemory Repository 实现"""
|
||||
|
||||
from datetime import UTC
|
||||
|
||||
from packages.domain import Asset
|
||||
|
||||
|
||||
@@ -47,7 +45,7 @@ class InMemoryAssetRepository:
|
||||
|
||||
def batch_delete(self, asset_ids: list[str]) -> int:
|
||||
"""批量删除素材(软删除,标记 status=deleted),返回实际影响数量。"""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from packages.domain import AssetStatus
|
||||
|
||||
@@ -56,26 +54,26 @@ class InMemoryAssetRepository:
|
||||
asset = self._assets.get(aid)
|
||||
if asset and asset.status != AssetStatus.DELETED:
|
||||
asset.status = AssetStatus.DELETED
|
||||
asset.updated_at = datetime.now(UTC)
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def batch_update_metadata(self, asset_ids: list[str], metadata_patch: dict[str, object]) -> int:
|
||||
"""批量更新素材 metadata(合并 patch),返回实际影响数量。"""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
count = 0
|
||||
for aid in asset_ids:
|
||||
asset = self._assets.get(aid)
|
||||
if asset:
|
||||
asset.metadata = {**asset.metadata, **metadata_patch}
|
||||
asset.updated_at = datetime.now(UTC)
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def batch_add_tags(self, asset_ids: list[str], tag_ids: list[str]) -> int:
|
||||
"""批量给素材添加标签(合并去重),返回实际影响数量。"""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
count = 0
|
||||
for aid in asset_ids:
|
||||
@@ -87,20 +85,20 @@ class InMemoryAssetRepository:
|
||||
asset.tag_ids.append(tid)
|
||||
changed = True
|
||||
if changed:
|
||||
asset.updated_at = datetime.now(UTC)
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def batch_replace_tags(self, asset_ids: list[str], tag_ids: list[str]) -> int:
|
||||
"""批量替换素材标签(全量覆盖),返回实际影响数量。"""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
count = 0
|
||||
for aid in asset_ids:
|
||||
asset = self._assets.get(aid)
|
||||
if asset:
|
||||
asset.tag_ids = list(tag_ids)
|
||||
asset.updated_at = datetime.now(UTC)
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
@@ -170,13 +168,13 @@ class InMemoryAssetRepository:
|
||||
file_size: int = 0,
|
||||
) -> Asset | None:
|
||||
"""兜底去重:同库 + 同文件名(+同大小)且近期活动状态的素材。"""
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
if not name:
|
||||
return None
|
||||
from packages.domain import AssetStatus
|
||||
|
||||
cutoff = datetime.now(UTC) - timedelta(minutes=within_minutes)
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=within_minutes)
|
||||
candidates = [
|
||||
a
|
||||
for a in self._assets.values()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"""
|
||||
|
||||
import copy
|
||||
from typing import Optional
|
||||
from typing import Dict, Optional
|
||||
|
||||
from packages.domain.entities import User
|
||||
from packages.ports.user_repository import UserRepository
|
||||
@@ -13,14 +13,14 @@ class InMemoryUserRepository(UserRepository):
|
||||
"""用户仓储内存实现"""
|
||||
|
||||
def __init__(self):
|
||||
self._users: dict[str, User] = {}
|
||||
self._email_index: dict[str, str] = {} # email -> user_id
|
||||
self._username_index: dict[str, str] = {} # username -> user_id
|
||||
self._verification_token_index: dict[str, str] = {} # token -> user_id
|
||||
self._reset_token_index: dict[str, str] = {} # token -> user_id
|
||||
self._wechat_openid_index: dict[str, str] = {} # openid -> user_id
|
||||
self._wechat_unionid_index: dict[str, str] = {} # unionid -> user_id
|
||||
self._phone_index: dict[str, str] = {} # phone -> user_id
|
||||
self._users: Dict[str, User] = {}
|
||||
self._email_index: Dict[str, str] = {} # email -> user_id
|
||||
self._username_index: Dict[str, str] = {} # username -> user_id
|
||||
self._verification_token_index: Dict[str, str] = {} # token -> user_id
|
||||
self._reset_token_index: Dict[str, str] = {} # token -> user_id
|
||||
self._wechat_openid_index: Dict[str, str] = {} # openid -> user_id
|
||||
self._wechat_unionid_index: Dict[str, str] = {} # unionid -> user_id
|
||||
self._phone_index: Dict[str, str] = {} # phone -> user_id
|
||||
|
||||
def save(self, user: User) -> None:
|
||||
"""保存用户(存储独立副本,避免外部修改影响内部状态)"""
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import builtins
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
@@ -22,7 +21,7 @@ import threading
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
from typing import Optional, Set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,7 +36,7 @@ class FeatureFlagConfig:
|
||||
name: str
|
||||
enabled: bool = False
|
||||
percentage: int = 0 # 0-100
|
||||
whitelist: set[str] = field(default_factory=set)
|
||||
whitelist: Set[str] = field(default_factory=set)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -172,7 +171,7 @@ class RedisFeatureFlagStore(FeatureFlagStore):
|
||||
def _redis_key(self, name: str) -> str:
|
||||
return f"{self._key_prefix}{name}"
|
||||
|
||||
def _parse_whitelist(self, raw: Optional[str]) -> builtins.set[str]:
|
||||
def _parse_whitelist(self, raw: Optional[str]) -> Set[str]:
|
||||
if not raw:
|
||||
return set()
|
||||
try:
|
||||
|
||||
@@ -5,7 +5,7 @@ Redis Session 存储
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import redis
|
||||
@@ -118,7 +118,7 @@ class SessionStore(SessionStorePort):
|
||||
是否保存成功
|
||||
"""
|
||||
try:
|
||||
now = datetime.now(UTC)
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(seconds=expires_in_seconds)
|
||||
|
||||
session_data = {
|
||||
@@ -230,7 +230,7 @@ class SessionStore(SessionStorePort):
|
||||
if not session:
|
||||
return False
|
||||
|
||||
session["last_active_at"] = datetime.now(UTC).isoformat()
|
||||
session["last_active_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
session_key = self._session_key(session_id)
|
||||
ttl = self.redis.ttl(session_key)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from packages.domain.auth.email_service import EmailConfig, EmailServicePort
|
||||
|
||||
@@ -37,8 +37,8 @@ class EmailService(EmailServicePort):
|
||||
subject: str,
|
||||
html_body: str,
|
||||
text_body: Optional[str] = None,
|
||||
cc: Optional[list[str]] = None,
|
||||
bcc: Optional[list[str]] = None,
|
||||
cc: Optional[List[str]] = None,
|
||||
bcc: Optional[List[str]] = None,
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
发送邮件
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -112,7 +112,7 @@ class SQLAlchemyAssetRepository:
|
||||
return self.find_by_id(asset_id)
|
||||
|
||||
def create(self, asset: Asset) -> Asset:
|
||||
now = datetime.now(UTC)
|
||||
now = datetime.now(timezone.utc)
|
||||
model = AssetModel(
|
||||
id=asset.id,
|
||||
project_id=asset.project_id,
|
||||
@@ -176,7 +176,7 @@ class SQLAlchemyAssetRepository:
|
||||
model.file_hash = asset.file_hash or model.file_hash
|
||||
if getattr(model, "client_upload_id", None) is None and asset.client_upload_id:
|
||||
model.client_upload_id = asset.client_upload_id
|
||||
model.updated_at = datetime.now(UTC)
|
||||
model.updated_at = datetime.now(timezone.utc)
|
||||
self.session.flush()
|
||||
self._sync_asset_tags(asset.id, asset.tag_ids)
|
||||
self.session.commit()
|
||||
@@ -214,9 +214,9 @@ class SQLAlchemyAssetRepository:
|
||||
"""批量删除素材(软删除,标记 status=deleted),返回实际影响数量。"""
|
||||
if not asset_ids:
|
||||
return 0
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(UTC)
|
||||
now = datetime.now(timezone.utc)
|
||||
# 先查询待删除素材的库分布(用于更新计数)
|
||||
to_delete = (
|
||||
self.session.query(AssetModel.asset_library_id, AssetModel.file_size)
|
||||
@@ -262,9 +262,9 @@ class SQLAlchemyAssetRepository:
|
||||
"""批量更新素材 metadata(合并 patch),返回实际影响数量。"""
|
||||
if not asset_ids:
|
||||
return 0
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(UTC)
|
||||
now = datetime.now(timezone.utc)
|
||||
# 逐条读取 + 合并 + 更新,保证 JSON 合并正确
|
||||
models = self.session.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all()
|
||||
count = 0
|
||||
@@ -286,9 +286,9 @@ class SQLAlchemyAssetRepository:
|
||||
"""批量给素材添加标签(合并去重),返回实际影响数量。"""
|
||||
if not asset_ids or not tag_ids:
|
||||
return 0
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(UTC)
|
||||
now = datetime.now(timezone.utc)
|
||||
clean_tag_ids = list(set(tag_ids))
|
||||
count = 0
|
||||
for aid in asset_ids:
|
||||
@@ -313,9 +313,9 @@ class SQLAlchemyAssetRepository:
|
||||
"""批量替换素材标签(全量覆盖),返回实际影响数量。"""
|
||||
if not asset_ids:
|
||||
return 0
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(UTC)
|
||||
now = datetime.now(timezone.utc)
|
||||
clean_tag_ids = list(set(tag_ids))
|
||||
count = 0
|
||||
for aid in asset_ids:
|
||||
@@ -556,13 +556,13 @@ class SQLAlchemyAssetRepository:
|
||||
file_size=0(大小未知)时直接返回 None——宁可漏判(极端情况下多建一条
|
||||
占位)也不可仅凭同名 + processing 误杀内容全新的视频。
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
if not name:
|
||||
return None
|
||||
if not file_size or file_size <= 0:
|
||||
return None
|
||||
cutoff = datetime.now(UTC) - timedelta(minutes=within_minutes)
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=within_minutes)
|
||||
query = self.session.query(AssetModel).filter(
|
||||
AssetModel.asset_library_id == library_id,
|
||||
AssetModel.name == name,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -36,7 +36,7 @@ class SQLAlchemyBillingRepository:
|
||||
model.status = "paid"
|
||||
model.payment_method = payment_method
|
||||
model.payment_id = payment_id
|
||||
model.paid_at = datetime.now(UTC)
|
||||
model.paid_at = datetime.now(timezone.utc)
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import DailyUsageRecordModel
|
||||
from packages.domain.daily_usage_record import DailyUsageRecord
|
||||
|
||||
|
||||
class SQLAlchemyDailyUsageRepository:
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def create(self, record: DailyUsageRecord) -> DailyUsageRecord:
|
||||
model = DailyUsageRecordModel(
|
||||
id=record.id,
|
||||
user_id=record.user_id,
|
||||
usage_date=record.usage_date,
|
||||
usage_type=record.usage_type,
|
||||
count=record.count,
|
||||
updated_at=record.updated_at,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return record
|
||||
|
||||
def get_by_user_and_date(
|
||||
self, user_id: str, usage_date: date, usage_type: str = "free_clip"
|
||||
) -> DailyUsageRecord | None:
|
||||
model = (
|
||||
self.session.query(DailyUsageRecordModel)
|
||||
.filter(
|
||||
DailyUsageRecordModel.user_id == user_id,
|
||||
DailyUsageRecordModel.usage_date == usage_date,
|
||||
DailyUsageRecordModel.usage_type == usage_type,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
return None
|
||||
return self._to_domain(model)
|
||||
|
||||
def update_count(self, record: DailyUsageRecord) -> DailyUsageRecord:
|
||||
model = self.session.query(DailyUsageRecordModel).filter(DailyUsageRecordModel.id == record.id).first()
|
||||
if model is None:
|
||||
return record
|
||||
model.count = record.count
|
||||
model.updated_at = datetime.now(UTC)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return record
|
||||
|
||||
def upsert(self, user_id: str, usage_date: date, usage_type: str = "free_clip") -> DailyUsageRecord:
|
||||
"""Increment usage count for the given user/date/type, creating if needed."""
|
||||
model = (
|
||||
self.session.query(DailyUsageRecordModel)
|
||||
.filter(
|
||||
DailyUsageRecordModel.user_id == user_id,
|
||||
DailyUsageRecordModel.usage_date == usage_date,
|
||||
DailyUsageRecordModel.usage_type == usage_type,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
record = DailyUsageRecord.create(user_id=user_id, usage_date=usage_date, usage_type=usage_type)
|
||||
record.count = 1
|
||||
model = DailyUsageRecordModel(
|
||||
id=record.id,
|
||||
user_id=record.user_id,
|
||||
usage_date=record.usage_date,
|
||||
usage_type=record.usage_type,
|
||||
count=1,
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return record
|
||||
|
||||
model.count += 1
|
||||
model.updated_at = datetime.now(UTC)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return self._to_domain(model)
|
||||
|
||||
@staticmethod
|
||||
def _to_domain(model: DailyUsageRecordModel) -> DailyUsageRecord:
|
||||
return DailyUsageRecord(
|
||||
id=model.id,
|
||||
user_id=model.user_id,
|
||||
usage_date=model.usage_date,
|
||||
usage_type=model.usage_type,
|
||||
count=model.count,
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -23,7 +23,7 @@ class SQLAlchemyEditPlanClipRepository:
|
||||
status: Optional[EditPlanClipStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> list[EditPlanClip]:
|
||||
) -> List[EditPlanClip]:
|
||||
"""按计划列出片段,按 order 排序"""
|
||||
query = self.session.query(EditPlanClipModel).filter(
|
||||
EditPlanClipModel.plan_id == plan_id,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -23,7 +23,7 @@ class SQLAlchemyEditPlanRepository:
|
||||
status: Optional[EditPlanStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[EditPlan]:
|
||||
) -> List[EditPlan]:
|
||||
"""按模板列出剪辑计划"""
|
||||
query = self.session.query(EditPlanModel).filter(
|
||||
EditPlanModel.template_id == template_id,
|
||||
@@ -40,7 +40,7 @@ class SQLAlchemyEditPlanRepository:
|
||||
status: Optional[EditPlanStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[EditPlan]:
|
||||
) -> List[EditPlan]:
|
||||
"""列出所有剪辑计划"""
|
||||
query = self.session.query(EditPlanModel)
|
||||
if status:
|
||||
@@ -63,7 +63,7 @@ class SQLAlchemyEditPlanRepository:
|
||||
status: Optional[EditPlanStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[EditPlan]:
|
||||
) -> List[EditPlan]:
|
||||
"""按项目列出剪辑计划"""
|
||||
query = self.session.query(EditPlanModel).filter(
|
||||
EditPlanModel.project_id == project_id,
|
||||
@@ -81,7 +81,7 @@ class SQLAlchemyEditPlanRepository:
|
||||
status: Optional[EditPlanStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[EditPlan]:
|
||||
) -> List[EditPlan]:
|
||||
"""列出用户创建的剪辑计划"""
|
||||
query = self.session.query(EditPlanModel).filter(
|
||||
EditPlanModel.created_by_user_id == user_id,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -22,7 +22,7 @@ class SQLAlchemyEditTemplateRepository:
|
||||
template_type: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[EditTemplate]:
|
||||
) -> List[EditTemplate]:
|
||||
"""列出所有激活的模板"""
|
||||
query = self.session.query(EditTemplateModel).filter(
|
||||
EditTemplateModel.status == EditTemplateStatus.ACTIVE,
|
||||
@@ -43,7 +43,7 @@ class SQLAlchemyEditTemplateRepository:
|
||||
status: Optional[EditTemplateStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[EditTemplate]:
|
||||
) -> List[EditTemplate]:
|
||||
"""列出所有模板(含非激活)"""
|
||||
query = self.session.query(EditTemplateModel)
|
||||
if template_type:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -339,7 +339,7 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
"""同 cleanup_stale_running,但返回 [(task_id, celery_task_id), ...] 供撤销队列消息。"""
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff = datetime.now(UTC) - timedelta(minutes=timeout_minutes)
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes)
|
||||
models = (
|
||||
self.session.query(GenerationTaskModel)
|
||||
.filter(
|
||||
@@ -358,9 +358,9 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
model.error_info = {
|
||||
"error_type": "WorkerInterrupted",
|
||||
"message": "任务在运行中中断,可能因 worker 重启或超时",
|
||||
"failed_at": datetime.now(UTC).isoformat(),
|
||||
"failed_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
model.completed_at = datetime.now(UTC)
|
||||
model.completed_at = datetime.now(timezone.utc)
|
||||
self.session.commit()
|
||||
return result
|
||||
|
||||
@@ -377,7 +377,7 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
"""同 cleanup_stale_pending,但返回 [(task_id, celery_task_id), ...] 供撤销队列消息。"""
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff = datetime.now(UTC) - timedelta(minutes=timeout_minutes)
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes)
|
||||
models = (
|
||||
self.session.query(GenerationTaskModel)
|
||||
.filter(
|
||||
@@ -391,7 +391,7 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
error_info = {
|
||||
"error_type": "PendingTimeout",
|
||||
"message": f"任务在 pending 状态停留超过 {timeout_minutes} 分钟,自动清理",
|
||||
"failed_at": datetime.now(UTC).isoformat(),
|
||||
"failed_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
result: list[tuple[str, str]] = []
|
||||
for model in models:
|
||||
@@ -399,6 +399,6 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
model.status = GenerationTaskStatus.FAILED.value
|
||||
model.error_message = "pending timeout: auto cleanup"
|
||||
model.error_info = error_info
|
||||
model.completed_at = datetime.now(UTC)
|
||||
model.completed_at = datetime.now(timezone.utc)
|
||||
self.session.commit()
|
||||
return result
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Index, Integer, String, Text, UniqueConstraint, text
|
||||
@@ -39,12 +39,7 @@ class UserModel(Base):
|
||||
phone_verified = Column(Boolean, nullable=False, default=False)
|
||||
binding_completed_at = Column(DateTime, nullable=True)
|
||||
profile_completed = Column(Boolean, nullable=False, default=True, server_default="true")
|
||||
# 会员+积分 (#1895)
|
||||
is_member = Column(Boolean, nullable=False, default=False)
|
||||
member_type = Column(String(20), nullable=True)
|
||||
member_expires_at = Column(DateTime, nullable=True)
|
||||
points_balance = Column(Integer, nullable=False, default=0)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class ProjectModel(Base):
|
||||
@@ -62,7 +57,7 @@ class ProjectModel(Base):
|
||||
shared_users = Column(JSON, nullable=False, default=list) # 被共享的用户 ID 列表
|
||||
is_default = Column(Boolean, nullable=False, default=False, server_default="false")
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class AssetLibraryModel(Base):
|
||||
@@ -76,8 +71,8 @@ class AssetLibraryModel(Base):
|
||||
asset_count = Column(Float, nullable=False, default=0)
|
||||
total_size = Column(Float, nullable=False, default=0)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class AssetModel(Base):
|
||||
@@ -108,8 +103,8 @@ class AssetModel(Base):
|
||||
file_hash = Column(String(64), nullable=True, index=True)
|
||||
client_upload_id = Column(String(64), nullable=True, index=True)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC), index=True)
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), index=True)
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class TagModel(Base):
|
||||
@@ -120,7 +115,7 @@ class TagModel(Base):
|
||||
id = Column(String(36), primary_key=True)
|
||||
user_id = Column(String(36), nullable=False, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_tags_user_name"),)
|
||||
|
||||
@@ -132,7 +127,7 @@ class AssetTagModel(Base):
|
||||
|
||||
asset_id = Column(String(36), primary_key=True)
|
||||
tag_id = Column(String(36), primary_key=True)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class EditTemplateModel(Base):
|
||||
@@ -153,8 +148,8 @@ class EditTemplateModel(Base):
|
||||
sort_weight = Column(Integer, nullable=False, default=0, index=True)
|
||||
status = Column(String(20), nullable=False, default="active", index=True)
|
||||
version = Column(Integer, nullable=False, default=1)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class EditTemplateVersionModel(Base):
|
||||
@@ -174,7 +169,7 @@ class EditTemplateVersionModel(Base):
|
||||
clip_configs = Column(JSON, nullable=False, default=list)
|
||||
change_note = Column(String(500), nullable=False, default="")
|
||||
published_by = Column(String(36), nullable=False, default="")
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class EditPlanModel(Base):
|
||||
@@ -194,8 +189,8 @@ class EditPlanModel(Base):
|
||||
source_edit_plan_id = Column(String(36), nullable=True, index=True)
|
||||
project_id = Column(String(36), nullable=False, default="", index=True)
|
||||
created_by_user_id = Column(String(36), nullable=False, default="", index=True)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class TemplateClipConfigModel(Base):
|
||||
@@ -216,8 +211,8 @@ class TemplateClipConfigModel(Base):
|
||||
material_requirements = Column(JSON, nullable=False, default=dict)
|
||||
transition_effect = Column(String(20), nullable=False, default="cut")
|
||||
config = Column(JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class EditPlanClipModel(Base):
|
||||
@@ -242,8 +237,8 @@ class EditPlanClipModel(Base):
|
||||
playback_speed = Column(Float, nullable=False, default=1.0)
|
||||
status = Column(String(20), nullable=False, default="pending", index=True)
|
||||
config = Column(JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class IngestJobModel(Base):
|
||||
@@ -259,8 +254,8 @@ class IngestJobModel(Base):
|
||||
file_hash = Column(String(64), nullable=True, index=True)
|
||||
asset_id = Column(String(36), nullable=False, default="", index=True)
|
||||
celery_task_id = Column(String(64), nullable=False, default="", server_default="")
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class ClassificationJobModel(Base):
|
||||
@@ -273,8 +268,8 @@ class ClassificationJobModel(Base):
|
||||
classification = Column(String(50), nullable=False, default="")
|
||||
confidence = Column(Float, nullable=False, default=0.0)
|
||||
error_message = Column(Text, nullable=False, default="")
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class GenerationTaskModel(Base):
|
||||
@@ -320,12 +315,12 @@ class GenerationTaskModel(Base):
|
||||
bgm_config = Column(JSON, nullable=False, default=dict)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
logs = Column(Text, nullable=False, default="[]", server_default="[]")
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@@ -349,8 +344,8 @@ class GeneratedVideoModel(Base):
|
||||
status = Column(String(20), nullable=False, default="completed", index=True)
|
||||
review_status = Column(String(20), nullable=False, default="pending_review", index=True)
|
||||
generation_params = Column(Text, nullable=False, default="{}")
|
||||
generated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
generated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=True)
|
||||
video_fingerprint = Column(Text, nullable=True)
|
||||
is_duplicate = Column(Boolean, nullable=False, default=False)
|
||||
@@ -373,8 +368,8 @@ class TitleLibraryModel(Base):
|
||||
usage_count = Column(Integer, nullable=False, default=0)
|
||||
is_active = Column(Boolean, nullable=False, default=True, index=True)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class VoiceCloneProfileModel(Base):
|
||||
@@ -396,8 +391,8 @@ class VoiceCloneProfileModel(Base):
|
||||
retry_count = Column(Integer, nullable=False, default=0)
|
||||
max_retries = Column(Integer, nullable=False, default=3)
|
||||
metadata_ = Column("metadata", JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class VoiceLibraryModel(Base):
|
||||
@@ -417,8 +412,8 @@ class VoiceLibraryModel(Base):
|
||||
status = Column(String(20), nullable=False, default="completed", index=True)
|
||||
tags = Column(JSON, nullable=False, default=list)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class DuplicationRecordModel(Base):
|
||||
@@ -438,8 +433,8 @@ class DuplicationRecordModel(Base):
|
||||
match_count = Column(Integer, nullable=True)
|
||||
video_fingerprint = Column(Text, nullable=True)
|
||||
error_message = Column(Text, nullable=False, default="")
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class DuplicationSegmentModel(Base):
|
||||
@@ -467,8 +462,8 @@ class RecipeModel(Base):
|
||||
generation_params = Column(JSON, nullable=False, default=dict)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class RecipeItemModel(Base):
|
||||
@@ -496,8 +491,8 @@ class TemplateModel(Base):
|
||||
bgm_config = Column(JSON, nullable=False, default=dict)
|
||||
estimated_duration = Column(Float, nullable=False, default=0.0)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class TemplateSegmentModel(Base):
|
||||
@@ -509,8 +504,8 @@ class TemplateSegmentModel(Base):
|
||||
duration_min = Column(Float, nullable=False)
|
||||
duration_max = Column(Float, nullable=False)
|
||||
material_type = Column(String(20), nullable=True) # 仅 voice_over 模式: 人物/场景
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class TemplateCategoryModel(Base):
|
||||
@@ -520,7 +515,7 @@ class TemplateCategoryModel(Base):
|
||||
user_id = Column(String(36), nullable=False, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
sort_order = Column(Integer, nullable=False, default=0)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class JobModel(Base):
|
||||
@@ -544,8 +539,8 @@ class JobModel(Base):
|
||||
created_by_user_id = Column(String(36), nullable=False, default="", index=True)
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class TTSJobModel(Base):
|
||||
@@ -573,8 +568,8 @@ class TTSJobModel(Base):
|
||||
metadata_ = Column("metadata", JSON, nullable=False, default=dict)
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class BillingRecordModel(Base):
|
||||
@@ -591,7 +586,7 @@ class BillingRecordModel(Base):
|
||||
payment_method = Column(String(50), nullable=True)
|
||||
payment_id = Column(String(100), nullable=True) # 第三方支付流水号
|
||||
invoice_url = Column(String(500), nullable=True)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
|
||||
|
||||
@@ -607,7 +602,7 @@ class VerificationCodeModel(Base):
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
used_at = Column(DateTime, nullable=True)
|
||||
attempts = Column(Integer, nullable=False, default=0)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class VideoShareModel(Base):
|
||||
@@ -624,8 +619,8 @@ class VideoShareModel(Base):
|
||||
view_count = Column(Integer, nullable=False, default=0)
|
||||
download_count = Column(Integer, nullable=False, default=0)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class CoverTemplateModel(Base):
|
||||
@@ -639,8 +634,8 @@ class CoverTemplateModel(Base):
|
||||
thumbnail_url = Column(String(1000), nullable=False, default="")
|
||||
is_system = Column(Boolean, nullable=False, default=False, index=True)
|
||||
config = Column(JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class VideoFingerprintChunkModel(Base):
|
||||
@@ -657,7 +652,7 @@ class VideoFingerprintChunkModel(Base):
|
||||
phash_binary = Column(String(16), nullable=False)
|
||||
color_histogram = Column(JSON, nullable=False)
|
||||
frame_count = Column(Integer, nullable=False, default=1)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class ScriptModel(Base):
|
||||
@@ -671,8 +666,8 @@ class ScriptModel(Base):
|
||||
content = Column(Text, nullable=False, default="")
|
||||
segments = Column(JSON, nullable=False, default=list)
|
||||
tags = Column(JSON, nullable=False, default=list)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class LipsyncJobModel(Base):
|
||||
@@ -714,8 +709,8 @@ class LipsyncJobModel(Base):
|
||||
# 时间戳
|
||||
submitted_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class AiAvatarRenderJob(Base):
|
||||
@@ -746,70 +741,5 @@ class AiAvatarRenderJob(Base):
|
||||
submitted_at = Column(DateTime, nullable=True)
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class PointsAccountModel(Base):
|
||||
"""积分账户 ORM 模型 (#1895)"""
|
||||
|
||||
__tablename__ = "points_accounts"
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
user_id = Column(String(36), nullable=False, unique=True, index=True)
|
||||
balance = Column(Integer, nullable=False, default=0)
|
||||
total_earned = Column(Integer, nullable=False, default=0)
|
||||
total_spent = Column(Integer, nullable=False, default=0)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class PointsTransactionModel(Base):
|
||||
"""积分流水 ORM 模型 (#1895)"""
|
||||
|
||||
__tablename__ = "points_transactions"
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
user_id = Column(String(36), nullable=False, index=True)
|
||||
account_id = Column(String(36), nullable=False, index=True)
|
||||
type = Column(String(20), nullable=False, index=True) # earn / spend / refund
|
||||
source = Column(String(50), nullable=False, index=True)
|
||||
amount = Column(Integer, nullable=False)
|
||||
balance_after = Column(Integer, nullable=False)
|
||||
description = Column(String(255), nullable=False, default="")
|
||||
ref_id = Column(String(100), nullable=False, default="")
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class PointsOrderModel(Base):
|
||||
"""积分/会员订单 ORM 模型 (#1895)"""
|
||||
|
||||
__tablename__ = "points_orders"
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
user_id = Column(String(36), nullable=False, index=True)
|
||||
order_type = Column(String(20), nullable=False) # membership / points
|
||||
product_code = Column(String(50), nullable=False)
|
||||
amount_cents = Column(Integer, nullable=False)
|
||||
original_amount_cents = Column(Integer, nullable=False, default=0)
|
||||
discount = Column(Float, nullable=False, default=1.0)
|
||||
points_amount = Column(Integer, nullable=False, default=0)
|
||||
status = Column(String(20), nullable=False, default="pending", index=True)
|
||||
payment_method = Column(String(50), nullable=True)
|
||||
payment_id = Column(String(100), nullable=True)
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class DailyUsageRecordModel(Base):
|
||||
"""每日使用记录 ORM 模型 (#1895)"""
|
||||
|
||||
__tablename__ = "daily_usage_records"
|
||||
__table_args__ = (UniqueConstraint("user_id", "usage_date", "usage_type", name="uq_daily_usage_user_date_type"),)
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
user_id = Column(String(36), nullable=False, index=True)
|
||||
usage_date = Column(DateTime, nullable=False) # stored as DATE in SQL but DateTime for ORM compat
|
||||
usage_type = Column(String(50), nullable=False, default="free_clip")
|
||||
count = Column(Integer, nullable=False, default=0)
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import PointsAccountModel
|
||||
from packages.domain.points_account import PointsAccount
|
||||
|
||||
|
||||
class SQLAlchemyPointsAccountRepository:
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def create(self, account: PointsAccount) -> PointsAccount:
|
||||
model = PointsAccountModel(
|
||||
id=account.id,
|
||||
user_id=account.user_id,
|
||||
balance=account.balance,
|
||||
total_earned=account.total_earned,
|
||||
total_spent=account.total_spent,
|
||||
created_at=account.created_at,
|
||||
updated_at=account.updated_at,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return account
|
||||
|
||||
def get_by_user_id(self, user_id: str) -> PointsAccount | None:
|
||||
model = self.session.query(PointsAccountModel).filter(PointsAccountModel.user_id == user_id).first()
|
||||
if model is None:
|
||||
return None
|
||||
return self._to_domain(model)
|
||||
|
||||
def update_balance(self, account: PointsAccount) -> PointsAccount:
|
||||
model = self.session.query(PointsAccountModel).filter(PointsAccountModel.id == account.id).first()
|
||||
if model is None:
|
||||
return account
|
||||
model.balance = account.balance
|
||||
model.total_earned = account.total_earned
|
||||
model.total_spent = account.total_spent
|
||||
model.updated_at = datetime.now(UTC)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return account
|
||||
|
||||
@staticmethod
|
||||
def _to_domain(model: PointsAccountModel) -> PointsAccount:
|
||||
return PointsAccount(
|
||||
id=model.id,
|
||||
user_id=model.user_id,
|
||||
balance=model.balance,
|
||||
total_earned=model.total_earned,
|
||||
total_spent=model.total_spent,
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
@@ -1,96 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import PointsOrderModel
|
||||
from packages.domain.points_order import PointsOrder
|
||||
|
||||
|
||||
class SQLAlchemyPointsOrderRepository:
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def create(self, order: PointsOrder) -> PointsOrder:
|
||||
model = PointsOrderModel(
|
||||
id=order.id,
|
||||
user_id=order.user_id,
|
||||
order_type=order.order_type,
|
||||
product_code=order.product_code,
|
||||
amount_cents=order.amount_cents,
|
||||
original_amount_cents=order.original_amount_cents,
|
||||
discount=order.discount,
|
||||
points_amount=order.points_amount,
|
||||
status=order.status,
|
||||
payment_method=order.payment_method,
|
||||
payment_id=order.payment_id,
|
||||
paid_at=order.paid_at,
|
||||
created_at=order.created_at,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return order
|
||||
|
||||
def get(self, order_id: str) -> PointsOrder | None:
|
||||
model = self.session.query(PointsOrderModel).filter(PointsOrderModel.id == order_id).first()
|
||||
if model is None:
|
||||
return None
|
||||
return self._to_domain(model)
|
||||
|
||||
def update_status(
|
||||
self,
|
||||
order_id: str,
|
||||
status: str,
|
||||
*,
|
||||
payment_id: str | None = None,
|
||||
paid_at: datetime | None = None,
|
||||
) -> PointsOrder | None:
|
||||
model = self.session.query(PointsOrderModel).filter(PointsOrderModel.id == order_id).first()
|
||||
if model is None:
|
||||
return None
|
||||
model.status = status
|
||||
if payment_id is not None:
|
||||
model.payment_id = payment_id
|
||||
if paid_at is not None:
|
||||
model.paid_at = paid_at
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return self._to_domain(model)
|
||||
|
||||
def list_by_user(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
order_type: str | None = None,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[PointsOrder], int]:
|
||||
query = self.session.query(PointsOrderModel).filter(PointsOrderModel.user_id == user_id)
|
||||
if order_type:
|
||||
query = query.filter(PointsOrderModel.order_type == order_type)
|
||||
if status:
|
||||
query = query.filter(PointsOrderModel.status == status)
|
||||
|
||||
total = query.count()
|
||||
models = (
|
||||
query.order_by(PointsOrderModel.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
)
|
||||
return [self._to_domain(m) for m in models], total
|
||||
|
||||
@staticmethod
|
||||
def _to_domain(model: PointsOrderModel) -> PointsOrder:
|
||||
return PointsOrder(
|
||||
id=model.id,
|
||||
user_id=model.user_id,
|
||||
order_type=model.order_type,
|
||||
product_code=model.product_code,
|
||||
amount_cents=model.amount_cents,
|
||||
original_amount_cents=model.original_amount_cents,
|
||||
discount=model.discount,
|
||||
points_amount=model.points_amount,
|
||||
status=model.status,
|
||||
payment_method=model.payment_method,
|
||||
payment_id=model.payment_id,
|
||||
paid_at=model.paid_at,
|
||||
created_at=model.created_at,
|
||||
)
|
||||
@@ -1,65 +0,0 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import PointsTransactionModel
|
||||
from packages.domain.points_transaction import PointsTransaction
|
||||
|
||||
|
||||
class SQLAlchemyPointsTransactionRepository:
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def create(self, transaction: PointsTransaction) -> PointsTransaction:
|
||||
model = PointsTransactionModel(
|
||||
id=transaction.id,
|
||||
user_id=transaction.user_id,
|
||||
account_id=transaction.account_id,
|
||||
type=transaction.type,
|
||||
source=transaction.source,
|
||||
amount=transaction.amount,
|
||||
balance_after=transaction.balance_after,
|
||||
description=transaction.description,
|
||||
ref_id=transaction.ref_id,
|
||||
created_at=transaction.created_at,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return transaction
|
||||
|
||||
def list_by_user(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
type: str | None = None,
|
||||
source: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[PointsTransaction], int]:
|
||||
query = self.session.query(PointsTransactionModel).filter(PointsTransactionModel.user_id == user_id)
|
||||
if type:
|
||||
query = query.filter(PointsTransactionModel.type == type)
|
||||
if source:
|
||||
query = query.filter(PointsTransactionModel.source == source)
|
||||
|
||||
total = query.count()
|
||||
models = (
|
||||
query.order_by(PointsTransactionModel.created_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [self._to_domain(m) for m in models], total
|
||||
|
||||
@staticmethod
|
||||
def _to_domain(model: PointsTransactionModel) -> PointsTransaction:
|
||||
return PointsTransaction(
|
||||
id=model.id,
|
||||
user_id=model.user_id,
|
||||
account_id=model.account_id,
|
||||
type=model.type,
|
||||
source=model.source,
|
||||
amount=model.amount,
|
||||
balance_after=model.balance_after,
|
||||
description=model.description or "",
|
||||
ref_id=model.ref_id or "",
|
||||
created_at=model.created_at,
|
||||
)
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -22,7 +22,7 @@ class SQLAlchemyRecipeRepository:
|
||||
*,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[Recipe]:
|
||||
) -> List[Recipe]:
|
||||
models = (
|
||||
self.session.query(RecipeModel)
|
||||
.filter(
|
||||
@@ -121,7 +121,7 @@ class SQLAlchemyRecipeRepository:
|
||||
.count()
|
||||
)
|
||||
|
||||
def list_items(self, recipe_id: str) -> list[RecipeItem]:
|
||||
def list_items(self, recipe_id: str) -> List[RecipeItem]:
|
||||
models = (
|
||||
self.session.query(RecipeItemModel)
|
||||
.filter(RecipeItemModel.recipe_id == recipe_id)
|
||||
@@ -130,7 +130,7 @@ class SQLAlchemyRecipeRepository:
|
||||
)
|
||||
return [self._item_model_to_entity(m) for m in models]
|
||||
|
||||
def create_items(self, items: list[RecipeItem]) -> list[RecipeItem]:
|
||||
def create_items(self, items: List[RecipeItem]) -> List[RecipeItem]:
|
||||
for item in items:
|
||||
model = RecipeItemModel(
|
||||
id=item.id,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -30,7 +30,7 @@ class SQLAlchemyTemplateClipConfigRepository:
|
||||
clip_type: Optional[ClipType] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> list[TemplateClipConfig]:
|
||||
) -> List[TemplateClipConfig]:
|
||||
"""按模板列出片段配置,按 order 排序"""
|
||||
query = self.session.query(TemplateClipConfigModel).filter(
|
||||
TemplateClipConfigModel.template_id == template_id,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -56,7 +56,7 @@ class SQLAlchemyTemplateRepository:
|
||||
keyword: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
valid_only: bool = False,
|
||||
) -> list[Template]:
|
||||
) -> List[Template]:
|
||||
query = self.session.query(TemplateModel).filter(
|
||||
TemplateModel.user_id == user_id,
|
||||
TemplateModel.is_active.is_(True),
|
||||
@@ -252,7 +252,7 @@ class SQLAlchemyTemplateRepository:
|
||||
created = self.create(new_template)
|
||||
|
||||
# 复用 create_segments 写入 template_clip_configs
|
||||
new_segments: list[TemplateSegment] = []
|
||||
new_segments: List[TemplateSegment] = []
|
||||
for seg in source.segments:
|
||||
new_segments.append(
|
||||
TemplateSegment(
|
||||
@@ -274,7 +274,7 @@ class SQLAlchemyTemplateRepository:
|
||||
|
||||
# ── Segments ──
|
||||
|
||||
def list_segments(self, template_id: str) -> list[TemplateSegment]:
|
||||
def list_segments(self, template_id: str) -> List[TemplateSegment]:
|
||||
"""优先从 template_clip_configs 读取,回退读 template_segments。"""
|
||||
clips = (
|
||||
self.session.query(TemplateClipConfigModel)
|
||||
@@ -293,7 +293,7 @@ class SQLAlchemyTemplateRepository:
|
||||
)
|
||||
return [self._segment_model_to_entity(m) for m in old]
|
||||
|
||||
def create_segments(self, segments: list[TemplateSegment]) -> list[TemplateSegment]:
|
||||
def create_segments(self, segments: List[TemplateSegment]) -> List[TemplateSegment]:
|
||||
"""写入 template_clip_configs 表。material_type 存入 config JSON。"""
|
||||
for seg in segments:
|
||||
config = {"material_type": seg.material_type} if seg.material_type else {}
|
||||
@@ -330,7 +330,7 @@ class SQLAlchemyTemplateRepository:
|
||||
|
||||
# ── Categories ──
|
||||
|
||||
def list_categories(self, user_id: str) -> list[TemplateCategory]:
|
||||
def list_categories(self, user_id: str) -> List[TemplateCategory]:
|
||||
models = (
|
||||
self.session.query(TemplateCategoryModel)
|
||||
.filter(TemplateCategoryModel.user_id == user_id)
|
||||
@@ -380,7 +380,7 @@ class SQLAlchemyTemplateRepository:
|
||||
|
||||
# ── Tags ──
|
||||
|
||||
def list_tags(self, user_id: str) -> list[str]:
|
||||
def list_tags(self, user_id: str) -> List[str]:
|
||||
"""获取用户所有模板的标签(去重)。"""
|
||||
models = (
|
||||
self.session.query(TemplateModel)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.template_version import EditTemplateVersion
|
||||
@@ -49,7 +51,7 @@ class SQLAlchemyTemplateVersionRepository:
|
||||
return None
|
||||
return self._to_entity(model)
|
||||
|
||||
def list_by_template(self, template_id: str, limit: int = 50) -> list[EditTemplateVersion]:
|
||||
def list_by_template(self, template_id: str, limit: int = 50) -> List[EditTemplateVersion]:
|
||||
"""列出模板的所有历史版本(按版本号倒序)"""
|
||||
from packages.adapters.sqlalchemy_impl.models import EditTemplateVersionModel
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -24,7 +24,7 @@ class SQLAlchemyTitleLibraryRepository:
|
||||
is_active: bool = True,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[TitleLibraryItem]:
|
||||
) -> List[TitleLibraryItem]:
|
||||
query = self.session.query(TitleLibraryModel).filter(
|
||||
TitleLibraryModel.user_id == user_id,
|
||||
TitleLibraryModel.is_active == is_active,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user