Compare commits

..

12 Commits

Author SHA1 Message Date
CI Bot 4607977496 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m5s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m46s
AI Code Review / AI Code Review (pull_request) Successful in 6m24s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 11s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m41s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 5m5s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m39s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m41s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m17s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 12m15s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 12m57s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 12h44m8s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 12h38m45s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 12h38m52s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 12h38m53s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 12h38m53s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 12h43m23s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 12h43m23s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 12h43m34s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 13h5m43s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 12h33m15s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 12h33m19s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 12h33m19s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 12h33m19s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 12h38m45s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 12h38m45s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 12h38m45s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 12h43m55s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 12h44m0s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 12h33m15s
2026-09-15 18:11:06 +00:00
xiaoxia-agent be1451dc21 test(points): add lipsync endpoint tests for insufficient/refund paths
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 0s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m21s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m48s
AI Code Review / AI Code Review (pull_request) Successful in 6m22s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 24s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 35s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Failing after 6m4s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 13h14m0s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 13h13m16s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 13h13m17s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 13h31m15s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 13h13m41s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 13h13m40s
2026-09-16 01:39:54 +08:00
xiaoxia-agent be9031abc9 test(points): strengthen TTS endpoint tests for 402/refund paths 2026-09-16 01:39:54 +08:00
xiaoxia-agent a96aff1ac4 feat(points): P2 step 2.7 - wire generation_cover points gate (ai_cover scene)
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m17s
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 14h4m23s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Failing after 6m24s
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 16s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 16s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 14h3m39s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 14h3m55s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 14h3m39s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 14h3m54s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 14h3m55s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 14h15m27s
2026-09-16 00:36:13 +08:00
xiaoxia-agent a7317bb8e4 feat(points): P2 step 2.6 - wire ai_avatar_render points gate (ai_digital_human per_unit=15)
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m13s
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 14h5m24s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 14h5m30s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 5m50s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 22s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 23s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 14h4m36s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 14h4m46s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 14h4m46s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 14h4m46s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 14h16m45s
2026-09-16 00:36:13 +08:00
xiaoxia-agent b5a593feb9 feat(points): P2 step 2.5 - wire generation_preview points gate (ai_video scene)
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m12s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m17s
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 14h11m4s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 11s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 11s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 10s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 11s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 14h7m51s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 14h10m21s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 14h10m21s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 14h17m36s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 14h8m1s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 14h8m1s
2026-09-16 00:36:13 +08:00
xiaoxia-agent b3ff3c9936 feat(points): P2 step 2.4 - wire generation_tasks points gate (ai_video scene) 2026-09-16 00:36:13 +08:00
CI Bot 1cf9bbd9a5 style: auto-format with black + isort + ruff + prettier [skip ci-format-check] 2026-09-16 00:36:13 +08:00
xiaoxia-agent f2a13ef5a7 feat(points): P2 step 2.3 - wire scripts_ai 3 endpoints points gate
- extract_from_douyin: @points_gate("douyin_extract") 1分/次
- ai_rewrite: @points_gate("ai_rewrite") 1分/次
- ai_generate_titles: @points_gate("ai_title") 1分/次
- All gated by settings.points_enabled (default false)
- Refund on exception handled by decorator
- Renamed authenticated_user → current_user in route + tests for decorator compat
- 4 new points tests; 32+4=36 scripts_ai tests pass; 15472 total passed
2026-09-16 00:36:13 +08:00
xiaoxia-agent 6084b2f662 feat(points): P2 step 2.2 - wire lipsync points gate
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m37s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m0s
AI Code Review / AI Code Review (pull_request) Successful in 6m59s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 9s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 11s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m52s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 5m57s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 6m53s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 14h11m41s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 14h10m57s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 14h10m58s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 14h11m6s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 14h18m53s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 14h10m57s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 14h11m6s
- create_lipsync_job(): deduct ai_digital_human points (15/min), estimated from audio_duration (if provided) or script_text (chars/240), min 1 min; refund on ValueError/MediaKitError/Exception; refund on failed job
- preview_tts(): deduct ai_digital_human points by script_text length; refund on MediaKitError/Exception
- Both gated by settings.points_enabled (default false)
- Raises HTTP 402 INSUFFICIENT_POINTS on insufficient balance
- 14 new unit tests; 15468 total passed
2026-09-16 00:36:13 +08:00
xiaoxia-agent 3862d9158f test(points): add unit tests for TTS + voice_clone preview points gating
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 20s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m11s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m41s
AI Code Review / AI Code Review (pull_request) Successful in 7m7s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 17s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Failing after 13m0s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 5m9s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m8s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 14h12m47s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 14h12m8s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 13h56m59s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 14h12m3s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 14h12m3s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 14h19m17s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 13h56m58s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 13h56m58s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 13h56m26s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 13h56m27s
- Estimate minutes (ceil(chars/240), min 1) parametrized
- Disabled gate (zero cost), min cost, insufficient (402), member discount
- Refund called on exception
- voice_clone_synth scene cost validation
2026-09-16 00:36:13 +08:00
xiaoxia-agent 580ec51928 feat(points): P2 step 2.1 - wire TTS + voice_clone preview points gate
- synthesize(): deduct ai_voice points (1/min, est ceil(chars/240)), refund on workflow/Celery failure
- preview_tts(): deduct ai_voice points, refund on CosyVoiceError/ValueError
- get_voice_clone_preview(): deduct voice_clone_synth points (1/min), refund on CosyVoiceError/ValueError
- All points logic gated by settings.points_enabled (default false)
- Raises HTTP 402 INSUFFICIENT_POINTS when balance insufficient
2026-09-16 00:36:13 +08:00
109 changed files with 4921 additions and 5023 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
CI trigger file - safe to delete
retrigger at 2026-09-15 20:31:24 UTC
updated!
File diff suppressed because it is too large Load Diff
-2
View File
@@ -494,5 +494,3 @@
- [Fixed] Bug 修复
- [Security] 安全相关更新
- [Performance] 性能优化
---
- 2026-09-16: fix extract-from-douyin 异常路径全部返回业务码(消除500) #1963
-67
View File
@@ -1,67 +0,0 @@
"""#1894: merge title_libraries into scripts — add title_text/title_category/title_config
Revision ID: 077_merge_title_libs
Revises: 076_membership_points
Create Date: 2026-09-15
"""
import sqlalchemy as sa
from alembic import context, op
revision = "077_merge_title_libs"
down_revision = "076_membership_points"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("scripts") as batch:
batch.add_column(
sa.Column("title_text", sa.String(500), nullable=False, server_default=""),
)
batch.add_column(
sa.Column("title_category", sa.String(50), nullable=False, server_default=""),
)
batch.add_column(
sa.Column("title_config", sa.JSON, nullable=False, server_default="{}"),
)
if context.get_context().dialect.name == "postgresql":
conn = op.get_bind()
result = conn.execute(sa.text("SELECT to_regclass('public.title_libraries')"))
if result.scalar() is not None:
conn.execute(sa.text("""
INSERT INTO scripts
(id, user_id, title, content, segments, tags,
title_text, title_category, title_config,
created_at, updated_at)
SELECT
gen_random_uuid()::TEXT,
tl.user_id,
COALESCE(tl.name, '迁移标题'),
COALESCE(tl.text, ''),
'[]'::JSONB,
COALESCE(tl.tags, '[]'::JSONB),
COALESCE(tl.text, ''),
COALESCE(tl.category, ''),
COALESCE(tl."metadata", '{}'::JSONB),
tl.created_at,
tl.updated_at
FROM title_libraries tl
WHERE tl.is_active = true
AND NOT EXISTS (
SELECT 1 FROM scripts s
WHERE s.user_id = tl.user_id
AND s.title_text = COALESCE(tl.text, '')
AND s.title_category = COALESCE(tl.category, '')
AND s.created_at = tl.created_at
)
"""))
def downgrade() -> None:
with op.batch_alter_table("scripts") as batch:
batch.drop_column("title_config")
batch.drop_column("title_category")
batch.drop_column("title_text")
@@ -1,33 +0,0 @@
"""#1894: drop obsolete script title fields (title_text/title_category/title_config)
Revision ID: 078_drop_script_title_fields
Revises: 077_merge_title_libs
Create Date: 2026-09-16
口播文案(scripts)不再自带配套标题、标题分类和标题样式字段。
智能剪辑 / AI 数字人等生成场景各自通过入参配置标题,不再从文案读取。
保留字段:title(名称)、content(正文)、segments(分段)、tags(标签)。
"""
import sqlalchemy as sa
from alembic import op
revision = "078_drop_script_title_fields"
down_revision = "077_merge_title_libs"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("scripts") as batch:
batch.drop_column("title_config")
batch.drop_column("title_category")
batch.drop_column("title_text")
def downgrade() -> None:
with op.batch_alter_table("scripts") as batch:
batch.add_column(sa.Column("title_text", sa.String(500), nullable=False, server_default=""))
batch.add_column(sa.Column("title_category", sa.String(50), nullable=False, server_default=""))
batch.add_column(sa.Column("title_config", sa.JSON, nullable=False, server_default="{}"))
+2 -17
View File
@@ -25,27 +25,12 @@ def check_project_access(project_id: str, user_id: str, project_repository) -> N
raise HTTPException(status_code=403, detail="无权访问该项目")
_LEGACY_PLANS = {"standard", "pro", "enterprise", "basic", "premium"}
def get_user_plan(user_id: str, user_repository: UserRepository) -> str:
"""获取用户的会员类型,兼容旧档位值。
旧档位 standard/pro/enterprise/basic/premium 统一映射到当前体系:
- standard/basic → monthly
- pro/premium/enterprise → quarterly
"""
"""获取用户的订阅计划名称。"""
user = user_repository.find_by_id(user_id)
if user is None:
return "free"
plan = getattr(user, "subscription_plan", "free") or "free"
if plan in {"standard", "basic"}:
return "monthly"
if plan in {"pro", "premium", "enterprise"}:
return "quarterly"
if plan not in {"free", "monthly", "quarterly", "yearly"}:
return "free"
return plan
return getattr(user, "subscription_plan", "free") or "free"
def require_project_and_library(
+1 -18
View File
@@ -13,7 +13,7 @@ from typing import Optional
import jwt
from app.auth import AuthenticatedUser, blacklist_token, get_current_user
from app.config import settings
from app.dependencies import get_auth_email_service, get_auth_session_store, get_db_session, get_user_repository
from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, EmailStr, field_validator
@@ -126,7 +126,6 @@ async def register(
request: RegisterRequest,
user_repository: UserRepository = Depends(get_user_repository),
email_service=Depends(get_auth_email_service),
db=Depends(get_db_session),
) -> RegisterResponse:
use_case = RegisterUserUseCase(
user_repository=user_repository,
@@ -144,22 +143,6 @@ async def register(
if error or response is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=_translate_auth_error(error))
# 新用户注册赠送 50 积分(失败不影响注册)
if settings.points_enabled:
try:
from packages.domain.points_service import PointsService
_svc = PointsService()
_svc.add_points(
user_id=response.user_id,
amount=50,
source="task_reward",
db=db,
description="新用户注册赠送",
)
except Exception as _bonus_err:
import logging
logging.getLogger(__name__).warning("注册送积分失败: user_id=%s err=%s", response.user_id, _bonus_err)
return RegisterResponse(
user_id=response.user_id,
email=response.email,
+1 -8
View File
@@ -295,14 +295,7 @@ def get_lipsync_job(
from datetime import datetime as _dt
_now = _dt.now(UTC)
_upd = job.updated_at
# DB 返回的 DateTime 列可能是 naive(取决于方言/驱动):代码写入统一用
# datetime.now(UTC),经 SQLAlchemy 存入 TIMESTAMP WITHOUT TIMEZONE 后再
# 读回就是 UTC wall clock 的 naive datetime,直接补 UTC tz 即可;避免
# TypeError: can't subtract offset-naive and offset-aware datetimes。
if _upd is not None and _upd.tzinfo is None:
_upd = _upd.replace(tzinfo=UTC)
_stale = _upd is None or (_now - _upd).total_seconds() > 30
_stale = job.updated_at is None or (_now - job.updated_at).total_seconds() > 30
if _stale:
try:
refreshed = svc.refresh_job_status(job_id, current_user.user.id)
+3 -21
View File
@@ -8,7 +8,7 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from datetime import datetime
from typing import Optional
from app.auth import AuthenticatedUser, get_current_user
@@ -125,7 +125,6 @@ def get_rules(
base_points=scene_data["base_points"],
unit=scene_data["unit"],
extra_per_30s=scene_data.get("extra_per_30s"),
description=scene_data.get("description", ""),
)
)
return PointsRulesResponse(
@@ -162,16 +161,7 @@ def check_points(
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""消费前检查余额是否足够。未知 scene_key 返回 400(而非 500)。"""
if body.scene_key not in POINTS_SCENES:
raise HTTPException(
status_code=400,
detail={
"code": "UNKNOWN_SCENE",
"message": f"未知场景: {body.scene_key}",
"valid_scenes": sorted(POINTS_SCENES.keys()),
},
)
"""消费前检查余额是否足够。"""
is_mem = _is_member(current_user)
mt = _member_type(current_user)
@@ -277,7 +267,7 @@ def create_recharge_order(
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""创建积分充值订单。pay_params 在支付通道接入后填入 prepay_id/payment_url;当前为空 dict。"""
"""创建积分充值订单。"""
svc = _get_service()
try:
order = svc.create_order(
@@ -288,14 +278,6 @@ def create_recharge_order(
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from None
package = POINTS_PACKAGES.get(body.package_id, {})
now = datetime.now(timezone.utc)
expire_at = now + timedelta(hours=48)
# TODO: 接入微信/支付宝后填充真实 prepay_id / payment_url
order["points_amount"] = package.get("points", 0)
order["pay_params"] = {}
order["expire_at"] = expire_at.isoformat()
return PointsOrderResponse(**order)
+134 -433
View File
@@ -1,12 +1,7 @@
"""Scripts AI 能力路由 — Issue #1893/#1963.
"""Scripts AI 能力路由 — Issue #1893.
三个 AI 工具接口(均挂载在 /api/v1/scripts 前缀下):
- POST /extract-from-douyin 从抖音视频提取文案
- 入口自动从分享文本中正则提取 http(s) URL,兼容 "复制链接" 粘贴场景
- yt-dlp 仅解析视频元信息(download=False)拿无水印直链,避免整段下载
- 优先走火山 MediaKit ASRasr-subtitles),配置了 MEDIAKIT_API_KEY 即可用
- MediaKit 不可用/失败时,回退到本地 ASR(下载视频 + transcribe_to_text
- cookies/ytdlp 均失败时,返回友好 503 不暴露内部错误
- POST /extract-from-douyin 从抖音视频提取文案yt-dlp 下载 + ASR 转写)
- POST /ai-rewrite AI 文案改写(复用豆包 LLM)
- POST /ai-generate-titles AI 标题生成(复用 generate_smart_titles
"""
@@ -14,11 +9,8 @@
from __future__ import annotations
import logging
import os
import re
import tempfile
import time
from urllib.parse import urlparse
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
@@ -30,11 +22,6 @@ from app.schemas.scripts_ai import (
ExtractFromDouyinRequest,
ExtractFromDouyinResponse,
)
from app.services.mediakit_client import (
MediaKitClient,
MediaKitError,
get_mediakit_client,
)
from app.services.script_asr_service import (
ASRNotConfiguredError,
ASRTranscriptionError,
@@ -50,493 +37,207 @@ logger = logging.getLogger(__name__)
router = APIRouter()
DOUYIN_COOKIES_FILE = os.environ.get(
"DOUYIN_COOKIES_FILE",
"/app/configs/douyin_cookies.txt",
)
DOUYIN_COOKIES_FILE_BAKED = "/app/configs/douyin_cookies_default.txt"
_COOKIES_ERROR_KEYWORDS = (
"fresh cookies",
"cookies (not necessarily logged in)",
"cookies are needed",
"need cookies",
"cookie is expired",
"login required",
"sign in to continue",
"未登录",
"需要登录",
"cookies过期",
)
_TAIL_PUNCT = ".,;:!?,。;:!?)]》" + chr(34) + chr(39) + "<>"
def _resolve_cookies_file():
for p in (DOUYIN_COOKIES_FILE, DOUYIN_COOKIES_FILE_BAKED):
try:
if p and os.path.isfile(p) and os.path.getsize(p) > 200:
return p
except OSError:
continue
return None
def _dbg(key, val):
logger.debug("douyin_extract %s=%s", key, str(val)[:200])
def _is_cookies_related_error(msg):
low = msg.lower()
return any(kw in low for kw in _COOKIES_ERROR_KEYWORDS)
_cf = _resolve_cookies_file()
if _cf:
logger.info("抖音 cookies 文件已加载: %s (%d bytes)", _cf, os.path.getsize(_cf))
else:
logger.warning(
"抖音 cookies 文件未找到或无效: path=%s baked=%s",
DOUYIN_COOKIES_FILE,
DOUYIN_COOKIES_FILE_BAKED,
)
_DOUYIN_DEBUG_ERRORS = os.environ.get("DOUYIN_DEBUG_ERRORS", "").lower() in ("1", "true", "yes")
_URL_EXTRACT_RE = re.compile(r"https?://\S+", re.IGNORECASE)
_DOUYIN_HOST_RE = re.compile(
r"(^|\.)(douyin\.com|iesdouyin\.com|amemv\.com)$",
# 抖音 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,
)
_ANY_SCHEME_RE = re.compile(r"^[a-z][a-z0-9+.-]*://\S+", re.IGNORECASE)
def _extract_url_from_text(raw):
if not raw:
return None
m = _URL_EXTRACT_RE.search(raw)
if m:
return m.group(0).rstrip(_TAIL_PUNCT)
short = re.search(
r"(?:^|(?<![a-z0-9/:]))((?:v|www)\.douyin\.com/\S+|douyin\.com/(?:video|note)/\S+)",
raw,
re.IGNORECASE,
)
if short:
return "https://" + short.group(1).rstrip(_TAIL_PUNCT)
return None
def _extract_and_validate_douyin_url(raw_input):
raw = (raw_input or "").strip()
if not raw:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="链接不能为空")
url = _extract_url_from_text(raw)
if not url:
# 含非 http(s) 的 scheme 前缀(如 ftp://、file:// 等)→ 协议不支持
if _ANY_SCHEME_RE.search(raw):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的抖音链接,仅支持 http(s) 协议",
)
# 裸域名兜底:在去除 scheme 的情况下匹配 douyin 域名
short = re.search(
r"(?:^|(?<![a-z0-9]))((?:v|www)\.douyin\.com/\S+|douyin\.com/(?:video|note)/\S+)",
raw,
re.IGNORECASE,
)
if short:
url = "https://" + short.group(1).rstrip(_TAIL_PUNCT)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="未在输入中找到有效抖音链接,请粘贴包含 v.douyin.com 或 www.douyin.com 的分享文本",
)
if not re.match(r"^https?://", url, re.IGNORECASE):
url = "https://" + url
try:
parsed = urlparse(url)
host = parsed.hostname or ""
scheme = (parsed.scheme or "").lower()
except Exception:
host = ""
scheme = ""
if scheme not in ("http", "https"):
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="无效的抖音链接,仅支持 http(s) 协议",
detail="链接不能为空",
)
if not _DOUYIN_HOST_RE.search(host):
if not _DOUYIN_URL_RE.match(url.strip()):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的抖音链接,仅支持 douyin.com 域名(v.douyin.com 短链或 www.douyin.com 长链",
detail="无效的抖音链接,仅支持 v.douyin.com 短链或 www.douyin.com/video/ 长链",
)
return url
# ── MediaKitClient ASR 扩展(monkey patch ────────────────────────────
# ── 1. 从抖音视频提取文案 ─────────────────────────────────────────────────────
def _mk_post_json(self, path, payload):
import httpx
if not self.is_available:
raise MediaKitError("MediaKit API Key 未配置", code="NotConfigured")
url = self._base_url + path
try:
with httpx.Client(timeout=self._timeout) as http:
resp = http.post(url, headers=self._headers(), json=payload)
resp.raise_for_status()
data = resp.json()
except httpx.TimeoutException as exc:
raise MediaKitError("MediaKit API 超时 (%ss)" % self._timeout, code="Timeout") from exc
except httpx.HTTPStatusError as exc:
raise MediaKitError(
"MediaKit API HTTP %s: %s" % (exc.response.status_code, exc.response.text[:300]),
code="HttpError",
) from exc
except httpx.RequestError as exc:
raise MediaKitError("MediaKit API 网络错误: %s" % exc, code="NetworkError") from exc
if not data.get("success", True) and data.get("error"):
err = data["error"]
raise MediaKitError(err.get("message", "请求失败"), code=err.get("code", "RequestFailed"))
return data
def _mk_get_json(self, path):
import httpx
if not self.is_available:
raise MediaKitError("MediaKit API Key 未配置", code="NotConfigured")
url = self._base_url + path
try:
with httpx.Client(timeout=self._timeout) as http:
resp = http.get(url, headers=self._headers())
resp.raise_for_status()
return resp.json()
except httpx.TimeoutException as exc:
raise MediaKitError("MediaKit API 超时 (%ss)" % self._timeout, code="Timeout") from exc
except httpx.HTTPStatusError as exc:
raise MediaKitError(
"MediaKit API HTTP %s: %s" % (exc.response.status_code, exc.response.text[:300]),
code="HttpError",
) from exc
except httpx.RequestError as exc:
raise MediaKitError("MediaKit API 网络错误: %s" % exc, code="NetworkError") from exc
def _mediakit_asr_submit(self, video_url):
data = self._post_json(
"/tools/asr-subtitles",
{"video_url": video_url, "language": "cmn-Hans-CN"},
)
task_id = data.get("task_id")
if not task_id:
raise MediaKitError("MediaKit ASR 提交响应缺少 task_id")
return task_id
def _mediakit_asr_poll(self, task_id, poll_interval=2.0, max_attempts=90):
for _ in range(max_attempts):
time.sleep(poll_interval)
data = self._get_json("/tasks/" + task_id)
st = data.get("status")
if st in ("completed", "success"):
result = data.get("result") or {}
subs = result.get("subtitles") or []
text = "".join(s.get("subtitle_text", "") for s in subs if isinstance(s, dict))
duration = float(result.get("duration") or 0.0)
return text.strip(), duration
if st == "failed":
err = data.get("error") or {}
raise MediaKitError(
"MediaKit ASR 任务失败: %s" % err.get("message", "unknown"),
code=err.get("code", "TaskFailed"),
)
raise MediaKitError(
"MediaKit ASR 超时(%ss 未完成)" % int(poll_interval * max_attempts),
code="Timeout",
)
if not hasattr(MediaKitClient, "_post_json"):
MediaKitClient._post_json = _mk_post_json
if not hasattr(MediaKitClient, "_get_json"):
MediaKitClient._get_json = _mk_get_json
if not hasattr(MediaKitClient, "asr_submit"):
MediaKitClient.asr_submit = _mediakit_asr_submit
if not hasattr(MediaKitClient, "asr_poll"):
MediaKitClient.asr_poll = _mediakit_asr_poll
# ── yt-dlp 辅助 ──────────────────────────────────────────────────────
def _ytdlp_extract_video_url(page_url, cookiefile=None):
try:
import yt_dlp
except ImportError:
logger.warning("yt-dlp 未安装,无法解析抖音直链")
return None, 0.0
opts = {
"quiet": True,
"no_warnings": True,
"noplaylist": True,
"skip_download": True,
"http_headers": {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/128.0.0.0 Safari/537.36"
),
"Referer": "https://www.douyin.com/",
},
}
if cookiefile:
opts["cookiefile"] = cookiefile
try:
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(page_url, download=False)
except Exception as exc:
_dbg("ytdlp_err", str(exc)[:300])
logger.info("yt-dlp 解析抖音直链失败(将降级): %s", str(exc)[:200])
return None, 0.0
if not info:
return None, 0.0
video_url = info.get("url")
if not video_url:
for f in (info.get("requested_formats") or info.get("formats") or []):
if f.get("url"):
video_url = f["url"]
break
try:
duration = float(info.get("duration") or 0)
except (TypeError, ValueError):
duration = 0.0
return video_url, duration
def _ytdlp_download_and_local_asr(page_url, temp_dir, cookiefile=None):
try:
import yt_dlp
except ImportError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="抖音提取功能暂不可用(缺少依赖 yt-dlp)",
) from exc
opts = {
"format": "best[ext=mp4]/best",
"outtmpl": temp_dir + "/%(id)s.%(ext)s",
"quiet": True,
"no_warnings": True,
"noplaylist": True,
"http_headers": {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/128.0.0.0 Safari/537.36"
),
"Referer": "https://www.douyin.com/",
},
}
if cookiefile:
opts["cookiefile"] = cookiefile
info = None
try:
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(page_url, download=True)
except yt_dlp.utils.DownloadError as exc:
msg = str(exc)
logger.warning("抖音下载失败: url=%s error=%s", page_url, msg)
if _is_cookies_related_error(msg):
_detail = "抖音链接解析暂时不可用,请稍后重试或手动输入文案"
if _DOUYIN_DEBUG_ERRORS:
_detail = _detail + " [debug: " + msg[:300] + "]"
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=_detail) from exc
is_bad_url = any(
kw in msg.lower()
for kw in (
"404", "not found", "unable to download webpage",
"unsupported url", "no video formats", "video unavailable",
"this video isn't available",
)
)
_detail = "无法解析该抖音链接,请确认链接有效且视频未被下架" if is_bad_url else "视频下载失败,请稍后重试"
if _DOUYIN_DEBUG_ERRORS:
_detail = _detail + " [debug: " + msg[:300] + "]"
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST if is_bad_url else status.HTTP_502_BAD_GATEWAY,
detail=_detail,
) from exc
except Exception as exc:
msg = str(exc)
logger.exception("抖音视频下载异常: url=%s error=%s", page_url, msg)
if _is_cookies_related_error(msg):
_detail = "抖音链接解析暂时不可用,请稍后重试或手动输入文案"
if _DOUYIN_DEBUG_ERRORS:
_detail = _detail + " [debug: " + msg[:300] + "]"
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=_detail) from exc
_detail = "视频下载失败,请稍后重试"
if _DOUYIN_DEBUG_ERRORS:
_detail = _detail + " [debug: " + msg[:300] + "]"
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=_detail) from exc
if info is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="无法解析该抖音链接")
duration = 0.0
video_path = ""
try:
with yt_dlp.YoutubeDL(opts) as ydl:
video_path = ydl.prepare_filename(info)
try:
duration = float(info.get("duration") or 0)
except (TypeError, ValueError):
duration = 0.0
if not os.path.isfile(video_path) or os.path.getsize(video_path) == 0:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="视频下载异常:未获取到有效文件")
except HTTPException:
raise
except Exception as exc:
msg = str(exc)
logger.exception("抖音视频后处理异常: url=%s error=%s", page_url, msg)
if _is_cookies_related_error(msg):
_detail = "抖音链接解析暂时不可用,请稍后重试或手动输入文案"
if _DOUYIN_DEBUG_ERRORS:
_detail = _detail + " [debug: " + msg[:300] + "]"
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=_detail) from exc
_detail = "视频处理失败,请稍后重试"
if _DOUYIN_DEBUG_ERRORS:
_detail = _detail + " [debug: " + msg[:300] + "]"
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=_detail) from exc
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 Exception as exc:
logger.exception("ASR 转写异常: path=%s", video_path)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="语音识别失败: " + str(exc)[:200],
) from exc
return text.strip(), duration
# ── 1. 从抖音视频提取文案 ─────────────────────────────────────────────
@router.post("/extract-from-douyin", response_model=ExtractFromDouyinResponse)
@router.post(
"/extract-from-douyin",
response_model=ExtractFromDouyinResponse,
)
@points_gate("douyin_extract")
def extract_from_douyin(
request: ExtractFromDouyinRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
page_url = _extract_and_validate_douyin_url(request.url)
_dbg("page_url", page_url)
) -> ExtractFromDouyinResponse:
"""从抖音视频下载无水印视频并通过 ASR 提取文案."""
source_url = request.url.strip()
_validate_douyin_url(source_url)
text = ""
duration = 0.0
cookiefile = _resolve_cookies_file()
mk_client = get_mediakit_client()
# 确保 URL 有 schemeyt-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
# 路径 Ayt-dlp 拿直链 + MediaKit 云端 ASR
direct_url, meta_duration = _ytdlp_extract_video_url(page_url, cookiefile=cookiefile)
if meta_duration:
duration = meta_duration
_dbg("direct_url", direct_url or "<none>")
if direct_url and mk_client.is_available:
try:
task_id = mk_client.asr_submit(direct_url)
text, mk_duration = mk_client.asr_poll(task_id)
if mk_duration:
duration = mk_duration
logger.info(
"抖音 MediaKit ASR 成功: url=%s text_len=%d duration=%.1f",
page_url, len(text), duration,
)
except MediaKitError as exc:
logger.warning("MediaKit ASR 失败,回退本地 ASR: %s", exc)
text = ""
# 路径 B:回退下载 + 本地 ASR
if not text:
_dbg("fallback", "download+local_asr")
# 使用临时目录下载视频,退出时自动清理
try:
with tempfile.TemporaryDirectory(prefix="douyin_extract_") as temp_dir:
text, dl_duration = _ytdlp_download_and_local_asr(
page_url, temp_dir, cookiefile=cookiefile
)
if dl_duration and not duration:
duration = dl_duration
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=page_url,
source_url=source_url,
)
# ── 2. AI 文案改写 ────────────────────────────────────────────────────
# ── 2. AI 文案改写 ───────────────────────────────────────────────────────────
@router.post("/ai-rewrite", response_model=AiRewriteResponse)
@router.post(
"/ai-rewrite",
response_model=AiRewriteResponse,
)
@points_gate("ai_rewrite")
def ai_rewrite(
request: AiRewriteRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
) -> AiRewriteResponse:
"""使用豆包大模型改写文案."""
content = (request.content or "").strip()
if not content:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文案内容不能为空")
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 = system_prompt + "\n风格要求:" + style
system_prompt += f"\n风格要求:{style}"
user_prompt = f"请改写以下文案:\n\n{content}"
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": "请改写以下文案:\n\n" + content},
{"role": "user", "content": user_prompt},
]
try:
rewritten = client.chat_completion(messages=messages, temperature=0.8, max_tokens=2048)
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="AI 改写失败: " + str(exc)) from 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)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="AI 改写未返回有效结果",
)
return AiRewriteResponse(
original=content,
rewritten=rewritten.strip(),
style=style,
)
# ── 3. AI 标题生成 ────────────────────────────────────────────────────
# ── 3. AI 标题生成 ───────────────────────────────────────────────────────────
@router.post("/ai-generate-titles", response_model=AiGenerateTitlesResponse)
@router.post(
"/ai-generate-titles",
response_model=AiGenerateTitlesResponse,
)
@points_gate("ai_title")
def ai_generate_titles(
request: AiGenerateTitlesRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
) -> AiGenerateTitlesResponse:
"""使用现有 generate_smart_titles 生成标题."""
content = (request.content or "").strip()
if not content:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文案内容不能为空")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="文案内容不能为空",
)
# count 限制在 1-5Pydantic 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)
result = generate_smart_titles(
description=content,
style="viral",
count=count,
)
titles = result.get("titles", [])[:count]
return AiGenerateTitlesResponse(titles=titles)
+67 -85
View File
@@ -5,16 +5,13 @@ from __future__ import annotations
import logging
from dataclasses import replace
from datetime import UTC, datetime
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_user_repository
from app.schemas.subscription import (
BillingCycle,
BillingRecord,
ChangePlanRequest,
ChangePlanResponse,
MembershipType,
SimpleResponse,
SubscriptionInfo,
ToggleAutoRenewRequest,
@@ -28,18 +25,43 @@ logger = logging.getLogger(__name__)
router = APIRouter()
# ============ 会员展示名称(与 packages.domain.points_rules.MEMBERSHIP_PRICES 对应)============
# ============ 配额定义(硬编码,后续可迁移到配置中心) ============
_PLAN_NAMES: dict[str, str] = {
MembershipType.FREE: "免费用户",
MembershipType.MONTHLY: "月卡会员",
MembershipType.QUARTERLY: "季卡会员",
MembershipType.YEARLY: "年卡会员",
PLAN_QUOTAS = {
"free": {"max_projects": 3, "max_storage_gb": 10},
"standard": {"max_projects": 10, "max_storage_gb": 50},
"pro": {"max_projects": -1, "max_storage_gb": 100},
"enterprise": {"max_projects": -1, "max_storage_gb": 1000},
}
# ============ Helper Functions ============
def _get_plan_name(plan_id: str) -> str:
return _PLAN_NAMES.get(plan_id, "免费用户")
"""获取套餐显示名称"""
plan_names = {
"free": "体验版",
"standard": "标准版",
"pro": "专业版",
"enterprise": "企业版",
}
return plan_names.get(plan_id, "未知套餐")
def _get_plan_price(plan_id: str, billing_cycle: str) -> float:
"""获取套餐价格"""
prices = {
("free", "monthly"): 0,
("free", "yearly"): 0,
("standard", "monthly"): 99,
("standard", "yearly"): 999,
("pro", "monthly"): 299,
("pro", "yearly"): 2999,
("enterprise", "monthly"): 999,
("enterprise", "yearly"): 9999,
}
return prices.get((plan_id, billing_cycle), 0)
def _build_subscription_info(user: AuthenticatedUser) -> SubscriptionInfo:
@@ -52,20 +74,15 @@ def _build_subscription_info(user: AuthenticatedUser) -> SubscriptionInfo:
period_start = now.isoformat()
period_end = now.isoformat()
plan_id = user.user.subscription_plan or MembershipType.FREE
# 旧档位(standard/pro/enterprise)统一降级为 monthly,避免前端炸掉
if plan_id in {"standard", "pro", "enterprise"}:
plan_id = MembershipType.MONTHLY
return SubscriptionInfo(
id=f"sub-{user.user.id[:8]}",
plan_id=plan_id,
plan_name=_get_plan_name(plan_id),
plan_id=user.user.subscription_plan or "free",
plan_name=_get_plan_name(user.user.subscription_plan or "free"),
status=user.user.subscription_status or "active",
billing_cycle=plan_id if plan_id != MembershipType.FREE else BillingCycle.MONTHLY,
billing_cycle="monthly",
current_period_start=period_start,
current_period_end=period_end,
amount=0 if plan_id == MembershipType.FREE else 0, # 金额由前端 /plans 接口展示
amount=_get_plan_price(user.user.subscription_plan or "free", "monthly"),
auto_renew=True,
created_at=user.user.created_at.isoformat() if user.user.created_at else now.isoformat(),
)
@@ -82,39 +99,6 @@ async def get_current_subscription(
return _build_subscription_info(current_user)
@router.get("/plans")
def list_membership_plans(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> dict[str, list[dict[str, Any]]]:
"""查询所有会员档位(供前端会员购买页展示)。
返回 points 积分体系下的会员档位(月卡/季卡/年卡),含价格、时长、积分折扣等信息。
"""
from packages.domain.points_rules import MEMBER_DISCOUNT, MEMBERSHIP_PRICES
plans: list[dict[str, Any]] = []
for plan_id, info in MEMBERSHIP_PRICES.items():
days = info["duration_days"]
monthly_cents = round(info["price_cents"] * 30 / days)
features: dict[str, Any] = {"max_resolution": "1080p"}
if plan_id == MembershipType.MONTHLY:
features.update({"free_clips_daily": 2})
elif plan_id == MembershipType.QUARTERLY:
features.update({"free_clips_daily": 5})
elif plan_id == MembershipType.YEARLY:
features.update({"free_clips_daily": "unlimited"})
plans.append({
"plan_id": plan_id,
"name": info["name"],
"price_cents": info["price_cents"],
"monthly_price_cents": monthly_cents,
"duration_days": days,
"points_discount": MEMBER_DISCOUNT.get(plan_id, 1.0),
"features": features,
})
return {"plans": plans}
@router.get("/billing-records", response_model=list[BillingRecord])
async def get_billing_records(
current_user: AuthenticatedUser = Depends(get_current_user),
@@ -133,7 +117,7 @@ async def get_billing_records(
return [
BillingRecord(
id=r.id,
plan_name=_get_plan_name(r.plan_name),
plan_name=r.plan_name,
amount=r.amount,
billing_cycle=r.billing_cycle,
status=r.status,
@@ -147,10 +131,6 @@ async def get_billing_records(
session.close()
_VALID_PLANS = {MembershipType.MONTHLY, MembershipType.QUARTERLY, MembershipType.YEARLY}
_VALID_CYCLES = {BillingCycle.MONTHLY, BillingCycle.QUARTERLY, BillingCycle.YEARLY}
@router.post("/change-plan", response_model=ChangePlanResponse)
async def change_plan(
request: ChangePlanRequest,
@@ -159,45 +139,47 @@ async def change_plan(
) -> ChangePlanResponse:
"""变更订阅套餐(升级/降级)"""
# TODO: 接入支付验证(支付宝/微信支付)
target_plan = request.target_plan_id
if target_plan not in _VALID_PLANS:
valid_plans = {"free", "standard", "pro", "enterprise"}
if request.target_plan_id not in valid_plans:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的会员类型。支持: {', '.join(sorted(_VALID_PLANS))}",
detail=f"无效的套餐ID。支持的套餐: {', '.join(valid_plans)}",
)
if request.billing_cycle not in _VALID_CYCLES:
valid_cycles = {"monthly", "yearly"}
if request.billing_cycle not in valid_cycles:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的计费周期。支持: {', '.join(sorted(_VALID_CYCLES))}",
detail="无效的计费周期。支持: monthly, yearly",
)
user = current_user.user
current_plan = user.subscription_plan or MembershipType.FREE
# 旧档位归一化,避免永远显示"您已经是xxx"
if current_plan in {"standard", "pro", "enterprise"}:
current_plan = MembershipType.MONTHLY
current_plan = user.subscription_plan or "free"
target_plan = request.target_plan_id
if current_plan == target_plan:
return ChangePlanResponse(
success=False,
message=f"您已经是{_get_plan_name(target_plan)}",
message=f"您已经是 {_get_plan_name(target_plan)}",
)
# 通过 dataclasses.replace 创建新实例(不直接修改 dataclass)
quotas = PLAN_QUOTAS.get(target_plan, PLAN_QUOTAS["free"])
updated_user = replace(
user,
subscription_plan=target_plan,
subscription_status="active",
max_projects=-1, # 付费会员不限项目数
max_storage_gb=100,
max_projects=quotas["max_projects"],
max_storage_gb=quotas["max_storage_gb"],
)
user_repository.save(updated_user)
# 用更新后的用户构造响应
refreshed_auth_user = AuthenticatedUser(user=updated_user)
return ChangePlanResponse(
success=True,
message=f"套餐已成功变更为{_get_plan_name(target_plan)}",
message=f"套餐已成功变更为 {_get_plan_name(target_plan)}",
new_subscription=_build_subscription_info(refreshed_auth_user),
)
@@ -209,11 +191,10 @@ async def cancel_subscription(
) -> SimpleResponse:
"""取消订阅"""
user = current_user.user
plan_id = user.subscription_plan or MembershipType.FREE
if plan_id == MembershipType.FREE:
if user.subscription_plan == "free":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="免费用户无需取消订阅",
detail="体验版无需取消",
)
updated_user = replace(user, subscription_status="cancelled")
@@ -221,7 +202,7 @@ async def cancel_subscription(
return SimpleResponse(
success=True,
message="订阅已取消,当前周期结束后将降级为免费用户",
message="订阅已取消,当前周期结束后停止服务",
)
@@ -247,14 +228,11 @@ async def payment_callback(
if SessionLocal is None:
raise HTTPException(status_code=500, detail="Database not available")
# 仅接受当前会员体系的 plan 值
if plan not in _VALID_PLANS:
raise HTTPException(status_code=400, detail=f"未知的会员类型: {plan}")
session = SessionLocal()
try:
repo = SQLAlchemyBillingRepository(session)
# 创建账单记录
record_id = uuid.uuid4().hex
repo.create(
{
@@ -267,20 +245,19 @@ async def payment_callback(
}
)
# 在事务中标记支付成功并更新订阅
repo.mark_paid(record_id, payment_method, payment_id)
days_map = {BillingCycle.MONTHLY: 30, BillingCycle.QUARTERLY: 90, BillingCycle.YEARLY: 365}
days = days_map.get(billing_cycle, 30)
# 计算到期时间
days = 365 if billing_cycle == "yearly" else 30
expires_at = datetime.now(UTC) + timedelta(days=days)
repo.update_subscription_on_payment(user_id, plan, expires_at)
return {"success": True, "message": "支付成功", "record_id": record_id}
except HTTPException:
session.rollback()
raise
except Exception as e:
session.rollback()
logger.error("支付回调处理失败: user_id=%s, plan=%s, error=%s", user_id, plan, e)
logger.error(f"支付回调处理失败: user_id={user_id}, plan={plan}, error={e}")
# 不返回原始异常信息,避免泄漏内部实现细节
raise HTTPException(status_code=500, detail="支付处理失败,请稍后重试") from e
finally:
session.close()
@@ -292,5 +269,10 @@ async def toggle_auto_renew(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> SimpleResponse:
"""切换自动续费"""
# TODO: 实际需要在数据库中存储 auto_renew 字段
status_text = "已开启自动续费" if request.enabled else "已关闭自动续费"
return SimpleResponse(success=True, message=status_text)
return SimpleResponse(
success=True,
message=status_text,
)
+179 -23
View File
@@ -1,35 +1,191 @@
"""Title library routes — DEPRECATED (#1894).
独立标题库已废弃。前端应直接调用 GET /api/v1/scripts 获取文案列表,
取每条文案的 `title` 字段作为标题候选。
所有 /api/v1/titles 端点统一返回 HTTP 410 Gone。
"""
"""Title library CRUD routes."""
from __future__ import annotations
from fastapi import APIRouter, Response, status
from typing import Optional
from app.api.routes._helpers import get_user_plan
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_user_repository
from app.schemas.title_library import (
CreateTitleLibraryRequest,
ListTitleLibraryResponse,
TitleLibraryItemResponse,
UpdateTitleLibraryRequest,
)
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.title_library_repository import SQLAlchemyTitleLibraryRepository
from packages.application.title_library.commands import (
CreateTitleLibraryCommand,
PickTitleCommand,
UpdateTitleLibraryCommand,
)
from packages.application.title_library.use_cases import (
CreateTitleLibraryUseCase,
DeleteTitleLibraryUseCase,
GetTitleLibraryUseCase,
ListTitleLibraryUseCase,
NotFoundError,
PickTitleUseCase,
QuotaExceededError,
UpdateTitleLibraryUseCase,
)
from packages.ports.user_repository import UserRepository
router = APIRouter()
_GONE_MESSAGE = (
"标题库 API 已废弃(#1894):独立标题库已合并进文案库,"
"请使用 GET /api/v1/scripts 获取文案列表并取 title 字段作为标题。"
)
def _get_title_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyTitleLibraryRepository:
return SQLAlchemyTitleLibraryRepository(session)
def _gone(response: Response) -> dict:
response.status_code = status.HTTP_410_GONE
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = "Tue, 16 Sep 2026 00:00:00 GMT"
return {"error": {"code": "GONE", "message": _GONE_MESSAGE}}
def _to_response(item) -> TitleLibraryItemResponse:
return TitleLibraryItemResponse(
id=item.id,
user_id=item.user_id,
name=item.name,
text=item.text,
category=item.category,
description=item.description,
tags=item.tags,
usage_count=item.usage_count,
is_active=item.is_active,
created_at=item.created_at,
updated_at=item.updated_at,
)
@router.api_route("", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
def titles_root_gone(response: Response) -> dict:
return _gone(response)
@router.get("", response_model=ListTitleLibraryResponse)
def list_titles(
category: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
) -> ListTitleLibraryResponse:
user_id = authenticated_user.user.id
use_case = ListTitleLibraryUseCase(title_repository)
items = use_case.execute(user_id, category=category, skip=skip, limit=limit)
total = title_repository.count_by_user(user_id)
return ListTitleLibraryResponse(
items=[_to_response(i) for i in items],
total=total,
)
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
def titles_subpath_gone(response: Response, path: str) -> dict:
return _gone(response)
@router.post("/pick", response_model=TitleLibraryItemResponse)
def pick_title(
category: Optional[str] = Query(None, description="按分类筛选,不填则从全部标题中选"),
exclude_ids: Optional[str] = Query(
None,
description="排除的标题ID(逗号分隔),用于批量生成时避免重复",
),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
) -> TitleLibraryItemResponse:
"""智能选择一个标题。
策略:优先使用次数少的,从最少的前5个中随机选一个,兼顾公平和多样性。
"""
user_id = authenticated_user.user.id
exclude_list: list[str] = []
if exclude_ids:
exclude_list = [t.strip() for t in exclude_ids.split(",") if t.strip()]
use_case = PickTitleUseCase(title_repository)
item = use_case.execute(
PickTitleCommand(
user_id=user_id,
category=category,
exclude_ids=exclude_list,
)
)
if item is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="标题库为空,请先添加标题",
)
return _to_response(item)
@router.get("/{title_id}", response_model=TitleLibraryItemResponse)
def get_title(
title_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
) -> TitleLibraryItemResponse:
user_id = authenticated_user.user.id
use_case = GetTitleLibraryUseCase(title_repository)
item = use_case.execute(title_id, user_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
return _to_response(item)
@router.post("", response_model=TitleLibraryItemResponse, status_code=status.HTTP_201_CREATED)
def create_title(
request: CreateTitleLibraryRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
user_repository: UserRepository = Depends(get_user_repository),
) -> TitleLibraryItemResponse:
user_id = authenticated_user.user.id
plan_name = get_user_plan(user_id, user_repository)
command = CreateTitleLibraryCommand(
user_id=user_id,
name=request.name,
text=request.text,
category=request.category,
description=request.description,
tags=request.tags,
)
use_case = CreateTitleLibraryUseCase(title_repository)
try:
item = use_case.execute(command, plan_name=plan_name)
except QuotaExceededError as exc:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"标题库配额已满({exc.used}/{exc.limit}),请升级套餐",
) from exc
return _to_response(item)
@router.put("/{title_id}", response_model=TitleLibraryItemResponse)
def update_title(
title_id: str,
request: UpdateTitleLibraryRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
) -> TitleLibraryItemResponse:
user_id = authenticated_user.user.id
command = UpdateTitleLibraryCommand(
title_id=title_id,
user_id=user_id,
name=request.name,
text=request.text,
category=request.category,
description=request.description,
tags=request.tags,
)
use_case = UpdateTitleLibraryUseCase(title_repository)
try:
item = use_case.execute(command)
except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found") from _e
return _to_response(item)
@router.delete("/{title_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
def delete_title(
title_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
) -> Response:
user_id = authenticated_user.user.id
use_case = DeleteTitleLibraryUseCase(title_repository)
deleted = use_case.execute(title_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
return
-27
View File
@@ -57,7 +57,6 @@ class PointRuleItem(BaseModel):
base_points: int
unit: str
extra_per_30s: Optional[int] = None
description: str = Field(default="", description="规则中文说明,例如 AI 配音每分钟消耗 X 积分")
class PointsRulesResponse(BaseModel):
@@ -140,12 +139,7 @@ class PointsOrderResponse(BaseModel):
order_type: str
product_code: str
amount_cents: int
points_amount: int = Field(0, description="本次充值/购买可获得的积分(仅 points 类型订单有意义)")
status: str
pay_params: dict[str, Any] = Field(
default_factory=dict, description="拉起支付所需参数(payment_url/prepay_id 等),支付通道接入后填充"
)
expire_at: Optional[str] = Field(None, description="订单过期时间(ISO 8601),默认创建后 48 小时")
created_at: Optional[str] = None
@@ -177,27 +171,6 @@ class MembershipStatusResponse(BaseModel):
)
# ============ 订阅档位 ============
class MembershipPlanItem(BaseModel):
"""单个会员档位"""
plan_id: str = Field(..., description="档位标识: monthly/quarterly/yearly")
name: str = Field(..., description="档位名称,例如 月卡")
monthly_price_cents: int = Field(..., description="折算月价(分)")
price_cents: int = Field(..., description="该档位总价(分)")
duration_days: int = Field(..., description="时长(天)")
points_discount: float = Field(..., description="该档位积分折扣,如 0.9 表示 9 折")
features: dict[str, Any] = Field(default_factory=dict, description="档位权益(max_resolution 等)")
class MembershipPlansResponse(BaseModel):
"""所有会员档位列表"""
plans: list[MembershipPlanItem]
# ============ 通用响应 ============
+7 -14
View File
@@ -7,21 +7,15 @@ from typing import Optional
from pydantic import BaseModel, Field
# ============ Enums / Types ============
# 会员体系(#1951/#1955 实装):
# free — 免费用户
# monthly — 月卡
# quarterly — 季卡
# yearly — 年卡
# 已废弃档位:standard / pro / enterprise(保留常量名便于识别旧字段,但不在 API 中暴露)
class MembershipType(str):
"""会员类型(与 packages.domain.points_rules.MEMBERSHIP_PRICES 一致)"""
class PlanType(str):
"""套餐类型"""
FREE = "free"
MONTHLY = "monthly"
QUARTERLY = "quarterly"
YEARLY = "yearly"
STANDARD = "standard"
PRO = "pro"
ENTERPRISE = "enterprise"
class SubscriptionStatus(str):
@@ -46,7 +40,6 @@ class BillingCycle(str):
"""计费周期"""
MONTHLY = "monthly"
QUARTERLY = "quarterly"
YEARLY = "yearly"
@@ -102,8 +95,8 @@ class SimpleResponse(BaseModel):
class ChangePlanRequest(BaseModel):
"""升级/降级请求"""
target_plan_id: str = Field(..., description="目标会员类型: monthly/quarterly/yearly")
billing_cycle: str = Field(..., description="计费周期: monthly/quarterly/yearly")
target_plan_id: str = Field(..., description="目标套餐ID")
billing_cycle: str = Field(..., description="计费周期: monthly/yearly")
class ToggleAutoRenewRequest(BaseModel):
+70 -54
View File
@@ -51,7 +51,7 @@ type AssetListResponse = {
test.describe("Core generation flow", () => {
test.describe.configure({ timeout: 360_000 })
test("walks through wizard with count modal and starts generation", async ({ page, request }) => {
test("walks through 6-step wizard and starts generation", async ({ page, request }) => {
test.setTimeout(360_000)
await routeBrowserApiToTestApi(page)
@@ -125,7 +125,8 @@ test.describe("Core generation flow", () => {
)
.toBe("ready")
// GET /templates auto-creates a default template for new users
// #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 {
@@ -168,51 +169,57 @@ test.describe("Core generation flow", () => {
timeout: 20_000,
})
// 5步向导:素材(1)→配音(2)→标题(3)→确认生成(4)→封面(5)
// ── Step 1: 素材选择 ──
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible()
const librarySelect = page.locator("select").first()
await librarySelect.selectOption({ label: libraryName })
const materialCard = page.getByTestId("material-card").filter({ hasText: sourceFileName })
await expect(materialCard).toBeVisible({ timeout: 10_000 })
await materialCard.click({ position: { x: 15, y: 15 } })
await expect(materialCard.getByTestId("material-card-check")).toBeVisible({ timeout: 5_000 })
// Step 1: template - default selected, click next
await expect(page.locator(".xx-choice-item.selected")).toBeVisible()
await page.getByRole("button", { name: "下一步" }).click()
// ── 数量弹窗(PreviewCountModal ──
// Step1 下一步弹出数量选择弹窗(Issue #1677 固定6步:模板→素材→配音→标题→确认生成→封面)
// 单视频流程:默认 1 个,点击「生成 1 个视频」进入步骤2
await expect(page.getByRole("heading", { name: "要生成几个视频?" })).toBeVisible({
timeout: 5_000,
timeout: 10_000,
})
await page.getByRole("button", { name: "生成 1 个视频" }).click()
// ── Step 2: 配音(新注册用户无配音素材,跳过) ──
// Step 2: select material (card grid UI)
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible()
const librarySelect = page.locator("select").first()
await librarySelect.selectOption({ label: libraryName })
// 新 UI: 素材以 9:16 竖屏卡片展示,点击卡片选中
// 注意:卡片中心是播放按钮(stopPropagation 会阻止选中),所以点击左上角避开
const materialCard = page.getByTestId("material-card").filter({ hasText: sourceFileName })
await expect(materialCard).toBeVisible({ timeout: 10_000 })
await materialCard.click({ position: { x: 15, y: 15 } })
// 验证选中:卡片应出现勾选标记(用 testid 定位,避免 ✓ 字符文本匹配不稳定)
await expect(materialCard.getByTestId("material-card-check")).toBeVisible({ timeout: 5_000 })
await page.getByRole("button", { name: "下一步" }).click()
// Step 3: voice (可选步骤,新注册用户无配音素材,直接跳过)
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible({ timeout: 15000 })
await page.getByRole("button", { name: "下一步" }).click()
// ── Step 3: 标题设置 ──
// Step 4: title(新顺序:标题在预览之前)
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 })
// 等待组件完全渲染
await page.waitForTimeout(2000)
// Antd AutoComplete 的 placeholder 渲染在 span 上,input 无 placeholder 属性
// 使用 Antd AutoComplete 特有的 class 定位输入框
const titleInput = page.locator(".ant-select-auto-complete input")
await expect(titleInput).toBeVisible({ timeout: 5000 })
await titleInput.fill(`E2E Test ${suffix}`)
// Step 3 底部是「下一步 →」,点击进入 Step 4(确认生成)
await page.getByRole("button", { name: "下一步" }).click()
const titleText = `E2E Test ${suffix}`
await titleInput.fill(titleText)
// ── Step 4: 确认生成 ──
// 等待实时预览就绪(占位消失)
// Step 4(标题+实时预览):确认生成按钮已移到标题页,点击直接创建最终渲染任务
// 等待前端实时预览就绪:未就绪时右侧 FrontendPreviewPlayer 显示「准备预览素材...」占位,
// 就绪(previewReady:素材已解析 + 模板已选中)后占位消失;否则按钮会被校验拦截弹 warning
await page
.getByText("准备预览素材")
.waitFor({ state: "detached", timeout: 30_000 })
.catch(() => {})
// Step 4 底部是「✨ 确认生成视频」
const confirmBtn = page.locator(".xx-step-actions .xx-btn-primary").first()
await expect(confirmBtn).toBeVisible({ timeout: 15_000 })
// 先挂 API 监听再点击
// Wait for generation API to be called
// 前端直接创建生成任务:POST /generation/tasks
const generatePromise = page.waitForResponse(
(response) => {
const url = response.url()
@@ -222,51 +229,59 @@ test.describe("Core generation flow", () => {
{ timeout: 30_000 },
)
await confirmBtn.click()
// 点击「确认生成视频」
await page.locator(".xx-btn-primary").filter({ hasText: "确认生成视频" }).first().click()
// 验证生成 API 被调用
const genResp = await generatePromise.catch(() => null)
if (!genResp) {
// staging 预览未就绪导致按钮校验拦截,未触发 API — 向导导航仍通过
console.log(
"[E2E] Generation API not triggered (preview not ready) — wizard navigation verified",
// Verify generation was triggered
const genResp = await generatePromise
if (!genResp.ok()) {
const body = await genResp.text()
console.error(
`[E2E DEBUG] 触发生成接口失败: status=${genResp.status()} url=${genResp.url()} body=${body.slice(0, 500)}`,
)
} else if (genResp.ok()) {
}
// Generate API may return 400 in test env if template has no ready segments
// That is OK for a wizard flow smoke test
if (genResp.ok()) {
const genData = (await genResp.json()) as {
items: Array<{ id: string; status: string }>
total: number
}
expect(genData.items.length).toBeGreaterThan(0)
expect(genData.items[0].id).toBeTruthy()
// race:渲染完成 vs 生成失败/超时
const downloadReady = page
.getByText("视频生成完成")
.isVisible({ timeout: 180_000 })
.then((v) => (v ? "completed" : null))
const generationFailed = page
.getByText(/生成失败|重新生成/)
.isVisible({ timeout: 180_000 })
.then((v) => (v ? "failed" : null))
// 单视频(N=1):点击「确认生成视频」后跳 Step 5「确认生成」,展示实时渲染进度
await expect(page.getByRole("heading", { name: "🎬 确认生成" })).toBeVisible({
timeout: 30_000,
})
const outcome = await Promise.any([downloadReady, generationFailed]).catch(() => "timeout")
// 等待渲染完成:进度卡变为「视频生成完成」(最长等待 3 分钟)
await expect(page.getByText("视频生成完成")).toBeVisible({ timeout: 180_000 })
if (outcome === "completed") {
await page.getByRole("button", { name: /下一步:选择封面/ }).click()
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({
timeout: 30_000,
})
} else {
console.log(`[E2E] Video rendering ${outcome} on staging — wizard flow verified`)
}
// 全部完成后「下一步:选择封面」解锁,点击进入 Step 6
await page.getByRole("button", { name: /下一步:选择封面/ }).click()
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({
timeout: 30_000,
})
} else {
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
// 创建失败时停留在标题页并展示错误提示
await page
.getByText(/生成失败|重新生成/)
.isVisible({ timeout: 15_000 })
.catch(() => false)
}
// 验证成品库页面加载
// Verify product library page loads (smoke: just verify page renders)
await page.goto("/app/products")
await expect(page).toHaveURL(/\/app\/products/)
await expect(page.locator(".xx-products-page")).toBeVisible({ timeout: 15_000 })
// Verify page container exists = page rendered correctly
// (works in all states: loading/error/success - more reliable than checking search input)
await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 15_000,
})
// 清理所有路由,避免页面关闭时飞地API请求导致测试报错
await page.unrouteAll({ behavior: "ignoreErrors" })
})
@@ -291,6 +306,7 @@ test.describe("Core generation flow", () => {
})
expect(project.status()).toBe(200)
// List generation tasks via task center API
const tasks = await request.get(`${apiBase}/tasks`, { headers })
expect(tasks.status()).toBe(200)
const tasksData = await tasks.json()
+419 -287
View File
@@ -1,345 +1,477 @@
/**
* 积分系统 API 封装
* 对齐后端 staging 实测最终契约(2026-09-16
*
* 当前 POINTS_API_MOCK=true:使用 MOCK_* 常量 + setTimeout 模拟延迟,
* 等后端 P0(支付通道接入、change-plan 校验)稳定后切 false 联调。
*
* 会员/订阅 API 在 @/api/subscription 中定义,避免重复封装。
* 积分 & 会员 API 封装v2 两档会员制)
* 后端接口未就绪阶段使用 mock 数据;接口就绪后把 MOCK 开关关掉即可。
*/
import apiClient from "../client"
import type {
PointsBalance,
PointsRulesResponse,
PointsPackagesResponse,
PointsTransaction,
PointsTransactionsParams,
PointsTransactionsResponse,
PointsPackagesResponse,
PointsRechargeRequest,
PointsOrder,
PointsRulesResponse,
PointsCheckRequest,
PointsCheckResponse,
CreateRechargeOrderRequest,
CreateRechargeOrderResponse,
DailyUsage,
MembershipResponse,
SubscriptionCurrent,
SubscribeRequest,
SubscriptionPlan,
} from "./types"
/** 模拟网络延迟(ms */
const MOCK_DELAY = 500
/**
* 是否启用 mock 数据(后端 PR 合入前为 true;对接真实接口后改为 false)
*/
export const POINTS_API_MOCK = true
/* ================================================================
* Mock 数据
* ================================================================ */
/** mock 余额(无 free_clips_* 字段,已拆分到 dailyUsage */
// ==================== Mock 数据 ====================
const MOCK_BALANCE: PointsBalance = {
balance: 258,
total_earned: 500,
total_spent: 242,
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",
name: "AI 配音",
base_points: 2,
unit: "",
description: "单次配音消耗 2 积分,超 30 秒每 30 秒 +1 积分",
extra_per_30s: 1,
scene_name: "AI 配音",
points_per_use: 1,
unit: "分钟",
description: "每生成 1 分钟配音",
},
{
scene_key: "ai_video",
name: "AI 视频生成",
base_points: 8,
scene_name: "智能混剪",
points_per_use: 3,
unit: "条",
description: "单条视频 8 积分起,按视频时长加收",
extra_per_30s: 3,
extra_per_30s: 1,
description: "每条 ≤30s 3 积分,每加 30s +1",
},
{
scene_key: "ai_digital_human",
name: "AI 数字人",
base_points: 15,
unit: "",
description: "数字人生成 15 积分起",
extra_per_30s: 5,
scene_name: "AI 数字人",
points_per_use: 15,
unit: "分钟",
description: "生成 1 分钟口播",
},
{
scene_key: "voice_clone_train",
name: "声音克隆训练",
base_points: 20,
scene_name: "声音克隆训练",
points_per_use: 0,
unit: "次",
description: "声音模型训练一次性消耗 20 积分",
},
{
scene_key: "voice_clone_synth",
name: "声音克隆合成",
base_points: 3,
unit: "次",
description: "使用克隆声音合成音频每次 3 积分",
},
{
scene_key: "douyin_extract",
name: "抖音文案提取",
base_points: 1,
unit: "次",
description: "提取抖音视频文案每次 1 积分",
},
{
scene_key: "ai_rewrite",
name: "AI 文案改写",
base_points: 2,
unit: "次",
description: "AI 改写文案每次 2 积分",
},
{
scene_key: "ai_title",
name: "AI 标题生成",
base_points: 1,
unit: "次",
description: "AI 生成标题每次 1 积分,一次生成多条",
},
{
scene_key: "ai_cover",
name: "AI 封面生成",
base_points: 3,
unit: "次",
description: "AI 生成封面每次 3 积分",
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,向上取整",
}
const MOCK_PACKAGES: PointsPackagesResponse = {
packages: [
{ code: "points_100", name: "100 积分", points: 100, price_cents: 990, unit_price: 0.099 },
{ code: "points_500", name: "500 积分", points: 500, price_cents: 4490, unit_price: 0.0898 },
{ code: "points_1000", name: "1000 积分", points: 1000, price_cents: 7990, unit_price: 0.0799 },
function genMockTransactions(): PointsTransactionsResponse {
const now = new Date()
const list = [
{
code: "points_3000",
name: "3000 积分",
points: 3000,
price_cents: 19900,
unit_price: 0.0663,
src: "ai_voice",
name: "AI 配音",
type: "spend" as const,
amt: 1,
desc: "生成配音 1 分钟",
days: 0,
hours: 0,
mins: 30,
},
],
user_discount: null,
{
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
}
const MOCK_TRANSACTIONS: PointsTransaction[] = [
// ==================== 真实 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: 1,
type: "deduct",
source: "ai_video",
amount: 10,
balance_after: 248,
description: "AI 视频生成 ×1(非会员倍率)",
ref_id: "task_abc123",
created_at: "2026-09-16T08:30:00Z",
id: "monthly",
name: "月卡",
price_cents: 1990,
price_yuan: 19.9,
per_month_yuan: 19.9,
billing_label: "/月",
},
{
id: 2,
type: "add",
source: "recharge",
amount: 100,
balance_after: 258,
description: "充值 100 积分",
ref_id: "order_xyz789",
created_at: "2026-09-15T14:20:00Z",
id: "quarterly",
name: "季卡",
price_cents: 3990,
price_yuan: 39.9,
per_month_yuan: 13.3,
savings_percent: 33,
recommended: true,
billing_label: "/季",
},
{
id: 3,
type: "deduct",
source: "ai_voice",
amount: 3,
balance_after: 158,
description: "AI 配音 ×145s 加收)",
ref_id: "",
created_at: "2026-09-15T10:15:00Z",
},
{
id: 4,
type: "add",
source: "sign_up",
amount: 60,
balance_after: 161,
description: "新用户注册赠送",
ref_id: "",
created_at: "2026-09-10T09:00:00Z",
},
{
id: 5,
type: "deduct",
source: "ai_title",
amount: 1,
balance_after: 101,
description: "AI 标题生成 ×1",
ref_id: "",
created_at: "2026-09-14T16:45:00Z",
id: "yearly",
name: "年卡",
price_cents: 15900,
price_yuan: 159,
per_month_yuan: 13.25,
savings_percent: 33,
billing_label: "/年",
},
]
const MOCK_DAILY_USAGE: DailyUsage = {
free_clips_used: 1,
free_clips_limit: 3,
free_clips_remaining: 2,
reset_at: new Date(Date.now() + 8 * 3600_000).toISOString(),
}
const MOCK_MEMBERSHIP: MembershipResponse = {
is_member: false,
member_type: null,
member_expires_at: null,
points_balance: 258,
max_resolution: "720p",
}
/* ================================================================
* 积分 API
* ================================================================ */
/** 获取积分余额 */
export async function getPointsBalance(): Promise<PointsBalance> {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { ...MOCK_BALANCE }
/** 查询当前订阅 */
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 { data } = await apiClient.get(`/points/balance`)
return data
const res = await apiClient.get("/subscription/current")
return res.data
}
/** 获取积分消耗规则 */
export async function getPointsRules(): Promise<PointsRulesResponse> {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { rules: [...MOCK_RULES.rules], free_user_multiplier: MOCK_RULES.free_user_multiplier }
/** 开通/续费会员 */
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 { data } = await apiClient.get(`/points/rules`)
return data
const res = await apiClient.post("/subscription/subscribe", req)
return res.data
}
/** 获取充值包列表 */
export async function getPointsPackages(): Promise<PointsPackagesResponse> {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { packages: MOCK_PACKAGES.packages.map((p) => ({ ...p })), user_discount: null }
/** 取消自动续费 */
export async function cancelAutoRenew(): Promise<{ success: boolean; message: string }> {
if (POINTS_API_MOCK) {
return new Promise((r) =>
setTimeout(() => r({ success: true, message: "已取消自动续费" }), 200),
)
}
const { data } = await apiClient.get(`/points/packages`)
return data
}
/**
* 获取积分流水(分页)
*/
export async function getPointsTransactions(
page = 1,
pageSize = 20,
): Promise<PointsTransactionsResponse> {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
const start = (page - 1) * pageSize
const items = MOCK_TRANSACTIONS.slice(start, start + pageSize)
return {
items: items.map((t) => ({ ...t })),
total: MOCK_TRANSACTIONS.length,
page,
page_size: pageSize,
}
}
const { data } = await apiClient.get(`/points/transactions`, {
params: { page, page_size: pageSize },
})
return data
}
/**
* 创建充值订单
* 注意:当前 pay_params 返回空对象 {}(支付通道未接入),
* 前端可以完成订单创建 UI,但无法发起真实支付,待后续支付通道接入后联调。
*/
export async function createPointsOrder(
data: CreateRechargeOrderRequest,
): Promise<CreateRechargeOrderResponse> {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY * 2))
const pkg = MOCK_PACKAGES.packages.find((p) => p.code === data.package_id)
if (!pkg) throw new Error("充值包不存在")
return {
id: `mock_order_${Date.now()}`,
order_type: "points_recharge",
product_code: pkg.code,
amount_cents: pkg.price_cents,
points_amount: pkg.points,
status: "pending",
pay_params: {},
expire_at: new Date(Date.now() + 30 * 60_000).toISOString(),
created_at: new Date().toISOString(),
}
}
const { data: d } = await apiClient.post(`/points/recharge`, data)
return d
}
/**
* 积分预检查(消耗前调用)
*/
export async function checkPoints(data: PointsCheckRequest): Promise<PointsCheckResponse> {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
const rule = MOCK_RULES.rules.find((r) => r.scene_key === data.scene_key)
if (!rule) {
throw {
error: {
code: 400,
message: `未知场景:${data.scene_key}`,
valid_scenes: MOCK_RULES.rules.map((r) => r.scene_key),
},
}
}
const durationExtra =
data.duration_minutes && data.duration_minutes > 0.5 && rule.extra_per_30s
? Math.ceil((data.duration_minutes * 60 - 30) / 30) * rule.extra_per_30s
: 0
const base = (rule.base_points + durationExtra) * data.quantity
const balance = MOCK_BALANCE.balance
const multiplier = MOCK_BALANCE.is_member ? 1 : MOCK_RULES.free_user_multiplier
const required = Math.ceil(base * multiplier)
// 免费额度抵扣
const isFreeQuota = !MOCK_BALANCE.is_member && MOCK_DAILY_USAGE.free_clips_remaining > 0
const finalRequired = isFreeQuota ? 0 : required
return {
allowed: balance >= finalRequired,
required_points: finalRequired,
current_balance: balance,
remaining_after: balance - finalRequired,
is_free_quota: isFreeQuota,
}
}
const { data: d2 } = await apiClient.post(`/points/check`, data)
return d2
}
/* ================================================================
* 每日免费额度 + 会员聚合信息(新接口)
* ================================================================ */
/** 获取每日免费额度使用情况 */
export async function getDailyUsage(): Promise<DailyUsage> {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { ...MOCK_DAILY_USAGE }
}
const { data } = await apiClient.get(`/usage/daily`)
return data
}
/** 获取会员聚合信息(创作页可用来判断 max_resolution */
export async function getMembership(): Promise<MembershipResponse> {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { ...MOCK_MEMBERSHIP }
}
const { data } = await apiClient.get(`/points/subscription/membership`)
return data
const res = await apiClient.post("/subscription/cancel")
return res.data
}
+165 -175
View File
@@ -1,134 +1,76 @@
/**
* 积分系统类型定义
* 对齐后端 staging 实测最终契约(2026-09-16
*
* Base path: /api/v1/
* 会员/订阅相关类型请从 @/api/subscription/types 引入,本文件仅保留积分核心类型。
* 积分 & 会员系统 API 类型定义(v2 两档会员制)
* 接口契约对齐后端设计文档 membership-points-design-v2.md
*/
/* ================================================================
* 场景键
* ================================================================ */
/**
* 积分消耗场景键(9 个)
* - ai_script 已拆分为 douyin_extract / ai_rewrite / ai_title,前端禁止再传 ai_script
*/
/** 会员类型 */
export type MemberType = "free" | "monthly" | "quarterly" | "yearly"
/** 积分流水类型 */
export type PointsTxType = "earn" | "spend" | "refund"
/** 积分来源/消耗场景 */
export type PointsSource =
| "recharge" // 充值
| "task_reward" // 任务奖励
| "ai_voice" // AI 配音
| "ai_video" // AI 视频生成
| "ai_digital_human" // AI 数字人
| "ai_video" // 智能混剪
| "voice_clone_train" // 声音克隆训练
| "voice_clone_synth" // 声音克隆合成
| "douyin_extract" // 抖音提取文案
| "ai_rewrite" // AI 文案改写
| "douyin_extract" // 抖音链接提取
| "ai_rewrite" // AI 改写文案
| "ai_title" // AI 标题生成
| "ai_cover" // AI 封面生成
| "subscription_bonus" // 会员赠送
| "admin_adjust" // 管理员调整
| "refund" // 失败退还
/** 非消耗场景 source 前缀(用于流水 source 字段) */
export type PointsSourceExtra =
PointsSource | `refund:${string}` | "recharge" | "sign_up" | "bind_phone" | "gift" | "admin"
/* ================================================================
* 通用
* ================================================================ */
/** ISO 8601 时间字符串 */
export type ISODate = string
/* ================================================================
* 积分余额(GET /points/balance
* ================================================================ */
/** 会员 & 积分余额响应 */
export interface PointsBalance {
/** 当前可用积分 */
balance: number
/** 累计获得积分 */
/** 累计获得 */
total_earned: number
/** 累计消耗积分 */
/** 累计消耗 */
total_spent: number
/** 是否付费会员 */
/** 是否付费会员free 用户为 false */
is_member: boolean
/** 会员类型monthly/quarterly/yearly,非会员 null)。推荐使用 /subscription/current 的 plan_id+billing_cycle 做判断 */
member_type: "monthly" | "quarterly" | "yearly" | null
/** 会员到期时间 */
member_expires_at: ISODate | null
/** 会员类型monthly / quarterly / yearlyfree 用户为 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
}
/* ================================================================
* 积分规则(GET /points/rules
* ================================================================ */
export interface PointsRule {
scene_key: PointsSource
/** 场景中文名 */
name: string
/** 基准消耗积分(points_per_use 改名) */
base_points: number
/** 单位描述,如「次」「分钟」「个」 */
unit: string
/** 超过30秒后每30秒额外积分(视频/语音类) */
extra_per_30s?: number
/** 场景说明(后端已补回) */
description?: string
}
export interface PointsRulesResponse {
rules: PointsRule[]
/** 非会员消耗倍率(如 1.15) */
free_user_multiplier: number
}
/* ================================================================
* 充值包(GET /points/packages
* ================================================================ */
export interface PointsPackage {
/** 包编码(id 改名) */
code: string
name: string
points: number
/** 原价,单位分 */
price_cents: number
/** 每积分单价(元),展示用 */
unit_price: number
}
export interface PointsPackagesResponse {
packages: PointsPackage[]
/** 当前用户折扣(会员折扣或活动折扣),null 表示无折扣 */
user_discount: number | null
}
/**
* 充值包前端展示辅助:折后价(分)
* 后端废弃 4 档 discounted_price_for_*,前端按 price_cents * (user_discount ?? 1) 计算。
*/
export function getDiscountPriceCents(pkg: PointsPackage, userDiscount: number | null): number {
return Math.round(pkg.price_cents * (userDiscount ?? 1))
}
/* ================================================================
* 积分流水(GET /points/transactions
* ================================================================ */
export type PointsTxType = "add" | "deduct"
/** 积分流水记录 */
export interface PointsTransaction {
id: number
/** 流水类型:add=获得/退款,deduct=消耗 */
id: string
/** earn / spend / refund */
type: PointsTxType
/**
* 消耗/获得来源:
* - 消耗场景直接用 PointsSource 值
* - 充值/退款/赠送使用 recharge / refund:<source> / sign_up / bind_phone / gift / admin
*/
source: string
/** 变动数量(绝对值,正负由 type 决定) */
/** 来源场景 */
source: PointsSource
/** 场景中文名称 */
source_name: string
/** 变动数量(正数) */
amount: number
/** 带符号的变动数(收入+,支出-) */
signed_amount: number
/** 变动后余额 */
balance_after: number
/** 中文描述 */
/** 备注描述 */
description: string
/** 关联订单/任务 ID,空字符串 "" 表示无关联(不是 null */
ref_id: string
created_at: ISODate
/** 关联业务 ID */
ref_id: string | null
created_at: string
}
/** 积分流水分页响应 */
export interface PointsTransactionsResponse {
items: PointsTransaction[]
total: number
@@ -136,92 +78,140 @@ export interface PointsTransactionsResponse {
page_size: number
}
/* ================================================================
* 创建充值订单(POST /points/recharge
* ================================================================ */
export interface CreateRechargeOrderRequest {
/** 充值包 code(字段名保留 package_id 与后端一致) */
/** 积分流水查询参数 */
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 interface CreateRechargeOrderResponse {
/** 订单状态 */
export type OrderStatus = "pending" | "paid" | "failed" | "refunded" | "expired"
/** 充值订单响应 */
export interface PointsOrder {
id: string
order_type: string
product_code: string
/** 订单金额(分) */
amount_cents: number
/** 充值积分数量 */
package_id: string
package_name: string
points_amount: number
status: string
/**
* 支付参数(支付通道未接入时返回空对象 {},前端可透传)
*/
pay_params: Record<string, unknown>
/** 订单过期时间 */
expire_at: ISODate
created_at: ISODate
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>
}
/* ================================================================
* 积分预检查(POST /points/check
* ================================================================ */
/** 订阅套餐(月/季/年) */
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
/** 数量(units 改名) */
quantity: number
/** 预计时长(分钟),可选 */
duration_minutes?: number
/** 单位数量(时长/条数),默认 1 */
units?: number
}
/** 消费前余额检查响应 */
export interface PointsCheckResponse {
/** 是否可以执行 */
allowed: boolean
/** 需要消耗积分 */
required_points: number
/** 当前余额 */
current_balance: number
/** 扣除后剩余 */
remaining_after: number
/** 是否走免费额度 */
/** 是否走免费额度(混剪场景) */
is_free_quota: boolean
}
/* ================================================================
* 每日使用情况(GET /usage/daily,新接口)
* ================================================================ */
export interface DailyUsage {
/** 今日已用免费次数 */
free_clips_used: number
/** 每日免费次数上限 */
free_clips_limit: number
/** 今日剩余免费次数 */
free_clips_remaining: number
/** 额度重置时间 */
reset_at: ISODate
}
/* ================================================================
* 会员聚合信息(GET /points/subscription/membership,新接口)
* ================================================================ */
export interface MembershipResponse {
is_member: boolean
/** 会员类型(monthly/quarterly/yearly,非会员 null */
member_type: "monthly" | "quarterly" | "yearly" | null
member_expires_at: ISODate | null
/** 当前积分余额(冗余,可与 balance 互校) */
points_balance: number
/** 最大分辨率,如 "720p" / "1080p" / "4k" */
max_resolution: string
}
/* ================================================================
* 错误响应(统一格式 {error:{code,message}}
* ================================================================ */
export interface ApiError {
error: {
code: number
message: string
/** 部分场景会返回,如 unknown scene_key */
valid_scenes?: PointsSource[]
}
/** 拒绝原因代码 */
code?: "INSUFFICIENT_POINTS" | "FREE_QUOTA_EXCEEDED" | "SCENE_NOT_FOUND"
message?: string
/** 充值页跳转 URL */
recharge_url?: string
}
+2 -6
View File
@@ -11,12 +11,8 @@ import type {
ScriptCategory,
} from "./types"
/**
* 是否启用 mock。
* #1894:文案库接口已上线,默认 false 走真实 API;
* 通过 SCRIPTS_API_MOCK=true 环境变量可本地开启 mock 调试(行为同 POINTS_API_MOCK)。
*/
export const SCRIPTS_API_MOCK = (process.env.SCRIPTS_API_MOCK as string | undefined) === "true"
/** 是否启用 mock(后端合入后改为 false */
export const SCRIPTS_API_MOCK = true
// ==================== Mock 数据 ====================
+2 -8
View File
@@ -1,30 +1,24 @@
/**
* 订阅 API — 目录化入口
* 对齐后端 staging 最终契约(2026-09-16
* 保持与原 subscription.ts 相同导出,向后兼容
*/
// 类型
export type {
PlanId,
PlanType,
SubscriptionStatus,
BillingStatus,
BillingCycle,
Plan,
SubscriptionInfo,
SubscriptionPlan,
SubscriptionPlansResponse,
BillingRecord,
ChangePlanRequest,
ChangePlanResponse,
ToggleAutoRenewRequest,
} from "./types"
export { PLAN_LABEL, BILLING_CYCLE_LABEL } from "./types"
// API 函数
export {
getCurrentSubscription,
getSubscriptionPlans,
getBillingRecords,
changePlan,
cancelSubscription,
+22 -129
View File
@@ -1,154 +1,47 @@
/**
* 订阅/会员 API 封装
* 对齐后端 staging 实测最终契约(2026-09-16
*
* Base path: /api/v1/
* 所有请求走 apiClient(已配置 baseURL=/api/v1 和 token 拦截器)。
* 订阅相关 API 函数
*/
import apiClient from "../client"
import type {
SubscriptionInfo,
SubscriptionPlan,
SubscriptionPlansResponse,
BillingRecord,
ChangePlanRequest,
ChangePlanResponse,
ToggleAutoRenewRequest,
SubscriptionInfo,
} from "./types"
const MOCK_DELAY = 500
const MOCK_SUBSCRIPTION: SubscriptionInfo = {
id: "sub_mock_001",
plan_id: "free",
plan_name: "免费版",
status: "active",
billing_cycle: "monthly",
current_period_start: new Date(Date.now() - 30 * 86400_000).toISOString(),
current_period_end: new Date(Date.now() + 30 * 86400_000).toISOString(),
amount: 0,
auto_renew: false,
created_at: new Date(Date.now() - 30 * 86400_000).toISOString(),
}
const MOCK_PLANS: SubscriptionPlan[] = [
{
plan_id: "free",
name: "免费版",
price_cents: 0,
monthly_price_cents: 0,
duration_days: 0,
points_discount: 1,
features: { max_resolution: "720p", free_clips_daily: 3 },
},
{
plan_id: "monthly",
name: "月度会员",
price_cents: 1990,
monthly_price_cents: 1990,
duration_days: 30,
points_discount: 0.9,
features: { max_resolution: "1080p", free_clips_daily: 10 },
},
{
plan_id: "quarterly",
name: "季度会员",
price_cents: 3990,
monthly_price_cents: 1330,
duration_days: 90,
points_discount: 0.85,
features: { max_resolution: "1080p", free_clips_daily: 15 },
},
{
plan_id: "yearly",
name: "年度会员",
price_cents: 15900,
monthly_price_cents: 1325,
duration_days: 365,
points_discount: 0.8,
features: { max_resolution: "4k", free_clips_daily: 30 },
},
]
const MOCK_BILLING: BillingRecord[] = []
const isMock = () => (process.env.POINTS_API_MOCK as string | undefined) === "true"
/** 获取当前订阅 */
/** 获取当前订阅信息 */
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
if (isMock()) {
await new Promise((r) => setTimeout(r, MOCK_DELAY))
return { ...MOCK_SUBSCRIPTION }
}
const { data } = await apiClient.get("/subscription/current")
return data
const response = await apiClient.get("/subscription/current")
return response.data
}
/** 获取所有订阅档位 */
export const getSubscriptionPlans = async (): Promise<SubscriptionPlansResponse> => {
if (isMock()) {
await new Promise((r) => setTimeout(r, MOCK_DELAY))
return { plans: MOCK_PLANS.map((p) => ({ ...p, features: { ...p.features } })) }
}
const { data } = await apiClient.get("/subscription/plans")
return data
}
/** 获取账单记录 */
/** 获取账单记录列表 */
export const getBillingRecords = async (): Promise<BillingRecord[]> => {
if (isMock()) {
await new Promise((r) => setTimeout(r, MOCK_DELAY))
return MOCK_BILLING.map((r) => ({ ...r }))
}
const { data } = await apiClient.get("/subscription/billing-records")
return data
const response = await apiClient.get("/subscription/billing-records")
return response.data
}
/** 升级/降级套餐 */
export const changePlan = async (request: ChangePlanRequest): Promise<ChangePlanResponse> => {
if (isMock()) {
await new Promise((r) => setTimeout(r, MOCK_DELAY * 2))
const plan = MOCK_PLANS.find((p) => p.plan_id === request.target_plan_id)
if (!plan) return { success: false, message: "套餐不存在" }
const newSub: SubscriptionInfo = {
...MOCK_SUBSCRIPTION,
plan_id: plan.plan_id,
plan_name: plan.name,
billing_cycle: request.billing_cycle,
amount: plan.price_cents,
status: "pending",
current_period_start: new Date().toISOString(),
current_period_end: new Date(Date.now() + plan.duration_days * 86400_000).toISOString(),
auto_renew: true,
}
return {
success: true,
message: "订阅变更成功(mock,支付通道待接入)",
new_subscription: newSub,
}
}
const { data } = await apiClient.post("/subscription/change-plan", request)
return data
const response = await apiClient.post("/subscription/change-plan", request)
return response.data
}
/** 取消订阅(到期后失效) */
export const cancelSubscription = async (): Promise<{ success: boolean; message: string }> => {
if (isMock()) {
await new Promise((r) => setTimeout(r, MOCK_DELAY))
return { success: true, message: "已取消订阅,到期后将不再续费" }
}
const { data } = await apiClient.post("/subscription/cancel")
return data
/** 取消订阅 */
export const cancelSubscription = async (): Promise<{
success: boolean
message: string
}> => {
const response = await apiClient.post("/subscription/cancel")
return response.data
}
/** 切换自动续费 */
export const toggleAutoRenew = async (
req: ToggleAutoRenewRequest,
enabled: boolean,
): Promise<{ success: boolean; message: string }> => {
if (isMock()) {
await new Promise((r) => setTimeout(r, MOCK_DELAY))
return { success: true, message: req.enabled ? "已开启自动续费" : "已关闭自动续费" }
}
const { data } = await apiClient.post("/subscription/toggle-auto-renew", req)
return data
const response = await apiClient.post("/subscription/toggle-auto-renew", {
enabled,
})
return response.data
}
+29 -79
View File
@@ -1,115 +1,65 @@
/**
* 订阅/会员类型定义
* 对齐后端 staging 实测最终契约(2026-09-16
*
* Base path: /api/v1/
* 订阅相关类型定义
*/
/** 订阅计划 ID */
export type PlanId = "free" | "monthly" | "quarterly" | "yearly"
/** 计费周期 */
export type BillingCycle = "monthly" | "yearly"
/** 套餐类型 */
export type PlanType = "free" | "standard" | "pro" | "enterprise"
/** 订阅状态 */
export type SubscriptionStatus = "active" | "expired" | "cancelled" | "pending"
export type SubscriptionStatus = "active" | "expired" | "cancelled" | "trial"
/** 账单状态 */
export type BillingStatus = "paid" | "pending" | "failed" | "refunded"
/* ================================================================
* 当前订阅(GET /subscription/current
* ================================================================ */
/** 计费周期 */
export type BillingCycle = "monthly" | "yearly"
/** 套餐信息 */
export interface Plan {
id: PlanType
name: string
price: number | null
yearly_price?: number | null
description: string
recommended: boolean
features: string[]
}
/** 当前订阅信息 */
export interface SubscriptionInfo {
id: string
plan_id: PlanId
plan_id: PlanType
plan_name: string
status: SubscriptionStatus
/** 当前计费周期:monthly 对月卡/季卡按自然月续费;yearly 对年卡 */
billing_cycle: BillingCycle
current_period_start: string
current_period_end: string
/** 本期金额(分) */
amount: number
auto_renew: boolean
created_at: string
}
/* ================================================================
* 订阅计划(GET /subscription/plans
* ================================================================ */
export interface SubscriptionPlan {
plan_id: PlanId
/** 中文名 */
name: string
/** 价格(分),年卡/季卡为总价 */
price_cents: number
/** 折算月价(分),对比用 */
monthly_price_cents: number
/** 时长(天) */
duration_days: number
/** 积分折扣(0.9 = 9折,1 = 无折扣) */
points_discount: number
features: {
max_resolution: string
free_clips_daily: number
[key: string]: unknown
}
}
export interface SubscriptionPlansResponse {
plans: SubscriptionPlan[]
}
/* ================================================================
* 账单(GET /subscription/billing-records
* ================================================================ */
/** 账单记录 */
export interface BillingRecord {
id: string
/** 订单类型:subscribe/renew/upgrade/refund */
order_type: string
plan_id: PlanId
/** 金额(分) */
amount_cents: number
plan_name: string
amount: number
billing_cycle: BillingCycle
status: BillingStatus
payment_method: string
created_at: string
paid_at?: string
invoice_url?: string
}
/* ================================================================
* 变更/取消/开关自动续费
* ================================================================ */
/** 升级/降级请求 */
export interface ChangePlanRequest {
target_plan_id: PlanId
target_plan_id: PlanType
billing_cycle: BillingCycle
}
/** 升级/降级响应 */
export interface ChangePlanResponse {
success: boolean
message: string
new_subscription?: SubscriptionInfo
}
export interface ToggleAutoRenewRequest {
enabled: boolean
}
/* ================================================================
* 中文标签映射
* ================================================================ */
export const PLAN_LABEL: Record<PlanId, string> = {
free: "免费版",
monthly: "月度会员",
quarterly: "季度会员",
yearly: "年度会员",
}
export const BILLING_CYCLE_LABEL: Record<BillingCycle, string> = {
monthly: "月付",
yearly: "年付",
}
/**
* @deprecated 旧命名保留别名,新代码请直接用 PlanId
*/
export type PlanType = PlanId
+19
View File
@@ -0,0 +1,19 @@
/**
* 标题相关 API — 目录化入口
* 保持与原 titles.ts 相同导出,向后兼容
*/
// 类型
export type {
TitleItem,
BackendTitleResponse,
BackendCreateTitleRequest,
BackendUpdateTitleRequest,
CreateTitleRequest,
} from "./types"
// 工具函数
export { toTitleItem } from "./utils"
// API 函数
export { getTitles, createTitle, updateTitle, deleteTitle, batchImportTitles } from "./titles"
+65
View File
@@ -0,0 +1,65 @@
/**
* 标题相关 API 函数
* Phase 1 新增:全局标题库
* 注意:后端 schema 使用 name + text 字段,前端 UI 用 content 展示
*/
import apiClient from "../client"
import type {
BackendCreateTitleRequest,
BackendTitleResponse,
BackendUpdateTitleRequest,
CreateTitleRequest,
TitleItem,
} from "./types"
import { toTitleItem } from "./utils"
/** 获取当前用户的所有标题 */
export const getTitles = async (): Promise<TitleItem[]> => {
const response = await apiClient.get<{ items: BackendTitleResponse[] } | BackendTitleResponse[]>(
"/titles",
)
// 兼容两种后端返回格式:{ items: [...] } 或直接 [...]
const items = Array.isArray(response.data) ? response.data : response.data.items || []
return items.map(toTitleItem)
}
/** 创建标题 */
export const createTitle = async (data: CreateTitleRequest): Promise<TitleItem> => {
// 后端要求 name(≤255)和 text(≤500),name 从 content 截取
const payload: BackendCreateTitleRequest = {
name: data.content.slice(0, 255),
text: data.content.slice(0, 500),
category: data.category || "default",
}
const response = await apiClient.post<BackendTitleResponse>("/titles", payload)
return toTitleItem(response.data)
}
/** 更新标题 */
export const updateTitle = async (
titleId: string,
data: Partial<CreateTitleRequest>,
): Promise<TitleItem> => {
const payload: BackendUpdateTitleRequest = {}
if (data.content !== undefined) {
payload.name = data.content.slice(0, 255)
payload.text = data.content.slice(0, 500)
}
if (data.category !== undefined) {
payload.category = data.category
}
// 后端用 PUT,非 PATCH
const response = await apiClient.put<BackendTitleResponse>(`/titles/${titleId}`, payload)
return toTitleItem(response.data)
}
/** 删除标题 */
export const deleteTitle = async (titleId: string): Promise<void> => {
await apiClient.delete(`/titles/${titleId}`)
}
/** 批量导入标题 */
export const batchImportTitles = async (titles: string[]): Promise<{ imported_count: number }> => {
const response = await apiClient.post("/titles/batch-import", { titles })
return response.data
}
+54
View File
@@ -0,0 +1,54 @@
/**
* 标题相关类型定义
*/
/** 标题条目(前端展示用) */
export interface TitleItem {
id: string
content: string
category?: string
source?: string
word_count?: number
is_favorite?: boolean
created_at?: string
updated_at?: string
}
/** 后端标题响应格式 */
export interface BackendTitleResponse {
id: string
user_id: string
name: string
text: string
category: string
description: string
tags: string[]
usage_count: number
is_active: boolean
created_at: string
updated_at: string
}
/** 后端创建标题请求格式 */
export interface BackendCreateTitleRequest {
name: string
text: string
category: string
description?: string
tags?: string[]
}
/** 后端更新标题请求格式 */
export interface BackendUpdateTitleRequest {
name?: string
text?: string
category?: string
description?: string
tags?: string[]
}
/** 创建标题请求(前端接口,保持向后兼容) */
export interface CreateTitleRequest {
content: string
category?: string
}
+14
View File
@@ -0,0 +1,14 @@
/**
* 标题数据转换工具函数
*/
import type { BackendTitleResponse, TitleItem } from "./types"
/** 将后端响应映射为前端 TitleItem */
export const toTitleItem = (item: BackendTitleResponse): TitleItem => ({
id: item.id,
content: item.text,
category: item.category,
word_count: item.text?.length || 0,
created_at: item.created_at,
updated_at: item.updated_at,
})
@@ -1,11 +1,7 @@
/**
* Header 右上角积分徽章
* Header 右上角积分徽章(💎 580
* - 余额 <10 时橙色告警
* - 点击弹出 Popover:余额、会员信息、充值入口、积分明细入口
*
* 字段对齐新契约:
* - balance.is_member / balance.member_type 保留但降级;推荐用 membership.member_type
* - 免费额度、会员 max_resolution 在 popover 展示
*/
import React, { useEffect } from "react"
import { Popover, Button, Tag, Space, Typography, Badge } from "antd"
@@ -21,32 +17,27 @@ import "./PointsBadge.css"
const { Text, Paragraph } = Typography
const MEMBER_LABEL: Record<string, string> = {
monthly: "月卡会员",
quarterly: "季卡会员",
yearly: "年卡会员",
}
const PointsBadge: React.FC = () => {
const navigate = useNavigate()
const { balance, membership, subscription, dailyUsage, init, loading } = usePointsStore()
const { balance, init, loading } = usePointsStore()
useEffect(() => {
if (!balance) init()
}, [balance, init])
// 余额:优先用 membership.points_balance(冗余字段),降级 balance.balance
const bal = membership?.points_balance ?? balance?.balance ?? 0
const bal = balance?.balance ?? 0
const lowBalance = bal > 0 && bal < 10
const zero = bal === 0
const isMember = membership?.is_member ?? balance?.is_member ?? false
const memberKey =
membership?.member_type ??
(subscription?.plan_id && subscription.plan_id !== "free" ? subscription.plan_id : null)
const memberLabel = memberKey ? MEMBER_LABEL[memberKey] || "付费会员" : "免费会员"
const maxRes = membership?.max_resolution
const freeRemain = dailyUsage?.free_clips_remaining ?? 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">
@@ -67,16 +58,9 @@ const PointsBadge: React.FC = () => {
</Paragraph>
)}
{!isMember && dailyUsage && freeRemain > 0 && (
<Text type="secondary" className="xx-points-expire">
{freeRemain}/{dailyUsage.free_clips_limit}
</Text>
)}
{balance?.member_expires_at && isMember && (
{balance?.member_expires_at && (
<Text type="secondary" className="xx-points-expire">
{new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
{maxRes ? ` · ${maxRes}` : ""}
</Text>
)}
@@ -96,16 +80,16 @@ const PointsBadge: React.FC = () => {
type="primary"
icon={<ThunderboltOutlined />}
block
onClick={() => navigate("/points/recharge")}
onClick={() => navigate("/app/points")}
>
</Button>
<Button block onClick={() => navigate("/points/transactions")}>
<Button block onClick={() => navigate("/app/points/transactions")}>
<RightOutlined />
</Button>
{!isMember && (
<Button block type="link" onClick={() => navigate("/subscription")}>
<Button block type="link" onClick={() => navigate("/app/subscription")}>
<CrownOutlined />
</Button>
)}
@@ -1,40 +1,31 @@
/**
* 功能操作按钮旁的"消耗积分"提示
* 例:[生成配音] 💎 -1 积分
* - 根据 scene_key 自动读取规则(来自 store.rules
* - 免费用户自动计算 ×free_user_multiplier 向上取整
* - 会员 floor(base × points_discount)
* - 根据 scene_key 自动读取规则
* - 免费用户自动计算 ×1.15 向上取整
* - 余额不足时显示红色告警 + 充值提示
*
* 字段对齐新契约:
* - rule.points_per_use → base_points
* - balance.free_clips_remaining → dailyUsage.free_clips_remaining
* - props.units → 保留兼容,新代码优先用 quantity
* 使用:<PointsCost scene="ai_voice" units={1} />
*/
import React, { useMemo } from "react"
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 */
quantity?: number
/** 预计时长(分钟),可选 */
durationMinutes?: number
/** 单位数(分钟数/条数/张数),默认 1 */
units?: number
/** 是否显示为紧凑模式(仅图标+数字,不显示单位文字) */
compact?: boolean
/** 余额不足时,是否显示充值提示 */
showRechargeHint?: boolean
/** 自定义 class */
className?: string
/**
* @deprecated 旧字段保留兼容,内部映射为 quantity
*/
units?: number
}
/** 单位中文 */
@@ -47,26 +38,31 @@ const UNIT_LABEL: Record<string, string> = {
const PointsCost: React.FC<Props> = ({
scene,
quantity,
units,
durationMinutes,
units = 1,
compact = false,
showRechargeHint = true,
className = "",
}) => {
const { balance, dailyUsage, rules, membership } = usePointsStore()
const qty = quantity ?? units ?? 1
const { balance, init } = usePointsStore()
const [rules, setRules] = useState<Awaited<ReturnType<typeof getPointsRules>> | null>(null)
const { cost, isFreeQuota, rule, isFreeUser, insufficient, freeRemain } = useMemo(() => {
const isMem = membership?.is_member ?? balance?.is_member ?? false
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: !isMem,
isFreeUser: !balance?.is_member,
insufficient: false,
freeRemain: 0,
}
}
const rule = rules.rules.find((r) => r.scene_key === scene)
@@ -75,54 +71,49 @@ const PointsCost: React.FC<Props> = ({
cost: 0,
isFreeQuota: false,
rule: null,
isFreeUser: !isMem,
isFreeUser: !balance.is_member,
insufficient: false,
freeRemain: 0,
}
// 免费训练不扣费
if (rule.base_points === 0) {
if (rule.points_per_use === 0) {
return {
cost: 0,
isFreeQuota: false,
rule,
isFreeUser: !isMem,
isFreeUser: !balance.is_member,
insufficient: false,
freeRemain: 0,
}
}
// 计算 base
// 智能混剪:首条30s=3分,每+30s +1
let baseCost: number
if (scene === "ai_video" && rule.extra_per_30s) {
const minutes = durationMinutes ?? qty
const segments = Math.max(1, Math.ceil(minutes * 2))
baseCost = rule.base_points + rule.extra_per_30s * (segments - 1)
// 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.base_points * Math.max(1, qty)
baseCost = rule.points_per_use * Math.max(1, units)
}
// 非会员 + 今日免费额度 → 免费
const freeRemain = dailyUsage?.free_clips_remaining ?? 0
const isFree = scene === "ai_video" && !isMem && freeRemain > 0
// 混剪 + 免费用户 + 今日免费额度 → 免费
const isFree =
scene === "ai_video" && !balance.is_member && (balance.free_clips_remaining ?? 0) > 0
const multiplier = rules.free_user_multiplier ?? 1.15
const cost = isFree ? 0 : isMem ? Math.floor(baseCost) : Math.ceil(baseCost * multiplier)
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: !isMem,
insufficient,
freeRemain,
}
}, [rules, balance, dailyUsage, membership, scene, qty, durationMinutes])
return { cost, isFreeQuota: isFree, rule, isFreeUser, insufficient }
}, [rules, balance, scene, units])
if (!rule || !balance) {
return <span className={`xx-points-cost ${className}`} />
}
if (rule.base_points === 0) {
if (rule.points_per_use === 0) {
return (
<span className={`xx-points-cost free ${className}`}>
<span className="xx-points-tag-free"></span>
@@ -132,10 +123,12 @@ const PointsCost: React.FC<Props> = ({
if (isFreeQuota) {
return (
<Tooltip title={`今日免费额度剩余 ${freeRemain} 条,不扣积分`}>
<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"> {freeRemain} </span>}
{!compact && (
<span className="xx-points-desc"> {balance.free_clips_remaining} </span>
)}
</span>
</Tooltip>
)
@@ -143,7 +136,7 @@ const PointsCost: React.FC<Props> = ({
const unitLabel = compact
? ""
: `/${qty > 1 ? `${qty}${UNIT_LABEL[rule.unit] ?? rule.unit}` : rule.unit}`
: ` /${units > 1 ? `${units}${UNIT_LABEL[rule.unit] ?? rule.unit}` : rule.unit}`
return (
<span
@@ -4,6 +4,7 @@ export const ROUTE_TITLE_MAP: Record<string, string> = {
"/app/generate": "智能剪辑",
"/app/assets": "视频库",
"/app/voices": "配音库",
"/app/titles": "标题库",
"/app/products": "成片库",
"/app/templates": "模板库",
"/app/history": "任务历史",
+13
View File
@@ -6,6 +6,7 @@ import React from "react"
import {
DashboardOutlined,
FileOutlined,
FileTextOutlined,
AudioOutlined,
EditOutlined,
VideoCameraOutlined,
@@ -50,6 +51,12 @@ export const NAV_ITEMS: NavItem[] = [
path: "/app/assets",
icon: React.createElement(FileOutlined),
},
{
key: "titles",
label: "标题库",
path: "/app/titles",
icon: React.createElement(FileTextOutlined),
},
{
key: "scripts",
label: "文案库",
@@ -153,6 +160,12 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/voices",
icon: React.createElement(AudioOutlined),
},
{
key: "titles",
label: "标题库",
path: "/app/titles",
icon: React.createElement(FileTextOutlined),
},
{
key: "scripts",
label: "文案库",
@@ -619,7 +619,6 @@ const AiAvatarPage: React.FC = () => {
scriptText={state.scriptText}
onScriptTextChange={state.setScriptText}
onOpenScriptModal={() => state.setShowScriptModal(true)}
onScriptCreated={(s) => state.selectScript(s as import("./types").Script)}
/>
<div className="aa-step-btn-row">
<button
@@ -1147,9 +1146,8 @@ const ScriptSelectModalLazy: React.FC<{
useEffect(() => {
if (!open) return
setLoading(true)
// #1894: getScripts 返回 { items, total } 分页结构,取 items 即可
getScripts({ page_size: 200 })
.then((res) => setScripts(Array.isArray(res) ? res : (res.items ?? [])))
getScripts()
.then((items) => setScripts(Array.isArray(items) ? items : []))
.catch(() => setScripts([]))
.finally(() => setLoading(false))
}, [open])
+24 -8
View File
@@ -2,15 +2,31 @@
* AI数字人 — API 封装(#1822 契约对齐)
*/
import apiClient from "@/api/client"
// #1894: Script 类型统一从 @/api/scripts 取(ai-avatar 本地 Script 仅保留渲染/对口型等自有类型)
import type { LipsyncJob, RenderJob, BRollSegment, SentenceTiming } from "../types"
import type { Script, LipsyncJob, RenderJob, BRollSegment, SentenceTiming } from "../types"
/* ── 文案库 ──
* #1894: 统一走 @/api/scripts 的 getScripts,不再各自封装;
* 这样 mock 开关、分页/搜索参数、字段对齐都和文案库页面保持一致。
*/
// #1894: 统一复用文案库 API,不再在 ai-avatar 里重复实现
export { getScripts, getScript as getScriptById, createScript, deleteScript } from "@/api/scripts"
/* ── 文案库 ── */
export const getScripts = async (): Promise<Script[]> => {
const response = await apiClient.get<{ items?: Script[] } | Script[]>("/scripts")
// 后端列表返回 { items, total } 分页对象,做兼容解包 + 数组防御(#1809 白屏修复)
const data = response.data as unknown
if (Array.isArray(data)) return data
const items = (data as { items?: Script[] })?.items
return Array.isArray(items) ? items : []
}
export const getScriptById = async (id: string): Promise<Script> => {
const response = await apiClient.get<Script>(`/scripts/${id}`)
return response.data
}
export const createScript = async (data: { title: string; content: string }): Promise<Script> => {
const response = await apiClient.post<Script>("/scripts", data)
return response.data
}
export const deleteScript = async (id: string): Promise<void> => {
await apiClient.delete(`/scripts/${id}`)
}
/* ── 素材单查(拿到 file_url 作为对口型的 video_url ── */
export const getAssetById = async (id: string): Promise<{ file_url?: string; id: string }> => {
@@ -1,17 +1,13 @@
/**
* AI数字人 — 文案面板(步骤1用)
* #1894: 文案库选择走 @/api/scripts;手动输入支持一键「保存到文案库」
* 文案库选择 / 手动输入 + 字数统计
*/
import { useState } from "react"
import { message } from "antd"
import { createScript } from "../api/aiAvatar"
interface PanelScriptProps {
scriptText: string
onScriptTextChange: (text: string) => void
onOpenScriptModal: () => void
/** 手动保存到文案库后回调(把新脚本传入,父组件可更新 selectedScript */
onScriptCreated?: (script: { id: string; title: string; content: string }) => void
}
type ScriptTab = "library" | "manual"
@@ -20,30 +16,8 @@ export function PanelScript({
scriptText,
onScriptTextChange,
onOpenScriptModal,
onScriptCreated,
}: PanelScriptProps) {
const [scriptTab, setScriptTab] = useState<ScriptTab>("library")
const [saving, setSaving] = useState(false)
const handleSaveToLibrary = async () => {
const text = scriptText.trim()
if (!text) {
message.warning("请先输入文案内容")
return
}
// 用正文前 20 字作为默认标题
const autoTitle = text.slice(0, 20).replace(/\n+/g, " ").trim() || "手动输入文案"
setSaving(true)
try {
const created = await createScript({ title: autoTitle, content: text, tags: [] })
message.success({ content: "已保存到文案库", duration: 1 })
onScriptCreated?.(created)
} catch {
message.error("保存到文案库失败,请稍后重试")
} finally {
setSaving(false)
}
}
return (
<div className="aa-script-lipsync">
@@ -85,20 +59,7 @@ export function PanelScript({
}
onChange={(e) => onScriptTextChange(e.target.value)}
/>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div className="aa-char-count">{scriptText.length} </div>
{scriptTab === "manual" && scriptText.trim().length > 0 && (
<button
type="button"
className="aa-btn aa-btn--text"
disabled={saving}
onClick={handleSaveToLibrary}
style={{ fontSize: 12, padding: "2px 8px" }}
>
{saving ? "保存中..." : "💾 保存到文案库"}
</button>
)}
</div>
<div className="aa-char-count">{scriptText.length} </div>
</div>
)
}
@@ -15,8 +15,7 @@ import type { TitleOption } from "@/pages/generate/components/title/TitleLibrary
import type { TitleSettings } from "@/pages/generate/types"
import { POSITION_OPTIONS, FONT_OPTIONS, TITLE_PRESETS } from "@/pages/generate/constants"
import type { AiAvatarTitleConfig } from "../types"
// #1894: 标题数据源切换到文案库,取 script.title 作为候选
import { getScripts } from "@/api/scripts"
import { getTitles } from "@/api/titles"
const { TextArea } = Input
@@ -29,23 +28,11 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
/** TitleStylePanel 内部高亮的预设 key(面板本地状态) */
const [activePreset, setActivePreset] = useState<string | null>(null)
/** 标题库选项(#1894:从文案库 scripts[].title 取候选 */
/** 标题库选项(复用智能剪辑的标题库 */
const [titleOptions, setTitleOptions] = useState<TitleOption[]>([])
useEffect(() => {
getScripts({ page_size: 200 })
.then((res) => {
const items = Array.isArray(res) ? res : (res.items ?? [])
// 去重 + 过滤空标题
const seen = new Set<string>()
const opts: TitleOption[] = []
for (const s of items) {
const t = (s.title || "").trim()
if (!t || seen.has(t)) continue
seen.add(t)
opts.push({ label: t, value: t })
}
setTitleOptions(opts)
})
getTitles()
.then((items) => setTitleOptions(items.map((t) => ({ label: t.content, value: t.content }))))
.catch(() => setTitleOptions([]))
}, [])
@@ -97,12 +84,10 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
style={{ fontSize: 15 }}
/>
<div style={{ marginTop: 8, display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 12, color: "#8c8ca1", whiteSpace: "nowrap" }}>
📚
</span>
<span style={{ fontSize: 12, color: "#8c8ca1", whiteSpace: "nowrap" }}>📚 </span>
<TitleLibraryAutoComplete
key={titleConfig.title}
placeholder="从文案库选择标题"
placeholder="选择标题填入上方"
value=""
onChange={(val) => {
if (val) onUpdate({ title: val })
@@ -0,0 +1,97 @@
/**
* AI数字人 — 标题库选择弹窗
* 复用智能剪辑的标题库 API,选择标题后填入输入框
*/
import React, { useEffect, useState } from "react"
import { getTitles } from "@/api/titles"
import type { TitleItem } from "@/api/titles/types"
interface TitleLibraryModalProps {
open: boolean
onClose: () => void
onSelect: (title: string) => void
}
const TitleLibraryModal: React.FC<TitleLibraryModalProps> = ({ open, onClose, onSelect }) => {
const [titles, setTitles] = useState<TitleItem[]>([])
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState("")
useEffect(() => {
if (!open) return
setLoading(true)
getTitles()
.then((items) => setTitles(items))
.catch(() => setTitles([]))
.finally(() => setLoading(false))
}, [open])
const filtered = titles.filter(
(t) => !search || t.content.toLowerCase().includes(search.toLowerCase()),
)
if (!open) return null
return (
<div className="aa-modal-overlay" onClick={onClose}>
<div className="aa-modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 600 }}>
<div className="aa-modal__header">
<span className="aa-modal__title"></span>
<button className="aa-modal__close" onClick={onClose}></button>
</div>
<div className="aa-modal__body">
<div style={{ marginBottom: 12 }}>
<input
className="aa-input"
placeholder="搜索标题..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{loading ? (
<div style={{ textAlign: "center", padding: 40, color: "#8c8ca1" }}>...</div>
) : filtered.length === 0 ? (
<div style={{ textAlign: "center", padding: 40, color: "#8c8ca1" }}>
</div>
) : (
<div style={{ maxHeight: 400, overflowY: "auto" }}>
{filtered.map((t) => (
<div
key={t.id}
style={{
padding: "12px 16px",
marginBottom: 8,
background: "#f8f8fc",
borderRadius: 8,
cursor: "pointer",
transition: "background 0.2s",
}}
onMouseEnter={(e) => (e.currentTarget.style.background = "#eef0ff")}
onMouseLeave={(e) => (e.currentTarget.style.background = "#f8f8fc")}
onClick={() => {
onSelect(t.content)
onClose()
}}
>
<div style={{ fontSize: 14, color: "#1a1a2e", marginBottom: 4 }}>{t.content}</div>
<div style={{ fontSize: 12, color: "#8c8ca1" }}>
{t.word_count ?? t.content.length} ·{" "}
{t.created_at ? new Date(t.created_at).toLocaleDateString() : ""}
</div>
</div>
))}
</div>
)}
</div>
<div className="aa-modal__footer">
<button className="aa-btn" onClick={onClose}>
</button>
</div>
</div>
</div>
)
}
export default TitleLibraryModal
+9 -5
View File
@@ -56,11 +56,15 @@ export interface TtsPreviewResult {
error: string | null
}
/* ── 文案 ──
* #1894: 直接复用文案库的 ScriptItem 类型,保证字段(title/content/tags/...)一致;
* 个别 ai-avatar 专属属性如有需要再在此处扩展。
*/
export type Script = import("@/api/scripts").ScriptItem
/* ── 文案 ── */
export interface Script {
id: string
title: string
content: string
char_count: number
created_at: string
updated_at?: string
}
/* ── 对口型任务 ── */
export interface LipsyncJob {
+4 -21
View File
@@ -39,7 +39,7 @@ const GeneratePage: React.FC = () => {
/* ── 表单状态 ── */
const formState = useGenerateFormState()
/* ── 积分状态 ── */
const { balance, dailyUsage, rules, init: initPoints } = usePointsStore()
const { balance, init: initPoints } = usePointsStore()
useEffect(() => {
initPoints()
}, [initPoints])
@@ -358,14 +358,7 @@ const GeneratePage: React.FC = () => {
const handleConfirmGenerate = useCallback(async () => {
// 积分预检查
const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
const check = hasEnoughPoints(
balance ?? null,
units,
dailyUsage ?? null,
[],
"free",
rules?.free_user_multiplier ?? 1.15,
)
const check = hasEnoughPoints(balance ?? null, units)
if (!check.sufficient) {
message.error(check.reason ?? "积分不足,请充值")
return
@@ -404,8 +397,6 @@ const GeneratePage: React.FC = () => {
handleGenerate,
setCurrentStep,
balance,
dailyUsage,
rules,
])
/* ── 步骤导航 ── */
@@ -432,16 +423,8 @@ const GeneratePage: React.FC = () => {
/* ── 积分消耗估算(步骤3确认生成展示用) ── */
const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
const pointsEstimate = useMemo(
() =>
hasEnoughPoints(
balance ?? null,
unitsForCost,
dailyUsage ?? null,
[],
"free",
rules?.free_user_multiplier ?? 1.15,
),
[unitsForCost, balance, dailyUsage, rules],
() => hasEnoughPoints(balance ?? null, unitsForCost),
[unitsForCost, balance],
)
const insufficientPoints = !pointsEstimate.sufficient
@@ -1,11 +1,11 @@
/**
* GeneratePage 步骤底部操作按钮(5 步向导:素材→配音→标题→确认生成→封面
* v3: 修复 #1954 off-by-one —— 步骤4 单视频/批量生成完成后正确显示「下一步:选择封面」
* GeneratePage 步骤底部操作按钮(Issue #1677 修正:固定 6 步
* v2: 步骤4 按钮前显示本次积分消耗;积分不足时禁用按钮并提示充值
*
* 步骤 1~3:上一步 / 下一步
* 步骤 4确认生成/进度):未开始 →「✨ 确认生成视频」;生成中 →「⏳ 视频渲染中…」;
* 失败 →「🔄 重新生成」;全部完成「下一步:选择封面
* 步骤 5(选择封面):仅上一步,无主按钮
* 步骤 4选择标题):「✨ 确认生成视频 / 确认生成 N 个视频」→ 创建正式生成任务,成功后跳步骤5
* 步骤 5(确认生成):渲染进度页,全部完成「下一步:选择封面」;仅上一步
* 步骤 6(选择封面):仅上一步
*/
import React from "react"
import { Tooltip } from "antd"
@@ -22,13 +22,14 @@ export interface GenerateStepActionsProps {
generateError: string | null
/** 批量模式下勾选的视频数量(N=1 时为1) */
selectedCount?: number
// v2: 积分相关
/** 本次预估消耗积分 */
estimatedCost?: number
/** 是否积分不足 */
pointsInsufficient?: boolean
/** 积分不足原因 */
insufficientReason?: string
/** 本次使用的免费混剪次数 */
/** 剩余免费混剪次数 */
freeClipsUsedThisTime?: number
/** 前往充值 */
onRecharge?: () => void
@@ -59,17 +60,8 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
)
}
/* 步骤 4:确认生成 / 进度 / 完成进封面 */
/* 步骤 4选择标题 — 确认生成 */
if (currentStep === 4) {
// #1954 修复:单视频/批量 全部生成完成后显示「下一步:选择封面」
if (generated && !generating && !generateError) {
return (
<button className="xx-btn xx-btn-primary" onClick={onNext}>
</button>
)
}
const costTag =
typeof estimatedCost === "number" && estimatedCost > 0 ? (
<span className="xx-step-cost-tag">
@@ -126,7 +118,23 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
)
}
/* 步骤 5(封面,最后一步):无主按钮 */
/* 步骤 5:确认生成进度页 — 全部完成后下一步进封面 */
if (currentStep === 5) {
if (generated) {
return (
<button className="xx-btn xx-btn-primary" onClick={onNext}>
</button>
)
}
return (
<button className="xx-btn xx-btn-primary" disabled>
</button>
)
}
/* 步骤 6(封面,最后一步):无主按钮 */
return null
}
+40 -60
View File
@@ -1,74 +1,58 @@
/**
* 生成前积分消耗估算与余额校验
* 用于步骤「确认生成」按钮前展示本次消耗,积分不足时禁用并提示充值
*
* 契约对齐(2026-09-16 最终版):
* - balance 不再包含 free_clips_* 字段,免费额度通过 dailyUsage 传入
* - 乘数逻辑:非会员 ceil(base × free_user_multiplier),会员 floor(base × points_discount)
* - points_discount 从 subscription.plans.points_discount 获取(mock 阶段用 1 占位)
* 用于步骤4「确认生成」按钮前展示本次消耗,积分不足时禁用并提示充值
*/
import type { PointsBalance, DailyUsage } from "@/api/points/types"
import type { SubscriptionPlan } from "@/api/subscription/types"
import type { PointsBalance } from "@/api/points/types"
/** 生成单条视频基准积分(ai_video base_points=8,但向导默认使用短片段,先保守按 3 估算 */
/** 生成单条视频消耗积分(基准 */
export const BASE_VIDEO_POINTS = 3
/** 默认免费用户倍率(后端 free_user_multipliermock 默认 1.15 */
const DEFAULT_FREE_MULTIPLIER = 1.15
/**
* 估算生成任务的积分消耗
* @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)
}
export interface HasEnoughPointsResult {
/**
* 判断积分是否充足(含每日免费额度)
* @returns sufficient=true 表示可以继续生成;false 需要提示充值
*/
export function hasEnoughPoints(
balance: PointsBalance | null,
videoCount: number,
): {
sufficient: boolean
cost: number
reason?: string
freeClipsUsed?: number
freeClipsRemaining?: number
}
/**
* 判断积分是否充足(含每日免费额度)
* @param balance 积分余额
* @param videoCount 视频条数(批量模式下为 variant 数)
* @param dailyUsage 每日免费额度(可选;不传视为 0)
* @param plans 当前可用订阅计划列表(用于计算会员积分折扣;mock 传 [])
* @param currentPlanId 当前用户 plan_idfree/monthly/quarterly/yearly
* @param freeMultiplier 免费用户倍率,默认 1.15
*/
export function hasEnoughPoints(
balance: PointsBalance | null,
videoCount: number,
dailyUsage?: DailyUsage | null,
plans: SubscriptionPlan[] = [],
currentPlanId: string = "free",
freeMultiplier: number = DEFAULT_FREE_MULTIPLIER,
): HasEnoughPointsResult {
} {
if (!balance) {
return { sufficient: true, cost: estimateGenerateCost(videoCount, 1) }
// 未登录或未加载:不拦截,后端会校验
return { sufficient: true, cost: estimateGenerateCost(videoCount) }
}
const isMember = balance.is_member && currentPlanId !== "free"
const plan = plans.find((p) => p.plan_id === currentPlanId)
const multiplier = isMember ? (plan?.points_discount ?? 1) : freeMultiplier
const raw = BASE_VIDEO_POINTS * videoCount * multiplier
const cost = isMember ? Math.floor(raw) : Math.ceil(raw)
const isMember = balance.is_member
const multiplier = isMember ? 1 : 1.15
const cost = estimateGenerateCost(videoCount, multiplier)
const freeRemain = dailyUsage?.free_clips_remaining ?? 0
// 非会员优先用每日免费额度
if (!isMember && freeRemain > 0) {
const freeUsed = Math.min(freeRemain, videoCount)
const afterFree = videoCount - freeUsed
const paidCost =
afterFree === 0
? 0
: isMember
? Math.floor(BASE_VIDEO_POINTS * afterFree * multiplier)
: Math.ceil(BASE_VIDEO_POINTS * afterFree * 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: freeRemain - freeUsed,
freeClipsRemaining: balance.free_clips_remaining - freeUsed,
}
}
if (balance.balance >= paidCost) {
@@ -76,28 +60,24 @@ export function hasEnoughPoints(
sufficient: true,
cost: paidCost,
freeClipsUsed: freeUsed,
freeClipsRemaining: freeRemain - freeUsed,
freeClipsRemaining: balance.free_clips_remaining - freeUsed,
}
}
return {
sufficient: false,
cost: paidCost,
reason: `积分不足:本次需 ${paidCost} 积分(${freeUsed} 次免费额度),当前余额 ${balance.balance},还差 ${paidCost - balance.balance} 积分`,
reason: `积分不足:本次需 ${paidCost} 积分(使${freeUsed} 次免费额度),当前余额 ${balance.balance},还差 ${paidCost - balance.balance} 积分`,
freeClipsUsed: freeUsed,
}
}
if (balance.balance >= cost) return { sufficient: true, cost }
// 付费会员或免费额度用完
if (balance.balance >= cost) {
return { sufficient: true, cost }
}
return {
sufficient: false,
cost,
reason: `积分不足:本次需 ${cost} 积分,当前余额 ${balance.balance},还差 ${cost - balance.balance} 积分`,
}
}
/**
* 估算生成任务的积分消耗(导出给 UI 直接使用)
*/
export function estimateGenerateCost(videoCount: number, multiplier = 1): number {
return Math.ceil(BASE_VIDEO_POINTS * videoCount * multiplier)
}
@@ -1,7 +1,6 @@
import { useEffect, useRef } from "react"
import { useQuery } from "@tanstack/react-query"
// #1894: 标题候选从文案库 scripts[].title 获取,不再调用废弃的 /api/titles
import { getScripts } from "@/api/scripts"
import { getTitles } from "@/api/titles"
import type { TitleSettings } from "../../types"
import { useAiTitleGenerator } from "./useAiTitleGenerator"
import { useTitleStyleUpdaters } from "./useTitleStyleUpdaters"
@@ -23,14 +22,10 @@ export function useStep4Title({
onTitleSettingsChange,
selectedTemplate,
}: UseStep4TitleProps) {
// 标题候选(#1894:统一从文案库取 scripts[].title,去重)
// 标题库数据
const { data: userTitles = [] } = useQuery({
queryKey: ["scripts", "titles-source"],
queryFn: async () => {
const res = await getScripts({ page_size: 200 })
const items = Array.isArray(res) ? res : (res.items ?? [])
return items.map((s) => ({ content: (s.title || "").trim() })).filter((s) => !!s.content)
},
queryKey: ["titles"],
queryFn: () => getTitles(),
staleTime: 30_000,
})
+40 -82
View File
@@ -1,11 +1,6 @@
/**
* 积分中心主页(/points 或 /app/points
* 展示余额、会员信息、免费额度、快捷入口、最近流水
*
* 字段对齐新契约(2026-09-16):
* - balance 不含 free_clips_*,从 dailyUsage 取
* - subscription.member_type → plan_idfree/monthly/quarterly/yearly
* - subscription.member_type_name → 前端 PLAN_LABEL 映射
* 积分中心主页(/app/points
* 展示余额、会员信息、本月统计、快捷入口
*/
import React, { useEffect } from "react"
import {
@@ -35,7 +30,6 @@ import {
import { useNavigate } from "react-router-dom"
import PageHead from "@/components/layout/PageHead"
import { usePointsStore } from "@/store/pointsStore"
import { PLAN_LABEL } from "@/api/subscription/types"
import "./Points.css"
const { Text } = Typography
@@ -52,57 +46,32 @@ const SOURCE_NAME: Record<string, string> = {
ai_rewrite: "AI 改写",
ai_title: "AI 标题",
ai_cover: "AI 封面",
sign_up: "注册赠送",
bind_phone: "绑定手机",
gift: "活动赠送",
admin: "管理员调整",
}
const sourceLabel = (src: string): string => {
if (src.startsWith("refund:")) return `${SOURCE_NAME[src.slice(7)] || src.slice(7)}退款`
return SOURCE_NAME[src] || src
}
/** 会员标签:优先取 membership.member_type,降级 subscription.plan_id */
const memberKey = (
membership: { member_type: string | null } | null,
subscription: { plan_id: string } | null,
): string | null =>
membership?.member_type ??
(subscription?.plan_id && subscription.plan_id !== "free" ? subscription.plan_id : null)
const memberLabel = (
membership: { member_type: string | null } | null,
subscription: { plan_id: string; plan_name?: string } | null,
): string => {
const key = memberKey(membership, subscription)
if (!key) return "免费会员"
return PLAN_LABEL[key as keyof typeof PLAN_LABEL] || subscription?.plan_name || "付费会员"
subscription_bonus: "会员赠送",
admin_adjust: "管理员调整",
refund: "失败退还",
}
const PointsCenter: React.FC = () => {
const navigate = useNavigate()
const { balance, dailyUsage, membership, subscription, rules, init, loading } = usePointsStore()
const { balance, subscription, init, loading } = usePointsStore()
useEffect(() => {
init()
}, [init])
const bal = membership?.points_balance ?? balance?.balance ?? 0
const bal = balance?.balance ?? 0
const earned = balance?.total_earned ?? 0
const spent = balance?.total_spent ?? 0
const isMember = membership?.is_member ?? balance?.is_member ?? false
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)
// 免费额度从 dailyUsage 取
const freeUsed = dailyUsage?.free_clips_used ?? 0
const freeLimit = dailyUsage?.free_clips_limit ?? (isMember ? 0 : 3)
const freeRemain = dailyUsage?.free_clips_remaining ?? 0
// 最近流水 mock(后续可改为调用 getPointsTransactions(1,5)
// 近 5 条流水 mock(实际从 transactions 页加载)
const recentTx = [
{ type: "deduct" as const, source: "ai_voice", amount: 1, time: "今天 10:30" },
{ type: "deduct" as const, source: "ai_video", amount: 3, time: "今天 09:15" },
{ type: "add" as const, source: "recharge", amount: 100, time: "昨天" },
{ 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 (
@@ -110,15 +79,16 @@ const PointsCenter: React.FC = () => {
<PageHead
title="积分中心"
description="管理积分余额、查看流水、充值使用"
actions={
<Space>
<Button icon={<FileTextOutlined />} onClick={() => navigate("/points/rules")}>
<Button icon={<FileTextOutlined />} onClick={() => navigate("/app/points/rules")}>
</Button>
<Button
type="primary"
icon={<WalletOutlined />}
onClick={() => navigate("/points/recharge")}
onClick={() => navigate("/app/subscription")}
>
</Button>
@@ -141,7 +111,12 @@ const PointsCenter: React.FC = () => {
<Space size={8} wrap>
{isMember ? (
<Tag color="gold" icon={<CrownFilled />} style={{ padding: "4px 10px" }}>
{memberLabel(membership, subscription)}
{subscription?.member_type === "yearly"
? "年卡"
: subscription?.member_type === "quarterly"
? "季卡"
: "月卡"}
</Tag>
) : (
<Tag
@@ -155,24 +130,19 @@ const PointsCenter: React.FC = () => {
</Tag>
)}
{balance?.member_expires_at && isMember && (
{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>
)}
{membership?.max_resolution && isMember && (
<Text style={{ color: "rgba(255,255,255,0.85)", fontSize: 12 }}>
· {membership.max_resolution}
</Text>
)}
{!isMember && (
<Button
size="small"
type="primary"
onClick={() => navigate("/subscription")}
onClick={() => navigate("/app/subscription")}
style={{ background: "#fff", color: "#7c3aed", borderColor: "#fff" }}
>
<CrownFilled />
<CrownFilled /> 8
</Button>
)}
</Space>
@@ -186,24 +156,12 @@ const PointsCenter: React.FC = () => {
<InfoCircleOutlined />
</div>
<Progress
percent={freeLimit > 0 ? Math.round((freeUsed / freeLimit) * 100) : 0}
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}
{rules?.free_user_multiplier ? `(×${rules.free_user_multiplier} 倍率)` : ""}
{dailyUsage?.reset_at && (
<span>
{" "}
·{" "}
{new Date(dailyUsage.reset_at).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
)}
</Text>
</div>
)}
@@ -247,7 +205,7 @@ const PointsCenter: React.FC = () => {
<Card>
<Statistic
title={isMember ? "会员等级" : "今日免费剩余"}
value={isMember ? memberLabel(membership, subscription) : `${freeRemain}`}
value={isMember ? (subscription?.member_type_name ?? "付费会员") : `${freeRemain}`}
prefix={<CrownFilled style={{ color: "#f59e0b" }} />}
valueStyle={{ color: "#f59e0b" }}
/>
@@ -265,7 +223,7 @@ const PointsCenter: React.FC = () => {
</Space>
}
extra={<a onClick={() => navigate("/points/transactions")}> </a>}
extra={<a onClick={() => navigate("/app/points/transactions")}> </a>}
>
{recentTx.length === 0 ? (
<Empty description="暂无积分流水" />
@@ -279,22 +237,22 @@ const PointsCenter: React.FC = () => {
<Avatar
size="small"
style={{
background: item.type === "add" ? "#d1fae5" : "#fee2e2",
color: item.type === "add" ? "#059669" : "#dc2626",
background: item.type === "earn" ? "#d1fae5" : "#fee2e2",
color: item.type === "earn" ? "#059669" : "#dc2626",
}}
icon={item.type === "add" ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
icon={item.type === "earn" ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
/>
}
title={sourceLabel(item.source)}
title={SOURCE_NAME[item.source] ?? item.source}
description={item.time}
/>
<div
style={{
color: item.type === "add" ? "#10b981" : "#ef4444",
color: item.type === "earn" ? "#10b981" : "#ef4444",
fontWeight: 700,
}}
>
{item.type === "add" ? "+" : "-"}
{item.type === "earn" ? "+" : "-"}
{item.amount}
</div>
</List.Item>
@@ -311,7 +269,7 @@ const PointsCenter: React.FC = () => {
size="large"
type="primary"
icon={<WalletOutlined />}
onClick={() => navigate("/points/recharge")}
onClick={() => navigate("/app/subscription")}
>
</Button>
@@ -319,7 +277,7 @@ const PointsCenter: React.FC = () => {
block
size="large"
icon={<HistoryOutlined />}
onClick={() => navigate("/points/transactions")}
onClick={() => navigate("/app/points/transactions")}
>
</Button>
@@ -327,7 +285,7 @@ const PointsCenter: React.FC = () => {
block
size="large"
icon={<CrownFilled />}
onClick={() => navigate("/subscription")}
onClick={() => navigate("/app/subscription")}
>
{isMember ? "续费/升级会员" : "升级付费会员"}
</Button>
@@ -335,7 +293,7 @@ const PointsCenter: React.FC = () => {
block
size="large"
icon={<FileTextOutlined />}
onClick={() => navigate("/points/rules")}
onClick={() => navigate("/app/points/rules")}
>
</Button>
+57 -85
View File
@@ -1,72 +1,56 @@
/**
* 积分充值页(/points/recharge
* 积分充值页(/app/points/recharge
* 单独展示积分包,供入口直接跳转使用
*
* 字段对齐新契约(2026-09-16):
* - package 用 code 做唯一键(替代 id)
* - 价格单位为 cents,折后价 = price_cents × (user_discount ?? 1)
* - 下单接口返回 pay_params(当前为 {},支付通道未接入)
* - 余额优先取 membership.points_balance,降级 balance.balance
* 主 Plans 页面也有充值区,这里提供独立 URL 方便从"积分不足"弹窗跳转
*/
import React, { useEffect, useState } from "react"
import {
Card,
Col,
Row,
Button,
Tag,
Typography,
Space,
Modal,
message,
Tooltip,
Alert,
} from "antd"
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, PointsPackagesResponse } from "@/api/points/types"
import { getDiscountPriceCents } from "@/api/points/types"
import type { PointsPackage } from "@/api/points/types"
import "./Points.css"
const { Title, Text, Paragraph } = Typography
const PointsRecharge: React.FC = () => {
const navigate = useNavigate()
const { balance, membership, init } = usePointsStore()
const [packagesResp, setPackagesResp] = useState<PointsPackagesResponse | null>(null)
const { balance, init } = usePointsStore()
const [packages, setPackages] = useState<PointsPackage[]>([])
const [buying, setBuying] = useState<string | null>(null)
useEffect(() => {
init()
getPointsPackages()
.then((r) => setPackagesResp(r))
.then((r) => setPackages(r.packages))
.catch(() => {})
}, [init])
const packages = packagesResp?.packages ?? []
const userDiscount = packagesResp?.user_discount ?? null
const isMember = membership?.is_member ?? balance?.is_member ?? false
const currentBalance = membership?.points_balance ?? balance?.balance ?? 0
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.code)
const order = await createPointsOrder({ package_id: pkg.code })
setBuying(pkg.id)
const order = await createPointsOrder({ package_id: pkg.id })
Modal.info({
title: "支付功能开发中",
icon: <SafetyCertificateOutlined />,
content: (
<div>
<Paragraph>
{order.id.slice(0, 16)} {pkg.points} {" "}
<b>¥{(order.amount_cents / 100).toFixed(2).replace(/\.00$/, "")}</b>
{order.points_amount !== undefined && ` 到账 ${order.points_amount} 积分。`}
{order.expire_at && (
<span> {new Date(order.expire_at).toLocaleDateString("zh-CN")}</span>
)}
{order.id.slice(0, 16)}{" "}
<b>¥{(order.price_cents / 100).toFixed(2).replace(/\.00$/, "")}</b>
/
</Paragraph>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
@@ -77,9 +61,8 @@ const PointsRecharge: React.FC = () => {
okText: "知道了",
})
} catch (e) {
const err = e as { response?: { data?: { error?: { message?: string } } }; message?: string }
const msg = err?.response?.data?.error?.message || err?.message || "下单失败"
message.error(msg)
const err = e as { message?: string }
message.error(err?.message ?? "下单失败")
} finally {
setBuying(null)
}
@@ -90,61 +73,51 @@ const PointsRecharge: React.FC = () => {
<PageHead
title="积分充值"
description="积分永久有效,可用于全部 AI 功能;付费会员享折扣"
actions={
<Space>
{!isMember && (
<Button icon={<CrownFilled />} onClick={() => navigate("/subscription")}>
{!balance?.is_member && (
<Button icon={<CrownFilled />} onClick={() => navigate("/app/subscription")}>
8
</Button>
)}
<Button onClick={() => navigate("/points/transactions")}></Button>
<Button onClick={() => navigate("/app/points/transactions")}></Button>
</Space>
}
/>
<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 /> {currentBalance.toLocaleString()}
{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>
</div>
</Space>
</Card>
{userDiscount !== null && userDiscount < 1 && (
<Alert
type="success"
showIcon
style={{ marginBottom: 16 }}
message={`您当前享 ${Math.round(userDiscount * 100) / 10} 折优惠`}
description="会员/优惠已自动应用到下方价格"
/>
</Space>
</Card>
)}
<Title level={4}></Title>
<Row gutter={[20, 20]}>
{packages.map((pkg) => {
const priceCents = getDiscountPriceCents(pkg, userDiscount)
const originalCents = pkg.price_cents
const discount =
priceCents < originalCents ? Math.round((1 - priceCents / originalCents) * 100) : 0
const unit = priceCents / 100 / pkg.points
const isHot = pkg.unit_price < 0.1 // 单价低于 0.1 元/分视为热门
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.code}>
<Col xs={24} sm={12} md={8} key={pkg.id}>
<Card
className={`xx-pkg-card ${isHot ? "recommended" : ""} ${discount > 0 ? "has-discount" : ""}`}
className={`xx-pkg-card ${pkg.id === "basic_pack" ? "recommended" : ""} ${discount > 0 ? "has-discount" : ""}`}
hoverable
>
{isHot && <div className="xx-pkg-badge"></div>}
{pkg.id === "basic_pack" && <div className="xx-pkg-badge"></div>}
{discount > 0 && (
<Tag color="gold" className="xx-pkg-discount">
{Math.round((priceCents / originalCents) * 10) / 1}
{10 - discount / 10}
</Tag>
)}
<div className="xx-pkg-name">{pkg.name}</div>
@@ -154,17 +127,17 @@ const PointsRecharge: React.FC = () => {
<div className="xx-pkg-price">
<span className="currency">¥</span>
<span className="amount">
{(priceCents / 100).toFixed(priceCents % 100 === 0 ? 0 : 1).replace(/\.0$/, "")}
{(price / 100).toFixed(price % 100 === 0 ? 0 : 1).replace(/\.0$/, "")}
</span>
{discount > 0 && (
<span className="xx-pkg-origin">¥{(originalCents / 100).toFixed(0)}</span>
<span className="xx-pkg-origin">¥{(pkg.price / 100).toFixed(0)}</span>
)}
</div>
<div className="xx-pkg-unit">¥{unit.toFixed(3)}/ · </div>
<Button
block
type={isHot ? "primary" : "default"}
loading={buying === pkg.code}
type={pkg.id === "basic_pack" ? "primary" : "default"}
loading={buying === pkg.id}
onClick={() => handleBuy(pkg)}
size="large"
style={{ marginTop: 16 }}
@@ -180,13 +153,12 @@ const PointsRecharge: React.FC = () => {
<Card style={{ marginTop: 24 }}>
<Title level={5}></Title>
<ul style={{ paddingLeft: 20, color: "var(--text-secondary)", lineHeight: 2 }}>
<li>/30s 30s </li>
<li>AI / </li>
<li>AI </li>
<li> / AI / AI / AI </li>
<li>3 /30s 30s +1 </li>
<li>AI / 1 /</li>
<li>AI 15 /</li>
<li> / AI / AI / AI 1~2 /</li>
<li></li>
<li> AI ×1.15 </li>
<li></li>
<li> 2 AI ×1.15</li>
</ul>
<Tooltip title="具体规则以系统实际计算为准">
<Text type="secondary" style={{ fontSize: 12 }}>
+44 -63
View File
@@ -1,13 +1,6 @@
/**
* 积分明细页(/points/transactions
* 积分明细页(/app/points/transactions
* 分页展示积分流水,支持按类型/来源筛选
*
* 字段对齐新契约(2026-09-16):
* - 分页接口返回 {items, total, page, page_size}
* - type 仅 add/deductrefund 通过 source=refund:xxx 前缀体现
* - source_name 字段已移除,中文名在前端 SOURCE_LABEL 映射
* - signed_amount 字段已移除,根据 type 显示 +/-
* - ref_id 在新契约中为 string(不再是 number
*/
import React, { useEffect, useState, useCallback } from "react"
import {
@@ -36,12 +29,14 @@ const { Text } = Typography
const { RangePicker } = DatePicker
const TYPE_LABEL: Record<PointsTxType, { text: string; color: string }> = {
add: { text: "获得", color: "green" },
deduct: { text: "消耗", color: "red" },
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: "智能混剪",
@@ -51,18 +46,9 @@ const SOURCE_LABEL: Record<string, string> = {
ai_rewrite: "AI 文案改写",
ai_title: "AI 标题生成",
ai_cover: "AI 封面生成",
sign_up: "注册赠送",
bind_phone: "绑定手机",
gift: "活动赠送",
admin: "管理员调整",
}
const sourceLabel = (s: string): { label: string; isRefund: boolean } => {
if (s.startsWith("refund:")) {
const inner = s.slice(7)
return { label: `${SOURCE_LABEL[inner] || inner}(退款)`, isRefund: true }
}
return { label: SOURCE_LABEL[s] || s, isRefund: false }
subscription_bonus: "会员赠送",
admin_adjust: "管理员调整",
refund: "失败退还",
}
const PointsTransactions: React.FC = () => {
@@ -80,29 +66,22 @@ const PointsTransactions: React.FC = () => {
const load = useCallback(async () => {
setLoading(true)
try {
// 新契约后端暂不支持 type/source/date/keyword 过滤参数,先前端过滤
const res = await getPointsTransactions(page, pageSize)
let items = res.items
if (type !== "all") {
items = items.filter((it) => it.type === type)
}
if (source !== "all") {
items = items.filter((it) => it.source === source || it.source === `refund:${source}`)
}
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]) {
const start = dateRange[0].startOf("day")
const end = dateRange[1].endOf("day")
items = items.filter((it) => {
const t = dayjs(it.created_at)
return t.isAfter(start) && t.isBefore(end)
})
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) => {
const sl = sourceLabel(it.source).label
return (it.description || "").toLowerCase().includes(k) || sl.toLowerCase().includes(k)
})
items = items.filter(
(it) =>
it.description.toLowerCase().includes(k) ||
(SOURCE_LABEL[it.source] ?? it.source).includes(keyword),
)
}
setData(items)
setTotal(res.total)
@@ -126,41 +105,41 @@ const PointsTransactions: React.FC = () => {
title: "类型",
dataIndex: "type",
width: 90,
render: (t: PointsTxType, r: PointsTransaction) => {
if (r.source.startsWith("refund:")) {
return <Tag color="blue">退</Tag>
}
render: (t: PointsTxType) => {
const cfg = TYPE_LABEL[t]
return <Tag color={cfg?.color || "default"}>{cfg?.text || t}</Tag>
return <Tag color={cfg.color}>{cfg.text}</Tag>
},
},
{
title: "来源/场景",
dataIndex: "source",
width: 180,
render: (s: string) => <Text>{sourceLabel(s).label}</Text>,
width: 150,
render: (s: string, r: PointsTransaction) => (
<Space>
<Text>{r.source_name || SOURCE_LABEL[s] || s}</Text>
</Space>
),
},
{
title: "说明",
dataIndex: "description",
ellipsis: true,
render: (v: string) => v || "-",
},
{
title: "变动",
dataIndex: "amount",
dataIndex: "signed_amount",
width: 110,
align: "right",
render: (v: number, r: PointsTransaction) => {
const isRefund = r.source.startsWith("refund:")
const positive = r.type === "add" || isRefund
return (
<span className={positive ? "xx-tx-earn" : "xx-tx-spend"}>
{positive ? "+" : "-"}
{v}
</span>
)
},
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: "余额",
@@ -180,12 +159,13 @@ const PointsTransactions: React.FC = () => {
<PageHead
title="积分明细"
description="查看积分收入、消耗与退还记录"
actions={
<Space>
<Button icon={<ReloadOutlined />} onClick={load}>
</Button>
<Button type="primary" onClick={() => navigate("/points/recharge")}>
<Button type="primary" onClick={() => navigate("/app/subscription")}>
</Button>
</Space>
@@ -204,8 +184,9 @@ const PointsTransactions: React.FC = () => {
style={{ width: 120 }}
options={[
{ value: "all", label: "全部类型" },
{ value: "add", label: "获得" },
{ value: "deduct", label: "消耗" },
{ value: "earn", label: "获得" },
{ value: "spend", label: "消耗" },
{ value: "refund", label: "退还" },
]}
/>
<Select
+250 -32
View File
@@ -1,17 +1,19 @@
/**
* 文案库页面 — Issue #1811#1894 方向修正后)
* 文案库页面 — Issue #1811v2 完整版) + #1893 AI 能力
* 功能:
* - 列表页:卡片列表,搜索(标题/正文)、分类标签筛选、分页
* 每条卡片展示:title(标题)、content 前 100 字摘要、分类 Tag、tags、使用次数、时间
* 每条卡片展示:title、content 前 100 字摘要、title_text、分类 Tag、tags、使用次数、时间
* 操作:编辑 / 删除 / 复制 / 使用(跳创作页预填)
* - 新建/编辑弹窗:标题(原"名称")、正文(含 AI 改写)、分类、标签
* - #1893/#1894 AI 能力:
* - 新建/编辑弹窗:title、content 多行、segments(按空行自动拆分+手动编辑)、title_text、title_category、
* title_config(字体/颜色/位置/字号)、tags
* - #1893 AI 能力:
* - 顶部「🎬 从抖音提取」按钮 → 输入抖音链接 → ASR 提取文案 → 自动填充到新建弹窗
* - 正文下方「✨ AI 改写」按钮 → 点击直接执行(美化 loading spinner + "正在改写..."),
* 成功自动替换正文并 toast「改写成功」1s 自动关闭;失败 toast 错误
* - 标题旁「✨ AI 生成标题」按钮 → 候选列表一键填入
* - 新建/编辑弹窗中 content 下方「✨ AI 改写」按钮(带风格选择) → 对比弹窗让用户确认
* - title 旁「✨ AI 生成标题」按钮 → 候选列表一键填入
* - 删除确认(Popconfirm
* - 对接 api/scripts CRUDmock 阶段 SCRIPTS_API_MOCK=trueAI 接口始终走真实 API
*
* 风格对齐标题库(.xx-scripts-* 命名,沿用 CSS 变量)
*/
import React, { useCallback, useEffect, useState } from "react"
import {
@@ -20,6 +22,8 @@ import {
Empty,
Form,
Input,
InputNumber,
List,
Modal,
Pagination,
Popconfirm,
@@ -55,7 +59,13 @@ import {
aiGenerateTitles,
REWRITE_STYLE_OPTIONS,
} from "@/api/scripts"
import type { ScriptItem, ScriptCategory, ScriptUpsertRequest, RewriteStyle } from "@/api/scripts"
import type {
ScriptItem,
ScriptCategory,
ScriptUpsertRequest,
RewriteStyle,
AiRewriteResponse,
} from "@/api/scripts"
import { SCRIPT_CATEGORY_LABEL } from "@/api/scripts"
import "./scripts.css"
@@ -71,6 +81,19 @@ const CATEGORY_OPTIONS: { value: ScriptCategory | "all"; label: string }[] = [
})),
]
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
/** 提取后端返回的错误 detail(全局拦截器可能已弹 toast,但这里再兜一层) */
function extractErrMsg(err: unknown, fallback: string): string {
const e = err as {
@@ -109,9 +132,11 @@ const ScriptLibrary: React.FC = () => {
const [douyinUrl, setDouyinUrl] = useState("")
const [douyinLoading, setDouyinLoading] = useState(false)
// AI 改写(#1894: 点击直接执行,美化 loading + 1s 自动关闭 toast,不弹确认弹窗)
// AI 改写
const [rewriteModalOpen, setRewriteModalOpen] = useState(false)
const [rewriteStyle, setRewriteStyle] = useState<RewriteStyle>("口语化")
const [rewriteLoading, setRewriteLoading] = useState(false)
const [rewriteResult, setRewriteResult] = useState<AiRewriteResponse | null>(null)
// AI 生成标题
const [titleGenLoading, setTitleGenLoading] = useState(false)
@@ -151,8 +176,19 @@ const ScriptLibrary: React.FC = () => {
content: "",
segments: [],
tags: [],
title_text: "",
title_category: "other",
title_config: {
font: "default",
color: "#ffffff",
stroke: "#000000",
position: "center",
size: 48,
bold: true,
italic: false,
},
})
setRewriteResult(null)
setTitleCandidates([])
}
@@ -169,8 +205,17 @@ const ScriptLibrary: React.FC = () => {
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,
},
})
setRewriteResult(null)
setTitleCandidates([])
setModalOpen(true)
}
@@ -178,6 +223,7 @@ const ScriptLibrary: React.FC = () => {
const closeModal = () => {
setModalOpen(false)
setEditing(null)
setRewriteResult(null)
setTitleCandidates([])
}
@@ -191,8 +237,9 @@ const ScriptLibrary: React.FC = () => {
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,
// #1894: 配套标题 / 标题样式配置字段已从 UI 移除,后端即将删除,不再传
title_config: values.title_config,
}
if (editing) {
await updateScript(editing.id, payload)
@@ -296,7 +343,17 @@ const ScriptLibrary: React.FC = () => {
title: "",
content: res.text,
tags: [],
title_text: "",
title_category: "other",
title_config: {
font: "default",
color: "#ffffff",
stroke: "#000000",
position: "center",
size: 48,
bold: true,
italic: false,
},
})
setModalOpen(true)
} catch (err) {
@@ -306,18 +363,18 @@ const ScriptLibrary: React.FC = () => {
}
}
/** #1894: 执行 AI 改写,完成后自动替换正文并 toast 1 秒关闭 */
const handleAiRewrite = async (style: RewriteStyle) => {
/** 执行 AI 改写,结果写入 rewriteResult 让用户对比确认 */
const handleAiRewrite = async () => {
const content = form.getFieldValue("content") as string | undefined
if (!content || !content.trim()) {
message.warning("请先填写文案正文再改写")
return
}
setRewriteLoading(true)
setRewriteResult(null)
try {
const res = await aiRewriteScript({ content, style })
form.setFieldsValue({ content: res.rewritten })
message.success({ content: "改写成功", duration: 1 })
const res = await aiRewriteScript({ content, style: rewriteStyle })
setRewriteResult(res)
} catch (err) {
message.error(extractErrMsg(err, "AI 改写失败"))
} finally {
@@ -325,6 +382,15 @@ const ScriptLibrary: React.FC = () => {
}
}
/** 应用改写结果:替换 content 字段,关闭改写弹窗 */
const applyRewrite = () => {
if (!rewriteResult) return
form.setFieldsValue({ content: rewriteResult.rewritten })
setRewriteResult(null)
setRewriteModalOpen(false)
message.success("已应用改写结果")
}
/** 执行 AI 生成标题,生成候选 */
const handleGenerateTitles = async () => {
const content = form.getFieldValue("content") as string | undefined
@@ -461,6 +527,13 @@ const ScriptLibrary: React.FC = () => {
<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
@@ -517,13 +590,21 @@ const ScriptLibrary: React.FC = () => {
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={
<span>
{/* #1893 UX: disabled 时原生 title 在 antd Button 上不触发,
用 Tooltip + span 包裹保证提示可见 */}
<Tooltip
@@ -545,9 +626,9 @@ const ScriptLibrary: React.FC = () => {
</Tooltip>
</span>
}
rules={[{ required: true, message: "请填写视频标题" }, { max: 200 }]}
rules={[{ required: true, message: "请填写文案名称" }, { max: 200 }]}
>
<Input placeholder="输入视频标题" maxLength={200} />
<Input placeholder="给这段文案起个名字" maxLength={200} />
</Form.Item>
{/* AI 生成标题候选列表 */}
@@ -580,9 +661,9 @@ const ScriptLibrary: React.FC = () => {
<TextArea placeholder="在这里输入文案正文…" rows={6} maxLength={10000} />
</Form.Item>
{/* AI 改写工具条(#1894 UX:点击直接执行,自定义渐变圆环 loading) */}
{/* AI 改写工具条 */}
<div className="xx-ai-rewrite-bar">
<Space size={8} wrap align="center">
<Space size={8} wrap>
<Select
value={rewriteStyle}
onChange={setRewriteStyle}
@@ -591,19 +672,30 @@ const ScriptLibrary: React.FC = () => {
size="small"
disabled={rewriteLoading}
/>
<Button
size="small"
icon={<RobotOutlined />}
disabled={contentEmpty || rewriteLoading}
onClick={() => handleAiRewrite(rewriteStyle)}
>
AI
</Button>
{rewriteLoading && (
<span className="xx-ai-rewrite-loading">
<span className="xx-ai-rewrite-spinner" />
<span className="xx-ai-rewrite-loading-text">...</span>
{/* #1893 UX: content 为空时禁用改写按钮并给提示,避免用户点了才弹 warning */}
<Tooltip title={contentEmpty ? "请先填写文案正文再改写" : ""}>
<span style={{ display: "inline-flex" }}>
<Button
size="small"
icon={<RobotOutlined />}
loading={rewriteLoading}
disabled={contentEmpty}
onClick={() => {
// 每次打开重置上一次结果,避免误看旧对比
if (!rewriteLoading) {
setRewriteResult(null)
setRewriteModalOpen(true)
}
}}
>
AI
</Button>
</span>
</Tooltip>
{rewriteResult && !contentEmpty && (
<Button size="small" type="link" onClick={() => setRewriteModalOpen(true)}>
</Button>
)}
</Space>
</div>
@@ -612,6 +704,57 @@ const ScriptLibrary: React.FC = () => {
<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"
@@ -655,6 +798,81 @@ const ScriptLibrary: React.FC = () => {
</div>
)}
</Modal>
{/* AI 改写对比弹窗 */}
<Modal
title={`✨ AI 改写(${rewriteStyle}风格)`}
open={rewriteModalOpen}
onCancel={() => !rewriteLoading && setRewriteModalOpen(false)}
maskClosable={!rewriteLoading}
closable={!rewriteLoading}
footer={
rewriteResult ? (
<Space>
<Button onClick={() => setRewriteModalOpen(false)}></Button>
<Button type="primary" onClick={applyRewrite}>
</Button>
</Space>
) : (
<Button disabled={rewriteLoading} onClick={() => setRewriteModalOpen(false)}>
</Button>
)
}
width={640}
destroyOnClose={false}
>
{!rewriteResult && !rewriteLoading && (
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
{rewriteStyle}
</Paragraph>
)}
{rewriteLoading && (
<div className="xx-ai-loading-hint" style={{ padding: "32px 0" }}>
<Spin tip="AI 改写中…" />
</div>
)}
{rewriteResult && (
<List
dataSource={[
{ label: "原文", text: rewriteResult.original, type: "original" },
{
label: `改写(${rewriteResult.style}`,
text: rewriteResult.rewritten,
type: "rewrite",
},
]}
renderItem={(item) => (
<List.Item className="xx-ai-rewrite-item">
<div className="xx-ai-rewrite-block">
<div className="xx-ai-rewrite-label">
<Tag color={item.type === "original" ? "default" : "purple"}>{item.label}</Tag>
</div>
<Paragraph
className="xx-ai-rewrite-text"
style={{ whiteSpace: "pre-wrap", marginBottom: 0 }}
>
{item.text}
</Paragraph>
</div>
</List.Item>
)}
/>
)}
{!rewriteResult && !rewriteLoading && (
<div style={{ textAlign: "center" }}>
<Button
type="primary"
icon={<RobotOutlined />}
loading={rewriteLoading}
onClick={handleAiRewrite}
>
</Button>
</div>
)}
</Modal>
</div>
)
}
-48
View File
@@ -258,51 +258,3 @@
background: linear-gradient(180deg, #faf5ff 0%, #ffffff 100%);
border: 1px solid #eee6ff;
}
/* #1894: AI 改写内联 loading —— 渐变圆环旋转动画,替代 antd 默认 Spin */
.xx-ai-rewrite-loading {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: 4px;
}
.xx-ai-rewrite-spinner {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid transparent;
border-top-color: #9254de;
border-right-color: #722ed1;
background:
linear-gradient(#fff, #fff) padding-box,
conic-gradient(from 0deg, #9254de, #4096ff, #9254de) border-box;
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: xx-ai-rewrite-spin 0.9s linear infinite;
}
@keyframes xx-ai-rewrite-spin {
to {
transform: rotate(360deg);
}
}
.xx-ai-rewrite-loading-text {
font-size: 13px;
color: var(--text-secondary, #666);
background: linear-gradient(90deg, #722ed1, #4096ff, #722ed1);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: xx-ai-rewrite-text-shimmer 2s linear infinite;
}
@keyframes xx-ai-rewrite-text-shimmer {
0% {
background-position: 0% 0;
}
100% {
background-position: 200% 0;
}
}
+12 -171
View File
@@ -1,28 +1,15 @@
/**
* 账单管理页面
* 展示当前订阅信息 + 自动续费开关 + 账单历史
*
* 字段对齐新契约(2026-09-16):
* - toggleAutoRenew 参数改为 {enabled} 对象
* - billing_cycle 仅 monthly/yearly(季卡走 monthly 周期 + 3 个月时长)
* - 新增账单历史表格:order_type / amount_cents / status / created_at
* 展示当前订阅信息 + 自动续费开关
* P1-3: antd Switch→自定义ToggleSwitch, antd Spin→自定义Spinner
*/
import React, { useState, useEffect } from "react"
import { message, Table, Tag, Card, Space, Button, Modal, Typography } from "antd"
import type { ColumnsType } from "antd/es/table"
import {
getCurrentSubscription,
toggleAutoRenew,
cancelSubscription,
getBillingRecords,
} from "@/api/subscription"
import type { SubscriptionInfo, BillingRecord } from "@/api/subscription/types"
import { PLAN_LABEL, BILLING_CYCLE_LABEL } from "@/api/subscription/types"
import { message } from "antd"
import { getCurrentSubscription, toggleAutoRenew } from "@/api/subscription"
import type { SubscriptionInfo } from "@/api/subscription"
import PageHead from "@/components/layout/PageHead"
import "./Billing.css"
const { Text } = Typography
const formatDate = (iso: string): string => {
const d = new Date(iso)
return d.toLocaleDateString("zh-CN", {
@@ -64,40 +51,21 @@ const Spinner: React.FC<{ size?: "small" | "large" }> = ({ size = "large" }) =>
</div>
)
const ORDER_TYPE_LABEL: Record<string, string> = {
subscribe: "新购",
renew: "续费",
upgrade: "升级",
downgrade: "降级",
refund: "退款",
}
const BILLING_STATUS_TAG: Record<string, { color: string; text: string }> = {
paid: { color: "green", text: "已支付" },
pending: { color: "orange", text: "待支付" },
failed: { color: "red", text: "支付失败" },
refunded: { color: "blue", text: "已退款" },
cancelled: { color: "default", text: "已取消" },
}
const Billing: React.FC = () => {
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null)
const [billingRecords, setBillingRecords] = useState<BillingRecord[]>([])
const [loading, setLoading] = useState(true)
const [recordsLoading, setRecordsLoading] = useState(false)
const [autoRenewChecked, setAutoRenewChecked] = useState(false)
const [autoRenewLoading, setAutoRenewLoading] = useState(false)
useEffect(() => {
loadData()
loadRecords()
}, [])
const loadData = async () => {
try {
const data = await getCurrentSubscription()
setSubscription(data)
setAutoRenewChecked(!!data.auto_renew)
setAutoRenewChecked(data.auto_renew)
} catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("加载订阅数据失败")
} finally {
@@ -105,106 +73,22 @@ const Billing: React.FC = () => {
}
}
const loadRecords = async () => {
try {
setRecordsLoading(true)
const list = await getBillingRecords()
setBillingRecords(Array.isArray(list) ? list : [])
} catch {
// 账单加载失败不阻塞主流程
setBillingRecords([])
} finally {
setRecordsLoading(false)
}
}
const handleToggleAutoRenew = async (checked: boolean) => {
setAutoRenewLoading(true)
try {
const res = await toggleAutoRenew({ enabled: checked })
message.success(res?.message ?? (checked ? "已开启自动续费" : "已关闭自动续费"))
const res = await toggleAutoRenew(checked)
message.success(res.message)
setAutoRenewChecked(checked)
if (subscription) {
setSubscription({ ...subscription, auto_renew: checked })
}
} catch (err: unknown) {
const e = err as { response?: { data?: { error?: { message?: string } } }; message?: string }
message.error(e?.response?.data?.error?.message || e?.message || "操作失败")
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("操作失败")
} finally {
setAutoRenewLoading(false)
}
}
const handleCancelSubscription = () => {
Modal.confirm({
title: "确认取消订阅?",
content: "取消后,当前周期结束时将不再自动续费。您仍可使用当前会员权益至到期日。",
okText: "确认取消",
okType: "danger",
cancelText: "我再想想",
onOk: async () => {
try {
await cancelSubscription()
message.success("已取消订阅,到期后不再续费")
await loadData()
} catch (err: unknown) {
const e = err as {
response?: { data?: { error?: { message?: string } } }
message?: string
}
message.error(e?.response?.data?.error?.message || e?.message || "取消失败")
}
},
})
}
const columns: ColumnsType<BillingRecord> = [
{
title: "时间",
dataIndex: "created_at",
width: 170,
render: (v: string) => formatDate(v),
},
{
title: "类型",
dataIndex: "order_type",
width: 100,
render: (v: string) => ORDER_TYPE_LABEL[v] || v || "-",
},
{
title: "套餐",
dataIndex: "plan_id",
width: 120,
render: (v: string) => (v ? PLAN_LABEL[v as keyof typeof PLAN_LABEL] || v : "-"),
},
{
title: "金额",
dataIndex: "amount_cents",
width: 110,
align: "right",
render: (v: number) => (
<Text strong style={{ fontVariantNumeric: "tabular-nums" }}>
¥{((v ?? 0) / 100).toFixed(2)}
</Text>
),
},
{
title: "状态",
dataIndex: "status",
width: 100,
render: (v: string) => {
const cfg = BILLING_STATUS_TAG[v]
return <Tag color={cfg?.color || "default"}>{cfg?.text || v || "-"}</Tag>
},
},
{
title: "支付时间",
dataIndex: "paid_at",
width: 170,
render: (v?: string) => (v ? formatDate(v) : <Text type="secondary"></Text>),
},
]
if (loading) {
return (
<div className="xx-billing-page">
@@ -225,42 +109,19 @@ const Billing: React.FC = () => {
<div className="xx-overview-details">
<div className="xx-overview-item">
<span className="xx-label"></span>
<span className="xx-value">
{subscription.plan_name ||
PLAN_LABEL[subscription.plan_id as keyof typeof PLAN_LABEL] ||
"-"}
</span>
<span className="xx-value">{subscription.plan_name}</span>
</div>
<div className="xx-overview-item">
<span className="xx-label"></span>
<span className="xx-value">
{BILLING_CYCLE_LABEL[
subscription.billing_cycle as keyof typeof BILLING_CYCLE_LABEL
] ||
subscription.billing_cycle ||
"-"}
{subscription.billing_cycle === "monthly" ? "月付" : "年付"}
</span>
</div>
<div className="xx-overview-item">
<span className="xx-label"></span>
<span className="xx-value">¥{((subscription.amount ?? 0) / 100).toFixed(2)}</span>
</div>
<div className="xx-overview-item">
<span className="xx-label"></span>
<span className="xx-value">{formatDate(subscription.current_period_start)}</span>
</div>
<div className="xx-overview-item">
<span className="xx-label">/</span>
<span className="xx-label"></span>
<span className="xx-value">{formatDate(subscription.current_period_end)}</span>
</div>
</div>
{subscription.plan_id !== "free" && (
<Space style={{ marginTop: 16 }}>
<Button danger onClick={handleCancelSubscription}>
</Button>
</Space>
)}
</div>
{/* 自动续费 */}
@@ -284,26 +145,6 @@ const Billing: React.FC = () => {
</div>
</>
)}
{/* 账单历史 */}
<Card
title="账单历史"
style={{ marginTop: 16 }}
extra={
<Button size="small" onClick={loadRecords} loading={recordsLoading}>
</Button>
}
>
<Table<BillingRecord>
rowKey="id"
columns={columns}
dataSource={billingRecords}
loading={recordsLoading}
pagination={{ pageSize: 10, showSizeChanger: false }}
locale={{ emptyText: "暂无账单记录" }}
/>
</Card>
</div>
)
}
+143 -250
View File
@@ -1,10 +1,8 @@
/**
* 会员订阅 & 积分充值页
* v3: 对齐后端最终契约(2026-09-16)
* - 订阅计划走 GET /subscription/plans4 档:free/monthly/quarterly/yearly
* - 积分包走 GET /points/packages,折后价 = price_cents × (user_discount ?? 1)
* - 当前身份/余额优先从 membership + dailyUsage 取,降级 balance
* - 暂保留 SUBSCRIPTION_PLANS_FALLBACK 常量,API 失败时降级
* v2 两档会员制:免费 vs 付费
* 付费三档:月¥19.9 / 季¥39.9(推荐)/ 年¥159
* 积分包:100/¥9.9、500/¥39、2000/¥129
*/
import React, { useEffect, useMemo, useState } from "react"
import {
@@ -32,19 +30,16 @@ import {
import { useNavigate } from "react-router-dom"
import PageHead from "@/components/layout/PageHead"
import { usePointsStore } from "@/store/pointsStore"
import { createPointsOrder, getPointsPackages } from "@/api/points"
import { getSubscriptionPlans, changePlan } from "@/api/subscription"
import type { PointsPackage, PointsPackagesResponse } from "@/api/points/types"
import { getDiscountPriceCents } from "@/api/points/types"
import type { SubscriptionPlan } from "@/api/subscription/types"
import { PLAN_LABEL, BILLING_CYCLE_LABEL } from "@/api/subscription/types"
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
/** 免费会员权益(根据后端 features 动态展示,这里保留兜底) */
/** 免费会员权益 */
const FREE_FEATURES = [
{ include: true, text: "每日免费混剪额度" },
{ include: true, text: "每日 2 条免费混剪" },
{ include: true, text: "720p 导出分辨率" },
{ include: true, text: "AI 配音(×1.15 积分)" },
{ include: true, text: "AI 数字人(×1.15 积分)" },
@@ -54,181 +49,91 @@ const FREE_FEATURES = [
{ include: false, text: "去重检测报告" },
]
/** 付费会员权益(兜底) */
/** 付费会员权益 */
const PAID_FEATURES = [
{ include: true, text: "智能混剪按会员折扣积分" },
{ include: true, text: "无限次智能混剪" },
{ include: true, text: "最高 1080p 导出" },
{ include: true, text: "全部 AI 功能(会员折扣积分)" },
{ include: true, text: "全部 AI 功能(标准积分" },
{ include: true, text: "声音克隆训练免费" },
{ include: true, text: "积分购买最低折" },
{ include: true, text: "积分购买最低 8 折" },
{ include: true, text: "批量导出" },
{ include: true, text: "多平台一键发布" },
{ include: true, text: "去重检测报告" },
]
/** 旧 SUBSCRIPTION_PLANS 兜底(API 不可用时) */
const SUBSCRIPTION_PLANS_FALLBACK = [
{
id: "monthly" as const,
name: "月卡",
price_cents: 1990,
per_month_yuan: "19.9",
savings_percent: 0,
recommended: false,
billing_label: "/月",
billing_cycle: "monthly" as const,
},
{
id: "quarterly" as const,
name: "季卡",
price_cents: 3990,
per_month_yuan: "13.3",
savings_percent: 33,
recommended: true,
billing_label: "/季",
billing_cycle: "monthly" as const,
},
{
id: "yearly" as const,
name: "年卡",
price_cents: 15900,
per_month_yuan: "13.25",
savings_percent: 34,
recommended: false,
billing_label: "/年",
billing_cycle: "yearly" as const,
},
]
const formatYuan = (cents: number) =>
`¥${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 1).replace(/\.0$/, "")}`
const Plans: React.FC = () => {
const navigate = useNavigate()
const { balance, dailyUsage, membership, subscription, init } = usePointsStore()
const [plans, setPlans] = useState<SubscriptionPlan[]>([])
const [packagesResp, setPackagesResp] = useState<PointsPackagesResponse | null>(null)
const [subscribing, setSubscribing] = useState(false)
const [buying, setBuying] = useState<string | null>(null)
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)
useEffect(() => {
init()
// 拉取订阅计划
getSubscriptionPlans()
.then((r) => {
const paid = r.plans.filter((p) => p.plan_id !== "free")
setPlans(paid)
// 默认选季卡,没有就选第一个
const hasQuarterly = paid.some((p) => p.plan_id === "quarterly")
if (!hasQuarterly && paid.length > 0) setSelectedBilling(paid[0].plan_id as "monthly")
})
.catch(() => {
// 降级
})
getPointsPackages()
.then((r) => setPackagesResp(r))
.then((r) => setPackages(r.packages))
.catch(() => {})
}, [init])
const packages = packagesResp?.packages ?? []
const userDiscount = packagesResp?.user_discount ?? null
const isMember = !!balance?.is_member
const memberType = balance?.member_type ?? null
const isMember = membership?.is_member ?? balance?.is_member ?? false
// 当前会员档位:优先 membership.member_type,降级 subscription.plan_id
const memberPlanId =
membership?.member_type ??
(subscription?.plan_id && subscription.plan_id !== "free" ? subscription.plan_id : null)
const bal = membership?.points_balance ?? balance?.balance ?? 0
const freeUsed = dailyUsage?.free_clips_used ?? 0
const freeLimit = dailyUsage?.free_clips_limit ?? (isMember ? 0 : 3)
const freeRemain = dailyUsage?.free_clips_remaining ?? (isMember ? 0 : freeLimit - freeUsed)
/** 统一的可选付费档位(API 返回 + 兜底) */
const billingOptions = useMemo(() => {
if (plans.length > 0) {
return plans.map((p) => {
const id = p.plan_id as "monthly" | "quarterly" | "yearly"
const perMonth =
p.duration_days > 0
? (p.price_cents / 100 / (p.duration_days / 30)).toFixed(1)
: (p.monthly_price_cents / 100).toFixed(1)
const monthlyCents = p.monthly_price_cents || p.price_cents
const savings =
p.price_cents > 0 && monthlyCents > 0
? Math.max(
0,
Math.round((1 - p.price_cents / (monthlyCents * (p.duration_days / 30))) * 100),
)
: 0
return {
id,
name: p.name,
price_cents: p.price_cents,
per_month_yuan: perMonth,
savings_percent: savings,
recommended: id === "quarterly",
billing_label: id === "yearly" ? "/年" : id === "quarterly" ? "/季" : "/月",
billing_cycle: (id === "yearly" ? "yearly" : "monthly") as "monthly" | "yearly",
}
})
}
return SUBSCRIPTION_PLANS_FALLBACK
}, [plans])
/** 根据会员等级计算积分包折后价(分) */
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(
() => billingOptions.find((p) => p.id === selectedBilling) ?? billingOptions[0],
[billingOptions, selectedBilling],
() => SUBSCRIPTION_PLANS.find((p) => p.id === selectedBilling)!,
[selectedBilling],
)
const handleSubscribe = async () => {
if (!selectedPlan) return
try {
setSubscribing(true)
await changePlan({
target_plan_id: selectedPlan.id,
billing_cycle: selectedPlan.billing_cycle,
})
Modal.success({
title: "订阅已提交",
icon: <SafetyCertificateOutlined />,
content: `已为您切换到 ${selectedPlan.name}${BILLING_CYCLE_LABEL[selectedPlan.billing_cycle]} ${formatYuan(selectedPlan.price_cents)}。支付通道接入中,正式上线后会自动扣费。`,
okText: "知道了",
})
} catch (e) {
const err = e as { response?: { data?: { error?: { message?: string } } }; message?: string }
// 支付未接入阶段,保持演示体验
Modal.confirm({
title: "支付功能开发中",
icon: <SafetyCertificateOutlined />,
content:
err?.response?.data?.error?.message ||
"微信/支付宝支付正在接入中,完成后会第一时间通知。是否返回首页继续使用免费功能?",
okText: "返回首页",
cancelText: "留在此页",
onOk: () => navigate("/app/dashboard"),
})
} finally {
setSubscribing(false)
}
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) => {
try {
setBuying(pkg.code)
const order = await createPointsOrder({ package_id: pkg.code })
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> {pkg.points} {" "}
<b>{formatYuan(order.amount_cents)}</b>
{order.points_amount !== undefined && `到账 ${order.points_amount} 积分。`}
<Text code>{order.id.slice(0, 16)}</Text> {" "}
<b>{formatYuan(order.price_cents)}</b>
/线
</Paragraph>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
@@ -239,8 +144,8 @@ const Plans: React.FC = () => {
okText: "知道了",
})
} catch (e) {
const err = e as { response?: { data?: { error?: { message?: string } } }; message?: string }
message.error(err?.response?.data?.error?.message || err?.message || "创建订单失败")
const err = e as { message?: string }
message.error(err?.message ?? "创建订单失败")
} finally {
setBuying(null)
}
@@ -251,6 +156,7 @@ const Plans: React.FC = () => {
<PageHead
title="会员与积分"
description="开通会员解锁全部功能,按需充值积分灵活使用 AI 能力"
actions={
<Space>
<Button
@@ -264,75 +170,77 @@ const Plans: React.FC = () => {
/>
{/* 当前状态卡片 */}
<Card className="xx-current-status" bordered={false}>
<Row align="middle" gutter={24}>
<Col flex="auto">
<Space size="large" wrap>
<div>
<Text type="secondary"></Text>
{balance && (
<Card className="xx-current-status" bordered={false}>
<Row align="middle" gutter={24}>
<Col flex="auto">
<Space size="large" wrap>
<div>
{isMember ? (
<Tag
color="gold"
icon={<CrownFilled />}
style={{ marginTop: 4, fontSize: 14, padding: "4px 10px" }}
>
{memberPlanId
? PLAN_LABEL[memberPlanId as keyof typeof PLAN_LABEL] || "付费会员"
: "付费会员"}
</Tag>
) : (
<Tag style={{ marginTop: 4, fontSize: 14, padding: "4px 10px" }}></Tag>
)}
{balance?.member_expires_at && isMember && (
<Text type="secondary" style={{ marginLeft: 8 }}>
{new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
</Text>
)}
{membership?.max_resolution && (
<Text type="secondary" style={{ marginLeft: 8 }}>
· {membership.max_resolution}
</Text>
)}
</div>
</div>
<div>
<Text type="secondary"></Text>
<div className="xx-current-balance">
<ThunderboltOutlined style={{ color: "#8b5cf6" }} />
<span className="xx-current-balance-val">{bal}</span>
</div>
</div>
{!isMember && freeLimit > 0 && (
<div>
<Text type="secondary"></Text>
<Text type="secondary"></Text>
<div>
<Text strong>{freeUsed}</Text>
<Text type="secondary"> / {freeLimit} </Text>
{!isMember && (
<Tag color="blue" style={{ marginLeft: 8 }}>
{freeRemain}
{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>
)}
</Space>
</Col>
<Col>
<Button
type="primary"
icon={<ThunderboltOutlined />}
onClick={() => {
const el = document.getElementById("points-packages")
el?.scrollIntoView({ behavior: "smooth" })
}}
>
</Button>
</Col>
</Row>
</Card>
<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 }}>
@@ -342,7 +250,7 @@ const Plans: React.FC = () => {
{/* 计费周期切换 */}
<div className="xx-billing-switch">
{billingOptions.map((p) => (
{SUBSCRIPTION_PLANS.map((p) => (
<button
key={p.id}
type="button"
@@ -355,11 +263,7 @@ const Plans: React.FC = () => {
<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_cents / 100)
.toFixed(p.price_cents % 100 === 0 ? 0 : 1)
.replace(/\.0$/, "")}
</span>
<span className="xx-billing-amount">{p.price_yuan}</span>
</div>
<div className="xx-billing-sub">
¥{p.per_month_yuan}/
@@ -399,8 +303,8 @@ const Plans: React.FC = () => {
</li>
))}
</ul>
<Button block size="large" disabled={!isMember ? false : true}>
{!isMember ? "当前方案" : "免费方案"}
<Button block size="large" disabled>
</Button>
</Card>
</Col>
@@ -413,21 +317,15 @@ const Plans: React.FC = () => {
</div>
<div className="xx-plan-head">
<Title level={4} style={{ margin: 0, color: "#7c3aed" }}>
<CrownFilled style={{ color: "#f59e0b" }} /> {selectedPlan?.name || "付费会员"}
<CrownFilled style={{ color: "#f59e0b" }} />
</Title>
<div className="xx-plan-price">
<span className="currency">¥</span>
<span className="amount">
{selectedPlan
? (selectedPlan.price_cents / 100)
.toFixed(selectedPlan.price_cents % 100 === 0 ? 0 : 1)
.replace(/\.0$/, "")
: "—"}
</span>
<span className="period">{selectedPlan?.billing_label || ""}</span>
<span className="amount">{selectedPlan.price_yuan}</span>
<span className="period">{selectedPlan.billing_label}</span>
</div>
<Text type="secondary">
¥{selectedPlan?.per_month_yuan}/ · AI
¥{selectedPlan.per_month_yuan}/ · AI
</Text>
</div>
<Divider style={{ margin: "16px 0" }} />
@@ -444,11 +342,11 @@ const Plans: React.FC = () => {
size="large"
type="primary"
loading={subscribing}
disabled={isMember && memberPlanId === selectedBilling}
disabled={isMember && memberType === selectedBilling}
onClick={handleSubscribe}
icon={<ThunderboltOutlined />}
>
{isMember && memberPlanId === selectedBilling
{isMember && memberType === selectedBilling
? "当前方案"
: isMember
? "续费/升级"
@@ -475,22 +373,19 @@ const Plans: React.FC = () => {
<Row gutter={[16, 16]}>
{packages.map((pkg) => {
const priceCents = getDiscountPriceCents(pkg, userDiscount)
const originalCents = pkg.price_cents
const discount =
priceCents < originalCents ? Math.round((1 - priceCents / originalCents) * 100) : 0
const unit = priceCents / 100 / pkg.points
const isHot = pkg.unit_price < 0.1
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.code}>
<Col xs={24} sm={8} key={pkg.id}>
<Card
className={`xx-pkg-card ${discount > 0 ? "has-discount" : ""} ${isHot ? "recommended" : ""}`}
className={`xx-pkg-card ${discount > 0 ? "has-discount" : ""} ${pkg.id === "basic_pack" ? "recommended" : ""}`}
hoverable
>
{isHot && <div className="xx-pkg-badge"></div>}
{pkg.id === "basic_pack" && <div className="xx-pkg-badge"></div>}
{discount > 0 && (
<Tag color="gold" className="xx-pkg-discount">
{Math.round((priceCents / originalCents) * 10) / 1}
{10 - discount / 10}
</Tag>
)}
<div className="xx-pkg-name">{pkg.name}</div>
@@ -500,19 +395,17 @@ const Plans: React.FC = () => {
<div className="xx-pkg-price">
<span className="currency">¥</span>
<span className="amount">
{(priceCents / 100)
.toFixed(priceCents % 100 === 0 ? 0 : 1)
.replace(/\.0$/, "")}
{(price / 100).toFixed(price % 100 === 0 ? 0 : 1).replace(/\.0$/, "")}
</span>
{discount > 0 && (
<span className="xx-pkg-origin">¥{(originalCents / 100).toFixed(0)}</span>
<span className="xx-pkg-origin">¥{(pkg.price / 100).toFixed(0)}</span>
)}
</div>
<div className="xx-pkg-unit">¥{unit.toFixed(3)}/</div>
<Button
block
type={isHot ? "primary" : "default"}
loading={buying === pkg.code}
type={pkg.id === "basic_pack" ? "primary" : "default"}
loading={buying === pkg.id}
onClick={() => handleBuyPoints(pkg)}
style={{ marginTop: 12 }}
>
@@ -1,21 +1,18 @@
/**
* 升级/降级/续费页面(#1894 清理后:价格统一走 /subscription/plans API
* 升级/降级/续费页面
* P1-3: antd Button/Modal/Radio/Spin → 自定义 UI 组件
*/
import React, { useEffect, useMemo, useState } from "react"
import React from "react"
import { Modal } from "@/components/ui"
import { useNavigate } from "react-router-dom"
import type { PlanId, SubscriptionPlan, BillingCycle } from "@/api/subscription/types"
import type { PlanType } from "@/api/subscription"
import PageHead from "@/components/layout/PageHead"
import { Button } from "@/components/ui"
import { getPlanName } from "./constants"
import { getSubscriptionPlans } from "@/api/subscription"
import { PLANS_META, getPlanName, getPlanPrice } from "./constants"
import { BillingCycleSwitch, Spinner } from "./components/SubscriptionUI"
import { useSubscription } from "./hooks/useSubscription"
import "./UpgradeSubscription.css"
/** 可选择的付费档位(不含 free) */
const PAID_PLANS: PlanId[] = ["monthly", "quarterly", "yearly"]
const UpgradeSubscription: React.FC = () => {
const navigate = useNavigate()
const {
@@ -31,53 +28,18 @@ const UpgradeSubscription: React.FC = () => {
handleCancel,
} = useSubscription()
// #1894: 从 /subscription/plans 拉真实价格,不使用任何硬编码价格
const [apiPlans, setApiPlans] = useState<SubscriptionPlan[]>([])
useEffect(() => {
getSubscriptionPlans()
.then((r) => setApiPlans(r.plans))
.catch(() => setApiPlans([]))
}, [])
const planMap = useMemo(() => {
const m = new Map<string, SubscriptionPlan>()
apiPlans.forEach((p) => m.set(p.plan_id, p))
return m
}, [apiPlans])
const getPlanDisplay = (planId: PlanId) => {
const apiPlan = planMap.get(planId)
const name = apiPlan?.name ?? getPlanName(planId)
// 年卡按年价,月/季卡按总价
const isYearly = planId === "yearly"
const priceCents = apiPlan?.price_cents ?? 0
const monthlyCents =
apiPlan?.monthly_price_cents ??
(apiPlan && apiPlan.duration_days > 0
? Math.round(apiPlan.price_cents / (apiPlan.duration_days / 30))
: 0)
return {
name,
priceYuan: priceCents / 100,
monthlyYuan: monthlyCents / 100,
billingLabel: isYearly ? "/年" : planId === "quarterly" ? "/季" : "/月",
}
}
const handleUpgradeClick = () => {
if (!subscription) return
if (selectedPlan === subscription.plan_id && billingCycle === subscription.billing_cycle) {
return
}
const display = getPlanDisplay(selectedPlan)
const cycleLabel = billingCycle === "monthly" ? "月付" : "年付"
const priceLabel =
display.priceYuan > 0 ? `费用 ¥${display.priceYuan}${display.billingLabel}` : "免费"
const plan = PLANS_META[selectedPlan]
const price = getPlanPrice(selectedPlan, billingCycle)
Modal.confirm({
title: "确认变更套餐",
content: `即将变更为「${display.name}」(${cycleLabel}),${priceLabel}。变更立即生效。`,
content: `即将变更为「${plan.name}」(${billingCycle === "monthly" ? "月付" : "年付"}),${price > 0 ? `费用 ¥${price}${billingCycle === "monthly" ? "/月" : "/年"}` : "免费"}。变更立即生效。`,
okText: "确认变更",
cancelText: "取消",
onOk: executeChangePlan,
@@ -87,12 +49,12 @@ const UpgradeSubscription: React.FC = () => {
const handleCancelClick = () => {
Modal.confirm({
title: "确认取消订阅",
content: "取消后,当前周期结束前仍可正常使用,到期后降级为免费版。",
content: "取消后,当前周期结束前仍可正常使用,到期后降级为体验版。",
okText: "确认取消",
cancelText: "再想想",
onOk: async () => {
const ok = await handleCancel()
if (ok) navigate("/subscription")
if (ok) navigate("/app/subscription")
},
})
}
@@ -112,31 +74,23 @@ const UpgradeSubscription: React.FC = () => {
<PageHead title="变更订阅方案" description={`当前套餐:${getPlanName(currentPlan)}`} />
<div className="xx-upgrade-plans">
{PAID_PLANS.map((planId) => {
const display = getPlanDisplay(planId)
{(["standard", "pro", "enterprise"] as PlanType[]).map((planId) => {
const plan = PLANS_META[planId]
const isCurrent = planId === currentPlan
// 年卡显示年价,其他显示月价折算
const monthlyPrice = display.monthlyYuan
const yearlyPrice = display.priceYuan
// 选中季卡时默认切到月付周期;年卡切到年付
const resolvedCycle: BillingCycle = planId === "yearly" ? "yearly" : "monthly"
return (
<div
key={planId}
className={`xx-upgrade-card ${isCurrent ? "current" : ""} ${selectedPlan === planId ? "selected" : ""}`}
onClick={() => {
setSelectedPlan(planId)
setBillingCycle(resolvedCycle)
}}
onClick={() => setSelectedPlan(planId)}
>
{isCurrent && <div className="xx-current-badge"></div>}
<h3>{display.name}</h3>
<h3>{plan.name}</h3>
<div className="xx-price">
<BillingCycleSwitch
value={billingCycle}
onChange={setBillingCycle}
monthlyPrice={monthlyPrice}
yearlyPrice={yearlyPrice}
monthlyPrice={plan.price}
yearlyPrice={plan.yearlyPrice}
/>
</div>
</div>
+15 -12
View File
@@ -1,13 +1,16 @@
/**
* 订阅套餐元数据(#1894 清理后)
*
* - 套餐名兜底:API 失败时用 getPlanName 展示档位名
* - 价格已统一走 GET /subscription/plans 动态获取,此处不再硬编码价格
* (旧 getPlanPrice / PLANS_META.priceYuan / yearlyPriceYuan 已移除)
*/
import type { PlanId } from "@/api/subscription/types"
import { PLAN_LABEL } from "@/api/subscription/types"
import type { PlanType, BillingCycle } from "@/api/subscription"
/** 套餐展示名兜底(优先使用 API 返回的 plan.name / PLAN_LABEL */
export const getPlanName = (planId: PlanId | string): string =>
PLAN_LABEL[planId as PlanId] ?? "免费版"
export const PLANS_META: Record<string, { name: string; price: number; yearlyPrice: number }> = {
free: { name: "体验版", price: 0, yearlyPrice: 0 },
standard: { name: "标准版", price: 99, yearlyPrice: 990 },
pro: { name: "专业版", price: 299, yearlyPrice: 2990 },
enterprise: { name: "企业版", price: 0, yearlyPrice: 0 },
}
export const getPlanName = (planId: PlanType | string) => PLANS_META[planId]?.name ?? "体验版"
export const getPlanPrice = (planId: PlanType | string, cycle: BillingCycle) => {
const plan = PLANS_META[planId]
if (!plan) return 0
return cycle === "yearly" ? plan.yearlyPrice : plan.price
}
@@ -1,8 +1,3 @@
/**
* 订阅管理 Hook
* 封装订阅信息查询、套餐变更、自动续费切换、取消订阅等逻辑
* 对齐最终契约(plan_id=free/monthly/quarterly/yearly + billing_cycle=monthly/yearly
*/
import { useState, useEffect, useCallback } from "react"
import { message } from "antd"
import {
@@ -10,14 +5,20 @@ import {
changePlan,
toggleAutoRenew,
cancelSubscription,
type SubscriptionInfo,
type PlanType,
type BillingCycle,
} from "@/api/subscription"
import type { SubscriptionInfo, PlanId, BillingCycle } from "@/api/subscription/types"
/**
* 订阅管理 Hook
* 封装订阅信息查询、套餐变更、自动续费切换、取消订阅等逻辑
*/
export function useSubscription() {
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [selectedPlan, setSelectedPlan] = useState<PlanId>("monthly")
const [selectedPlan, setSelectedPlan] = useState<PlanType>("standard")
const [billingCycle, setBillingCycle] = useState<BillingCycle>("monthly")
const loadSubscription = useCallback(async () => {
@@ -25,7 +26,6 @@ export function useSubscription() {
const data = await getCurrentSubscription()
setSubscription(data)
setSelectedPlan(data.plan_id)
setBillingCycle(data.billing_cycle)
} catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("获取订阅信息失败")
} finally {
@@ -37,41 +37,45 @@ export function useSubscription() {
loadSubscription()
}, [loadSubscription])
const executeChangePlan = useCallback(async () => {
if (!subscription) return false
const handleUpgrade = useCallback(async () => {
if (!subscription) return
if (selectedPlan === subscription.plan_id && billingCycle === subscription.billing_cycle) {
message.info("当前已是该套餐")
return false
return
}
// 由调用方决定是否弹确认框
}, [subscription, selectedPlan, billingCycle])
const executeChangePlan = useCallback(async () => {
try {
setSubmitting(true)
const res = await changePlan({ target_plan_id: selectedPlan, billing_cycle: billingCycle })
const res = await changePlan({
target_plan_id: selectedPlan,
billing_cycle: billingCycle,
})
if (res.success) {
message.success(res.message)
setSubscription(res.new_subscription ?? null)
return true
} else {
message.error(res.message)
return false
}
} catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("套餐变更失败,请重试")
return false
} finally {
setSubmitting(false)
}
}, [subscription, selectedPlan, billingCycle])
}, [selectedPlan, billingCycle])
const handleToggleAutoRenew = useCallback(
async (enabled: boolean) => {
try {
const res = await toggleAutoRenew({ enabled })
const res = await toggleAutoRenew(enabled)
message.success(res.message)
if (subscription) setSubscription({ ...subscription, auto_renew: enabled })
return true
if (subscription) {
setSubscription({ ...subscription, auto_renew: enabled })
}
} catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("操作失败")
return false
}
},
[subscription],
@@ -89,6 +93,7 @@ export function useSubscription() {
}, [])
return {
// 状态
subscription,
loading,
submitting,
@@ -96,7 +101,9 @@ export function useSubscription() {
billingCycle,
setSelectedPlan,
setBillingCycle,
// 操作
loadSubscription,
handleUpgrade,
executeChangePlan,
handleToggleAutoRenew,
handleCancel,
+137
View File
@@ -0,0 +1,137 @@
/**
* 标题库页面 — V21 设计系统
* 两栏布局:左侧分类列表(220px)+ 右侧标题卡片网格(3列)
* 支持:标题卡片展示、AI 生成标题、复制/编辑/删除、收藏、分类筛选、搜索
* 对接后端真实 APIGET/POST/PUT/DELETE /titles
*/
import React from "react"
import { useTitleLibrary } from "./hooks/useTitleLibrary"
import { useTitleEdit } from "./hooks/useTitleEdit"
import { useTitleAI } from "./hooks/useTitleAI"
import { CategorySidebar } from "./components/title-library/CategorySidebar"
import { FilterBar } from "./components/title-library/FilterBar"
import { TitleGrid } from "./components/title-library/TitleGrid"
import { CreateTitleModal } from "./components/title-library/CreateTitleModal"
import { AIGenerateModal } from "./components/title-library/AIGenerateModal"
import "./titles.css"
const TitleLibrary: React.FC = () => {
const {
categories,
activeCatId,
filteredTitles,
searchText,
filterType,
filterIndustry,
filterFrequency,
createMutation,
updateMutation,
setActiveCatId,
setSearchText,
setFilterType,
setFilterIndustry,
setFilterFrequency,
handleToggleFavorite,
handleCopy,
handleDelete,
} = useTitleLibrary()
const {
editingId,
editText,
setEditText,
createTitleModalOpen,
setCreateTitleModalOpen,
newTitleContent,
setNewTitleContent,
newTitleType,
setNewTitleType,
handleStartEdit,
handleSaveEdit,
handleCancelEdit,
handleCreateTitle,
handleCloseCreateModal,
} = useTitleEdit({ updateMutation, createMutation })
const {
aiModalOpen,
setAiModalOpen,
aiKeyword,
setAiKeyword,
aiLoading,
aiResults,
handleAIGenerate,
handleAdoptAITitle,
handleCopyAI,
handleCloseAIModal,
} = useTitleAI({ createMutation })
return (
<div className="xx-titles-page">
<div className="xx-titles-layout">
{/* 左侧:分类列表 */}
<CategorySidebar
categories={categories}
activeCatId={activeCatId}
onSelect={setActiveCatId}
/>
{/* 右侧:内容区 */}
<div className="xx-titles-content">
<FilterBar
searchText={searchText}
onSearchChange={setSearchText}
filterType={filterType}
onFilterTypeChange={setFilterType}
filterIndustry={filterIndustry}
onFilterIndustryChange={setFilterIndustry}
filterFrequency={filterFrequency}
onFilterFrequencyChange={setFilterFrequency}
onCreateClick={() => setCreateTitleModalOpen(true)}
onAIClick={() => setAiModalOpen(true)}
/>
<TitleGrid
titles={filteredTitles}
editingId={editingId}
editText={editText}
searchText={searchText}
onEditChange={setEditText}
onStartEdit={handleStartEdit}
onSaveEdit={handleSaveEdit}
onCancelEdit={handleCancelEdit}
onCopy={handleCopy}
onDelete={handleDelete}
onToggleFavorite={handleToggleFavorite}
/>
</div>
</div>
{/* 新建标题弹窗 */}
<CreateTitleModal
open={createTitleModalOpen}
newTitleContent={newTitleContent}
newTitleType={newTitleType}
onContentChange={setNewTitleContent}
onTypeChange={setNewTitleType}
onCancel={handleCloseCreateModal}
onSubmit={handleCreateTitle}
/>
{/* AI 生成标题弹窗 */}
<AIGenerateModal
open={aiModalOpen}
aiKeyword={aiKeyword}
aiLoading={aiLoading}
aiResults={aiResults}
onKeywordChange={setAiKeyword}
onGenerate={handleAIGenerate}
onCancel={handleCloseAIModal}
onCopy={handleCopyAI}
onAdopt={handleAdoptAITitle}
/>
</div>
)
}
export default TitleLibrary
@@ -0,0 +1,121 @@
import React from "react"
import Modal from "@/components/ui/Modal"
import { CopyOutlined, CheckOutlined } from "@ant-design/icons"
import { Button, Input } from "@/components/ui"
import { AI_KEYWORD_MAX_LENGTH } from "../../constants/titleLibrary"
interface AIGenerateModalProps {
open: boolean
aiKeyword: string
aiLoading: boolean
aiResults: string[]
onKeywordChange: (keyword: string) => void
onGenerate: () => void
onCancel: () => void
onCopy: (text: string) => void
onAdopt: (text: string) => void
}
export const AIGenerateModal: React.FC<AIGenerateModalProps> = ({
open,
aiKeyword,
aiLoading,
aiResults,
onKeywordChange,
onGenerate,
onCancel,
onCopy,
onAdopt,
}) => {
return (
<Modal
title="AI 生成标题"
open={open}
onCancel={onCancel}
onOk={onGenerate}
okText={aiLoading ? "生成中..." : "生成"}
cancelText="关闭"
okButtonProps={{ disabled: aiLoading }}
destroyOnClose
width={640}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
}}
>
<div>
<div
style={{
marginBottom: 6,
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
}}
>
</div>
<Input
placeholder="例如:美食探店、科技评测、旅行攻略..."
value={aiKeyword}
onChange={(e) => onKeywordChange(e.target.value)}
maxLength={AI_KEYWORD_MAX_LENGTH}
onPressEnter={onGenerate}
/>
</div>
{/* AI 加载动画 */}
{aiLoading && (
<div className="xx-ai-loading">
<div className="xx-ai-loading-dots">
<div className="xx-ai-loading-dot" />
<div className="xx-ai-loading-dot" />
<div className="xx-ai-loading-dot" />
</div>
<span>AI ...</span>
</div>
)}
{/* AI 生成结果列表 */}
{aiResults.length > 0 && (
<div className="xx-ai-results">
<div
style={{
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
marginBottom: 4,
}}
>
{aiResults.length}
</div>
{aiResults.map((text, idx) => (
<div key={idx} className="xx-ai-result-item">
<span className="xx-ai-result-text">{text}</span>
<div className="xx-ai-result-actions">
<Button
buttonType="ghost"
buttonSize="sm"
icon={<CopyOutlined />}
onClick={() => onCopy(text)}
>
</Button>
<Button
buttonType="primary"
buttonSize="sm"
icon={<CheckOutlined />}
onClick={() => onAdopt(text)}
>
</Button>
</div>
</div>
))}
</div>
)}
</div>
</Modal>
)
}
@@ -0,0 +1,44 @@
import React from "react"
import { FileTextOutlined } from "@ant-design/icons"
import type { CategoryItem } from "../../types/titleLibrary"
interface CategorySidebarProps {
categories: CategoryItem[]
activeCatId: string
onSelect: (catId: string) => void
}
export const CategorySidebar: React.FC<CategorySidebarProps> = ({
categories,
activeCatId,
onSelect,
}) => {
return (
<div className="xx-title-category-list">
{categories.map((cat) => (
<div
key={cat.id}
className={`xx-title-category-item${cat.id === activeCatId ? " active" : ""}`}
onClick={() => onSelect(cat.id)}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flex: 1,
minWidth: 0,
}}
>
<div style={{ minWidth: 0 }}>
<h4 style={{ margin: 0 }}>
<FileTextOutlined /> {cat.name}
</h4>
<span>{cat.count} </span>
</div>
</div>
</div>
))}
</div>
)
}
@@ -0,0 +1,89 @@
import React from "react"
import Modal from "@/components/ui/Modal"
import { Input, Select } from "@/components/ui"
import type { TitleType } from "../../types/titleLibrary"
import { TITLE_MAX_LENGTH } from "../../constants/titleLibrary"
const TITLE_TYPE_CREATE_OPTIONS: Array<{ value: TitleType; label: string }> = [
{ value: "hot", label: "爆款" },
{ value: "normal", label: "常规" },
{ value: "creative", label: "创意" },
]
interface CreateTitleModalProps {
open: boolean
newTitleContent: string
newTitleType: TitleType
onContentChange: (content: string) => void
onTypeChange: (type: TitleType) => void
onCancel: () => void
onSubmit: () => void
}
export const CreateTitleModal: React.FC<CreateTitleModalProps> = ({
open,
newTitleContent,
newTitleType,
onContentChange,
onTypeChange,
onCancel,
onSubmit,
}) => {
return (
<Modal
title="新建标题"
open={open}
onCancel={onCancel}
onOk={onSubmit}
okText="创建"
cancelText="取消"
destroyOnClose
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
}}
>
<div>
<div
style={{
marginBottom: 6,
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
}}
>
</div>
<Input.TextArea
placeholder="请输入标题内容"
value={newTitleContent}
onChange={(e) => onContentChange(e.target.value)}
rows={3}
maxLength={TITLE_MAX_LENGTH}
showCount
/>
</div>
<div>
<div
style={{
marginBottom: 6,
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
}}
>
</div>
<Select
value={newTitleType}
onChange={(v) => onTypeChange(v as TitleType)}
style={{ width: "100%" }}
options={TITLE_TYPE_CREATE_OPTIONS}
/>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,76 @@
import React from "react"
import { SearchOutlined, PlusOutlined, RobotOutlined } from "@ant-design/icons"
import { Button, Input, Select } from "@/components/ui"
import type { Frequency } from "../../types/titleLibrary"
import {
TITLE_TYPE_OPTIONS,
INDUSTRY_OPTIONS,
FREQUENCY_OPTIONS,
} from "../../constants/titleLibrary"
interface FilterBarProps {
searchText: string
onSearchChange: (text: string) => void
filterType: string
onFilterTypeChange: (value: string) => void
filterIndustry: string
onFilterIndustryChange: (value: string) => void
filterFrequency: Frequency
onFilterFrequencyChange: (value: Frequency) => void
onCreateClick: () => void
onAIClick: () => void
}
export const FilterBar: React.FC<FilterBarProps> = ({
searchText,
onSearchChange,
filterType,
onFilterTypeChange,
filterIndustry,
onFilterIndustryChange,
filterFrequency,
onFilterFrequencyChange,
onCreateClick,
onAIClick,
}) => {
return (
<div className="xx-titles-filters">
<div className="xx-titles-filters-left">
<Input
placeholder="搜索标题关键词..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
allowClear
style={{ width: 220 }}
/>
<Select
value={filterType}
onChange={onFilterTypeChange}
style={{ width: 110 }}
options={TITLE_TYPE_OPTIONS}
/>
<Select
value={filterIndustry}
onChange={onFilterIndustryChange}
style={{ width: 110 }}
options={INDUSTRY_OPTIONS}
/>
<Select
value={filterFrequency}
onChange={(v) => onFilterFrequencyChange(v as Frequency)}
style={{ width: 120 }}
options={FREQUENCY_OPTIONS}
/>
</div>
<div className="xx-titles-filters-right">
<Button buttonType="ghost" buttonSize="sm" icon={<PlusOutlined />} onClick={onCreateClick}>
</Button>
<Button buttonType="primary" buttonSize="sm" icon={<RobotOutlined />} onClick={onAIClick}>
AI
</Button>
</div>
</div>
)
}
@@ -0,0 +1,121 @@
import React from "react"
import { Popconfirm } from "antd"
import {
StarOutlined,
StarFilled,
EditOutlined,
CopyOutlined,
DeleteOutlined,
CheckOutlined,
} from "@ant-design/icons"
import type { TitleData } from "../../types/titleLibrary"
import { typeLabel } from "../../utils/titleLibrary"
interface TitleCardProps {
title: TitleData
isEditing: boolean
editText: string
onEditChange: (text: string) => void
onStartEdit: () => void
onSaveEdit: () => void
onCancelEdit: () => void
onCopy: () => void
onDelete: () => void
onToggleFavorite: () => void
}
export const TitleCard: React.FC<TitleCardProps> = ({
title,
isEditing,
editText,
onEditChange,
onStartEdit,
onSaveEdit,
onCancelEdit,
onCopy,
onDelete,
onToggleFavorite,
}) => {
return (
<div className="xx-title-card">
{/* 收藏按钮 */}
<button
className="xx-title-fav-btn"
onClick={onToggleFavorite}
title={title.isFavorited ? "取消收藏" : "收藏"}
style={{
position: "absolute",
top: 12,
right: 12,
color: title.isFavorited ? "#f59e0b" : "var(--text-tertiary)",
}}
>
{title.isFavorited ? <StarFilled /> : <StarOutlined />}
</button>
{/* 标题文本 / 编辑区 */}
{isEditing ? (
<textarea
className="xx-title-card-edit"
value={editText}
onChange={(e) => onEditChange(e.target.value)}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
onSaveEdit()
}
if (e.key === "Escape") {
onCancelEdit()
}
}}
/>
) : (
<div className="xx-title-card-text" style={{ paddingRight: 24 }}>
{title.content}
</div>
)}
{/* 底部元信息 */}
<div className="xx-title-card-meta">
<div className="xx-title-card-meta-left">
<span className={`xx-title-type-tag ${title.type}`}>{typeLabel(title.type)}</span>
<span className="xx-title-card-stat">使 {title.usageCount} </span>
<span className="xx-title-card-stat">{title.createdAt}</span>
</div>
<div className="xx-title-card-actions">
{isEditing ? (
<>
<button className="xx-title-card-action-btn" onClick={onSaveEdit} title="保存">
<CheckOutlined />
</button>
<button className="xx-title-card-action-btn" onClick={onCancelEdit} title="取消">
</button>
</>
) : (
<>
<button className="xx-title-card-action-btn" onClick={onCopy} title="复制">
<CopyOutlined />
</button>
<button className="xx-title-card-action-btn" onClick={onStartEdit} title="编辑">
<EditOutlined />
</button>
<Popconfirm
title="确定删除此标题?"
onConfirm={onDelete}
okText="删除"
cancelText="取消"
>
<button className="xx-title-card-action-btn danger" title="删除">
<DeleteOutlined />
</button>
</Popconfirm>
</>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,63 @@
import React from "react"
import { FileTextOutlined } from "@ant-design/icons"
import { TitleCard } from "./TitleCard"
import type { TitleData } from "../../types/titleLibrary"
interface TitleGridProps {
titles: TitleData[]
editingId: string | null
editText: string
searchText: string
onEditChange: (text: string) => void
onStartEdit: (title: TitleData) => void
onSaveEdit: () => void
onCancelEdit: () => void
onCopy: (title: TitleData) => void
onDelete: (id: string) => void
onToggleFavorite: (id: string) => void
}
export const TitleGrid: React.FC<TitleGridProps> = ({
titles,
editingId,
editText,
searchText,
onEditChange,
onStartEdit,
onSaveEdit,
onCancelEdit,
onCopy,
onDelete,
onToggleFavorite,
}) => {
if (titles.length > 0) {
return (
<div className="xx-title-grid">
{titles.map((title) => (
<TitleCard
key={title.id}
title={title}
isEditing={editingId === title.id}
editText={editingId === title.id ? editText : ""}
onEditChange={onEditChange}
onStartEdit={() => onStartEdit(title)}
onSaveEdit={onSaveEdit}
onCancelEdit={onCancelEdit}
onCopy={() => onCopy(title)}
onDelete={() => onDelete(title.id)}
onToggleFavorite={() => onToggleFavorite(title.id)}
/>
))}
</div>
)
}
return (
<div className="xx-titles-empty">
<div className="xx-titles-empty-icon">
<FileTextOutlined />
</div>
<p>{searchText ? "未找到匹配的标题" : "暂无标题,点击「新建标题」或「AI 生成标题」开始"}</p>
</div>
)
}
@@ -0,0 +1,34 @@
import type { TitleType, Industry, Frequency } from "../types/titleLibrary"
export const TITLE_TYPE_OPTIONS: Array<{ value: TitleType | "all"; label: string }> = [
{ value: "all", label: "全部类型" },
{ value: "hot", label: "爆款" },
{ value: "normal", label: "常规" },
{ value: "creative", label: "创意" },
]
export const INDUSTRY_OPTIONS: Array<{ value: Industry | "all"; label: string }> = [
{ value: "all", label: "全部行业" },
{ value: "food", label: "美食" },
{ value: "tech", label: "科技" },
{ value: "beauty", label: "美妆" },
{ value: "education", label: "教育" },
{ value: "travel", label: "旅行" },
]
export const FREQUENCY_OPTIONS: Array<{ value: Frequency; label: string }> = [
{ value: "all", label: "全部频率" },
{ value: "high", label: "高频使用" },
{ value: "medium", label: "中频使用" },
{ value: "low", label: "低频使用" },
]
export const FREQUENCY_THRESHOLDS = {
high: 100,
medium: 30,
} as const
export const AI_GENERATE_DELAY = 2000
export const TITLE_MAX_LENGTH = 200
export const AI_KEYWORD_MAX_LENGTH = 100
export const ALL_CATEGORY_ID = "cat-all"
@@ -0,0 +1,84 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import type { UseMutationResult } from "@tanstack/react-query"
import type { TitleItem } from "@/api/titles"
import { copyToClipboard } from "../utils/titleLibrary"
import { AI_GENERATE_DELAY } from "../constants/titleLibrary"
interface UseTitleAIProps {
createMutation: UseMutationResult<TitleItem, Error, string, unknown>
}
const generateMockTitles = (keyword: string): string[] => [
`${keyword}:这个方法让我事半功倍!`,
`关于${keyword}99%的人都不知道的事`,
`${keyword}全攻略,看完这篇就够了`,
`我花了 3 个月研究${keyword},总结出这些经验`,
`${keyword}避坑指南,帮你省下 1000 块`,
]
export const useTitleAI = ({ createMutation }: UseTitleAIProps) => {
const [aiModalOpen, setAiModalOpen] = useState(false)
const [aiKeyword, setAiKeyword] = useState("")
const [aiLoading, setAiLoading] = useState(false)
const [aiResults, setAiResults] = useState<string[]>([])
/* AI 生成标题 */
const handleAIGenerate = useCallback(() => {
if (!aiKeyword.trim()) {
message.warning("请输入关键词或主题")
return
}
setAiLoading(true)
setAiResults([])
setTimeout(() => {
const results = generateMockTitles(aiKeyword.trim())
setAiResults(results)
setAiLoading(false)
}, AI_GENERATE_DELAY)
}, [aiKeyword])
/* 采纳 AI 生成的标题 */
const handleAdoptAITitle = useCallback(
(text: string) => {
createMutation.mutate(text, {
onSuccess: () => {
message.success("标题已采纳并添加到标题库")
},
})
},
[createMutation],
)
/* 复制 AI 生成的标题 */
const handleCopyAI = useCallback(async (text: string) => {
const ok = await copyToClipboard(text)
if (ok) {
message.success("已复制到剪贴板")
} else {
message.error("复制失败")
}
}, [])
/* 关闭 AI 弹窗 */
const handleCloseAIModal = useCallback(() => {
setAiModalOpen(false)
setAiLoading(false)
setAiResults([])
setAiKeyword("")
}, [])
return {
aiModalOpen,
setAiModalOpen,
aiKeyword,
setAiKeyword,
aiLoading,
aiResults,
handleAIGenerate,
handleAdoptAITitle,
handleCopyAI,
handleCloseAIModal,
}
}
@@ -0,0 +1,87 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import type { TitleData, TitleType } from "../types/titleLibrary"
import type { UseMutationResult } from "@tanstack/react-query"
import type { TitleItem } from "@/api/titles"
interface UseTitleEditProps {
updateMutation: UseMutationResult<TitleItem, Error, { id: string; content: string }, unknown>
createMutation: UseMutationResult<TitleItem, Error, string, unknown>
}
export const useTitleEdit = ({ updateMutation, createMutation }: UseTitleEditProps) => {
/* 编辑状态 */
const [editingId, setEditingId] = useState<string | null>(null)
const [editText, setEditText] = useState("")
/* 新建标题弹窗 */
const [createTitleModalOpen, setCreateTitleModalOpen] = useState(false)
const [newTitleContent, setNewTitleContent] = useState("")
const [newTitleType, setNewTitleType] = useState<TitleType>("normal")
/* 开始编辑 */
const handleStartEdit = useCallback((title: TitleData) => {
setEditingId(title.id)
setEditText(title.content)
}, [])
/* 保存编辑 */
const handleSaveEdit = useCallback(() => {
if (!editText.trim()) {
message.warning("标题内容不能为空")
return
}
if (editingId) {
updateMutation.mutate({ id: editingId, content: editText.trim() })
}
setEditingId(null)
setEditText("")
message.success("标题已更新")
}, [editingId, editText, updateMutation])
/* 取消编辑 */
const handleCancelEdit = useCallback(() => {
setEditingId(null)
setEditText("")
}, [])
/* 新建标题提交 */
const handleCreateTitle = useCallback(() => {
if (!newTitleContent.trim()) {
message.warning("请输入标题内容")
return
}
createMutation.mutate(newTitleContent.trim(), {
onSuccess: () => {
setCreateTitleModalOpen(false)
setNewTitleContent("")
setNewTitleType("normal")
message.success("标题创建成功")
},
})
}, [newTitleContent, createMutation])
/* 关闭新建弹窗 */
const handleCloseCreateModal = useCallback(() => {
setCreateTitleModalOpen(false)
setNewTitleContent("")
setNewTitleType("normal")
}, [])
return {
editingId,
editText,
setEditText,
createTitleModalOpen,
setCreateTitleModalOpen,
newTitleContent,
setNewTitleContent,
newTitleType,
setNewTitleType,
handleStartEdit,
handleSaveEdit,
handleCancelEdit,
handleCreateTitle,
handleCloseCreateModal,
}
}
@@ -0,0 +1,55 @@
import { useTitleFilters } from "./useTitleFilters"
import { useTitleMutations } from "./useTitleMutations"
import { useTitleData } from "./useTitleData"
import { useTitleActions } from "./useTitleActions"
export const useTitleLibrary = () => {
/* 数据获取与派生 */
const { titles, categories, activeCatId, activeCategory, setActiveCatId } = useTitleData()
/* 筛选 */
const {
searchText,
filterType,
filterIndustry,
filterFrequency,
setSearchText,
setFilterType,
setFilterIndustry,
setFilterFrequency,
filteredTitles,
} = useTitleFilters(titles, categories, activeCatId, activeCategory)
/* CRUD mutations */
const { createMutation, updateMutation, deleteMutation } = useTitleMutations()
/* 操作 handlers */
const { handleToggleFavorite, handleCopy, handleDelete } = useTitleActions(deleteMutation)
return {
/* 状态 */
titles,
categories,
activeCatId,
activeCategory,
filteredTitles,
searchText,
filterType,
filterIndustry,
filterFrequency,
/* mutations */
createMutation,
updateMutation,
deleteMutation,
/* setters */
setActiveCatId,
setSearchText,
setFilterType,
setFilterIndustry,
setFilterFrequency,
/* handlers */
handleToggleFavorite,
handleCopy,
handleDelete,
}
}
@@ -0,0 +1,34 @@
import { useCallback } from "react"
import { message } from "antd"
import type { UseMutationResult } from "@tanstack/react-query"
import type { TitleData } from "../../types/titleLibrary"
import { copyToClipboard } from "../../utils/titleLibrary"
export const useTitleActions = (
deleteMutation: UseMutationResult<void, Error, string, unknown>,
) => {
/* 操作:收藏 */
const handleToggleFavorite = useCallback((_id: string) => {
message.info("收藏功能即将上线")
}, [])
/* 操作:复制 */
const handleCopy = useCallback(async (title: TitleData) => {
const ok = await copyToClipboard(title.content)
if (ok) {
message.success("已复制到剪贴板")
} else {
message.error("复制失败")
}
}, [])
/* 操作:删除 */
const handleDelete = useCallback(
(id: string) => {
deleteMutation.mutate(id)
},
[deleteMutation],
)
return { handleToggleFavorite, handleCopy, handleDelete }
}
@@ -0,0 +1,47 @@
import { useMemo, useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { getTitles } from "@/api/titles"
import type { TitleData, CategoryItem } from "../../types/titleLibrary"
import { toTitleData } from "../../utils/titleLibrary"
import { ALL_CATEGORY_ID } from "../../constants/titleLibrary"
export const useTitleData = () => {
/* 分类 */
const [activeCatId, setActiveCatId] = useState<string>(ALL_CATEGORY_ID)
/* 数据获取 */
const { data: apiTitles = [] } = useQuery({
queryKey: ["titles"],
queryFn: getTitles,
staleTime: 30_000,
})
const titles: TitleData[] = useMemo(() => apiTitles.map(toTitleData), [apiTitles])
/* 动态派生分类 */
const categories: CategoryItem[] = useMemo(() => {
const cats = new Map<string, number>()
apiTitles.forEach((t) => {
const cat = t.category || "未分类"
cats.set(cat, (cats.get(cat) || 0) + 1)
})
return [
{ id: ALL_CATEGORY_ID, name: "全部标题", count: apiTitles.length },
...Array.from(cats.entries()).map(([name, count]) => ({
id: `cat-${name}`,
name,
count,
})),
]
}, [apiTitles])
const activeCategory = categories.find((c) => c.id === activeCatId)
return {
titles,
categories,
activeCatId,
activeCategory,
setActiveCatId,
}
}
@@ -0,0 +1,77 @@
import { useMemo, useState } from "react"
import type { TitleData, CategoryItem, Frequency } from "../../types/titleLibrary"
import { ALL_CATEGORY_ID, FREQUENCY_THRESHOLDS } from "../../constants/titleLibrary"
export const useTitleFilters = (
titles: TitleData[],
_categories: CategoryItem[],
activeCatId: string,
activeCategory: CategoryItem | undefined,
) => {
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState<string>("all")
const [filterIndustry, setFilterIndustry] = useState<string>("all")
const [filterFrequency, setFilterFrequency] = useState<Frequency>("all")
/* 派生:筛选后的标题列表 */
const filteredTitles = useMemo(() => {
let list = titles
/* 按分类过滤 */
if (activeCatId !== ALL_CATEGORY_ID) {
const catName = activeCategory?.name || ""
if (catName) {
list = list.filter((t) => t.category === catName)
}
}
/* 按类型筛选 */
if (filterType !== "all") {
list = list.filter((t) => t.type === filterType)
}
/* 按行业筛选 */
if (filterIndustry !== "all") {
list = list.filter((t) => t.industry === filterIndustry)
}
/* 按使用频率筛选 */
if (filterFrequency !== "all") {
switch (filterFrequency) {
case "high":
list = list.filter((t) => t.usageCount >= FREQUENCY_THRESHOLDS.high)
break
case "medium":
list = list.filter(
(t) =>
t.usageCount >= FREQUENCY_THRESHOLDS.medium &&
t.usageCount < FREQUENCY_THRESHOLDS.high,
)
break
case "low":
list = list.filter((t) => t.usageCount < FREQUENCY_THRESHOLDS.medium)
break
}
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase()
list = list.filter((t) => t.content.toLowerCase().includes(q))
}
return list
}, [titles, activeCatId, activeCategory, filterType, filterIndustry, filterFrequency, searchText])
return {
searchText,
setSearchText,
filterType,
setFilterType,
filterIndustry,
setFilterIndustry,
filterFrequency,
setFilterFrequency,
filteredTitles,
}
}
@@ -0,0 +1,34 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { createTitle, updateTitle, deleteTitle } from "@/api/titles"
export const useTitleMutations = () => {
const queryClient = useQueryClient()
const createMutation = useMutation({
mutationFn: (content: string) => createTitle({ content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("创建标题失败"),
})
const updateMutation = useMutation({
mutationFn: ({ id, content }: { id: string; content: string }) => updateTitle(id, { content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("更新标题失败"),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteTitle(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
message.success("标题已删除")
},
onError: () => message.error("删除标题失败"),
})
return { createMutation, updateMutation, deleteMutation }
}
+488
View File
@@ -0,0 +1,488 @@
/**
* 标题库页面 - V21 设计系统样式
* 两栏布局:左侧分类列表(220px)+ 右侧标题卡片网格(3列)
* 统一使用 CSS 变量,支持深色/浅色主题
*/
@import "../../styles/global.css";
/* ============================================================
页面容器
============================================================ */
.xx-titles-page {
min-height: 100%;
padding: var(--space-xl);
}
/* ============================================================
两栏布局
============================================================ */
.xx-titles-layout {
display: grid;
grid-template-columns: 220px 1fr;
gap: 20px;
align-items: start;
overflow: visible;
}
/* ============================================================
左侧分类列表
============================================================ */
.xx-title-category-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
position: sticky;
top: var(--space-md);
align-self: start;
}
.xx-title-category-item {
border: 1px solid var(--border-color);
background: var(--bg-primary);
border-radius: var(--radius-sm);
padding: 14px;
cursor: pointer;
transition: var(--transition-all);
display: flex;
align-items: center;
justify-content: space-between;
}
.xx-title-category-item:hover {
border-color: var(--primary-color);
background: var(--primary-soft);
}
.xx-title-category-item.active {
border-color: var(--primary-color);
background: var(--primary-soft);
box-shadow: var(--shadow-primary);
}
.xx-title-category-item h4 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.xx-title-category-item span {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.xx-title-category-delete {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 4px;
border-radius: var(--radius-xs);
font-size: var(--font-size-sm);
transition: var(--transition-all);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
flex-shrink: 0;
}
.xx-title-category-item:hover .xx-title-category-delete {
opacity: 1;
}
.xx-title-category-delete:hover {
color: var(--error-color);
background: var(--error-soft);
}
.xx-title-category-add {
border: 1px dashed var(--border-color);
background: transparent;
border-radius: var(--radius-sm);
padding: 14px;
cursor: pointer;
text-align: center;
color: var(--text-secondary);
font-size: var(--font-size-sm);
transition: var(--transition-all);
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
}
.xx-title-category-add:hover {
border-color: var(--primary-color);
color: var(--primary-color);
background: var(--primary-soft);
}
/* ============================================================
右侧内容区
============================================================ */
.xx-titles-content {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
/* ============================================================
筛选栏
============================================================ */
.xx-titles-filters {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
flex-wrap: wrap;
}
.xx-titles-filters-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.xx-titles-filters-right {
display: flex;
align-items: center;
gap: var(--space-sm);
}
/* ============================================================
标题卡片网格(3列)
============================================================ */
.xx-title-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
/* ============================================================
标题卡片
============================================================ */
.xx-title-card {
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;
position: relative;
}
.xx-title-card:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-sm);
}
/* 标题文本 */
.xx-title-card-text {
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
line-height: 1.5;
word-break: break-word;
/* 最多3行,超出省略 */
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 编辑态 */
.xx-title-card-edit {
width: 100%;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
line-height: 1.5;
border: 1px solid var(--primary-color);
border-radius: var(--radius-sm);
padding: 6px 10px;
background: var(--bg-primary);
outline: none;
resize: vertical;
min-height: 60px;
font-family: inherit;
}
.xx-title-card-edit:focus {
box-shadow: 0 0 0 2px var(--primary-soft);
}
/* 底部元信息 */
.xx-title-card-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
flex-wrap: wrap;
}
.xx-title-card-meta-left {
display: flex;
align-items: center;
gap: var(--space-xs);
flex-wrap: wrap;
}
/* 类型标签 */
.xx-title-type-tag {
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
white-space: nowrap;
}
.xx-title-type-tag.hot {
background: var(--error-soft);
color: var(--error-color);
}
.xx-title-type-tag.normal {
background: var(--primary-soft);
color: var(--primary-color);
}
.xx-title-type-tag.creative {
background: var(--warning-soft);
color: var(--warning-color);
}
/* 深色模式 */
.dark .xx-title-type-tag.hot,
[data-theme="dark"] .xx-title-type-tag.hot {
background: rgba(220, 38, 38, 0.15);
color: var(--error-color);
}
.dark .xx-title-type-tag.normal,
[data-theme="dark"] .xx-title-type-tag.normal {
background: var(--primary-soft);
color: var(--primary-color);
}
.dark .xx-title-type-tag.creative,
[data-theme="dark"] .xx-title-type-tag.creative {
background: rgba(202, 138, 4, 0.15);
color: var(--warning-color);
}
/* 使用次数 & 时间 */
.xx-title-card-stat {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
white-space: nowrap;
}
/* 操作按钮区 */
.xx-title-card-actions {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.xx-title-card-action-btn {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 4px 6px;
border-radius: var(--radius-xs);
font-size: var(--font-size-sm);
transition: var(--transition-all);
display: flex;
align-items: center;
gap: var(--space-xxs);
}
.xx-title-card-action-btn:hover {
color: var(--primary-color);
background: var(--primary-soft);
}
.xx-title-card-action-btn.danger:hover {
color: var(--error-color);
background: var(--error-soft);
}
/* 收藏按钮 */
.xx-title-fav-btn {
background: none;
border: none;
cursor: pointer;
font-size: var(--font-size-md);
padding: var(--space-xxs);
line-height: 1;
transition: var(--transition-all);
}
.xx-title-fav-btn:hover {
transform: scale(1.2);
}
/* ============================================================
空状态
============================================================ */
.xx-titles-empty {
text-align: center;
padding: var(--space-3xl) var(--space-xl);
color: var(--text-secondary);
}
.xx-titles-empty-icon {
font-size: var(--font-size-3xl);
margin-bottom: var(--space-md);
opacity: 0.5;
}
/* ============================================================
AI 生成结果列表
============================================================ */
.xx-ai-results {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-top: var(--space-md);
}
.xx-ai-result-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: 10px 14px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
transition: var(--transition-all);
}
.xx-ai-result-item:hover {
border-color: var(--primary-color);
background: var(--primary-soft);
}
.xx-ai-result-text {
flex: 1;
font-size: var(--font-size-base);
color: var(--text-primary);
line-height: 1.5;
}
.xx-ai-result-actions {
display: flex;
align-items: center;
gap: var(--space-xs);
flex-shrink: 0;
}
/* AI 加载动画 */
.xx-ai-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-md);
padding: var(--space-xl);
color: var(--text-secondary);
}
.xx-ai-loading-dots {
display: flex;
gap: 6px;
}
.xx-ai-loading-dot {
width: 10px;
height: 10px;
border-radius: var(--radius-full);
background: var(--primary-color);
animation: ai-dot-bounce 1.4s ease-in-out infinite both;
}
.xx-ai-loading-dot:nth-child(1) {
animation-delay: 0s;
}
.xx-ai-loading-dot:nth-child(2) {
animation-delay: 0.16s;
}
.xx-ai-loading-dot:nth-child(3) {
animation-delay: 0.32s;
}
@keyframes ai-dot-bounce {
0%,
80%,
100% {
transform: scale(0.4);
opacity: 0.4;
}
40% {
transform: scale(1);
opacity: 1;
}
}
/* ============================================================
响应式
============================================================ */
@media (max-width: 1200px) {
.xx-titles-layout {
grid-template-columns: 1fr;
}
.xx-title-category-list {
flex-direction: row;
overflow-x: auto;
position: static;
gap: var(--space-sm);
padding-bottom: var(--space-sm);
}
.xx-title-category-item {
min-width: 160px;
flex-shrink: 0;
}
.xx-title-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.xx-titles-page {
padding: var(--space-md);
}
.xx-title-grid {
grid-template-columns: 1fr;
}
.xx-titles-filters {
flex-direction: column;
align-items: stretch;
}
.xx-titles-filters-left,
.xx-titles-filters-right {
justify-content: flex-start;
}
}
@media (max-width: 480px) {
.xx-titles-page {
padding: var(--space-sm);
}
.xx-title-card {
padding: 12px;
}
}
@@ -0,0 +1,20 @@
export type TitleType = "hot" | "normal" | "creative"
export type Industry = "general" | "food" | "tech" | "beauty" | "education" | "travel"
export type Frequency = "all" | "high" | "medium" | "low"
export interface TitleData {
id: string
content: string
type: TitleType
industry: Industry
category: string
usageCount: number
isFavorited: boolean
createdAt: string
}
export interface CategoryItem {
id: string
name: string
count: number
}
@@ -0,0 +1,49 @@
import type { TitleData, TitleType } from "../types/titleLibrary"
import type { TitleItem } from "@/api/titles"
export const typeLabel = (type: TitleType): string => {
switch (type) {
case "hot":
return "爆款"
case "normal":
return "常规"
case "creative":
return "创意"
}
}
/** 后端 TitleItem → 前端 TitleData 映射 */
export const toTitleData = (item: TitleItem): TitleData => ({
id: item.id,
content: item.content,
type: (item.category as TitleType) || "normal",
industry: "general",
category: item.category || "未分类",
usageCount: 0,
isFavorited: false,
createdAt: item.created_at?.slice(0, 10) || "",
})
/** 复制文本到剪贴板 */
export const copyToClipboard = async (text: string): Promise<boolean> => {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
/* 降级方案 */
const textarea = document.createElement("textarea")
textarea.value = text
textarea.style.position = "fixed"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand("copy")
return true
} catch {
return false
} finally {
document.body.removeChild(textarea)
}
}
}
+4
View File
@@ -20,6 +20,10 @@ const appChildren: RouteObject[] = [
path: "assets",
lazy: lazyRoute(() => import("@/pages/assets/AssetLibrary")),
},
{
path: "titles",
lazy: lazyRoute(() => import("@/pages/titles/TitleLibrary")),
},
{
path: "scripts",
lazy: lazyRoute(() => import("@/pages/scripts/ScriptLibrary")),
+36 -83
View File
@@ -1,130 +1,83 @@
/**
* 积分 & 会员状态管理
* 对齐后端 staging 最终契约(2026-09-16):
* - balance: GET /points/balance(无 free_clips_*
* - dailyUsage: GET /usage/daily(每日免费额度)
* - membership: GET /points/subscription/membership(聚合会员信息)
* - rules: GET /points/rulesbase_points + free_user_multiplier
* - subscription: GET /subscription/currentplan_id + billing_cycle
* 积分 & 会员状态管理Zustand
* - 启动时拉取余额 & 订阅信息
* - 提供刷新、余额扣减(乐观更新)等工具
*/
import { create } from "zustand"
import { getPointsBalance, getPointsRules, getDailyUsage, getMembership } from "@/api/points"
import { getCurrentSubscription } from "@/api/subscription"
import type {
PointsBalance,
PointsRulesResponse,
DailyUsage,
MembershipResponse,
} from "@/api/points/types"
import type { SubscriptionInfo } from "@/api/subscription/types"
import type { PointsBalance, PointsRulesResponse, SubscriptionCurrent } from "@/api/points/types"
import { getCurrentSubscription, getPointsBalance, getPointsRules } from "@/api/points"
interface PointsState {
/** 积分余额 & 会员状态(来自 /points/balance */
balance: PointsBalance | null
dailyUsage: DailyUsage | null
membership: MembershipResponse | null
/** 订阅详情(来自 /subscription/current */
subscription: SubscriptionCurrent | null
/** 积分消耗规则缓存 */
rules: PointsRulesResponse | null
subscription: SubscriptionInfo | null
loading: boolean
error: string | null
/** 初始化:并行拉取 balance / rules / subscription / dailyUsage / membership */
/** 初始化:拉取余额 + 订阅信息 + 规则 */
init: () => Promise<void>
/** 刷新余额(充值/消费后调用) */
/** 强制刷新余额 */
refreshBalance: () => Promise<void>
/** 乐观扣减 */
/** 乐观扣减:在支付/业务发起前调用,失败时用 refreshBalance 兜底 */
optimisticDeduct: (points: number) => void
/** 乐观增加 */
/** 乐观增加(充值成功后调用) */
optimisticAdd: (points: number) => void
/** 清除积分状态(退出登录) */
reset: () => void
}
export const usePointsStore = create<PointsState>((set, get) => ({
balance: null,
dailyUsage: null,
membership: null,
rules: null,
subscription: null,
rules: null,
loading: false,
error: null,
init: async () => {
// 已加载过不重复拉取
if (get().balance && get().rules && get().subscription) return
if (get().loading) return
set({ loading: true, error: null })
try {
const [balance, rules, subscription, dailyUsage, membership] = await Promise.all([
getPointsBalance().catch(() => null),
getPointsRules().catch(() => null),
getCurrentSubscription().catch(() => null),
getDailyUsage().catch(() => null),
getMembership().catch(() => null),
const [balance, sub, rules] = await Promise.all([
getPointsBalance(),
getCurrentSubscription(),
getPointsRules(),
])
set({
balance,
rules,
subscription,
dailyUsage,
membership,
loading: false,
})
} catch (err) {
set({ error: (err as Error).message || "加载积分信息失败", loading: false })
set({ balance, subscription: sub, rules, loading: false })
} catch (e) {
set({ error: (e as Error).message, loading: false })
}
},
refreshBalance: async () => {
try {
const [balance, dailyUsage, membership, subscription] = await Promise.all([
getPointsBalance(),
getDailyUsage().catch(() => null),
getMembership().catch(() => null),
getCurrentSubscription().catch(() => get().subscription),
])
set({ balance, dailyUsage, membership, subscription })
} catch (err) {
set({ error: (err as Error).message || "刷新积分失败" })
const balance = await getPointsBalance()
set({ balance })
} catch (e) {
set({ error: (e as Error).message })
}
},
optimisticDeduct: (points: number) => {
const { balance, membership } = get()
if (!balance) return
const b = get().balance
if (!b) return
set({
balance: {
...balance,
balance: Math.max(0, balance.balance - points),
total_spent: balance.total_spent + points,
...b,
balance: Math.max(0, b.balance - points),
total_spent: b.total_spent + points,
},
membership: membership
? { ...membership, points_balance: Math.max(0, membership.points_balance - points) }
: null,
})
},
optimisticAdd: (points: number) => {
const { balance, membership } = get()
if (!balance) return
const b = get().balance
if (!b) return
set({
balance: {
...balance,
balance: balance.balance + points,
total_earned: balance.total_earned + points,
...b,
balance: b.balance + points,
total_earned: b.total_earned + points,
},
membership: membership
? { ...membership, points_balance: membership.points_balance + points }
: null,
})
},
reset: () => {
set({
balance: null,
dailyUsage: null,
membership: null,
rules: null,
subscription: null,
loading: false,
error: null,
})
},
}))
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import { getTitles, createTitle, updateTitle, deleteTitle, batchImportTitles } from "@/api/titles"
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
const mockPatch = vi.fn()
vi.mock("@/api/client", () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
patch: (...args: unknown[]) => mockPatch(...args),
},
}))
vi.mock("antd", () => ({ message: { error: vi.fn(), success: vi.fn() } }))
vi.mock("@/store/authStore", () => ({ useAuthStore: { getState: vi.fn(() => ({})) } }))
describe("titles API", () => {
beforeEach(() => {
vi.clearAllMocks()
mockGet.mockResolvedValue({ data: { success: true, items: [] } })
mockPost.mockResolvedValue({ data: { success: true, items: [] } })
mockPut.mockResolvedValue({ data: { success: true, items: [] } })
mockDelete.mockResolvedValue({ data: { success: true, items: [] } })
mockPatch.mockResolvedValue({ data: { success: true, items: [] } })
})
describe("getTitles", () => {
it("should resolve successfully", async () => {
await expect(getTitles()).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(getTitles()).rejects.toThrow()
})
})
describe("createTitle", () => {
it("should resolve successfully", async () => {
await expect(createTitle({ title: "测试标题", content: "测试内容" })).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(createTitle({ name: "test-item" })).rejects.toThrow()
})
})
describe("updateTitle", () => {
it("should resolve successfully", async () => {
await expect(updateTitle("test-titleId", { title: "新标题" })).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(updateTitle("test-titleId")).rejects.toThrow()
})
})
describe("deleteTitle", () => {
it("should resolve successfully", async () => {
await expect(deleteTitle("test-titleId")).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(deleteTitle("test-titleId")).rejects.toThrow()
})
})
describe("batchImportTitles", () => {
it("should resolve successfully", async () => {
await expect(batchImportTitles("test-titles")).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(batchImportTitles("test-titles")).rejects.toThrow()
})
})
})
@@ -45,60 +45,49 @@ vi.mock("@/config/navigation", () => ({
],
}))
// mock antd icons — 透传未显式 mock 的图标,避免 PointsBadge 等子组件引用新图标时报错
vi.mock("@ant-design/icons", async () => {
const actual = (await vi.importActual<typeof import("@ant-design/icons")>(
"@ant-design/icons",
)) as Record<string, unknown>
return {
...actual,
LogoutOutlined: () => <span data-testid="logout-icon" />,
SettingOutlined: () => <span data-testid="setting-icon" />,
UserOutlined: () => <span data-testid="user-icon" />,
MenuOutlined: () => <span data-testid="menu-icon" />,
}
})
// mock antd icons
vi.mock("@ant-design/icons", () => ({
LogoutOutlined: () => <span data-testid="logout-icon" />,
SettingOutlined: () => <span data-testid="setting-icon" />,
UserOutlined: () => <span data-testid="user-icon" />,
MenuOutlined: () => <span data-testid="menu-icon" />,
}))
// mock antd components — 用 importActual 透传未显式覆盖的组件(Popover/Button/Tag/Typography/Badge 等),
// 避免 Header 子组件(PointsBadge)使用新 antd 导出时出现 "No xxx export is defined on the antd mock"
vi.mock("antd", async () => {
const actual = (await vi.importActual<typeof import("antd")>("antd")) as Record<string, unknown>
return {
...actual,
Avatar: ({ children, className }: any) => (
<span data-testid="mock-avatar" className={className}>
{children}
</span>
),
Dropdown: ({ children, menu }: any) => (
<div data-testid="mock-dropdown">
{children}
<div data-testid="dropdown-menu" style={{ display: "none" }}>
{menu.items?.map((item: any, idx: number) => (
<div key={idx} data-testid={`menu-item-${item.key}`} onClick={item.onClick}>
{item.label}
</div>
))}
</div>
// mock antd components
vi.mock("antd", () => ({
Avatar: ({ children, className }: any) => (
<span data-testid="mock-avatar" className={className}>
{children}
</span>
),
Dropdown: ({ children, menu }: any) => (
<div data-testid="mock-dropdown">
{children}
<div data-testid="dropdown-menu" style={{ display: "none" }}>
{menu.items?.map((item: any, idx: number) => (
<div key={idx} data-testid={`menu-item-${item.key}`} onClick={item.onClick}>
{item.label}
</div>
))}
</div>
),
Space: ({ children, className }: any) => (
<div data-testid="mock-space" className={className}>
</div>
),
Space: ({ children, className }: any) => (
<div data-testid="mock-space" className={className}>
{children}
</div>
),
Drawer: ({ title, open, children, onClose, placement }: any) =>
open ? (
<div data-testid="mock-drawer" data-placement={placement}>
<div data-testid="drawer-title">{title}</div>
<button data-testid="drawer-close" onClick={onClose}>
Close
</button>
{children}
</div>
),
Drawer: ({ title, open, children, onClose, placement }: any) =>
open ? (
<div data-testid="mock-drawer" data-placement={placement}>
<div data-testid="drawer-title">{title}</div>
<button data-testid="drawer-close" onClick={onClose}>
Close
</button>
{children}
</div>
) : null,
}
})
) : null,
}))
// mock CSS
vi.mock("@/components/layout/Header.css", () => ({}))
@@ -39,6 +39,7 @@ describe("navigation config", () => {
expect(keys).toContain("dashboard")
expect(keys).toContain("assets")
expect(keys).toContain("voices")
expect(keys).toContain("titles")
})
})
@@ -215,14 +215,8 @@ vi.mock("@/api/editing-planner", () => ({
MODE_LABELS: { pip: "画中画" },
}))
// #1894: 标题数据源已切到 @/api/scriptsmock scripts 返回空数组作为默认
vi.mock("@/api/scripts", () => ({
getScripts: vi.fn().mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20 }),
aiRewriteScript: vi.fn(),
aiGenerateTitles: vi.fn(),
SCRIPTS_API_MOCK: false,
SCRIPT_CATEGORY_LABEL: {},
REWRITE_STYLE_OPTIONS: [],
vi.mock("@/api/titles", () => ({
getTitles: vi.fn().mockResolvedValue({ items: [] }),
}))
vi.mock("@/api/template-editor", () => ({
@@ -0,0 +1,90 @@
import React from "react"
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
vi.mock("@/components/layout/PageHead", () => ({
default: ({ title }: { title: string }) => <div data-testid="page-head">{title}</div>,
}))
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: [], isLoading: false, isError: false, refetch: vi.fn() }),
useMutation: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isLoading: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}))
vi.mock("@/components/ui", () => ({
Button: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
Input: ({ placeholder }: any) => <input placeholder={placeholder} />,
Select: ({ options }: any) => (
<select>
{options?.map((o: any) => (
<option key={o.value}>{o.label}</option>
))}
</select>
),
Modal: ({ open, children }: any) => (open ? <div role="dialog">{children}</div> : null),
Empty: () => <div>Empty</div>,
Card: ({ children }: any) => <div>{children}</div>,
Tag: ({ children }: any) => <span>{children}</span>,
Tooltip: ({ children }: any) => <span>{children}</span>,
}))
vi.mock("antd", () => ({
Modal: ({ open, children }: any) => (open ? <div role="dialog">{children}</div> : null),
message: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
Popconfirm: ({ children }: any) => <span>{children}</span>,
Form: ({ children }: any) => <form>{children}</form>,
Input: ({ placeholder }: any) => <input placeholder={placeholder} />,
InputNumber: () => <input type="number" />,
}))
vi.mock("@ant-design/icons", () => ({
PlusOutlined: () => <span />,
EditOutlined: () => <span />,
DeleteOutlined: () => <span />,
SearchOutlined: () => <span />,
FileTextOutlined: () => <span />,
CopyOutlined: () => <span />,
RobotOutlined: () => <span />,
CheckOutlined: () => <span />,
StarOutlined: () => <span />,
}))
vi.mock("@/api/titles", () => ({
getTitles: vi.fn().mockResolvedValue([]),
createTitle: vi.fn().mockResolvedValue({ success: true }),
updateTitle: vi.fn().mockResolvedValue({ success: true }),
deleteTitle: vi.fn().mockResolvedValue({ success: true }),
}))
vi.mock("@/store/authStore", () => ({
useAuthStore: (sel: any) => sel({ user: { id: "1", vip_level: 0 }, isAuthenticated: true }),
}))
vi.mock("@/pages/titles/titles.css", () => ({}))
import TitleLibrary from "@/pages/titles/TitleLibrary"
import "@/pages/titles/types/titleLibrary"
import "@/pages/titles/constants/titleLibrary"
import "@/pages/titles/utils/titleLibrary"
import "@/pages/titles/hooks/useTitleLibrary"
import "@/pages/titles/hooks/useTitleEdit"
import "@/pages/titles/hooks/useTitleAI"
import "@/pages/titles/components/title-library/TitleCard"
import "@/pages/titles/components/title-library/CategorySidebar"
import "@/pages/titles/components/title-library/FilterBar"
import "@/pages/titles/components/title-library/TitleGrid"
import "@/pages/titles/components/title-library/CreateTitleModal"
import "@/pages/titles/components/title-library/AIGenerateModal"
describe("TitleLibrary Page", () => {
it("should render without crashing", () => {
const { container } = render(
<MemoryRouter>
<TitleLibrary />
</MemoryRouter>,
)
expect(container.firstChild).toBeTruthy()
})
})
@@ -32,7 +32,6 @@ vi.mock("antd", () => ({
vi.mock("@/api/subscription", () => ({
getCurrentSubscription: vi.fn().mockResolvedValue({ plan: "free", status: "active" }),
getSubscriptionPlans: vi.fn().mockResolvedValue({ items: [{ plan_id: "free", name: "Free" }] }),
changePlan: vi.fn().mockResolvedValue({ success: true }),
toggleAutoRenew: vi.fn().mockResolvedValue({ success: true }),
cancelSubscription: vi.fn().mockResolvedValue({ success: true }),
@@ -0,0 +1,29 @@
/**
* TitleLibrary 模块 smoke test
* 建立完整依赖链,确保 vitest related 模式能匹配到
* titles 目录下所有文件的改动
*/
import { describe, it, expect } from "vitest"
// 主组件
import "@/pages/titles/TitleLibrary"
// Hooks
import "@/pages/titles/hooks/useTitleLibrary"
import "@/pages/titles/hooks/useTitleLibrary/useTitleData"
import "@/pages/titles/hooks/useTitleLibrary/useTitleFilters"
import "@/pages/titles/hooks/useTitleLibrary/useTitleMutations"
import "@/pages/titles/hooks/useTitleLibrary/useTitleActions"
// 类型与常量
import "@/pages/titles/types/titleLibrary"
import "@/pages/titles/constants/titleLibrary"
// 工具函数
import "@/pages/titles/utils/titleLibrary"
describe("TitleLibrary module smoke test", () => {
it("should load all title modules", () => {
expect(true).toBe(true)
})
})
-22
View File
@@ -216,30 +216,8 @@ MEDIAKIT_TIMEOUT=60
# SENTRY_DSN=${SENTRY_DSN}
# ==================== 豆包大模型 (火山引擎 ARK) ====================
# 豆包大模型 API Key(ARK 平台颁发)
# ${DOUBAO_API_KEY} — 替换为实际的 ARK API Key
DOUBAO_API_KEY=${DOUBAO_API_KEY}
# 模型 Endpoint ID
DOUBAO_MODEL=${DOUBAO_MODEL}
# API Base URL
DOUBAO_BASE_URL=${DOUBAO_BASE_URL}
# 请求超时(秒)
DOUBAO_TIMEOUT=60
# 最大重试次数
DOUBAO_MAX_RETRIES=2
# ==================== 微信开放平台 OAuth(网页扫码登录)====================
# 回调域名:xiaoxiajianji.com(微信开放平台已配置)
WECHAT_OPEN_APP_ID=${WECHAT_APP_ID}
WECHAT_OPEN_APP_SECRET=${WECHAT_APP_SECRET}
WECHAT_OPEN_REDIRECT_URI=https://saas.xiaoxiajianji.com/auth/wechat/callback
# 抖音 cookies 文件路径(yt-dlp 解析抖音视频需要)
DOUYIN_COOKIES_FILE=/app/configs/douyin_cookies.txt
-23
View File
@@ -233,31 +233,8 @@ MEDIAKIT_BASE_URL=https://mediakit.cn-beijing.volces.com/api/v1
MEDIAKIT_TIMEOUT=60
# ==================== 豆包大模型 (火山引擎 ARK) ====================
# 豆包大模型 API Key(ARK 平台颁发)
# ${DOUBAO_API_KEY} — 替换为实际的 ARK API Key
DOUBAO_API_KEY=${DOUBAO_API_KEY}
# 模型 Endpoint ID(在 ARK 控制台创建推理接入点后获得)
DOUBAO_MODEL=${DOUBAO_MODEL}
# API Base URL
DOUBAO_BASE_URL=${DOUBAO_BASE_URL}
# 请求超时(秒)
DOUBAO_TIMEOUT=60
# 最大重试次数
DOUBAO_MAX_RETRIES=2
# ==================== 微信开放平台 OAuth(网页扫码登录)====================
# 回调域名:xiaoxiajianji.com(微信开放平台已配置)
WECHAT_OPEN_APP_ID=${WECHAT_APP_ID}
WECHAT_OPEN_APP_SECRET=${WECHAT_APP_SECRET}
WECHAT_OPEN_REDIRECT_URI=https://staging.xiaoxiajianji.com/auth/wechat/callback
# 抖音 cookies 文件路径(yt-dlp 解析抖音视频需要)
DOUYIN_COOKIES_FILE=/app/configs/douyin_cookies.txt
DOUYIN_DEBUG_ERRORS=false
-3
View File
@@ -1,3 +0,0 @@
# Netscape HTTP Cookie File
# 抖音 cookies 占位。CI 部署时会通过 scp 上传真实 cookies。
# 若本文件被使用说明 CI 上传失败,请检查 deploy-staging job。
-10
View File
@@ -19,16 +19,6 @@ COPY alembic/ ./alembic/
COPY scripts/ ./scripts/
COPY packages/ ./packages/
COPY apps/api/ ./apps/api/
# 抖音 cookies 文件:镜像内 baked-in 兜底 + host 挂载可覆盖
# - /app/configs/douyin_cookies_default.txt: 镜像构建时 COPY 的兜底 cookies(始终有效)
# - /app/configs/douyin_cookies.txt: host volume 挂载点(部署脚本 scp 覆盖,过期需更新)
RUN mkdir -p /app/configs
COPY deploy/configs/douyin_cookies.txt /app/configs/douyin_cookies_default.txt
# 初始 COPY 一份到挂载点,host 挂载为空文件时 Python 代码会自动 fallback 到 default
COPY deploy/configs/douyin_cookies.txt /app/configs/douyin_cookies.txt
# 强制升级 yt-dlp 到最新(抖音反爬经常变更,旧版 cookies 支持失效;#1968/#1963
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com --upgrade "yt-dlp>=2026.8.19"
# 设置环境变量
ENV PATH="/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
+1 -2
View File
@@ -67,10 +67,9 @@ services:
ports:
- "127.0.0.1:${API_PORT:-8000}:8000"
# 共享生成文件目录 + 抖音 cookies 等运行时配置
# 共享生成文件目录
volumes:
- generated-files:/app/generated
- ../../deploy/configs:/app/configs:ro
networks:
- xiaoxia-net
+8 -39
View File
@@ -8,51 +8,20 @@ import math
# 每个场景: base_points(基础积分), unit(计费单位), name(显示名称)
POINTS_SCENES: dict[str, dict] = {
"ai_voice": {
"base_points": 1,
"unit": "分钟",
"name": "AI 配音",
"description": "AI 配音每分钟消耗 1 积分(免费用户上浮 15%,会员 8~9 折)",
},
"ai_voice": {"base_points": 1, "unit": "分钟", "name": "AI 配音"},
"ai_video": {
"base_points": 3,
"unit": "",
"name": "智能混剪",
"extra_per_30s": 1,
"description": "智能混剪每条 3 积分起,视频超过 30 秒后每 30 秒加 1 积分;免费用户每日 2 条免费额度",
},
"ai_digital_human": {
"base_points": 15,
"unit": "分钟",
"name": "AI 数字人",
"description": "AI 数字人每分钟消耗 15 积分",
},
"voice_clone_train": {
"base_points": 0,
"unit": "",
"name": "声音克隆训练",
"description": "声音克隆训练免费(每用户限 1 个声音)",
},
"voice_clone_synth": {
"base_points": 1,
"unit": "分钟",
"name": "声音克隆合成",
"description": "克隆音色合成每分钟消耗 1 积分",
},
"douyin_extract": {
"base_points": 1,
"unit": "",
"name": "抖音链接提取",
"description": "抖音文案提取每次 1 积分",
},
"ai_rewrite": {"base_points": 1, "unit": "", "name": "AI 改写文案", "description": "AI 改写文案每次 1 积分"},
"ai_title": {
"base_points": 1,
"unit": "",
"name": "AI 标题生成",
"description": "AI 生成标题每次 1 积分(免费用户实际上浮后 2 积分/次)",
},
"ai_cover": {"base_points": 1, "unit": "", "name": "AI 封面生成", "description": "AI 封面生成每张 1 积分"},
"ai_digital_human": {"base_points": 15, "unit": "分钟", "name": "AI 数字人"},
"voice_clone_train": {"base_points": 0, "unit": "", "name": "声音克隆训练"},
"voice_clone_synth": {"base_points": 1, "unit": "分钟", "name": "声音克隆合成"},
"douyin_extract": {"base_points": 1, "unit": "", "name": "抖音链接提取"},
"ai_rewrite": {"base_points": 1, "unit": "", "name": "AI 改写文案"},
"ai_title": {"base_points": 1, "unit": "", "name": "AI 标题生成"},
"ai_cover": {"base_points": 1, "unit": "", "name": "AI 封面生成"},
}
# 免费用户积分消耗上浮系数
+17 -40
View File
@@ -1,14 +1,13 @@
"""Quota system with registry pattern.
Member tiers (see packages.domain.points_rules.MEMBERSHIP_PRICES):
- free: 2GB storage, 5 videos/month, 3 concurrent, 3 templates, 10 voiceovers, no AI voice
- monthly: 月卡会员(同 basic 级别)
- quarterly: 季卡会员(同 premium 级别)
- yearly: 年卡会员(同 premium 级别,更多每日免费额度)
Four subscription tiers with different limits:
- free: 2GB storage, 5 videos/month, 3 concurrent, 3 templates, 50 titles, 10 voiceovers, no AI voice
- basic: 20GB storage, 30 videos/month, 10 concurrent, 15 templates, 500 titles, 100 voiceovers, AI voice
- premium: 100GB storage, 100 videos/month, 20 concurrent, unlimited templates, 500 titles, 100 voiceovers, AI voice
- pro: Same as premium (alias for premium tier)
旧档位(standard/pro/enterprise/basic/premium)已在 #1894 清理,统一为 free/monthly/quarterly/yearly。
Quota dimensions are registered by modules via the ModuleRegistry,
and checked against the user's membership type.
and checked against the user's subscription plan.
"""
from __future__ import annotations
@@ -60,6 +59,7 @@ QUOTA_TIERS: dict[str, QuotaTier] = {
QuotaDimension.VIDEOS_PER_MONTH: 5,
QuotaDimension.MAX_CONCURRENT: 3,
QuotaDimension.MAX_TEMPLATES: 3,
QuotaDimension.MAX_TITLES: 50,
QuotaDimension.MAX_VOICEOVERS: 10,
QuotaDimension.AI_VOICE_ENABLED: 0,
QuotaDimension.AI_VOICE_CREDITS: 0,
@@ -68,14 +68,14 @@ QUOTA_TIERS: dict[str, QuotaTier] = {
QuotaDimension.DEDUP_REPORT_ENABLED: 0,
},
),
# 月卡会员:基础付费档(原 basic)
"monthly": QuotaTier(
name="monthly",
"basic": QuotaTier(
name="basic",
limits={
QuotaDimension.STORAGE_GB: 20,
QuotaDimension.VIDEOS_PER_MONTH: 30,
QuotaDimension.MAX_CONCURRENT: 10,
QuotaDimension.MAX_TEMPLATES: 15,
QuotaDimension.MAX_TITLES: 500,
QuotaDimension.MAX_VOICEOVERS: 100,
QuotaDimension.AI_VOICE_ENABLED: 1,
QuotaDimension.AI_VOICE_CREDITS: 100,
@@ -84,14 +84,14 @@ QUOTA_TIERS: dict[str, QuotaTier] = {
QuotaDimension.DEDUP_REPORT_ENABLED: 0,
},
),
# 季卡会员:高级付费档(原 premium)
"quarterly": QuotaTier(
name="quarterly",
"premium": QuotaTier(
name="premium",
limits={
QuotaDimension.STORAGE_GB: 100,
QuotaDimension.VIDEOS_PER_MONTH: 100,
QuotaDimension.MAX_CONCURRENT: 20,
QuotaDimension.MAX_TEMPLATES: float("inf"),
QuotaDimension.MAX_TEMPLATES: float("inf"), # 不限量
QuotaDimension.MAX_TITLES: 500,
QuotaDimension.MAX_VOICEOVERS: 100,
QuotaDimension.AI_VOICE_ENABLED: 1,
QuotaDimension.AI_VOICE_CREDITS: 500,
@@ -100,32 +100,9 @@ QUOTA_TIERS: dict[str, QuotaTier] = {
QuotaDimension.DEDUP_REPORT_ENABLED: 1,
},
),
# 年卡会员:同季卡配额 + 每日不限免费条数(由前端/积分规则实现)
"yearly": QuotaTier(
name="yearly",
limits={
QuotaDimension.STORAGE_GB: 100,
QuotaDimension.VIDEOS_PER_MONTH: float("inf"),
QuotaDimension.MAX_CONCURRENT: 20,
QuotaDimension.MAX_TEMPLATES: float("inf"),
QuotaDimension.MAX_VOICEOVERS: 200,
QuotaDimension.AI_VOICE_ENABLED: 1,
QuotaDimension.AI_VOICE_CREDITS: 2000,
QuotaDimension.BATCH_EXPORT_ENABLED: 1,
QuotaDimension.MULTI_PLATFORM_ENABLED: 1,
QuotaDimension.DEDUP_REPORT_ENABLED: 1,
},
),
}
# #1894: 旧档位别名(basic/standard → monthly, premium/pro/enterprise → quarterly
# 历史 DB 数据、单测和内部模块可能仍在传旧 plan_name;这里保留别名保证配额查询不炸。
# 新代码请统一使用 free/monthly/quarterly/yearly。
QUOTA_TIERS["basic"] = QUOTA_TIERS["monthly"]
QUOTA_TIERS["standard"] = QUOTA_TIERS["monthly"]
QUOTA_TIERS["premium"] = QUOTA_TIERS["quarterly"]
QUOTA_TIERS["pro"] = QUOTA_TIERS["quarterly"]
QUOTA_TIERS["enterprise"] = QUOTA_TIERS["quarterly"]
# pro 套餐与 premium 配额相同,使用别名引用避免重复维护
QUOTA_TIERS["pro"] = QUOTA_TIERS["premium"]
class QuotaWarningLevel:
@@ -239,7 +216,7 @@ class QuotaChecker:
"""检查指定维度的配额使用情况
Args:
plan_name: 会员类型 (free/monthly/quarterly/yearly)
plan_name: 用户套餐等级 (free/basic/premium)
dimension: 配额维度
used: 当前已使用量
+1 -1
View File
@@ -19,4 +19,4 @@ numpy==1.26.4
opencv-python-headless==4.10.0.84
# yt-dlp: 抖音视频下载(#1893 文案提取)
yt-dlp>=2026.8.19
yt-dlp>=2024.1.0
+7 -26
View File
@@ -120,29 +120,6 @@ wait_tcp_ready() {
return 1
}
# --- 幂等容器清理(无论成功/失败/被取消都回收临时 PG、Redis,杜绝泄漏)---
# 背景:服务容器用 docker run -d 起在宿主机上,仅在脚本走到结尾时清理;
# job 失败(set -e)或被取消(SIGTERM)时会永久残留,堆积压垮构建机。
cleanup_containers() {
# 清理过程自身不能再次触发退出,避免掩盖原始退出码
set +e
if [ -n "${PG_CONTAINER:-}" ]; then
docker rm -f "$PG_CONTAINER" >/dev/null 2>&1 && echo "✅ 已清理PG容器: $PG_CONTAINER"
fi
if [ -n "${REDIS_CONTAINER:-}" ]; then
docker rm -f "$REDIS_CONTAINER" >/dev/null 2>&1 && echo "✅ 已清理Redis容器: $REDIS_CONTAINER"
fi
}
on_exit() {
local code=$?
cleanup_containers
exit "$code"
}
# 必须在启动任何服务容器之前注册;INT/TERM 覆盖 Gitea 取消任务场景
trap on_exit EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
# --- 启动 Redis ---
echo ""
echo "=== 启动 Redis ==="
@@ -325,10 +302,14 @@ conn.close()
" 2>/dev/null || echo "WARN: 数据库清理失败(可能已被清理)"
echo "✅ 共享PG数据库已清理"
else
# 临时PG容器由 EXIT trap 的 cleanup_containers 统一回收(失败/取消也保证清理)
:
# 清理临时PG容器
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
echo "✅ PG容器已清理"
fi
# 临时Redis容器同样由 EXIT trap 统一回收
# 清理Redis容器
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
echo "✅ Redis容器已清理"
# --- 覆盖率汇总 ---
echo ""
+3 -12
View File
@@ -16,17 +16,6 @@ CONTAINER_NAME="staging-${MODE}-$$"
# 强制清理可能残留的同名容器
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
# 任何退出路径(成功/失败/被取消 SIGTERM)都回收容器,杜绝 staging 测试容器泄漏
cleanup_container() {
local code=$?
set +e
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 && echo "✅ 已清理容器: $CONTAINER_NAME"
exit "$code"
}
trap cleanup_container EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
if [ "$MODE" = "e2e" ]; then
docker create --name "$CONTAINER_NAME" --ipc=host \
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
@@ -55,5 +44,7 @@ docker cp apps "$CONTAINER_NAME:/workspace/"
docker start -a "$CONTAINER_NAME"
EXIT_CODE=$(docker wait "$CONTAINER_NAME")
# 容器由 EXIT trap 的 cleanup_container 统一回收(失败/取消也保证清理)
# 清理容器
docker rm "$CONTAINER_NAME" 2>/dev/null || true
exit "$EXIT_CODE"
+1 -5
View File
@@ -69,11 +69,7 @@ if [ -z "$IMAGE_TAG" ]; then
exit 1
fi
if [ ! -f "$ENV_FILE" ]; then
echo "ERROR: $ENV_FILE 不存在。CI 应先在 render_env 步骤渲染并上传此文件"
exit 1
fi
echo "✅ .env file found: $ENV_FILE ($(wc -l < $ENV_FILE) lines)"
test -f "$ENV_FILE"
mkdir -p "$GENERATED_DIR"
mkdir -p "$LEGACY_ASSETS_DIR"
+1 -1
View File
@@ -310,7 +310,7 @@ docker run -d \
--restart unless-stopped \
--cpus 2 \
--memory 2g \
--health-cmd "grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1" \
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
+2 -13
View File
@@ -47,7 +47,6 @@ ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}"
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-staging/legacy-assets}"
NGINX_CONF_FILE="${NGINX_CONF_FILE:-/var/lib/xiaoxia-saas-staging/nginx-staging.conf}"
COOKIES_FILE="${COOKIES_FILE:-/var/lib/xiaoxia-saas-staging/configs/douyin_cookies.txt}"
SKIP_MIGRATION="${SKIP_MIGRATION:-false}"
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
@@ -66,14 +65,6 @@ fi
echo "✅ .env file found: $ENV_FILE ($(wc -l < "$ENV_FILE") lines)"
mkdir -p "$GENERATED_DIR"
mkdir -p "$LEGACY_ASSETS_DIR"
mkdir -p "$(dirname "$COOKIES_FILE")"
# 抖音 cookies 文件:CI workflow 已通过 scp 上传;如果不存在(非 CI 环境)则创建占位
if [ ! -f "$COOKIES_FILE" ] || [ "$(wc -c < "$COOKIES_FILE" 2>/dev/null || echo 0)" -lt 200 ]; then
printf '# Netscape HTTP Cookie File\n# 抖音 cookies 占位(CI 应通过 scp 上传真实 cookies\n' > "$COOKIES_FILE"
echo "WARNING: Douyin cookies not found or too small at $COOKIES_FILE (extraction will 503)"
else
echo "Douyin cookies ready: $COOKIES_FILE ($(wc -c < "$COOKIES_FILE") bytes)"
fi
# ── 写入 Staging Nginx 配置 ──
# 运行时覆盖 nginx 配置,确保 upstream 指向正确的 staging 网络
@@ -201,7 +192,7 @@ rollback() {
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--health-cmd "grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1" \
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"\$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
@@ -489,9 +480,7 @@ docker run -d \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-e DOUYIN_COOKIES_FILE=/app/configs/douyin_cookies.txt \
-v "$GENERATED_DIR:/app/generated" \
-v "$COOKIES_FILE:/app/configs/douyin_cookies.txt:ro" \
--restart unless-stopped \
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
--health-interval 30s \
@@ -515,7 +504,7 @@ docker run -d \
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--health-cmd "grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1" \
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"\$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
+1 -1
View File
@@ -305,7 +305,7 @@ docker run -d \
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--health-cmd "grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1" \
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
+1 -1
View File
@@ -57,7 +57,7 @@ if [ "$TARGET_ENV" = "staging" ]; then
fi
# 共用 secrets 直接导出(如果存在)
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY DOUBAO_API_KEY DOUBAO_MODEL DOUBAO_BASE_URL WECHAT_APP_ID WECHAT_APP_SECRET"
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY WECHAT_APP_ID WECHAT_APP_SECRET"
for var in $SHARED_SECRETS; do
value="${!var:-}"
# 已经在环境中了,无需额外操作
+1 -1
View File
@@ -201,7 +201,7 @@ docker run -d \
--restart unless-stopped \
--cpus 2 \
--memory 2g \
--health-cmd "grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1" \
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
+11 -19
View File
@@ -354,18 +354,13 @@ class TestQuotaRegistry:
assert len(reg.list_dimensions()) == len(QuotaDimension)
def test_list_tiers(self):
"""套餐等级包含核心四档 + 旧档位别名."""
"""四个套餐等级."""
reg = QuotaRegistry()
tiers = reg.list_tiers()
assert "pro" in tiers
assert "free" in tiers
assert "monthly" in tiers
assert "quarterly" in tiers
assert "yearly" in tiers
assert "basic" in tiers # alias → monthly
assert "premium" in tiers # alias → quarterly
assert "pro" in tiers # alias → quarterly
assert "standard" in tiers # alias → monthly
assert "enterprise" in tiers # alias → quarterly
assert "basic" in tiers
assert len(tiers) == 4
def test_get_tier_existing(self):
"""获取已有的套餐."""
@@ -375,12 +370,9 @@ class TestQuotaRegistry:
assert tier.name == "free"
def test_get_tier_nonexistent(self):
"""不存在的套餐返回 Noneenterprise 现为 quarterly 别名)."""
from packages.domain.quota import QUOTA_TIERS
"""不存在的套餐返回 None"""
reg = QuotaRegistry()
assert reg.get_tier("totally_unknown_plan_xyz") is None
assert reg.get_tier("enterprise") is QUOTA_TIERS["quarterly"]
assert reg.get_tier("enterprise") is None
def test_get_limit_existing(self):
"""获取已有限制."""
@@ -388,10 +380,9 @@ class TestQuotaRegistry:
assert reg.get_limit("free", QuotaDimension.STORAGE_GB) == 2
def test_get_limit_nonexistent_plan(self):
"""不存在的套餐返回 0enterprise 现为 quarterly 别名,返回 100."""
"""不存在的套餐 fallback 到 free 配额"""
reg = QuotaRegistry()
assert reg.get_limit("totally_unknown_plan_xyz", QuotaDimension.STORAGE_GB) == 0
assert reg.get_limit("enterprise", QuotaDimension.STORAGE_GB) == 100
assert reg.get_limit("enterprise", QuotaDimension.STORAGE_GB) == 0
def test_get_limit_unknown_dimension(self):
"""未知维度返回 0."""
@@ -515,10 +506,11 @@ class TestQuotaChecker:
assert result.usage_percent == 0.0
def test_check_unknown_plan(self):
"""未知套餐,限制为0enterprise现为quarterly别名,这里用一个真不存在的名)."""
"""未知套餐,限制为0."""
checker = QuotaChecker()
result = checker.check("totally_unknown_plan_xyz", QuotaDimension.STORAGE_GB, 0)
result = checker.check("enterprise", QuotaDimension.STORAGE_GB, 0)
assert result.limit == 0
# used=0, limit=0 → 0 < 0 is False → allowed=False
assert result.allowed is False
assert result.warning_level == QuotaWarningLevel.NORMAL
-111
View File
@@ -1,111 +0,0 @@
"""注册送积分单元测试 (#1895 P2 step 3)"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture
def mock_settings(monkeypatch):
"""默认关闭 points_enabled,不影响现有用例。"""
from app.config import settings
monkeypatch.setattr(settings, "points_enabled", False)
return settings
class TestRegisterBonusPoints:
@pytest.mark.asyncio
async def test_bonus_when_enabled(self, mock_settings):
"""开启积分时注册成功送50分。"""
from app.api.routes import auth
from app.api.routes.auth import RegisterRequest
mock_settings.points_enabled = True
mock_uc = MagicMock()
mock_resp = MagicMock()
mock_resp.user_id = "new-user-1"
mock_resp.email = "n***@example.com"
mock_resp.username = "newuser"
mock_resp.display_name = "New User"
mock_uc.execute.return_value = (mock_resp, None)
mock_svc = MagicMock()
def _mock_uc_cls(*args, **kwargs):
return mock_uc
db = MagicMock()
with (
patch("app.api.routes.auth.RegisterUserUseCase", side_effect=_mock_uc_cls),
patch("packages.domain.points_service.PointsService", return_value=mock_svc),
):
req = RegisterRequest(email="n***@example.com", password="Secret123!", username="newuser")
resp = await auth.register(request=req, user_repository=MagicMock(), email_service=MagicMock(), db=db)
assert resp.user_id == "new-user-1"
mock_svc.add_points.assert_called_once()
call_kwargs = mock_svc.add_points.call_args.kwargs
assert call_kwargs["user_id"] == "new-user-1"
assert call_kwargs["amount"] == 50
assert call_kwargs["source"] == "task_reward"
@pytest.mark.asyncio
async def test_no_bonus_when_disabled(self, mock_settings):
"""关闭积分时不送分。"""
from app.api.routes import auth
from app.api.routes.auth import RegisterRequest
mock_uc = MagicMock()
mock_resp = MagicMock()
mock_resp.user_id = "new-user-2"
mock_resp.email = "n***@example.com"
mock_resp.username = "newuser2"
mock_resp.display_name = "New User 2"
mock_uc.execute.return_value = (mock_resp, None)
def _mock_uc_cls(*args, **kwargs):
return mock_uc
db = MagicMock()
with (
patch("app.api.routes.auth.RegisterUserUseCase", side_effect=_mock_uc_cls),
patch("packages.domain.points_service.PointsService") as MockSvc,
):
req = RegisterRequest(email="n***@example.com", password="Secret123!", username="newuser2")
resp = await auth.register(request=req, user_repository=MagicMock(), email_service=MagicMock(), db=db)
MockSvc.assert_not_called()
assert resp.user_id == "new-user-2"
@pytest.mark.asyncio
async def test_bonus_failure_does_not_break_register(self, mock_settings):
"""送积分失败不应影响注册流程。"""
from app.api.routes import auth
from app.api.routes.auth import RegisterRequest
mock_settings.points_enabled = True
mock_uc = MagicMock()
mock_resp = MagicMock()
mock_resp.user_id = "new-user-3"
mock_resp.email = "n***@example.com"
mock_resp.username = "newuser3"
mock_resp.display_name = "New User 3"
mock_uc.execute.return_value = (mock_resp, None)
mock_svc = MagicMock()
mock_svc.add_points.side_effect = Exception("DB error")
def _mock_uc_cls(*args, **kwargs):
return mock_uc
db = MagicMock()
with (
patch("app.api.routes.auth.RegisterUserUseCase", side_effect=_mock_uc_cls),
patch("packages.domain.points_service.PointsService", return_value=mock_svc),
):
req = RegisterRequest(email="n***@example.com", password="Secret123!", username="newuser3")
resp = await auth.register(request=req, user_repository=MagicMock(), email_service=MagicMock(), db=db)
assert resp.user_id == "new-user-3"
-73
View File
@@ -1,73 +0,0 @@
"""抖音分享文本 URL 提取单测。"""
from __future__ import annotations
import pytest
# 直接 import 模块,用 _extract_url_from_text / _extract_and_validate_douyin_url 测试
from app.api.routes.scripts_ai import (
_extract_and_validate_douyin_url,
_extract_url_from_text,
)
from fastapi import HTTPException, status
class TestExtractUrlFromText:
def test_pure_url(self):
assert _extract_url_from_text("https://v.douyin.com/iZ7vU2qH/") == "https://v.douyin.com/iZ7vU2qH/"
def test_share_text_with_prefix_suffix(self):
"""典型"复制链接"场景:包含中文+表情+链接+话题标签。"""
s = "这个视频太搞笑了 https://v.douyin.com/iZ7vU2qH/ 快来看看!#搞笑 #日常"
assert _extract_url_from_text(s) == "https://v.douyin.com/iZ7vU2qH/"
def test_share_text_no_http_prefix(self):
s = "复制此链接,打开Dou音搜索,直接观看视频!v.douyin.com/iZ7vU2qH/"
# Should pick up v.douyin.com/... and add https:// prefix
url = _extract_url_from_text(s)
assert url and url.endswith("v.douyin.com/iZ7vU2qH/")
def test_long_url_www(self):
s = "https://www.douyin.com/video/7234567890123456789?previous_page=web_code_link"
assert _extract_url_from_text(s) == s
def test_empty_input(self):
assert _extract_url_from_text("") is None
assert _extract_url_from_text(None) is None # type: ignore[arg-type]
def test_no_url(self):
assert _extract_url_from_text("这个视频很好看,但是没有链接") is None
def test_trailing_punct_stripped(self):
s = "https://v.douyin.com/iZ7vU2qH/。"
assert _extract_url_from_text(s) == "https://v.douyin.com/iZ7vU2qH/"
class TestValidateUrl:
def test_pure_short_url_ok(self):
assert _extract_and_validate_douyin_url("https://v.douyin.com/iZ7vU2qH/").startswith("https://")
def test_share_text_ok(self):
s = "这个视频太搞笑了 https://v.douyin.com/abcdefG/ 快来看看!"
url = _extract_and_validate_douyin_url(s)
assert "douyin.com" in url
def test_empty_raises_400(self):
with pytest.raises(HTTPException) as ei:
_extract_and_validate_douyin_url("")
assert ei.value.status_code == 400
def test_no_url_raises_400(self):
with pytest.raises(HTTPException) as ei:
_extract_and_validate_douyin_url("这个视频没有链接")
assert ei.value.status_code == 400
def test_non_douyin_raises_400(self):
with pytest.raises(HTTPException) as ei:
_extract_and_validate_douyin_url("https://www.bilibili.com/video/BV1xx411c7mD")
assert ei.value.status_code == 400
def test_scheme_added_when_missing(self):
"""只输入 v.douyin.com/xxx 时,补 https://。"""
url = _extract_and_validate_douyin_url("v.douyin.com/iZ7vU2qH/")
assert url.startswith("https://")
@@ -1,436 +0,0 @@
"""验证 extract-from-douyin 在各种失败场景返回正确的 HTTP 状态码(绝不能 500)"""
from __future__ import annotations
import sys
import types
from unittest import mock
import pytest
from app.auth import AuthenticatedUser
from fastapi import HTTPException, status
class _FakeUser:
id = "u-test"
is_member = False
member_type = None
@pytest.fixture
def fake_user():
return AuthenticatedUser(user=_FakeUser())
class _FakeYDLBase:
"""通用假 yt-dlp 基类(支持上下文管理器 with 语法)"""
extract_info_result = None
extract_info_raises = None
prepare_filename_result = "/tmp/fake.mp4"
def __init__(self, *a, **kw):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
def extract_info(self, url, download=True):
if self.__class__.extract_info_raises:
raise self.__class__.extract_info_raises
return self.__class__.extract_info_result
def prepare_filename(self, info):
return self.__class__.prepare_filename_result
def _install_fake_ytdlp(fake_ydl_class, *, download_error_cls=None):
"""把假 yt-dlp 注入 sys.modules,函数内 import yt_dlp 会拿到我们的假版本"""
fake_mod = types.ModuleType("yt_dlp")
fake_mod.YoutubeDL = fake_ydl_class
if download_error_cls is None:
download_error_cls = type("DownloadError", (Exception,), {})
fake_mod.DownloadError = download_error_cls
utils_mod = types.ModuleType("yt_dlp.utils")
utils_mod.DownloadError = download_error_cls
fake_mod.utils = utils_mod
sys.modules["yt_dlp"] = fake_mod
sys.modules["yt_dlp.utils"] = utils_mod
return fake_mod
def _import_target():
from app.api.routes import scripts_ai
return scripts_ai
def _fake_mk_unavailable():
"""Mock MediaKitClient 不可用,强制走下载+本地 ASR 路径。"""
fake_mk = mock.MagicMock()
fake_mk.is_available = False
return mock.patch("app.api.routes.scripts_ai.get_mediakit_client", return_value=fake_mk)
def test_download_http404_returns_400_not_500(fake_user):
"""无效短链 / 视频 404 → 应返回 400 业务错误,不能 500"""
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/test123/")
class DownloadError(Exception):
pass
class FailingYDL(_FakeYDLBase):
extract_info_raises = DownloadError("ERROR: Unable to download webpage: HTTP Error 404: Not Found")
_install_fake_ytdlp(FailingYDL, download_error_cls=DownloadError)
with _fake_mk_unavailable():
with pytest.raises(HTTPException) as exc:
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert (
exc.value.status_code == status.HTTP_400_BAD_REQUEST
), f"应为400,实际 {exc.value.status_code}: {exc.value.detail}"
assert "无法解析" in exc.value.detail or "抖音" in exc.value.detail
def test_download_network_error_returns_502_not_500(fake_user):
"""网络错误 / 上游异常 → 502,不能 500"""
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
class DownloadError(Exception):
pass
class NetErrYDL(_FakeYDLBase):
# 路径A(元信息解析)会吞异常返回 None;路径B(下载)抛网络错误
@staticmethod
def _raise():
raise DownloadError("ERROR: Connection reset by peer")
def extract_info(self, url, download=True):
# 元信息探测返回 None(拿不到直链),下载时再抛
if not download:
return None
self._raise()
_install_fake_ytdlp(NetErrYDL, download_error_cls=DownloadError)
with _fake_mk_unavailable():
with pytest.raises(HTTPException) as exc:
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert exc.value.status_code == status.HTTP_502_BAD_GATEWAY
def test_info_none_returns_400(fake_user):
"""yt-dlp 返回 None info → 400"""
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
class NoneInfoYDL(_FakeYDLBase):
def extract_info(self, url, download=True):
# 元信息探测返回 None;下载也返回 None
return None
_install_fake_ytdlp(NoneInfoYDL)
with _fake_mk_unavailable():
with pytest.raises(HTTPException) as exc:
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert exc.value.status_code == status.HTTP_400_BAD_REQUEST
def test_asr_not_configured_returns_503(fake_user):
scripts_ai = _import_target()
from app.services.script_asr_service import ASRNotConfiguredError
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
class OkYDL(_FakeYDLBase):
def extract_info(self, url, download=True):
# 元信息返回 None(不走 MediaKit);下载返回正常 info
if not download:
return None
return {"id": "x", "duration": 10, "title": "t"}
_install_fake_ytdlp(OkYDL)
with (
_fake_mk_unavailable(),
mock.patch.object(scripts_ai.os.path, "isfile", return_value=True),
mock.patch.object(scripts_ai.os.path, "getsize", return_value=1024),
mock.patch.object(scripts_ai, "transcribe_to_text", side_effect=ASRNotConfiguredError("未配置")),
):
with pytest.raises(HTTPException) as exc:
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert exc.value.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
def test_asr_failure_returns_502(fake_user):
scripts_ai = _import_target()
from app.services.script_asr_service import ASRTranscriptionError
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
class OkYDL(_FakeYDLBase):
def extract_info(self, url, download=True):
if not download:
return None
return {"id": "x", "duration": 10, "title": "t"}
_install_fake_ytdlp(OkYDL)
with (
_fake_mk_unavailable(),
mock.patch.object(scripts_ai.os.path, "isfile", return_value=True),
mock.patch.object(scripts_ai.os.path, "getsize", return_value=1024),
mock.patch.object(scripts_ai, "transcribe_to_text", side_effect=ASRTranscriptionError("识别失败")),
):
with pytest.raises(HTTPException) as exc:
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert exc.value.status_code == status.HTTP_502_BAD_GATEWAY
def test_asr_unexpected_error_returns_502_not_500(fake_user):
"""ASR 抛未预期异常也应被兜住,不能 500"""
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
class OkYDL(_FakeYDLBase):
def extract_info(self, url, download=True):
if not download:
return None
return {"id": "x", "duration": 10, "title": "t"}
_install_fake_ytdlp(OkYDL)
with (
_fake_mk_unavailable(),
mock.patch.object(scripts_ai.os.path, "isfile", return_value=True),
mock.patch.object(scripts_ai.os.path, "getsize", return_value=1024),
mock.patch.object(scripts_ai, "transcribe_to_text", side_effect=RuntimeError("ffmpeg crashed")),
):
with pytest.raises(HTTPException) as exc:
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert exc.value.status_code == status.HTTP_502_BAD_GATEWAY, f"应为502,实际 {exc.value.status_code}"
def test_missing_downloaded_file_returns_502_not_500(fake_user):
"""yt-dlp 返回 info 但文件未落地(isfile False)→ 502"""
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
class OkYDL(_FakeYDLBase):
def extract_info(self, url, download=True):
if not download:
return None
return {"id": "x", "duration": 10, "title": "t"}
_install_fake_ytdlp(OkYDL)
with (
_fake_mk_unavailable(),
mock.patch.object(scripts_ai.os.path, "isfile", return_value=False),
):
with pytest.raises(HTTPException) as exc:
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert exc.value.status_code != 500
assert "下载异常" in exc.value.detail or "文件" in exc.value.detail
def test_any_unexpected_error_does_not_return_500_raw(fake_user):
"""兜底:prepare_filename 抛未预期异常也应被捕获,返回500 code但含业务detail"""
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
class BuggyYDL(_FakeYDLBase):
def extract_info(self, url, download=True):
if not download:
return None
return {"id": "x", "duration": "not_a_number", "title": "t"}
def prepare_filename(self, info):
raise RuntimeError("some internal bug")
_install_fake_ytdlp(BuggyYDL)
with _fake_mk_unavailable():
with pytest.raises(HTTPException) as exc:
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert "抖音" in exc.value.detail or "失败" in exc.value.detail or exc.value.status_code != 500
# ── cookies 相关测试 ──────────────────────────────────────────────
def test_cookies_error_returns_503_friendly_message(fake_user):
"""cookies 缺失/过期 → 返回 503 + 友好文案,不暴露原始错误"""
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/test123/")
class DownloadError(Exception):
pass
class CookiesYDL(_FakeYDLBase):
def extract_info(self, url, download=True):
raise DownloadError(
"ERROR: [Douyin] 7623712911260650802: Fresh cookies (not necessarily logged in) are needed"
)
_install_fake_ytdlp(CookiesYDL, download_error_cls=DownloadError)
with _fake_mk_unavailable():
with pytest.raises(HTTPException) as exc:
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert exc.value.status_code == status.HTTP_503_SERVICE_UNAVAILABLE, f"应为503,实际 {exc.value.status_code}"
assert (
"暂时不可用" in exc.value.detail or "稍后重试" in exc.value.detail
), f"应有友好提示,实际: {exc.value.detail}"
assert "Fresh cookies" not in exc.value.detail
def test_cookies_error_in_generic_except_also_returns_503(fake_user):
"""cookies 错误绕过 DownloadError 时,兜底异常分支也应识别并返回 503"""
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
class CookieBugYDL(_FakeYDLBase):
def extract_info(self, url, download=True):
raise RuntimeError("Fresh cookies are needed to access this video")
_install_fake_ytdlp(CookieBugYDL)
with _fake_mk_unavailable():
with pytest.raises(HTTPException) as exc:
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert exc.value.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
def test_ydl_opts_includes_cookiefile_when_file_exists(fake_user):
"""cookies 文件存在时,ydl_opts 应包含 cookiefile 指向该路径(在下载分支)"""
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
captured_opts_download = {}
class CaptureOptsYDL(_FakeYDLBase):
def __init__(self, opts):
# 下载分支会触发 download=True;元信息探测 download=False
# 元信息也会传 cookiefile,但我们只在下载分支记录(更接近真实)
super().__init__()
self._opts = opts
# 总是记录最后一次的 opts,方便断言
captured_opts_download.clear()
captured_opts_download.update(opts)
def extract_info(self, url, download=True):
if not download:
return None # 元信息失败,走下载分支
return {"id": "x", "duration": 5, "title": "t"}
_install_fake_ytdlp(CaptureOptsYDL)
with (
_fake_mk_unavailable(),
mock.patch.object(scripts_ai, "_resolve_cookies_file", return_value="/tmp/fake_cookies.txt"),
mock.patch.object(scripts_ai.os.path, "isfile", return_value=True),
mock.patch.object(scripts_ai.os.path, "getsize", return_value=1024),
mock.patch.object(scripts_ai, "transcribe_to_text", return_value="ok"),
):
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert (
captured_opts_download.get("cookiefile") == "/tmp/fake_cookies.txt"
), f"cookiefile 应被设置,opts={captured_opts_download}"
def test_ydl_opts_no_cookiefile_when_file_missing(fake_user):
"""cookies 文件不存在时,ydl_opts 不应包含 cookiefile 键"""
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
captured_opts_download = {}
class CaptureOptsYDL(_FakeYDLBase):
def __init__(self, opts):
super().__init__()
captured_opts_download.clear()
captured_opts_download.update(opts)
def extract_info(self, url, download=True):
if not download:
return None
return {"id": "x", "duration": 5, "title": "t"}
_install_fake_ytdlp(CaptureOptsYDL)
with (
_fake_mk_unavailable(),
mock.patch.object(scripts_ai, "_resolve_cookies_file", return_value=None),
mock.patch.object(scripts_ai.os.path, "isfile", return_value=True),
mock.patch.object(scripts_ai.os.path, "getsize", return_value=1024),
mock.patch.object(scripts_ai, "transcribe_to_text", return_value="ok"),
):
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert (
"cookiefile" not in captured_opts_download
), f"cookies 文件缺失时不应设置 cookiefileopts={captured_opts_download}"
def test_generic_download_error_hides_raw_message(fake_user):
"""非 cookies 非 404 的通用下载错误 → 502,且不暴露 yt-dlp 原始错误文本"""
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
class DownloadError(Exception):
pass
class GenErrYDL(_FakeYDLBase):
def extract_info(self, url, download=True):
raise DownloadError("ERROR: some internal yt-dlp weird failure with trace")
_install_fake_ytdlp(GenErrYDL, download_error_cls=DownloadError)
with _fake_mk_unavailable():
with pytest.raises(HTTPException) as exc:
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert exc.value.status_code == status.HTTP_502_BAD_GATEWAY
assert "下载失败" in exc.value.detail
assert "weird failure" not in exc.value.detail, "不应暴露 yt-dlp 内部错误文本"
def test_share_text_input_extracts_url_correctly(fake_user):
"""分享文本(含前后说明文字)应能正确提取 URL"""
scripts_ai = _import_target()
share_text = "这个视频太搞笑了 https://v.douyin.com/abcdeFG/ 快来看看!#搞笑 #日常"
body = scripts_ai.ExtractFromDouyinRequest(url=share_text)
class OkYDL(_FakeYDLBase):
def extract_info(self, url, download=True):
if not download:
return {"url": "https://example.com/direct.mp4", "duration": 5}
return {"id": "x", "duration": 5, "title": "t"}
_install_fake_ytdlp(OkYDL)
fake_mk = mock.MagicMock()
fake_mk.is_available = True
fake_mk.asr_submit.return_value = "tk1"
fake_mk.asr_poll.return_value = ("识别成功的文案", 5.0)
with (
mock.patch("app.api.routes.scripts_ai.get_mediakit_client", return_value=fake_mk),
mock.patch.object(scripts_ai, "_ytdlp_extract_video_url", return_value=("https://example.com/direct.mp4", 5.0)),
):
resp = scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert resp.source_url == "https://v.douyin.com/abcdeFG/"
assert resp.text == "识别成功的文案"
assert resp.duration_seconds == 5.0
def test_non_douyin_share_text_returns_400(fake_user):
"""粘贴非抖音分享链接 → 400"""
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="看看这个 https://www.bilibili.com/video/BV1xx 哈哈哈")
with _fake_mk_unavailable():
with pytest.raises(HTTPException) as exc:
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert exc.value.status_code == 400
-7
View File
@@ -19,13 +19,6 @@ from packages.application.generation_tasks import (
from packages.domain import GenerationTask
@pytest.fixture(autouse=True)
def _disable_points_gate(monkeypatch):
"""默认关闭积分闸门,避免影响既有用例。"""
monkeypatch.setattr(_pg_module, "_points_gate_enabled", lambda: False)
yield
@pytest.fixture(autouse=True)
def _disable_points_gate(monkeypatch):
"""默认关闭积分闸门,避免影响既有用例。"""
-72
View File
@@ -1,72 +0,0 @@
"""验证 _helpers.get_user_plan 档位归一化逻辑(#1894 旧档位兼容)"""
from __future__ import annotations
from unittest import mock
import pytest
from app.api.routes import _helpers
class _FakeUser:
def __init__(self, plan):
self.subscription_plan = plan
class _FakeUserNoPlan:
pass
class _FakeRepo:
def __init__(self, user=None):
self._user = user
def find_by_id(self, uid):
return self._user
def test_user_not_found_returns_free():
"""用户不存在时返回 free(覆盖 _helpers.py 第 41 行 user is None 分支)"""
repo = _FakeRepo(user=None)
assert _helpers.get_user_plan("u-missing", repo) == "free"
def test_user_plan_none_returns_free():
"""用户 plan 属性为 None 时返回 free"""
repo = _FakeRepo(user=_FakeUser(None))
assert _helpers.get_user_plan("u1", repo) == "free"
@pytest.mark.parametrize(
"legacy,expected",
[
("standard", "monthly"),
("basic", "monthly"),
("pro", "quarterly"),
("premium", "quarterly"),
("enterprise", "quarterly"),
],
)
def test_legacy_plans_normalized(legacy, expected):
"""旧档位值正确归一化到新体系"""
repo = _FakeRepo(user=_FakeUser(legacy))
assert _helpers.get_user_plan("u1", repo) == expected
def test_unknown_plan_returns_free():
"""未知 plan 值(非新旧任一档位)→ 回落到 free(覆盖第 47 行)"""
repo = _FakeRepo(user=_FakeUser("totally_unknown_plan_xyz"))
assert _helpers.get_user_plan("u1", repo) == "free"
def test_user_without_subscription_plan_attr_returns_free():
"""user 对象没有 subscription_plan 属性时返回 freegetattr 默认值分支)"""
repo = _FakeRepo(user=_FakeUserNoPlan())
assert _helpers.get_user_plan("u1", repo) == "free"
@pytest.mark.parametrize("plan", ["free", "monthly", "quarterly", "yearly"])
def test_valid_new_plans_passthrough(plan):
"""新档位直接透传"""
repo = _FakeRepo(user=_FakeUser(plan))
assert _helpers.get_user_plan("u1", repo) == plan
-45
View File
@@ -827,48 +827,3 @@ class TestLipsyncRouteStaleRefresh:
with pytest.raises(HTTPException) as exc_info:
self._call(None, svc, bg)
assert exc_info.value.status_code == 404
def test_stale_job_with_naive_updated_at_does_not_raise(self, mock_mediakit, mock_cosyvoice):
"""#1894 P1 修复:Postgres TIMESTAMP WITHOUT TIMEZONE 返回 naive datetime
与 UTC-aware 的 _now 相减会抛 TypeError: can't subtract offset-naive and
offset-aware datetimes,导致轮询接口 500。修复后应自动补 tz 正常 stale 判断。"""
from datetime import datetime, timedelta
from app.services.lipsync_service import LipsyncService
mock_job = _make_mock_job(status="submitted")
# 模拟 PG 返回的 naive UTC wall clock45 秒前)—— 代码里把 naive 当 UTC
mock_job.updated_at = datetime.utcnow() - timedelta(seconds=45)
assert mock_job.updated_at.tzinfo is None # sanity: naive
refreshed_job = _make_mock_job(status="completed")
mock_db = MagicMock()
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
svc.get_job = MagicMock(return_value=mock_job)
svc.refresh_job_status = MagicMock(return_value=refreshed_job)
bg = MagicMock()
# 不应抛 TypeError,应正确判定为 stale 并同步刷新
result = self._call(mock_job, svc, bg)
svc.refresh_job_status.assert_called_once_with("job-1", "user-1")
bg.add_task.assert_not_called()
assert result is refreshed_job
def test_fresh_job_with_naive_updated_at_uses_background(self, mock_mediakit, mock_cosyvoice):
"""naive datetime 新鲜(10 秒内)→ 走后台刷新,不抛异常。"""
from datetime import datetime
from app.services.lipsync_service import LipsyncService
mock_job = _make_mock_job(status="processing")
mock_job.updated_at = datetime.utcnow() # naive (UTC wall clock), 0s ago
assert mock_job.updated_at.tzinfo is None
mock_db = MagicMock()
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
svc.get_job = MagicMock(return_value=mock_job)
svc.refresh_job_status = MagicMock()
bg = MagicMock()
result = self._call(mock_job, svc, bg)
svc.refresh_job_status.assert_not_called()
bg.add_task.assert_called_once()
assert result is mock_job
+46 -34
View File
@@ -31,7 +31,7 @@ from fastapi.testclient import TestClient
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from app.api.routes.subscription import _get_plan_name, router
from app.api.routes.subscription import _get_plan_name, _get_plan_price, router
# ---------------------------------------------------------------------------
# 1. Mock Billing Repository
@@ -140,8 +140,8 @@ class TestPaymentCallbackSuccess:
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
def test_monthly_payment_success(self, MockSession, MockRepo):
"""月卡支付成功。"""
def test_monthly_pro_payment_success(self, MockSession, MockRepo):
"""Pro 套餐月付支付成功。"""
mock_repo = MockBillingRepository()
MockRepo.return_value = mock_repo
MockSession.return_value = MagicMock()
@@ -154,7 +154,7 @@ class TestPaymentCallbackSuccess:
"/subscription/payment-callback",
params={
"user_id": "user-001",
"plan": "monthly",
"plan": "pro",
"billing_cycle": "monthly",
"amount": 299.0,
"payment_method": "alipay",
@@ -175,12 +175,12 @@ class TestPaymentCallbackSuccess:
# 验证订阅更新
assert mock_repo.update_subscription_count == 1
assert "user-001" in mock_repo.updated_subscriptions
assert mock_repo.updated_subscriptions["user-001"]["plan"] == "monthly"
assert mock_repo.updated_subscriptions["user-001"]["plan"] == "pro"
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
def test_yearly_payment_success(self, MockSession, MockRepo):
"""年卡支付成功。"""
def test_yearly_standard_payment_success(self, MockSession, MockRepo):
"""标准版年付支付成功。"""
mock_repo = MockBillingRepository()
MockRepo.return_value = mock_repo
MockSession.return_value = MagicMock()
@@ -193,7 +193,7 @@ class TestPaymentCallbackSuccess:
"/subscription/payment-callback",
params={
"user_id": "user-002",
"plan": "monthly",
"plan": "standard",
"billing_cycle": "yearly",
"amount": 999.0,
"payment_method": "wechat",
@@ -204,7 +204,7 @@ class TestPaymentCallbackSuccess:
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert mock_repo.updated_subscriptions["user-002"]["plan"] == "monthly"
assert mock_repo.updated_subscriptions["user-002"]["plan"] == "standard"
# 年付到期时间应为约 365 天后
expires_at = mock_repo.updated_subscriptions["user-002"]["expires_at"]
expected = datetime.now(UTC) + timedelta(days=365)
@@ -212,8 +212,8 @@ class TestPaymentCallbackSuccess:
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
def test_quarterly_payment_success(self, MockSession, MockRepo):
"""季卡支付成功。"""
def test_enterprise_payment_success(self, MockSession, MockRepo):
"""企业版支付成功。"""
mock_repo = MockBillingRepository()
MockRepo.return_value = mock_repo
MockSession.return_value = MagicMock()
@@ -226,8 +226,8 @@ class TestPaymentCallbackSuccess:
"/subscription/payment-callback",
params={
"user_id": "user-003",
"plan": "quarterly",
"billing_cycle": "quarterly",
"plan": "enterprise",
"billing_cycle": "monthly",
"amount": 999.0,
"payment_method": "bank_transfer",
"payment_id": "ent_20240101_003",
@@ -236,7 +236,7 @@ class TestPaymentCallbackSuccess:
assert resp.status_code == 200
assert resp.json()["success"] is True
assert mock_repo.updated_subscriptions["user-003"]["plan"] == "quarterly"
assert mock_repo.updated_subscriptions["user-003"]["plan"] == "enterprise"
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
@@ -254,7 +254,7 @@ class TestPaymentCallbackSuccess:
"/subscription/payment-callback",
params={
"user_id": "user-004",
"plan": "monthly",
"plan": "standard",
"billing_cycle": "monthly",
"amount": 99.0,
},
@@ -288,7 +288,7 @@ class TestPaymentCallbackIdempotency:
params = {
"user_id": "user-idem-1",
"plan": "monthly",
"plan": "pro",
"billing_cycle": "monthly",
"amount": 299.0,
"payment_id": "pay_dup_001",
@@ -351,7 +351,7 @@ class TestPaymentCallbackValidation:
resp = client.post(
"/subscription/payment-callback",
params={"plan": "monthly", "billing_cycle": "monthly", "amount": 299.0},
params={"plan": "pro", "billing_cycle": "monthly", "amount": 299.0},
)
assert resp.status_code == 422
@@ -385,7 +385,7 @@ class TestPaymentCallbackValidation:
resp = client.post(
"/subscription/payment-callback",
params={"user_id": "u1", "plan": "monthly", "billing_cycle": "monthly"},
params={"user_id": "u1", "plan": "pro", "billing_cycle": "monthly"},
)
assert resp.status_code == 422
@@ -405,7 +405,7 @@ class TestPaymentCallbackValidation:
"/subscription/payment-callback",
params={
"user_id": "u1",
"plan": "monthly",
"plan": "pro",
"billing_cycle": "monthly",
"amount": -100.0,
},
@@ -420,23 +420,35 @@ class TestPaymentCallbackValidation:
class TestHelperFunctions:
"""订阅辅助函数测试 — #1894 新档位 free/monthly/quarterly/yearly"""
"""订阅辅助函数测试。"""
def test_get_plan_name_all_plans(self):
"""所有套餐名称映射正确。"""
assert _get_plan_name("free") == "免费用户"
assert _get_plan_name("monthly") == "月卡会员"
assert _get_plan_name("quarterly") == "季卡会员"
assert _get_plan_name("yearly") == "年卡会员"
assert _get_plan_name("free") == "体验版"
assert _get_plan_name("standard") == "标准版"
assert _get_plan_name("pro") == "专业版"
assert _get_plan_name("enterprise") == "企业版"
def test_get_plan_name_unknown_defaults_free(self):
"""未知套餐返回默认「免费用户」。"""
assert _get_plan_name("unknown") == "免费用户"
assert _get_plan_name("") == "免费用户"
# legacy 旧值不直接命中 → 也回落免费用户(实际会被 _helpers.get_user_plan 归一化到 monthly/quarterly
assert _get_plan_name("standard") == "免费用户"
assert _get_plan_name("pro") == "免费用户"
assert _get_plan_name("enterprise") == "免费用户"
def test_get_plan_name_unknown(self):
"""未知套餐返回「未知套餐」。"""
assert _get_plan_name("unknown") == "未知套餐"
assert _get_plan_name("") == "未知套餐"
def test_get_plan_price_all_combinations(self):
"""所有套餐价格映射正确。"""
assert _get_plan_price("free", "monthly") == 0
assert _get_plan_price("free", "yearly") == 0
assert _get_plan_price("standard", "monthly") == 99
assert _get_plan_price("standard", "yearly") == 999
assert _get_plan_price("pro", "monthly") == 299
assert _get_plan_price("pro", "yearly") == 2999
assert _get_plan_price("enterprise", "monthly") == 999
assert _get_plan_price("enterprise", "yearly") == 9999
def test_get_plan_price_unknown(self):
"""未知组合返回 0。"""
assert _get_plan_price("unknown", "monthly") == 0
assert _get_plan_price("pro", "weekly") == 0
# ---------------------------------------------------------------------------
@@ -556,12 +568,12 @@ class TestMockBillingRepository:
repo = MockBillingRepository()
expires = datetime.now(UTC) + timedelta(days=30)
repo.update_subscription_on_payment("user-001", "monthly", expires)
repo.update_subscription_on_payment("user-001", "pro", expires)
assert repo.update_subscription_count == 1
assert "user-001" in repo.updated_subscriptions
sub = repo.updated_subscriptions["user-001"]
assert sub["plan"] == "monthly"
assert sub["plan"] == "pro"
assert sub["status"] == "active"
assert sub["expires_at"] == expires

Some files were not shown because too many files have changed in this diff Show More