diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 3f07c89e9..98d0beefd 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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' diff --git a/alembic/versions/003_add_project_titles.py b/alembic/versions/003_add_project_titles.py new file mode 100644 index 000000000..2b5b09d1a --- /dev/null +++ b/alembic/versions/003_add_project_titles.py @@ -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") diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index e4a39040c..50347f8ae 100644 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -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=["素材诊断"], diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index f13a0121d..5e11a7d01 100644 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -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) diff --git a/apps/api/app/api/routes/project_titles.py b/apps/api/app/api/routes/project_titles.py new file mode 100644 index 000000000..d7f38881b --- /dev/null +++ b/apps/api/app/api/routes/project_titles.py @@ -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)) diff --git a/apps/api/app/dependencies.py b/apps/api/app/dependencies.py index 0ed7bfb36..e0c0d57ac 100644 --- a/apps/api/app/dependencies.py +++ b/apps/api/app/dependencies.py @@ -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: diff --git a/apps/api/app/schemas/project_title.py b/apps/api/app/schemas/project_title.py new file mode 100644 index 000000000..cb23da231 --- /dev/null +++ b/apps/api/app/schemas/project_title.py @@ -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 diff --git a/apps/web/e2e/core-titles.spec.ts b/apps/web/e2e/core-titles.spec.ts new file mode 100644 index 000000000..6d156f325 --- /dev/null +++ b/apps/web/e2e/core-titles.spec.ts @@ -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(); + }); +}); diff --git a/apps/web/src/api/projectTitles.ts b/apps/web/src/api/projectTitles.ts new file mode 100644 index 000000000..3c719769d --- /dev/null +++ b/apps/web/src/api/projectTitles.ts @@ -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 => { + 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 => { + 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 => { + const response = await apiClient.patch(`/project-titles/${titleId}`, data); + return response.data; +}; diff --git a/apps/web/src/pages/workspace/ProjectGeneration.tsx b/apps/web/src/pages/workspace/ProjectGeneration.tsx index ff2dbabb3..73f90c1c2 100644 --- a/apps/web/src/pages/workspace/ProjectGeneration.tsx +++ b/apps/web/src/pages/workspace/ProjectGeneration.tsx @@ -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 = () => { + diff --git a/apps/web/src/pages/workspace/ProjectTitles.tsx b/apps/web/src/pages/workspace/ProjectTitles.tsx new file mode 100644 index 000000000..4de37d440 --- /dev/null +++ b/apps/web/src/pages/workspace/ProjectTitles.tsx @@ -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 = { + 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 ( + + titlesQuery.refetch()}>刷新}> +
createMutation.mutate(values)} + > + + + + +