Initial commit: Vue rewrite frontend

This commit is contained in:
2026-07-10 18:20:06 +08:00
commit 22bdfdc4d5
65 changed files with 17290 additions and 0 deletions
+484
View File
@@ -0,0 +1,484 @@
<script setup>
import { computed, nextTick, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
ArrowRight,
Bot,
Clapperboard,
Lightbulb,
MessageSquare,
Palette,
Send,
Sparkles,
UserRound,
} from "lucide-vue-next";
import {
useAssistantMessagesQuery,
useSendAssistantMessageMutation,
} from "@/queries/assistant";
import { useCharactersQuery } from "@/queries/characters";
import { useEpisodesQuery } from "@/queries/episodes";
import { useStylesQuery } from "@/queries/styles";
import { useTasksQuery } from "@/queries/tasks";
const route = useRoute();
const router = useRouter();
const project = computed(() => String(route.params.project || ""));
const draft = ref("");
const messageListRef = ref(null);
const messagesQuery = useAssistantMessagesQuery(project.value);
const sendMessage = useSendAssistantMessageMutation(project.value);
const episodesQuery = useEpisodesQuery(project.value);
const charactersQuery = useCharactersQuery(project.value);
const stylesQuery = useStylesQuery(project.value);
const tasksQuery = useTasksQuery(project.value);
const messages = computed(() => messagesQuery.data.value?.data || messagesQuery.data.value || []);
const episodes = computed(() => episodesQuery.data.value?.data || episodesQuery.data.value || []);
const characters = computed(() => charactersQuery.data.value?.data || charactersQuery.data.value || []);
const tasks = computed(() => tasksQuery.data.value?.data || tasksQuery.data.value || []);
const selectedStyle = computed(() => {
const styles = stylesQuery.data.value?.data || stylesQuery.data.value || [];
return styles.find((style) => style.selected) || styles[0];
});
const runningTasks = computed(() => tasks.value.filter((task) => task.status === "running").length);
const failedTasks = computed(() => tasks.value.filter((task) => task.status === "failed").length);
const busy = computed(() => sendMessage.isPending.value);
const prompts = [
{
title: "下一步建议",
text: "我现在应该优先推进哪个模块?请按影响成片质量排序。",
},
{
title: "优化分镜",
text: "帮我优化第 1 集的分镜节奏,让普通用户更容易看懂。",
},
{
title: "检查角色",
text: "这个项目的角色资产还缺什么?请给我一个补齐清单。",
},
{
title: "提升视频感",
text: "如何让当前短剧画面更有现代科技感和电影感?",
},
];
const contextCards = computed(() => [
{ label: "剧集", value: episodes.value.length, icon: Clapperboard, path: "episodes" },
{ label: "角色", value: characters.value.length, icon: UserRound, path: "characters" },
{ label: "任务中", value: runningTasks.value, icon: MessageSquare, path: "tasks" },
{ label: "异常", value: failedTasks.value, icon: Lightbulb, path: "tasks" },
]);
watch(
() => messages.value.length,
async () => {
await nextTick();
if (messageListRef.value) {
messageListRef.value.scrollTop = messageListRef.value.scrollHeight;
}
},
);
async function submit(text = draft.value) {
const content = text.trim();
if (!content || busy.value) return;
draft.value = "";
await sendMessage.mutateAsync(content);
}
function openProjectArea(path) {
router.push(`/projects/${project.value}/${path}`);
}
</script>
<template>
<section class="assistant-page">
<aside class="assistant-context">
<div class="context-hero">
<div class="page-kicker">
<Sparkles :size="14" />
AI 创作顾问
</div>
<h1>项目助手</h1>
<p>结合当前项目的剧集角色风格和任务状态给出下一步建议质量检查和生成策略</p>
</div>
<div class="context-grid">
<button
v-for="card in contextCards"
:key="card.label"
class="context-card"
@click="openProjectArea(card.path)"
>
<component :is="card.icon" :size="18" />
<span>{{ card.label }}</span>
<strong>{{ card.value }}</strong>
<ArrowRight :size="14" />
</button>
</div>
<section class="style-card">
<div>
<Palette :size="18" />
<span>当前风格</span>
</div>
<strong>{{ selectedStyle?.name || "未选择" }}</strong>
<p>{{ selectedStyle?.description || "选择视觉风格后,助手会基于风格给出更准确的生成建议。" }}</p>
</section>
<section class="prompt-panel">
<h2>快捷提问</h2>
<button v-for="prompt in prompts" :key="prompt.title" @click="submit(prompt.text)">
<strong>{{ prompt.title }}</strong>
<span>{{ prompt.text }}</span>
</button>
</section>
</aside>
<main class="chat-panel">
<header class="chat-header">
<div>
<h2>项目对话</h2>
<p>当前为 Mock 建议流后续可替换为真实 SuperChat 流式能力</p>
</div>
<span class="online-pill">AI Ready</span>
</header>
<div ref="messageListRef" class="message-list">
<el-skeleton v-if="messagesQuery.isLoading.value" :rows="8" animated />
<template v-else>
<div
v-for="message in messages"
:key="message.id"
class="message-row"
:class="message.role"
>
<div class="message-avatar">
<Bot v-if="message.role === 'assistant'" :size="18" />
<UserRound v-else :size="18" />
</div>
<div class="message-bubble">
<small>{{ message.role === "assistant" ? "AI 助手" : "你" }}</small>
<p>{{ message.text }}</p>
</div>
</div>
<div v-if="busy" class="message-row assistant">
<div class="message-avatar"><Bot :size="18" /></div>
<div class="message-bubble typing">
<small>AI 助手</small>
<p>正在读取项目上下文并生成建议...</p>
</div>
</div>
</template>
</div>
<form class="composer" @submit.prevent="submit()">
<el-input
v-model="draft"
type="textarea"
:rows="2"
resize="none"
placeholder="问问 AI:如何优化这一集?角色是否完整?下一步该做什么?"
@keydown.enter.exact.prevent="submit()"
/>
<el-button type="primary" :loading="busy" @click="submit()">
<Send :size="16" />
发送
</el-button>
</form>
</main>
</section>
</template>
<style scoped lang="scss">
.assistant-page {
display: grid;
grid-template-columns: 380px minmax(0, 1fr);
gap: 18px;
height: 100%;
min-height: 0;
padding: 24px;
}
.assistant-context,
.chat-panel,
.context-card,
.style-card {
border: 1px solid var(--app-border);
border-radius: 8px;
background:
linear-gradient(145deg, rgba(79, 140, 255, 0.12), transparent 42%),
var(--app-card);
box-shadow: 0 18px 52px rgba(0, 0, 0, 0.22);
}
.assistant-context {
min-height: 0;
overflow: auto;
padding: 20px;
}
.context-hero {
h1 {
margin: 0;
color: #f4f7fb;
font-size: 34px;
}
p {
color: var(--app-muted);
line-height: 1.7;
}
}
.context-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 20px;
}
.context-card {
position: relative;
display: grid;
min-height: 112px;
cursor: pointer;
grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: start;
gap: 8px;
padding: 14px;
color: inherit;
text-align: left;
transition:
border-color 180ms ease,
background 180ms ease,
box-shadow 180ms ease;
&:hover {
border-color: var(--app-border-strong);
background:
linear-gradient(145deg, rgba(79, 140, 255, 0.18), rgba(45, 212, 191, 0.07)),
var(--app-card-hover);
}
svg {
color: var(--app-cyan);
}
span,
strong {
display: block;
}
span {
color: var(--app-muted);
font-size: 12px;
font-weight: 760;
}
strong {
grid-column: 1 / -1;
color: #f4f7fb;
font-size: 24px;
}
> svg:last-child {
color: var(--app-muted);
}
}
.style-card {
margin-top: 14px;
padding: 14px;
div {
display: flex;
align-items: center;
gap: 8px;
color: var(--app-cyan);
font-size: 12px;
font-weight: 760;
}
strong {
display: block;
margin-top: 8px;
color: #f4f7fb;
}
p {
margin: 8px 0 0;
color: var(--app-muted);
line-height: 1.55;
}
}
.prompt-panel {
margin-top: 20px;
h2 {
margin: 0 0 10px;
color: #edf5ff;
font-size: 16px;
}
button {
display: grid;
width: 100%;
cursor: pointer;
gap: 5px;
border: 1px solid var(--app-border);
border-radius: 8px;
background: rgba(8, 12, 22, 0.42);
margin-top: 8px;
padding: 11px;
color: #dce8fb;
text-align: left;
transition: border-color 180ms ease, background 180ms ease;
}
button:hover {
border-color: var(--app-border-strong);
background: rgba(79, 140, 255, 0.12);
}
span {
color: var(--app-muted);
font-size: 12px;
line-height: 1.45;
}
}
.chat-panel {
display: flex;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--app-border);
padding: 18px 20px;
h2 {
margin: 0;
color: #f4f7fb;
}
p {
margin: 6px 0 0;
color: var(--app-muted);
}
}
.online-pill {
flex: 0 0 auto;
border: 1px solid rgba(34, 197, 94, 0.24);
border-radius: 999px;
background: rgba(34, 197, 94, 0.08);
padding: 6px 10px;
color: #9ef2b3;
font-size: 12px;
font-weight: 800;
}
.message-list {
min-height: 0;
flex: 1;
overflow-y: auto;
padding: 20px;
}
.message-row {
display: flex;
gap: 12px;
margin-bottom: 16px;
&.user {
flex-direction: row-reverse;
}
}
.message-avatar {
display: grid;
width: 38px;
height: 38px;
flex: 0 0 auto;
place-items: center;
border-radius: 8px;
background: linear-gradient(135deg, var(--app-primary), var(--app-cyan));
color: #fff;
}
.message-bubble {
max-width: min(680px, 78%);
border: 1px solid var(--app-border);
border-radius: 8px;
background: rgba(255, 255, 255, 0.055);
padding: 12px 14px;
small {
color: var(--app-muted);
font-size: 12px;
font-weight: 800;
}
p {
margin: 7px 0 0;
color: #e5edf8;
line-height: 1.7;
white-space: pre-wrap;
}
}
.user .message-bubble {
background: rgba(79, 140, 255, 0.16);
}
.typing {
opacity: 0.82;
}
.composer {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
border-top: 1px solid var(--app-border);
background: rgba(7, 10, 18, 0.38);
padding: 16px;
}
@media (max-width: 980px) {
.assistant-page {
grid-template-columns: 1fr;
height: auto;
overflow: auto;
}
.chat-panel {
min-height: 620px;
}
}
@media (max-width: 620px) {
.assistant-page {
padding: 16px;
}
.context-grid,
.composer {
grid-template-columns: 1fr;
}
.message-bubble {
max-width: 86%;
}
}
</style>