Files
tuanwei-uniapp/pages/creative-assistant/index.vue
2026-07-20 11:56:02 +08:00

1039 lines
24 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>
<view class="page">
<scroll-view scroll-y class="page-scroll">
<view class="hero-card">
<view class="hero-title">AI 创意助手</view>
<view class="hero-desc">
支持文本指令与多类型附件联合驱动工作流自动返回视频脚本 Markdown 或图像 / 排版结果
</view>
<view class="hero-actions">
<view class="hero-action" @tap="fillExample('video')">短视频创意</view>
<view class="hero-action" @tap="fillExample('script')">脚本创作</view>
<view class="hero-action" @tap="fillExample('image')">海报排版</view>
</view>
</view>
<view class="panel-card">
<view class="panel-title">输入配置</view>
<view class="field-list">
<view class="field-item">
<view class="field-label">文本指令</view>
<textarea
v-model="content"
class="field-textarea field-textarea--large"
placeholder="请输入用户的文本指令或需求描述,例如:根据活动照片和通知文档,生成一条公众号推文封面及 60 秒短视频脚本。"
placeholder-style="color: #c0c4cc;"
maxlength="-1"
/>
</view>
<view class="field-item">
<view class="field-label">上传附件</view>
<view class="upload-bar">
<view class="upload-btn" @tap="chooseFiles">{{ uploading ? '上传中...' : '添加附件' }}</view>
<view class="upload-tip">支持图片视频音频和常见文档类型</view>
</view>
<view v-if="uploadedFiles.length" class="file-list">
<view
v-for="(item, index) in uploadedFiles"
:key="`${item.url || item.path || item.name}-${index}`"
class="file-item-card"
>
<view class="file-main">
<view class="file-badge">{{ getFileTypeLabel(item) }}</view>
<view class="file-text">
<view class="file-name">{{ item.name || getFileName(item) }}</view>
<view class="file-url">{{ resolveFileUrl(item) || '-' }}</view>
</view>
</view>
<view class="file-remove" @tap="removeUploadedFile(index)">移除</view>
</view>
</view>
</view>
</view>
<view class="submit-bar">
<view class="ghost-btn" @tap="resetAll">重置</view>
<view class="primary-btn" :class="{ 'primary-btn--disabled': !canSubmit || loading }" @tap="runWorkflow">
{{ loading ? '执行中' : '生成' }}
</view>
</view>
</view>
<view class="panel-card result-card">
<view class="result-head">
<view class="panel-title">工作流结果</view>
<view class="result-status" :class="loading ? 'result-status--loading' : hasAnyResult ? 'result-status--done' : ''">
{{ loading ? '执行中' : hasAnyResult ? '已完成' : '待执行' }}
</view>
</view>
<view v-if="loading" class="loading-panel">
<view class="loading-kicker">WORKFLOW RUNNING</view>
<view class="loading-title">{{ loadingTitle }}</view>
<view class="loading-desc">{{ loadingDescription }}</view>
<view class="loading-steps">
<view
v-for="(item, index) in loadingSteps"
:key="item.title"
class="loading-step"
:class="{ 'is-active': index === 1 }"
>
<view class="loading-step-index">0{{ index + 1 }}</view>
<view class="loading-step-main">
<view class="loading-step-title">{{ item.title }}</view>
<view class="loading-step-desc">{{ item.desc }}</view>
</view>
</view>
</view>
</view>
<view v-else-if="!hasAnyResult" class="result-empty">
填写文本指令并上传附件后执行工作流
</view>
<view v-else class="result-body">
<view v-if="statusMessage" class="status-alert">
{{ statusMessage }}
</view>
<view v-if="videoFiles.length" class="result-section">
<view class="section-title">视频生成结果</view>
<view class="video-list">
<view
v-for="(item, index) in videoFiles"
:key="`${resolveFileUrl(item)}-${index}`"
class="video-card"
>
<video
v-if="isVideoFile(item)"
class="video-player"
controls
object-fit="cover"
:src="resolveFileUrl(item)"
></video>
<view class="result-meta">
<view class="result-name">{{ item.name || getFileName(item) }}</view>
<view class="result-link" @tap="copyFileLink(item)">复制链接</view>
</view>
</view>
</view>
</view>
<view v-if="scriptDisplayContent" class="result-section">
<view class="section-title">脚本创作结果</view>
<zero-markdown-view
class="script-shell script-markdown"
:markdown="scriptDisplayContent"
/>
</view>
<view v-if="photoFiles.length" class="result-section">
<view class="section-title">图像 / 排版结果</view>
<view class="photo-grid">
<view
v-for="(item, index) in photoFiles"
:key="`${resolveFileUrl(item)}-${index}`"
class="photo-card"
>
<image
v-if="isImageFile(item)"
class="photo-image"
mode="aspectFill"
:src="resolveFileUrl(item)"
@tap="previewImage(item)"
/>
<view v-else class="photo-fallback">
<view class="file-badge">{{ getFileTypeLabel(item) }}</view>
<view class="result-link" @tap="copyFileLink(item)">复制结果链接</view>
</view>
<view class="result-meta">
<view class="result-name">{{ item.name || getFileName(item) }}</view>
<view class="result-link" @tap="copyFileLink(item)">复制链接</view>
</view>
</view>
</view>
</view>
</view>
<view class="result-actions">
<view class="result-btn" @tap="copyScript">复制脚本</view>
<view class="result-btn" @tap="clearResult">清空结果</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { creativeAssistant, uploadAiFile } from './service'
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', 'AAC', 'OGG'])
const VIDEO_FILE_EXTS = new Set(['MP4', 'MOV', 'MPEG', 'MPGA', 'WEBM', 'AVI', 'M4V'])
export default {
data() {
return {
loading: false,
uploading: false,
content: '',
uploadedFiles: [],
workflowResponse: null,
workflowOutputs: null,
loadingSteps: [
{
title: '输入理解',
desc: '解析文本指令、识别目标输出和执行路径'
},
{
title: '内容生成',
desc: '调用工作流处理视频、脚本或图像排版任务'
},
{
title: '结果整理',
desc: '回收文件链接并整理结构化输出'
}
]
}
},
computed: {
canSubmit() {
return !!String(this.content || '').trim() || this.uploadedFiles.length > 0
},
videoFiles() {
return (this.workflowOutputs && this.workflowOutputs.video_result) || []
},
scriptMarkdown() {
return String((this.workflowOutputs && this.workflowOutputs.Script_result) || '')
},
scriptDisplayContent() {
console.log(this.scriptMarkdown
.replace(/<think>[\s\S]*?<\/think>/g, '')
.trim())
return this.scriptMarkdown
.replace(/<think>[\s\S]*?<\/think>/g, '')
.trim()
},
photoFiles() {
return (this.workflowOutputs && this.workflowOutputs.photo_files) || []
},
statusMessage() {
return (
(this.workflowOutputs && this.workflowOutputs.video_message) ||
(this.workflowOutputs && this.workflowOutputs.photo_message) ||
''
)
},
hasAnyResult() {
return !!(
this.videoFiles.length ||
this.scriptMarkdown.trim() ||
this.photoFiles.length
)
},
loadingTitle() {
if (this.uploadedFiles.length && String(this.content || '').trim()) {
return '正在分析指令并处理附件'
}
if (this.uploadedFiles.length) {
return '正在解析附件并生成结果'
}
return '正在根据文本指令生成内容'
},
loadingDescription() {
return '该工作流可能持续数分钟,请保持页面开启。系统正在后台完成素材理解、内容生成与结果整理。'
}
},
methods: {
showToast(title) {
uni.showToast({
title,
icon: 'none'
})
},
normalizeOutputPayload(payload) {
if (!payload) {
return {}
}
if (payload.outputs && typeof payload.outputs === 'object') {
return payload.outputs
}
if (payload.data && payload.data.outputs && typeof payload.data.outputs === 'object') {
return payload.data.outputs
}
return payload
},
resolveFileUrl(file) {
if (!file) {
return ''
}
return String(file.url || file.downloadUrl || file.thumbnail || file.path || '')
},
getFileName(file) {
const value = this.resolveFileUrl(file)
if (!value) {
return '未命名文件'
}
try {
return decodeURIComponent(value.split('?')[0].split('/').pop() || value)
} catch (error) {
return value.split('?')[0].split('/').pop() || value
}
},
getFileExt(file) {
const name = String((file && (file.name || this.getFileName(file))) || '').toLowerCase()
return name.split('.').pop() || ''
},
isImageFile(file) {
const contentType = String((file && file.contentType) || '').toLowerCase()
if (contentType.startsWith('image/')) {
return true
}
return ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].includes(this.getFileExt(file))
},
isVideoFile(file) {
const contentType = String((file && file.contentType) || '').toLowerCase()
if (contentType.startsWith('video/')) {
return true
}
return ['mp4', 'mov', 'm4v', 'webm', 'avi'].includes(this.getFileExt(file))
},
isAudioFile(file) {
const contentType = String((file && file.contentType) || '').toLowerCase()
if (contentType.startsWith('audio/')) {
return true
}
return ['mp3', 'wav', 'aac', 'm4a', 'ogg', 'amr'].includes(this.getFileExt(file))
},
getUploadFileType(file) {
const contentType = String((file && file.contentType) || '').toLowerCase()
const ext = this.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'
},
getFileTypeLabel(file) {
if (this.isImageFile(file)) {
return '图片'
}
if (this.isVideoFile(file)) {
return '视频'
}
if (this.isAudioFile(file)) {
return '音频'
}
return '文档'
},
clearResult() {
this.workflowResponse = null
this.workflowOutputs = null
},
resetAll() {
this.content = ''
this.uploadedFiles = []
this.clearResult()
},
removeUploadedFile(index) {
this.uploadedFiles = this.uploadedFiles.filter((item, current) => current !== index)
},
async chooseFiles() {
if (this.uploading) {
return
}
try {
const result = await new Promise((resolve, reject) => {
uni.chooseMessageFile({
count: 9,
type: 'all',
success: resolve,
fail: reject
})
})
const tempFiles = (result && result.tempFiles) || []
if (!tempFiles.length) {
return
}
this.uploading = true
uni.showLoading({
title: '上传中',
mask: true
})
for (let index = 0; index < tempFiles.length; index += 1) {
const file = tempFiles[index]
if (file.size && file.size / 1024 / 1024 > 100) {
this.showToast('单个附件不能超过 100MB')
continue
}
const uploaded = await uploadAiFile(file)
this.uploadedFiles = [
...this.uploadedFiles,
{
id: uploaded.id,
name: uploaded.name || file.name,
path: uploaded.path,
url: uploaded.url,
downloadUrl: uploaded.downloadUrl,
contentType: uploaded.contentType
}
]
}
this.showToast('附件上传成功')
} catch (error) {
if (error && error.errMsg && String(error.errMsg).includes('cancel')) {
return
}
this.showToast((error && error.message) || '上传失败')
} finally {
this.uploading = false
uni.hideLoading()
}
},
buildRequestPayload() {
const payload = {
user_content: String(this.content || '').trim(),
user_contentString: String(this.content || '').trim()
}
if (this.uploadedFiles.length) {
payload.user_files = this.uploadedFiles.map((item) => ({
transfer_method: 'local_file',
type: this.getUploadFileType(item),
upload_file_id: item.id,
url: ''
}))
}
return payload
},
async runWorkflow() {
if (!this.canSubmit) {
this.showToast('请先输入文本指令或上传附件')
return
}
if (this.loading) {
return
}
this.loading = true
try {
const result = await creativeAssistant(this.buildRequestPayload())
this.workflowResponse = result || null
this.workflowOutputs = this.normalizeOutputPayload(result || null)
if (!this.hasAnyResult) {
this.showToast('接口已返回,但未识别到文档约定的输出字段')
} else {
this.showToast('工作流执行完成')
}
} catch (error) {
this.showToast((error && error.message) || '工作流执行失败')
} finally {
this.loading = false
}
},
copyScript() {
if (!this.scriptDisplayContent) {
this.showToast('暂无脚本结果')
return
}
uni.setClipboardData({
data: this.scriptDisplayContent,
success: () => {
this.showToast('脚本已复制')
}
})
},
copyFileLink(file) {
const url = this.resolveFileUrl(file)
if (!url) {
this.showToast('当前文件缺少链接')
return
}
uni.setClipboardData({
data: url,
success: () => {
this.showToast('链接已复制')
}
})
},
previewImage(file) {
const url = this.resolveFileUrl(file)
if (!url) {
this.showToast('当前图片缺少地址')
return
}
const urls = this.photoFiles.map((item) => this.resolveFileUrl(item)).filter(Boolean)
uni.previewImage({
current: url,
urls
})
},
fillExample(type) {
if (type === 'video') {
this.content = '请根据我上传的活动照片和通知文档,生成一条 45 秒校园活动宣传短视频,画面节奏明快,配一段适合配音的解说文案,并输出视频结果。'
return
}
if (type === 'script') {
this.content = '围绕“青年志愿者进社区”主题创作一份短视频分镜脚本要求包含开场钩子、3 个主要场景和结尾号召,输出为 Markdown 脚本。'
return
}
this.content = '请根据上传的活动照片、LOGO 和文案素材,生成一套公众号头图与活动海报排版,风格青春、明亮,输出图片文件。'
}
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background:
radial-gradient(circle at top right, rgba(20, 150, 242, 0.14), transparent 24%),
linear-gradient(180deg, #fff8f3 0%, #f7fbff 100%);
}
.page-scroll {
height: 100vh;
padding: 28rpx 24rpx 40rpx;
}
.hero-card {
padding: 34rpx 30rpx;
border-radius: 32rpx;
background: linear-gradient(135deg, #0c5f9f 0%, #1496f2 48%, #67c7ff 100%);
box-shadow: 0 18rpx 40rpx rgba(143, 31, 45, 0.2);
color: #333;
}
.hero-kicker {
font-size: 22rpx;
letter-spacing: 4rpx;
color: rgba(255, 235, 237, 0.72);
}
.hero-title {
margin-top: 18rpx;
font-size: 42rpx;
line-height: 1.35;
font-weight: 700;
color: #fff8f8;
}
.hero-desc {
margin-top: 16rpx;
font-size: 24rpx;
line-height: 1.8;
color: rgba(255, 239, 241, 0.88);
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 14rpx;
margin-top: 26rpx;
}
.hero-action {
padding: 12rpx 20rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.12);
font-size: 22rpx;
color: #fff6f6;
}
.panel-card {
margin-top: 24rpx;
padding: 26rpx;
border-radius: 30rpx;
background: rgba(255, 255, 255, 0.96);
border: 1rpx solid rgba(20, 150, 242, 0.08);
box-shadow: 0 14rpx 34rpx rgba(87, 54, 30, 0.08);
}
.panel-title {
font-size: 30rpx;
font-weight: 700;
color: #1f2329;
}
.field-list {
display: flex;
flex-direction: column;
gap: 22rpx;
margin-top: 20rpx;
}
.field-label {
margin-bottom: 12rpx;
font-size: 25rpx;
font-weight: 600;
color: #2b3037;
}
.field-textarea {
width: 100%;
min-height: 220rpx;
padding: 22rpx 24rpx;
border-radius: 22rpx;
background: #fcf6f2;
border: 1rpx solid #d6eafb;
font-size: 24rpx;
line-height: 1.8;
color: #2a2e35;
box-sizing: border-box;
}
.field-textarea--large {
min-height: 360rpx;
}
:deep(.field-input.uv-input) {
width: 100%;
min-height: 88rpx;
padding: 0 24rpx;
border-radius: 12rpx;
border: 1px solid #dcdfe6;
background: #ffffff;
box-sizing: border-box;
}
:deep(.field-input.uv-input .uv-input__content__field-wrapper__field) {
height: 88rpx;
min-height: 88rpx;
font-size: 28rpx;
line-height: 88rpx;
color: #303133;
}
.upload-bar {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.upload-btn {
align-self: flex-start;
padding: 14rpx 24rpx;
border-radius: 20rpx;
background: rgba(20, 150, 242, 0.1);
color: #1496f2;
font-size: 24rpx;
font-weight: 600;
}
.upload-tip {
font-size: 22rpx;
line-height: 1.7;
color: #88715f;
}
.file-list {
display: flex;
flex-direction: column;
gap: 14rpx;
margin-top: 16rpx;
}
.file-item-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
padding: 18rpx 20rpx;
border-radius: 22rpx;
background: #f4faff;
border: 1rpx solid #d6eafb;
}
.file-main {
display: flex;
align-items: center;
gap: 12rpx;
flex: 1;
min-width: 0;
}
.file-badge {
flex-shrink: 0;
min-width: 72rpx;
padding: 8rpx 14rpx;
border-radius: 999rpx;
background: #eaf5ff;
color: #1496f2;
text-align: center;
font-size: 20rpx;
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-size: 24rpx;
font-weight: 600;
}
.file-url {
margin-top: 8rpx;
color: #8c7462;
font-size: 20rpx;
}
.file-remove {
flex-shrink: 0;
color: #ff4d4f;
font-size: 22rpx;
}
.submit-bar,
.result-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
margin-top: 24rpx;
}
.ghost-btn,
.primary-btn,
.result-btn {
height: 88rpx;
line-height: 88rpx;
border-radius: 22rpx;
font-size: 28rpx;
font-weight: 700;
text-align: center;
}
.ghost-btn {
background: #f6f7fa;
color: #6f5a4b;
border: 1rpx solid #ead7c8;
}
.primary-btn {
background: linear-gradient(135deg, #0f7dd1 0%, #1496f2 100%);
color: #fff;
}
.primary-btn--disabled {
opacity: 0.6;
}
.result-btn {
background: #edf6ff;
color: #0f7dd1;
}
.result-card {
margin-bottom: 20rpx;
}
.result-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.result-status {
padding: 10rpx 16rpx;
border-radius: 999rpx;
background: #f3f0ed;
color: #8b827d;
font-size: 20rpx;
font-weight: 600;
}
.result-status--loading {
background: #edf5ff;
color: #1554ad;
}
.result-status--done {
background: #eef9f1;
color: #1f8f49;
}
.result-empty {
margin-top: 22rpx;
padding: 32rpx 24rpx;
border-radius: 24rpx;
background: #f8fafc;
font-size: 24rpx;
line-height: 1.75;
color: #8a94a0;
text-align: center;
}
.result-body {
display: flex;
flex-direction: column;
gap: 18rpx;
margin-top: 22rpx;
}
.status-alert {
padding: 20rpx 22rpx;
border-radius: 22rpx;
background: #f8fbff;
color: #5f6b7a;
font-size: 22rpx;
line-height: 1.7;
}
.loading-panel {
display: grid;
gap: 18rpx;
margin-top: 22rpx;
padding: 22rpx;
border-radius: 20rpx;
background:
radial-gradient(circle at top right, rgba(255, 182, 122, 0.24), transparent 30%),
linear-gradient(135deg, #fff7ef 0%, #fffdfb 100%);
border: 1rpx solid #d6eafb;
}
.loading-kicker {
font-size: 20rpx;
letter-spacing: 3rpx;
color: #1496f2;
}
.loading-title {
font-size: 30rpx;
font-weight: 700;
color: #0f7dd1;
}
.loading-desc {
font-size: 22rpx;
line-height: 1.75;
color: #8b735e;
}
.loading-steps {
display: flex;
flex-direction: column;
gap: 14rpx;
}
.loading-step {
display: flex;
align-items: flex-start;
gap: 16rpx;
padding: 16rpx 18rpx;
border-radius: 18rpx;
background: rgba(255, 255, 255, 0.72);
}
.loading-step.is-active {
border: 1rpx solid rgba(214, 117, 44, 0.18);
}
.loading-step-index {
font-size: 22rpx;
font-weight: 700;
color: #1496f2;
}
.loading-step-title {
font-size: 24rpx;
font-weight: 700;
color: #0f7dd1;
}
.loading-step-desc {
margin-top: 6rpx;
font-size: 20rpx;
line-height: 1.6;
color: #8b735e;
}
.result-section {
display: flex;
flex-direction: column;
gap: 14rpx;
}
.section-title {
font-size: 26rpx;
font-weight: 700;
color: #2d3238;
}
.video-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.video-card,
.photo-card {
padding: 18rpx;
border-radius: 22rpx;
background: #f4faff;
border: 1rpx solid #d6eafb;
}
.video-player {
width: 100%;
height: 360rpx;
border-radius: 18rpx;
background: #000;
}
.script-shell {
padding: 22rpx 24rpx;
border-radius: 22rpx;
background: #f8fafc;
border: 1rpx solid #ebeff5;
}
.script-markdown {
display: block;
font-size: 24rpx;
line-height: 1.85;
color: #2b3037;
word-break: break-all;
p { margin: 0 0 16rpx; }
p:last-child { margin-bottom: 0; }
h1, h2, h3, h4, h5, h6 {
font-weight: 700;
margin: 20rpx 0 10rpx;
color: #1a1f27;
}
h1 { font-size: 36rpx; }
h2 { font-size: 32rpx; }
h3 { font-size: 28rpx; }
ul, ol {
padding-left: 36rpx;
margin: 10rpx 0;
}
li { margin-bottom: 8rpx; }
code {
display: inline;
padding: 2rpx 10rpx;
border-radius: 8rpx;
background: rgba(20, 150, 242, 0.08);
color: #1496f2;
font-size: 22rpx;
font-family: monospace;
}
pre {
padding: 20rpx;
border-radius: 14rpx;
background: #f1f3f7;
margin: 14rpx 0;
overflow-x: auto;
}
pre code {
padding: 0;
background: transparent;
color: #2d3a47;
font-size: 22rpx;
white-space: pre;
}
blockquote {
margin: 12rpx 0;
padding: 10rpx 20rpx;
border-left: 6rpx solid rgba(20, 150, 242, 0.3);
color: #6b5f59;
background: rgba(20, 150, 242, 0.04);
border-radius: 0 12rpx 12rpx 0;
}
hr {
margin: 18rpx 0;
border: none;
border-top: 1rpx solid #e0e6ed;
}
strong { font-weight: 700; }
em { font-style: italic; }
}
.photo-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
}
.photo-image {
width: 100%;
height: 240rpx;
border-radius: 18rpx;
background: #f2f4f8;
}
.photo-fallback {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
height: 240rpx;
border-radius: 18rpx;
background: #fff;
}
.result-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
margin-top: 14rpx;
}
.result-name {
flex: 1;
font-size: 22rpx;
color: #3b2c20;
font-weight: 600;
}
.result-link {
flex-shrink: 0;
font-size: 22rpx;
color: #c45f17;
}
</style>