feat: 公众端绘本创作流程与作品展示优化,乐读派同步及封面回填迁移
Made-with: Cursor
This commit is contained in:
parent
430eba6bd6
commit
1862204ac5
@ -321,6 +321,16 @@ public class LeaiSyncService implements ILeaiSyncService {
|
|||||||
ugcWorkPageMapper.insert(page);
|
ugcWorkPageMapper.insert(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 列表封面与前端创作页一致:使用 pageList[0] 插画,而非远程 originalImageUrl/coverUrl 元数据
|
||||||
|
String firstCover = LeaiUtil.toString(pageList.get(0).get("imageUrl"), null);
|
||||||
|
if (firstCover != null && !firstCover.isEmpty()) {
|
||||||
|
LambdaUpdateWrapper<UgcWork> uw = new LambdaUpdateWrapper<>();
|
||||||
|
uw.eq(UgcWork::getId, workId)
|
||||||
|
.set(UgcWork::getCoverUrl, firstCover)
|
||||||
|
.set(UgcWork::getModifyTime, LocalDateTime.now());
|
||||||
|
ugcWorkMapper.update(null, uw);
|
||||||
|
}
|
||||||
|
|
||||||
log.info("[乐读派] 保存作品页面数据: workId={}, 页数={}", workId, pageList.size());
|
log.info("[乐读派] 保存作品页面数据: workId={}, 页数={}", workId, pageList.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -221,6 +221,19 @@ public class PublicUserWorkService {
|
|||||||
|
|
||||||
// 插入新页面
|
// 插入新页面
|
||||||
saveWorkPages(workId, pages);
|
saveWorkPages(workId, pages);
|
||||||
|
|
||||||
|
// 与乐读派同步逻辑一致:首图作为作品库列表封面
|
||||||
|
if (pages != null && !pages.isEmpty()) {
|
||||||
|
Object img = pages.get(0).get("imageUrl");
|
||||||
|
String firstCover = img != null ? img.toString().trim() : null;
|
||||||
|
if (firstCover != null && !firstCover.isEmpty()) {
|
||||||
|
LambdaUpdateWrapper<UgcWork> uw = new LambdaUpdateWrapper<>();
|
||||||
|
uw.eq(UgcWork::getId, workId)
|
||||||
|
.set(UgcWork::getCoverUrl, firstCover)
|
||||||
|
.set(UgcWork::getModifyTime, LocalDateTime.now());
|
||||||
|
ugcWorkMapper.update(null, uw);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void saveWorkPages(Long workId, List<Map<String, Object>> pages) {
|
private void saveWorkPages(Long workId, List<Map<String, Object>> pages) {
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
-- 历史数据:列表封面与创作页对齐,用首页插画(page_no=1)回填 cover_url
|
||||||
|
UPDATE t_ugc_work w
|
||||||
|
INNER JOIN t_ugc_work_page p ON p.work_id = w.id AND p.page_no = 1
|
||||||
|
SET w.cover_url = p.image_url,
|
||||||
|
w.modify_time = NOW()
|
||||||
|
WHERE w.is_deleted = 0
|
||||||
|
AND p.image_url IS NOT NULL
|
||||||
|
AND TRIM(p.image_url) <> '';
|
||||||
File diff suppressed because it is too large
Load Diff
@ -47,9 +47,29 @@ export function createStory(params: CreateStoryParams) {
|
|||||||
return publicApi.post('/leai-proxy/create-story', body)
|
return publicApi.post('/leai-proxy/create-story', body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 乐读派 B2 作品详情:响应体常为 { code, data: Work },经 public 拦截器可能已剥一层,
|
||||||
|
* 仍可能出现嵌套 data,统一解包为 Work 对象(与 CreatingView 原 detail.data 语义一致)。
|
||||||
|
*/
|
||||||
|
export function unwrapLeaiWorkDetail(raw: unknown): any {
|
||||||
|
let cur: any = raw
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
if (!cur || typeof cur !== 'object') return cur
|
||||||
|
if (cur.workId != null || Array.isArray(cur.pageList)) return cur
|
||||||
|
if (cur.data != null && typeof cur.data === 'object') {
|
||||||
|
cur = cur.data
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return cur
|
||||||
|
}
|
||||||
|
|
||||||
/** 查询作品详情 */
|
/** 查询作品详情 */
|
||||||
export function getWorkDetail(workId: string) {
|
export function getWorkDetail(workId: string) {
|
||||||
return publicApi.get(`/leai-proxy/work/${workId}`)
|
return publicApi
|
||||||
|
.get(`/leai-proxy/work/${workId}`)
|
||||||
|
.then(unwrapLeaiWorkDetail)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 额度校验 */
|
/** 额度校验 */
|
||||||
|
|||||||
@ -47,7 +47,13 @@ publicApi.interceptors.response.use(
|
|||||||
// 后端返回格式:{ code: 200, message: "success", data: xxx }
|
// 后端返回格式:{ code: 200, message: "success", data: xxx }
|
||||||
// 检查业务状态码,非 200 视为业务错误
|
// 检查业务状态码,非 200 视为业务错误
|
||||||
const resData = response.data;
|
const resData = response.data;
|
||||||
if (resData && resData.code !== undefined && resData.code !== 200) {
|
// 后端统一 Result 为 200;乐读派 B2/B3 等原始体常用 0 表示成功(见 lesingle-aicreate-client)
|
||||||
|
if (
|
||||||
|
resData &&
|
||||||
|
resData.code !== undefined &&
|
||||||
|
resData.code !== 200 &&
|
||||||
|
resData.code !== 0
|
||||||
|
) {
|
||||||
// 兼容后端 Result.message 和乐读派原始响应的 msg 字段
|
// 兼容后端 Result.message 和乐读派原始响应的 msg 字段
|
||||||
const error: any = new Error(
|
const error: any = new Error(
|
||||||
resData.message || resData.msg || "请求失败",
|
resData.message || resData.msg || "请求失败",
|
||||||
@ -432,6 +438,8 @@ export type WorkStatus =
|
|||||||
export interface UserWork {
|
export interface UserWork {
|
||||||
id: number;
|
id: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
|
/** 乐读派 remote work id,与创作路由参数一致 */
|
||||||
|
remoteWorkId?: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
coverUrl: string | null;
|
coverUrl: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
|||||||
@ -4,81 +4,81 @@
|
|||||||
* 敏感信息(phone/orgId/appSecret)不再存储在 localStorage
|
* 敏感信息(phone/orgId/appSecret)不再存储在 localStorage
|
||||||
* orgId 仅存 sessionStorage(会话级),sessionToken 判断是否已初始化
|
* orgId 仅存 sessionStorage(会话级),sessionToken 判断是否已初始化
|
||||||
*/
|
*/
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from "pinia";
|
||||||
import { ref } from 'vue'
|
import { ref } from "vue";
|
||||||
import { clearExtractDraft } from '@/utils/aicreate/extractDraft'
|
import { clearExtractDraft } from "@/utils/aicreate/extractDraft";
|
||||||
|
|
||||||
export const useAicreateStore = defineStore('aicreate', () => {
|
export const useAicreateStore = defineStore("aicreate", () => {
|
||||||
// ─── 认证信息(不再存储敏感信息到 localStorage) ───
|
// ─── 认证信息(不再存储敏感信息到 localStorage) ───
|
||||||
const orgId = ref(sessionStorage.getItem('le_orgId') || '')
|
const orgId = ref(sessionStorage.getItem("le_orgId") || "");
|
||||||
const sessionToken = ref(sessionStorage.getItem('le_sessionToken') || '')
|
const sessionToken = ref(sessionStorage.getItem("le_sessionToken") || "");
|
||||||
|
|
||||||
// ─── 创作流程数据 ───
|
// ─── 创作流程数据 ───
|
||||||
const imageUrl = ref('')
|
const imageUrl = ref("");
|
||||||
const extractId = ref('')
|
const extractId = ref("");
|
||||||
const characters = ref<any[]>([])
|
const characters = ref<any[]>([]);
|
||||||
const selectedCharacter = ref<any>(null)
|
const selectedCharacter = ref<any>(null);
|
||||||
const selectedStyle = ref('')
|
const selectedStyle = ref("");
|
||||||
const storyData = ref<any>(null)
|
const storyData = ref<any>(null);
|
||||||
const workId = ref('')
|
const workId = ref("");
|
||||||
/** extract 接口可能返回的 workId,供下游使用 */
|
/** extract 接口可能返回的 workId,供下游使用 */
|
||||||
const originalWorkId = ref('')
|
const originalWorkId = ref("");
|
||||||
const workDetail = ref<any>(null)
|
const workDetail = ref<any>(null);
|
||||||
|
|
||||||
// ─── Tab 切换状态保存 ───
|
// ─── Tab 切换状态保存 ───
|
||||||
const lastCreateRoute = ref('')
|
const lastCreateRoute = ref("");
|
||||||
|
|
||||||
// ─── 方法 ───
|
// ─── 方法 ───
|
||||||
function setSession(id: string, token: string) {
|
function setSession(id: string, token: string) {
|
||||||
orgId.value = id
|
orgId.value = id;
|
||||||
sessionToken.value = token
|
sessionToken.value = token;
|
||||||
sessionStorage.setItem('le_orgId', id)
|
sessionStorage.setItem("le_orgId", id);
|
||||||
sessionStorage.setItem('le_sessionToken', token)
|
sessionStorage.setItem("le_sessionToken", token);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSession() {
|
function clearSession() {
|
||||||
sessionToken.value = ''
|
sessionToken.value = "";
|
||||||
orgId.value = ''
|
orgId.value = "";
|
||||||
sessionStorage.removeItem('le_sessionToken')
|
sessionStorage.removeItem("le_sessionToken");
|
||||||
sessionStorage.removeItem('le_orgId')
|
sessionStorage.removeItem("le_orgId");
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLastCreateRoute(path: string) {
|
function setLastCreateRoute(path: string) {
|
||||||
lastCreateRoute.value = path
|
lastCreateRoute.value = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearLastCreateRoute() {
|
function clearLastCreateRoute() {
|
||||||
lastCreateRoute.value = ''
|
lastCreateRoute.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
imageUrl.value = ''
|
imageUrl.value = "";
|
||||||
extractId.value = ''
|
extractId.value = "";
|
||||||
characters.value = []
|
characters.value = [];
|
||||||
selectedCharacter.value = null
|
selectedCharacter.value = null;
|
||||||
selectedStyle.value = ''
|
selectedStyle.value = "";
|
||||||
storyData.value = null
|
storyData.value = null;
|
||||||
workId.value = ''
|
workId.value = "";
|
||||||
originalWorkId.value = ''
|
originalWorkId.value = "";
|
||||||
workDetail.value = null
|
workDetail.value = null;
|
||||||
lastCreateRoute.value = ''
|
lastCreateRoute.value = "";
|
||||||
// 只清除创作流程数据,保留认证信息
|
// 只清除创作流程数据,保留认证信息
|
||||||
localStorage.removeItem('le_workId')
|
localStorage.removeItem("le_workId");
|
||||||
// 清除 sessionStorage 中的恢复数据
|
// 清除 sessionStorage 中的恢复数据
|
||||||
sessionStorage.removeItem('le_recovery')
|
sessionStorage.removeItem("le_recovery");
|
||||||
clearExtractDraft()
|
clearExtractDraft();
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveRecoveryState() {
|
function saveRecoveryState() {
|
||||||
const recovery = {
|
const recovery = {
|
||||||
path: window.location.pathname || '/',
|
path: window.location.pathname || "/",
|
||||||
workId: workId.value || localStorage.getItem('le_workId') || '',
|
workId: workId.value || localStorage.getItem("le_workId") || "",
|
||||||
imageUrl: imageUrl.value || '',
|
imageUrl: imageUrl.value || "",
|
||||||
extractId: extractId.value || '',
|
extractId: extractId.value || "",
|
||||||
selectedStyle: selectedStyle.value || '',
|
selectedStyle: selectedStyle.value || "",
|
||||||
savedAt: Date.now()
|
savedAt: Date.now(),
|
||||||
}
|
};
|
||||||
sessionStorage.setItem('le_recovery', JSON.stringify(recovery))
|
sessionStorage.setItem("le_recovery", JSON.stringify(recovery));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -89,28 +89,29 @@ export const useAicreateStore = defineStore('aicreate', () => {
|
|||||||
function fillMockData(count: number = 3) {
|
function fillMockData(count: number = 3) {
|
||||||
// 纯渐变占位图(不带文字,模拟真实 AI 抠图返回的角色形象)
|
// 纯渐变占位图(不带文字,模拟真实 AI 抠图返回的角色形象)
|
||||||
const mockSvg = (hue: number) =>
|
const mockSvg = (hue: number) =>
|
||||||
'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(
|
"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">` +
|
`<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">` +
|
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
|
||||||
`<stop offset="0" stop-color="hsl(${hue},75%,72%)"/>` +
|
`<stop offset="0" stop-color="hsl(${hue},75%,72%)"/>` +
|
||||||
`<stop offset="1" stop-color="hsl(${(hue + 35) % 360},80%,58%)"/>` +
|
`<stop offset="1" stop-color="hsl(${(hue + 35) % 360},80%,58%)"/>` +
|
||||||
`</linearGradient></defs>` +
|
`</linearGradient></defs>` +
|
||||||
`<rect width="240" height="240" fill="url(#g)"/>` +
|
`<rect width="240" height="240" fill="url(#g)"/>` +
|
||||||
`</svg>`
|
`</svg>`,
|
||||||
)
|
);
|
||||||
|
|
||||||
imageUrl.value = mockSvg(250)
|
imageUrl.value = mockSvg(250);
|
||||||
extractId.value = 'mock-extract-' + Date.now()
|
extractId.value = "mock-extract-" + Date.now();
|
||||||
selectedCharacter.value = null
|
selectedCharacter.value = null;
|
||||||
|
|
||||||
// 注意:真实 AI 接口不返回 name 字段,mock 数据也不写 name,由用户在 StoryInputView 自己起名
|
// 注意:真实 AI 接口不返回 name 字段,mock 数据也不写 name,由用户在 StoryInputView 自己起名
|
||||||
const allChars = [
|
const allChars = [
|
||||||
{ charId: 'mock-c1', type: 'HERO', originalCropUrl: mockSvg(280) },
|
{ charId: "mock-c1", type: "HERO", originalCropUrl: mockSvg(280) },
|
||||||
{ charId: 'mock-c2', type: 'SIDEKICK', originalCropUrl: mockSvg(30) },
|
{ charId: "mock-c2", type: "SIDEKICK", originalCropUrl: mockSvg(30) },
|
||||||
{ charId: 'mock-c3', type: 'SIDEKICK', originalCropUrl: mockSvg(100) },
|
{ charId: "mock-c3", type: "SIDEKICK", originalCropUrl: mockSvg(100) },
|
||||||
]
|
];
|
||||||
const n = Math.max(1, Math.min(count, allChars.length))
|
const n = Math.max(1, Math.min(count, allChars.length));
|
||||||
characters.value = allChars.slice(0, n)
|
characters.value = allChars.slice(0, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -120,83 +121,97 @@ export const useAicreateStore = defineStore('aicreate', () => {
|
|||||||
function fillMockWorkDetail() {
|
function fillMockWorkDetail() {
|
||||||
// 16:9 渐变占位图(800x450),模拟真实绘本插画
|
// 16:9 渐变占位图(800x450),模拟真实绘本插画
|
||||||
const mockPage = (hue: number) =>
|
const mockPage = (hue: number) =>
|
||||||
'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(
|
"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">` +
|
`<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">` +
|
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
|
||||||
`<stop offset="0" stop-color="hsl(${hue},70%,75%)"/>` +
|
`<stop offset="0" stop-color="hsl(${hue},70%,75%)"/>` +
|
||||||
`<stop offset="1" stop-color="hsl(${(hue + 45) % 360},75%,55%)"/>` +
|
`<stop offset="1" stop-color="hsl(${(hue + 45) % 360},75%,55%)"/>` +
|
||||||
`</linearGradient></defs>` +
|
`</linearGradient></defs>` +
|
||||||
`<rect width="800" height="450" fill="url(#g)"/>` +
|
`<rect width="800" height="450" fill="url(#g)"/>` +
|
||||||
`</svg>`
|
`</svg>`,
|
||||||
)
|
);
|
||||||
|
|
||||||
// 13 页(封面 + 12 内页),模拟真实绘本规模,便于测试横向胶卷式缩略图
|
// 13 页(封面 + 12 内页),模拟真实绘本规模,便于测试横向胶卷式缩略图
|
||||||
const pageTexts = [
|
const pageTexts = [
|
||||||
'', // 封面
|
"", // 封面
|
||||||
'一个阳光明媚的早晨,小主角推开窗,看见远处的森林闪着金光。',
|
"一个阳光明媚的早晨,小主角推开窗,看见远处的森林闪着金光。",
|
||||||
'它沿着小路走啊走,遇到了一只迷路的小鸟,羽毛湿漉漉的。',
|
"它沿着小路走啊走,遇到了一只迷路的小鸟,羽毛湿漉漉的。",
|
||||||
'小主角轻轻抱起小鸟,决定送它回家。',
|
"小主角轻轻抱起小鸟,决定送它回家。",
|
||||||
'路过一片野花田时,一只小狐狸从草丛里跳出来打招呼。',
|
"路过一片野花田时,一只小狐狸从草丛里跳出来打招呼。",
|
||||||
'小狐狸说它认识森林里所有的小路,愿意做大家的向导。',
|
"小狐狸说它认识森林里所有的小路,愿意做大家的向导。",
|
||||||
'三个朋友来到一条清澈的溪水边,溪里有一群闪闪发光的小鱼。',
|
"三个朋友来到一条清澈的溪水边,溪里有一群闪闪发光的小鱼。",
|
||||||
'小鱼们告诉他们,那棵会发光的大树就在前方不远处。',
|
"小鱼们告诉他们,那棵会发光的大树就在前方不远处。",
|
||||||
'森林深处真的有一棵会发光的大树,挂满了亮晶晶的果实。',
|
"森林深处真的有一棵会发光的大树,挂满了亮晶晶的果实。",
|
||||||
'原来这就是小鸟的家,妈妈正在树枝上焦急地张望。',
|
"原来这就是小鸟的家,妈妈正在树枝上焦急地张望。",
|
||||||
'小鸟欢快地飞回妈妈身边,鸟妈妈给大家每人一颗果实。',
|
"小鸟欢快地飞回妈妈身边,鸟妈妈给大家每人一颗果实。",
|
||||||
'夕阳下,小主角和小狐狸告别,小狐狸送他们到森林边缘。',
|
"夕阳下,小主角和小狐狸告别,小狐狸送他们到森林边缘。",
|
||||||
'小主角带着这份美好回到家,心里也开出了一朵花。',
|
"小主角带着这份美好回到家,心里也开出了一朵花。",
|
||||||
]
|
];
|
||||||
|
|
||||||
const wid = 'mock-work-' + Date.now()
|
const wid = "mock-work-" + Date.now();
|
||||||
workId.value = wid
|
workId.value = wid;
|
||||||
workDetail.value = {
|
workDetail.value = {
|
||||||
workId: wid,
|
workId: wid,
|
||||||
status: 3, // COMPLETED
|
status: 3, // COMPLETED
|
||||||
title: storyData.value?.title || '森林大冒险',
|
title: storyData.value?.title || "森林大冒险",
|
||||||
subtitle: '',
|
subtitle: "",
|
||||||
author: '',
|
author: "",
|
||||||
coverUrl: mockPage(280),
|
coverUrl: mockPage(280),
|
||||||
pageList: pageTexts.map((text, i) => ({
|
pageList: pageTexts.map((text, i) => ({
|
||||||
pageNum: i,
|
pageNum: i,
|
||||||
text,
|
text,
|
||||||
imageUrl: mockPage((280 + i * 27) % 360),
|
imageUrl: mockPage((280 + i * 27) % 360),
|
||||||
})),
|
})),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreRecoveryState() {
|
function restoreRecoveryState() {
|
||||||
const raw = sessionStorage.getItem('le_recovery')
|
const raw = sessionStorage.getItem("le_recovery");
|
||||||
if (!raw) return null
|
if (!raw) return null;
|
||||||
try {
|
try {
|
||||||
const recovery = JSON.parse(raw)
|
const recovery = JSON.parse(raw);
|
||||||
if (Date.now() - recovery.savedAt > 30 * 60 * 1000) {
|
if (Date.now() - recovery.savedAt > 30 * 60 * 1000) {
|
||||||
sessionStorage.removeItem('le_recovery')
|
sessionStorage.removeItem("le_recovery");
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
if (recovery.workId) workId.value = recovery.workId
|
if (recovery.workId) workId.value = recovery.workId;
|
||||||
if (recovery.imageUrl) imageUrl.value = recovery.imageUrl
|
if (recovery.imageUrl) imageUrl.value = recovery.imageUrl;
|
||||||
if (recovery.extractId) extractId.value = recovery.extractId
|
if (recovery.extractId) extractId.value = recovery.extractId;
|
||||||
if (recovery.selectedStyle) selectedStyle.value = recovery.selectedStyle
|
if (recovery.selectedStyle) selectedStyle.value = recovery.selectedStyle;
|
||||||
sessionStorage.removeItem('le_recovery')
|
sessionStorage.removeItem("le_recovery");
|
||||||
return recovery
|
return recovery;
|
||||||
} catch {
|
} catch {
|
||||||
sessionStorage.removeItem('le_recovery')
|
sessionStorage.removeItem("le_recovery");
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 认证
|
// 认证
|
||||||
orgId, sessionToken,
|
orgId,
|
||||||
setSession, clearSession,
|
sessionToken,
|
||||||
|
setSession,
|
||||||
|
clearSession,
|
||||||
// 创作流程
|
// 创作流程
|
||||||
imageUrl, extractId, characters, selectedCharacter,
|
imageUrl,
|
||||||
selectedStyle, storyData, workId, originalWorkId, workDetail,
|
extractId,
|
||||||
reset, saveRecoveryState, restoreRecoveryState,
|
characters,
|
||||||
|
selectedCharacter,
|
||||||
|
selectedStyle,
|
||||||
|
storyData,
|
||||||
|
workId,
|
||||||
|
originalWorkId,
|
||||||
|
workDetail,
|
||||||
|
reset,
|
||||||
|
saveRecoveryState,
|
||||||
|
restoreRecoveryState,
|
||||||
// 开发模式
|
// 开发模式
|
||||||
fillMockData,
|
fillMockData,
|
||||||
fillMockWorkDetail,
|
fillMockWorkDetail,
|
||||||
// Tab 切换状态
|
// Tab 切换状态
|
||||||
lastCreateRoute, setLastCreateRoute, clearLastCreateRoute,
|
lastCreateRoute,
|
||||||
}
|
setLastCreateRoute,
|
||||||
})
|
clearLastCreateRoute,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|||||||
77
frontend/src/utils/aicreate/resumeLeaiWork.ts
Normal file
77
frontend/src/utils/aicreate/resumeLeaiWork.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* 根据乐读派作品详情恢复创作环节(对应上游 B2 query/work,经 /leai-proxy/work/{id})
|
||||||
|
*/
|
||||||
|
import type { Router } from "vue-router";
|
||||||
|
import { getWorkDetail } from "@/api/aicreate";
|
||||||
|
import { STATUS, getRouteByStatus } from "@/utils/aicreate/status";
|
||||||
|
import { clearExtractDraft } from "@/utils/aicreate/extractDraft";
|
||||||
|
|
||||||
|
type AicreateStoreLike = {
|
||||||
|
workId: string;
|
||||||
|
workDetail: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseWorkPayload(res: unknown): Record<string, any> | null {
|
||||||
|
if (!res || typeof res !== "object") return null;
|
||||||
|
const r = res as Record<string, any>;
|
||||||
|
const inner = r.data !== undefined ? r.data : r;
|
||||||
|
if (!inner || typeof inner !== "object") return null;
|
||||||
|
return inner as Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取作品详情、写入 store 与 le_workId,并按 status 跳转到对应子路由。
|
||||||
|
* @returns 是否已成功发起跳转(失败时返回 false,调用方可继续其它恢复逻辑)
|
||||||
|
*/
|
||||||
|
export async function resumeLeaiWorkFromApi(
|
||||||
|
workId: string,
|
||||||
|
router: Router,
|
||||||
|
store: AicreateStoreLike,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const id = String(workId || "").trim();
|
||||||
|
if (!id) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getWorkDetail(id);
|
||||||
|
const work = parseWorkPayload(res);
|
||||||
|
if (!work) {
|
||||||
|
localStorage.removeItem("le_workId");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wid = String(work.workId ?? id);
|
||||||
|
store.workId = wid;
|
||||||
|
store.workDetail = work;
|
||||||
|
localStorage.setItem("le_workId", wid);
|
||||||
|
|
||||||
|
const st = Number(work.status);
|
||||||
|
if (st === STATUS.FAILED) {
|
||||||
|
clearExtractDraft();
|
||||||
|
await router.replace({
|
||||||
|
name: "PublicCreateCreating",
|
||||||
|
query: { workId: wid },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = getRouteByStatus(
|
||||||
|
work.status as Parameters<typeof getRouteByStatus>[0],
|
||||||
|
wid,
|
||||||
|
);
|
||||||
|
if (!route) {
|
||||||
|
clearExtractDraft();
|
||||||
|
await router.replace({
|
||||||
|
name: "PublicCreateCreating",
|
||||||
|
query: { workId: wid },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearExtractDraft();
|
||||||
|
await router.replace(route);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem("le_workId");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -29,7 +29,7 @@ export function getRouteByStatus(status: StatusValue, workId: string): { name: s
|
|||||||
case STATUS.CATALOGED:
|
case STATUS.CATALOGED:
|
||||||
return { name: 'PublicCreateDubbing', params: { workId } }
|
return { name: 'PublicCreateDubbing', params: { workId } }
|
||||||
case STATUS.DUBBED:
|
case STATUS.DUBBED:
|
||||||
return { name: 'PublicCreateRead', params: { workId } }
|
return { name: 'PublicCreateEditInfo', params: { workId } }
|
||||||
case STATUS.FAILED:
|
case STATUS.FAILED:
|
||||||
return null
|
return null
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -62,6 +62,8 @@ const initToken = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// 乐读派作品恢复(localStorage le_workId、路由 ?resumeWorkId=)在子页 WelcomeView 挂载后执行,
|
||||||
|
// 须先完成 initToken,故不在此壳层重复拉取,避免与 loading 竞态。
|
||||||
// 如果 store 中已有有效 token 且 orgId 已初始化,跳过加载
|
// 如果 store 中已有有效 token 且 orgId 已初始化,跳过加载
|
||||||
if (store.sessionToken && store.orgId) {
|
if (store.sessionToken && store.orgId) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@ -103,7 +105,9 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-text {
|
.loading-text {
|
||||||
@ -122,7 +126,15 @@ onMounted(() => {
|
|||||||
.ai-slide-leave-active {
|
.ai-slide-leave-active {
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
.ai-slide-enter-from { opacity: 0; transform: translateX(30px); }
|
|
||||||
.ai-slide-leave-to { opacity: 0; transform: translateX(-30px); }
|
.ai-slide-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-slide-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-30px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -111,7 +111,6 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAicreateStore()
|
const store = useAicreateStore()
|
||||||
|
|
||||||
const isDev = import.meta.env.DEV
|
|
||||||
const fromWorks = new URLSearchParams(window.location.search).get('from') === 'works'
|
const fromWorks = new URLSearchParams(window.location.search).get('from') === 'works'
|
||||||
|| sessionStorage.getItem('le_from') === 'works'
|
|| sessionStorage.getItem('le_from') === 'works'
|
||||||
|
|
||||||
@ -195,13 +194,6 @@ function applyWork(work: any) {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const workId = route.params.workId
|
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
|
if (!workId) return
|
||||||
try {
|
try {
|
||||||
let work
|
let work
|
||||||
|
|||||||
@ -29,10 +29,8 @@ export default { name: 'CharactersView' }
|
|||||||
<div class="single-img-wrap">
|
<div class="single-img-wrap">
|
||||||
<img v-if="characters[0].originalCropUrl" :src="characters[0].originalCropUrl" class="single-img" />
|
<img v-if="characters[0].originalCropUrl" :src="characters[0].originalCropUrl" class="single-img" />
|
||||||
<user-outlined v-else class="single-placeholder" />
|
<user-outlined v-else class="single-placeholder" />
|
||||||
<div
|
<div class="zoom-hint"
|
||||||
class="zoom-hint"
|
@click.stop="characters[0].originalCropUrl && (previewImg = characters[0].originalCropUrl)">
|
||||||
@click.stop="characters[0].originalCropUrl && (previewImg = characters[0].originalCropUrl)"
|
|
||||||
>
|
|
||||||
<zoom-in-outlined />
|
<zoom-in-outlined />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -52,13 +50,8 @@ export default { name: 'CharactersView' }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="char-grid">
|
<div class="char-grid">
|
||||||
<div
|
<div v-for="c in characters" :key="c.charId" class="char-card" :class="{ selected: selected === c.charId }"
|
||||||
v-for="c in characters"
|
@click="selected = c.charId">
|
||||||
:key="c.charId"
|
|
||||||
class="char-card"
|
|
||||||
:class="{ selected: selected === c.charId }"
|
|
||||||
@click="selected = c.charId"
|
|
||||||
>
|
|
||||||
<!-- 推荐角标 -->
|
<!-- 推荐角标 -->
|
||||||
<div v-if="c.type === 'HERO'" class="hero-badge">
|
<div v-if="c.type === 'HERO'" class="hero-badge">
|
||||||
<crown-filled />
|
<crown-filled />
|
||||||
@ -72,10 +65,7 @@ export default { name: 'CharactersView' }
|
|||||||
<div class="char-img-wrap">
|
<div class="char-img-wrap">
|
||||||
<img v-if="c.originalCropUrl" :src="c.originalCropUrl" class="char-img" />
|
<img v-if="c.originalCropUrl" :src="c.originalCropUrl" class="char-img" />
|
||||||
<user-outlined v-else class="char-placeholder" />
|
<user-outlined v-else class="char-placeholder" />
|
||||||
<div
|
<div class="zoom-hint" @click.stop="c.originalCropUrl && (previewImg = c.originalCropUrl)">
|
||||||
class="zoom-hint"
|
|
||||||
@click.stop="c.originalCropUrl && (previewImg = c.originalCropUrl)"
|
|
||||||
>
|
|
||||||
<zoom-in-outlined />
|
<zoom-in-outlined />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -212,6 +202,7 @@ const goNext = () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
@ -228,16 +219,19 @@ const goNext = () => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 60px 0;
|
padding: 60px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
font-size: 44px;
|
font-size: 44px;
|
||||||
color: var(--ai-primary);
|
color: var(--ai-primary);
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-title {
|
.loading-title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--ai-text);
|
color: var(--ai-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-sub {
|
.loading-sub {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--ai-text-sub);
|
color: var(--ai-text-sub);
|
||||||
@ -254,16 +248,19 @@ const goNext = () => {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 60px 0;
|
padding: 60px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-icon {
|
.error-icon {
|
||||||
font-size: 48px;
|
font-size: 48px;
|
||||||
color: var(--ai-text-sub);
|
color: var(--ai-text-sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-text {
|
.error-text {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--ai-text);
|
color: var(--ai-text);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-btn {
|
.back-btn {
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
@ -279,6 +276,7 @@ const goNext = () => {
|
|||||||
gap: 24px;
|
gap: 24px;
|
||||||
padding: 12px 0 24px;
|
padding: 12px 0 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.single-card {
|
.single-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 360px;
|
max-width: 360px;
|
||||||
@ -288,6 +286,7 @@ const goNext = () => {
|
|||||||
padding: 14px;
|
padding: 14px;
|
||||||
box-shadow: 0 14px 36px rgba(99, 102, 241, 0.22);
|
box-shadow: 0 14px 36px rgba(99, 102, 241, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.single-img-wrap {
|
.single-img-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -299,18 +298,26 @@ const goNext = () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
&:hover .zoom-hint { opacity: 1; }
|
&:hover .zoom-hint {
|
||||||
&:active { transform: scale(0.98); }
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.single-img {
|
.single-img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.single-placeholder {
|
.single-placeholder {
|
||||||
font-size: 72px;
|
font-size: 72px;
|
||||||
color: var(--ai-text-sub);
|
color: var(--ai-text-sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
.single-tip {
|
.single-tip {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -345,6 +352,7 @@ const goNext = () => {
|
|||||||
margin: 0 2px;
|
margin: 0 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-icon {
|
.result-icon {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: var(--ai-primary);
|
color: var(--ai-primary);
|
||||||
@ -395,13 +403,17 @@ const goNext = () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
&:hover .zoom-hint { opacity: 1; }
|
&:hover .zoom-hint {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.char-img {
|
.char-img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.char-placeholder {
|
.char-placeholder {
|
||||||
font-size: 36px;
|
font-size: 36px;
|
||||||
color: var(--ai-text-sub);
|
color: var(--ai-text-sub);
|
||||||
@ -423,7 +435,9 @@ const goNext = () => {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4);
|
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4);
|
||||||
|
|
||||||
:deep(.anticon) { font-size: 9px; }
|
:deep(.anticon) {
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.check-badge {
|
.check-badge {
|
||||||
@ -489,6 +503,7 @@ const goNext = () => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: zoom-out;
|
cursor: zoom-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-full-img {
|
.preview-full-img {
|
||||||
max-width: 90%;
|
max-width: 90%;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
@ -496,10 +511,12 @@ const goNext = () => {
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-enter-active,
|
.fade-enter-active,
|
||||||
.fade-leave-active {
|
.fade-leave-active {
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-enter-from,
|
.fade-enter-from,
|
||||||
.fade-leave-to {
|
.fade-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@ -14,18 +14,8 @@ export default { name: 'CreatingView' }
|
|||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</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="rgba(99, 102, 241, 0.12)" stroke-width="8" />
|
||||||
<circle
|
<circle cx="90" cy="90" r="80" fill="none" stroke="url(#ringGrad)" stroke-width="8" :stroke-dasharray="502"
|
||||||
cx="90"
|
:stroke-dashoffset="502 - (502 * progress / 100)" stroke-linecap="round" class="ring-fill" />
|
||||||
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>
|
</svg>
|
||||||
<div class="ring-center">
|
<div class="ring-center">
|
||||||
<div class="ring-pct">{{ progress }}%</div>
|
<div class="ring-pct">{{ progress }}%</div>
|
||||||
@ -57,7 +47,8 @@ export default { name: 'CreatingView' }
|
|||||||
<button v-if="store.workId" class="btn-primary error-btn" @click="resumePolling">
|
<button v-if="store.workId" class="btn-primary error-btn" @click="resumePolling">
|
||||||
恢复查询进度
|
恢复查询进度
|
||||||
</button>
|
</button>
|
||||||
<button v-if="!isQuotaError" 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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -264,8 +255,7 @@ const startPolling = (workId: string) => {
|
|||||||
|
|
||||||
pollTimer = setInterval(async () => {
|
pollTimer = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const detail = await getWorkDetail(workId)
|
const work = await getWorkDetail(workId)
|
||||||
const work = detail.data
|
|
||||||
if (!work) return
|
if (!work) return
|
||||||
|
|
||||||
if (consecutiveErrors > 0 || networkWarn.value) {
|
if (consecutiveErrors > 0 || networkWarn.value) {
|
||||||
@ -422,8 +412,15 @@ onUnmounted(() => {
|
|||||||
height: 180px;
|
height: 180px;
|
||||||
margin-bottom: 28px;
|
margin-bottom: 28px;
|
||||||
}
|
}
|
||||||
.ring-svg { transform: rotate(-90deg); }
|
|
||||||
.ring-fill { transition: stroke-dashoffset 0.8s ease; }
|
.ring-svg {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-fill {
|
||||||
|
transition: stroke-dashoffset 0.8s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.ring-center {
|
.ring-center {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@ -432,6 +429,7 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ring-pct {
|
.ring-pct {
|
||||||
font-size: 38px;
|
font-size: 38px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
@ -441,6 +439,7 @@ onUnmounted(() => {
|
|||||||
background-clip: text;
|
background-clip: text;
|
||||||
letter-spacing: -1px;
|
letter-spacing: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ring-label {
|
.ring-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ai-text-sub);
|
color: var(--ai-text-sub);
|
||||||
@ -464,6 +463,7 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rotating-tip {
|
.rotating-tip {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--ai-text-sub);
|
color: var(--ai-text-sub);
|
||||||
@ -471,12 +471,21 @@ onUnmounted(() => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tip-fade-enter-active,
|
.tip-fade-enter-active,
|
||||||
.tip-fade-leave-active {
|
.tip-fade-leave-active {
|
||||||
transition: opacity 0.5s ease, transform 0.5s ease;
|
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 {
|
.network-warn {
|
||||||
@ -491,7 +500,9 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|
||||||
:deep(.anticon) { font-size: 13px; }
|
:deep(.anticon) {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 错误状态 ---------- */
|
/* ---------- 错误状态 ---------- */
|
||||||
@ -502,11 +513,13 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-icon {
|
.error-icon {
|
||||||
font-size: 44px;
|
font-size: 44px;
|
||||||
color: var(--ai-text-sub);
|
color: var(--ai-text-sub);
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-text {
|
.error-text {
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@ -514,6 +527,7 @@ onUnmounted(() => {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
max-width: 280px;
|
max-width: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-actions {
|
.error-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -522,11 +536,13 @@ onUnmounted(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 240px;
|
max-width: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-btn {
|
.error-btn {
|
||||||
font-size: 14px !important;
|
font-size: 14px !important;
|
||||||
padding: 12px 0 !important;
|
padding: 12px 0 !important;
|
||||||
border-radius: 24px !important;
|
border-radius: 24px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-btn.btn-outline {
|
.error-btn.btn-outline {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
color: var(--ai-primary) !important;
|
color: var(--ai-primary) !important;
|
||||||
@ -544,6 +560,7 @@ onUnmounted(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-hint-row {
|
.task-hint-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -553,16 +570,19 @@ onUnmounted(() => {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-icon {
|
.task-icon {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
color: var(--ai-primary);
|
color: var(--ai-primary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-hint-sub {
|
.task-hint-sub {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--ai-text-sub);
|
color: var(--ai-text-sub);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leave-btn {
|
.leave-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -579,13 +599,18 @@ onUnmounted(() => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
:deep(.anticon) { font-size: 15px; }
|
:deep(.anticon) {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: var(--ai-primary);
|
border-color: var(--ai-primary);
|
||||||
background: rgba(99, 102, 241, 0.04);
|
background: rgba(99, 102, 241, 0.04);
|
||||||
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.12);
|
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.12);
|
||||||
}
|
}
|
||||||
&:active { transform: scale(0.98); }
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -175,8 +175,6 @@ const route = useRoute()
|
|||||||
const store = useAicreateStore()
|
const store = useAicreateStore()
|
||||||
const workId = computed(() => route.params.workId || store.workId)
|
const workId = computed(() => route.params.workId || store.workId)
|
||||||
|
|
||||||
const isDev = import.meta.env.DEV
|
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const pages = ref<any[]>([])
|
const pages = ref<any[]>([])
|
||||||
@ -289,12 +287,6 @@ function togglePlay() {
|
|||||||
const src = currentAudioSrc.value
|
const src = currentAudioSrc.value
|
||||||
if (!src) return
|
if (!src) return
|
||||||
|
|
||||||
// dev mock 兜底:mock 音频直接 toast 不真实播放
|
|
||||||
if (typeof src === 'string' && src.startsWith('mock-audio-')) {
|
|
||||||
showToast('模拟音频暂不支持播放')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlaying.value) {
|
if (isPlaying.value) {
|
||||||
audioEl?.pause()
|
audioEl?.pause()
|
||||||
isPlaying.value = false
|
isPlaying.value = false
|
||||||
@ -404,19 +396,6 @@ function autoAdvance() {
|
|||||||
async function voiceSingle() {
|
async function voiceSingle() {
|
||||||
voicingSingle.value = true
|
voicingSingle.value = true
|
||||||
try {
|
try {
|
||||||
// dev 兜底:mock workId 直接 mock 配音
|
|
||||||
if (isDev && String(workId.value || '').startsWith('mock-')) {
|
|
||||||
await new Promise(r => setTimeout(r, 400))
|
|
||||||
const p = pages.value[idx.value]
|
|
||||||
if (p) {
|
|
||||||
p.audioUrl = 'mock-audio-' + p.pageNum
|
|
||||||
p.localBlob = null
|
|
||||||
p.isAiVoice = true
|
|
||||||
}
|
|
||||||
showToast('AI 配音完成')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await voicePage({ workId: workId.value, voiceAll: false, pageNum: currentPage.value.pageNum })
|
const res = await voicePage({ workId: workId.value, voiceAll: false, pageNum: currentPage.value.pageNum })
|
||||||
const data = res
|
const data = res
|
||||||
if (data.voicedPages?.length) {
|
if (data.voicedPages?.length) {
|
||||||
@ -449,19 +428,6 @@ async function voiceAllConfirm() {
|
|||||||
|
|
||||||
voicingAll.value = true
|
voicingAll.value = true
|
||||||
try {
|
try {
|
||||||
// dev 兜底:mock workId 直接 mock 全部配音
|
|
||||||
if (isDev && String(workId.value || '').startsWith('mock-')) {
|
|
||||||
await new Promise(r => setTimeout(r, 800))
|
|
||||||
pages.value.forEach(p => {
|
|
||||||
if (!p.audioUrl && !p.localBlob) {
|
|
||||||
p.audioUrl = 'mock-audio-' + p.pageNum
|
|
||||||
p.isAiVoice = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
showToast('全部 AI 配音完成')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await voicePage({ workId: workId.value, voiceAll: true })
|
const res = await voicePage({ workId: workId.value, voiceAll: true })
|
||||||
const data = res
|
const data = res
|
||||||
if (data.voicedPages) {
|
if (data.voicedPages) {
|
||||||
@ -487,15 +453,6 @@ async function voiceAllConfirm() {
|
|||||||
async function finish() {
|
async function finish() {
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
// dev 兜底:mock workId 跳过真实上传与提交
|
|
||||||
if (isDev && String(workId.value || '').startsWith('mock-')) {
|
|
||||||
await new Promise(r => setTimeout(r, 500))
|
|
||||||
store.workDetail = null
|
|
||||||
showToast('配音完成')
|
|
||||||
setTimeout(() => router.push(`/p/create/read/${workId.value}`), 600)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingLocal = pages.value.filter(p => p.localBlob)
|
const pendingLocal = pages.value.filter(p => p.localBlob)
|
||||||
|
|
||||||
if (pendingLocal.length > 0) {
|
if (pendingLocal.length > 0) {
|
||||||
@ -518,14 +475,28 @@ async function finish() {
|
|||||||
|
|
||||||
store.workDetail = null
|
store.workDetail = null
|
||||||
showToast('配音完成')
|
showToast('配音完成')
|
||||||
setTimeout(() => router.push(`/p/create/read/${workId.value}`), 800)
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
router.push({
|
||||||
|
name: 'PublicCreateEditInfo',
|
||||||
|
params: { workId: String(workId.value || '') },
|
||||||
|
}),
|
||||||
|
800,
|
||||||
|
)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
try {
|
try {
|
||||||
const check = await getWorkDetail(workId.value)
|
const check = await getWorkDetail(workId.value)
|
||||||
if (check?.status >= 5) {
|
if (check?.status >= 5) {
|
||||||
store.workDetail = null
|
store.workDetail = null
|
||||||
showToast('配音已完成')
|
showToast('配音已完成')
|
||||||
setTimeout(() => router.push(`/p/create/read/${workId.value}`), 800)
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
router.push({
|
||||||
|
name: 'PublicCreateEditInfo',
|
||||||
|
params: { workId: String(workId.value || '') },
|
||||||
|
}),
|
||||||
|
800,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
@ -539,24 +510,6 @@ async function finish() {
|
|||||||
async function loadWork() {
|
async function loadWork() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const wid = String(workId.value || '')
|
|
||||||
|
|
||||||
// dev 兜底:mock workId 直接用 store.workDetail
|
|
||||||
if (isDev && wid.startsWith('mock-')) {
|
|
||||||
if (!store.workDetail) store.fillMockWorkDetail()
|
|
||||||
const w = store.workDetail
|
|
||||||
pages.value = (w.pageList || []).map((p: any) => ({
|
|
||||||
pageNum: p.pageNum,
|
|
||||||
text: p.text,
|
|
||||||
imageUrl: p.imageUrl,
|
|
||||||
audioUrl: p.audioUrl || null,
|
|
||||||
localBlob: null,
|
|
||||||
isAiVoice: p.audioUrl ? true : null,
|
|
||||||
}))
|
|
||||||
loading.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!store.workDetail || store.workDetail.workId !== workId.value) {
|
if (!store.workDetail || store.workDetail.workId !== workId.value) {
|
||||||
store.workDetail = null
|
store.workDetail = null
|
||||||
const res = await getWorkDetail(workId.value)
|
const res = await getWorkDetail(workId.value)
|
||||||
|
|||||||
@ -129,6 +129,7 @@ import {
|
|||||||
AudioOutlined,
|
AudioOutlined,
|
||||||
SendOutlined,
|
SendOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
||||||
import { getWorkDetail, updateWork } from '@/api/aicreate'
|
import { getWorkDetail, updateWork } from '@/api/aicreate'
|
||||||
import { useAicreateStore } from '@/stores/aicreate'
|
import { useAicreateStore } from '@/stores/aicreate'
|
||||||
@ -140,8 +141,6 @@ const route = useRoute()
|
|||||||
const store = useAicreateStore()
|
const store = useAicreateStore()
|
||||||
const workId = computed(() => route.params.workId || store.workId)
|
const workId = computed(() => route.params.workId || store.workId)
|
||||||
|
|
||||||
const isDev = import.meta.env.DEV
|
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const processing = ref(false)
|
const processing = ref(false)
|
||||||
const coverUrl = ref('')
|
const coverUrl = ref('')
|
||||||
@ -184,21 +183,6 @@ function confirmAddTag() {
|
|||||||
async function loadWork() {
|
async function loadWork() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
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 时重新请求
|
// 缓存不匹配当前 workId 时重新请求
|
||||||
if (!store.workDetail || store.workDetail.workId !== workId.value) {
|
if (!store.workDetail || store.workDetail.workId !== workId.value) {
|
||||||
store.workDetail = null
|
store.workDetail = null
|
||||||
@ -207,7 +191,8 @@ async function loadWork() {
|
|||||||
}
|
}
|
||||||
const w = store.workDetail
|
const w = store.workDetail
|
||||||
|
|
||||||
if (w.status > STATUS.CATALOGED) {
|
// 已配音(DUBBED)仍可在本页编辑元数据/发布;仅当状态高于当前流程终态时再按 status 跳转
|
||||||
|
if (w.status > STATUS.DUBBED) {
|
||||||
const nextRoute = getRouteByStatus(w.status, w.workId)
|
const nextRoute = getRouteByStatus(w.status, w.workId)
|
||||||
if (nextRoute) { router.replace(nextRoute); return }
|
if (nextRoute) { router.replace(nextRoute); return }
|
||||||
}
|
}
|
||||||
@ -240,20 +225,6 @@ function validate() {
|
|||||||
* 不做跳转,由各 handler 决定下一步去哪
|
* 不做跳转,由各 handler 决定下一步去哪
|
||||||
*/
|
*/
|
||||||
async function saveFormToServer() {
|
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 {
|
try {
|
||||||
const data = { tags: selectedTags.value }
|
const data = { tags: selectedTags.value }
|
||||||
data.author = form.value.author.trim()
|
data.author = form.value.author.trim()
|
||||||
@ -273,21 +244,24 @@ async function saveFormToServer() {
|
|||||||
// 容错:保存报错时检查实际状态,可能已经成功但重试导致 CAS 失败
|
// 容错:保存报错时检查实际状态,可能已经成功但重试导致 CAS 失败
|
||||||
try {
|
try {
|
||||||
const check = await getWorkDetail(workId.value)
|
const check = await getWorkDetail(workId.value)
|
||||||
if (check?.data?.status >= 4) return true
|
if (check?.status >= 4) return true
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
alert(e.message || '保存失败,请重试')
|
message.error(e.message || '保存失败,请重试')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保存(编目完成 → unpublished)→ 跳作品库未发布 tab */
|
/** 保存(编目完成 → unpublished)→ 保存成功页,可继续配音或进作品库 */
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!validate()) return
|
if (!validate()) return
|
||||||
processing.value = true
|
processing.value = true
|
||||||
try {
|
try {
|
||||||
if (await saveFormToServer()) {
|
if (await saveFormToServer()) {
|
||||||
store.workDetail = null
|
store.workDetail = null
|
||||||
router.push('/p/works?tab=unpublished')
|
router.push({
|
||||||
|
name: 'PublicCreateSaveSuccess',
|
||||||
|
params: { workId: String(workId.value || '') },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
processing.value = false
|
processing.value = false
|
||||||
@ -308,27 +282,21 @@ async function handleGoDubbing() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 发布作品 → 进入超管端待审核,跳作品库审核中 tab */
|
/** 发布作品 → 进入超管端待审核;完成后留在本页并刷新数据、提示用户 */
|
||||||
async function handlePublish() {
|
async function handlePublish() {
|
||||||
if (!validate()) return
|
if (!validate()) return
|
||||||
processing.value = true
|
processing.value = true
|
||||||
try {
|
try {
|
||||||
if (!(await saveFormToServer())) return
|
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 作品 id(leai workId 到本地 id 的映射),
|
// TODO: 真实发布接口需要本地 DB 作品 id(leai workId 到本地 id 的映射),
|
||||||
// 等后端联调 publicUserWorksApi.publish 完成后接入
|
// 等后端联调 publicUserWorksApi.publish 完成后接入
|
||||||
store.workDetail = null
|
store.workDetail = null
|
||||||
router.push('/p/works?tab=pending_review')
|
message.success('提交成功,作品已进入审核,可在作品库「审核中」查看进度')
|
||||||
|
router.push({
|
||||||
|
name: 'PublicCreateSaveSuccess',
|
||||||
|
params: { workId: String(workId.value || '') },
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
processing.value = false
|
processing.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -99,8 +99,6 @@ const router = useRouter()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useAicreateStore()
|
const store = useAicreateStore()
|
||||||
|
|
||||||
const isDev = import.meta.env.DEV
|
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const pages = ref<any[]>([])
|
const pages = ref<any[]>([])
|
||||||
@ -134,17 +132,8 @@ async function loadWork() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
|
||||||
// dev 兜底:mock workId 或 dev 模式无 workId 时使用 store.workDetail
|
if (!workId.value) {
|
||||||
const wid = String(workId.value || '')
|
error.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
|
loading.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ export default { name: 'SaveSuccessView' }
|
|||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="success-page">
|
<div class="success-page">
|
||||||
<!-- 撒花装饰 -->
|
|
||||||
<div class="confetti c1">🎊</div>
|
<div class="confetti c1">🎊</div>
|
||||||
<div class="confetti c2">🌟</div>
|
<div class="confetti c2">🌟</div>
|
||||||
<div class="confetti c3">✨</div>
|
<div class="confetti c3">✨</div>
|
||||||
@ -12,12 +11,10 @@ export default { name: 'SaveSuccessView' }
|
|||||||
<div class="confetti c6">🎊</div>
|
<div class="confetti c6">🎊</div>
|
||||||
|
|
||||||
<div class="success-content">
|
<div class="success-content">
|
||||||
<!-- 撒花大图标 -->
|
|
||||||
<div class="celebration-icon">🎉</div>
|
<div class="celebration-icon">🎉</div>
|
||||||
<div class="success-title">保存成功!</div>
|
<div class="success-title">{{ headline }}</div>
|
||||||
<div class="success-sub">太棒了,你的绘本已保存</div>
|
<div class="success-sub">{{ subline }}</div>
|
||||||
|
|
||||||
<!-- 封面卡片 - 3D 微倾斜效果 -->
|
|
||||||
<div class="cover-card-wrap" v-if="coverUrl">
|
<div class="cover-card-wrap" v-if="coverUrl">
|
||||||
<div class="cover-card">
|
<div class="cover-card">
|
||||||
<img :src="coverUrl" class="cover-img" />
|
<img :src="coverUrl" class="cover-img" />
|
||||||
@ -28,15 +25,30 @@ export default { name: 'SaveSuccessView' }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
|
||||||
<div class="action-group">
|
<div class="action-group">
|
||||||
<button class="btn-primary action-btn" @click="goDubbing">
|
<button
|
||||||
|
v-if="showDubbingCta"
|
||||||
|
class="btn-primary action-btn"
|
||||||
|
@click="goDubbing"
|
||||||
|
>
|
||||||
<span class="action-icon">🎙️</span>
|
<span class="action-icon">🎙️</span>
|
||||||
<div class="action-text">
|
<div class="action-text">
|
||||||
<div class="action-main">给绘本配音</div>
|
<div class="action-main">给绘本配音</div>
|
||||||
<div class="action-desc">为每一页添加AI语音</div>
|
<div class="action-desc">为每一页添加AI语音</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="showWorksCta"
|
||||||
|
class="btn-outline action-btn"
|
||||||
|
@click="goWorks"
|
||||||
|
>
|
||||||
|
<span class="action-icon">📚</span>
|
||||||
|
<div class="action-text">
|
||||||
|
<div class="action-main">{{ worksCtaMain }}</div>
|
||||||
|
<div class="action-desc">{{ worksCtaDesc }}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -51,7 +63,34 @@ import { useAicreateStore } from '@/stores/aicreate'
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useAicreateStore()
|
const store = useAicreateStore()
|
||||||
const workId = computed(() => route.params.workId || store.workId)
|
const workId = computed(() => String(route.params.workId || store.workId || ''))
|
||||||
|
|
||||||
|
const afterPublish = computed(() => route.query.after === 'publish')
|
||||||
|
|
||||||
|
const headline = computed(() => {
|
||||||
|
if (afterPublish.value) return '提交成功'
|
||||||
|
return '保存成功!'
|
||||||
|
})
|
||||||
|
|
||||||
|
const subline = computed(() => {
|
||||||
|
if (afterPublish.value) return '作品已进入审核,请耐心等待'
|
||||||
|
return '太棒了,你的绘本已保存'
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 提交审核成功后不展示「去配音」 */
|
||||||
|
const showDubbingCta = computed(() => !afterPublish.value)
|
||||||
|
|
||||||
|
const showWorksCta = computed(() => true)
|
||||||
|
|
||||||
|
const worksCtaMain = computed(() => {
|
||||||
|
if (afterPublish.value) return '查看审核进度'
|
||||||
|
return '查看作品库'
|
||||||
|
})
|
||||||
|
|
||||||
|
const worksCtaDesc = computed(() => {
|
||||||
|
if (afterPublish.value) return '在「审核中」查看状态'
|
||||||
|
return '未发布作品在「未发布」分类'
|
||||||
|
})
|
||||||
|
|
||||||
const coverUrl = ref('')
|
const coverUrl = ref('')
|
||||||
const title = ref('')
|
const title = ref('')
|
||||||
@ -75,7 +114,13 @@ function goDubbing() {
|
|||||||
router.push(`/p/create/dubbing/${workId.value}`)
|
router.push(`/p/create/dubbing/${workId.value}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goWorks() {
|
||||||
|
if (afterPublish.value) {
|
||||||
|
router.push('/p/works?tab=pending_review')
|
||||||
|
} else {
|
||||||
|
router.push('/p/works?tab=unpublished')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadWork)
|
onMounted(loadWork)
|
||||||
</script>
|
</script>
|
||||||
@ -91,7 +136,6 @@ onMounted(loadWork)
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 撒花动画 */
|
|
||||||
.confetti {
|
.confetti {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
@ -148,7 +192,6 @@ onMounted(loadWork)
|
|||||||
margin-bottom: 28px;
|
margin-bottom: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 封面卡片 3D 效果 */
|
|
||||||
.cover-card-wrap {
|
.cover-card-wrap {
|
||||||
perspective: 600px;
|
perspective: 600px;
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
@ -195,9 +238,24 @@ onMounted(loadWork)
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
border-radius: var(--ai-radius);
|
border-radius: var(--ai-radius);
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--ai-gradient, linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%));
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-outline {
|
||||||
|
background: #fff;
|
||||||
|
color: #4A3728;
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.35);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||||
}
|
}
|
||||||
.action-icon { font-size: 24px; flex-shrink: 0; }
|
.action-icon { font-size: 24px; flex-shrink: 0; }
|
||||||
.action-text { flex: 1; }
|
.action-text { flex: 1; }
|
||||||
.action-main { font-size: 15px; font-weight: 700; }
|
.action-main { font-size: 15px; font-weight: 700; }
|
||||||
.action-desc { font-size: 12px; opacity: 0.7; margin-top: 2px; }
|
.action-desc { font-size: 12px; opacity: 0.7; margin-top: 2px; }
|
||||||
|
.btn-outline .action-desc { opacity: 0.8; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -127,8 +127,8 @@ export default { name: 'WelcomeView' }
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { onMounted, onActivated, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
CameraOutlined,
|
CameraOutlined,
|
||||||
SmileOutlined,
|
SmileOutlined,
|
||||||
@ -143,34 +143,109 @@ import {
|
|||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import { useAicreateStore } from '@/stores/aicreate'
|
import { useAicreateStore } from '@/stores/aicreate'
|
||||||
import { loadExtractDraft } from '@/utils/aicreate/extractDraft'
|
import { loadExtractDraft } from '@/utils/aicreate/extractDraft'
|
||||||
|
import { resumeLeaiWorkFromApi } from '@/utils/aicreate/resumeLeaiWork'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAicreateStore()
|
const store = useAicreateStore()
|
||||||
|
|
||||||
onMounted(async () => {
|
/** 作品库「编辑」传入的 resumeWorkId(query 可能已解码,容错二次 decode) */
|
||||||
// 检查恢复状态(短会话内 Tab 切换等)
|
function parseResumeWorkIdFromQuery(raw: string): string {
|
||||||
const recovery = store.restoreRecoveryState()
|
const t = String(raw || '').trim()
|
||||||
if (recovery && recovery.path && recovery.path !== '/') {
|
if (!t) return ''
|
||||||
const newPath = '/p/create' + recovery.path
|
try {
|
||||||
router.push(newPath)
|
return decodeURIComponent(t)
|
||||||
return
|
} catch {
|
||||||
|
return t
|
||||||
}
|
}
|
||||||
// 角色提取草稿(10 天内):继续到选角页
|
}
|
||||||
const draft = loadExtractDraft()
|
|
||||||
if (draft && store.sessionToken) {
|
function getResumeWorkIdFromRoute(): string {
|
||||||
store.imageUrl = draft.imageUrl
|
const qResume = route.query.resumeWorkId
|
||||||
store.extractId = draft.extractId
|
const resumeFromQuery =
|
||||||
store.characters = draft.characters
|
typeof qResume === 'string'
|
||||||
store.selectedCharacter = null
|
? qResume
|
||||||
store.storyData = null
|
: Array.isArray(qResume) && qResume[0]
|
||||||
store.selectedStyle = ''
|
? qResume[0]
|
||||||
store.workId = ''
|
: ''
|
||||||
store.workDetail = null
|
return resumeFromQuery ? String(resumeFromQuery) : ''
|
||||||
localStorage.removeItem('le_workId')
|
}
|
||||||
router.replace('/p/create/characters')
|
|
||||||
|
/** 欢迎页恢复逻辑:与 keep-alive 配合,onMounted 仅首次;再次进入需 onActivated + watch */
|
||||||
|
let welcomeResumeRunning = false
|
||||||
|
|
||||||
|
async function runWelcomeEntry() {
|
||||||
|
if (route.name !== 'PublicCreateWelcome') return
|
||||||
|
if (welcomeResumeRunning) return
|
||||||
|
welcomeResumeRunning = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resumeFromQuery = getResumeWorkIdFromRoute()
|
||||||
|
|
||||||
|
// 1) 作品库 ?resumeWorkId= 优先于短会话 recovery,避免错跳;无 token 时等待壳层/ watch 再试
|
||||||
|
if (resumeFromQuery) {
|
||||||
|
if (!store.sessionToken) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const decoded = parseResumeWorkIdFromQuery(resumeFromQuery)
|
||||||
|
if (!decoded) {
|
||||||
|
await router.replace({ name: 'PublicCreateWelcome', query: {} })
|
||||||
|
} else {
|
||||||
|
const ok = await resumeLeaiWorkFromApi(decoded, router, store)
|
||||||
|
if (ok) return
|
||||||
|
await router.replace({ name: 'PublicCreateWelcome', query: {} })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 短会话恢复(Tab 切换等)
|
||||||
|
const recovery = store.restoreRecoveryState()
|
||||||
|
if (recovery && recovery.path && recovery.path !== '/') {
|
||||||
|
const newPath = '/p/create' + recovery.path
|
||||||
|
router.push(newPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 本地 le_workId:乐读派已生成作品,按状态继续
|
||||||
|
const storedWid = localStorage.getItem('le_workId')
|
||||||
|
if (storedWid && store.sessionToken) {
|
||||||
|
const ok = await resumeLeaiWorkFromApi(storedWid, router, store)
|
||||||
|
if (ok) return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 角色提取草稿(10 天内):继续到选角页
|
||||||
|
const draft = loadExtractDraft()
|
||||||
|
if (draft && store.sessionToken) {
|
||||||
|
store.imageUrl = draft.imageUrl
|
||||||
|
store.extractId = draft.extractId
|
||||||
|
store.characters = draft.characters
|
||||||
|
store.selectedCharacter = null
|
||||||
|
store.storyData = null
|
||||||
|
store.selectedStyle = ''
|
||||||
|
store.workId = ''
|
||||||
|
store.workDetail = null
|
||||||
|
localStorage.removeItem('le_workId')
|
||||||
|
router.replace('/p/create/characters')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
welcomeResumeRunning = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void runWelcomeEntry()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
void runWelcomeEntry()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [store.sessionToken, route.query.resumeWorkId] as const,
|
||||||
|
() => {
|
||||||
|
void runWelcomeEntry()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const handleStart = () => {
|
const handleStart = () => {
|
||||||
if (!store.sessionToken) return
|
if (!store.sessionToken) return
|
||||||
store.reset()
|
store.reset()
|
||||||
@ -204,18 +279,36 @@ $gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 12px 32px rgba(99, 102, 241, 0.22);
|
box-shadow: 0 12px 32px rgba(99, 102, 241, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-deco {
|
.hero-deco {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
.deco {
|
.deco {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
color: rgba(255, 255, 255, 0.4);
|
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-1 {
|
||||||
.deco-3 { bottom: 18px; right: 30%; font-size: 12px; }
|
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-icon {
|
.hero-icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -229,12 +322,14 @@ $gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-title {
|
.hero-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-sub {
|
.hero-sub {
|
||||||
margin: 6px 0 0;
|
margin: 6px 0 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@ -250,6 +345,7 @@ $gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
|||||||
border: 1px solid rgba(99, 102, 241, 0.06);
|
border: 1px solid rgba(99, 102, 241, 0.06);
|
||||||
box-shadow: 0 2px 12px rgba(99, 102, 241, 0.05);
|
box-shadow: 0 2px 12px rgba(99, 102, 241, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
margin: 0 0 14px;
|
margin: 0 0 14px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@ -258,8 +354,16 @@ $gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 创作流程 ---------- */
|
/* ---------- 创作流程 ---------- */
|
||||||
.steps { display: flex; flex-direction: column; }
|
.steps {
|
||||||
.step { display: flex; gap: 12px; }
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.step-left {
|
.step-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -267,6 +371,7 @@ $gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
|||||||
width: 28px;
|
width: 28px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-num {
|
.step-num {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
@ -280,6 +385,7 @@ $gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 4px 10px rgba(99, 102, 241, 0.25);
|
box-shadow: 0 4px 10px rgba(99, 102, 241, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-line {
|
.step-line {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 2px;
|
width: 2px;
|
||||||
@ -287,19 +393,34 @@ $gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
|||||||
background: linear-gradient(180deg, rgba(99, 102, 241, 0.35), rgba(236, 72, 153, 0.18));
|
background: linear-gradient(180deg, rgba(99, 102, 241, 0.35), rgba(236, 72, 153, 0.18));
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-right {
|
.step-right {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding-bottom: 14px;
|
padding-bottom: 14px;
|
||||||
}
|
}
|
||||||
.step:last-child .step-right { padding-bottom: 0; }
|
|
||||||
|
.step:last-child .step-right {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.step-head {
|
.step-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.step-icon { color: $primary; font-size: 15px; }
|
|
||||||
.step-title { font-size: 14px; font-weight: 700; color: $text-strong; }
|
.step-icon {
|
||||||
|
color: $primary;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-strong;
|
||||||
|
}
|
||||||
|
|
||||||
.step-tag {
|
.step-tag {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@ -309,6 +430,7 @@ $gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
|||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-desc {
|
.step-desc {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: $text-muted;
|
color: $text-muted;
|
||||||
@ -334,6 +456,7 @@ $gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
|||||||
bottom: 24px;
|
bottom: 24px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.cta-btn {
|
.cta-btn {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -353,22 +476,33 @@ $gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
|||||||
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.38);
|
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.38);
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
:deep(.anticon) { font-size: 18px; }
|
:deep(.anticon) {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 10px 28px rgba(99, 102, 241, 0.44);
|
box-shadow: 0 10px 28px rgba(99, 102, 241, 0.44);
|
||||||
}
|
}
|
||||||
&:active { transform: scale(0.98); opacity: 0.95; }
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
&--disabled {
|
&--disabled {
|
||||||
background: #e5e7eb;
|
background: #e5e7eb;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
&:hover { transform: none; box-shadow: none; }
|
|
||||||
|
&:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.slogan {
|
.slogan {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
|
|||||||
@ -117,7 +117,7 @@
|
|||||||
<button v-if="work.status === 'unpublished'" class="op-btn primary" :disabled="actionLoading"
|
<button v-if="work.status === 'unpublished'" class="op-btn primary" :disabled="actionLoading"
|
||||||
@click="handlePublish">
|
@click="handlePublish">
|
||||||
<send-outlined />
|
<send-outlined />
|
||||||
<span>公开发布</span>
|
<span>提交审核</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button v-else-if="work.status === 'rejected'" class="op-btn primary" :disabled="actionLoading"
|
<button v-else-if="work.status === 'rejected'" class="op-btn primary" :disabled="actionLoading"
|
||||||
@ -126,9 +126,10 @@
|
|||||||
<span>修改后重交</span>
|
<span>修改后重交</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button v-else-if="work.status === 'draft'" class="op-btn primary" @click="handleContinue">
|
<button v-else-if="work.status === 'draft'" class="op-btn primary" :disabled="actionLoading"
|
||||||
|
@click="handleContinue">
|
||||||
<edit-outlined />
|
<edit-outlined />
|
||||||
<span>继续创作</span>
|
<span>编辑</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button v-else-if="work.status === 'pending_review'" class="op-btn outline" :disabled="actionLoading"
|
<button v-else-if="work.status === 'pending_review'" class="op-btn outline" :disabled="actionLoading"
|
||||||
@ -143,12 +144,6 @@
|
|||||||
<span>下架</span>
|
<span>下架</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 编辑信息(unpublished 状态)-->
|
|
||||||
<button v-if="work.status === 'unpublished'" class="op-btn outline-soft" @click="handleEditInfo">
|
|
||||||
<edit-outlined />
|
|
||||||
<span>编辑信息</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- 删除(所有状态)-->
|
<!-- 删除(所有状态)-->
|
||||||
<button class="op-btn ghost-danger" :disabled="actionLoading" @click="handleDelete">
|
<button class="op-btn ghost-danger" :disabled="actionLoading" @click="handleDelete">
|
||||||
<delete-outlined />
|
<delete-outlined />
|
||||||
@ -198,17 +193,15 @@ import {
|
|||||||
publicUserWorksApi,
|
publicUserWorksApi,
|
||||||
publicGalleryApi,
|
publicGalleryApi,
|
||||||
publicInteractionApi,
|
publicInteractionApi,
|
||||||
|
publicCreationApi,
|
||||||
type UserWork,
|
type UserWork,
|
||||||
} from '@/api/public'
|
} from '@/api/public'
|
||||||
import { getMockWorkDetail, isMockWorkId } from './_dev-mock'
|
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const workId = Number(route.params.id)
|
const workId = Number(route.params.id)
|
||||||
|
|
||||||
const isDev = import.meta.env.DEV
|
|
||||||
|
|
||||||
const work = ref<UserWork | null>(null)
|
const work = ref<UserWork | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const currentPageIndex = ref(0)
|
const currentPageIndex = ref(0)
|
||||||
@ -220,12 +213,36 @@ const currentPageData = computed(() => work.value?.pages?.[currentPageIndex.valu
|
|||||||
|
|
||||||
const isLoggedIn = computed(() => !!localStorage.getItem('public_token'))
|
const isLoggedIn = computed(() => !!localStorage.getItem('public_token'))
|
||||||
|
|
||||||
|
/** 当前登录公众用户 ID(与 Login 写入的 public_user 一致) */
|
||||||
|
function getPublicUserId(): number | null {
|
||||||
|
const raw = localStorage.getItem('public_user')
|
||||||
|
if (!raw || raw === 'undefined' || raw === 'null') return null
|
||||||
|
try {
|
||||||
|
const id = (JSON.parse(raw) as { id?: unknown }).id
|
||||||
|
if (id == null) return null
|
||||||
|
const n = Number(id)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 作品作者 sys_user id。
|
||||||
|
* 「我的作品库」详情经 normalize 后有顶层 userId;广场 GET /public/gallery/{id} 仅返回 creator/user,无 userId 字段。
|
||||||
|
*/
|
||||||
|
function resolveWorkOwnerUserId(w: UserWork): number | null {
|
||||||
|
if (typeof w.userId === 'number' && !Number.isNaN(w.userId)) return w.userId
|
||||||
|
const c = w.creator?.id
|
||||||
|
if (typeof c === 'number' && !Number.isNaN(c)) return c
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const isOwner = computed(() => {
|
const isOwner = computed(() => {
|
||||||
// dev mock 模式:mock 作品默认是当前用户作品
|
const uid = getPublicUserId()
|
||||||
if (isDev && work.value && isMockWorkId(work.value.id)) return true
|
const oid = work.value ? resolveWorkOwnerUserId(work.value) : null
|
||||||
const u = localStorage.getItem('public_user')
|
if (uid == null || oid == null) return false
|
||||||
if (!u || !work.value) return false
|
return uid === oid
|
||||||
try { return JSON.parse(u).id === work.value.userId } catch { return false }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const displayLikeCount = computed(() => work.value?.likeCount || 0)
|
const displayLikeCount = computed(() => work.value?.likeCount || 0)
|
||||||
@ -242,6 +259,97 @@ const statusTextMap: Record<string, string> = {
|
|||||||
|
|
||||||
const formatDate = (d: string) => dayjs(d).format('YYYY-MM-DD')
|
const formatDate = (d: string) => dayjs(d).format('YYYY-MM-DD')
|
||||||
|
|
||||||
|
/** 从接口对象上解析乐读派 remoteWorkId(兼容 camelCase / snake_case) */
|
||||||
|
function pickRemoteWorkId(obj: Record<string, unknown> | null | undefined): string | null {
|
||||||
|
if (!obj) return null
|
||||||
|
const v = obj.remoteWorkId ?? obj.remote_work_id
|
||||||
|
if (v == null || v === '') return null
|
||||||
|
const s = String(v).trim()
|
||||||
|
return s || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 ai_meta 中解析乐读派作品 ID(与创作流程落库字段对齐) */
|
||||||
|
function pickRemoteWorkIdFromAiMeta(aiMeta: unknown): string | null {
|
||||||
|
if (aiMeta == null) return null
|
||||||
|
let o: Record<string, unknown>
|
||||||
|
if (typeof aiMeta === 'string') {
|
||||||
|
try {
|
||||||
|
o = JSON.parse(aiMeta) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} else if (typeof aiMeta === 'object') {
|
||||||
|
o = aiMeta as Record<string, unknown>
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const v =
|
||||||
|
o.remoteWorkId ??
|
||||||
|
o.remote_work_id ??
|
||||||
|
o.workId ??
|
||||||
|
o.work_id ??
|
||||||
|
o.leaiWorkId
|
||||||
|
if (v == null || v === '') return null
|
||||||
|
const s = String(v).trim()
|
||||||
|
return s || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析乐读派 remoteWorkId(与创作页 resume 一致:必须为乐读派 workId,不能误用本地数字 id)。
|
||||||
|
* 顺序:详情字段 → aiMeta → GET /public/creation/{本地id}/status
|
||||||
|
*/
|
||||||
|
async function resolveLeaiRemoteWorkId(w: UserWork): Promise<string | null> {
|
||||||
|
const fromRow = pickRemoteWorkId(w as unknown as Record<string, unknown>)
|
||||||
|
if (fromRow) return fromRow
|
||||||
|
const fromMeta = pickRemoteWorkIdFromAiMeta(w.aiMeta)
|
||||||
|
if (fromMeta) return fromMeta
|
||||||
|
const localId = typeof w.id === 'number' && !Number.isNaN(w.id) ? w.id : null
|
||||||
|
if (localId == null) return null
|
||||||
|
try {
|
||||||
|
const st = await publicCreationApi.getStatus(localId)
|
||||||
|
const rw = st?.remoteWorkId
|
||||||
|
if (rw != null && String(rw).trim()) return String(rw).trim()
|
||||||
|
} catch {
|
||||||
|
/* 忽略,由调用方提示 */
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 作品库详情接口可能返回 { work, pages },与广场扁平结构统一 */
|
||||||
|
function normalizeMyWorkDetail(raw: unknown): UserWork | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const o = raw as Record<string, unknown>
|
||||||
|
if (o.work && typeof o.work === 'object') {
|
||||||
|
const w = o.work as Record<string, unknown>
|
||||||
|
const pages = (Array.isArray(o.pages) ? o.pages : []).map((p: unknown) => {
|
||||||
|
const row = p as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
id: row.id as number,
|
||||||
|
workId: (row.workId ?? w.id) as number,
|
||||||
|
pageNo: row.pageNo as number,
|
||||||
|
imageUrl: (row.imageUrl ?? null) as string | null,
|
||||||
|
text: (row.text ?? null) as string | null,
|
||||||
|
audioUrl: (row.audioUrl ?? null) as string | null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const rw = pickRemoteWorkId(w)
|
||||||
|
const base = { ...(w as unknown as UserWork), pages }
|
||||||
|
if (rw && !base.remoteWorkId) base.remoteWorkId = rw
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return raw as UserWork
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 详情接口扁平或嵌套返回时,统一补齐 remoteWorkId */
|
||||||
|
function ensureRemoteWorkIdOnWork(raw: unknown, w: UserWork | null) {
|
||||||
|
if (!w || !raw || typeof raw !== 'object') return
|
||||||
|
if (w.remoteWorkId) return
|
||||||
|
const o = raw as Record<string, unknown>
|
||||||
|
const nested = o.work && typeof o.work === 'object' ? (o.work as Record<string, unknown>) : null
|
||||||
|
const rw = pickRemoteWorkId(nested) || pickRemoteWorkId(o)
|
||||||
|
if (rw) w.remoteWorkId = rw
|
||||||
|
}
|
||||||
|
|
||||||
const prevPage = () => { if (currentPageIndex.value > 0) currentPageIndex.value-- }
|
const prevPage = () => { if (currentPageIndex.value > 0) currentPageIndex.value-- }
|
||||||
const nextPage = () => { if (work.value?.pages && currentPageIndex.value < work.value.pages.length - 1) currentPageIndex.value++ }
|
const nextPage = () => { if (work.value?.pages && currentPageIndex.value < work.value.pages.length - 1) currentPageIndex.value++ }
|
||||||
|
|
||||||
@ -288,36 +396,54 @@ const handleFavorite = async () => {
|
|||||||
|
|
||||||
// ─── 作者操作 ───
|
// ─── 作者操作 ───
|
||||||
|
|
||||||
const isMock = computed(() => isDev && work.value && isMockWorkId(work.value.id))
|
/** 提交审核:unpublished → pending_review */
|
||||||
|
|
||||||
/** 公开发布:unpublished → pending_review */
|
|
||||||
async function handlePublish() {
|
async function handlePublish() {
|
||||||
if (!work.value) return
|
if (!work.value) return
|
||||||
actionLoading.value = true
|
actionLoading.value = true
|
||||||
try {
|
try {
|
||||||
if (isMock.value) {
|
await publicUserWorksApi.publish(workId)
|
||||||
await new Promise(r => setTimeout(r, 300))
|
|
||||||
} else {
|
|
||||||
await publicUserWorksApi.publish(workId)
|
|
||||||
}
|
|
||||||
work.value.status = 'pending_review'
|
work.value.status = 'pending_review'
|
||||||
message.success('已提交审核,等待超管确认')
|
message.success('已提交审核,等待超管确认')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || '发布失败')
|
message.error(e.message || '提交审核失败')
|
||||||
} finally {
|
} finally {
|
||||||
actionLoading.value = false
|
actionLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 修改后重交:rejected → 跳到编辑信息页 */
|
/** 修改后重交:rejected → 编辑信息页 */
|
||||||
function handleResubmit() {
|
async function handleResubmit() {
|
||||||
// TODO: 真实场景需要 leai workId 跳到 EditInfoView,等后端 work.leaiWorkId 字段确认后接入
|
if (!work.value) return
|
||||||
message.info('编辑功能待后端联调,dev 模式暂无法跳转')
|
actionLoading.value = true
|
||||||
|
try {
|
||||||
|
const rw = await resolveLeaiRemoteWorkId(work.value)
|
||||||
|
if (!rw) {
|
||||||
|
message.warning('暂无乐读派作品信息,请从创作流程进入')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
work.value.remoteWorkId = rw
|
||||||
|
router.push(`/p/create/edit-info/${encodeURIComponent(rw)}`)
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 继续创作:draft → 跳回创作流程 */
|
/** 草稿编辑:带乐读派 resumeWorkId 进入欢迎页,与 resumeLeaiWorkFromApi 对齐(先解析再跳转,避免把本地 id 当乐读派 id) */
|
||||||
function handleContinue() {
|
async function handleContinue() {
|
||||||
router.push('/p/create')
|
if (!work.value) return
|
||||||
|
actionLoading.value = true
|
||||||
|
try {
|
||||||
|
const rw = await resolveLeaiRemoteWorkId(work.value)
|
||||||
|
if (rw) {
|
||||||
|
work.value.remoteWorkId = rw
|
||||||
|
router.push({ name: 'PublicCreateWelcome', query: { resumeWorkId: rw } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message.warning('暂无乐读派作品信息,将打开创作首页')
|
||||||
|
router.push({ name: 'PublicCreateWelcome' })
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 撤回审核:pending_review → unpublished */
|
/** 撤回审核:pending_review → unpublished */
|
||||||
@ -330,15 +456,9 @@ function handleWithdraw() {
|
|||||||
if (!work.value) return
|
if (!work.value) return
|
||||||
actionLoading.value = true
|
actionLoading.value = true
|
||||||
try {
|
try {
|
||||||
if (isMock.value) {
|
// TODO: 后端需要新增 POST /public/works/{id}/withdraw 接口
|
||||||
await new Promise(r => setTimeout(r, 300))
|
message.warning('撤回接口待后端联调')
|
||||||
} else {
|
return
|
||||||
// TODO: 后端需要新增 POST /public/works/{id}/withdraw 接口
|
|
||||||
message.warning('撤回接口待后端联调')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
work.value.status = 'unpublished'
|
|
||||||
message.success('已撤回审核')
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || '撤回失败')
|
message.error(e.message || '撤回失败')
|
||||||
} finally {
|
} finally {
|
||||||
@ -358,15 +478,9 @@ function handleUnpublish() {
|
|||||||
if (!work.value) return
|
if (!work.value) return
|
||||||
actionLoading.value = true
|
actionLoading.value = true
|
||||||
try {
|
try {
|
||||||
if (isMock.value) {
|
// TODO: 后端需要新增 POST /public/works/{id}/unpublish 接口
|
||||||
await new Promise(r => setTimeout(r, 300))
|
message.warning('下架接口待后端联调')
|
||||||
} else {
|
return
|
||||||
// TODO: 后端需要新增 POST /public/works/{id}/unpublish 接口
|
|
||||||
message.warning('下架接口待后端联调')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
work.value.status = 'unpublished'
|
|
||||||
message.success('已下架到「未发布」')
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || '下架失败')
|
message.error(e.message || '下架失败')
|
||||||
} finally {
|
} finally {
|
||||||
@ -376,12 +490,6 @@ function handleUnpublish() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 编辑信息:跳到 EditInfoView */
|
|
||||||
function handleEditInfo() {
|
|
||||||
// TODO: 真实场景需要 work.leaiWorkId 字段,等后端确认后接入
|
|
||||||
message.info('编辑信息功能待后端联调')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 删除作品 */
|
/** 删除作品 */
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
showConfirm(
|
showConfirm(
|
||||||
@ -391,11 +499,7 @@ function handleDelete() {
|
|||||||
async () => {
|
async () => {
|
||||||
actionLoading.value = true
|
actionLoading.value = true
|
||||||
try {
|
try {
|
||||||
if (isMock.value) {
|
await publicUserWorksApi.delete(workId)
|
||||||
await new Promise(r => setTimeout(r, 300))
|
|
||||||
} else {
|
|
||||||
await publicUserWorksApi.delete(workId)
|
|
||||||
}
|
|
||||||
message.success('已删除')
|
message.success('已删除')
|
||||||
router.push('/p/works')
|
router.push('/p/works')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@ -437,22 +541,16 @@ function handleConfirmCancel() {
|
|||||||
const fetchWork = async () => {
|
const fetchWork = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
// dev 兜底:mock id 直接用 mock 数据
|
|
||||||
if (isDev && isMockWorkId(workId)) {
|
|
||||||
const mock = getMockWorkDetail(workId)
|
|
||||||
if (mock) {
|
|
||||||
work.value = mock
|
|
||||||
loading.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 优先尝试广场接口(公开作品),失败再尝试自己作品库
|
// 优先尝试广场接口(公开作品),失败再尝试自己作品库
|
||||||
try {
|
try {
|
||||||
work.value = await publicGalleryApi.detail(workId)
|
const rawGallery = await publicGalleryApi.detail(workId)
|
||||||
|
work.value = rawGallery as UserWork
|
||||||
|
ensureRemoteWorkIdOnWork(rawGallery, work.value)
|
||||||
} catch {
|
} catch {
|
||||||
work.value = await publicUserWorksApi.detail(workId)
|
const raw = await publicUserWorksApi.detail(workId)
|
||||||
|
work.value = normalizeMyWorkDetail(raw) ?? (raw as UserWork)
|
||||||
|
ensureRemoteWorkIdOnWork(raw, work.value)
|
||||||
}
|
}
|
||||||
if (isLoggedIn.value) {
|
if (isLoggedIn.value) {
|
||||||
try {
|
try {
|
||||||
@ -460,17 +558,7 @@ const fetchWork = async () => {
|
|||||||
} catch { /* 忽略 */ }
|
} catch { /* 忽略 */ }
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// dev 兜底:真实接口失败时尝试 mock 数据
|
message.error('获取作品详情失败')
|
||||||
if (isDev) {
|
|
||||||
const mock = getMockWorkDetail(workId) || getMockWorkDetail(101)
|
|
||||||
if (mock) {
|
|
||||||
work.value = mock
|
|
||||||
} else {
|
|
||||||
message.error('获取作品详情失败')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
message.error('获取作品详情失败')
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user