library-picturebook-activity/lesingle-aicreate-client/src/views/EditInfo.vue
En 3c24cc3102 feat: 添加CLAUDE.md项目指导文件及AI创作客户端更新
添加 CLAUDE.md 用于 Claude Code 项目导航,包含架构说明和开发规范。
更新 AI 创作客户端至 V4.0,新增后端对接示例项目。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 12:11:15 +08:00

371 lines
11 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="edit-page page-fullscreen">
<PageHeader title="编辑绘本信息" subtitle="补充信息让绘本更完整" :showBack="true" />
<div v-if="loading" class="loading-state">
<div style="font-size:36px">📖</div>
<div style="color:var(--text-sub);margin-top:8px">加载中...</div>
</div>
<div v-else class="content page-content">
<!-- 封面预览 -->
<div class="cover-preview card" v-if="coverUrl">
<img :src="coverUrl" class="cover-img" />
<div class="cover-title-overlay">{{ store.workDetail?.title || '未命名' }}</div>
</div>
<!-- 基本信息 -->
<div class="card form-card">
<div class="field-item">
<div class="field-label"><span>✍️</span> 作者署名 <span class="required-mark">必填</span></div>
<input v-model="form.author" class="text-input" :class="{ 'input-error': authorError }" placeholder="如:宝宝的名字" maxlength="16" @input="authorError = ''" />
<span class="char-count-inline">{{ form.author.length }}/16</span>
<div v-if="authorError" class="field-error">{{ authorError }}</div>
</div>
<div class="field-item">
<div class="field-label"><span>📝</span> 副标题 <span class="optional-mark">选填</span></div>
<input v-model="form.subtitle" class="text-input" placeholder="如:一个关于勇气的故事" maxlength="20" />
<span class="char-count-inline">{{ form.subtitle.length }}/20</span>
</div>
<div class="field-item">
<div class="field-label">
<span>📖</span> 绘本简介 <span class="optional-mark">选填</span>
<span class="char-count">{{ form.intro.length }}/250</span>
</div>
<textarea v-model="form.intro" class="textarea-input" placeholder="简单介绍一下这个绘本的故事" maxlength="250" rows="3" />
</div>
</div>
<!-- 标签 -->
<div class="card form-card">
<div class="field-label" style="margin-bottom:12px"><span>🏷️</span> 绘本标签</div>
<div class="tags-wrap">
<span v-for="(tag, i) in selectedTags" :key="'s'+i" class="tag selected-tag">
{{ tag }}
<span v-if="selectedTags.length > 1" class="tag-remove" @click="removeTag(i)">×</span>
</span>
<!-- 添加标签达到5个上限时隐藏 -->
<template v-if="selectedTags.length < 5">
<span v-if="!addingTag" class="tag add-tag" @click="addingTag = true">+</span>
<span v-else class="tag adding-tag">
<input
ref="tagInput"
v-model="newTag"
class="tag-input"
placeholder="输入标签"
maxlength="8"
@keydown.enter="confirmAddTag"
@blur="confirmAddTag"
/>
</span>
</template>
</div>
<!-- 推荐标签达到5个上限时隐藏只显示未选中的 -->
<div v-if="selectedTags.length < 5 && limitedPresets.length > 0" class="preset-tags">
<span
v-for="p in limitedPresets" :key="p"
class="tag preset-tag"
@click="addPresetTag(p)"
>+ {{ p }}</span>
</div>
</div>
</div>
<!-- 底部 -->
<div v-if="!loading" class="page-bottom">
<button class="btn-primary" :disabled="saving" @click="handleSave">
{{ saving ? '保存中...' : '保存绘本 ' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import PageHeader from '@/components/PageHeader.vue'
import { getWorkDetail, updateWork } from '@/api'
import { store } from '@/utils/store'
import { STATUS, getRouteByStatus } from '@/utils/status'
const router = useRouter()
const route = useRoute()
const workId = computed(() => route.params.workId || store.workId)
const loading = ref(true)
const saving = ref(false)
const coverUrl = ref('')
const form = ref({ author: '', subtitle: '', intro: '' })
const selectedTags = ref([])
const addingTag = ref(false)
const newTag = ref('')
const tagInput = ref(null)
const PRESET_TAGS = ['冒险', '成长', '友谊', '魔法', '勇敢', '快乐', '温暖', '探索', '梦想', '好奇']
const availablePresets = computed(() =>
PRESET_TAGS.filter(t => !selectedTags.value.includes(t))
)
// 推荐标签最多显示到总标签数 5 个的剩余空位
const limitedPresets = computed(() => {
const remaining = 5 - selectedTags.value.length
if (remaining <= 0) return []
return availablePresets.value.slice(0, remaining)
})
function removeTag(i) {
if (selectedTags.value.length <= 1) return
selectedTags.value.splice(i, 1)
}
function addPresetTag(tag) {
if (selectedTags.value.length >= 5) return
if (!selectedTags.value.includes(tag)) selectedTags.value.push(tag)
}
function confirmAddTag() {
const t = newTag.value.trim()
if (t && !selectedTags.value.includes(t) && selectedTags.value.length < 5) {
selectedTags.value.push(t)
}
newTag.value = ''
addingTag.value = false
}
async function loadWork() {
loading.value = true
try {
// 缓存不匹配当前 workId 时重新请求(防止上一个作品数据残留)
if (!store.workDetail || store.workDetail.workId !== workId.value) {
store.workDetail = null
const res = await getWorkDetail(workId.value)
store.workDetail = res.data
}
const w = store.workDetail
// 如果作品状态已超过 CATALOGED重定向到对应页面
if (w.status > STATUS.CATALOGED) {
const route = getRouteByStatus(w.status, w.workId)
if (route) { router.replace(route); return }
}
form.value.author = w.author || ''
form.value.subtitle = w.subtitle || ''
form.value.intro = w.intro || ''
selectedTags.value = Array.isArray(w.tags) && w.tags.length ? [...w.tags] : ['冒险']
coverUrl.value = w.pageList?.[0]?.imageUrl || ''
} catch (e) {
// fallback: proceed with empty form
} finally {
loading.value = false
}
}
const authorError = ref('')
async function handleSave() {
// 作者署名必填校验
if (!form.value.author.trim()) {
authorError.value = '请填写作者署名'
return
}
authorError.value = ''
saving.value = true
try {
const data = { tags: selectedTags.value }
data.author = form.value.author.trim()
if (form.value.subtitle.trim()) data.subtitle = form.value.subtitle.trim()
if (form.value.intro.trim()) data.intro = form.value.intro.trim()
await updateWork(workId.value, data)
// 更新缓存
if (store.workDetail) {
if (data.author) store.workDetail.author = data.author
if (data.subtitle) store.workDetail.subtitle = data.subtitle
if (data.intro) store.workDetail.intro = data.intro
store.workDetail.tags = [...selectedTags.value]
}
// C1 保存后进入配音
store.workDetail = null // 清除缓存
router.push(`/dubbing/${workId.value}`)
} catch (e) {
// 容错保存报错时检查实际状态可能已经成功但重试导致CAS失败
try {
const check = await getWorkDetail(workId.value)
if (check?.data?.status >= 4) {
store.workDetail = null
router.push(`/dubbing/${workId.value}`)
return
}
} catch { /* ignore */ }
alert(e.message || '保存失败,请重试')
} finally {
saving.value = false
}
}
onMounted(() => {
loadWork()
nextTick(() => { if (tagInput.value) tagInput.value.focus() })
})
</script>
<style lang="scss" scoped>
.edit-page {
background: var(--bg);
}
.loading-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.content {
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.cover-preview {
position: relative;
border-radius: 16px;
overflow: hidden;
height: 120px;
}
.cover-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-title-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 10px 16px;
background: linear-gradient(transparent, rgba(0,0,0,0.6));
color: #fff;
font-size: 16px;
font-weight: 700;
}
.form-card { padding: 20px; }
.field-item { margin-bottom: 16px; &:last-child { margin-bottom: 0; } }
.field-label {
font-size: 13px;
color: var(--text-sub);
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 6px;
}
.required-mark { color: var(--primary); font-size: 11px; }
.optional-mark { color: #94A3B8; font-size: 11px; }
.required-mark { color: #EF4444; font-size: 11px; font-weight: 600; }
.input-error { border-color: #EF4444 !important; }
.field-error { color: #EF4444; font-size: 12px; margin-top: 4px; }
.char-count { margin-left: auto; font-size: 11px; color: #94A3B8; }
.char-count-inline { display: block; text-align: right; font-size: 11px; color: #94A3B8; margin-top: 2px; }
.text-input {
width: 100%;
border: none;
background: #F8F7F4;
border-radius: var(--radius-sm);
padding: 14px 16px;
font-size: 16px;
outline: none;
color: var(--text);
box-sizing: border-box;
transition: box-shadow 0.2s;
&:focus { box-shadow: 0 0 0 2px var(--primary); }
&.input-error { box-shadow: 0 0 0 2px #EF4444; }
}
.textarea-input {
width: 100%;
border: 1.5px solid var(--border);
background: #FAFAF8;
border-radius: var(--radius-sm);
padding: 12px 14px;
font-size: 15px;
outline: none;
color: var(--text);
resize: none;
box-sizing: border-box;
transition: border 0.3s;
&:focus { border-color: var(--primary); }
}
.error-text { color: #EF4444; font-size: 12px; margin-top: 4px; }
.tags-wrap {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.selected-tag {
background: var(--primary-light);
color: var(--primary);
font-weight: 600;
}
.tag-remove {
font-size: 15px;
font-weight: 700;
opacity: 0.6;
&:hover { opacity: 1; }
}
.add-tag {
background: var(--border);
color: var(--text-sub);
font-size: 16px;
font-weight: 700;
padding: 6px 16px;
}
.adding-tag {
background: #fff;
border: 1.5px solid var(--primary);
padding: 4px 8px;
}
.tag-input {
border: none;
outline: none;
font-size: 13px;
width: 60px;
background: transparent;
color: var(--text);
}
.preset-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.preset-tag {
background: #F0EDE8;
color: var(--text-sub);
font-size: 12px;
padding: 4px 12px;
}
</style>