fix: 公众端创作链路恢复与页面加载(resumeWorkId、onMounted、extract 解析)

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-20 12:41:24 +08:00
parent 65a8e0b127
commit fe210b52ee
23 changed files with 661 additions and 552 deletions

View File

@ -130,6 +130,8 @@ public class PublicGalleryService {
result.put("coverUrl", work.getCoverUrl());
result.put("description", work.getDescription());
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("likeCount", work.getLikeCount());
result.put("favoriteCount", work.getFavoriteCount());

View File

@ -510,6 +510,8 @@ export interface UserWork {
description: string | null;
visibility: string;
status: WorkStatus;
/** 乐读派创作阶段(整型,与库表 leai_status 一致;如 2 表示创作中) */
leaiStatus?: number | null;
/**
* `POST /content-review/works/{id}/reject` `reason` `note`
* `review_note`

View File

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

View File

@ -1,34 +1,16 @@
/**
* AI Pinia Store
*
* phone/orgId/appSecret localStorage
* orgId sessionStoragesessionToken
* AI Pinia Tab
* {@link useAicreateCreation}
*/
import { defineStore } from "pinia";
import { ref } from "vue";
import { clearExtractDraft } from "@/utils/aicreate/extractDraft";
export const useAicreateStore = defineStore("aicreate", () => {
// ─── 认证信息(不再存储敏感信息到 localStorage ───
const orgId = ref(sessionStorage.getItem("le_orgId") || "");
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("");
// ─── 方法 ───
function setSession(id: string, token: string) {
orgId.value = id;
sessionToken.value = token;
@ -51,165 +33,11 @@ export const useAicreateStore = defineStore("aicreate", () => {
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 {
// 认证
orgId,
sessionToken,
setSession,
clearSession,
// 创作流程
imageUrl,
extractId,
characters,
selectedCharacter,
selectedStyle,
storyData,
workId,
originalWorkId,
workDetail,
reset,
saveRecoveryState,
restoreRecoveryState,
// 开发模式
fillMockData,
fillMockWorkDetail,
// Tab 切换状态
lastCreateRoute,
setLastCreateRoute,
clearLastCreateRoute,

View File

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

View File

@ -2,13 +2,14 @@
* B2 query/work /leai-proxy/work/{id}
*/
import type { Router } from "vue-router";
import type { Ref } from "vue";
import { getWorkDetail } from "@/api/aicreate";
import { STATUS, getRouteByStatus } from "@/utils/aicreate/status";
import { clearExtractDraft } from "@/utils/aicreate/extractDraft";
import { getResumeNavigationByStatus } from "@/utils/aicreate/status";
import { getCreationFlowRefs } from "@/composables/useAicreateCreation";
type AicreateStoreLike = {
workId: string;
workDetail: any;
export type ResumeFlowWritable = {
workId: Ref<string>;
workDetail: Ref<any>;
};
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
*/
export async function resumeLeaiWorkFromApi(
workId: string,
router: Router,
store: AicreateStoreLike,
flow: ResumeFlowWritable = getCreationFlowRefs(),
): Promise<boolean> {
const id = String(workId || "").trim();
if (!id) return false;
@ -35,43 +36,25 @@ export async function resumeLeaiWorkFromApi(
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);
flow.workId.value = wid;
flow.workDetail.value = work;
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],
const rawStatus = work.status;
const statusNum =
rawStatus === null || rawStatus === undefined
? NaN
: Number(rawStatus);
const target = getResumeNavigationByStatus(
Number.isFinite(statusNum) ? statusNum : NaN,
wid,
);
if (!route) {
clearExtractDraft();
await router.replace({
name: "PublicCreateCreating",
query: { workId: wid },
});
return true;
}
clearExtractDraft();
await router.replace(route);
await router.replace(target);
return true;
} catch {
localStorage.removeItem("le_workId");
return false;
}
}

View File

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

View File

@ -16,27 +16,51 @@ export const 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(
status: StatusValue,
workId: string,
): { name: string; params?: Record<string, string> } | null {
): CreateFlowRouteTarget {
switch (status) {
case STATUS.PENDING:
case STATUS.PROCESSING:
return { name: "PublicCreateCreating" };
return { name: "PublicCreateCreating", query: { workId } };
case STATUS.COMPLETED:
return { name: "PublicCreatePreview", params: { workId } };
case STATUS.CATALOGED:
return { name: "PublicCreatePreview", params: { workId } };
// return { name: 'PublicCreateDubbing', params: { workId } }
case STATUS.DUBBED:
return { name: "PublicCreatePreview", params: { workId } };
// return { name: 'PublicCreateEditInfo', params: { workId } }
case STATUS.FAILED:
return null;
return { name: "PublicCreateCreating", query: { workId } };
default:
return null;
}

View File

@ -13,12 +13,10 @@ export default { name: 'AiCreateShell' }
重新加载
</a-button>
</div>
<!-- 子路由渲染 -->
<!-- 子路由渲染 keep-alive避免创作状态错乱 -->
<router-view v-else v-slot="{ Component }">
<transition name="ai-slide" mode="out-in">
<keep-alive>
<component :is="Component" />
</keep-alive>
<component :is="Component" />
</transition>
</router-view>
</div>
@ -36,14 +34,17 @@ const route = useRoute()
const loading = ref(true)
const loadError = ref('')
// store
watch(() => route.path, (path) => {
if (path.startsWith('/p/create')) {
store.setLastCreateRoute(path)
}
}, { immediate: true })
watch(
() => route.path,
(path) => {
if (path.startsWith('/p/create')) {
store.setLastCreateRoute(path)
}
},
{ immediate: true },
)
/** 获取乐读派 Token 并存入 store */
/** 获取乐读派 Token 并存入 store;子路由(如 Welcome再处理 resumeWorkId / 内存恢复 */
const initToken = async () => {
loading.value = true
loadError.value = ''
@ -62,13 +63,10 @@ const initToken = async () => {
}
onMounted(() => {
// localStorage le_workId ?resumeWorkId= WelcomeView
// initToken loading
// store token orgId
if (store.sessionToken && store.orgId) {
loading.value = false
} else {
initToken()
void initToken()
}
})
</script>

View File

@ -102,11 +102,13 @@ import {
PlusOutlined,
} from '@ant-design/icons-vue'
import { useAicreateStore } from '@/stores/aicreate'
import { useAicreateCreation } from '@/composables/useAicreateCreation'
import { getWorkDetail } from '@/api/aicreate'
const route = useRoute()
const router = useRouter()
const store = useAicreateStore()
const { resetCreation } = useAicreateCreation()
const fromWorks = new URLSearchParams(window.location.search).get('from') === 'works'
|| sessionStorage.getItem('le_from') === 'works'
@ -171,7 +173,7 @@ const onTouchEnd = (e: TouchEvent) => {
}
const goHome = () => {
store.reset()
resetCreation()
router.push('/p/create')
}

View File

@ -91,7 +91,7 @@ export default { name: 'CharactersView' }
</template>
<script setup lang="ts">
import { ref, computed, onActivated } from 'vue'
import { ref, computed, onMounted, onActivated } from 'vue'
import { useRouter } from 'vue-router'
import {
LoadingOutlined,
@ -105,12 +105,11 @@ import {
} from '@ant-design/icons-vue'
import PageHeader from '@/components/aicreate/PageHeader.vue'
import { useAicreateStore } from '@/stores/aicreate'
import { useAicreateCreation } from '@/composables/useAicreateCreation'
import { extractCharacters } from '@/api/aicreate'
import { saveExtractDraft } from '@/utils/aicreate/extractDraft'
const router = useRouter()
const store = useAicreateStore()
const flow = useAicreateCreation()
const loading = ref(true)
const selected = ref<string | null>(null)
const characters = ref<any[]>([])
@ -135,47 +134,81 @@ const nextLabel = computed(() => {
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() {
if (store.characters && store.characters.length > 0) {
characters.value = store.characters
error.value = ''
if (flow.characters.value && flow.characters.value.length > 0) {
characters.value = flow.characters.value.map(normalizeCharacterItem)
autoSelect()
loading.value = false
return
}
if (!store.imageUrl) {
if (!flow.imageUrl.value) {
error.value = '未上传图片,请返回上传'
loading.value = false
return
}
try {
const res = await extractCharacters(store.imageUrl)
const data = res || {}
characters.value = (data.characters || []).map((c: any) => ({
...c,
type: c.charType || c.type || 'SIDEKICK'
}))
const res = await extractCharacters(flow.imageUrl.value)
const { characters: list, extractId } = unwrapExtractResult(res)
characters.value = list.map(normalizeCharacterItem)
if (characters.value.length === 0) {
error.value = 'AI 未识别到角色,请更换图片重试'
}
store.extractId = data.extractId || ''
store.characters = characters.value
flow.extractId.value = extractId
flow.characters.value = characters.value
autoSelect()
if (characters.value.length > 0) {
saveExtractDraft({
imageUrl: store.imageUrl,
extractId: store.extractId,
characters: characters.value,
raw: data,
})
}
} catch (e: any) {
error.value = '角色识别失败:' + (e.message || '请检查网络')
const msg =
e?.response?.data?.message ||
e?.response?.data?.msg ||
e?.message ||
'请检查网络'
error.value = '角色识别失败:' + msg
} finally {
loading.value = false
}
}
onMounted(() => {
void loadCharacters()
})
onActivated(() => {
void loadCharacters()
})
@ -194,7 +227,7 @@ const goNext = () => {
const target = characters.value.length === 1
? characters.value[0]
: characters.value.find(c => c.charId === selected.value)
store.selectedCharacter = target
flow.selectedCharacter.value = target
router.push('/p/create/story')
}
</script>

View File

@ -44,10 +44,10 @@ export default { name: 'CreatingView' }
<frown-outlined class="error-icon" />
<div class="error-text">{{ error }}</div>
<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 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">
重新创作
</button>
@ -86,13 +86,21 @@ import {
InboxOutlined,
} from '@ant-design/icons-vue'
import { useAicreateStore } from '@/stores/aicreate'
import { useAicreateCreation } from '@/composables/useAicreateCreation'
import { createStory, getWorkDetail } from '@/api/aicreate'
import { STATUS, getRouteByStatus } from '@/utils/aicreate/status'
import { clearExtractDraft } from '@/utils/aicreate/extractDraft'
import config from '@/utils/aicreate/config'
const router = useRouter()
const store = useAicreateStore()
const sessionStore = useAicreateStore()
const {
workId,
imageUrl,
storyData,
selectedStyle,
selectedCharacter,
extractId,
} = useAicreateCreation()
const progress = ref(0)
const stage = ref('准备中…')
const dots = ref('')
@ -158,47 +166,30 @@ function friendlyStage(pct: number, msg: string): string {
return '绘本创作完成'
}
// workId localStorage
/** 仅内存中保存当前乐读派 workId刷新页面后不恢复 */
function saveWorkId(id: string) {
store.workId = id
if (id) {
const urlWorkId = new URLSearchParams(window.location.search).get('workId');
if (!urlWorkId) {
localStorage.setItem('le_workId', id)
}
} else {
localStorage.removeItem('le_workId')
}
workId.value = id
}
function restoreWorkId() {
if (!store.workId) {
store.workId = localStorage.getItem('le_workId') || ''
}
}
/** 创作已推进到预览/配音等后续步骤时清除 extract 本地草稿 */
/** 创作已推进到后续步骤时的路由跳转 */
function replaceWhenCreationAdvances(route: ReturnType<typeof getRouteByStatus>) {
if (!route) return
if (route.name !== 'PublicCreateCreating') {
clearExtractDraft()
}
setTimeout(() => router.replace(route), 800)
}
// WebSocket (使)
const startWebSocket = (workId: string) => {
const startWebSocket = (remoteWid: string) => {
wsDegraded = false
const wsBase = config.wsBaseUrl
? config.wsBaseUrl
: `${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({
brokerURL: wsUrl,
reconnectDelay: 0,
onConnect: () => {
stompClient.subscribe(`/topic/progress/${workId}`, (msg: any) => {
stompClient.subscribe(`/topic/progress/${remoteWid}`, (msg: any) => {
try {
const data = JSON.parse(msg.body)
if (data.progress != null && data.progress > progress.value) progress.value = data.progress
@ -225,20 +216,20 @@ const startWebSocket = (workId: string) => {
if (wsDegraded) return
wsDegraded = true
closeWebSocket()
startPolling(workId)
startPolling(remoteWid)
},
onWebSocketError: () => {
if (wsDegraded) return
wsDegraded = true
closeWebSocket()
startPolling(workId)
startPolling(remoteWid)
},
onWebSocketClose: () => {
if (wsDegraded) return
if (store.workId) {
if (workId.value) {
wsDegraded = true
closeWebSocket()
startPolling(workId)
startPolling(remoteWid)
}
}
})
@ -316,34 +307,34 @@ const startCreation = async () => {
try {
const res = await createStory({
imageUrl: store.imageUrl,
storyHint: store.storyData?.storyHint || '',
style: store.selectedStyle,
title: store.storyData?.title || '',
heroName: store.storyData?.heroName || '',
author: store.storyData?.author,
heroCharId: store.selectedCharacter?.charId,
extractId: store.extractId,
imageUrl: imageUrl.value,
storyHint: storyData.value?.storyHint || '',
style: selectedStyle.value,
title: storyData.value?.title || '',
heroName: storyData.value?.heroName || '',
author: storyData.value?.author,
heroCharId: selectedCharacter.value?.charId,
extractId: extractId.value,
})
const workId = res?.workId
if (!workId) {
const wid = res?.workId
if (!wid) {
error.value = res.msg || '创作提交失败'
submitted = false
return
}
saveWorkId(workId)
saveWorkId(wid)
progress.value = 0
stage.value = '故事构思中…'
// startWebSocket(workId)
startPolling(store.workId)
// startWebSocket(wid)
startPolling(workId.value)
} catch (e: any) {
console.error('e', e);
if (store.workId) {
if (workId.value) {
progress.value = 0
stage.value = '创作已提交到后台…'
startPolling(store.workId)
startPolling(workId.value)
} else {
error.value = sanitizeError(e.message)
submitted = false
@ -356,7 +347,7 @@ const resumePolling = () => {
networkWarn.value = false
progress.value = 0
stage.value = '正在查询创作进度…'
startPolling(store.workId)
startPolling(workId.value)
}
const retry = () => {
@ -366,7 +357,7 @@ const retry = () => {
}
const leaveToWorks = () => {
// store.workId localStorage CreatingView
//
closeWebSocket()
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
router.push('/p/works?tab=draft')
@ -380,22 +371,20 @@ onMounted(() => {
tipTimer = setInterval(() => {
currentTipIdx.value = (currentTipIdx.value + 1) % creatingTips.length
}, 3500)
// workId
const urlWorkId = new URLSearchParams(window.location.search).get('workId')
console.log('store.workId', urlWorkId, window.location.search)
if (!urlWorkId) {
restoreWorkId()
if (urlWorkId) {
saveWorkId(urlWorkId)
}
if (store.workId) {
if (workId.value) {
try {
getWorkDetailApi(store.workId)
getWorkDetailApi(workId.value)
} catch (error) {
console.log('error', error);
}
submitted = true
progress.value = 0
stage.value = '正在查询创作进度…'
startPolling(store.workId)
startPolling(workId.value)
} else {
startCreation()
}
@ -405,11 +394,9 @@ onActivated(() => {
const urlWorkId = new URLSearchParams(window.location.search).get('workId')
if (urlWorkId) {
saveWorkId(urlWorkId)
} else {
restoreWorkId()
}
if (store.workId) {
void getWorkDetailApi(store.workId)
if (workId.value) {
void getWorkDetailApi(workId.value)
}
})

View File

@ -132,7 +132,7 @@ export default { name: 'DubbingView' }
</template>
<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 {
LoadingOutlined,
@ -148,7 +148,9 @@ import {
import PageHeader from '@/components/aicreate/PageHeader.vue'
import { voicePage, ossUpload } from '@/api/aicreate'
import { getLeaiWorkFormDetail, saveLeaiWorkForm } from '@/api/public'
import { useAicreateCreation } from '@/composables/useAicreateCreation'
const { resetCreation } = useAicreateCreation()
const router = useRouter()
const route = useRoute()
const workId = computed(() => route.params.workId)
@ -547,6 +549,7 @@ async function finish() {
// --- Load ---
async function loadWork() {
resetCreation()
loading.value = true
try {
const res = await getLeaiWorkFormDetail(String(workId.value || ''))
@ -567,7 +570,13 @@ async function loadWork() {
loading.value = false
}
onActivated(loadWork)
/** 子路由未 keep-alive首次进入仅触发 onMounted */
onMounted(() => {
void loadWork()
})
onActivated(() => {
void loadWork()
})
onBeforeUnmount(() => {
stopAudio()
if (isRecording.value && mediaRecorder?.state === 'recording') {

View File

@ -115,7 +115,7 @@ export default { name: 'EditInfoView' }
</template>
<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 {
LoadingOutlined,
@ -133,6 +133,9 @@ import { message } from 'ant-design-vue'
import PageHeader from '@/components/aicreate/PageHeader.vue'
import { getLeaiWorkFormDetail, publicUserWorksApi, saveLeaiWorkForm } from '@/api/public'
import { useAicreateCreation } from '@/composables/useAicreateCreation'
const { resetCreation } = useAicreateCreation()
const router = useRouter()
const route = useRoute()
const workId = computed(() => route.params.workId)
@ -215,6 +218,7 @@ function applyDetailToForm(w) {
}
async function loadWork() {
resetCreation()
const id = resolvedWorkIdStr()
if (!id) {
loading.value = false
@ -381,8 +385,12 @@ watch(
},
)
/** 子路由未 keep-alive首次进入仅触发 onMounted否则一直卡在加载中 */
onMounted(() => {
void loadWork()
})
onActivated(() => {
loadWork()
void loadWork()
nextTick(() => { if (tagInput.value) tagInput.value.focus() })
})
</script>

View File

@ -75,7 +75,7 @@ export default { name: 'PreviewView' }
</template>
<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 {
LoadingOutlined,
@ -86,13 +86,13 @@ import {
ArrowRightOutlined,
} from '@ant-design/icons-vue'
import { getWorkDetail } from '@/api/aicreate'
import { useAicreateStore } from '@/stores/aicreate'
import { useAicreateCreation } from '@/composables/useAicreateCreation'
import { STATUS, getRouteByStatus } from '@/utils/aicreate/status'
const router = useRouter()
const route = useRoute()
const store = useAicreateStore()
const { resetCreation } = useAicreateCreation()
const loading = ref(true)
const error = ref('')
const pages = ref<any[]>([])
@ -123,7 +123,7 @@ function scrollThumbIntoView(i: number) {
const workId = computed(() => route.params.workId)
async function loadWork() {
store.reset();
resetCreation()
loading.value = true
error.value = ''
@ -154,7 +154,13 @@ function goEditInfo() {
router.push(`/p/create/edit-info/${workId.value}`)
}
onActivated(loadWork)
/** 壳层子路由未 keep-alive首次进入只有 onMounted无 onActivated否则一直卡在「加载中」 */
onMounted(() => {
void loadWork()
})
onActivated(() => {
void loadWork()
})
</script>
<style lang="scss" scoped>

View File

@ -54,12 +54,12 @@ export default { name: 'SaveSuccessView' }
import { ref, computed, onMounted, onActivated } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { getWorkDetail } from '@/api/aicreate'
import { useAicreateStore } from '@/stores/aicreate'
import { useAicreateCreation } from '@/composables/useAicreateCreation'
const router = useRouter()
const route = useRoute()
const store = useAicreateStore()
const workId = computed(() => String(route.params.workId || store.workId || ''))
const flow = useAicreateCreation()
const workId = computed(() => String(route.params.workId || flow.workId.value || ''))
const afterPublish = computed(() => route.query.after === 'publish')
@ -93,13 +93,14 @@ const title = ref('')
const author = ref('')
async function loadWork() {
flow.resetCreation()
try {
if (!store.workDetail || store.workDetail.workId !== workId.value) {
store.workDetail = null
if (!flow.workDetail.value || flow.workDetail.value.workId !== workId.value) {
flow.workDetail.value = null
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 || '我的绘本'
author.value = w.author || ''
coverUrl.value = w.pageList?.[0]?.imageUrl || ''
@ -119,8 +120,7 @@ function goWorks() {
}
onMounted(() => {
// reset
store.reset()
void loadWork()
})
onActivated(() => {

View File

@ -102,12 +102,12 @@ import {
ClockCircleOutlined,
} from '@ant-design/icons-vue'
import PageHeader from '@/components/aicreate/PageHeader.vue'
import { useAicreateStore } from '@/stores/aicreate'
import { useAicreateCreation } from '@/composables/useAicreateCreation'
const router = useRouter()
const store = useAicreateStore()
const flow = useAicreateCreation()
const bookTitle = ref('')
const heroName = ref(store.selectedCharacter?.name || '')
const heroName = ref(flow.selectedCharacter.value?.name || '')
const storyStart = ref('')
const meetWho = ref('')
const whatHappens = ref('')
@ -115,7 +115,7 @@ const whatHappens = ref('')
const bookTitleFocus = ref(false)
const heroNameFocus = ref(false)
const heroAvatar = computed(() => store.selectedCharacter?.originalCropUrl || '')
const heroAvatar = computed(() => flow.selectedCharacter.value?.originalCropUrl || '')
const fields = [
{ 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())
// keep-alive 退 onActivated
// 退 onActivated
let submitted = false
onActivated(() => {
submitted = false
@ -140,7 +140,7 @@ const goNext = () => {
if (meetWho.value.trim()) parts.push(`遇见谁:${meetWho.value.trim()}`)
parts.push(`发生什么:${whatHappens.value.trim()}`)
store.storyData = {
flow.storyData.value = {
heroName: heroName.value,
storyHint: parts.join(''),
title: bookTitle.value.trim()

View File

@ -58,7 +58,7 @@ import {
ArrowRightOutlined,
} from '@ant-design/icons-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'
@ -67,7 +67,7 @@ import styleInkNormal from '@/assets/images/style-ink-normal.png'
import stylePencilNormal from '@/assets/images/style-pencil-normal.png'
const router = useRouter()
const store = useAicreateStore()
const flow = useAicreateCreation()
const selected = ref('')
interface StyleItem {
@ -94,12 +94,12 @@ const styles: StyleItem[] = [
const visibleStyles = styles.filter(s => !s.hidden)
const goNext = () => {
store.selectedStyle = selected.value
flow.selectedStyle.value = selected.value
router.push('/p/create/creating')
}
onActivated(() => {
selected.value = store.selectedStyle || ''
selected.value = flow.selectedStyle.value || ''
})
</script>

View File

@ -109,9 +109,8 @@ export default { name: 'UploadView' }
import { ref, onActivated } from 'vue'
import { useRouter } from 'vue-router'
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 { saveExtractDraft } from '@/utils/aicreate/extractDraft'
import {
PictureOutlined,
CameraOutlined,
@ -127,7 +126,13 @@ import {
} from '@ant-design/icons-vue'
const router = useRouter()
const store = useAicreateStore()
const {
imageUrl,
extractId,
characters,
originalWorkId,
resetCreation,
} = useAicreateCreation()
const preview = ref<string | null>(null)
const uploading = ref(false)
const cameraInput = ref<HTMLInputElement | null>(null)
@ -141,6 +146,7 @@ const quotaOk = ref(true)
const quotaMsg = ref('')
let selectedFile: File | null = null
/** 仅校验额度 */
async function refreshQuota() {
try {
await checkQuota()
@ -230,6 +236,8 @@ const onFileChange = async (e: Event) => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
//
resetCreation()
uploading.value = true
compressed.value = false
@ -256,11 +264,17 @@ const onFileChange = async (e: Event) => {
}
const reset = () => {
resetCreation()
preview.value = null
selectedFile = null
fileSizeInfo.value = ''
compressed.value = false
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('')
@ -286,6 +300,8 @@ const goNext = async () => {
uploading.value = true
uploadError.value = ''
uploadStage.value = 1
// workId
resetCreation()
try {
// Step 1: STS OSS
uploadProgress.value = '上传画作到云端...'
@ -306,16 +322,10 @@ const goNext = async () => {
type: c.charType || c.type || 'SIDEKICK'
}))
if (chars.length === 0) throw new Error('AI未识别到角色请更换图片重试')
store.extractId = data.extractId || ''
store.characters = chars
store.imageUrl = ossUrl
if (data.workId) store.originalWorkId = data.workId
saveExtractDraft({
imageUrl: ossUrl,
extractId: data.extractId || '',
characters: chars,
raw: data,
})
extractId.value = data.extractId || ''
characters.value = chars
imageUrl.value = ossUrl
if (data.workId) originalWorkId.value = data.workId
router.push('/p/create/characters')
} catch (e: any) {
uploadError.value = '识别失败:' + sanitizeError(e.message)

View File

@ -3,6 +3,11 @@ export default { name: 'WelcomeView' }
</script>
<template>
<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 -->
<section class="hero">
<div class="hero-deco">
@ -127,7 +132,7 @@ export default { name: 'WelcomeView' }
</template>
<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 {
CameraOutlined,
@ -144,110 +149,51 @@ import {
} from '@ant-design/icons-vue'
import { Modal } from 'ant-design-vue'
import { useAicreateStore } from '@/stores/aicreate'
import { loadExtractDraft } from '@/utils/aicreate/extractDraft'
import { resumeLeaiWorkFromApi } from '@/utils/aicreate/resumeLeaiWork'
import { useAicreateCreation } from '@/composables/useAicreateCreation'
import { runCreateShellEntry } from '@/utils/aicreate/runCreateShellEntry'
const route = useRoute()
const router = useRouter()
const store = useAicreateStore()
const flow = useAicreateCreation()
const { resetCreation } = flow
/** 作品库「编辑」传入的 resumeWorkIdquery 可能已解码,容错二次 decode */
function parseResumeWorkIdFromQuery(raw: string): string {
const t = String(raw || '').trim()
if (!t) return ''
try {
return decodeURIComponent(t)
} catch {
return t
}
const resumeResolving = ref(false)
function rawResumeWorkIdFromRoute(): string {
const q = route.query.resumeWorkId
if (typeof q === 'string') return q.trim()
if (Array.isArray(q) && q[0]) return String(q[0]).trim()
return ''
}
function getResumeWorkIdFromRoute(): string {
const qResume = route.query.resumeWorkId
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() {
/** Token 已由壳层 Index 拉取后再执行;?resumeWorkId= 时拉详情创作中→Creating否则→Preview无参数时可按内存 workId 恢复 */
async function tryWelcomeEntry() {
if (route.name !== 'PublicCreateWelcome') return
if (welcomeResumeRunning) return
welcomeResumeRunning = true
const fromQuery = rawResumeWorkIdFromRoute()
if (fromQuery) resumeResolving.value = 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
}
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')
}
await runCreateShellEntry(router, route, store.sessionToken, flow)
} finally {
welcomeResumeRunning = false
if (fromQuery) resumeResolving.value = false
}
}
onMounted(() => {
void runWelcomeEntry()
})
onActivated(() => {
void runWelcomeEntry()
void tryWelcomeEntry()
})
watch(
() => [store.sessionToken, route.query.resumeWorkId] as const,
() => [store.sessionToken, route.query.resumeWorkId, route.name] as const,
() => {
void runWelcomeEntry()
void tryWelcomeEntry()
},
)
const handleStart = () => {
if (!store.sessionToken) return
store.reset()
resetCreation()
store.clearLastCreateRoute()
// Modal.warning confirm +
Modal.confirm({
@ -279,6 +225,41 @@ $gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
display: flex;
flex-direction: column;
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 ---------- */

View File

@ -116,9 +116,11 @@ import {
} from "@ant-design/icons-vue"
import { publicProfileApi, publicMineApi, type PublicProfileUpdatePayload } from "@/api/public"
import { useAicreateStore } from "@/stores/aicreate"
import { useAicreateCreation } from "@/composables/useAicreateCreation"
const router = useRouter()
const aicreateStore = useAicreateStore()
const { resetCreation } = useAicreateCreation()
const user = ref<any>(null)
const showEditModal = ref(false)
const editLoading = ref(false)
@ -234,8 +236,9 @@ const handleLogout = () => {
localStorage.removeItem("public_user")
localStorage.removeItem("parent_token_backup")
localStorage.removeItem("parent_user_backup")
//
aicreateStore.reset()
//
resetCreation()
aicreateStore.clearLastCreateRoute()
aicreateStore.clearSession()
router.push("/p/login")
}

View File

@ -8,7 +8,8 @@
<left-outlined />
</button>
<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>
<!-- 审核拒绝原因仅作者 + rejected置于内容区顶部便于阅读-->
@ -115,12 +116,6 @@
<!-- 作者私有操作 -->
<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"
@click="handleResubmit">
@ -128,12 +123,23 @@
<span>修改后重交</span>
</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"
:disabled="actionLoading" @click="handleContinue">
<edit-outlined />
<span>编辑</span>
</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"
@click="handleWithdraw">
<undo-outlined />
@ -231,7 +237,7 @@ function getPublicUserId(): number | null {
/**
* 作品作者 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 {
if (typeof w.userId === 'number' && !Number.isNaN(w.userId)) return w.userId
@ -675,6 +681,11 @@ $accent: #ec4899;
&.taken_down {
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 {
display: flex;
gap: 10px;
flex-wrap: wrap;
gap: 6px;
flex-wrap: nowrap;
align-items: stretch;
background: #fff;
border-radius: 16px;
padding: 14px 16px;
padding: 12px 10px;
border: 1px solid rgba($primary, 0.06);
box-shadow: 0 2px 12px rgba($primary, 0.05);
}
.op-btn {
flex: 1;
min-width: 100px;
flex: 1 1 0;
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 11px 12px;
gap: 4px;
padding: 10px 6px;
border-radius: 20px;
font-size: 13px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
:deep(.anticon) {
font-size: 13px;
font-size: 12px;
flex-shrink: 0;
}
&:active {
@ -1146,9 +1159,9 @@ $accent: #ec4899;
}
.op-btn.ghost-danger {
flex: 0 0 auto;
flex: 1 1 0;
min-width: 0;
padding: 11px 16px;
padding: 10px 6px;
background: transparent;
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.2);

View File

@ -9,12 +9,8 @@
<!-- 状态 Tab -->
<div class="status-tabs">
<span
v-for="tab in tabs"
:key="tab.key"
:class="['tab-item', { active: activeTab === tab.key }]"
@click="switchTab(tab.key)"
>
<span v-for="tab in tabs" :key="tab.key" :class="['tab-item', { active: activeTab === tab.key }]"
@click="switchTab(tab.key)">
{{ tab.label }}
</span>
</div>
@ -36,14 +32,12 @@
<picture-outlined />
</div>
<!-- 右下角 PIP用户上传的原图 -->
<div
v-if="work.originalImageUrl && work.originalImageUrl !== work.coverUrl"
class="cover-pip"
:title="'原图'"
>
<div v-if="work.originalImageUrl && work.originalImageUrl !== work.coverUrl" class="cover-pip" :title="'原图'">
<img :src="work.originalImageUrl" alt="原图" />
</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 }}
</div>
</div>
@ -62,13 +56,7 @@
<!-- 分页 -->
<div v-if="total > pageSize" class="pagination-wrap">
<a-pagination
v-model:current="currentPage"
:total="total"
:page-size="pageSize"
simple
@change="fetchWorks"
/>
<a-pagination v-model:current="currentPage" :total="total" :page-size="pageSize" simple @change="fetchWorks" />
</div>
</div>
</template>
@ -194,7 +182,13 @@ $primary: #6366f1;
justify-content: space-between;
align-items: center;
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 {
@ -213,12 +207,19 @@ $primary: #6366f1;
white-space: nowrap;
transition: all 0.2s;
&.active { background: $primary; color: #fff; }
&:hover:not(.active) { background: #e5e7eb; }
&.active {
background: $primary;
color: #fff;
}
&:hover:not(.active) {
background: #e5e7eb;
}
}
}
.loading-wrap, .empty-wrap {
.loading-wrap,
.empty-wrap {
padding: 60px 0;
display: flex;
justify-content: center;
@ -242,14 +243,21 @@ $primary: #6366f1;
transition: all 0.2s;
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 {
position: relative;
aspect-ratio: 3/4;
background: #f5f3ff;
img { width: 100%; height: 100%; object-fit: cover; }
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-placeholder {
display: flex;
@ -270,12 +278,40 @@ $primary: #6366f1;
font-size: 10px;
font-weight: 600;
&.draft { 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; }
&.draft {
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用户原图 */
@ -308,12 +344,24 @@ $primary: #6366f1;
.work-info {
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 {
display: flex;
gap: 8px;
span { font-size: 11px; color: #9ca3af; }
span {
font-size: 11px;
color: #9ca3af;
}
}
}
}