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:
parent
387ee5ccfb
commit
8995e2f2e2
@ -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());
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换到子女账号
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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 被拒绝:审核未通过
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
<script lang="ts">
|
||||
export default { name: 'BookReaderView' }
|
||||
</script>
|
||||
<template>
|
||||
<div class="reader-page page-fullscreen" @touchstart="onTouchStart" @touchend="onTouchEnd">
|
||||
<!-- 顶栏 -->
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
<script>
|
||||
export default { name: 'EditInfoView' }
|
||||
</script>
|
||||
<template>
|
||||
<div class="edit-page page-fullscreen">
|
||||
<PageHeader title="编辑绘本信息" subtitle="完善信息,作品将进入「未发布」可随时发布" :showBack="true" />
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
<script lang="ts">
|
||||
export default { name: 'PreviewView' }
|
||||
</script>
|
||||
<template>
|
||||
<div class="preview-page page-fullscreen">
|
||||
<!-- 顶部 -->
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
<script lang="ts">
|
||||
export default { name: 'SaveSuccessView' }
|
||||
</script>
|
||||
<template>
|
||||
<div class="success-page">
|
||||
<!-- 撒花装饰 -->
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user