Files
tuanwei-uniapp/pages/creative-assistant/index.vue
T
weicw-dshsa 6bbbd386c8 fix(ai): 补齐失败态判定的三处遗漏(评审跟进)
对抗式评审发现上一版仍有三处同类缺口,一并修掉:

- formatCreativeResult 不再回落 payload.answer:失败报文里 answer 是
  workflow_run_id(UUID),而 status 缺失时(message_end 事件、后端流式兜底
  分支)失败短路不生效,裸 UUID 会进气泡
- 新增 isDisplayableAnswer / pickDisplayable:把 UUID 与无空格长 token
  这类内部标识符挡在展示之外,formatOutlineResult 与 formatManuscriptResult
  复用;大纳/稿件也一并加失败态短路,避免 03 落地后端后渲染成
  「标题 + 失败文案」
- creative-assistant 的 workflowFailed 补 data.status 回退,与 assistant 页
  及后端 workflowStatus(两处都看)对齐,避免退回误导性的「待执行」空态

验证:verify-frontend-fallback.js 17/17;verify-frontend-e2e.js 以后端真实产出
为输入跑通,渲染结果无内部标识符与原始 JSON。

Refs: .scratch/ai-assistant/issues/01-creative-assistant-raw-json-bubble.md
2026-09-24 03:51:53 +08:00

1070 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
}
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.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>