creative_studio/frontend/src/pages/ProjectList.tsx

373 lines
12 KiB
TypeScript

/**
* 项目列表页面 - 采用卡片网格布局
*
* 设计理念:
* - 不使用传统列表,而是卡片网格展示
* - 每个项目卡片显示项目状态和进度
* - 支持快速操作:继续编辑、查看详情、删除
*/
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button, Space, Tag, Card, message, Empty, Row, Col, Progress, Tooltip, Badge } from 'antd'
import {
PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined,
ClockCircleOutlined, CheckCircleOutlined, LoadingOutlined,
FileTextOutlined, SettingOutlined
} from '@ant-design/icons'
import { useProjectStore } from '@/stores/projectStore'
import { SeriesProject } from '@/services/projectService'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn'
dayjs.extend(relativeTime)
dayjs.locale('zh-cn')
type ProjectStatus = 'draft' | 'in_progress' | 'completed'
// 项目状态配置
const STATUS_CONFIG: Record<ProjectStatus, {
text: string
color: string
icon: React.ReactNode
}> = {
draft: {
text: '草稿',
color: 'default',
icon: <FileTextOutlined />
},
in_progress: {
text: '创作中',
color: 'processing',
icon: <LoadingOutlined />
},
completed: {
text: '已完成',
color: 'success',
icon: <CheckCircleOutlined />
}
}
// 计算项目完成度
const calculateCompletion = (project: SeriesProject): number => {
let completed = 0
let total = 3
// 检查世界观设定
if (project.globalContext?.worldSetting) completed += 1
// 检查人物设定
if (project.globalContext?.characterProfiles &&
Object.keys(project.globalContext.characterProfiles).length > 0) {
completed += 1
}
// 检查大纲
if (project.globalContext?.overallOutline) completed += 1
// 检查剧集完成度
if (project.totalEpisodes && project.episodes) {
total = 3 + project.totalEpisodes
completed += project.episodes.length
}
return Math.min(100, Math.round((completed / total) * 100))
}
// 获取项目状态
const getProjectStatus = (project: SeriesProject): ProjectStatus => {
const completion = calculateCompletion(project)
if (completion === 0) return 'draft'
if (completion >= 100) return 'completed'
return 'in_progress'
}
// 项目卡片组件
const ProjectCard = ({ project, onEdit, onDelete, onView }: {
project: SeriesProject
onEdit: (id: string) => void
onDelete: (id: string) => void
onView: (id: string) => void
}) => {
const status = getProjectStatus(project)
const statusConfig = STATUS_CONFIG[status]
const completion = calculateCompletion(project)
return (
<Badge.Ribbon
text={statusConfig.text}
color={statusConfig.color}
>
<Card
hoverable
style={{ height: '100%' }}
bodyStyle={{ display: 'flex', flexDirection: 'column', height: '100%' }}
>
{/* 项目标题 */}
<div style={{ marginBottom: '12px' }}>
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: 600 }}>
{project.name}
</h3>
<div style={{ display: 'flex', alignItems: 'center', marginTop: '4px', gap: '4px' }}>
<Tag color="blue" icon={<FileTextOutlined />}>
{project.totalEpisodes || 0}
</Tag>
<Tag color={statusConfig.color} icon={statusConfig.icon}>
{statusConfig.text}
</Tag>
</div>
</div>
{/* 项目描述/内容预览 */}
<div style={{ flex: 1, marginBottom: '16px' }}>
{project.globalContext?.overallOutline ? (
<div
style={{
fontSize: '12px',
color: '#666',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical'
}}
>
{project.globalContext.overallOutline}
</div>
) : project.globalContext?.worldSetting ? (
<div
style={{
fontSize: '12px',
color: '#666',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical'
}}
>
{project.globalContext.worldSetting}
</div>
) : (
<div style={{ fontSize: '12px', color: '#999' }}>
</div>
)}
</div>
{/* 进度条 */}
<div style={{ marginBottom: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
<span style={{ fontSize: '12px', color: '#666' }}></span>
<span style={{ fontSize: '12px', color: '#1677ff', fontWeight: 600 }}>
{completion}%
</span>
</div>
<Progress
percent={completion}
size="small"
status={completion === 100 ? 'success' : 'active'}
/>
</div>
{/* 时间信息 */}
<div style={{ fontSize: '12px', color: '#999', marginBottom: '16px' }}>
<ClockCircleOutlined style={{ marginRight: '4px' }} />
{dayjs(project.createdAt).fromNow()}
</div>
{/* 操作按钮 */}
<div style={{ display: 'flex', gap: '8px' }}>
{status === 'draft' || status === 'in_progress' ? (
<Button
type="primary"
size="small"
icon={<EditOutlined />}
onClick={() => onEdit(project.id)}
style={{ flex: 1 }}
>
</Button>
) : (
<Button
type="default"
size="small"
icon={<EyeOutlined />}
onClick={() => onView(project.id)}
style={{ flex: 1 }}
>
</Button>
)}
<Tooltip title="删除">
<Button
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => onDelete(project.id)}
/>
</Tooltip>
</div>
</Card>
</Badge.Ribbon>
)
}
export const ProjectList = () => {
const navigate = useNavigate()
const { projects, loading, fetchProjects, deleteProject } = useProjectStore()
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
useEffect(() => {
fetchProjects()
}, [])
const handleDelete = async (id: string) => {
try {
await deleteProject(id)
message.success('项目已删除')
} catch (error) {
message.error(`删除失败: ${(error as Error).message}`)
}
}
const handleContinueEdit = (id: string) => {
// 跳转到项目详情页继续编辑
navigate(`/projects/${id}`)
}
const handleView = (id: string) => {
navigate(`/projects/${id}`)
}
// 按状态分组项目
const draftProjects = projects.filter(p => getProjectStatus(p) === 'draft')
const inProgressProjects = projects.filter(p => getProjectStatus(p) === 'in_progress')
const completedProjects = projects.filter(p => getProjectStatus(p) === 'completed')
return (
<div style={{ padding: '24px' }}>
{/* 头部 */}
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1 style={{ margin: 0, fontSize: '24px', fontWeight: 600 }}></h1>
<p style={{ margin: '4px 0 0 0', color: '#666' }}>
{projects.length}
{inProgressProjects.length > 0 && ` · ${inProgressProjects.length} 个创作中`}
{completedProjects.length > 0 && ` · ${completedProjects.length} 个已完成`}
</p>
</div>
<Space>
<Button
icon={<SettingOutlined />}
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
>
{viewMode === 'grid' ? '列表视图' : '网格视图'}
</Button>
<Button
type="primary"
size="large"
icon={<PlusOutlined />}
onClick={() => navigate('/projects/progressive')}
>
</Button>
</Space>
</div>
{/* 项目列表 */}
{loading ? (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
<LoadingOutlined style={{ fontSize: '48px', color: '#1677ff' }} />
<p style={{ marginTop: '16px', color: '#666' }}>...</p>
</div>
) : projects.length === 0 ? (
<Empty
style={{ padding: '100px 0' }}
description={
<div>
<p style={{ fontSize: '16px', marginBottom: '8px' }}></p>
<p style={{ color: '#999' }}></p>
</div>
}
>
<Button
type="primary"
size="large"
icon={<PlusOutlined />}
onClick={() => navigate('/projects/progressive')}
>
</Button>
</Empty>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
{/* 创作中的项目 */}
{inProgressProjects.length > 0 && (
<div>
<h2 style={{ marginBottom: '16px', fontSize: '18px', fontWeight: 600 }}>
({inProgressProjects.length})
</h2>
<Row gutter={[16, 16]}>
{inProgressProjects.map(project => (
<Col key={project.id} xs={24} sm={12} md={8} lg={6}>
<ProjectCard
project={project}
onEdit={handleContinueEdit}
onView={handleView}
onDelete={handleDelete}
/>
</Col>
))}
</Row>
</div>
)}
{/* 草稿 */}
{draftProjects.length > 0 && (
<div>
<h2 style={{ marginBottom: '16px', fontSize: '18px', fontWeight: 600 }}>
稿 ({draftProjects.length})
</h2>
<Row gutter={[16, 16]}>
{draftProjects.map(project => (
<Col key={project.id} xs={24} sm={12} md={8} lg={6}>
<ProjectCard
project={project}
onEdit={handleContinueEdit}
onView={handleView}
onDelete={handleDelete}
/>
</Col>
))}
</Row>
</div>
)}
{/* 已完成的项目 */}
{completedProjects.length > 0 && (
<div>
<h2 style={{ marginBottom: '16px', fontSize: '18px', fontWeight: 600 }}>
({completedProjects.length})
</h2>
<Row gutter={[16, 16]}>
{completedProjects.map(project => (
<Col key={project.id} xs={24} sm={12} md={8} lg={6}>
<ProjectCard
project={project}
onEdit={handleContinueEdit}
onView={handleView}
onDelete={handleDelete}
/>
</Col>
))}
</Row>
</div>
)}
</div>
)}
</div>
)
}