feat: 选角仅放大镜预览、extract草稿与作品提交审核
Made-with: Cursor
This commit is contained in:
parent
df1817fe23
commit
7ad98e92ea
@ -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 = {
|
||||
// 获取乐读派创作 Token(iframe 模式主入口)
|
||||
getToken: (): Promise<{
|
||||
token: string
|
||||
orgId: string
|
||||
token: string;
|
||||
orgId: string;
|
||||
}> => publicApi.get("/leai-auth/token"),
|
||||
|
||||
// 刷新 Token(TOKEN_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;
|
||||
|
||||
@ -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,
|
||||
|
||||
60
frontend/src/utils/aicreate/extractDraft.ts
Normal file
60
frontend/src/utils/aicreate/extractDraft.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -291,7 +291,7 @@ function togglePlay() {
|
||||
|
||||
// dev mock 兜底:mock 音频直接 toast 不真实播放
|
||||
if (typeof src === 'string' && src.startsWith('mock-audio-')) {
|
||||
showToast('开发模式:模拟音频暂不支持播放')
|
||||
showToast('模拟音频暂不支持播放')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -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() })
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user