refactor: 使用 Naive UI 重构后台布局与黑白双主题

This commit is contained in:
GouJ
2026-09-01 16:31:21 +08:00
parent 55516537bf
commit db8c176b55
51 changed files with 3277 additions and 2442 deletions
+67 -58
View File
@@ -1,76 +1,85 @@
<script setup lang="ts">
import { computed, provide } from 'vue'
import { useRoute } from 'vue-router'
import {
ArrowLeft,
RefreshCw,
FileText,
Layers,
Camera,
Images,
Palette,
Fingerprint,
LoaderCircle,
Clapperboard
} from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import { NAlert, NButton, NPageHeader, NSpin, NTab, NTabs, NEllipsis } from 'naive-ui'
import { RefreshCw } from '@lucide/vue'
import { StatusBadge } from '../../components/ui'
import { projectContextKey, useProjectData } from './context'
import { getOperation } from '../workflows/operations'
/** 项目级数据与操作状态跨 graph 页面共享。 */
/** 项目共享查询保持不变,标题与导航固定,子工作区负责内容滚动。 */
const route = useRoute()
const router = useRouter()
const id = computed(() => String(route.params.projectId))
const context = useProjectData(id)
provide(projectContextKey, context)
const operation = computed(() => getOperation(id.value))
const tabs = [
['create-drama', '剧本创作'],
['breakdown', '剧本拆解'],
['visual-style', '视觉风格'],
['subject-identity', '主体身份'],
['subject-images', '形态图片'],
['storyboard', '分镜设计'],
['production', '镜头生产']
] as const
/** 路由控制当前工作流,不额外缓存一份可能失步的标签状态。 */
function switchWorkflow(path: string) {
void router.push('/projects/' + id.value + '/' + path)
}
</script>
<template>
<section class="page-container">
<RouterLink to="/projects" class="back-link"><ArrowLeft :size="14" />全部剧本</RouterLink>
<div class="page-heading mt-5">
<div class="min-w-0">
<p class="eyebrow">项目工作台</p>
<h1 class="break-words">
{{ context.project.value?.title || context.project.value?.topic || '读取项目' }}
</h1>
<p class="page-description">{{ context.project.value?.style || '剧本与拆解结果' }}</p>
</div>
<div class="flex shrink-0 items-center gap-3">
<StatusBadge v-if="context.project.value" :status="context.project.value.status" /><button
class="button button-secondary"
:disabled="context.loading.value"
@click="context.refresh"
<section class="project-frame">
<header class="project-header">
<NPageHeader @back="router.push('/projects')">
<template #title
><NEllipsis class="project-title">{{
context.project.value?.title || context.project.value?.topic || '读取项目'
}}</NEllipsis></template
>
<RefreshCw :size="14" :class="{ 'animate-spin': context.loading.value }" /><span
class="hidden sm:inline"
>刷新</span
>
</button>
</div>
<template #extra
><div class="flex items-center gap-3">
<StatusBadge v-if="context.project.value" :status="context.project.value.status" /><NButton
size="small"
:loading="context.loading.value"
@click="context.refresh"
><template #icon><RefreshCw :size="14" /></template>刷新</NButton
>
</div></template
>
</NPageHeader>
<NTabs
:value="String(route.path.split('/').at(-1))"
type="line"
size="small"
class="project-tabs"
aria-label="项目工作流"
@update:value="switchWorkflow"
>
<NTab v-for="[path, label] in tabs" :key="path" :name="path">{{ label }}</NTab>
</NTabs>
</header>
<div
v-if="context.error.value || operation.pending || operation.error || operation.notice"
class="project-notices"
>
<NAlert v-if="context.error.value" type="error" :show-icon="false"
>{{ context.error.value
}}<span v-if="context.project.value"> 当前保留上次成功读取的数据</span></NAlert
>
<NAlert v-if="operation.pending" :show-icon="false" role="status"
>{{ operation.label }}可切换页面查看结果请勿重复提交关闭页面不会取消后端任务</NAlert
>
<NAlert v-if="operation.error" type="error" :show-icon="false">{{ operation.error }}</NAlert>
<NAlert v-if="operation.notice" :show-icon="false" role="status">{{ operation.notice }}</NAlert>
</div>
<nav class="workflow-nav" aria-label="项目工作流">
<RouterLink :to="`/projects/${id}/create-drama`"><FileText :size="17" />剧本创作</RouterLink>
<RouterLink :to="`/projects/${id}/breakdown`"><Layers :size="17" />剧本拆解</RouterLink>
<RouterLink :to="`/projects/${id}/visual-style`"><Palette :size="17" />视觉风格</RouterLink>
<RouterLink :to="`/projects/${id}/subject-identity`"><Fingerprint :size="17" />主体身份</RouterLink>
<RouterLink :to="`/projects/${id}/subject-images`"><Images :size="17" />形态图片</RouterLink>
<RouterLink :to="`/projects/${id}/storyboard`"><Camera :size="17" />分镜设计</RouterLink>
<RouterLink :to="`/projects/${id}/production`"><Clapperboard :size="17" />镜头生产</RouterLink>
</nav>
<p v-if="context.error.value" class="alert alert-error mt-4" role="alert">
{{ context.error.value }}<span v-if="context.project.value"> 当前保留上次成功读取的数据</span>
</p>
<p v-if="operation.pending" class="alert mt-4 flex items-center gap-2" role="status">
<LoaderCircle :size="16" class="shrink-0 animate-spin" />{{
operation.label
}}可切换页面查看结果请勿重复提交或关闭浏览器关闭页面不会取消后端任务
</p>
<p v-if="operation.error" class="alert alert-error mt-4" role="alert">{{ operation.error }}</p>
<p v-if="operation.notice" class="alert mt-4" role="status">{{ operation.notice }}</p>
<RouterView v-if="context.project.value" :key="id" />
<div v-else-if="context.loading.value" class="py-12 text-sm text-muted" role="status">
正在读取项目和工作流记录
<div class="project-view">
<RouterView v-if="context.project.value" :key="id" />
<NSpin
v-else-if="context.loading.value"
class="workspace-loading"
description="正在读取项目和工作流记录…"
/>
</div>
</section>
</template>
+101 -129
View File
@@ -1,162 +1,134 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ArrowUpRight, Search, RefreshCw, Clapperboard } from '@lucide/vue'
import { computed, h, ref, watch } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { NAlert, NButton, NDataTable, NInput, NRadioButton, NRadioGroup, type DataTableColumns } from 'naive-ui'
import { RefreshCw, Search } from '@lucide/vue'
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import { usePolling } from '../../composables/usePolling'
import { EmptyState, StatusBadge } from '../../components/ui'
import { StatusBadge } from '../../components/ui'
import { formatDate } from '../../lib/format'
import { projectsApi } from './api'
import type { Project } from './types'
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 page = ref(1)
const projects = computed(() => query.data.value ?? [])
const filtered = computed(() =>
projects.value.filter(item => {
const matches = `${item.title ?? ''} ${item.topic} ${item.style ?? ''}`
const matches = ((item.title ?? '') + ' ' + item.topic + ' ' + (item.style ?? ''))
.toLowerCase()
.includes(search.value.toLowerCase().trim())
.includes(search.value.trim().toLowerCase())
return matches && (filter.value === 'all' || item.status === filter.value)
})
)
/** 202 后直接打开工作流,后续刷新由项目布局负责。 */
const filters = [
{ value: 'all', label: '全部' },
{ value: 'generating', label: '生成中' },
{ value: 'completed', label: '已完成' },
{ value: 'need_review', label: '待审核' },
{ value: 'failed', label: '失败' }
]
watch([search, filter], () => {
page.value = 1
})
/** 表格列通过正式项目 ID 跳转,不依赖列表索引。 */
const columns: DataTableColumns<Project> = [
{
title: '剧本名称',
key: 'title',
width: 300,
render: row =>
h(
RouterLink,
{ to: '/projects/' + encodeURIComponent(row.id) + '/create-drama', class: 'project-name-cell' },
() => [h('strong', row.title || row.topic), h('small', row.topic)]
)
},
{ title: '风格', key: 'style', width: 200, ellipsis: { tooltip: true }, render: row => row.style || '未设置' },
{ title: '状态', key: 'status', width: 120, render: row => h(StatusBadge, { status: row.status }) },
{ title: '最近更新', key: 'updatedAt', width: 165, render: row => formatDate(row.updatedAt) }
]
/** 创建回执只用于跳转,生成进度以后端查询为准。 */
function openProject(id: string) {
void router.push(`/projects/${encodeURIComponent(id)}/create-drama`)
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="搜索剧本" />
<WorkspacePage split>
<template #header>
<div class="page-heading project-index-heading">
<div>
<h1>我的剧本</h1>
<p class="page-description">从故事到镜头继续你的创作</p>
</div>
<button
class="icon-button"
aria-label="刷新项目"
:disabled="query.loading.value"
@click="query.refresh"
>
<RefreshCw :size="16" :class="{ 'animate-spin': query.loading.value }" />
</button>
<CreateProjectDialog @created="openProject" />
</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
</template>
<div class="project-index-toolbar">
<NRadioGroup v-model:value="filter" size="small" aria-label="项目状态筛选"
><NRadioButton v-for="item in filters" :key="item.value" :value="item.value">{{
item.label
}}</NRadioButton></NRadioGroup
>
<EmptyState
v-else
title="等待连接后端"
description="启动后端服务并检查 API_PROXY_TARGET,连接成功后会显示你已有的项目。"
/>
<div class="flex items-center gap-2">
<NInput
v-model:value="search"
clearable
placeholder="搜索剧本"
class="project-search"
:input-props="{ 'aria-label': '搜索项目' }"
><template #prefix><Search :size="15" /></template
></NInput>
<NButton :loading="query.loading.value" aria-label="刷新项目" @click="query.refresh"
><RefreshCw :size="16"
/></NButton>
</div>
</div>
<p class="mt-4 text-xs text-muted">
项目数据来自后端数据库<span v-if="query.updatedAt.value">
<NAlert v-if="query.error.value" type="error" :show-icon="false"
>{{ query.error.value }}<NButton text class="ml-3" @click="query.refresh">重新连接</NButton></NAlert
>
<div class="project-table-area">
<NDataTable
class="project-data-table"
:columns="columns"
:data="filtered"
:row-key="row => row.id"
:loading="query.loading.value"
flex-height
table-layout="fixed"
:scroll-x="785"
:pagination="{ page, pageSize: 20, onUpdatePage: (value: number) => (page = value) }"
>
<template #empty
><div>
{{
query.error.value
? '等待连接后端'
: projects.length
? '没有匹配的剧本'
: '还没有剧本,点击右上角新建'
}}
</div>
<NButton v-if="search || filter !== 'all'" text class="mt-3" @click="clearFilters"
>清除筛选</NButton
></template
>
</NDataTable>
</div>
<p class="text-xs text-muted">
共 {{ filtered.length }} 个项目<span v-if="query.updatedAt.value">
· 更新于 {{ formatDate(query.updatedAt.value) }}</span
>
</p>
</section>
</WorkspacePage>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { NAlert, NButton, NInput, NInputNumber } from 'naive-ui'
import { reactive, ref } from 'vue'
import { DialogTrigger } from 'reka-ui'
import { ArrowRight, LoaderCircle, Plus } from '@lucide/vue'
import { AppDialog } from '../../../components/ui'
import { projectsApi } from '../api'
@@ -47,58 +47,54 @@ async function submit() {
:busy="busy"
>
<template #trigger
><DialogTrigger class="button button-primary"><Plus :size="16" />新建剧本</DialogTrigger></template
><NButton @click="open = true" type="primary"><Plus :size="16" />新建剧本</NButton></template
>
<form class="mt-7 space-y-5" @submit.prevent="submit">
<div>
<label class="field-label" for="topic">故事主题 <span class="text-accent">*</span></label
><textarea
id="topic"
v-model="form.topic"
class="input min-h-32 resize-y"
><NInput
placeholder="描述主角、故事背景,以及你希望展开的核心冲突……"
required
:disabled="busy"
></textarea>
:input-props="{ id: 'topic', required: true }"
class="min-h-32 resize-y"
v-model:value="form.topic"
type="textarea"
:autosize="{ minRows: 3, maxRows: 12 }"
></NInput>
</div>
<div class="grid grid-cols-[1fr_110px] gap-4">
<div>
<label class="field-label" for="style">剧本风格</label
><input
id="style"
v-model="form.style"
class="input"
><NInput
placeholder="如:都市悬疑、爽文反转"
:disabled="busy"
/>
:input-props="{ id: 'style' }"
v-model:value="form.style"
></NInput>
</div>
<div>
<label class="field-label" for="episode-count">计划集数</label
><input
id="episode-count"
v-model.number="form.episodeCount"
class="input"
type="number"
min="1"
step="1"
required
><NInputNumber
:disabled="busy"
/>
:input-props="{ id: 'episode-count', required: true }"
:value="typeof form.episodeCount === 'number' ? form.episodeCount : null"
@update:value="form.episodeCount = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
</div>
</div>
<p class="text-xs leading-5 text-muted">
建议先用 3 集验证生成效果生成会实际调用后端模型产生相应费用
</p>
<p v-if="error" class="alert alert-error" role="alert">{{ error }}</p>
<NAlert v-if="error" role="alert" type="error" :show-icon="false" class=" ">{{ error }}</NAlert>
<div class="dialog-footer">
<button type="button" class="button button-secondary" :disabled="busy" @click="open = false">
取消</button
><button type="submit" class="button button-primary" :disabled="busy">
<LoaderCircle v-if="busy" :size="16" class="animate-spin" />开始生成<ArrowRight
<NButton :disabled="busy" @click="open = false"> 取消</NButton
><NButton :disabled="busy" type="primary" attr-type="submit"
><LoaderCircle v-if="busy" :size="16" class="animate-spin" />开始生成<ArrowRight
v-if="!busy"
:size="16"
/>
</button>
/></NButton>
</div>
</form>
</AppDialog>