diff --git a/alembic/versions/004_add_project_title_favorite.py b/alembic/versions/004_add_project_title_favorite.py new file mode 100644 index 000000000..7f2cbc2e1 --- /dev/null +++ b/alembic/versions/004_add_project_title_favorite.py @@ -0,0 +1,26 @@ +"""Add project title favorite flag. + +Revision ID: 004 +Revises: 003 +Create Date: 2026-06-24 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "004" +down_revision: Union[str, None] = "003" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("project_titles", sa.Column("favorite", sa.Boolean(), nullable=False, server_default=sa.false())) + op.create_index(op.f("ix_project_titles_favorite"), "project_titles", ["favorite"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_project_titles_favorite"), table_name="project_titles") + op.drop_column("project_titles", "favorite") diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index 5e11a7d01..a75ce895e 100644 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -77,6 +77,14 @@ def _ensure_library_has_ready_video_assets(assets) -> None: ) +def _select_title_id(project_title_repository: Any, project_id: str) -> str: + active_titles = project_title_repository.list_by_project(project_id, active_only=True) + if not active_titles: + return "" + selected = sorted(active_titles, key=lambda title: (0 if getattr(title, "favorite", False) else 1, int(title.usage_count or 0), title.created_at))[0] + return selected.id + + @router.post("/tasks", response_model=GenerationTaskResponse) def create_generation_task( request: CreateGenerationTaskRequest, @@ -104,16 +112,11 @@ def create_generation_task( workspace_id=request.workspace_id, project_id=request.project_id, asset_library_id=request.asset_library_id, - strategy_id=request.strategy_id, + strategy_id=request.strategy_id or _select_title_id(project_title_repository, request.project_id), voice_library_id=request.voice_library_id, 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 index d7f38881b..4fefc8897 100644 --- a/apps/api/app/api/routes/project_titles.py +++ b/apps/api/app/api/routes/project_titles.py @@ -27,6 +27,7 @@ def _to_response(item) -> ProjectTitleResponse: project_id=item.project_id, text=item.text, category=item.category, + favorite=bool(getattr(item, "favorite", False)), usage_count=int(item.usage_count or 0), is_active=bool(item.is_active), created_at=item.created_at, @@ -73,6 +74,7 @@ def create_project_title( project_id=project_id, text=request.text, category=request.category, + favorite=request.favorite, created_by_user_id=authenticated_user.user.id, ) return _to_response(item) @@ -94,6 +96,8 @@ def update_project_title( item.text = request.text.strip() if request.category is not None: item.category = request.category + if request.favorite is not None: + item.favorite = request.favorite 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/schemas/project_title.py b/apps/api/app/schemas/project_title.py index cb23da231..c531b9059 100644 --- a/apps/api/app/schemas/project_title.py +++ b/apps/api/app/schemas/project_title.py @@ -12,6 +12,7 @@ class ProjectTitleResponse(BaseModel): project_id: str text: str category: str + favorite: bool usage_count: int is_active: bool created_at: datetime @@ -26,9 +27,11 @@ class CreateProjectTitleRequest(BaseModel): workspace_id: str text: str = Field(min_length=1, max_length=200) category: TitleCategory = "default" + favorite: bool = False class UpdateProjectTitleRequest(BaseModel): text: str | None = Field(default=None, min_length=1, max_length=200) category: TitleCategory | None = None + favorite: bool | None = None is_active: bool | None = None diff --git a/apps/web/e2e/core-generation.spec.ts b/apps/web/e2e/core-generation.spec.ts index ddc505261..d8d511dd6 100644 --- a/apps/web/e2e/core-generation.spec.ts +++ b/apps/web/e2e/core-generation.spec.ts @@ -11,7 +11,8 @@ type WorkspaceResponse = { id?: string; workspace_id?: string }; type ProjectResponse = { id: string }; type LibraryResponse = { id: string }; type AssetListResponse = { items: Array<{ name: string; status: string; mime_type?: string; file_type?: string }> }; -type GenerationTaskResponse = { id: string; status: string; progress: number; result_count: number; error_message?: string | null }; +type GenerationTaskResponse = { id: string; status: string; progress: number; result_count: number; error_message?: string | null; strategy_id?: string | null }; +type ProjectTitleResponse = { id: string; text: string; usage_count: number }; type GeneratedVideoResponse = { id: string; name: string; file_url: string; file_size: number }; test.describe('Core generation and download flow', () => { @@ -59,6 +60,14 @@ test.describe('Core generation and download flow', () => { expect(library.status(), await library.text()).toBe(200); const libraryData = (await library.json()) as LibraryResponse; + const projectTitleText = `E2E 生成标题 ${suffix}`; + const title = await request.post(`${apiBase}/projects/${projectData.id}/titles`, { + headers, + data: { workspace_id: workspaceId, text: projectTitleText, category: 'marketing', favorite: true }, + }); + expect(title.status(), await title.text()).toBe(200); + const titleData = (await title.json()) as ProjectTitleResponse; + const fixture = fs.readFileSync(path.join(currentDir, 'fixtures', 'sample.mp4')); const upload = await request.post(`${apiBase}/upload`, { headers, @@ -103,6 +112,8 @@ test.describe('Core generation and download flow', () => { await expect(page.getByText('项目生成任务')).toBeVisible({ timeout: 20_000 }); await page.locator('.ant-select-selector').first().click(); await page.getByText(`${libraryName} (video)`).click(); + await page.getByLabel('标题').locator('.ant-select-selector').click(); + await page.getByText(projectTitleText).click(); await expect(page.getByText(/素材准备度:/)).toBeVisible({ timeout: 20_000 }); await expect(page.getByRole('button', { name: '发起生成' })).toBeEnabled({ timeout: 20_000 }); @@ -127,11 +138,11 @@ test.describe('Core generation and download flow', () => { return `http_${task.status()}`; } const data = (await task.json()) as GenerationTaskResponse; - return `${data.status}:${data.result_count}:${data.error_message || ''}`; + return `${data.status}:${data.result_count}:${data.strategy_id || ''}:${data.error_message || ''}`; }, { timeout: 90_000, intervals: [1_000, 2_000, 5_000] } ) - .toMatch(/^completed:[1-9]\d*:/); + .toMatch(new RegExp(`^completed:[1-9]\\d*:${titleData.id}:`)); const results = await request.get(`${apiBase}/generation/tasks/${createdTask.id}/results`, { headers }); expect(results.status(), await results.text()).toBe(200); @@ -161,6 +172,9 @@ test.describe('Core generation and download flow', () => { const sourceAsset = assetsAfterGenerationData.items.find((item) => item.name === 'e2e-generation-source.mp4'); expect(sourceAsset?.metadata.generation_use_count).toBe(1); expect(sourceAsset?.metadata.review_status).toBe('pending_review'); - expect(sourceAsset?.metadata.last_used_at).toBeTruthy(); + const titleAfterGeneration = await request.get(`${apiBase}/projects/${projectData.id}/titles`, { headers }); + expect(titleAfterGeneration.status(), await titleAfterGeneration.text()).toBe(200); + const titlesData = (await titleAfterGeneration.json()) as { items: ProjectTitleResponse[] }; + expect(titlesData.items.find((item) => item.id === titleData.id)?.usage_count).toBe(1); }); }); diff --git a/apps/web/e2e/core-titles.spec.ts b/apps/web/e2e/core-titles.spec.ts index 6d156f325..7262ed9da 100644 --- a/apps/web/e2e/core-titles.spec.ts +++ b/apps/web/e2e/core-titles.spec.ts @@ -43,9 +43,13 @@ test.describe('Project title library flow', () => { await page.goto(`/projects/${projectData.id}/titles`); await expect(page.getByText('标题库')).toBeVisible({ timeout: 20_000 }); await page.getByPlaceholder('输入可复用标题').fill(`E2E 标题 ${suffix}`); + await page.getByRole('switch', { name: /普通|常用/ }).first().click(); 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('常用')).toBeVisible(); + await page.getByPlaceholder('搜索标题').fill(`E2E 标题 ${suffix}`); + await expect(page.getByText(`E2E 标题 ${suffix}`)).toBeVisible(); await expect(page.getByText('使用次数:0')).toBeVisible(); }); }); diff --git a/apps/web/src/api/projectTitles.ts b/apps/web/src/api/projectTitles.ts index 3c719769d..c5c04921a 100644 --- a/apps/web/src/api/projectTitles.ts +++ b/apps/web/src/api/projectTitles.ts @@ -6,6 +6,7 @@ export interface ProjectTitleItem { project_id: string; text: string; category: 'default' | 'marketing' | 'tutorial' | 'story' | 'promo'; + favorite: boolean; usage_count: number; is_active: boolean; created_at: string; @@ -17,12 +18,12 @@ export const getProjectTitles = async (projectId: string, activeOnly = false): P return response.data.items; }; -export const createProjectTitle = async (projectId: string, data: { workspace_id: string; text: string; category?: ProjectTitleItem['category'] }): Promise => { +export const createProjectTitle = async (projectId: string, data: { workspace_id: string; text: string; category?: ProjectTitleItem['category']; favorite?: boolean }): 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 => { +export const updateProjectTitle = async (titleId: string, data: { text?: string; category?: ProjectTitleItem['category']; favorite?: boolean; is_active?: boolean }): Promise => { const response = await apiClient.patch(`/project-titles/${titleId}`, data); return response.data; }; diff --git a/apps/web/src/pages/workspace/ProjectTitles.tsx b/apps/web/src/pages/workspace/ProjectTitles.tsx index 4de37d440..36d6e5cc9 100644 --- a/apps/web/src/pages/workspace/ProjectTitles.tsx +++ b/apps/web/src/pages/workspace/ProjectTitles.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo, useState } 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'; @@ -24,7 +24,10 @@ const categoryLabels: Record = { 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 [form] = Form.useForm<{ text: string; category: 'default' | 'marketing' | 'tutorial' | 'story' | 'promo'; favorite: boolean }>(); + const [searchText, setSearchText] = useState(''); + const [categoryFilter, setCategoryFilter] = useState('all'); + const [favoriteOnly, setFavoriteOnly] = useState(false); const queryClient = useQueryClient(); const projectQuery = useQuery({ @@ -40,11 +43,12 @@ const ProjectTitles: React.FC = () => { }); const createMutation = useMutation({ - mutationFn: (values: { text: string; category: 'default' | 'marketing' | 'tutorial' | 'story' | 'promo' }) => + mutationFn: (values: { text: string; category: 'default' | 'marketing' | 'tutorial' | 'story' | 'promo'; favorite: boolean }) => createProjectTitle(projectId, { workspace_id: projectQuery.data!.workspace_id, text: values.text, category: values.category, + favorite: values.favorite, }), onSuccess: () => { message.success('标题已加入标题库'); @@ -55,8 +59,8 @@ const ProjectTitles: React.FC = () => { }); const toggleMutation = useMutation({ - mutationFn: ({ titleId, isActive }: { titleId: string; isActive: boolean }) => - updateProjectTitle(titleId, { is_active: isActive }), + mutationFn: ({ titleId, isActive, favorite }: { titleId: string; isActive?: boolean; favorite?: boolean }) => + updateProjectTitle(titleId, { is_active: isActive, favorite }), onSuccess: () => { message.success('标题状态已更新'); queryClient.invalidateQueries({ queryKey: ['project-titles', projectId] }); @@ -64,13 +68,23 @@ const ProjectTitles: React.FC = () => { onError: () => message.error('标题状态更新失败'), }); + const filteredTitles = useMemo(() => { + const keyword = searchText.trim().toLowerCase(); + return (titlesQuery.data || []).filter((item) => { + if (categoryFilter !== 'all' && item.category !== categoryFilter) return false; + if (favoriteOnly && !item.favorite) return false; + if (keyword && !item.text.toLowerCase().includes(keyword)) return false; + return true; + }); + }, [categoryFilter, favoriteOnly, searchText, titlesQuery.data]); + return ( titlesQuery.refetch()}>刷新}>
createMutation.mutate(values)} > @@ -79,6 +93,9 @@ const ProjectTitles: React.FC = () => { + + ( toggleMutation.mutate({ titleId: item.id, favorite: checked })} + />, { title={ {item.text} + {item.favorite && 常用} {item.is_active ? '启用' : '停用'} {categoryLabels[item.category] || item.category} diff --git a/apps/worker/worker_app/core/title_usage.py b/apps/worker/worker_app/core/title_usage.py new file mode 100644 index 000000000..a8644bbbe --- /dev/null +++ b/apps/worker/worker_app/core/title_usage.py @@ -0,0 +1,15 @@ +from datetime import datetime, timezone + +from packages.adapters.sqlalchemy_impl.models import ProjectTitleModel + + +def mark_title_used_for_generation(db, task) -> None: + if not task.strategy_id: + return + title = db.query(ProjectTitleModel).filter(ProjectTitleModel.id == task.strategy_id).first() + if title is None or title.workspace_id != task.workspace_id or title.project_id != task.project_id: + return + title.usage_count = int(title.usage_count or 0) + 1 + title.updated_at = datetime.now(timezone.utc) + db.add(title) + db.commit() diff --git a/apps/worker/worker_app/tasks/generation.py b/apps/worker/worker_app/tasks/generation.py index 47e3e38fb..24c05bf20 100644 --- a/apps/worker/worker_app/tasks/generation.py +++ b/apps/worker/worker_app/tasks/generation.py @@ -9,6 +9,7 @@ from urllib.parse import urlparse import oss2 from worker_app.core.asset_usage import mark_asset_used_for_generation +from worker_app.core.title_usage import mark_title_used_for_generation from worker_app.celery_app import celery_app from worker_app.db import SessionLocal @@ -249,6 +250,7 @@ def generate_video(task_id: str) -> dict: task.error_message = "" task.completed_at = datetime.now(timezone.utc) task_repo.update(task) + mark_title_used_for_generation(db, task) return { "status": "completed", diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index ece30492f..f98e3c5f6 100644 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -123,6 +123,7 @@ class ProjectTitleModel(Base): 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) + favorite = Column(Boolean, nullable=False, default=False, 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) diff --git a/packages/adapters/sqlalchemy_impl/project_title_repository.py b/packages/adapters/sqlalchemy_impl/project_title_repository.py index 31124a9f3..d2ed31eeb 100644 --- a/packages/adapters/sqlalchemy_impl/project_title_repository.py +++ b/packages/adapters/sqlalchemy_impl/project_title_repository.py @@ -25,6 +25,7 @@ class SQLAlchemyProjectTitleRepository: text: str, category: str, created_by_user_id: str, + favorite: bool = False, ) -> ProjectTitleModel: now = datetime.now(timezone.utc) item = ProjectTitleModel( @@ -33,6 +34,7 @@ class SQLAlchemyProjectTitleRepository: project_id=project_id, text=text.strip(), category=category, + favorite=favorite, usage_count=0, is_active=True, created_by_user_id=created_by_user_id, diff --git a/tests/unit/test_project_title_generation.py b/tests/unit/test_project_title_generation.py new file mode 100644 index 000000000..5488cb00a --- /dev/null +++ b/tests/unit/test_project_title_generation.py @@ -0,0 +1,75 @@ +import sys +from datetime import datetime, timezone +from pathlib import Path +from types import SimpleNamespace + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker")) + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.api.routes.generation_tasks import _select_title_id +from packages.adapters.sqlalchemy_impl.models import Base, ProjectTitleModel +from packages.adapters.sqlalchemy_impl.project_title_repository import SQLAlchemyProjectTitleRepository +from worker_app.core.title_usage import mark_title_used_for_generation + + +def _session_and_repository(): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + session = sessionmaker(bind=engine)() + return session, SQLAlchemyProjectTitleRepository(session) + + +def test_select_title_prefers_favorite_then_lowest_usage(): + _, repository = _session_and_repository() + normal = repository.create( + workspace_id="workspace-1", + project_id="project-1", + text="普通标题", + category="default", + created_by_user_id="user-1", + ) + favorite = repository.create( + workspace_id="workspace-1", + project_id="project-1", + text="常用标题", + category="default", + favorite=True, + created_by_user_id="user-1", + ) + normal.usage_count = 0 + favorite.usage_count = 10 + repository.update(normal) + repository.update(favorite) + + assert _select_title_id(repository, "project-1") == favorite.id + + +def test_mark_title_used_after_generation_completion(): + session, _ = _session_and_repository() + now = datetime.now(timezone.utc) + title = ProjectTitleModel( + id="title-1", + workspace_id="workspace-1", + project_id="project-1", + text="生成标题", + category="default", + favorite=False, + usage_count=2, + is_active=True, + created_by_user_id="user-1", + created_at=now, + updated_at=now, + ) + session.add(title) + session.commit() + + mark_title_used_for_generation( + session, + SimpleNamespace(strategy_id="title-1", workspace_id="workspace-1", project_id="project-1"), + ) + + updated = session.query(ProjectTitleModel).filter(ProjectTitleModel.id == "title-1").first() + assert updated.usage_count == 3 diff --git a/tests/unit/test_project_title_repository.py b/tests/unit/test_project_title_repository.py index 35c401a37..17e850352 100644 --- a/tests/unit/test_project_title_repository.py +++ b/tests/unit/test_project_title_repository.py @@ -25,11 +25,13 @@ def test_project_title_repository_creates_and_lists_titles(): project_id="project-1", text=" 3 分钟看懂产品亮点 ", category="marketing", + favorite=True, created_by_user_id="user-1", ) assert title.text == "3 分钟看懂产品亮点" assert title.category == "marketing" + assert title.favorite is True assert title.usage_count == 0 assert title.is_active is True assert repository.list_by_project("project-1") == [title]