Merge branch 'main' into online
This commit is contained in:
+813
@@ -0,0 +1,813 @@
|
||||
# VideoGen 代码架构指南
|
||||
|
||||
> 本文档面向开发者或 AI 模型,帮助快速理解代码风格、架构约定和修改规则。
|
||||
# AI 工作规则(最高优先级)
|
||||
|
||||
当你执行开发任务时:
|
||||
|
||||
1. 不要扫描整个 Repository。
|
||||
2. 不要执行全量目录探索。
|
||||
3. 仅阅读本任务涉及的目录。
|
||||
4. 优先按照本文档中的"快速定位表"寻找文件。
|
||||
5. 如果无法定位文件,再进行有限范围搜索。
|
||||
6. 单次最多搜索一级目录。
|
||||
7. 不允许重复搜索已经访问过的目录。
|
||||
8. 阅读完目标文件后立即开始修改代码。
|
||||
---
|
||||
|
||||
## 一、整体架构
|
||||
|
||||
```
|
||||
video_item/
|
||||
├── video-gen-api/ # 后端 (Python FastAPI)
|
||||
├── video-gen-app/ # 前台/用户端 (React + Vite) ──┤
|
||||
└── video-gen-admin/ # 后台管理端 (React + Vite) ──┘ 两者共用同一个后端 API
|
||||
```
|
||||
|
||||
**核心原则:** 三个项目完全分离,前台和后台是两个独立的 SPA,部署在不同的子域名上,但调用同一套后端接口。
|
||||
|
||||
---
|
||||
|
||||
## 二、后端架构 (video-gen-api)
|
||||
|
||||
### 2.1 目录结构
|
||||
|
||||
```
|
||||
app/
|
||||
├── api/ # API 路由层(薄层,仅做参数解析和调用 service)
|
||||
│ ├── v1/ # 前台 + 部分管理接口(FastAPI 意义上的 v1 版本)
|
||||
│ │ ├── __init__.py # 聚合所有 v1 router
|
||||
│ │ ├── auth.py
|
||||
│ │ ├── projects.py
|
||||
│ │ ├── generation.py
|
||||
│ │ ├── admin.py # prefix="/admin"(管理接口混入 v1)
|
||||
│ │ └── ...
|
||||
│ └── admin/ # 额外的管理端细分路由
|
||||
│ ├── __init__.py
|
||||
│ ├── team.py
|
||||
│ ├── home_material.py
|
||||
│ └── ...
|
||||
├── schemas/ # Pydantic 模型(请求入参 + 响应序列化)
|
||||
│ ├── common.py # 公共基类:NaiveDatetime, PaginatedResponse
|
||||
│ ├── generation.py
|
||||
│ ├── auth.py
|
||||
│ └── ...
|
||||
├── models/ # SQLAlchemy ORM 模型(41 个)
|
||||
│ ├── base.py # Base, TimestampMixin, SoftDeleteMixin
|
||||
│ ├── user.py
|
||||
│ └── ...
|
||||
├── services/ # 业务逻辑层(核心业务写在这里)
|
||||
│ ├── auth.py # JWT 认证、密码哈希
|
||||
│ ├── credits.py # 积分扣减
|
||||
│ ├── operation_log.py # 操作日志
|
||||
│ └── ...
|
||||
├── enums/ # 枚举定义(全部用 str, Enum)
|
||||
│ ├── generation_status.py
|
||||
│ ├── celery_queue.py
|
||||
│ └── ...
|
||||
├── tasks/ # Celery 异步任务
|
||||
│ ├── celery_app.py # Celery 实例 + 配置
|
||||
│ ├── cleanup.py
|
||||
│ └── ...
|
||||
├── middleware/ # FastAPI 中间件
|
||||
│ ├── logging.py
|
||||
│ ├── rate_limit.py
|
||||
│ ├── request_encrypt.py
|
||||
│ ├── anti_crawler.py
|
||||
│ └── ...
|
||||
├── utils/ # 通用工具
|
||||
│ ├── id_gen.py # generate_id(), generate_order_no()
|
||||
│ ├── exceptions.py # 自定义 HTTPException
|
||||
│ └── security.py # AES 加密、HMAC
|
||||
├── dependencies.py # FastAPI 依赖注入(get_db, get_current_user 等)
|
||||
├── config.py # Pydantic Settings,读取 .env
|
||||
└── main.py # FastAPI 应用入口、lifespan、种子数据
|
||||
```
|
||||
|
||||
### 2.2 分层约定(重要)
|
||||
|
||||
请求处理严格遵循 **路由 → 服务 → 模型** 三层:
|
||||
|
||||
```
|
||||
Route (api/) → Service (services/) → Model (models/)
|
||||
解析参数 业务逻辑 数据库操作
|
||||
调用 service 编排调用 纯 CRUD
|
||||
返回序列化结果 不直接操作 ORM 无业务规则
|
||||
```
|
||||
|
||||
**禁止:**
|
||||
- 路由中直接写复杂 SQL/ORM 操作 → 应抽到 service
|
||||
- Model 文件中写业务逻辑 → model 只定义字段和关系
|
||||
- Service 中混入 HTTP 相关代码 → 不 import FastAPI 对象
|
||||
|
||||
### 2.3 SQLAlchemy 模型约定
|
||||
|
||||
使用 SQLAlchemy 2.0 风格的 `mapped_column`:
|
||||
|
||||
```python
|
||||
# app/models/project.py — 标准模板
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.models.base import Base, TimestampMixin, SoftDeleteMixin
|
||||
|
||||
class Project(Base, TimestampMixin, SoftDeleteMixin):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
industry: Mapped[str] = mapped_column(String(32))
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- 主键统一用 `String(32)`,由 `generate_id()` 生成(13 位时间戳 hex + 6 位随机 hex)
|
||||
- 继承 `TimestampMixin` 自动获得 `created_at` / `updated_at`
|
||||
- 需要软删除的继承 `SoftDeleteMixin` 获得 `deleted_at`
|
||||
- 外键统一加 `index=True`,`ondelete="CASCADE"` 按需
|
||||
- 所有 `str` 类型指定长度:`String(32)` / `String(128)` / `String(255)` 等
|
||||
- 时间统一用 `DateTime(timezone=True)`
|
||||
- **新增 model 后**必须在 `app/models/__init__.py` 中 import
|
||||
|
||||
```python
|
||||
# app/models/base.py — 公共 Mixin
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
class SoftDeleteMixin:
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
```
|
||||
|
||||
### 2.4 Pydantic Schema 约定
|
||||
|
||||
```python
|
||||
# app/schemas/project.py
|
||||
from pydantic import BaseModel, Field
|
||||
from app.schemas.common import NaiveDatetime
|
||||
|
||||
class ProjectCreate(BaseModel): # 请求入参(客户端 → 服务端)
|
||||
name: str = Field(..., max_length=128)
|
||||
industry: str = Field(..., max_length=64)
|
||||
|
||||
class ProjectOut(BaseModel): # 响应序列化(服务端 → 客户端)
|
||||
id: str
|
||||
name: str
|
||||
industry: str
|
||||
created_at: NaiveDatetime
|
||||
updated_at: NaiveDatetime
|
||||
|
||||
model_config = {"from_attributes": True} # ← 必加!允许从 ORM 对象直接构造
|
||||
```
|
||||
|
||||
**命名约定:**
|
||||
- `XxxCreate` — POST 创建请求
|
||||
- `XxxUpdate` — PUT 更新请求
|
||||
- `XxxOut` / `XxxResponse` — 响应
|
||||
- `XxxListOut` — 列表响应(含 items + total)
|
||||
- `XxxRequest` — 通用请求
|
||||
|
||||
**公共工具:**
|
||||
- `NaiveDatetime` / `NaiveDatetimeOptional` — 自动将带时区的 datetime 转为北京时间 naive datetime
|
||||
- `PaginatedResponse` — 分页响应基类
|
||||
|
||||
### 2.5 API 路由约定
|
||||
|
||||
```python
|
||||
# app/api/v1/projects.py — 标准模板
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.project import Project
|
||||
from app.schemas.project import ProjectCreate, ProjectOut
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"]) # prefix 不含 /api
|
||||
|
||||
@router.get("", response_model=list[ProjectOut]) # GET 列表用 list[XxxOut]
|
||||
async def list_projects(
|
||||
current_user: User = Depends(get_current_user), # 鉴权依赖
|
||||
db: AsyncSession = Depends(get_db), # 数据库 session
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.user_id == current_user.id,
|
||||
Project.deleted_at.is_(None), # 软删除过滤
|
||||
).order_by(Project.created_at.desc())
|
||||
)
|
||||
return result.scalars().all() # 直接返回 ORM 对象,Pydantic 自动序列化
|
||||
|
||||
@router.post("", response_model=ProjectOut)
|
||||
async def create_project(
|
||||
req: ProjectCreate, # 请求体自动校验
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
project = Project(
|
||||
id=generate_id(), # 主键手工生成
|
||||
user_id=current_user.id,
|
||||
name=req.name,
|
||||
industry=req.industry,
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush() # flush 获取自增字段但不出事务
|
||||
return project
|
||||
```
|
||||
|
||||
**关键约定:**
|
||||
- `prefix` 不含 `/api`(`/api` 在 `main.py` 的 `include_router` 统一加)
|
||||
- 受保护接口用 `Depends(get_current_user)` 获取当前用户
|
||||
- 管理员接口用 `Depends(get_admin_user)`
|
||||
- 可选登录用 `Depends(get_optional_current_user)`
|
||||
- 查询过滤软删除: `.where(Model.deleted_at.is_(None))`
|
||||
- 返回 ORM 对象时 Schema 必须有 `model_config = {"from_attributes": True}`
|
||||
- 错误抛 `HTTPException` 或用自定义异常(见 `app/utils/exceptions.py`)
|
||||
|
||||
### 2.6 依赖注入约定
|
||||
|
||||
```python
|
||||
# app/dependencies.py
|
||||
get_db # 生成 AsyncSession,自动 commit/rollback
|
||||
get_current_user # 验证 JWT → 返回 User,要求密码已设置
|
||||
get_current_user_allow_password_pending # 验证 JWT → 返回 User(允许未设置密码)
|
||||
get_optional_current_user # 验证 JWT → User | None(不强制登录)
|
||||
get_admin_user # 验证 JWT + is_admin + user_type=="admin"
|
||||
get_backend_user # 验证 JWT + user_type=="admin"(不要求 is_admin)
|
||||
```
|
||||
|
||||
### 2.7 服务层约定
|
||||
|
||||
```python
|
||||
# app/services/team_service.py — 标准模板
|
||||
from __future__ import annotations # 前向引用必需的 import
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.team import Team
|
||||
from app.schemas.team import TeamCreate, TeamUpdate
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
async def create_team(db: AsyncSession, req: TeamCreate) -> Team:
|
||||
team = Team(
|
||||
id=generate_id(),
|
||||
name=req.name,
|
||||
code=req.code,
|
||||
description=req.description,
|
||||
)
|
||||
db.add(team)
|
||||
await db.flush()
|
||||
return team
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- 函数签名统一 `(db: AsyncSession, ...) → Model | list[Model] | dict`
|
||||
- 不处理 HTTP 异常,HTTP 相关处理留给 route 层
|
||||
- 用 `from __future__ import annotations` 支持前向引用
|
||||
- 纯查询类服务可以接收 `db` 作为第一个参数
|
||||
|
||||
### 2.8 枚举约定
|
||||
|
||||
```python
|
||||
# app/enums/generation_status.py
|
||||
from enum import Enum
|
||||
|
||||
class GenerationStatus(str, Enum): # 继承 str 使值可直接序列化
|
||||
prompt_optimized = "prompt_optimized"
|
||||
generating = "generating"
|
||||
completed = "completed"
|
||||
failed = "failed"
|
||||
|
||||
class GenerationType(str, Enum):
|
||||
video = "video"
|
||||
image = "image"
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- 所有枚举继承 `str, Enum`(可序列化为 JSON)
|
||||
- 枚举值用 snake_case
|
||||
- 枚举文件统一放 `app/enums/`
|
||||
- 在 schema 中引用枚举做校验: `gen_type: GenerationType = Field(...)`
|
||||
|
||||
### 2.9 异常处理约定
|
||||
|
||||
```python
|
||||
# app/utils/exceptions.py — 预定义异常
|
||||
InsufficientCreditsError # 402 积分不足
|
||||
CaptchaFailedError # 400 验证码失败
|
||||
RecordNotFoundError # 404 记录不存在
|
||||
ProjectNotFoundError # 404 项目不存在
|
||||
InvalidStatusError # 400 状态不允许操作
|
||||
|
||||
# 在 service 或 route 中抛出
|
||||
raise InsufficientCreditsError()
|
||||
```
|
||||
|
||||
### 2.10 认证约定
|
||||
|
||||
```python
|
||||
# Token 创建
|
||||
create_access_token(user_id, remember_me=False) → str # JWT 令牌
|
||||
|
||||
# Token 解码
|
||||
decode_access_token(token) → str | None # 返回 user_id 或 None
|
||||
|
||||
# 密码
|
||||
hash_password(plain) → str # bcrypt 哈希
|
||||
verify_password(plain, hashed) → bool # bcrypt 校验
|
||||
```
|
||||
|
||||
**鉴权流程:**
|
||||
1. 前端 Authorization header: `Bearer <token>`
|
||||
2. `HTTPBearer` 提取 token → `decode_access_token` 获取 `user_id`
|
||||
3. 查 User 表验证 `is_active`
|
||||
4. 检查 `user_must_set_password`(短信注册用户首次需设置密码)
|
||||
|
||||
### 2.11 数据库 Session 模式
|
||||
|
||||
```python
|
||||
# dependencies.py 中的 get_db — 自动管理事务
|
||||
async def get_db():
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit() # 成功自动提交
|
||||
except Exception:
|
||||
await session.rollback() # 异常自动回滚
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
```
|
||||
|
||||
**注意:** 在 route 中如果只读查询不需要手动 commit(get_db 自动处理)。在 service 中做写入时需要调用者负责 commit,或由 get_db 处理。
|
||||
|
||||
### 2.12 中间件约定
|
||||
|
||||
中间件按**注册顺序**从外到内执行(`main.py`):
|
||||
|
||||
```python
|
||||
application.add_middleware(RequestLoggingMiddleware) # 1. 请求日志
|
||||
application.add_middleware(AntiCrawlerMiddleware) # 2. 反爬虫
|
||||
application.add_middleware(RateLimitMiddleware) # 3. 限流 (Redis)
|
||||
application.add_middleware(RequestEncryptMiddleware) # 4. 加密/解密
|
||||
application.add_middleware(CORSMiddleware, ...) # 5. CORS
|
||||
```
|
||||
|
||||
**加密中间件行为:**
|
||||
- 请求有 `X-Encrypted: true` header → 解密请求体
|
||||
- GET 请求无 body 但**响应仍加密**
|
||||
- 支付回调路径白名单跳过加密 (`/payments/alipay/callback`, `/payments/wechat/callback`)
|
||||
|
||||
### 2.13 Celery 任务约定
|
||||
|
||||
```python
|
||||
# app/tasks/cleanup.py — 标准模板
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
@celery_app.task # 装饰器注册任务
|
||||
def cleanup_expired_video_urls():
|
||||
"""Run hourly. Clear expired video URL tokens."""
|
||||
asyncio.run(_cleanup_urls()) # 同步任务内跑异步代码
|
||||
|
||||
async def _cleanup_urls(): # 实际逻辑写在 async 函数里
|
||||
from app.models.base import async_session # 延迟导入避免循环
|
||||
async with async_session() as db:
|
||||
...
|
||||
await db.commit()
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- 任务装饰器: `@celery_app.task`
|
||||
- 同步入口 → `asyncio.run()` 包异步逻辑
|
||||
- 任务名自动生成: `文件名.函数名`(如 `app.tasks.cleanup.cleanup_expired_video_urls`)
|
||||
- 队列路由在 `celery_app.py` 的 `task_routes` 配置
|
||||
- 必需参数通过 `apply_async(args=[...], queue="xxx", priority=0)` 传递
|
||||
|
||||
### 2.14 配置约定
|
||||
|
||||
```python
|
||||
# app/config.py — 基于 pydantic-settings
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./videogen.db"
|
||||
SECRET_KEY: str = "change-me"
|
||||
# ... 所有配置项有默认值,生产通过 .env 覆盖
|
||||
|
||||
settings = Settings() # 全局单例
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- 所有环境变量在 `config.py` 中有类型注解和默认值
|
||||
- 通过 `settings.XXX` 全局访问
|
||||
- 布尔值用 `bool` 类型(pydantic 自动解析 "true"/"false" 字符串)
|
||||
- 列表值用 `list[str]`(pydantic 自动解析 JSON 数组字符串)
|
||||
|
||||
### 2.15 操作日志约定
|
||||
|
||||
```python
|
||||
from app.services.operation_log import log_operation
|
||||
|
||||
# 在路由中调用(通常在变更操作后)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"创建团队 {team.name}", # action 描述
|
||||
"POST", # HTTP method
|
||||
"/admin/teams", # path
|
||||
detail=None, # 可选详情
|
||||
ip=None, # 可选 IP
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、前端架构 (video-gen-app & video-gen-admin)
|
||||
|
||||
### 3.1 相同点
|
||||
|
||||
两个前端项目遵循完全相同的架构约定:
|
||||
- React 19 + TypeScript + Vite 8 + Ant Design 6 + Tailwind CSS 3
|
||||
- Zustand 状态管理
|
||||
- 统一的 API 调用层(crypto/client/index 三层)
|
||||
- 路由结构 SPA(react-router-dom v7)
|
||||
|
||||
### 3.2 目录结构(以 video-gen-app 为例)
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # API 调用层
|
||||
│ ├── client.ts # 核心:apiRequest<T>(加密/解密、错误处理)
|
||||
│ ├── crypto.ts # AES-GCM 加密/解密(Web Crypto API)
|
||||
│ ├── index.ts # 所有业务 API 函数(按业务域分组)
|
||||
│ └── mock.ts # Mock 数据(VITE_USE_MOCK=true 时使用)
|
||||
├── store/ # Zustand 状态
|
||||
│ ├── useAuthStore.ts # 认证状态(登录/登出/当前用户)
|
||||
│ └── useAppStore.ts # 应用状态
|
||||
├── types/ # TypeScript 类型定义
|
||||
│ └── index.ts # 所有 interface/type
|
||||
├── pages/ # 页面组件(路由对应)
|
||||
│ ├── LoginPage.tsx
|
||||
│ ├── ProjectsPage.tsx
|
||||
│ └── ...
|
||||
├── components/ # 可复用组件
|
||||
│ ├── Layout/
|
||||
│ │ ├── AppLayout.tsx # 主布局(Header + Sider + Content)
|
||||
│ │ └── AppLayout.css
|
||||
│ ├── privatePortrait/
|
||||
│ └── ...
|
||||
├── utils/ # 工具函数
|
||||
│ ├── formatDate.ts
|
||||
│ └── ...
|
||||
├── App.tsx # 路由定义 + 全局配置
|
||||
└── main.tsx # 入口
|
||||
```
|
||||
|
||||
### 3.3 API 调用约定(核心)
|
||||
|
||||
```typescript
|
||||
// ── client.ts — 核心请求函数 ──
|
||||
// 所有请求统一走 apiRequest<T>,自动处理加密和响应解析
|
||||
async function apiRequest<T>(path: string, options: RequestOptions): Promise<T>
|
||||
|
||||
// 调用参数:
|
||||
interface RequestOptions {
|
||||
method?: string // GET/POST/PUT/DELETE,默认 GET
|
||||
body?: unknown // 请求体(自动 JSON 序列化 + 加密)
|
||||
auth?: boolean // 是否带 Authorization header,默认 true
|
||||
encryptBody?: boolean // 是否加密,默认跟随全局开关
|
||||
signal?: AbortSignal // 取消请求
|
||||
skipAuthRedirect?: boolean // 401 时不跳转登录页
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ── index.ts — 业务 API 函数 ──
|
||||
// 命名约定:动词 + 业务名,camelCase
|
||||
export async function login(username: string, password: string): Promise<User> {
|
||||
if (USE_MOCK) return mock.mockLogin({ username, password });
|
||||
const res = await api.post<{ accessToken: string; user: User }>(
|
||||
'/auth/login',
|
||||
{ username, password },
|
||||
false, // auth: false(登录不需要 token)
|
||||
true // encryptBody: true(登录凭证需要加密)
|
||||
);
|
||||
setToken(res.accessToken);
|
||||
return res.user;
|
||||
}
|
||||
|
||||
// GET 列表
|
||||
export async function getProjects(): Promise<Project[]> {
|
||||
if (USE_MOCK) return mock.mockGetProjects();
|
||||
return api.get<Project[]>('/projects');
|
||||
}
|
||||
|
||||
// POST 创建
|
||||
export async function createProject(name: string, industry: Industry): Promise<Project> {
|
||||
if (USE_MOCK) return mock.mockCreateProject(name, industry);
|
||||
return api.post<Project>('/projects', { name, industry });
|
||||
}
|
||||
|
||||
// 带查询参数的 GET
|
||||
export async function getRecordsPage(params): Promise<GenerationRecordPageListOut> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.projectId) query.set('project_id', params.projectId);
|
||||
query.set('page', String(page));
|
||||
return api.get<GenerationRecordPageListOut>(`/generation-records?${query.toString()}`);
|
||||
}
|
||||
```
|
||||
|
||||
**关键约定:**
|
||||
- `api.get/post/put/delete<T>(path, body?, auth?, encryptBody?)` — 返回**已解析的 T 类型数据**(不含响应包裹层)
|
||||
- 后端返回 `{ data: ... }` 的结构,`apiRequest` 会自动解包
|
||||
- 所有 id 字段前端用 `string`(不转 number)
|
||||
- 后端 `snake_case` 字段在 `client.ts` 自动转 `camelCase`(user_id → userId)
|
||||
- 文件上传不走 `apiRequest`,直接用 `fetch` + `FormData`(见 `uploadAudio/uploadImage` 等)
|
||||
- 上传相关 API 直接读 `import.meta.env.VITE_API_BASE` 拼 URL
|
||||
|
||||
### 3.4 状态管理约定(Zustand)
|
||||
|
||||
```typescript
|
||||
// store/useAuthStore.ts — 标准模板
|
||||
import { create } from 'zustand';
|
||||
import type { User } from '../types';
|
||||
import * as api from '../api';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
checkAuth: () => Promise<void>; // 启动时验证 token 有效性
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
loading: true,
|
||||
|
||||
login: async (username, password) => {
|
||||
const user = await api.login(username, password);
|
||||
set({ user });
|
||||
},
|
||||
// ...
|
||||
}));
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- 全局状态用 Zustand `create`,不用 Redux
|
||||
- API 调用写在 store actions 或页面中,**不要**写在 components 里
|
||||
- 状态更新用 `set()`,异步操作加 `async/await`
|
||||
- 组件中消费: `const { user, login } = useAuthStore()`
|
||||
|
||||
### 3.5 页面组件约定
|
||||
|
||||
```typescript
|
||||
// pages/ProjectsPage.tsx — 标准模板
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Empty, Form, Input, message, Modal, Typography } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { getIndustries } from '../api';
|
||||
import type { IndustryConfig } from '../types';
|
||||
|
||||
const ProjectsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadProjects = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getProjects(); // 调 API
|
||||
setProjects(data);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadProjects(); }, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>我的项目</Typography.Title>
|
||||
{/* ... */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectsPage;
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- 页面组件是 `React.FC`,默认导出
|
||||
- 业务数据类型从 `../types` 导入(`import type`)
|
||||
- API 函数从 `../api` 导入
|
||||
- 不用 `axios`,所有请求走 `api/index.ts`
|
||||
- 用 `message.success/error/warning` 显示操作反馈
|
||||
- 用 `useNavigate()` 编程式导航
|
||||
- 列表加载用 `loading` 状态 + `Spin` 组件
|
||||
- 错误统一 `catch (e: any)` + `message.error(e?.message || '默认消息')`
|
||||
|
||||
### 3.6 路由约定
|
||||
|
||||
```typescript
|
||||
// App.tsx — 路由结构
|
||||
// 公开路由
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/join-team" element={<JoinTeamPage />} />
|
||||
|
||||
// 受保护路由(需要登录)
|
||||
<Route path="/" element={<ProtectedRoute><AppLayout /></ProtectedRoute>}>
|
||||
<Route index element={<Navigate to="/projects" replace />} />
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="projects/:projectId/generate" element={<GeneratePage />} />
|
||||
<Route path="*" element={<Navigate to="/projects" replace />} />
|
||||
</Route>
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- 路由定义集中在 `App.tsx`
|
||||
- 登录保护通过 `ProtectedRoute` 包装器实现(检查 `useAuthStore.user`)
|
||||
- 未登录用户访问受保护路由 → 跳 `/login`
|
||||
- 动态路由参数用 `:paramName`
|
||||
- 嵌套路由用 `<Outlet />` 渲染子页面
|
||||
|
||||
### 3.7 TypeScript 类型约定
|
||||
|
||||
```typescript
|
||||
// types/index.ts — 全局类型定义
|
||||
export interface User {
|
||||
id: string; // 所有 ID 都是 string
|
||||
username: string;
|
||||
credits: number;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- 全局共享类型在 `types/index.ts` 定义
|
||||
- 类型/接口名 PascalCase + 业务含义
|
||||
- 所有 ID、外键字段类型为 `string`
|
||||
- 可选字段用 `?:`,不可选但不一定传的用联合类型 `string | null`
|
||||
- 从其他文件导入类型: `import type { ... } from '../types'`(用 `import type` 优化打包)
|
||||
|
||||
### 3.8 加密约定
|
||||
|
||||
```typescript
|
||||
// crypto.ts
|
||||
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY && isCryptoAvailable();
|
||||
|
||||
// 前端加密流程:
|
||||
// 1. apiRequest 中如果 encryptBody=true:
|
||||
// body → JSON.stringify → encrypt() → { data: "<密文>" }
|
||||
// headers['X-Encrypted'] = 'true'
|
||||
// 2. 收到响应如果 encryptBody=true 且 parsed.data 是字符串:
|
||||
// decrypt(parsed.data) → JSON.parse → 返回
|
||||
//
|
||||
// 自动判断: USE_ENCRYPTION 全局开关控制是否启用加密
|
||||
// 加密算法: AES-256-GCM (12 字节 IV + 128 位认证标签)
|
||||
// 密钥来源: VITE_ENCRYPTION_KEY (32 字节 base64)
|
||||
```
|
||||
|
||||
### 3.9 Mock 模式约定
|
||||
|
||||
当 `VITE_USE_MOCK=true` 时,前端走本地假数据,无需后端。
|
||||
|
||||
```typescript
|
||||
// api/index.ts 中每个 API 函数都检查 USE_MOCK
|
||||
const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true';
|
||||
|
||||
export async function getProjects(): Promise<Project[]> {
|
||||
if (USE_MOCK) return mock.mockGetProjects(); // ← 先看 mock
|
||||
return api.get<Project[]>('/projects'); // ← 后走真实请求
|
||||
}
|
||||
```
|
||||
|
||||
**新增 API 函数时**必须同时提供 mock 数据函数(在 `mock.ts` 中)。
|
||||
|
||||
---
|
||||
|
||||
## 四、跨项目约定(前后端协作)
|
||||
|
||||
### 4.1 字段名转换
|
||||
|
||||
| 后端 (Python) | 前端 (TypeScript) | 转换 |
|
||||
|--------------|-------------------|------|
|
||||
| `snake_case` | `camelCase` | client.ts 自动转 |
|
||||
| `created_at` | `createdAt` | 自动 |
|
||||
| `user_id` | `userId` | 自动 |
|
||||
|
||||
**新增 API 时**:
|
||||
- 后端 request schema 字段用 `snake_case`
|
||||
- 前端 types/interface 字段用 `camelCase`
|
||||
- **不需要**手动转,`client.ts` 的 `keysToCamel()` 递归转换所有 key
|
||||
|
||||
### 4.2 API 路径约定
|
||||
|
||||
```
|
||||
/api/auth/login → 登录(公开)
|
||||
/api/projects → 项目管理(需登录)
|
||||
/api/admin/users → 用户管理(需管理员)
|
||||
/admin/teams → 团队管理(需管理员,二级路由)
|
||||
```
|
||||
|
||||
- `/api/` 前缀统一
|
||||
- 管理接口路径含 `/admin/`
|
||||
- 路径用小写 + 短横线(kebab-case)
|
||||
|
||||
### 4.3 分页约定
|
||||
|
||||
后端返回:
|
||||
```python
|
||||
@router.get("", response_model=GenerationRecordPageListOut)
|
||||
# { items: [...], total: 100, page: 1, page_size: 20 }
|
||||
```
|
||||
|
||||
前端类型:
|
||||
```typescript
|
||||
export interface GenerationRecordPageListOut {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
items: GenerationRecord[];
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 错误处理约定
|
||||
|
||||
后端抛 `HTTPException(status_code, detail)` → 前端 `apiRequest` 捕获后抛 `Error` → 页面 `catch` + `message.error(e.message)`。
|
||||
|
||||
常见错误码:
|
||||
- `401` — token 无效/过期 → 自动跳 `/login`
|
||||
- `403` — 权限不足
|
||||
- `404` — 资源不存在
|
||||
- `402` — 积分不足(`InsufficientCreditsError`)
|
||||
- `400` — 参数错误
|
||||
|
||||
---
|
||||
|
||||
## 五、修改检查清单
|
||||
|
||||
### 新增后端接口时 ✅
|
||||
|
||||
- [ ] 在 `app/schemas/` 中定义 Request/Response Schema(含 `from_attributes`)
|
||||
- [ ] 在 `app/services/` 中实现业务逻辑函数
|
||||
- [ ] 在 `app/api/v1/` (或 `app/api/admin/`) 中定义路由(`APIRouter` + `Depends`)
|
||||
- [ ] 确认 `__init__.py` 中注册了新 router
|
||||
- [ ] 复杂操作记录 `log_operation()`
|
||||
- [ ] 写入操作后正确 `flush()` + `commit()`
|
||||
|
||||
### 新增后端 Model 时 ✅
|
||||
|
||||
- [ ] 在 `app/models/` 中新建或编辑 model 文件
|
||||
- [ ] 继承 `Base` + 按需加 `TimestampMixin` / `SoftDeleteMixin`
|
||||
- [ ] 主键用 `generate_id()`,外键加 `index=True`
|
||||
- [ ] 在 `app/models/__init__.py` 中 import
|
||||
- [ ] 执行 `alembic revision --autogenerate -m "描述"` 生成迁移
|
||||
- [ ] 执行 `alembic upgrade head` 应用迁移
|
||||
|
||||
### 新增前端页面/功能时 ✅
|
||||
|
||||
- [ ] 在 `types/index.ts` 中定义/补充类型
|
||||
- [ ] 在 `api/index.ts` 中添加 API 函数(含 mock 分支)
|
||||
- [ ] 在 `pages/` 中新建页面组件(`React.FC` + 默认导出)
|
||||
- [ ] 在 `App.tsx` 中注册路由
|
||||
- [ ] 受保护路由包裹 `<ProtectedRoute>`
|
||||
- [ ] 在 `mock.ts` 中添加对应 mock 数据函数
|
||||
|
||||
---
|
||||
|
||||
## 六、常见反模式(应避免)
|
||||
|
||||
| ❌ 反模式 | ✅ 正确做法 |
|
||||
|----------|-----------|
|
||||
| Route 中写复杂 ORM 查询 | 抽到 `services/` 函数 |
|
||||
| Model 中有业务逻辑 | Model 只定义字段和关系 |
|
||||
| 前端直接用 `fetch` 调后端 API | 走 `api/index.ts` + `apiRequest` |
|
||||
| 新增 Model 不加到 `__init__.py` | 立即 import |
|
||||
| 主键用数据库自增 int | 用 `generate_id()` 生成 string |
|
||||
| Schema 不加 `from_attributes` | 响应 Schema 必须加 |
|
||||
| 前端用 `any` 不定义类型 | 在 `types/index.ts` 定义 |
|
||||
| 密码明文存储 | bcrypt 哈希(通过 `hash_password`) |
|
||||
|
||||
---
|
||||
|
||||
## 七、快速定位表
|
||||
|
||||
| 想修改什么 | 去哪里 |
|
||||
|-----------|--------|
|
||||
| 新增/改表字段 | `app/models/xxx.py` + `alembic revision` |
|
||||
| 新增接口 | `app/api/v1/xxx.py` + `app/schemas/xxx.py` + `app/services/xxx.py` |
|
||||
| 改业务规则 | `app/services/xxx.py` |
|
||||
| 改枚举值 | `app/enums/xxx.py` |
|
||||
| 加新页面 | `src/pages/XxxPage.tsx` + `App.tsx` 路由 + `src/types/index.ts` |
|
||||
| 加新 API 调用 | `src/api/index.ts` + `src/api/mock.ts` |
|
||||
| 改全局状态 | `src/store/useAuthStore.ts` / `useAppStore.ts` |
|
||||
| 改配置项 | `app/config.py` + `.env.example` |
|
||||
| 改中间件行为 | `app/middleware/xxx.py` |
|
||||
| 加异步任务 | `app/tasks/xxx.py` + `celery_app.py` 的 `task_routes` |
|
||||
+245
-70
@@ -5,11 +5,13 @@
|
||||
```
|
||||
video_item/
|
||||
├── video-gen-api/ # 后端 (Python FastAPI + SQLAlchemy + Alembic)
|
||||
├── video-gen-app/ # 前台 (React 19 + Vite 8 + Ant Design 6 + Tailwind)
|
||||
├── video-gen-admin/ # 后台管理 (React 19 + Vite 8 + Ant Design 6)
|
||||
├── video-gen-app/ # 前台/用户端 (React 19 + Vite 8 + Ant Design 6)
|
||||
├── video-gen-admin/ # 后台管理端 (React 19 + Vite 8 + Ant Design 6)
|
||||
└── DEPLOYMENT.md # 本文档
|
||||
```
|
||||
|
||||
**三个前端/后端的关系:** `video-gen-app` (用户前台) 和 `video-gen-admin` (管理后台) 都连接同一个 `video-gen-api` 后端。
|
||||
|
||||
---
|
||||
|
||||
## 一、环境要求
|
||||
@@ -18,14 +20,13 @@ video_item/
|
||||
|------|---------|------|
|
||||
| Python | >= 3.10 | 推荐 3.12 |
|
||||
| Node.js | >= 18 | 推荐 20+ |
|
||||
| PostgreSQL | >= 14 | 推荐 16 |
|
||||
| Redis | >= 6 | 可选,推荐用于限流/验证码/Celery |
|
||||
| FFmpeg | 任意 | 可选,用于视频封面截帧 |
|
||||
| alipay-sdk-python | >=3.7.1160 | 可选,用于支付 |
|
||||
| wechatpayv3 | >=2.0.2 | 可选,用于支付 |
|
||||
| volcengine-python-sdk | >=1.1.0 | 可选,用于视频生成 |
|
||||
| PostgreSQL | >= 14 | 推荐 16;**必须** |
|
||||
| Redis | >= 6 | 推荐用于: 限流/验证码/Celery/任务状态 |
|
||||
| FFmpeg | 任意 | 用于视频封面截帧,留空时从 PATH 自动查找 |
|
||||
| ca-certificates | 任意 | **必须**,HTTPS 请求需要(新服务器/容器常缺) |
|
||||
|
||||
| alipay-sdk-python | >=3.7.1160 | 可选,支付宝支付 |
|
||||
| wechatpayv3 | >=2.0.2 | 可选,微信支付 |
|
||||
| volcengine-python-sdk | >=1.1.0 | 可选,视频生成/短信 |
|
||||
|
||||
---
|
||||
|
||||
@@ -34,7 +35,7 @@ video_item/
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
# ⚠️ 新服务器/容器必须先装 CA 证书,否则 HTTPS 请求(支付宝/火山等)全部失败
|
||||
# ⚠️ 新服务器/容器必须先装 CA 证书,否则 HTTPS(支付宝/火山等)全部失败
|
||||
# CentOS/RHEL
|
||||
sudo yum install -y ca-certificates
|
||||
# Ubuntu/Debian
|
||||
@@ -49,7 +50,10 @@ python -m venv .venv
|
||||
# Linux/Mac
|
||||
source .venv/bin/activate
|
||||
|
||||
# 安装基础依赖 + PostgreSQL 驱动
|
||||
# 安装基础依赖
|
||||
pip install -e .
|
||||
|
||||
# 安装 PostgreSQL 驱动(生产环境必须)
|
||||
pip install -e ".[pg]"
|
||||
|
||||
# 如需 Redis 支持(限流、验证码、Celery)
|
||||
@@ -58,21 +62,19 @@ pip install -e ".[pg,redis]"
|
||||
# 如需 Celery 异步任务(ChatAPI 生成流水线)
|
||||
pip install -e ".[pg,redis,celery]"
|
||||
|
||||
#安装阿里支付sdk
|
||||
# 如需支付宝
|
||||
pip install -e ".[pg,redis,celery,alipay]"
|
||||
|
||||
# 安装微信支付sdk
|
||||
pip install -e ".[pg,redis,celery,alipay,wechatpayv3]"
|
||||
|
||||
#安装火山sdk
|
||||
pip install -e ".[pg,redis,celery,alipay,wechatpayv3,volc]"
|
||||
|
||||
# 如需微信支付
|
||||
pip install -e ".[pg,redis,celery,alipay,wxpay]"
|
||||
|
||||
# 如需火山引擎 SDK(短信等)
|
||||
pip install -e ".[pg,redis,celery,alipay,wxpay,volc]"
|
||||
```
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
复制 `.env.example` 为 `.env`,修改以下关键配置:
|
||||
复制 `.env.example` 为 `.env`,修改关键配置:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
@@ -81,13 +83,14 @@ cp .env.example .env
|
||||
```ini
|
||||
# ── 基础配置 ──
|
||||
APP_NAME=VideoGen API
|
||||
APP_VERSION=1.0.0
|
||||
DEBUG=false
|
||||
SECRET_KEY=改成一个随机的长字符串
|
||||
SECRET_KEY=改成一个随机的长字符串(JWT 签名密钥)
|
||||
|
||||
# ── 数据库 ──
|
||||
# ── 数据库(必须) ──
|
||||
DATABASE_URL=postgresql+asyncpg://用户名:密码@localhost:5432/videogen
|
||||
|
||||
# ── Redis(可选,留空则禁用限流和验证码) ──
|
||||
# ── Redis(可选,留空则禁用限流/验证码/Celery) ──
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# ── JWT ──
|
||||
@@ -101,24 +104,30 @@ SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3
|
||||
SEEDANCE_CALLBACK_URL=https://你的域名/api/generation-records/callback
|
||||
|
||||
# ── LLM 提示词优化 ──
|
||||
LLM_MOCK=false
|
||||
LLM_API_BASE=https://api.openai.com/v1
|
||||
LLM_API_KEY=
|
||||
LLM_MODEL=gpt-4o
|
||||
LLM_MOCK=true # true=使用 mock 响应,不调用真实 LLM
|
||||
|
||||
# ── 前后端通信加密(32字节 base64,留空则禁用) ──
|
||||
ENCRYPTION_KEY=你的32字节base64密钥
|
||||
|
||||
# ── 存储路径 ──
|
||||
# ── 存储路径(本地存储) ──
|
||||
STORAGE_TYPE=local
|
||||
STORAGE_LOCAL_PATH=./storage/generate/videos
|
||||
STORAGE_IMAGE_LOCAL_PATH=./storage/generate/images
|
||||
STORAGE_VIDEO_COVER_LOCAL_PATH=./storage/generate/covers
|
||||
UPLOAD_LOCAL_PATH=./storage/uploads
|
||||
|
||||
# ── 跨域(生产环境务必限制域名) ──
|
||||
# ── 跨域(生产环境务必限制域名,默认 ["*"] 允许所有) ──
|
||||
CORS_ORIGINS=["https://你的前台域名.com", "https://你的后台域名.com"]
|
||||
|
||||
# ── 回调基础地址 ──
|
||||
BASE_URL=https://你的域名
|
||||
|
||||
# ── 验证码 ──
|
||||
CAPTCHA_ENABLED=true
|
||||
|
||||
# ── 短信(火山引擎 SDK,SMS_MOCK=false 时生效) ──
|
||||
SMS_MOCK=true
|
||||
VOLC_SMS_ACCESS_KEY_ID=
|
||||
@@ -129,6 +138,12 @@ VOLC_SMS_SIGN=短信签名
|
||||
|
||||
# ── 支付(PAYMENT_MOCK=false 时生效) ──
|
||||
PAYMENT_MOCK=true
|
||||
WECHAT_MCH_ID=
|
||||
WECHAT_API_KEY=
|
||||
ALIPAY_APP_ID=
|
||||
ALIPAY_PRIVATE_KEY=
|
||||
ALIPAY_PUBLIC_KEY=
|
||||
ALIPAY_NOTIFY_URL=
|
||||
|
||||
# ── Celery(可选,留空则禁用 ChatAPI 异步流水线) ──
|
||||
CELERY_BROKER_URL=redis://localhost:6379/5
|
||||
@@ -138,27 +153,30 @@ CELERY_RESULT_BACKEND=redis://localhost:6379/6
|
||||
FFMPEG_BIN=/usr/bin/ffmpeg
|
||||
```
|
||||
|
||||
> **关于 ENCRYPTION_KEY 的生成:** 需要 32 字节(256 位)的 base64 编码字符串。生成方式:`openssl rand -base64 32`。前后端必须使用**完全相同**的密钥。
|
||||
|
||||
### 3. 初始化数据库
|
||||
|
||||
```bash
|
||||
# 创建 PostgreSQL 数据库
|
||||
psql -U postgres -c "CREATE DATABASE videogen OWNER videogen;"
|
||||
|
||||
# 启动后端(首次启动自动建表 + 填充种子数据)
|
||||
# 启动后端(首次启动自动建表)
|
||||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
首次启动会自动完成:
|
||||
- 创建所有数据表(23 个 model)
|
||||
- 创建管理员账号:`admin` / `123456`
|
||||
- 创建演示用户:`demo` / `123456`(手机号 `13888888888`)
|
||||
- 填充系统配置、视频引擎(Seedance 2.0 / 2.0 fast)、图片引擎(Seedream 5.0)、模型配置、积分比例、菜单配置、充值套餐(4 档)、行业配置
|
||||
首次启动时会**自动创建所有数据表**(41 个 model)。
|
||||
|
||||
**首次部署后务必修改默认密码。**
|
||||
> ⚠️ **关于种子数据:** 代码中包含 `_seed_data()` 函数(创建管理员/演示用户、系统配置、引擎配置等),但当前在 `main.py` 中被**注释掉了**(`# await _seed_data()`)。因此首次启动**不会**自动创建管理员账号。
|
||||
>
|
||||
> **如果你需要种子数据,**有以下选择:
|
||||
> 1. 在 `main.py` 中取消注释 `# await _seed_data()` 后重启
|
||||
> 2. 手动通过 API 或数据库脚本创建管理员账号
|
||||
> 3. 自行编写独立的种子脚本调用 `_seed_data()`
|
||||
|
||||
### 4. 数据库迁移 (Alembic)
|
||||
|
||||
项目使用 Alembic 管理数据库结构变更,`env.py` 已导入全部 20 个 model。
|
||||
项目使用 Alembic 管理数据库结构变更。
|
||||
|
||||
```bash
|
||||
cd video-gen-api
|
||||
@@ -179,17 +197,13 @@ python -m alembic history
|
||||
python -m alembic downgrade -1
|
||||
```
|
||||
|
||||
**部署流程:** 拉取代码后先执行 `alembic upgrade head`,再重启后端服务。
|
||||
|
||||
**新增 model 时:** 需要在 `alembic/env.py` 中添加对应的 import。
|
||||
**部署流程:** 拉取代码后先执行 `alembic upgrade head`,再重启后端服务。
|
||||
|
||||
### 5. 生产运行
|
||||
|
||||
```bash
|
||||
# 方式一:直接运行(推荐 4 workers)
|
||||
# 直接运行(推荐 4 workers)
|
||||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||
|
||||
# 方式二:使用 systemd (Linux)
|
||||
```
|
||||
|
||||
**systemd 服务文件** `/etc/systemd/system/videogen-api.service`:
|
||||
@@ -220,11 +234,22 @@ sudo systemctl start videogen-api
|
||||
|
||||
### 6. Celery Worker(可选)
|
||||
|
||||
ChatAPI 异步生成流水线需要 Celery Worker。需要 Redis 作为 Broker。
|
||||
ChatAPI 异步生成流水线需要 Celery Worker,依赖 Redis 作为 Broker。
|
||||
|
||||
Celery 使用 **6 个队列**,按功能分离:
|
||||
|
||||
| 队列 | 用途 |
|
||||
|------|------|
|
||||
| `gen_chatapi_create` | ChatAPI 生成任务创建(含爆款开头/拆镜复刻的提词步骤) |
|
||||
| `gen_provider_poll` | 轮询火山引擎生成状态 |
|
||||
| `gen_result_download` | 下载生成的视频/图片结果 |
|
||||
| `gen_recovery` | 容灾恢复任务(统一队列,避免占用业务 worker) |
|
||||
| `gen_private_portrait` | 真人素材认证与同步 |
|
||||
| `default` | 默认队列(用户 OAuth、清理任务等) |
|
||||
|
||||
```bash
|
||||
# 启动 Worker(处理 3 个队列:gen_chatapi_create, gen_provider_poll, gen_result_download)
|
||||
celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,default
|
||||
# 启动 Worker(消费所有队列)
|
||||
celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_recovery,gen_private_portrait,default
|
||||
```
|
||||
|
||||
**systemd 服务文件** `/etc/systemd/system/videogen-worker.service`:
|
||||
@@ -239,7 +264,7 @@ Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/opt/video-gen-api
|
||||
Environment=PATH=/opt/video-gen-api/.venv/bin
|
||||
ExecStart=/opt/video-gen-api/.venv/bin/celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,default
|
||||
ExecStart=/opt/video-gen-api/.venv/bin/celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_recovery,gen_private_portrait,default
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
@@ -249,23 +274,25 @@ WantedBy=multi-user.target
|
||||
|
||||
### 7. Docker 部署(可选)
|
||||
|
||||
项目提供 `Dockerfile` 和 `docker-compose.yml`,一键启动完整环境:
|
||||
项目提供 `Dockerfile` 和 `docker-compose.yml`。
|
||||
|
||||
> ⚠️ **注意:** 默认 `Dockerfile` 只安装基础依赖(`pip install .`),生产使用需改为 `".[pg,redis,celery]"`。
|
||||
|
||||
```bash
|
||||
cd video-gen-api
|
||||
|
||||
# 注意:Dockerfile 默认只装基础依赖,需修改为安装 pg+redis+celery
|
||||
# 将 Dockerfile 中的 RUN pip install --no-cache-dir . 改为:
|
||||
# 使用前需修改 Dockerfile 第 6 行为:
|
||||
# RUN pip install --no-cache-dir ".[pg,redis,celery]"
|
||||
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
启动的服务:
|
||||
|
||||
| 服务 | 端口 | 说明 |
|
||||
|------|------|------|
|
||||
| api | 8000 | FastAPI 应用(带 `--reload`,开发模式) |
|
||||
| worker | - | Celery Worker |
|
||||
| api | 8000 | FastAPI 应用(`--reload` 开发模式) |
|
||||
| worker | - | Celery Worker(消费所有队列) |
|
||||
| postgres | 5432 | PostgreSQL 16 |
|
||||
| redis | 6379 | Redis 7 |
|
||||
|
||||
@@ -276,7 +303,7 @@ server {
|
||||
listen 80;
|
||||
server_name api.yourdomain.com;
|
||||
|
||||
# 上传文件大小限制
|
||||
# 上传文件大小限制(视频上传需要较大值)
|
||||
client_max_body_size 100M;
|
||||
|
||||
location / {
|
||||
@@ -300,6 +327,8 @@ server {
|
||||
|
||||
## 三、前台部署 (video-gen-app)
|
||||
|
||||
用户前台,面向最终用户。
|
||||
|
||||
### 1. 安装依赖 & 构建
|
||||
|
||||
```bash
|
||||
@@ -310,8 +339,8 @@ npm install
|
||||
# 配置 API 地址(创建 .env.production)
|
||||
echo "VITE_API_BASE=https://api.yourdomain.com" > .env.production
|
||||
|
||||
# 如需前后端加密通信
|
||||
echo "VITE_ENCRYPTION_KEY=与后端ENCRYPTION_KEY相同" >> .env.production
|
||||
# 如需前后端加密通信(与后端 ENCRYPTION_KEY 相同)
|
||||
echo "VITE_ENCRYPTION_KEY=密钥" >> .env.production
|
||||
|
||||
# 构建
|
||||
npm run build
|
||||
@@ -319,7 +348,15 @@ npm run build
|
||||
|
||||
构建产物在 `dist/` 目录。
|
||||
|
||||
### 2. Nginx 配置
|
||||
### 2. 前端环境变量
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `VITE_API_BASE` | 后端 API 地址 | `http://localhost:8000` |
|
||||
| `VITE_USE_MOCK` | 是否使用 mock 数据(无需后端) | `false` |
|
||||
| `VITE_ENCRYPTION_KEY` | 前后端通信加密密钥(需与后端一致) | 空(不加密) |
|
||||
|
||||
### 3. Nginx 配置
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -328,12 +365,12 @@ server {
|
||||
root /opt/video-gen-app/dist;
|
||||
index index.html;
|
||||
|
||||
# SPA 路由
|
||||
# SPA 路由:所有页面请求回退到 index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 静态资源缓存
|
||||
# 静态资源缓存(带 hash 的文件名可长期缓存)
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
@@ -345,6 +382,8 @@ server {
|
||||
|
||||
## 四、后台管理部署 (video-gen-admin)
|
||||
|
||||
管理员后台,面向运营/管理人员。
|
||||
|
||||
### 1. 安装依赖 & 构建
|
||||
|
||||
```bash
|
||||
@@ -356,13 +395,21 @@ npm install
|
||||
echo "VITE_API_BASE=https://api.yourdomain.com" > .env.production
|
||||
|
||||
# 如需前后端加密通信
|
||||
echo "VITE_ENCRYPTION_KEY=与后端ENCRYPTION_KEY相同" >> .env.production
|
||||
echo "VITE_ENCRYPTION_KEY=密钥" >> .env.production
|
||||
|
||||
# 构建
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 2. Nginx 配置
|
||||
### 2. 后台环境变量
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `VITE_API_BASE` | 后端 API 地址 | `http://localhost:8000` |
|
||||
| `VITE_USE_MOCK` | 是否使用 mock 数据 | `false` |
|
||||
| `VITE_ENCRYPTION_KEY` | 前后端通信加密密钥 | 空(不加密) |
|
||||
|
||||
### 3. Nginx 配置
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -456,6 +503,8 @@ sudo certbot --nginx -d yourdomain.com -d admin.yourdomain.com -d api.yourdomain
|
||||
|
||||
## 七、默认账号
|
||||
|
||||
> ⚠️ 默认账号仅在种子数据被执行后存在(见第三节第 3 点说明)。
|
||||
|
||||
| 角色 | 用户名 | 手机号 | 密码 | 积分 |
|
||||
|------|--------|--------|------|------|
|
||||
| 管理员 | admin | 13800000000 | 123456 | 10000 |
|
||||
@@ -467,32 +516,84 @@ sudo certbot --nginx -d yourdomain.com -d admin.yourdomain.com -d api.yourdomain
|
||||
|
||||
## 八、架构说明
|
||||
|
||||
### 异步任务处理
|
||||
### 后端启动时的后台任务
|
||||
|
||||
系统有两套异步任务机制:
|
||||
后端启动时(`lifespan`)会自动启动以下**进程内**后台任务:
|
||||
|
||||
| 机制 | 说明 | 依赖 |
|
||||
|------|------|------|
|
||||
| 内置 TaskQueue | asyncio 实现,运行在 uvicorn 进程内,轮询视频/图片生成状态 | 无额外依赖 |
|
||||
| Celery Worker | 处理 ChatAPI 异步生成流水线,3 个队列分优先级 | Redis 作为 Broker |
|
||||
| 任务 | 说明 |
|
||||
|------|------|
|
||||
| `task_queue` (video_queue) | 内置 asyncio 任务队列,轮询视频/图片生成状态 |
|
||||
| `upload_queue` | 上传任务队列 |
|
||||
| `material_consumption_queue` | 素材消耗队列 |
|
||||
| `token_refresh_scheduler` | 每 5 分钟检查并刷新即将过期的 token |
|
||||
| `poll_pre_test_results` | 每分钟轮询前测结果 |
|
||||
| `schedule_daily_sync` | 每天 9 点自动同步素材消耗 |
|
||||
| `_order_expiry_loop` | 每分钟同步待支付订单状态 + 自动过期订单 |
|
||||
|
||||
### 中间件栈(从外到内)
|
||||
加上 **Celery Worker**(可选,处理 ChatAPI 异步流水线)。
|
||||
|
||||
### 中间件栈(从外到内,即请求到达的顺序)
|
||||
|
||||
1. `RequestLoggingMiddleware` — 请求/响应日志记录
|
||||
2. `AntiCrawlerMiddleware` — 反爬虫(拦截空 UA 和常见 bot)
|
||||
3. `RateLimitMiddleware` — 滑动窗口限流(Redis 支撑)
|
||||
4. `RequestEncryptMiddleware` — AES-256-GCM 请求/响应加密
|
||||
5. `CORSMiddleware` — 跨域
|
||||
5. `CORSMiddleware` — 跨域(expose `X-Encrypted` 响应头)
|
||||
|
||||
### 日志系统
|
||||
### 前后端通信加密
|
||||
|
||||
| 日志类型 | 目录 | 控制方式 |
|
||||
**机制:** AES-256-GCM 对称加密,前后端共享同一个 `ENCRYPTION_KEY`。使用 Web Crypto API(前端)和 `cryptography` 库(后端)。
|
||||
|
||||
**生效条件:** 同时满足以下两个条件才启用加密:
|
||||
- 后端 `.env` 中 `ENCRYPTION_KEY` 非空
|
||||
- 前端 `.env` 中 `VITE_ENCRYPTION_KEY` 非空
|
||||
- 运行在安全上下文(HTTPS 或 localhost)
|
||||
|
||||
**加密范围:**
|
||||
|
||||
| 请求类型 | 请求体 | 响应体 |
|
||||
|---------|--------|--------|
|
||||
| GET(无 body) | 不加密(无内容) | **加密** |
|
||||
| POST/PUT/DELETE(有 body) | **加密** | **加密** |
|
||||
|
||||
**工作流:**
|
||||
1. 前端发送 POST 请求时,将 JSON 请求体加密为 `{ data: "<密文>" }`,并加 `X-Encrypted: true` 请求头
|
||||
2. 后端中间件检测到 `X-Encrypted: true` 时解密请求体,处理完后加密响应体
|
||||
3. 支付回调接口(`/payments/alipay/callback`, `/payments/wechat/callback`)白名单跳过加密
|
||||
|
||||
> **GET 请求注意:** GET 没有请求体,但响应仍会被加密。前端会自动检测并解密。
|
||||
|
||||
### 文件日志加密(独立机制)
|
||||
|
||||
与通信加密不同,**文件日志存储**使用另一套独立的 AES-CBC-256 加密:
|
||||
|
||||
| 日志类型 | 目录 | 加密方式 |
|
||||
|---------|------|---------|
|
||||
| 请求/响应日志 | `log/RequestResponse/{日期}.log` | 始终开启 |
|
||||
| AI 模型日志 | `log/AiModel/{日期}.log` | `AI_LOG_ENABLED` 环境变量 |
|
||||
| Python 控制台日志 | stderr | `DEBUG=true` 时输出 INFO,否则 WARNING |
|
||||
| 请求/响应日志 | `log/RequestResponse/{日期}.log` | AES-CBC-256,密钥硬编码 |
|
||||
| AI 模型日志 | `log/AiModel/{日期}.log` | AES-CBC-256,密钥硬编码 |
|
||||
|
||||
文件日志使用 AES-CBC 加密存储,解密工具:`/internal/decrypt-data` 端点。
|
||||
日志加密密钥与 `ENCRYPTION_KEY` 无关,是代码中硬编码的值。解密工具:`/internal/decrypt-data` 页面。
|
||||
|
||||
### 内部管理端点
|
||||
|
||||
| 端点 | 用途 |
|
||||
|------|------|
|
||||
| `/internal/` | 后端入口导航页 |
|
||||
| `/internal/health` | 健康检查 (`{"status": "ok"}`) |
|
||||
| `/internal/status` | 服务状态监控页 (Celery/Redis 连接状态) |
|
||||
| `/internal/decrypt-data` | 日志数据解密工具(AES-CBC) |
|
||||
| `/internal/api-docs` | Swagger API 文档 |
|
||||
| `/internal/api-redoc` | ReDoc API 文档 |
|
||||
| `/uploads/` | 用户上传的静态文件(挂载为静态目录) |
|
||||
| `/api/decrypt` | 解密接口(POST,供解密工具调用) |
|
||||
|
||||
### 前端 Mock 模式
|
||||
|
||||
前端支持 Mock 数据模式,无需后端即可开发:
|
||||
|
||||
- 设置 `VITE_USE_MOCK=true` 时,所有 API 调用返回本地假数据
|
||||
- 适用场景:纯前端开发、演示、无后端环境
|
||||
- 注意:mock 模式下仍会调用部分真实 API(如站点信息、验证码)
|
||||
|
||||
### 存储路径
|
||||
|
||||
@@ -522,6 +623,15 @@ sudo certbot --nginx -d yourdomain.com -d admin.yourdomain.com -d api.yourdomain
|
||||
│ ├── .env # 环境变量
|
||||
│ ├── .venv/ # Python 虚拟环境
|
||||
│ ├── app/ # 应用代码
|
||||
│ │ ├── api/ # API 路由
|
||||
│ │ │ ├── v1/ # v1 版本接口(前端用户端 + 部分管理接口)
|
||||
│ │ │ └── admin/ # 管理端接口
|
||||
│ │ ├── middleware/ # 中间件
|
||||
│ │ ├── models/ # 数据模型 (41 个)
|
||||
│ │ ├── services/ # 业务服务
|
||||
│ │ ├── tasks/ # Celery 任务
|
||||
│ │ ├── enums/ # 枚举定义
|
||||
│ │ └── utils/ # 工具函数
|
||||
│ ├── alembic/ # 数据库迁移文件
|
||||
│ ├── storage/
|
||||
│ │ ├── generate/
|
||||
@@ -565,4 +675,69 @@ cd /opt/video-gen-api && python -m alembic upgrade head
|
||||
|
||||
# 生成迁移文件
|
||||
cd /opt/video-gen-api && python -m alembic revision --autogenerate -m "描述"
|
||||
|
||||
# 查看 Celery 活动任务
|
||||
celery -A app.tasks.celery_app inspect active
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十一、环境变量完整参考
|
||||
|
||||
### 后端 (video-gen-api/.env)
|
||||
|
||||
| 变量 | 必填 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `SECRET_KEY` | **是** | `change-me` | JWT 签名密钥 |
|
||||
| `DATABASE_URL` | **是** | sqlite | PostgreSQL 连接串 |
|
||||
| `REDIS_URL` | 否 | 空 | Redis 连接串,留空禁用限流/验证码 |
|
||||
| `JWT_EXPIRE_MINUTES` | 否 | 1440 | Token 有效期(分钟) |
|
||||
| `JWT_EXPIRE_REMEMBER_MINUTES` | 否 | 10080 | 记住登录 Token 有效期 |
|
||||
| `SEEDANCE_API_KEY` | **是** | 空 | 火山引擎 API Key |
|
||||
| `SEEDANCE_API_BASE` | 否 | 火山地址 | API 基础 URL |
|
||||
| `SEEDANCE_CALLBACK_URL` | 否 | 空 | 生成结果回调 URL |
|
||||
| `LLM_API_BASE` | 否 | OpenAI | LLM API 地址 |
|
||||
| `LLM_API_KEY` | 否 | 空 | LLM API Key |
|
||||
| `LLM_MODEL` | 否 | gpt-4o | LLM 模型名称 |
|
||||
| `LLM_MOCK` | 否 | true | 是否 mock LLM 响应 |
|
||||
| `ENCRYPTION_KEY` | 否 | 占位符 | 前后端通信加密密钥 |
|
||||
| `SMS_MOCK` | 否 | true | 是否 mock 短信 |
|
||||
| `PAYMENT_MOCK` | 否 | false | 是否 mock 支付 |
|
||||
| `CORS_ORIGINS` | 否 | `["*"]` | 允许的跨域来源 |
|
||||
| `BASE_URL` | 否 | 测试地址 | 站点基础 URL,用于回调拼接 |
|
||||
| `STORAGE_TYPE` | 否 | local | 存储类型 |
|
||||
| `UPLOAD_LOCAL_PATH` | 否 | `./storage/uploads` | 上传文件存储路径 |
|
||||
| `FFMPEG_BIN` | 否 | 空 | FFmpeg 路径(留空自动查找) |
|
||||
| `CAPTCHA_ENABLED` | 否 | true | 是否启用验证码 |
|
||||
| `CELERY_BROKER_URL` | 否 | 空 | Celery Broker(留空禁用 Celery) |
|
||||
| `CELERY_RESULT_BACKEND` | 否 | 空 | Celery 结果后端 |
|
||||
| `RATE_LIMIT_ENABLED` | 否 | true | 是否启用限流 |
|
||||
|
||||
### 前台 (video-gen-app/.env.production)
|
||||
|
||||
| 变量 | 必填 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `VITE_API_BASE` | **是** | localhost:8000 | 后端 API 地址 |
|
||||
| `VITE_USE_MOCK` | 否 | false | 是否使用 mock 数据 |
|
||||
| `VITE_ENCRYPTION_KEY` | 否 | 空 | 通信加密密钥(与后端一致) |
|
||||
|
||||
### 后台管理 (video-gen-admin/.env.production)
|
||||
|
||||
同前台。
|
||||
|
||||
---
|
||||
|
||||
## 十二、故障排查
|
||||
|
||||
| 现象 | 可能原因 | 解决方案 |
|
||||
|------|---------|---------|
|
||||
| 前端登录后 401 | Token 过期或 SECRET_KEY 不一致 | 检查后端 `SECRET_KEY` 是否变更 |
|
||||
| 上传文件失败 413 | Nginx 上传大小限制 | 增大 `client_max_body_size` |
|
||||
| 加密请求报错"解密失败" | 前后端密钥不一致 | 确保 `VITE_ENCRYPTION_KEY` = 后端 `ENCRYPTION_KEY` |
|
||||
| 非 HTTPS 环境加密无效 | Web Crypto API 需要安全上下文 | 本地开发用 localhost,生产用 HTTPS |
|
||||
| Celery 任务不执行 | Redis 未启动或地址错误 | 检查 `CELERY_BROKER_URL` 和 Redis |
|
||||
| 限流不生效 | Redis 未配置 | 检查 `REDIS_URL` |
|
||||
| 短信发送失败 | 火山配置缺失或 `SMS_MOCK=true` | 填入 `VOLC_SMS_*` 变量并设 `SMS_MOCK=false` |
|
||||
| 视频封面无法生成 | FFmpeg 未安装 | 安装 FFmpeg 或设置 `FFMPEG_BIN` |
|
||||
| HTTPS 请求支付宝/火山失败 | 缺少 CA 证书 | 安装 `ca-certificates` |
|
||||
| 数据库迁移失败 | Model 定义与迁移不一致 | 重新生成迁移文件后执行 |
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+36
-36
@@ -1,37 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>后台管理</title>
|
||||
<script>
|
||||
(function() {
|
||||
var cached = localStorage.getItem('siteInfo');
|
||||
if (cached) {
|
||||
try {
|
||||
var info = JSON.parse(cached);
|
||||
if (info.siteName) {
|
||||
document.title = info.siteName + ' - 管理后台';
|
||||
}
|
||||
if (info.siteLogo) {
|
||||
var link = document.querySelector('link[rel="icon"]');
|
||||
if (link) {
|
||||
link.href = info.siteLogo;
|
||||
link.type = 'image/png';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-B0tEuCkU.js"></script>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>后台管理</title>
|
||||
<script>
|
||||
(function() {
|
||||
var cached = localStorage.getItem('siteInfo');
|
||||
if (cached) {
|
||||
try {
|
||||
var info = JSON.parse(cached);
|
||||
if (info.siteName) {
|
||||
document.title = info.siteName + ' - 管理后台';
|
||||
}
|
||||
if (info.siteLogo) {
|
||||
var link = document.querySelector('link[rel="icon"]');
|
||||
if (link) {
|
||||
link.href = info.siteLogo;
|
||||
link.type = 'image/png';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-B5S0hm2T.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>␍
|
||||
</body>
|
||||
</html>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -191,7 +191,7 @@ const AdminUsers: React.FC = () => {
|
||||
user_type: userType,
|
||||
is_admin: userType === 'admin' ? (values.is_admin ?? false) : false,
|
||||
frontend_user_kind: values.frontend_user_kind || 'external',
|
||||
private_portrait_asset_limit: userType === 'frontend' ? Number(values.private_portrait_asset_limit ?? 5) : 0,
|
||||
private_portrait_asset_limit: userType === 'frontend' ? Number(values.private_portrait_asset_limit ?? 50) : 0,
|
||||
});
|
||||
message.success('用户创建成功');
|
||||
setCreateModal(false);
|
||||
@@ -882,7 +882,7 @@ const AdminUsers: React.FC = () => {
|
||||
<Form.Item
|
||||
name="private_portrait_asset_limit"
|
||||
label="私域人像素材总量上限"
|
||||
initialValue={5}
|
||||
initialValue={50}
|
||||
extra="0 表示关闭私域人像素材库;大于 0 表示开启并限制该用户所有私域人像素材总量。"
|
||||
rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
# 用户私域人像素材默认上限。users.private_portrait_asset_limit = 0 表示关闭模块;>0 表示启用并限制总素材量。
|
||||
# 统计口径:真人 + 虚拟;图片 + 视频。音频当前业务暂不开放。
|
||||
PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT = 5
|
||||
PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT = 50
|
||||
|
||||
# 火山 Ark 私域素材 ProjectName:火山侧项目空间固定使用 default,并快照到各业务表 remote_project_name。
|
||||
# 用户/项目隔离依赖本地 project_id 和火山返回的 Asset Group ID,不再动态拼接 ProjectName。
|
||||
|
||||
@@ -41,7 +41,7 @@ class User(Base, TimestampMixin):
|
||||
|
||||
# 私域人像素材总量限制。0 表示关闭模块;>0 表示启用并限制真人/虚拟、图片/视频素材总量。
|
||||
private_portrait_asset_limit: Mapped[int] = mapped_column(
|
||||
Integer, default=5, server_default="5", nullable=False
|
||||
Integer, default=50, server_default="50", nullable=False
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -58,7 +58,7 @@ class AdminUserOut(BaseModel):
|
||||
last_login_at: NaiveDatetimeOptional = None
|
||||
allowed_menus: list | None = None
|
||||
resource_capacity: ResourceCapacityUsageOut | None = None
|
||||
private_portrait_asset_limit: int = 5
|
||||
private_portrait_asset_limit: int = 50
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -73,7 +73,7 @@ class CreateUserRequest(BaseModel):
|
||||
is_admin: bool = False
|
||||
frontend_user_kind: str = Field(default="external", pattern="^(internal|external)$")
|
||||
allowed_menus: list | None = None
|
||||
private_portrait_asset_limit: int = Field(default=5, ge=0, le=9999, description="私域人像素材总量限制,真人/虚拟、图片/视频共用,0 表示关闭")
|
||||
private_portrait_asset_limit: int = Field(default=50, ge=0, le=9999, description="私域人像素材总量限制,真人/虚拟、图片/视频共用,0 表示关闭")
|
||||
|
||||
|
||||
class UpdateFrontendUserKindRequest(BaseModel):
|
||||
|
||||
@@ -15,7 +15,7 @@ class UserOut(BaseModel):
|
||||
allowed_menus: list | None = None
|
||||
must_set_password: bool = False
|
||||
resource_capacity: ResourceCapacityUsageOut | None = None
|
||||
private_portrait_asset_limit: int = 5
|
||||
private_portrait_asset_limit: int = 50
|
||||
team_id: str | None = None
|
||||
team_name: str | None = None
|
||||
is_team_manager: bool = False
|
||||
|
||||
+104
-104
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-I3eif2op.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-mGpmDG8h.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-JhRVnnL-.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import React from 'react';
|
||||
import { Button, DatePicker, Input, Space } from 'antd';
|
||||
import {
|
||||
CheckOutlined,
|
||||
ClearOutlined,
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
SearchOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
interface UnifiedFilterBarProps {
|
||||
// 搜索
|
||||
searchValue: string;
|
||||
searchPlaceholder?: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSearch: () => void;
|
||||
// 日期
|
||||
showDate?: boolean;
|
||||
dateValue?: Dayjs | null;
|
||||
onDateChange?: (date: Dayjs | null) => void;
|
||||
datePlaceholder?: string;
|
||||
// 批量操作
|
||||
batchCount: number;
|
||||
totalCount: number;
|
||||
onSelectAll: () => void;
|
||||
onClearSelection: () => void;
|
||||
onDelete: () => void;
|
||||
onDownload?: () => void;
|
||||
onPush?: () => void;
|
||||
pushLoading?: boolean;
|
||||
pushText?: string;
|
||||
// 右侧额外操作
|
||||
extraActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const btnBase: React.CSSProperties = {
|
||||
borderRadius: 8,
|
||||
fontWeight: 600,
|
||||
height: 36,
|
||||
};
|
||||
|
||||
const btnSecondary: React.CSSProperties = {
|
||||
...btnBase,
|
||||
background: '#f8f9fc',
|
||||
border: '1px solid #e2e8f0',
|
||||
color: '#334155',
|
||||
};
|
||||
|
||||
const btnDanger: React.CSSProperties = {
|
||||
...btnBase,
|
||||
background: 'linear-gradient(135deg, #ef4444, #dc2626)',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
};
|
||||
|
||||
const btnPrimary: React.CSSProperties = {
|
||||
...btnBase,
|
||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
};
|
||||
|
||||
const UnifiedFilterBar: React.FC<UnifiedFilterBarProps> = ({
|
||||
searchValue,
|
||||
searchPlaceholder = '搜索...',
|
||||
onSearchChange,
|
||||
onSearch,
|
||||
showDate = false,
|
||||
dateValue,
|
||||
onDateChange,
|
||||
datePlaceholder = '选择日期',
|
||||
batchCount,
|
||||
totalCount,
|
||||
onSelectAll,
|
||||
onClearSelection,
|
||||
onDelete,
|
||||
onDownload,
|
||||
onPush,
|
||||
pushLoading = false,
|
||||
pushText = '推送至账户',
|
||||
extraActions,
|
||||
}) => {
|
||||
const allSelected = batchCount === totalCount && totalCount > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
padding: '12px 16px',
|
||||
borderRadius: 12,
|
||||
background: '#fff',
|
||||
border: '1px solid #f0f0f5',
|
||||
}}
|
||||
>
|
||||
{/* 左侧:搜索 + 日期 */}
|
||||
<Space size={8} wrap>
|
||||
<Input.Search
|
||||
placeholder={searchPlaceholder}
|
||||
allowClear
|
||||
value={searchValue}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
onSearchChange(val);
|
||||
if (!val) onSearch();
|
||||
}}
|
||||
onSearch={onSearch}
|
||||
style={{ width: 240, borderRadius: 8 }}
|
||||
enterButton
|
||||
/>
|
||||
{showDate && (
|
||||
<>
|
||||
<DatePicker
|
||||
value={dateValue || undefined}
|
||||
onChange={(date) => onDateChange?.(date)}
|
||||
format="YYYY-MM-DD"
|
||||
placeholder={datePlaceholder}
|
||||
style={{ width: 160, borderRadius: 8 }}
|
||||
allowClear
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{/* 右侧:批量操作 + 额外操作 */}
|
||||
<Space size={8} wrap>
|
||||
{/* 全选 / 取消选择 */}
|
||||
<Button
|
||||
icon={allSelected ? <ClearOutlined /> : <CheckOutlined />}
|
||||
onClick={allSelected ? onClearSelection : onSelectAll}
|
||||
style={btnSecondary}
|
||||
>
|
||||
{allSelected ? '取消全选' : '全选'}
|
||||
</Button>
|
||||
|
||||
{/* 删除 */}
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={onDelete}
|
||||
disabled={batchCount === 0}
|
||||
style={batchCount === 0 ? { ...btnSecondary, opacity: 0.5 } : btnDanger}
|
||||
>
|
||||
删除 {batchCount > 0 && `(${batchCount})`}
|
||||
</Button>
|
||||
|
||||
{/* 下载 */}
|
||||
{onDownload && (
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={onDownload}
|
||||
disabled={batchCount === 0}
|
||||
style={batchCount === 0 ? { ...btnSecondary, opacity: 0.5 } : btnPrimary}
|
||||
>
|
||||
下载 {batchCount > 0 && `(${batchCount})`}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 推送 */}
|
||||
{onPush && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UploadOutlined />}
|
||||
onClick={onPush}
|
||||
loading={pushLoading}
|
||||
disabled={batchCount === 0}
|
||||
style={batchCount === 0 ? { ...btnBase, opacity: 0.5 } : btnPrimary}
|
||||
>
|
||||
{pushLoading ? '推送中...' : `${pushText} ${batchCount > 0 ? `(${batchCount})` : ''}`}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 额外操作按钮 */}
|
||||
{extraActions}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnifiedFilterBar;
|
||||
@@ -30,6 +30,7 @@ interface UploadSelectorProps {
|
||||
usedAudioCount?: number;
|
||||
maxAudioDuration?: number;
|
||||
usedAudioDuration?: number;
|
||||
hideLimitHint?: boolean;
|
||||
}
|
||||
|
||||
const UploadSelector: React.FC<UploadSelectorProps> = ({
|
||||
@@ -52,6 +53,7 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
||||
usedAudioCount,
|
||||
maxAudioDuration,
|
||||
usedAudioDuration,
|
||||
hideLimitHint,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [portraitPickerOpen, setPortraitPickerOpen] = useState(false);
|
||||
@@ -243,6 +245,7 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
||||
usedAudioCount={usedAudioCount}
|
||||
maxAudioDuration={maxAudioDuration}
|
||||
usedAudioDuration={usedAudioDuration}
|
||||
hideLimitHint={hideLimitHint}
|
||||
/>
|
||||
|
||||
<PrivatePortraitAssetPicker
|
||||
|
||||
@@ -120,7 +120,7 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
|
||||
activeKey={activeKey}
|
||||
onChange={handleTabChange}
|
||||
items={items}
|
||||
destroyInactiveTabPane={false}
|
||||
destroyOnHidden={false}
|
||||
tabBarExtraContent={
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}。真人/虚拟共用,图片/视频共用;音频暂不开放。</div>
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ interface Props {
|
||||
const PrivatePortraitProjectList: React.FC<Props> = ({ items, selectedId, onSelect }) => {
|
||||
if (!items.length) return <Empty description="暂无项目组" />;
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={10}>
|
||||
<Space orientation="vertical" style={{ width: '100%' }} size={10}>
|
||||
{items.map((project) => {
|
||||
const active = selectedId === project.id;
|
||||
return (
|
||||
|
||||
@@ -122,7 +122,14 @@ const RealPersonLibraryPanel: React.FC = () => {
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>真人素材库</Typography.Title>
|
||||
<Typography.Text type="secondary">创建项目组时先完成真人认证,认证成功后项目组才会正式创建并可上传素材。</Typography.Text>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>创建项目组</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none' }}
|
||||
>
|
||||
创建项目组
|
||||
</Button>
|
||||
</div>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={5}>
|
||||
@@ -155,7 +162,7 @@ const RealPersonLibraryPanel: React.FC = () => {
|
||||
onOk={validateSession ? undefined : handleCreate}
|
||||
okText="开始认证并创建"
|
||||
confirmLoading={creating}
|
||||
maskClosable={!polling}
|
||||
mask={{ closable: !polling }}
|
||||
>
|
||||
{!validateSession ? (
|
||||
<Form form={form} layout="vertical">
|
||||
|
||||
@@ -338,7 +338,7 @@ const VirtualMaterialPanel: React.FC = () => {
|
||||
<Card
|
||||
key={asset.id}
|
||||
hoverable
|
||||
bodyStyle={{ padding: 12 }}
|
||||
styles={{ body: { padding: 12 } }}
|
||||
style={{ borderRadius: 16, overflow: 'hidden', borderColor: '#eef2f7' }}
|
||||
cover={(
|
||||
<div style={{ height: 170, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
|
||||
@@ -392,7 +392,14 @@ const VirtualMaterialPanel: React.FC = () => {
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>虚拟素材库</Typography.Title>
|
||||
<Typography.Text type="secondary">可提前上传虚拟人像素材,后续生成视频时可直接使用。</Typography.Text>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>创建项目组</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none' }}
|
||||
>
|
||||
创建项目组
|
||||
</Button>
|
||||
</div>
|
||||
<Row gutter={[10, 10]}>
|
||||
<Col xs={24} lg={5} >
|
||||
@@ -446,11 +453,24 @@ const VirtualMaterialPanel: React.FC = () => {
|
||||
title={selectedProject ? selectedProject.name : '素材资产'}
|
||||
extra={(
|
||||
<Space wrap>
|
||||
{/* <Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(assetPage, assetPageSize); }} loading={assetLoading}>刷新</Button> */}
|
||||
<Button type="primary" icon={<UploadOutlined />} disabled={!selectedProjectId} onClick={() => setUploadOpen(true)}>上传图片/视频</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UploadOutlined />}
|
||||
disabled={!selectedProjectId}
|
||||
onClick={() => setUploadOpen(true)}
|
||||
style={{ borderRadius: 8, fontWeight: 600, borderColor: 'transparent' }}
|
||||
>
|
||||
上传图片/视频
|
||||
</Button>
|
||||
{selectedProjectId && (
|
||||
<Popconfirm title="确认删除当前虚拟人像项目组吗?" onConfirm={handleDeleteProject}>
|
||||
<Button danger icon={<DeleteOutlined />}>删除项目组</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
style={{ borderRadius: 8, fontWeight: 600 }}
|
||||
>
|
||||
删除项目组
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
@@ -464,14 +484,15 @@ const VirtualMaterialPanel: React.FC = () => {
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onSearch={() => loadAssets(1, assetPageSize)}
|
||||
style={{ width: 240 }}
|
||||
style={{ width: 240, borderRadius: 8 }}
|
||||
enterButton
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="素材状态"
|
||||
value={assetStatus}
|
||||
onChange={(value) => setAssetStatus(value)}
|
||||
style={{ width: 150 }}
|
||||
style={{ width: 140, borderRadius: 8 }}
|
||||
options={[
|
||||
{ value: 'Processing', label: '处理中' },
|
||||
{ value: 'Active', label: '可用' },
|
||||
@@ -483,13 +504,19 @@ const VirtualMaterialPanel: React.FC = () => {
|
||||
placeholder="素材类型"
|
||||
value={assetType}
|
||||
onChange={(value) => setAssetType(value)}
|
||||
style={{ width: 130 }}
|
||||
style={{ width: 120, borderRadius: 8 }}
|
||||
options={[
|
||||
{ value: 'Image', label: '图片' },
|
||||
{ value: 'Video', label: '视频' },
|
||||
]}
|
||||
/>
|
||||
<Button onClick={() => loadAssets(1, assetPageSize)}>筛选</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => loadAssets(1, assetPageSize)}
|
||||
style={{ borderRadius: 8, fontWeight: 600, borderColor: 'transparent' }}
|
||||
>
|
||||
筛选
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Spin spinning={assetLoading}>
|
||||
@@ -563,7 +590,7 @@ const VirtualMaterialPanel: React.FC = () => {
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
<Modal title="素材预览" open={previewOpen} onCancel={() => setPreviewOpen(false)} footer={null} width={760} destroyOnClose>
|
||||
<Modal title="素材预览" open={previewOpen} onCancel={() => setPreviewOpen(false)} footer={null} width={760} destroyOnHidden>
|
||||
<div style={{ minHeight: 420, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0f172a', borderRadius: 12, overflow: 'hidden' }}>
|
||||
{previewType === 'Video' ? (
|
||||
<video src={previewUrl} controls autoPlay style={{ maxWidth: '100%', maxHeight: 520 }} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Empty, Input, List, Modal, Select, Space, Spin, Tag, Typography, message } from 'antd';
|
||||
import { Button, Empty, Input, Modal, Select, Space, Spin, Tag, Typography, message } from 'antd';
|
||||
import { CheckOutlined, PictureOutlined, ReloadOutlined, SearchOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
getPrivatePortraitProjects,
|
||||
@@ -251,33 +251,36 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={loadProjects} loading={loadingProjects} />
|
||||
</Space>
|
||||
<Spin spinning={loadingProjects}>
|
||||
<List
|
||||
dataSource={projects}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无项目组" /> }}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
onClick={() => setProjectId(item.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 10,
|
||||
marginBottom: 6,
|
||||
border: projectId === item.id ? '1px solid #8b5cf6' : '1px solid transparent',
|
||||
background: projectId === item.id ? '#f5f3ff' : '#fff',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
<Text strong ellipsis style={{ display: 'block' }}>{item.name}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{/* {item.activeAssetCount || 0} */}
|
||||
{typeof item.activeImageAssetCount === 'number' || typeof item.activeVideoAssetCount === 'number'
|
||||
? ` 图 ${item.activeImageAssetCount || 0} / 视 ${item.activeVideoAssetCount || 0}`
|
||||
: ''}
|
||||
</Text>
|
||||
{projects.length === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无项目组" />
|
||||
) : (
|
||||
<div>
|
||||
{projects.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => setProjectId(item.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 10,
|
||||
marginBottom: 6,
|
||||
border: projectId === item.id ? '1px solid #8b5cf6' : '1px solid transparent',
|
||||
background: projectId === item.id ? '#f5f3ff' : '#fff',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
<Text strong ellipsis style={{ display: 'block' }}>{item.name}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{/* {item.activeAssetCount || 0} */}
|
||||
{typeof item.activeImageAssetCount === 'number' || typeof item.activeVideoAssetCount === 'number'
|
||||
? ` 图 ${item.activeImageAssetCount || 0} / 视 ${item.activeVideoAssetCount || 0}`
|
||||
: ''}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -173,45 +173,93 @@ const UploadResourceHistoryPanel: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<Space wrap>
|
||||
<Select
|
||||
value={resourceType}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '图片', value: 'image' },
|
||||
{ label: '视频', value: 'video' },
|
||||
{ label: '音频', value: 'audio' },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setResourceType(value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
{/* 统一筛选栏:媒体类型 + 搜索 + 批量操作 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
padding: '12px 16px',
|
||||
borderRadius: 12,
|
||||
background: '#fff',
|
||||
border: '1px solid #f0f0f5',
|
||||
}}>
|
||||
{/* 左侧:媒体类型按钮 */}
|
||||
<Space size={8} wrap>
|
||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 14, marginRight: 4 }}>媒体类型:</Typography.Text>
|
||||
<Button
|
||||
type={resourceType === '' ? 'primary' : 'default'}
|
||||
onClick={() => { setResourceType(''); setPage(1); }}
|
||||
style={{ borderRadius: 8, fontWeight: 600, borderColor: resourceType === '' ? 'transparent' : '#e2e8f0', color: resourceType === '' ? '#fff' : '#64748b' }}
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
<Button
|
||||
type={resourceType === 'image' ? 'primary' : 'default'}
|
||||
onClick={() => { setResourceType('image'); setPage(1); }}
|
||||
style={{ borderRadius: 8, fontWeight: 600, borderColor: resourceType === 'image' ? 'transparent' : '#e2e8f0', color: resourceType === 'image' ? '#fff' : '#64748b' }}
|
||||
>
|
||||
图片
|
||||
</Button>
|
||||
<Button
|
||||
type={resourceType === 'video' ? 'primary' : 'default'}
|
||||
onClick={() => { setResourceType('video'); setPage(1); }}
|
||||
style={{ borderRadius: 8, fontWeight: 600, borderColor: resourceType === 'video' ? 'transparent' : '#e2e8f0', color: resourceType === 'video' ? '#fff' : '#64748b' }}
|
||||
>
|
||||
视频
|
||||
</Button>
|
||||
<Button
|
||||
type={resourceType === 'audio' ? 'primary' : 'default'}
|
||||
onClick={() => { setResourceType('audio'); setPage(1); }}
|
||||
style={{ borderRadius: 8, fontWeight: 600, borderColor: resourceType === 'audio' ? 'transparent' : '#e2e8f0', color: resourceType === 'audio' ? '#fff' : '#64748b' }}
|
||||
>
|
||||
音频
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{/* 右侧:搜索 + 批量操作 */}
|
||||
<Space size={8} wrap>
|
||||
<Input.Search
|
||||
allowClear
|
||||
value={keyword}
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索文件名或URL"
|
||||
style={{ width: 240 }}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onPressEnter={() => {
|
||||
setPage(1);
|
||||
load();
|
||||
}}
|
||||
onSearch={() => { setPage(1); load(); }}
|
||||
style={{ width: 240, borderRadius: 8 }}
|
||||
enterButton
|
||||
/>
|
||||
{/* <Button icon={<ReloadOutlined />} onClick={load}>刷新</Button> */}
|
||||
</Space>
|
||||
<Space>
|
||||
{batchMode ? (
|
||||
<>
|
||||
<Button onClick={selectAll}>{selectedIds.size && selectedIds.size === allItems.length ? '取消全选' : '全选'}</Button>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={handleBatchDelete}>批量删除 ({selectedIds.size})</Button>
|
||||
<Button onClick={() => { setBatchMode(false); setSelectedIds(new Set()); }}>取消批量操作</Button>
|
||||
<Button
|
||||
onClick={selectAll}
|
||||
style={{ borderRadius: 8, fontWeight: 600, background: '#f8f9fc', border: '1px solid #e2e8f0', color: '#334155' }}
|
||||
>
|
||||
{selectedIds.size && selectedIds.size === allItems.length ? '取消全选' : '全选'}
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
onClick={handleBatchDelete}
|
||||
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #ef4444, #dc2626)', border: 'none', color: '#fff' }}
|
||||
>
|
||||
批量删除 {selectedIds.size > 0 && `(${selectedIds.size})`}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => { setBatchMode(false); setSelectedIds(new Set()); }}
|
||||
style={{ borderRadius: 8, fontWeight: 600, background: '#f8f9fc', border: '1px solid #e2e8f0', color: '#64748b' }}
|
||||
>
|
||||
取消选择
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={() => setBatchMode(true)}>批量操作</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => setBatchMode(true)}
|
||||
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none' }}
|
||||
>
|
||||
批量操作
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
@@ -232,7 +280,7 @@ const UploadResourceHistoryPanel: React.FC = () => {
|
||||
const checked = selectedIds.has(item.id);
|
||||
return (
|
||||
<div key={item.id} style={{ width: '17%', minWidth: 240, margin: 16, border: checked ? '2px solid #8b5cf6' : '1px solid #e2e8f0', borderRadius: 16, overflow: 'hidden', background: '#fff', boxShadow: '0 10px 24px rgba(15,23,42,0.06)' }}>
|
||||
<div style={{ position: 'relative', background: '#f1f5f9' }}>
|
||||
<div style={{ position: 'relative', background: '#f1f5f9', cursor: batchMode ? 'pointer' : 'default' }} onClick={() => batchMode && toggle(item.id)}>
|
||||
{renderMedia(item)}
|
||||
{batchMode && <Checkbox checked={checked} onChange={() => toggle(item.id)} style={{ position: 'absolute', top: 10, left: 10, background: '#fff', borderRadius: 6, padding: 4 }} />}
|
||||
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ position: 'absolute', top: 10, right: 10, margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
|
||||
|
||||
@@ -26,15 +26,72 @@ interface UploadResourceHistoryPickerProps {
|
||||
usedAudioCount?: number;
|
||||
maxAudioDuration?: number;
|
||||
usedAudioDuration?: number;
|
||||
hideLimitHint?: boolean;
|
||||
}
|
||||
|
||||
const buildPreviewUrl = (url: string) => {
|
||||
if (!url) return '';
|
||||
if (/^(https?:|data:|blob:)/i.test(url)) return url;
|
||||
if (/^data:|blob:/i.test(url)) return url;
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.pathname.startsWith('/uploads')) {
|
||||
return parsed.pathname + parsed.search;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
const base = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
|
||||
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
};
|
||||
|
||||
const validateImageDimensions = (width: number, height: number): string | null => {
|
||||
if (width < 300 || width > 6000) return `图片宽度需在 300~6000px 之间,当前为 ${width}px`;
|
||||
if (height < 300 || height > 6000) return `图片高度需在 300~6000px 之间,当前为 ${height}px`;
|
||||
const ratio = width / height;
|
||||
if (ratio < 0.4 || ratio > 2.5) return `图片宽高比需在 0.4~2.5 之间,当前为 ${ratio.toFixed(2)}`;
|
||||
return null;
|
||||
};
|
||||
|
||||
const validateVideoDimensions = (width: number, height: number): string | null => {
|
||||
if (width < 300 || width > 6000) return `视频宽度需在 300~6000px 之间,当前为 ${width}px`;
|
||||
if (height < 300 || height > 6000) return `视频高度需在 300~6000px 之间,当前为 ${height}px`;
|
||||
const ratio = width / height;
|
||||
if (ratio < 0.4 || ratio > 2.5) return `视频宽高比需在 0.4~2.5 之间,当前为 ${ratio.toFixed(2)}`;
|
||||
const totalPixels = width * height;
|
||||
if (totalPixels < 409600) return `视频总像素数过小(${width}×${height}=${totalPixels}),需 ≥ 640×640=409600`;
|
||||
if (totalPixels > 8295044) return `视频总像素数过大(${width}×${height}=${totalPixels}),需 ≤ 3326×2494=8295044`;
|
||||
return null;
|
||||
};
|
||||
|
||||
const getItemValidationError = (item: UploadResourceHistoryItem): string | null => {
|
||||
const itemAny = item as any;
|
||||
const width = itemAny.width || itemAny.videoWidth || itemAny.imageWidth || 0;
|
||||
const height = itemAny.height || itemAny.videoHeight || itemAny.imageHeight || 0;
|
||||
|
||||
if (item.resourceType === 'image') {
|
||||
if (width > 0 && height > 0) {
|
||||
return validateImageDimensions(width, height);
|
||||
}
|
||||
} else if (item.resourceType === 'video') {
|
||||
if (width > 0 && height > 0) {
|
||||
return validateVideoDimensions(width, height);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getImageDimensions = (url: string): Promise<{ width: number; height: number }> => {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve({ width: img.width, height: img.height });
|
||||
};
|
||||
img.onerror = () => {
|
||||
resolve({ width: 0, height: 0 });
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
};
|
||||
|
||||
const typeIcon = (type: string) => {
|
||||
if (type === 'video') return <VideoCameraOutlined />;
|
||||
if (type === 'audio') return <AudioOutlined />;
|
||||
@@ -64,6 +121,7 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
|
||||
usedAudioCount,
|
||||
maxAudioDuration,
|
||||
usedAudioDuration,
|
||||
hideLimitHint,
|
||||
}) => {
|
||||
const [resourceType, setResourceType] = useState<MediaTypeFilter>('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
@@ -175,11 +233,42 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
|
||||
if (open) setCheckedMap(new Map());
|
||||
}, [open]);
|
||||
|
||||
const toggle = (item: UploadResourceHistoryItem) => {
|
||||
const toggle = async (item: UploadResourceHistoryItem) => {
|
||||
if (selectedIdSet.has(item.id)) {
|
||||
message.warning('该素材已经在参考内容中');
|
||||
return;
|
||||
}
|
||||
|
||||
const itemAny = item as any;
|
||||
let width = itemAny.width || itemAny.videoWidth || itemAny.imageWidth || 0;
|
||||
let height = itemAny.height || itemAny.videoHeight || itemAny.imageHeight || 0;
|
||||
|
||||
if (item.resourceType === 'image') {
|
||||
if (width <= 0 || height <= 0) {
|
||||
const preview = buildPreviewUrl(item.previewUrl || item.displayUrl || item.resourceUrl);
|
||||
if (preview) {
|
||||
const dims = await getImageDimensions(preview);
|
||||
width = dims.width;
|
||||
height = dims.height;
|
||||
}
|
||||
}
|
||||
if (width > 0 && height > 0) {
|
||||
const error = validateImageDimensions(width, height);
|
||||
if (error) {
|
||||
message.error(`${item.fileName || '图片'}:${error}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (item.resourceType === 'video') {
|
||||
if (width > 0 && height > 0) {
|
||||
const error = validateVideoDimensions(width, height);
|
||||
if (error) {
|
||||
message.error(`${item.fileName || '视频'}:${error}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setCheckedMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(item.id)) next.delete(item.id);
|
||||
@@ -249,19 +338,21 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadGroups}>刷新</Button>
|
||||
</Space>
|
||||
<div style={{ display: 'flex', gap: 16, marginTop: 12, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, color: imageExceeded ? '#ef4444' : '#64748b', fontWeight: imageExceeded ? 600 : 400 }}>
|
||||
还可选取图片 {selectedImages}/{maxImageCount !== undefined && usedImageCount !== undefined ? maxImageCount - usedImageCount : '-'} 张
|
||||
</span>
|
||||
<span style={{ fontSize: 13, color: videoExceeded || durationExceeded ? '#ef4444' : '#64748b', fontWeight: videoExceeded || durationExceeded ? 600 : 400 }}>
|
||||
视频还可选取 {selectedVideos}/{maxVideoCount !== undefined && usedVideoCount !== undefined ? maxVideoCount - usedVideoCount : '-'} 个({selectedVideoDuration.toFixed(1)}/{maxVideoDuration !== undefined && usedVideoDuration !== undefined ? (maxVideoDuration - usedVideoDuration).toFixed(1) : '-'}秒)
|
||||
</span>
|
||||
{maxAudioCount !== undefined && (
|
||||
<span style={{ fontSize: 13, color: audioExceeded || audioDurationExceeded ? '#ef4444' : '#64748b', fontWeight: audioExceeded || audioDurationExceeded ? 600 : 400 }}>
|
||||
音频还可选取 {selectedAudios}/{maxAudioCount !== undefined && usedAudioCount !== undefined ? maxAudioCount - usedAudioCount : '-'} 个({selectedAudioDuration.toFixed(1)}/{maxAudioDuration !== undefined && usedAudioDuration !== undefined ? (maxAudioDuration - usedAudioDuration).toFixed(1) : '-'}秒)
|
||||
{!hideLimitHint && (
|
||||
<div style={{ display: 'flex', gap: 16, marginTop: 12, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, color: imageExceeded ? '#ef4444' : '#64748b', fontWeight: imageExceeded ? 600 : 400 }}>
|
||||
还可选取图片 {selectedImages}/{maxImageCount !== undefined && usedImageCount !== undefined ? maxImageCount - usedImageCount : '-'} 张
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ fontSize: 13, color: videoExceeded || durationExceeded ? '#ef4444' : '#64748b', fontWeight: videoExceeded || durationExceeded ? 600 : 400 }}>
|
||||
视频还可选取 {selectedVideos}/{maxVideoCount !== undefined && usedVideoCount !== undefined ? maxVideoCount - usedVideoCount : '-'} 个({selectedVideoDuration.toFixed(1)}/{maxVideoDuration !== undefined && usedVideoDuration !== undefined ? (maxVideoDuration - usedVideoDuration).toFixed(1) : '-'}秒)
|
||||
</span>
|
||||
{maxAudioCount !== undefined && (
|
||||
<span style={{ fontSize: 13, color: audioExceeded || audioDurationExceeded ? '#ef4444' : '#64748b', fontWeight: audioExceeded || audioDurationExceeded ? 600 : 400 }}>
|
||||
音频还可选取 {selectedAudios}/{maxAudioCount !== undefined && usedAudioCount !== undefined ? maxAudioCount - usedAudioCount : '-'} 个({selectedAudioDuration.toFixed(1)}/{maxAudioDuration !== undefined && usedAudioDuration !== undefined ? (maxAudioDuration - usedAudioDuration).toFixed(1) : '-'}秒)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 16, height: 500 }}>
|
||||
@@ -310,7 +401,9 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14 }}>
|
||||
{items.map((item) => {
|
||||
const checked = checkedMap.has(item.id);
|
||||
const disabled = selectedIdSet.has(item.id);
|
||||
const selectedDisabled = selectedIdSet.has(item.id);
|
||||
const validationError = getItemValidationError(item);
|
||||
const disabled = selectedDisabled || !!validationError;
|
||||
const preview = buildPreviewUrl(item.previewUrl || item.displayUrl || item.resourceUrl);
|
||||
return (
|
||||
<div
|
||||
@@ -326,6 +419,7 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
|
||||
overflow: 'hidden',
|
||||
boxShadow: checked ? '0 10px 24px rgba(139, 92, 246, 0.18)' : '0 6px 18px rgba(15,23,42,0.06)',
|
||||
}}
|
||||
title={validationError ? validationError : undefined}
|
||||
>
|
||||
<div style={{ background: '#f1f5f9' }}>
|
||||
{item.resourceType === 'image' ? (
|
||||
@@ -340,6 +434,7 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
|
||||
<Space size={6} style={{ marginBottom: 6 }}>
|
||||
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
|
||||
{item.durationSeconds ? <Tag style={{ margin: 0 }}>{Number(item.durationSeconds).toFixed(1)}s</Tag> : null}
|
||||
{validationError && <Tag color="red" style={{ margin: 0 }}>尺寸不符</Tag>}
|
||||
</Space>
|
||||
<Text ellipsis style={{ display: 'block', fontSize: 13, color: '#334155' }} title={item.fileName || item.id}>
|
||||
{item.fileName || item.id}
|
||||
@@ -352,7 +447,12 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
|
||||
<CheckOutlined />
|
||||
</div>
|
||||
)}
|
||||
{disabled && <div style={{ position: 'absolute', top: 8, right: 8 }}><Tag>已选</Tag></div>}
|
||||
{selectedDisabled && <div style={{ position: 'absolute', top: 8, right: 8 }}><Tag>已选</Tag></div>}
|
||||
{validationError && (
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(239, 68, 68, 0.9)', color: '#fff', padding: '4px 8px', fontSize: 11, textAlign: 'center', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{validationError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -7,6 +7,9 @@ import dayjs from 'dayjs';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
// 统一按钮样式
|
||||
const btnBase: React.CSSProperties = { borderRadius: 8, fontWeight: 600 };
|
||||
|
||||
const toCamelCase = (str: string) => str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
|
||||
interface ConsumptionRecord {
|
||||
@@ -164,13 +167,24 @@ const ConsumePage: React.FC = () => {
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>消耗记录</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
padding: '12px 16px',
|
||||
borderRadius: 12,
|
||||
background: '#fff',
|
||||
border: '1px solid #f0f0f5',
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Input
|
||||
placeholder="广告主ID"
|
||||
value={advertiserId}
|
||||
onChange={(e) => setAdvertiserId(e.target.value)}
|
||||
style={{ width: 180 }}
|
||||
style={{ width: 180, borderRadius: 8 }}
|
||||
allowClear
|
||||
onPressEnter={() => { setCurrentPage(1); loadData(1, pageSize); }}
|
||||
/>
|
||||
@@ -183,25 +197,32 @@ const ConsumePage: React.FC = () => {
|
||||
setConsumeDateRange(undefined);
|
||||
}
|
||||
}}
|
||||
style={{ borderRadius: 8 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
size="medium"
|
||||
onClick={handleSearch}
|
||||
style={{ ...btnBase, borderColor: 'transparent' }}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
onClick={handleSync}
|
||||
loading={syncLoading}
|
||||
style={{ borderRadius: 8 }}
|
||||
style={btnBase}
|
||||
>
|
||||
拉取消耗
|
||||
</Button>
|
||||
<Button icon={<SettingOutlined />} onClick={() => setShowModal(true)} style={{ borderRadius: 8 }}>自定义表头</Button>
|
||||
<Button
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => setShowModal(true)}
|
||||
style={{ ...btnBase, background: '#f8f9fc', border: '1px solid #e2e8f0', color: '#334155' }}
|
||||
>
|
||||
自定义表头
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -978,7 +978,7 @@ const AIChatPage: React.FC = () => {
|
||||
}
|
||||
|
||||
|
||||
console.log(mediaReferences);
|
||||
// console.log(mediaReferences);
|
||||
|
||||
// 创建用户消息对象
|
||||
const newMessage: Message = {
|
||||
@@ -1240,7 +1240,7 @@ const AIChatPage: React.FC = () => {
|
||||
|
||||
try {
|
||||
const { width, height } = await getImageDimensions(file);
|
||||
const error = validateVideoDimensions(width, height);
|
||||
const error = validateImageDimensions(width, height);
|
||||
if (error) {
|
||||
antdMessage.error(error);
|
||||
return false;
|
||||
@@ -1316,7 +1316,7 @@ const AIChatPage: React.FC = () => {
|
||||
if (isImage) {
|
||||
try {
|
||||
const { width, height } = await getImageDimensions(file);
|
||||
const error = validateVideoDimensions(width, height);
|
||||
const error = validateImageDimensions(width, height);
|
||||
if (error) {
|
||||
antdMessage.error(error);
|
||||
return false;
|
||||
@@ -1456,7 +1456,7 @@ const AIChatPage: React.FC = () => {
|
||||
if (isImage) {
|
||||
try {
|
||||
const { width, height } = await getImageDimensions(file);
|
||||
const error = validateVideoDimensions(width, height);
|
||||
const error = validateImageDimensions(width, height);
|
||||
if (error) {
|
||||
antdMessage.error(error);
|
||||
return false;
|
||||
@@ -2183,13 +2183,16 @@ const AIChatPage: React.FC = () => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setInputValue(msg.originalPrompt || '');
|
||||
if (msg.genType) {
|
||||
setMediaType(msg.genType);
|
||||
}
|
||||
if (msg.mediaReferences && msg.mediaReferences.length > 0) {
|
||||
const hasFirstLastFrame = msg.mediaReferences.some((ref: any) => ref.role === 'first_frame' || ref.role === 'last_frame');
|
||||
if (hasFirstLastFrame && msg.genType === 'video') {
|
||||
const first = msg.mediaReferences.find((ref: any) => ref.role === 'first_frame');
|
||||
const last = msg.mediaReferences.find((ref: any) => ref.role === 'last_frame');
|
||||
setFirstFrame(first ? { ...first, label: first.label || '' } : null);
|
||||
setLastFrame(last ? { ...last, label: last.label || '' } : null);
|
||||
setFirstFrame(first ? { ...first, label: first.label || '', duration: first.duration } : null);
|
||||
setLastFrame(last ? { ...last, label: last.label || '', duration: last.duration } : null);
|
||||
setCurrentMedia([]);
|
||||
setReferenceMode('first_last_frame');
|
||||
} else {
|
||||
@@ -2199,6 +2202,7 @@ const AIChatPage: React.FC = () => {
|
||||
url: ref.url,
|
||||
label: ref.label || '',
|
||||
role: ref.role,
|
||||
duration: ref.duration,
|
||||
})));
|
||||
setFirstFrame(null);
|
||||
setLastFrame(null);
|
||||
@@ -2887,6 +2891,7 @@ const AIChatPage: React.FC = () => {
|
||||
usedAudioCount={currentMedia.filter(m => m.type === 'audio').length}
|
||||
maxAudioDuration={15}
|
||||
usedAudioDuration={currentMedia.filter(m => m.type === 'audio').reduce((sum, m) => sum + (m.duration || 0), 0)}
|
||||
hideLimitHint={mediaType === 'image'}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -3068,6 +3073,7 @@ const AIChatPage: React.FC = () => {
|
||||
usedAudioCount={currentMedia.filter(m => m.type === 'audio').length}
|
||||
maxAudioDuration={15}
|
||||
usedAudioDuration={currentMedia.filter(m => m.type === 'audio').reduce((sum, m) => sum + (m.duration || 0), 0)}
|
||||
hideLimitHint={mediaType === 'image'}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -261,6 +261,25 @@ const GeneratePage: React.FC = () => {
|
||||
const [creditRatios, setCreditRatios] = useState<any>([]);
|
||||
const [cimage, setCimage] = useState<any>([]);
|
||||
|
||||
const validateImageDimensions = (width: number, height: number): string | null => {
|
||||
if (width < 300 || width > 6000) return `图片宽度需在 300~6000px 之间,当前为 ${width}px`;
|
||||
if (height < 300 || height > 6000) return `图片高度需在 300~6000px 之间,当前为 ${height}px`;
|
||||
const ratio = width / height;
|
||||
if (ratio < 0.4 || ratio > 2.5) return `图片宽高比需在 0.4~2.5 之间,当前为 ${ratio.toFixed(2)}`;
|
||||
return null;
|
||||
};
|
||||
|
||||
const validateVideoDimensions = (width: number, height: number): string | null => {
|
||||
if (width < 300 || width > 6000) return `视频宽度需在 300~6000px 之间,当前为 ${width}px`;
|
||||
if (height < 300 || height > 6000) return `视频高度需在 300~6000px 之间,当前为 ${height}px`;
|
||||
const ratio = width / height;
|
||||
if (ratio < 0.4 || ratio > 2.5) return `视频宽高比需在 0.4~2.5 之间,当前为 ${ratio.toFixed(2)}`;
|
||||
const totalPixels = width * height;
|
||||
if (totalPixels < 409600) return `视频总像素数过小(${width}×${height}=${totalPixels}),需 ≥ 640×640=409600`;
|
||||
if (totalPixels > 8295044) return `视频总像素数过大(${width}×${height}=${totalPixels}),需 ≤ 3326×2494=8295044`;
|
||||
return null;
|
||||
};
|
||||
|
||||
const handlePasteUpload = async (file: File) => {
|
||||
const isImage = file.type.startsWith("image/");
|
||||
const isVideo = file.type.startsWith("video/");
|
||||
@@ -299,23 +318,51 @@ const GeneratePage: React.FC = () => {
|
||||
}
|
||||
let fileDuration = 0;
|
||||
if (isVideo) {
|
||||
fileDuration = await new Promise<number>((resolve) => {
|
||||
const videoInfo = await new Promise<{ duration: number; width: number; height: number }>((resolve) => {
|
||||
const video = document.createElement("video");
|
||||
video.preload = "metadata";
|
||||
video.onloadedmetadata = () => {
|
||||
resolve(video.duration || 0);
|
||||
resolve({ duration: video.duration || 0, width: video.videoWidth || 0, height: video.videoHeight || 0 });
|
||||
video.remove();
|
||||
};
|
||||
video.onerror = () => {
|
||||
resolve(0);
|
||||
resolve({ duration: 0, width: 0, height: 0 });
|
||||
video.remove();
|
||||
};
|
||||
video.src = URL.createObjectURL(file);
|
||||
});
|
||||
fileDuration = videoInfo.duration;
|
||||
if (videoInfo.width > 0 && videoInfo.height > 0) {
|
||||
const error = validateVideoDimensions(videoInfo.width, videoInfo.height);
|
||||
if (error) {
|
||||
message.error(`${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (videoDuration + fileDuration > MAX_VIDEO_DURATION) {
|
||||
message.error(`视频总时长不能超过${MAX_VIDEO_DURATION}秒`);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const imageInfo = await new Promise<{ width: number; height: number }>((resolve) => {
|
||||
const img = document.createElement('img');
|
||||
img.onload = () => {
|
||||
resolve({ width: img.width, height: img.height });
|
||||
URL.revokeObjectURL(img.src);
|
||||
};
|
||||
img.onerror = () => {
|
||||
resolve({ width: 0, height: 0 });
|
||||
URL.revokeObjectURL(img.src);
|
||||
};
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
if (imageInfo.width > 0 && imageInfo.height > 0) {
|
||||
const error = validateImageDimensions(imageInfo.width, imageInfo.height);
|
||||
if (error) {
|
||||
message.error(`${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
@@ -1968,7 +2015,7 @@ const GeneratePage: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
placeholder="上传参考素材、输入文字,自由组合图、文多元素。输入 @ 可引用参考内容..."
|
||||
placeholder="上传参考素材(只用做模型理解,不参与生成)、输入文字,自由组合图、文多元素。输入 @ 可引用参考内容..."
|
||||
maxLength={500}
|
||||
bordered={false}
|
||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
DeleteOutlined,
|
||||
LoadingOutlined,
|
||||
UserOutlined,
|
||||
CheckOutlined,
|
||||
ClearOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { PrivatePortraitLibraryPanel } from '../components/privatePortrait';
|
||||
@@ -299,7 +301,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
folder?.file(filename, blob);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.warn(`文件下载失败(CORS限制): ${filename},将使用备用方式下载`);
|
||||
// console.warn(`文件下载失败(CORS限制): ${filename},将使用备用方式下载`);
|
||||
hasError = true;
|
||||
break;
|
||||
}
|
||||
@@ -448,7 +450,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
const res = await getPreTestList({ page: 1, pageSize: 100 });
|
||||
setPreTestTemplates(res.data?.data || res.data || []);
|
||||
} catch (error) {
|
||||
console.error('加载前测模板失败:', error);
|
||||
// console.error('加载前测模板失败:', error);
|
||||
} finally {
|
||||
setPreTestTemplatesLoading(false);
|
||||
}
|
||||
@@ -489,7 +491,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
setOauthList(data || []);
|
||||
setOauthTotal(res.pagination.total || 0);
|
||||
} catch (error) {
|
||||
console.error('加载授权列表失败:', error);
|
||||
// console.error('加载授权列表失败:', error);
|
||||
setOauthList([]);
|
||||
setOauthTotal(0);
|
||||
} finally {
|
||||
@@ -513,7 +515,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
});
|
||||
setOpenTypeMap(map);
|
||||
} catch (error) {
|
||||
console.error('加载历史授权账户列表失败:', error);
|
||||
// console.error('加载历史授权账户列表失败:', error);
|
||||
setHistoryOAuthList([]);
|
||||
setOpenTypeMap({});
|
||||
} finally {
|
||||
@@ -532,7 +534,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
setUploadHistoryList(data || []);
|
||||
setUploadHistoryTotal(res.pagination?.total || res.total || 0);
|
||||
} catch (error) {
|
||||
console.error('加载推送历史失败:', error);
|
||||
// console.error('加载推送历史失败:', error);
|
||||
setUploadHistoryList([]);
|
||||
setUploadHistoryTotal(0);
|
||||
} finally {
|
||||
@@ -669,7 +671,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
setIsPreTest('2');
|
||||
setPreTestTemplate('');
|
||||
} catch (error: any) {
|
||||
console.error('批量推送失败:', error);
|
||||
// console.error('批量推送失败:', error);
|
||||
message.error(error.message || '批量推送失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
@@ -699,7 +701,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
});
|
||||
message.success('文件名更新成功');
|
||||
} catch (error: any) {
|
||||
console.error('文件名更新失败:', error);
|
||||
// console.error('文件名更新失败:', error);
|
||||
message.error(error.message || '文件名更新失败');
|
||||
}
|
||||
};
|
||||
@@ -733,11 +735,11 @@ const GeneratedRecord: React.FC = () => {
|
||||
}),
|
||||
}));
|
||||
});
|
||||
console.log(response);
|
||||
// console.log(response);
|
||||
const successCount = response?.successCount || 0;
|
||||
message.success(`已更新 ${successCount} 个文件名`);
|
||||
} catch (error: any) {
|
||||
console.error('文件名更新失败:', error);
|
||||
// console.error('文件名更新失败:', error);
|
||||
message.error(error.message || '文件名更新失败');
|
||||
}
|
||||
};
|
||||
@@ -909,10 +911,36 @@ const GeneratedRecord: React.FC = () => {
|
||||
}, [filterType, filterMedia]);
|
||||
return (
|
||||
<div className="content_box" >
|
||||
{/* 操作栏:筛选 + 推送按钮 */}
|
||||
{/* 顶部:标签切换 */}
|
||||
<Tabs
|
||||
activeKey={filterType}
|
||||
onChange={(key) => {
|
||||
setFilterType(key as typeof filterType);
|
||||
setIsSelectionMode(false);
|
||||
setSelectedItems(new Set());
|
||||
}}
|
||||
items={[
|
||||
{ key: 'project', label: <span><FolderOpenOutlined /> 项目记录</span> },
|
||||
{ key: 'creation', label: <span><FileTextOutlined /> 创作记录</span> },
|
||||
{ key: 'hot_opening_replicate', label: <span><StarOutlined /> 爆款复刻</span> },
|
||||
{ key: 'shot_replicate', label: <span><PlayCircleOutlined /> 拆镜复刻</span> },
|
||||
{ key: 'private_portrait', label: <span><UserOutlined /> 私域素材库</span> },
|
||||
{ key: 'upload_resource', label: <span><UploadOutlined /> 历史素材</span> },
|
||||
]}
|
||||
size="middle"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
{filterType === 'private_portrait' ? (
|
||||
<PrivatePortraitLibraryPanel />
|
||||
) : filterType === 'upload_resource' ? (
|
||||
<UploadResourceHistoryPanel />
|
||||
) : (
|
||||
<>
|
||||
{/* 统一筛选栏:搜索 + 日期 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
@@ -920,338 +948,110 @@ const GeneratedRecord: React.FC = () => {
|
||||
borderRadius: 12,
|
||||
background: '#fff',
|
||||
border: '1px solid #f0f0f5',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||
<FilterOutlined style={{ color: '#090a0cff', fontSize: 14 }} />
|
||||
<Space size={8} wrap={true}>
|
||||
<Button
|
||||
type={filterType === 'project' ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
setFilterType('project');
|
||||
setIsSelectionMode(false);
|
||||
setSelectedItems(new Set());
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: filterType === 'project'
|
||||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||||
: '#f8f9fc',
|
||||
border: filterType === 'project' ? 'none' : '1px solid #e2e8f0',
|
||||
color: filterType === 'project' ? '#fff' : '#64748b',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
icon={<FolderOpenOutlined />}
|
||||
>
|
||||
项目记录
|
||||
</Button>
|
||||
<Button
|
||||
type={filterType === 'creation' ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
setFilterType('creation');
|
||||
setIsSelectionMode(false);
|
||||
setSelectedItems(new Set());
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: filterType === 'creation'
|
||||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||||
: '#f8f9fc',
|
||||
border: filterType === 'creation' ? 'none' : '1px solid #e2e8f0',
|
||||
color: filterType === 'creation' ? '#fff' : '#64748b',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
icon={<FileTextOutlined />}
|
||||
>
|
||||
创作记录
|
||||
</Button>
|
||||
<Button
|
||||
type={filterType === 'hot_opening_replicate' ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
setFilterType('hot_opening_replicate');
|
||||
setIsSelectionMode(false);
|
||||
setSelectedItems(new Set());
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: filterType === 'hot_opening_replicate'
|
||||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||||
: '#f8f9fc',
|
||||
border: filterType === 'hot_opening_replicate' ? 'none' : '1px solid #e2e8f0',
|
||||
color: filterType === 'hot_opening_replicate' ? '#fff' : '#64748b',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
icon={<StarOutlined />}
|
||||
>
|
||||
爆款复刻
|
||||
</Button>
|
||||
<Button
|
||||
type={filterType === 'shot_replicate' ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
setFilterType('shot_replicate');
|
||||
setIsSelectionMode(false);
|
||||
setSelectedItems(new Set());
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: filterType === 'shot_replicate'
|
||||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||||
: '#f8f9fc',
|
||||
border: filterType === 'shot_replicate' ? 'none' : '1px solid #e2e8f0',
|
||||
color: filterType === 'shot_replicate' ? '#fff' : '#64748b',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
icon={<PlayCircleOutlined />}
|
||||
>
|
||||
拆镜复刻
|
||||
</Button>
|
||||
<Button
|
||||
type={filterType === 'private_portrait' ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
setFilterType('private_portrait');
|
||||
setIsSelectionMode(false);
|
||||
setSelectedItems(new Set());
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: filterType === 'private_portrait'
|
||||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||||
: '#f8f9fc',
|
||||
border: filterType === 'private_portrait' ? 'none' : '1px solid #e2e8f0',
|
||||
color: filterType === 'private_portrait' ? '#fff' : '#64748b',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
icon={<UserOutlined />}
|
||||
>
|
||||
私域素材库
|
||||
</Button>
|
||||
<Button
|
||||
type={filterType === 'upload_resource' ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
setFilterType('upload_resource');
|
||||
setIsSelectionMode(false);
|
||||
setSelectedItems(new Set());
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: filterType === 'upload_resource'
|
||||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||||
: '#f8f9fc',
|
||||
border: filterType === 'upload_resource' ? 'none' : '1px solid #e2e8f0',
|
||||
color: filterType === 'upload_resource' ? '#fff' : '#64748b',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
icon={<UploadOutlined />}
|
||||
>
|
||||
历史素材
|
||||
</Button>
|
||||
</Space>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 14, marginRight: 4 }}>媒体类型:</Typography.Text>
|
||||
<Button
|
||||
type={filterMedia === 'video' ? 'primary' : 'default'}
|
||||
onClick={() => { setFilterMedia('video'); setIsSelectionMode(false); setSelectedItems(new Set()); }}
|
||||
style={{ borderRadius: 8, fontWeight: 600, borderColor: filterMedia === 'video' ? 'transparent' : '#e2e8f0', color: filterMedia === 'video' ? '#fff' : '#64748b' }}
|
||||
icon={<VideoCameraOutlined />}
|
||||
>
|
||||
视频
|
||||
</Button>
|
||||
<Button
|
||||
type={filterMedia === 'image' ? 'primary' : 'default'}
|
||||
onClick={() => { setFilterMedia('image'); setIsSelectionMode(false); setSelectedItems(new Set()); }}
|
||||
style={{ borderRadius: 8, fontWeight: 600, borderColor: filterMedia === 'image' ? 'transparent' : '#e2e8f0', color: filterMedia === 'image' ? '#fff' : '#64748b' }}
|
||||
icon={<PictureOutlined />}
|
||||
>
|
||||
图片
|
||||
</Button>
|
||||
</div>
|
||||
{filterType !== 'private_portrait' && filterType !== 'upload_resource' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||
{/* 多选模式按钮 */}
|
||||
{isSelectionMode ? (
|
||||
<Space size={8} wrap={true}>
|
||||
<Button
|
||||
onClick={handleSelectAll}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: '#f8f9fc',
|
||||
border: '1px solid #e2e8f0',
|
||||
color: '#222222ff',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{selectedItems.size === recordlist.reduce((sum: number, group: any) => sum + group.items.length, 0) ? '取消全选' : '全选'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsSelectionMode(false);
|
||||
setSelectedItems(new Set());
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: '#f8f9fc',
|
||||
border: '1px solid #e2e8f0',
|
||||
color: '#64748b',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
取消选择
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDeleteSelected()}
|
||||
style={{ borderRadius: 8, background: 'linear-gradient(135deg, #bf0b0a 0%, #ff8165 100%)', fontWeight: 600, padding: '8px 24px', color: '#fff' }}
|
||||
>
|
||||
删除 ({selectedItems.size})
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDownloadSelected()}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
下载 ({selectedItems.size})
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleBatchUploadSelected}
|
||||
loading={uploading}
|
||||
disabled={uploading || selectedItems.size === 0}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{uploading ? '推送中...' : `推送至账户 (${selectedItems.size})`}
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UploadOutlined />}
|
||||
onClick={() => setIsSelectionMode(true)}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
border: 'none',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
批量操作
|
||||
</Button>
|
||||
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{filterType === 'private_portrait' ? (
|
||||
<PrivatePortraitLibraryPanel />
|
||||
) : filterType === 'upload_resource' ? (
|
||||
<UploadResourceHistoryPanel />
|
||||
) : (
|
||||
<>
|
||||
{/* Second row filter: 视频 / 图片 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
padding: '12px 16px',
|
||||
borderRadius: 12,
|
||||
background: '#fff',
|
||||
border: '1px solid #f0f0f5',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 14 }}>媒体类型:</Typography.Text>
|
||||
<Space size={8} wrap={true}>
|
||||
<Button
|
||||
type={filterMedia === 'video' ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
setFilterMedia('video');
|
||||
setIsSelectionMode(false);
|
||||
setSelectedItems(new Set());
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: filterMedia === 'video'
|
||||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||||
: '#f8f9fc',
|
||||
border: filterMedia === 'video' ? 'none' : '1px solid #e2e8f0',
|
||||
color: filterMedia === 'video' ? '#fff' : '#64748b',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
icon={<VideoCameraOutlined />}
|
||||
>
|
||||
视频
|
||||
</Button>
|
||||
<Button
|
||||
type={filterMedia === 'image' ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
setFilterMedia('image');
|
||||
setIsSelectionMode(false);
|
||||
setSelectedItems(new Set());
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: filterMedia === 'image'
|
||||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||||
: '#f8f9fc',
|
||||
border: filterMedia === 'image' ? 'none' : '1px solid #e2e8f0',
|
||||
color: filterMedia === 'image' ? '#fff' : '#64748b',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
icon={<PictureOutlined />}
|
||||
>
|
||||
图片
|
||||
</Button>
|
||||
<DatePicker
|
||||
picker="date"
|
||||
value={selectedDate ? dayjs(selectedDate) : undefined}
|
||||
onChange={(date, dateString) => handleDateChange(dateString || '')}
|
||||
format="YYYY-MM-DD"
|
||||
style={{ width: 160, borderRadius: 8, border: '1px solid #e2e8f0' }}
|
||||
placeholder="选择日期"
|
||||
/>
|
||||
{selectedDate && (
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => handleDateChange('')}
|
||||
style={{ color: '#94a3b8', fontSize: 12 }}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
<Input.Search
|
||||
placeholder="搜索提示词"
|
||||
allowClear
|
||||
value={searchKeyword}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setSearchKeyword(val);
|
||||
if (!val) {
|
||||
setPagebreak(prev => ({ ...prev, page: 1 }));
|
||||
loadRecordList();
|
||||
}
|
||||
}}
|
||||
onSearch={() => {
|
||||
<Space size={8} wrap>
|
||||
<DatePicker
|
||||
picker="date"
|
||||
value={selectedDate ? dayjs(selectedDate) : undefined}
|
||||
onChange={(date, dateString) => handleDateChange(dateString || '')}
|
||||
format="YYYY-MM-DD"
|
||||
style={{ width: 160, borderRadius: 8, border: '1px solid #e2e8f0' }}
|
||||
placeholder="选择日期"
|
||||
allowClear
|
||||
/>
|
||||
<Input.Search
|
||||
placeholder="搜索提示词"
|
||||
allowClear
|
||||
value={searchKeyword}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setSearchKeyword(val);
|
||||
if (!val) {
|
||||
setPagebreak(prev => ({ ...prev, page: 1 }));
|
||||
loadRecordList();
|
||||
}}
|
||||
style={{ width: 240, borderRadius: 8 }}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
<Button
|
||||
icon={<ClockCircleOutlined />}
|
||||
onClick={handleOpenUploadHistory}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
border: 'none',
|
||||
color: '#ffffff',
|
||||
fontWeight: 600,
|
||||
boxShadow: '0 4px 15px rgba(102, 126, 234, 0.4)',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 20px rgba(102, 126, 234, 0.6)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 15px rgba(102, 126, 234, 0.4)';
|
||||
}}
|
||||
>
|
||||
查询推送任务历史
|
||||
</Button>
|
||||
}
|
||||
}}
|
||||
onSearch={() => {
|
||||
setPagebreak(prev => ({ ...prev, page: 1 }));
|
||||
loadRecordList();
|
||||
}}
|
||||
style={{ width: 240, borderRadius: 8 }}
|
||||
enterButton
|
||||
/>
|
||||
<Button
|
||||
icon={<ClockCircleOutlined />}
|
||||
onClick={handleOpenUploadHistory}
|
||||
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', color: '#fff' }}
|
||||
>
|
||||
推送任务历史
|
||||
</Button>
|
||||
{/* 批量操作按钮 */}
|
||||
{isSelectionMode ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={selectedItems.size === recordlist.reduce((sum: number, group: any) => sum + group.items.length, 0) && recordlist.length > 0 ? () => { setSelectedItems(new Set()); } : handleSelectAll}
|
||||
style={{ borderRadius: 8, fontWeight: 600, background: '#f8f9fc', border: '1px solid #e2e8f0', color: '#334155' }}
|
||||
>
|
||||
{selectedItems.size === recordlist.reduce((sum: number, group: any) => sum + group.items.length, 0) && recordlist.length > 0 ? '取消全选' : '全选'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => { setIsSelectionMode(false); setSelectedItems(new Set()); }}
|
||||
style={{ borderRadius: 8, fontWeight: 600, background: '#f8f9fc', border: '1px solid #e2e8f0', color: '#64748b' }}
|
||||
>
|
||||
取消选择
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
onClick={handleDeleteSelected}
|
||||
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #ef4444, #dc2626)', border: 'none', color: '#fff' }}
|
||||
>
|
||||
删除 {selectedItems.size > 0 && `(${selectedItems.size})`}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDownloadSelected}
|
||||
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', border: 'none', color: '#fff' }}
|
||||
>
|
||||
下载 {selectedItems.size > 0 && `(${selectedItems.size})`}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleBatchUploadSelected}
|
||||
loading={uploading}
|
||||
disabled={uploading || selectedItems.size === 0}
|
||||
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', border: 'none', color: '#fff' }}
|
||||
>
|
||||
{uploading ? '推送中...' : `推送至账户 ${selectedItems.size > 0 ? `(${selectedItems.size})` : ''}`}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => setIsSelectionMode(true)}
|
||||
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none' }}
|
||||
>
|
||||
批量操作
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
{/* Content area */}
|
||||
{loading ? (
|
||||
@@ -1263,7 +1063,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
}}>
|
||||
<Spin
|
||||
indicator={<LoadingOutlined style={{ fontSize: 24, color: '#64748b' }} spin />}
|
||||
tip="加载中..."
|
||||
description="加载中..."
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
@@ -1408,18 +1208,20 @@ const GeneratedRecord: React.FC = () => {
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
width: 20,
|
||||
height: 20,
|
||||
top: 6,
|
||||
left: 6,
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: isSelectedItem ? '#10b981' : 'rgba(255,255,255,0.9)',
|
||||
border: isSelectedItem ? '2px solid #10b981' : '2px solid #d1d5db',
|
||||
backgroundColor: isSelectedItem ? '#6366f1' : 'rgba(255,255,255,0.92)',
|
||||
border: isSelectedItem ? '2px solid #6366f1' : '2px solid #cbd5e1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
zIndex: 10,
|
||||
boxShadow: isSelectedItem ? '0 2px 8px rgba(99,102,241,0.4)' : '0 1px 3px rgba(0,0,0,0.1)',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -1517,10 +1319,11 @@ const GeneratedRecord: React.FC = () => {
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
border: '3px solid #10b981',
|
||||
border: '3px solid #6366f1',
|
||||
borderRadius: 4,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 5,
|
||||
boxShadow: 'inset 0 0 0 1px rgba(99,102,241,0.2)',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -206,7 +206,7 @@ const HomePage: React.FC = () => {
|
||||
setActiveCaseTab(firstId);
|
||||
// 初始请求第一个 tab 的数据
|
||||
getHomeCaseButton(firstId).then((btnRes: any) => {
|
||||
console.log('caseButton:', btnRes);
|
||||
// console.log('caseButton:', btnRes);
|
||||
if (btnRes?.categories?.[0]?.assets) {
|
||||
setCaseAssets(btnRes.categories[0].assets);
|
||||
}
|
||||
@@ -357,7 +357,7 @@ const HomePage: React.FC = () => {
|
||||
{
|
||||
icon: <FileTextOutlined style={{ fontSize: 24 }} />,
|
||||
title: '爆款复刻',
|
||||
description: '上传参考视频与产品图片,一键复刻爆款视频开头',
|
||||
description: '上传参考视频与产品图片,一键复刻爆款',
|
||||
action: '立即创作',
|
||||
path: '/initial',
|
||||
},
|
||||
|
||||
@@ -14,6 +14,13 @@ function clonePlain<T>(value: T): T {
|
||||
return value === undefined ? value : JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
const buildMediaUrl = (url: string): string => {
|
||||
if (!url) return '';
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
const base = (import.meta.env.VITE_API_BASE || 'http://localhost:8000').replace(/\/$/, '');
|
||||
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
};
|
||||
|
||||
function InitialInfo() {
|
||||
const navigate = useNavigate();
|
||||
const { creatID } = useParams<{ creatID: string }>();
|
||||
@@ -101,7 +108,7 @@ function InitialInfo() {
|
||||
}, [previewVisible, previewType]);
|
||||
|
||||
const openPreview = (url: string, type: 'image' | 'video') => {
|
||||
console.log(url, type);
|
||||
// console.log(url, type);
|
||||
setPreviewUrl(url);
|
||||
setPreviewType(type);
|
||||
setPreviewVisible(true);
|
||||
@@ -587,7 +594,7 @@ function InitialInfo() {
|
||||
{taskDetail?.material?.materialVideoUrl ? (
|
||||
<video
|
||||
|
||||
src={taskDetail.material.materialVideoUrl}
|
||||
src={buildMediaUrl(taskDetail.material.materialVideoUrl)}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
@@ -602,7 +609,7 @@ function InitialInfo() {
|
||||
</span>
|
||||
<div className="medio_box" style={{ aspectRatio: '1/1', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: taskDetail?.material?.materialImageUrl ? 'pointer' : 'default' }} onClick={() => taskDetail?.material?.materialImageUrl && openPreview(taskDetail.material.materialImageUrl, 'image')}>
|
||||
{taskDetail?.material?.materialImageUrl ? (
|
||||
<img src={taskDetail.material.materialImageUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||
<img src={buildMediaUrl(taskDetail.material.materialImageUrl)} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 13 }}>暂无图片</div>
|
||||
)}
|
||||
@@ -721,7 +728,7 @@ function InitialInfo() {
|
||||
<div style={{ height: 180, borderRadius: 12, background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid rgba(99, 102, 241, 0.08)', boxShadow: '0 2px 8px rgba(99, 102, 241, 0.04)' }}>
|
||||
{taskDetail?.material?.materialVideoUrl ? (
|
||||
<video
|
||||
src={taskDetail.material.materialVideoUrl}
|
||||
src={buildMediaUrl(taskDetail.material.materialVideoUrl)}
|
||||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10 }}
|
||||
controls
|
||||
/>
|
||||
@@ -741,7 +748,7 @@ function InitialInfo() {
|
||||
<div style={{ height: 180, borderRadius: 10, background: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{taskDetail?.material?.materialImageUrl ? (
|
||||
<img
|
||||
src={taskDetail.material.materialImageUrl}
|
||||
src={buildMediaUrl(taskDetail.material.materialImageUrl)}
|
||||
alt="产品图片"
|
||||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10 }}
|
||||
/>
|
||||
|
||||
@@ -383,13 +383,24 @@ const MaterialListPage: React.FC = () => {
|
||||
<FolderOpenOutlined style={{ color: '#6366f1', fontSize: 16 }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>素材列表</Typography.Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'top', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
padding: '12px 16px',
|
||||
borderRadius: 12,
|
||||
background: '#fff',
|
||||
border: '1px solid #f0f0f5',
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Input
|
||||
placeholder="广告主ID"
|
||||
value={searchParams.advertiser_id}
|
||||
onChange={(e) => setSearchParams(prev => ({ ...prev, advertiser_id: e.target.value }))}
|
||||
style={{ width: 160 }}
|
||||
style={{ width: 150, borderRadius: 8 }}
|
||||
allowClear
|
||||
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
||||
/>
|
||||
@@ -397,7 +408,7 @@ const MaterialListPage: React.FC = () => {
|
||||
placeholder="素材ID"
|
||||
value={searchParams.material_id}
|
||||
onChange={(e) => setSearchParams(prev => ({ ...prev, material_id: e.target.value }))}
|
||||
style={{ width: 160 }}
|
||||
style={{ width: 150, borderRadius: 8 }}
|
||||
allowClear
|
||||
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
||||
/>
|
||||
@@ -405,7 +416,7 @@ const MaterialListPage: React.FC = () => {
|
||||
placeholder="上传ID"
|
||||
value={searchParams.upload_id}
|
||||
onChange={(e) => setSearchParams(prev => ({ ...prev, upload_id: e.target.value }))}
|
||||
style={{ width: 160 }}
|
||||
style={{ width: 150, borderRadius: 8 }}
|
||||
allowClear
|
||||
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
||||
/>
|
||||
@@ -413,7 +424,7 @@ const MaterialListPage: React.FC = () => {
|
||||
placeholder="文件名"
|
||||
value={searchParams.file_name}
|
||||
onChange={(e) => setSearchParams(prev => ({ ...prev, file_name: e.target.value }))}
|
||||
style={{ width: 140 }}
|
||||
style={{ width: 140, borderRadius: 8 }}
|
||||
allowClear
|
||||
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
||||
/>
|
||||
@@ -421,7 +432,7 @@ const MaterialListPage: React.FC = () => {
|
||||
placeholder="资源类型"
|
||||
value={searchParams.resource_type}
|
||||
onChange={(value) => setSearchParams(prev => ({ ...prev, resource_type: value }))}
|
||||
style={{ width: 120 }}
|
||||
style={{ width: 110, borderRadius: 8 }}
|
||||
allowClear
|
||||
options={[
|
||||
{ value: 'image', label: '图片' },
|
||||
@@ -431,21 +442,16 @@ const MaterialListPage: React.FC = () => {
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
||||
style={{ borderRadius: 8, fontWeight: 600, borderColor: 'transparent' }}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
onClick={() => navigate('/generated?filterType=private_portrait&portraitTab=aigc_virtual')}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
fontSize: 14,
|
||||
borderColor: '#8b5cf6',
|
||||
color: '#7c3aed',
|
||||
background: '#f5f3ff',
|
||||
}}
|
||||
style={{ borderRadius: 8, fontWeight: 600, borderColor: '#8b5cf6', color: '#7c3aed', background: '#f5f3ff' }}
|
||||
>
|
||||
私域虚拟人像库
|
||||
</Button>
|
||||
@@ -454,16 +460,9 @@ const MaterialListPage: React.FC = () => {
|
||||
loading={pushTemplatesLoading}
|
||||
onClick={handleOpenPushModal}
|
||||
disabled={selectedRows.size === 0}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
fontSize: 14,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 14px rgba(99,102,241,0.3)',
|
||||
}}
|
||||
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', color: '#fff' }}
|
||||
>
|
||||
推送前测 ({selectedRows.size})
|
||||
推送前测 {selectedRows.size > 0 && `(${selectedRows.size})`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -128,7 +128,7 @@ const ProjectsPage: React.FC = () => {
|
||||
backgroundClip: 'text',
|
||||
// textAlign: 'center',
|
||||
}}>
|
||||
我的项目
|
||||
行业制造
|
||||
</h2>
|
||||
<p style={{ fontSize: 13, color: '#64748b', margin: '4px 0 0 0' }}>
|
||||
共 {projects.length} 个项目 · 按行业分类管理
|
||||
|
||||
@@ -473,7 +473,7 @@ function RemoveInfo() {
|
||||
</>
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 12 }}>
|
||||
{record.splitStatus === 'failed' ? '切割失败' : '切割中'}
|
||||
{record.splitStatus === 'failed' ? '切割失败' : record.splitStatus === 'pending' ? '等待切割' : '切割中'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -486,6 +486,14 @@ function RemoveInfo() {
|
||||
render: (_: any, record: any) => {
|
||||
const splitStatus = record.split_status || record.splitStatus;
|
||||
|
||||
if (splitStatus === 'pending') {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: 14, color: '#64748b' }}>等待切割</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (splitStatus === 'processing') {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
|
||||
@@ -14,6 +14,13 @@ function clonePlain<T>(value: T): T {
|
||||
return value === undefined ? value : JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
const buildMediaUrl = (url: string): string => {
|
||||
if (!url) return '';
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
const base = (import.meta.env.VITE_API_BASE || 'http://localhost:8000').replace(/\/$/, '');
|
||||
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
};
|
||||
|
||||
function InitialInfo() {
|
||||
const navigate = useNavigate();
|
||||
const { creatID } = useParams<{ creatID: string }>();
|
||||
@@ -590,7 +597,7 @@ function InitialInfo() {
|
||||
{taskDetail?.material?.materialVideoUrl ? (
|
||||
<video
|
||||
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.material.materialVideoUrl}`}
|
||||
src={buildMediaUrl(taskDetail.material.materialVideoUrl)}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
@@ -605,7 +612,7 @@ function InitialInfo() {
|
||||
</span>
|
||||
<div className="medio_box" style={{ aspectRatio: '1/1', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: taskDetail?.material?.materialImageUrl ? 'pointer' : 'default' }} onClick={() => taskDetail?.material?.materialImageUrl && openPreview(taskDetail.material.materialImageUrl, 'image')}>
|
||||
{taskDetail?.material?.materialImageUrl ? (
|
||||
<img src={taskDetail.material.materialImageUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||
<img src={buildMediaUrl(taskDetail.material.materialImageUrl)} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 13 }}>暂无图片</div>
|
||||
)}
|
||||
@@ -721,7 +728,7 @@ function InitialInfo() {
|
||||
<div style={{ height: 180, borderRadius: 12, background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid rgba(99, 102, 241, 0.06)' }}>
|
||||
{taskDetail?.material?.materialVideoUrl ? (
|
||||
<video
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.material.materialVideoUrl}`}
|
||||
src={buildMediaUrl(taskDetail.material.materialVideoUrl)}
|
||||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10 }}
|
||||
controls
|
||||
/>
|
||||
@@ -741,7 +748,7 @@ function InitialInfo() {
|
||||
<div style={{ height: 180, borderRadius: 12, background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid rgba(99, 102, 241, 0.06)' }}>
|
||||
{taskDetail?.material?.materialImageUrl ? (
|
||||
<img
|
||||
src={taskDetail.material.materialImageUrl}
|
||||
src={buildMediaUrl(taskDetail.material.materialImageUrl)}
|
||||
alt="产品图片"
|
||||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10 }}
|
||||
/>
|
||||
|
||||
@@ -4,4 +4,12 @@ import react from '@vitejs/plugin-react'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/uploads': {
|
||||
target: 'http://ceshi.apiforeign.minzhong.cn',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user