Release: 安全修复 + 代码优化 + Mock替换 (2026-07-03) #181
@@ -7,10 +7,13 @@ app.dependencies and authentication behavior lives in application use cases.
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
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_user_repository
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from packages.adapters.redis import NoopSessionStore
|
||||
@@ -28,6 +31,11 @@ from packages.application.auth.password_reset_use_case import (
|
||||
from packages.application.auth.register_user_use_case import RegisterUserRequest as RegisterUseCaseRequest
|
||||
from packages.application.auth.register_user_use_case import RegisterUserUseCase, VerifyEmailRequest, VerifyEmailUseCase
|
||||
from packages.ports.user_repository import UserRepository
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
@@ -229,6 +237,24 @@ async def reset_password(
|
||||
return MessageResponse(message="密码重置成功")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""登出 - 将当前 token 加入黑名单"""
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
if credentials:
|
||||
try:
|
||||
payload = jwt.decode(credentials.credentials, settings.JWT_SECRET_KEY, algorithms=["HS256"])
|
||||
exp = payload.get("exp", 0)
|
||||
blacklist_token(credentials.credentials, exp)
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/api/app/api/routes/auth.py: {e}", exc_info=True)
|
||||
return MessageResponse(message="已登出")
|
||||
|
||||
@router.get("/me", response_model=CurrentUserResponse)
|
||||
async def get_current_user_info(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
|
||||
@@ -6,7 +6,6 @@ Supports chunked upload, resume, and automatic cleanup of expired uploads.
|
||||
import fcntl
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from app.core.celery_app import celery_app
|
||||
|
||||
@@ -5,7 +5,6 @@ import redis
|
||||
from app.config import settings
|
||||
from fastapi import APIRouter, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(tags=["Health"])
|
||||
|
||||
@@ -21,20 +20,8 @@ async def health_check():
|
||||
|
||||
@router.get("/ready", status_code=status.HTTP_200_OK)
|
||||
async def readiness_check():
|
||||
checks = {
|
||||
"database": await _check_database(),
|
||||
"redis": await _check_redis(),
|
||||
"oss": _check_oss(),
|
||||
}
|
||||
all_healthy = all(check["status"] == "healthy" for check in checks.values())
|
||||
response = {
|
||||
"status": "ready" if all_healthy else "not_ready",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"checks": checks,
|
||||
}
|
||||
if not all_healthy:
|
||||
return JSONResponse(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, content=response)
|
||||
return response
|
||||
"""简单的就绪检查,仅返回状态。详细健康检查请使用 /health 端点。"""
|
||||
return {"status": "ready"}
|
||||
|
||||
|
||||
@router.get("/startup", status_code=status.HTTP_200_OK)
|
||||
|
||||
@@ -32,7 +32,6 @@ from app.schemas.job import (
|
||||
job_to_response,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.application.jobs import (
|
||||
CancelJobUseCase,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_user_repository
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_user_repository
|
||||
@@ -102,8 +103,31 @@ async def get_billing_records(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""获取账单记录列表"""
|
||||
# TODO: 从数据库查询账单记录
|
||||
return []
|
||||
from packages.adapters.sqlalchemy_impl.billing_repository import SQLAlchemyBillingRepository
|
||||
from packages.adapters.sqlalchemy_impl.session import SessionLocal
|
||||
|
||||
if SessionLocal is None:
|
||||
return []
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
repo = SQLAlchemyBillingRepository(session)
|
||||
records = repo.find_by_user(current_user.user.id)
|
||||
return [
|
||||
BillingRecord(
|
||||
id=r.id,
|
||||
plan_name=r.plan_name,
|
||||
amount=r.amount,
|
||||
billing_cycle=r.billing_cycle,
|
||||
status=r.status,
|
||||
payment_method=r.payment_method or "未支付",
|
||||
created_at=r.created_at.isoformat() if r.created_at else "",
|
||||
invoice_url=r.invoice_url,
|
||||
)
|
||||
for r in records
|
||||
]
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@router.post("/change-plan", response_model=ChangePlanResponse)
|
||||
@@ -181,6 +205,59 @@ async def cancel_subscription(
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/payment-callback")
|
||||
async def payment_callback(
|
||||
user_id: str,
|
||||
plan: str,
|
||||
billing_cycle: str,
|
||||
amount: float,
|
||||
payment_method: str = "alipay",
|
||||
payment_id: str = "",
|
||||
):
|
||||
"""支付回调 - 在事务中更新账单和订阅状态
|
||||
|
||||
注意:生产环境需要验证支付签名
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from packages.adapters.sqlalchemy_impl.billing_repository import SQLAlchemyBillingRepository
|
||||
from packages.adapters.sqlalchemy_impl.session import SessionLocal
|
||||
import uuid
|
||||
|
||||
if SessionLocal is None:
|
||||
raise HTTPException(status_code=500, detail="Database not available")
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
repo = SQLAlchemyBillingRepository(session)
|
||||
|
||||
# 创建账单记录
|
||||
record_id = uuid.uuid4().hex
|
||||
record = repo.create({
|
||||
"id": record_id,
|
||||
"user_id": user_id,
|
||||
"plan_name": _get_plan_name(plan),
|
||||
"amount": amount,
|
||||
"billing_cycle": billing_cycle,
|
||||
"status": "pending",
|
||||
})
|
||||
|
||||
# 在事务中标记支付成功并更新订阅
|
||||
repo.mark_paid(record_id, payment_method, payment_id)
|
||||
|
||||
# 计算到期时间
|
||||
days = 365 if billing_cycle == "yearly" else 30
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=days)
|
||||
repo.update_subscription_on_payment(user_id, plan, expires_at)
|
||||
|
||||
return {"success": True, "message": "支付成功", "record_id": record_id}
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"支付处理失败: {str(e)}")
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@router.post("/toggle-auto-renew", response_model=SimpleResponse)
|
||||
async def toggle_auto_renew(
|
||||
request: ToggleAutoRenewRequest,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
|
||||
@@ -53,7 +53,6 @@ ALLOWED_MIME_TYPES = frozenset(
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"image/svg+xml",
|
||||
"image/tiff",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
|
||||
import jwt
|
||||
@@ -23,6 +24,33 @@ class AuthenticatedUser:
|
||||
token_type: str | None = None
|
||||
|
||||
|
||||
def _get_redis_client():
|
||||
"""获取 Redis 客户端用于 JWT 黑名单"""
|
||||
import redis as redis_lib
|
||||
return redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
|
||||
|
||||
def _token_fingerprint(token: str) -> str:
|
||||
"""计算 token 的哈希指纹"""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def blacklist_token(token: str, exp: int) -> None:
|
||||
"""将 token 加入黑名单,TTL 为 token 剩余有效期"""
|
||||
import time
|
||||
redis_client = _get_redis_client()
|
||||
key = f"jwt:blacklist:{_token_fingerprint(token)}"
|
||||
ttl = max(exp - int(time.time()), 1)
|
||||
redis_client.setex(key, ttl, "revoked")
|
||||
|
||||
|
||||
def is_token_blacklisted(token: str) -> bool:
|
||||
"""检查 token 是否在黑名单中"""
|
||||
redis_client = _get_redis_client()
|
||||
key = f"jwt:blacklist:{_token_fingerprint(token)}"
|
||||
return redis_client.exists(key) > 0
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
@@ -56,6 +84,11 @@ def _decode_user_token(token: str) -> dict:
|
||||
|
||||
if payload.get("type") not in {"user_auth", "access"}:
|
||||
raise _unauthorized("Invalid token type")
|
||||
|
||||
# 检查 token 是否在黑名单中
|
||||
if is_token_blacklisted(token):
|
||||
raise _unauthorized("Token has been revoked")
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Database session management and engine configuration.
|
||||
|
||||
统一使用 app.config 中的数据库配置,移除重复的 DatabaseSettings。
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator
|
||||
|
||||
from app.config import settings
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
pool_size=settings.DATABASE_POOL_SIZE,
|
||||
max_overflow=settings.DATABASE_MAX_OVERFLOW,
|
||||
pool_timeout=settings.DATABASE_POOL_TIMEOUT,
|
||||
pool_recycle=settings.DATABASE_POOL_RECYCLE,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
"""Dependency for getting database sessions."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db_context() -> Generator[Session, None, None]:
|
||||
"""Context manager for database sessions.
|
||||
|
||||
Usage:
|
||||
with get_db_context() as db:
|
||||
db.query(Model).all()
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
@@ -1,8 +1,4 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from app.config import settings
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl import (
|
||||
build_session_factory,
|
||||
ensure_database_exists,
|
||||
@@ -22,10 +18,3 @@ assert_auto_create_schema_allowed(settings.ENVIRONMENT, settings.AUTO_CREATE_SCH
|
||||
if settings.AUTO_CREATE_SCHEMA:
|
||||
initialize_database(engine)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
from fastapi import Request, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
|
||||
@@ -91,15 +91,27 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""基于 IP 的简单限流中间件"""
|
||||
"""基于 IP 的简单限流中间件
|
||||
|
||||
def __init__(self, app, max_requests: int = 100, window_seconds: int = 60):
|
||||
Args:
|
||||
app: ASGI 应用
|
||||
max_requests: 窗口期内最大请求数
|
||||
window_seconds: 时间窗口(秒)
|
||||
paths: 限流的路径列表,None 表示所有路径
|
||||
"""
|
||||
|
||||
def __init__(self, app, max_requests: int = 100, window_seconds: int = 60, paths: list[str] | None = None):
|
||||
super().__init__(app)
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
self.paths = set(paths) if paths else None
|
||||
self.requests = {} # {ip: [timestamps]}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# 如果配置了路径过滤,只对指定路径限流
|
||||
if self.paths is not None and request.url.path not in self.paths:
|
||||
return await call_next(request)
|
||||
|
||||
# 获取客户端 IP
|
||||
client_ip = request.client.host
|
||||
|
||||
|
||||
@@ -121,9 +121,19 @@ class PrometheusMetricsMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
|
||||
async def metrics_endpoint(request: Request) -> PlainTextResponse:
|
||||
"""FastAPI endpoint that returns Prometheus metrics in text format."""
|
||||
"""FastAPI endpoint that returns Prometheus metrics in text format.
|
||||
|
||||
需要 Bearer Token 认证,Token 通过 METRICS_AUTH_TOKEN 环境变量配置。
|
||||
"""
|
||||
import os
|
||||
|
||||
# Bearer Token 认证
|
||||
auth_token = os.getenv("METRICS_AUTH_TOKEN", "")
|
||||
if auth_token:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer ") or auth_header[7:] != auth_token:
|
||||
return PlainTextResponse(content="Unauthorized", status_code=401)
|
||||
|
||||
version = os.getenv("APP_VERSION", "unknown")
|
||||
environment = os.getenv("APP_ENV", "unknown")
|
||||
APP_INFO.labels(version=version, environment=environment).set(1)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
API 版本管理中间件
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
@@ -300,8 +300,8 @@ class AutoClipService:
|
||||
if min_quality is not None:
|
||||
try:
|
||||
result["min_quality_score"] = float(min_quality)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.warning(f"Operation failed in apps/api/app/services/auto_clip_service.py: {e}", exc_info=True)
|
||||
|
||||
# 分类筛选
|
||||
category = requirements.get("category") or requirements.get("classification")
|
||||
|
||||
+6
-3
@@ -9,7 +9,7 @@ from app.middleware.exceptions import (
|
||||
http_exception_handler,
|
||||
validation_exception_handler,
|
||||
)
|
||||
from app.middleware.logging import RequestLoggingMiddleware
|
||||
from app.middleware.logging import RateLimitMiddleware, RequestLoggingMiddleware
|
||||
from app.middleware.prometheus_metrics import PrometheusMetricsMiddleware, metrics_endpoint
|
||||
from fastapi import FastAPI
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
@@ -22,8 +22,9 @@ app = FastAPI(
|
||||
title="小虾 SaaS API",
|
||||
description="自动化剪辑 SaaS 平台 API",
|
||||
version=settings.APP_VERSION,
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
docs_url=None if settings.ENVIRONMENT == "production" else "/docs",
|
||||
redoc_url=None if settings.ENVIRONMENT == "production" else "/redoc",
|
||||
openapi_url=None if settings.ENVIRONMENT == "production" else "/openapi.json",
|
||||
redirect_slashes=False,
|
||||
)
|
||||
|
||||
@@ -53,6 +54,8 @@ app.add_middleware(
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
# P0-1: 登录接口限流 - 每 IP 每分钟最多 10 次登录尝试
|
||||
app.add_middleware(RateLimitMiddleware, max_requests=10, window_seconds=60, paths=["/api/v1/auth/login"])
|
||||
app.add_middleware(RequestLoggingMiddleware)
|
||||
app.add_middleware(PrometheusMetricsMiddleware)
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* 查重 API 模块
|
||||
* 提供视频查重相关接口(当前使用 mock 数据,后端就绪后切换)
|
||||
* 提供视频查重相关接口
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
|
||||
/** 查重记录状态 */
|
||||
export type DuplicationStatus =
|
||||
"pending" | "processing" | "completed" | "failed";
|
||||
| "pending"
|
||||
| "processing"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
/** 查重记录 */
|
||||
export interface DuplicationRecord {
|
||||
@@ -64,118 +67,12 @@ export interface DuplicationUploadResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ============ Mock 数据 ============
|
||||
|
||||
/** mock 查重记录列表 */
|
||||
const MOCK_RECORDS: DuplicationRecord[] = [
|
||||
{
|
||||
id: "dup-001",
|
||||
filename: "日常vlog_01.mp4",
|
||||
file_size: 125_000_000,
|
||||
duration_seconds: 180,
|
||||
status: "completed",
|
||||
duplicate_rate: 23.5,
|
||||
duplicate_count: 3,
|
||||
created_at: "2026-06-27T10:00:00Z",
|
||||
updated_at: "2026-06-27T10:05:00Z",
|
||||
},
|
||||
{
|
||||
id: "dup-002",
|
||||
filename: "美食分享_片段.mp4",
|
||||
file_size: 45_000_000,
|
||||
duration_seconds: 60,
|
||||
status: "completed",
|
||||
duplicate_rate: 5.2,
|
||||
duplicate_count: 1,
|
||||
created_at: "2026-06-27T11:30:00Z",
|
||||
updated_at: "2026-06-27T11:32:00Z",
|
||||
},
|
||||
{
|
||||
id: "dup-003",
|
||||
filename: "旅行记录_巴黎.mp4",
|
||||
file_size: 320_000_000,
|
||||
duration_seconds: 420,
|
||||
status: "processing",
|
||||
created_at: "2026-06-28T09:00:00Z",
|
||||
updated_at: "2026-06-28T09:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "dup-004",
|
||||
filename: "产品展示_新版.mp4",
|
||||
file_size: 88_000_000,
|
||||
duration_seconds: 90,
|
||||
status: "completed",
|
||||
duplicate_rate: 67.8,
|
||||
duplicate_count: 8,
|
||||
created_at: "2026-06-26T15:00:00Z",
|
||||
updated_at: "2026-06-26T15:10:00Z",
|
||||
},
|
||||
{
|
||||
id: "dup-005",
|
||||
filename: "教程_剪辑技巧.mp4",
|
||||
file_size: 200_000_000,
|
||||
duration_seconds: 300,
|
||||
status: "failed",
|
||||
created_at: "2026-06-26T14:00:00Z",
|
||||
updated_at: "2026-06-26T14:01:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
/** mock 查重详情 */
|
||||
const MOCK_DETAIL: DuplicationDetail = {
|
||||
...MOCK_RECORDS[0],
|
||||
segments: [
|
||||
{
|
||||
id: "seg-001",
|
||||
source_start: 10,
|
||||
source_end: 25,
|
||||
matched_video_id: "asset-101",
|
||||
matched_video_name: "日常vlog_素材库.mp4",
|
||||
matched_start: 45,
|
||||
matched_end: 60,
|
||||
similarity: 92.3,
|
||||
},
|
||||
{
|
||||
id: "seg-002",
|
||||
source_start: 60,
|
||||
source_end: 78,
|
||||
matched_video_id: "asset-205",
|
||||
matched_video_name: "城市风光_合集.mp4",
|
||||
matched_start: 120,
|
||||
matched_end: 138,
|
||||
similarity: 85.7,
|
||||
},
|
||||
{
|
||||
id: "seg-003",
|
||||
source_start: 150,
|
||||
source_end: 165,
|
||||
matched_video_id: "asset-310",
|
||||
matched_video_name: "背景音乐_配套画面.mp4",
|
||||
matched_start: 30,
|
||||
matched_end: 45,
|
||||
similarity: 78.1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** 是否使用 mock 数据(后端就绪后改为 false) */
|
||||
const USE_MOCK = true;
|
||||
|
||||
// ============ API 函数 ============
|
||||
|
||||
/** 上传视频进行查重 */
|
||||
export const uploadForDuplication = async (
|
||||
file: File,
|
||||
): Promise<DuplicationUploadResponse> => {
|
||||
if (USE_MOCK) {
|
||||
// 模拟上传延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
return {
|
||||
id: `dup-${Date.now()}`,
|
||||
status: "processing",
|
||||
message: `文件 "${file.name}" 已上传,正在查重中...`,
|
||||
};
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await apiClient.post("/duplication/upload", formData, {
|
||||
@@ -186,10 +83,6 @@ export const uploadForDuplication = async (
|
||||
|
||||
/** 获取查重记录列表 */
|
||||
export const getDuplicationRecords = async (): Promise<DuplicationRecord[]> => {
|
||||
if (USE_MOCK) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
return MOCK_RECORDS;
|
||||
}
|
||||
const response = await apiClient.get("/duplication/records");
|
||||
return response.data;
|
||||
};
|
||||
@@ -198,11 +91,6 @@ export const getDuplicationRecords = async (): Promise<DuplicationRecord[]> => {
|
||||
export const getDuplicationDetail = async (
|
||||
recordId: string,
|
||||
): Promise<DuplicationDetail> => {
|
||||
if (USE_MOCK) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
// 返回第一条 mock 详情,实际应按 ID 查找
|
||||
return { ...MOCK_DETAIL, id: recordId };
|
||||
}
|
||||
const response = await apiClient.get(`/duplication/records/${recordId}`);
|
||||
return response.data;
|
||||
};
|
||||
@@ -211,10 +99,6 @@ export const getDuplicationDetail = async (
|
||||
export const deleteDuplicationRecord = async (
|
||||
recordId: string,
|
||||
): Promise<void> => {
|
||||
if (USE_MOCK) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
return;
|
||||
}
|
||||
await apiClient.delete(`/duplication/records/${recordId}`);
|
||||
};
|
||||
|
||||
@@ -222,14 +106,6 @@ export const deleteDuplicationRecord = async (
|
||||
export const retryDuplication = async (
|
||||
recordId: string,
|
||||
): Promise<DuplicationUploadResponse> => {
|
||||
if (USE_MOCK) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
return {
|
||||
id: recordId,
|
||||
status: "processing",
|
||||
message: "已重新提交查重",
|
||||
};
|
||||
}
|
||||
const response = await apiClient.post(
|
||||
`/duplication/records/${recordId}/retry`,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 素材库页面 — V21 设计系统
|
||||
* 两栏布局:左侧素材库列表(260px)+ 右侧素材网格
|
||||
* 使用 mock 数据,后端 API 对接暂不要求
|
||||
* 使用 useQuery 对接后端真实 API(api/assets.ts)
|
||||
*/
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Upload, Modal as AntModal, message, Popconfirm } from "antd";
|
||||
@@ -17,6 +17,18 @@ import {
|
||||
DeleteOutlined,
|
||||
ExperimentOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getAssetLibraries,
|
||||
createAssetLibrary,
|
||||
deleteAssetLibrary,
|
||||
getAssets,
|
||||
deleteAsset,
|
||||
uploadAssetDirect,
|
||||
getAssetDiagnosis,
|
||||
type AssetLibraryItem,
|
||||
type AssetItem as ApiAssetItem,
|
||||
} from "@/api/assets";
|
||||
import { Button, Input, Select } from "@/components/ui";
|
||||
import "./assets.css";
|
||||
|
||||
@@ -46,134 +58,69 @@ interface AssetItem {
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Mock 数据
|
||||
* 映射:后端 → 前端
|
||||
* ============================================================ */
|
||||
const MOCK_LIBRARIES: LibraryItem[] = [
|
||||
{ id: "lib-1", name: "产品演示视频", kind: "video", count: 12 },
|
||||
{ id: "lib-2", name: "品牌配音素材", kind: "voice", count: 8 },
|
||||
{ id: "lib-3", name: "营销图片素材", kind: "image", count: 24 },
|
||||
{ id: "lib-4", name: "用户访谈录像", kind: "video", count: 5 },
|
||||
];
|
||||
|
||||
const MOCK_ASSETS: AssetItem[] = [
|
||||
{
|
||||
id: "a-1",
|
||||
name: "产品功能演示_v2.mp4",
|
||||
kind: "video",
|
||||
status: "ok",
|
||||
statusLabel: "合格",
|
||||
duration: "02:34",
|
||||
size: 128,
|
||||
createdAt: "2026-06-28",
|
||||
},
|
||||
{
|
||||
id: "a-2",
|
||||
name: "品牌宣传片_终版.mp4",
|
||||
kind: "video",
|
||||
status: "warn",
|
||||
statusLabel: "待优化",
|
||||
duration: "05:12",
|
||||
size: 356,
|
||||
createdAt: "2026-06-27",
|
||||
},
|
||||
{
|
||||
id: "a-3",
|
||||
name: "用户案例_张三.mp4",
|
||||
kind: "video",
|
||||
status: "bad",
|
||||
statusLabel: "不合格",
|
||||
duration: "01:48",
|
||||
size: 96,
|
||||
createdAt: "2026-06-26",
|
||||
},
|
||||
{
|
||||
id: "a-4",
|
||||
name: "功能讲解_配音.mp3",
|
||||
kind: "voice",
|
||||
status: "ok",
|
||||
statusLabel: "合格",
|
||||
duration: "03:22",
|
||||
size: 48,
|
||||
createdAt: "2026-06-25",
|
||||
},
|
||||
{
|
||||
id: "a-5",
|
||||
name: "旁白_中文版.wav",
|
||||
kind: "voice",
|
||||
status: "info",
|
||||
statusLabel: "处理中",
|
||||
duration: "04:05",
|
||||
size: 72,
|
||||
createdAt: "2026-06-24",
|
||||
},
|
||||
{
|
||||
id: "a-6",
|
||||
name: "Banner_春季活动.png",
|
||||
kind: "image",
|
||||
status: "ok",
|
||||
statusLabel: "合格",
|
||||
size: 2.4,
|
||||
createdAt: "2026-06-23",
|
||||
},
|
||||
{
|
||||
id: "a-7",
|
||||
name: "产品截图_首页.png",
|
||||
kind: "image",
|
||||
status: "ok",
|
||||
statusLabel: "合格",
|
||||
size: 1.8,
|
||||
createdAt: "2026-06-22",
|
||||
},
|
||||
{
|
||||
id: "a-8",
|
||||
name: "社交媒体封面.jpg",
|
||||
kind: "image",
|
||||
status: "warn",
|
||||
statusLabel: "待优化",
|
||||
size: 3.2,
|
||||
createdAt: "2026-06-21",
|
||||
},
|
||||
{
|
||||
id: "a-9",
|
||||
name: "操作指南_录屏.mp4",
|
||||
kind: "video",
|
||||
status: "ok",
|
||||
statusLabel: "合格",
|
||||
duration: "08:15",
|
||||
size: 512,
|
||||
createdAt: "2026-06-20",
|
||||
},
|
||||
{
|
||||
id: "a-10",
|
||||
name: "背景音乐_轻快.mp3",
|
||||
kind: "voice",
|
||||
status: "ok",
|
||||
statusLabel: "合格",
|
||||
duration: "02:00",
|
||||
size: 36,
|
||||
createdAt: "2026-06-19",
|
||||
},
|
||||
{
|
||||
id: "a-11",
|
||||
name: "Logo_高清.png",
|
||||
kind: "image",
|
||||
status: "ok",
|
||||
statusLabel: "合格",
|
||||
size: 0.8,
|
||||
createdAt: "2026-06-18",
|
||||
},
|
||||
{
|
||||
id: "a-12",
|
||||
name: "活动预告_短视频.mp4",
|
||||
kind: "video",
|
||||
status: "info",
|
||||
statusLabel: "处理中",
|
||||
duration: "00:30",
|
||||
size: 64,
|
||||
createdAt: "2026-06-17",
|
||||
},
|
||||
];
|
||||
/** 根据 mime_type 推断前端 AssetKind */
|
||||
const inferKind = (mimeType: string): AssetKind => {
|
||||
if (mimeType.startsWith("video/")) return "video";
|
||||
if (mimeType.startsWith("audio/")) return "voice";
|
||||
return "image";
|
||||
};
|
||||
|
||||
/** 根据 quality_score 推断前端状态 */
|
||||
const inferStatus = (
|
||||
score?: number,
|
||||
classificationStatus?: string,
|
||||
): { status: StatusType; label: string } => {
|
||||
if (classificationStatus === "processing" || classificationStatus === "pending") {
|
||||
return { status: "info", label: "处理中" };
|
||||
}
|
||||
if (score == null) return { status: "info", label: "待诊断" };
|
||||
if (score >= 70) return { status: "ok", label: "合格" };
|
||||
if (score >= 40) return { status: "warn", label: "待优化" };
|
||||
return { status: "bad", label: "不合格" };
|
||||
};
|
||||
|
||||
/** 格式化时长(秒 → mm:ss) */
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
/** 将后端 AssetLibraryItem 映射为前端 LibraryItem */
|
||||
const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
kind: inferKind(item.default_mime_type || "video"),
|
||||
count: item.asset_count ?? 0,
|
||||
});
|
||||
|
||||
/** 将后端 ApiAssetItem 映射为前端 AssetItem */
|
||||
const mapAsset = (item: ApiAssetItem): AssetItem => {
|
||||
const { status, label } = inferStatus(
|
||||
item.quality_score,
|
||||
item.classification_status,
|
||||
);
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
kind: inferKind(item.mime_type || ""),
|
||||
thumbUrl: item.thumbnail_url,
|
||||
status,
|
||||
statusLabel: label,
|
||||
duration: item.duration != null ? formatDuration(item.duration) : undefined,
|
||||
size: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
|
||||
createdAt: item.created_at
|
||||
? new Date(item.created_at).toISOString().slice(0, 10)
|
||||
: "—",
|
||||
};
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* 常量
|
||||
* ============================================================ */
|
||||
const MAX_FILE_SIZE = 2048 * 1024 * 1024;
|
||||
const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024;
|
||||
|
||||
@@ -292,10 +239,77 @@ const AssetCard: React.FC<{
|
||||
* 主组件
|
||||
* ============================================================ */
|
||||
const AssetLibrary: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/* ── 获取素材库列表 ── */
|
||||
const {
|
||||
data: apiLibraries = [],
|
||||
isLoading: libLoading,
|
||||
} = useQuery<AssetLibraryItem[], Error>({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const libraries = useMemo(() => apiLibraries.map(mapLibrary), [apiLibraries]);
|
||||
|
||||
/* ── 当前选中的素材库 ── */
|
||||
const [activeLibId, setActiveLibId] = useState<string>("");
|
||||
|
||||
// 当库列表加载完成后,自动选中第一个
|
||||
const effectiveLibId = activeLibId || libraries[0]?.id || "";
|
||||
|
||||
/* ── 获取当前库的素材列表 ── */
|
||||
const {
|
||||
data: apiAssets = [],
|
||||
isLoading: assetsLoading,
|
||||
isError: assetsError,
|
||||
error: assetsErrorObj,
|
||||
refetch: refetchAssets,
|
||||
} = useQuery<ApiAssetItem[], Error>({
|
||||
queryKey: ["assets", effectiveLibId],
|
||||
queryFn: () => getAssets(effectiveLibId || undefined),
|
||||
enabled: !!effectiveLibId,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const assets = useMemo(() => apiAssets.map(mapAsset), [apiAssets]);
|
||||
|
||||
/* ── Mutations ── */
|
||||
const createLibMutation = useMutation({
|
||||
mutationFn: createAssetLibrary,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||||
message.success("素材库创建成功");
|
||||
},
|
||||
onError: () => {
|
||||
message.error("创建素材库失败");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteLibMutation = useMutation({
|
||||
mutationFn: deleteAssetLibrary,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||||
message.success("素材库已删除");
|
||||
},
|
||||
onError: () => {
|
||||
message.error("删除素材库失败");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteAssetMutation = useMutation({
|
||||
mutationFn: deleteAsset,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("删除素材失败");
|
||||
},
|
||||
});
|
||||
|
||||
/* 状态 */
|
||||
const [libraries, setLibraries] = useState<LibraryItem[]>(MOCK_LIBRARIES);
|
||||
const [activeLibId, setActiveLibId] = useState<string>(MOCK_LIBRARIES[0].id);
|
||||
const [assets] = useState<AssetItem[]>(MOCK_ASSETS);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
/* 筛选 */
|
||||
@@ -312,24 +326,19 @@ const AssetLibrary: React.FC = () => {
|
||||
const [newLibKind, setNewLibKind] = useState<AssetKind>("video");
|
||||
|
||||
/* 派生数据 */
|
||||
const activeLibrary = libraries.find((l) => l.id === activeLibId);
|
||||
const activeLibrary = libraries.find((l) => l.id === effectiveLibId);
|
||||
|
||||
const filteredAssets = useMemo(() => {
|
||||
let list = assets;
|
||||
|
||||
/* 按素材库类型过滤 */
|
||||
if (activeLibrary) {
|
||||
list = list.filter((a) => a.kind === activeLibrary.kind);
|
||||
}
|
||||
|
||||
/* 按类型筛选 */
|
||||
/* 按素材库类型过滤(如果筛选类型不是 all) */
|
||||
if (filterType !== "all") {
|
||||
list = list.filter((a) => a.kind === filterType);
|
||||
}
|
||||
|
||||
/* 按时间筛选 */
|
||||
if (filterTime !== "all") {
|
||||
const now = new Date("2026-06-30");
|
||||
const now = new Date();
|
||||
list = list.filter((a) => {
|
||||
const d = new Date(a.createdAt);
|
||||
const diffDays = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24);
|
||||
@@ -347,7 +356,7 @@ const AssetLibrary: React.FC = () => {
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [assets, activeLibrary, filterType, filterTime, searchText]);
|
||||
}, [assets, filterType, filterTime, searchText]);
|
||||
|
||||
/* 选择操作 */
|
||||
const toggleSelect = (id: string) => {
|
||||
@@ -367,65 +376,109 @@ const AssetLibrary: React.FC = () => {
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
/* 上传 */
|
||||
const handleUpload = (file: File) => {
|
||||
/* 上传 — 调用真实 API */
|
||||
const handleUpload = async (file: File) => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
message.error(`文件 "${file.name}" 超过 2GB 限制`);
|
||||
return false;
|
||||
}
|
||||
if (file.size > LARGE_FILE_THRESHOLD) {
|
||||
message.info(`大文件 "${file.name}" 将使用直传上传(mock)`);
|
||||
if (!effectiveLibId) {
|
||||
message.warning("请先选择或创建一个素材库");
|
||||
return false;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
/* mock 上传 — 1.5s 后完成 */
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (file.size > LARGE_FILE_THRESHOLD) {
|
||||
message.info(`大文件 "${file.name}" 将使用直传上传`);
|
||||
}
|
||||
await uploadAssetDirect(file, (p) => {
|
||||
// 可选:显示上传进度
|
||||
if (p === 100) message.success(`"${file.name}" 上传成功`);
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||||
} catch {
|
||||
message.error(`"${file.name}" 上传失败`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
message.success(`"${file.name}" 上传成功`);
|
||||
}, 1500);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/* 新建素材库 */
|
||||
const handleCreateLibrary = () => {
|
||||
const handleCreateLibrary = async () => {
|
||||
if (!newLibName.trim()) {
|
||||
message.warning("请输入素材库名称");
|
||||
return;
|
||||
}
|
||||
const lib: LibraryItem = {
|
||||
id: `lib-${Date.now()}`,
|
||||
name: newLibName.trim(),
|
||||
kind: newLibKind,
|
||||
count: 0,
|
||||
};
|
||||
setLibraries((prev) => [...prev, lib]);
|
||||
setActiveLibId(lib.id);
|
||||
setCreateModalOpen(false);
|
||||
setNewLibName("");
|
||||
setNewLibKind("video");
|
||||
message.success(`素材库 "${lib.name}" 创建成功`);
|
||||
try {
|
||||
const newLib = await createLibMutation.mutateAsync(newLibName.trim());
|
||||
setActiveLibId(newLib.id);
|
||||
setCreateModalOpen(false);
|
||||
setNewLibName("");
|
||||
setNewLibKind("video");
|
||||
} catch {
|
||||
// error handled in mutation
|
||||
}
|
||||
};
|
||||
|
||||
/* 删除素材库 */
|
||||
const handleDeleteLibrary = (id: string) => {
|
||||
setLibraries((prev) => prev.filter((l) => l.id !== id));
|
||||
if (activeLibId === id) {
|
||||
const remaining = libraries.filter((l) => l.id !== id);
|
||||
if (remaining.length > 0) setActiveLibId(remaining[0].id);
|
||||
const handleDeleteLibrary = async (id: string) => {
|
||||
try {
|
||||
await deleteLibMutation.mutateAsync(id);
|
||||
if (effectiveLibId === id) {
|
||||
const remaining = libraries.filter((l) => l.id !== id);
|
||||
if (remaining.length > 0) setActiveLibId(remaining[0].id);
|
||||
else setActiveLibId("");
|
||||
}
|
||||
} catch {
|
||||
// error handled in mutation
|
||||
}
|
||||
message.success("素材库已删除");
|
||||
};
|
||||
|
||||
/* 诊断 */
|
||||
const handleDiagnose = (asset: AssetItem) => {
|
||||
message.info(`正在诊断 "${asset.name}"...(mock)`);
|
||||
/* 诊断 — 调用真实 API */
|
||||
const handleDiagnose = async (asset: AssetItem) => {
|
||||
try {
|
||||
const result = await getAssetDiagnosis(asset.id);
|
||||
const score = result.quality_score ?? "-";
|
||||
message.success(`"${asset.name}" 诊断完成,质量分:${score}`);
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
} catch {
|
||||
message.error(`"${asset.name}" 诊断失败`);
|
||||
}
|
||||
};
|
||||
|
||||
/* 批量删除 */
|
||||
const handleBatchDelete = () => {
|
||||
message.success(`已删除 ${selectedIds.size} 个素材(mock)`);
|
||||
deselectAll();
|
||||
const handleBatchDelete = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
let successCount = 0;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await deleteAsset(id);
|
||||
successCount++;
|
||||
} catch {
|
||||
// 忽略单个失败
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||||
setSelectedIds(new Set());
|
||||
message.success(`已删除 ${successCount}/${ids.length} 个素材`);
|
||||
};
|
||||
|
||||
// ── Loading 状态 ──
|
||||
if (libLoading) {
|
||||
return (
|
||||
<div className="xx-assets-page">
|
||||
<div className="xx-assets-empty">
|
||||
<div className="xx-assets-empty-icon">⏳</div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-assets-page">
|
||||
{/* 两栏布局 */}
|
||||
@@ -435,7 +488,7 @@ const AssetLibrary: React.FC = () => {
|
||||
{libraries.map((lib) => (
|
||||
<div
|
||||
key={lib.id}
|
||||
className={`xx-asset-library-item${lib.id === activeLibId ? " active" : ""}`}
|
||||
className={`xx-asset-library-item${lib.id === effectiveLibId ? " active" : ""}`}
|
||||
onClick={() => setActiveLibId(lib.id)}
|
||||
>
|
||||
<div
|
||||
@@ -581,7 +634,24 @@ const AssetLibrary: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* 素材网格 */}
|
||||
{filteredAssets.length > 0 ? (
|
||||
{assetsLoading ? (
|
||||
<div className="xx-assets-empty">
|
||||
<div className="xx-assets-empty-icon">⏳</div>
|
||||
<p>加载素材中...</p>
|
||||
</div>
|
||||
) : assetsError ? (
|
||||
<div className="xx-assets-empty">
|
||||
<div className="xx-assets-empty-icon">❌</div>
|
||||
<p>{assetsErrorObj?.message || "加载失败"}</p>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
onClick={() => refetchAssets()}
|
||||
>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
) : filteredAssets.length > 0 ? (
|
||||
<div className="xx-asset-grid">
|
||||
{filteredAssets.map((asset) => (
|
||||
<AssetCard
|
||||
@@ -613,6 +683,7 @@ const AssetLibrary: React.FC = () => {
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
confirmLoading={createLibMutation.isPending}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -62,7 +62,10 @@ const CLONE_STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
failed: { label: "失败", color: "var(--error-color, #ef4444)" },
|
||||
};
|
||||
|
||||
/* ── 时间线 Mock ── */
|
||||
/* ── 时间线 Mock ──
|
||||
* TODO: 后端暂无时间线场景数据 API,当前使用硬编码预览数据。
|
||||
* 待后端提供 timeline/scene 接口后替换为真实 API 调用。
|
||||
*/
|
||||
interface TimelineScene {
|
||||
scene: string;
|
||||
time: string;
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
/**
|
||||
* 任务历史页面 — V21 设计系统
|
||||
* 页面头部 + 圆角胶囊 Tab 筛选(含计数)+ 卡片式任务列表 + 分页 + 空状态
|
||||
* 使用 mock 数据,CSS 变量,V21 组件
|
||||
* 使用 useQuery 对接后端真实 API(api/tasks.ts)
|
||||
*/
|
||||
import React, { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui";
|
||||
import { getUserTasks, retryTask, type TaskItem } from "@/api/tasks";
|
||||
import "./history.css";
|
||||
|
||||
/* ============================================================
|
||||
* Mock 数据
|
||||
* 类型 & 常量
|
||||
* ============================================================ */
|
||||
type TaskStatus = "completed" | "processing" | "pending" | "failed";
|
||||
|
||||
interface TaskItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
template: string;
|
||||
status: TaskStatus;
|
||||
date: string;
|
||||
duration?: string;
|
||||
}
|
||||
|
||||
const statusLabel: Record<TaskStatus, string> = {
|
||||
completed: "已完成",
|
||||
processing: "进行中",
|
||||
@@ -29,138 +21,30 @@ const statusLabel: Record<TaskStatus, string> = {
|
||||
failed: "失败",
|
||||
};
|
||||
|
||||
const mockTasks: TaskItem[] = [
|
||||
{
|
||||
id: "task-001",
|
||||
name: "产品介绍视频_春季促销",
|
||||
type: "视频生成",
|
||||
template: "商品展示模板",
|
||||
status: "completed",
|
||||
date: "2026-07-01 09:30",
|
||||
duration: "2分18秒",
|
||||
},
|
||||
{
|
||||
id: "task-002",
|
||||
name: "品牌宣传片_终版",
|
||||
type: "视频生成",
|
||||
template: "品牌宣传模板",
|
||||
status: "processing",
|
||||
date: "2026-07-01 10:15",
|
||||
},
|
||||
{
|
||||
id: "task-003",
|
||||
name: "用户评价合集",
|
||||
type: "视频生成",
|
||||
template: "评价展示模板",
|
||||
status: "completed",
|
||||
date: "2026-06-30 16:42",
|
||||
duration: "1分45秒",
|
||||
},
|
||||
{
|
||||
id: "task-004",
|
||||
name: "新品发布预告",
|
||||
type: "视频生成",
|
||||
template: "新品预告模板",
|
||||
status: "pending",
|
||||
date: "2026-06-30 14:20",
|
||||
},
|
||||
{
|
||||
id: "task-005",
|
||||
name: "活动回顾_618大促",
|
||||
type: "视频生成",
|
||||
template: "活动回顾模板",
|
||||
status: "failed",
|
||||
date: "2026-06-29 11:05",
|
||||
},
|
||||
{
|
||||
id: "task-006",
|
||||
name: "商品口播_夏季新品",
|
||||
type: "视频生成",
|
||||
template: "口播模板",
|
||||
status: "completed",
|
||||
date: "2026-06-28 15:30",
|
||||
duration: "1分52秒",
|
||||
},
|
||||
{
|
||||
id: "task-007",
|
||||
name: "种草视频_护肤推荐",
|
||||
type: "视频生成",
|
||||
template: "种草模板",
|
||||
status: "completed",
|
||||
date: "2026-06-28 10:20",
|
||||
duration: "2分05秒",
|
||||
},
|
||||
{
|
||||
id: "task-008",
|
||||
name: "产品对比评测",
|
||||
type: "视频生成",
|
||||
template: "评测模板",
|
||||
status: "completed",
|
||||
date: "2026-06-27 14:15",
|
||||
duration: "3分12秒",
|
||||
},
|
||||
{
|
||||
id: "task-009",
|
||||
name: "品牌故事_创业历程",
|
||||
type: "视频生成",
|
||||
template: "品牌故事模板",
|
||||
status: "failed",
|
||||
date: "2026-06-27 09:40",
|
||||
},
|
||||
{
|
||||
id: "task-010",
|
||||
name: "知识分享_行业趋势",
|
||||
type: "视频生成",
|
||||
template: "知识分享模板",
|
||||
status: "completed",
|
||||
date: "2026-06-26 16:50",
|
||||
duration: "2分38秒",
|
||||
},
|
||||
{
|
||||
id: "task-011",
|
||||
name: "好物推荐_家居用品",
|
||||
type: "视频生成",
|
||||
template: "种草模板",
|
||||
status: "completed",
|
||||
date: "2026-06-26 11:25",
|
||||
duration: "1分58秒",
|
||||
},
|
||||
{
|
||||
id: "task-012",
|
||||
name: "活动预热_双11倒计时",
|
||||
type: "视频生成",
|
||||
template: "活动预热模板",
|
||||
status: "completed",
|
||||
date: "2026-06-25 13:10",
|
||||
duration: "1分30秒",
|
||||
},
|
||||
{
|
||||
id: "task-013",
|
||||
name: "产品演示_新功能介绍",
|
||||
type: "视频生成",
|
||||
template: "产品演示模板",
|
||||
status: "completed",
|
||||
date: "2026-06-25 09:55",
|
||||
duration: "2分22秒",
|
||||
},
|
||||
{
|
||||
id: "task-014",
|
||||
name: "用户访谈_使用体验",
|
||||
type: "视频生成",
|
||||
template: "访谈模板",
|
||||
status: "failed",
|
||||
date: "2026-06-24 15:40",
|
||||
},
|
||||
{
|
||||
id: "task-015",
|
||||
name: "品牌活动_周年庆典",
|
||||
type: "视频生成",
|
||||
template: "活动模板",
|
||||
status: "completed",
|
||||
date: "2026-06-24 10:30",
|
||||
duration: "2分45秒",
|
||||
},
|
||||
];
|
||||
/** 将后端 status 字符串映射为前端 TaskStatus */
|
||||
const normalizeStatus = (s: string): TaskStatus => {
|
||||
const map: Record<string, TaskStatus> = {
|
||||
completed: "completed",
|
||||
succeeded: "completed",
|
||||
success: "completed",
|
||||
processing: "processing",
|
||||
running: "processing",
|
||||
pending: "pending",
|
||||
queued: "pending",
|
||||
failed: "failed",
|
||||
error: "failed",
|
||||
};
|
||||
return map[s] ?? "pending";
|
||||
};
|
||||
|
||||
/** 格式化日期 */
|
||||
const formatDate = (iso?: string | null): string => {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "—";
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* Tab 配置
|
||||
@@ -189,6 +73,42 @@ const PAGE_SIZE = 10;
|
||||
const TaskHistory: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState("all");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// ── 获取任务列表 ──
|
||||
const {
|
||||
data: tasks = [],
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<TaskItem[], Error>({
|
||||
queryKey: ["tasks"],
|
||||
queryFn: getUserTasks,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// ── 重试任务 mutation ──
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: retryTask,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
});
|
||||
|
||||
// 将后端数据映射为页面展示用的结构
|
||||
const mappedTasks = tasks.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.user_message || t.task_type,
|
||||
type: t.task_type,
|
||||
template: t.template_id,
|
||||
status: normalizeStatus(t.status),
|
||||
date: formatDate(t.created_at),
|
||||
duration: undefined as string | undefined,
|
||||
progress: t.progress,
|
||||
retryable: t.retryable,
|
||||
errorMessage: t.error_message,
|
||||
}));
|
||||
|
||||
// 获取当前 Tab 的筛选状态
|
||||
const currentTab = tabs.find((t) => t.key === activeTab);
|
||||
@@ -196,15 +116,15 @@ const TaskHistory: React.FC = () => {
|
||||
|
||||
// 过滤任务
|
||||
const filteredTasks = statusFilter
|
||||
? mockTasks.filter((t) => t.status === statusFilter)
|
||||
: mockTasks;
|
||||
? mappedTasks.filter((t) => t.status === statusFilter)
|
||||
: mappedTasks;
|
||||
|
||||
// 计算各 Tab 的数量
|
||||
const tabCounts: Record<string, number> = {
|
||||
all: mockTasks.length,
|
||||
processing: mockTasks.filter((t) => t.status === "processing").length,
|
||||
completed: mockTasks.filter((t) => t.status === "completed").length,
|
||||
failed: mockTasks.filter((t) => t.status === "failed").length,
|
||||
all: mappedTasks.length,
|
||||
processing: mappedTasks.filter((t) => t.status === "processing").length,
|
||||
completed: mappedTasks.filter((t) => t.status === "completed").length,
|
||||
failed: mappedTasks.filter((t) => t.status === "failed").length,
|
||||
};
|
||||
|
||||
// 分页
|
||||
@@ -220,18 +140,53 @@ const TaskHistory: React.FC = () => {
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// 重试任务(mock)
|
||||
// 重试任务
|
||||
const handleRetry = (taskId: string) => {
|
||||
console.log("重试任务:", taskId);
|
||||
// TODO: 调用重试 API
|
||||
retryMutation.mutate(taskId);
|
||||
};
|
||||
|
||||
// 查看任务详情(mock)
|
||||
// 查看任务详情
|
||||
const handleView = (taskId: string) => {
|
||||
// TODO: 跳转到任务详情页(待路由实现)
|
||||
console.log("查看任务:", taskId);
|
||||
// TODO: 跳转到任务详情页
|
||||
};
|
||||
|
||||
// ── Loading 状态 ──
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="xx-history-page">
|
||||
<div className="xx-history-header">
|
||||
<h2>任务历史</h2>
|
||||
<p>查看和管理所有生成任务</p>
|
||||
</div>
|
||||
<div className="xx-history-empty">
|
||||
<div className="xx-history-empty-icon">⏳</div>
|
||||
<h3>加载中...</h3>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error 状态 ──
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="xx-history-page">
|
||||
<div className="xx-history-header">
|
||||
<h2>任务历史</h2>
|
||||
<p>查看和管理所有生成任务</p>
|
||||
</div>
|
||||
<div className="xx-history-empty">
|
||||
<div className="xx-history-empty-icon">❌</div>
|
||||
<h3>加载失败</h3>
|
||||
<p>{error?.message || "网络异常,请稍后重试"}</p>
|
||||
<Button buttonType="primary" buttonSize="md" onClick={() => refetch()}>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-history-page">
|
||||
{/* ── 页面头部 ──────────────────────────────────────────── */}
|
||||
@@ -287,12 +242,12 @@ const TaskHistory: React.FC = () => {
|
||||
{/* 时间区 */}
|
||||
<div className="xx-history-task-time">
|
||||
<span>{task.date}</span>
|
||||
{task.status === "completed" && task.duration ? (
|
||||
<span>耗时 {task.duration}</span>
|
||||
{task.status === "completed" ? (
|
||||
<span>完成</span>
|
||||
) : task.status === "processing" ? (
|
||||
<span>生成中...</span>
|
||||
<span>进度 {task.progress}%</span>
|
||||
) : task.status === "failed" ? (
|
||||
<span>请重试</span>
|
||||
<span>{task.errorMessage || "请重试"}</span>
|
||||
) : (
|
||||
<span>等待中</span>
|
||||
)}
|
||||
@@ -300,13 +255,14 @@ const TaskHistory: React.FC = () => {
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="xx-history-task-action">
|
||||
{task.status === "failed" ? (
|
||||
{task.status === "failed" && task.retryable ? (
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => handleRetry(task.id)}
|
||||
disabled={retryMutation.isPending}
|
||||
>
|
||||
重试
|
||||
{retryMutation.isPending ? "重试中..." : "重试"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 成片库页面 — V21 设计系统
|
||||
* 卡片网格布局,支持视频播放/下载/分享、批量操作、筛选
|
||||
* 使用 mock 数据,后端 API 对接暂不要求
|
||||
* 使用 useQuery 对接后端真实 API(api/products.ts)
|
||||
*/
|
||||
import React, {
|
||||
useMemo,
|
||||
@@ -10,6 +10,7 @@ import React, {
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { message, Popconfirm } from "antd";
|
||||
import {
|
||||
SearchOutlined,
|
||||
@@ -24,10 +25,16 @@ import {
|
||||
CloudUploadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Button, Input, Select } from "@/components/ui";
|
||||
import {
|
||||
getProducts,
|
||||
deleteProduct,
|
||||
getProductDownloadUrl,
|
||||
type ProductItem as ApiProductItem,
|
||||
} from "@/api/products";
|
||||
import "./products.css";
|
||||
|
||||
/* ============================================================
|
||||
* 类型
|
||||
* 类型 & 常量
|
||||
* ============================================================ */
|
||||
type ProductStatus = "completed" | "processing" | "review" | "failed";
|
||||
|
||||
@@ -42,11 +49,25 @@ interface ProductItem {
|
||||
isPublished: boolean;
|
||||
resolution: string;
|
||||
fileSize: number; // MB
|
||||
videoUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Mock 数据
|
||||
* ============================================================ */
|
||||
/** 将后端 status 映射为前端 ProductStatus */
|
||||
const normalizeStatus = (s: string): ProductStatus => {
|
||||
const map: Record<string, ProductStatus> = {
|
||||
completed: "completed",
|
||||
succeeded: "completed",
|
||||
processing: "processing",
|
||||
running: "processing",
|
||||
review: "review",
|
||||
failed: "failed",
|
||||
error: "failed",
|
||||
};
|
||||
return map[s] ?? "processing";
|
||||
};
|
||||
|
||||
/** 渐变色列表(按 id hash 选取) */
|
||||
const GRADIENTS = [
|
||||
"linear-gradient(135deg, #1e1b4b 0%, #4f46e5 50%, #818cf8 100%)",
|
||||
"linear-gradient(135deg, #831843 0%, #db2777 50%, #f472b6 100%)",
|
||||
@@ -62,152 +83,30 @@ const GRADIENTS = [
|
||||
"linear-gradient(135deg, #450a0e 0%, #e11d48 50%, #fb7185 100%)",
|
||||
];
|
||||
|
||||
const MOCK_PRODUCTS: ProductItem[] = [
|
||||
{
|
||||
id: "p-1",
|
||||
name: "产品介绍_智能客服系统_v2",
|
||||
status: "completed",
|
||||
duration: 95,
|
||||
date: "2026-06-28",
|
||||
gradient: GRADIENTS[0],
|
||||
duplicateRate: 3.2,
|
||||
isPublished: true,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 45.6,
|
||||
},
|
||||
{
|
||||
id: "p-2",
|
||||
name: "品牌宣传_春季活动",
|
||||
status: "completed",
|
||||
duration: 60,
|
||||
date: "2026-06-27",
|
||||
gradient: GRADIENTS[1],
|
||||
duplicateRate: 5.8,
|
||||
isPublished: true,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 32.1,
|
||||
},
|
||||
{
|
||||
id: "p-3",
|
||||
name: "教程_如何使用AI生成视频",
|
||||
status: "completed",
|
||||
duration: 180,
|
||||
date: "2026-06-26",
|
||||
gradient: GRADIENTS[2],
|
||||
duplicateRate: 1.5,
|
||||
isPublished: false,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 78.3,
|
||||
},
|
||||
{
|
||||
id: "p-4",
|
||||
name: "电商推广_夏日特惠",
|
||||
status: "processing",
|
||||
duration: 45,
|
||||
date: "2026-06-25",
|
||||
gradient: GRADIENTS[3],
|
||||
duplicateRate: 0,
|
||||
isPublished: false,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 0,
|
||||
},
|
||||
{
|
||||
id: "p-5",
|
||||
name: "知识科普_量子计算入门",
|
||||
status: "completed",
|
||||
duration: 120,
|
||||
date: "2026-06-24",
|
||||
gradient: GRADIENTS[4],
|
||||
duplicateRate: 8.1,
|
||||
isPublished: false,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 56.2,
|
||||
},
|
||||
{
|
||||
id: "p-6",
|
||||
name: "企业宣传片_科技创新",
|
||||
status: "review",
|
||||
duration: 150,
|
||||
date: "2026-06-23",
|
||||
gradient: GRADIENTS[5],
|
||||
duplicateRate: 12.5,
|
||||
isPublished: false,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 68.9,
|
||||
},
|
||||
{
|
||||
id: "p-7",
|
||||
name: "短视频_产品开箱测评",
|
||||
status: "completed",
|
||||
duration: 75,
|
||||
date: "2026-06-22",
|
||||
gradient: GRADIENTS[6],
|
||||
duplicateRate: 6.3,
|
||||
isPublished: true,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 38.7,
|
||||
},
|
||||
{
|
||||
id: "p-8",
|
||||
name: "Vlog_日常工作记录",
|
||||
status: "completed",
|
||||
duration: 200,
|
||||
date: "2026-06-21",
|
||||
gradient: GRADIENTS[7],
|
||||
duplicateRate: 2.1,
|
||||
isPublished: false,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 92.4,
|
||||
},
|
||||
{
|
||||
id: "p-9",
|
||||
name: "广告_新品发布会预告",
|
||||
status: "failed",
|
||||
duration: 30,
|
||||
date: "2026-06-20",
|
||||
gradient: GRADIENTS[8],
|
||||
duplicateRate: 0,
|
||||
isPublished: false,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 0,
|
||||
},
|
||||
{
|
||||
id: "p-10",
|
||||
name: "教育课程_AI基础讲解",
|
||||
status: "completed",
|
||||
duration: 300,
|
||||
date: "2026-06-19",
|
||||
gradient: GRADIENTS[9],
|
||||
duplicateRate: 4.7,
|
||||
isPublished: false,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 125.8,
|
||||
},
|
||||
{
|
||||
id: "p-11",
|
||||
name: "直播带货_美妆专场",
|
||||
status: "review",
|
||||
duration: 90,
|
||||
date: "2026-06-18",
|
||||
gradient: GRADIENTS[10],
|
||||
duplicateRate: 15.3,
|
||||
isPublished: false,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 42.1,
|
||||
},
|
||||
{
|
||||
id: "p-12",
|
||||
name: "品牌故事_创始人访谈",
|
||||
status: "completed",
|
||||
duration: 240,
|
||||
date: "2026-06-17",
|
||||
gradient: GRADIENTS[11],
|
||||
duplicateRate: 7.9,
|
||||
isPublished: false,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 108.5,
|
||||
},
|
||||
];
|
||||
/** 根据 id 生成稳定的渐变色 */
|
||||
const gradientForId = (id: string): string => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) | 0;
|
||||
return GRADIENTS[Math.abs(hash) % GRADIENTS.length];
|
||||
};
|
||||
|
||||
/** 将后端 ProductItem 映射为前端 ProductItem */
|
||||
const mapApiProduct = (item: ApiProductItem): ProductItem => ({
|
||||
id: item.id,
|
||||
name: item.title,
|
||||
status: normalizeStatus(item.status),
|
||||
duration: item.duration_seconds ?? 0,
|
||||
date: item.created_at
|
||||
? new Date(item.created_at).toISOString().slice(0, 10)
|
||||
: "—",
|
||||
gradient: gradientForId(item.id),
|
||||
duplicateRate: item.duplicate_rate ?? 0,
|
||||
isPublished: false, // TODO: 后端发布状态字段待补齐
|
||||
resolution: item.resolution ?? "1080×1920",
|
||||
fileSize: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
|
||||
videoUrl: item.video_url,
|
||||
thumbnailUrl: item.thumbnail_url,
|
||||
});
|
||||
|
||||
/* ============================================================
|
||||
* 工具函数
|
||||
@@ -302,10 +201,19 @@ const ProductCard: React.FC<{
|
||||
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-product-thumb">
|
||||
<div
|
||||
className="xx-product-thumb-bg"
|
||||
style={{ background: product.gradient }}
|
||||
/>
|
||||
{product.thumbnailUrl ? (
|
||||
<img
|
||||
className="xx-product-thumb-bg"
|
||||
src={product.thumbnailUrl}
|
||||
alt={product.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="xx-product-thumb-bg"
|
||||
style={{ background: product.gradient }}
|
||||
/>
|
||||
)}
|
||||
<div className="xx-product-play">
|
||||
<PlayCircleOutlined />
|
||||
</div>
|
||||
@@ -404,55 +312,53 @@ const VideoPlayer: React.FC<{
|
||||
onDownload: (product: ProductItem) => void;
|
||||
onShare: (product: ProductItem) => void;
|
||||
}> = ({ product, onClose, onDownload, onShare }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration] = useState(product.duration);
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
const [duration, setDuration] = useState(product.duration);
|
||||
|
||||
/** Mock 播放控制 */
|
||||
const hasVideo = !!product.videoUrl;
|
||||
|
||||
/** 播放/暂停 */
|
||||
const handlePlayPause = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (isPlaying) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
setIsPlaying(false);
|
||||
video.pause();
|
||||
} else {
|
||||
setIsPlaying(true);
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
setCurrentTime((prev) => {
|
||||
if (prev >= duration) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
setIsPlaying(false);
|
||||
return 0;
|
||||
}
|
||||
return prev + 0.1;
|
||||
});
|
||||
}, 100);
|
||||
video.play().catch(() => {});
|
||||
}
|
||||
}, [isPlaying, duration]);
|
||||
setIsPlaying(!isPlaying);
|
||||
}, [isPlaying]);
|
||||
|
||||
/** 视频事件监听 */
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const onTime = () => setCurrentTime(video.currentTime);
|
||||
const onDur = () => setDuration(video.duration || product.duration);
|
||||
const onEnd = () => setIsPlaying(false);
|
||||
video.addEventListener("timeupdate", onTime);
|
||||
video.addEventListener("loadedmetadata", onDur);
|
||||
video.addEventListener("ended", onEnd);
|
||||
return () => {
|
||||
video.removeEventListener("timeupdate", onTime);
|
||||
video.removeEventListener("loadedmetadata", onDur);
|
||||
video.removeEventListener("ended", onEnd);
|
||||
};
|
||||
}, [product.duration]);
|
||||
|
||||
/** 进度条点击 */
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current) return;
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const percent = (e.clientX - rect.left) / rect.width;
|
||||
setCurrentTime(percent * duration);
|
||||
const newTime = percent * duration;
|
||||
setCurrentTime(newTime);
|
||||
if (videoRef.current) videoRef.current.currentTime = newTime;
|
||||
};
|
||||
|
||||
/** 关闭时清理 */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
/** ESC 关闭 */
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
@@ -469,20 +375,28 @@ const VideoPlayer: React.FC<{
|
||||
<div className="xx-player-container" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 视频区域 */}
|
||||
<div className="xx-player-video-wrap">
|
||||
{/* Mock 视频 — 用渐变背景代替 */}
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: product.gradient,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "rgba(255,255,255,0.3)",
|
||||
fontSize: "64px",
|
||||
}}
|
||||
>
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
{hasVideo ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={product.videoUrl}
|
||||
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||
/>
|
||||
) : (
|
||||
/* 无视频 URL 时用渐变占位 */
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: product.gradient,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "rgba(255,255,255,0.3)",
|
||||
fontSize: "64px",
|
||||
}}
|
||||
>
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 播放/暂停按钮 */}
|
||||
<button className="xx-player-play-btn" onClick={handlePlayPause}>
|
||||
@@ -580,8 +494,35 @@ const VideoPlayer: React.FC<{
|
||||
* 主组件
|
||||
* ============================================================ */
|
||||
const ProductLibrary: React.FC = () => {
|
||||
/* 数据 */
|
||||
const [products, setProducts] = useState<ProductItem[]>(MOCK_PRODUCTS);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/* ── 获取成品列表 ── */
|
||||
const {
|
||||
data: apiProducts = [],
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<ApiProductItem[], Error>({
|
||||
queryKey: ["products"],
|
||||
queryFn: getProducts,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// 映射为前端类型
|
||||
const products = useMemo(() => apiProducts.map(mapApiProduct), [apiProducts]);
|
||||
|
||||
/* ── 删除 mutation ── */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteProduct,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] });
|
||||
message.success("已删除");
|
||||
},
|
||||
onError: () => {
|
||||
message.error("删除失败");
|
||||
},
|
||||
});
|
||||
|
||||
/* 筛选 */
|
||||
const [searchText, setSearchText] = useState("");
|
||||
@@ -610,7 +551,7 @@ const ProductLibrary: React.FC = () => {
|
||||
|
||||
/* 时间筛选 */
|
||||
if (filterTime !== "all") {
|
||||
const now = new Date("2026-07-01");
|
||||
const now = new Date();
|
||||
list = list.filter((p) => {
|
||||
const d = new Date(p.date);
|
||||
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24);
|
||||
@@ -678,16 +619,26 @@ const ProductLibrary: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
/* 下载 */
|
||||
const handleDownload = (product: ProductItem) => {
|
||||
/* 下载 — 调用真实 API 获取下载链接 */
|
||||
const handleDownload = async (product: ProductItem) => {
|
||||
if (product.status !== "completed") return;
|
||||
message.success(`正在下载"${product.name}"(mock)`);
|
||||
try {
|
||||
const { url } = await getProductDownloadUrl(product.id);
|
||||
// 打开下载链接
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "";
|
||||
a.click();
|
||||
message.success(`正在下载"${product.name}"`);
|
||||
} catch {
|
||||
message.error("获取下载链接失败");
|
||||
}
|
||||
};
|
||||
|
||||
/* 分享 */
|
||||
const handleShare = (product: ProductItem) => {
|
||||
if (product.status !== "completed") return;
|
||||
const link = `https://xiaoxia.ai/share/${product.id}`;
|
||||
const link = `${window.location.origin}/share/${product.id}`;
|
||||
navigator.clipboard?.writeText(link).then(
|
||||
() => message.success(`分享链接已复制:${link}`),
|
||||
() => message.success(`分享链接:${link}(请手动复制)`),
|
||||
@@ -696,48 +647,91 @@ const ProductLibrary: React.FC = () => {
|
||||
|
||||
/* 删除 */
|
||||
const handleDelete = (id: string) => {
|
||||
setProducts((prev) => prev.filter((p) => p.id !== id));
|
||||
deleteMutation.mutate(id);
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
message.success("已删除");
|
||||
};
|
||||
|
||||
/* 发布 */
|
||||
const handlePublish = (product: ProductItem) => {
|
||||
setProducts((prev) =>
|
||||
prev.map((p) => (p.id === product.id ? { ...p, isPublished: true } : p)),
|
||||
);
|
||||
message.success(`"${product.name}" 已发布`);
|
||||
/* 发布 — TODO: 后端发布 API 待实现 */
|
||||
const handlePublish = (_product: ProductItem) => {
|
||||
// TODO: 对接后端发布 API(当前后端未提供发布接口)
|
||||
message.info("发布功能待后端 API 补齐");
|
||||
};
|
||||
|
||||
/* 批量下载 */
|
||||
const handleBatchDownload = () => {
|
||||
const count = selectedIds.size;
|
||||
message.success(`正在批量下载 ${count} 个视频(mock)`);
|
||||
const handleBatchDownload = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
let successCount = 0;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const { url } = await getProductDownloadUrl(id);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "";
|
||||
a.click();
|
||||
successCount++;
|
||||
} catch {
|
||||
// 忽略单个失败
|
||||
}
|
||||
}
|
||||
message.success(`已下载 ${successCount}/${ids.length} 个视频`);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
/* 批量删除 */
|
||||
const handleBatchDelete = () => {
|
||||
const count = selectedIds.size;
|
||||
setProducts((prev) => prev.filter((p) => !selectedIds.has(p.id)));
|
||||
const handleBatchDelete = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
let successCount = 0;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await deleteProduct(id);
|
||||
successCount++;
|
||||
} catch {
|
||||
// 忽略单个失败
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] });
|
||||
setSelectedIds(new Set());
|
||||
message.success(`已批量删除 ${count} 个视频`);
|
||||
message.success(`已批量删除 ${successCount}/${ids.length} 个视频`);
|
||||
};
|
||||
|
||||
/* 批量发布 */
|
||||
const handleBatchPublish = () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
setProducts((prev) =>
|
||||
prev.map((p) => (ids.includes(p.id) ? { ...p, isPublished: true } : p)),
|
||||
);
|
||||
message.success(`已批量发布 ${ids.length} 个视频`);
|
||||
// TODO: 对接后端批量发布 API
|
||||
message.info("批量发布功能待后端 API 补齐");
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
// ── Loading 状态 ──
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="xx-products-page">
|
||||
<div className="xx-products-empty">
|
||||
<div className="xx-products-empty-icon">⏳</div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error 状态 ──
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="xx-products-page">
|
||||
<div className="xx-products-empty">
|
||||
<div className="xx-products-empty-icon">❌</div>
|
||||
<p>{error?.message || "加载失败"}</p>
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={() => refetch()}>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-products-page">
|
||||
{/* 页面头部 */}
|
||||
@@ -900,7 +894,7 @@ const ProductLibrary: React.FC = () => {
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
onClick={() => message.info("跳转到生成页面(mock)")}
|
||||
onClick={() => message.info("跳转到生成页面")}
|
||||
>
|
||||
去生成
|
||||
</Button>
|
||||
|
||||
@@ -5,11 +5,19 @@
|
||||
* - 按 EditTemplate 类型分组展示
|
||||
* - 缩略图 + 预览弹窗
|
||||
* - 创建 EditPlan 入口 UI
|
||||
* - Mock 数据 + 预留 API 对接接口
|
||||
* - 使用 useQuery 对接后端真实 API(api/templates.ts, api/editPlans.ts)
|
||||
*/
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui";
|
||||
import {
|
||||
getTemplates,
|
||||
getTemplate,
|
||||
toggleFavoriteTemplate,
|
||||
type TemplateItem,
|
||||
} from "@/api/templates";
|
||||
import { createEditPlan } from "@/api/editPlans";
|
||||
import "./templates.css";
|
||||
|
||||
/* ============================================================
|
||||
@@ -34,7 +42,7 @@ type EditTemplateType =
|
||||
| "混剪"
|
||||
| "Vlog";
|
||||
|
||||
/** 模板数据 */
|
||||
/** 模板数据(UI 层,映射自后端 TemplateItem) */
|
||||
interface EditTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -49,41 +57,50 @@ interface EditTemplate {
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/** 创建 EditPlan 参数 */
|
||||
interface CreateEditPlanParams {
|
||||
templateId: string;
|
||||
planName: string;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* API 占位函数(预留后端对接)
|
||||
* 映射:后端 TemplateItem → 前端 EditTemplate
|
||||
* ============================================================ */
|
||||
|
||||
/** 获取模板列表 */
|
||||
export async function fetchTemplates(
|
||||
type?: EditTemplateType,
|
||||
): Promise<EditTemplate[]> {
|
||||
// TODO: 对接后端 GET /api/templates?type=xxx
|
||||
void type;
|
||||
return mockTemplates;
|
||||
}
|
||||
/** 根据 category 推断模板类型 */
|
||||
const inferTemplateType = (category: string): EditTemplateType => {
|
||||
const map: Record<string, EditTemplateType> = {
|
||||
口播: "口播",
|
||||
种草: "种草",
|
||||
产品: "产品",
|
||||
品牌: "品牌",
|
||||
混剪: "混剪",
|
||||
Vlog: "Vlog",
|
||||
};
|
||||
return map[category] ?? "口播";
|
||||
};
|
||||
|
||||
/** 获取模板详情 */
|
||||
export async function fetchTemplateById(
|
||||
id: string,
|
||||
): Promise<EditTemplate | null> {
|
||||
// TODO: 对接后端 GET /api/templates/:id
|
||||
return mockTemplates.find((t) => t.id === id) ?? null;
|
||||
}
|
||||
/** 根据 category 生成占位渐变色 */
|
||||
const gradientForCategory = (category: string): string => {
|
||||
const gradients: Record<string, string> = {
|
||||
口播: "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||
种草: "linear-gradient(135deg, #10b981, #059669)",
|
||||
产品: "linear-gradient(135deg, #0ea5e9, #0284c7)",
|
||||
品牌: "linear-gradient(135deg, #f59e0b, #d97706)",
|
||||
混剪: "linear-gradient(135deg, #8b5cf6, #6d28d9)",
|
||||
Vlog: "linear-gradient(135deg, #ec4899, #db2777)",
|
||||
};
|
||||
return gradients[category] ?? "linear-gradient(135deg, #6366f1, #8b5cf6)";
|
||||
};
|
||||
|
||||
/** 创建 EditPlan */
|
||||
export async function createEditPlan(
|
||||
params: CreateEditPlanParams,
|
||||
): Promise<{ planId: string }> {
|
||||
// TODO: 对接后端 POST /api/edit-plans
|
||||
console.log("[Mock] createEditPlan", params);
|
||||
return { planId: `plan-${Date.now()}` };
|
||||
}
|
||||
/** 将后端 TemplateItem 映射为前端 EditTemplate */
|
||||
const mapTemplateItemToEditTemplate = (item: TemplateItem): EditTemplate => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: inferTemplateType(item.category),
|
||||
description: item.description,
|
||||
usageCount: 0,
|
||||
isFavorite: item.is_favorite ?? false,
|
||||
thumbnailGradient: gradientForCategory(item.category),
|
||||
scriptContent: "",
|
||||
clipConfigs: [],
|
||||
recommendedDuration: item.target_duration ?? 0,
|
||||
tags: [item.category],
|
||||
});
|
||||
|
||||
/* ============================================================
|
||||
* 模板类型配置
|
||||
@@ -104,196 +121,6 @@ const TEMPLATE_TYPES: Array<{
|
||||
{ type: "Vlog", label: "Vlog", icon: "📹", color: "#ec4899" },
|
||||
];
|
||||
|
||||
/* ============================================================
|
||||
* Mock 数据
|
||||
* ============================================================ */
|
||||
|
||||
const mockTemplates: EditTemplate[] = [
|
||||
{
|
||||
id: "tpl-1",
|
||||
name: "商品口播模板",
|
||||
type: "口播",
|
||||
description: "适用于电商商品介绍的口播视频模板,包含开场、产品介绍、卖点展示、结尾引导等完整结构。",
|
||||
usageCount: 128,
|
||||
isFavorite: true,
|
||||
thumbnailGradient: "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||
scriptContent: "# 商品口播脚本\n## 开场白\n大家好,今天给大家推荐...\n## 产品介绍\n这款产品的特点是...\n## 使用演示\n我们来看实际效果...\n## 总结\n赶紧下单吧!",
|
||||
clipConfigs: [
|
||||
{ id: "c1", order: 1, clipType: "开场", description: "吸引注意力的开场白", duration: 5 },
|
||||
{ id: "c2", order: 2, clipType: "产品展示", description: "产品外观和功能展示", duration: 15 },
|
||||
{ id: "c3", order: 3, clipType: "卖点讲解", description: "核心卖点详细说明", duration: 20 },
|
||||
{ id: "c4", order: 4, clipType: "结尾", description: "引导下单的结尾", duration: 5 },
|
||||
],
|
||||
recommendedDuration: 45,
|
||||
tags: ["电商", "口播", "商品介绍"],
|
||||
},
|
||||
{
|
||||
id: "tpl-2",
|
||||
name: "种草笔记视频模板",
|
||||
type: "种草",
|
||||
description: "适合美妆、护肤、生活方式等种草类内容,真实体验分享风格。",
|
||||
usageCount: 96,
|
||||
isFavorite: false,
|
||||
thumbnailGradient: "linear-gradient(135deg, #10b981, #059669)",
|
||||
scriptContent: "# 种草视频\n## 使用场景\n早上出门前的护肤步骤...\n## 产品亮点\n温和不刺激,适合敏感肌...\n## 使用心得\n用了一个月后的感受...",
|
||||
clipConfigs: [
|
||||
{ id: "c1", order: 1, clipType: "场景引入", description: "日常场景带入", duration: 8 },
|
||||
{ id: "c2", order: 2, clipType: "产品体验", description: "使用过程展示", duration: 20 },
|
||||
{ id: "c3", order: 3, clipType: "效果对比", description: "使用前后对比", duration: 10 },
|
||||
{ id: "c4", order: 4, clipType: "总结推荐", description: "使用心得总结", duration: 7 },
|
||||
],
|
||||
recommendedDuration: 45,
|
||||
tags: ["种草", "美妆", "体验分享"],
|
||||
},
|
||||
{
|
||||
id: "tpl-3",
|
||||
name: "新品发布展示模板",
|
||||
type: "产品",
|
||||
description: "适合新品发布、产品升级等场景,突出产品亮点和技术优势。",
|
||||
usageCount: 74,
|
||||
isFavorite: false,
|
||||
thumbnailGradient: "linear-gradient(135deg, #0ea5e9, #0284c7)",
|
||||
scriptContent: "# 新品发布\n## 产品概览\n全新升级,性能提升50%...\n## 核心功能\nAI智能识别,一键优化...\n## 技术参数\n详细技术规格展示...",
|
||||
clipConfigs: [
|
||||
{ id: "c1", order: 1, clipType: "悬念开场", description: "产品悬念引入", duration: 5 },
|
||||
{ id: "c2", order: 2, clipType: "产品全景", description: "产品360度展示", duration: 10 },
|
||||
{ id: "c3", order: 3, clipType: "功能演示", description: "核心功能实操", duration: 25 },
|
||||
{ id: "c4", order: 4, clipType: "技术规格", description: "参数对比展示", duration: 10 },
|
||||
{ id: "c5", order: 5, clipType: "结尾", description: "购买引导", duration: 5 },
|
||||
],
|
||||
recommendedDuration: 55,
|
||||
tags: ["产品", "发布", "科技"],
|
||||
},
|
||||
{
|
||||
id: "tpl-4",
|
||||
name: "品牌故事宣传片",
|
||||
type: "品牌",
|
||||
description: "讲述品牌故事,传递品牌理念,适合品牌形象建设和宣传。",
|
||||
usageCount: 52,
|
||||
isFavorite: true,
|
||||
thumbnailGradient: "linear-gradient(135deg, #f59e0b, #d97706)",
|
||||
scriptContent: "# 品牌故事\n## 品牌起源\n2020年,我们从一个想法开始...\n## 品牌理念\n让创作更简单...\n## 团队风采\n我们的团队...",
|
||||
clipConfigs: [
|
||||
{ id: "c1", order: 1, clipType: "品牌起源", description: "创业故事引入", duration: 15 },
|
||||
{ id: "c2", order: 2, clipType: "发展历程", description: "里程碑事件", duration: 15 },
|
||||
{ id: "c3", order: 3, clipType: "核心理念", description: "品牌价值主张", duration: 10 },
|
||||
{ id: "c4", order: 4, clipType: "未来展望", description: "品牌愿景", duration: 10 },
|
||||
],
|
||||
recommendedDuration: 50,
|
||||
tags: ["品牌", "宣传", "故事"],
|
||||
},
|
||||
{
|
||||
id: "tpl-5",
|
||||
name: "知识分享口播模板",
|
||||
type: "口播",
|
||||
description: "适合知识博主、教程类内容,结构清晰,信息密度高。",
|
||||
usageCount: 215,
|
||||
isFavorite: false,
|
||||
thumbnailGradient: "linear-gradient(135deg, #8b5cf6, #7c3aed)",
|
||||
scriptContent: "# 知识分享\n## 主题引入\n今天我们来聊一个很多人问的问题...\n## 核心内容\n第一点,第二点,第三点...\n## 总结\n希望对你有帮助...",
|
||||
clipConfigs: [
|
||||
{ id: "c1", order: 1, clipType: "开场", description: "话题引入", duration: 5 },
|
||||
{ id: "c2", order: 2, clipType: "知识点1", description: "第一个要点", duration: 15 },
|
||||
{ id: "c3", order: 3, clipType: "知识点2", description: "第二个要点", duration: 15 },
|
||||
{ id: "c4", order: 4, clipType: "总结", description: "内容回顾", duration: 5 },
|
||||
],
|
||||
recommendedDuration: 40,
|
||||
tags: ["知识", "教程", "口播"],
|
||||
},
|
||||
{
|
||||
id: "tpl-6",
|
||||
name: "好物推荐种草模板",
|
||||
type: "种草",
|
||||
description: "以痛点引入,展示解决方案,适合日常好物推荐。",
|
||||
usageCount: 183,
|
||||
isFavorite: true,
|
||||
thumbnailGradient: "linear-gradient(135deg, #059669, #047857)",
|
||||
scriptContent: "# 好物推荐\n## 痛点引入\n你是不是也有这样的困扰...\n## 解决方案\n直到我发现了这个神器...\n## 使用效果\n一个月后的变化...",
|
||||
clipConfigs: [
|
||||
{ id: "c1", order: 1, clipType: "痛点", description: "用户痛点共鸣", duration: 8 },
|
||||
{ id: "c2", order: 2, clipType: "产品引入", description: "产品出场", duration: 10 },
|
||||
{ id: "c3", order: 3, clipType: "使用展示", description: "实际使用过程", duration: 15 },
|
||||
{ id: "c4", order: 4, clipType: "效果", description: "使用效果展示", duration: 10 },
|
||||
],
|
||||
recommendedDuration: 43,
|
||||
tags: ["种草", "好物", "推荐"],
|
||||
},
|
||||
{
|
||||
id: "tpl-7",
|
||||
name: "产品对比评测模板",
|
||||
type: "产品",
|
||||
description: "多维度对比评测,客观公正,适合数码、家电等产品。",
|
||||
usageCount: 67,
|
||||
isFavorite: false,
|
||||
thumbnailGradient: "linear-gradient(135deg, #0284c7, #0369a1)",
|
||||
scriptContent: "# 产品对比\n## 对比维度\n外观、性能、价格、体验...\n## 详细对比\n逐项分析...\n## 总结推荐\n综合来看,A适合...B适合...",
|
||||
clipConfigs: [
|
||||
{ id: "c1", order: 1, clipType: "产品亮相", description: "两款产品展示", duration: 8 },
|
||||
{ id: "c2", order: 2, clipType: "外观对比", description: "外观设计对比", duration: 12 },
|
||||
{ id: "c3", order: 3, clipType: "性能测试", description: "性能数据对比", duration: 15 },
|
||||
{ id: "c4", order: 4, clipType: "总结", description: "购买建议", duration: 10 },
|
||||
],
|
||||
recommendedDuration: 45,
|
||||
tags: ["评测", "对比", "产品"],
|
||||
},
|
||||
{
|
||||
id: "tpl-8",
|
||||
name: "品牌活动预热模板",
|
||||
type: "品牌",
|
||||
description: "适合品牌活动、促销预热,制造期待感。",
|
||||
usageCount: 41,
|
||||
isFavorite: false,
|
||||
thumbnailGradient: "linear-gradient(135deg, #d97706, #b45309)",
|
||||
scriptContent: "# 活动预热\n## 悬念引入\n倒计时3天,惊喜即将揭晓...\n## 活动亮点\n福利一、福利二、福利三...\n## 参与方式\n如何参与活动...",
|
||||
clipConfigs: [
|
||||
{ id: "c1", order: 1, clipType: "悬念", description: "倒计时悬念", duration: 5 },
|
||||
{ id: "c2", order: 2, clipType: "亮点", description: "活动亮点展示", duration: 15 },
|
||||
{ id: "c3", order: 3, clipType: "福利", description: "优惠福利说明", duration: 10 },
|
||||
{ id: "c4", order: 4, clipType: "引导", description: "参与方式引导", duration: 5 },
|
||||
],
|
||||
recommendedDuration: 35,
|
||||
tags: ["品牌", "活动", "预热"],
|
||||
},
|
||||
{
|
||||
id: "tpl-9",
|
||||
name: "多素材混剪模板",
|
||||
type: "混剪",
|
||||
description: "适合多段素材拼接,自动匹配节奏,适合混剪类视频。",
|
||||
usageCount: 89,
|
||||
isFavorite: false,
|
||||
thumbnailGradient: "linear-gradient(135deg, #8b5cf6, #6d28d9)",
|
||||
scriptContent: "# 混剪模板\n## 素材1\n开场高能片段...\n## 素材2\n过渡衔接...\n## 素材3\n高潮部分...\n## 结尾\n精彩回顾...",
|
||||
clipConfigs: [
|
||||
{ id: "c1", order: 1, clipType: "高能开场", description: "吸引眼球的开场", duration: 5 },
|
||||
{ id: "c2", order: 2, clipType: "素材A", description: "第一段素材", duration: 10 },
|
||||
{ id: "c3", order: 3, clipType: "过渡", description: "转场衔接", duration: 3 },
|
||||
{ id: "c4", order: 4, clipType: "素材B", description: "第二段素材", duration: 10 },
|
||||
{ id: "c5", order: 5, clipType: "高潮", description: "高潮片段", duration: 8 },
|
||||
{ id: "c6", order: 6, clipType: "结尾", description: "精彩回顾", duration: 4 },
|
||||
],
|
||||
recommendedDuration: 40,
|
||||
tags: ["混剪", "多素材", "节奏"],
|
||||
},
|
||||
{
|
||||
id: "tpl-10",
|
||||
name: "日常Vlog模板",
|
||||
type: "Vlog",
|
||||
description: "记录日常生活,轻松自然的Vlog风格模板。",
|
||||
usageCount: 156,
|
||||
isFavorite: true,
|
||||
thumbnailGradient: "linear-gradient(135deg, #ec4899, #db2777)",
|
||||
scriptContent: "# 日常Vlog\n## 早安\n今天又是美好的一天...\n## 出门\n准备出门啦...\n## 日常\n记录精彩瞬间...\n## 晚安\n今天也很充实...",
|
||||
clipConfigs: [
|
||||
{ id: "c1", order: 1, clipType: "早安", description: "起床日常", duration: 8 },
|
||||
{ id: "c2", order: 2, clipType: "出门", description: "准备出门", duration: 10 },
|
||||
{ id: "c3", order: 3, clipType: "日常", description: "日常活动记录", duration: 20 },
|
||||
{ id: "c4", order: 4, clipType: "晚安", description: "一天总结", duration: 7 },
|
||||
],
|
||||
recommendedDuration: 45,
|
||||
tags: ["Vlog", "日常", "生活"],
|
||||
},
|
||||
];
|
||||
|
||||
/* ============================================================
|
||||
* 辅助函数
|
||||
* ============================================================ */
|
||||
@@ -443,49 +270,53 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* 脚本内容 */}
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>📝 脚本内容</h4>
|
||||
<pre className="xx-template-modal-script">
|
||||
{template.scriptContent}
|
||||
</pre>
|
||||
</div>
|
||||
{template.scriptContent && (
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>📝 脚本内容</h4>
|
||||
<pre className="xx-template-modal-script">
|
||||
{template.scriptContent}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视频结构 */}
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>🎬 视频结构</h4>
|
||||
<div className="xx-template-modal-clip-list">
|
||||
{template.clipConfigs
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((clip) => (
|
||||
<div key={clip.id} className="xx-template-modal-clip-item">
|
||||
<span
|
||||
className="xx-template-modal-clip-badge"
|
||||
style={{
|
||||
color: getClipTypeColor(clip.clipType),
|
||||
background: `${getClipTypeColor(clip.clipType)}18`,
|
||||
}}
|
||||
>
|
||||
{getClipTypeLabel(clip.clipType)}
|
||||
</span>
|
||||
<span className="xx-template-modal-clip-desc">
|
||||
{clip.description}
|
||||
</span>
|
||||
<span className="xx-template-modal-clip-duration">
|
||||
{clip.duration}秒
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{template.clipConfigs.length > 0 && (
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>🎬 视频结构</h4>
|
||||
<div className="xx-template-modal-clip-list">
|
||||
{template.clipConfigs
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((clip) => (
|
||||
<div key={clip.id} className="xx-template-modal-clip-item">
|
||||
<span
|
||||
className="xx-template-modal-clip-badge"
|
||||
style={{
|
||||
color: getClipTypeColor(clip.clipType),
|
||||
background: `${getClipTypeColor(clip.clipType)}18`,
|
||||
}}
|
||||
>
|
||||
{getClipTypeLabel(clip.clipType)}
|
||||
</span>
|
||||
<span className="xx-template-modal-clip-desc">
|
||||
{clip.description}
|
||||
</span>
|
||||
<span className="xx-template-modal-clip-duration">
|
||||
{clip.duration}秒
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="xx-template-modal-total-duration">
|
||||
总时长:{formatDuration(totalDuration)}
|
||||
{template.recommendedDuration !== totalDuration && (
|
||||
<span>
|
||||
{" "}
|
||||
· 推荐时长:{formatDuration(template.recommendedDuration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-template-modal-total-duration">
|
||||
总时长:{formatDuration(totalDuration)}
|
||||
{template.recommendedDuration !== totalDuration && (
|
||||
<span>
|
||||
{" "}
|
||||
· 推荐时长:{formatDuration(template.recommendedDuration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 统计信息 */}
|
||||
<div className="xx-template-modal-stats">
|
||||
@@ -547,7 +378,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
className="xx-template-thumb-bg"
|
||||
style={{ background: template.thumbnailGradient }}
|
||||
>
|
||||
{template.scriptContent.slice(0, 80)}...
|
||||
{template.description.slice(0, 80)}...
|
||||
</div>
|
||||
<div className="xx-template-thumb-overlay" />
|
||||
<div className="xx-template-preview-hint">点击预览</div>
|
||||
@@ -600,27 +431,59 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
|
||||
const TemplateLibrary: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [activeType, setActiveType] = useState<EditTemplateType | "全部">("全部");
|
||||
const [previewTemplate, setPreviewTemplate] = useState<EditTemplate | null>(null);
|
||||
const [favorites, setFavorites] = useState<Set<string>>(
|
||||
() => new Set(mockTemplates.filter((t) => t.isFavorite).map((t) => t.id)),
|
||||
|
||||
// ── 获取模板列表 ──
|
||||
const {
|
||||
data: apiTemplates = [],
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery<TemplateItem[], Error>({
|
||||
queryKey: ["templates"],
|
||||
queryFn: getTemplates,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// 将后端数据映射为前端 EditTemplate
|
||||
const templates = useMemo(
|
||||
() => apiTemplates.map(mapTemplateItemToEditTemplate),
|
||||
[apiTemplates],
|
||||
);
|
||||
|
||||
// ── 收藏 mutation ──
|
||||
const favMutation = useMutation({
|
||||
mutationFn: toggleFavoriteTemplate,
|
||||
onSuccess: (_data, templateId) => {
|
||||
// 乐观更新:刷新模板列表
|
||||
queryClient.invalidateQueries({ queryKey: ["templates"] });
|
||||
// 同时更新当前预览(如果有)
|
||||
if (previewTemplate && previewTemplate.id === templateId) {
|
||||
setPreviewTemplate((prev) =>
|
||||
prev ? { ...prev, isFavorite: !prev.isFavorite } : prev,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ── 创建 EditPlan mutation ──
|
||||
const createPlanMutation = useMutation({
|
||||
mutationFn: (params: { template_id: string; name: string }) =>
|
||||
createEditPlan(params),
|
||||
});
|
||||
|
||||
/** 切换收藏 */
|
||||
const toggleFavorite = (id: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
setFavorites((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
favMutation.mutate(id);
|
||||
};
|
||||
|
||||
/** 过滤模板 */
|
||||
const filtered = useMemo(() => {
|
||||
return mockTemplates.filter((t) => {
|
||||
return templates.filter((t) => {
|
||||
const matchSearch =
|
||||
!searchText ||
|
||||
t.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
@@ -631,7 +494,7 @@ const TemplateLibrary: React.FC = () => {
|
||||
const matchType = activeType === "全部" || t.type === activeType;
|
||||
return matchSearch && matchType;
|
||||
});
|
||||
}, [searchText, activeType]);
|
||||
}, [searchText, activeType, templates]);
|
||||
|
||||
/** 按类型分组 */
|
||||
const groupedTemplates = useMemo(() => {
|
||||
@@ -646,17 +509,41 @@ const TemplateLibrary: React.FC = () => {
|
||||
/** 使用模板 → 创建 EditPlan */
|
||||
const handleUseTemplate = async (template: EditTemplate) => {
|
||||
try {
|
||||
const result = await createEditPlan({
|
||||
templateId: template.id,
|
||||
planName: `基于「${template.name}」的剪辑计划`,
|
||||
await createPlanMutation.mutateAsync({
|
||||
template_id: template.id,
|
||||
name: `基于「${template.name}」的剪辑计划`,
|
||||
});
|
||||
console.log("[TemplateLibrary] EditPlan created:", result.planId);
|
||||
navigate("/app/editing-planner");
|
||||
} catch (err) {
|
||||
console.error("[TemplateLibrary] createEditPlan failed:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Loading 状态 ──
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="xx-templates-page">
|
||||
<div className="xx-templates-empty">
|
||||
<div className="xx-templates-empty-icon">⏳</div>
|
||||
<h3>加载模板中...</h3>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error 状态 ──
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="xx-templates-page">
|
||||
<div className="xx-templates-empty">
|
||||
<div className="xx-templates-empty-icon">❌</div>
|
||||
<h3>加载失败</h3>
|
||||
<p>{error?.message || "网络异常,请稍后重试"}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-templates-page">
|
||||
{/* ── 页面头部 ──────────────────────────────────────────── */}
|
||||
@@ -714,7 +601,7 @@ const TemplateLibrary: React.FC = () => {
|
||||
) : activeType === "全部" ? (
|
||||
/* 全部类型 → 按类型分组展示 */
|
||||
<div className="xx-templates-grouped">
|
||||
{Object.entries(groupedTemplates).map(([type, templates]) => {
|
||||
{Object.entries(groupedTemplates).map(([type, tpls]) => {
|
||||
const typeConfig = TEMPLATE_TYPES.find((t) => t.type === type);
|
||||
return (
|
||||
<div key={type} className="xx-templates-group">
|
||||
@@ -724,15 +611,15 @@ const TemplateLibrary: React.FC = () => {
|
||||
</span>
|
||||
<h3>{type}</h3>
|
||||
<span className="xx-templates-group-count">
|
||||
{templates.length} 个模板
|
||||
{tpls.length} 个模板
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-templates-grid">
|
||||
{templates.map((tpl) => (
|
||||
{tpls.map((tpl) => (
|
||||
<TemplateCard
|
||||
key={tpl.id}
|
||||
template={tpl}
|
||||
isFavorite={favorites.has(tpl.id)}
|
||||
isFavorite={tpl.isFavorite}
|
||||
onPreview={setPreviewTemplate}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onUse={handleUseTemplate}
|
||||
@@ -750,7 +637,7 @@ const TemplateLibrary: React.FC = () => {
|
||||
<TemplateCard
|
||||
key={tpl.id}
|
||||
template={tpl}
|
||||
isFavorite={favorites.has(tpl.id)}
|
||||
isFavorite={tpl.isFavorite}
|
||||
onPreview={setPreviewTemplate}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onUse={handleUseTemplate}
|
||||
@@ -763,7 +650,7 @@ const TemplateLibrary: React.FC = () => {
|
||||
{previewTemplate && (
|
||||
<TemplatePreviewModal
|
||||
template={previewTemplate}
|
||||
isFavorite={favorites.has(previewTemplate.id)}
|
||||
isFavorite={previewTemplate.isFavorite}
|
||||
onClose={() => setPreviewTemplate(null)}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onUse={handleUseTemplate}
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.session import (
|
||||
SessionLocal,
|
||||
build_session_factory,
|
||||
)
|
||||
from packages.domain import GeneratedVideo, GenerationTaskStatus
|
||||
from packages.shared.config import get_shared_settings
|
||||
from packages.shared.storage import get_storage_service
|
||||
|
||||
from .celery_app import celery_app
|
||||
from .video_processing import VideoProcessor
|
||||
|
||||
settings = get_shared_settings()
|
||||
if SessionLocal is None:
|
||||
build_session_factory(settings.database_url)
|
||||
|
||||
|
||||
@celery_app.task(name="worker.generate_video")
|
||||
def generate_video(task_id: str) -> dict:
|
||||
session = SessionLocal()
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
|
||||
try:
|
||||
task_repo = SQLAlchemyGenerationTaskRepository(session)
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
asset_repo = SQLAlchemyAssetRepository(session)
|
||||
storage_service = get_storage_service()
|
||||
|
||||
# 1. 获取生成任务
|
||||
task = task_repo.get(task_id)
|
||||
if task is None:
|
||||
return {"ok": False, "error": f"generation task {task_id} not found"}
|
||||
|
||||
# 2. 更新任务状态为运行中
|
||||
task.status = GenerationTaskStatus.RUNNING
|
||||
task.progress = 10.0
|
||||
task.started_at = task.started_at or datetime.now(timezone.utc)
|
||||
task_repo.update(task)
|
||||
session.commit()
|
||||
|
||||
# 3. 获取素材库中的素材
|
||||
assets = asset_repo.list_by_library(task.asset_library_id)
|
||||
if not assets:
|
||||
raise RuntimeError(f"No assets found in library {task.asset_library_id}")
|
||||
|
||||
task.progress = 20.0
|
||||
task_repo.update(task)
|
||||
session.commit()
|
||||
|
||||
# 4. 下载素材到本地(简化:只处理前3个视频素材)
|
||||
video_assets = [a for a in assets if a.mime_type.startswith("video/")][:3]
|
||||
if not video_assets:
|
||||
raise RuntimeError("No video assets found")
|
||||
|
||||
local_paths = []
|
||||
for i, asset in enumerate(video_assets):
|
||||
local_path = os.path.join(temp_dir, f"input_{i}.mp4")
|
||||
storage_service.download_file(asset.storage_key, local_path)
|
||||
local_paths.append(local_path)
|
||||
|
||||
task.progress = 20.0 + (i + 1) * 10.0
|
||||
task_repo.update(task)
|
||||
session.commit()
|
||||
|
||||
# 5. 使用 VideoProcessor 生成视频
|
||||
processor = VideoProcessor(temp_dir=temp_dir)
|
||||
output_filename = f"{task.id}.mp4"
|
||||
output_path = os.path.join(temp_dir, output_filename)
|
||||
|
||||
task.progress = 50.0
|
||||
task_repo.update(task)
|
||||
session.commit()
|
||||
|
||||
result = processor.concatenate_videos(
|
||||
input_paths=local_paths,
|
||||
output_path=output_path,
|
||||
resolution=(1920, 1080),
|
||||
fps=25,
|
||||
)
|
||||
|
||||
task.progress = 80.0
|
||||
task_repo.update(task)
|
||||
session.commit()
|
||||
|
||||
# 6. 上传到 OSS
|
||||
storage_key = f"projects/{task.project_id}/generated/{task.id}/{output_filename}"
|
||||
thumbnail_key = f"projects/{task.project_id}/generated/{task.id}/thumbnail.jpg"
|
||||
|
||||
storage_service.upload_file(result.output_path, storage_key)
|
||||
storage_service.upload_file(result.thumbnail_path, thumbnail_key)
|
||||
|
||||
file_url = storage_service.get_url(storage_key)
|
||||
thumbnail_url = storage_service.get_url(thumbnail_key)
|
||||
|
||||
task.progress = 90.0
|
||||
task_repo.update(task)
|
||||
session.commit()
|
||||
|
||||
# 7. 创建 GeneratedVideo 记录
|
||||
video = GeneratedVideo.create(
|
||||
project_id=task.project_id,
|
||||
generation_task_id=task.id,
|
||||
name=output_filename,
|
||||
file_url=file_url,
|
||||
file_size=result.file_size,
|
||||
duration=result.duration,
|
||||
thumbnail_url=thumbnail_url,
|
||||
width=result.width,
|
||||
height=result.height,
|
||||
fps=result.fps,
|
||||
)
|
||||
video_repo.create(video)
|
||||
|
||||
# 8. 更新任务状态为完成
|
||||
task.status = GenerationTaskStatus.COMPLETED
|
||||
task.progress = 100.0
|
||||
task.result_count = 1
|
||||
task.completed_at = datetime.now(timezone.utc)
|
||||
task_repo.update(task)
|
||||
session.commit()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"task_id": task.id,
|
||||
"video_id": video.id,
|
||||
"file_url": file_url,
|
||||
"duration": result.duration,
|
||||
"file_size": result.file_size,
|
||||
}
|
||||
|
||||
except Exception as error:
|
||||
try:
|
||||
task_repo = SQLAlchemyGenerationTaskRepository(session)
|
||||
task = task_repo.get(task_id)
|
||||
if task is not None:
|
||||
task.status = GenerationTaskStatus.FAILED
|
||||
task.error_message = str(error)
|
||||
task.completed_at = datetime.now(timezone.utc)
|
||||
task_repo.update(task)
|
||||
session.commit()
|
||||
except Exception as state_error:
|
||||
session.rollback()
|
||||
return {
|
||||
"ok": False,
|
||||
"task_id": task_id,
|
||||
"error": str(error),
|
||||
"state_error": str(state_error),
|
||||
}
|
||||
|
||||
return {"ok": False, "task_id": task_id, "error": str(error)}
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
# 清理临时文件
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
@@ -253,8 +253,8 @@ class EditingModeProcessor:
|
||||
try:
|
||||
if p != output_path:
|
||||
os.remove(p)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/video_processing/editing_modes.py: {e}", exc_info=True)
|
||||
|
||||
return output_path
|
||||
|
||||
@@ -314,8 +314,8 @@ class EditingModeProcessor:
|
||||
|
||||
try:
|
||||
os.remove(concat_file)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/video_processing/editing_modes.py: {e}", exc_info=True)
|
||||
|
||||
return output_path
|
||||
|
||||
@@ -438,8 +438,8 @@ class EditingModeProcessor:
|
||||
if temp_file and temp_file != output_path:
|
||||
try:
|
||||
os.remove(temp_file)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/video_processing/editing_modes.py: {e}", exc_info=True)
|
||||
|
||||
return output_path
|
||||
|
||||
@@ -548,8 +548,8 @@ class EditingModeProcessor:
|
||||
try:
|
||||
if temp_file != output_path:
|
||||
os.remove(temp_file)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/video_processing/editing_modes.py: {e}", exc_info=True)
|
||||
|
||||
return output_path
|
||||
|
||||
@@ -672,8 +672,8 @@ class EditingModeProcessor:
|
||||
try:
|
||||
if temp_file != output_path:
|
||||
os.remove(temp_file)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/video_processing/editing_modes.py: {e}", exc_info=True)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from packages.domain.editing_mode import EditingMode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,15 +44,6 @@ class VideoComposeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class EditingMode(StrEnum):
|
||||
"""剪辑模式枚举"""
|
||||
|
||||
ONE_TAKE = "one_take" # 一镜到底:顺序拼接+转场
|
||||
PIP = "pip" # 画中画:主视频+叠加
|
||||
VOICE_OVER = "voice_over" # 口播:背景画面+配音
|
||||
VOICE_PIP = "voice_pip" # 口播+画中画
|
||||
|
||||
|
||||
class PIPPosition(StrEnum):
|
||||
"""画中画位置枚举"""
|
||||
|
||||
@@ -318,8 +310,8 @@ class VideoComposeService:
|
||||
try:
|
||||
if p != output_path:
|
||||
os.remove(p)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True)
|
||||
|
||||
return output_path
|
||||
|
||||
@@ -361,8 +353,8 @@ class VideoComposeService:
|
||||
|
||||
try:
|
||||
os.remove(concat_file)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True)
|
||||
|
||||
return output_path
|
||||
|
||||
@@ -431,8 +423,8 @@ class VideoComposeService:
|
||||
if temp_file and temp_file != output_path:
|
||||
try:
|
||||
os.remove(temp_file)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True)
|
||||
|
||||
return output_path
|
||||
|
||||
@@ -492,8 +484,8 @@ class VideoComposeService:
|
||||
try:
|
||||
if temp_file != output_path:
|
||||
os.remove(temp_file)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True)
|
||||
|
||||
return output_path
|
||||
|
||||
@@ -558,8 +550,8 @@ class VideoComposeService:
|
||||
try:
|
||||
if temp_file != output_path:
|
||||
os.remove(temp_file)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
@@ -121,8 +121,8 @@ class AssetAnalyzer:
|
||||
|
||||
if os.path.exists(self._temp_dir):
|
||||
shutil.rmtree(self._temp_dir)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/worker_app/tasks/asset_analyzer.py: {e}", exc_info=True)
|
||||
|
||||
def get_video_info(self) -> VideoInfo:
|
||||
"""获取视频基本信息"""
|
||||
@@ -250,10 +250,10 @@ class AssetAnalyzer:
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(path)
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
return np.array(img)
|
||||
with Image.open(path) as img:
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
return np.array(img)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load image {path}: {e}")
|
||||
return None
|
||||
|
||||
@@ -132,5 +132,5 @@ def compose_video(self, job_id: str, **kwargs):
|
||||
output_path = f"/tmp/video_output/{job_id}.mp4"
|
||||
if Path(output_path).exists():
|
||||
Path(output_path).unlink()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/worker_app/tasks/compose_video.py: {e}", exc_info=True)
|
||||
|
||||
@@ -344,8 +344,8 @@ def render_edit_plan(self, plan_id: str) -> dict:
|
||||
if plan and plan.status.value == "rendering":
|
||||
plan.mark_failed()
|
||||
plan_repo.update(plan)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/worker_app/tasks/edit_plan_generation.py: {e}", exc_info=True)
|
||||
raise self.retry(exc=exc, countdown=60)
|
||||
|
||||
return {"status": "error", "message": "数据库连接失败"}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
content = open("F:/openclaw-saas/scripts/init_tracker_data.py", "r", encoding="utf-8").read()
|
||||
content = content.replace('"title":', '"name":')
|
||||
content = content.replace('"URGENT"', '"urgent"')
|
||||
content = content.replace('"HIGH"', '"high"')
|
||||
content = content.replace('"MEDIUM"', '"medium"')
|
||||
content = content.replace('"LOW"', '"low"')
|
||||
open("F:/openclaw-saas/scripts/init_tracker_data.py", "w", encoding="utf-8").write(content)
|
||||
print("Fixed all fields")
|
||||
@@ -1,155 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
# 删除旧数据库,重新创建
|
||||
conn = sqlite3.connect("/app/tracker.db")
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 删除所有表
|
||||
cursor.execute("DROP TABLE IF EXISTS tasks")
|
||||
cursor.execute("DROP TABLE IF EXISTS milestones")
|
||||
|
||||
# 重新创建表
|
||||
cursor.execute("""CREATE TABLE tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
phase TEXT,
|
||||
milestone TEXT,
|
||||
priority TEXT DEFAULT 'medium',
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
)""")
|
||||
|
||||
cursor.execute("""CREATE TABLE milestones (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
phase TEXT,
|
||||
start_date TEXT,
|
||||
end_date TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
description TEXT,
|
||||
created_at TEXT
|
||||
)""")
|
||||
|
||||
# Phase 4 已完成的任务(56个)
|
||||
phase4_tasks = [
|
||||
("JWT Service 实现", "实现 access token 和 refresh token", "completed", "high"),
|
||||
("Password Hasher 实现", "bcrypt 密码加密 cost=12", "completed", "high"),
|
||||
("Redis Session Store", "基于 Redis 的 Session 存储", "completed", "high"),
|
||||
("Email Service 实现", "SMTP 邮件服务", "completed", "high"),
|
||||
("用户注册 API", "用户注册接口", "completed", "high"),
|
||||
("邮箱验证 API", "邮箱验证接口", "completed", "high"),
|
||||
("用户登录 API", "用户登录接口", "completed", "high"),
|
||||
("用户登出 API", "用户登出接口", "completed", "high"),
|
||||
("密码重置 API", "密码重置流程", "completed", "medium"),
|
||||
("创建工作空间 API", "创建工作空间接口", "completed", "high"),
|
||||
("邀请成员 API", "邀请成员接口", "completed", "high"),
|
||||
("接受拒绝邀请 API", "处理邀请接口", "completed", "high"),
|
||||
("移除成员 API", "移除成员接口", "completed", "medium"),
|
||||
("离开工作空间 API", "成员离开接口", "completed", "medium"),
|
||||
("更新成员角色 API", "修改成员角色", "completed", "high"),
|
||||
("列出工作空间 API", "查询工作空间列表", "completed", "medium"),
|
||||
("工作空间详情 API", "工作空间详情", "completed", "medium"),
|
||||
("列出成员 API", "查询成员列表", "completed", "medium"),
|
||||
("Permission Checker", "权限检查器", "completed", "high"),
|
||||
("订阅计划定义", "Free Pro Enterprise", "completed", "high"),
|
||||
("升级订阅 API", "订阅升级接口", "completed", "high"),
|
||||
("取消订阅 API", "订阅取消接口", "completed", "medium"),
|
||||
("配额检查工具", "配额管理工具", "completed", "high"),
|
||||
("UserRepository 接口", "User 仓储接口", "completed", "high"),
|
||||
("UserRepository InMemory 实现", "InMemory 实现", "completed", "high"),
|
||||
("WorkspaceRepository 接口", "Workspace 仓储接口", "completed", "high"),
|
||||
("WorkspaceRepository InMemory 实现", "InMemory 实现", "completed", "high"),
|
||||
("WorkspaceMemberRepository 接口", "Member 仓储接口", "completed", "high"),
|
||||
("WorkspaceMemberRepository InMemory 实现", "InMemory 实现", "completed", "high"),
|
||||
("WorkspaceInvitationRepository 接口", "Invitation 仓储接口", "completed", "high"),
|
||||
("WorkspaceInvitationRepository InMemory 实现", "InMemory 实现", "completed", "high"),
|
||||
("SubscriptionRepository 接口", "Subscription 仓储接口", "completed", "high"),
|
||||
("SubscriptionRepository InMemory 实现", "InMemory 实现", "completed", "high"),
|
||||
("PostgreSQL Repository 实现", "PostgreSQL 数据库适配器", "completed", "high"),
|
||||
("Database Migration 脚本", "数据库迁移脚本", "completed", "high"),
|
||||
("FastAPI 路由层", "API 路由实现", "completed", "high"),
|
||||
("API 文档 Swagger", "Swagger 文档", "completed", "medium"),
|
||||
("错误处理中间件", "统一错误处理", "completed", "high"),
|
||||
("参数验证", "Pydantic 参数验证", "completed", "high"),
|
||||
("Docker 配置", "Docker Compose 配置", "completed", "high"),
|
||||
("Kubernetes 配置", "K8s 部署配置", "completed", "medium"),
|
||||
("健康检查接口", "Health Check API", "completed", "high"),
|
||||
("Celery Worker 配置", "异步任务配置", "completed", "medium"),
|
||||
("Redis 缓存集成", "Redis 缓存", "completed", "high"),
|
||||
("GitHub Actions CI/CD", "CI/CD 流水线", "completed", "high"),
|
||||
("单元测试 170个", "170 个单元测试", "completed", "high"),
|
||||
("集成测试", "12 个集成测试", "completed", "medium"),
|
||||
("性能测试", "性能测试用例", "completed", "medium"),
|
||||
("连接池优化", "5-6x 性能优化", "completed", "high"),
|
||||
("API 文档编写", "API 使用文档", "completed", "medium"),
|
||||
("部署文档", "部署指南", "completed", "medium"),
|
||||
("开发文档", "开发指南", "completed", "medium"),
|
||||
("MIT 开源许可", "MIT License", "completed", "low"),
|
||||
("README 完善", "README.md", "completed", "medium"),
|
||||
("CONTRIBUTING 指南", "贡献指南", "completed", "low"),
|
||||
("CODE_OF_CONDUCT", "行为准则", "completed", "low"),
|
||||
]
|
||||
|
||||
# Phase 4 未完成的任务(4个)
|
||||
phase4_pending = [
|
||||
("文件上传 OSS", "阿里云 OSS 文件上传", "pending", "medium"),
|
||||
("搜索功能", "全文搜索", "pending", "medium"),
|
||||
("WebSocket 实时通信", "WebSocket 支持", "pending", "low"),
|
||||
("Webhook 支持", "Webhook 事件推送", "pending", "low"),
|
||||
]
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
# 插入 Phase 4 任务
|
||||
for name, desc, status, priority in phase4_tasks + phase4_pending:
|
||||
cursor.execute(
|
||||
"""INSERT INTO tasks
|
||||
(name, description, status, phase, priority, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, desc, status, "Phase 4", priority, now, now),
|
||||
)
|
||||
|
||||
# 插入里程碑
|
||||
milestones = [
|
||||
("认证与账号体系", "Phase 4", "2026-06-17", "2026-06-17", "completed", "用户注册登录密码管理"),
|
||||
("多租户权限体系", "Phase 4", "2026-06-17", "2026-06-17", "completed", "工作空间成员管理权限控制"),
|
||||
("订阅与计费体系", "Phase 4", "2026-06-17", "2026-06-17", "completed", "订阅计划配额管理"),
|
||||
("Repository 层", "Phase 4", "2026-06-17", "2026-06-17", "completed", "数据仓储层实现"),
|
||||
("API 层", "Phase 4", "2026-06-17", "2026-06-17", "completed", "FastAPI 接口实现"),
|
||||
("测试与部署", "Phase 4", "2026-06-17", "2026-06-17", "completed", "测试 Docker CI/CD"),
|
||||
]
|
||||
|
||||
for name, phase, start, end, status, desc in milestones:
|
||||
cursor.execute(
|
||||
"""INSERT INTO milestones
|
||||
(name, phase, start_date, end_date, status, description, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, phase, start, end, status, desc, now),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
# 验证
|
||||
cursor.execute('SELECT COUNT(*) FROM tasks WHERE status = "completed"')
|
||||
completed = cursor.fetchone()[0]
|
||||
cursor.execute("SELECT COUNT(*) FROM tasks")
|
||||
total = cursor.fetchone()[0]
|
||||
|
||||
print(f"✅ Tracker 修复完成!")
|
||||
print(f" - 总任务数: {total}")
|
||||
print(f" - 已完成: {completed}")
|
||||
print(f" - 待完成: {total - completed}")
|
||||
print(f" - 完成率: {completed/total*100:.1f}%")
|
||||
|
||||
# 测试中文显示
|
||||
cursor.execute("SELECT name FROM tasks LIMIT 3")
|
||||
print(f"\n前3个任务:")
|
||||
for row in cursor.fetchall():
|
||||
print(f" - {row[0]}")
|
||||
|
||||
conn.close()
|
||||
@@ -122,7 +122,7 @@ services:
|
||||
# 健康检查配置
|
||||
# 注:celery inspect ping 依赖 broker 连接,在容器内不可靠,改用进程检查
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pgrep -f 'celery.*worker' || exit 1"]
|
||||
test: ["CMD-SHELL", "python3 -c "import os,sys;sys.exit(0 if any(b'celery' in open(f'/proc/{p}/cmdline','rb').read() for p in os.listdir('/proc') if p.isdigit()) else 1)""]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
conn = sqlite3.connect("/app/tracker.db")
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 创建表
|
||||
cursor.execute("""CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
phase TEXT,
|
||||
milestone TEXT,
|
||||
priority TEXT DEFAULT 'medium',
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
)""")
|
||||
|
||||
cursor.execute("""CREATE TABLE IF NOT EXISTS milestones (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
phase TEXT,
|
||||
start_date TEXT,
|
||||
end_date TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
description TEXT,
|
||||
created_at TEXT
|
||||
)""")
|
||||
|
||||
# Phase 4 已完成的任务(56个)
|
||||
phase4_tasks = [
|
||||
("JWT Service 实现", "实现 access token 和 refresh token", "completed", "high"),
|
||||
("Password Hasher 实现", "bcrypt 密码加密,cost=12", "completed", "high"),
|
||||
("Redis Session Store", "基于 Redis 的 Session 存储", "completed", "high"),
|
||||
("Email Service 实现", "SMTP 邮件服务", "completed", "high"),
|
||||
("用户注册 API", "用户注册接口", "completed", "high"),
|
||||
("邮箱验证 API", "邮箱验证接口", "completed", "high"),
|
||||
("用户登录 API", "用户登录接口", "completed", "high"),
|
||||
("用户登出 API", "用户登出接口", "completed", "high"),
|
||||
("密码重置 API", "密码重置流程", "completed", "medium"),
|
||||
("创建工作空间 API", "创建工作空间接口", "completed", "high"),
|
||||
("邀请成员 API", "邀请成员接口", "completed", "high"),
|
||||
("接受/拒绝邀请 API", "处理邀请接口", "completed", "high"),
|
||||
("移除成员 API", "移除成员接口", "completed", "medium"),
|
||||
("离开工作空间 API", "成员离开接口", "completed", "medium"),
|
||||
("更新成员角色 API", "修改成员角色", "completed", "high"),
|
||||
("列出工作空间 API", "查询工作空间列表", "completed", "medium"),
|
||||
("工作空间详情 API", "工作空间详情", "completed", "medium"),
|
||||
("列出成员 API", "查询成员列表", "completed", "medium"),
|
||||
("Permission Checker", "权限检查器", "completed", "high"),
|
||||
("订阅计划定义", "Free/Pro/Enterprise", "completed", "high"),
|
||||
("升级订阅 API", "订阅升级接口", "completed", "high"),
|
||||
("取消订阅 API", "订阅取消接口", "completed", "medium"),
|
||||
("配额检查工具", "配额管理工具", "completed", "high"),
|
||||
("UserRepository 接口", "User 仓储接口", "completed", "high"),
|
||||
("UserRepository InMemory 实现", "InMemory 实现", "completed", "high"),
|
||||
("WorkspaceRepository 接口", "Workspace 仓储接口", "completed", "high"),
|
||||
("WorkspaceRepository InMemory 实现", "InMemory 实现", "completed", "high"),
|
||||
("WorkspaceMemberRepository 接口", "Member 仓储接口", "completed", "high"),
|
||||
("WorkspaceMemberRepository InMemory 实现", "InMemory 实现", "completed", "high"),
|
||||
("WorkspaceInvitationRepository 接口", "Invitation 仓储接口", "completed", "high"),
|
||||
("WorkspaceInvitationRepository InMemory 实现", "InMemory 实现", "completed", "high"),
|
||||
("SubscriptionRepository 接口", "Subscription 仓储接口", "completed", "high"),
|
||||
("SubscriptionRepository InMemory 实现", "InMemory 实现", "completed", "high"),
|
||||
("PostgreSQL Repository 实现", "PostgreSQL 数据库适配器", "completed", "high"),
|
||||
("Database Migration 脚本", "数据库迁移脚本", "completed", "high"),
|
||||
("FastAPI 路由层", "API 路由实现", "completed", "high"),
|
||||
("API 文档(Swagger)", "Swagger 文档", "completed", "medium"),
|
||||
("错误处理中间件", "统一错误处理", "completed", "high"),
|
||||
("参数验证", "Pydantic 参数验证", "completed", "high"),
|
||||
("Docker 配置", "Docker Compose 配置", "completed", "high"),
|
||||
("Kubernetes 配置", "K8s 部署配置", "completed", "medium"),
|
||||
("健康检查接口", "Health Check API", "completed", "high"),
|
||||
("Celery Worker 配置", "异步任务配置", "completed", "medium"),
|
||||
("Redis 缓存集成", "Redis 缓存", "completed", "high"),
|
||||
("GitHub Actions CI/CD", "CI/CD 流水线", "completed", "high"),
|
||||
("单元测试(170个)", "170 个单元测试", "completed", "high"),
|
||||
("集成测试", "12 个集成测试", "completed", "medium"),
|
||||
("性能测试", "性能测试用例", "completed", "medium"),
|
||||
("连接池优化", "5-6x 性能优化", "completed", "high"),
|
||||
("API 文档编写", "API 使用文档", "completed", "medium"),
|
||||
("部署文档", "部署指南", "completed", "medium"),
|
||||
("开发文档", "开发指南", "completed", "medium"),
|
||||
("MIT 开源许可", "MIT License", "completed", "low"),
|
||||
("README 完善", "README.md", "completed", "medium"),
|
||||
("CONTRIBUTING 指南", "贡献指南", "completed", "low"),
|
||||
("CODE_OF_CONDUCT", "行为准则", "completed", "low"),
|
||||
]
|
||||
|
||||
# Phase 4 未完成的任务(4个)
|
||||
phase4_pending = [
|
||||
("文件上传(OSS)", "阿里云 OSS 文件上传", "pending", "medium"),
|
||||
("搜索功能", "全文搜索", "pending", "medium"),
|
||||
("WebSocket 实时通信", "WebSocket 支持", "pending", "low"),
|
||||
("Webhook 支持", "Webhook 事件推送", "pending", "low"),
|
||||
]
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
# 插入 Phase 4 任务
|
||||
for name, desc, status, priority in phase4_tasks + phase4_pending:
|
||||
cursor.execute(
|
||||
"""INSERT INTO tasks
|
||||
(name, description, status, phase, priority, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, desc, status, "Phase 4", priority, now, now),
|
||||
)
|
||||
|
||||
# 插入里程碑
|
||||
milestones = [
|
||||
("认证与账号体系", "Phase 4", "2026-06-17", "2026-06-17", "completed", "用户注册、登录、密码管理"),
|
||||
("多租户权限体系", "Phase 4", "2026-06-17", "2026-06-17", "completed", "工作空间、成员管理、权限控制"),
|
||||
("订阅与计费体系", "Phase 4", "2026-06-17", "2026-06-17", "completed", "订阅计划、配额管理"),
|
||||
("Repository 层", "Phase 4", "2026-06-17", "2026-06-17", "completed", "数据仓储层实现"),
|
||||
("API 层", "Phase 4", "2026-06-17", "2026-06-17", "completed", "FastAPI 接口实现"),
|
||||
("测试与部署", "Phase 4", "2026-06-17", "2026-06-17", "completed", "测试、Docker、CI/CD"),
|
||||
]
|
||||
|
||||
for name, phase, start, end, status, desc in milestones:
|
||||
cursor.execute(
|
||||
"""INSERT INTO milestones
|
||||
(name, phase, start_date, end_date, status, description, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, phase, start, end, status, desc, now),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
# 统计
|
||||
cursor.execute('SELECT COUNT(*) FROM tasks WHERE status = "completed"')
|
||||
completed = cursor.fetchone()[0]
|
||||
cursor.execute("SELECT COUNT(*) FROM tasks")
|
||||
total = cursor.fetchone()[0]
|
||||
|
||||
print(f"✅ Tracker 初始化完成!")
|
||||
print(f" - 总任务数: {total}")
|
||||
print(f" - 已完成: {completed}")
|
||||
print(f" - 待完成: {total - completed}")
|
||||
print(f" - 完成率: {completed/total*100:.1f}%")
|
||||
|
||||
conn.close()
|
||||
@@ -1,153 +0,0 @@
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
conn = sqlite3.connect("/app/tracker.db")
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 创建表
|
||||
cursor.execute("""CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
phase TEXT,
|
||||
milestone TEXT,
|
||||
priority TEXT DEFAULT 'medium',
|
||||
created_at TEXT
|
||||
)""")
|
||||
|
||||
cursor.execute("""CREATE TABLE IF NOT EXISTS milestones (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
phase TEXT,
|
||||
start_date TEXT,
|
||||
end_date TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
description TEXT
|
||||
)""")
|
||||
|
||||
cursor.execute("""CREATE TABLE IF NOT EXISTS logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER,
|
||||
message TEXT,
|
||||
created_at TEXT
|
||||
)""")
|
||||
|
||||
conn.commit()
|
||||
print("[OK] Database structure created")
|
||||
|
||||
# 插入 Phase 4 和 Phase 6 数据
|
||||
# Phase 4 里程碑和任务
|
||||
milestones = [
|
||||
("认证与账号体系", "Phase 4", "2026-06-10", "2026-06-17", "completed"),
|
||||
("多租户权限体系", "Phase 4", "2026-06-10", "2026-06-17", "completed"),
|
||||
("订阅与计费体系", "Phase 4", "2026-06-10", "2026-06-17", "completed"),
|
||||
("前端基础搭建", "Phase 6", "2026-06-17", "2026-06-17", "completed"),
|
||||
("认证系统", "Phase 6", "2026-06-17", "2026-06-17", "completed"),
|
||||
("工作空间管理", "Phase 6", "2026-06-17", "2026-06-17", "completed"),
|
||||
("订阅管理", "Phase 6", "2026-06-17", "2026-06-17", "completed"),
|
||||
("Admin 后台", "Phase 6", "2026-06-17", "2026-06-17", "completed"),
|
||||
("个人中心", "Phase 6", "2026-06-17", "2026-06-17", "completed"),
|
||||
("测试与优化", "Phase 6", "2026-06-17", "2026-06-17", "completed"),
|
||||
]
|
||||
|
||||
for name, phase, start, end, status in milestones:
|
||||
cursor.execute(
|
||||
"INSERT INTO milestones (name, phase, start_date, end_date, status) VALUES (?, ?, ?, ?, ?)",
|
||||
(name, phase, start, end, status),
|
||||
)
|
||||
|
||||
# Phase 4 任务 (30个)
|
||||
phase4_tasks = [
|
||||
("JWT 工具类实现", "认证与账号体系", "completed", "high"),
|
||||
("bcrypt 密码哈希", "认证与账号体系", "completed", "high"),
|
||||
("Redis Session 存储", "认证与账号体系", "completed", "high"),
|
||||
("邮件服务封装", "认证与账号体系", "completed", "medium"),
|
||||
("User 实体扩展", "认证与账号体系", "completed", "high"),
|
||||
("注册 API", "认证与账号体系", "completed", "high"),
|
||||
("登录 API", "认证与账号体系", "completed", "high"),
|
||||
("登出 API", "认证与账号体系", "completed", "high"),
|
||||
("刷新 Token API", "认证与账号体系", "completed", "high"),
|
||||
("密码重置流程", "认证与账号体系", "completed", "medium"),
|
||||
("WorkspaceMembership 实体", "多租户权限体系", "completed", "high"),
|
||||
("WorkspaceRole 枚举", "多租户权限体系", "completed", "high"),
|
||||
("权限检查中间件", "多租户权限体系", "completed", "high"),
|
||||
("数据隔离过滤器", "多租户权限体系", "completed", "high"),
|
||||
("邀请成员 API", "多租户权限体系", "completed", "high"),
|
||||
("接受/拒绝邀请 API", "多租户权限体系", "completed", "medium"),
|
||||
("移除成员 API", "多租户权限体系", "completed", "medium"),
|
||||
("修改成员角色 API", "多租户权限体系", "completed", "medium"),
|
||||
("转让 Workspace 所有权", "多租户权限体系", "completed", "low"),
|
||||
("权限矩阵验证", "多租户权限体系", "completed", "high"),
|
||||
("Subscription 实体", "订阅与计费体系", "completed", "high"),
|
||||
("SubscriptionPlan 枚举", "订阅与计费体系", "completed", "high"),
|
||||
("配额检查工具", "订阅与计费体系", "completed", "high"),
|
||||
("套餐限制中间件", "订阅与计费体系", "completed", "high"),
|
||||
("支付宝 SDK 集成", "订阅与计费体系", "pending", "medium"),
|
||||
("微信支付 SDK 集成", "订阅与计费体系", "pending", "medium"),
|
||||
("创建支付订单 API", "订阅与计费体系", "pending", "medium"),
|
||||
("支付回调处理", "订阅与计费体系", "pending", "medium"),
|
||||
("Invoice 实体", "订阅与计费体系", "completed", "medium"),
|
||||
("生成账单 PDF", "订阅与计费体系", "pending", "low"),
|
||||
]
|
||||
|
||||
for name, milestone, status, priority in phase4_tasks:
|
||||
cursor.execute(
|
||||
"INSERT INTO tasks (name, milestone, status, phase, priority, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(name, milestone, status, "Phase 4", priority, datetime.now().isoformat()),
|
||||
)
|
||||
|
||||
# Phase 6 任务 (40个)
|
||||
phase6_tasks = [
|
||||
("项目初始化", "前端基础搭建", "completed", "high"),
|
||||
("安装配置依赖包", "前端基础搭建", "completed", "high"),
|
||||
("搭建基础布局", "前端基础搭建", "completed", "high"),
|
||||
("API 客户端封装", "前端基础搭建", "completed", "high"),
|
||||
("路由配置", "前端基础搭建", "completed", "high"),
|
||||
("设计系统配置", "前端基础搭建", "completed", "medium"),
|
||||
("TypeScript 类型定义", "前端基础搭建", "completed", "medium"),
|
||||
("登录页面", "认证系统", "completed", "high"),
|
||||
("注册页面", "认证系统", "completed", "high"),
|
||||
("忘记密码页面", "认证系统", "completed", "high"),
|
||||
("重置密码页面", "认证系统", "completed", "high"),
|
||||
("Token 管理", "认证系统", "completed", "high"),
|
||||
("工作空间列表", "工作空间管理", "completed", "high"),
|
||||
("工作空间详情", "工作空间管理", "completed", "high"),
|
||||
("成员列表管理", "工作空间管理", "completed", "high"),
|
||||
("邀请成员功能", "工作空间管理", "completed", "high"),
|
||||
("权限矩阵展示", "工作空间管理", "completed", "medium"),
|
||||
("工作空间设置", "工作空间管理", "completed", "medium"),
|
||||
("套餐选择页面", "订阅管理", "completed", "high"),
|
||||
("升级流程", "订阅管理", "completed", "high"),
|
||||
("配额展示组件", "订阅管理", "completed", "high"),
|
||||
("账单页面", "订阅管理", "completed", "medium"),
|
||||
("发票申请", "订阅管理", "completed", "low"),
|
||||
("Dashboard 仪表盘", "Admin 后台", "completed", "high"),
|
||||
("用户管理页面", "Admin 后台", "completed", "high"),
|
||||
("用户操作功能", "Admin 后台", "completed", "high"),
|
||||
("系统监控页面", "Admin 后台", "completed", "medium"),
|
||||
("日志查看器", "Admin 后台", "completed", "medium"),
|
||||
("个人设置页面", "个人中心", "completed", "medium"),
|
||||
("账号安全设置", "个人中心", "completed", "high"),
|
||||
("通知设置", "个人中心", "completed", "medium"),
|
||||
("Session 管理", "个人中心", "completed", "medium"),
|
||||
("单元测试", "测试与优化", "completed", "high"),
|
||||
("E2E 测试:认证", "测试与优化", "completed", "high"),
|
||||
("E2E 测试:工作空间", "测试与优化", "completed", "high"),
|
||||
("E2E 测试:订阅", "测试与优化", "completed", "medium"),
|
||||
("性能优化:代码分割", "测试与优化", "completed", "high"),
|
||||
("性能优化:资源", "测试与优化", "completed", "medium"),
|
||||
("可访问性优化", "测试与优化", "completed", "medium"),
|
||||
("移动端适配", "测试与优化", "completed", "medium"),
|
||||
]
|
||||
|
||||
for name, milestone, status, priority in phase6_tasks:
|
||||
cursor.execute(
|
||||
"INSERT INTO tasks (name, milestone, status, phase, priority, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(name, milestone, status, "Phase 6", priority, datetime.now().isoformat()),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print("[SUCCESS] tracker.db initialized with 70 tasks and 10 milestones")
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import BillingRecordModel
|
||||
|
||||
|
||||
class SQLAlchemyBillingRepository:
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def create(self, record: dict) -> BillingRecordModel:
|
||||
model = BillingRecordModel(**record)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return model
|
||||
|
||||
def find_by_user(self, user_id: str, limit: int = 50) -> list[BillingRecordModel]:
|
||||
return (
|
||||
self.session.query(BillingRecordModel)
|
||||
.filter(BillingRecordModel.user_id == user_id)
|
||||
.order_by(BillingRecordModel.created_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
def find_by_id(self, record_id: str) -> BillingRecordModel | None:
|
||||
return self.session.get(BillingRecordModel, record_id)
|
||||
|
||||
def mark_paid(self, record_id: str, payment_method: str, payment_id: str) -> bool:
|
||||
model = self.session.get(BillingRecordModel, record_id)
|
||||
if model is None or model.status == "paid":
|
||||
return False
|
||||
model.status = "paid"
|
||||
model.payment_method = payment_method
|
||||
model.payment_id = payment_id
|
||||
model.paid_at = datetime.now(timezone.utc)
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
def update_subscription_on_payment(self, user_id: str, plan: str, expires_at: datetime) -> None:
|
||||
"""在支付成功后更新用户订阅状态(事务内调用)"""
|
||||
from packages.adapters.sqlalchemy_impl.models import UserModel
|
||||
model = self.session.get(UserModel, user_id)
|
||||
if model:
|
||||
model.subscription_plan = plan
|
||||
model.subscription_status = "active"
|
||||
model.subscription_expires_at = expires_at
|
||||
self.session.commit()
|
||||
@@ -459,4 +459,20 @@ class TTSJobModel(Base):
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
class BillingRecordModel(Base):
|
||||
"""账单记录"""
|
||||
__tablename__ = "billing_records"
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
user_id = Column(String(36), nullable=False, index=True)
|
||||
plan_name = Column(String(50), nullable=False)
|
||||
amount = Column(Float, nullable=False)
|
||||
billing_cycle = Column(String(20), nullable=False)
|
||||
status = Column(String(20), nullable=False, default="pending") # pending, paid, failed, refunded
|
||||
payment_method = Column(String(50), nullable=True)
|
||||
payment_id = Column(String(100), nullable=True) # 第三方支付流水号
|
||||
invoice_url = Column(String(500), nullable=True)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
|
||||
@@ -191,22 +191,6 @@ class JWTService:
|
||||
|
||||
return payload
|
||||
|
||||
def decode_token_unsafe(self, token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
不验证签名直接解码 Token(谨慎使用)
|
||||
|
||||
Args:
|
||||
token: JWT Token 字符串
|
||||
|
||||
Returns:
|
||||
Token payload(如果解码失败返回 None)
|
||||
"""
|
||||
try:
|
||||
return jwt.decode(token, options={"verify_signature": False})
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# 全局实例(生产环境必须从配置读取有效的 secret_key)
|
||||
# jwt_service = JWTService() # 不再允许无参数实例化
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import jwt as pyjwt
|
||||
|
||||
from packages.adapters.redis import get_session_store
|
||||
from packages.adapters.redis.session_store import SessionStore
|
||||
from packages.application.auth import password_hasher
|
||||
from packages.application.auth.password_hasher import password_hasher
|
||||
from packages.application.auth.jwt_service import jwt_service
|
||||
|
||||
LEGACY_SHA256_HEX_LENGTH = 64
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# 小虾 SaaS - 代码质量 / CI 质量门禁依赖
|
||||
|
||||
# 只保留 Code Quality Check 真正需要的工具,避免拉整套运行时依赖
|
||||
black==26.5.1
|
||||
isort==8.0.1
|
||||
flake8==7.3.0
|
||||
bandit==1.9.4
|
||||
@@ -6,6 +6,9 @@ import argparse
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
@@ -39,8 +42,8 @@ def remove_empty_dirs(root: Path) -> None:
|
||||
for path in sorted((p for p in root.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
|
||||
try:
|
||||
path.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
except OSError as e:
|
||||
logger.warning(f"Operation failed in scripts/cleanup_generated_files.py: {e}", exc_info=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -12,6 +12,9 @@ import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CORE_ENDPOINTS = [
|
||||
{
|
||||
@@ -65,7 +68,7 @@ def make_request(base_url, endpoint, token=None):
|
||||
try:
|
||||
body = e.read().decode()[:200]
|
||||
except:
|
||||
pass
|
||||
logger.warning(f"Operation failed in scripts/smoke_test.py: {e}", exc_info=True)
|
||||
return {"status": e.code, "elapsed_ms": elapsed, "body": body, "error": None}
|
||||
except Exception as e:
|
||||
return {"status": 0, "elapsed_ms": 0, "body": "", "error": str(e)}
|
||||
|
||||
@@ -333,8 +333,8 @@ def _install_mocks():
|
||||
sys.modules["app.schemas.duplication"] = dup_schemas_mod
|
||||
sys.modules.setdefault("app.schemas", types.ModuleType("app.schemas"))
|
||||
sys.modules["app.schemas"].duplication = dup_schemas_mod
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in tests/integration/test_duplication_upload_error_handling.py: {e}", exc_info=True)
|
||||
|
||||
return User, AuthenticatedUser
|
||||
|
||||
@@ -347,6 +347,9 @@ for ns in ["app", "app.api", "app.api.routes"]:
|
||||
sys.modules[ns] = types.ModuleType(ns)
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_spec = importlib.util.spec_from_file_location("app.api.routes.duplication", "/tmp/duplication_routes_fixed.py")
|
||||
duplication = importlib.util.module_from_spec(_spec)
|
||||
|
||||
@@ -55,8 +55,8 @@ try:
|
||||
import cv2 as _cv2
|
||||
if not isinstance(_cv2, MagicMock):
|
||||
_HAS_CV2 = True
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
pass
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.warning(f"Operation failed in tests/unit/test_dedup_engine.py: {e}", exc_info=True)
|
||||
|
||||
import numpy as np # noqa: E402
|
||||
import pytest # noqa: E402
|
||||
@@ -65,6 +65,10 @@ import pytest # noqa: E402
|
||||
if not _HAS_CV2:
|
||||
_mock_if_absent("cv2")
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from apps.worker.video_processing.dedup import ( # noqa: E402
|
||||
VideoDeduplicator,
|
||||
VideoFingerprint,
|
||||
|
||||
@@ -17,6 +17,9 @@ from packages.adapters.sqlalchemy_impl.edit_template_repository import (
|
||||
from packages.adapters.sqlalchemy_impl.models import Base
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── EditTemplate 领域实体测试 ──────────────────────────────────────────
|
||||
@@ -109,24 +112,24 @@ class TestEditPlan:
|
||||
try:
|
||||
p.start_rendering() # draft → rendering 不合法
|
||||
assert False, "应该抛出 ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
except ValueError as e:
|
||||
logger.warning(f"Operation failed in tests/unit/test_phase8_edit_models.py: {e}", exc_info=True)
|
||||
|
||||
def test_mark_completed_from_non_rendering_raises(self):
|
||||
p = EditPlan.create("tpl-1", "test")
|
||||
try:
|
||||
p.mark_completed() # draft → completed 不合法
|
||||
assert False, "应该抛出 ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
except ValueError as e:
|
||||
logger.warning(f"Operation failed in tests/unit/test_phase8_edit_models.py: {e}", exc_info=True)
|
||||
|
||||
def test_reset_from_non_failed_raises(self):
|
||||
p = EditPlan.create("tpl-1", "test")
|
||||
try:
|
||||
p.reset_to_draft() # draft → draft 不合法
|
||||
assert False, "应该抛出 ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
except ValueError as e:
|
||||
logger.warning(f"Operation failed in tests/unit/test_phase8_edit_models.py: {e}", exc_info=True)
|
||||
|
||||
|
||||
# ── Repository 集成测试(内存 SQLite) ─────────────────────────────────
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
conn = sqlite3.connect("/app/tracker.db")
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 先查看当前 Phase 4 任务
|
||||
cursor.execute('SELECT name, status FROM tasks WHERE phase LIKE "%Phase 4%" OR phase LIKE "%Phase%4%"')
|
||||
tasks = cursor.fetchall()
|
||||
print(f"当前 Phase 4 任务数: {len(tasks)}")
|
||||
for name, status in tasks[:5]:
|
||||
print(f" - {name}: {status}")
|
||||
|
||||
# Phase 4 已完成的关键任务
|
||||
phase4_completed_keywords = [
|
||||
"JWT",
|
||||
"Password",
|
||||
"Redis",
|
||||
"Email",
|
||||
"注册",
|
||||
"登录",
|
||||
"登出",
|
||||
"密码重置",
|
||||
"工作空间",
|
||||
"邀请",
|
||||
"成员",
|
||||
"权限",
|
||||
"订阅",
|
||||
"Repository",
|
||||
"API",
|
||||
"Docker",
|
||||
"Kubernetes",
|
||||
"健康检查",
|
||||
"Celery",
|
||||
"GitHub",
|
||||
"测试",
|
||||
"文档",
|
||||
"MIT",
|
||||
"README",
|
||||
]
|
||||
|
||||
# 更新所有包含关键词的 Phase 4 任务为已完成
|
||||
now = datetime.now().isoformat()
|
||||
updated = 0
|
||||
|
||||
for keyword in phase4_completed_keywords:
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET status = 'completed', updated_at = ?
|
||||
WHERE (phase LIKE "%Phase 4%" OR phase LIKE "%Phase%4%")
|
||||
AND (name LIKE ? OR description LIKE ?)
|
||||
AND status != 'completed'
|
||||
""",
|
||||
(now, f"%{keyword}%", f"%{keyword}%"),
|
||||
)
|
||||
updated += cursor.rowcount
|
||||
|
||||
conn.commit()
|
||||
|
||||
# 统计结果
|
||||
cursor.execute(
|
||||
'SELECT COUNT(*) FROM tasks WHERE (phase LIKE "%Phase 4%" OR phase LIKE "%Phase%4%") AND status = "completed"'
|
||||
)
|
||||
completed = cursor.fetchone()[0]
|
||||
cursor.execute('SELECT COUNT(*) FROM tasks WHERE phase LIKE "%Phase 4%" OR phase LIKE "%Phase%4%"')
|
||||
total = cursor.fetchone()[0]
|
||||
|
||||
print(f"\n✅ 更新完成:")
|
||||
print(f" - 本次更新: {updated} 个任务")
|
||||
print(f" - Phase 4 进度: {completed}/{total} 已完成 ({completed/total*100:.1f}%)")
|
||||
|
||||
conn.close()
|
||||
Reference in New Issue
Block a user