8ca7a00afb
上游「AI 创意助手」开始节点有 file-list 变量 user_files(最多 5 个,允许
image/video/document/audio),但 web 端图片模式此前完全没有附件入口 —— 发送键左侧
那两个图标按钮没有绑定任何事件,是纯装饰。
- 聊天页图片模式新增「参考素材」区:添加附件(隐藏 input[type=file])、已选列表可
逐个移除、上限 5 个(对齐上游 max_length),按 MIME/扩展名判定
image/video/audio/document,发送时组装 file-list 契约
{transfer_method:"local_file", type, upload_file_id, url:""};上传中禁止发送
- uploadAiFile(file, app?) 支持应用代号;gxmu/creativeAssistant 表单页改传
creativeAssistant —— 该页用同一个接口上传却不指定应用,附件必然报
Invalid upload file(既有缺陷,本次一并修掉)
- CreativeAssistantUploadFile.upload_file_id 由 number 改为 string | number:
它实际是 Dify /files/upload 返回的 uuid 字符串,原声明与实现不符
- formatCreativeResult 清洗 video_message / photo_message:剥离 thinking 思维链、
丢弃 {"cover_prompt":…,"width":…} 中间产物 JSON,不再把上游 LLM 的原始输出
当正文展示(截图实测过:气泡里出现整段英文思考过程或裸 JSON)
- 删除发送键左侧两个无事件的装饰性图标按钮(.icon-btn 样式仍被消息操作按钮使用,保留)
1014 lines
27 KiB
Vue
1014 lines
27 KiB
Vue
<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-item label="上传附件">
|
||
<a-upload
|
||
:multiple="true"
|
||
:show-upload-list="false"
|
||
:accept="acceptTypes"
|
||
:customRequest="handleUpload"
|
||
>
|
||
<a-button>
|
||
<template #icon>
|
||
<UploadOutlined />
|
||
</template>
|
||
添加附件
|
||
</a-button>
|
||
</a-upload>
|
||
<div class="upload-tip">
|
||
支持图片、视频、文档、音频。
|
||
</div>
|
||
<div v-if="uploadedFiles.length" class="file-list">
|
||
<div
|
||
v-for="(item, index) in uploadedFiles"
|
||
:key="`${item.url || item.path || item.name}-${index}`"
|
||
class="file-item"
|
||
>
|
||
<div class="file-main">
|
||
<div class="file-badge">{{ getFileTypeLabel(item) }}</div>
|
||
<div class="file-text">
|
||
<div class="file-name">{{ item.name || getFileName(item) }}</div>
|
||
<div class="file-url">{{ resolveFileUrl(item) || '-' }}</div>
|
||
</div>
|
||
</div>
|
||
<a class="file-remove" @click="removeUploadedFile(index)">移除</a>
|
||
</div>
|
||
</div>
|
||
</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 { UploadOutlined } from '@ant-design/icons-vue';
|
||
import { messageLoading } from 'ele-admin-pro';
|
||
import ByteMdViewer from '@/components/ByteMdViewer/index.vue';
|
||
import { creativeAssistant, uploadAiFile } 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 acceptTypes =
|
||
'image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md';
|
||
const plugins = ref([
|
||
gfm({
|
||
locale: zh_HansGfm
|
||
}),
|
||
highlight()
|
||
]);
|
||
|
||
const loading = ref(false);
|
||
const content = ref('');
|
||
const uploadedFiles = ref<Partial<FileRecord>[]>([]);
|
||
const workflowResponse = ref<CreativeAssistantResponse | null>(null);
|
||
const workflowOutputs = ref<CreativeAssistantOutputs | null>(null);
|
||
|
||
const canSubmit = computed(() => {
|
||
return !!content.value.trim() || uploadedFiles.value.length > 0;
|
||
});
|
||
|
||
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(() => {
|
||
if (uploadedFiles.value.length && content.value.trim()) {
|
||
return '正在分析指令并处理附件';
|
||
}
|
||
if (uploadedFiles.value.length) {
|
||
return '正在解析附件并生成结果';
|
||
}
|
||
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 DOCUMENT_FILE_EXTS = new Set([
|
||
'TXT',
|
||
'MD',
|
||
'MARKDOWN',
|
||
'PDF',
|
||
'HTML',
|
||
'XLSX',
|
||
'XLS',
|
||
'DOCX',
|
||
'CSV',
|
||
'EML',
|
||
'MSG',
|
||
'PPTX',
|
||
'PPT',
|
||
'XML',
|
||
'EPUB'
|
||
]);
|
||
const AUDIO_FILE_EXTS = new Set(['MP3', 'M4A', 'WAV', 'WEBM', 'AMR']);
|
||
const VIDEO_FILE_EXTS = new Set(['MP4', 'MOV', 'MPEG', 'MPGA']);
|
||
|
||
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 getUploadFileType = (
|
||
file?: Partial<FileRecord> | null
|
||
): 'image' | 'document' | 'audio' | 'video' => {
|
||
const contentType = String(file?.contentType || '').toLowerCase();
|
||
const ext = getFileExt(file).toUpperCase();
|
||
if (contentType.startsWith('image/')) {
|
||
return 'image';
|
||
}
|
||
if (AUDIO_FILE_EXTS.has(ext)) {
|
||
return 'audio';
|
||
}
|
||
if (VIDEO_FILE_EXTS.has(ext)) {
|
||
return 'video';
|
||
}
|
||
if (DOCUMENT_FILE_EXTS.has(ext)) {
|
||
return 'document';
|
||
}
|
||
return 'document';
|
||
};
|
||
|
||
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 = '';
|
||
uploadedFiles.value = [];
|
||
clearResult();
|
||
};
|
||
|
||
const removeUploadedFile = (index: number) => {
|
||
uploadedFiles.value = uploadedFiles.value.filter((_, current) => current !== index);
|
||
};
|
||
|
||
const handleUpload = async (options: any) => {
|
||
const rawFile = options.file as File;
|
||
if (rawFile.size / 1024 / 1024 > 100) {
|
||
message.error('单个附件不能超过 100MB');
|
||
options.onError?.(new Error('文件过大'));
|
||
return;
|
||
}
|
||
const hide = messageLoading({
|
||
content: '上传中..',
|
||
duration: 0,
|
||
mask: true
|
||
});
|
||
try {
|
||
// 必须用创意助手自己的应用凭证上传:Dify 的 /files/upload 按应用鉴权,
|
||
// 用别的应用的 Key 上传,工作流的 file-list 变量会报 Invalid upload file。
|
||
const result = await uploadAiFile(rawFile, 'creativeAssistant');
|
||
uploadedFiles.value = [
|
||
...uploadedFiles.value,
|
||
{
|
||
id: result.id,
|
||
name: result.name || rawFile.name,
|
||
path: result.path,
|
||
url: result.url,
|
||
downloadUrl: result.downloadUrl,
|
||
contentType: result.contentType
|
||
}
|
||
];
|
||
options.onSuccess?.(result);
|
||
message.success('附件上传成功');
|
||
} catch (e: any) {
|
||
message.error(e.message || '上传失败');
|
||
options.onError?.(e);
|
||
} finally {
|
||
hide();
|
||
}
|
||
};
|
||
|
||
const buildRequestPayload = () => {
|
||
const payload: any = {
|
||
user_content: content.value.trim(),
|
||
user_contentString: content.value.trim()
|
||
};
|
||
if (uploadedFiles.value.length) {
|
||
payload.user_files = uploadedFiles.value.map((item) => ({
|
||
transfer_method: 'local_file',
|
||
type: getUploadFileType(item),
|
||
upload_file_id: item.id,
|
||
url: ''
|
||
}));
|
||
}
|
||
return payload;
|
||
};
|
||
|
||
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 =
|
||
'请根据上传的活动照片、LOGO 和文案素材,生成一套公众号头图与活动海报排版,风格青春、明亮,输出图片文件。';
|
||
};
|
||
</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;
|
||
}
|
||
|
||
.upload-tip {
|
||
margin-top: 10px;
|
||
color: #88715f;
|
||
font-size: 12px;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.file-list {
|
||
margin-top: 14px;
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.file-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 12px 14px;
|
||
border-radius: 14px;
|
||
background: #faf5ef;
|
||
border: 1px solid #c7e8ff;
|
||
}
|
||
|
||
.file-main {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.file-text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.file-name,
|
||
.file-url,
|
||
.result-name {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.file-name {
|
||
color: #3b2c20;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.file-url {
|
||
margin-top: 4px;
|
||
color: #8c7462;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.file-remove {
|
||
flex-shrink: 0;
|
||
color: #ff4d4f;
|
||
}
|
||
|
||
.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>
|