feat(titles): add project title library

This commit is contained in:
Xiaoxia AI
2026-06-24 00:30:58 +08:00
parent 4070dd1952
commit 6b66601d67
16 changed files with 544 additions and 3 deletions
+1 -1
View File
@@ -246,4 +246,4 @@ jobs:
-v "$PWD:/workspace" \
-w /workspace/apps/web \
mcr.microsoft.com/playwright:v1.45.0-jammy \
sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/core-upload.spec.ts e2e/core-generation.spec.ts'
sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts'
@@ -0,0 +1,43 @@
"""Add project titles.
Revision ID: 003
Revises: 002
Create Date: 2026-06-24
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "003"
down_revision: Union[str, None] = "002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"project_titles",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("workspace_id", sa.String(length=36), nullable=False),
sa.Column("project_id", sa.String(length=36), nullable=False),
sa.Column("text", sa.String(length=200), nullable=False),
sa.Column("category", sa.String(length=50), nullable=False, server_default="default"),
sa.Column("usage_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_by_user_id", sa.String(length=36), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_project_titles_project_id"), "project_titles", ["project_id"], unique=False)
op.create_index(op.f("ix_project_titles_workspace_id"), "project_titles", ["workspace_id"], unique=False)
op.create_index(op.f("ix_project_titles_category"), "project_titles", ["category"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_project_titles_category"), table_name="project_titles")
op.drop_index(op.f("ix_project_titles_workspace_id"), table_name="project_titles")
op.drop_index(op.f("ix_project_titles_project_id"), table_name="project_titles")
op.drop_table("project_titles")
+5
View File
@@ -8,6 +8,7 @@ from app.api.routes.generation_tasks import router as generation_tasks_router
from app.api.routes.health import router as health_check_router
from app.api.routes.ingest_jobs import router as ingest_jobs_router
from app.api.routes.project_management import router as project_management_router
from app.api.routes.project_titles import router as project_titles_router
from app.api.routes.projects import router as projects_router
from app.api.routes.upload import router as upload_router
from app.api.routes.workspaces import router as workspaces_router
@@ -30,6 +31,10 @@ api_router.include_router(
prefix="/projects",
tags=["项目管理"],
)
api_router.include_router(
project_titles_router,
tags=["标题库"],
)
api_router.include_router(
asset_diagnosis_router,
tags=["素材诊断"],
@@ -9,6 +9,7 @@ from app.dependencies import (
get_generated_video_repository,
get_generation_task_repository,
get_project_repository,
get_project_title_repository,
get_workspace_member_repository,
)
from app.schemas.generated_video import (
@@ -84,6 +85,7 @@ def create_generation_task(
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
project_title_repository: Any = Depends(get_project_title_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> GenerationTaskResponse:
require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository)
@@ -107,6 +109,11 @@ def create_generation_task(
created_by_user_id=authenticated_user.user.id,
)
)
if request.strategy_id:
title = project_title_repository.get(request.strategy_id)
if title is not None and title.workspace_id == request.workspace_id and title.project_id == request.project_id:
title.usage_count = int(title.usage_count or 0) + 1
project_title_repository.update(title)
celery_app.send_task("worker.generate_video", args=[task.id])
return _to_generation_task_response(task)
+99
View File
@@ -0,0 +1,99 @@
from typing import Any
from app.api.routes.permissions import require_workspace_member
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
get_project_repository,
get_project_title_repository,
get_workspace_member_repository,
)
from app.schemas.project_title import (
CreateProjectTitleRequest,
ListProjectTitlesResponse,
ProjectTitleResponse,
UpdateProjectTitleRequest,
)
from fastapi import APIRouter, Depends, HTTPException, status
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
router = APIRouter()
def _to_response(item) -> ProjectTitleResponse:
return ProjectTitleResponse(
id=item.id,
workspace_id=item.workspace_id,
project_id=item.project_id,
text=item.text,
category=item.category,
usage_count=int(item.usage_count or 0),
is_active=bool(item.is_active),
created_at=item.created_at,
updated_at=item.updated_at,
)
def _get_project_or_404(project_id: str, project_repository: Any):
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
return project
@router.get("/projects/{project_id}/titles", response_model=ListProjectTitlesResponse)
def list_project_titles(
project_id: str,
active_only: bool = False,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
title_repository: Any = Depends(get_project_title_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> ListProjectTitlesResponse:
project = _get_project_or_404(project_id, project_repository)
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
return ListProjectTitlesResponse(items=[_to_response(item) for item in title_repository.list_by_project(project_id, active_only)])
@router.post("/projects/{project_id}/titles", response_model=ProjectTitleResponse)
def create_project_title(
project_id: str,
request: CreateProjectTitleRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
title_repository: Any = Depends(get_project_title_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> ProjectTitleResponse:
project = _get_project_or_404(project_id, project_repository)
if project.workspace_id != request.workspace_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="workspace_id does not match project")
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
item = title_repository.create(
workspace_id=project.workspace_id,
project_id=project_id,
text=request.text,
category=request.category,
created_by_user_id=authenticated_user.user.id,
)
return _to_response(item)
@router.patch("/project-titles/{title_id}", response_model=ProjectTitleResponse)
def update_project_title(
title_id: str,
request: UpdateProjectTitleRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
title_repository: Any = Depends(get_project_title_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> ProjectTitleResponse:
item = title_repository.get(title_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project title not found")
require_workspace_member(item.workspace_id, authenticated_user, workspace_member_repository)
if request.text is not None:
item.text = request.text.strip()
if request.category is not None:
item.category = request.category
if request.is_active is not None:
item.is_active = request.is_active
return _to_response(title_repository.update(item))
+9
View File
@@ -24,6 +24,9 @@ from packages.adapters.sqlalchemy_impl.ingest_job_repository import (
from packages.adapters.sqlalchemy_impl.project_repository import (
SQLAlchemyProjectRepository,
)
from packages.adapters.sqlalchemy_impl.project_title_repository import (
SQLAlchemyProjectTitleRepository,
)
from packages.adapters.sqlalchemy_impl.session import build_session_factory
from packages.adapters.sqlalchemy_impl.user_repository import SQLAlchemyUserRepository
from packages.adapters.sqlalchemy_impl.workspace_invitation_repository import SQLAlchemyWorkspaceInvitationRepository
@@ -87,6 +90,12 @@ def get_project_repository(
return SQLAlchemyProjectRepository(session)
def get_project_title_repository(
session: Session = Depends(get_db_session),
) -> SQLAlchemyProjectTitleRepository:
return SQLAlchemyProjectTitleRepository(session)
def get_user_repository(
session: Session = Depends(get_db_session),
) -> UserRepository:
+34
View File
@@ -0,0 +1,34 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
TitleCategory = Literal["default", "marketing", "tutorial", "story", "promo"]
class ProjectTitleResponse(BaseModel):
id: str
workspace_id: str
project_id: str
text: str
category: str
usage_count: int
is_active: bool
created_at: datetime
updated_at: datetime
class ListProjectTitlesResponse(BaseModel):
items: list[ProjectTitleResponse]
class CreateProjectTitleRequest(BaseModel):
workspace_id: str
text: str = Field(min_length=1, max_length=200)
category: TitleCategory = "default"
class UpdateProjectTitleRequest(BaseModel):
text: str | None = Field(default=None, min_length=1, max_length=200)
category: TitleCategory | None = None
is_active: bool | None = None
+51
View File
@@ -0,0 +1,51 @@
import { expect, test } from '@playwright/test';
const PASSWORD = 'SmokePass123!';
test.describe('Project title library flow', () => {
test('creates a reusable title from the browser', async ({ page, request }) => {
const suffix = Date.now().toString(36);
const email = `e2e-title-${suffix}@example.com`;
const username = `e2e_title_${suffix}`;
const apiBase = '/api/v1';
const register = await request.post(`${apiBase}/auth/register`, {
data: { email, username, password: PASSWORD, display_name: username },
});
expect(register.status(), await register.text()).toBe(201);
const login = await request.post(`${apiBase}/auth/login`, { data: { email, password: PASSWORD } });
expect(login.status(), await login.text()).toBe(200);
const headers = { Authorization: `Bearer ${((await login.json()) as { access_token: string }).access_token}` };
const workspace = await request.post(`${apiBase}/workspaces`, {
headers,
data: { name: `E2E Title Workspace ${suffix}` },
});
expect(workspace.status(), await workspace.text()).toBe(201);
const workspaceData = (await workspace.json()) as { id?: string; workspace_id?: string };
const workspaceId = workspaceData.id || workspaceData.workspace_id;
expect(workspaceId).toBeTruthy();
const project = await request.post(`${apiBase}/projects`, {
headers,
data: { workspace_id: workspaceId, name: `E2E Title Project ${suffix}`, description: 'Playwright title smoke' },
});
expect(project.status(), await project.text()).toBe(200);
const projectData = (await project.json()) as { id: string };
await page.goto('/login');
await page.getByPlaceholder('邮箱').fill(email);
await page.getByPlaceholder('密码').fill(PASSWORD);
await page.getByRole('button', { name: /登\s*录/ }).click();
await expect(page).toHaveURL(/\/workspaces/, { timeout: 20_000 });
await page.goto(`/projects/${projectData.id}/titles`);
await expect(page.getByText('标题库')).toBeVisible({ timeout: 20_000 });
await page.getByPlaceholder('输入可复用标题').fill(`E2E 标题 ${suffix}`);
await page.getByRole('button', { name: '新增标题' }).click();
await expect(page.getByText('标题已加入标题库')).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(`E2E 标题 ${suffix}`)).toBeVisible({ timeout: 20_000 });
await expect(page.getByText('使用次数:0')).toBeVisible();
});
});
+28
View File
@@ -0,0 +1,28 @@
import apiClient from './client';
export interface ProjectTitleItem {
id: string;
workspace_id: string;
project_id: string;
text: string;
category: 'default' | 'marketing' | 'tutorial' | 'story' | 'promo';
usage_count: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
export const getProjectTitles = async (projectId: string, activeOnly = false): Promise<ProjectTitleItem[]> => {
const response = await apiClient.get(`/projects/${projectId}/titles`, { params: { active_only: activeOnly } });
return response.data.items;
};
export const createProjectTitle = async (projectId: string, data: { workspace_id: string; text: string; category?: ProjectTitleItem['category'] }): Promise<ProjectTitleItem> => {
const response = await apiClient.post(`/projects/${projectId}/titles`, data);
return response.data;
};
export const updateProjectTitle = async (titleId: string, data: { text?: string; category?: ProjectTitleItem['category']; is_active?: boolean }): Promise<ProjectTitleItem> => {
const response = await apiClient.patch(`/project-titles/${titleId}`, data);
return response.data;
};
@@ -5,6 +5,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { createGenerationTask, getGeneratedVideoDownloadUrl, getGenerationResults, getGenerationTask } from '@/api/generation';
import { getAssetLibraries, getProjectAssetDiagnosis } from '@/api/assets';
import { getProject } from '@/api/projects';
import { getProjectTitles } from '@/api/projectTitles';
import { useAuthStore } from '@/store/authStore';
const humanizeGenerationStatus = (status: string) => {
@@ -86,6 +87,12 @@ const ProjectGeneration: React.FC = () => {
refetchOnWindowFocus: false,
});
const titlesQuery = useQuery({
queryKey: ['generation-project-titles', projectId],
queryFn: () => getProjectTitles(projectId, true),
enabled: !!projectId,
});
const taskQuery = useQuery({
queryKey: ['generation-task', taskId],
queryFn: () => getGenerationTask(taskId),
@@ -127,6 +134,11 @@ const ProjectGeneration: React.FC = () => {
[librariesQuery.data]
);
const titleOptions = useMemo(
() => (titlesQuery.data || []).map((item) => ({ label: item.text, value: item.id })),
[titlesQuery.data]
);
const failedReason = taskQuery.data?.status === 'failed'
? humanizeGenerationError(taskQuery.data.error_message)
: '';
@@ -194,7 +206,7 @@ const ProjectGeneration: React.FC = () => {
project_id: projectId,
asset_library_id: values.asset_library_id,
voice_library_id: values.voice_library_id || '',
strategy_id: values.strategy_id || '',
strategy_id: values.title_id || values.strategy_id || '',
created_by_user_id: user?.id || '',
})
}
@@ -205,6 +217,9 @@ const ProjectGeneration: React.FC = () => {
<Form.Item label="配音库" name="voice_library_id">
<Select allowClear options={voiceLibraryOptions} placeholder="可选:选择配音库" loading={librariesQuery.isLoading} />
</Form.Item>
<Form.Item label="标题" name="title_id">
<Select allowClear options={titleOptions} placeholder="可选:选择标题库中的标题" loading={titlesQuery.isLoading} />
</Form.Item>
<Form.Item label="策略 ID" name="strategy_id">
<Input placeholder="可选:例如 default-strategy" />
</Form.Item>
@@ -0,0 +1,126 @@
import React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button, Card, Form, Input, List, Select, Space, Switch, Tag, message } from 'antd';
import { useParams } from 'react-router-dom';
import { createProjectTitle, getProjectTitles, updateProjectTitle } from '@/api/projectTitles';
import { getProject } from '@/api/projects';
const categoryOptions = [
{ label: '默认', value: 'default' },
{ label: '营销', value: 'marketing' },
{ label: '教程', value: 'tutorial' },
{ label: '故事', value: 'story' },
{ label: '促销', value: 'promo' },
];
const categoryLabels: Record<string, string> = {
default: '默认',
marketing: '营销',
tutorial: '教程',
story: '故事',
promo: '促销',
};
const ProjectTitles: React.FC = () => {
const { id } = useParams<{ id: string }>();
const projectId = id || '';
const [form] = Form.useForm<{ text: string; category: 'default' | 'marketing' | 'tutorial' | 'story' | 'promo' }>();
const queryClient = useQueryClient();
const projectQuery = useQuery({
queryKey: ['project', projectId],
queryFn: () => getProject(projectId),
enabled: !!projectId,
});
const titlesQuery = useQuery({
queryKey: ['project-titles', projectId],
queryFn: () => getProjectTitles(projectId),
enabled: !!projectId,
});
const createMutation = useMutation({
mutationFn: (values: { text: string; category: 'default' | 'marketing' | 'tutorial' | 'story' | 'promo' }) =>
createProjectTitle(projectId, {
workspace_id: projectQuery.data!.workspace_id,
text: values.text,
category: values.category,
}),
onSuccess: () => {
message.success('标题已加入标题库');
form.resetFields();
queryClient.invalidateQueries({ queryKey: ['project-titles', projectId] });
},
onError: () => message.error('标题创建失败'),
});
const toggleMutation = useMutation({
mutationFn: ({ titleId, isActive }: { titleId: string; isActive: boolean }) =>
updateProjectTitle(titleId, { is_active: isActive }),
onSuccess: () => {
message.success('标题状态已更新');
queryClient.invalidateQueries({ queryKey: ['project-titles', projectId] });
},
onError: () => message.error('标题状态更新失败'),
});
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Card title="标题库" extra={<Button onClick={() => titlesQuery.refetch()}>刷新</Button>}>
<Form
form={form}
layout="inline"
initialValues={{ category: 'default' }}
onFinish={(values) => createMutation.mutate(values)}
>
<Form.Item name="text" rules={[{ required: true, message: '请输入标题' }, { max: 200, message: '标题最多 200 字' }]} style={{ flex: 1 }}>
<Input placeholder="输入可复用标题,例如:3 分钟看懂产品亮点" />
</Form.Item>
<Form.Item name="category">
<Select options={categoryOptions} style={{ width: 120 }} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={createMutation.isPending} disabled={!projectQuery.data}>
新增标题
</Button>
</Form.Item>
</Form>
</Card>
<Card title="项目标题">
<List
loading={titlesQuery.isLoading}
dataSource={titlesQuery.data || []}
locale={{ emptyText: '暂无标题,先新增一个标题用于生成脚本和成片命名' }}
renderItem={(item) => (
<List.Item
actions={[
<Switch
key="active"
checked={item.is_active}
checkedChildren="启用"
unCheckedChildren="停用"
onChange={(checked) => toggleMutation.mutate({ titleId: item.id, isActive: checked })}
/>,
]}
>
<List.Item.Meta
title={
<Space wrap>
<span>{item.text}</span>
<Tag color={item.is_active ? 'green' : 'default'}>{item.is_active ? '启用' : '停用'}</Tag>
<Tag>{categoryLabels[item.category] || item.category}</Tag>
</Space>
}
description={`使用次数:${item.usage_count}`}
/>
</List.Item>
)}
/>
</Card>
</Space>
);
};
export const Component = ProjectTitles;
export default ProjectTitles;
@@ -94,6 +94,9 @@ const WorkspaceDetail: React.FC = () => {
<Button key="assets" type="link" onClick={() => navigate(`/projects/${project.id}/assets`, { state: { workspaceId: id } })}>
素材管理
</Button>,
<Button key="titles" type="link" onClick={() => navigate(`/projects/${project.id}/titles`, { state: { workspaceId: id } })}>
标题库
</Button>,
<Button key="generation" type="link" onClick={() => navigate(`/projects/${project.id}/generation`, { state: { workspaceId: id } })}>
视频生成
</Button>,
+4
View File
@@ -64,6 +64,10 @@ export const router = createBrowserRouter([
path: 'projects/:id/assets',
lazy: () => import('@/pages/workspace/ProjectAssets'),
},
{
path: 'projects/:id/titles',
lazy: () => import('@/pages/workspace/ProjectTitles'),
},
{
path: 'projects/:id/generation',
lazy: () => import('@/pages/workspace/ProjectGeneration'),
+16 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone
from sqlalchemy import Boolean, Column, DateTime, Float, String, Text, UniqueConstraint, create_engine
from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, Text, UniqueConstraint, create_engine
from sqlalchemy.orm import declarative_base
Base = declarative_base()
@@ -115,6 +115,21 @@ class AssetModel(Base):
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
class ProjectTitleModel(Base):
__tablename__ = "project_titles"
id = Column(String(36), primary_key=True)
workspace_id = Column(String(36), nullable=False, index=True)
project_id = Column(String(36), nullable=False, index=True)
text = Column(String(200), nullable=False)
category = Column(String(50), nullable=False, default="default", index=True)
usage_count = Column(Integer, nullable=False, default=0)
is_active = Column(Boolean, nullable=False, default=True)
created_by_user_id = Column(String(36), nullable=False)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
class IngestJobModel(Base):
__tablename__ = "ingest_jobs"
@@ -0,0 +1,52 @@
from datetime import datetime, timezone
from uuid import uuid4
from packages.adapters.sqlalchemy_impl.models import ProjectTitleModel
class SQLAlchemyProjectTitleRepository:
def __init__(self, session):
self.session = session
def list_by_project(self, project_id: str, active_only: bool = False) -> list[ProjectTitleModel]:
query = self.session.query(ProjectTitleModel).filter(ProjectTitleModel.project_id == project_id)
if active_only:
query = query.filter(ProjectTitleModel.is_active.is_(True))
return query.order_by(ProjectTitleModel.created_at.desc()).all()
def get(self, title_id: str) -> ProjectTitleModel | None:
return self.session.query(ProjectTitleModel).filter(ProjectTitleModel.id == title_id).first()
def create(
self,
*,
workspace_id: str,
project_id: str,
text: str,
category: str,
created_by_user_id: str,
) -> ProjectTitleModel:
now = datetime.now(timezone.utc)
item = ProjectTitleModel(
id=str(uuid4()),
workspace_id=workspace_id,
project_id=project_id,
text=text.strip(),
category=category,
usage_count=0,
is_active=True,
created_by_user_id=created_by_user_id,
created_at=now,
updated_at=now,
)
self.session.add(item)
self.session.commit()
self.session.refresh(item)
return item
def update(self, item: ProjectTitleModel) -> ProjectTitleModel:
item.updated_at = datetime.now(timezone.utc)
self.session.add(item)
self.session.commit()
self.session.refresh(item)
return item
@@ -0,0 +1,50 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from packages.adapters.sqlalchemy_impl.models import Base
from packages.adapters.sqlalchemy_impl.project_title_repository import SQLAlchemyProjectTitleRepository
def _repository():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine)()
return SQLAlchemyProjectTitleRepository(session)
def test_project_title_repository_creates_and_lists_titles():
repository = _repository()
title = repository.create(
workspace_id="workspace-1",
project_id="project-1",
text=" 3 分钟看懂产品亮点 ",
category="marketing",
created_by_user_id="user-1",
)
assert title.text == "3 分钟看懂产品亮点"
assert title.category == "marketing"
assert title.usage_count == 0
assert title.is_active is True
assert repository.list_by_project("project-1") == [title]
def test_project_title_repository_filters_inactive_titles():
repository = _repository()
title = repository.create(
workspace_id="workspace-1",
project_id="project-1",
text="停用标题",
category="default",
created_by_user_id="user-1",
)
title.is_active = False
repository.update(title)
assert repository.list_by_project("project-1", active_only=True) == []