163 lines
7.6 KiB
Vue
163 lines
7.6 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { ArrowUpRight, Search, RefreshCw, Clapperboard } from '@lucide/vue'
|
||
import { usePolling } from '../../composables/usePolling'
|
||
import { EmptyState, StatusBadge } from '../../components/ui'
|
||
import { formatDate } from '../../lib/format'
|
||
import { projectsApi } from './api'
|
||
import CreateProjectDialog from './components/CreateProjectDialog.vue'
|
||
|
||
/** 项目索引:真实查询、客户端筛选,以及进入两条 graph 的入口。 */
|
||
const router = useRouter()
|
||
const query = usePolling(ref('projects'), (_, signal) => projectsApi.list(signal), 12_000)
|
||
const search = ref('')
|
||
const filter = ref('all')
|
||
const projects = computed(() => query.data.value ?? [])
|
||
const filtered = computed(() =>
|
||
projects.value.filter(item => {
|
||
const matches = `${item.title ?? ''} ${item.topic} ${item.style ?? ''}`
|
||
.toLowerCase()
|
||
.includes(search.value.toLowerCase().trim())
|
||
return matches && (filter.value === 'all' || item.status === filter.value)
|
||
})
|
||
)
|
||
|
||
/** 202 后直接打开工作流,后续刷新由项目布局负责。 */
|
||
function openProject(id: string) {
|
||
void router.push(`/projects/${encodeURIComponent(id)}/create-drama`)
|
||
}
|
||
|
||
/** 多项筛选的重置放在函数中,避免格式化后产生无效模板表达式。 */
|
||
function clearFilters() {
|
||
search.value = ''
|
||
filter.value = 'all'
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section class="page-container">
|
||
<div class="page-heading">
|
||
<div>
|
||
<p class="eyebrow">工作空间 / 项目</p>
|
||
<h1>我的剧本</h1>
|
||
<p class="page-description">从故事到镜头,在这里继续你的创作。</p>
|
||
</div>
|
||
<CreateProjectDialog @created="openProject" />
|
||
</div>
|
||
<div class="workspace-note">
|
||
<Clapperboard :size="20" :stroke-width="1.5" /><span
|
||
>剧本创作 <span class="mx-3 text-faint">/</span> 主体拆解
|
||
<span class="mx-3 text-faint">/</span> 分镜规划</span
|
||
><span class="ml-auto hidden text-xs text-muted sm:block">两个工作流,一个项目</span>
|
||
</div>
|
||
<div class="toolbar mt-7">
|
||
<div class="flex flex-wrap gap-1">
|
||
<button
|
||
v-for="item in [
|
||
{ value: 'all', label: '全部项目' },
|
||
{ value: 'generating', label: '生成中' },
|
||
{ value: 'completed', label: '已完成' },
|
||
{ value: 'need_review', label: '待审核' },
|
||
{ value: 'failed', label: '失败' }
|
||
]"
|
||
:key="item.value"
|
||
class="filter-button"
|
||
:class="{ active: filter === item.value }"
|
||
:aria-pressed="filter === item.value"
|
||
@click="filter = item.value"
|
||
>
|
||
{{ item.label
|
||
}}<span v-if="item.value === 'all'" class="ml-2 text-muted">{{ projects.length }}</span>
|
||
</button>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<div class="search-field">
|
||
<Search :size="15" /><input v-model="search" aria-label="搜索项目" placeholder="搜索剧本" />
|
||
</div>
|
||
<button
|
||
class="icon-button"
|
||
aria-label="刷新项目"
|
||
:disabled="query.loading.value"
|
||
@click="query.refresh"
|
||
>
|
||
<RefreshCw :size="16" :class="{ 'animate-spin': query.loading.value }" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div v-if="query.error.value" class="alert alert-error mt-4" role="alert">
|
||
{{ query.error.value }}<button class="ml-3 underline" @click="query.refresh">重新连接</button>
|
||
</div>
|
||
<div class="panel mt-4 overflow-hidden" :aria-busy="query.loading.value">
|
||
<div v-if="!query.data.value && query.loading.value" class="p-10 text-sm text-muted" role="status">
|
||
正在读取项目……
|
||
</div>
|
||
<div v-else-if="filtered.length" class="table-scroll">
|
||
<table class="project-table">
|
||
<thead>
|
||
<tr>
|
||
<th>剧本名称</th>
|
||
<th>风格</th>
|
||
<th>创作状态</th>
|
||
<th>最近更新</th>
|
||
<th><span class="sr-only">打开项目</span></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="project in filtered" :key="project.id">
|
||
<td>
|
||
<RouterLink :to="`/projects/${project.id}/create-drama`" class="project-name"
|
||
><span class="project-monogram">{{
|
||
(project.title || project.topic).slice(0, 1)
|
||
}}</span
|
||
><span class="min-w-0"
|
||
><strong class="block truncate font-medium">{{
|
||
project.title || project.topic
|
||
}}</strong
|
||
><span class="mt-1 block max-w-md truncate text-xs text-muted">{{
|
||
project.topic
|
||
}}</span></span
|
||
></RouterLink
|
||
>
|
||
</td>
|
||
<td class="text-muted">{{ project.style || '未设置' }}</td>
|
||
<td><StatusBadge :status="project.status" /></td>
|
||
<td class="whitespace-nowrap text-xs text-muted">{{ formatDate(project.updatedAt) }}</td>
|
||
<td>
|
||
<RouterLink
|
||
:to="`/projects/${project.id}/create-drama`"
|
||
class="icon-button"
|
||
:aria-label="`打开 ${project.title || project.topic}`"
|
||
><ArrowUpRight :size="17"
|
||
/></RouterLink>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<EmptyState
|
||
v-else-if="query.data.value"
|
||
:title="projects.length ? '没有匹配的剧本' : '第一部故事,从这里开始'"
|
||
:description="
|
||
projects.length
|
||
? '试试其他关键词,或切换项目状态。'
|
||
: '新建一个剧本,生成角色与剧集;完成后,再将故事拆解为主体和分镜。'
|
||
"
|
||
><button v-if="projects.length" class="button button-secondary" @click="clearFilters">
|
||
清除筛选
|
||
</button></EmptyState
|
||
>
|
||
<EmptyState
|
||
v-else
|
||
title="等待连接后端"
|
||
description="启动后端服务并检查 API_PROXY_TARGET,连接成功后会显示你已有的项目。"
|
||
/>
|
||
</div>
|
||
<p class="mt-4 text-xs text-muted">
|
||
项目数据来自后端数据库<span v-if="query.updatedAt.value">
|
||
· 更新于 {{ formatDate(query.updatedAt.value) }}</span
|
||
>
|
||
</p>
|
||
</section>
|
||
</template>
|