feat: 公众端多项功能增强——短信登录、作品状态优化、创作流程组件 keep-alive

后端:
- 新增手机号验证码登录接口及 PublicSmsLoginDto
- LeaiSync 作品同步状态阈值从 CATALOGED 调整为 DUBBED
- UgcWork 实体字段微调、数据库迁移脚本修正

前端:
- Login 页面支持用户名/手机号双模式登录
- public.ts 新增 loginBySms、sendSmsCode API
- AI 创作流程全部视图添加 keep-alive 组件名导出
- CreatingView 生成逻辑优化
- WelcomeView 欢迎页增强
- BookReaderView、作品库等页面细节修复

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
En 2026-04-10 11:19:42 +08:00
parent 387ee5ccfb
commit 8995e2f2e2
21 changed files with 344 additions and 76 deletions

View File

@ -109,8 +109,8 @@ public class LeaiSyncService implements ILeaiSyncService {
work.setTitle(LeaiUtil.toString(remoteData.get("title"), "未命名作品"));
int leaiStatus = LeaiUtil.toInt(remoteData.get("status"), LeaiCreationStatus.PENDING);
work.setLeaiStatus(leaiStatus);
// 本地发布状态创作进度 >= CATALOGED 时自动设为 unpublished否则为 draft
work.setStatus(leaiStatus >= LeaiCreationStatus.CATALOGED
// 本地发布状态创作进度 >= DUBBED 时自动设为 unpublished否则为 draft
work.setStatus(leaiStatus >= LeaiCreationStatus.DUBBED
? WorkPublishStatus.UNPUBLISHED.getValue()
: WorkPublishStatus.DRAFT.getValue());
work.setVisibility(Visibility.PRIVATE.getValue());
@ -228,8 +228,8 @@ public class LeaiSyncService implements ILeaiSyncService {
.lt(UgcWork::getLeaiStatus, remoteStatus)
.set(UgcWork::getLeaiStatus, remoteStatus);
// leaiStatus 推进到 CATALOGED 且当前 status 仍为 draft 自动设 status unpublished
if (remoteStatus >= LeaiCreationStatus.CATALOGED
// leaiStatus 推进到 DUBBED 且当前 status 仍为 draft 自动设 status unpublished
if (remoteStatus >= LeaiCreationStatus.DUBBED
&& WorkPublishStatus.DRAFT.getValue().equals(work.getStatus())) {
wrapper.set(UgcWork::getStatus, WorkPublishStatus.UNPUBLISHED.getValue());
}

View File

@ -5,6 +5,7 @@ import com.competition.common.result.Result;
import com.competition.common.util.SecurityUtil;
import com.competition.modules.pub.dto.PublicLoginDto;
import com.competition.modules.pub.dto.PublicRegisterDto;
import com.competition.modules.pub.dto.PublicSmsLoginDto;
import com.competition.modules.pub.dto.SendSmsCodeDto;
import com.competition.modules.pub.service.PublicAuthService;
import com.competition.modules.pub.service.SmsCodeService;
@ -42,6 +43,14 @@ public class PublicAuthController {
return Result.success(publicAuthService.login(dto));
}
@Public
@PostMapping("/login/sms")
@RateLimit(permits = 5, duration = 1)
@Operation(summary = "手机号验证码登录")
public Result<Map<String, Object>> loginBySms(@Valid @RequestBody PublicSmsLoginDto dto) {
return Result.success(publicAuthService.loginBySms(dto));
}
@Public
@PostMapping("/sms/send")
@RateLimit(permits = 1, duration = 60)

View File

@ -0,0 +1,22 @@
package com.competition.modules.pub.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
@Schema(description = "手机号验证码登录请求")
public class PublicSmsLoginDto {
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
@Schema(description = "手机号")
private String phone;
@NotBlank(message = "短信验证码不能为空")
@Size(min = 6, max = 6, message = "验证码为6位数字")
@Schema(description = "短信验证码")
private String smsCode;
}

View File

@ -9,6 +9,7 @@ import com.competition.common.enums.UserSource;
import com.competition.common.enums.UserType;
import com.competition.modules.pub.dto.PublicLoginDto;
import com.competition.modules.pub.dto.PublicRegisterDto;
import com.competition.modules.pub.dto.PublicSmsLoginDto;
import com.competition.modules.sys.entity.SysRole;
import com.competition.modules.sys.entity.SysTenant;
import com.competition.modules.sys.entity.SysUser;
@ -160,6 +161,49 @@ public class PublicAuthService {
return buildAuthResult(token, user, roles);
}
/**
* 手机号验证码登录
*/
public Map<String, Object> loginBySms(PublicSmsLoginDto dto) {
// 校验短信验证码
smsCodeService.verifyCode(dto.getPhone(), dto.getSmsCode());
// 先在公众租户下按手机号查找用户
SysTenant publicTenant = sysTenantMapper.selectOne(
new LambdaQueryWrapper<SysTenant>().eq(SysTenant::getCode, TenantConstants.CODE_PUBLIC));
SysUser user = null;
if (publicTenant != null) {
user = sysUserMapper.selectOne(
new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getPhone, dto.getPhone())
.eq(SysUser::getTenantId, publicTenant.getId()));
}
// 如未找到在所有租户中搜索
if (user == null) {
user = sysUserMapper.selectOne(
new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getPhone, dto.getPhone())
.last("LIMIT 1"));
}
if (user == null) {
throw new BusinessException(404, "该手机号未注册");
}
if (!CommonStatus.ENABLED.getValue().equals(user.getStatus())) {
throw new BusinessException(403, "账号已被禁用");
}
// 获取角色
List<String> roles = getUserRoleCodes(user.getId());
String token = jwtUtil.generateToken(user.getId(), user.getUsername(), user.getTenantId());
log.info("手机号验证码登录成功userId={}, phone={}", user.getId(), dto.getPhone());
return buildAuthResult(token, user, roles);
}
/**
* 切换到子女账号
*/

View File

@ -45,7 +45,7 @@ public class UgcWork implements Serializable {
@Schema(description = "本地发布状态: draft/unpublished/pending_review/published/rejected")
private String status;
@Schema(description = "乐读派创作进度: -1=FAILED, 0=DRAFT, 1=PENDING, 2=PROCESSING, 3=COMPLETED, 4=CATALOGED, 5=DUBBED")
@Schema(description = "乐读派创作进度: -1=FAILED, 0=INIT, 1=PENDING, 2=PROCESSING, 3=COMPLETED, 4=CATALOGED, 5=DUBBED")
@TableField("leai_status")
private Integer leaiStatus;

View File

@ -4,7 +4,7 @@
-- 1. 新增 leai_status 字段
ALTER TABLE t_ugc_work ADD COLUMN leai_status INT NOT NULL DEFAULT 0
COMMENT '乐读派创作进度: -1=FAILED, 0=DRAFT, 1=PENDING, 2=PROCESSING, 3=COMPLETED, 4=CATALOGED, 5=DUBBED';
COMMENT '乐读派创作进度: -1=FAILED, 0=INIT, 1=PENDING, 2=PROCESSING, 3=COMPLETED, 4=CATALOGED, 5=DUBBED';
-- 2. 把现有 status (INT) 值复制到 leai_status
UPDATE t_ugc_work SET leai_status = status;

View File

@ -18,7 +18,7 @@ UPDATE t_ugc_work SET status = '-2' WHERE status = 'taken_down';
UPDATE t_ugc_work SET status = '0' WHERE status NOT REGEXP '^-?[0-9]+$';
ALTER TABLE t_ugc_work MODIFY COLUMN status INT NOT NULL DEFAULT 0
COMMENT '创作状态:-1=FAILED, 0=DRAFT, 1=PENDING, 2=PROCESSING, 3=COMPLETED, 4=CATALOGED, 5=DUBBED';
COMMENT '创作状态:-1=FAILED, 0=INIT, 1=PENDING, 2=PROCESSING, 3=COMPLETED, 4=CATALOGED, 5=DUBBED';
-- 2. 新增乐读派关联字段
ALTER TABLE t_ugc_work ADD COLUMN remote_work_id VARCHAR(64) DEFAULT NULL COMMENT '乐读派远程作品ID' AFTER user_id;

View File

@ -46,8 +46,8 @@ draft / pending_review / published / rejected
引入新的中间状态 **`unpublished`(未发布)**,明确语义边界:
- **草稿draft** = 作品技术上还没完成(生成失败 / 还在生成 / 没保存编目
- **未发布unpublished** = 作品技术上完整(编目已保存),但用户没主动公开
- **草稿draft** = 作品技术上还没完成(生成失败 / 还在生成 / 没完成配音
- **未发布unpublished** = 作品技术上完整(配音已完成),但用户没主动公开
- **审核中pending_review** = 用户主动提交审核
- **已发布published** = 审核通过,发现页可见
- **被拒绝rejected** = 审核未通过,可改后重新提交
@ -70,16 +70,16 @@ draft / pending_review / published / rejected
┌───────────────┐
│ DRAFT │ 半成品:还在生成或未保存编目
│ DRAFT │ 半成品:还在生成或未完成配音
│ 草稿 │
└───────┬───────┘
│ 用户在 EditInfoView 点
│ 保存 / 去配音 / 立即发布
│ 任意按钮 = 编目完成
│ 任意按钮 = 配音完成
┌───────────────┐
│ UNPUBLISHED │ 成品私有:已编目,可补配音,可发布
│ UNPUBLISHED │ 成品私有:已配音,可发布
│ 未发布 │◀──────────────┐
└───────┬───────┘ │
│ │
@ -111,7 +111,7 @@ draft / pending_review / published / rejected
| 起始状态 | 触发 | 目标状态 | 触发方 | 接口 |
|---|---|---|---|---|
| (无) | 创建作品 | DRAFT | 系统 | leai 创作流程内部 |
| DRAFT | 编目完成leai status → CATALOGED | UNPUBLISHED | 系统webhook 同步) | LeaiSyncService |
| DRAFT | 配音完成leai status → DUBBED | UNPUBLISHED | 系统webhook 同步) | LeaiSyncService |
| UNPUBLISHED | 用户点「公开发布」 | PENDING_REVIEW | 用户 | `POST /public/works/{id}/publish` |
| UNPUBLISHED | 用户补充配音/编辑 | UNPUBLISHED | 用户 | 更新内容接口 |
| PENDING_REVIEW | 审核通过 | PUBLISHED | 超管 | `POST /content-review/works/{id}/approve` |
@ -218,7 +218,7 @@ taken_down → -2
-- 改为:
ALTER TABLE t_ugc_work
ADD COLUMN leai_status INT NOT NULL DEFAULT 0
COMMENT 'leai 创作进度: -1=FAILED, 0=DRAFT, 1=PENDING, 2=PROCESSING, 3=COMPLETED, 4=CATALOGED, 5=DUBBED';
COMMENT 'leai 创作进度: -1=FAILED, 0=INIT, 1=PENDING, 2=PROCESSING, 3=COMPLETED, 4=CATALOGED, 5=DUBBED';
-- status 字段重定义为本地发布状态VARCHAR 跟前端对齐)
ALTER TABLE t_ugc_work
@ -285,7 +285,7 @@ CREATE INDEX idx_ugc_work_leai_status ON t_ugc_work(leai_status);
- 加 `leaiStatus: Integer`(保持 leai 进度)
- 改 `status: String`(本地发布状态,对应新枚举)
- [ ] **新建 migration `V9__split_work_status.sql`**(见 4.2
- [ ] **`LeaiSyncService.updateStatusForward`**:当 leai status 推进到 CATALOGED 时,自动把本地 status 从 draft 升到 unpublished如果当前不是更高级状态
- [ ] **`LeaiSyncService.updateStatusForward`**:当 leai status 推进到 DUBBED 时,自动把本地 status 从 draft 升到 unpublished如果当前不是更高级状态
- [ ] **`PublicUserWorkService.publish()`**:检查作品 `status == 'unpublished'`,改为 `unpublished → pending_review`;同时支持 `rejected → pending_review`
- [ ] **新增 `POST /public/works/{id}/unpublish`**:用户主动下架,`published → unpublished`
- [ ] **新增 `POST /public/works/{id}/withdraw`**:用户撤回审核,`pending_review → unpublished`

View File

@ -48,7 +48,8 @@ publicApi.interceptors.response.use(
// 检查业务状态码,非 200 视为业务错误
const resData = response.data
if (resData && resData.code !== undefined && resData.code !== 200) {
const error: any = new Error(resData.message || "请求失败")
// 兼容后端 Result.message 和乐读派原始响应的 msg 字段
const error: any = new Error(resData.message || resData.msg || "请求失败")
error.response = { data: resData }
return Promise.reject(error)
}
@ -86,6 +87,11 @@ export interface PublicLoginParams {
password: string
}
export interface PublicSmsLoginParams {
phone: string
smsCode: string
}
export interface PublicUser {
id: number
username: string
@ -116,6 +122,10 @@ export const publicAuthApi = {
login: (data: PublicLoginParams): Promise<LoginResponse> =>
publicApi.post("/public/auth/login", data),
/** 手机号验证码登录 */
loginBySms: (data: PublicSmsLoginParams): Promise<LoginResponse> =>
publicApi.post("/public/auth/login/sms", data),
/** 发送短信验证码 */
sendSmsCode: (phone: string): Promise<void> =>
publicApi.post("/public/auth/sms/send", { phone }),
@ -395,8 +405,8 @@ export const publicInteractionApi = {
* published unpublished
* rejected pending_review
*
* - draft 稿 / /
* - unpublished
* - draft 稿 / /
* - unpublished
* - pending_review
* - published
* - rejected

View File

@ -9,12 +9,12 @@
<a-form
:model="form"
:rules="rules"
:rules="currentRules"
@finish="handleSubmit"
layout="vertical"
class="auth-form"
>
<a-form-item v-if="isRegister" name="nickname" label="昵称">
<a-form-item v-if="showNicknameField" name="nickname" label="昵称">
<a-input
v-model:value="form.nickname"
placeholder="给自己取个名字吧"
@ -22,7 +22,7 @@
/>
</a-form-item>
<a-form-item v-if="isRegister" name="phone" label="手机号">
<a-form-item v-if="showPhoneField" name="phone" label="手机号">
<a-input
v-model:value="form.phone"
placeholder="请输入手机号"
@ -31,7 +31,7 @@
/>
</a-form-item>
<a-form-item v-if="isRegister" name="smsCode" label="验证码">
<a-form-item v-if="showSmsCodeField" name="smsCode" label="验证码">
<div style="display: flex; gap: 8px">
<a-input
v-model:value="form.smsCode"
@ -52,7 +52,7 @@
</div>
</a-form-item>
<a-form-item name="username" label="用户名">
<a-form-item v-if="showUsernameField" name="username" label="用户名">
<a-input
v-model:value="form.username"
placeholder="请输入用户名"
@ -60,7 +60,7 @@
/>
</a-form-item>
<a-form-item name="password" label="密码">
<a-form-item v-if="showPasswordField" name="password" label="密码">
<a-input-password
v-model:value="form.password"
placeholder="请输入密码"
@ -90,14 +90,20 @@
</a-form-item>
</a-form>
<div v-if="!isRegister" class="login-method-switch">
<a @click="toggleLoginMethod">
{{ loginMethod === 'password' ? '使用手机号登录' : '使用账号密码登录' }}
</a>
</div>
<div class="auth-switch">
<template v-if="isRegister">
已有账号
<a @click="isRegister = false">去登录</a>
<a @click="switchToLogin">去登录</a>
</template>
<template v-else>
还没有账号
<a @click="isRegister = true">立即注册</a>
<a @click="switchToRegister">立即注册</a>
</template>
</div>
@ -124,10 +130,11 @@ const router = useRouter()
const route = useRoute()
const loading = ref(false)
const isRegister = ref(false)
const loginMethod = ref<'password' | 'sms'>('password')
const form = reactive({
username: "demo",
password: "demo123456",
username: import.meta.env.DEV ? "demo" : "",
password: import.meta.env.DEV ? "demo123456" : "",
confirmPassword: "",
nickname: "",
phone: import.meta.env.DEV ? "13800138000" : "",
@ -141,35 +148,15 @@ let smsTimer: ReturnType<typeof setInterval> | null = null
const isPhoneValid = computed(() => /^1[3-9]\d{9}$/.test(form.phone))
const handleSendSms = async () => {
if (!isPhoneValid.value || smsCountdown.value > 0) return
smsSending.value = true
try {
await publicAuthApi.sendSmsCode(form.phone)
message.success("验证码已发送")
// 60
smsCountdown.value = 60
smsTimer = setInterval(() => {
smsCountdown.value--
if (smsCountdown.value <= 0) {
if (smsTimer) clearInterval(smsTimer)
smsTimer = null
}
}, 1000)
} catch (error: any) {
const msg = error?.response?.data?.message || "验证码发送失败"
message.error(msg)
} finally {
smsSending.value = false
}
}
// computed
const showNicknameField = computed(() => isRegister.value)
const showPhoneField = computed(() => isRegister.value || (!isRegister.value && loginMethod.value === 'sms'))
const showSmsCodeField = computed(() => isRegister.value || (!isRegister.value && loginMethod.value === 'sms'))
const showUsernameField = computed(() => !isRegister.value && loginMethod.value === 'password')
const showPasswordField = computed(() => !isRegister.value && loginMethod.value === 'password')
//
onUnmounted(() => {
if (smsTimer) clearInterval(smsTimer)
})
const rules: Record<string, Rule[]> = {
//
const baseRules: Record<string, Rule[]> = {
username: [
{ required: true, message: "请输入用户名", trigger: "blur" },
{ min: 4, message: "用户名至少4个字符", trigger: "blur" },
@ -205,6 +192,71 @@ const rules: Record<string, Rule[]> = {
],
}
//
const currentRules = computed(() => {
const rules: Record<string, Rule[]> = {}
if (isRegister.value) {
//
rules.nickname = baseRules.nickname
rules.phone = baseRules.phone
rules.smsCode = baseRules.smsCode
rules.username = baseRules.username
rules.password = baseRules.password
rules.confirmPassword = baseRules.confirmPassword
} else if (loginMethod.value === 'password') {
//
rules.username = baseRules.username
rules.password = baseRules.password
} else {
//
rules.phone = baseRules.phone
rules.smsCode = baseRules.smsCode
}
return rules
})
const handleSendSms = async () => {
if (!isPhoneValid.value || smsCountdown.value > 0) return
smsSending.value = true
try {
await publicAuthApi.sendSmsCode(form.phone)
message.success("验证码已发送")
// 60
smsCountdown.value = 60
smsTimer = setInterval(() => {
smsCountdown.value--
if (smsCountdown.value <= 0) {
if (smsTimer) clearInterval(smsTimer)
smsTimer = null
}
}, 1000)
} catch (error: any) {
const msg = error?.response?.data?.message || "验证码发送失败"
message.error(msg)
} finally {
smsSending.value = false
}
}
//
onUnmounted(() => {
if (smsTimer) clearInterval(smsTimer)
})
const toggleLoginMethod = () => {
loginMethod.value = loginMethod.value === 'password' ? 'sms' : 'password'
}
const switchToRegister = () => {
isRegister.value = true
loginMethod.value = 'password'
}
const switchToLogin = () => {
isRegister.value = false
loginMethod.value = 'password'
}
const handleSubmit = async () => {
loading.value = true
try {
@ -218,6 +270,12 @@ const handleSubmit = async () => {
smsCode: form.smsCode,
})
message.success("注册成功")
} else if (loginMethod.value === 'sms') {
result = await publicAuthApi.loginBySms({
phone: form.phone,
smsCode: form.smsCode,
})
message.success("登录成功")
} else {
result = await publicAuthApi.login({
username: form.username,
@ -349,8 +407,24 @@ $primary: #6366f1;
}
}
.login-method-switch {
margin-top: 16px;
font-size: 13px;
color: #9ca3af;
a {
color: $primary;
font-weight: 600;
cursor: pointer;
&:hover {
color: #ec4899;
}
}
}
.auth-switch {
margin-top: 20px;
margin-top: 12px;
font-size: 13px;
color: #9ca3af;

View File

@ -1,3 +1,6 @@
<script lang="ts">
export default { name: 'BookReaderView' }
</script>
<template>
<div class="reader-page page-fullscreen" @touchstart="onTouchStart" @touchend="onTouchEnd">
<!-- 顶栏 -->

View File

@ -1,3 +1,6 @@
<script lang="ts">
export default { name: 'CharactersView' }
</script>
<template>
<div class="char-page page-fullscreen">
<PageHeader title="画作角色" :subtitle="subtitle" :step="1" />

View File

@ -1,3 +1,6 @@
<script lang="ts">
export default { name: 'CreatingView' }
</script>
<template>
<div class="creating-page">
<!-- 开发模式状态切换 -->
@ -63,7 +66,7 @@
<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 v-if="!isQuotaError" class="btn-primary error-btn" :class="{ 'btn-outline': !!store.workId }" @click="retry">
重新创作
</button>
</div>
@ -91,7 +94,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Client } from '@stomp/stompjs'
import {
@ -112,6 +115,11 @@ const progress = ref(0)
const stage = ref('准备中…')
const dots = ref('')
const error = ref('')
/** 额度/次数限制类错误,不应显示"重新创作"按钮 */
const isQuotaError = computed(() => {
const msg = error.value
return msg.includes('次数已达上限') || msg.includes('额度不足')
})
const networkWarn = ref(false)
const currentTipIdx = ref(0)
const creatingTips = [
@ -137,6 +145,8 @@ const MAX_POLL_ERRORS = 15
//
function sanitizeError(msg: string | undefined): string {
if (!msg) return '创作遇到问题,请重新尝试'
if (msg.includes('调用次数已达上限') || msg.includes('30004')) return '今日创作次数已达上限,请明天再试'
if (msg.includes('额度') || msg.includes('30003')) return '创作额度不足,请联系管理员'
if (msg.includes('400') || msg.includes('status code')) return '创作请求异常,请返回重新操作'
if (msg.includes('火山引擎') || msg.includes('VolcEngine')) return 'AI 服务暂时繁忙,请稍后重试'
if (msg.includes('额度')) return msg

View File

@ -1,3 +1,6 @@
<script lang="ts">
export default { name: 'DubbingView' }
</script>
<template>
<div class="dubbing-page page-fullscreen">
<PageHeader title="绘本配音" subtitle="为每一页添加 AI 配音或亲自录音" :showBack="true" />

View File

@ -1,3 +1,6 @@
<script>
export default { name: 'EditInfoView' }
</script>
<template>
<div class="edit-page page-fullscreen">
<PageHeader title="编辑绘本信息" subtitle="完善信息,作品将进入「未发布」可随时发布" :showBack="true" />

View File

@ -1,3 +1,6 @@
<script lang="ts">
export default { name: 'PreviewView' }
</script>
<template>
<div class="preview-page page-fullscreen">
<!-- 顶部 -->

View File

@ -1,3 +1,6 @@
<script lang="ts">
export default { name: 'SaveSuccessView' }
</script>
<template>
<div class="success-page">
<!-- 撒花装饰 -->

View File

@ -1,3 +1,6 @@
<script lang="ts">
export default { name: 'StoryInputView' }
</script>
<template>
<div class="story-page page-fullscreen">
<PageHeader title="编写故事" subtitle="告诉 AI 你想要什么样的故事" :step="2" />

View File

@ -1,3 +1,6 @@
<script lang="ts">
export default { name: 'UploadView' }
</script>
<template>
<div class="upload-page page-fullscreen">
<PageHeader title="上传作品" subtitle="上传你的画作AI 自动识别角色" :step="0" />

View File

@ -1,3 +1,6 @@
<script lang="ts">
export default { name: 'WelcomeView' }
</script>
<template>
<div class="welcome-page">
<!-- Hero -->
@ -18,18 +21,95 @@
<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">
<div class="step-left">
<div class="step-num">{{ i + 1 }}</div>
<div v-if="i < steps.length - 1" class="step-line" />
<div class="step-num">1</div>
<div 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>
<camera-outlined class="step-icon" />
<span class="step-title">拍照上传</span>
</div>
<div class="step-desc">{{ s.desc }}</div>
<div class="step-desc">拍下你的画作</div>
</div>
</div>
<div class="step">
<div class="step-left">
<div class="step-num">2</div>
<div class="step-line" />
</div>
<div class="step-right">
<div class="step-head">
<smile-outlined class="step-icon" />
<span class="step-title">角色提取</span>
</div>
<div class="step-desc">AI 智能识别画中角色</div>
</div>
</div>
<div class="step">
<div class="step-left">
<div class="step-num">3</div>
<div class="step-line" />
</div>
<div class="step-right">
<div class="step-head">
<edit-outlined class="step-icon" />
<span class="step-title">编排故事</span>
</div>
<div class="step-desc">起书名定主角填故事要素</div>
</div>
</div>
<div class="step">
<div class="step-left">
<div class="step-num">4</div>
<div class="step-line" />
</div>
<div class="step-right">
<div class="step-head">
<bg-colors-outlined class="step-icon" />
<span class="step-title">选择画风</span>
</div>
<div class="step-desc">水墨3D 等多种风格</div>
</div>
</div>
<div class="step">
<div class="step-left">
<div class="step-num">5</div>
<div class="step-line" />
</div>
<div class="step-right">
<div class="step-head">
<thunderbolt-outlined class="step-icon" />
<span class="step-title">AI 生成</span>
</div>
<div class="step-desc">一键生成完整绘本</div>
</div>
</div>
<div class="step">
<div class="step-left">
<div class="step-num">6</div>
<div class="step-line" />
</div>
<div class="step-right">
<div class="step-head">
<eye-outlined class="step-icon" />
<span class="step-title">预览编目</span>
</div>
<div class="step-desc">浏览成果补充作者署名</div>
</div>
</div>
<div class="step">
<div class="step-left">
<div class="step-num">7</div>
</div>
<div class="step-right">
<div class="step-head">
<sound-outlined class="step-icon" />
<span class="step-title">配音发布</span>
<span class="step-tag">可选</span>
</div>
<div class="step-desc">AI 配音或亲自录音</div>
</div>
</div>
</div>
@ -66,16 +146,6 @@ import { useAicreateStore } from '@/stores/aicreate'
const router = useRouter()
const store = useAicreateStore()
const steps = [
{ 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()

View File

@ -105,8 +105,10 @@ import {
EditOutlined,
} from "@ant-design/icons-vue"
import { publicProfileApi, publicMineApi } from "@/api/public"
import { useAicreateStore } from "@/stores/aicreate"
const router = useRouter()
const aicreateStore = useAicreateStore()
const user = ref<any>(null)
const showEditModal = ref(false)
const editLoading = ref(false)
@ -191,6 +193,9 @@ const handleLogout = () => {
localStorage.removeItem("public_user")
localStorage.removeItem("parent_token_backup")
localStorage.removeItem("parent_user_backup")
//
aicreateStore.reset()
aicreateStore.clearSession()
router.push("/p/login")
}