Files
tuanwei-admin/src/views/gxmu/creativeAssistant/index.vue
T
weicw-dshsa 8ca7a00afb feat(ai): 图片模式接入 file-list 参考素材上传(issue 14)
上游「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 样式仍被消息操作按钮使用,保留)
2026-10-13 19:57:57 +08:00

1014 lines
27 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-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>