feat: 选角仅放大镜预览、extract草稿与作品提交审核

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-10 14:07:07 +08:00
parent df1817fe23
commit 7ad98e92ea
10 changed files with 617 additions and 582 deletions

View File

@ -1,43 +1,43 @@
import axios from "axios"
import axios from "axios";
// 公众端专用 axios 实例
const publicApi = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || "/api",
timeout: 15000,
})
});
// 请求拦截器
publicApi.interceptors.request.use((config) => {
const token = localStorage.getItem("public_token")
const token = localStorage.getItem("public_token");
if (token) {
// 检查 Token 是否过期
if (isTokenExpired(token)) {
localStorage.removeItem("public_token")
localStorage.removeItem("public_user")
localStorage.removeItem("public_token");
localStorage.removeItem("public_user");
// 如果在公众端页面,跳转到登录页
if (window.location.pathname.startsWith("/p/")) {
window.location.href = "/p/login"
window.location.href = "/p/login";
}
return config
return config;
}
config.headers.Authorization = `Bearer ${token}`
config.headers.Authorization = `Bearer ${token}`;
}
return config
})
return config;
});
/**
* JWT payload Token
*/
function isTokenExpired(token: string): boolean {
try {
const parts = token.split(".")
if (parts.length !== 3) return true
const payload = JSON.parse(atob(parts[1]))
if (!payload.exp) return false
const parts = token.split(".");
if (parts.length !== 3) return true;
const payload = JSON.parse(atob(parts[1]));
if (!payload.exp) return false;
// exp 是秒级时间戳,转换为毫秒比较
return Date.now() >= payload.exp * 1000
return Date.now() >= payload.exp * 1000;
} catch {
return true
return true;
}
}
@ -46,73 +46,75 @@ publicApi.interceptors.response.use(
(response) => {
// 后端返回格式:{ code: 200, message: "success", data: xxx }
// 检查业务状态码,非 200 视为业务错误
const resData = response.data
const resData = response.data;
if (resData && resData.code !== undefined && resData.code !== 200) {
// 兼容后端 Result.message 和乐读派原始响应的 msg 字段
const error: any = new Error(resData.message || resData.msg || "请求失败")
error.response = { data: resData }
return Promise.reject(error)
const error: any = new Error(
resData.message || resData.msg || "请求失败",
);
error.response = { data: resData };
return Promise.reject(error);
}
if (resData) {
return resData.data !== undefined ? resData.data : resData
return resData.data !== undefined ? resData.data : resData;
}
return resData
return resData;
},
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem("public_token")
localStorage.removeItem("public_user")
localStorage.removeItem("public_token");
localStorage.removeItem("public_user");
// 如果在公众端页面,跳转到公众端登录
if (window.location.pathname.startsWith("/p/")) {
window.location.href = "/p/login"
window.location.href = "/p/login";
}
}
return Promise.reject(error)
return Promise.reject(error);
},
)
);
// ==================== 认证 ====================
export interface PublicRegisterParams {
username: string
password: string
nickname: string
phone?: string
smsCode?: string
city?: string
username: string;
password: string;
nickname: string;
phone?: string;
smsCode?: string;
city?: string;
}
export interface PublicLoginParams {
username: string
password: string
username: string;
password: string;
}
export interface PublicSmsLoginParams {
phone: string
smsCode: string
phone: string;
smsCode: string;
}
export interface PublicUser {
id: number
username: string
nickname: string
phone: string | null
city: string | null
avatar: string | null
tenantId: number
tenantCode: string
userSource: string
userType: "adult" | "child"
parentUserId: number | null
roles: string[]
permissions: string[]
children?: any[]
childrenCount?: number
id: number;
username: string;
nickname: string;
phone: string | null;
city: string | null;
avatar: string | null;
tenantId: number;
tenantCode: string;
userSource: string;
userType: "adult" | "child";
parentUserId: number | null;
roles: string[];
permissions: string[];
children?: any[];
childrenCount?: number;
}
export interface LoginResponse {
token: string
user: PublicUser
token: string;
user: PublicUser;
}
export const publicAuthApi = {
@ -129,7 +131,7 @@ export const publicAuthApi = {
/** 发送短信验证码 */
sendSmsCode: (phone: string): Promise<void> =>
publicApi.post("/public/auth/sms/send", { phone }),
}
};
// ==================== 个人信息 ====================
@ -137,34 +139,34 @@ export const publicProfileApi = {
getProfile: (): Promise<PublicUser> => publicApi.get("/public/mine/profile"),
updateProfile: (data: {
nickname?: string
city?: string
avatar?: string
gender?: string
nickname?: string;
city?: string;
avatar?: string;
gender?: string;
}) => publicApi.put("/public/mine/profile", data),
}
};
// ==================== 子女管理 ====================
export interface Child {
id: number
parentId: number
name: string
gender: string | null
birthday: string | null
grade: string | null
city: string | null
schoolName: string | null
avatar: string | null
id: number;
parentId: number;
name: string;
gender: string | null;
birthday: string | null;
grade: string | null;
city: string | null;
schoolName: string | null;
avatar: string | null;
}
export interface CreateChildParams {
name: string
gender?: string
birthday?: string
grade?: string
city?: string
schoolName?: string
name: string;
gender?: string;
birthday?: string;
grade?: string;
city?: string;
schoolName?: string;
}
export const publicChildrenApi = {
@ -180,34 +182,34 @@ export const publicChildrenApi = {
publicApi.put(`/public/mine/children/${id}`, data),
delete: (id: number) => publicApi.delete(`/public/mine/children/${id}`),
}
};
// ==================== 子女独立账号管理 ====================
export interface CreateChildAccountParams {
username: string
password: string
nickname: string
gender?: string
birthday?: string
city?: string
avatar?: string
relationship?: string
username: string;
password: string;
nickname: string;
gender?: string;
birthday?: string;
city?: string;
avatar?: string;
relationship?: string;
}
export interface ChildAccount {
id: number
username: string
nickname: string
avatar: string | null
gender: string | null
birthday: string | null
city: string | null
status: string
userType: string
createTime: string
relationship: string | null
controlMode: string
id: number;
username: string;
nickname: string;
avatar: string | null;
gender: string | null;
birthday: string | null;
city: string | null;
status: string;
userType: string;
createTime: string;
relationship: string | null;
controlMode: string;
}
export const publicChildAccountApi = {
@ -224,108 +226,109 @@ export const publicChildAccountApi = {
publicApi.post("/public/auth/switch-child", { childUserId }),
// 更新子女账号信息
update: (id: number, data: {
nickname?: string
password?: string
gender?: string
birthday?: string
city?: string
avatar?: string
controlMode?: string
}): Promise<any> =>
publicApi.put(`/public/children/accounts/${id}`, data),
update: (
id: number,
data: {
nickname?: string;
password?: string;
gender?: string;
birthday?: string;
city?: string;
avatar?: string;
controlMode?: string;
},
): Promise<any> => publicApi.put(`/public/children/accounts/${id}`, data),
// 子女查看家长信息
getParentInfo: (): Promise<{
parentId: number
nickname: string
avatar: string | null
relationship: string | null
} | null> =>
publicApi.get("/public/mine/parent-info"),
}
parentId: number;
nickname: string;
avatar: string | null;
relationship: string | null;
} | null> => publicApi.get("/public/mine/parent-info"),
};
// ==================== 活动 ====================
export interface PublicActivity {
id: number
contestName: string
contestType: string
contestState: string
status: string
startTime: string
endTime: string
coverUrl: string | null
posterUrl: string | null
registerStartTime: string
registerEndTime: string
submitStartTime: string
submitEndTime: string
submitRule: string
reviewStartTime: string
reviewEndTime: string
organizers: any
visibility: string
resultState: string
resultPublishTime: string | null
content: string
address: string | null
contactName: string | null
contactPhone: string | null
contactQrcode: string | null
coOrganizers: any
sponsors: any
registerState: string
workType: string
workRequirement: string
id: number;
contestName: string;
contestType: string;
contestState: string;
status: string;
startTime: string;
endTime: string;
coverUrl: string | null;
posterUrl: string | null;
registerStartTime: string;
registerEndTime: string;
submitStartTime: string;
submitEndTime: string;
submitRule: string;
reviewStartTime: string;
reviewEndTime: string;
organizers: any;
visibility: string;
resultState: string;
resultPublishTime: string | null;
content: string;
address: string | null;
contactName: string | null;
contactPhone: string | null;
contactQrcode: string | null;
coOrganizers: any;
sponsors: any;
registerState: string;
workType: string;
workRequirement: string;
}
/** 公众端活动详情(含公告、附件等扩展字段) */
export interface PublicActivityNotice {
id: number
title: string
content: string
noticeType?: string
publishTime?: string
createTime?: string
id: number;
title: string;
content: string;
noticeType?: string;
publishTime?: string;
createTime?: string;
}
export interface PublicActivityAttachment {
id: number
fileName: string
fileUrl: string
fileType?: string
format?: string
size?: string
id: number;
fileName: string;
fileUrl: string;
fileType?: string;
format?: string;
size?: string;
}
export interface PublicActivityDetail extends PublicActivity {
/** 兼容旧字段;详情正文以后端 content 为准 */
description?: string
notices?: PublicActivityNotice[]
attachments?: PublicActivityAttachment[]
ageMin?: number
ageMax?: number
targetCities?: string[]
description?: string;
notices?: PublicActivityNotice[];
attachments?: PublicActivityAttachment[];
ageMin?: number;
ageMax?: number;
targetCities?: string[];
}
/** 公众端公示成果行(无报名账号等敏感字段) */
export interface PublicActivityResultItem {
id: number
workNo: string | null
title: string | null
rank: number | null
finalScore: number | string | null
awardName: string | null
participantName: string
id: number;
workNo: string | null;
title: string | null;
rank: number | null;
finalScore: number | string | null;
awardName: string | null;
participantName: string;
}
export const publicActivitiesApi = {
list: (params?: {
page?: number
pageSize?: number
keyword?: string
contestType?: string
page?: number;
pageSize?: number;
keyword?: string;
contestType?: string;
}): Promise<{ list: PublicActivity[]; total: number }> =>
publicApi.get("/public/activities", { params }),
@ -339,26 +342,31 @@ export const publicActivitiesApi = {
getMyRegistration: (id: number) =>
publicApi.get<{
id: number
contestId: number
userId: number
registrationType: string
registrationState: string
registrationTime: string
hasSubmittedWork: boolean
workCount: number
id: number;
contestId: number;
userId: number;
registrationType: string;
registrationState: string;
registrationTime: string;
hasSubmittedWork: boolean;
workCount: number;
} | null>(`/public/activities/${id}/my-registration`),
submitWork: (
id: number,
data: {
registrationId: number
userWorkId?: number
title?: string
description?: string
files?: string[]
previewUrl?: string
attachments?: { fileName: string; fileUrl: string; fileType?: string; size?: string }[]
registrationId: number;
userWorkId?: number;
title?: string;
description?: string;
files?: string[];
previewUrl?: string;
attachments?: {
fileName: string;
fileUrl: string;
fileType?: string;
size?: string;
}[];
},
) => publicApi.post(`/public/activities/${id}/submit-work`, data),
@ -367,25 +375,24 @@ export const publicActivitiesApi = {
id: number,
params?: { page?: number; pageSize?: number },
): Promise<{
list: PublicActivityResultItem[]
total: number
page: number
pageSize: number
list: PublicActivityResultItem[];
total: number;
page: number;
pageSize: number;
}> => publicApi.get(`/public/activities/${id}/results`, { params }),
}
};
// ==================== 我的报名 ====================
export const publicMineApi = {
registrations: (params?: { page?: number; pageSize?: number }) =>
publicApi.get("/public/activities/mine/registrations", { params }),
}
};
// ==================== 点赞 & 收藏 ====================
export const publicInteractionApi = {
like: (workId: number) =>
publicApi.post(`/public/works/${workId}/like`),
like: (workId: number) => publicApi.post(`/public/works/${workId}/like`),
favorite: (workId: number) =>
publicApi.post(`/public/works/${workId}/favorite`),
getInteraction: (workId: number) =>
@ -394,7 +401,7 @@ export const publicInteractionApi = {
publicApi.post("/public/works/batch-interaction", { workIds }),
myFavorites: (params?: { page?: number; pageSize?: number }) =>
publicApi.get("/public/mine/favorites", { params }),
}
};
// ==================== 用户作品库 ====================
@ -415,70 +422,85 @@ export const publicInteractionApi = {
* docs/design/public/ugc-work-status-redesign.md
*/
export type WorkStatus =
| 'draft'
| 'unpublished'
| 'pending_review'
| 'published'
| 'rejected'
| 'taken_down'
| "draft"
| "unpublished"
| "pending_review"
| "published"
| "rejected"
| "taken_down";
export interface UserWork {
id: number
userId: number
title: string
coverUrl: string | null
description: string | null
visibility: string
status: WorkStatus
reviewNote: string | null
originalImageUrl: string | null
voiceInputUrl: string | null
textInput: string | null
aiMeta: any
viewCount: number
likeCount: number
favoriteCount: number
commentCount: number
shareCount: number
publishTime: string | null
createTime: string
modifyTime: string
pages?: UserWorkPage[]
tags?: Array<{ tag: { id: number; name: string; category: string } }>
creator?: { id: number; nickname: string; avatar: string | null; username: string }
_count?: { pages: number; likes: number; favorites: number; comments: number }
id: number;
userId: number;
title: string;
coverUrl: string | null;
description: string | null;
visibility: string;
status: WorkStatus;
reviewNote: string | null;
originalImageUrl: string | null;
voiceInputUrl: string | null;
textInput: string | null;
aiMeta: any;
viewCount: number;
likeCount: number;
favoriteCount: number;
commentCount: number;
shareCount: number;
publishTime: string | null;
createTime: string;
modifyTime: string;
pages?: UserWorkPage[];
tags?: Array<{ tag: { id: number; name: string; category: string } }>;
creator?: {
id: number;
nickname: string;
avatar: string | null;
username: string;
};
_count?: {
pages: number;
likes: number;
favorites: number;
comments: number;
};
}
export interface UserWorkPage {
id: number
workId: number
pageNo: number
imageUrl: string | null
text: string | null
audioUrl: string | null
id: number;
workId: number;
pageNo: number;
imageUrl: string | null;
text: string | null;
audioUrl: string | null;
}
export const publicUserWorksApi = {
// 创建作品
create: (data: {
title: string
coverUrl?: string
description?: string
visibility?: string
originalImageUrl?: string
voiceInputUrl?: string
textInput?: string
aiMeta?: any
pages?: Array<{ pageNo: number; imageUrl?: string; text?: string; audioUrl?: string }>
tagIds?: number[]
title: string;
coverUrl?: string;
description?: string;
visibility?: string;
originalImageUrl?: string;
voiceInputUrl?: string;
textInput?: string;
aiMeta?: any;
pages?: Array<{
pageNo: number;
imageUrl?: string;
text?: string;
audioUrl?: string;
}>;
tagIds?: number[];
}): Promise<UserWork> => publicApi.post("/public/works", data),
// 我的作品列表
list: (params?: {
page?: number
pageSize?: number
status?: string
keyword?: string
page?: number;
pageSize?: number;
status?: string;
keyword?: string;
}): Promise<{ list: UserWork[]; total: number }> =>
publicApi.get("/public/works", { params }),
@ -487,13 +509,16 @@ export const publicUserWorksApi = {
publicApi.get(`/public/works/${id}`),
// 更新作品
update: (id: number, data: {
title?: string
description?: string
coverUrl?: string
visibility?: string
tagIds?: number[]
}): Promise<UserWork> => publicApi.put(`/public/works/${id}`, data),
update: (
id: number,
data: {
title?: string;
description?: string;
coverUrl?: string;
visibility?: string;
tagIds?: number[];
},
): Promise<UserWork> => publicApi.put(`/public/works/${id}`, data),
// 删除作品
delete: (id: number) => publicApi.delete(`/public/works/${id}`),
@ -506,30 +531,39 @@ export const publicUserWorksApi = {
publicApi.get(`/public/works/${id}/pages`),
// 保存绘本分页
savePages: (id: number, pages: Array<{ pageNo: number; imageUrl?: string; text?: string; audioUrl?: string }>) =>
publicApi.post(`/public/works/${id}/pages`, { pages }),
}
savePages: (
id: number,
pages: Array<{
pageNo: number;
imageUrl?: string;
text?: string;
audioUrl?: string;
}>,
) => publicApi.post(`/public/works/${id}/pages`, { pages }),
};
// ==================== AI 创作流程 ====================
export const publicCreationApi = {
// 提交创作请求(保留但降级为辅助接口)
submit: (data: {
originalImageUrl: string
voiceInputUrl?: string
textInput?: string
originalImageUrl: string;
voiceInputUrl?: string;
textInput?: string;
}): Promise<{ id: number; status: string; message: string }> =>
publicApi.post("/public/creation/submit", data),
// 查询生成进度(返回 INT 类型 status + progress
getStatus: (id: number): Promise<{
id: number
status: number
progress: number
progressMessage: string | null
remoteWorkId: string | null
title: string
coverUrl: string | null
getStatus: (
id: number,
): Promise<{
id: number;
status: number;
progress: number;
progressMessage: string | null;
remoteWorkId: string | null;
title: string;
coverUrl: string | null;
}> => publicApi.get(`/public/creation/${id}/status`),
// 获取生成结果(包含 pageList
@ -537,39 +571,42 @@ export const publicCreationApi = {
publicApi.get(`/public/creation/${id}/result`),
// 创作历史
history: (params?: { page?: number; pageSize?: number }): Promise<{ list: any[]; total: number }> =>
history: (params?: {
page?: number;
pageSize?: number;
}): Promise<{ list: any[]; total: number }> =>
publicApi.get("/public/creation/history", { params }),
}
};
// ==================== 乐读派 AI 创作集成 ====================
export const leaiApi = {
// 获取乐读派创作 Tokeniframe 模式主入口)
getToken: (): Promise<{
token: string
orgId: string
token: string;
orgId: string;
}> => publicApi.get("/leai-auth/token"),
// 刷新 TokenTOKEN_EXPIRED 时调用)
refreshToken: (): Promise<{
token: string
orgId: string
token: string;
orgId: string;
}> => publicApi.get("/leai-auth/refresh-token"),
}
};
// ==================== 标签 ====================
export interface WorkTag {
id: number
name: string
category: string | null
usageCount: number
id: number;
name: string;
category: string | null;
usageCount: number;
}
export const publicTagsApi = {
list: (): Promise<WorkTag[]> => publicApi.get("/public/tags"),
hot: (): Promise<WorkTag[]> => publicApi.get("/public/tags/hot"),
}
};
// ==================== 作品广场 ====================
@ -578,20 +615,23 @@ export const publicGalleryApi = {
publicApi.get("/public/gallery/recommended"),
list: (params?: {
page?: number
pageSize?: number
tagId?: number
category?: string
sortBy?: string
keyword?: string
page?: number;
pageSize?: number;
tagId?: number;
category?: string;
sortBy?: string;
keyword?: string;
}): Promise<{ list: UserWork[]; total: number }> =>
publicApi.get("/public/gallery", { params }),
detail: (id: number): Promise<UserWork> =>
publicApi.get(`/public/gallery/${id}`),
userWorks: (userId: number, params?: { page?: number; pageSize?: number }): Promise<{ list: UserWork[]; total: number }> =>
userWorks: (
userId: number,
params?: { page?: number; pageSize?: number },
): Promise<{ list: UserWork[]; total: number }> =>
publicApi.get(`/public/users/${userId}/works`, { params }),
}
};
export default publicApi
export default publicApi;

View File

@ -6,6 +6,7 @@
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { clearExtractDraft } from '@/utils/aicreate/extractDraft'
export const useAicreateStore = defineStore('aicreate', () => {
// ─── 认证信息(不再存储敏感信息到 localStorage ───
@ -20,6 +21,8 @@ export const useAicreateStore = defineStore('aicreate', () => {
const selectedStyle = ref('')
const storyData = ref<any>(null)
const workId = ref('')
/** extract 接口可能返回的 workId供下游使用 */
const originalWorkId = ref('')
const workDetail = ref<any>(null)
// ─── Tab 切换状态保存 ───
@ -56,12 +59,14 @@ export const useAicreateStore = defineStore('aicreate', () => {
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() {
@ -186,7 +191,7 @@ export const useAicreateStore = defineStore('aicreate', () => {
setSession, clearSession,
// 创作流程
imageUrl, extractId, characters, selectedCharacter,
selectedStyle, storyData, workId, workDetail,
selectedStyle, storyData, workId, originalWorkId, workDetail,
reset, saveRecoveryState, restoreRecoveryState,
// 开发模式
fillMockData,

View File

@ -0,0 +1,60 @@
/**
* 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

@ -6,14 +6,6 @@ export default { name: 'CharactersView' }
<PageHeader title="画作角色" :subtitle="subtitle" :step="1" />
<div class="content page-content">
<!-- 开发模式mock 数据切换 -->
<div v-if="isDev" class="dev-bar">
<experiment-outlined />
<span class="dev-label">Mock 角色数</span>
<button class="dev-btn" :class="{ active: characters.length === 1 }" @click="regenMock(1)">1 </button>
<button class="dev-btn" :class="{ active: characters.length === 3 }" @click="regenMock(3)">3 </button>
</div>
<!-- 加载中 -->
<div v-if="loading" class="loading-state">
<loading-outlined class="loading-spinner" spin />
@ -34,10 +26,15 @@ export default { name: 'CharactersView' }
<template v-else-if="characters.length === 1">
<div class="single-wrap">
<div class="single-card">
<div class="single-img-wrap" @click="characters[0].originalCropUrl && (previewImg = characters[0].originalCropUrl)">
<div class="single-img-wrap">
<img v-if="characters[0].originalCropUrl" :src="characters[0].originalCropUrl" class="single-img" />
<user-outlined v-else class="single-placeholder" />
<div class="zoom-hint"><zoom-in-outlined /></div>
<div
class="zoom-hint"
@click.stop="characters[0].originalCropUrl && (previewImg = characters[0].originalCropUrl)"
>
<zoom-in-outlined />
</div>
</div>
</div>
<div class="single-tip">
@ -72,10 +69,15 @@ export default { name: 'CharactersView' }
<check-outlined />
</div>
<!-- 头像 -->
<div class="char-img-wrap" @click.stop="c.originalCropUrl && (previewImg = c.originalCropUrl)">
<div class="char-img-wrap">
<img v-if="c.originalCropUrl" :src="c.originalCropUrl" class="char-img" />
<user-outlined v-else class="char-placeholder" />
<div class="zoom-hint"><zoom-in-outlined /></div>
<div
class="zoom-hint"
@click.stop="c.originalCropUrl && (previewImg = c.originalCropUrl)"
>
<zoom-in-outlined />
</div>
</div>
</div>
</div>
@ -110,13 +112,12 @@ import {
CheckOutlined,
ArrowRightOutlined,
ZoomInOutlined,
ExperimentOutlined,
} from '@ant-design/icons-vue'
const isDev = import.meta.env.DEV
import PageHeader from '@/components/aicreate/PageHeader.vue'
import { useAicreateStore } from '@/stores/aicreate'
import { extractCharacters } from '@/api/aicreate'
import { saveExtractDraft } from '@/utils/aicreate/extractDraft'
const router = useRouter()
const store = useAicreateStore()
@ -170,6 +171,14 @@ onMounted(async () => {
store.extractId = data.extractId || ''
store.characters = 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 || '请检查网络')
} finally {
@ -186,15 +195,6 @@ function autoSelect() {
if (hero) selected.value = hero.charId
}
const regenMock = (count: number) => {
store.fillMockData(count)
characters.value = store.characters
selected.value = null
error.value = ''
loading.value = false
autoSelect()
}
const goNext = () => {
// selected
const target = characters.value.length === 1
@ -219,51 +219,6 @@ const goNext = () => {
flex-direction: column;
}
/* ---------- 开发模式切换器 ---------- */
.dev-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 12px;
margin-bottom: 12px;
background: rgba(99, 102, 241, 0.04);
border: 1px dashed rgba(99, 102, 241, 0.3);
border-radius: 12px;
font-size: 11px;
color: var(--ai-text-sub);
:deep(.anticon) {
font-size: 12px;
color: var(--ai-primary);
}
}
.dev-label {
font-weight: 600;
}
.dev-btn {
padding: 4px 12px;
border-radius: 10px;
background: #fff;
color: var(--ai-text-sub);
font-size: 11px;
font-weight: 600;
border: 1px solid rgba(99, 102, 241, 0.2);
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--ai-primary);
color: var(--ai-primary);
}
&.active {
background: var(--ai-gradient);
border-color: transparent;
color: #fff;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
}
}
/* ---------- 加载状态 ---------- */
.loading-state {
flex: 1;
@ -343,7 +298,6 @@ const goNext = () => {
display: flex;
align-items: center;
justify-content: center;
cursor: zoom-in;
&:hover .zoom-hint { opacity: 1; }
&:active { transform: scale(0.98); }
@ -440,7 +394,6 @@ const goNext = () => {
display: flex;
align-items: center;
justify-content: center;
cursor: zoom-in;
&:hover .zoom-hint { opacity: 1; }
}
@ -494,8 +447,9 @@ const goNext = () => {
position: absolute;
right: 6px;
bottom: 6px;
width: 22px;
height: 22px;
z-index: 3;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(15, 12, 41, 0.55);
color: #fff;
@ -504,6 +458,7 @@ const goNext = () => {
justify-content: center;
font-size: 11px;
opacity: 0.7;
cursor: pointer;
transition: opacity 0.2s;
}

View File

@ -3,15 +3,6 @@ export default { name: 'CreatingView' }
</script>
<template>
<div class="creating-page">
<!-- 开发模式状态切换 -->
<div v-if="isDev" class="dev-bar">
<experiment-outlined />
<span class="dev-label">Mock</span>
<button class="dev-btn" @click="enterMockProgress">进度</button>
<button class="dev-btn" @click="enterMockError">错误</button>
<button class="dev-btn" @click="goMockPreview">跳到预览</button>
</div>
<!-- 进度环 -->
<div class="ring-wrap">
<svg width="180" height="180" class="ring-svg">
@ -98,7 +89,6 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Client } from '@stomp/stompjs'
import {
ExperimentOutlined,
FrownOutlined,
WifiOutlined,
CloudServerOutlined,
@ -107,6 +97,7 @@ import {
import { useAicreateStore } from '@/stores/aicreate'
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()
@ -130,8 +121,6 @@ const creatingTips = [
'色彩正在调和',
]
const isDev = import.meta.env.DEV
let pollTimer: ReturnType<typeof setInterval> | null = null
let dotTimer: ReturnType<typeof setInterval> | null = null
let tipTimer: ReturnType<typeof setInterval> | null = null
@ -194,6 +183,15 @@ function restoreWorkId() {
}
}
/** 创作已推进到预览/配音等后续步骤时清除 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) => {
wsDegraded = false
@ -285,7 +283,7 @@ const startPolling = (workId: string) => {
pollTimer = null
saveWorkId('')
const route = getRouteByStatus(work.status, workId)
if (route) setTimeout(() => router.replace(route), 800)
if (route) replaceWhenCreationAdvances(route)
} else if (work.status === STATUS.FAILED) {
clearInterval(pollTimer!)
pollTimer = null
@ -358,10 +356,6 @@ const resumePolling = () => {
}
const retry = () => {
if (isDev && !store.imageUrl) {
enterMockProgress()
return
}
saveWorkId('')
submitted = false
startCreation()
@ -374,31 +368,6 @@ const leaveToWorks = () => {
router.push('/p/works?tab=draft')
}
//
const enterMockProgress = () => {
closeWebSocket()
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
submitted = true
error.value = ''
networkWarn.value = false
progress.value = 35
stage.value = '正在编写故事…'
}
const enterMockError = () => {
closeWebSocket()
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
submitted = false
error.value = '创作请求异常,请返回重新操作'
}
const goMockPreview = () => {
closeWebSocket()
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
store.fillMockWorkDetail()
router.push(`/p/create/preview/${store.workId}`)
}
onMounted(() => {
dotTimer = setInterval(() => {
dots.value = dots.value.length >= 3 ? '' : dots.value + '.'
@ -416,12 +385,6 @@ onMounted(() => {
restoreWorkId()
}
//
if (isDev && !store.workId && (!store.imageUrl || !store.storyData)) {
enterMockProgress()
return
}
if (store.workId) {
submitted = true
progress.value = 10
@ -452,46 +415,6 @@ onUnmounted(() => {
position: relative;
}
/* ---------- 开发模式切换器 ---------- */
.dev-bar {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(99, 102, 241, 0.04);
border: 1px dashed rgba(99, 102, 241, 0.3);
border-radius: 12px;
font-size: 11px;
color: var(--ai-text-sub);
z-index: 5;
:deep(.anticon) {
font-size: 12px;
color: var(--ai-primary);
}
}
.dev-label { font-weight: 600; }
.dev-btn {
padding: 4px 10px;
border-radius: 10px;
background: #fff;
color: var(--ai-text-sub);
font-size: 11px;
font-weight: 600;
border: 1px solid rgba(99, 102, 241, 0.2);
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--ai-primary);
color: var(--ai-primary);
}
}
/* ---------- 进度环 ---------- */
.ring-wrap {
position: relative;

View File

@ -291,7 +291,7 @@ function togglePlay() {
// dev mock mock toast
if (typeof src === 'string' && src.startsWith('mock-audio-')) {
showToast('开发模式:模拟音频暂不支持播放')
showToast('模拟音频暂不支持播放')
return
}

View File

@ -161,6 +161,7 @@ import {
import PageHeader from '@/components/aicreate/PageHeader.vue'
import { getWorkDetail, updateWork } from '@/api/aicreate'
import { useAicreateStore } from '@/stores/aicreate'
import { clearExtractDraft } from '@/utils/aicreate/extractDraft'
import { STATUS, getRouteByStatus } from '@/utils/aicreate/status'
const router = useRouter()
@ -363,6 +364,7 @@ async function handlePublish() {
}
onMounted(() => {
clearExtractDraft()
loadWork()
nextTick(() => { if (tagInput.value) tagInput.value.focus() })
})

View File

@ -6,16 +6,6 @@ export default { name: 'UploadView' }
<PageHeader title="上传作品" subtitle="上传你的画作AI 自动识别角色" :step="0" />
<div class="content page-content">
<!-- 开发模式跳过真实后端调用 -->
<div v-if="isDev" class="dev-skip">
<span class="dev-skip-label">
<experiment-outlined />
开发模式
</span>
<button class="dev-skip-btn" @click="handleSkipUpload(3)">跳过 · 3 个角色</button>
<button class="dev-skip-btn" @click="handleSkipUpload(1)">跳过 · 1 个角色</button>
</div>
<template v-if="!preview">
<!-- 上传区域 -->
<div class="upload-area card">
@ -118,6 +108,7 @@ import { useRouter } from 'vue-router'
import PageHeader from '@/components/aicreate/PageHeader.vue'
import { useAicreateStore } from '@/stores/aicreate'
import { extractCharacters, ossUpload, checkQuota } from '@/api/aicreate'
import { saveExtractDraft } from '@/utils/aicreate/extractDraft'
import {
PictureOutlined,
CameraOutlined,
@ -130,16 +121,8 @@ import {
TeamOutlined,
BulbOutlined,
ArrowRightOutlined,
ExperimentOutlined,
} from '@ant-design/icons-vue'
const isDev = import.meta.env.DEV
const handleSkipUpload = (count: number) => {
store.fillMockData(count)
router.push('/p/create/characters')
}
const router = useRouter()
const store = useAicreateStore()
const preview = ref<string | null>(null)
@ -325,6 +308,12 @@ const goNext = async () => {
store.characters = chars
store.imageUrl = ossUrl
if (data.workId) store.originalWorkId = data.workId
saveExtractDraft({
imageUrl: ossUrl,
extractId: data.extractId || '',
characters: chars,
raw: data,
})
router.push('/p/create/characters')
} catch (e: any) {
uploadError.value = '识别失败:' + sanitizeError(e.message)
@ -545,37 +534,4 @@ const goNext = async () => {
:deep(.anticon) { font-size: 16px; }
}
/* 开发模式跳过按钮 */
.dev-skip {
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
}
.dev-skip-label {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 600;
color: var(--ai-text-sub);
:deep(.anticon) { font-size: 12px; }
}
.dev-skip-btn {
padding: 6px 12px;
border-radius: 14px;
background: rgba(99, 102, 241, 0.06);
color: var(--ai-primary);
font-size: 11px;
font-weight: 600;
border: 1px dashed rgba(99, 102, 241, 0.32);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(99, 102, 241, 0.1);
border-color: var(--ai-primary);
}
}
</style>

View File

@ -142,16 +142,32 @@ import {
StarOutlined,
} from '@ant-design/icons-vue'
import { useAicreateStore } from '@/stores/aicreate'
import { loadExtractDraft } from '@/utils/aicreate/extractDraft'
const router = useRouter()
const store = useAicreateStore()
onMounted(async () => {
//
// Tab
const recovery = store.restoreRecoveryState()
if (recovery && recovery.path && recovery.path !== '/') {
const newPath = '/p/create' + recovery.path
router.push(newPath)
return
}
// 稿10
const draft = loadExtractDraft()
if (draft && store.sessionToken) {
store.imageUrl = draft.imageUrl
store.extractId = draft.extractId
store.characters = draft.characters
store.selectedCharacter = null
store.storyData = null
store.selectedStyle = ''
store.workId = ''
store.workDetail = null
localStorage.removeItem('le_workId')
router.replace('/p/create/characters')
}
})

View File

@ -41,7 +41,8 @@
<!-- 绘本阅读器 -->
<div class="book-reader" v-if="work.pages && work.pages.length > 0">
<div class="page-display">
<img v-if="currentPageData?.imageUrl" :src="currentPageData.imageUrl" :alt="`第${currentPageIndex + 1}页`" class="page-image" />
<img v-if="currentPageData?.imageUrl" :src="currentPageData.imageUrl" :alt="`第${currentPageIndex + 1}页`"
class="page-image" />
<div v-else class="page-placeholder">
<picture-outlined />
</div>
@ -113,71 +114,43 @@
<!-- 作者私有操作 -->
<div v-if="isOwner" class="owner-actions">
<!-- 主操作根据 status 切换 -->
<button
v-if="work.status === 'unpublished'"
class="op-btn primary"
:disabled="actionLoading"
@click="handlePublish"
>
<button v-if="work.status === 'unpublished'" class="op-btn primary" :disabled="actionLoading"
@click="handlePublish">
<send-outlined />
<span>公开发布</span>
</button>
<button
v-else-if="work.status === 'rejected'"
class="op-btn primary"
:disabled="actionLoading"
@click="handleResubmit"
>
<button v-else-if="work.status === 'rejected'" class="op-btn primary" :disabled="actionLoading"
@click="handleResubmit">
<send-outlined />
<span>修改后重交</span>
</button>
<button
v-else-if="work.status === 'draft'"
class="op-btn primary"
@click="handleContinue"
>
<button v-else-if="work.status === 'draft'" class="op-btn primary" @click="handleContinue">
<edit-outlined />
<span>继续创作</span>
</button>
<button
v-else-if="work.status === 'pending_review'"
class="op-btn outline"
:disabled="actionLoading"
@click="handleWithdraw"
>
<button v-else-if="work.status === 'pending_review'" class="op-btn outline" :disabled="actionLoading"
@click="handleWithdraw">
<undo-outlined />
<span>撤回审核</span>
</button>
<button
v-else-if="work.status === 'published'"
class="op-btn outline"
:disabled="actionLoading"
@click="handleUnpublish"
>
<button v-else-if="work.status === 'published'" class="op-btn outline" :disabled="actionLoading"
@click="handleUnpublish">
<inbox-outlined />
<span>下架</span>
</button>
<!-- 编辑信息unpublished 状态-->
<button
v-if="work.status === 'unpublished'"
class="op-btn outline-soft"
@click="handleEditInfo"
>
<button v-if="work.status === 'unpublished'" class="op-btn outline-soft" @click="handleEditInfo">
<edit-outlined />
<span>编辑信息</span>
</button>
<!-- 删除所有状态-->
<button
class="op-btn ghost-danger"
:disabled="actionLoading"
@click="handleDelete"
>
<button class="op-btn ghost-danger" :disabled="actionLoading" @click="handleDelete">
<delete-outlined />
<span>删除</span>
</button>
@ -188,15 +161,8 @@
</a-spin>
<!-- 二次确认弹窗 -->
<a-modal
v-model:open="confirmVisible"
:title="confirmTitle"
:ok-text="confirmOkText"
cancel-text="取消"
:confirm-loading="actionLoading"
@ok="handleConfirmOk"
@cancel="handleConfirmCancel"
>
<a-modal v-model:open="confirmVisible" :title="confirmTitle" :ok-text="confirmOkText" cancel-text="取消"
:confirm-loading="actionLoading" @ok="handleConfirmOk" @cancel="handleConfirmCancel">
<p>{{ confirmContent }}</p>
</a-modal>
@ -541,6 +507,7 @@ $accent: #ec4899;
white-space: nowrap;
}
}
.back-btn {
display: inline-flex;
align-items: center;
@ -555,10 +522,15 @@ $accent: #ec4899;
transition: all 0.2s;
flex-shrink: 0;
:deep(.anticon) { font-size: 15px; }
&:hover { background: rgba($primary, 0.14); }
:deep(.anticon) {
font-size: 15px;
}
&:hover {
background: rgba($primary, 0.14);
}
}
.status-tag {
display: inline-block;
padding: 3px 10px;
@ -568,12 +540,29 @@ $accent: #ec4899;
color: #fff;
letter-spacing: 0.3px;
&.draft { background: rgba(107, 114, 128, 0.85); }
&.unpublished { background: rgba(99, 102, 241, 0.9); }
&.pending_review { background: rgba(245, 158, 11, 0.92); }
&.published { background: rgba(16, 185, 129, 0.92); }
&.rejected { background: rgba(239, 68, 68, 0.92); }
&.taken_down { background: rgba(107, 114, 128, 0.85); }
&.draft {
background: rgba(107, 114, 128, 0.85);
}
&.unpublished {
background: rgba(99, 102, 241, 0.9);
}
&.pending_review {
background: rgba(245, 158, 11, 0.92);
}
&.published {
background: rgba(16, 185, 129, 0.92);
}
&.rejected {
background: rgba(239, 68, 68, 0.92);
}
&.taken_down {
background: rgba(107, 114, 128, 0.85);
}
}
/* ---------- 拒绝原因 / 信息提示卡片 ---------- */
@ -585,33 +574,64 @@ $accent: #ec4899;
border-radius: 14px;
margin-bottom: 14px;
}
.reject-card {
background: rgba(239, 68, 68, 0.06);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.reject-icon {
font-size: 18px;
color: #ef4444;
flex-shrink: 0;
margin-top: 2px;
}
.reject-body { flex: 1; }
.reject-title { font-size: 13px; font-weight: 700; color: #b91c1c; margin-bottom: 4px; }
.reject-content { font-size: 13px; color: #4b5563; line-height: 1.6; }
.reject-body {
flex: 1;
}
.reject-title {
font-size: 13px;
font-weight: 700;
color: #b91c1c;
margin-bottom: 4px;
}
.reject-content {
font-size: 13px;
color: #4b5563;
line-height: 1.6;
}
.info-card {
background: linear-gradient(135deg, rgba($primary, 0.08), rgba($accent, 0.05));
border: 1px solid rgba($primary, 0.15);
}
.info-icon {
font-size: 18px;
color: $primary;
flex-shrink: 0;
margin-top: 2px;
}
.info-body { flex: 1; }
.info-title { font-size: 13px; font-weight: 700; color: #1e1b4b; margin-bottom: 3px; }
.info-desc { font-size: 12px; color: #6b7280; line-height: 1.6; }
.info-body {
flex: 1;
}
.info-title {
font-size: 13px;
font-weight: 700;
color: #1e1b4b;
margin-bottom: 3px;
}
.info-desc {
font-size: 12px;
color: #6b7280;
line-height: 1.6;
}
/* ---------- 画作原图卡片 ---------- */
.original-card {
@ -625,6 +645,7 @@ $accent: #ec4899;
margin-bottom: 14px;
box-shadow: 0 2px 12px rgba($primary, 0.05);
}
.original-thumb {
position: relative;
width: 84px;
@ -640,7 +661,10 @@ $accent: #ec4899;
&:hover {
border-color: $primary;
transform: scale(1.03);
.zoom-hint { opacity: 1; }
.zoom-hint {
opacity: 1;
}
}
img {
@ -663,16 +687,19 @@ $accent: #ec4899;
transition: opacity 0.2s;
}
}
.original-text {
flex: 1;
min-width: 0;
}
.original-title {
font-size: 14px;
font-weight: 700;
color: #1e1b4b;
margin-bottom: 4px;
}
.original-desc {
font-size: 12px;
color: #6b7280;
@ -691,6 +718,7 @@ $accent: #ec4899;
justify-content: center;
cursor: zoom-out;
}
.preview-full-img {
max-width: 90%;
max-height: 80vh;
@ -698,10 +726,16 @@ $accent: #ec4899;
border-radius: 16px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
}
.fade-enter-active,
.fade-leave-active { transition: opacity 0.2s; }
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter-from,
.fade-leave-to { opacity: 0; }
.fade-leave-to {
opacity: 0;
}
/* ---------- 绘本阅读器 ---------- */
.book-reader {
@ -750,7 +784,11 @@ $accent: #ec4899;
.page-audio {
padding: 0 20px 12px;
.audio-player { width: 100%; height: 36px; }
.audio-player {
width: 100%;
height: 36px;
}
}
.page-nav {
@ -773,7 +811,9 @@ $accent: #ec4899;
justify-content: center;
transition: all 0.2s;
:deep(.anticon) { font-size: 14px; }
:deep(.anticon) {
font-size: 14px;
}
&:hover:not(:disabled) {
border-color: $primary;
@ -810,9 +850,21 @@ $accent: #ec4899;
gap: 10px;
margin-bottom: 12px;
.author-info { display: flex; flex-direction: column; }
.author-name { font-size: 14px; font-weight: 600; color: #1e1b4b; }
.create-time { font-size: 11px; color: #9ca3af; }
.author-info {
display: flex;
flex-direction: column;
}
.author-name {
font-size: 14px;
font-weight: 600;
color: #1e1b4b;
}
.create-time {
font-size: 11px;
color: #9ca3af;
}
}
.description {
@ -863,7 +915,10 @@ $accent: #ec4899;
transition: all 0.2s;
user-select: none;
span { font-size: 13px; font-weight: 500; }
span {
font-size: 13px;
font-weight: 500;
}
&:hover {
background: rgba($primary, 0.04);
@ -872,7 +927,10 @@ $accent: #ec4899;
&.active {
color: $accent;
&:hover { background: rgba($accent, 0.06); }
&:hover {
background: rgba($accent, 0.06);
}
}
&.active :deep(.anticon) {
@ -882,9 +940,17 @@ $accent: #ec4899;
}
@keyframes pop {
0% { transform: scale(1); }
50% { transform: scale(1.3); }
100% { transform: scale(1); }
0% {
transform: scale(1);
}
50% {
transform: scale(1.3);
}
100% {
transform: scale(1);
}
}
/* ---------- 作者私有操作区 ---------- */
@ -898,6 +964,7 @@ $accent: #ec4899;
border: 1px solid rgba($primary, 0.06);
box-shadow: 0 2px 12px rgba($primary, 0.05);
}
.op-btn {
flex: 1;
min-width: 100px;
@ -913,10 +980,18 @@ $accent: #ec4899;
transition: all 0.2s;
white-space: nowrap;
:deep(.anticon) { font-size: 13px; }
:deep(.anticon) {
font-size: 13px;
}
&:active { transform: scale(0.97); }
&:disabled { opacity: 0.4; pointer-events: none; }
&:active {
transform: scale(0.97);
}
&:disabled {
opacity: 0.4;
pointer-events: none;
}
}
.op-btn.primary {
@ -925,7 +1000,10 @@ $accent: #ec4899;
border: none;
box-shadow: 0 4px 14px rgba($primary, 0.32);
&:hover { transform: translateY(-1px); box-shadow: 0 6px 18px rgba($primary, 0.4); }
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba($primary, 0.4);
}
}
.op-btn.outline {