refactor: 前端代码清理-移除无用代码和遗留demo
Deploy / Staging E2E Tests (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Failing after 138h57m55s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 138h58m2s
Deploy / Staging E2E Tests (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Failing after 138h57m55s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 138h58m2s
- 删除 apps/web/app/ 遗留 Next.js demo 目录(9 个文件) 该目录是早期项目推进器 demo,与主 Vite 应用完全隔离,不被 src/ 引用 - 删除 3 个未被 import 的 editing-planner 组件 TemplatePanel.tsx / SettingsPanel.tsx / GenerateModal.tsx - 移除 3 个未使用的 npm 依赖 react-hook-form / recharts / zod(仅 package.json 声明,源码无引用) - TypeScript 零错误,Vite 构建正常通过
This commit is contained in:
@@ -1,145 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
interface CreateIssueFormProps {
|
||||
taskId: string;
|
||||
projectId: string;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function CreateIssueForm({ taskId, projectId, onSuccess, onCancel }: CreateIssueFormProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/project-management/issues`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
task_id: taskId,
|
||||
project_id: projectId,
|
||||
...formData,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.detail || '创建失败');
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={{
|
||||
background: 'var(--bg-white)',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
}}>
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: '#FFECE8',
|
||||
border: '1px solid var(--error)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--error)',
|
||||
marginBottom: '16px',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500' }}>
|
||||
问题标题 <span style={{ color: 'var(--error)' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
placeholder="简要描述问题"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500' }}>
|
||||
详细描述
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
placeholder="详细说明问题情况"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: loading ? '#ccc' : 'var(--primary)',
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{loading ? '创建中...' : '创建问题'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'white',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
interface CreateTaskFormProps {
|
||||
projectId: string;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export default function CreateTaskForm({ projectId, onSuccess, onCancel }: CreateTaskFormProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
assignee_user_id: '',
|
||||
parent_task_id: '',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/project-management/tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project_id: projectId,
|
||||
...formData,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.detail || '创建失败');
|
||||
}
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
} else {
|
||||
router.push('/projects');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={{
|
||||
background: 'var(--bg-white)',
|
||||
padding: '24px',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
}}>
|
||||
<h2 style={{ marginBottom: '20px', fontSize: '20px', fontWeight: 'bold' }}>新增任务</h2>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: '#FFECE8',
|
||||
border: '1px solid var(--error)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--error)',
|
||||
marginBottom: '20px',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
|
||||
任务名称 <span style={{ color: 'var(--error)' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
placeholder="输入任务名称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
|
||||
任务描述
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={4}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
placeholder="详细描述任务内容"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
|
||||
优先级
|
||||
</label>
|
||||
<select
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="low">低</option>
|
||||
<option value="medium">中</option>
|
||||
<option value="high">高</option>
|
||||
<option value="urgent">紧急</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
|
||||
负责人 ID(可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.assignee_user_id}
|
||||
onChange={(e) => setFormData({ ...formData, assignee_user_id: e.target.value })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
placeholder="输入负责人 ID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: loading ? '#ccc' : 'var(--primary)',
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{loading ? '创建中...' : '创建任务'}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'white',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
interface EditTaskFormProps {
|
||||
taskId: string;
|
||||
initialData: {
|
||||
name: string;
|
||||
description: string;
|
||||
priority: string;
|
||||
assignee_user_id: string;
|
||||
};
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export default function EditTaskForm({ taskId, initialData, onSuccess, onCancel }: EditTaskFormProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [formData, setFormData] = useState(initialData);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/project-management/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.detail || '保存失败');
|
||||
}
|
||||
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={{
|
||||
background: 'var(--bg-white)',
|
||||
padding: '24px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
}}>
|
||||
<h3 style={{ marginBottom: '20px', fontSize: '18px', fontWeight: 'bold' }}>编辑任务</h3>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: '#FFECE8',
|
||||
border: '1px solid var(--error)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--error)',
|
||||
marginBottom: '20px',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
|
||||
任务名称 <span style={{ color: 'var(--error)' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
|
||||
任务描述
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={4}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
|
||||
优先级
|
||||
</label>
|
||||
<select
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="low">低</option>
|
||||
<option value="medium">中</option>
|
||||
<option value="high">高</option>
|
||||
<option value="urgent">紧急</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: loading ? '#ccc' : 'var(--primary)',
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{loading ? '保存中...' : '保存修改'}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'white',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Microsoft YaHei", sans-serif;
|
||||
background: #F5F7FA;
|
||||
color: #1D2129;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary: #165DFF;
|
||||
--primary-hover: #0e48d1;
|
||||
--border: #E5E6EB;
|
||||
--bg-white: #fff;
|
||||
--bg-gray: #F5F7FA;
|
||||
--text-primary: #1D2129;
|
||||
--text-secondary: #6E7681;
|
||||
--success: #00B42A;
|
||||
--warning: #FF7D00;
|
||||
--error: #F53F3F;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const metadata: Metadata = {
|
||||
title: "小虾 SaaS - 项目推进器",
|
||||
description: "AI 视频自动化剪辑系统 - 项目管理",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Milestone {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
target_date: string | null;
|
||||
completed: boolean;
|
||||
completed_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
export default function MilestonesPage() {
|
||||
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [formData, setFormData] = useState({ name: '', description: '' });
|
||||
|
||||
const projectId = 'demo_project_1';
|
||||
|
||||
useEffect(() => {
|
||||
fetchMilestones();
|
||||
}, []);
|
||||
|
||||
const fetchMilestones = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/project-management/milestones?project_id=${projectId}`);
|
||||
if (!res.ok) throw new Error('获取里程碑列表失败');
|
||||
const data = await res.json();
|
||||
setMilestones(data);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateMilestone = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/project-management/milestones`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project_id: projectId,
|
||||
...formData,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error('创建失败');
|
||||
setFormData({ name: '', description: '' });
|
||||
setShowCreateForm(false);
|
||||
fetchMilestones();
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
|
||||
{/* Header */}
|
||||
<header style={{
|
||||
height: '60px',
|
||||
background: 'var(--bg-white)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 20px',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
|
||||
<Link href="/" style={{ fontSize: '18px', fontWeight: 'bold', color: 'var(--primary)', textDecoration: 'none' }}>
|
||||
📁 项目推进器
|
||||
</Link>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>里程碑管理</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(!showCreateForm)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: 'var(--primary)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{showCreateForm ? '取消' : '+ 新增里程碑'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Main */}
|
||||
<main style={{ flex: 1, padding: '20px', overflow: 'auto' }}>
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: '#FFECE8',
|
||||
border: '1px solid var(--error)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--error)',
|
||||
marginBottom: '20px',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreateForm && (
|
||||
<div style={{ background: 'var(--bg-white)', padding: '24px', borderRadius: '8px', marginBottom: '20px' }}>
|
||||
<h3 style={{ marginBottom: '16px', fontSize: '18px', fontWeight: 'bold' }}>新增里程碑</h3>
|
||||
<form onSubmit={handleCreateMilestone}>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500' }}>里程碑名称 *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
placeholder="例如:V1.0 发布"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500' }}>描述</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
placeholder="详细说明里程碑内容"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: 'var(--primary)',
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
创建里程碑
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{milestones.length === 0 ? (
|
||||
<div style={{
|
||||
background: 'var(--bg-white)',
|
||||
borderRadius: '8px',
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
color: 'var(--text-secondary)',
|
||||
}}>
|
||||
<p>暂无里程碑</p>
|
||||
<p style={{ fontSize: '14px', marginTop: '8px' }}>点击右上角"+ 新增里程碑"创建第一个里程碑</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
{milestones.map((milestone) => (
|
||||
<div
|
||||
key={milestone.id}
|
||||
style={{
|
||||
background: 'var(--bg-white)',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
border: `2px solid ${milestone.completed ? 'var(--success)' : 'var(--border)'}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '24px' }}>{milestone.completed ? '🎉' : '🎯'}</span>
|
||||
<h3 style={{ fontSize: '18px', fontWeight: 'bold', flex: 1 }}>{milestone.name}</h3>
|
||||
<span style={{
|
||||
padding: '4px 12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
background: milestone.completed ? 'var(--success)' : '#E5E6EB',
|
||||
color: milestone.completed ? 'white' : 'var(--text-secondary)',
|
||||
}}>
|
||||
{milestone.completed ? '已完成' : '进行中'}
|
||||
</span>
|
||||
</div>
|
||||
{milestone.description && (
|
||||
<p style={{ color: 'var(--text-secondary)', lineHeight: '1.6', marginBottom: '12px' }}>
|
||||
{milestone.description}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
|
||||
创建时间:{new Date(milestone.created_at).toLocaleDateString('zh-CN')}
|
||||
{milestone.completed_at && (
|
||||
<span style={{ marginLeft: '12px' }}>
|
||||
完成时间:{new Date(milestone.completed_at).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '20px'
|
||||
}}>
|
||||
<h1 style={{ fontSize: '32px', fontWeight: 'bold', color: 'var(--primary)' }}>
|
||||
📁 小虾 SaaS 项目推进器
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>
|
||||
完整的项目管理与任务跟踪系统
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<a
|
||||
href="/projects"
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: 'var(--primary)',
|
||||
color: 'white',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
进入项目列表
|
||||
</a>
|
||||
<a
|
||||
href="/milestones"
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: 'white',
|
||||
color: 'var(--primary)',
|
||||
border: '1px solid var(--primary)',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
里程碑管理
|
||||
</a>
|
||||
<a
|
||||
href="/api/docs"
|
||||
target="_blank"
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: 'white',
|
||||
color: 'var(--primary)',
|
||||
border: '1px solid var(--primary)',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none'
|
||||
}}
|
||||
>
|
||||
查看 API 文档
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import CreateTaskForm from '../components/CreateTaskForm';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
progress: number;
|
||||
assignee_user_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
// 模拟项目ID,生产环境应该从路由或上下文获取
|
||||
const projectId = 'demo_project_1';
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, []);
|
||||
|
||||
const fetchTasks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/project-management/tasks?project_id=${projectId}`);
|
||||
if (!res.ok) throw new Error('获取任务列表失败');
|
||||
const data = await res.json();
|
||||
setTasks(data);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
pending: '#86909C',
|
||||
in_progress: '#165DFF',
|
||||
completed: '#00B42A',
|
||||
blocked: '#F53F3F',
|
||||
cancelled: '#6E7681',
|
||||
};
|
||||
return colors[status] || '#6E7681';
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const texts: Record<string, string> = {
|
||||
pending: '待开始',
|
||||
in_progress: '进行中',
|
||||
completed: '已完成',
|
||||
blocked: '阻塞',
|
||||
cancelled: '已取消',
|
||||
};
|
||||
return texts[status] || status;
|
||||
};
|
||||
|
||||
const getPriorityText = (priority: string) => {
|
||||
const texts: Record<string, string> = {
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
urgent: '紧急',
|
||||
};
|
||||
return texts[priority] || priority;
|
||||
};
|
||||
|
||||
if (loading && !showCreateForm) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>加载中...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
|
||||
{/* Header */}
|
||||
<header style={{
|
||||
height: '60px',
|
||||
background: 'var(--bg-white)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 20px',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
|
||||
<Link href="/" style={{ fontSize: '18px', fontWeight: 'bold', color: 'var(--primary)', textDecoration: 'none' }}>
|
||||
📁 项目推进器
|
||||
</Link>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>Demo 项目</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button
|
||||
onClick={fetchTasks}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg-white)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(!showCreateForm)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: 'var(--primary)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{showCreateForm ? '取消' : '+ 新增任务'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main style={{ flex: 1, padding: '20px', overflow: 'auto' }}>
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: '#FFECE8',
|
||||
border: '1px solid #F53F3F',
|
||||
borderRadius: '4px',
|
||||
color: '#F53F3F',
|
||||
marginBottom: '20px',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreateForm ? (
|
||||
<CreateTaskForm
|
||||
projectId={projectId}
|
||||
onSuccess={() => {
|
||||
setShowCreateForm(false);
|
||||
fetchTasks();
|
||||
}}
|
||||
onCancel={() => setShowCreateForm(false)}
|
||||
/>
|
||||
) : tasks.length === 0 ? (
|
||||
<div style={{
|
||||
background: 'var(--bg-white)',
|
||||
borderRadius: '8px',
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
color: 'var(--text-secondary)',
|
||||
}}>
|
||||
<p>暂无任务</p>
|
||||
<p style={{ fontSize: '14px', marginTop: '8px' }}>点击右上角"+ 新增任务"创建第一个任务</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: 'var(--bg-white)',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', color: 'var(--text-secondary)', fontWeight: 'normal' }}>任务名称</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', color: 'var(--text-secondary)', fontWeight: 'normal' }}>状态</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', color: 'var(--text-secondary)', fontWeight: 'normal' }}>优先级</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', color: 'var(--text-secondary)', fontWeight: 'normal' }}>进度</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', color: 'var(--text-secondary)', fontWeight: 'normal' }}>创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((task) => (
|
||||
<tr key={task.id} style={{ borderBottom: '1px solid #F7F8FA' }}>
|
||||
<td style={{ padding: '12px', fontWeight: '500' }}>
|
||||
<Link href={`/tasks/${task.id}`} style={{ color: 'var(--primary)', textDecoration: 'none' }}>
|
||||
{task.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
color: 'white',
|
||||
background: getStatusColor(task.status),
|
||||
}}>
|
||||
{getStatusText(task.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', color: 'var(--text-secondary)' }}>
|
||||
{getPriorityText(task.priority)}
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
height: '6px',
|
||||
background: '#E5E6EB',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${task.progress}%`,
|
||||
height: '100%',
|
||||
background: 'var(--primary)',
|
||||
transition: 'width 0.3s',
|
||||
}} />
|
||||
</div>
|
||||
<span style={{ fontSize: '12px', color: 'var(--text-secondary)', minWidth: '40px' }}>
|
||||
{task.progress}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '12px', color: 'var(--text-secondary)' }}>
|
||||
{new Date(task.created_at).toLocaleDateString('zh-CN')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer style={{
|
||||
height: '30px',
|
||||
lineHeight: '30px',
|
||||
background: 'var(--bg-white)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
padding: '0 20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-secondary)',
|
||||
}}>
|
||||
<div>当前项目:Demo 项目</div>
|
||||
<div>总计任务:{tasks.length}</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import CreateIssueForm from '../../components/CreateIssueForm';
|
||||
import EditTaskForm from '../../components/EditTaskForm';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
progress: number;
|
||||
assignee_user_id: string;
|
||||
parent_task_id: string;
|
||||
project_id: string;
|
||||
planned_start_date: string | null;
|
||||
planned_end_date: string | null;
|
||||
actual_start_date: string | null;
|
||||
actual_end_date: string | null;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface TaskIssue {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
resolved: boolean;
|
||||
resolved_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
export default function TaskDetailPage() {
|
||||
const params = useParams();
|
||||
const taskId = params.id as string;
|
||||
|
||||
const [task, setTask] = useState<Task | null>(null);
|
||||
const [issues, setIssues] = useState<TaskIssue[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [showIssueForm, setShowIssueForm] = useState(false);
|
||||
const [showEditForm, setShowEditForm] = useState(false);
|
||||
|
||||
const fetchTaskDetail = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/project-management/tasks/${taskId}`);
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
throw new Error('任务不存在');
|
||||
}
|
||||
throw new Error('获取任务详情失败');
|
||||
}
|
||||
const data = await res.json();
|
||||
setTask(data);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
const fetchTaskIssues = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/project-management/issues?task_id=${taskId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setIssues(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取问题列表失败:', err);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTaskDetail();
|
||||
fetchTaskIssues();
|
||||
}, [fetchTaskDetail, fetchTaskIssues]);
|
||||
|
||||
const resolveIssue = async (issueId: string) => {
|
||||
setUpdating(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/project-management/issues/${issueId}/resolve`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
if (!res.ok) throw new Error('解决问题失败');
|
||||
await fetchTaskIssues();
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatus = async (newStatus: string) => {
|
||||
setUpdating(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/project-management/tasks/${taskId}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
if (!res.ok) throw new Error('更新状态失败');
|
||||
await fetchTaskDetail();
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateProgress = async (newProgress: number) => {
|
||||
setUpdating(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/project-management/tasks/${taskId}/progress`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ progress: newProgress }),
|
||||
});
|
||||
if (!res.ok) throw new Error('更新进度失败');
|
||||
await fetchTaskDetail();
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: 'var(--bg-gray)' }}>
|
||||
{/* Header */}
|
||||
<header style={{
|
||||
height: '60px',
|
||||
background: 'var(--bg-white)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 20px',
|
||||
gap: '20px',
|
||||
}}>
|
||||
<Link href="/projects" style={{ fontSize: '18px', fontWeight: 'bold', color: 'var(--primary)', textDecoration: 'none' }}>
|
||||
← 返回任务列表
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{/* Main */}
|
||||
<main style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
background: 'var(--bg-white)',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: '12px' }}>{error}</p>
|
||||
<p style={{ fontSize: '14px', color: 'var(--text-secondary)' }}>
|
||||
提示:需要在后端补充 <code>GET /api/v1/project-management/tasks/{'{task_id}'}</code> 接口
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task && (
|
||||
<div style={{ display: 'grid', gap: '20px' }}>
|
||||
{/* 任务基本信息 */}
|
||||
<div style={{ background: 'var(--bg-white)', padding: '24px', borderRadius: '8px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 'bold' }}>{task.name}</h1>
|
||||
<button
|
||||
onClick={() => setShowEditForm(!showEditForm)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'white',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{showEditForm ? '取消编辑' : '编辑任务'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showEditForm ? (
|
||||
<EditTaskForm
|
||||
taskId={taskId}
|
||||
initialData={{
|
||||
name: task.name,
|
||||
description: task.description,
|
||||
priority: task.priority,
|
||||
assignee_user_id: task.assignee_user_id,
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setShowEditForm(false);
|
||||
fetchTaskDetail();
|
||||
}}
|
||||
onCancel={() => setShowEditForm(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<p style={{ color: 'var(--text-secondary)', lineHeight: '1.6', marginBottom: '20px' }}>
|
||||
{task.description || '暂无描述'}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '20px', paddingTop: '20px', borderTop: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>状态</span>
|
||||
<select
|
||||
value={task.status}
|
||||
onChange={(e) => updateStatus(e.target.value)}
|
||||
disabled={updating}
|
||||
style={{
|
||||
display: 'block',
|
||||
marginTop: '8px',
|
||||
padding: '6px 10px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
fontWeight: 'bold',
|
||||
cursor: updating ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value="pending">待开始</option>
|
||||
<option value="in_progress">进行中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="blocked">阻塞</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>优先级</span>
|
||||
<p style={{ fontWeight: 'bold', marginTop: '8px' }}>{task.priority}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>进度</span>
|
||||
<div style={{ marginTop: '8px', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={task.progress}
|
||||
onChange={(e) => updateProgress(parseFloat(e.target.value))}
|
||||
disabled={updating}
|
||||
style={{ flex: 1, cursor: updating ? 'not-allowed' : 'pointer' }}
|
||||
/>
|
||||
<span style={{ fontWeight: 'bold', minWidth: '45px' }}>{task.progress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 问题卡点列表 */}
|
||||
<div style={{ background: 'var(--bg-white)', padding: '24px', borderRadius: '8px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: 'bold' }}>问题卡点 ({issues.length})</h2>
|
||||
<button
|
||||
onClick={() => setShowIssueForm(!showIssueForm)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: 'var(--primary)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{showIssueForm ? '取消' : '+ 添加问题'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showIssueForm && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<CreateIssueForm
|
||||
taskId={taskId}
|
||||
projectId={task.project_id}
|
||||
onSuccess={() => {
|
||||
setShowIssueForm(false);
|
||||
fetchTaskIssues();
|
||||
}}
|
||||
onCancel={() => setShowIssueForm(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{issues.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-secondary)' }}>暂无问题</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{issues.map(issue => (
|
||||
<div key={issue.id} style={{
|
||||
padding: '12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}>
|
||||
<span style={{ fontSize: '20px' }}>{issue.resolved ? '🟢' : '🔴'}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontWeight: '500' }}>{issue.title}</p>
|
||||
{issue.description && (
|
||||
<p style={{ fontSize: '14px', color: 'var(--text-secondary)', marginTop: '4px' }}>
|
||||
{issue.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!issue.resolved && (
|
||||
<button
|
||||
onClick={() => resolveIssue(issue.id)}
|
||||
disabled={updating}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--success)',
|
||||
background: 'white',
|
||||
color: 'var(--success)',
|
||||
cursor: updating ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
标记已解决
|
||||
</button>
|
||||
)}
|
||||
<span style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
|
||||
{issue.resolved ? '已解决' : '未解决'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,10 +23,7 @@
|
||||
"axios": "^1.7.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.52.0",
|
||||
"react-router-dom": "^6.24.0",
|
||||
"recharts": "^3.8.1",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* 使用模板生成视频弹窗 — V21 设计系统
|
||||
* P1-4: voiceover_id → voiceover_duration (number)
|
||||
*/
|
||||
import React from "react";
|
||||
import { Modal, Input } from "@/components/ui";
|
||||
|
||||
interface GenerateModalProps {
|
||||
open: boolean;
|
||||
loading: boolean;
|
||||
voiceoverDuration: number | null;
|
||||
estimatedDuration: number;
|
||||
onDurationChange: (v: number | null) => void;
|
||||
onGenerate: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const GenerateModal: React.FC<GenerateModalProps> = ({
|
||||
open,
|
||||
loading,
|
||||
voiceoverDuration,
|
||||
estimatedDuration,
|
||||
onDurationChange,
|
||||
onGenerate,
|
||||
onCancel,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title="使用模板生成视频"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={onGenerate}
|
||||
confirmLoading={loading}
|
||||
okText="开始生成"
|
||||
>
|
||||
<div className="ep-modal-field">
|
||||
<label>配音时长(秒)*</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="输入配音时长"
|
||||
value={voiceoverDuration ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value ? Number(e.target.value) : null;
|
||||
onDurationChange(v);
|
||||
}}
|
||||
min={1}
|
||||
max={600}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ep-modal-info">
|
||||
⏱️ 预估时长:~{estimatedDuration}s,配音时长偏差超过 ±30% 时将收到警告
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenerateModal;
|
||||
@@ -1,329 +0,0 @@
|
||||
/**
|
||||
* 右侧设置面板 — V21 设计系统
|
||||
* 标题设置 / 字幕设置 / BGM 设置
|
||||
*/
|
||||
import React from "react";
|
||||
import { Input, Select, Tag } from "@/components/ui";
|
||||
import type {
|
||||
TitleConfig,
|
||||
SubtitleConfig,
|
||||
BgmConfig,
|
||||
} from "@/api/editingPlanner";
|
||||
|
||||
/* ── 常量 ── */
|
||||
const FONT_PRESETS = ["思源黑体", "站酷快乐体", "方正兰亭", "汉仪旗黑"];
|
||||
const POSITIONS = [
|
||||
{ value: "top", label: "顶部" },
|
||||
{ value: "center", label: "居中" },
|
||||
{ value: "bottom", label: "底部" },
|
||||
];
|
||||
const SUBTITLE_FONTS = ["思源黑体", "微软雅黑", "苹方"];
|
||||
const SUBTITLE_ANIMATIONS = [
|
||||
{ value: "none", label: "无" },
|
||||
{ value: "fade", label: "淡入" },
|
||||
{ value: "typewriter", label: "打字机" },
|
||||
{ value: "slide", label: "滑动" },
|
||||
];
|
||||
|
||||
interface SettingsPanelProps {
|
||||
titleConfig: TitleConfig;
|
||||
subtitleConfig: SubtitleConfig;
|
||||
bgmConfig: BgmConfig;
|
||||
onTitleChange: (config: TitleConfig) => void;
|
||||
onSubtitleChange: (config: SubtitleConfig) => void;
|
||||
onBgmChange: (config: BgmConfig) => void;
|
||||
}
|
||||
|
||||
const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
||||
titleConfig,
|
||||
subtitleConfig,
|
||||
bgmConfig,
|
||||
onTitleChange,
|
||||
onSubtitleChange,
|
||||
onBgmChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="ep-right">
|
||||
{/* ── 标题设置 ── */}
|
||||
<div className="ep-right-section">
|
||||
<h3>🔤 标题设置</h3>
|
||||
|
||||
{/* AI 自动选择开关 */}
|
||||
<div className="ep-setting-item">
|
||||
<div className="ep-setting-label">
|
||||
<span className="ep-setting-label-text">AI 自动选择</span>
|
||||
<label className="ep-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={titleConfig.ai_auto_select}
|
||||
onChange={(e) =>
|
||||
onTitleChange({
|
||||
...titleConfig,
|
||||
ai_auto_select: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="ep-switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 手动输入标题 */}
|
||||
{!titleConfig.ai_auto_select && (
|
||||
<div className="ep-setting-item">
|
||||
<Input.TextArea
|
||||
placeholder="手动输入标题内容"
|
||||
value={titleConfig.content}
|
||||
onChange={(e) =>
|
||||
onTitleChange({ ...titleConfig, content: e.target.value })
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 字体预设 */}
|
||||
<div className="ep-setting-item">
|
||||
<div className="ep-setting-label">
|
||||
<span className="ep-setting-label-text">字体预设</span>
|
||||
</div>
|
||||
<div className="ep-font-presets">
|
||||
{FONT_PRESETS.map((font) => (
|
||||
<Tag
|
||||
key={font}
|
||||
variant={titleConfig.font_preset === font ? "primary" : "info"}
|
||||
className="ep-font-preset-tag"
|
||||
onClick={() =>
|
||||
onTitleChange({ ...titleConfig, font_preset: font })
|
||||
}
|
||||
>
|
||||
{font}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 颜色 + 位置 */}
|
||||
<div className="ep-setting-item">
|
||||
<div className="ep-setting-row">
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="ep-setting-label">
|
||||
<span className="ep-setting-label-text">颜色</span>
|
||||
</div>
|
||||
<div className="ep-color-input">
|
||||
<span
|
||||
className="ep-color-swatch"
|
||||
style={{ background: titleConfig.font_color }}
|
||||
/>
|
||||
<Input
|
||||
value={titleConfig.font_color}
|
||||
onChange={(e) =>
|
||||
onTitleChange({
|
||||
...titleConfig,
|
||||
font_color: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="ep-setting-label">
|
||||
<span className="ep-setting-label-text">位置</span>
|
||||
</div>
|
||||
<Select
|
||||
value={titleConfig.position}
|
||||
onChange={(v: string) =>
|
||||
onTitleChange({ ...titleConfig, position: v })
|
||||
}
|
||||
options={POSITIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 字号 */}
|
||||
<div className="ep-setting-item">
|
||||
<div className="ep-setting-label">
|
||||
<span className="ep-setting-label-text">
|
||||
字号:{titleConfig.font_size}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={16}
|
||||
max={72}
|
||||
value={titleConfig.font_size}
|
||||
onChange={(e) =>
|
||||
onTitleChange({
|
||||
...titleConfig,
|
||||
font_size: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 字幕设置 ── */}
|
||||
<div className="ep-right-section">
|
||||
<h3>📝 字幕设置</h3>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<div className="ep-setting-item">
|
||||
<div className="ep-setting-label">
|
||||
<span className="ep-setting-label-text">启用字幕</span>
|
||||
<label className="ep-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={subtitleConfig.enabled}
|
||||
onChange={(e) =>
|
||||
onSubtitleChange({
|
||||
...subtitleConfig,
|
||||
enabled: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="ep-switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{subtitleConfig.enabled && (
|
||||
<>
|
||||
{/* 位置 */}
|
||||
<div className="ep-setting-item">
|
||||
<div className="ep-setting-label">
|
||||
<span className="ep-setting-label-text">位置</span>
|
||||
</div>
|
||||
<Select
|
||||
value={subtitleConfig.position}
|
||||
onChange={(v: string) =>
|
||||
onSubtitleChange({ ...subtitleConfig, position: v })
|
||||
}
|
||||
options={POSITIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 字体 */}
|
||||
<div className="ep-setting-item">
|
||||
<div className="ep-setting-label">
|
||||
<span className="ep-setting-label-text">字体</span>
|
||||
</div>
|
||||
<Select
|
||||
value={subtitleConfig.font}
|
||||
onChange={(v: string) =>
|
||||
onSubtitleChange({ ...subtitleConfig, font: v })
|
||||
}
|
||||
options={SUBTITLE_FONTS.map((f) => ({ value: f, label: f }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 颜色 + 动画 */}
|
||||
<div className="ep-setting-item">
|
||||
<div className="ep-setting-row">
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="ep-setting-label">
|
||||
<span className="ep-setting-label-text">颜色</span>
|
||||
</div>
|
||||
<div className="ep-color-input">
|
||||
<span
|
||||
className="ep-color-swatch"
|
||||
style={{ background: subtitleConfig.color }}
|
||||
/>
|
||||
<Input
|
||||
value={subtitleConfig.color}
|
||||
onChange={(e) =>
|
||||
onSubtitleChange({
|
||||
...subtitleConfig,
|
||||
color: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="ep-setting-label">
|
||||
<span className="ep-setting-label-text">动画</span>
|
||||
</div>
|
||||
<Select
|
||||
value={subtitleConfig.animation}
|
||||
onChange={(v: string) =>
|
||||
onSubtitleChange({ ...subtitleConfig, animation: v })
|
||||
}
|
||||
options={SUBTITLE_ANIMATIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 字号 */}
|
||||
<div className="ep-setting-item">
|
||||
<div className="ep-setting-label">
|
||||
<span className="ep-setting-label-text">
|
||||
字号:{subtitleConfig.size}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={12}
|
||||
max={48}
|
||||
value={subtitleConfig.size}
|
||||
onChange={(e) =>
|
||||
onSubtitleChange({
|
||||
...subtitleConfig,
|
||||
size: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── BGM 设置 ── */}
|
||||
<div className="ep-right-section">
|
||||
<h3>🎵 BGM 设置</h3>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<div className="ep-setting-item">
|
||||
<div className="ep-setting-label">
|
||||
<span className="ep-setting-label-text">启用背景音乐</span>
|
||||
<label className="ep-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bgmConfig.enabled}
|
||||
onChange={(e) =>
|
||||
onBgmChange({ ...bgmConfig, enabled: e.target.checked })
|
||||
}
|
||||
/>
|
||||
<span className="ep-switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bgmConfig.enabled && (
|
||||
<div className="ep-setting-item">
|
||||
<div className="ep-setting-label">
|
||||
<span className="ep-setting-label-text">选择音乐</span>
|
||||
</div>
|
||||
<Select
|
||||
placeholder="选择背景音乐"
|
||||
value={bgmConfig.music_id || undefined}
|
||||
onChange={(v: string) =>
|
||||
onBgmChange({ ...bgmConfig, music_id: v })
|
||||
}
|
||||
options={[
|
||||
{ value: "bgm-1", label: "🎶 轻快节奏" },
|
||||
{ value: "bgm-2", label: "🎹 舒缓氛围" },
|
||||
{ value: "bgm-3", label: "🥁 动感活力" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPanel;
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* 左侧模板面板 — V21 设计系统
|
||||
* 搜索、分类筛选、模板卡片列表
|
||||
*/
|
||||
import React from "react";
|
||||
import { Input, Select, Button, Tag } from "@/components/ui";
|
||||
import {
|
||||
MODE_LABELS,
|
||||
MODE_COLORS,
|
||||
type EditingTemplate,
|
||||
type TemplateCategory,
|
||||
type TemplateMode,
|
||||
} from "@/api/editingPlanner";
|
||||
|
||||
/** antd Tag color → V21 Tag variant */
|
||||
const modeVariantMap: Record<
|
||||
string,
|
||||
"primary" | "success" | "warning" | "info"
|
||||
> = {
|
||||
blue: "primary",
|
||||
green: "success",
|
||||
orange: "warning",
|
||||
purple: "info",
|
||||
};
|
||||
|
||||
interface TemplatePanelProps {
|
||||
templates: EditingTemplate[];
|
||||
categories: TemplateCategory[];
|
||||
isLoading: boolean;
|
||||
searchText: string;
|
||||
filterCategory: string;
|
||||
loadedTemplateId: string | null;
|
||||
onSearchChange: (v: string) => void;
|
||||
onCategoryChange: (v: string) => void;
|
||||
onTemplateSelect: (tpl: EditingTemplate) => void;
|
||||
onNewTemplate: () => void;
|
||||
}
|
||||
|
||||
const TemplatePanel: React.FC<TemplatePanelProps> = ({
|
||||
templates,
|
||||
categories,
|
||||
isLoading,
|
||||
searchText,
|
||||
filterCategory,
|
||||
loadedTemplateId,
|
||||
onSearchChange,
|
||||
onCategoryChange,
|
||||
onTemplateSelect,
|
||||
onNewTemplate,
|
||||
}) => {
|
||||
return (
|
||||
<div className="ep-left">
|
||||
{/* 头部:搜索 + 筛选 */}
|
||||
<div className="ep-left-header">
|
||||
<h3>📂 我的模板</h3>
|
||||
<Input.Search
|
||||
placeholder="搜索模板..."
|
||||
value={searchText}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
<Select
|
||||
placeholder="按分类筛选"
|
||||
value={filterCategory || undefined}
|
||||
onChange={(v: string) => onCategoryChange(v || "")}
|
||||
allowClear
|
||||
options={categories.map((c) => ({ value: c.name, label: c.name }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 模板卡片列表 */}
|
||||
<div className="ep-left-list">
|
||||
{isLoading ? (
|
||||
<div className="ep-left-empty">
|
||||
<div className="ep-left-empty-icon">⏳</div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="ep-left-empty">
|
||||
<div className="ep-left-empty-icon">📭</div>
|
||||
<p>暂无已保存的模板</p>
|
||||
<p>请先编辑并保存模板</p>
|
||||
</div>
|
||||
) : (
|
||||
templates.map((tpl) => {
|
||||
const modeColor = MODE_COLORS[tpl.mode as TemplateMode] || "blue";
|
||||
const variant = modeVariantMap[modeColor] || "primary";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`ep-template-card${loadedTemplateId === tpl.id ? " selected" : ""}`}
|
||||
onClick={() => onTemplateSelect(tpl)}
|
||||
>
|
||||
<div className="ep-template-card-name">{tpl.name}</div>
|
||||
<div className="ep-template-card-tags">
|
||||
<Tag variant={variant}>
|
||||
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
|
||||
</Tag>
|
||||
{tpl.tags.slice(0, 3).map((tag) => (
|
||||
<Tag key={tag} variant="info">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<div className="ep-template-card-meta">
|
||||
{tpl.segments.length} 片段 · ~{tpl.estimated_duration}s
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部:新建空白模板 */}
|
||||
{loadedTemplateId && (
|
||||
<div className="ep-left-footer">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
block
|
||||
onClick={onNewTemplate}
|
||||
>
|
||||
✨ 新建空白模板
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplatePanel;
|
||||
Reference in New Issue
Block a user