refactor: AI 创作流程 11 页界面全面重做与紫粉主题统一

- aicreate.scss 主题变量紫粉化,对齐 PublicLayout 设计语言
- 11 个创作流程 view 清理 emoji 改 antd 图标,文案去除"孩子/家长"等第三人称
- 路由调整:编排故事改到选画风之前(更顺的产品逻辑)
- WelcomeView 浮动 CTA + 完整 7 步流程引导
- CharactersView 单角色大图 / 多角色网格自适应
- StyleSelectView 预设路径 /aicreate/styles/{styleId}.jpg + SVG fallback
- CreatingView 改为异步任务式说明 + 去作品库入口
- PreviewView / DubbingView 缩略图统一为横向胶卷
- EditInfoView 底部三按钮(保存草稿 / 去配音 / 发布作品),配音改为可选
- BookReaderView 修复 dev 模式数据加载 + 紫粉封面
- DubbingView / BookReaderView 改用 page-fullscreen 布局类避免被 tabbar 遮挡
- store 新增 fillMockData / fillMockWorkDetail,支持 dev 无后端走通完整流程
- works/Index.vue 加 query.tab 双向同步,支持跳转携带 tab 参数

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
aid 2026-04-09 18:14:26 +08:00
parent 87ac3b5ed9
commit 951346a7a8
15 changed files with 3046 additions and 1617 deletions

View File

@ -1,24 +1,24 @@
// 乐读派 C端 AI 创作专用样式隔离在 .ai-create-shell 容器内
// 暖橙 + 奶油白 儿童绘本风格
// AI 创作专用样式隔离在 .ai-create-shell 容器内
// 紫粉风格 PublicLayout 设计语言保持一致
// 所有 CSS 变量使用 --ai- 前缀避免与主前端冲突
.ai-create-shell {
--ai-primary: #FF6B35;
--ai-primary-light: #FFF0E8;
--ai-secondary: #6C63FF;
--ai-accent: #FFD166;
--ai-success: #2EC4B6;
--ai-bg: #FFFDF7;
--ai-card: #FFFFFF;
--ai-text: #2D2D3F;
--ai-text-sub: #8E8EA0;
--ai-border: #F0EDE8;
--ai-primary: #6366f1;
--ai-primary-light: #eef0ff;
--ai-secondary: #ec4899;
--ai-accent: #a78bfa;
--ai-success: #10b981;
--ai-bg: #f8f7fc;
--ai-card: #ffffff;
--ai-text: #1e1b4b;
--ai-text-sub: #6b7280;
--ai-border: #e5e7eb;
--ai-radius: 20px;
--ai-radius-sm: 14px;
--ai-shadow: 0 8px 32px rgba(255, 107, 53, 0.12);
--ai-shadow-soft: 0 4px 20px rgba(0, 0, 0, 0.06);
--ai-gradient: linear-gradient(135deg, #FF6B35 0%, #FF8F65 50%, #FFB088 100%);
--ai-gradient-purple: linear-gradient(135deg, #6C63FF 0%, #9B93FF 100%);
--ai-shadow: 0 8px 28px rgba(99, 102, 241, 0.22);
--ai-shadow-soft: 0 4px 20px rgba(99, 102, 241, 0.06);
--ai-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
--ai-gradient-purple: linear-gradient(135deg, #a78bfa 0%, #c084fc 100%);
--ai-font: 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', -apple-system, sans-serif;
font-family: var(--ai-font);

View File

@ -28,12 +28,12 @@ const baseRoutes: RouteRecordRaw[] = [
},
{
path: "/p",
name: "PublicMain",
component: () => import("@/layouts/PublicLayout.vue"),
meta: { requiresAuth: false },
children: [
{
path: "",
name: "PublicMain",
redirect: "/p/gallery",
},
{
@ -100,16 +100,16 @@ const baseRoutes: RouteRecordRaw[] = [
name: "PublicCreateCharacters",
component: () => import("@/views/public/create/views/CharactersView.vue"),
},
{
path: "style",
name: "PublicCreateStyle",
component: () => import("@/views/public/create/views/StyleSelectView.vue"),
},
{
path: "story",
name: "PublicCreateStory",
component: () => import("@/views/public/create/views/StoryInputView.vue"),
},
{
path: "style",
name: "PublicCreateStyle",
component: () => import("@/views/public/create/views/StyleSelectView.vue"),
},
{
path: "creating",
name: "PublicCreateCreating",

View File

@ -93,6 +93,89 @@ export const useAicreateStore = defineStore('aicreate', () => {
sessionStorage.setItem('le_recovery', JSON.stringify(recovery))
}
/**
* mock UI
* UI 使
* @param count mock 1-3 3
*/
function fillMockData(count: number = 3) {
// 纯渐变占位图(不带文字,模拟真实 AI 抠图返回的角色形象)
const mockSvg = (hue: number) =>
'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">` +
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
`<stop offset="0" stop-color="hsl(${hue},75%,72%)"/>` +
`<stop offset="1" stop-color="hsl(${(hue + 35) % 360},80%,58%)"/>` +
`</linearGradient></defs>` +
`<rect width="240" height="240" fill="url(#g)"/>` +
`</svg>`
)
imageUrl.value = mockSvg(250)
extractId.value = 'mock-extract-' + Date.now()
selectedCharacter.value = null
// 注意:真实 AI 接口不返回 name 字段mock 数据也不写 name由用户在 StoryInputView 自己起名
const allChars = [
{ charId: 'mock-c1', type: 'HERO', originalCropUrl: mockSvg(280) },
{ charId: 'mock-c2', type: 'SIDEKICK', originalCropUrl: mockSvg(30) },
{ charId: 'mock-c3', type: 'SIDEKICK', originalCropUrl: mockSvg(100) },
]
const n = Math.max(1, Math.min(count, allChars.length))
characters.value = allChars.slice(0, n)
}
/**
* mock AI // UI
* UI 使
*/
function fillMockWorkDetail() {
// 16:9 渐变占位图800x450模拟真实绘本插画
const mockPage = (hue: number) =>
'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="800" height="450" viewBox="0 0 800 450">` +
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
`<stop offset="0" stop-color="hsl(${hue},70%,75%)"/>` +
`<stop offset="1" stop-color="hsl(${(hue + 45) % 360},75%,55%)"/>` +
`</linearGradient></defs>` +
`<rect width="800" height="450" fill="url(#g)"/>` +
`</svg>`
)
// 13 页(封面 + 12 内页),模拟真实绘本规模,便于测试横向胶卷式缩略图
const pageTexts = [
'', // 封面
'一个阳光明媚的早晨,小主角推开窗,看见远处的森林闪着金光。',
'它沿着小路走啊走,遇到了一只迷路的小鸟,羽毛湿漉漉的。',
'小主角轻轻抱起小鸟,决定送它回家。',
'路过一片野花田时,一只小狐狸从草丛里跳出来打招呼。',
'小狐狸说它认识森林里所有的小路,愿意做大家的向导。',
'三个朋友来到一条清澈的溪水边,溪里有一群闪闪发光的小鱼。',
'小鱼们告诉他们,那棵会发光的大树就在前方不远处。',
'森林深处真的有一棵会发光的大树,挂满了亮晶晶的果实。',
'原来这就是小鸟的家,妈妈正在树枝上焦急地张望。',
'小鸟欢快地飞回妈妈身边,鸟妈妈给大家每人一颗果实。',
'夕阳下,小主角和小狐狸告别,小狐狸送他们到森林边缘。',
'小主角带着这份美好回到家,心里也开出了一朵花。',
]
const wid = 'mock-work-' + Date.now()
workId.value = wid
workDetail.value = {
workId: wid,
status: 3, // COMPLETED
title: storyData.value?.title || '森林大冒险',
subtitle: '',
author: '',
coverUrl: mockPage(280),
pageList: pageTexts.map((text, i) => ({
pageNum: i,
text,
imageUrl: mockPage((280 + i * 27) % 360),
})),
}
}
function restoreRecoveryState() {
const raw = sessionStorage.getItem('le_recovery')
if (!raw) return null
@ -122,6 +205,9 @@ export const useAicreateStore = defineStore('aicreate', () => {
imageUrl, extractId, characters, selectedCharacter,
selectedStyle, storyData, workId, workDetail,
reset, saveRecoveryState, restoreRecoveryState,
// 开发模式
fillMockData,
fillMockWorkDetail,
// Tab 切换状态
lastCreateRoute, setLastCreateRoute, clearLastCreateRoute,
}

View File

@ -74,16 +74,13 @@ onMounted(() => {
<style lang="scss">
// 使 scoped aicreate.scss .ai-create-shell
// PublicLayout public-main
// tabbar
// PublicLayout public-main H5
.public-main:has(> .ai-create-shell) {
padding-left: 0 !important;
padding-right: 0 !important;
padding-top: 0 !important;
// padding-bottom tabbar 40px / 80px
max-width: 430px;
overflow: hidden;
background: var(--ai-bg, #FFFDF7);
}
.ai-create-shell {
@ -99,8 +96,8 @@ onMounted(() => {
.spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255, 107, 53, 0.15);
border-top-color: #FF6B35;
border: 4px solid rgba(99, 102, 241, 0.15);
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;

View File

@ -1,15 +1,15 @@
<template>
<div class="reader-page" @touchstart="onTouchStart" @touchend="onTouchEnd">
<div class="reader-page page-fullscreen" @touchstart="onTouchStart" @touchend="onTouchEnd">
<!-- 顶栏 -->
<div class="reader-top">
<div v-if="fromWorks" class="back-btn" @click="handleBack">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round"><path d="M15 18l-6-6 6-6"/></svg>
<left-outlined />
</div>
<div class="top-title">{{ title }}</div>
</div>
<!-- 书本区域 -->
<div class="book-area">
<div class="book-area page-content">
<div
class="book"
:class="{ 'flip-left': flipDir === -1, 'flip-right': flipDir === 1 }"
@ -19,22 +19,24 @@
<!-- 封面 -->
<div v-if="isCover" class="page-cover">
<div class="cover-deco star"></div>
<div class="cover-image" v-if="coverImageUrl">
<img :src="coverImageUrl" class="cover-real-img" />
<div class="cover-image">
<img v-if="coverImageUrl" :src="coverImageUrl" class="cover-real-img" />
<picture-outlined v-else class="cover-placeholder" />
</div>
<div class="cover-image" v-else>📖</div>
<div class="cover-title">{{ currentPage.text }}</div>
<div class="cover-divider" />
<div class="cover-brand">{{ brandName }} AI 绘本</div>
<div v-if="authorDisplay" class="cover-author"> {{ authorDisplay }}</div>
<div class="cover-brand">{{ brandName }} · AI 绘本创作</div>
<div v-if="authorDisplay" class="cover-author">
<user-outlined />
<span>{{ authorDisplay }}</span>
</div>
</div>
<!-- 正文页 -->
<div v-else-if="isContent" class="page-content">
<div v-else-if="isContent" class="book-content-page">
<div class="content-image">
<img v-if="currentPage.imageUrl" :src="currentPage.imageUrl" class="content-real-img" />
<span v-else class="content-emoji">{{ pageEmoji }}</span>
<picture-outlined v-else class="content-placeholder" />
<div class="page-num">P{{ idx }}</div>
</div>
<div class="content-text">{{ currentPage.text }}</div>
@ -42,29 +44,31 @@
<!-- 封底 -->
<div v-else-if="isBack" class="page-back">
<div class="back-emoji">🎉</div>
<div class="back-title">故事讲完</div>
<heart-filled class="back-icon" />
<div class="back-title">故事讲完</div>
<div class="back-divider" />
<div class="back-desc">每一个孩子的画<br/>都是一个精彩的故事</div>
<div class="back-desc">你的画作<br />变成了一个精彩的故事</div>
<div v-if="workTags.length" class="book-tags">
<span v-for="tag in workTags" :key="tag" class="book-tag">{{ tag }}</span>
</div>
<div class="back-replay" @click="jumpTo(0)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 105.64-8.36L1 10"/></svg>
重新阅读
<reload-outlined />
<span>重新阅读</span>
</div>
<div class="back-brand">{{ brandName }} AI 绘本 · {{ brandSlogan }}</div>
<div class="back-brand">{{ brandName }} · AI 绘本创作</div>
</div>
</div>
<!-- 翻页导航 -->
<div class="nav-row">
<div class="nav-btn prev" :class="{ disabled: idx <= 0 }" @click="go(-1)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M15 18l-6-6 6-6"/></svg>
<left-outlined />
</div>
<div class="nav-label">
{{ isCover ? '封面' : isBack ? '— 完 —' : `${idx} / ${totalContent}` }}
</div>
<div class="nav-label">{{ isCover ? '封面' : isBack ? '— 完 —' : `${idx} / ${totalContent}` }}</div>
<div class="nav-btn next" :class="{ disabled: idx >= pages.length - 1 }" @click="go(1)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 18l6-6-6-6"/></svg>
<right-outlined />
</div>
</div>
@ -74,10 +78,13 @@
</div>
</div>
<!-- 底部再次创作仅创作流程入口显示作品列表入口不显示 -->
<div v-if="!fromWorks" class="reader-bottom safe-bottom">
<button class="btn-primary" @click="goHome">再次创作 </button>
<div class="bottom-hint">本作品可在作品板块中查看</div>
<!-- 底部再次创作仅创作流程入口显示作品库入口不显示 -->
<div v-if="!fromWorks" class="reader-bottom page-bottom">
<button class="btn-primary again-btn" @click="goHome">
<plus-outlined />
<span>再创作一本</span>
</button>
<div class="bottom-hint">本作品可在作品库继续查看</div>
</div>
</div>
</template>
@ -85,16 +92,28 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
LeftOutlined,
RightOutlined,
PictureOutlined,
UserOutlined,
HeartFilled,
ReloadOutlined,
PlusOutlined,
} from '@ant-design/icons-vue'
import { useAicreateStore } from '@/stores/aicreate'
import { getWorkDetail } from '@/api/aicreate'
import config from '@/utils/aicreate/config'
const route = useRoute()
const router = useRouter()
const store = useAicreateStore()
const isDev = import.meta.env.DEV
const fromWorks = new URLSearchParams(window.location.search).get('from') === 'works'
|| sessionStorage.getItem('le_from') === 'works'
const brandName = '乐绘世界'
function handleBack() {
if (fromWorks) {
router.push('/p/works')
@ -103,17 +122,14 @@ function handleBack() {
}
}
const brandName = config.brand.title || '乐读派'
const brandSlogan = config.brand.slogan || '让想象力飞翔'
const idx = ref(0)
const flipDir = ref(0)
const title = ref('我的绘本')
const coverImageUrl = ref('')
const authorDisplay = ref('')
const workTags = ref([])
const pages = ref([
const workTags = ref<string[]>([])
const pages = ref<any[]>([
{ pageNum: 0, text: '我的绘本', type: 'cover' },
{ pageNum: 99, text: '', type: 'backcover' },
])
@ -125,35 +141,29 @@ const isContent = computed(() => !isCover.value && !isBack.value)
const totalContent = computed(() => pages.value.length - 2)
const progressPct = computed(() => ((idx.value) / (pages.value.length - 1)) * 100)
const bgColors = ['#FFF5EB', '#E8F4FD', '#F0F9EC', '#FFF8E1', '#F3E8FF', '#E0F7F4', '#FFF9E6', '#FCE4EC']
const emojis = ['📖', '🌅', '🐦', '🗣️', '🍄', '🏠', '❤️', '🌟']
const pageEmoji = computed(() => emojis[idx.value % emojis.length])
const pageBg = computed(() => {
if (isCover.value) return 'linear-gradient(135deg, #FF8F65 0%, #FF6B35 40%, #E85D26 100%)'
if (isBack.value) return 'linear-gradient(135deg, #FFD4A8 0%, #FFB874 50%, #FF9F43 100%)'
return `linear-gradient(180deg, ${bgColors[idx.value % bgColors.length]} 0%, #FFFFFF 100%)`
if (isCover.value) return 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%)'
if (isBack.value) return 'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 50%, #f472b6 100%)'
return '#ffffff'
})
const go = (dir) => {
const go = (dir: number) => {
const next = idx.value + dir
if (next < 0 || next >= pages.value.length) return
flipDir.value = dir
setTimeout(() => { idx.value = next; flipDir.value = 0 }, 250)
}
const jumpTo = (i) => { idx.value = i; flipDir.value = 0 }
const jumpTo = (i: number) => { idx.value = i; flipDir.value = 0 }
//
let touchStartX = 0
let touchStartY = 0
const onTouchStart = (e) => {
const onTouchStart = (e: TouchEvent) => {
touchStartX = e.touches[0].clientX
touchStartY = e.touches[0].clientY
}
const onTouchEnd = (e) => {
const onTouchEnd = (e: TouchEvent) => {
const dx = e.changedTouches[0].clientX - touchStartX
const dy = e.changedTouches[0].clientY - touchStartY
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5) {
@ -162,22 +172,41 @@ const onTouchEnd = (e) => {
}
const goHome = () => {
store.reset() //
store.reset()
router.push('/p/create')
}
function applyWork(work: any) {
title.value = work.title || '我的绘本'
const list: any[] = [{ pageNum: 0, text: work.title || '我的绘本', type: 'cover' }]
;(work.pageList || []).forEach((p: any) => {
if (p.pageNum > 0) list.push({ pageNum: p.pageNum, text: p.text, imageUrl: p.imageUrl })
})
if (work.pageList?.[0]?.imageUrl) coverImageUrl.value = work.pageList[0].imageUrl
if (work.author) authorDisplay.value = work.author
if (Array.isArray(work.tags) && work.tags.length > 0) workTags.value = work.tags
list.push({ pageNum: 99, text: '', type: 'backcover' })
pages.value = list
}
onMounted(async () => {
const workId = route.params.workId
// dev mock workId store.workDetail
if (isDev && String(workId || '').startsWith('mock-')) {
if (!store.workDetail) store.fillMockWorkDetail()
if (store.workDetail) applyWork(store.workDetail)
return
}
if (!workId) return
try {
let work
const shareToken = new URLSearchParams(window.location.search).get('st') || ''
if (store.sessionToken || store.appSecret) {
// : axios HMAC/Bearer
const res = await getWorkDetail(workId)
const res = await getWorkDetail(workId as string)
work = res.data
} else if (shareToken) {
// : shareToken
const leaiBase = import.meta.env.VITE_LEAI_API_URL || ''
const resp = await fetch(`${leaiBase}/api/v1/query/work/${workId}?shareToken=${encodeURIComponent(shareToken)}`)
const json = await resp.json()
@ -185,62 +214,73 @@ onMounted(async () => {
} else {
return
}
if (work) {
title.value = work.title || '我的绘本'
const list = [{ pageNum: 0, text: work.title || '我的绘本', type: 'cover' }]
;(work.pageList || []).forEach(p => {
if (p.pageNum > 0) list.push({ pageNum: p.pageNum, text: p.text, imageUrl: p.imageUrl })
})
if (work.pageList?.[0]?.imageUrl) coverImageUrl.value = work.pageList[0].imageUrl
if (work.author) authorDisplay.value = work.author
if (Array.isArray(work.tags) && work.tags.length > 0) workTags.value = work.tags
list.push({ pageNum: 99, text: '', type: 'backcover' })
pages.value = list
}
if (work) applyWork(work)
} catch { /* use default */ }
})
</script>
<style lang="scss" scoped>
.reader-page {
min-height: 100vh;
background: #F5F0E8;
display: flex;
flex-direction: column;
background: var(--ai-bg);
user-select: none;
-webkit-user-select: none;
}
//
/* ---------- 顶栏 ---------- */
.reader-top {
padding: 14px 20px;
flex-shrink: 0;
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(255,255,255,0.85);
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(99, 102, 241, 0.08);
position: relative;
}
.back-btn { padding: 4px; cursor: pointer; background: rgba(0,0,0,0.2); border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.top-title { font-size: 15px; font-weight: 700; color: var(--ai-text); flex: 1; text-align: center; }
//
.book-area {
flex: 1;
.back-btn {
position: absolute;
left: 12px;
width: 32px;
height: 32px;
cursor: pointer;
background: rgba(99, 102, 241, 0.08);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--ai-primary);
:deep(.anticon) { font-size: 16px; }
}
.top-title {
font-size: 15px;
font-weight: 700;
color: var(--ai-text);
flex: 1;
text-align: center;
letter-spacing: 0.5px;
}
/* ---------- 书本区域 ---------- */
.book-area {
display: flex !important;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 12px 16px;
padding: 14px 16px 16px;
}
.book {
width: 100%;
max-width: 360px;
aspect-ratio: 3/4;
max-width: 320px;
aspect-ratio: 3 / 4;
border-radius: 4px 16px 16px 4px;
position: relative;
overflow: hidden;
box-shadow: inset -4px 0 8px rgba(0,0,0,0.04), 4px 0 12px rgba(0,0,0,0.08), 0 8px 32px rgba(0,0,0,0.12);
box-shadow:
inset -4px 0 8px rgba(0, 0, 0, 0.04),
4px 0 12px rgba(99, 102, 241, 0.14),
0 14px 36px rgba(99, 102, 241, 0.2);
transition: transform 0.25s ease;
&.flip-left { transform: perspective(800px) rotateY(8deg); }
@ -248,146 +288,313 @@ onMounted(async () => {
}
.book-spine {
position: absolute; left: 0; top: 0; bottom: 0; width: 6px;
background: linear-gradient(180deg, rgba(0,0,0,0.08), rgba(0,0,0,0.02), rgba(0,0,0,0.08));
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.02), rgba(0, 0, 0, 0.12));
z-index: 1;
}
//
/* ---------- 封面 ---------- */
.page-cover {
width: 100%; height: 100%;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: 32px; text-align: center;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 26px;
text-align: center;
position: relative;
}
.cover-deco { position: absolute; opacity: 0.4; &.star { top: 20px; right: 24px; font-size: 24px; } }
.cover-image {
width: calc(100% - 32px); aspect-ratio: 4/3; border-radius: 16px;
background: rgba(255,255,255,0.2);
display: flex; align-items: center; justify-content: center;
font-size: 72px; margin-bottom: 20px; overflow: hidden;
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 14px;
background: rgba(255, 255, 255, 0.18);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 18px;
overflow: hidden;
backdrop-filter: blur(8px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
}
.cover-real-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-placeholder {
font-size: 56px;
color: rgba(255, 255, 255, 0.5);
}
.cover-real-img { width: 100%; height: 100%; object-fit: cover; border-radius: 16px; }
.cover-title {
font-size: 24px; font-weight: 900; color: #fff;
text-shadow: 0 2px 8px rgba(0,0,0,0.2);
line-height: 1.4; letter-spacing: 2px;
font-size: 22px;
font-weight: 900;
color: #fff;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
line-height: 1.3;
letter-spacing: 1.5px;
}
.cover-divider {
width: 50px;
height: 2px;
background: rgba(255, 255, 255, 0.55);
border-radius: 2px;
margin: 14px 0 12px;
}
.cover-brand {
font-size: 12px;
color: rgba(255, 255, 255, 0.88);
font-weight: 500;
letter-spacing: 0.5px;
}
.cover-divider { width: 60px; height: 3px; background: rgba(255,255,255,0.5); border-radius: 2px; margin: 16px 0; }
.cover-brand { font-size: 13px; color: rgba(255,255,255,0.8); }
.cover-author {
margin-top: 8px; font-size: 12px; color: rgba(255,255,255,0.7);
background: rgba(255,255,255,0.15); border-radius: 12px; padding: 4px 14px;
}
//
.page-content { width: 100%; height: 100%; display: flex; flex-direction: column; }
.content-image {
flex: 1; display: flex; align-items: center; justify-content: center;
padding: 16px; position: relative;
}
.content-emoji { font-size: 64px; }
.content-real-img { width: 100%; height: 100%; object-fit: contain; border-radius: 12px; }
.page-num {
position: absolute; top: 20px; left: 20px;
background: rgba(0,0,0,0.4); border-radius: 8px; padding: 2px 10px;
font-size: 11px; font-weight: 600; color: #fff;
}
.content-text {
padding: 12px 24px 20px; text-align: center;
background: rgba(255,255,255,0.6); border-top: 1px solid rgba(0,0,0,0.04);
font-size: 16px; font-weight: 500; line-height: 1.8; letter-spacing: 0.5px;
}
//
.page-back {
width: 100%; height: 100%;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: 40px; text-align: center;
}
.back-emoji { font-size: 56px; margin-bottom: 20px; }
.back-title { font-size: 22px; font-weight: 900; color: #fff; text-shadow: 0 2px 6px rgba(0,0,0,0.15); }
.back-divider { width: 40px; height: 3px; background: rgba(255,255,255,0.5); border-radius: 2px; margin: 14px 0; }
.back-desc { font-size: 14px; color: rgba(255,255,255,0.8); line-height: 1.8; }
.back-replay {
margin-top: 24px;
margin-top: 12px;
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255,255,255,0.95);
color: var(--ai-primary);
font-size: 15px;
gap: 5px;
font-size: 11px;
color: #fff;
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 12px;
padding: 4px 12px;
backdrop-filter: blur(4px);
:deep(.anticon) { font-size: 11px; }
}
/* ---------- 正文页 ---------- */
.book-content-page {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.content-image {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 14px;
position: relative;
background: #1e1b4b;
}
.content-real-img {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 10px;
}
.content-placeholder {
font-size: 48px;
color: rgba(255, 255, 255, 0.3);
}
.page-num {
position: absolute;
top: 14px;
left: 14px;
background: rgba(15, 12, 41, 0.6);
backdrop-filter: blur(4px);
border-radius: 8px;
padding: 3px 10px;
font-size: 11px;
font-weight: 700;
padding: 12px 32px;
border-radius: 28px;
cursor: pointer;
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
transition: transform 0.2s;
&:active { transform: scale(0.95); }
color: #fff;
letter-spacing: 0.5px;
}
.back-brand {
margin-top: 20px;
font-size: 12px; color: rgba(255,255,255,0.7);
.content-text {
padding: 14px 22px 18px;
text-align: center;
background: #fff;
border-top: 1px solid rgba(99, 102, 241, 0.08);
font-size: 14px;
font-weight: 500;
line-height: 1.7;
color: var(--ai-text);
letter-spacing: 0.3px;
}
/* ---------- 封底 ---------- */
.page-back {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 28px;
text-align: center;
}
.back-icon {
font-size: 44px;
color: #fff;
margin-bottom: 16px;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.18));
}
.back-title {
font-size: 20px;
font-weight: 900;
color: #fff;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
letter-spacing: 1px;
}
.back-divider {
width: 36px;
height: 2px;
background: rgba(255, 255, 255, 0.55);
border-radius: 2px;
margin: 12px 0;
}
.back-desc {
font-size: 13px;
color: rgba(255, 255, 255, 0.92);
line-height: 1.7;
font-weight: 500;
}
.book-tags {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
margin-top: 16px;
padding: 0 20px;
gap: 6px;
margin-top: 14px;
padding: 0 16px;
}
.book-tag {
display: inline-block;
padding: 4px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
color: #E11D48;
background: linear-gradient(135deg, #FFF1F2, #FFE4E6);
border: 1px solid #FECDD3;
padding: 4px 12px;
border-radius: 14px;
font-size: 11px;
font-weight: 600;
color: #fff;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.32);
backdrop-filter: blur(4px);
}
//
.back-replay {
margin-top: 22px;
display: inline-flex;
align-items: center;
gap: 6px;
background: #fff;
color: var(--ai-primary);
font-size: 14px;
font-weight: 700;
padding: 11px 26px;
border-radius: 24px;
cursor: pointer;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
transition: transform 0.2s;
:deep(.anticon) { font-size: 13px; }
&:active { transform: scale(0.96); }
}
.back-brand {
margin-top: 18px;
font-size: 11px;
color: rgba(255, 255, 255, 0.85);
letter-spacing: 0.5px;
font-weight: 500;
}
/* ---------- 翻页导航 ---------- */
.nav-row {
display: flex; align-items: center; justify-content: space-between;
width: 100%; max-width: 360px; margin-top: 16px; padding: 0 4px;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: 320px;
margin-top: 16px;
padding: 0 4px;
}
.nav-btn {
width: 44px; height: 44px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all 0.2s;
&.prev { background: var(--ai-card); box-shadow: var(--ai-shadow-soft); color: var(--ai-text); }
&.next { background: var(--ai-gradient); box-shadow: var(--ai-shadow); color: #fff; }
&.disabled { opacity: 0; pointer-events: none; }
}
.nav-label { font-size: 13px; color: var(--ai-text-sub); font-weight: 500; }
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
//
:deep(.anticon) { font-size: 16px; }
&.prev {
background: #fff;
color: var(--ai-primary);
border: 1px solid rgba(99, 102, 241, 0.2);
box-shadow: 0 2px 10px rgba(99, 102, 241, 0.08);
&:hover { border-color: var(--ai-primary); }
}
&.next {
background: var(--ai-gradient);
color: #fff;
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.32);
&:hover { transform: scale(1.05); }
}
&.disabled {
opacity: 0;
pointer-events: none;
}
}
.nav-label {
font-size: 12px;
color: var(--ai-text-sub);
font-weight: 600;
letter-spacing: 0.5px;
}
/* ---------- 进度条 ---------- */
.progress-bar-wrap {
width: 100%; max-width: 360px; height: 3px;
background: #E2DDD4; border-radius: 2px; margin-top: 12px; overflow: hidden;
width: 100%;
max-width: 320px;
height: 3px;
background: rgba(99, 102, 241, 0.12);
border-radius: 2px;
margin-top: 12px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%; background: var(--ai-primary); border-radius: 2px;
transition: width 0.3s ease;
height: 100%;
background: var(--ai-gradient);
border-radius: 2px;
transition: width 0.4s ease;
}
//
/* ---------- 底部 ---------- */
.reader-bottom {
padding: 12px 20px 24px;
display: flex;
flex-direction: column;
align-items: center;
background: rgba(255,255,255,0.85);
backdrop-filter: blur(10px);
button { width: 100%; }
background: #fff;
border-top: 1px solid rgba(99, 102, 241, 0.08);
}
.again-btn {
display: flex !important;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
font-size: 16px !important;
padding: 14px 0 !important;
border-radius: 28px !important;
:deep(.anticon) { font-size: 16px; }
}
.bottom-hint {
margin-top: 8px;
font-size: 12px;
color: #9E9E9E;
color: var(--ai-text-sub);
text-align: center;
}
</style>

View File

@ -1,36 +1,57 @@
<template>
<div class="char-page page-fullscreen">
<PageHeader title="选择主角" subtitle="AI已识别画中角色请选择绘本主角" :step="1" />
<PageHeader title="画作角色" :subtitle="subtitle" :step="1" />
<div class="content page-content">
<!-- 开发模式mock 数据切换 -->
<div v-if="isDev" class="dev-bar">
<experiment-outlined />
<span class="dev-label">Mock 角色数</span>
<button class="dev-btn" :class="{ active: characters.length === 1 }" @click="regenMock(1)">1 </button>
<button class="dev-btn" :class="{ active: characters.length === 3 }" @click="regenMock(3)">3 </button>
</div>
<!-- 加载中 -->
<div v-if="loading" class="loading-state">
<div class="loading-emojis">
<span class="loading-emoji e1">🔍</span>
<span class="loading-emoji e2">🎨</span>
<span class="loading-emoji e3"></span>
</div>
<div class="loading-title">AI正在识别角色...</div>
<loading-outlined class="loading-spinner" spin />
<div class="loading-title">AI 正在识别角色</div>
<div class="loading-sub">通常需要 10-20 </div>
<div class="progress-bar"><div class="progress-fill" /></div>
</div>
<!-- 错误状态 -->
<template v-else-if="error">
<div class="error-state">
<div class="error-emoji">😔</div>
<frown-outlined class="error-icon" />
<div class="error-text">{{ error }}</div>
<button class="btn-ghost" style="max-width:200px;margin-top:20px" @click="$router.back()">返回重新上传</button>
<button class="btn-ghost back-btn" @click="$router.back()">返回重新上传</button>
</div>
</template>
<!-- 单角色大图展示 -->
<template v-else-if="characters.length === 1">
<div class="single-wrap">
<div class="single-card">
<div class="single-img-wrap" @click="characters[0].originalCropUrl && (previewImg = characters[0].originalCropUrl)">
<img v-if="characters[0].originalCropUrl" :src="characters[0].originalCropUrl" class="single-img" />
<user-outlined v-else class="single-placeholder" />
<div class="zoom-hint"><zoom-in-outlined /></div>
</div>
</div>
<div class="single-tip">
<check-circle-filled />
<span>AI 识别到 1 个角色将作为绘本主角</span>
</div>
</div>
</template>
<!-- 多角色网格选择 -->
<template v-else>
<div class="result-tip">
<span class="result-icon">🎉</span>
<span>发现 <strong>{{ characters.length }}</strong> 个角色点击选择绘本主角</span>
<check-circle-filled class="result-icon" />
<span>AI 识别到 <strong>{{ characters.length }}</strong> 个角色选一个作为主角</span>
</div>
<div class="char-list">
<div class="char-grid">
<div
v-for="c in characters"
:key="c.charId"
@ -38,50 +59,58 @@
:class="{ selected: selected === c.charId }"
@click="selected = c.charId"
>
<!-- 选中星星装饰 -->
<div v-if="selected === c.charId" class="selected-stars">
<span class="star s1"></span>
<span class="star s2"></span>
<span class="star s3"></span>
<!-- 推荐角标 -->
<div v-if="c.type === 'HERO'" class="hero-badge">
<crown-filled />
<span>推荐</span>
</div>
<div class="char-avatar" @click.stop="c.originalCropUrl && (previewImg = c.originalCropUrl)">
<!-- 选中角标 -->
<div v-if="selected === c.charId" class="check-badge">
<check-outlined />
</div>
<!-- 头像 -->
<div class="char-img-wrap" @click.stop="c.originalCropUrl && (previewImg = c.originalCropUrl)">
<img v-if="c.originalCropUrl" :src="c.originalCropUrl" class="char-img" />
<div v-else class="char-placeholder">🎭</div>
</div>
<div class="char-info">
<div class="char-name-row">
<span class="char-name">{{ c.name }}</span>
<span v-if="c.type === 'HERO'" class="hero-badge"> 推荐主角</span>
</div>
<div class="char-hint">点击头像可放大查看</div>
</div>
<div class="check-badge" :class="{ checked: selected === c.charId }">
<span v-if="selected === c.charId"></span>
<user-outlined v-else class="char-placeholder" />
<div class="zoom-hint"><zoom-in-outlined /></div>
</div>
</div>
</div>
<!-- 图片预览 -->
<Transition name="fade">
<div v-if="previewImg" class="preview-overlay" @click="previewImg = ''">
<img :src="previewImg" class="preview-full-img" />
</div>
</Transition>
</template>
<!-- 图片预览 -->
<Transition name="fade">
<div v-if="previewImg" class="preview-overlay" @click="previewImg = ''">
<img :src="previewImg" class="preview-full-img" />
</div>
</Transition>
</div>
<div class="page-bottom">
<button class="btn-primary next-btn" :disabled="!selected" @click="goNext">
确定主角选画风
<button class="btn-primary next-btn" :disabled="!canNext" @click="goNext">
<span>{{ nextLabel }}</span>
<arrow-right-outlined />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import {
LoadingOutlined,
FrownOutlined,
CheckCircleFilled,
UserOutlined,
CrownFilled,
CheckOutlined,
ArrowRightOutlined,
ZoomInOutlined,
ExperimentOutlined,
} from '@ant-design/icons-vue'
const isDev = import.meta.env.DEV
import PageHeader from '@/components/aicreate/PageHeader.vue'
import { useAicreateStore } from '@/stores/aicreate'
import { extractCharacters } from '@/api/aicreate'
@ -94,12 +123,28 @@ const characters = ref<any[]>([])
const error = ref('')
const previewImg = ref('')
const subtitle = computed(() => {
if (loading.value) return 'AI 正在识别画中的角色'
if (error.value) return ''
if (characters.value.length === 0) return ''
if (characters.value.length === 1) return 'AI 识别出的角色形象'
return 'AI 识别出多个角色,选一个作为主角'
})
const canNext = computed(() => {
if (characters.value.length === 1) return true
return !!selected.value
})
const nextLabel = computed(() => {
if (characters.value.length === 1) return '使用此角色,编排故事'
return '确定主角,编排故事'
})
onMounted(async () => {
if (store.characters && store.characters.length > 0) {
characters.value = store.characters
//
const hero = characters.value.find(c => c.type === 'HERO')
if (hero) selected.value = hero.charId
autoSelect()
loading.value = false
return
}
@ -117,12 +162,11 @@ onMounted(async () => {
type: c.charType || c.type || 'SIDEKICK'
}))
if (characters.value.length === 0) {
error.value = 'AI未识别到角色,请更换图片重试'
error.value = 'AI 未识别到角色,请更换图片重试'
}
store.extractId = data.extractId || ''
store.characters = characters.value
const hero = characters.value.find(c => c.type === 'HERO')
if (hero) selected.value = hero.charId
autoSelect()
} catch (e: any) {
error.value = '角色识别失败:' + (e.message || '请检查网络')
} finally {
@ -130,233 +174,358 @@ onMounted(async () => {
}
})
function autoSelect() {
if (characters.value.length === 1) {
selected.value = characters.value[0].charId
return
}
const hero = characters.value.find(c => c.type === 'HERO')
if (hero) selected.value = hero.charId
}
const regenMock = (count: number) => {
store.fillMockData(count)
characters.value = store.characters
selected.value = null
error.value = ''
loading.value = false
autoSelect()
}
const goNext = () => {
store.selectedCharacter = characters.value.find(c => c.charId === selected.value)
router.push('/p/create/style')
// selected
const target = characters.value.length === 1
? characters.value[0]
: characters.value.find(c => c.charId === selected.value)
store.selectedCharacter = target
router.push('/p/create/story')
}
</script>
<style lang="scss" scoped>
.char-page {
min-height: 100vh;
background: linear-gradient(180deg, #F0F4FF 0%, #F5F0FF 40%, #FFF5F8 70%, #FFFDF7 100%);
background: var(--ai-bg);
display: flex;
flex-direction: column;
}
.content {
flex: 1;
padding: 16px 20px;
display: flex;
flex-direction: column;
}
.content { flex: 1; padding: 16px 20px; display: flex; flex-direction: column; }
// Loading
/* ---------- 开发模式切换器 ---------- */
.dev-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 12px;
margin-bottom: 12px;
background: rgba(99, 102, 241, 0.04);
border: 1px dashed rgba(99, 102, 241, 0.3);
border-radius: 12px;
font-size: 11px;
color: var(--ai-text-sub);
:deep(.anticon) {
font-size: 12px;
color: var(--ai-primary);
}
}
.dev-label {
font-weight: 600;
}
.dev-btn {
padding: 4px 12px;
border-radius: 10px;
background: #fff;
color: var(--ai-text-sub);
font-size: 11px;
font-weight: 600;
border: 1px solid rgba(99, 102, 241, 0.2);
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--ai-primary);
color: var(--ai-primary);
}
&.active {
background: var(--ai-gradient);
border-color: transparent;
color: #fff;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
}
}
/* ---------- 加载状态 ---------- */
.loading-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 0;
}
.loading-emojis {
display: flex;
gap: 16px;
margin-bottom: 16px;
.loading-spinner {
font-size: 44px;
color: var(--ai-primary);
margin-bottom: 18px;
}
.loading-emoji {
font-size: 40px;
display: inline-block;
animation: emojiPop 1.8s ease-in-out infinite;
&.e1 { animation-delay: 0s; }
&.e2 { animation-delay: 0.4s; }
&.e3 { animation-delay: 0.8s; }
.loading-title {
font-size: 16px;
font-weight: 700;
color: var(--ai-text);
}
@keyframes emojiPop {
0%, 100% { transform: scale(1) rotate(0deg); opacity: 0.5; }
50% { transform: scale(1.3) rotate(10deg); opacity: 1; }
.loading-sub {
font-size: 13px;
color: var(--ai-text-sub);
margin-top: 6px;
}
.loading-title { font-size: 18px; font-weight: 700; margin-top: 4px; color: var(--ai-text); }
.loading-sub { font-size: 14px; color: var(--ai-text-sub); margin-top: 8px; }
.progress-bar { width: 220px; height: 6px; background: rgba(108,99,255,0.15); border-radius: 3px; margin-top: 20px; overflow: hidden; }
.progress-fill { width: 100%; height: 100%; background: linear-gradient(90deg, #6C63FF, #9B93FF); border-radius: 3px; animation: loading 2s ease-in-out infinite; }
@keyframes loading { 0%{transform:translateX(-100%)} 100%{transform:translateX(200%)} }
// Error
/* ---------- 错误状态 ---------- */
.error-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 60px 0;
}
.error-icon {
font-size: 48px;
color: var(--ai-text-sub);
}
.error-text {
font-size: 15px;
font-weight: 600;
color: var(--ai-text);
text-align: center;
}
.back-btn {
max-width: 200px;
margin-top: 8px;
}
.error-emoji { font-size: 56px; }
.error-text { font-size: 16px; font-weight: 600; margin-top: 16px; color: var(--ai-text); text-align: center; }
// Result tip
/* ---------- 单角色大图 ---------- */
.single-wrap {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
padding: 12px 0 24px;
}
.single-card {
width: 100%;
max-width: 360px;
background: #fff;
border: 2px solid var(--ai-primary);
border-radius: 26px;
padding: 14px;
box-shadow: 0 14px 36px rgba(99, 102, 241, 0.22);
}
.single-img-wrap {
position: relative;
width: 100%;
aspect-ratio: 1;
border-radius: 20px;
overflow: hidden;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.1), rgba(236, 72, 153, 0.08));
display: flex;
align-items: center;
justify-content: center;
cursor: zoom-in;
&:hover .zoom-hint { opacity: 1; }
&:active { transform: scale(0.98); }
}
.single-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.single-placeholder {
font-size: 72px;
color: var(--ai-text-sub);
}
.single-tip {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--ai-text);
font-weight: 600;
:deep(.anticon) {
color: var(--ai-primary);
font-size: 18px;
}
}
/* ---------- 多角色结果提示 ---------- */
.result-tip {
background: linear-gradient(135deg, #E8F5E9, #F1F8E9);
border: 1.5px solid #C8E6C9;
border-radius: 18px;
padding: 14px 18px;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(236, 72, 153, 0.05));
border: 1px solid rgba(99, 102, 241, 0.15);
border-radius: var(--ai-radius-sm);
padding: 12px 16px;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
color: #2E7D32;
font-weight: 600;
}
.result-icon { font-size: 22px; }
font-size: 14px;
color: var(--ai-text);
font-weight: 500;
// Character list
.char-list {
display: flex;
flex-direction: column;
gap: 14px;
strong {
color: var(--ai-primary);
font-weight: 800;
margin: 0 2px;
}
}
.result-icon {
font-size: 18px;
color: var(--ai-primary);
flex-shrink: 0;
}
/* ---------- 多角色网格 ---------- */
.char-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.char-card {
background: rgba(255,255,255,0.95);
border-radius: 20px;
padding: 16px 18px;
display: flex;
align-items: center;
gap: 16px;
border: 3px solid transparent;
transition: all 0.3s ease;
cursor: pointer;
box-shadow: 0 4px 16px rgba(0,0,0,0.05);
position: relative;
overflow: visible;
background: #fff;
border-radius: var(--ai-radius);
padding: 10px;
border: 2px solid transparent;
transition: all 0.25s ease;
cursor: pointer;
box-shadow: 0 2px 12px rgba(99, 102, 241, 0.05);
&:active { transform: scale(0.98); }
&:hover {
border-color: rgba(99, 102, 241, 0.18);
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.1);
}
&.selected {
border-color: var(--ai-primary);
background: linear-gradient(135deg, #FFF5F0 0%, #FFFAF7 50%, #FFF0F5 100%);
box-shadow: 0 6px 24px rgba(255, 107, 53, 0.2);
transform: scale(1.02);
background: linear-gradient(135deg, rgba(99, 102, 241, 0.04), rgba(236, 72, 153, 0.03));
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.18);
}
&:active {
transform: scale(0.99);
}
}
//
.selected-stars {
position: absolute;
top: -8px;
right: -4px;
display: flex;
gap: 2px;
pointer-events: none;
}
.star {
font-size: 14px;
animation: starPop 1.5s ease-in-out infinite;
&.s1 { animation-delay: 0s; }
&.s2 { animation-delay: 0.3s; font-size: 12px; }
&.s3 { animation-delay: 0.6s; }
}
@keyframes starPop {
0%, 100% { transform: scale(1); opacity: 0.6; }
50% { transform: scale(1.3); opacity: 1; }
}
.char-avatar {
width: 88px;
height: 88px;
border-radius: 20px;
.char-img-wrap {
position: relative;
width: 100%;
aspect-ratio: 1;
border-radius: 14px;
overflow: hidden;
background: linear-gradient(135deg, #FFF5F0, #FFE8D6);
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(236, 72, 153, 0.06));
display: flex;
align-items: center;
justify-content: center;
cursor: zoom-in;
transition: transform 0.2s;
&:active { transform: scale(0.95); }
&:hover .zoom-hint { opacity: 1; }
}
.char-img {
width: 100%;
height: 100%;
object-fit: contain;
object-fit: cover;
}
.char-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 36px;
}
.char-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.char-name-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.char-name {
font-size: 18px;
font-weight: 800;
color: var(--ai-text);
}
.char-hint {
font-size: 12px;
color: var(--ai-text-sub);
}
// Check badge (right side)
.hero-badge {
position: absolute;
top: 6px;
left: 6px;
z-index: 2;
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10px;
background: linear-gradient(135deg, #6366f1, #ec4899);
color: #fff;
padding: 3px 7px;
border-radius: 8px;
font-weight: 700;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4);
:deep(.anticon) { font-size: 9px; }
}
.check-badge {
width: 32px;
height: 32px;
position: absolute;
top: 6px;
right: 6px;
z-index: 2;
width: 26px;
height: 26px;
border-radius: 50%;
border: 2.5px solid #E2E8F0;
background: var(--ai-gradient);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: #fff;
flex-shrink: 0;
transition: all 0.3s;
font-size: 13px;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
&.checked {
background: linear-gradient(135deg, #FF8C42, #FF6B35);
border-color: var(--ai-primary);
box-shadow: 0 3px 10px rgba(255, 107, 53, 0.4);
transform: scale(1.1);
.zoom-hint {
position: absolute;
right: 6px;
bottom: 6px;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(15, 12, 41, 0.55);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
opacity: 0.7;
transition: opacity 0.2s;
}
/* ---------- 底部按钮 ---------- */
.next-btn {
display: flex !important;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 16px !important;
padding: 14px 0 !important;
border-radius: 28px !important;
:deep(.anticon) {
font-size: 16px;
}
}
.hero-badge {
font-size: 11px;
background: linear-gradient(135deg, #FFD166, #FFBE4A);
color: #fff;
padding: 3px 10px;
border-radius: 12px;
font-weight: 700;
box-shadow: 0 2px 6px rgba(255,209,102,0.3);
}
.bottom-area { margin-top: auto; padding-top: 20px; }
.next-btn {
font-size: 17px !important;
padding: 16px 0 !important;
border-radius: 28px !important;
background: linear-gradient(135deg, #FF8C42, #FF6B35) !important;
box-shadow: 0 6px 24px rgba(255,107,53,0.3) !important;
}
//
/* ---------- 图片预览 ---------- */
.preview-overlay {
position: fixed;
inset: 0;
z-index: 999;
background: rgba(0,0,0,0.85);
background: rgba(15, 12, 41, 0.88);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
@ -367,8 +536,14 @@ const goNext = () => {
max-height: 80vh;
object-fit: contain;
border-radius: 16px;
box-shadow: 0 8px 40px rgba(0,0,0,0.3);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

View File

@ -1,31 +1,42 @@
<template>
<div class="creating-page">
<!-- 飘浮装饰元素 -->
<div class="floating-deco d1"></div>
<div class="floating-deco d2">🌟</div>
<div class="floating-deco d3">🎨</div>
<div class="floating-deco d4">📖</div>
<div class="floating-deco d5"></div>
<!-- 开发模式状态切换 -->
<div v-if="isDev" class="dev-bar">
<experiment-outlined />
<span class="dev-label">Mock</span>
<button class="dev-btn" @click="enterMockProgress">进度</button>
<button class="dev-btn" @click="enterMockError">错误</button>
<button class="dev-btn" @click="goMockPreview">跳到预览</button>
</div>
<!-- 进度环 -->
<div class="ring-wrap">
<svg width="180" height="180" class="ring-svg">
<circle cx="90" cy="90" r="80" fill="none" stroke="rgba(255,255,255,0.5)" stroke-width="8" />
<circle cx="90" cy="90" r="80" fill="none" stroke="var(--ai-primary)" stroke-width="8"
:stroke-dasharray="502" :stroke-dashoffset="502 - (502 * progress / 100)"
stroke-linecap="round" class="ring-fill" />
<defs>
<linearGradient id="ringGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#6366f1" />
<stop offset="50%" stop-color="#8b5cf6" />
<stop offset="100%" stop-color="#ec4899" />
</linearGradient>
</defs>
<circle cx="90" cy="90" r="80" fill="none" stroke="rgba(99, 102, 241, 0.12)" stroke-width="8" />
<circle
cx="90"
cy="90"
r="80"
fill="none"
stroke="url(#ringGrad)"
stroke-width="8"
:stroke-dasharray="502"
:stroke-dashoffset="502 - (502 * progress / 100)"
stroke-linecap="round"
class="ring-fill"
/>
</svg>
<div class="ring-center">
<div class="ring-pct">{{ progress }}%</div>
<div class="ring-label">创作进度</div>
</div>
<!-- 星星点缀 -->
<div class="ring-stars">
<span class="ring-star s1"></span>
<span class="ring-star s2"></span>
<span class="ring-star s3"></span>
<span class="ring-star s4"></span>
</div>
</div>
<!-- 状态文字 -->
@ -38,17 +49,43 @@
</Transition>
</div>
<!-- 网络波动提示非致命轮询仍在继续 -->
<!-- 网络波动提示 -->
<div v-if="networkWarn && !error" class="network-warn">
网络不太稳定正在尝试重新连接{{ dots }}
<wifi-outlined />
<span>网络不太稳定正在尝试重新连接{{ dots }}</span>
</div>
<!-- 错误重试 -->
<div v-if="error" class="error-box">
<div class="error-emoji">😔</div>
<frown-outlined class="error-icon" />
<div class="error-text">{{ error }}</div>
<button v-if="store.workId" class="btn-primary error-retry-btn" @click="resumePolling">恢复查询进度</button>
<button class="btn-primary error-retry-btn" :class="{ 'btn-outline': store.workId }" @click="retry">重新创作</button>
<div class="error-actions">
<button v-if="store.workId" class="btn-primary error-btn" @click="resumePolling">
恢复查询进度
</button>
<button class="btn-primary error-btn" :class="{ 'btn-outline': !!store.workId }" @click="retry">
重新创作
</button>
</div>
</div>
<!-- 任务式说明 + 离开入口 -->
<div v-if="!error" class="task-hint">
<div class="task-hint-row">
<cloud-server-outlined class="task-icon" />
<span>AI 正在后台为你创作绘本可以随时离开</span>
</div>
<button class="leave-btn" @click="leaveToWorks">
<inbox-outlined />
<span>去逛逛看看其他作品</span>
</button>
<div class="task-hint-sub">完成后会自动出现在作品库 · 草稿</div>
</div>
<div v-else class="task-hint">
<button class="leave-btn" @click="leaveToWorks">
<inbox-outlined />
<span>去逛逛看看其他作品</span>
</button>
</div>
</div>
</template>
@ -57,6 +94,13 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Client } from '@stomp/stompjs'
import {
ExperimentOutlined,
FrownOutlined,
WifiOutlined,
CloudServerOutlined,
InboxOutlined,
} from '@ant-design/icons-vue'
import { useAicreateStore } from '@/stores/aicreate'
import { createStory, getWorkDetail } from '@/api/aicreate'
import { STATUS, getRouteByStatus } from '@/utils/aicreate/status'
@ -65,19 +109,21 @@ import config from '@/utils/aicreate/config'
const router = useRouter()
const store = useAicreateStore()
const progress = ref(0)
const stage = ref('准备中...')
const stage = ref('准备中')
const dots = ref('')
const error = ref('')
const networkWarn = ref(false)
const currentTipIdx = ref(0)
const creatingTips = [
'AI 画师正在构思精彩故事...',
'魔法画笔正在绘制插画...',
'故事世界正在成形...',
'角色们正在准备登场...',
'色彩魔法正在施展中...',
'AI 正在为你构思故事',
'画笔正在绘制插画',
'故事世界正在成形',
'角色们正在准备登场',
'色彩正在调和',
]
const isDev = import.meta.env.DEV
let pollTimer: ReturnType<typeof setInterval> | null = null
let dotTimer: ReturnType<typeof setInterval> | null = null
let tipTimer: ReturnType<typeof setInterval> | null = null
@ -93,35 +139,33 @@ function sanitizeError(msg: string | undefined): string {
if (!msg) return '创作遇到问题,请重新尝试'
if (msg.includes('400') || msg.includes('status code')) return '创作请求异常,请返回重新操作'
if (msg.includes('火山引擎') || msg.includes('VolcEngine')) return 'AI 服务暂时繁忙,请稍后重试'
if (msg.includes('额度')) return msg //
if (msg.includes('重复') || msg.includes('DUPLICATE')) return '有正在创作的作品,请等待完成'
if (msg.includes('额度')) return msg
if (msg.includes('重复') || msg.includes('DUPLICATE')) return '有正在创作的作品,请等待完成'
if (msg.includes('timeout') || msg.includes('超时')) return '创作超时,请重新尝试'
if (msg.includes('OSS') || msg.includes('MiniMax')) return '服务暂时不可用,请稍后重试'
if (msg.length > 50) return '创作遇到问题,请重新尝试'
return msg
}
// //
// emoji
function friendlyStage(pct: number, msg: string): string {
if (!msg) return '创作中...'
//
if (msg.includes('创作完成')) return '🎉 绘本创作完成!'
if (msg.includes('绘图完成') || msg.includes('绘制完成')) return '🎨 插画绘制完成'
if (msg.includes('第') && msg.includes('组')) return '🎨 正在绘制插画...'
if (msg.includes('绘图') || msg.includes('绘制') || msg.includes('插画')) return '🎨 正在绘制插画...'
if (msg.includes('补生成')) return '🎨 正在绘制插画...'
if (msg.includes('语音合成') || msg.includes('配音')) return '🔊 正在合成语音...'
if (msg.includes('故事') && msg.includes('完成')) return '📝 故事编写完成,开始绘图...'
if (msg.includes('故事') || msg.includes('创作故事')) return '📝 正在编写故事...'
if (msg.includes('适配') || msg.includes('角色')) return '🎨 正在准备绘图...'
if (msg.includes('重试')) return '✨ 遇到小问题,正在重新创作...'
if (msg.includes('失败')) return '⏳ 处理中,请稍候...'
//
if (pct < 20) return '✨ 正在提交创作...'
if (pct < 50) return '📝 正在编写故事...'
if (pct < 80) return '🎨 正在绘制插画...'
if (pct < 100) return '🔊 即将完成...'
return '🎉 绘本创作完成!'
if (!msg) return '创作中…'
if (msg.includes('创作完成')) return '绘本创作完成'
if (msg.includes('绘图完成') || msg.includes('绘制完成')) return '插画绘制完成'
if (msg.includes('第') && msg.includes('组')) return '正在绘制插画…'
if (msg.includes('绘图') || msg.includes('绘制') || msg.includes('插画')) return '正在绘制插画…'
if (msg.includes('补生成')) return '正在绘制插画…'
if (msg.includes('语音合成') || msg.includes('配音')) return '正在合成语音…'
if (msg.includes('故事') && msg.includes('完成')) return '故事编写完成,开始绘图…'
if (msg.includes('故事') || msg.includes('创作故事')) return '正在编写故事…'
if (msg.includes('适配') || msg.includes('角色')) return '正在准备绘图…'
if (msg.includes('重试')) return '遇到小问题,正在重新创作…'
if (msg.includes('失败')) return '处理中,请稍候…'
if (pct < 20) return '正在提交创作…'
if (pct < 50) return '正在编写故事…'
if (pct < 80) return '正在绘制插画…'
if (pct < 100) return '即将完成…'
return '绘本创作完成'
}
// workId localStorage
@ -150,7 +194,7 @@ const startWebSocket = (workId: string) => {
stompClient = new Client({
brokerURL: wsUrl,
reconnectDelay: 0, //
reconnectDelay: 0,
onConnect: () => {
stompClient.subscribe(`/topic/progress/${workId}`, (msg: any) => {
try {
@ -160,7 +204,7 @@ const startWebSocket = (workId: string) => {
if (data.progress >= 100) {
progress.value = 100
stage.value = '🎉 绘本创作完成'
stage.value = '绘本创作完成'
closeWebSocket()
saveWorkId('')
const route = getRouteByStatus(STATUS.COMPLETED, workId)
@ -204,7 +248,7 @@ const closeWebSocket = () => {
}
}
// B2 ( / WebSocket 使)
// B2
const startPolling = (workId: string) => {
if (pollTimer) clearInterval(pollTimer)
consecutiveErrors = 0
@ -216,7 +260,6 @@ const startPolling = (workId: string) => {
const work = detail.data
if (!work) return
//
if (consecutiveErrors > 0 || networkWarn.value) {
consecutiveErrors = 0
networkWarn.value = false
@ -225,10 +268,9 @@ const startPolling = (workId: string) => {
if (work.progress != null && work.progress > progress.value) progress.value = work.progress
if (work.progressMessage) stage.value = friendlyStage(progress.value, work.progressMessage)
// >= COMPLETED(3)
if (work.status >= STATUS.COMPLETED) {
progress.value = 100
stage.value = '🎉 绘本创作完成'
stage.value = '绘本创作完成'
clearInterval(pollTimer!)
pollTimer = null
saveWorkId('')
@ -243,27 +285,24 @@ const startPolling = (workId: string) => {
} catch {
consecutiveErrors++
if (consecutiveErrors > MAX_POLL_ERRORS) {
//
clearInterval(pollTimer!)
pollTimer = null
networkWarn.value = false
error.value = '网络连接异常,创作仍在后台进行中'
} else if (consecutiveErrors > MAX_SILENT_ERRORS) {
//
networkWarn.value = true
}
//
}
}, 8000)
}
const startCreation = async () => {
if (submitted) return //
if (submitted) return
submitted = true
error.value = ''
progress.value = 5
stage.value = '📝 正在提交创作请求...'
stage.value = '正在提交创作请求…'
try {
const res = await createStory({
@ -285,15 +324,13 @@ const startCreation = async () => {
saveWorkId(workId)
progress.value = 10
stage.value = '📝 故事构思中...'
// WebSocket
stage.value = '故事构思中…'
startWebSocket(workId)
} catch (e: any) {
//
if (store.workId) {
progress.value = 10
stage.value = '📝 创作已提交到后台...'
stage.value = '创作已提交到后台…'
startPolling(store.workId)
} else {
error.value = sanitizeError(e.message)
@ -306,16 +343,52 @@ const resumePolling = () => {
error.value = ''
networkWarn.value = false
progress.value = 10
stage.value = '📝 正在查询创作进度...'
stage.value = '正在查询创作进度…'
startPolling(store.workId)
}
const retry = () => {
if (isDev && !store.imageUrl) {
enterMockProgress()
return
}
saveWorkId('')
submitted = false
startCreation()
}
const leaveToWorks = () => {
// store.workId localStorage CreatingView
closeWebSocket()
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
router.push('/p/works?tab=draft')
}
//
const enterMockProgress = () => {
closeWebSocket()
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
submitted = true
error.value = ''
networkWarn.value = false
progress.value = 35
stage.value = '正在编写故事…'
}
const enterMockError = () => {
closeWebSocket()
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
submitted = false
error.value = '创作请求异常,请返回重新操作'
}
const goMockPreview = () => {
closeWebSocket()
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
store.fillMockWorkDetail()
router.push(`/p/create/preview/${store.workId}`)
}
onMounted(() => {
dotTimer = setInterval(() => {
dots.value = dots.value.length >= 3 ? '' : dots.value + '.'
@ -325,7 +398,7 @@ onMounted(() => {
currentTipIdx.value = (currentTipIdx.value + 1) % creatingTips.length
}, 3500)
// workIdURLlocalStorage
// workId
const urlWorkId = new URLSearchParams(window.location.search).get('workId')
if (urlWorkId) {
saveWorkId(urlWorkId)
@ -333,11 +406,16 @@ onMounted(() => {
restoreWorkId()
}
//
//
if (isDev && !store.workId && (!store.imageUrl || !store.storyData)) {
enterMockProgress()
return
}
if (store.workId) {
submitted = true
progress.value = 10
stage.value = '📝 正在查询创作进度...'
stage.value = '正在查询创作进度…'
startPolling(store.workId)
} else {
startCreation()
@ -355,41 +433,61 @@ onUnmounted(() => {
<style lang="scss" scoped>
.creating-page {
min-height: 100vh;
background: linear-gradient(160deg, #FFF8E1 0%, #FFF0F0 40%, #F0F8FF 100%);
background: var(--ai-bg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
padding: 24px 20px 32px;
position: relative;
overflow: hidden;
}
/* 飘浮装饰元素 */
.floating-deco {
/* ---------- 开发模式切换器 ---------- */
.dev-bar {
position: absolute;
font-size: 28px;
opacity: 0.35;
pointer-events: none;
animation: floatDeco 6s ease-in-out infinite;
top: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(99, 102, 241, 0.04);
border: 1px dashed rgba(99, 102, 241, 0.3);
border-radius: 12px;
font-size: 11px;
color: var(--ai-text-sub);
z-index: 5;
:deep(.anticon) {
font-size: 12px;
color: var(--ai-primary);
}
}
.d1 { top: 8%; left: 10%; animation-delay: 0s; font-size: 36px; }
.d2 { top: 15%; right: 12%; animation-delay: 1.2s; font-size: 24px; }
.d3 { bottom: 20%; left: 8%; animation-delay: 2.4s; }
.d4 { bottom: 12%; right: 15%; animation-delay: 0.8s; font-size: 32px; }
.d5 { top: 40%; right: 6%; animation-delay: 3.6s; font-size: 20px; }
@keyframes floatDeco {
0%, 100% { transform: translateY(0) rotate(0deg); }
25% { transform: translateY(-12px) rotate(5deg); }
50% { transform: translateY(-6px) rotate(-3deg); }
75% { transform: translateY(-16px) rotate(3deg); }
.dev-label { font-weight: 600; }
.dev-btn {
padding: 4px 10px;
border-radius: 10px;
background: #fff;
color: var(--ai-text-sub);
font-size: 11px;
font-weight: 600;
border: 1px solid rgba(99, 102, 241, 0.2);
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--ai-primary);
color: var(--ai-primary);
}
}
/* ---------- 进度环 ---------- */
.ring-wrap {
position: relative;
width: 180px;
height: 180px;
margin-bottom: 32px;
margin-bottom: 28px;
}
.ring-svg { transform: rotate(-90deg); }
.ring-fill { transition: stroke-dashoffset 0.8s ease; }
@ -401,42 +499,41 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
}
.ring-pct { font-size: 36px; font-weight: 900; color: var(--ai-primary); }
.ring-label { font-size: 12px; color: var(--ai-text-sub); }
/* 星星点缀在进度环外圈 */
.ring-stars {
position: absolute;
inset: -12px;
pointer-events: none;
.ring-pct {
font-size: 38px;
font-weight: 900;
background: var(--ai-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -1px;
}
.ring-star {
position: absolute;
font-size: 14px;
animation: starTwinkle 2s ease-in-out infinite;
}
.ring-star.s1 { top: -4px; left: 50%; transform: translateX(-50%); animation-delay: 0s; }
.ring-star.s2 { bottom: -4px; left: 50%; transform: translateX(-50%); animation-delay: 0.5s; }
.ring-star.s3 { left: -4px; top: 50%; transform: translateY(-50%); animation-delay: 1s; }
.ring-star.s4 { right: -4px; top: 50%; transform: translateY(-50%); animation-delay: 1.5s; }
@keyframes starTwinkle {
0%, 100% { opacity: 0.3; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.2); }
.ring-label {
font-size: 12px;
color: var(--ai-text-sub);
margin-top: 2px;
letter-spacing: 1px;
}
.stage-text { font-size: 18px; font-weight: 700; text-align: center; }
/* ---------- 阶段文字 ---------- */
.stage-text {
font-size: 17px;
font-weight: 700;
text-align: center;
color: var(--ai-text);
}
/* 轮转 tips */
/* ---------- 轮转 tips ---------- */
.rotating-tips {
margin-top: 14px;
height: 28px;
margin-top: 12px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.rotating-tip {
font-size: 14px;
color: #B0876E;
font-size: 13px;
color: var(--ai-text-sub);
font-weight: 500;
text-align: center;
letter-spacing: 0.3px;
@ -445,22 +542,26 @@ onUnmounted(() => {
.tip-fade-leave-active {
transition: opacity 0.5s ease, transform 0.5s ease;
}
.tip-fade-enter-from {
opacity: 0;
transform: translateY(8px);
}
.tip-fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
.tip-fade-enter-from { opacity: 0; transform: translateY(8px); }
.tip-fade-leave-to { opacity: 0; transform: translateY(-8px); }
/* ---------- 网络警告 ---------- */
.network-warn {
margin-top: 12px;
font-size: 13px;
color: #F59E0B;
text-align: center;
margin-top: 14px;
padding: 6px 14px;
border-radius: 12px;
background: rgba(245, 158, 11, 0.08);
color: #d97706;
font-size: 12px;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
:deep(.anticon) { font-size: 13px; }
}
/* ---------- 错误状态 ---------- */
.error-box {
margin-top: 24px;
display: flex;
@ -468,13 +569,90 @@ onUnmounted(() => {
align-items: center;
text-align: center;
}
.error-emoji { font-size: 40px; margin-bottom: 12px; }
.error-text { color: #EF4444; font-size: 14px; font-weight: 600; line-height: 1.6; max-width: 260px; }
.error-retry-btn { max-width: 200px; margin-top: 16px; }
.error-retry-btn.btn-outline {
background: transparent;
.error-icon {
font-size: 44px;
color: var(--ai-text-sub);
border: 1px solid var(--ai-border);
margin-bottom: 12px;
}
.error-text {
color: #ef4444;
font-size: 14px;
font-weight: 600;
line-height: 1.6;
max-width: 280px;
}
.error-actions {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 18px;
width: 100%;
max-width: 240px;
}
.error-btn {
font-size: 14px !important;
padding: 12px 0 !important;
border-radius: 24px !important;
}
.error-btn.btn-outline {
background: transparent !important;
color: var(--ai-primary) !important;
border: 1.5px solid rgba(99, 102, 241, 0.3) !important;
box-shadow: none !important;
}
/* ---------- 任务式说明 + 离开按钮 ---------- */
.task-hint {
margin-top: 36px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
max-width: 320px;
}
.task-hint-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--ai-text);
font-weight: 500;
text-align: center;
}
.task-icon {
font-size: 15px;
color: var(--ai-primary);
flex-shrink: 0;
}
.task-hint-sub {
font-size: 11px;
color: var(--ai-text-sub);
text-align: center;
}
.leave-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 11px 22px;
border-radius: 22px;
background: #fff;
color: var(--ai-primary);
font-size: 14px;
font-weight: 600;
border: 1.5px solid rgba(99, 102, 241, 0.3);
box-shadow: 0 2px 10px rgba(99, 102, 241, 0.06);
cursor: pointer;
transition: all 0.2s;
:deep(.anticon) { font-size: 15px; }
&:hover {
border-color: var(--ai-primary);
background: rgba(99, 102, 241, 0.04);
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.12);
}
&:active { transform: scale(0.98); }
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,15 @@
<template>
<div class="edit-page page-fullscreen">
<PageHeader title="编辑绘本信息" subtitle="补充信息让绘本更完整" :showBack="true" />
<PageHeader title="编辑绘本信息" subtitle="完善信息后选择保存、发布或继续配音" :showBack="true" />
<div v-if="loading" class="loading-state">
<div style="font-size:36px">📖</div>
<div style="color:var(--ai-text-sub);margin-top:8px">加载中...</div>
<loading-outlined class="loading-icon" spin />
<div class="loading-text">加载中</div>
</div>
<div v-else class="content page-content">
<!-- 封面预览 -->
<div class="cover-preview card" v-if="coverUrl">
<div class="cover-preview" v-if="coverUrl">
<img :src="coverUrl" class="cover-img" />
<div class="cover-title-overlay">{{ store.workDetail?.title || '未命名' }}</div>
</div>
@ -17,39 +17,77 @@
<!-- 基本信息 -->
<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 class="field-label">
<edit-outlined />
<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 = ''"
/>
<div class="field-row-meta">
<span v-if="authorError" class="field-error">{{ authorError }}</span>
<span v-else class="field-error placeholder-error">&nbsp;</span>
<span class="char-count">{{ form.author.length }}/16</span>
</div>
</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>
<file-text-outlined />
<span>副标题</span>
<span class="optional-mark">选填</span>
</div>
<textarea v-model="form.intro" class="textarea-input" placeholder="简单介绍一下这个绘本的故事" maxlength="250" rows="3" />
<input
v-model="form.subtitle"
class="text-input"
placeholder="如:一个关于勇气的故事"
maxlength="20"
/>
<div class="field-row-meta">
<span class="char-count">{{ form.subtitle.length }}/20</span>
</div>
</div>
<div class="field-item">
<div class="field-label">
<book-outlined />
<span>绘本简介</span>
<span class="optional-mark">选填</span>
<span class="char-count char-count-right">{{ 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="field-label" style="margin-bottom: 12px">
<tags-outlined />
<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 v-for="(tag, i) in selectedTags" :key="'s' + i" class="tag selected-tag">
<span>{{ tag }}</span>
<close-outlined v-if="selectedTags.length > 1" class="tag-remove" @click="removeTag(i)" />
</span>
<!-- 添加标签达到5个上限时隐藏 -->
<template v-if="selectedTags.length < 5">
<span v-if="!addingTag" class="tag add-tag" @click="addingTag = true">+</span>
<span v-if="!addingTag" class="tag add-tag" @click="addingTag = true">
<plus-outlined />
</span>
<span v-else class="tag adding-tag">
<input
ref="tagInput"
@ -64,29 +102,59 @@
</template>
</div>
<!-- 推荐标签达到5个上限时隐藏只显示未选中的 -->
<!-- 推荐标签 -->
<div v-if="selectedTags.length < 5 && limitedPresets.length > 0" class="preset-tags">
<span
v-for="p in limitedPresets" :key="p"
v-for="p in limitedPresets"
:key="p"
class="tag preset-tag"
@click="addPresetTag(p)"
>+ {{ p }}</span>
>
<plus-outlined />
<span>{{ p }}</span>
</span>
</div>
</div>
</div>
<!-- 底部 -->
<!-- 底部三按钮 -->
<div v-if="!loading" class="page-bottom">
<button class="btn-primary" :disabled="saving" @click="handleSave">
{{ saving ? '保存中...' : '保存绘本 →' }}
</button>
<div class="action-row">
<button class="action-btn draft-btn" :disabled="processing" @click="handleSaveDraft">
<inbox-outlined />
<span>保存草稿</span>
</button>
<button class="action-btn dubbing-btn" :disabled="processing" @click="handleGoDubbing">
<audio-outlined />
<span>去配音</span>
</button>
<button class="action-btn publish-btn" :disabled="processing" @click="handlePublish">
<send-outlined />
<span>发布作品</span>
</button>
</div>
<div class="action-hint">
草稿可在作品库继续编辑 · 配音是可选步骤 · 发布后进入审核
</div>
</div>
</div>
</template>
<script setup lang="ts">
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import {
LoadingOutlined,
EditOutlined,
FileTextOutlined,
BookOutlined,
TagsOutlined,
PlusOutlined,
CloseOutlined,
InboxOutlined,
AudioOutlined,
SendOutlined,
} from '@ant-design/icons-vue'
import PageHeader from '@/components/aicreate/PageHeader.vue'
import { getWorkDetail, updateWork } from '@/api/aicreate'
import { useAicreateStore } from '@/stores/aicreate'
@ -97,8 +165,10 @@ const route = useRoute()
const store = useAicreateStore()
const workId = computed(() => route.params.workId || store.workId)
const isDev = import.meta.env.DEV
const loading = ref(true)
const saving = ref(false)
const processing = ref(false)
const coverUrl = ref('')
const form = ref({ author: '', subtitle: '', intro: '' })
@ -111,7 +181,6 @@ 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 []
@ -140,7 +209,22 @@ function confirmAddTag() {
async function loadWork() {
loading.value = true
try {
// workId
const wid = String(workId.value || '')
// dev mock workId store.workDetail
if (isDev && wid.startsWith('mock-')) {
if (!store.workDetail) store.fillMockWorkDetail()
const w = store.workDetail
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 || ''
loading.value = false
return
}
// workId
if (!store.workDetail || store.workDetail.workId !== workId.value) {
store.workDetail = null
const res = await getWorkDetail(workId.value)
@ -148,7 +232,6 @@ async function loadWork() {
}
const w = store.workDetail
// CATALOGED
if (w.status > STATUS.CATALOGED) {
const nextRoute = getRouteByStatus(w.status, w.workId)
if (nextRoute) { router.replace(nextRoute); return }
@ -168,14 +251,34 @@ async function loadWork() {
const authorError = ref('')
async function handleSave() {
//
function validate() {
if (!form.value.author.trim()) {
authorError.value = '请填写作者署名'
return
return false
}
authorError.value = ''
saving.value = true
return true
}
/**
* 保存表单数据到后端返回是否成功
* 不做跳转由各 handler 决定下一步去哪
*/
async function saveFormToServer() {
const wid = String(workId.value || '')
// dev mock workId store
if (isDev && wid.startsWith('mock-')) {
if (store.workDetail) {
store.workDetail.author = form.value.author.trim()
store.workDetail.subtitle = form.value.subtitle.trim()
store.workDetail.intro = form.value.intro.trim()
store.workDetail.tags = [...selectedTags.value]
}
await new Promise(r => setTimeout(r, 200))
return true
}
try {
const data = { tags: selectedTags.value }
data.author = form.value.author.trim()
@ -184,30 +287,75 @@ async function handleSave() {
await updateWork(workId.value, data)
//
if (store.workDetail) {
if (data.author) store.workDetail.author = 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(`/p/create/dubbing/${workId.value}`)
return true
} catch (e) {
// CAS
// CAS
try {
const check = await getWorkDetail(workId.value)
if (check?.data?.status >= 4) {
store.workDetail = null
router.push(`/p/create/dubbing/${workId.value}`)
return
}
if (check?.data?.status >= 4) return true
} catch { /* ignore */ }
alert(e.message || '保存失败,请重试')
return false
}
}
/** 保存草稿 → 跳作品库草稿 tab */
async function handleSaveDraft() {
if (!validate()) return
processing.value = true
try {
if (await saveFormToServer()) {
store.workDetail = null
router.push('/p/works?tab=draft')
}
} finally {
saving.value = false
processing.value = false
}
}
/** 去配音 → 跳 DubbingView */
async function handleGoDubbing() {
if (!validate()) return
processing.value = true
try {
if (await saveFormToServer()) {
store.workDetail = null
router.push(`/p/create/dubbing/${workId.value}`)
}
} finally {
processing.value = false
}
}
/** 发布作品 → 进入超管端待审核,跳作品库审核中 tab */
async function handlePublish() {
if (!validate()) return
processing.value = true
try {
if (!(await saveFormToServer())) return
const wid = String(workId.value || '')
// dev mock workId
if (isDev && wid.startsWith('mock-')) {
await new Promise(r => setTimeout(r, 300))
store.workDetail = null
router.push('/p/works?tab=pending_review')
return
}
// TODO: DB idleai workId id
// publicUserWorksApi.publish
store.workDetail = null
router.push('/p/works?tab=pending_review')
} finally {
processing.value = false
}
}
@ -220,94 +368,175 @@ onMounted(() => {
<style lang="scss" scoped>
.edit-page {
background: var(--ai-bg);
display: flex;
flex-direction: column;
}
/* ---------- 加载状态 ---------- */
.loading-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 0;
}
.loading-icon {
font-size: 44px;
color: var(--ai-primary);
margin-bottom: 14px;
}
.loading-text {
color: var(--ai-text-sub);
font-size: 14px;
}
/* ---------- 内容区 ---------- */
.content {
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 16px;
gap: 14px;
}
/* ---------- 封面预览 ---------- */
.cover-preview {
position: relative;
border-radius: 16px;
border-radius: var(--ai-radius);
overflow: hidden;
height: 120px;
height: 130px;
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.12);
border: 1px solid rgba(99, 102, 241, 0.06);
}
.cover-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cover-title-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 10px 16px;
background: linear-gradient(transparent, rgba(0,0,0,0.6));
padding: 16px 18px 12px;
background: linear-gradient(transparent, rgba(15, 12, 41, 0.75));
color: #fff;
font-size: 16px;
font-weight: 700;
font-size: 17px;
font-weight: 800;
letter-spacing: 0.5px;
}
.form-card { padding: 20px; }
/* ---------- 表单卡片 ---------- */
.form-card {
background: #fff;
border: 1px solid rgba(99, 102, 241, 0.06);
border-radius: var(--ai-radius);
padding: 18px;
box-shadow: 0 2px 12px rgba(99, 102, 241, 0.05);
}
.field-item {
margin-bottom: 16px;
&:last-child { margin-bottom: 0; }
}
.field-item { margin-bottom: 16px; &:last-child { margin-bottom: 0; } }
.field-label {
font-size: 13px;
color: var(--ai-text-sub);
margin-bottom: 6px;
color: var(--ai-text);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.required-mark { color: var(--ai-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; }
font-weight: 600;
.text-input {
width: 100%;
border: none;
background: #F8F7F4;
border-radius: var(--ai-radius-sm);
padding: 14px 16px;
font-size: 16px;
outline: none;
color: var(--ai-text);
box-sizing: border-box;
transition: box-shadow 0.2s;
&:focus { box-shadow: 0 0 0 2px var(--ai-primary); }
&.input-error { box-shadow: 0 0 0 2px #EF4444; }
:deep(.anticon) {
font-size: 14px;
color: var(--ai-primary);
}
}
/* ---------- 必填 / 选填标签 ---------- */
.required-mark {
color: var(--ai-primary);
font-size: 11px;
background: rgba(99, 102, 241, 0.1);
padding: 2px 8px;
border-radius: 8px;
font-weight: 700;
letter-spacing: 0.5px;
}
.optional-mark {
color: var(--ai-text-sub);
font-size: 11px;
background: rgba(107, 114, 128, 0.08);
padding: 2px 8px;
border-radius: 8px;
font-weight: 600;
letter-spacing: 0.5px;
}
/* ---------- 输入框 ---------- */
.text-input,
.textarea-input {
width: 100%;
border: 1.5px solid var(--ai-border);
background: #FAFAF8;
border-radius: var(--ai-radius-sm);
padding: 12px 14px;
border: 1.5px solid rgba(99, 102, 241, 0.12);
background: rgba(99, 102, 241, 0.04);
border-radius: 12px;
padding: 13px 14px;
font-size: 15px;
outline: none;
color: var(--ai-text);
resize: none;
box-sizing: border-box;
transition: border 0.3s;
&:focus { border-color: var(--ai-primary); }
}
.error-text { color: #EF4444; font-size: 12px; margin-top: 4px; }
font-weight: 500;
transition: all 0.2s;
&::placeholder { color: #b8b6c4; }
&:focus {
border-color: var(--ai-primary);
background: #fff;
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1);
}
}
.textarea-input {
resize: none;
line-height: 1.6;
font-size: 14px;
}
.input-error {
border-color: #ef4444 !important;
&:focus {
border-color: #ef4444 !important;
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.1) !important;
}
}
/* ---------- 字段下方元信息行 ---------- */
.field-row-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 6px;
min-height: 16px;
}
.field-error {
color: #ef4444;
font-size: 12px;
font-weight: 500;
}
.placeholder-error { visibility: hidden; }
.char-count {
font-size: 11px;
color: var(--ai-text-sub);
margin-left: auto;
}
.char-count-right {
margin-left: auto;
}
/* ---------- 标签区 ---------- */
.tags-wrap {
display: flex;
flex-wrap: wrap;
@ -318,42 +547,52 @@ onMounted(() => {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 14px;
border-radius: 20px;
padding: 6px 12px;
border-radius: 18px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.selected-tag {
background: var(--ai-primary-light);
color: var(--ai-primary);
font-weight: 600;
}
.selected-tag {
background: rgba(99, 102, 241, 0.1);
color: var(--ai-primary);
border: 1px solid rgba(99, 102, 241, 0.2);
}
.tag-remove {
font-size: 15px;
font-weight: 700;
font-size: 11px;
opacity: 0.6;
margin-left: 2px;
transition: opacity 0.2s;
&:hover { opacity: 1; }
}
.add-tag {
background: var(--ai-border);
background: rgba(99, 102, 241, 0.06);
color: var(--ai-text-sub);
font-size: 16px;
font-weight: 700;
padding: 6px 16px;
border: 1px dashed rgba(99, 102, 241, 0.3);
padding: 6px 14px;
:deep(.anticon) { font-size: 12px; }
&:hover {
color: var(--ai-primary);
border-color: var(--ai-primary);
}
}
.adding-tag {
background: #fff;
border: 1.5px solid var(--ai-primary);
padding: 4px 8px;
padding: 4px 10px;
}
.tag-input {
border: none;
outline: none;
font-size: 13px;
width: 60px;
width: 70px;
background: transparent;
color: var(--ai-text);
font-weight: 600;
}
.preset-tags {
@ -362,10 +601,94 @@ onMounted(() => {
gap: 6px;
}
.preset-tag {
background: #F0EDE8;
background: rgba(99, 102, 241, 0.04);
color: var(--ai-text-sub);
border: 1px solid rgba(99, 102, 241, 0.1);
font-size: 12px;
padding: 4px 12px;
padding: 4px 10px;
font-weight: 500;
:deep(.anticon) { font-size: 10px; }
&:hover {
background: rgba(99, 102, 241, 0.1);
color: var(--ai-primary);
border-color: rgba(99, 102, 241, 0.25);
}
}
/* ---------- 底部三按钮 ---------- */
.action-row {
display: flex;
gap: 10px;
}
.action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 13px 8px;
border-radius: 22px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
min-width: 0;
:deep(.anticon) { font-size: 14px; }
&:active { transform: scale(0.97); }
&:disabled { opacity: 0.4; pointer-events: none; }
}
/* 保存草稿:最弱,透明 + 紫色淡边 */
.draft-btn {
background: transparent;
color: var(--ai-text-sub);
border: 1.5px solid rgba(99, 102, 241, 0.2);
&:hover {
color: var(--ai-primary);
border-color: var(--ai-primary);
background: rgba(99, 102, 241, 0.04);
}
}
/* 去配音:中等,白底紫边 */
.dubbing-btn {
background: #fff;
color: var(--ai-primary);
border: 1.5px solid rgba(99, 102, 241, 0.4);
box-shadow: 0 2px 10px rgba(99, 102, 241, 0.06);
&:hover {
border-color: var(--ai-primary);
background: rgba(99, 102, 241, 0.04);
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.12);
}
}
/* 发布作品:主操作,紫粉渐变 */
.publish-btn {
background: var(--ai-gradient);
color: #fff;
border: none;
box-shadow: 0 6px 18px rgba(99, 102, 241, 0.32);
&:hover {
transform: translateY(-1px);
box-shadow: 0 8px 22px rgba(99, 102, 241, 0.4);
}
}
.action-hint {
margin-top: 10px;
text-align: center;
font-size: 11px;
color: var(--ai-text-sub);
font-weight: 500;
letter-spacing: 0.3px;
}
</style>

View File

@ -3,64 +3,75 @@
<!-- 顶部 -->
<div class="top-bar">
<div class="top-title">绘本预览</div>
<div class="top-sub">你的绘本已生成!</div>
<div class="top-sub">你的绘本已生成翻一翻看看吧</div>
</div>
<!-- 加载中 -->
<div v-if="loading" class="loading-state">
<div class="loading-icon">📖</div>
<div class="loading-text">加载中...</div>
<loading-outlined class="loading-icon" spin />
<div class="loading-text">加载中</div>
</div>
<!-- 加载失败 -->
<div v-else-if="error" class="error-state card">
<div style="font-size:36px;margin-bottom:12px">😥</div>
<div style="font-weight:600;margin-bottom:8px">加载失败</div>
<div style="color:var(--ai-text-sub);font-size:13px;margin-bottom:16px">{{ error }}</div>
<button class="btn-primary" style="width:auto;padding:10px 32px" @click="loadWork">重试</button>
<div v-else-if="error" class="error-state">
<frown-outlined class="error-icon" />
<div class="error-title">加载失败</div>
<div class="error-msg">{{ error }}</div>
<button class="btn-primary error-btn" @click="loadWork">重试</button>
</div>
<!-- 主内容 -->
<template v-else-if="pages.length">
<div class="content page-content" @touchstart="onTouchStart" @touchend="onTouchEnd">
<!-- 1. 图片区16:9 完整展示不裁切 -->
<!-- 1. 图片区16:9 完整展示 -->
<div class="image-section">
<div class="page-badge">{{ pageBadge }}</div>
<img v-if="currentPage.imageUrl" :src="currentPage.imageUrl" class="page-image" />
<div v-else class="page-image placeholder-img">📖</div>
<img v-if="currentPage.imageUrl" :src="currentPage.imageUrl" class="page-image" :alt="pageBadge" />
<div v-else class="page-image placeholder-img">
<picture-outlined />
</div>
</div>
<!-- 2. 故事文字区 -->
<div class="text-section">
<div class="text-deco">"</div>
<div class="story-text">{{ currentPage.text || '' }}</div>
<div class="story-text">{{ currentPage.text || '(封面)' }}</div>
</div>
<!-- 3. 翻页 -->
<div class="nav-row">
<button class="nav-btn" :class="{ invisible: idx <= 0 }" @click="prev"></button>
<button class="nav-btn" :class="{ invisible: idx <= 0 }" @click="prev" aria-label="上一页">
<left-outlined />
</button>
<span class="page-counter">{{ idx + 1 }} / {{ pages.length }}</span>
<button class="nav-btn" :class="{ invisible: idx >= pages.length - 1 }" @click="next"></button>
<button class="nav-btn" :class="{ invisible: idx >= pages.length - 1 }" @click="next" aria-label="下一页">
<right-outlined />
</button>
</div>
<!-- 4. 横版卡片网格2 -->
<div class="thumb-grid">
<div v-for="(p, i) in pages" :key="i"
class="thumb-card" :class="{ active: i === idx }"
@click="idx = i">
<div class="thumb-card-img-wrap">
<img v-if="p.imageUrl" :src="p.imageUrl" class="thumb-card-img" />
<div v-else class="thumb-card-placeholder">📖</div>
<div class="thumb-card-badge">{{ i === 0 ? '封面' : 'P' + i }}</div>
<!-- 4. 缩略图横向胶卷 -->
<div class="thumb-strip" ref="thumbStrip">
<div
v-for="(p, i) in pages"
:key="i"
class="thumb-item"
:class="{ active: i === idx }"
@click="idx = i"
>
<img v-if="p.imageUrl" :src="p.imageUrl" class="thumb-img" />
<div v-else class="thumb-placeholder">
<picture-outlined />
</div>
<div class="thumb-card-text">{{ p.text ? (p.text.length > 16 ? p.text.slice(0,16) + '...' : p.text) : '' }}</div>
<div class="thumb-num">{{ i === 0 ? '封面' : i }}</div>
</div>
</div>
</div>
<!-- 底部按钮 -->
<div class="page-bottom">
<button class="btn-primary" @click="goEditInfo">下一步: 编辑绘本信息 </button>
<button class="btn-primary next-btn" @click="goEditInfo">
<span>下一步编辑绘本信息</span>
<arrow-right-outlined />
</button>
</div>
</template>
</div>
@ -69,6 +80,14 @@
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import {
LoadingOutlined,
FrownOutlined,
PictureOutlined,
LeftOutlined,
RightOutlined,
ArrowRightOutlined,
} from '@ant-design/icons-vue'
import { getWorkDetail } from '@/api/aicreate'
import { useAicreateStore } from '@/stores/aicreate'
import { STATUS, getRouteByStatus } from '@/utils/aicreate/status'
@ -77,32 +96,31 @@ const router = useRouter()
const route = useRoute()
const store = useAicreateStore()
const isDev = import.meta.env.DEV
const loading = ref(true)
const error = ref('')
const pages = ref([])
const pages = ref<any[]>([])
const idx = ref(0)
const thumbStrip = ref(null)
const thumbStrip = ref<HTMLElement | null>(null)
// Touch swipe
//
let touchX = 0
const onTouchStart = (e) => { touchX = e.touches[0].clientX }
const onTouchEnd = (e) => {
const onTouchStart = (e: TouchEvent) => { touchX = e.touches[0].clientX }
const onTouchEnd = (e: TouchEvent) => {
const dx = e.changedTouches[0].clientX - touchX
if (Math.abs(dx) > 50) dx > 0 ? prev() : next()
}
const currentPage = computed(() => pages.value[idx.value] || {})
const pageBadge = computed(() => {
if (idx.value === 0) return '封面'
return `P${idx.value}`
})
const pageBadge = computed(() => idx.value === 0 ? '封面' : `P${idx.value}`)
function prev() { if (idx.value > 0) { idx.value--; scrollThumbIntoView(idx.value) } }
function next() { if (idx.value < pages.value.length - 1) { idx.value++; scrollThumbIntoView(idx.value) } }
function scrollThumbIntoView(i) {
function scrollThumbIntoView(i: number) {
nextTick(() => {
const el = thumbStrip.value?.children[i]
const el = thumbStrip.value?.children[i] as HTMLElement | undefined
if (el) el.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
})
}
@ -112,8 +130,24 @@ const workId = computed(() => route.params.workId || store.workId)
async function loadWork() {
loading.value = true
error.value = ''
// dev mock workId dev workId 使 store.workDetail
const wid = String(workId.value || '')
if (isDev && (wid.startsWith('mock-') || !wid)) {
if (!store.workDetail) store.fillMockWorkDetail()
const work = store.workDetail
pages.value = (work.pageList || []).map((p: any) => ({
pageNum: p.pageNum,
text: p.text,
imageUrl: p.imageUrl,
audioUrl: p.audioUrl,
}))
loading.value = false
return
}
try {
const res = await getWorkDetail(workId.value)
const res = await getWorkDetail(workId.value as string)
const work = res.data
store.workDetail = work
store.workId = work.workId
@ -124,13 +158,13 @@ async function loadWork() {
if (nextRoute) { router.replace(nextRoute); return }
}
pages.value = (work.pageList || []).map(p => ({
pages.value = (work.pageList || []).map((p: any) => ({
pageNum: p.pageNum,
text: p.text,
imageUrl: p.imageUrl,
audioUrl: p.audioUrl
audioUrl: p.audioUrl,
}))
} catch (e) {
} catch (e: any) {
error.value = e.message || '加载失败'
} finally {
loading.value = false
@ -147,186 +181,260 @@ onMounted(loadWork)
<style lang="scss" scoped>
.preview-page {
background: var(--ai-bg);
display: flex;
flex-direction: column;
}
/* ---------- 顶部 ---------- */
.top-bar {
background: var(--ai-gradient);
padding: 20px 20px 16px;
padding: 22px 22px 18px;
color: #fff;
text-align: center;
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.18);
}
.top-title {
font-size: 20px;
font-weight: 800;
letter-spacing: 1px;
}
.top-sub {
font-size: 13px;
opacity: 0.92;
margin-top: 4px;
font-weight: 500;
}
.top-title { font-size: 20px; font-weight: 800; }
.top-sub { font-size: 13px; opacity: 0.85; margin-top: 4px; }
.loading-state, .error-state {
/* ---------- 加载/错误状态 ---------- */
.loading-state,
.error-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
padding: 60px 24px;
}
.loading-icon {
font-size: 44px;
color: var(--ai-primary);
margin-bottom: 14px;
}
.loading-text {
color: var(--ai-text-sub);
font-size: 14px;
}
.error-icon {
font-size: 48px;
color: var(--ai-text-sub);
margin-bottom: 14px;
}
.error-title {
font-size: 16px;
font-weight: 700;
color: var(--ai-text);
margin-bottom: 6px;
}
.error-msg {
color: var(--ai-text-sub);
font-size: 13px;
margin-bottom: 18px;
text-align: center;
max-width: 280px;
}
.error-btn {
width: auto !important;
padding: 10px 32px !important;
font-size: 14px !important;
}
.loading-icon { font-size: 48px; animation: pulse 1.5s ease infinite; }
.loading-text { margin-top: 12px; color: var(--ai-text-sub); }
/* ---------- 主内容 ---------- */
.content {
padding: 10px 14px 14px;
padding: 16px 16px 14px;
flex: 1;
overflow-y: auto;
}
/* 1. 图片区16:9 完整展示 */
/* 图片区 */
.image-section {
position: relative;
border-radius: 18px;
overflow: hidden;
background: #1A1A1A;
box-shadow: 0 6px 24px rgba(0,0,0,0.12);
background: #1e1b4b; /* 深紫黑底,电影画幅感 */
box-shadow: 0 8px 28px rgba(99, 102, 241, 0.2);
}
.page-image {
width: 100%;
display: block;
object-fit: contain; /* 16:9 横图完整显示,不裁切 */
object-fit: contain;
aspect-ratio: 16 / 9;
background: #1A1A1A; /* 上下留黑,像电影画幅 */
background: #1e1b4b;
}
.placeholder-img {
aspect-ratio: 16 / 9;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
background: #F5F3EE;
font-size: 56px;
color: rgba(255, 255, 255, 0.3);
background: #1e1b4b;
}
.page-badge {
position: absolute;
top: 10px;
left: 10px;
background: linear-gradient(135deg, rgba(255,107,53,0.9), rgba(255,140,66,0.9));
top: 12px;
left: 12px;
background: var(--ai-gradient);
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 4px 14px;
border-radius: 20px;
z-index: 2;
box-shadow: 0 2px 8px rgba(255,107,53,0.3);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
letter-spacing: 0.5px;
}
/* 2. 故事文字区 */
/* 故事文字区 */
.text-section {
background: rgba(255,255,255,0.92);
background: #fff;
border: 1px solid rgba(99, 102, 241, 0.08);
border-top: none;
border-radius: 0 0 18px 18px;
margin-top: -8px; /* 与图片无缝衔接 */
margin-top: -8px;
padding: 18px 22px 16px;
position: relative;
box-shadow: 0 4px 16px rgba(0,0,0,0.05);
min-height: 60px;
}
.text-deco {
position: absolute;
top: 4px; left: 14px;
font-size: 36px; color: #FFD166; opacity: 0.25;
font-family: Georgia, serif; font-weight: 900; line-height: 1;
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.06);
min-height: 64px;
}
.story-text {
font-size: 16px;
font-size: 15px;
line-height: 1.8;
color: #3D2E1E;
color: var(--ai-text);
font-weight: 500;
padding-left: 4px;
}
/* 3. 翻页行 */
/* 翻页 */
.nav-row {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
gap: 22px;
margin-top: 18px;
}
.nav-btn {
width: 36px;
height: 36px;
width: 38px;
height: 38px;
border-radius: 50%;
border: none;
background: var(--ai-card);
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
font-size: 18px;
font-weight: 700;
border: 1px solid rgba(99, 102, 241, 0.18);
background: #fff;
box-shadow: 0 2px 10px rgba(99, 102, 241, 0.1);
font-size: 16px;
color: var(--ai-primary);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:active { transform: scale(0.9); }
&:hover {
border-color: var(--ai-primary);
background: rgba(99, 102, 241, 0.04);
}
&:active { transform: scale(0.92); }
&.invisible { opacity: 0; pointer-events: none; }
}
.page-counter {
font-size: 12px;
color: var(--ai-text-sub);
font-weight: 500;
font-weight: 600;
letter-spacing: 0.5px;
}
/* 4. 横版卡片网格 */
.thumb-grid {
display: grid;
grid-template-columns: 1fr 1fr;
/* ---------- 横向胶卷缩略图 ---------- */
.thumb-strip {
display: flex;
gap: 10px;
padding: 4px 0;
padding: 18px 4px 8px;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x proximity;
scroll-padding: 0 50%;
/* 隐藏滚动条 */
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar { display: none; }
}
.thumb-card {
border-radius: 14px;
.thumb-item {
position: relative;
flex-shrink: 0;
width: 88px;
aspect-ratio: 16 / 9;
border-radius: 10px;
overflow: hidden;
background: rgba(255,255,255,0.9);
box-shadow: 0 2px 10px rgba(0,0,0,0.06);
cursor: pointer;
transition: all 0.2s;
border: 2.5px solid transparent;
border: 2px solid transparent;
background: #1e1b4b;
box-shadow: 0 2px 10px rgba(99, 102, 241, 0.08);
transition: all 0.25s ease;
scroll-snap-align: center;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.18);
}
&.active {
border-color: var(--ai-primary);
box-shadow: 0 3px 14px rgba(255,107,53,0.25);
transform: scale(1.02);
transform: translateY(-3px) scale(1.06);
box-shadow: 0 8px 22px rgba(99, 102, 241, 0.36);
z-index: 1;
}
}
.thumb-card-img-wrap {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
background: #F0EDE8;
}
.thumb-card-img {
.thumb-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.thumb-card-placeholder {
.thumb-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-size: 22px;
color: rgba(255, 255, 255, 0.3);
}
.thumb-card-badge {
.thumb-num {
position: absolute;
top: 4px; left: 4px;
background: rgba(0,0,0,0.5);
bottom: 0;
left: 0;
right: 0;
padding: 8px 4px 3px;
background: linear-gradient(to top, rgba(15, 12, 41, 0.85), transparent);
color: #fff;
font-size: 9px;
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 10px;
}
.thumb-card-text {
padding: 6px 8px;
font-size: 11px;
color: #64748B;
line-height: 1.4;
min-height: 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
letter-spacing: 0.5px;
line-height: 1;
}
/* ---------- 底部按钮 ---------- */
.next-btn {
display: flex !important;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 16px !important;
padding: 14px 0 !important;
border-radius: 28px !important;
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.08); }
:deep(.anticon) { font-size: 16px; }
}
</style>

View File

@ -1,54 +1,66 @@
<template>
<div class="story-page page-fullscreen">
<PageHeader title="编写故事" subtitle="告诉AI你想要什么样的故事" :step="3" />
<PageHeader title="编写故事" subtitle="告诉 AI 你想要什么样的故事" :step="2" />
<div class="content page-content">
<!-- 绘本信息 -->
<!-- 绘本标题 -->
<div class="section-card">
<div class="section-header">
<span class="section-icon">📚</span>
<span class="section-label">绘本信息</span>
<book-outlined class="section-icon" />
<span class="section-label">绘本标题</span>
<span class="required-mark">必填</span>
</div>
<div class="field-item">
<div class="field-label">
<span></span> 绘本标题
<span class="required-mark">必填</span>
</div>
<div class="input-wrap" :class="{ focus: bookTitleFocus }">
<input v-model="bookTitle" class="text-input" placeholder="如:小璃的冒险"
maxlength="12" @focus="bookTitleFocus = true" @blur="bookTitleFocus = false" />
</div>
<div class="input-wrap" :class="{ focus: bookTitleFocus }">
<input
v-model="bookTitle"
class="text-input"
placeholder="如:森林大冒险"
maxlength="12"
@focus="bookTitleFocus = true"
@blur="bookTitleFocus = false"
/>
</div>
</div>
<!-- 主角名字 -->
<div class="section-card">
<div class="section-header">
<span class="section-icon">🐰</span>
<user-outlined class="section-icon" />
<span class="section-label">主角名字</span>
<span class="required-mark">必填</span>
</div>
<div class="input-wrap" :class="{ focus: heroNameFocus }">
<input v-model="heroName" class="text-input" placeholder="给主角起个名字吧~"
maxlength="10" @focus="heroNameFocus = true" @blur="heroNameFocus = false" />
<div class="hero-row">
<div v-if="heroAvatar" class="hero-mini">
<img :src="heroAvatar" alt="主角形象" />
</div>
<div class="input-wrap" :class="{ focus: heroNameFocus }">
<input
v-model="heroName"
class="text-input"
placeholder="为主角起个名字"
maxlength="10"
@focus="heroNameFocus = true"
@blur="heroNameFocus = false"
/>
</div>
</div>
<div v-if="heroAvatar" class="hero-hint">这是你画作中的角色形象</div>
</div>
<!-- 故事要素 -->
<div class="section-card story-elements">
<div class="section-card">
<div class="section-header">
<span class="section-icon">📖</span>
<edit-outlined class="section-icon" />
<span class="section-label">故事要素</span>
</div>
<div v-for="(f, i) in fields" :key="i" class="field-item">
<div class="field-label">
<span class="field-emoji">{{ f.emoji }}</span>
<span>{{ f.label }}</span>
<span v-if="f.required" class="required-mark">必填</span>
<span v-else class="optional-mark">选填</span>
</div>
<div class="textarea-wrap" :class="{ focus: f.focused?.value }">
<div class="textarea-wrap" :class="{ focus: f.focused.value }">
<textarea
v-model="f.value.value"
:placeholder="f.placeholder"
@ -61,20 +73,31 @@
</div>
</div>
</div>
</div>
<div class="page-bottom">
<button class="btn-primary create-btn" :disabled="!canSubmit" @click="goNext">
<span class="btn-rocket">🚀</span> 开始创作绘本
<rocket-outlined />
<span>开始创作绘本</span>
</button>
<div class="time-hint" style="text-align:center;margin-top:6px;font-size:12px;color:var(--ai-text-sub)"> 创作预计需要 1-3 分钟</div>
<div class="time-hint">
<clock-circle-outlined />
<span>创作预计需要 1-3 分钟</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onActivated } from 'vue'
import { useRouter } from 'vue-router'
import {
BookOutlined,
UserOutlined,
EditOutlined,
RocketOutlined,
ClockCircleOutlined,
} from '@ant-design/icons-vue'
import PageHeader from '@/components/aicreate/PageHeader.vue'
import { useAicreateStore } from '@/stores/aicreate'
@ -89,17 +112,23 @@ const whatHappens = ref('')
const bookTitleFocus = ref(false)
const heroNameFocus = ref(false)
const heroAvatar = computed(() => store.selectedCharacter?.originalCropUrl || '')
const fields = [
{ emoji: '🌅', label: '故事开始', placeholder: '如:一个阳光明媚的早晨...', value: storyStart, required: false, focused: ref(false) },
{ emoji: '👋', label: '遇见谁', placeholder: '如:遇到了一只迷路的小鸟', value: meetWho, required: false, focused: ref(false) },
{ emoji: '⚡', label: '发生什么', placeholder: '如:一起去森林探险寻找宝藏', value: whatHappens, required: true, focused: ref(false) },
{ label: '故事开始', placeholder: '如:一个阳光明媚的早晨', value: storyStart, required: false, focused: ref(false) },
{ label: '遇见谁', placeholder: '如:遇到了一只迷路的小鸟', value: meetWho, required: false, focused: ref(false) },
{ label: '发生什么', placeholder: '如:一起去森林探险寻找宝藏', value: whatHappens, required: true, focused: ref(false) },
]
const canSubmit = computed(() => bookTitle.value.trim() && heroName.value.trim() && whatHappens.value.trim())
// keep-alive 退 onActivated
let submitted = false
onActivated(() => {
submitted = false
})
const goNext = () => {
if (submitted) return //
if (submitted) return
submitted = true
const parts = []
@ -113,51 +142,33 @@ const goNext = () => {
storyHint: parts.join(''),
title: bookTitle.value.trim()
}
router.push('/p/create/creating')
router.push('/p/create/style')
}
</script>
<style lang="scss" scoped>
.story-page {
min-height: 100vh;
background: linear-gradient(180deg, #FFF8E7 0%, #FFFAF0 30%, #FFF5E6 60%, #FFFDF7 100%);
background: var(--ai-bg);
display: flex;
flex-direction: column;
}
.content {
flex: 1;
padding: 12px 20px;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 14px;
overflow-y: auto;
}
/* ---------- 卡片 ---------- */
.section-card {
background: rgba(255,255,255,0.92);
border-radius: 22px;
padding: 18px 18px;
box-shadow: 0 4px 20px rgba(0,0,0,0.05);
//
border-left: 4px solid #FFE4C8;
position: relative;
&::before {
content: '';
position: absolute;
top: 12px;
left: -4px;
bottom: 12px;
width: 4px;
background: repeating-linear-gradient(
180deg,
transparent 0px,
transparent 4px,
#FFD4A8 4px,
#FFD4A8 8px
);
border-radius: 2px;
}
background: #fff;
border: 1px solid rgba(99, 102, 241, 0.06);
border-radius: var(--ai-radius);
padding: 18px;
box-shadow: 0 2px 12px rgba(99, 102, 241, 0.05);
}
.section-header {
@ -166,91 +177,135 @@ const goNext = () => {
gap: 8px;
margin-bottom: 14px;
}
.section-icon { font-size: 20px; }
.section-icon {
font-size: 18px;
color: var(--ai-primary);
}
.section-label {
font-size: 16px;
font-weight: 800;
font-size: 15px;
font-weight: 700;
color: var(--ai-text);
flex: 1;
}
/* ---------- 必填 / 选填标签 ---------- */
.required-mark {
color: var(--ai-primary);
font-size: 11px;
background: rgba(99, 102, 241, 0.1);
padding: 2px 8px;
border-radius: 8px;
font-weight: 700;
letter-spacing: 0.5px;
}
.optional-mark {
color: var(--ai-text-sub);
font-size: 11px;
background: rgba(107, 114, 128, 0.08);
padding: 2px 8px;
border-radius: 8px;
font-weight: 600;
letter-spacing: 0.5px;
}
/* ---------- 输入框 ---------- */
.input-wrap {
background: #FFF8F0;
border-radius: 14px;
border: 2px solid #FFE8D4;
transition: all 0.3s;
background: rgba(99, 102, 241, 0.04);
border-radius: 12px;
border: 1.5px solid rgba(99, 102, 241, 0.12);
transition: all 0.2s;
overflow: hidden;
flex: 1;
min-width: 0;
&.focus {
border-color: var(--ai-primary);
box-shadow: 0 0 0 4px rgba(255,107,53,0.1);
background: #FFF;
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1);
background: #fff;
}
}
.text-input {
width: 100%;
border: none;
background: transparent;
padding: 14px 16px;
font-size: 16px;
padding: 13px 14px;
font-size: 15px;
outline: none;
color: var(--ai-text);
box-sizing: border-box;
font-weight: 500;
&::placeholder { color: #C8B8A8; }
&::placeholder {
color: #b8b6c4;
}
}
/* ---------- 主角名字(含形象回显) ---------- */
.hero-row {
display: flex;
align-items: center;
gap: 12px;
}
.hero-mini {
width: 52px;
height: 52px;
border-radius: 14px;
overflow: hidden;
border: 2px solid var(--ai-primary);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.22);
flex-shrink: 0;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(236, 72, 153, 0.06));
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.hero-hint {
font-size: 11px;
color: var(--ai-text-sub);
margin-top: 8px;
padding-left: 64px;
}
/* ---------- 故事要素字段 ---------- */
.field-item {
margin-bottom: 14px;
&:last-child { margin-bottom: 0; }
}
.field-label {
font-size: 14px;
font-size: 13px;
color: var(--ai-text);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
font-weight: 600;
}
.field-emoji { font-size: 16px; }
.required-mark {
color: var(--ai-primary);
font-size: 11px;
background: rgba(255,107,53,0.1);
padding: 1px 6px;
border-radius: 6px;
font-weight: 700;
}
.optional-mark {
color: #94A3B8;
font-size: 11px;
background: rgba(148,163,184,0.1);
padding: 1px 6px;
border-radius: 6px;
}
.textarea-wrap {
background: #FFF8F0;
border-radius: 14px;
border: 2px solid #FFE8D4;
transition: all 0.3s;
background: rgba(99, 102, 241, 0.04);
border-radius: 12px;
border: 1.5px solid rgba(99, 102, 241, 0.12);
transition: all 0.2s;
overflow: hidden;
&.focus {
border-color: var(--ai-primary);
box-shadow: 0 0 0 4px rgba(255,107,53,0.1);
background: #FFF;
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1);
background: #fff;
}
}
.textarea-input {
width: 100%;
border: none;
background: transparent;
padding: 12px 16px;
font-size: 15px;
padding: 12px 14px;
font-size: 14px;
outline: none;
color: var(--ai-text);
resize: none;
@ -258,30 +313,36 @@ const goNext = () => {
line-height: 1.6;
font-weight: 500;
&::placeholder { color: #C8B8A8; }
&::placeholder {
color: #b8b6c4;
}
}
.bottom-area { margin-top: auto; padding-top: 8px; padding-bottom: 20px; }
/* ---------- 底部按钮 ---------- */
.create-btn {
display: flex !important;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 18px !important;
padding: 18px 0 !important;
gap: 10px;
font-size: 17px !important;
padding: 16px 0 !important;
border-radius: 28px !important;
background: linear-gradient(135deg, #FF8C42, #FF6B35, #FF5722) !important;
box-shadow: 0 6px 24px rgba(255,107,53,0.35) !important;
letter-spacing: 1px;
:deep(.anticon) { font-size: 20px; }
}
.btn-rocket { font-size: 20px; }
.time-hint {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
text-align: center;
font-size: 13px;
font-size: 12px;
color: var(--ai-primary);
margin-top: 10px;
margin-top: 8px;
font-weight: 600;
:deep(.anticon) { font-size: 13px; }
}
</style>

View File

@ -1,12 +1,12 @@
<template>
<div class="style-page page-fullscreen">
<PageHeader title="选择画风" subtitle="为绘本挑选一种你喜欢的画风" :step="2" />
<PageHeader title="选择画风" subtitle="为绘本挑选一种你喜欢的画风" :step="3" />
<div class="content page-content">
<!-- 提示文字 -->
<!-- 提示 -->
<div class="tip-banner">
<span class="tip-icon">🎨</span>
<span>每种画风都有独特魅力选一个最喜欢的</span>
<bg-colors-outlined class="tip-icon" />
<span>每种画风都有独特魅力选一个最喜欢的</span>
</div>
<div class="style-grid">
@ -18,27 +18,30 @@
@click="selected = s.styleId"
>
<!-- 选中角标 -->
<div v-if="selected === s.styleId" class="check-corner" :style="{ background: s.color }">
<span></span>
<div v-if="selected === s.styleId" class="check-corner">
<check-outlined />
</div>
<div class="style-preview" :style="{ background: `linear-gradient(135deg, ${s.color}18, ${s.color}35)` }">
<span class="style-emoji">{{ s.emoji }}</span>
<div class="style-preview">
<img
:src="previewUrl(s.styleId)"
:alt="s.styleName"
class="style-preview-img"
@error="(e) => onPreviewError(e, s.hue)"
/>
</div>
<div class="style-info">
<div class="style-name">{{ s.styleName }}</div>
<div class="style-desc">{{ s.desc }}</div>
</div>
<div v-if="selected === s.styleId" class="style-selected-tag" :style="{ background: s.color }">
已选择
</div>
</div>
</div>
</div>
<div class="page-bottom">
<button class="btn-primary next-btn" :disabled="!selected" @click="goNext">
下一步编故事
<span>确定画风开始创作</span>
<arrow-right-outlined />
</button>
</div>
</div>
@ -47,6 +50,11 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import {
BgColorsOutlined,
CheckOutlined,
ArrowRightOutlined,
} from '@ant-design/icons-vue'
import PageHeader from '@/components/aicreate/PageHeader.vue'
import { useAicreateStore } from '@/stores/aicreate'
@ -54,157 +62,182 @@ const router = useRouter()
const store = useAicreateStore()
const selected = ref('')
const styles = [
{ styleId: 'style_cartoon', styleName: '卡通风格', emoji: '🎨', color: '#FF6B35', desc: '色彩鲜明,充满童趣' },
{ styleId: 'style_watercolor', styleName: '水彩风格', emoji: '🖌️', color: '#2EC4B6', desc: '柔和透明,梦幻浪漫' },
{ styleId: 'style_ink', styleName: '水墨国风', emoji: '🏮', color: '#6C63FF', desc: '古韵悠长,意境深远' },
{ styleId: 'style_pencil', styleName: '彩铅风格', emoji: '✏️', color: '#FFD166', desc: '细腻温暖,自然亲切' },
{ styleId: 'style_oilpaint', styleName: '油画风格', emoji: '🖼️', color: '#8B5E3C', desc: '色彩浓郁,质感丰富' },
{ styleId: 'style_collage', styleName: '剪贴画', emoji: '✂️', color: '#E91E63', desc: '趣味拼贴,创意满满' },
interface StyleItem {
styleId: string
styleName: string
desc: string
/** 用于预览图加载失败时的 fallback 渐变色调HSL 色相) */
hue: number
}
const styles: StyleItem[] = [
{ styleId: 'style_cartoon', styleName: '卡通风格', desc: '色彩鲜明,充满童趣', hue: 30 },
{ styleId: 'style_watercolor', styleName: '水彩风格', desc: '柔和透明,梦幻浪漫', hue: 200 },
{ styleId: 'style_ink', styleName: '水墨国风', desc: '古韵悠长,意境深远', hue: 270 },
{ styleId: 'style_pencil', styleName: '彩铅风格', desc: '细腻温暖,自然亲切', hue: 50 },
{ styleId: 'style_oilpaint', styleName: '油画风格', desc: '色彩浓郁,质感丰富', hue: 25 },
{ styleId: 'style_collage', styleName: '剪贴画', desc: '趣味拼贴,创意满满', hue: 320 },
]
/** 预览图路径约定:/public/aicreate/styles/{styleId}.jpg */
const previewUrl = (id: string) => `/aicreate/styles/${id}.jpg`
/** 加载失败时切换为对应色调的 SVG 渐变占位 */
const fallbackSvg = (hue: number) =>
'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">` +
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
`<stop offset="0" stop-color="hsl(${hue},65%,84%)"/>` +
`<stop offset="1" stop-color="hsl(${(hue + 30) % 360},70%,66%)"/>` +
`</linearGradient></defs>` +
`<rect width="240" height="240" fill="url(#g)"/>` +
`</svg>`
)
const onPreviewError = (e: Event, hue: number) => {
const img = e.target as HTMLImageElement
if (img.dataset.fallback === '1') return // fallback
img.dataset.fallback = '1'
img.src = fallbackSvg(hue)
}
const goNext = () => {
store.selectedStyle = selected.value
router.push('/p/create/story')
router.push('/p/create/creating')
}
</script>
<style lang="scss" scoped>
.style-page {
min-height: 100vh;
background: linear-gradient(180deg, #F5F0FF 0%, #FFF0F5 40%, #FFF5F0 70%, #FFFDF7 100%);
background: var(--ai-bg);
display: flex;
flex-direction: column;
}
.content {
flex: 1;
padding: 12px 16px;
padding: 16px;
display: flex;
flex-direction: column;
}
/* ---------- 提示条 ---------- */
.tip-banner {
background: rgba(255,255,255,0.85);
border: 1.5px solid #E8D5F5;
border-radius: 16px;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(236, 72, 153, 0.05));
border: 1px solid rgba(99, 102, 241, 0.15);
border-radius: var(--ai-radius-sm);
padding: 12px 16px;
margin-bottom: 14px;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
font-size: 14px;
font-weight: 600;
color: #7B61B8;
font-weight: 500;
color: var(--ai-text);
}
.tip-icon {
font-size: 16px;
color: var(--ai-primary);
flex-shrink: 0;
}
.tip-icon { font-size: 18px; }
/* ---------- 风格网格 ---------- */
.style-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
flex: 1;
gap: 12px;
}
.style-card {
background: rgba(255,255,255,0.92);
border-radius: 22px;
padding: 14px;
text-align: center;
border: 3px solid transparent;
transition: all 0.3s ease;
cursor: pointer;
box-shadow: 0 4px 16px rgba(0,0,0,0.05);
position: relative;
overflow: hidden;
background: #fff;
border-radius: var(--ai-radius);
padding: 10px;
border: 2px solid transparent;
transition: all 0.25s ease;
cursor: pointer;
box-shadow: 0 2px 12px rgba(99, 102, 241, 0.05);
display: flex;
flex-direction: column;
overflow: hidden;
&:active { transform: scale(0.97); }
&:hover {
border-color: rgba(99, 102, 241, 0.18);
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.1);
}
&.selected {
transform: scale(1.04);
box-shadow: 0 8px 28px rgba(0,0,0,0.12);
border-color: var(--ai-primary);
background: linear-gradient(135deg, rgba(99, 102, 241, 0.04), rgba(236, 72, 153, 0.03));
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.18);
}
.style-preview {
transform: scale(1.05);
}
&:active {
transform: scale(0.99);
}
}
//
.style-card.selected {
border-color: var(--ai-primary);
}
/* ---------- 选中角标 ---------- */
.check-corner {
position: absolute;
top: 0;
right: 0;
width: 36px;
height: 36px;
border-radius: 0 19px 0 18px;
top: 8px;
right: 8px;
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--ai-gradient);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
font-weight: 700;
font-size: 13px;
z-index: 2;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
/* ---------- 预览图 ---------- */
.style-preview {
width: 100%;
aspect-ratio: 1;
border-radius: 18px;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s;
}
.style-emoji {
font-size: 52px;
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.1));
border-radius: 14px;
overflow: hidden;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.06), rgba(236, 72, 153, 0.04));
}
.style-preview-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* ---------- 风格名称 ---------- */
.style-info {
flex: 1;
padding: 10px 4px 4px;
}
.style-name {
font-size: 16px;
font-weight: 800;
font-size: 15px;
font-weight: 700;
color: var(--ai-text);
}
.style-desc {
font-size: 12px;
font-size: 11px;
color: var(--ai-text-sub);
margin-top: 4px;
margin-top: 3px;
line-height: 1.4;
}
.style-selected-tag {
margin-top: 8px;
font-size: 12px;
color: #fff;
border-radius: 14px;
padding: 4px 14px;
display: inline-block;
font-weight: 700;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
align-self: center;
}
.bottom-area { margin-top: auto; padding-top: 16px; }
/* ---------- 底部按钮 ---------- */
.next-btn {
font-size: 17px !important;
padding: 16px 0 !important;
display: flex !important;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 16px !important;
padding: 14px 0 !important;
border-radius: 28px !important;
background: linear-gradient(135deg, #FF8C42, #FF6B35) !important;
box-shadow: 0 6px 24px rgba(255,107,53,0.3) !important;
:deep(.anticon) {
font-size: 16px;
}
}
</style>

View File

@ -1,32 +1,44 @@
<template>
<div class="upload-page page-fullscreen">
<PageHeader title="上传作品" subtitle="拍下孩子的画让AI识别角色" :step="0" />
<PageHeader title="上传作品" subtitle="上传你的画作AI 自动识别角色" :step="0" />
<div class="content page-content">
<!-- 开发模式跳过真实后端调用 -->
<div v-if="isDev" class="dev-skip">
<span class="dev-skip-label">
<experiment-outlined />
开发模式
</span>
<button class="dev-skip-btn" @click="handleSkipUpload(3)">跳过 · 3 个角色</button>
<button class="dev-skip-btn" @click="handleSkipUpload(1)">跳过 · 1 个角色</button>
</div>
<template v-if="!preview">
<!-- 上传区域 -->
<div class="upload-area card">
<template v-if="uploading">
<div class="uploading-icon">📤</div>
<cloud-upload-outlined class="uploading-icon" />
<div class="uploading-text">正在上传...</div>
<div class="progress-bar"><div class="progress-fill" /></div>
</template>
<template v-else>
<div class="upload-icon">🖼</div>
<div class="upload-title">上传孩子的画作</div>
<div class="upload-desc">支持拍照或从相册选择<br/>AI会自动识别画中的角色</div>
<div class="upload-icon-wrap">
<picture-outlined class="upload-icon" />
</div>
<div class="upload-title">上传你的画作</div>
<div class="upload-desc">支持拍照或从相册选择<br/>AI 会自动识别画中的角色</div>
</template>
</div>
<!-- 拍照/相册按钮 -->
<div class="action-btns">
<div class="action-btn camera" @click="pickImage('camera')">
<div class="action-emoji">📷</div>
<camera-outlined class="action-icon" />
<div class="action-label">拍照</div>
<input ref="cameraInput" type="file" accept="image/*" capture="environment" @change="onFileChange" style="display:none" />
</div>
<div class="action-btn album" @click="pickImage('album')">
<div class="action-emoji">🖼</div>
<folder-open-outlined class="action-icon" />
<div class="action-label">相册</div>
<input ref="albumInput" type="file" accept="image/*" @change="onFileChange" style="display:none" />
</div>
@ -39,44 +51,41 @@
<div class="preview-image">
<img :src="preview" alt="预览" />
</div>
<!-- 识别中填满空间 -->
<!-- 识别中 -->
<div v-if="uploading" class="recognizing-box">
<div class="recognizing-emojis">
<span class="recognizing-emoji e1">🎨</span>
<span class="recognizing-emoji e2"></span>
<span class="recognizing-emoji e3">🖌</span>
</div>
<div class="recognizing-text">{{ uploadProgress || 'AI 小画家正在认识你的角色...' }}</div>
<loading-outlined class="recognizing-spinner" spin />
<div class="recognizing-text">{{ uploadProgress || 'AI 正在识别你的角色...' }}</div>
</div>
<div v-else class="preview-info">
<div class="preview-ok"> 已选择图片</div>
<check-circle-filled class="preview-ok-icon" />
<span class="preview-ok-text">已选择图片</span>
</div>
</div>
<!-- 识别中的趣味等待内容填满空白 -->
<!-- 等待内容 -->
<div v-if="uploading" class="waiting-content">
<div class="waiting-card">
<div class="waiting-title"> AI 正在为你做这些事</div>
<div class="waiting-title">AI 正在为你做这些事</div>
<div class="waiting-steps">
<div class="w-step" :class="{ active: uploadStage >= 1 }">
<span class="w-icon">📤</span>
<cloud-upload-outlined class="w-icon" />
<span>上传画作到云端</span>
<span v-if="uploadStage >= 1" class="w-done"></span>
<check-outlined v-if="uploadStage >= 1" class="w-done" />
</div>
<div class="w-step" :class="{ active: uploadStage >= 2 }">
<span class="w-icon">👀</span>
<eye-outlined class="w-icon" />
<span>AI 识别画中角色</span>
<span v-if="uploadStage >= 2" class="w-done"></span>
<check-outlined v-if="uploadStage >= 2" class="w-done" />
</div>
<div class="w-step" :class="{ active: uploadStage >= 3 }">
<span class="w-icon">🎭</span>
<team-outlined class="w-icon" />
<span>提取角色特征</span>
</div>
</div>
</div>
<div class="waiting-funfact">
<span class="ff-icon">💡</span>
<span class="ff-text">你知道吗AI 画师可以识别超过 100 种不同的卡通角色</span>
<bulb-outlined class="ff-icon" />
<span class="ff-text">小知识AI 画师可以识别超过 100 种不同的卡通角色</span>
</div>
</div>
@ -84,8 +93,15 @@
<div v-if="uploadError" class="upload-error">{{ uploadError }}</div>
<div class="preview-actions">
<button class="btn-ghost" @click="reset">重新上传</button>
<button class="btn-primary" :disabled="uploading" @click="goNext">
{{ uploading ? 'AI 识别中...' : '识别角色 →' }}
<button class="btn-primary preview-next-btn" :disabled="uploading" @click="goNext">
<template v-if="uploading">
<loading-outlined spin />
<span>AI 识别中...</span>
</template>
<template v-else>
<span>识别角色</span>
<arrow-right-outlined />
</template>
</button>
</div>
</template>
@ -99,6 +115,27 @@ import { useRouter } from 'vue-router'
import PageHeader from '@/components/aicreate/PageHeader.vue'
import { useAicreateStore } from '@/stores/aicreate'
import { extractCharacters, ossUpload, checkQuota } from '@/api/aicreate'
import {
PictureOutlined,
CameraOutlined,
FolderOpenOutlined,
CloudUploadOutlined,
LoadingOutlined,
CheckOutlined,
CheckCircleFilled,
EyeOutlined,
TeamOutlined,
BulbOutlined,
ArrowRightOutlined,
ExperimentOutlined,
} from '@ant-design/icons-vue'
const isDev = import.meta.env.DEV
const handleSkipUpload = (count: number) => {
store.fillMockData(count)
router.push('/p/create/characters')
}
const router = useRouter()
const store = useAicreateStore()
@ -314,17 +351,44 @@ const goNext = async () => {
flex-direction: column;
align-items: center;
justify-content: center;
border: 3px dashed var(--ai-border);
border: 2px dashed rgba(99, 102, 241, 0.28);
border-radius: var(--ai-radius);
background: rgba(99, 102, 241, 0.03);
text-align: center;
padding: 32px;
transition: all 0.2s;
}
.upload-icon, .uploading-icon {
font-size: 64px;
.upload-icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
border-radius: 22px;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.1), rgba(236, 72, 153, 0.1));
margin-bottom: 18px;
}
.uploading-icon { animation: pulse 1.5s infinite; }
.upload-title { font-size: 18px; font-weight: 700; margin-top: 16px; }
.upload-desc { font-size: 14px; color: var(--ai-text-sub); margin-top: 8px; line-height: 1.6; }
.uploading-text { font-size: 16px; font-weight: 600; margin-top: 16px; }
.upload-icon {
font-size: 36px;
color: var(--ai-primary);
}
.uploading-icon {
font-size: 56px;
color: var(--ai-primary);
animation: pulse 1.5s infinite;
}
.upload-title {
font-size: 17px;
font-weight: 700;
color: var(--ai-text);
}
.upload-desc {
font-size: 13px;
color: var(--ai-text-sub);
margin-top: 8px;
line-height: 1.6;
}
.uploading-text { font-size: 16px; font-weight: 600; margin-top: 16px; color: var(--ai-text); }
.progress-bar {
width: 200px; height: 6px; background: var(--ai-border); border-radius: 3px; margin-top: 16px; overflow: hidden;
}
@ -341,93 +405,174 @@ const goNext = async () => {
}
.action-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
border-radius: var(--ai-radius);
padding: 20px 0;
padding: 18px 0;
text-align: center;
cursor: pointer;
box-shadow: var(--ai-shadow);
transition: all 0.2s;
&.camera { background: var(--ai-gradient); }
&.album { background: var(--ai-gradient-purple); }
&.camera {
background: var(--ai-gradient);
color: #fff;
box-shadow: 0 8px 22px rgba(99, 102, 241, 0.32);
.action-icon { color: #fff; }
.action-label { color: #fff; }
&:hover { transform: translateY(-2px); box-shadow: 0 10px 26px rgba(99, 102, 241, 0.38); }
&:active { transform: scale(0.98); }
}
&.album {
background: #fff;
border: 1.5px solid rgba(99, 102, 241, 0.22);
box-shadow: 0 2px 10px rgba(99, 102, 241, 0.06);
.action-icon { color: var(--ai-primary); }
.action-label { color: var(--ai-primary); }
&:hover {
transform: translateY(-2px);
border-color: var(--ai-primary);
box-shadow: 0 6px 18px rgba(99, 102, 241, 0.14);
}
&:active { transform: scale(0.98); }
}
}
.action-emoji { font-size: 32px; }
.action-label { font-size: 15px; font-weight: 700; color: #fff; margin-top: 8px; }
.action-icon { font-size: 26px; }
.action-label { font-size: 15px; font-weight: 700; }
.preview-card { overflow: hidden; flex: 1; }
.preview-image {
width: 100%;
aspect-ratio: 4/3;
background: #F5F0E8;
background: #f1f0f7;
img { width: 100%; height: 100%; object-fit: cover; }
}
.preview-info { padding: 20px; text-align: center; }
.preview-ok { font-size: 15px; font-weight: 600; color: var(--ai-success); }
.preview-info {
padding: 18px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.preview-ok-icon { font-size: 18px; color: var(--ai-success); }
.preview-ok-text { font-size: 14px; font-weight: 600; color: var(--ai-success); }
/* 儿童风格识别中动画 */
/* 识别中 */
.recognizing-box {
background: linear-gradient(135deg, #FFF8E1, #FFFDE7);
border-radius: 0 0 16px 16px;
padding: 20px 16px;
background: linear-gradient(135deg, rgba(99,102,241,0.06), rgba(236,72,153,0.04));
border-radius: 0 0 var(--ai-radius) var(--ai-radius);
padding: 24px 16px;
text-align: center;
}
.recognizing-emojis {
display: flex;
justify-content: center;
gap: 12px;
.recognizing-spinner {
font-size: 32px;
color: var(--ai-primary);
margin-bottom: 12px;
padding: 8px 0;
}
.recognizing-emoji {
font-size: 28px;
display: inline-block;
animation: emojiPop 1.8s ease-in-out infinite;
}
.recognizing-emoji.e1 { animation-delay: 0s; }
.recognizing-emoji.e2 { animation-delay: 0.4s; }
.recognizing-emoji.e3 { animation-delay: 0.8s; }
@keyframes emojiPop {
0%, 100% { transform: scale(1) rotate(0deg); opacity: 0.5; }
50% { transform: scale(1.3) rotate(10deg); opacity: 1; }
}
.recognizing-text {
font-size: 15px;
font-weight: 700;
color: #F59E0B;
letter-spacing: 0.5px;
font-size: 14px;
font-weight: 600;
color: var(--ai-primary);
}
//
//
.waiting-content {
display: flex; flex-direction: column; gap: 12px; flex: 1;
}
.waiting-card {
background: rgba(255,255,255,0.92); border-radius: 20px;
padding: 18px 20px; box-shadow: 0 4px 16px rgba(0,0,0,0.05);
background: #fff;
border: 1px solid rgba(99, 102, 241, 0.06);
border-radius: var(--ai-radius);
padding: 18px 20px;
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.06);
}
.waiting-title {
font-size: 15px; font-weight: 800; color: #1E293B; margin-bottom: 14px;
font-size: 15px; font-weight: 700; color: var(--ai-text); margin-bottom: 14px;
}
.waiting-steps { display: flex; flex-direction: column; gap: 10px; }
.w-step {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; border-radius: 14px; background: #F8F8F5;
font-size: 14px; color: #94A3B8; transition: all 0.3s;
&.active { background: #FFF8E7; color: #1E293B; font-weight: 600; }
padding: 10px 14px; border-radius: 12px;
background: rgba(99, 102, 241, 0.04);
font-size: 14px; color: var(--ai-text-sub); transition: all 0.3s;
&.active {
background: rgba(99, 102, 241, 0.1);
color: var(--ai-text);
font-weight: 600;
}
}
.w-icon { font-size: 20px; }
.w-done { margin-left: auto; color: #10B981; font-weight: 700; font-size: 16px; }
.w-icon { font-size: 18px; color: var(--ai-primary); }
.w-done { margin-left: auto; color: var(--ai-success); font-size: 16px; }
.waiting-funfact {
display: flex; align-items: flex-start; gap: 10px;
background: linear-gradient(135deg, #EDE9FE, #F5F3FF);
border-radius: 16px; padding: 14px 16px;
display: flex; align-items: center; gap: 10px;
background: linear-gradient(135deg, rgba(167, 139, 250, 0.1), rgba(236, 72, 153, 0.06));
border: 1px solid rgba(99, 102, 241, 0.08);
border-radius: var(--ai-radius-sm);
padding: 14px 16px;
}
.ff-icon { font-size: 20px; flex-shrink: 0; }
.ff-text { font-size: 13px; color: #6D28D9; line-height: 1.6; }
.ff-icon { font-size: 18px; flex-shrink: 0; color: #8b5cf6; }
.ff-text { font-size: 13px; color: var(--ai-text); line-height: 1.6; }
.quota-warn {
background: #FEF3C7; color: #92400E; font-size: 13px; text-align: center;
padding: 10px 16px; border-radius: 10px; margin-top: 12px; font-weight: 600;
background: rgba(245, 158, 11, 0.1);
color: #b45309;
font-size: 13px;
text-align: center;
padding: 10px 16px;
border-radius: 10px;
margin-top: 12px;
font-weight: 600;
}
.upload-error {
color: #ef4444;
font-size: 13px;
text-align: center;
margin-top: 12px;
font-weight: 500;
}
.upload-error { color: #EF4444; font-size: 13px; text-align: center; margin-top: 12px; font-weight: 500; }
.preview-actions { display: flex; gap: 12px; margin-top: 12px; button { flex: 1; } }
.preview-next-btn {
display: flex !important;
align-items: center;
justify-content: center;
gap: 8px;
:deep(.anticon) { font-size: 16px; }
}
/* 开发模式跳过按钮 */
.dev-skip {
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
}
.dev-skip-label {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 600;
color: var(--ai-text-sub);
:deep(.anticon) { font-size: 12px; }
}
.dev-skip-btn {
padding: 6px 12px;
border-radius: 14px;
background: rgba(99, 102, 241, 0.06);
color: var(--ai-primary);
font-size: 11px;
font-weight: 600;
border: 1px dashed rgba(99, 102, 241, 0.32);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(99, 102, 241, 0.1);
border-color: var(--ai-primary);
}
}
</style>

View File

@ -1,88 +1,63 @@
<template>
<div class="welcome-page">
<!-- Hero紧凑版 -->
<div class="hero-compact">
<div class="hero-bg-deco d1"></div>
<div class="hero-bg-deco d2">🌈</div>
<div class="hero-bg-deco d3"></div>
<div class="hero-row">
<div class="hero-books">
<span v-for="(b, i) in ['📕','📗','📘','📙']" :key="i" class="book-icon">{{ b }}</span>
</div>
<div class="hero-text">
<div class="hero-title">{{ brandTitle }}</div>
<div class="hero-sub">{{ brandSubtitle }}</div>
</div>
<!-- Hero -->
<section class="hero">
<div class="hero-deco">
<thunderbolt-outlined class="deco deco-1" />
<star-outlined class="deco deco-2" />
<star-outlined class="deco deco-3" />
</div>
<div class="hero-tag"> 拍一张画AI帮你变成绘本</div>
</div>
<div class="hero-icon">
<book-outlined />
</div>
<h1 class="hero-title">AI 绘本创作</h1>
<p class="hero-sub">把你的画变成会讲故事的绘本</p>
</section>
<!-- 主内容区flex:1 撑满中间 -->
<div class="main-area">
<!-- 流程步骤垂直时间线 -->
<div class="steps-card">
<div class="steps-header">🎯 创作流程</div>
<div class="steps-timeline">
<div v-for="(s, i) in steps" :key="i" class="step-item">
<div class="step-left">
<div class="step-num" :style="{ background: s.color }">{{ i + 1 }}</div>
<div v-if="i < steps.length - 1" class="step-line" :style="{ background: s.color + '40' }" />
</div>
<div class="step-right">
<div class="step-head">
<span class="step-emoji">{{ s.emoji }}</span>
<span class="step-title">{{ s.title }}</span>
</div>
<div class="step-desc">{{ s.desc }}</div>
<!-- 创作流程 -->
<section class="card steps-card">
<h2 class="card-title">创作流程</h2>
<div class="steps">
<div v-for="(s, i) in steps" :key="i" class="step">
<div class="step-left">
<div class="step-num">{{ i + 1 }}</div>
<div v-if="i < steps.length - 1" class="step-line" />
</div>
<div class="step-right">
<div class="step-head">
<component :is="s.icon" class="step-icon" />
<span class="step-title">{{ s.title }}</span>
<span v-if="s.optional" class="step-tag">可选</span>
</div>
<div class="step-desc">{{ s.desc }}</div>
</div>
</div>
</div>
</section>
<!-- 特色标签 -->
<div class="features-row">
<div class="feature-tag">🎨 AI绘画</div>
<div class="feature-tag">📖 自动排版</div>
<div class="feature-tag">🔊 语音配音</div>
<div class="feature-tag">🎤 人工配音</div>
</div>
<!-- 亮点描述 -->
<div class="highlights">
<div class="hl-item">
<span class="hl-icon">🖌</span>
<span class="hl-text">上传孩子的画作AI 自动识别角色</span>
</div>
<div class="hl-item">
<span class="hl-icon">📖</span>
<span class="hl-text">一键生成多页精美绘本故事</span>
</div>
<div class="hl-item">
<span class="hl-icon">🔊</span>
<span class="hl-text">AI 配音或亲自录音让故事活起来</span>
</div>
</div>
</div>
<!-- 底部固定 -->
<div class="bottom-area safe-bottom">
<!-- Token模式无token时 -->
<!-- 底部固定 CTA -->
<div class="cta-fab">
<template v-if="isTokenMode && !store.sessionToken">
<div class="auth-prompt" style="text-align:center;padding:20px;">
<p style="font-size:16px;color:#666;margin-bottom:8px;">本应用需要通过企业入口访问</p>
<p style="font-size:14px;color:#999;margin-bottom:20px;">请联系您的管理员获取访问链接</p>
<button v-if="store.authRedirectUrl" class="btn-primary start-btn" @click="goToEnterprise">
<span class="btn-icon">🔑</span> 前往企业认证
</button>
</div>
</template>
<!-- 有token或HMAC模式 -->
<template v-else>
<button class="btn-primary start-btn" @click="handleStart">
<span class="btn-icon">🚀</span> 开始创作
<button
v-if="store.authRedirectUrl"
class="cta-btn"
@click="goToEnterprise"
>
<key-outlined />
<span>前往企业认证</span>
</button>
<button v-else class="cta-btn cta-btn--disabled" disabled>
<span>需通过企业入口访问</span>
</button>
<p class="slogan">请联系管理员获取访问链接</p>
</template>
<template v-else>
<button class="cta-btn" @click="handleStart">
<rocket-outlined />
<span>开始创作</span>
</button>
<p class="slogan">你的画作会讲故事</p>
</template>
<div class="slogan">让每个孩子都是小画家 </div>
</div>
</div>
</template>
@ -90,24 +65,38 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import {
CameraOutlined,
SmileOutlined,
BgColorsOutlined,
EditOutlined,
ThunderboltOutlined,
EyeOutlined,
SoundOutlined,
BookOutlined,
RocketOutlined,
KeyOutlined,
StarOutlined,
} from '@ant-design/icons-vue'
import { useAicreateStore } from '@/stores/aicreate'
import config from '@/utils/aicreate/config'
const router = useRouter()
const store = useAicreateStore()
const steps = [
{ emoji: '📸', title: '拍照上传', desc: '拍下孩子的画作', color: '#FF6B35' },
{ emoji: '🎭', title: '角色提取', desc: 'AI智能识别角色', color: '#6C63FF' },
{ emoji: '✏️', title: '编排故事', desc: '选画风填要素', color: '#2EC4B6' },
{ emoji: '📖', title: '绘本创作', desc: 'AI生成完整绘本', color: '#FFD166' },
{ icon: CameraOutlined, title: '拍照上传', desc: '拍下你的画作' },
{ icon: SmileOutlined, title: '角色提取', desc: 'AI 智能识别画中角色' },
{ icon: EditOutlined, title: '编排故事', desc: '起书名、定主角、填故事要素' },
{ icon: BgColorsOutlined, title: '选择画风', desc: '水墨、3D 等多种风格' },
{ icon: ThunderboltOutlined, title: 'AI 生成', desc: '一键生成完整绘本' },
{ icon: EyeOutlined, title: '预览编目', desc: '浏览成果,补充作者署名' },
{ icon: SoundOutlined, title: '配音发布', desc: 'AI 配音或亲自录音', optional: true },
]
onMounted(async () => {
//
const recovery = store.restoreRecoveryState()
if (recovery && recovery.path && recovery.path !== '/') {
//
const newPath = '/p/create' + recovery.path
router.push(newPath)
}
@ -123,231 +112,207 @@ const goToEnterprise = () => {
window.location.href = store.authRedirectUrl
}
const brandTitle = config.brand.title || '乐读派'
const brandSubtitle = config.brand.subtitle || 'AI智能儿童绘本创作'
const isTokenMode = true // Token
</script>
<style lang="scss" scoped>
$primary: #6366f1;
$accent: #ec4899;
$text-strong: #1e1b4b;
$text: #4b5563;
$text-muted: #9ca3af;
$gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
.welcome-page {
height: 100vh;
background: linear-gradient(180deg, #FFF8E7 0%, #FFF3E0 30%, #FFF0F0 60%, #FFFDF7 100%);
padding: 16px 16px 140px; // CTA
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
gap: 16px;
}
/* ---- Hero 紧凑版 ---- */
.hero-compact {
background: linear-gradient(135deg, #FF6B35 0%, #FF8F65 40%, #FFB088 70%, #FFCBA4 100%);
border-radius: 0 0 32px 32px;
padding: 40px 20px 18px;
text-align: center;
/* ---------- Hero ---------- */
.hero {
position: relative;
overflow: hidden;
flex-shrink: 0;
}
.hero-bg-deco {
position: absolute;
opacity: 0.18;
pointer-events: none;
animation: twinkle 3s ease-in-out infinite;
&.d1 { top: 10px; left: 16px; font-size: 20px; }
&.d2 { top: 16px; right: 20px; font-size: 18px; animation-delay: 0.8s; }
&.d3 { bottom: 10px; right: 30%; font-size: 16px; animation-delay: 1.5s; }
}
@keyframes twinkle {
0%, 100% { transform: scale(1) rotate(0deg); opacity: 0.18; }
50% { transform: scale(1.15) rotate(8deg); opacity: 0.35; }
}
.hero-row {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
}
.hero-books {
display: flex;
gap: 4px;
}
.book-icon {
font-size: 26px;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.15));
animation: bookBounce 2.5s ease-in-out infinite;
&:nth-child(2) { animation-delay: 0.3s; }
&:nth-child(3) { animation-delay: 0.6s; }
&:nth-child(4) { animation-delay: 0.9s; }
}
@keyframes bookBounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
.hero-text { text-align: left; }
.hero-title {
font-size: 28px;
font-weight: 900;
padding: 28px 24px;
border-radius: 20px;
background: $gradient;
color: #fff;
letter-spacing: 3px;
text-shadow: 0 2px 8px rgba(0,0,0,0.18);
text-align: center;
overflow: hidden;
box-shadow: 0 12px 32px rgba(99, 102, 241, 0.22);
}
.hero-sub {
font-size: 13px;
color: rgba(255,255,255,0.9);
margin-top: 2px;
letter-spacing: 1.5px;
font-weight: 500;
.hero-deco {
position: absolute;
inset: 0;
pointer-events: none;
.deco {
position: absolute;
color: rgba(255, 255, 255, 0.4);
}
.deco-1 { top: 14px; right: 18px; font-size: 22px; }
.deco-2 { top: 18px; left: 22px; font-size: 14px; }
.deco-3 { bottom: 18px; right: 30%; font-size: 12px; }
}
.hero-tag {
margin-top: 10px;
.hero-icon {
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(255,255,255,0.22);
border-radius: 20px;
padding: 5px 16px;
font-size: 12px;
color: #fff;
backdrop-filter: blur(8px);
font-weight: 600;
}
/* ---- 主内容区 ---- */
.main-area {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-evenly;
padding: 10px 16px 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
/* 流程步骤(垂直时间线) */
.steps-card {
background: rgba(255,255,255,0.92);
justify-content: center;
width: 56px;
height: 56px;
border-radius: 16px;
padding: 12px 14px;
box-shadow: 0 2px 12px rgba(0,0,0,0.05);
background: rgba(255, 255, 255, 0.22);
backdrop-filter: blur(8px);
font-size: 28px;
color: #fff;
margin-bottom: 12px;
}
.steps-header {
font-size: 15px;
.hero-title {
margin: 0;
font-size: 24px;
font-weight: 800;
color: #333;
margin-bottom: 8px;
letter-spacing: 2px;
}
.steps-timeline { display: flex; flex-direction: column; }
.step-item {
display: flex;
align-items: flex-start;
gap: 12px;
.hero-sub {
margin: 6px 0 0;
font-size: 13px;
opacity: 0.92;
font-weight: 500;
}
/* ---------- 通用卡片 ---------- */
.card {
background: #fff;
border-radius: 16px;
padding: 18px;
border: 1px solid rgba(99, 102, 241, 0.06);
box-shadow: 0 2px 12px rgba(99, 102, 241, 0.05);
}
.card-title {
margin: 0 0 14px;
font-size: 15px;
font-weight: 700;
color: $text-strong;
}
/* ---------- 创作流程 ---------- */
.steps { display: flex; flex-direction: column; }
.step { display: flex; gap: 12px; }
.step-left {
display: flex;
flex-direction: column;
align-items: center;
width: 32px;
width: 28px;
flex-shrink: 0;
}
.step-num {
width: 32px;
height: 32px;
width: 28px;
height: 28px;
border-radius: 50%;
background: $gradient;
color: #fff;
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 13px;
font-weight: 800;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
box-shadow: 0 4px 10px rgba(99, 102, 241, 0.25);
}
.step-line {
width: 3px;
height: 12px;
border-radius: 2px;
margin: 3px 0;
flex: 1;
width: 2px;
min-height: 14px;
background: linear-gradient(180deg, rgba(99, 102, 241, 0.35), rgba(236, 72, 153, 0.18));
margin: 4px 0;
}
.step-right { padding-top: 4px; }
.step-right {
flex: 1;
padding-bottom: 14px;
}
.step:last-child .step-right { padding-bottom: 0; }
.step-head {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.step-emoji { font-size: 18px; }
.step-title { font-size: 14px; font-weight: 800; color: #333; }
.step-desc { font-size: 11px; color: #999; margin-top: 1px; padding-left: 24px; }
/* 特色标签 */
.features-row {
display: flex;
gap: 6px;
justify-content: center;
margin-bottom: 12px;
flex-wrap: nowrap;
}
.feature-tag {
background: rgba(255,255,255,0.85);
border: 1.5px solid #FFE4D0;
border-radius: 20px;
padding: 4px 10px;
font-size: 11px;
.step-icon { color: $primary; font-size: 15px; }
.step-title { font-size: 14px; font-weight: 700; color: $text-strong; }
.step-tag {
font-size: 10px;
font-weight: 600;
color: var(--ai-primary, #FF6B35);
white-space: nowrap;
color: $primary;
background: rgba(99, 102, 241, 0.1);
border-radius: 8px;
padding: 1px 6px;
letter-spacing: 0.5px;
}
.step-desc {
font-size: 12px;
color: $text-muted;
margin-top: 2px;
}
/* 亮点描述 */
.highlights {
background: rgba(255,255,255,0.88);
border-radius: 16px;
padding: 12px 14px;
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
/* ---------- 浮动 CTA ---------- */
.cta-fab {
position: fixed;
left: 50%;
transform: translateX(-50%);
width: calc(100% - 32px);
max-width: 398px;
z-index: 50;
text-align: center;
pointer-events: none; //
// tabbar
bottom: calc(64px + env(safe-area-inset-bottom));
// tabbar 24px
@media (min-width: 768px) {
bottom: 24px;
}
}
.hl-item {
.cta-btn {
pointer-events: auto;
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
&:not(:last-child) { border-bottom: 1px dashed #FFE4D0; }
}
.hl-icon { font-size: 20px; flex-shrink: 0; }
.hl-text {
font-size: 13px;
color: #555;
font-weight: 500;
line-height: 1.4;
}
/* ---- 底部固定区 ---- */
.bottom-area {
flex-shrink: 0;
padding: 12px 16px 8px;
}
.start-btn {
display: flex !important;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 16px 0 !important;
font-size: 18px !important;
border-radius: 28px !important;
background: linear-gradient(135deg, #FF8C42, #FF6B35, #FF5722) !important;
box-shadow: 0 6px 24px rgba(255,107,53,0.35) !important;
letter-spacing: 2px;
padding: 14px 0;
border: none;
border-radius: 28px;
background: $gradient;
color: #fff;
font-size: 16px;
font-weight: 700;
letter-spacing: 2px;
cursor: pointer;
&:active { transform: scale(0.98); opacity: 0.9; }
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.38);
transition: all 0.2s;
:deep(.anticon) { font-size: 18px; }
&:hover {
transform: translateY(-1px);
box-shadow: 0 10px 28px rgba(99, 102, 241, 0.44);
}
&:active { transform: scale(0.98); opacity: 0.95; }
&--disabled {
background: #e5e7eb;
color: #9ca3af;
box-shadow: none;
cursor: not-allowed;
&:hover { transform: none; box-shadow: none; }
}
}
.btn-icon { font-size: 20px; }
.slogan {
text-align: center;
pointer-events: auto;
margin: 8px 0 0;
font-size: 12px;
color: var(--ai-primary, #FF6B35);
color: $primary;
font-weight: 600;
margin-top: 8px;
letter-spacing: 1px;
text-shadow: 0 1px 4px rgba(248, 247, 252, 0.9);
}
</style>

View File

@ -65,18 +65,28 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { PlusOutlined, PictureOutlined } from '@ant-design/icons-vue'
import { publicUserWorksApi, type UserWork } from '@/api/public'
import dayjs from 'dayjs'
const route = useRoute()
const router = useRouter()
const works = ref<UserWork[]>([])
const loading = ref(true)
const currentPage = ref(1)
const pageSize = 12
const total = ref(0)
const activeTab = ref('')
// tab key query
const VALID_TABS = ['', 'draft', 'pending_review', 'published', 'rejected']
const initialTab = typeof route.query.tab === 'string' && VALID_TABS.includes(route.query.tab)
? route.query.tab
: ''
const activeTab = ref(initialTab)
const tabs = [
{ key: '', label: '全部' },
@ -116,9 +126,24 @@ const fetchWorks = async () => {
const switchTab = (key: string) => {
activeTab.value = key
currentPage.value = 1
// URL tab
router.replace({ query: { ...route.query, tab: key || undefined } })
fetchWorks()
}
// query.tab EditInfoView
watch(
() => route.query.tab,
(newTab) => {
const t = typeof newTab === 'string' && VALID_TABS.includes(newTab) ? newTab : ''
if (t !== activeTab.value) {
activeTab.value = t
currentPage.value = 1
fetchWorks()
}
}
)
onMounted(fetchWorks)
</script>