feat: 实现剧本创作与拆解前端工作台
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ArrowLeft, RefreshCw, FileText, Layers, LoaderCircle } from '@lucide/vue'
|
||||
import { StatusBadge } from '../../components/ui'
|
||||
import { projectContextKey, useProjectData } from './context'
|
||||
import { getOperation } from '../workflows/operations'
|
||||
|
||||
/** 项目级数据与操作状态跨 graph 页面共享。 */
|
||||
const route = useRoute()
|
||||
const id = computed(() => String(route.params.projectId))
|
||||
const context = useProjectData(id)
|
||||
provide(projectContextKey, context)
|
||||
const operation = computed(() => getOperation(id.value))
|
||||
</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"
|
||||
>
|
||||
<RefreshCw :size="14" :class="{ 'animate-spin': context.loading.value }" /><span
|
||||
class="hidden sm:inline"
|
||||
>刷新</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="workflow-nav" aria-label="项目工作流">
|
||||
<RouterLink :to="`/projects/${id}/create-drama`"
|
||||
><FileText :size="17" />剧本创作<span class="nav-code">create-drama</span></RouterLink
|
||||
>
|
||||
<RouterLink :to="`/projects/${id}/breakdown`"
|
||||
><Layers :size="17" />剧本拆解<span class="nav-code">breakdown</span></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>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,162 @@
|
||||
<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>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { request, optionalResource } from '../../lib/http'
|
||||
import type { Checkpoint } from '../workflows/types'
|
||||
import type { CreateProjectInput, DramaState, Project, ProjectDetail } from './types'
|
||||
|
||||
/** 项目与 Create Drama API;路径严格对应后端 dev。 */
|
||||
export const projectsApi = {
|
||||
list: (signal?: AbortSignal) => request<Project[]>('/projects', { signal }),
|
||||
detail: (id: string, signal?: AbortSignal) =>
|
||||
request<ProjectDetail>(`/projects/${encodeURIComponent(id)}`, { signal }),
|
||||
create: (input: CreateProjectInput) =>
|
||||
request<{ projectId: string; status: string }>('/projects', { method: 'POST', body: input }),
|
||||
state: (id: string, signal?: AbortSignal) =>
|
||||
optionalResource(request<DramaState>(`/projects/${encodeURIComponent(id)}/state`, { signal })),
|
||||
checkpoints: (id: string, signal?: AbortSignal) =>
|
||||
request<Checkpoint[]>(`/projects/${encodeURIComponent(id)}/checkpoints`, { signal }),
|
||||
resume: (id: string, action: 'resume-generation' | 'resume-rewrite') =>
|
||||
request<unknown>(`/projects/${encodeURIComponent(id)}/${action}`, { method: 'POST', timeoutMs: 0 })
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
import { errorMessage } from '../../../lib/http'
|
||||
|
||||
/** 新建剧本表单,202 成功后交由父级跳转,不在前端模拟生成。 */
|
||||
const emit = defineEmits<{ created: [projectId: string] }>()
|
||||
const open = ref(false)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const form = reactive({ topic: '', style: '爽文反转', episodeCount: 3 })
|
||||
|
||||
/** 校验正整数集数和主题,禁止双击产生重复项目。 */
|
||||
async function submit() {
|
||||
if (busy.value) return
|
||||
error.value = ''
|
||||
if (!form.topic.trim() || !Number.isSafeInteger(form.episodeCount) || form.episodeCount <= 0) {
|
||||
error.value = '请填写故事主题,并输入大于 0 的整数集数。'
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await projectsApi.create({
|
||||
...form,
|
||||
topic: form.topic.trim(),
|
||||
style: form.style.trim() || '爽文反转'
|
||||
})
|
||||
open.value = false
|
||||
form.topic = ''
|
||||
emit('created', result.projectId)
|
||||
} catch (cause) {
|
||||
error.value = errorMessage(cause)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog
|
||||
v-model:open="open"
|
||||
title="新建剧本"
|
||||
description="从一个故事想法开始。提交后,工作流将依次生成角色、世界观和剧集,并进行审核。"
|
||||
:busy="busy"
|
||||
>
|
||||
<template #trigger
|
||||
><DialogTrigger class="button button-primary"><Plus :size="16" />新建剧本</DialogTrigger></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"
|
||||
placeholder="描述主角、故事背景,以及你希望展开的核心冲突……"
|
||||
required
|
||||
:disabled="busy"
|
||||
></textarea>
|
||||
</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"
|
||||
placeholder="如:都市悬疑、爽文反转"
|
||||
:disabled="busy"
|
||||
/>
|
||||
</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
|
||||
:disabled="busy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs leading-5 text-muted">
|
||||
建议先用 3 集验证生成效果。生成会实际调用后端模型,产生相应费用。
|
||||
</p>
|
||||
<p v-if="error" class="alert alert-error" role="alert">{{ error }}</p>
|
||||
<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
|
||||
v-if="!busy"
|
||||
:size="16"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { computed, inject, type InjectionKey } from 'vue'
|
||||
import { usePolling } from '../../composables/usePolling'
|
||||
import { projectsApi } from './api'
|
||||
import type { ProjectDetail } from './types'
|
||||
import type { Checkpoint } from '../workflows/types'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
/** 同一项目的两个 graph 共用一份查询,避免每个面板重复请求。 */
|
||||
export function useProjectData(id: Ref<string>) {
|
||||
const query = usePolling(id, async (projectId, signal) => {
|
||||
const [project, checkpoints] = await Promise.all([
|
||||
projectsApi.detail(projectId, signal),
|
||||
projectsApi.checkpoints(projectId, signal)
|
||||
])
|
||||
return { project, checkpoints }
|
||||
})
|
||||
const project = computed<ProjectDetail | null>(() => query.data.value?.project ?? null)
|
||||
const checkpoints = computed<Checkpoint[]>(() => query.data.value?.checkpoints ?? [])
|
||||
return { ...query, project, checkpoints }
|
||||
}
|
||||
|
||||
/** 项目布局向子页面提供的类型安全上下文。 */
|
||||
export const projectContextKey: InjectionKey<ReturnType<typeof useProjectData>> = Symbol('project-context')
|
||||
|
||||
/** 读取项目上下文,错误布局在开发阶段立即暴露。 */
|
||||
export function useProjectContext() {
|
||||
const context = inject(projectContextKey)
|
||||
if (!context) throw new Error('项目页面必须位于 ProjectLayout 中')
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/** 项目模块公共入口。 */
|
||||
export { projectsApi } from './api'
|
||||
export type { Project, ProjectDetail, CreateProjectInput } from './types'
|
||||
@@ -0,0 +1,80 @@
|
||||
/** 后端 DramaProject.status 的原始取值。 */
|
||||
export type ProjectStatus = 'draft' | 'generating' | 'completed' | 'need_review' | 'failed'
|
||||
|
||||
/** 创建请求不包含标题,标题由后端在工作流收尾时更新。 */
|
||||
export interface CreateProjectInput {
|
||||
topic: string
|
||||
style: string
|
||||
episodeCount: number
|
||||
}
|
||||
|
||||
/** 项目列表记录;列表接口不包含剧集数量,不能据此推断生成进度。 */
|
||||
export interface Project {
|
||||
id: string
|
||||
title: string | null
|
||||
topic: string
|
||||
style: string | null
|
||||
status: ProjectStatus
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** 正式数据库剧集;episode 是编号,与 Breakdown 的 episodeNo 区分。 */
|
||||
export interface Episode {
|
||||
id?: string
|
||||
episode: number
|
||||
title: string
|
||||
summary?: string | null
|
||||
content: string
|
||||
conflict?: string | null
|
||||
hook?: string | null
|
||||
}
|
||||
|
||||
/** 剧本阶段的角色设定,不等同于拆解后的主体资产。 */
|
||||
export interface Character {
|
||||
id: string
|
||||
name: string
|
||||
role?: string | null
|
||||
age?: number | null
|
||||
occupation?: string | null
|
||||
personality?: string | null
|
||||
goal?: string | null
|
||||
secret?: string | null
|
||||
}
|
||||
|
||||
/** 正式数据库保存的世界观。 */
|
||||
export interface World {
|
||||
background?: string | null
|
||||
era?: string | null
|
||||
location?: string | null
|
||||
coreConflict?: string | null
|
||||
tone?: string | null
|
||||
rules?: unknown
|
||||
}
|
||||
|
||||
/** 后端审核记录,列表按最新在前返回。 */
|
||||
export interface Review {
|
||||
id: string
|
||||
passed: boolean
|
||||
message?: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 项目详情接口包含的关联数据。 */
|
||||
export interface ProjectDetail extends Project {
|
||||
episodes: Episode[]
|
||||
characters: Character[]
|
||||
world: World | null
|
||||
reviews: Review[]
|
||||
tasks: { id: string; type: string; status: string; error?: string | null }[]
|
||||
}
|
||||
|
||||
/** Create Drama checkpoint 中页面使用的状态切片。 */
|
||||
export interface DramaState {
|
||||
episodeCount?: number
|
||||
episodes?: Episode[]
|
||||
retryCount?: number
|
||||
reviewPassed?: boolean
|
||||
rewriteSuggestion?: unknown
|
||||
rewritePlan?: unknown
|
||||
}
|
||||
Reference in New Issue
Block a user