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

View File

@ -6,6 +6,7 @@
*/ */
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { clearExtractDraft } from '@/utils/aicreate/extractDraft'
export const useAicreateStore = defineStore('aicreate', () => { export const useAicreateStore = defineStore('aicreate', () => {
// ─── 认证信息(不再存储敏感信息到 localStorage ─── // ─── 认证信息(不再存储敏感信息到 localStorage ───
@ -20,6 +21,8 @@ export const useAicreateStore = defineStore('aicreate', () => {
const selectedStyle = ref('') const selectedStyle = ref('')
const storyData = ref<any>(null) const storyData = ref<any>(null)
const workId = ref('') const workId = ref('')
/** extract 接口可能返回的 workId供下游使用 */
const originalWorkId = ref('')
const workDetail = ref<any>(null) const workDetail = ref<any>(null)
// ─── Tab 切换状态保存 ─── // ─── Tab 切换状态保存 ───
@ -56,12 +59,14 @@ export const useAicreateStore = defineStore('aicreate', () => {
selectedStyle.value = '' selectedStyle.value = ''
storyData.value = null storyData.value = null
workId.value = '' workId.value = ''
originalWorkId.value = ''
workDetail.value = null workDetail.value = null
lastCreateRoute.value = '' lastCreateRoute.value = ''
// 只清除创作流程数据,保留认证信息 // 只清除创作流程数据,保留认证信息
localStorage.removeItem('le_workId') localStorage.removeItem('le_workId')
// 清除 sessionStorage 中的恢复数据 // 清除 sessionStorage 中的恢复数据
sessionStorage.removeItem('le_recovery') sessionStorage.removeItem('le_recovery')
clearExtractDraft()
} }
function saveRecoveryState() { function saveRecoveryState() {
@ -186,7 +191,7 @@ export const useAicreateStore = defineStore('aicreate', () => {
setSession, clearSession, setSession, clearSession,
// 创作流程 // 创作流程
imageUrl, extractId, characters, selectedCharacter, imageUrl, extractId, characters, selectedCharacter,
selectedStyle, storyData, workId, workDetail, selectedStyle, storyData, workId, originalWorkId, workDetail,
reset, saveRecoveryState, restoreRecoveryState, reset, saveRecoveryState, restoreRecoveryState,
// 开发模式 // 开发模式
fillMockData, 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" /> <PageHeader title="画作角色" :subtitle="subtitle" :step="1" />
<div class="content page-content"> <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"> <div v-if="loading" class="loading-state">
<loading-outlined class="loading-spinner" spin /> <loading-outlined class="loading-spinner" spin />
@ -34,10 +26,15 @@ export default { name: 'CharactersView' }
<template v-else-if="characters.length === 1"> <template v-else-if="characters.length === 1">
<div class="single-wrap"> <div class="single-wrap">
<div class="single-card"> <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" /> <img v-if="characters[0].originalCropUrl" :src="characters[0].originalCropUrl" class="single-img" />
<user-outlined v-else class="single-placeholder" /> <user-outlined v-else class="single-placeholder" />
<div 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> </div>
<div class="single-tip"> <div class="single-tip">
@ -72,10 +69,15 @@ export default { name: 'CharactersView' }
<check-outlined /> <check-outlined />
</div> </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" /> <img v-if="c.originalCropUrl" :src="c.originalCropUrl" class="char-img" />
<user-outlined v-else class="char-placeholder" /> <user-outlined v-else class="char-placeholder" />
<div 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> </div>
</div> </div>
@ -110,13 +112,12 @@ import {
CheckOutlined, CheckOutlined,
ArrowRightOutlined, ArrowRightOutlined,
ZoomInOutlined, ZoomInOutlined,
ExperimentOutlined,
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
const isDev = import.meta.env.DEV
import PageHeader from '@/components/aicreate/PageHeader.vue' import PageHeader from '@/components/aicreate/PageHeader.vue'
import { useAicreateStore } from '@/stores/aicreate' import { useAicreateStore } from '@/stores/aicreate'
import { extractCharacters } from '@/api/aicreate' import { extractCharacters } from '@/api/aicreate'
import { saveExtractDraft } from '@/utils/aicreate/extractDraft'
const router = useRouter() const router = useRouter()
const store = useAicreateStore() const store = useAicreateStore()
@ -170,6 +171,14 @@ onMounted(async () => {
store.extractId = data.extractId || '' store.extractId = data.extractId || ''
store.characters = characters.value store.characters = characters.value
autoSelect() autoSelect()
if (characters.value.length > 0) {
saveExtractDraft({
imageUrl: store.imageUrl,
extractId: store.extractId,
characters: characters.value,
raw: data,
})
}
} catch (e: any) { } catch (e: any) {
error.value = '角色识别失败:' + (e.message || '请检查网络') error.value = '角色识别失败:' + (e.message || '请检查网络')
} finally { } finally {
@ -186,15 +195,6 @@ function autoSelect() {
if (hero) selected.value = hero.charId 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 = () => { const goNext = () => {
// selected // selected
const target = characters.value.length === 1 const target = characters.value.length === 1
@ -219,51 +219,6 @@ const goNext = () => {
flex-direction: column; 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 { .loading-state {
flex: 1; flex: 1;
@ -343,7 +298,6 @@ const goNext = () => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: zoom-in;
&:hover .zoom-hint { opacity: 1; } &:hover .zoom-hint { opacity: 1; }
&:active { transform: scale(0.98); } &:active { transform: scale(0.98); }
@ -440,7 +394,6 @@ const goNext = () => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: zoom-in;
&:hover .zoom-hint { opacity: 1; } &:hover .zoom-hint { opacity: 1; }
} }
@ -494,8 +447,9 @@ const goNext = () => {
position: absolute; position: absolute;
right: 6px; right: 6px;
bottom: 6px; bottom: 6px;
width: 22px; z-index: 3;
height: 22px; width: 28px;
height: 28px;
border-radius: 50%; border-radius: 50%;
background: rgba(15, 12, 41, 0.55); background: rgba(15, 12, 41, 0.55);
color: #fff; color: #fff;
@ -504,6 +458,7 @@ const goNext = () => {
justify-content: center; justify-content: center;
font-size: 11px; font-size: 11px;
opacity: 0.7; opacity: 0.7;
cursor: pointer;
transition: opacity 0.2s; transition: opacity 0.2s;
} }

View File

@ -3,15 +3,6 @@ export default { name: 'CreatingView' }
</script> </script>
<template> <template>
<div class="creating-page"> <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"> <div class="ring-wrap">
<svg width="180" height="180" class="ring-svg"> <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 { useRouter } from 'vue-router'
import { Client } from '@stomp/stompjs' import { Client } from '@stomp/stompjs'
import { import {
ExperimentOutlined,
FrownOutlined, FrownOutlined,
WifiOutlined, WifiOutlined,
CloudServerOutlined, CloudServerOutlined,
@ -107,6 +97,7 @@ import {
import { useAicreateStore } from '@/stores/aicreate' import { useAicreateStore } from '@/stores/aicreate'
import { createStory, getWorkDetail } from '@/api/aicreate' import { createStory, getWorkDetail } from '@/api/aicreate'
import { STATUS, getRouteByStatus } from '@/utils/aicreate/status' import { STATUS, getRouteByStatus } from '@/utils/aicreate/status'
import { clearExtractDraft } from '@/utils/aicreate/extractDraft'
import config from '@/utils/aicreate/config' import config from '@/utils/aicreate/config'
const router = useRouter() const router = useRouter()
@ -130,8 +121,6 @@ const creatingTips = [
'色彩正在调和', '色彩正在调和',
] ]
const isDev = import.meta.env.DEV
let pollTimer: ReturnType<typeof setInterval> | null = null let pollTimer: ReturnType<typeof setInterval> | null = null
let dotTimer: ReturnType<typeof setInterval> | null = null let dotTimer: ReturnType<typeof setInterval> | null = null
let tipTimer: 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 (使) // WebSocket (使)
const startWebSocket = (workId: string) => { const startWebSocket = (workId: string) => {
wsDegraded = false wsDegraded = false
@ -285,7 +283,7 @@ const startPolling = (workId: string) => {
pollTimer = null pollTimer = null
saveWorkId('') saveWorkId('')
const route = getRouteByStatus(work.status, workId) const route = getRouteByStatus(work.status, workId)
if (route) setTimeout(() => router.replace(route), 800) if (route) replaceWhenCreationAdvances(route)
} else if (work.status === STATUS.FAILED) { } else if (work.status === STATUS.FAILED) {
clearInterval(pollTimer!) clearInterval(pollTimer!)
pollTimer = null pollTimer = null
@ -358,10 +356,6 @@ const resumePolling = () => {
} }
const retry = () => { const retry = () => {
if (isDev && !store.imageUrl) {
enterMockProgress()
return
}
saveWorkId('') saveWorkId('')
submitted = false submitted = false
startCreation() startCreation()
@ -374,31 +368,6 @@ const leaveToWorks = () => {
router.push('/p/works?tab=draft') 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(() => { onMounted(() => {
dotTimer = setInterval(() => { dotTimer = setInterval(() => {
dots.value = dots.value.length >= 3 ? '' : dots.value + '.' dots.value = dots.value.length >= 3 ? '' : dots.value + '.'
@ -416,12 +385,6 @@ onMounted(() => {
restoreWorkId() restoreWorkId()
} }
//
if (isDev && !store.workId && (!store.imageUrl || !store.storyData)) {
enterMockProgress()
return
}
if (store.workId) { if (store.workId) {
submitted = true submitted = true
progress.value = 10 progress.value = 10
@ -452,46 +415,6 @@ onUnmounted(() => {
position: relative; 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 { .ring-wrap {
position: relative; position: relative;

View File

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

View File

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

View File

@ -6,16 +6,6 @@ export default { name: 'UploadView' }
<PageHeader title="上传作品" subtitle="上传你的画作AI 自动识别角色" :step="0" /> <PageHeader title="上传作品" subtitle="上传你的画作AI 自动识别角色" :step="0" />
<div class="content page-content"> <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"> <template v-if="!preview">
<!-- 上传区域 --> <!-- 上传区域 -->
<div class="upload-area card"> <div class="upload-area card">
@ -118,6 +108,7 @@ import { useRouter } from 'vue-router'
import PageHeader from '@/components/aicreate/PageHeader.vue' import PageHeader from '@/components/aicreate/PageHeader.vue'
import { useAicreateStore } from '@/stores/aicreate' import { useAicreateStore } from '@/stores/aicreate'
import { extractCharacters, ossUpload, checkQuota } from '@/api/aicreate' import { extractCharacters, ossUpload, checkQuota } from '@/api/aicreate'
import { saveExtractDraft } from '@/utils/aicreate/extractDraft'
import { import {
PictureOutlined, PictureOutlined,
CameraOutlined, CameraOutlined,
@ -130,16 +121,8 @@ import {
TeamOutlined, TeamOutlined,
BulbOutlined, BulbOutlined,
ArrowRightOutlined, ArrowRightOutlined,
ExperimentOutlined,
} from '@ant-design/icons-vue' } 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 router = useRouter()
const store = useAicreateStore() const store = useAicreateStore()
const preview = ref<string | null>(null) const preview = ref<string | null>(null)
@ -325,6 +308,12 @@ const goNext = async () => {
store.characters = chars store.characters = chars
store.imageUrl = ossUrl store.imageUrl = ossUrl
if (data.workId) store.originalWorkId = data.workId if (data.workId) store.originalWorkId = data.workId
saveExtractDraft({
imageUrl: ossUrl,
extractId: data.extractId || '',
characters: chars,
raw: data,
})
router.push('/p/create/characters') router.push('/p/create/characters')
} catch (e: any) { } catch (e: any) {
uploadError.value = '识别失败:' + sanitizeError(e.message) uploadError.value = '识别失败:' + sanitizeError(e.message)
@ -545,37 +534,4 @@ const goNext = async () => {
:deep(.anticon) { font-size: 16px; } :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> </style>

View File

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