Files
tuanwei-uniapp/pages/creative-assistant/index.vue
T
weicw-dshsa 244f0c24d8 feat(ai): 问答助手页入口对齐 web,改为「聊天/写作/图片」(issue 15)
模式标签由「聊天/稿件/创意/策划」改为「聊天/写作/图片」,去掉策划链路;
写作与图片两个入口补上 web 端已有的表单与附件能力。

- 新增 pages/assistant/writing-params.js:web 端 writing-params.ts 的等价移植
  (零依赖纯函数)。写作模式加「文体/字数」面板,入参优先级改为
  「界面显式选择 > 键值入参 > 自然语言推断 > 默认值」——过去没有表单,
  只按「键:值」解析,解析不到就一律回落 doc_type=新闻稿 / Number=800,
  而上游提示词把这两项当主控指令,会直接压掉用户原话里的诉求(issue 13)
- 图片模式加「图片比例 + 参考素材」:附件组装成 user_files(上限 5,
  类型 image/video/audio/document),比例作为文本追加进 user_content
- chat-service 新增 uploadAiFile(file, app);上传必须带 app='creativeAssistant'
  —— Dify 的 /files/upload 按应用鉴权,不传 app 的附件必然报
  Invalid upload file(issue 14)
- 删除 buildOutlinePayload / formatOutlineResult 与 activityOutlineGen import;
  顺带清掉本次改动块内的死代码(未使用的 getCurrentUser、pickDisplayableText)

顺带修掉同源缺陷:pages/creative-assistant 的 uploadAiFile(file) 也没传应用凭证,
该页附件同样是坏的(issue 14 记的 gxmu/creativeAssistant 表单页那个缺陷)。

验证:移植等价性用例 8/8 —— 同一张用例表同时喂给小程序与 web 两端实现逐字段比对
JSON,另加 4 条常量/边界断言(防「两边一起错」);模板↔脚本静态一致性检查
(模板绑定名、this.X() 调用点、已删标识符残留)全绿,判别力经 5 处变异测试证伪;
5 个改动文件 node --check 通过,style 块花括号配平。
本机无 HBuilderX CLI 与 package.json,真机/微信开发者工具未跑。

本文件同时带有此前未提交的 issue 01 跟进改动(resolveFailedCreativeText:
创意失败态采用后端按原因映射的文案,但仍过 isDisplayableAnswer 兜底),
同一文件无法拆分。

Refs: .scratch/ai-assistant/issues/15-uniapp-assistant-modes-align-web.md
2026-10-13 20:17:58 +08:00

1072 lines
25 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' : workflowFailed ? 'result-status--failed' : hasAnyResult ? 'result-status--done' : ''">
{{ loading ? '执行中' : workflowFailed ? '执行失败' : 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="workflowFailed" class="result-empty">
{{ (workflowResponse && workflowResponse.answer) || '工作流执行失败,请稍后重试' }}
</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
)
},
/**
* 后端判定工作流失败时不再下发 outputs/raw,只给 status + 可读 answer。
* 这里据此提示失败,避免落到「待执行」空态误导用户(见 issue 01)。
*
* <p>判定需与 assistant 页及后端 `workflowStatus` 一致:顶层优先,
* 回退 `data.status`(后端两处都看,前端不能只看一处)。
*/
workflowFailed() {
const payload = this.workflowResponse
if (!payload || typeof payload !== 'object') {
return false
}
const status =
payload.status !== undefined && payload.status !== null
? payload.status
: payload.data && payload.data.status
return String(status || '').trim().toLowerCase() === 'failed'
},
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
}
// app 必须传 creativeAssistant:Dify 的 /files/upload 按应用鉴权,
// 用 chat 凭证上传的 file id,工作流会报 Invalid upload file。
const uploaded = await uploadAiFile(file, 'creativeAssistant')
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.workflowFailed) {
this.showToast(
(result && result.answer) || '工作流执行失败,请稍后重试'
)
} else 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-status--failed {
background: #fdeeee;
color: #c0392b;
}
.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>