Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6897a4be96 | |||
| 6d2d5abff2 | |||
| e3f5ab5611 | |||
| d061bccdd2 | |||
| a3967c6829 | |||
| 97a81a39f3 | |||
| eab471703e | |||
| 32a53ab9e6 | |||
| e50ba67c11 | |||
| e8f9e2dabe | |||
| 3f773795ed | |||
| 29ed5df884 | |||
| 188e535af8 | |||
| 6227aa610c | |||
| d90bc6bbfc | |||
| 3ebcc7e066 | |||
| 19d5dcbc5a | |||
| 1a57878f76 | |||
| 923c6bad1c |
@@ -5,7 +5,6 @@ APP_ENV=production
|
||||
ENVIRONMENT=production
|
||||
DEBUG=false
|
||||
USE_IN_MEMORY_DB=false
|
||||
LOG_LEVEL=WARNING
|
||||
|
||||
# ==================== 数据库(必须修改)====================
|
||||
DATABASE_URL=postgresql://prod_user:CHANGE_THIS_PASSWORD@db-prod:5432/xiaoxia_prod
|
||||
|
||||
+14
-10
@@ -19,6 +19,10 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Code Quality And Tests
|
||||
@@ -88,9 +92,9 @@ jobs:
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python3 -m pip install -q -r requirements-base.txt
|
||||
python3 -m pip install -q -r requirements.txt
|
||||
python3 -m pip install -q -r requirements-dev.txt
|
||||
python3 -m pip install --break-system-packages -q -r requirements-base.txt
|
||||
python3 -m pip install --break-system-packages -q -r requirements.txt
|
||||
python3 -m pip install --break-system-packages -q -r requirements-dev.txt
|
||||
python3 -m black --version
|
||||
python3 -m isort --version-number
|
||||
python3 -m flake8 --version
|
||||
@@ -217,9 +221,9 @@ jobs:
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python3 -m pip install -q -r requirements-base.txt
|
||||
python3 -m pip install -q -r requirements.txt
|
||||
python3 -m pip install -q -r requirements-dev.txt
|
||||
python3 -m pip install --break-system-packages -q -r requirements-base.txt
|
||||
python3 -m pip install --break-system-packages -q -r requirements.txt
|
||||
python3 -m pip install --break-system-packages -q -r requirements-dev.txt
|
||||
pytest --version
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
@@ -316,9 +320,9 @@ jobs:
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python3 -m pip install -q -r requirements-base.txt
|
||||
python3 -m pip install -q -r requirements.txt
|
||||
python3 -m pip install -q -r requirements-dev.txt
|
||||
python3 -m pip install --break-system-packages -q -r requirements-base.txt
|
||||
python3 -m pip install --break-system-packages -q -r requirements.txt
|
||||
python3 -m pip install --break-system-packages -q -r requirements-dev.txt
|
||||
pytest --version
|
||||
|
||||
- name: Start Redis
|
||||
@@ -390,7 +394,7 @@ jobs:
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
pip install -q pytest-rerunfailures
|
||||
pip install --break-system-packages -q pytest-rerunfailures
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run --append \
|
||||
--source=apps/api/app,packages \
|
||||
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
|
||||
|
||||
@@ -23,7 +23,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from packages.adapters.redis.feature_flag_store import (
|
||||
FEATURE_FLAG_REDIS_PREFIX,
|
||||
FeatureFlagConfig,
|
||||
RedisFeatureFlagStore,
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@ from app.core.task_enqueue import (
|
||||
USER_PENDING_LIMIT,
|
||||
GlobalQueueFull,
|
||||
UserPendingLimitExceeded,
|
||||
check_queue_limits,
|
||||
safe_enqueue_generation_task,
|
||||
)
|
||||
from app.dependencies import (
|
||||
|
||||
@@ -19,7 +19,6 @@ class Settings(BaseSettings):
|
||||
# Container bind address; external expose is controlled by Docker/Nginx.
|
||||
API_HOST: str = "0.0.0.0" # nosec: B104
|
||||
API_PORT: int = 8000
|
||||
API_PREFIX: str = "/api/v1"
|
||||
|
||||
DATABASE_URL: str = "postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas"
|
||||
DATABASE_POOL_SIZE: int = 20
|
||||
@@ -30,16 +29,10 @@ class Settings(BaseSettings):
|
||||
AUTO_CREATE_SCHEMA: bool = False
|
||||
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
REDIS_MAX_CONNECTION: int = 50
|
||||
ENABLE_REDIS_SESSIONS: bool = False
|
||||
|
||||
# JWT secret key - MUST be set via environment variable, no default allowed
|
||||
JWT_SECRET_KEY: Optional[str] = None
|
||||
# 旧的 JWT secret key(用于密钥轮换期间验证旧 token)
|
||||
# 在密钥轮换时,先设置新密钥,旧密钥保留在此处直到所有旧 token 过期
|
||||
JWT_SECRET_KEY_OLD: Optional[str] = None
|
||||
# 密钥轮换天数(到达此天数后建议更换密钥)
|
||||
SECRET_ROTATION_DAYS: int = 90
|
||||
|
||||
# JWT 算法与过期时间(与 .env.example 对齐)
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
@@ -111,7 +104,6 @@ class Settings(BaseSettings):
|
||||
)
|
||||
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS: int = 900
|
||||
|
||||
LOG_LEVEL: str = "INFO"
|
||||
CORS_ORIGINS_RAW: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000"
|
||||
|
||||
# 渲染引擎选择:legacy=旧VideoComposeService,unified=新UnifiedRenderService
|
||||
|
||||
+11
-280
@@ -1,283 +1,14 @@
|
||||
"""阿里云 OSS 存储服务"""
|
||||
"""Backward-compatible re-export from shared storage.
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
All storage logic now lives in ``packages.shared.storage``.
|
||||
This module keeps old import paths working so existing code
|
||||
does not need to change.
|
||||
"""
|
||||
|
||||
try:
|
||||
import oss2
|
||||
except ImportError: # pragma: no cover - exercised in minimal local/test environments
|
||||
oss2 = None
|
||||
from app.config import get_settings
|
||||
from packages.shared.storage import (
|
||||
SharedStorageService as OSSStorageService,
|
||||
get_shared_storage_service,
|
||||
get_storage_service,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OSSStorageService:
|
||||
"""阿里云 OSS 存储服务"""
|
||||
|
||||
def __init__(self):
|
||||
settings = get_settings()
|
||||
self.bucket_name = settings.OSS_BUCKET_NAME
|
||||
self.public_url = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
|
||||
self.local_url_prefix = os.getenv("GENERATED_FILES_URL_PREFIX", "/generated-files")
|
||||
self.bucket = None
|
||||
|
||||
has_key_id = bool(settings.OSS_ACCESS_KEY_ID)
|
||||
has_key_secret = bool(settings.OSS_ACCESS_KEY_SECRET)
|
||||
|
||||
if has_key_id and has_key_secret:
|
||||
if oss2 is not None:
|
||||
try:
|
||||
# P0-2 修复:oss2.Bucket 的 endpoint 必须带 https:// 前缀,
|
||||
# 否则 sign_url 默认生成 HTTP URL。
|
||||
bucket_endpoint = settings.OSS_ENDPOINT
|
||||
if not bucket_endpoint.startswith(("http://", "https://")):
|
||||
bucket_endpoint = f"https://{bucket_endpoint}"
|
||||
auth = oss2.Auth(
|
||||
settings.OSS_ACCESS_KEY_ID,
|
||||
settings.OSS_ACCESS_KEY_SECRET,
|
||||
)
|
||||
self.bucket = oss2.Bucket(
|
||||
auth,
|
||||
bucket_endpoint,
|
||||
settings.OSS_BUCKET_NAME,
|
||||
)
|
||||
logger.info(
|
||||
"OSS initialized: endpoint=%s bucket=%s",
|
||||
settings.OSS_ENDPOINT,
|
||||
settings.OSS_BUCKET_NAME,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Failed to initialize OSS bucket client: %s", error)
|
||||
else:
|
||||
logger.error("oss2 SDK is not installed — OSS operations will fail")
|
||||
else:
|
||||
missing = []
|
||||
if not has_key_id:
|
||||
missing.append("OSS_ACCESS_KEY_ID")
|
||||
if not has_key_secret:
|
||||
missing.append("OSS_ACCESS_KEY_SECRET")
|
||||
logger.error("OSS credentials not configured — missing: %s", ", ".join(missing))
|
||||
|
||||
self.access_key_id = settings.OSS_ACCESS_KEY_ID
|
||||
self.access_key_secret = settings.OSS_ACCESS_KEY_SECRET
|
||||
self.endpoint = settings.OSS_ENDPOINT
|
||||
|
||||
def diagnose(self) -> None:
|
||||
"""启动诊断:输出 OSS 配置状态,帮助排查预签名 URL 问题。"""
|
||||
key_id_display = (
|
||||
f"{self.access_key_id[:4]}...{self.access_key_id[-4:]}" if len(self.access_key_id) > 8 else "(empty)"
|
||||
)
|
||||
logger.info(
|
||||
"[OSS诊断] endpoint=%s bucket_name=%s access_key_id=%s",
|
||||
self.endpoint,
|
||||
self.bucket_name,
|
||||
key_id_display,
|
||||
)
|
||||
if self.bucket is None:
|
||||
logger.error(
|
||||
"[OSS诊断] ❌ bucket=None — 预签名URL不可用!"
|
||||
"原因: OSS_ACCESS_KEY_ID/OSS_ACCESS_KEY_SECRET 未配置或 oss2 未安装。"
|
||||
"请检查服务器 .env 文件(如 /var/lib/xiaoxia-saas-staging/.env)"
|
||||
)
|
||||
else:
|
||||
logger.info("[OSS诊断] ✅ bucket 已配置,预签名URL可用")
|
||||
|
||||
def _is_local_generated_url(self, storage_key_or_url: str) -> bool:
|
||||
parsed = urlparse(storage_key_or_url)
|
||||
path = parsed.path if parsed.scheme else storage_key_or_url
|
||||
return path.startswith(f"{self.local_url_prefix}/")
|
||||
|
||||
def create_direct_upload_post(
|
||||
self,
|
||||
storage_key: str,
|
||||
content_type: str,
|
||||
max_size_bytes: int,
|
||||
expires_seconds: int,
|
||||
) -> dict[str, object]:
|
||||
"""创建浏览器直传 OSS 的 PostObject 表单。"""
|
||||
if not self.access_key_id or not self.access_key_secret:
|
||||
raise RuntimeError("OSS storage is not configured")
|
||||
normalized_key = self._normalize_storage_key(storage_key)
|
||||
if not normalized_key.startswith("uploads/"):
|
||||
raise ValueError("direct upload key must be under uploads/")
|
||||
|
||||
expiration = (dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=expires_seconds)).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S.000Z"
|
||||
)
|
||||
policy = {
|
||||
"expiration": expiration,
|
||||
"conditions": [
|
||||
{"bucket": self.bucket_name},
|
||||
{"key": normalized_key},
|
||||
["content-length-range", 1, max_size_bytes],
|
||||
["starts-with", "$Content-Type", content_type.split("/", 1)[0] + "/" if "/" in content_type else ""],
|
||||
],
|
||||
}
|
||||
encoded_policy = base64.b64encode(json.dumps(policy, separators=(",", ":")).encode("utf-8")).decode("ascii")
|
||||
signature = base64.b64encode(
|
||||
hmac.new(self.access_key_secret.encode("utf-8"), encoded_policy.encode("utf-8"), hashlib.sha1).digest()
|
||||
).decode("ascii")
|
||||
|
||||
return {
|
||||
"url": self.public_url,
|
||||
"method": "POST",
|
||||
"storage_key": normalized_key,
|
||||
"expires_at": expiration,
|
||||
"fields": {
|
||||
"key": normalized_key,
|
||||
"OSSAccessKeyId": self.access_key_id,
|
||||
"policy": encoded_policy,
|
||||
"Signature": signature,
|
||||
"success_action_status": "201",
|
||||
"Content-Type": content_type,
|
||||
},
|
||||
}
|
||||
|
||||
def upload_file(
|
||||
self,
|
||||
file_or_path,
|
||||
storage_key: str,
|
||||
content_type: str = "application/octet-stream",
|
||||
) -> str:
|
||||
"""
|
||||
上传文件到 OSS
|
||||
|
||||
Args:
|
||||
file_or_path: 文件对象或本地文件路径
|
||||
storage_key: 存储键(文件路径)
|
||||
content_type: 内容类型
|
||||
|
||||
Returns:
|
||||
文件公网 URL
|
||||
"""
|
||||
if self.bucket is None:
|
||||
raise RuntimeError("OSS storage is not configured")
|
||||
|
||||
try:
|
||||
# 如果是字符串路径,从本地文件上传
|
||||
if isinstance(file_or_path, str):
|
||||
self.bucket.put_object_from_file(storage_key, file_or_path, headers={"Content-Type": content_type})
|
||||
else:
|
||||
# 文件对象
|
||||
file_or_path.seek(0)
|
||||
self.bucket.put_object(storage_key, file_or_path, headers={"Content-Type": content_type})
|
||||
|
||||
return f"{self.public_url}/{storage_key}"
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to upload file to OSS: {e}")
|
||||
|
||||
def get_url(self, storage_key: str) -> str:
|
||||
"""获取文件公网 URL"""
|
||||
return f"{self.public_url}/{storage_key}"
|
||||
|
||||
def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str:
|
||||
"""
|
||||
获取文件下载签名 URL(用于私有文件)
|
||||
|
||||
Args:
|
||||
storage_key_or_url: 存储键或完整 URL
|
||||
expires_seconds: 过期时间(秒)
|
||||
|
||||
Returns:
|
||||
签名 URL
|
||||
"""
|
||||
if self.bucket is None:
|
||||
if self._is_local_generated_url(storage_key_or_url):
|
||||
return storage_key_or_url
|
||||
logger.warning(
|
||||
"get_download_url: OSS bucket not configured, returning raw URL. " "storage_key_or_url=%s",
|
||||
storage_key_or_url[:200],
|
||||
)
|
||||
return self.get_url(self._normalize_storage_key(storage_key_or_url))
|
||||
|
||||
storage_key = self._normalize_storage_key(storage_key_or_url)
|
||||
try:
|
||||
signed = self.bucket.sign_url("GET", storage_key, expires_seconds)
|
||||
logger.info(
|
||||
"get_download_url: signed URL generated. storage_key=%s url_prefix=%s",
|
||||
storage_key[:80],
|
||||
signed[:60],
|
||||
)
|
||||
return signed
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"get_download_url: sign_url failed, falling back to raw URL. " "storage_key=%s",
|
||||
storage_key[:200],
|
||||
)
|
||||
return self.get_url(storage_key)
|
||||
|
||||
def _normalize_storage_key(self, storage_key_or_url: str) -> str:
|
||||
"""从 URL 中提取存储键"""
|
||||
if storage_key_or_url.startswith("http://") or storage_key_or_url.startswith("https://"):
|
||||
parsed = urlparse(storage_key_or_url)
|
||||
# 移除开头的 /
|
||||
return parsed.path.lstrip("/")
|
||||
return storage_key_or_url.lstrip("/")
|
||||
|
||||
def download_file(self, storage_key: str, local_path: str):
|
||||
"""
|
||||
从 OSS 下载文件到本地
|
||||
|
||||
Args:
|
||||
storage_key: 存储键
|
||||
local_path: 本地文件路径
|
||||
"""
|
||||
if self.bucket is None:
|
||||
raise RuntimeError("OSS storage is not configured")
|
||||
|
||||
try:
|
||||
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
||||
self.bucket.get_object_to_file(storage_key, local_path)
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to download file from OSS: {e}")
|
||||
|
||||
def delete_file(self, storage_key: str):
|
||||
"""
|
||||
删除 OSS 文件
|
||||
|
||||
Args:
|
||||
storage_key: 存储键
|
||||
"""
|
||||
if self.bucket is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.bucket.delete_object(storage_key)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"Failed to delete file from OSS",
|
||||
extra={"storage_key": storage_key, "error": str(error)},
|
||||
)
|
||||
|
||||
def file_exists(self, storage_key: str) -> bool:
|
||||
"""
|
||||
检查文件是否存在
|
||||
|
||||
Args:
|
||||
storage_key: 存储键
|
||||
|
||||
Returns:
|
||||
是否存在
|
||||
"""
|
||||
if self.bucket is None:
|
||||
return False
|
||||
return self.bucket.object_exists(storage_key)
|
||||
|
||||
|
||||
_storage_service = None
|
||||
|
||||
|
||||
def get_storage_service() -> OSSStorageService:
|
||||
"""获取存储服务实例(全局单例)"""
|
||||
global _storage_service
|
||||
if _storage_service is None:
|
||||
_storage_service = OSSStorageService()
|
||||
_storage_service.diagnose()
|
||||
return _storage_service
|
||||
__all__ = ["OSSStorageService", "get_storage_service", "get_shared_storage_service"]
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* 账号管理 Mock API
|
||||
*
|
||||
* 模拟多平台账号绑定/解绑操作
|
||||
* 支持平台:抖音、快手、小红书、微信视频号
|
||||
*/
|
||||
|
||||
/* ── 类型定义 ───────────────────────────────────────────── */
|
||||
|
||||
/** 平台 ID */
|
||||
export type PlatformId = "douyin" | "kuaishou" | "xiaohongshu" | "wechat";
|
||||
|
||||
/** 账号状态 */
|
||||
export type AccountStatus = "active" | "expired" | "limited";
|
||||
|
||||
/** 已绑定的账号 */
|
||||
export interface Account {
|
||||
id: string;
|
||||
platform_id: PlatformId;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
status: AccountStatus;
|
||||
bound_at: string;
|
||||
}
|
||||
|
||||
/** 平台信息 */
|
||||
export interface Platform {
|
||||
id: PlatformId;
|
||||
name: string;
|
||||
subName: string;
|
||||
icon: string;
|
||||
gradient: string;
|
||||
}
|
||||
|
||||
/** 绑定账号请求 */
|
||||
export interface BindAccountRequest {
|
||||
platform_id: PlatformId;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/* ── 平台配置 ───────────────────────────────────────────── */
|
||||
|
||||
export const PLATFORMS: Platform[] = [
|
||||
{
|
||||
id: "douyin",
|
||||
name: "抖音",
|
||||
subName: "短视频发布平台",
|
||||
icon: "📱",
|
||||
gradient: "linear-gradient(135deg, #fe2c55, #25f4ee)",
|
||||
},
|
||||
{
|
||||
id: "kuaishou",
|
||||
name: "快手",
|
||||
subName: "短视频发布平台",
|
||||
icon: "🎬",
|
||||
gradient: "linear-gradient(135deg, #ff4906, #ffba00)",
|
||||
},
|
||||
{
|
||||
id: "xiaohongshu",
|
||||
name: "小红书",
|
||||
subName: "种草笔记发布平台",
|
||||
icon: "📕",
|
||||
gradient: "linear-gradient(135deg, #ff2442, #ff6b6b)",
|
||||
},
|
||||
{
|
||||
id: "wechat",
|
||||
name: "微信视频号",
|
||||
subName: "视频号发布平台",
|
||||
icon: "💬",
|
||||
gradient: "linear-gradient(135deg, #07c160, #4cd964)",
|
||||
},
|
||||
];
|
||||
|
||||
/* ── Mock 数据 ───────────────────────────────────────────── */
|
||||
|
||||
let MOCK_ACCOUNTS: Account[] = [
|
||||
{
|
||||
id: "acc-001",
|
||||
platform_id: "douyin",
|
||||
name: "小虾官方号",
|
||||
avatar: "🦐",
|
||||
status: "active",
|
||||
bound_at: "2025-12-01T10:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "acc-002",
|
||||
platform_id: "douyin",
|
||||
name: "小虾日常",
|
||||
avatar: "🐟",
|
||||
status: "active",
|
||||
bound_at: "2025-12-15T14:30:00Z",
|
||||
},
|
||||
{
|
||||
id: "acc-003",
|
||||
platform_id: "kuaishou",
|
||||
name: "小虾剪辑",
|
||||
avatar: "🎬",
|
||||
status: "active",
|
||||
bound_at: "2026-01-05T09:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "acc-004",
|
||||
platform_id: "xiaohongshu",
|
||||
name: "小虾种草",
|
||||
avatar: "📕",
|
||||
status: "limited",
|
||||
bound_at: "2026-02-20T16:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
/* ── 模拟延迟 ───────────────────────────────────────────── */
|
||||
|
||||
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/* ── API 函数 ───────────────────────────────────────────── */
|
||||
|
||||
/** 获取指定平台的账号列表 */
|
||||
export async function getAccountsByPlatform(
|
||||
platformId: PlatformId,
|
||||
): Promise<Account[]> {
|
||||
await delay(300);
|
||||
return MOCK_ACCOUNTS.filter((a) => a.platform_id === platformId);
|
||||
}
|
||||
|
||||
/** 获取所有平台的账号总数 */
|
||||
export async function getAllAccounts(): Promise<Account[]> {
|
||||
await delay(200);
|
||||
return [...MOCK_ACCOUNTS];
|
||||
}
|
||||
|
||||
/** 绑定新账号 */
|
||||
export async function bindAccount(data: BindAccountRequest): Promise<Account> {
|
||||
await delay(500);
|
||||
const newAccount: Account = {
|
||||
id: `acc-${Date.now()}`,
|
||||
platform_id: data.platform_id,
|
||||
name: data.name,
|
||||
avatar: undefined,
|
||||
status: "active",
|
||||
bound_at: new Date().toISOString(),
|
||||
};
|
||||
MOCK_ACCOUNTS = [...MOCK_ACCOUNTS, newAccount];
|
||||
return newAccount;
|
||||
}
|
||||
|
||||
/** 解绑账号 */
|
||||
export async function unbindAccount(accountId: string): Promise<void> {
|
||||
await delay(400);
|
||||
MOCK_ACCOUNTS = MOCK_ACCOUNTS.filter((a) => a.id !== accountId);
|
||||
}
|
||||
|
||||
/* ── 状态配置 ───────────────────────────────────────────── */
|
||||
|
||||
export const ACCOUNT_STATUS_CONFIG: Record<
|
||||
AccountStatus,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
active: { label: "正常", className: "acc-status--active" },
|
||||
expired: { label: "已过期", className: "acc-status--expired" },
|
||||
limited: { label: "受限", className: "acc-status--limited" },
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* V21 Form 表单
|
||||
* 封装 Ant Design Form,应用 V21 设计系统样式
|
||||
*/
|
||||
import React from "react";
|
||||
import { Form as AntForm } from "antd";
|
||||
import type { FormProps as AntFormProps } from "antd";
|
||||
import classNames from "classnames";
|
||||
import "./ui.css";
|
||||
|
||||
export interface FormProps extends AntFormProps {
|
||||
/** 紧凑模式(减小表单项间距) */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const Form = ({ className, compact, children, ...rest }: FormProps) => {
|
||||
const v21Class = classNames(
|
||||
"xx-form",
|
||||
compact && "xx-form-compact",
|
||||
className,
|
||||
);
|
||||
return (
|
||||
<AntForm
|
||||
className={v21Class}
|
||||
{...(rest as Omit<FormProps, "className" | "compact" | "children">)}
|
||||
>
|
||||
{children as React.ReactNode}
|
||||
</AntForm>
|
||||
);
|
||||
};
|
||||
|
||||
/** 导出 Form 的子组件(保持 antd API 一致) */
|
||||
export const FormItem = AntForm.Item;
|
||||
export const FormList = AntForm.List;
|
||||
export const FormProvider = AntForm.Provider;
|
||||
|
||||
export default Form;
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* V21 Pagination 分页
|
||||
* 封装 Ant Design Pagination,应用 V21 设计系统样式
|
||||
*/
|
||||
import React from "react";
|
||||
import { Pagination as AntPagination } from "antd";
|
||||
import type { PaginationProps as AntPaginationProps } from "antd";
|
||||
import classNames from "classnames";
|
||||
import "./ui.css";
|
||||
|
||||
export interface PaginationProps extends AntPaginationProps {
|
||||
/** 使用 V21 样式 */
|
||||
v21?: boolean;
|
||||
}
|
||||
|
||||
const Pagination: React.FC<PaginationProps> = ({
|
||||
className,
|
||||
v21 = true,
|
||||
...rest
|
||||
}) => {
|
||||
const v21Class = classNames(v21 && "xx-pagination", className);
|
||||
return <AntPagination className={v21Class} {...rest} />;
|
||||
};
|
||||
|
||||
export default Pagination;
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* V21 Table 表格
|
||||
* 封装 Ant Design Table,应用 V21 设计系统样式
|
||||
*/
|
||||
import React from "react";
|
||||
import { Table as AntTable } from "antd";
|
||||
import type { TableProps as AntTableProps } from "antd";
|
||||
import classNames from "classnames";
|
||||
import "./ui.css";
|
||||
|
||||
export interface TableProps<
|
||||
RecordType = unknown,
|
||||
> extends AntTableProps<RecordType> {
|
||||
/** 使用 V21 样式 */
|
||||
v21?: boolean;
|
||||
}
|
||||
|
||||
function Table<RecordType extends object = Record<string, unknown>>({
|
||||
className,
|
||||
v21 = true,
|
||||
...rest
|
||||
}: TableProps<RecordType>) {
|
||||
const v21Class = classNames(v21 && "xx-table", className);
|
||||
return <AntTable<RecordType> className={v21Class} {...rest} />;
|
||||
}
|
||||
|
||||
export default Table as <RecordType extends object = Record<string, unknown>>(
|
||||
props: TableProps<RecordType> & React.RefAttributes<HTMLDivElement>,
|
||||
) => React.ReactElement;
|
||||
@@ -15,9 +15,6 @@ export type { SelectProps } from "./Select";
|
||||
export { default as Modal } from "./Modal";
|
||||
export type { ModalProps } from "./Modal";
|
||||
|
||||
export { default as Table } from "./Table";
|
||||
export type { TableProps } from "./Table";
|
||||
|
||||
export { default as Card } from "./Card";
|
||||
export type { CardProps } from "./Card";
|
||||
|
||||
@@ -26,9 +23,3 @@ export type { TagProps, TagVariant } from "./Tag";
|
||||
|
||||
export { Tooltip, Popover } from "./Tooltip";
|
||||
export type { TooltipProps, PopoverProps } from "./Tooltip";
|
||||
|
||||
export { default as Form, FormItem, FormList, FormProvider } from "./Form";
|
||||
export type { FormProps } from "./Form";
|
||||
|
||||
export { default as Pagination } from "./Pagination";
|
||||
export type { PaginationProps } from "./Pagination";
|
||||
|
||||
@@ -2,194 +2,66 @@
|
||||
* 账号管理页面 — V21 Design System
|
||||
*
|
||||
* 展示多平台账号绑定状态(抖音/快手/小红书/微信视频号)
|
||||
* 支持绑定/解绑操作
|
||||
* 后端账号管理 API 尚未就绪,当前展示占位状态
|
||||
*
|
||||
* 零 antd 直接导入,全部使用 CSS 变量
|
||||
*/
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { useQueries, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui";
|
||||
import PageHead from "@/components/layout/PageHead";
|
||||
import {
|
||||
PLATFORMS,
|
||||
getAccountsByPlatform,
|
||||
unbindAccount,
|
||||
bindAccount,
|
||||
ACCOUNT_STATUS_CONFIG,
|
||||
type Platform,
|
||||
type Account,
|
||||
type PlatformId,
|
||||
} from "@/api/accounts";
|
||||
import "./accounts.css";
|
||||
|
||||
/* ── Toast 系统 ─────────────────────────────────────────── */
|
||||
/* ── 类型定义 ───────────────────────────────────────────── */
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
type: "success" | "error";
|
||||
export type PlatformId =
|
||||
| "douyin"
|
||||
| "kuaishou"
|
||||
| "xiaohongshu"
|
||||
| "wechat";
|
||||
|
||||
export interface Platform {
|
||||
id: PlatformId;
|
||||
name: string;
|
||||
subName: string;
|
||||
icon: string;
|
||||
gradient: string;
|
||||
}
|
||||
|
||||
let toastIdCounter = 0;
|
||||
|
||||
/* ── 平台卡片组件 ───────────────────────────────────────── */
|
||||
|
||||
interface PlatformCardProps {
|
||||
platform: Platform;
|
||||
accounts: Account[];
|
||||
isLoading: boolean;
|
||||
onBind: (platformId: PlatformId) => void;
|
||||
onUnbind: (accountId: string, accountName: string) => void;
|
||||
}
|
||||
|
||||
const PlatformCard: React.FC<PlatformCardProps> = ({
|
||||
platform,
|
||||
accounts,
|
||||
isLoading,
|
||||
onBind,
|
||||
onUnbind,
|
||||
}) => {
|
||||
return (
|
||||
<div className="acc-card">
|
||||
{/* 平台头部 */}
|
||||
<div className="acc-card-header">
|
||||
<div
|
||||
className="acc-card-icon"
|
||||
style={{ background: platform.gradient }}
|
||||
>
|
||||
{platform.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="acc-card-title">{platform.name}</h3>
|
||||
<p className="acc-card-subtitle">{platform.subName}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 账号列表 */}
|
||||
<div className="acc-account-list">
|
||||
{isLoading ? (
|
||||
<div className="acc-empty">
|
||||
<p className="acc-empty-text">加载中…</p>
|
||||
</div>
|
||||
) : accounts.length > 0 ? (
|
||||
accounts.map((account) => {
|
||||
const statusCfg = ACCOUNT_STATUS_CONFIG[account.status];
|
||||
return (
|
||||
<div key={account.id} className="acc-account-row">
|
||||
<div
|
||||
className="acc-account-avatar"
|
||||
style={{ background: platform.gradient }}
|
||||
>
|
||||
{account.avatar || platform.icon}
|
||||
</div>
|
||||
<div className="acc-account-info">
|
||||
<div className="acc-account-name">{account.name}</div>
|
||||
<span className={`acc-status-pill ${statusCfg.className}`}>
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => onUnbind(account.id, account.name)}
|
||||
>
|
||||
解绑
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="acc-empty">
|
||||
<div className="acc-empty-icon">🔓</div>
|
||||
<p className="acc-empty-text">暂未绑定账号</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 绑定按钮 */}
|
||||
<Button buttonType="ghost" onClick={() => onBind(platform.id)}>
|
||||
+ 绑定新账号
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
/** 支持的平台列表 */
|
||||
const PLATFORMS: Platform[] = [
|
||||
{
|
||||
id: "douyin",
|
||||
name: "抖音",
|
||||
subName: "短视频发布",
|
||||
icon: "🎵",
|
||||
gradient: "linear-gradient(135deg, #000 0%, #333 100%)",
|
||||
},
|
||||
{
|
||||
id: "kuaishou",
|
||||
name: "快手",
|
||||
subName: "短视频发布",
|
||||
icon: "📹",
|
||||
gradient: "linear-gradient(135deg, #ff6600 0%, #ff9933 100%)",
|
||||
},
|
||||
{
|
||||
id: "xiaohongshu",
|
||||
name: "小红书",
|
||||
subName: "种草笔记 + 视频",
|
||||
icon: "📕",
|
||||
gradient: "linear-gradient(135deg, #fe2c55 0%, #ff6680 100%)",
|
||||
},
|
||||
{
|
||||
id: "wechat",
|
||||
name: "微信视频号",
|
||||
subName: "视频号发布",
|
||||
icon: "💬",
|
||||
gradient: "linear-gradient(135deg, #07c160 0%, #38d97a 100%)",
|
||||
},
|
||||
];
|
||||
|
||||
/* ── 主页面 ─────────────────────────────────────────────── */
|
||||
|
||||
const Accounts: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
/** 显示 toast */
|
||||
const showToast = useCallback((message: string, type: Toast["type"]) => {
|
||||
const id = ++toastIdCounter;
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 3000);
|
||||
}, []);
|
||||
|
||||
/** 查询所有平台的账号 */
|
||||
const _queriesResults = useQueries({
|
||||
queries: PLATFORMS.map((platform) => ({
|
||||
queryKey: ["accounts", platform.id] as const,
|
||||
queryFn: () => getAccountsByPlatform(platform.id),
|
||||
})),
|
||||
});
|
||||
const accountQueries = PLATFORMS.map((platform, i) => ({
|
||||
platform,
|
||||
..._queriesResults[i],
|
||||
}));
|
||||
|
||||
/** 解绑 mutation */
|
||||
const unbindMutation = useMutation({
|
||||
mutationFn: unbindAccount,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
||||
showToast("已解绑账号", "success");
|
||||
},
|
||||
onError: () => {
|
||||
showToast("解绑失败", "error");
|
||||
},
|
||||
});
|
||||
|
||||
/** 绑定 mutation */
|
||||
const bindMutation = useMutation({
|
||||
mutationFn: bindAccount,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
||||
showToast("账号绑定成功", "success");
|
||||
},
|
||||
onError: () => {
|
||||
showToast("绑定失败", "error");
|
||||
},
|
||||
});
|
||||
|
||||
/** 绑定新账号 */
|
||||
const handleBind = (platformId: PlatformId) => {
|
||||
const platform = PLATFORMS.find((p) => p.id === platformId);
|
||||
if (!platform) return;
|
||||
|
||||
const name = window.prompt(`请输入要绑定的${platform.name}账号名称:`);
|
||||
if (name && name.trim()) {
|
||||
bindMutation.mutate({ platform_id: platformId, name: name.trim() });
|
||||
}
|
||||
};
|
||||
|
||||
/** 解绑账号 */
|
||||
const handleUnbind = (accountId: string, accountName: string) => {
|
||||
if (window.confirm(`确定解绑账号「${accountName}」吗?`)) {
|
||||
unbindMutation.mutate(accountId);
|
||||
}
|
||||
};
|
||||
|
||||
/** 统计已绑定账号数 */
|
||||
const totalBound = accountQueries.reduce(
|
||||
(sum, q) => sum + (q.data?.length ?? 0),
|
||||
0,
|
||||
);
|
||||
const totalPlatforms = PLATFORMS.length;
|
||||
|
||||
return (
|
||||
<div className="acc-page">
|
||||
<PageHead
|
||||
@@ -197,17 +69,34 @@ const Accounts: React.FC = () => {
|
||||
description="绑定您的社交平台账号,用于视频一键发布到各平台"
|
||||
/>
|
||||
|
||||
{/* 平台卡片网格 */}
|
||||
{/* 平台卡片网格 — 占位状态 */}
|
||||
<div className="acc-grid">
|
||||
{accountQueries.map(({ platform, data, isLoading }) => (
|
||||
<PlatformCard
|
||||
key={platform.id}
|
||||
platform={platform}
|
||||
accounts={data ?? []}
|
||||
isLoading={isLoading}
|
||||
onBind={handleBind}
|
||||
onUnbind={handleUnbind}
|
||||
/>
|
||||
{PLATFORMS.map((platform) => (
|
||||
<div key={platform.id} className="acc-card">
|
||||
<div className="acc-card-header">
|
||||
<div
|
||||
className="acc-card-icon"
|
||||
style={{ background: platform.gradient }}
|
||||
>
|
||||
{platform.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="acc-card-title">{platform.name}</h3>
|
||||
<p className="acc-card-subtitle">{platform.subName}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="acc-account-list">
|
||||
<div className="acc-empty">
|
||||
<div className="acc-empty-icon">🔒</div>
|
||||
<p className="acc-empty-text">功能即将上线</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button buttonType="ghost" disabled>
|
||||
即将支持绑定
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -215,22 +104,10 @@ const Accounts: React.FC = () => {
|
||||
<div className="acc-stats-bar">
|
||||
<span className="acc-stats-icon">📊</span>
|
||||
<span className="acc-stats-text">
|
||||
已绑定 <span className="acc-stats-highlight">{totalBound}</span>{" "}
|
||||
个账号 / 支持{" "}
|
||||
<span className="acc-stats-highlight">{totalPlatforms}</span> 个平台
|
||||
支持 <span className="acc-stats-highlight">{PLATFORMS.length}</span>{" "}
|
||||
个平台,功能即将上线
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Toast 提示 */}
|
||||
{toasts.length > 0 && (
|
||||
<div className="vc-toast-container">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className={`vc-toast vc-toast--${t.type}`}>
|
||||
{t.type === "success" ? "✅" : "❌"} {t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -82,77 +82,6 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ── 账号行 ─────────────────────────────────────────────── */
|
||||
|
||||
.acc-account-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.acc-account-row:hover {
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
.acc-account-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-inverse);
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: var(--font-size-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.acc-account-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.acc-account-name {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── 状态标签 ───────────────────────────────────────────── */
|
||||
|
||||
.acc-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1.6;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.acc-status--active {
|
||||
background: var(--success-soft);
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.acc-status--expired {
|
||||
background: var(--error-soft);
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.acc-status--limited {
|
||||
background: var(--warning-soft);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* ── 空状态 ─────────────────────────────────────────────── */
|
||||
|
||||
.acc-empty {
|
||||
@@ -235,17 +164,6 @@
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.acc-account-row {
|
||||
padding: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.acc-account-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.acc-stats-bar {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
@@ -1,442 +1,20 @@
|
||||
/* V21 Admin 页面样式 */
|
||||
/* Admin 页面样式(Phase 3 精简)
|
||||
*
|
||||
* 原始 477 行 → 精简至仅保留实际使用的 class。
|
||||
* 已迁移至 global.css / ui.css 的样式不再重复定义:
|
||||
* .xx-page-head → global.css
|
||||
* .xx-primary-btn → global.css
|
||||
* .xx-tag / .xx-card → ui.css / global.css
|
||||
*
|
||||
* 以下 class 仅被 AdminComingSoon.tsx 使用。
|
||||
*/
|
||||
|
||||
/* 页面容器 */
|
||||
.dashboard-page,
|
||||
.analytics-page,
|
||||
.user-management-page,
|
||||
.log-viewer-page,
|
||||
.system-monitor-page,
|
||||
.admin-coming-soon-page {
|
||||
padding: 32px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 页面头部 */
|
||||
.xx-page-head {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
|
||||
padding: 32px 40px;
|
||||
margin-bottom: 32px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.xx-page-head-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-page-head h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
color: var(--slate, #0f172a);
|
||||
margin: 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.xx-page-head p {
|
||||
font-size: 15px;
|
||||
color: var(--muted, #64748b);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.xx-page-head-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 简化页面头部(无操作按钮) */
|
||||
.xx-page-head-simple {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
|
||||
padding: 32px 40px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.xx-page-head-simple h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
color: var(--slate, #0f172a);
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.xx-page-head-simple p {
|
||||
font-size: 15px;
|
||||
color: var(--muted, #64748b);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 统计卡片网格 - 4列 */
|
||||
.xx-grid-4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.xx-grid-4 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.xx-grid-4 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.xx-stat-card {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
||||
padding: 24px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.xx-stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.xx-stat-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-stat-card-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.xx-stat-card-icon.primary {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.xx-stat-card-icon.success {
|
||||
background: linear-gradient(135deg, #34d399, #10b981);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.xx-stat-card-icon.warning {
|
||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.xx-stat-card-icon.purple {
|
||||
background: linear-gradient(135deg, #a78bfa, #8b5cf6);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.xx-stat-card-icon.info {
|
||||
background: linear-gradient(135deg, #60a5fa, #3b82f6);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.xx-stat-card-icon.orange {
|
||||
background: linear-gradient(135deg, #fb923c, #f97316);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.xx-stat-card-label {
|
||||
font-size: 14px;
|
||||
color: var(--muted, #64748b);
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.xx-stat-card-value {
|
||||
font-size: 32px;
|
||||
font-weight: 900;
|
||||
color: var(--slate, #0f172a);
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.xx-stat-card-value.primary {
|
||||
color: var(--indigo, #4f46e5);
|
||||
}
|
||||
|
||||
.xx-stat-card-value.success {
|
||||
color: var(--green, #10b981);
|
||||
}
|
||||
|
||||
.xx-stat-card-value.warning {
|
||||
color: var(--amber, #f59e0b);
|
||||
}
|
||||
|
||||
.xx-stat-card-value.purple {
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.xx-stat-card-growth {
|
||||
font-size: 13px;
|
||||
color: var(--green, #10b981);
|
||||
font-weight: 600;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 数据表格 */
|
||||
.xx-table-wrapper {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-table-wrapper .ant-table {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.xx-table-wrapper .ant-table-thead > tr > th {
|
||||
background: rgba(248, 250, 252, 0.8);
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
color: var(--slate, #0f172a);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.xx-table-wrapper .ant-table-tbody > tr > td {
|
||||
padding: 16px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.xx-table-wrapper .ant-table-tbody > tr:hover > td {
|
||||
background: rgba(79, 70, 229, 0.03);
|
||||
}
|
||||
|
||||
/* V21 按钮 */
|
||||
.xx-primary-btn {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
|
||||
color: white !important;
|
||||
border: none !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
font-weight: 700 !important;
|
||||
box-shadow: 0 8px 20px rgba(79, 70, 229, 0.25) !important;
|
||||
transition: all 0.2s !important;
|
||||
}
|
||||
|
||||
.xx-primary-btn:hover {
|
||||
box-shadow: 0 12px 28px rgba(79, 70, 229, 0.3) !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.xx-ghost-btn {
|
||||
background: white !important;
|
||||
border: 1px solid rgba(226, 232, 240, 0.95) !important;
|
||||
color: var(--slate, #0f172a) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
font-weight: 600 !important;
|
||||
transition: all 0.2s !important;
|
||||
}
|
||||
|
||||
.xx-ghost-btn:hover {
|
||||
border-color: var(--indigo, #4f46e5) !important;
|
||||
color: var(--indigo, #4f46e5) !important;
|
||||
}
|
||||
|
||||
/* V21 输入框 */
|
||||
.xx-search-input {
|
||||
border-radius: var(--radius-md) !important;
|
||||
border: 1px solid rgba(226, 232, 240, 0.95) !important;
|
||||
padding: 8px 16px !important;
|
||||
}
|
||||
|
||||
.xx-search-input:hover,
|
||||
.xx-search-input:focus {
|
||||
border-color: var(--indigo, #4f46e5) !important;
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1) !important;
|
||||
}
|
||||
|
||||
/* V21 Tag */
|
||||
.xx-tag {
|
||||
border-radius: var(--radius-xs) !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 12px !important;
|
||||
padding: 4px 10px !important;
|
||||
}
|
||||
|
||||
.xx-tag.info {
|
||||
background: rgba(59, 130, 246, 0.1) !important;
|
||||
color: #3b82f6 !important;
|
||||
border: 1px solid rgba(59, 130, 246, 0.2) !important;
|
||||
}
|
||||
|
||||
.xx-tag.success {
|
||||
background: rgba(16, 185, 129, 0.1) !important;
|
||||
color: #10b981 !important;
|
||||
border: 1px solid rgba(16, 185, 129, 0.2) !important;
|
||||
}
|
||||
|
||||
.xx-tag.warning {
|
||||
background: rgba(245, 158, 11, 0.1) !important;
|
||||
color: #f59e0b !important;
|
||||
border: 1px solid rgba(245, 158, 11, 0.2) !important;
|
||||
}
|
||||
|
||||
.xx-tag.error {
|
||||
background: rgba(239, 68, 68, 0.1) !important;
|
||||
color: #ef4444 !important;
|
||||
border: 1px solid rgba(239, 68, 68, 0.2) !important;
|
||||
}
|
||||
|
||||
.xx-tag.debug {
|
||||
background: rgba(100, 116, 139, 0.1) !important;
|
||||
color: #64748b !important;
|
||||
border: 1px solid rgba(100, 116, 139, 0.2) !important;
|
||||
}
|
||||
|
||||
/* V21 Progress */
|
||||
.xx-progress-primary .ant-progress-circle .ant-progress-text {
|
||||
color: var(--indigo, #4f46e5) !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
.xx-progress-success .ant-progress-circle .ant-progress-text {
|
||||
color: var(--green, #10b981) !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
.xx-progress-warning .ant-progress-circle .ant-progress-text {
|
||||
color: var(--amber, #f59e0b) !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
/* 图表容器 */
|
||||
.xx-chart-container {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.xx-chart-container .ant-card-head {
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.8);
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.xx-chart-container .ant-card-head-title {
|
||||
font-weight: 800;
|
||||
font-size: 17px;
|
||||
color: var(--slate, #0f172a);
|
||||
}
|
||||
|
||||
/* 筛选器区域 */
|
||||
.xx-filter-bar {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 资源监控卡片 */
|
||||
.xx-resource-card {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-resource-card-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 16px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.xx-resource-card-label {
|
||||
font-size: 14px;
|
||||
color: var(--muted, #64748b);
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 日期选择器 */
|
||||
.xx-date-picker {
|
||||
border-radius: var(--radius-md) !important;
|
||||
}
|
||||
|
||||
/* Drawer */
|
||||
.xx-drawer .ant-drawer-header {
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.8);
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.xx-drawer .ant-drawer-title {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
color: var(--slate, #0f172a);
|
||||
}
|
||||
|
||||
/* 日志详情 */
|
||||
.xx-log-detail {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.xx-log-detail-label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--slate, #0f172a);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.xx-log-detail-value {
|
||||
font-size: 14px;
|
||||
color: var(--muted, #64748b);
|
||||
background: rgba(248, 250, 252, 0.8);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-log-detail-code {
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
background: rgba(248, 250, 252, 0.8);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Result 页面居中 */
|
||||
.xx-result-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -447,31 +25,3 @@
|
||||
.xx-result-center .ant-result {
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
/* 刷新时间显示 */
|
||||
.xx-refresh-time {
|
||||
font-size: 13px;
|
||||
color: var(--muted, #64748b);
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
/* 图表配色覆盖 */
|
||||
.recharts-text {
|
||||
fill: #64748b !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.recharts-cartesian-grid-horizontal line,
|
||||
.recharts-cartesian-grid-vertical line {
|
||||
stroke: rgba(226, 232, 240, 0.8) !important;
|
||||
}
|
||||
|
||||
/* 图表 Legend */
|
||||
.recharts-legend-wrapper {
|
||||
padding-top: 16px !important;
|
||||
}
|
||||
|
||||
.recharts-legend-item-text {
|
||||
color: #64748b !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
@@ -1,454 +1,85 @@
|
||||
/**
|
||||
* 控制台页面 — V21 设计系统
|
||||
* KPI 卡片网格 + 快速入口 + 最近任务卡片列表 + 使用统计图表 + 公告
|
||||
* 使用 mock 数据,CSS 变量,V21 组件
|
||||
* CSS 变量,V21 组件
|
||||
*/
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Tag } from "@/components/ui";
|
||||
import {
|
||||
VideoCameraOutlined,
|
||||
AppstoreOutlined,
|
||||
ThunderboltOutlined,
|
||||
DatabaseOutlined,
|
||||
FileTextOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Button } from "@/components/ui";
|
||||
import { DatabaseOutlined } from "@ant-design/icons";
|
||||
import "./dashboard.css";
|
||||
|
||||
/* ============================================================
|
||||
* Mock 数据
|
||||
* ============================================================ */
|
||||
interface KpiItem {
|
||||
key: string;
|
||||
icon: string;
|
||||
iconGradient: string;
|
||||
value: string;
|
||||
label: string;
|
||||
trend: string;
|
||||
trendDirection: "up" | "down" | "neutral";
|
||||
accent: string;
|
||||
}
|
||||
/* ── 主组件 ─────────────────────────────────────────────── */
|
||||
|
||||
// TODO: kpiData 当前使用硬编码 mock 数据,待后端提供 Dashboard 统计 API 后替换
|
||||
const kpiData: KpiItem[] = [
|
||||
{
|
||||
key: "projects",
|
||||
icon: "video",
|
||||
iconGradient: "linear-gradient(135deg, #6366f1, #4f46e5)",
|
||||
value: "12",
|
||||
label: "项目总数",
|
||||
trend: "↑ 2 本月新增",
|
||||
trendDirection: "up",
|
||||
accent: "#6366f1",
|
||||
},
|
||||
{
|
||||
key: "assets",
|
||||
icon: "appstore",
|
||||
iconGradient: "linear-gradient(135deg, #0ea5e9, #0284c7)",
|
||||
value: "486",
|
||||
label: "素材总数",
|
||||
trend: "↑ 38 本月上传",
|
||||
trendDirection: "up",
|
||||
accent: "#0ea5e9",
|
||||
},
|
||||
{
|
||||
key: "generations",
|
||||
icon: "thunderbolt",
|
||||
iconGradient: "linear-gradient(135deg, #10b981, #059669)",
|
||||
value: "156",
|
||||
label: "本月生成数",
|
||||
trend: "↑ 23% 较上月",
|
||||
trendDirection: "up",
|
||||
accent: "#10b981",
|
||||
},
|
||||
{
|
||||
key: "storage",
|
||||
icon: "database",
|
||||
iconGradient: "linear-gradient(135deg, #f59e0b, #d97706)",
|
||||
value: "2.4GB",
|
||||
label: "存储空间",
|
||||
trend: "已用 24%",
|
||||
trendDirection: "neutral",
|
||||
accent: "#f59e0b",
|
||||
},
|
||||
];
|
||||
|
||||
interface QuickEntry {
|
||||
id: string;
|
||||
icon: string;
|
||||
iconGradient: string;
|
||||
title: string;
|
||||
description: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
// TODO: quickEntries 描述中含硬编码计数(如 486个素材),待后端 API 后动态化
|
||||
const quickEntries: QuickEntry[] = [
|
||||
{
|
||||
id: "titles",
|
||||
icon: "filetext",
|
||||
iconGradient: "linear-gradient(135deg, #6366f1, #4f46e5)",
|
||||
title: "标题库",
|
||||
description: "24条标题 · 5个分类",
|
||||
path: "/app/titles",
|
||||
},
|
||||
{
|
||||
id: "assets",
|
||||
icon: "appstore",
|
||||
iconGradient: "linear-gradient(135deg, #0ea5e9, #0284c7)",
|
||||
title: "素材库",
|
||||
description: "486个素材 · 3个素材库",
|
||||
path: "/app/assets",
|
||||
},
|
||||
{
|
||||
id: "generate",
|
||||
icon: "thunderbolt",
|
||||
iconGradient: "linear-gradient(135deg, #10b981, #059669)",
|
||||
title: "一键生成",
|
||||
description: "开始创作新视频",
|
||||
path: "/app/generate",
|
||||
},
|
||||
{
|
||||
id: "products",
|
||||
icon: "video",
|
||||
iconGradient: "linear-gradient(135deg, #f59e0b, #d97706)",
|
||||
title: "成片库",
|
||||
description: "89个成片 · 3个待复核",
|
||||
path: "/app/products",
|
||||
},
|
||||
];
|
||||
|
||||
type TaskStatus = "completed" | "processing" | "pending" | "failed";
|
||||
|
||||
interface RecentTask {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
template: string;
|
||||
status: TaskStatus;
|
||||
date: string;
|
||||
duration?: string;
|
||||
}
|
||||
|
||||
const statusLabel: Record<TaskStatus, string> = {
|
||||
completed: "已完成",
|
||||
processing: "进行中",
|
||||
pending: "排队中",
|
||||
failed: "失败",
|
||||
};
|
||||
|
||||
// TODO: recentTasks 当前使用硬编码 mock 数据,待后端提供最近任务 API 后替换
|
||||
const recentTasks: RecentTask[] = [
|
||||
{
|
||||
id: "t-1",
|
||||
name: "产品介绍视频_春季促销",
|
||||
type: "视频生成",
|
||||
template: "商品展示模板",
|
||||
status: "completed",
|
||||
date: "2026-07-01 09:30",
|
||||
duration: "2分18秒",
|
||||
},
|
||||
{
|
||||
id: "t-2",
|
||||
name: "品牌宣传片_终版",
|
||||
type: "视频生成",
|
||||
template: "品牌宣传模板",
|
||||
status: "processing",
|
||||
date: "2026-07-01 10:15",
|
||||
},
|
||||
{
|
||||
id: "t-3",
|
||||
name: "用户评价合集",
|
||||
type: "视频生成",
|
||||
template: "评价展示模板",
|
||||
status: "completed",
|
||||
date: "2026-06-30 16:42",
|
||||
duration: "1分45秒",
|
||||
},
|
||||
{
|
||||
id: "t-4",
|
||||
name: "新品发布预告",
|
||||
type: "视频生成",
|
||||
template: "新品预告模板",
|
||||
status: "pending",
|
||||
date: "2026-06-30 14:20",
|
||||
},
|
||||
{
|
||||
id: "t-5",
|
||||
name: "活动回顾_618大促",
|
||||
type: "视频生成",
|
||||
template: "活动回顾模板",
|
||||
status: "failed",
|
||||
date: "2026-06-29 11:05",
|
||||
},
|
||||
];
|
||||
|
||||
interface ChartItem {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const weeklyData: ChartItem[] = [
|
||||
{ label: "周一", value: 18 },
|
||||
{ label: "周二", value: 25 },
|
||||
{ label: "周三", value: 32 },
|
||||
{ label: "周四", value: 28 },
|
||||
{ label: "周五", value: 42 },
|
||||
{ label: "周六", value: 15 },
|
||||
{ label: "周日", value: 8 },
|
||||
];
|
||||
|
||||
interface Announcement {
|
||||
id: string;
|
||||
tag: "update" | "notice" | "activity";
|
||||
tagLabel: string;
|
||||
title: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
const announcements: Announcement[] = [
|
||||
{
|
||||
id: "a-1",
|
||||
tag: "update",
|
||||
tagLabel: "更新",
|
||||
title: "系统已升级至 v2.0,新增批量生成功能",
|
||||
date: "2026-07-01",
|
||||
},
|
||||
{
|
||||
id: "a-2",
|
||||
tag: "activity",
|
||||
tagLabel: "活动",
|
||||
title: "7月创作挑战赛已开启,参与赢积分奖励",
|
||||
date: "2026-06-28",
|
||||
},
|
||||
{
|
||||
id: "a-3",
|
||||
tag: "notice",
|
||||
tagLabel: "公告",
|
||||
title: "7月3日凌晨 2:00-4:00 系统维护通知",
|
||||
date: "2026-06-25",
|
||||
},
|
||||
];
|
||||
|
||||
/* ============================================================
|
||||
* 工具函数
|
||||
* ============================================================ */
|
||||
const getGreeting = () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 6) return "夜深了";
|
||||
if (hour < 12) return "早上好";
|
||||
if (hour < 14) return "中午好";
|
||||
if (hour < 18) return "下午好";
|
||||
return "晚上好";
|
||||
};
|
||||
|
||||
const formatDate = () => {
|
||||
const d = new Date();
|
||||
const weekDays = ["日", "一", "二", "三", "四", "五", "六"];
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 星期${weekDays[d.getDay()]}`;
|
||||
};
|
||||
|
||||
/** 图标名称 → Ant Design 组件映射 */
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
video: <VideoCameraOutlined />,
|
||||
appstore: <AppstoreOutlined />,
|
||||
thunderbolt: <ThunderboltOutlined />,
|
||||
database: <DatabaseOutlined />,
|
||||
filetext: <FileTextOutlined />,
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* 组件
|
||||
* ============================================================ */
|
||||
const Dashboard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const maxChart = Math.max(...weeklyData.map((d) => d.value));
|
||||
|
||||
return (
|
||||
<div className="xx-dashboard-page">
|
||||
{/* ── 欢迎头部 ─────────────────────────────────────────── */}
|
||||
<div className="xx-dashboard-welcome">
|
||||
<h2>{getGreeting()},创作者</h2>
|
||||
<p>{formatDate()} — 欢迎回到小小剪辑控制台</p>
|
||||
</div>
|
||||
|
||||
{/* ── KPI 卡片网格 ─────────────────────────────────────── */}
|
||||
{/* KPI 卡片网格 */}
|
||||
<div className="xx-kpi-grid">
|
||||
{kpiData.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="xx-kpi-card"
|
||||
style={{ "--kpi-accent": item.accent } as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
className="xx-kpi-icon"
|
||||
style={{ background: item.iconGradient }}
|
||||
>
|
||||
{iconMap[item.icon] ?? item.icon}
|
||||
</div>
|
||||
<div className="xx-kpi-value">{item.value}</div>
|
||||
<div className="xx-kpi-label">{item.label}</div>
|
||||
<span
|
||||
className={`xx-kpi-trend xx-kpi-trend--${item.trendDirection}`}
|
||||
>
|
||||
{item.trend}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── 主内容区:左侧任务+图表 / 右侧公告 ──────────────── */}
|
||||
<div className="xx-dashboard-main">
|
||||
{/* 左列 */}
|
||||
<div className="xx-dashboard-left-col">
|
||||
{/* 最近任务 */}
|
||||
<div className="xx-dashboard-section">
|
||||
<div className="xx-dashboard-section-header">
|
||||
<h3>最近任务</h3>
|
||||
<button onClick={() => navigate("/app/history")}>查看全部</button>
|
||||
</div>
|
||||
<div className="xx-task-list">
|
||||
{recentTasks.map((task) => (
|
||||
<div key={task.id} className="xx-task-item">
|
||||
<div className="xx-task-info">
|
||||
<h4>{task.name}</h4>
|
||||
<span>
|
||||
{task.type} · 模板:{task.template}
|
||||
</span>
|
||||
</div>
|
||||
<Tag
|
||||
variant={
|
||||
task.status === "completed"
|
||||
? "success"
|
||||
: task.status === "processing"
|
||||
? "info"
|
||||
: task.status === "failed"
|
||||
? "error"
|
||||
: "warning"
|
||||
}
|
||||
>
|
||||
{statusLabel[task.status]}
|
||||
</Tag>
|
||||
<div className="xx-task-time">
|
||||
<span>{task.date}</span>
|
||||
{task.status === "completed"
|
||||
? `耗时 ${task.duration}`
|
||||
: task.status === "processing"
|
||||
? "生成中..."
|
||||
: task.status === "failed"
|
||||
? "请重试"
|
||||
: "等待中"}
|
||||
</div>
|
||||
<div className="xx-task-action">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => navigate("/app/history")}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 使用统计图表 */}
|
||||
<div className="xx-dashboard-section">
|
||||
<div className="xx-dashboard-section-header">
|
||||
<h3>本周生成趋势</h3>
|
||||
<span className="xx-chart-total">
|
||||
共 {weeklyData.reduce((s, d) => s + d.value, 0)} 次
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-chart-container">
|
||||
<div className="xx-chart-bars">
|
||||
{weeklyData.map((d, i) => (
|
||||
<div key={i} className="xx-chart-bar-wrapper">
|
||||
<div
|
||||
className="xx-chart-bar"
|
||||
style={{
|
||||
height: `${(d.value / maxChart) * 100}%`,
|
||||
}}
|
||||
>
|
||||
<span className="xx-chart-bar-value">{d.value}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="xx-chart-labels">
|
||||
{weeklyData.map((d, i) => (
|
||||
<div key={i} className="xx-chart-label">
|
||||
{d.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右列 — 公告 + 存储用量 */}
|
||||
<div className="xx-dashboard-section xx-dashboard-section--start">
|
||||
<div className="xx-dashboard-section-header">
|
||||
<h3>系统公告</h3>
|
||||
</div>
|
||||
<div className="xx-announcement-list">
|
||||
{announcements.map((a) => (
|
||||
<div key={a.id} className="xx-announcement-item">
|
||||
<span
|
||||
className={`xx-announcement-tag xx-announcement-tag--${a.tag}`}
|
||||
>
|
||||
{a.tagLabel}
|
||||
</span>
|
||||
<div className="xx-announcement-content">
|
||||
<h4>{a.title}</h4>
|
||||
<time>{a.date}</time>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 存储用量 */}
|
||||
<div className="xx-storage-section">
|
||||
<div className="xx-storage-section-title">存储用量</div>
|
||||
<div className="xx-storage-bar">
|
||||
<div className="xx-storage-bar-track">
|
||||
<div className="xx-storage-bar-fill" style={{ width: "24%" }} />
|
||||
</div>
|
||||
<div className="xx-storage-bar-label">
|
||||
<span>2.4 GB 已用</span>
|
||||
<span>10 GB 总量</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-dashboard-empty">
|
||||
<p>暂无统计数据</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 快速入口 ─────────────────────────────────────────── */}
|
||||
{/* 快速入口 */}
|
||||
<div className="xx-quick-entry-section">
|
||||
<div className="xx-quick-entry-header">
|
||||
<h3 className="xx-quick-entry-title">快速入口</h3>
|
||||
</div>
|
||||
<div className="xx-quick-grid">
|
||||
{quickEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="xx-quick-card"
|
||||
onClick={() => navigate(entry.path)}
|
||||
>
|
||||
<div
|
||||
className="xx-quick-card-icon"
|
||||
style={{ background: entry.iconGradient }}
|
||||
>
|
||||
{iconMap[entry.icon] ?? entry.icon}
|
||||
</div>
|
||||
<h3>{entry.title}</h3>
|
||||
<p>{entry.description}</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="xx-dashboard-empty">
|
||||
<p>暂无快速入口</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 最近任务 */}
|
||||
<section className="xx-dashboard-section">
|
||||
<div className="xx-dashboard-section-header">
|
||||
<h3>最近任务</h3>
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={() => navigate("/app/history")}>
|
||||
查看全部
|
||||
</Button>
|
||||
</div>
|
||||
<div className="xx-task-list">
|
||||
<div className="xx-dashboard-empty">
|
||||
<p>暂无最近任务</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 使用统计 */}
|
||||
<section className="xx-dashboard-section" style={{ marginTop: "var(--space-md)" }}>
|
||||
<div className="xx-dashboard-section-header">
|
||||
<h3>使用统计</h3>
|
||||
</div>
|
||||
<div className="xx-chart-container">
|
||||
<div className="xx-chart-bars">
|
||||
<div className="xx-dashboard-empty" style={{ width: "100%" }}>
|
||||
<DatabaseOutlined style={{ fontSize: 24, marginBottom: 8 }} />
|
||||
<p>暂无统计数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 公告 */}
|
||||
<section className="xx-dashboard-section" style={{ marginTop: "var(--space-md)" }}>
|
||||
<div className="xx-dashboard-section-header">
|
||||
<h3>公告</h3>
|
||||
</div>
|
||||
<div className="xx-announcement-list">
|
||||
<div className="xx-announcement-item">
|
||||
<span className="xx-announcement-tag xx-announcement-tag--notice">官方</span>
|
||||
<div className="xx-announcement-content">
|
||||
<h4>欢迎使用小应 SaaS 平台</h4>
|
||||
<time>当前为演示版本,部分功能正在开发中。</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 控制台页面 - V21 设计系统样式
|
||||
* KPI 卡片网格 + 快速入口 + 最近任务卡片列表 + 使用统计图表 + 公告
|
||||
* KPI 卡片网格 + 快速入口 + 最近任务 + 使用统计图表 + 公告
|
||||
* 统一使用 CSS 变量,支持深色/浅色主题
|
||||
*/
|
||||
@import "../../styles/global.css";
|
||||
@@ -13,26 +13,6 @@
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
欢迎头部
|
||||
============================================================ */
|
||||
.xx-dashboard-welcome {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.xx-dashboard-welcome h2 {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-dashboard-welcome p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
KPI 卡片网格
|
||||
============================================================ */
|
||||
@@ -43,76 +23,6 @@
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.xx-kpi-card {
|
||||
background: linear-gradient(180deg, var(--bg-primary), var(--bg-secondary));
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
transition: 0.18s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-kpi-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.xx-kpi-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-lg);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-kpi-value {
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.xx-kpi-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-kpi-trend {
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.xx-kpi-trend--up {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.xx-kpi-trend--down {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.xx-kpi-trend--neutral {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
主内容区两栏布局
|
||||
============================================================ */
|
||||
.xx-dashboard-main {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
区块卡片
|
||||
============================================================ */
|
||||
@@ -161,7 +71,25 @@
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
最近任务卡片列表
|
||||
空状态
|
||||
============================================================ */
|
||||
.xx-dashboard-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-xl) var(--space-md);
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.xx-dashboard-empty p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
最近任务列表
|
||||
============================================================ */
|
||||
.xx-task-list {
|
||||
display: flex;
|
||||
@@ -169,86 +97,6 @@
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-task-item {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
gap: var(--space-md);
|
||||
align-items: center;
|
||||
transition: var(--transition-all);
|
||||
}
|
||||
|
||||
.xx-task-item:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.xx-task-info h4 {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-task-info span {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-task-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-task-status--completed {
|
||||
color: var(--success-color);
|
||||
background: var(--success-soft);
|
||||
border: 1px solid var(--success-border);
|
||||
}
|
||||
|
||||
.xx-task-status--processing {
|
||||
color: var(--info-color);
|
||||
background: var(--primary-soft);
|
||||
border: 1px solid var(--color-primary-200);
|
||||
}
|
||||
|
||||
.xx-task-status--pending {
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.xx-task-status--failed {
|
||||
color: var(--error-color);
|
||||
background: var(--error-soft);
|
||||
border: 1px solid var(--error-border);
|
||||
}
|
||||
|
||||
.xx-task-time {
|
||||
text-align: right;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.xx-task-time span {
|
||||
display: block;
|
||||
margin-bottom: var(--space-xxs);
|
||||
}
|
||||
|
||||
.xx-task-action {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
使用统计图表(纯 CSS 柱状图)
|
||||
============================================================ */
|
||||
@@ -264,69 +112,27 @@
|
||||
padding-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-chart-bar-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.xx-chart-bar {
|
||||
width: 100%;
|
||||
max-width: 36px;
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
background: var(--gradient-primary);
|
||||
transition: height 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
position: relative;
|
||||
min-height: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-chart-bar:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.xx-chart-bar:active {
|
||||
opacity: 0.7;
|
||||
transform: scaleY(0.97);
|
||||
transform-origin: bottom;
|
||||
}
|
||||
|
||||
.xx-chart-bar-value {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: var(--transition-opacity);
|
||||
}
|
||||
|
||||
.xx-chart-bar:hover .xx-chart-bar-value {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.xx-chart-labels {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-chart-label {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
快速入口卡片网格
|
||||
快速入口
|
||||
============================================================ */
|
||||
.xx-quick-entry-section {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-quick-entry-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-quick-entry-title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-quick-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
@@ -334,53 +140,6 @@
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.xx-quick-card {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-all);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-quick-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.xx-quick-card:active {
|
||||
transform: translateY(0) scale(0.98);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition-duration: 0.1s;
|
||||
}
|
||||
|
||||
.xx-quick-card-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-xl);
|
||||
margin: 0 auto var(--space-md);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.xx-quick-card h3 {
|
||||
margin: 0 0 var(--space-sm);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-quick-card p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
公告区域
|
||||
============================================================ */
|
||||
@@ -451,90 +210,10 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
存储用量条
|
||||
============================================================ */
|
||||
.xx-storage-bar {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-storage-bar-track {
|
||||
height: var(--space-sm);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--space-xs);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-storage-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: var(--space-xs);
|
||||
background: var(--gradient-primary);
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
|
||||
.xx-storage-bar-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
迁移自内联样式的工具类
|
||||
============================================================ */
|
||||
.xx-dashboard-left-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-chart-total {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-dashboard-section--start {
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.xx-storage-section {
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-storage-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.xx-quick-entry-section {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-quick-entry-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-quick-entry-title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
响应式
|
||||
============================================================ */
|
||||
@media (max-width: 1200px) {
|
||||
.xx-dashboard-main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.xx-kpi-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
@@ -557,15 +236,6 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.xx-task-item {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-task-time {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.xx-chart-bars {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
@@ -69,22 +69,6 @@ const VOICE_GENDER_ICON: Record<string, string> = {
|
||||
neutral: "✨",
|
||||
};
|
||||
|
||||
/* ── 时间线 Mock ──
|
||||
* TODO: 后端暂无时间线场景数据 API,当前使用硬编码预览数据。
|
||||
* 待后端提供 timeline/scene 接口后替换为真实 API 调用。
|
||||
*/
|
||||
interface TimelineScene {
|
||||
scene: string;
|
||||
time: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
const MOCK_TIMELINE: TimelineScene[] = [
|
||||
{ scene: "主讲口播 · 开场钩子", time: "0-8s", duration: 8 },
|
||||
{ scene: "产品特写 · B-roll", time: "8-20s", duration: 12 },
|
||||
{ scene: "用户反馈 · 结尾", time: "20-30s", duration: 10 },
|
||||
];
|
||||
|
||||
/* ── 步骤定义 ── */
|
||||
const STEPS = [
|
||||
{ key: 1, label: "选择模板" },
|
||||
@@ -1758,15 +1742,19 @@ const GeneratePage: React.FC = () => {
|
||||
<div className="xx-preview-title">剪辑计划预览</div>
|
||||
|
||||
{/* 时间线列表 */}
|
||||
<div className="xx-preview-timeline">
|
||||
{MOCK_TIMELINE.map((item, idx) => (
|
||||
<div key={idx} className="xx-timeline-item">
|
||||
<div className="num">{idx + 1}</div>
|
||||
<span className="scene-name">{item.scene}</span>
|
||||
<span>{item.time}</span>
|
||||
{generated ? (
|
||||
<div className="xx-preview-timeline">
|
||||
<div className="xx-timeline-item">
|
||||
<span className="scene-name">生成完成,可下载或分享视频</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-preview-timeline">
|
||||
<div className="xx-timeline-item">
|
||||
<span className="scene-name">确认标题后自动生成剪辑计划</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成操作按钮 */}
|
||||
<div className="xx-generate-actions">
|
||||
|
||||
@@ -36,36 +36,24 @@ type TitleType = "hot" | "normal" | "creative";
|
||||
type Industry = "general" | "food" | "tech" | "beauty" | "education" | "travel";
|
||||
type Frequency = "all" | "high" | "medium" | "low";
|
||||
|
||||
interface CategoryItem {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface TitleData {
|
||||
id: string;
|
||||
content: string;
|
||||
type: TitleType;
|
||||
industry: Industry;
|
||||
category: string;
|
||||
usageCount: number;
|
||||
isFavorited: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Mock 数据
|
||||
* ============================================================ */
|
||||
// TODO: 分类数据当前为前端硬编码 mock,待后端提供标题分类 API 后替换
|
||||
const MOCK_CATEGORIES: CategoryItem[] = [
|
||||
{ id: "cat-all", name: "全部标题", count: 0 },
|
||||
];
|
||||
|
||||
/** 后端 TitleItem → 前端 TitleData 映射 */
|
||||
const toTitleData = (item: TitleItem): TitleData => ({
|
||||
id: item.id,
|
||||
content: item.content,
|
||||
type: (item.category as TitleType) || "normal",
|
||||
industry: "general",
|
||||
category: item.category || "未分类",
|
||||
usageCount: 0,
|
||||
isFavorited: false,
|
||||
createdAt: item.created_at?.slice(0, 10) || "",
|
||||
@@ -246,9 +234,8 @@ const TitleCard: React.FC<{
|
||||
const TitleLibrary: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/* 分类数据 */
|
||||
const [categories, setCategories] = useState<CategoryItem[]>(MOCK_CATEGORIES);
|
||||
const [activeCatId, setActiveCatId] = useState<string>(MOCK_CATEGORIES[0].id);
|
||||
/* 分类数据 — 从真实标题数据动态派生 */
|
||||
const [activeCatId, setActiveCatId] = useState<string>("cat-all");
|
||||
|
||||
/* 标题数据 — 真实 API */
|
||||
const { data: apiTitles = [] } = useQuery({
|
||||
@@ -261,6 +248,23 @@ const TitleLibrary: React.FC = () => {
|
||||
[apiTitles],
|
||||
);
|
||||
|
||||
/* 从真实标题数据动态派生分类(无需后端分类 API) */
|
||||
const categories = useMemo(() => {
|
||||
const cats = new Map<string, number>();
|
||||
apiTitles.forEach((t) => {
|
||||
const cat = t.category || "未分类";
|
||||
cats.set(cat, (cats.get(cat) || 0) + 1);
|
||||
});
|
||||
return [
|
||||
{ id: "cat-all", name: "全部标题", count: apiTitles.length },
|
||||
...Array.from(cats.entries()).map(([name, count]) => ({
|
||||
id: `cat-${name}`,
|
||||
name,
|
||||
count,
|
||||
})),
|
||||
];
|
||||
}, [apiTitles]);
|
||||
|
||||
/* CRUD mutations */
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (content: string) => createTitle({ content }),
|
||||
@@ -297,10 +301,6 @@ const TitleLibrary: React.FC = () => {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editText, setEditText] = useState("");
|
||||
|
||||
/* 新建分类 */
|
||||
const [createCatModalOpen, setCreateCatModalOpen] = useState(false);
|
||||
const [newCatName, setNewCatName] = useState("");
|
||||
|
||||
/* 新建标题 */
|
||||
const [createTitleModalOpen, setCreateTitleModalOpen] = useState(false);
|
||||
const [newTitleContent, setNewTitleContent] = useState("");
|
||||
@@ -318,19 +318,11 @@ const TitleLibrary: React.FC = () => {
|
||||
const filteredTitles = useMemo(() => {
|
||||
let list = titles;
|
||||
|
||||
/* 按分类过滤("全部标题" 不过滤) */
|
||||
/* 按分类过滤("全部标题" 不过滤)— 直接匹配后端 category 字段 */
|
||||
if (activeCatId !== "cat-all") {
|
||||
const catName = activeCategory?.name || "";
|
||||
const catToIndustry: Record<string, Industry> = {
|
||||
美食探店: "food",
|
||||
科技数码: "tech",
|
||||
生活日常: "general",
|
||||
美妆穿搭: "beauty",
|
||||
教育学习: "education",
|
||||
};
|
||||
const mappedIndustry = catToIndustry[catName];
|
||||
if (mappedIndustry) {
|
||||
list = list.filter((t) => t.industry === mappedIndustry);
|
||||
if (catName) {
|
||||
list = list.filter((t) => t.category === catName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,32 +416,6 @@ const TitleLibrary: React.FC = () => {
|
||||
[deleteMutation],
|
||||
);
|
||||
|
||||
/* 新建分类 */
|
||||
const handleCreateCategory = () => {
|
||||
if (!newCatName.trim()) {
|
||||
message.warning("请输入分类名称");
|
||||
return;
|
||||
}
|
||||
const cat: CategoryItem = {
|
||||
id: `cat-${Date.now()}`,
|
||||
name: newCatName.trim(),
|
||||
count: 0,
|
||||
};
|
||||
setCategories((prev) => [...prev, cat]);
|
||||
setActiveCatId(cat.id);
|
||||
setCreateCatModalOpen(false);
|
||||
setNewCatName("");
|
||||
message.success(`分类 "${cat.name}" 创建成功`);
|
||||
};
|
||||
|
||||
/* 删除分类 */
|
||||
const handleDeleteCategory = (id: string) => {
|
||||
setCategories((prev) => prev.filter((c) => c.id !== id));
|
||||
if (activeCatId === id) {
|
||||
setActiveCatId("cat-all");
|
||||
}
|
||||
message.success("分类已删除");
|
||||
};
|
||||
|
||||
/* 新建标题 */
|
||||
const handleCreateTitle = () => {
|
||||
@@ -537,38 +503,12 @@ const TitleLibrary: React.FC = () => {
|
||||
</h4>
|
||||
<span>{cat.count} 条</span>
|
||||
</div>
|
||||
{cat.id !== "cat-all" && (
|
||||
<Popconfirm
|
||||
title={`确定删除分类 "${cat.name}"?`}
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation();
|
||||
handleDeleteCategory(cat.id);
|
||||
}}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<button
|
||||
className="xx-title-category-delete"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="删除分类"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 新建分类 */}
|
||||
<div
|
||||
className="xx-title-category-add"
|
||||
onClick={() => setCreateCatModalOpen(true)}
|
||||
>
|
||||
<PlusOutlined />
|
||||
新建分类
|
||||
</div>
|
||||
{/* TODO: 新建分类功能待后端分类 API 就绪后启用 */}
|
||||
</div>
|
||||
|
||||
{/* ─── 右侧:内容区 ─── */}
|
||||
@@ -674,34 +614,7 @@ const TitleLibrary: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── 新建分类弹窗 ─── */}
|
||||
<AntModal
|
||||
title="新建分类"
|
||||
open={createCatModalOpen}
|
||||
onCancel={() => setCreateCatModalOpen(false)}
|
||||
onOk={handleCreateCategory}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
>
|
||||
<div style={{ padding: "8px 0" }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 6,
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
分类名称
|
||||
</div>
|
||||
<Input
|
||||
placeholder="请输入分类名称"
|
||||
value={newCatName}
|
||||
onChange={(e) => setNewCatName(e.target.value)}
|
||||
maxLength={30}
|
||||
/>
|
||||
</div>
|
||||
</AntModal>
|
||||
|
||||
|
||||
{/* ─── 新建标题弹窗 ─── */}
|
||||
<AntModal
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""Video deduplication module - compute fingerprints and detect duplicates."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
@@ -327,7 +325,6 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
|
||||
raise ValueError(f"Generated video {generated_video_id} not found")
|
||||
|
||||
local_path = os.path.join(temp_dir, f"{generated_video_id}.mp4")
|
||||
storage_key = video.file_url.split("/")[-1]
|
||||
storage_service.download_file(
|
||||
f"projects/{video.project_id}/generated/{generated_video_id}/{generated_video_id}.mp4", local_path
|
||||
)
|
||||
|
||||
@@ -81,7 +81,7 @@ def run_ffmpeg(
|
||||
timeout=timeout,
|
||||
)
|
||||
return (result.stdout or "", result.stderr or "")
|
||||
except subprocess.TimeoutExpired as e:
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(
|
||||
"FFmpeg 命令超时 (%ds): command=%s",
|
||||
timeout or -1,
|
||||
|
||||
@@ -11,7 +11,6 @@ import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import oss2
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import ffmpeg
|
||||
|
||||
@@ -17,15 +17,15 @@ import logging
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from typing import Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from video_processing.oss_helpers import download_asset, upload_to_oss
|
||||
from video_processing.unified_render_service import RenderResult, UnifiedRenderService
|
||||
from video_processing.unified_render_service import UnifiedRenderService
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import SQLAlchemyEditPlanClipRepository
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_repository import SQLAlchemyEditPlanRepository
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan import EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -10,12 +10,10 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from celery import Task
|
||||
from celery.utils.log import get_task_logger
|
||||
from worker_app.celery_app import celery_app
|
||||
|
||||
@@ -9,7 +8,6 @@ from packages.adapters.sqlalchemy_impl.classification_job_repository import (
|
||||
SQLAlchemyClassificationJobRepository,
|
||||
)
|
||||
from packages.domain import (
|
||||
ClassificationJob,
|
||||
ClassificationJobStatus,
|
||||
ClassificationStatus,
|
||||
)
|
||||
|
||||
@@ -5,12 +5,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
@@ -21,7 +21,6 @@ import logging
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from worker_app.celery_app import celery_app
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
@@ -13,15 +13,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
from worker_app.celery_app import celery_app
|
||||
from worker_app.db import SessionLocal
|
||||
@@ -174,7 +172,6 @@ def _build_plan_and_clips_from_task(
|
||||
path_duration[p] = probe_duration(p)
|
||||
|
||||
clips: list[_VirtualClip] = []
|
||||
n = len(downloaded_paths)
|
||||
|
||||
if mode == "pip":
|
||||
# 1 main + N-1 overlay
|
||||
@@ -912,7 +909,6 @@ def generate_video(self, task_id: str) -> dict:
|
||||
render_result = render_service.render()
|
||||
render_output_path = render_result.output_path
|
||||
render_duration = render_result.duration
|
||||
render_file_size = render_result.file_size
|
||||
render_elapsed = time.monotonic() - render_start
|
||||
logger.info(
|
||||
"[task_id=%s] [渲染] unified 引擎完成: 耗时=%.1fs",
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from celery import Celery
|
||||
from celery.app.task import Task
|
||||
from celery.utils.log import get_task_logger
|
||||
from worker_app.celery_app import celery_app
|
||||
from worker_app.core.asset_types import infer_mime_type_from_storage_key
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
"""Voice extraction tasks - extract voice tracks and background music from videos."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
from celery import Task
|
||||
from sqlalchemy.orm import Session
|
||||
from worker_app.celery_app import celery_app
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
|
||||
+72
-11
@@ -30,21 +30,67 @@ class SharedStorageService:
|
||||
self.local_url_prefix = os.getenv("GENERATED_FILES_URL_PREFIX", "/generated-files")
|
||||
self.bucket = None
|
||||
|
||||
if settings.oss_access_key_id and settings.oss_access_key_secret:
|
||||
has_key_id = bool(settings.oss_access_key_id)
|
||||
has_key_secret = bool(settings.oss_access_key_secret)
|
||||
|
||||
if has_key_id and has_key_secret:
|
||||
if oss2 is not None:
|
||||
auth = oss2.Auth(
|
||||
settings.oss_access_key_id,
|
||||
settings.oss_access_key_secret,
|
||||
)
|
||||
self.bucket = oss2.Bucket(
|
||||
auth,
|
||||
settings.oss_endpoint,
|
||||
settings.oss_bucket_name,
|
||||
)
|
||||
try:
|
||||
# P0-2 修复:oss2.Bucket 的 endpoint 必须带 https:// 前缀,
|
||||
# 否则 sign_url 默认生成 HTTP URL。
|
||||
bucket_endpoint = settings.oss_endpoint
|
||||
if not bucket_endpoint.startswith(("http://", "https://")):
|
||||
bucket_endpoint = f"https://{bucket_endpoint}"
|
||||
auth = oss2.Auth(
|
||||
settings.oss_access_key_id,
|
||||
settings.oss_access_key_secret,
|
||||
)
|
||||
self.bucket = oss2.Bucket(
|
||||
auth,
|
||||
bucket_endpoint,
|
||||
settings.oss_bucket_name,
|
||||
)
|
||||
logger.info(
|
||||
"OSS initialized: endpoint=%s bucket=%s",
|
||||
settings.oss_endpoint,
|
||||
settings.oss_bucket_name,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Failed to initialize OSS bucket client: %s", error)
|
||||
else:
|
||||
logger.error("oss2 SDK is not installed — OSS operations will fail")
|
||||
else:
|
||||
missing = []
|
||||
if not has_key_id:
|
||||
missing.append("OSS_ACCESS_KEY_ID")
|
||||
if not has_key_secret:
|
||||
missing.append("OSS_ACCESS_KEY_SECRET")
|
||||
logger.error("OSS credentials not configured — missing: %s", ", ".join(missing))
|
||||
|
||||
self.access_key_id = settings.oss_access_key_id
|
||||
self.access_key_secret = settings.oss_access_key_secret
|
||||
self.endpoint = settings.oss_endpoint
|
||||
|
||||
def diagnose(self) -> None:
|
||||
"""启动诊断:输出 OSS 配置状态,帮助排查预签名 URL 问题。"""
|
||||
key_id_display = (
|
||||
f"{self.access_key_id[:4]}...{self.access_key_id[-4:]}" if len(self.access_key_id) > 8 else "(empty)"
|
||||
)
|
||||
logger.info(
|
||||
"[OSS诊断] endpoint=%s bucket_name=%s access_key_id=%s",
|
||||
self.endpoint,
|
||||
self.bucket_name,
|
||||
key_id_display,
|
||||
)
|
||||
if self.bucket is None:
|
||||
logger.error(
|
||||
"[OSS诊断] ❌ bucket=None — 预签名URL不可用!"
|
||||
"原因: OSS_ACCESS_KEY_ID/OSS_ACCESS_KEY_SECRET 未配置或 oss2 未安装。"
|
||||
"请检查服务器 .env 文件(如 /var/lib/xiaoxia-saas-staging/.env)"
|
||||
)
|
||||
else:
|
||||
logger.info("[OSS诊断] ✅ bucket 已配置,预签名URL可用")
|
||||
|
||||
def _is_local_generated_url(self, storage_key_or_url: str) -> bool:
|
||||
parsed = urlparse(storage_key_or_url)
|
||||
path = parsed.path if parsed.scheme else storage_key_or_url
|
||||
@@ -90,12 +136,26 @@ class SharedStorageService:
|
||||
if self.bucket is None:
|
||||
if self._is_local_generated_url(storage_key_or_url):
|
||||
return storage_key_or_url
|
||||
logger.warning(
|
||||
"get_download_url: OSS bucket not configured, returning raw URL. storage_key_or_url=%s",
|
||||
storage_key_or_url[:200],
|
||||
)
|
||||
return self.get_url(self._normalize_storage_key(storage_key_or_url))
|
||||
|
||||
storage_key = self._normalize_storage_key(storage_key_or_url)
|
||||
try:
|
||||
return self.bucket.sign_url("GET", storage_key, expires_seconds)
|
||||
signed = self.bucket.sign_url("GET", storage_key, expires_seconds)
|
||||
logger.info(
|
||||
"get_download_url: signed URL generated. storage_key=%s url_prefix=%s",
|
||||
storage_key[:80],
|
||||
signed[:60],
|
||||
)
|
||||
return signed
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"get_download_url: sign_url failed, falling back to raw URL. storage_key=%s",
|
||||
storage_key[:200],
|
||||
)
|
||||
return self.get_url(storage_key)
|
||||
|
||||
def _normalize_storage_key(self, storage_key_or_url: str) -> str:
|
||||
@@ -175,6 +235,7 @@ def get_shared_storage_service() -> SharedStorageService:
|
||||
global _storage_service
|
||||
if _storage_service is None:
|
||||
_storage_service = SharedStorageService()
|
||||
_storage_service.diagnose()
|
||||
return _storage_service
|
||||
|
||||
|
||||
|
||||
@@ -24,9 +24,6 @@ celery==5.4.0
|
||||
|
||||
# 对象存储
|
||||
oss2==2.18.4
|
||||
cryptography==46.0.5
|
||||
# 覆盖系统预装的旧版pyOpenSSL,与cryptography 46.0.5兼容
|
||||
pyOpenSSL==26.2.0
|
||||
|
||||
# HTTP 客户端
|
||||
httpx==0.27.2
|
||||
|
||||
@@ -6,9 +6,6 @@ pydantic-settings==2.6.0
|
||||
email-validator==2.2.0
|
||||
python-multipart==0.0.32
|
||||
|
||||
# 邮件
|
||||
aiosmtplib==3.0.2
|
||||
|
||||
# 测试
|
||||
pytest==8.3.3
|
||||
pytest-asyncio==0.24.0
|
||||
|
||||
@@ -73,7 +73,6 @@ OSS_ACCESS_KEY_ID=$OSS_ACCESS_KEY_ID
|
||||
OSS_ACCESS_KEY_SECRET=$OSS_ACCESS_KEY_SECRET
|
||||
OSS_BUCKET_NAME=$OSS_BUCKET_NAME
|
||||
|
||||
LOG_LEVEL=INFO
|
||||
CORS_ORIGINS_RAW=https://xiaoxiajianji.com,https://api.xiaoxiajianji.com
|
||||
EOF
|
||||
|
||||
|
||||
+1
-6
@@ -17,18 +17,13 @@ os.environ.setdefault("USE_IN_MEMORY_DB", "True")
|
||||
# CI 环境没有 Redis,所有 Celery 异步任务都 mock 掉,避免连接超时报错
|
||||
# 集成测试只测 API 层逻辑(参数校验、权限、DB 操作),异步任务由 worker 单测覆盖
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def _mock_celery_task():
|
||||
"""全局 mock Celery 任务的 delay/apply_async/send_task 方法。"""
|
||||
from celery import Celery, Task
|
||||
|
||||
# 保存原始方法
|
||||
_orig_delay = Task.delay
|
||||
_orig_apply_async = Task.apply_async
|
||||
_orig_send_task = Celery.send_task
|
||||
|
||||
def _mock_delay(self, *args, **kwargs):
|
||||
mock_result = MagicMock()
|
||||
mock_result.id = "mock-task-id"
|
||||
|
||||
@@ -113,7 +113,6 @@ class PerfAssert:
|
||||
raise ValueError(f"未知的阈值级别: {threshold_level},可选: {list(PERF_THRESHOLDS.keys())}")
|
||||
|
||||
threshold_ms = PERF_THRESHOLDS[threshold_level]
|
||||
num_samples = samples or self.sample_count
|
||||
result = PerfResult(name=name or threshold_level, threshold_ms=threshold_ms)
|
||||
|
||||
# 预热(第一次请求可能有冷启动开销)
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
"""查重 API 路由。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import get_duplication_repository
|
||||
from app.schemas.duplication import (
|
||||
DuplicateSegmentResponse,
|
||||
DuplicationDetailResponse,
|
||||
DuplicationRecordResponse,
|
||||
DuplicationUploadResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
|
||||
|
||||
from packages.application import (
|
||||
DeleteDuplicationRecordUseCase,
|
||||
GetDuplicationDetailUseCase,
|
||||
ListDuplicationRecordsUseCase,
|
||||
RetryDuplicationUseCase,
|
||||
UploadForDuplicationCommand,
|
||||
UploadForDuplicationUseCase,
|
||||
)
|
||||
from packages.domain.duplication import DuplicationRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 查重功能只接受视频文件
|
||||
ALLOWED_VIDEO_MIME_TYPES = frozenset(
|
||||
{
|
||||
"video/mp4",
|
||||
"video/mpeg",
|
||||
"video/quicktime",
|
||||
"video/x-msvideo",
|
||||
"video/webm",
|
||||
"video/x-matroska",
|
||||
"video/3gpp",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_video_mime_type(content_type: str | None) -> str:
|
||||
"""验证视频文件的 MIME 类型,如果无效则抛出异常。"""
|
||||
if not content_type:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Content-Type header is required",
|
||||
)
|
||||
|
||||
# 处理带参数的类型,如 "video/mp4; charset=utf-8"
|
||||
base_type = content_type.split(";")[0].strip().lower()
|
||||
|
||||
if base_type not in ALLOWED_VIDEO_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
detail="只支持视频文件。支持的类型: mp4, mpeg, mov, avi, webm, mkv, 3gp",
|
||||
)
|
||||
|
||||
return base_type
|
||||
|
||||
|
||||
def _to_record_response(record: DuplicationRecord) -> DuplicationRecordResponse:
|
||||
return DuplicationRecordResponse(
|
||||
id=record.id,
|
||||
filename=record.filename,
|
||||
file_size=record.file_size,
|
||||
duration_seconds=record.duration_seconds,
|
||||
status=record.status,
|
||||
duplicate_rate=record.duplicate_rate,
|
||||
duplicate_count=record.duplicate_count,
|
||||
created_at=record.created_at.isoformat(),
|
||||
updated_at=record.updated_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _to_detail_response(record: DuplicationRecord) -> DuplicationDetailResponse:
|
||||
return DuplicationDetailResponse(
|
||||
id=record.id,
|
||||
filename=record.filename,
|
||||
file_size=record.file_size,
|
||||
duration_seconds=record.duration_seconds,
|
||||
status=record.status,
|
||||
duplicate_rate=record.duplicate_rate,
|
||||
duplicate_count=record.duplicate_count,
|
||||
created_at=record.created_at.isoformat(),
|
||||
updated_at=record.updated_at.isoformat(),
|
||||
segments=[
|
||||
DuplicateSegmentResponse(
|
||||
id=seg.id,
|
||||
source_start=seg.source_start,
|
||||
source_end=seg.source_end,
|
||||
matched_video_id=seg.matched_video_id,
|
||||
matched_video_name=seg.matched_video_name,
|
||||
matched_start=seg.matched_start,
|
||||
matched_end=seg.matched_end,
|
||||
similarity=seg.similarity,
|
||||
)
|
||||
for seg in record.segments
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload", response_model=DuplicationUploadResponse)
|
||||
async def upload_for_duplication(
|
||||
file: UploadFile = File(..., description="要查重的视频文件"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
duplication_repository: Any = Depends(get_duplication_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> DuplicationUploadResponse:
|
||||
"""上传视频进行查重。"""
|
||||
if file.filename is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="文件名不能为空",
|
||||
)
|
||||
|
||||
# P0-1: 验证 MIME 类型(只接受视频文件)
|
||||
validated_content_type = _validate_video_mime_type(file.content_type)
|
||||
|
||||
# P0-2: 验证文件大小(参考 OSS_DIRECT_UPLOAD_MAX_MB)
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
max_size_bytes = settings.OSS_DIRECT_UPLOAD_MAX_MB * 1024 * 1024
|
||||
|
||||
# 先检查 Content-Length header(如果可用)
|
||||
if file.size is not None and file.size > max_size_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"文件超过上传限制 ({settings.OSS_DIRECT_UPLOAD_MAX_MB}MB)",
|
||||
)
|
||||
|
||||
# 读取文件内容并上传到 OSS
|
||||
file_id = uuid4().hex[:8]
|
||||
safe_filename = file.filename.replace("/", "_").replace("\\", "_")
|
||||
storage_key = f"duplication/{file_id}/{safe_filename}"
|
||||
|
||||
try:
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
# 再次检查实际文件大小
|
||||
if file_size > max_size_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"文件超过上传限制 ({settings.OSS_DIRECT_UPLOAD_MAX_MB}MB)",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("读取查重文件失败: %s", exc, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="文件读取失败,请稍后重试",
|
||||
) from exc
|
||||
|
||||
try:
|
||||
storage_service.upload_file(
|
||||
content,
|
||||
storage_key,
|
||||
content_type=validated_content_type,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("查重文件上传 OSS 失败: %s", exc, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="文件上传失败,请稍后重试",
|
||||
) from exc
|
||||
|
||||
use_case = UploadForDuplicationUseCase(duplication_repository)
|
||||
record = use_case.execute(
|
||||
UploadForDuplicationCommand(
|
||||
user_id=authenticated_user.user.id,
|
||||
filename=file.filename,
|
||||
file_size=file_size,
|
||||
storage_key=storage_key,
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Duplication upload: record=%s file=%s user=%s",
|
||||
record.id,
|
||||
file.filename,
|
||||
authenticated_user.user.id,
|
||||
)
|
||||
|
||||
return DuplicationUploadResponse(
|
||||
id=record.id,
|
||||
status=record.status,
|
||||
message=f'文件 "{file.filename}" 已上传,正在查重中...',
|
||||
)
|
||||
|
||||
|
||||
@router.get("/records", response_model=list[DuplicationRecordResponse])
|
||||
def list_duplication_records(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
duplication_repository: Any = Depends(get_duplication_repository),
|
||||
) -> list[DuplicationRecordResponse]:
|
||||
"""获取当前用户的查重记录列表。"""
|
||||
use_case = ListDuplicationRecordsUseCase(duplication_repository)
|
||||
records = use_case.execute(authenticated_user.user.id)
|
||||
return [_to_record_response(r) for r in records]
|
||||
|
||||
|
||||
@router.get("/records/{record_id}", response_model=DuplicationDetailResponse)
|
||||
def get_duplication_detail(
|
||||
record_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
duplication_repository: Any = Depends(get_duplication_repository),
|
||||
) -> DuplicationDetailResponse:
|
||||
"""获取查重记录详情(含重复片段)。"""
|
||||
use_case = GetDuplicationDetailUseCase(duplication_repository)
|
||||
record = use_case.execute(record_id)
|
||||
if record is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"查重记录 {record_id} 不存在",
|
||||
)
|
||||
if record.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"查重记录 {record_id} 不存在",
|
||||
)
|
||||
return _to_detail_response(record)
|
||||
|
||||
|
||||
@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
|
||||
def delete_duplication_record(
|
||||
record_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
duplication_repository: Any = Depends(get_duplication_repository),
|
||||
) -> Response:
|
||||
"""删除查重记录。"""
|
||||
# 检查记录是否存在且属于当前用户
|
||||
detail_uc = GetDuplicationDetailUseCase(duplication_repository)
|
||||
record = detail_uc.execute(record_id)
|
||||
if record is None or record.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"查重记录 {record_id} 不存在",
|
||||
)
|
||||
|
||||
use_case = DeleteDuplicationRecordUseCase(duplication_repository)
|
||||
use_case.execute(record_id)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/records/{record_id}/retry", response_model=DuplicationUploadResponse)
|
||||
def retry_duplication(
|
||||
record_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
duplication_repository: Any = Depends(get_duplication_repository),
|
||||
) -> DuplicationUploadResponse:
|
||||
"""重新提交查重。"""
|
||||
# 检查记录存在且属于当前用户
|
||||
detail_uc = GetDuplicationDetailUseCase(duplication_repository)
|
||||
record = detail_uc.execute(record_id)
|
||||
if record is None or record.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"查重记录 {record_id} 不存在",
|
||||
)
|
||||
|
||||
use_case = RetryDuplicationUseCase(duplication_repository)
|
||||
updated = use_case.execute(record_id)
|
||||
if updated is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"查重记录 {record_id} 不存在",
|
||||
)
|
||||
|
||||
return DuplicationUploadResponse(
|
||||
id=updated.id,
|
||||
status=updated.status,
|
||||
message="已重新提交查重",
|
||||
)
|
||||
@@ -18,7 +18,6 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -14,7 +14,6 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
@@ -33,7 +32,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "
|
||||
from app.api.routes.duplication import router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import get_db_session, get_duplication_repository
|
||||
from app.dependencies import get_duplication_repository
|
||||
|
||||
from packages.domain.duplication import DuplicateSegment, DuplicationRecord
|
||||
|
||||
|
||||
@@ -32,11 +32,9 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "
|
||||
|
||||
from app.api.routes.duplication import _validate_video_mime_type, router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import get_duplication_repository
|
||||
|
||||
from packages.domain.duplication import DuplicationRecord
|
||||
|
||||
# ── 导入真实模块(不创建 fake module) ────────────────────────────────────────
|
||||
from packages.domain.entities import User
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
@@ -240,7 +239,7 @@ class TestForbidden403:
|
||||
"""测试 403 禁止访问场景。"""
|
||||
|
||||
def test_access_other_user_project(self, auth_headers, other_auth_headers):
|
||||
"""访问他人项目应返回 403/404(权限检查未实现时返回 200 为已知问题)。"""
|
||||
"""访问他人项目应返回 403。"""
|
||||
# 用户 A 创建项目
|
||||
created = client.post(
|
||||
"/api/v1/projects",
|
||||
@@ -255,11 +254,10 @@ class TestForbidden403:
|
||||
f"/api/v1/projects/{project_id}",
|
||||
headers=other_auth_headers,
|
||||
)
|
||||
# TODO: 项目权限检查未实现,当前返回 200;实现后应改为 [403, 404]
|
||||
assert response.status_code in [200, 403, 404], f"访问他人项目状态码异常: {response.status_code}"
|
||||
assert response.status_code == 403, f"访问他人项目应返回 403,实际: {response.status_code}"
|
||||
|
||||
def test_delete_other_user_project(self, auth_headers, other_auth_headers):
|
||||
"""删除他人项目应返回 403/404(权限检查未实现时返回 200/204 为已知问题)。"""
|
||||
"""删除他人项目应返回 403。"""
|
||||
created = client.post(
|
||||
"/api/v1/projects",
|
||||
json={"name": "Do Not Delete"},
|
||||
@@ -272,8 +270,7 @@ class TestForbidden403:
|
||||
f"/api/v1/projects/{project_id}",
|
||||
headers=other_auth_headers,
|
||||
)
|
||||
# TODO: 项目权限检查未实现,当前可能返回 200/204;DELETE 端点未实现时返回 405;实现后应改为 [403, 404]
|
||||
assert response.status_code in [200, 204, 403, 404, 405], f"删除他人项目状态码异常: {response.status_code}"
|
||||
assert response.status_code == 403, f"删除他人项目应返回 403,实际: {response.status_code}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -9,16 +9,12 @@ import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from video_processing.unified_render_service import (
|
||||
RenderResult,
|
||||
UnifiedRenderService,
|
||||
)
|
||||
from worker_app.tasks.generation import (
|
||||
OUTPUT_HEIGHT,
|
||||
OUTPUT_WIDTH,
|
||||
_build_plan_and_clips_from_task,
|
||||
_create_fallback_clip,
|
||||
_mux_audio_track,
|
||||
|
||||
@@ -515,7 +515,6 @@ class TestRetryGenerationTask:
|
||||
task_id = resp.json()["items"][0]["id"]
|
||||
|
||||
# 直接修改 repository 中的任务状态为 failed
|
||||
from app.dependencies import get_generation_task_repository
|
||||
|
||||
# 由于是 stub,我们需要通过另一种方式设置状态
|
||||
# 让我们直接通过 retry 测试来验证
|
||||
|
||||
@@ -3,7 +3,7 @@ from packages.adapters.in_memory import (
|
||||
InMemoryIngestJobRepository,
|
||||
)
|
||||
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||
from packages.domain import Asset, IngestJob, IngestJobStatus
|
||||
from packages.domain import Asset, IngestJobStatus
|
||||
|
||||
|
||||
def simulate_ingest_asset(
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
"""项目管理功能集成测试"""
|
||||
|
||||
import pytest
|
||||
|
||||
# 项目管理功能尚未实现,相关模块不存在,跳过整个文件
|
||||
pytest.skip(
|
||||
"项目管理功能尚未实现(project_management_repositories / "
|
||||
"project_management_use_cases / TaskPriority / TaskStatus 均不存在)",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
from packages.adapters.in_memory.project_management_repositories import (
|
||||
InMemoryMilestoneRepository,
|
||||
InMemoryTaskIssueRepository,
|
||||
InMemoryTaskRepository,
|
||||
)
|
||||
from packages.application.project_management_use_cases import (
|
||||
CreateMilestoneUseCase,
|
||||
CreateTaskIssueUseCase,
|
||||
CreateTaskUseCase,
|
||||
ListProjectTasksUseCase,
|
||||
ListTaskIssuesUseCase,
|
||||
ResolveTaskIssueUseCase,
|
||||
UpdateTaskProgressUseCase,
|
||||
UpdateTaskStatusUseCase,
|
||||
)
|
||||
from packages.domain import TaskPriority, TaskStatus
|
||||
|
||||
|
||||
def test_create_task():
|
||||
"""测试创建任务"""
|
||||
repo = InMemoryTaskRepository()
|
||||
use_case = CreateTaskUseCase(repo)
|
||||
|
||||
task = use_case.execute(
|
||||
project_id="proj_1",
|
||||
name="开发登录功能",
|
||||
description="实现用户登录功能",
|
||||
priority=TaskPriority.HIGH,
|
||||
)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.name == "开发登录功能"
|
||||
assert task.status == TaskStatus.PENDING
|
||||
assert task.priority == TaskPriority.HIGH
|
||||
assert task.progress == 0.0
|
||||
|
||||
|
||||
def test_list_tasks():
|
||||
"""测试获取任务列表"""
|
||||
repo = InMemoryTaskRepository()
|
||||
create_use_case = CreateTaskUseCase(repo)
|
||||
|
||||
# 创建两个任务
|
||||
create_use_case.execute(
|
||||
project_id="proj_1",
|
||||
name="任务1",
|
||||
)
|
||||
create_use_case.execute(
|
||||
project_id="proj_1",
|
||||
name="任务2",
|
||||
)
|
||||
|
||||
# 查询任务列表
|
||||
list_use_case = ListProjectTasksUseCase(repo)
|
||||
tasks = list_use_case.execute("proj_1")
|
||||
|
||||
assert len(tasks) == 2
|
||||
assert tasks[0].name == "任务1"
|
||||
assert tasks[1].name == "任务2"
|
||||
|
||||
|
||||
def test_update_task_status():
|
||||
"""测试更新任务状态"""
|
||||
repo = InMemoryTaskRepository()
|
||||
create_use_case = CreateTaskUseCase(repo)
|
||||
update_use_case = UpdateTaskStatusUseCase(repo)
|
||||
|
||||
# 创建任务
|
||||
task = create_use_case.execute(
|
||||
project_id="proj_1",
|
||||
name="测试任务",
|
||||
)
|
||||
|
||||
# 更新状态为进行中
|
||||
updated_task = update_use_case.execute(task.id, TaskStatus.IN_PROGRESS)
|
||||
|
||||
assert updated_task.status == TaskStatus.IN_PROGRESS
|
||||
assert updated_task.actual_start_date is not None
|
||||
|
||||
|
||||
def test_update_task_progress():
|
||||
"""测试更新任务进度"""
|
||||
repo = InMemoryTaskRepository()
|
||||
create_use_case = CreateTaskUseCase(repo)
|
||||
progress_use_case = UpdateTaskProgressUseCase(repo)
|
||||
|
||||
# 创建任务
|
||||
task = create_use_case.execute(
|
||||
project_id="proj_1",
|
||||
name="测试任务",
|
||||
)
|
||||
|
||||
# 更新进度到 50%
|
||||
updated_task = progress_use_case.execute(task.id, 50.0)
|
||||
|
||||
assert updated_task.progress == 50.0
|
||||
assert updated_task.status == TaskStatus.IN_PROGRESS
|
||||
|
||||
# 更新进度到 100%
|
||||
completed_task = progress_use_case.execute(task.id, 100.0)
|
||||
|
||||
assert completed_task.progress == 100.0
|
||||
assert completed_task.status == TaskStatus.COMPLETED
|
||||
assert completed_task.actual_end_date is not None
|
||||
|
||||
|
||||
def test_create_milestone():
|
||||
"""测试创建里程碑"""
|
||||
repo = InMemoryMilestoneRepository()
|
||||
use_case = CreateMilestoneUseCase(repo)
|
||||
|
||||
milestone = use_case.execute(
|
||||
project_id="proj_1",
|
||||
name="V1.0 发布",
|
||||
description="第一个正式版本",
|
||||
)
|
||||
|
||||
assert milestone.id is not None
|
||||
assert milestone.name == "V1.0 发布"
|
||||
assert milestone.completed is False
|
||||
|
||||
|
||||
def test_create_and_resolve_issue():
|
||||
"""测试创建和解决任务问题"""
|
||||
repo = InMemoryTaskIssueRepository()
|
||||
create_use_case = CreateTaskIssueUseCase(repo)
|
||||
resolve_use_case = ResolveTaskIssueUseCase(repo)
|
||||
list_use_case = ListTaskIssuesUseCase(repo)
|
||||
|
||||
# 创建问题
|
||||
issue = create_use_case.execute(
|
||||
task_id="task_1",
|
||||
project_id="proj_1",
|
||||
title="接口报错",
|
||||
description="调用登录接口返回 500",
|
||||
)
|
||||
|
||||
assert issue.id is not None
|
||||
assert issue.title == "接口报错"
|
||||
assert issue.resolved is False
|
||||
|
||||
# 解决问题
|
||||
resolved_issue = resolve_use_case.execute(issue.id)
|
||||
|
||||
assert resolved_issue.resolved is True
|
||||
assert resolved_issue.resolved_at is not None
|
||||
|
||||
# 查询任务问题列表
|
||||
issues = list_use_case.execute("task_1")
|
||||
assert len(issues) == 1
|
||||
assert issues[0].resolved is True
|
||||
|
||||
|
||||
def test_task_hierarchy():
|
||||
"""测试任务层级关系"""
|
||||
repo = InMemoryTaskRepository()
|
||||
create_use_case = CreateTaskUseCase(repo)
|
||||
|
||||
# 创建父任务
|
||||
parent_task = create_use_case.execute(
|
||||
project_id="proj_1",
|
||||
name="开发用户模块",
|
||||
)
|
||||
|
||||
# 创建子任务
|
||||
child_task_1 = create_use_case.execute(
|
||||
project_id="proj_1",
|
||||
name="登录功能",
|
||||
parent_task_id=parent_task.id,
|
||||
)
|
||||
|
||||
child_task_2 = create_use_case.execute(
|
||||
project_id="proj_1",
|
||||
name="注册功能",
|
||||
parent_task_id=parent_task.id,
|
||||
)
|
||||
|
||||
# 查询子任务
|
||||
children = repo.list_by_parent(parent_task.id)
|
||||
|
||||
assert len(children) == 2
|
||||
assert children[0].parent_task_id == parent_task.id
|
||||
assert children[1].parent_task_id == parent_task.id
|
||||
|
||||
|
||||
def test_get_task_detail():
|
||||
"""测试获取任务详情"""
|
||||
from packages.application.get_task_detail_use_case import GetTaskDetailUseCase
|
||||
|
||||
repo = InMemoryTaskRepository()
|
||||
create_use_case = CreateTaskUseCase(repo)
|
||||
get_use_case = GetTaskDetailUseCase(repo)
|
||||
|
||||
# 创建任务
|
||||
task = create_use_case.execute(
|
||||
project_id="proj_1",
|
||||
name="测试任务",
|
||||
description="这是一个测试任务",
|
||||
)
|
||||
|
||||
# 获取详情
|
||||
retrieved_task = get_use_case.execute(task.id)
|
||||
|
||||
assert retrieved_task.id == task.id
|
||||
assert retrieved_task.name == "测试任务"
|
||||
assert retrieved_task.description == "这是一个测试任务"
|
||||
|
||||
# 测试不存在的任务
|
||||
try:
|
||||
get_use_case.execute("nonexistent_id")
|
||||
assert False, "应该抛出异常"
|
||||
except ValueError as e:
|
||||
assert "not found" in str(e)
|
||||
|
||||
|
||||
def test_update_task():
|
||||
"""测试任务基本信息更新"""
|
||||
from packages.application.update_task_use_case import UpdateTaskUseCase
|
||||
|
||||
repo = InMemoryTaskRepository()
|
||||
create_use_case = CreateTaskUseCase(repo)
|
||||
update_use_case = UpdateTaskUseCase(repo)
|
||||
|
||||
# 创建任务
|
||||
task = create_use_case.execute(
|
||||
project_id="proj_1",
|
||||
name="原始任务",
|
||||
description="原始描述",
|
||||
priority="low",
|
||||
)
|
||||
|
||||
# 更新任务
|
||||
updated_task = update_use_case.execute(
|
||||
task_id=task.id,
|
||||
name="更新后的任务",
|
||||
description="更新后的描述",
|
||||
priority="high",
|
||||
)
|
||||
|
||||
assert updated_task.name == "更新后的任务"
|
||||
assert updated_task.description == "更新后的描述"
|
||||
assert updated_task.priority == "high"
|
||||
|
||||
# 部分更新
|
||||
partial_updated = update_use_case.execute(
|
||||
task_id=task.id,
|
||||
name="又更新了",
|
||||
)
|
||||
|
||||
assert partial_updated.name == "又更新了"
|
||||
assert partial_updated.description == "更新后的描述" # 保持不变
|
||||
assert partial_updated.priority == "high" # 保持不变
|
||||
@@ -16,7 +16,6 @@ from __future__ import annotations
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
@@ -30,12 +29,10 @@ from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_user_repository
|
||||
from app.auth import AuthenticatedUser
|
||||
|
||||
# ── 导入真实模块(不创建 fake module) ────────────────────────────────────────
|
||||
from packages.domain.entities import User
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
# ── 导入被测路由模块(从 fixtures 加载简化版路由) ─────────────────────────────
|
||||
_fixture_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures", "subscription_routes.py")
|
||||
|
||||
@@ -629,7 +629,6 @@ class TestTaskCenterCrossEndpoint:
|
||||
# 2. 重试失败任务
|
||||
retry_resp = tc.post("/tasks/gen-fail-cross/retry")
|
||||
assert retry_resp.status_code == 200
|
||||
new_task_id = retry_resp.json()["source_id"]
|
||||
|
||||
# 3. 再次列出,应有2个任务(旧的failed + 新的pending)
|
||||
list_resp2 = tc.get("/tasks")
|
||||
|
||||
@@ -18,7 +18,6 @@ from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
|
||||
@@ -18,7 +18,6 @@ from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
|
||||
@@ -181,7 +181,7 @@ def compute_audio_diff(
|
||||
timeout=120,
|
||||
)
|
||||
stderr = result.stderr or ""
|
||||
except subprocess.CalledProcessError as e:
|
||||
except subprocess.CalledProcessError:
|
||||
# 如果音频格式不兼容,返回失败
|
||||
return AudioDiffResult(
|
||||
audio_a=str(audio_a),
|
||||
|
||||
@@ -30,7 +30,7 @@ import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -403,9 +403,6 @@ def generate_html_report(summary: dict[str, Any], output_path: Path):
|
||||
scenarios = summary["scenarios"]
|
||||
|
||||
# 按通过/失败分组
|
||||
passed_list = [s for s in scenarios if s["passed"]]
|
||||
failed_list = [s for s in scenarios if not s["passed"]]
|
||||
|
||||
# 构建场景卡片
|
||||
scenario_cards = ""
|
||||
for s in scenarios:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
# 设置必要环境变量(必须在导入 app 模块之前)
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
@@ -9,7 +8,6 @@ os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
import pytest
|
||||
from app.api.routes.asset_diagnosis import _build_diagnosis
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -13,7 +13,6 @@ from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
@@ -86,7 +86,7 @@ def test_find_by_tag_ids(asset_repo, tag_repo):
|
||||
a2.add_tag(tag1.id)
|
||||
asset_repo.update(a2)
|
||||
|
||||
a3 = _create_asset(asset_repo, name="c.mp4")
|
||||
_create_asset(asset_repo, name="c.mp4")
|
||||
# 无标签
|
||||
|
||||
# 按 tag1 筛选 → a1, a2
|
||||
|
||||
@@ -8,8 +8,6 @@ from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestAudioUrlSigner:
|
||||
"""测试音频URL签名函数的行为。"""
|
||||
@@ -79,8 +77,8 @@ class TestAudioUrlSigner:
|
||||
mock_svc = MagicMock(spec=OSSStorageService)
|
||||
mock_svc.get_download_url.return_value = "https://signed/a.mp3?sig=123"
|
||||
|
||||
# 替换全局单例
|
||||
with patch("app.core.storage._storage_service", mock_svc):
|
||||
# 替换全局单例(现在位于 packages.shared.storage)
|
||||
with patch("packages.shared.storage._storage_service", mock_svc):
|
||||
from app.dependencies import get_audio_url_signer
|
||||
|
||||
signer = get_audio_url_signer()
|
||||
|
||||
@@ -10,10 +10,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from app.services.auto_clip_service import AutoClipService, ClipAssignDetail
|
||||
from app.services.auto_clip_service import AutoClipService
|
||||
|
||||
# ── Stub 实体 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ from unittest.mock import MagicMock
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
|
||||
# 确保 app 模块可导入
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
@@ -12,12 +12,9 @@ from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _load_settings_class():
|
||||
"""
|
||||
|
||||
@@ -15,9 +15,8 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
@@ -41,7 +40,7 @@ class TestNormalizePlanConfig:
|
||||
assert result == DEFAULT_EDIT_PLAN_CONFIG.copy()
|
||||
|
||||
def test_empty_dict_returns_full_defaults(self):
|
||||
from packages.domain.config_schemas import DEFAULT_EDIT_PLAN_CONFIG, normalize_plan_config
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
result = normalize_plan_config({})
|
||||
assert result["cover"]["type"] == "ai_frame"
|
||||
@@ -363,7 +362,7 @@ def _create_ai_test_app():
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan import EditPlan
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
@@ -37,13 +37,10 @@ if "worker_app.celery_app" in sys.modules and isinstance(sys.modules["worker_app
|
||||
if "worker_app.db" in sys.modules and isinstance(sys.modules["worker_app.db"], MagicMock):
|
||||
sys.modules["worker_app.db"].SessionLocal = MagicMock()
|
||||
|
||||
# Mock celery.Task base class — 仅在 celery 不可用时注入 mock,避免污染真实包
|
||||
try:
|
||||
import celery as _real_celery # noqa: F401
|
||||
except ImportError:
|
||||
_mock_if_absent("celery", MagicMock())
|
||||
if "celery" in sys.modules and isinstance(sys.modules["celery"], MagicMock):
|
||||
sys.modules["celery"].Task = object
|
||||
# Mock celery.Task base class
|
||||
_mock_if_absent("celery", MagicMock())
|
||||
if "celery" in sys.modules and isinstance(sys.modules["celery"], MagicMock):
|
||||
sys.modules["celery"].Task = object
|
||||
|
||||
# Mock packages.shared.storage
|
||||
_mock_if_absent("packages.shared")
|
||||
|
||||
@@ -22,7 +22,7 @@ from packages.application.duplication import (
|
||||
UploadForDuplicationCommand,
|
||||
UploadForDuplicationUseCase,
|
||||
)
|
||||
from packages.domain.duplication import DuplicateSegment, DuplicationRecord
|
||||
from packages.domain.duplication import DuplicationRecord
|
||||
|
||||
|
||||
def _make_record(status="pending", **kwargs):
|
||||
|
||||
@@ -12,7 +12,6 @@ from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from types import ModuleType
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -176,7 +175,6 @@ class TestRenderEditPlanFailureUpdatesGenTask:
|
||||
gen_task = StubGenerationTask(id="gen-task-001", status=_StubStatus("running"))
|
||||
|
||||
plan_repo = StubPlanRepo(plan)
|
||||
clip_repo = StubClipRepo([])
|
||||
gen_task_repo = StubGenTaskRepo(gen_task)
|
||||
|
||||
# 让 clip_repo 抛异常以触发 except 路径
|
||||
|
||||
@@ -13,7 +13,6 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -16,8 +16,8 @@ import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
from typing import List, Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
@@ -27,7 +27,7 @@ import pytest
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig, TransitionEffect
|
||||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub Repositories
|
||||
@@ -273,7 +273,7 @@ class TestEditTemplateServiceCRUD:
|
||||
|
||||
def test_list_templates_active_only(self):
|
||||
svc = _make_service()
|
||||
t1 = svc.create_template(name="活跃")
|
||||
svc.create_template(name="活跃")
|
||||
t2 = svc.create_template(name="停用")
|
||||
svc.deactivate_template(t2.id)
|
||||
result = svc.list_templates(active_only=True)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
邮件服务测试
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -5,10 +5,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.adapters.redis.feature_flag_store import (
|
||||
FeatureFlagConfig,
|
||||
@@ -337,7 +335,6 @@ class TestRedisStoreDegradation:
|
||||
|
||||
def test_get_returns_default_when_redis_unavailable(self):
|
||||
"""Redis 连接失败时返回默认关闭配置,不抛异常。"""
|
||||
import importlib
|
||||
|
||||
from packages.adapters.redis import feature_flag_store as ff_module
|
||||
|
||||
@@ -358,7 +355,6 @@ class TestRedisStoreDegradation:
|
||||
|
||||
def test_list_all_returns_empty_on_redis_error(self):
|
||||
"""Redis 错误时 list_all 返回空字典。"""
|
||||
import importlib
|
||||
|
||||
from packages.adapters.redis import feature_flag_store as ff_module
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ from unittest.mock import MagicMock
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
|
||||
# 确保 app 模块可导入
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
@@ -11,18 +11,14 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ── Mock worker 模块以避免数据库连接 ──────────────────────────────────────────
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "worker"))
|
||||
@@ -226,8 +222,6 @@ def test_legacy_engine_two_clips_concat_duration():
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from video_processing.ffmpeg_utils import probe_duration
|
||||
|
||||
from apps.worker.worker_app.tasks.generation import _render_with_legacy_engine
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
|
||||
@@ -17,7 +17,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
|
||||
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
@@ -429,7 +429,7 @@ class TestListJobsUseCase:
|
||||
|
||||
def test_list_by_project_with_status_filter(self, repo):
|
||||
create_uc = CreateJobUseCase(repo)
|
||||
j1 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
|
||||
create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
|
||||
j2 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
|
||||
j2.mark_running()
|
||||
repo.update(j2)
|
||||
@@ -460,7 +460,7 @@ class TestListJobsUseCase:
|
||||
class TestGetJobStatisticsUseCase:
|
||||
def test_statistics(self, repo):
|
||||
create_uc = CreateJobUseCase(repo)
|
||||
j1 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
|
||||
create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
|
||||
j2 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
|
||||
j2.mark_running()
|
||||
repo.update(j2)
|
||||
|
||||
@@ -15,8 +15,6 @@ import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ── oss_bucket connect_timeout 测试 ───────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -75,43 +75,43 @@ class TestOSSCredentialValidation:
|
||||
|
||||
|
||||
class TestOSSDiagnose:
|
||||
"""测试 OSSStorageService.diagnose() 方法。"""
|
||||
"""测试 SharedStorageService.diagnose() 方法。"""
|
||||
|
||||
@patch("apps.api.app.core.storage.oss2", None)
|
||||
@patch("apps.api.app.core.storage.get_settings")
|
||||
@patch("packages.shared.storage.oss2", None)
|
||||
@patch("packages.shared.storage.get_shared_settings")
|
||||
def test_diagnose_logs_error_when_bucket_none(self, mock_settings, caplog):
|
||||
"""bucket=None 时 diagnose 应输出 ERROR 日志。"""
|
||||
from apps.api.app.core.storage import OSSStorageService
|
||||
from packages.shared.storage import SharedStorageService
|
||||
|
||||
mock_settings.return_value.OSS_BUCKET_NAME = "test-bucket"
|
||||
mock_settings.return_value.OSS_ENDPOINT = "oss-cn-test.com"
|
||||
mock_settings.return_value.OSS_ACCESS_KEY_ID = ""
|
||||
mock_settings.return_value.OSS_ACCESS_KEY_SECRET = ""
|
||||
mock_settings.return_value.oss_bucket_name = "test-bucket"
|
||||
mock_settings.return_value.oss_endpoint = "oss-cn-test.com"
|
||||
mock_settings.return_value.oss_access_key_id = ""
|
||||
mock_settings.return_value.oss_access_key_secret = ""
|
||||
|
||||
service = OSSStorageService()
|
||||
service = SharedStorageService()
|
||||
assert service.bucket is None
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="apps.api.app.core.storage"):
|
||||
with caplog.at_level(logging.ERROR, logger="packages.shared.storage"):
|
||||
service.diagnose()
|
||||
|
||||
assert any("❌" in record.message for record in caplog.records)
|
||||
|
||||
@patch("apps.api.app.core.storage.oss2")
|
||||
@patch("apps.api.app.core.storage.get_settings")
|
||||
@patch("packages.shared.storage.oss2")
|
||||
@patch("packages.shared.storage.get_shared_settings")
|
||||
def test_diagnose_logs_success_when_bucket_configured(self, mock_settings, mock_oss2, caplog):
|
||||
"""bucket 已配置时 diagnose 应输出成功日志。"""
|
||||
from apps.api.app.core.storage import OSSStorageService
|
||||
from packages.shared.storage import SharedStorageService
|
||||
|
||||
mock_settings.return_value.OSS_BUCKET_NAME = "test-bucket"
|
||||
mock_settings.return_value.OSS_ENDPOINT = "oss-cn-test.com"
|
||||
mock_settings.return_value.OSS_ACCESS_KEY_ID = "test-key-id"
|
||||
mock_settings.return_value.OSS_ACCESS_KEY_SECRET = "test-key-secret"
|
||||
mock_settings.return_value.oss_bucket_name = "test-bucket"
|
||||
mock_settings.return_value.oss_endpoint = "oss-cn-test.com"
|
||||
mock_settings.return_value.oss_access_key_id = "test-key-id"
|
||||
mock_settings.return_value.oss_access_key_secret = "test-key-secret"
|
||||
mock_oss2.Bucket.return_value = MagicMock()
|
||||
|
||||
service = OSSStorageService()
|
||||
service = SharedStorageService()
|
||||
assert service.bucket is not None
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="apps.api.app.core.storage"):
|
||||
with caplog.at_level(logging.INFO, logger="packages.shared.storage"):
|
||||
service.diagnose()
|
||||
|
||||
assert any("OSS诊断" in record.message for record in caplog.records)
|
||||
@@ -120,38 +120,38 @@ class TestOSSDiagnose:
|
||||
class TestOSSHTTPSEndpoint:
|
||||
"""测试 P0-2 真正根因:sign_url 必须返回 HTTPS URL。"""
|
||||
|
||||
@patch("apps.api.app.core.storage.oss2")
|
||||
@patch("apps.api.app.core.storage.get_settings")
|
||||
@patch("packages.shared.storage.oss2")
|
||||
@patch("packages.shared.storage.get_shared_settings")
|
||||
def test_endpoint_without_scheme_gets_https_prefix(self, mock_settings, mock_oss2):
|
||||
"""endpoint 无 scheme 时应自动加 https://,确保 sign_url 生成 HTTPS URL。"""
|
||||
from apps.api.app.core.storage import OSSStorageService
|
||||
from packages.shared.storage import SharedStorageService
|
||||
|
||||
mock_settings.return_value.OSS_BUCKET_NAME = "test-bucket"
|
||||
mock_settings.return_value.OSS_ENDPOINT = "oss-cn-hangzhou.aliyuncs.com"
|
||||
mock_settings.return_value.OSS_ACCESS_KEY_ID = "test-key-id"
|
||||
mock_settings.return_value.OSS_ACCESS_KEY_SECRET = "test-key-secret"
|
||||
mock_settings.return_value.oss_bucket_name = "test-bucket"
|
||||
mock_settings.return_value.oss_endpoint = "oss-cn-hangzhou.aliyuncs.com"
|
||||
mock_settings.return_value.oss_access_key_id = "test-key-id"
|
||||
mock_settings.return_value.oss_access_key_secret = "test-key-secret"
|
||||
mock_oss2.Bucket.return_value = MagicMock()
|
||||
|
||||
OSSStorageService()
|
||||
SharedStorageService()
|
||||
|
||||
# 验证传给 oss2.Bucket 的 endpoint 带了 https://
|
||||
call_args = mock_oss2.Bucket.call_args
|
||||
endpoint_passed = call_args[0][1] # 第二个位置参数
|
||||
assert endpoint_passed == "https://oss-cn-hangzhou.aliyuncs.com"
|
||||
|
||||
@patch("apps.api.app.core.storage.oss2")
|
||||
@patch("apps.api.app.core.storage.get_settings")
|
||||
@patch("packages.shared.storage.oss2")
|
||||
@patch("packages.shared.storage.get_shared_settings")
|
||||
def test_endpoint_with_existing_https_not_doubled(self, mock_settings, mock_oss2):
|
||||
"""endpoint 已有 https:// 时不应重复添加。"""
|
||||
from apps.api.app.core.storage import OSSStorageService
|
||||
from packages.shared.storage import SharedStorageService
|
||||
|
||||
mock_settings.return_value.OSS_BUCKET_NAME = "test-bucket"
|
||||
mock_settings.return_value.OSS_ENDPOINT = "https://oss-cn-hangzhou.aliyuncs.com"
|
||||
mock_settings.return_value.OSS_ACCESS_KEY_ID = "test-key-id"
|
||||
mock_settings.return_value.OSS_ACCESS_KEY_SECRET = "test-key-secret"
|
||||
mock_settings.return_value.oss_bucket_name = "test-bucket"
|
||||
mock_settings.return_value.oss_endpoint = "https://oss-cn-hangzhou.aliyuncs.com"
|
||||
mock_settings.return_value.oss_access_key_id = "test-key-id"
|
||||
mock_settings.return_value.oss_access_key_secret = "test-key-secret"
|
||||
mock_oss2.Bucket.return_value = MagicMock()
|
||||
|
||||
OSSStorageService()
|
||||
SharedStorageService()
|
||||
|
||||
call_args = mock_oss2.Bucket.call_args
|
||||
endpoint_passed = call_args[0][1]
|
||||
|
||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from video_processing.ffmpeg_utils import build_xfade_filter_chain
|
||||
|
||||
# ── P0-3: build_xfade_filter_chain 安全钳制 ──────────────────────────────────
|
||||
|
||||
@@ -11,8 +11,6 @@ from __future__ import annotations
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ── oss_bucket endpoint scheme 修复 ──────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -34,11 +32,10 @@ class TestOSSBucketEndpointScheme:
|
||||
"OSS_BUCKET_NAME": "test-bucket",
|
||||
},
|
||||
),
|
||||
patch("video_processing.oss_helpers.oss2.Auth") as mock_auth,
|
||||
patch("video_processing.oss_helpers.oss2.Auth"),
|
||||
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket_instance) as mock_bucket_cls,
|
||||
):
|
||||
# 清除缓存,确保重新创建
|
||||
import video_processing.oss_helpers as oss_mod
|
||||
|
||||
bucket = oss_bucket()
|
||||
|
||||
@@ -67,9 +64,8 @@ class TestOSSBucketEndpointScheme:
|
||||
patch("video_processing.oss_helpers.oss2.Auth"),
|
||||
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket_instance) as mock_bucket_cls,
|
||||
):
|
||||
import video_processing.oss_helpers as oss_mod
|
||||
|
||||
bucket = oss_bucket()
|
||||
oss_bucket()
|
||||
|
||||
call_args = mock_bucket_cls.call_args
|
||||
endpoint_arg = call_args[0][1]
|
||||
@@ -95,9 +91,8 @@ class TestOSSBucketEndpointScheme:
|
||||
patch("video_processing.oss_helpers.oss2.Auth"),
|
||||
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket_instance) as mock_bucket_cls,
|
||||
):
|
||||
import video_processing.oss_helpers as oss_mod
|
||||
|
||||
bucket = oss_bucket()
|
||||
oss_bucket()
|
||||
|
||||
call_args = mock_bucket_cls.call_args
|
||||
endpoint_arg = call_args[0][1]
|
||||
@@ -117,7 +112,6 @@ class TestOSSBucketEndpointScheme:
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
import video_processing.oss_helpers as oss_mod
|
||||
|
||||
bucket = oss_bucket()
|
||||
assert bucket is None
|
||||
@@ -177,7 +171,7 @@ class TestGetSignedDownloadUrl:
|
||||
patch("video_processing.oss_helpers.oss2.Auth"),
|
||||
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket),
|
||||
):
|
||||
result = get_signed_download_url("https://test-bucket.oss-cn-hangzhou.aliyuncs.com/generated/test.mp4")
|
||||
get_signed_download_url("https://test-bucket.oss-cn-hangzhou.aliyuncs.com/generated/test.mp4")
|
||||
|
||||
mock_bucket.sign_url.assert_called_once()
|
||||
# 验证传给 sign_url 的是纯 storage key,不是完整 URL
|
||||
|
||||
@@ -300,7 +300,7 @@ from sqlalchemy.orm import sessionmaker
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import (
|
||||
SQLAlchemyEditPlanClipRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.models import Base, EditPlanClipModel, TemplateClipConfigModel
|
||||
from packages.adapters.sqlalchemy_impl.models import Base
|
||||
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
|
||||
SQLAlchemyTemplateClipConfigRepository,
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
import logging
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
||||
SQLAlchemyEditPlanRepository,
|
||||
|
||||
@@ -16,13 +16,12 @@ import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
from typing import List, Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
|
||||
@@ -6,12 +6,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from video_processing.render_adapter import RenderAdapter, RenderAdapterResult
|
||||
from video_processing.render_adapter import RenderAdapter
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -318,7 +316,7 @@ class TestRenderPlan:
|
||||
def progress_cb(progress: float, stage: str) -> None:
|
||||
progress_values.append((progress, stage))
|
||||
|
||||
result = adapter.render_plan(
|
||||
adapter.render_plan(
|
||||
"plan_001",
|
||||
work_dir=tmp_path / "work",
|
||||
progress_cb=progress_cb,
|
||||
|
||||
@@ -3,8 +3,7 @@ Redis Session 存储测试
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, Mock
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import SQLAlchemyGenerationTaskRepository
|
||||
from packages.adapters.sqlalchemy_impl.tts_job_repository import SQLAlchemyTTSJobRepository
|
||||
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository
|
||||
from packages.domain.generation_task import GenerationTaskStatus
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Template Use Cases 单元测试 — 剪辑计划模板 CRUD + 业务规则校验
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, Mock
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.cosyvoice_service import CosyVoiceService
|
||||
from packages.application.tts_job.workflow import TTSWorkflowService
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -287,7 +287,6 @@ class TestSaveToLibraryUseCase:
|
||||
repo = MagicMock()
|
||||
repo.count_by_user.return_value = 0 # 配额未满
|
||||
|
||||
expected_item = _make_voice_library_item()
|
||||
repo.create.side_effect = lambda item: item
|
||||
|
||||
use_case = CreateVoiceLibraryUseCase(repo)
|
||||
|
||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -9,14 +9,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
||||
from packages.application.tts_job.streaming_service import (
|
||||
TTSStreamingError,
|
||||
TTSStreamingService,
|
||||
)
|
||||
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from video_processing.unified_render_service import (
|
||||
RenderLayer,
|
||||
RenderResult,
|
||||
ResolvedClip,
|
||||
UnifiedRenderService,
|
||||
@@ -683,7 +682,7 @@ class TestAssSubtitles:
|
||||
def test_generate_ass_with_subtitle_only(self, tmp_path):
|
||||
"""只有字幕时生成 ASS 文件。"""
|
||||
ass_path = tmp_path / "test.ass"
|
||||
result = generate_ass_subtitles(
|
||||
generate_ass_subtitles(
|
||||
ass_path,
|
||||
video_width=1280,
|
||||
video_height=720,
|
||||
|
||||
@@ -20,7 +20,6 @@ from unittest.mock import MagicMock
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
|
||||
# 确保 app 模块可导入
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
@@ -13,7 +13,6 @@ import uuid
|
||||
import pytest
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
Base,
|
||||
ClassificationJobModel,
|
||||
EditPlanClipModel,
|
||||
EditPlanModel,
|
||||
|
||||
@@ -14,15 +14,14 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from app.services.video_compose_service import (
|
||||
ComposeCommand,
|
||||
ComposeValidation,
|
||||
VideoComposeService,
|
||||
_build_concat_filter,
|
||||
_build_xfade_filter,
|
||||
_chain_filters,
|
||||
)
|
||||
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
from packages.domain.edit_plan import EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClipStatus
|
||||
|
||||
# ── Stub 实体 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
4. 边界条件与异常场景
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, call
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
Reference in New Issue
Block a user