fix: 公众端创作链路恢复与页面加载(resumeWorkId、onMounted、extract 解析)
Made-with: Cursor
This commit is contained in:
parent
65a8e0b127
commit
fe210b52ee
@ -130,6 +130,8 @@ public class PublicGalleryService {
|
|||||||
result.put("coverUrl", work.getCoverUrl());
|
result.put("coverUrl", work.getCoverUrl());
|
||||||
result.put("description", work.getDescription());
|
result.put("description", work.getDescription());
|
||||||
result.put("status", work.getStatus());
|
result.put("status", work.getStatus());
|
||||||
|
// 乐读派侧创作阶段(与 t_ugc_work.leai_status 一致,供公众端详情展示/继续创作判断)
|
||||||
|
result.put("leaiStatus", work.getLeaiStatus());
|
||||||
result.put("viewCount", (work.getViewCount() != null ? work.getViewCount() : 0) + 1);
|
result.put("viewCount", (work.getViewCount() != null ? work.getViewCount() : 0) + 1);
|
||||||
result.put("likeCount", work.getLikeCount());
|
result.put("likeCount", work.getLikeCount());
|
||||||
result.put("favoriteCount", work.getFavoriteCount());
|
result.put("favoriteCount", work.getFavoriteCount());
|
||||||
|
|||||||
@ -510,6 +510,8 @@ export interface UserWork {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
visibility: string;
|
visibility: string;
|
||||||
status: WorkStatus;
|
status: WorkStatus;
|
||||||
|
/** 乐读派创作阶段(整型,与库表 leai_status 一致;如 2 表示创作中) */
|
||||||
|
leaiStatus?: number | null;
|
||||||
/**
|
/**
|
||||||
* 审核备注:与超管 `POST /content-review/works/{id}/reject` 请求体 `reason`(及可选 `note`)
|
* 审核备注:与超管 `POST /content-review/works/{id}/reject` 请求体 `reason`(及可选 `note`)
|
||||||
* 落库的 `review_note` 一致;驳回后作者在作品详情见「审核拒绝原因」。
|
* 落库的 `review_note` 一致;驳回后作者在作品详情见「审核拒绝原因」。
|
||||||
|
|||||||
@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* AI 绘本创作流程状态(模块级单例 ref,非持久化)
|
||||||
|
* 与 useAicreateStore(仅会话 + Tab 路由)分离,避免与 localStorage 等「缓存」混在一起。
|
||||||
|
*/
|
||||||
|
import { ref, type Ref } from "vue";
|
||||||
|
|
||||||
|
const imageUrl = ref("");
|
||||||
|
const extractId = ref("");
|
||||||
|
const characters = ref<any[]>([]);
|
||||||
|
const selectedCharacter = ref<any>(null);
|
||||||
|
const selectedStyle = ref("");
|
||||||
|
const storyData = ref<any>(null);
|
||||||
|
const workId = ref("");
|
||||||
|
const originalWorkId = ref("");
|
||||||
|
const workDetail = ref<any>(null);
|
||||||
|
|
||||||
|
export function resetCreation() {
|
||||||
|
imageUrl.value = "";
|
||||||
|
extractId.value = "";
|
||||||
|
characters.value = [];
|
||||||
|
selectedCharacter.value = null;
|
||||||
|
selectedStyle.value = "";
|
||||||
|
storyData.value = null;
|
||||||
|
workId.value = "";
|
||||||
|
originalWorkId.value = "";
|
||||||
|
workDetail.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开发模式:填充 mock 角色数据
|
||||||
|
* @param count 角色数量(1-3)
|
||||||
|
*/
|
||||||
|
export function fillMockData(count: number = 3) {
|
||||||
|
const mockSvg = (hue: number) =>
|
||||||
|
"data:image/svg+xml;charset=utf-8," +
|
||||||
|
encodeURIComponent(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">` +
|
||||||
|
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
|
||||||
|
`<stop offset="0" stop-color="hsl(${hue},75%,72%)"/>` +
|
||||||
|
`<stop offset="1" stop-color="hsl(${(hue + 35) % 360},80%,58%)"/>` +
|
||||||
|
`</linearGradient></defs>` +
|
||||||
|
`<rect width="240" height="240" fill="url(#g)"/>` +
|
||||||
|
`</svg>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
imageUrl.value = mockSvg(250);
|
||||||
|
extractId.value = "mock-extract-" + Date.now();
|
||||||
|
selectedCharacter.value = null;
|
||||||
|
|
||||||
|
const allChars = [
|
||||||
|
{ charId: "mock-c1", type: "HERO", originalCropUrl: mockSvg(280) },
|
||||||
|
{ charId: "mock-c2", type: "SIDEKICK", originalCropUrl: mockSvg(30) },
|
||||||
|
{ charId: "mock-c3", type: "SIDEKICK", originalCropUrl: mockSvg(100) },
|
||||||
|
];
|
||||||
|
const n = Math.max(1, Math.min(count, allChars.length));
|
||||||
|
characters.value = allChars.slice(0, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开发模式:填充 mock 作品详情(预览/编目等 UI 调试) */
|
||||||
|
export function fillMockWorkDetail() {
|
||||||
|
const mockPage = (hue: number) =>
|
||||||
|
"data:image/svg+xml;charset=utf-8," +
|
||||||
|
encodeURIComponent(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="800" height="450" viewBox="0 0 800 450">` +
|
||||||
|
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
|
||||||
|
`<stop offset="0" stop-color="hsl(${hue},70%,75%)"/>` +
|
||||||
|
`<stop offset="1" stop-color="hsl(${(hue + 45) % 360},75%,55%)"/>` +
|
||||||
|
`</linearGradient></defs>` +
|
||||||
|
`<rect width="800" height="450" fill="url(#g)"/>` +
|
||||||
|
`</svg>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pageTexts = [
|
||||||
|
"",
|
||||||
|
"一个阳光明媚的早晨,小主角推开窗,看见远处的森林闪着金光。",
|
||||||
|
"它沿着小路走啊走,遇到了一只迷路的小鸟,羽毛湿漉漉的。",
|
||||||
|
"小主角轻轻抱起小鸟,决定送它回家。",
|
||||||
|
"路过一片野花田时,一只小狐狸从草丛里跳出来打招呼。",
|
||||||
|
"小狐狸说它认识森林里所有的小路,愿意做大家的向导。",
|
||||||
|
"三个朋友来到一条清澈的溪水边,溪里有一群闪闪发光的小鱼。",
|
||||||
|
"小鱼们告诉他们,那棵会发光的大树就在前方不远处。",
|
||||||
|
"森林深处真的有一棵会发光的大树,挂满了亮晶晶的果实。",
|
||||||
|
"原来这就是小鸟的家,妈妈正在树枝上焦急地张望。",
|
||||||
|
"小鸟欢快地飞回妈妈身边,鸟妈妈给大家每人一颗果实。",
|
||||||
|
"夕阳下,小主角和小狐狸告别,小狐狸送他们到森林边缘。",
|
||||||
|
"小主角带着这份美好回到家,心里也开出了一朵花。",
|
||||||
|
];
|
||||||
|
|
||||||
|
const wid = "mock-work-" + Date.now();
|
||||||
|
workId.value = wid;
|
||||||
|
workDetail.value = {
|
||||||
|
workId: wid,
|
||||||
|
status: 3,
|
||||||
|
title: storyData.value?.title || "森林大冒险",
|
||||||
|
subtitle: "",
|
||||||
|
author: "",
|
||||||
|
coverUrl: mockPage(280),
|
||||||
|
pageList: pageTexts.map((text, i) => ({
|
||||||
|
pageNum: i,
|
||||||
|
text,
|
||||||
|
imageUrl: mockPage((280 + i * 27) % 360),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAicreateCreation(): {
|
||||||
|
imageUrl: Ref<string>;
|
||||||
|
extractId: Ref<string>;
|
||||||
|
characters: Ref<any[]>;
|
||||||
|
selectedCharacter: Ref<any>;
|
||||||
|
selectedStyle: Ref<string>;
|
||||||
|
storyData: Ref<any>;
|
||||||
|
workId: Ref<string>;
|
||||||
|
originalWorkId: Ref<string>;
|
||||||
|
workDetail: Ref<any>;
|
||||||
|
resetCreation: typeof resetCreation;
|
||||||
|
fillMockData: typeof fillMockData;
|
||||||
|
fillMockWorkDetail: typeof fillMockWorkDetail;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
imageUrl,
|
||||||
|
extractId,
|
||||||
|
characters,
|
||||||
|
selectedCharacter,
|
||||||
|
selectedStyle,
|
||||||
|
storyData,
|
||||||
|
workId,
|
||||||
|
originalWorkId,
|
||||||
|
workDetail,
|
||||||
|
resetCreation,
|
||||||
|
fillMockData,
|
||||||
|
fillMockWorkDetail,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供 resumeLeaiWork 等工具直接写入(与 useAicreateCreation() 为同一组 ref) */
|
||||||
|
export function getCreationFlowRefs() {
|
||||||
|
return {
|
||||||
|
imageUrl,
|
||||||
|
extractId,
|
||||||
|
characters,
|
||||||
|
selectedCharacter,
|
||||||
|
selectedStyle,
|
||||||
|
storyData,
|
||||||
|
workId,
|
||||||
|
originalWorkId,
|
||||||
|
workDetail,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,34 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* AI 创作全局状态(Pinia Store)
|
* AI 创作:Pinia 仅保存乐读派会话与「创作」Tab 子路由记忆。
|
||||||
*
|
* 创作业务数据见 {@link useAicreateCreation}。
|
||||||
* 敏感信息(phone/orgId/appSecret)不再存储在 localStorage
|
|
||||||
* 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";
|
|
||||||
|
|
||||||
export const useAicreateStore = defineStore("aicreate", () => {
|
export const useAicreateStore = defineStore("aicreate", () => {
|
||||||
// ─── 认证信息(不再存储敏感信息到 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 extractId = ref("");
|
|
||||||
const characters = ref<any[]>([]);
|
|
||||||
const selectedCharacter = ref<any>(null);
|
|
||||||
const selectedStyle = ref("");
|
|
||||||
const storyData = ref<any>(null);
|
|
||||||
const workId = ref("");
|
|
||||||
/** extract 接口可能返回的 workId,供下游使用 */
|
|
||||||
const originalWorkId = ref("");
|
|
||||||
const workDetail = ref<any>(null);
|
|
||||||
|
|
||||||
// ─── 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;
|
||||||
@ -51,165 +33,11 @@ export const useAicreateStore = defineStore("aicreate", () => {
|
|||||||
lastCreateRoute.value = "";
|
lastCreateRoute.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
|
||||||
imageUrl.value = "";
|
|
||||||
extractId.value = "";
|
|
||||||
characters.value = [];
|
|
||||||
selectedCharacter.value = null;
|
|
||||||
selectedStyle.value = "";
|
|
||||||
storyData.value = null;
|
|
||||||
workId.value = "";
|
|
||||||
originalWorkId.value = "";
|
|
||||||
workDetail.value = null;
|
|
||||||
lastCreateRoute.value = "";
|
|
||||||
// 只清除创作流程数据,保留认证信息
|
|
||||||
localStorage.removeItem("le_workId");
|
|
||||||
// 清除 sessionStorage 中的恢复数据
|
|
||||||
sessionStorage.removeItem("le_recovery");
|
|
||||||
clearExtractDraft();
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveRecoveryState() {
|
|
||||||
const recovery = {
|
|
||||||
path: window.location.pathname || "/",
|
|
||||||
workId: workId.value || localStorage.getItem("le_workId") || "",
|
|
||||||
imageUrl: imageUrl.value || "",
|
|
||||||
extractId: extractId.value || "",
|
|
||||||
selectedStyle: selectedStyle.value || "",
|
|
||||||
savedAt: Date.now(),
|
|
||||||
};
|
|
||||||
sessionStorage.setItem("le_recovery", JSON.stringify(recovery));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 开发模式:填充一份 mock 数据,用于跳过真实后端调用走通 UI 流程
|
|
||||||
* 仅供开发期 UI 调试使用,不要在生产逻辑中调用
|
|
||||||
* @param count 要 mock 的角色数量(1-3),默认 3
|
|
||||||
*/
|
|
||||||
function fillMockData(count: number = 3) {
|
|
||||||
// 纯渐变占位图(不带文字,模拟真实 AI 抠图返回的角色形象)
|
|
||||||
const mockSvg = (hue: number) =>
|
|
||||||
"data:image/svg+xml;charset=utf-8," +
|
|
||||||
encodeURIComponent(
|
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">` +
|
|
||||||
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
|
|
||||||
`<stop offset="0" stop-color="hsl(${hue},75%,72%)"/>` +
|
|
||||||
`<stop offset="1" stop-color="hsl(${(hue + 35) % 360},80%,58%)"/>` +
|
|
||||||
`</linearGradient></defs>` +
|
|
||||||
`<rect width="240" height="240" fill="url(#g)"/>` +
|
|
||||||
`</svg>`,
|
|
||||||
);
|
|
||||||
|
|
||||||
imageUrl.value = mockSvg(250);
|
|
||||||
extractId.value = "mock-extract-" + Date.now();
|
|
||||||
selectedCharacter.value = null;
|
|
||||||
|
|
||||||
// 注意:真实 AI 接口不返回 name 字段,mock 数据也不写 name,由用户在 StoryInputView 自己起名
|
|
||||||
const allChars = [
|
|
||||||
{ charId: "mock-c1", type: "HERO", originalCropUrl: mockSvg(280) },
|
|
||||||
{ charId: "mock-c2", type: "SIDEKICK", originalCropUrl: mockSvg(30) },
|
|
||||||
{ charId: "mock-c3", type: "SIDEKICK", originalCropUrl: mockSvg(100) },
|
|
||||||
];
|
|
||||||
const n = Math.max(1, Math.min(count, allChars.length));
|
|
||||||
characters.value = allChars.slice(0, n);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 开发模式:填充一份完整的 mock 作品数据,用于跳过真实 AI 生成走通预览/编辑/发布等下游 UI
|
|
||||||
* 仅供开发期 UI 调试使用
|
|
||||||
*/
|
|
||||||
function fillMockWorkDetail() {
|
|
||||||
// 16:9 渐变占位图(800x450),模拟真实绘本插画
|
|
||||||
const mockPage = (hue: number) =>
|
|
||||||
"data:image/svg+xml;charset=utf-8," +
|
|
||||||
encodeURIComponent(
|
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" width="800" height="450" viewBox="0 0 800 450">` +
|
|
||||||
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
|
|
||||||
`<stop offset="0" stop-color="hsl(${hue},70%,75%)"/>` +
|
|
||||||
`<stop offset="1" stop-color="hsl(${(hue + 45) % 360},75%,55%)"/>` +
|
|
||||||
`</linearGradient></defs>` +
|
|
||||||
`<rect width="800" height="450" fill="url(#g)"/>` +
|
|
||||||
`</svg>`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 13 页(封面 + 12 内页),模拟真实绘本规模,便于测试横向胶卷式缩略图
|
|
||||||
const pageTexts = [
|
|
||||||
"", // 封面
|
|
||||||
"一个阳光明媚的早晨,小主角推开窗,看见远处的森林闪着金光。",
|
|
||||||
"它沿着小路走啊走,遇到了一只迷路的小鸟,羽毛湿漉漉的。",
|
|
||||||
"小主角轻轻抱起小鸟,决定送它回家。",
|
|
||||||
"路过一片野花田时,一只小狐狸从草丛里跳出来打招呼。",
|
|
||||||
"小狐狸说它认识森林里所有的小路,愿意做大家的向导。",
|
|
||||||
"三个朋友来到一条清澈的溪水边,溪里有一群闪闪发光的小鱼。",
|
|
||||||
"小鱼们告诉他们,那棵会发光的大树就在前方不远处。",
|
|
||||||
"森林深处真的有一棵会发光的大树,挂满了亮晶晶的果实。",
|
|
||||||
"原来这就是小鸟的家,妈妈正在树枝上焦急地张望。",
|
|
||||||
"小鸟欢快地飞回妈妈身边,鸟妈妈给大家每人一颗果实。",
|
|
||||||
"夕阳下,小主角和小狐狸告别,小狐狸送他们到森林边缘。",
|
|
||||||
"小主角带着这份美好回到家,心里也开出了一朵花。",
|
|
||||||
];
|
|
||||||
|
|
||||||
const wid = "mock-work-" + Date.now();
|
|
||||||
workId.value = wid;
|
|
||||||
workDetail.value = {
|
|
||||||
workId: wid,
|
|
||||||
status: 3, // COMPLETED
|
|
||||||
title: storyData.value?.title || "森林大冒险",
|
|
||||||
subtitle: "",
|
|
||||||
author: "",
|
|
||||||
coverUrl: mockPage(280),
|
|
||||||
pageList: pageTexts.map((text, i) => ({
|
|
||||||
pageNum: i,
|
|
||||||
text,
|
|
||||||
imageUrl: mockPage((280 + i * 27) % 360),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreRecoveryState() {
|
|
||||||
const raw = sessionStorage.getItem("le_recovery");
|
|
||||||
if (!raw) return null;
|
|
||||||
try {
|
|
||||||
const recovery = JSON.parse(raw);
|
|
||||||
if (Date.now() - recovery.savedAt > 30 * 60 * 1000) {
|
|
||||||
sessionStorage.removeItem("le_recovery");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (recovery.workId) workId.value = recovery.workId;
|
|
||||||
if (recovery.imageUrl) imageUrl.value = recovery.imageUrl;
|
|
||||||
if (recovery.extractId) extractId.value = recovery.extractId;
|
|
||||||
if (recovery.selectedStyle) selectedStyle.value = recovery.selectedStyle;
|
|
||||||
sessionStorage.removeItem("le_recovery");
|
|
||||||
return recovery;
|
|
||||||
} catch {
|
|
||||||
sessionStorage.removeItem("le_recovery");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 认证
|
|
||||||
orgId,
|
orgId,
|
||||||
sessionToken,
|
sessionToken,
|
||||||
setSession,
|
setSession,
|
||||||
clearSession,
|
clearSession,
|
||||||
// 创作流程
|
|
||||||
imageUrl,
|
|
||||||
extractId,
|
|
||||||
characters,
|
|
||||||
selectedCharacter,
|
|
||||||
selectedStyle,
|
|
||||||
storyData,
|
|
||||||
workId,
|
|
||||||
originalWorkId,
|
|
||||||
workDetail,
|
|
||||||
reset,
|
|
||||||
saveRecoveryState,
|
|
||||||
restoreRecoveryState,
|
|
||||||
// 开发模式
|
|
||||||
fillMockData,
|
|
||||||
fillMockWorkDetail,
|
|
||||||
// Tab 切换状态
|
|
||||||
lastCreateRoute,
|
lastCreateRoute,
|
||||||
setLastCreateRoute,
|
setLastCreateRoute,
|
||||||
clearLastCreateRoute,
|
clearLastCreateRoute,
|
||||||
|
|||||||
@ -1,60 +0,0 @@
|
|||||||
/**
|
|
||||||
* 角色提取(extract)结果本地草稿:用于断线后继续创作,10 天过期
|
|
||||||
*/
|
|
||||||
const STORAGE_KEY = 'le_extract_draft'
|
|
||||||
const TEN_DAYS_MS = 10 * 24 * 60 * 60 * 1000
|
|
||||||
|
|
||||||
export interface ExtractDraftPayload {
|
|
||||||
savedAt: number
|
|
||||||
imageUrl: string
|
|
||||||
extractId: string
|
|
||||||
characters: any[]
|
|
||||||
/** 接口原始响应,便于扩展 */
|
|
||||||
raw?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveExtractDraft(
|
|
||||||
payload: Omit<ExtractDraftPayload, 'savedAt'> & { savedAt?: number }
|
|
||||||
): void {
|
|
||||||
const data: ExtractDraftPayload = {
|
|
||||||
savedAt: payload.savedAt ?? Date.now(),
|
|
||||||
imageUrl: payload.imageUrl,
|
|
||||||
extractId: payload.extractId ?? '',
|
|
||||||
characters: payload.characters ?? [],
|
|
||||||
...(payload.raw !== undefined ? { raw: payload.raw } : {}),
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
|
|
||||||
} catch {
|
|
||||||
// quota / 隐私模式等忽略
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 未过期返回草稿并校验字段;过期或损坏则删除并返回 null */
|
|
||||||
export function loadExtractDraft(): ExtractDraftPayload | null {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
|
||||||
if (!raw) return null
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(raw) as ExtractDraftPayload
|
|
||||||
if (!data.savedAt || Date.now() - data.savedAt > TEN_DAYS_MS) {
|
|
||||||
localStorage.removeItem(STORAGE_KEY)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (!data.imageUrl || !Array.isArray(data.characters)) {
|
|
||||||
localStorage.removeItem(STORAGE_KEY)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
} catch {
|
|
||||||
localStorage.removeItem(STORAGE_KEY)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearExtractDraft(): void {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(STORAGE_KEY)
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,13 +2,14 @@
|
|||||||
* 根据乐读派作品详情恢复创作环节(对应上游 B2 query/work,经 /leai-proxy/work/{id})
|
* 根据乐读派作品详情恢复创作环节(对应上游 B2 query/work,经 /leai-proxy/work/{id})
|
||||||
*/
|
*/
|
||||||
import type { Router } from "vue-router";
|
import type { Router } from "vue-router";
|
||||||
|
import type { Ref } from "vue";
|
||||||
import { getWorkDetail } from "@/api/aicreate";
|
import { getWorkDetail } from "@/api/aicreate";
|
||||||
import { STATUS, getRouteByStatus } from "@/utils/aicreate/status";
|
import { getResumeNavigationByStatus } from "@/utils/aicreate/status";
|
||||||
import { clearExtractDraft } from "@/utils/aicreate/extractDraft";
|
import { getCreationFlowRefs } from "@/composables/useAicreateCreation";
|
||||||
|
|
||||||
type AicreateStoreLike = {
|
export type ResumeFlowWritable = {
|
||||||
workId: string;
|
workId: Ref<string>;
|
||||||
workDetail: any;
|
workDetail: Ref<any>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseWorkPayload(res: unknown): Record<string, any> | null {
|
function parseWorkPayload(res: unknown): Record<string, any> | null {
|
||||||
@ -20,13 +21,13 @@ function parseWorkPayload(res: unknown): Record<string, any> | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 拉取作品详情、写入 store 与 le_workId,并按 status 跳转到对应子路由。
|
* 拉取作品详情、写入创作流 refs,并按状态二选一跳转(创作中 → Creating + query;否则 → Preview)。
|
||||||
* @returns 是否已成功发起跳转(失败时返回 false,调用方可继续其它恢复逻辑)
|
* @returns 是否已成功发起跳转(失败时返回 false,调用方可继续其它恢复逻辑)
|
||||||
*/
|
*/
|
||||||
export async function resumeLeaiWorkFromApi(
|
export async function resumeLeaiWorkFromApi(
|
||||||
workId: string,
|
workId: string,
|
||||||
router: Router,
|
router: Router,
|
||||||
store: AicreateStoreLike,
|
flow: ResumeFlowWritable = getCreationFlowRefs(),
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const id = String(workId || "").trim();
|
const id = String(workId || "").trim();
|
||||||
if (!id) return false;
|
if (!id) return false;
|
||||||
@ -35,43 +36,25 @@ export async function resumeLeaiWorkFromApi(
|
|||||||
const res = await getWorkDetail(id);
|
const res = await getWorkDetail(id);
|
||||||
const work = parseWorkPayload(res);
|
const work = parseWorkPayload(res);
|
||||||
if (!work) {
|
if (!work) {
|
||||||
localStorage.removeItem("le_workId");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wid = String(work.workId ?? id);
|
const wid = String(work.workId ?? id);
|
||||||
store.workId = wid;
|
flow.workId.value = wid;
|
||||||
store.workDetail = work;
|
flow.workDetail.value = work;
|
||||||
localStorage.setItem("le_workId", wid);
|
|
||||||
|
|
||||||
const st = Number(work.status);
|
const rawStatus = work.status;
|
||||||
if (st === STATUS.FAILED) {
|
const statusNum =
|
||||||
clearExtractDraft();
|
rawStatus === null || rawStatus === undefined
|
||||||
await router.replace({
|
? NaN
|
||||||
name: "PublicCreateCreating",
|
: Number(rawStatus);
|
||||||
query: { workId: wid },
|
const target = getResumeNavigationByStatus(
|
||||||
});
|
Number.isFinite(statusNum) ? statusNum : NaN,
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = getRouteByStatus(
|
|
||||||
work.status as Parameters<typeof getRouteByStatus>[0],
|
|
||||||
wid,
|
wid,
|
||||||
);
|
);
|
||||||
if (!route) {
|
await router.replace(target);
|
||||||
clearExtractDraft();
|
|
||||||
await router.replace({
|
|
||||||
name: "PublicCreateCreating",
|
|
||||||
query: { workId: wid },
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearExtractDraft();
|
|
||||||
await router.replace(route);
|
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem("le_workId");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* 创作壳层入口:token 就绪后处理 ?resumeWorkId= 或欢迎页内存 workId
|
||||||
|
*/
|
||||||
|
import type { Router } from "vue-router";
|
||||||
|
import type { RouteLocationNormalizedLoaded } from "vue-router";
|
||||||
|
import { unref, type MaybeRef } from "vue";
|
||||||
|
import { resumeLeaiWorkFromApi, type ResumeFlowWritable } from "./resumeLeaiWork";
|
||||||
|
|
||||||
|
function parseResumeWorkIdFromQuery(raw: string): string {
|
||||||
|
const t = String(raw || "").trim();
|
||||||
|
if (!t) return "";
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(t);
|
||||||
|
} catch {
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let shellEntryRunning = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1) query 含 resumeWorkId:在 /p/create 下即可恢复(不限欢迎页)
|
||||||
|
* 2) 无 resumeWorkId:仅在欢迎页用内存 flow.workId 尝试恢复
|
||||||
|
*/
|
||||||
|
export async function runCreateShellEntry(
|
||||||
|
router: Router,
|
||||||
|
route: RouteLocationNormalizedLoaded,
|
||||||
|
/** Pinia setup store 在组件侧访问 sessionToken 可能已是解包后的 string,须用 unref */
|
||||||
|
sessionToken: MaybeRef<string>,
|
||||||
|
flow: ResumeFlowWritable,
|
||||||
|
): Promise<void> {
|
||||||
|
if (shellEntryRunning) return;
|
||||||
|
|
||||||
|
const tokenReady = () => Boolean(unref(sessionToken));
|
||||||
|
|
||||||
|
const qResume = route.query.resumeWorkId;
|
||||||
|
const resumeFromQuery =
|
||||||
|
typeof qResume === "string"
|
||||||
|
? qResume
|
||||||
|
: Array.isArray(qResume) && qResume[0]
|
||||||
|
? qResume[0]
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (resumeFromQuery) {
|
||||||
|
if (!tokenReady()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!route.path.startsWith("/p/create")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shellEntryRunning = true;
|
||||||
|
try {
|
||||||
|
const decoded = parseResumeWorkIdFromQuery(String(resumeFromQuery));
|
||||||
|
if (!decoded) {
|
||||||
|
await router.replace({ name: "PublicCreateWelcome", query: {} });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ok = await resumeLeaiWorkFromApi(decoded, router, flow);
|
||||||
|
if (!ok) {
|
||||||
|
await router.replace({ name: "PublicCreateWelcome", query: {} });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
shellEntryRunning = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.name !== "PublicCreateWelcome") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tokenReady()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shellEntryRunning = true;
|
||||||
|
try {
|
||||||
|
const wid = flow.workId.value || "";
|
||||||
|
await resumeLeaiWorkFromApi(wid, router, flow);
|
||||||
|
} finally {
|
||||||
|
shellEntryRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,27 +16,51 @@ export const STATUS = {
|
|||||||
|
|
||||||
export type StatusValue = (typeof STATUS)[keyof typeof STATUS];
|
export type StatusValue = (typeof STATUS)[keyof typeof STATUS];
|
||||||
|
|
||||||
|
/** 与 Vue Router replace/push 兼容;Creating 须带 query.workId 便于刷新/分享 */
|
||||||
|
export type CreateFlowRouteTarget = {
|
||||||
|
name: string;
|
||||||
|
params?: Record<string, string>;
|
||||||
|
query?: Record<string, string>;
|
||||||
|
} | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据作品状态决定应导航到的路由
|
* 作品库 / URL ?resumeWorkId= 恢复:仅「创作中 vs 预览」二分
|
||||||
|
* - PENDING / PROCESSING(排队、生成中)→ 创作进度页 CreatingView(带 workId query)
|
||||||
|
* - 其余(含 COMPLETED / CATALOGED / DUBBED / FAILED / 未知)→ 预览页 PreviewView
|
||||||
|
*/
|
||||||
|
export function getResumeNavigationByStatus(
|
||||||
|
status: number,
|
||||||
|
workId: string,
|
||||||
|
): { name: string; params?: Record<string, string>; query?: Record<string, string> } {
|
||||||
|
const wid = String(workId ?? "").trim();
|
||||||
|
const s = Number(status);
|
||||||
|
|
||||||
|
if (s === STATUS.PENDING || s === STATUS.PROCESSING) {
|
||||||
|
return { name: "PublicCreateCreating", query: { workId: wid } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name: "PublicCreatePreview", params: { workId: wid } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据作品状态决定应导航到的路由(创作流程内轮询/WebSocket 完成后的页内跳转)
|
||||||
*/
|
*/
|
||||||
export function getRouteByStatus(
|
export function getRouteByStatus(
|
||||||
status: StatusValue,
|
status: StatusValue,
|
||||||
workId: string,
|
workId: string,
|
||||||
): { name: string; params?: Record<string, string> } | null {
|
): CreateFlowRouteTarget {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case STATUS.PENDING:
|
case STATUS.PENDING:
|
||||||
case STATUS.PROCESSING:
|
case STATUS.PROCESSING:
|
||||||
return { name: "PublicCreateCreating" };
|
return { name: "PublicCreateCreating", query: { workId } };
|
||||||
case STATUS.COMPLETED:
|
case STATUS.COMPLETED:
|
||||||
return { name: "PublicCreatePreview", params: { workId } };
|
return { name: "PublicCreatePreview", params: { workId } };
|
||||||
case STATUS.CATALOGED:
|
case STATUS.CATALOGED:
|
||||||
return { name: "PublicCreatePreview", params: { workId } };
|
return { name: "PublicCreatePreview", params: { workId } };
|
||||||
// return { name: 'PublicCreateDubbing', params: { workId } }
|
|
||||||
case STATUS.DUBBED:
|
case STATUS.DUBBED:
|
||||||
return { name: "PublicCreatePreview", params: { workId } };
|
return { name: "PublicCreatePreview", params: { workId } };
|
||||||
// return { name: 'PublicCreateEditInfo', params: { workId } }
|
|
||||||
case STATUS.FAILED:
|
case STATUS.FAILED:
|
||||||
return null;
|
return { name: "PublicCreateCreating", query: { workId } };
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,12 +13,10 @@ export default { name: 'AiCreateShell' }
|
|||||||
重新加载
|
重新加载
|
||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
<!-- 子路由渲染 -->
|
<!-- 子路由渲染(不 keep-alive,避免创作状态错乱) -->
|
||||||
<router-view v-else v-slot="{ Component }">
|
<router-view v-else v-slot="{ Component }">
|
||||||
<transition name="ai-slide" mode="out-in">
|
<transition name="ai-slide" mode="out-in">
|
||||||
<keep-alive>
|
<component :is="Component" />
|
||||||
<component :is="Component" />
|
|
||||||
</keep-alive>
|
|
||||||
</transition>
|
</transition>
|
||||||
</router-view>
|
</router-view>
|
||||||
</div>
|
</div>
|
||||||
@ -36,14 +34,17 @@ const route = useRoute()
|
|||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
|
|
||||||
// 监听路由变化,保存最后创作路由到 store
|
watch(
|
||||||
watch(() => route.path, (path) => {
|
() => route.path,
|
||||||
if (path.startsWith('/p/create')) {
|
(path) => {
|
||||||
store.setLastCreateRoute(path)
|
if (path.startsWith('/p/create')) {
|
||||||
}
|
store.setLastCreateRoute(path)
|
||||||
}, { immediate: true })
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
/** 获取乐读派 Token 并存入 store */
|
/** 获取乐读派 Token 并存入 store;子路由(如 Welcome)再处理 resumeWorkId / 内存恢复 */
|
||||||
const initToken = async () => {
|
const initToken = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
@ -62,13 +63,10 @@ const initToken = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 乐读派作品恢复(localStorage le_workId、路由 ?resumeWorkId=)在子页 WelcomeView 挂载后执行,
|
|
||||||
// 须先完成 initToken,故不在此壳层重复拉取,避免与 loading 竞态。
|
|
||||||
// 如果 store 中已有有效 token 且 orgId 已初始化,跳过加载
|
|
||||||
if (store.sessionToken && store.orgId) {
|
if (store.sessionToken && store.orgId) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
} else {
|
} else {
|
||||||
initToken()
|
void initToken()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -102,11 +102,13 @@ import {
|
|||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import { useAicreateStore } from '@/stores/aicreate'
|
import { useAicreateStore } from '@/stores/aicreate'
|
||||||
|
import { useAicreateCreation } from '@/composables/useAicreateCreation'
|
||||||
import { getWorkDetail } from '@/api/aicreate'
|
import { getWorkDetail } from '@/api/aicreate'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAicreateStore()
|
const store = useAicreateStore()
|
||||||
|
const { resetCreation } = useAicreateCreation()
|
||||||
|
|
||||||
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'
|
||||||
@ -171,7 +173,7 @@ const onTouchEnd = (e: TouchEvent) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const goHome = () => {
|
const goHome = () => {
|
||||||
store.reset()
|
resetCreation()
|
||||||
router.push('/p/create')
|
router.push('/p/create')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -91,7 +91,7 @@ export default { name: 'CharactersView' }
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onActivated } from 'vue'
|
import { ref, computed, onMounted, onActivated } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
@ -105,12 +105,11 @@ import {
|
|||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
|
|
||||||
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
||||||
import { useAicreateStore } from '@/stores/aicreate'
|
import { useAicreateCreation } from '@/composables/useAicreateCreation'
|
||||||
import { extractCharacters } from '@/api/aicreate'
|
import { extractCharacters } from '@/api/aicreate'
|
||||||
import { saveExtractDraft } from '@/utils/aicreate/extractDraft'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAicreateStore()
|
const flow = useAicreateCreation()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const selected = ref<string | null>(null)
|
const selected = ref<string | null>(null)
|
||||||
const characters = ref<any[]>([])
|
const characters = ref<any[]>([])
|
||||||
@ -135,47 +134,81 @@ const nextLabel = computed(() => {
|
|||||||
return '确定主角,编排故事'
|
return '确定主角,编排故事'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 乐读派 extract-original 响应经 /api 拦截器可能再剥一层;
|
||||||
|
* 仍可能出现 { data: { characters } } 嵌套,与 aicreate 旧版 `const data = res.data || {}` 对齐并加强解包。
|
||||||
|
*/
|
||||||
|
function unwrapExtractResult(raw: unknown): { characters: any[]; extractId: string } {
|
||||||
|
let cur: any = raw
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
if (cur == null || typeof cur !== 'object') break
|
||||||
|
const list =
|
||||||
|
cur.characters ??
|
||||||
|
cur.characterList ??
|
||||||
|
(Array.isArray((cur as any).data) ? (cur as any).data : undefined)
|
||||||
|
if (Array.isArray(list)) {
|
||||||
|
return {
|
||||||
|
characters: list,
|
||||||
|
extractId: String(cur.extractId ?? (cur as any).extract_id ?? ''),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((cur as any).data != null && typeof (cur as any).data === 'object') {
|
||||||
|
cur = (cur as any).data
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return { characters: [], extractId: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCharacterItem(c: any) {
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
charId: c.charId ?? c.id ?? '',
|
||||||
|
originalCropUrl: c.originalCropUrl ?? c.imageUrl ?? c.cropUrl ?? '',
|
||||||
|
type: c.charType || c.type || 'SIDEKICK',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCharacters() {
|
async function loadCharacters() {
|
||||||
if (store.characters && store.characters.length > 0) {
|
error.value = ''
|
||||||
characters.value = store.characters
|
if (flow.characters.value && flow.characters.value.length > 0) {
|
||||||
|
characters.value = flow.characters.value.map(normalizeCharacterItem)
|
||||||
autoSelect()
|
autoSelect()
|
||||||
loading.value = false
|
loading.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!store.imageUrl) {
|
if (!flow.imageUrl.value) {
|
||||||
error.value = '未上传图片,请返回上传'
|
error.value = '未上传图片,请返回上传'
|
||||||
loading.value = false
|
loading.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await extractCharacters(store.imageUrl)
|
const res = await extractCharacters(flow.imageUrl.value)
|
||||||
const data = res || {}
|
const { characters: list, extractId } = unwrapExtractResult(res)
|
||||||
characters.value = (data.characters || []).map((c: any) => ({
|
characters.value = list.map(normalizeCharacterItem)
|
||||||
...c,
|
|
||||||
type: c.charType || c.type || 'SIDEKICK'
|
|
||||||
}))
|
|
||||||
if (characters.value.length === 0) {
|
if (characters.value.length === 0) {
|
||||||
error.value = 'AI 未识别到角色,请更换图片重试'
|
error.value = 'AI 未识别到角色,请更换图片重试'
|
||||||
}
|
}
|
||||||
store.extractId = data.extractId || ''
|
flow.extractId.value = extractId
|
||||||
store.characters = characters.value
|
flow.characters.value = characters.value
|
||||||
autoSelect()
|
autoSelect()
|
||||||
if (characters.value.length > 0) {
|
|
||||||
saveExtractDraft({
|
|
||||||
imageUrl: store.imageUrl,
|
|
||||||
extractId: store.extractId,
|
|
||||||
characters: characters.value,
|
|
||||||
raw: data,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = '角色识别失败:' + (e.message || '请检查网络')
|
const msg =
|
||||||
|
e?.response?.data?.message ||
|
||||||
|
e?.response?.data?.msg ||
|
||||||
|
e?.message ||
|
||||||
|
'请检查网络'
|
||||||
|
error.value = '角色识别失败:' + msg
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadCharacters()
|
||||||
|
})
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
void loadCharacters()
|
void loadCharacters()
|
||||||
})
|
})
|
||||||
@ -194,7 +227,7 @@ const goNext = () => {
|
|||||||
const target = characters.value.length === 1
|
const target = characters.value.length === 1
|
||||||
? characters.value[0]
|
? characters.value[0]
|
||||||
: characters.value.find(c => c.charId === selected.value)
|
: characters.value.find(c => c.charId === selected.value)
|
||||||
store.selectedCharacter = target
|
flow.selectedCharacter.value = target
|
||||||
router.push('/p/create/story')
|
router.push('/p/create/story')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -44,10 +44,10 @@ export default { name: 'CreatingView' }
|
|||||||
<frown-outlined class="error-icon" />
|
<frown-outlined class="error-icon" />
|
||||||
<div class="error-text">{{ error }}</div>
|
<div class="error-text">{{ error }}</div>
|
||||||
<div class="error-actions">
|
<div class="error-actions">
|
||||||
<button v-if="store.workId" class="btn-primary error-btn" @click="resumePolling">
|
<button v-if="workId" class="btn-primary error-btn" @click="resumePolling">
|
||||||
恢复查询进度
|
恢复查询进度
|
||||||
</button>
|
</button>
|
||||||
<button v-if="!isQuotaError" class="btn-primary error-btn" :class="{ 'btn-outline': !!store.workId }"
|
<button v-if="!isQuotaError" class="btn-primary error-btn" :class="{ 'btn-outline': !!workId }"
|
||||||
@click="retry">
|
@click="retry">
|
||||||
重新创作
|
重新创作
|
||||||
</button>
|
</button>
|
||||||
@ -86,13 +86,21 @@ import {
|
|||||||
InboxOutlined,
|
InboxOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import { useAicreateStore } from '@/stores/aicreate'
|
import { useAicreateStore } from '@/stores/aicreate'
|
||||||
|
import { useAicreateCreation } from '@/composables/useAicreateCreation'
|
||||||
import { createStory, getWorkDetail } from '@/api/aicreate'
|
import { createStory, getWorkDetail } from '@/api/aicreate'
|
||||||
import { STATUS, getRouteByStatus } from '@/utils/aicreate/status'
|
import { STATUS, getRouteByStatus } from '@/utils/aicreate/status'
|
||||||
import { clearExtractDraft } from '@/utils/aicreate/extractDraft'
|
|
||||||
import config from '@/utils/aicreate/config'
|
import config from '@/utils/aicreate/config'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAicreateStore()
|
const sessionStore = useAicreateStore()
|
||||||
|
const {
|
||||||
|
workId,
|
||||||
|
imageUrl,
|
||||||
|
storyData,
|
||||||
|
selectedStyle,
|
||||||
|
selectedCharacter,
|
||||||
|
extractId,
|
||||||
|
} = useAicreateCreation()
|
||||||
const progress = ref(0)
|
const progress = ref(0)
|
||||||
const stage = ref('准备中…')
|
const stage = ref('准备中…')
|
||||||
const dots = ref('')
|
const dots = ref('')
|
||||||
@ -158,47 +166,30 @@ function friendlyStage(pct: number, msg: string): string {
|
|||||||
return '绘本创作完成'
|
return '绘本创作完成'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 持久化 workId 到 localStorage,页面刷新后可恢复轮询
|
/** 仅内存中保存当前乐读派 workId(刷新页面后不恢复) */
|
||||||
function saveWorkId(id: string) {
|
function saveWorkId(id: string) {
|
||||||
store.workId = id
|
workId.value = id
|
||||||
if (id) {
|
|
||||||
const urlWorkId = new URLSearchParams(window.location.search).get('workId');
|
|
||||||
if (!urlWorkId) {
|
|
||||||
localStorage.setItem('le_workId', id)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem('le_workId')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreWorkId() {
|
/** 创作已推进到后续步骤时的路由跳转 */
|
||||||
if (!store.workId) {
|
|
||||||
store.workId = localStorage.getItem('le_workId') || ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 创作已推进到预览/配音等后续步骤时清除 extract 本地草稿 */
|
|
||||||
function replaceWhenCreationAdvances(route: ReturnType<typeof getRouteByStatus>) {
|
function replaceWhenCreationAdvances(route: ReturnType<typeof getRouteByStatus>) {
|
||||||
if (!route) return
|
if (!route) return
|
||||||
if (route.name !== 'PublicCreateCreating') {
|
|
||||||
clearExtractDraft()
|
|
||||||
}
|
|
||||||
setTimeout(() => router.replace(route), 800)
|
setTimeout(() => router.replace(route), 800)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── WebSocket 实时推送 (首次进入使用) ───
|
// ─── WebSocket 实时推送 (首次进入使用) ───
|
||||||
const startWebSocket = (workId: string) => {
|
const startWebSocket = (remoteWid: string) => {
|
||||||
wsDegraded = false
|
wsDegraded = false
|
||||||
const wsBase = config.wsBaseUrl
|
const wsBase = config.wsBaseUrl
|
||||||
? config.wsBaseUrl
|
? config.wsBaseUrl
|
||||||
: `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`
|
: `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`
|
||||||
const wsUrl = `${wsBase}/ws/websocket?orgId=${encodeURIComponent(store.orgId)}`
|
const wsUrl = `${wsBase}/ws/websocket?orgId=${encodeURIComponent(sessionStore.orgId)}`
|
||||||
|
|
||||||
stompClient = new Client({
|
stompClient = new Client({
|
||||||
brokerURL: wsUrl,
|
brokerURL: wsUrl,
|
||||||
reconnectDelay: 0,
|
reconnectDelay: 0,
|
||||||
onConnect: () => {
|
onConnect: () => {
|
||||||
stompClient.subscribe(`/topic/progress/${workId}`, (msg: any) => {
|
stompClient.subscribe(`/topic/progress/${remoteWid}`, (msg: any) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(msg.body)
|
const data = JSON.parse(msg.body)
|
||||||
if (data.progress != null && data.progress > progress.value) progress.value = data.progress
|
if (data.progress != null && data.progress > progress.value) progress.value = data.progress
|
||||||
@ -225,20 +216,20 @@ const startWebSocket = (workId: string) => {
|
|||||||
if (wsDegraded) return
|
if (wsDegraded) return
|
||||||
wsDegraded = true
|
wsDegraded = true
|
||||||
closeWebSocket()
|
closeWebSocket()
|
||||||
startPolling(workId)
|
startPolling(remoteWid)
|
||||||
},
|
},
|
||||||
onWebSocketError: () => {
|
onWebSocketError: () => {
|
||||||
if (wsDegraded) return
|
if (wsDegraded) return
|
||||||
wsDegraded = true
|
wsDegraded = true
|
||||||
closeWebSocket()
|
closeWebSocket()
|
||||||
startPolling(workId)
|
startPolling(remoteWid)
|
||||||
},
|
},
|
||||||
onWebSocketClose: () => {
|
onWebSocketClose: () => {
|
||||||
if (wsDegraded) return
|
if (wsDegraded) return
|
||||||
if (store.workId) {
|
if (workId.value) {
|
||||||
wsDegraded = true
|
wsDegraded = true
|
||||||
closeWebSocket()
|
closeWebSocket()
|
||||||
startPolling(workId)
|
startPolling(remoteWid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -316,34 +307,34 @@ const startCreation = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await createStory({
|
const res = await createStory({
|
||||||
imageUrl: store.imageUrl,
|
imageUrl: imageUrl.value,
|
||||||
storyHint: store.storyData?.storyHint || '',
|
storyHint: storyData.value?.storyHint || '',
|
||||||
style: store.selectedStyle,
|
style: selectedStyle.value,
|
||||||
title: store.storyData?.title || '',
|
title: storyData.value?.title || '',
|
||||||
heroName: store.storyData?.heroName || '',
|
heroName: storyData.value?.heroName || '',
|
||||||
author: store.storyData?.author,
|
author: storyData.value?.author,
|
||||||
heroCharId: store.selectedCharacter?.charId,
|
heroCharId: selectedCharacter.value?.charId,
|
||||||
extractId: store.extractId,
|
extractId: extractId.value,
|
||||||
})
|
})
|
||||||
|
|
||||||
const workId = res?.workId
|
const wid = res?.workId
|
||||||
if (!workId) {
|
if (!wid) {
|
||||||
error.value = res.msg || '创作提交失败'
|
error.value = res.msg || '创作提交失败'
|
||||||
submitted = false
|
submitted = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
saveWorkId(workId)
|
saveWorkId(wid)
|
||||||
progress.value = 0
|
progress.value = 0
|
||||||
stage.value = '故事构思中…'
|
stage.value = '故事构思中…'
|
||||||
// startWebSocket(workId)
|
// startWebSocket(wid)
|
||||||
startPolling(store.workId)
|
startPolling(workId.value)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('e', e);
|
console.error('e', e);
|
||||||
if (store.workId) {
|
if (workId.value) {
|
||||||
progress.value = 0
|
progress.value = 0
|
||||||
stage.value = '创作已提交到后台…'
|
stage.value = '创作已提交到后台…'
|
||||||
startPolling(store.workId)
|
startPolling(workId.value)
|
||||||
} else {
|
} else {
|
||||||
error.value = sanitizeError(e.message)
|
error.value = sanitizeError(e.message)
|
||||||
submitted = false
|
submitted = false
|
||||||
@ -356,7 +347,7 @@ const resumePolling = () => {
|
|||||||
networkWarn.value = false
|
networkWarn.value = false
|
||||||
progress.value = 0
|
progress.value = 0
|
||||||
stage.value = '正在查询创作进度…'
|
stage.value = '正在查询创作进度…'
|
||||||
startPolling(store.workId)
|
startPolling(workId.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const retry = () => {
|
const retry = () => {
|
||||||
@ -366,7 +357,7 @@ const retry = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const leaveToWorks = () => {
|
const leaveToWorks = () => {
|
||||||
// 关闭前端监听,但后端任务继续;store.workId 仍在 localStorage,下次进入 CreatingView 会恢复
|
// 关闭前端监听,后端任务仍继续
|
||||||
closeWebSocket()
|
closeWebSocket()
|
||||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||||
router.push('/p/works?tab=draft')
|
router.push('/p/works?tab=draft')
|
||||||
@ -380,22 +371,20 @@ onMounted(() => {
|
|||||||
tipTimer = setInterval(() => {
|
tipTimer = setInterval(() => {
|
||||||
currentTipIdx.value = (currentTipIdx.value + 1) % creatingTips.length
|
currentTipIdx.value = (currentTipIdx.value + 1) % creatingTips.length
|
||||||
}, 3500)
|
}, 3500)
|
||||||
// 恢复 workId
|
|
||||||
const urlWorkId = new URLSearchParams(window.location.search).get('workId')
|
const urlWorkId = new URLSearchParams(window.location.search).get('workId')
|
||||||
console.log('store.workId', urlWorkId, window.location.search)
|
if (urlWorkId) {
|
||||||
if (!urlWorkId) {
|
saveWorkId(urlWorkId)
|
||||||
restoreWorkId()
|
|
||||||
}
|
}
|
||||||
if (store.workId) {
|
if (workId.value) {
|
||||||
try {
|
try {
|
||||||
getWorkDetailApi(store.workId)
|
getWorkDetailApi(workId.value)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('error', error);
|
console.log('error', error);
|
||||||
}
|
}
|
||||||
submitted = true
|
submitted = true
|
||||||
progress.value = 0
|
progress.value = 0
|
||||||
stage.value = '正在查询创作进度…'
|
stage.value = '正在查询创作进度…'
|
||||||
startPolling(store.workId)
|
startPolling(workId.value)
|
||||||
} else {
|
} else {
|
||||||
startCreation()
|
startCreation()
|
||||||
}
|
}
|
||||||
@ -405,11 +394,9 @@ onActivated(() => {
|
|||||||
const urlWorkId = new URLSearchParams(window.location.search).get('workId')
|
const urlWorkId = new URLSearchParams(window.location.search).get('workId')
|
||||||
if (urlWorkId) {
|
if (urlWorkId) {
|
||||||
saveWorkId(urlWorkId)
|
saveWorkId(urlWorkId)
|
||||||
} else {
|
|
||||||
restoreWorkId()
|
|
||||||
}
|
}
|
||||||
if (store.workId) {
|
if (workId.value) {
|
||||||
void getWorkDetailApi(store.workId)
|
void getWorkDetailApi(workId.value)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -132,7 +132,7 @@ export default { name: 'DubbingView' }
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onActivated, onBeforeUnmount, watch } from 'vue'
|
import { ref, computed, onMounted, onActivated, onBeforeUnmount, watch } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
@ -148,7 +148,9 @@ import {
|
|||||||
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
||||||
import { voicePage, ossUpload } from '@/api/aicreate'
|
import { voicePage, ossUpload } from '@/api/aicreate'
|
||||||
import { getLeaiWorkFormDetail, saveLeaiWorkForm } from '@/api/public'
|
import { getLeaiWorkFormDetail, saveLeaiWorkForm } from '@/api/public'
|
||||||
|
import { useAicreateCreation } from '@/composables/useAicreateCreation'
|
||||||
|
|
||||||
|
const { resetCreation } = useAicreateCreation()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const workId = computed(() => route.params.workId)
|
const workId = computed(() => route.params.workId)
|
||||||
@ -547,6 +549,7 @@ async function finish() {
|
|||||||
|
|
||||||
// --- Load ---
|
// --- Load ---
|
||||||
async function loadWork() {
|
async function loadWork() {
|
||||||
|
resetCreation()
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getLeaiWorkFormDetail(String(workId.value || ''))
|
const res = await getLeaiWorkFormDetail(String(workId.value || ''))
|
||||||
@ -567,7 +570,13 @@ async function loadWork() {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
onActivated(loadWork)
|
/** 子路由未 keep-alive:首次进入仅触发 onMounted */
|
||||||
|
onMounted(() => {
|
||||||
|
void loadWork()
|
||||||
|
})
|
||||||
|
onActivated(() => {
|
||||||
|
void loadWork()
|
||||||
|
})
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
stopAudio()
|
stopAudio()
|
||||||
if (isRecording.value && mediaRecorder?.state === 'recording') {
|
if (isRecording.value && mediaRecorder?.state === 'recording') {
|
||||||
|
|||||||
@ -115,7 +115,7 @@ export default { name: 'EditInfoView' }
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onActivated, nextTick, watch } from 'vue'
|
import { ref, computed, onMounted, onActivated, nextTick, watch } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
@ -133,6 +133,9 @@ import { message } from 'ant-design-vue'
|
|||||||
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
||||||
import { getLeaiWorkFormDetail, publicUserWorksApi, saveLeaiWorkForm } from '@/api/public'
|
import { getLeaiWorkFormDetail, publicUserWorksApi, saveLeaiWorkForm } from '@/api/public'
|
||||||
|
|
||||||
|
import { useAicreateCreation } from '@/composables/useAicreateCreation'
|
||||||
|
|
||||||
|
const { resetCreation } = useAicreateCreation()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const workId = computed(() => route.params.workId)
|
const workId = computed(() => route.params.workId)
|
||||||
@ -215,6 +218,7 @@ function applyDetailToForm(w) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadWork() {
|
async function loadWork() {
|
||||||
|
resetCreation()
|
||||||
const id = resolvedWorkIdStr()
|
const id = resolvedWorkIdStr()
|
||||||
if (!id) {
|
if (!id) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@ -381,8 +385,12 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 子路由未 keep-alive:首次进入仅触发 onMounted,否则一直卡在加载中 */
|
||||||
|
onMounted(() => {
|
||||||
|
void loadWork()
|
||||||
|
})
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
loadWork()
|
void loadWork()
|
||||||
nextTick(() => { if (tagInput.value) tagInput.value.focus() })
|
nextTick(() => { if (tagInput.value) tagInput.value.focus() })
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -75,7 +75,7 @@ export default { name: 'PreviewView' }
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onActivated, nextTick } from 'vue'
|
import { ref, computed, onMounted, onActivated, nextTick } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
@ -86,13 +86,13 @@ import {
|
|||||||
ArrowRightOutlined,
|
ArrowRightOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import { getWorkDetail } from '@/api/aicreate'
|
import { getWorkDetail } from '@/api/aicreate'
|
||||||
import { useAicreateStore } from '@/stores/aicreate'
|
import { useAicreateCreation } from '@/composables/useAicreateCreation'
|
||||||
import { STATUS, getRouteByStatus } from '@/utils/aicreate/status'
|
import { STATUS, getRouteByStatus } from '@/utils/aicreate/status'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const store = useAicreateStore()
|
const { resetCreation } = useAicreateCreation()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const pages = ref<any[]>([])
|
const pages = ref<any[]>([])
|
||||||
@ -123,7 +123,7 @@ function scrollThumbIntoView(i: number) {
|
|||||||
const workId = computed(() => route.params.workId)
|
const workId = computed(() => route.params.workId)
|
||||||
|
|
||||||
async function loadWork() {
|
async function loadWork() {
|
||||||
store.reset();
|
resetCreation()
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
|
||||||
@ -154,7 +154,13 @@ function goEditInfo() {
|
|||||||
router.push(`/p/create/edit-info/${workId.value}`)
|
router.push(`/p/create/edit-info/${workId.value}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
onActivated(loadWork)
|
/** 壳层子路由未 keep-alive:首次进入只有 onMounted,无 onActivated,否则一直卡在「加载中」 */
|
||||||
|
onMounted(() => {
|
||||||
|
void loadWork()
|
||||||
|
})
|
||||||
|
onActivated(() => {
|
||||||
|
void loadWork()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@ -54,12 +54,12 @@ export default { name: 'SaveSuccessView' }
|
|||||||
import { ref, computed, onMounted, onActivated } from 'vue'
|
import { ref, computed, onMounted, onActivated } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { getWorkDetail } from '@/api/aicreate'
|
import { getWorkDetail } from '@/api/aicreate'
|
||||||
import { useAicreateStore } from '@/stores/aicreate'
|
import { useAicreateCreation } from '@/composables/useAicreateCreation'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useAicreateStore()
|
const flow = useAicreateCreation()
|
||||||
const workId = computed(() => String(route.params.workId || store.workId || ''))
|
const workId = computed(() => String(route.params.workId || flow.workId.value || ''))
|
||||||
|
|
||||||
const afterPublish = computed(() => route.query.after === 'publish')
|
const afterPublish = computed(() => route.query.after === 'publish')
|
||||||
|
|
||||||
@ -93,13 +93,14 @@ const title = ref('')
|
|||||||
const author = ref('')
|
const author = ref('')
|
||||||
|
|
||||||
async function loadWork() {
|
async function loadWork() {
|
||||||
|
flow.resetCreation()
|
||||||
try {
|
try {
|
||||||
if (!store.workDetail || store.workDetail.workId !== workId.value) {
|
if (!flow.workDetail.value || flow.workDetail.value.workId !== workId.value) {
|
||||||
store.workDetail = null
|
flow.workDetail.value = null
|
||||||
const res = await getWorkDetail(workId.value)
|
const res = await getWorkDetail(workId.value)
|
||||||
store.workDetail = res
|
flow.workDetail.value = res
|
||||||
}
|
}
|
||||||
const w = store.workDetail
|
const w = flow.workDetail.value
|
||||||
title.value = w.title || '我的绘本'
|
title.value = w.title || '我的绘本'
|
||||||
author.value = w.author || ''
|
author.value = w.author || ''
|
||||||
coverUrl.value = w.pageList?.[0]?.imageUrl || ''
|
coverUrl.value = w.pageList?.[0]?.imageUrl || ''
|
||||||
@ -119,8 +120,7 @@ function goWorks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 仅首次进入:清空创作流程内存与本地残留键(再次激活缓存页时不重复 reset)
|
void loadWork()
|
||||||
store.reset()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
|
|||||||
@ -102,12 +102,12 @@ import {
|
|||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
||||||
import { useAicreateStore } from '@/stores/aicreate'
|
import { useAicreateCreation } from '@/composables/useAicreateCreation'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAicreateStore()
|
const flow = useAicreateCreation()
|
||||||
const bookTitle = ref('')
|
const bookTitle = ref('')
|
||||||
const heroName = ref(store.selectedCharacter?.name || '')
|
const heroName = ref(flow.selectedCharacter.value?.name || '')
|
||||||
const storyStart = ref('')
|
const storyStart = ref('')
|
||||||
const meetWho = ref('')
|
const meetWho = ref('')
|
||||||
const whatHappens = ref('')
|
const whatHappens = ref('')
|
||||||
@ -115,7 +115,7 @@ const whatHappens = ref('')
|
|||||||
const bookTitleFocus = ref(false)
|
const bookTitleFocus = ref(false)
|
||||||
const heroNameFocus = ref(false)
|
const heroNameFocus = ref(false)
|
||||||
|
|
||||||
const heroAvatar = computed(() => store.selectedCharacter?.originalCropUrl || '')
|
const heroAvatar = computed(() => flow.selectedCharacter.value?.originalCropUrl || '')
|
||||||
|
|
||||||
const fields = [
|
const fields = [
|
||||||
{ label: '故事开始', placeholder: '如:一个阳光明媚的早晨…', value: storyStart, required: false, focused: ref(false) },
|
{ label: '故事开始', placeholder: '如:一个阳光明媚的早晨…', value: storyStart, required: false, focused: ref(false) },
|
||||||
@ -125,7 +125,7 @@ const fields = [
|
|||||||
|
|
||||||
const canSubmit = computed(() => bookTitle.value.trim() && heroName.value.trim() && whatHappens.value.trim())
|
const canSubmit = computed(() => bookTitle.value.trim() && heroName.value.trim() && whatHappens.value.trim())
|
||||||
|
|
||||||
// 防重复点击:组件被 keep-alive 缓存,从下一页退回时通过 onActivated 重置
|
// 防重复点击:从下一页退回时通过 onActivated 重置
|
||||||
let submitted = false
|
let submitted = false
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
submitted = false
|
submitted = false
|
||||||
@ -140,7 +140,7 @@ const goNext = () => {
|
|||||||
if (meetWho.value.trim()) parts.push(`遇见谁:${meetWho.value.trim()}`)
|
if (meetWho.value.trim()) parts.push(`遇见谁:${meetWho.value.trim()}`)
|
||||||
parts.push(`发生什么:${whatHappens.value.trim()}`)
|
parts.push(`发生什么:${whatHappens.value.trim()}`)
|
||||||
|
|
||||||
store.storyData = {
|
flow.storyData.value = {
|
||||||
heroName: heroName.value,
|
heroName: heroName.value,
|
||||||
storyHint: parts.join(';'),
|
storyHint: parts.join(';'),
|
||||||
title: bookTitle.value.trim()
|
title: bookTitle.value.trim()
|
||||||
|
|||||||
@ -58,7 +58,7 @@ import {
|
|||||||
ArrowRightOutlined,
|
ArrowRightOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
||||||
import { useAicreateStore } from '@/stores/aicreate'
|
import { useAicreateCreation } from '@/composables/useAicreateCreation'
|
||||||
|
|
||||||
// 导入画风图片
|
// 导入画风图片
|
||||||
import styleCartoonNormal from '@/assets/images/style-cartoon-normal.png'
|
import styleCartoonNormal from '@/assets/images/style-cartoon-normal.png'
|
||||||
@ -67,7 +67,7 @@ import styleInkNormal from '@/assets/images/style-ink-normal.png'
|
|||||||
import stylePencilNormal from '@/assets/images/style-pencil-normal.png'
|
import stylePencilNormal from '@/assets/images/style-pencil-normal.png'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAicreateStore()
|
const flow = useAicreateCreation()
|
||||||
const selected = ref('')
|
const selected = ref('')
|
||||||
|
|
||||||
interface StyleItem {
|
interface StyleItem {
|
||||||
@ -94,12 +94,12 @@ const styles: StyleItem[] = [
|
|||||||
const visibleStyles = styles.filter(s => !s.hidden)
|
const visibleStyles = styles.filter(s => !s.hidden)
|
||||||
|
|
||||||
const goNext = () => {
|
const goNext = () => {
|
||||||
store.selectedStyle = selected.value
|
flow.selectedStyle.value = selected.value
|
||||||
router.push('/p/create/creating')
|
router.push('/p/create/creating')
|
||||||
}
|
}
|
||||||
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
selected.value = store.selectedStyle || ''
|
selected.value = flow.selectedStyle.value || ''
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -109,9 +109,8 @@ export default { name: 'UploadView' }
|
|||||||
import { ref, onActivated } from 'vue'
|
import { ref, onActivated } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
||||||
import { useAicreateStore } from '@/stores/aicreate'
|
import { useAicreateCreation } from '@/composables/useAicreateCreation'
|
||||||
import { extractCharacters, ossUpload, checkQuota } from '@/api/aicreate'
|
import { extractCharacters, ossUpload, checkQuota } from '@/api/aicreate'
|
||||||
import { saveExtractDraft } from '@/utils/aicreate/extractDraft'
|
|
||||||
import {
|
import {
|
||||||
PictureOutlined,
|
PictureOutlined,
|
||||||
CameraOutlined,
|
CameraOutlined,
|
||||||
@ -127,7 +126,13 @@ import {
|
|||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAicreateStore()
|
const {
|
||||||
|
imageUrl,
|
||||||
|
extractId,
|
||||||
|
characters,
|
||||||
|
originalWorkId,
|
||||||
|
resetCreation,
|
||||||
|
} = useAicreateCreation()
|
||||||
const preview = ref<string | null>(null)
|
const preview = ref<string | null>(null)
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
const cameraInput = ref<HTMLInputElement | null>(null)
|
const cameraInput = ref<HTMLInputElement | null>(null)
|
||||||
@ -141,6 +146,7 @@ const quotaOk = ref(true)
|
|||||||
const quotaMsg = ref('')
|
const quotaMsg = ref('')
|
||||||
let selectedFile: File | null = null
|
let selectedFile: File | null = null
|
||||||
|
|
||||||
|
/** 仅校验额度 */
|
||||||
async function refreshQuota() {
|
async function refreshQuota() {
|
||||||
try {
|
try {
|
||||||
await checkQuota()
|
await checkQuota()
|
||||||
@ -230,6 +236,8 @@ const onFileChange = async (e: Event) => {
|
|||||||
const target = e.target as HTMLInputElement
|
const target = e.target as HTMLInputElement
|
||||||
const file = target.files?.[0]
|
const file = target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
// 换图即视为新一次上传:清空上一趟创作流状态
|
||||||
|
resetCreation()
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
compressed.value = false
|
compressed.value = false
|
||||||
|
|
||||||
@ -256,11 +264,17 @@ const onFileChange = async (e: Event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
|
resetCreation()
|
||||||
preview.value = null
|
preview.value = null
|
||||||
selectedFile = null
|
selectedFile = null
|
||||||
fileSizeInfo.value = ''
|
fileSizeInfo.value = ''
|
||||||
compressed.value = false
|
compressed.value = false
|
||||||
uploadError.value = ''
|
uploadError.value = ''
|
||||||
|
uploadStage.value = 0
|
||||||
|
uploadProgress.value = ''
|
||||||
|
uploading.value = false
|
||||||
|
if (cameraInput.value) cameraInput.value.value = ''
|
||||||
|
if (albumInput.value) albumInput.value.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadError = ref('')
|
const uploadError = ref('')
|
||||||
@ -286,6 +300,8 @@ const goNext = async () => {
|
|||||||
uploading.value = true
|
uploading.value = true
|
||||||
uploadError.value = ''
|
uploadError.value = ''
|
||||||
uploadStage.value = 1
|
uploadStage.value = 1
|
||||||
|
// 识别前清空上一流程残留(故事、画风、生成 workId 等),避免与本次提取结果混用
|
||||||
|
resetCreation()
|
||||||
try {
|
try {
|
||||||
// Step 1: STS 直传图片到 OSS
|
// Step 1: STS 直传图片到 OSS
|
||||||
uploadProgress.value = '上传画作到云端...'
|
uploadProgress.value = '上传画作到云端...'
|
||||||
@ -306,16 +322,10 @@ const goNext = async () => {
|
|||||||
type: c.charType || c.type || 'SIDEKICK'
|
type: c.charType || c.type || 'SIDEKICK'
|
||||||
}))
|
}))
|
||||||
if (chars.length === 0) throw new Error('AI未识别到角色,请更换图片重试')
|
if (chars.length === 0) throw new Error('AI未识别到角色,请更换图片重试')
|
||||||
store.extractId = data.extractId || ''
|
extractId.value = data.extractId || ''
|
||||||
store.characters = chars
|
characters.value = chars
|
||||||
store.imageUrl = ossUrl
|
imageUrl.value = ossUrl
|
||||||
if (data.workId) store.originalWorkId = data.workId
|
if (data.workId) originalWorkId.value = data.workId
|
||||||
saveExtractDraft({
|
|
||||||
imageUrl: ossUrl,
|
|
||||||
extractId: data.extractId || '',
|
|
||||||
characters: chars,
|
|
||||||
raw: data,
|
|
||||||
})
|
|
||||||
router.push('/p/create/characters')
|
router.push('/p/create/characters')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
uploadError.value = '识别失败:' + sanitizeError(e.message)
|
uploadError.value = '识别失败:' + sanitizeError(e.message)
|
||||||
|
|||||||
@ -3,6 +3,11 @@ export default { name: 'WelcomeView' }
|
|||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="welcome-page">
|
<div class="welcome-page">
|
||||||
|
<!-- 带 ?resumeWorkId= 进入:拉详情并按状态跳转 Creating / Preview 前显示 -->
|
||||||
|
<div v-if="resumeResolving" class="resume-overlay">
|
||||||
|
<div class="resume-spinner" />
|
||||||
|
<p class="resume-text">正在恢复创作…</p>
|
||||||
|
</div>
|
||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="hero-deco">
|
<div class="hero-deco">
|
||||||
@ -127,7 +132,7 @@ export default { name: 'WelcomeView' }
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { createVNode, onMounted, onActivated, watch } from 'vue'
|
import { createVNode, onMounted, watch, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
CameraOutlined,
|
CameraOutlined,
|
||||||
@ -144,110 +149,51 @@ import {
|
|||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import { Modal } from 'ant-design-vue'
|
import { Modal } from 'ant-design-vue'
|
||||||
import { useAicreateStore } from '@/stores/aicreate'
|
import { useAicreateStore } from '@/stores/aicreate'
|
||||||
import { loadExtractDraft } from '@/utils/aicreate/extractDraft'
|
import { useAicreateCreation } from '@/composables/useAicreateCreation'
|
||||||
import { resumeLeaiWorkFromApi } from '@/utils/aicreate/resumeLeaiWork'
|
import { runCreateShellEntry } from '@/utils/aicreate/runCreateShellEntry'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAicreateStore()
|
const store = useAicreateStore()
|
||||||
|
const flow = useAicreateCreation()
|
||||||
|
const { resetCreation } = flow
|
||||||
|
|
||||||
/** 作品库「编辑」传入的 resumeWorkId(query 可能已解码,容错二次 decode) */
|
const resumeResolving = ref(false)
|
||||||
function parseResumeWorkIdFromQuery(raw: string): string {
|
|
||||||
const t = String(raw || '').trim()
|
function rawResumeWorkIdFromRoute(): string {
|
||||||
if (!t) return ''
|
const q = route.query.resumeWorkId
|
||||||
try {
|
if (typeof q === 'string') return q.trim()
|
||||||
return decodeURIComponent(t)
|
if (Array.isArray(q) && q[0]) return String(q[0]).trim()
|
||||||
} catch {
|
return ''
|
||||||
return t
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getResumeWorkIdFromRoute(): string {
|
/** Token 已由壳层 Index 拉取后再执行;?resumeWorkId= 时拉详情:创作中→Creating,否则→Preview;无参数时可按内存 workId 恢复 */
|
||||||
const qResume = route.query.resumeWorkId
|
async function tryWelcomeEntry() {
|
||||||
const resumeFromQuery =
|
|
||||||
typeof qResume === 'string'
|
|
||||||
? qResume
|
|
||||||
: Array.isArray(qResume) && qResume[0]
|
|
||||||
? qResume[0]
|
|
||||||
: ''
|
|
||||||
return resumeFromQuery ? String(resumeFromQuery) : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 欢迎页恢复逻辑:与 keep-alive 配合,onMounted 仅首次;再次进入需 onActivated + watch */
|
|
||||||
let welcomeResumeRunning = false
|
|
||||||
|
|
||||||
async function runWelcomeEntry() {
|
|
||||||
if (route.name !== 'PublicCreateWelcome') return
|
if (route.name !== 'PublicCreateWelcome') return
|
||||||
if (welcomeResumeRunning) return
|
const fromQuery = rawResumeWorkIdFromRoute()
|
||||||
welcomeResumeRunning = true
|
if (fromQuery) resumeResolving.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resumeFromQuery = getResumeWorkIdFromRoute()
|
await runCreateShellEntry(router, route, store.sessionToken, flow)
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
if (store.sessionToken) {
|
|
||||||
const ok = await resumeLeaiWorkFromApi(store.workId, 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
|
|
||||||
router.replace('/p/create/characters')
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
welcomeResumeRunning = false
|
if (fromQuery) resumeResolving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void runWelcomeEntry()
|
void tryWelcomeEntry()
|
||||||
})
|
|
||||||
|
|
||||||
onActivated(() => {
|
|
||||||
void runWelcomeEntry()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [store.sessionToken, route.query.resumeWorkId] as const,
|
() => [store.sessionToken, route.query.resumeWorkId, route.name] as const,
|
||||||
() => {
|
() => {
|
||||||
void runWelcomeEntry()
|
void tryWelcomeEntry()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleStart = () => {
|
const handleStart = () => {
|
||||||
if (!store.sessionToken) return
|
if (!store.sessionToken) return
|
||||||
store.reset()
|
resetCreation()
|
||||||
|
store.clearLastCreateRoute()
|
||||||
|
|
||||||
// Modal.warning 为单按钮提示,不展示取消;需双按钮时用 confirm + 警告图标
|
// Modal.warning 为单按钮提示,不展示取消;需双按钮时用 confirm + 警告图标
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
@ -279,6 +225,41 @@ $gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resume-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resume-spinner {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border: 3px solid rgba(99, 102, 241, 0.2);
|
||||||
|
border-top-color: #6366f1;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: welcome-spin 0.85s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resume-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes welcome-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Hero ---------- */
|
/* ---------- Hero ---------- */
|
||||||
|
|||||||
@ -116,9 +116,11 @@ import {
|
|||||||
} from "@ant-design/icons-vue"
|
} from "@ant-design/icons-vue"
|
||||||
import { publicProfileApi, publicMineApi, type PublicProfileUpdatePayload } from "@/api/public"
|
import { publicProfileApi, publicMineApi, type PublicProfileUpdatePayload } from "@/api/public"
|
||||||
import { useAicreateStore } from "@/stores/aicreate"
|
import { useAicreateStore } from "@/stores/aicreate"
|
||||||
|
import { useAicreateCreation } from "@/composables/useAicreateCreation"
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const aicreateStore = useAicreateStore()
|
const aicreateStore = useAicreateStore()
|
||||||
|
const { resetCreation } = useAicreateCreation()
|
||||||
const user = ref<any>(null)
|
const user = ref<any>(null)
|
||||||
const showEditModal = ref(false)
|
const showEditModal = ref(false)
|
||||||
const editLoading = ref(false)
|
const editLoading = ref(false)
|
||||||
@ -234,8 +236,9 @@ const handleLogout = () => {
|
|||||||
localStorage.removeItem("public_user")
|
localStorage.removeItem("public_user")
|
||||||
localStorage.removeItem("parent_token_backup")
|
localStorage.removeItem("parent_token_backup")
|
||||||
localStorage.removeItem("parent_user_backup")
|
localStorage.removeItem("parent_user_backup")
|
||||||
// 重置创作页状态
|
// 重置创作流与会话
|
||||||
aicreateStore.reset()
|
resetCreation()
|
||||||
|
aicreateStore.clearLastCreateRoute()
|
||||||
aicreateStore.clearSession()
|
aicreateStore.clearSession()
|
||||||
router.push("/p/login")
|
router.push("/p/login")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,8 @@
|
|||||||
<left-outlined />
|
<left-outlined />
|
||||||
</button>
|
</button>
|
||||||
<h1>{{ work.title }}</h1>
|
<h1>{{ work.title }}</h1>
|
||||||
<span :class="['status-tag', work.status]">{{ statusTextMap[work.status] }}</span>
|
<span v-if="work.leaiStatus === 2 || work.leaiStatus === 1" class="status-tag status-tag-creating">创作中</span>
|
||||||
|
<span v-else :class="['status-tag', work.status]">{{ statusTextMap[work.status] }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 审核拒绝原因(仅作者 + rejected,置于内容区顶部便于阅读)-->
|
<!-- 审核拒绝原因(仅作者 + rejected,置于内容区顶部便于阅读)-->
|
||||||
@ -115,12 +116,6 @@
|
|||||||
|
|
||||||
<!-- 作者私有操作 -->
|
<!-- 作者私有操作 -->
|
||||||
<div v-if="isOwner" class="owner-actions">
|
<div v-if="isOwner" class="owner-actions">
|
||||||
<!-- 主操作:根据 status 切换 -->
|
|
||||||
<button v-if="work.status === 'unpublished'" class="op-btn primary" :disabled="actionLoading"
|
|
||||||
@click="handlePublish">
|
|
||||||
<send-outlined />
|
|
||||||
<span>提交审核</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button v-if="work.status === 'rejected'" class="op-btn primary" :disabled="actionLoading"
|
<button v-if="work.status === 'rejected'" class="op-btn primary" :disabled="actionLoading"
|
||||||
@click="handleResubmit">
|
@click="handleResubmit">
|
||||||
@ -128,12 +123,23 @@
|
|||||||
<span>修改后重交</span>
|
<span>修改后重交</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button v-if="(work.leaiStatus === 2 || work.leaiStatus === 1) && work.status === 'draft'"
|
||||||
|
class="op-btn primary" :disabled="actionLoading" @click="handleContinue">
|
||||||
|
<edit-outlined />
|
||||||
|
<span>查看进度</span>
|
||||||
|
</button>
|
||||||
<button v-else-if="work.status === 'draft' || work.status === 'unpublished'" class="op-btn primary"
|
<button v-else-if="work.status === 'draft' || work.status === 'unpublished'" class="op-btn primary"
|
||||||
:disabled="actionLoading" @click="handleContinue">
|
:disabled="actionLoading" @click="handleContinue">
|
||||||
<edit-outlined />
|
<edit-outlined />
|
||||||
<span>编辑</span>
|
<span>编辑</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- 主操作:根据 status 切换 -->
|
||||||
|
<button v-if="work.status === 'unpublished'" class="op-btn primary" :disabled="actionLoading"
|
||||||
|
@click="handlePublish">
|
||||||
|
<send-outlined />
|
||||||
|
<span>提交审核</span>
|
||||||
|
</button>
|
||||||
<button v-if="work.status === 'pending_review'" class="op-btn outline" :disabled="actionLoading"
|
<button v-if="work.status === 'pending_review'" class="op-btn outline" :disabled="actionLoading"
|
||||||
@click="handleWithdraw">
|
@click="handleWithdraw">
|
||||||
<undo-outlined />
|
<undo-outlined />
|
||||||
@ -231,7 +237,7 @@ function getPublicUserId(): number | null {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 作品作者 sys_user id。
|
* 作品作者 sys_user id。
|
||||||
* 「我的作品库」详情经 normalize 后有顶层 userId;广场 GET /public/gallery/{id} 仅返回 creator/user,无 userId 字段。
|
* 「我的作品库」详情经 normalize 后有顶层 userId;广场 GET /public/gallery/{id} 仅返回 creator/user(无顶层 userId),但含 leaiStatus 等扁平字段。
|
||||||
*/
|
*/
|
||||||
function resolveWorkOwnerUserId(w: UserWork): number | null {
|
function resolveWorkOwnerUserId(w: UserWork): number | null {
|
||||||
if (typeof w.userId === 'number' && !Number.isNaN(w.userId)) return w.userId
|
if (typeof w.userId === 'number' && !Number.isNaN(w.userId)) return w.userId
|
||||||
@ -675,6 +681,11 @@ $accent: #ec4899;
|
|||||||
&.taken_down {
|
&.taken_down {
|
||||||
background: rgba(107, 114, 128, 0.85);
|
background: rgba(107, 114, 128, 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.status-tag-creating {
|
||||||
|
background: rgba(8, 217, 81, 0.85);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 拒绝原因 / 信息提示卡片 ---------- */
|
/* ---------- 拒绝原因 / 信息提示卡片 ---------- */
|
||||||
@ -1073,32 +1084,34 @@ $accent: #ec4899;
|
|||||||
/* ---------- 作者私有操作区 ---------- */
|
/* ---------- 作者私有操作区 ---------- */
|
||||||
.owner-actions {
|
.owner-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 6px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
|
align-items: stretch;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 14px 16px;
|
padding: 12px 10px;
|
||||||
border: 1px solid rgba($primary, 0.06);
|
border: 1px solid rgba($primary, 0.06);
|
||||||
box-shadow: 0 2px 12px rgba($primary, 0.05);
|
box-shadow: 0 2px 12px rgba($primary, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.op-btn {
|
.op-btn {
|
||||||
flex: 1;
|
flex: 1 1 0;
|
||||||
min-width: 100px;
|
min-width: 0;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
padding: 11px 12px;
|
padding: 10px 6px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
:deep(.anticon) {
|
:deep(.anticon) {
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
@ -1146,9 +1159,9 @@ $accent: #ec4899;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.op-btn.ghost-danger {
|
.op-btn.ghost-danger {
|
||||||
flex: 0 0 auto;
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 11px 16px;
|
padding: 10px 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
|||||||
@ -9,12 +9,8 @@
|
|||||||
|
|
||||||
<!-- 状态 Tab -->
|
<!-- 状态 Tab -->
|
||||||
<div class="status-tabs">
|
<div class="status-tabs">
|
||||||
<span
|
<span v-for="tab in tabs" :key="tab.key" :class="['tab-item', { active: activeTab === tab.key }]"
|
||||||
v-for="tab in tabs"
|
@click="switchTab(tab.key)">
|
||||||
:key="tab.key"
|
|
||||||
:class="['tab-item', { active: activeTab === tab.key }]"
|
|
||||||
@click="switchTab(tab.key)"
|
|
||||||
>
|
|
||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -36,14 +32,12 @@
|
|||||||
<picture-outlined />
|
<picture-outlined />
|
||||||
</div>
|
</div>
|
||||||
<!-- 右下角 PIP:用户上传的原图 -->
|
<!-- 右下角 PIP:用户上传的原图 -->
|
||||||
<div
|
<div v-if="work.originalImageUrl && work.originalImageUrl !== work.coverUrl" class="cover-pip" :title="'原图'">
|
||||||
v-if="work.originalImageUrl && work.originalImageUrl !== work.coverUrl"
|
|
||||||
class="cover-pip"
|
|
||||||
:title="'原图'"
|
|
||||||
>
|
|
||||||
<img :src="work.originalImageUrl" alt="原图" />
|
<img :src="work.originalImageUrl" alt="原图" />
|
||||||
</div>
|
</div>
|
||||||
<div class="work-status-tag" :class="work.status">
|
<div class="work-status-tag status-tag-creating" v-if="work.leaiStatus === 2 || work.leaiStatus === 1">创作中
|
||||||
|
</div>
|
||||||
|
<div v-else class="work-status-tag" :class="work.status">
|
||||||
{{ statusTextMap[work.status] || work.status }}
|
{{ statusTextMap[work.status] || work.status }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -62,13 +56,7 @@
|
|||||||
|
|
||||||
<!-- 分页 -->
|
<!-- 分页 -->
|
||||||
<div v-if="total > pageSize" class="pagination-wrap">
|
<div v-if="total > pageSize" class="pagination-wrap">
|
||||||
<a-pagination
|
<a-pagination v-model:current="currentPage" :total="total" :page-size="pageSize" simple @change="fetchWorks" />
|
||||||
v-model:current="currentPage"
|
|
||||||
:total="total"
|
|
||||||
:page-size="pageSize"
|
|
||||||
simple
|
|
||||||
@change="fetchWorks"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -194,7 +182,13 @@ $primary: #6366f1;
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
h2 { font-size: 20px; font-weight: 700; color: #1e1b4b; margin: 0; }
|
|
||||||
|
h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e1b4b;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-tabs {
|
.status-tabs {
|
||||||
@ -213,12 +207,19 @@ $primary: #6366f1;
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
&.active { background: $primary; color: #fff; }
|
&.active {
|
||||||
&:hover:not(.active) { background: #e5e7eb; }
|
background: $primary;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(.active) {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-wrap, .empty-wrap {
|
.loading-wrap,
|
||||||
|
.empty-wrap {
|
||||||
padding: 60px 0;
|
padding: 60px 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -242,14 +243,21 @@ $primary: #6366f1;
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
border: 1px solid rgba($primary, 0.04);
|
border: 1px solid rgba($primary, 0.04);
|
||||||
|
|
||||||
&:hover { box-shadow: 0 4px 20px rgba($primary, 0.1); transform: translateY(-2px); }
|
&:hover {
|
||||||
|
box-shadow: 0 4px 20px rgba($primary, 0.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
.work-cover {
|
.work-cover {
|
||||||
position: relative;
|
position: relative;
|
||||||
aspect-ratio: 3/4;
|
aspect-ratio: 3/4;
|
||||||
background: #f5f3ff;
|
background: #f5f3ff;
|
||||||
|
|
||||||
img { width: 100%; height: 100%; object-fit: cover; }
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
.cover-placeholder {
|
.cover-placeholder {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -270,12 +278,40 @@ $primary: #6366f1;
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
||||||
&.draft { background: rgba(107,114,128,0.85); color: #fff; }
|
&.draft {
|
||||||
&.unpublished { background: rgba(99,102,241,0.9); color: #fff; }
|
background: rgba(107, 114, 128, 0.85);
|
||||||
&.pending_review { background: rgba(245,158,11,0.92); color: #fff; }
|
color: #fff;
|
||||||
&.published { background: rgba(16,185,129,0.92); color: #fff; }
|
}
|
||||||
&.rejected { background: rgba(239,68,68,0.92); color: #fff; }
|
|
||||||
&.taken_down { background: rgba(107,114,128,0.85); color: #fff; }
|
&.unpublished {
|
||||||
|
background: rgba(99, 102, 241, 0.9);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.pending_review {
|
||||||
|
background: rgba(245, 158, 11, 0.92);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.published {
|
||||||
|
background: rgba(16, 185, 129, 0.92);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.rejected {
|
||||||
|
background: rgba(239, 68, 68, 0.92);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.taken_down {
|
||||||
|
background: rgba(107, 114, 128, 0.85);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-tag-creating {
|
||||||
|
background: rgba(8, 217, 81, 0.85);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 右下角 PIP:用户原图 */
|
/* 右下角 PIP:用户原图 */
|
||||||
@ -308,12 +344,24 @@ $primary: #6366f1;
|
|||||||
.work-info {
|
.work-info {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
|
|
||||||
h3 { font-size: 13px; font-weight: 600; color: #1e1b4b; margin: 0 0 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
h3 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e1b4b;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.work-meta {
|
.work-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
span { font-size: 11px; color: #9ca3af; }
|
|
||||||
|
span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user