Files
tuanwei-admin/src/views/gxmu/creativeAssistant/index.vue
T
weicw-dshsa 038e789cb0 feat(ai): 创意助手各页下线参考素材上传,图片模式改传 mode(issue 19)
- 聊天页「图片」模式与独立创意助手页都删掉上传入口、上传状态与相关样式;
  上游出图工具只做文生图,开始节点的 file-list 变量没有任何节点消费(issue 16)。
- 图片模式请求体改为 { user_content, mode: 'image' },由后端包装意图声明;
  独立页不传 mode(通用入口,视频/脚本/图片靠用户文字说明)。
- CreativeAssistantRequest 增加 mode 字段,user_files 保留待上游补齐后回退。
2026-10-21 15:42:58 +08:00

836 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="creative-page">
<div class="hero-panel">
<div>
<div class="hero-kicker">AI CREATIVE ASSISTANT</div>
<h1>AI创意助手</h1>
<p>
支持文本指令与多类型附件联合驱动工作流,自动返回视频、脚本 Markdown 或图像/排版结果。
</p>
</div>
</div>
<a-row :gutter="[24, 24]">
<a-col :xs="24" :xl="9">
<div class="quick-actions-panel">
<div class="quick-actions-title">快捷模板</div>
<div class="quick-actions">
<a-button block @click="fillExample('video')">短视频创意</a-button>
<a-button block @click="fillExample('script')">脚本创作</a-button>
<a-button block @click="fillExample('image')">海报排版</a-button>
</div>
</div>
<a-card :bordered="false" class="panel-card">
<template #title>输入配置</template>
<a-form layout="vertical">
<a-form-item label="文本指令">
<a-textarea
v-model:value="content"
:auto-size="{ minRows: 10, maxRows: 16 }"
placeholder="请输入用户的文本指令或需求描述,例如:根据活动照片和通知文档,生成一条公众号推文封面及 60 秒短视频脚本。"
/>
</a-form-item>
</a-form>
<div class="submit-bar">
<a-button :disabled="loading" @click="resetAll">重置</a-button>
<a-button type="primary" :loading="loading" :disabled="!canSubmit" @click="runWorkflow">
生成
</a-button>
</div>
</a-card>
</a-col>
<a-col :xs="24" :xl="15">
<a-card :bordered="false" class="panel-card result-card">
<template #title>
<div class="result-head">
<span>工作流结果</span>
<!-- <a-tag :color="resultTagColor">{{ resultTagText }}</a-tag>-->
</div>
</template>
<template #extra>
<a-space>
<a-button :disabled="!scriptMarkdown" @click="copyScript">
复制脚本
</a-button>
<a-button :disabled="!hasAnyResult" @click="clearResult">清空结果</a-button>
</a-space>
</template>
<!--
必须同时判 `!workflowResponse`:若只判 `!hasAnyResult`,
则「已执行但失败 / 未识别到输出」时 hasAnyResult 为假,
会落回这个空状态,把下方 result-body 里的 status-alert
(失败文案 / 兜底文案)整个盖住 —— 用户看到的仍是
「填写文本指令后执行工作流」,等于什么都没发生(见 issue 10)。
-->
<a-empty
v-if="!loading && !hasAnyResult && !workflowResponse"
description="填写文本指令后执行工作流"
/>
<div v-else class="result-body">
<div v-if="loading" class="loading-panel">
<div class="loading-visual">
<div class="loading-orbit orbit-one"></div>
<div class="loading-orbit orbit-two"></div>
<div class="loading-core">
<span class="loading-core-dot"></span>
<span class="loading-core-dot"></span>
<span class="loading-core-dot"></span>
</div>
</div>
<div class="loading-copy">
<div class="loading-kicker">WORKFLOW RUNNING</div>
<div class="loading-title">{{ loadingTitle }}</div>
<div class="loading-desc">{{ loadingDescription }}</div>
</div>
<div class="loading-steps">
<div
v-for="(item, index) in loadingSteps"
:key="item.title"
class="loading-step"
:class="{ 'is-active': index === 1 }"
>
<div class="loading-step-index">0{{ index + 1 }}</div>
<div class="loading-step-main">
<div class="loading-step-title">{{ item.title }}</div>
<div class="loading-step-desc">{{ item.desc }}</div>
</div>
</div>
</div>
</div>
<a-alert
v-if="statusMessage"
class="status-alert"
type="info"
show-icon
:message="statusMessage"
/>
<div v-if="videoFiles.length" class="result-section">
<div class="section-title">视频生成结果</div>
<div class="video-grid">
<div
v-for="(item, index) in videoFiles"
:key="`${resolveFileUrl(item)}-${index}`"
class="video-card"
>
<video
v-if="isVideoFile(item)"
class="video-player"
controls
:src="resolveFileUrl(item)"
></video>
<div class="result-meta">
<div class="result-name">{{ item.name || getFileName(item) }}</div>
<a :href="resolveFileUrl(item)" target="_blank" rel="noreferrer">
打开文件
</a>
</div>
</div>
</div>
</div>
<div v-if="scriptMarkdown" class="result-section">
<div class="section-title">脚本创作结果</div>
<ByteMdViewer
class="markdown-viewer"
:value="scriptMarkdown"
:plugins="plugins"
/>
</div>
<div v-if="photoFiles.length" class="result-section">
<div class="section-title">图像 / 排版结果</div>
<div class="photo-grid">
<div
v-for="(item, index) in photoFiles"
:key="`${resolveFileUrl(item)}-${index}`"
class="photo-card"
>
<a-image
v-if="isImageFile(item)"
:src="resolveFileUrl(item)"
:preview="true"
class="photo-image"
/>
<div v-else class="photo-fallback">
<div class="file-badge">{{ getFileTypeLabel(item) }}</div>
<a :href="resolveFileUrl(item)" target="_blank" rel="noreferrer">
查看结果文件
</a>
</div>
<div class="result-meta">
<div class="result-name">{{ item.name || getFileName(item) }}</div>
<a :href="resolveFileUrl(item)" target="_blank" rel="noreferrer">
打开文件
</a>
</div>
</div>
</div>
</div>
</div>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import 'bytemd/dist/index.min.css';
import 'github-markdown-css/github-markdown-light.css';
import gfm from '@bytemd/plugin-gfm';
import zh_HansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json';
import highlight from '@bytemd/plugin-highlight-ssr';
import 'highlight.js/styles/default.css';
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import ByteMdViewer from '@/components/ByteMdViewer/index.vue';
import { creativeAssistant } from '@/api/ai/creativeAssistant';
import type { FileRecord } from '@/api/system/file/model';
import type {
CreativeAssistantOutputs,
CreativeAssistantResponse
} from '@/api/ai/creativeAssistant/model';
import { copyText } from '@/utils/common';
import { resolveDisplayText, isFailedPayload, FAILED_TEXT } from '@/utils/ai-result';
type ExampleType = 'video' | 'script' | 'image';
const plugins = ref([
gfm({
locale: zh_HansGfm
}),
highlight()
]);
const loading = ref(false);
const content = ref('');
const workflowResponse = ref<CreativeAssistantResponse | null>(null);
const workflowOutputs = ref<CreativeAssistantOutputs | null>(null);
const canSubmit = computed(() => {
return !!content.value.trim();
});
const videoFiles = computed(() => workflowOutputs.value?.video_result || []);
const scriptMarkdown = computed(() => workflowOutputs.value?.Script_result || '');
const photoFiles = computed(() => workflowOutputs.value?.photo_files || []);
const statusMessage = computed(() => {
// 失败态优先:后端失败时 outputs 为空,若不先判失败,用户既看不到正文
// 也看不到原因,只有一个 toast 一闪而过(见 issue 10)。
if (isFailedPayload(workflowResponse.value)) {
return FAILED_TEXT;
}
const text =
workflowOutputs.value?.video_message ||
workflowOutputs.value?.photo_message ||
'';
if (text) {
return text;
}
// 接口已返回但认不出约定的输出字段:给可读文案,不要静默什么都不显示。
if (workflowResponse.value && !hasAnyResult.value) {
return resolveDisplayText({
payload: workflowResponse.value,
scope: 'gxmu/creativeAssistant.statusMessage',
candidates: []
});
}
return '';
});
const hasAnyResult = computed(() => {
return !!(
videoFiles.value.length ||
scriptMarkdown.value.trim() ||
photoFiles.value.length
);
});
// 原本这里有一个 rawOutputsText(JSON.stringify 整包),已移除:
// 它会把 taskId / workflowRunId 等内部标识符渲染给用户,且模板里的引用
// 早已被注释掉、留下一个「看似有原始输出视图」的死代码(见 issue 10)。
const resultTagText = computed(() => {
if (loading.value) {
return '执行中';
}
if (videoFiles.value.length) {
return '视频生成';
}
if (scriptMarkdown.value.trim()) {
return '脚本创作';
}
if (photoFiles.value.length) {
return '图像/排版';
}
return '待执行';
});
const resultTagColor = computed(() => {
if (loading.value) {
return 'processing';
}
if (hasAnyResult.value) {
return 'success';
}
return 'default';
});
const loadingTitle = computed(() => {
return '正在根据文本指令生成内容';
});
const loadingDescription = computed(() => {
return '该工作流可能持续数分钟,请保持页面开启。系统正在后台完成素材理解、内容生成与结果整理。';
});
const loadingSteps = [
{
title: '输入理解',
desc: '解析文本指令、识别目标输出和执行路径'
},
{
title: '内容生成',
desc: '调用工作流处理视频、脚本或图像排版任务'
},
{
title: '结果整理',
desc: '回收文件链接并整理结构化输出'
}
];
const normalizeOutputPayload = (payload: CreativeAssistantResponse | null) => {
if (!payload) {
return {};
}
if (payload.outputs && typeof payload.outputs === 'object') {
return payload.outputs;
}
if ((payload as any).data?.outputs && typeof (payload as any).data.outputs === 'object') {
return (payload as any).data.outputs;
}
return payload as CreativeAssistantOutputs;
};
const resolveFileUrl = (file?: Partial<FileRecord> | null) => {
if (!file) {
return '';
}
return String(file.url || file.downloadUrl || file.thumbnail || file.path || '');
};
const getFileName = (file?: Partial<FileRecord> | null) => {
const value = resolveFileUrl(file);
if (!value) {
return '未命名文件';
}
return decodeURIComponent(value.split('?')[0].split('/').pop() || value);
};
const getFileExt = (file?: Partial<FileRecord> | null) => {
const name = String(file?.name || getFileName(file)).toLowerCase();
return name.split('.').pop() || '';
};
const isImageFile = (file?: Partial<FileRecord> | null) => {
const contentType = String(file?.contentType || '').toLowerCase();
if (contentType.startsWith('image/')) {
return true;
}
return ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].includes(getFileExt(file));
};
const isVideoFile = (file?: Partial<FileRecord> | null) => {
const contentType = String(file?.contentType || '').toLowerCase();
if (contentType.startsWith('video/')) {
return true;
}
return ['mp4', 'mov', 'm4v', 'webm', 'avi'].includes(getFileExt(file));
};
const isAudioFile = (file?: Partial<FileRecord> | null) => {
const contentType = String(file?.contentType || '').toLowerCase();
if (contentType.startsWith('audio/')) {
return true;
}
return ['mp3', 'wav', 'aac', 'm4a', 'ogg'].includes(getFileExt(file));
};
const getFileTypeLabel = (file?: Partial<FileRecord> | null) => {
if (isImageFile(file)) {
return '图片';
}
if (isVideoFile(file)) {
return '视频';
}
if (isAudioFile(file)) {
return '音频';
}
return '文档';
};
const clearResult = () => {
workflowResponse.value = null;
workflowOutputs.value = null;
};
const resetAll = () => {
content.value = '';
clearResult();
};
/**
* 请求体。
*
* <p>附件已下线:上游出图工具只做文生图,开始节点的 file-list 变量没有任何节点消费
* (见 issue 16),继续传只是让用户白等上传。
*
* <p>不传 {@code mode}:这个页面是通用的创意助手入口,用户可能写视频、脚本或图片需求,
* 由用户的文字自己说明意图;只有聊天页「图片」模式这类**用户显式选了功能**的地方
* 才传 {@code mode=image} 让后端包装提示词(见 issue 19)。
*/
const buildRequestPayload = () => {
const value = content.value.trim();
return {
user_content: value,
user_contentString: value
};
};
const runWorkflow = async () => {
if (!canSubmit.value) {
message.warning('请先输入文本指令');
return;
}
loading.value = true;
try {
const result = await creativeAssistant(buildRequestPayload());
workflowResponse.value = result || null;
workflowOutputs.value = normalizeOutputPayload(result || null);
if (
!videoFiles.value.length &&
!scriptMarkdown.value.trim() &&
!photoFiles.value.length
) {
message.warning('接口已返回,但未识别到文档约定的输出字段');
} else {
message.success('工作流执行完成');
}
} catch (e: any) {
message.error(e.message || '工作流执行失败');
} finally {
loading.value = false;
}
};
const copyScript = () => {
if (!scriptMarkdown.value.trim()) {
message.warning('暂无脚本结果');
return;
}
copyText(scriptMarkdown.value);
};
const fillExample = (type: ExampleType) => {
if (type === 'video') {
content.value =
'以「青年志愿者进社区」为主题,生成一条 45 秒校园活动宣传短视频,画面节奏明快,配一段适合配音的解说文案,并输出视频结果。';
return;
}
if (type === 'script') {
content.value =
'围绕“青年志愿者进社区”主题创作一份短视频分镜脚本,要求包含开场钩子、3 个主要场景和结尾号召,输出为 Markdown 脚本。';
return;
}
content.value =
'以「毕业季」为主题,生成一套公众号头图与活动海报排版,风格青春、明亮,输出图片文件。';
};
</script>
<script lang="ts">
export default {
name: 'OaCreativeAssistant'
};
</script>
<style scoped lang="less">
.creative-page {
min-height: 100%;
padding: 24px;
background:
radial-gradient(circle at top right, rgba(255, 154, 93, 0.16), transparent 24%),
linear-gradient(180deg, #fff8f3 0%, #f7fbff 100%);
}
.hero-panel {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
padding: 28px 32px;
margin-bottom: 24px;
border-radius: 24px;
background: linear-gradient(135deg, #3b0f14 0%, #8f1f2d 48%, #d6404f 100%);
color: #f5fbff;
box-shadow: 0 18px 40px rgba(143, 31, 45, 0.2);
}
.hero-kicker {
margin-bottom: 8px;
font-size: 12px;
letter-spacing: 0.22em;
color: rgba(255, 235, 237, 0.72);
}
.hero-panel h1 {
margin: 0;
font-size: 32px;
line-height: 1.2;
color: #fff8f8;
}
.hero-panel p {
max-width: 760px;
margin: 14px 0 0;
line-height: 1.8;
color: rgba(255, 239, 241, 0.88);
}
.panel-card {
border-radius: 20px;
box-shadow: 0 14px 34px rgba(87, 54, 30, 0.08);
}
.quick-actions-panel {
margin-bottom: 14px;
padding: 16px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 14px 34px rgba(87, 54, 30, 0.08);
}
.quick-actions-title {
margin-bottom: 12px;
color: #4d3828;
font-size: 14px;
font-weight: 700;
}
.quick-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.file-badge {
flex-shrink: 0;
min-width: 48px;
padding: 4px 10px;
border-radius: 999px;
background: #f2e1d3;
color: #8a4f1d;
text-align: center;
font-size: 12px;
font-weight: 600;
}
.result-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.submit-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.result-card {
min-height: 100%;
}
.result-head {
display: flex;
align-items: center;
gap: 10px;
}
.result-body {
display: grid;
gap: 18px;
}
.status-alert {
border-radius: 14px;
}
.loading-panel {
display: grid;
gap: 18px;
padding: 22px;
border-radius: 20px;
background:
radial-gradient(circle at top right, rgba(255, 182, 122, 0.24), transparent 30%),
linear-gradient(135deg, #fff7ef 0%, #fffdfb 100%);
border: 1px solid #c7e8ff;
overflow: hidden;
}
.loading-visual {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 132px;
height: 132px;
margin: 0 auto;
}
.loading-orbit {
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid rgba(214, 117, 44, 0.18);
}
.orbit-one {
animation: orbitPulse 2.4s ease-in-out infinite;
}
.orbit-two {
inset: 14px;
border-color: rgba(214, 117, 44, 0.28);
animation: orbitPulse 2.4s ease-in-out infinite 0.7s;
}
.loading-core {
display: flex;
align-items: center;
gap: 8px;
padding: 18px 24px;
border-radius: 999px;
background: linear-gradient(135deg, #1590ff 0%, #32bfff 100%);
box-shadow: 0 14px 30px rgba(160, 83, 29, 0.2);
}
.loading-core-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff8f1;
animation: dotBounce 1.2s ease-in-out infinite;
}
.loading-core-dot:nth-child(2) {
animation-delay: 0.18s;
}
.loading-core-dot:nth-child(3) {
animation-delay: 0.36s;
}
.loading-copy {
text-align: center;
}
.loading-kicker {
margin-bottom: 8px;
color: #b37545;
font-size: 12px;
letter-spacing: 0.24em;
}
.loading-title {
color: #4e311c;
font-size: 22px;
font-weight: 700;
line-height: 1.4;
}
.loading-desc {
max-width: 680px;
margin: 10px auto 0;
color: #876650;
line-height: 1.8;
}
.loading-steps {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.loading-step {
display: flex;
gap: 12px;
padding: 14px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.78);
border: 1px solid #f1e3d7;
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
}
.loading-step.is-active {
transform: translateY(-2px);
border-color: #edb27f;
box-shadow: 0 10px 24px rgba(214, 117, 44, 0.12);
}
.loading-step-index {
flex-shrink: 0;
color: #ce8348;
font-size: 12px;
font-weight: 700;
line-height: 1.8;
}
.loading-step-title {
color: #4f3320;
font-weight: 600;
}
.loading-step-desc {
margin-top: 4px;
color: #8c705d;
font-size: 12px;
line-height: 1.7;
}
.result-section {
padding: 18px;
border-radius: 18px;
background: #faf8f5;
}
.section-title {
margin-bottom: 14px;
color: #4d3828;
font-size: 16px;
font-weight: 700;
}
.video-grid,
.photo-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.video-card,
.photo-card {
padding: 12px;
border-radius: 14px;
background: #fff;
border: 1px solid #efe6dc;
}
.video-player {
display: block;
//width: 100%;
height: 500px;
object-fit: cover;
border-radius: 10px;
background: #000;
}
.photo-image :deep(img) {
width: 100%;
height: 220px;
object-fit: cover;
border-radius: 10px;
}
.photo-fallback {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
height: 220px;
border-radius: 10px;
background: linear-gradient(135deg, #f7f1ea 0%, #fff 100%);
}
.result-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
}
.result-meta a {
flex-shrink: 0;
}
.markdown-viewer :deep(.markdown-body) {
padding: 18px;
border-radius: 14px;
background: #fff;
}
.raw-output {
margin: 0;
padding: 14px;
border-radius: 12px;
background: #1f1a17;
color: #f7ede2;
overflow: auto;
font-size: 12px;
line-height: 1.7;
}
@keyframes orbitPulse {
0% {
transform: scale(0.9);
opacity: 0.25;
}
50% {
transform: scale(1.04);
opacity: 0.7;
}
100% {
transform: scale(0.9);
opacity: 0.25;
}
}
@keyframes dotBounce {
0%,
80%,
100% {
transform: translateY(0);
opacity: 0.55;
}
40% {
transform: translateY(-4px);
opacity: 1;
}
}
@media (max-width: 991px) {
.creative-page {
padding: 16px;
}
.hero-panel {
flex-direction: column;
padding: 22px 20px;
}
.hero-panel h1 {
font-size: 28px;
}
.video-grid,
.photo-grid {
grid-template-columns: 1fr;
}
.loading-steps {
grid-template-columns: 1fr;
}
}
</style>