feat: 公众端绘本创作流程与作品展示优化,乐读派同步及封面回填迁移

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-10 17:09:15 +08:00
parent 430eba6bd6
commit 1862204ac5
19 changed files with 1412 additions and 850 deletions

View File

@ -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());
} }

View File

@ -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) {

View File

@ -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

View File

@ -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)
} }
/** 额度校验 */ /** 额度校验 */

View File

@ -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;

View File

@ -4,81 +4,81 @@
* phone/orgId/appSecret localStorage * phone/orgId/appSecret localStorage
* orgId sessionStoragesessionToken * orgId sessionStoragesessionToken
*/ */
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,
};
});

View 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;
}
}

View File

@ -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:

View File

@ -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>

View File

@ -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

View File

@ -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;

View File

@ -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>

View File

@ -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)

View File

@ -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 idleai workId id // TODO: DB idleai 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
} }

View File

@ -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
} }

View File

@ -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>

View File

@ -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 () => { /** 作品库「编辑」传入的 resumeWorkIdquery 可能已解码,容错二次 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;

View File

@ -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
} }