feat(titles): add project title library
This commit is contained in:
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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) == []
|
||||
Reference in New Issue
Block a user