feat: 公众端绘本创作流程与作品展示优化,乐读派同步及封面回填迁移
Made-with: Cursor
This commit is contained in:
parent
430eba6bd6
commit
1862204ac5
@ -321,6 +321,16 @@ public class LeaiSyncService implements ILeaiSyncService {
|
||||
ugcWorkPageMapper.insert(page);
|
||||
}
|
||||
|
||||
// 列表封面与前端创作页一致:使用 pageList[0] 插画,而非远程 originalImageUrl/coverUrl 元数据
|
||||
String firstCover = LeaiUtil.toString(pageList.get(0).get("imageUrl"), null);
|
||||
if (firstCover != null && !firstCover.isEmpty()) {
|
||||
LambdaUpdateWrapper<UgcWork> uw = new LambdaUpdateWrapper<>();
|
||||
uw.eq(UgcWork::getId, workId)
|
||||
.set(UgcWork::getCoverUrl, firstCover)
|
||||
.set(UgcWork::getModifyTime, LocalDateTime.now());
|
||||
ugcWorkMapper.update(null, uw);
|
||||
}
|
||||
|
||||
log.info("[乐读派] 保存作品页面数据: workId={}, 页数={}", workId, pageList.size());
|
||||
}
|
||||
|
||||
|
||||
@ -221,6 +221,19 @@ public class PublicUserWorkService {
|
||||
|
||||
// 插入新页面
|
||||
saveWorkPages(workId, pages);
|
||||
|
||||
// 与乐读派同步逻辑一致:首图作为作品库列表封面
|
||||
if (pages != null && !pages.isEmpty()) {
|
||||
Object img = pages.get(0).get("imageUrl");
|
||||
String firstCover = img != null ? img.toString().trim() : null;
|
||||
if (firstCover != null && !firstCover.isEmpty()) {
|
||||
LambdaUpdateWrapper<UgcWork> uw = new LambdaUpdateWrapper<>();
|
||||
uw.eq(UgcWork::getId, workId)
|
||||
.set(UgcWork::getCoverUrl, firstCover)
|
||||
.set(UgcWork::getModifyTime, LocalDateTime.now());
|
||||
ugcWorkMapper.update(null, uw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void saveWorkPages(Long workId, List<Map<String, Object>> pages) {
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
-- 历史数据:列表封面与创作页对齐,用首页插画(page_no=1)回填 cover_url
|
||||
UPDATE t_ugc_work w
|
||||
INNER JOIN t_ugc_work_page p ON p.work_id = w.id AND p.page_no = 1
|
||||
SET w.cover_url = p.image_url,
|
||||
w.modify_time = NOW()
|
||||
WHERE w.is_deleted = 0
|
||||
AND p.image_url IS NOT NULL
|
||||
AND TRIM(p.image_url) <> '';
|
||||
@ -1,15 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>数据统计 — 活动管理平台</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,500;0,9..40,700;1,9..40,400&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>数据统计 — 活动管理平台</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,500;0,9..40,700;1,9..40,400&family=Noto+Sans+SC:wght@300;400;500;700&display=swap"
|
||||
rel="stylesheet">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
@ -29,44 +32,131 @@ tailwind.config = {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { background: #f8f7fc; }
|
||||
.tab-active { color: #6366f1; border-bottom: 2px solid #6366f1; font-weight: 700; }
|
||||
.tab-inactive { color: #9ca3af; border-bottom: 2px solid transparent; }
|
||||
.tab-inactive:hover { color: #6b7280; }
|
||||
.stat-card { transition: all 0.25s cubic-bezier(0.4,0,0.2,1); }
|
||||
.stat-card:hover { transform: translateY(-3px); box-shadow: 0 8px 24px rgba(99,102,241,0.12); }
|
||||
.funnel-bar { transition: width 0.8s cubic-bezier(0.4,0,0.2,1); }
|
||||
.fade-in { animation: fadeIn 0.5s ease both; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.stagger-1 { animation-delay: 0.05s; }
|
||||
.stagger-2 { animation-delay: 0.1s; }
|
||||
.stagger-3 { animation-delay: 0.15s; }
|
||||
.stagger-4 { animation-delay: 0.2s; }
|
||||
.stagger-5 { animation-delay: 0.25s; }
|
||||
.stagger-6 { animation-delay: 0.3s; }
|
||||
table th { font-weight: 600; font-size: 13px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
table td { font-size: 14px; }
|
||||
.rate-pill { display: inline-flex; padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; }
|
||||
select { appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 5l3 3 3-3' fill='none' stroke='%239ca3af' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px; }
|
||||
</style>
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
background: #f8f7fc;
|
||||
}
|
||||
|
||||
.tab-active {
|
||||
color: #6366f1;
|
||||
border-bottom: 2px solid #6366f1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tab-inactive {
|
||||
color: #9ca3af;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tab-inactive:hover {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
|
||||
.funnel-bar {
|
||||
transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.5s ease both;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.stagger-1 {
|
||||
animation-delay: 0.05s;
|
||||
}
|
||||
|
||||
.stagger-2 {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.stagger-3 {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
.stagger-4 {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.stagger-5 {
|
||||
animation-delay: 0.25s;
|
||||
}
|
||||
|
||||
.stagger-6 {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
table th {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
table td {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rate-pill {
|
||||
display: inline-flex;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 5l3 3 3-3' fill='none' stroke='%239ca3af' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 32px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="font-body text-gray-800 min-h-screen">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="max-w-[1280px] mx-auto px-6 pt-6">
|
||||
<!-- Header -->
|
||||
<div class="max-w-[1280px] mx-auto px-6 pt-6">
|
||||
<!-- Title -->
|
||||
<div class="bg-white rounded-card shadow-card px-6 py-4 mb-5 flex items-center justify-between">
|
||||
<h1 class="text-xl font-display font-bold text-gray-900 tracking-tight">数据统计</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="exportPDF()" class="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-200 text-sm font-medium text-gray-600 hover:bg-gray-50 transition">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M12 16V4m0 12l-4-4m4 4l4-4M4 20h16"/></svg>
|
||||
<button onclick="exportPDF()"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-200 text-sm font-medium text-gray-600 hover:bg-gray-50 transition">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path d="M12 16V4m0 12l-4-4m4 4l4-4M4 20h16" />
|
||||
</svg>
|
||||
导出 PDF
|
||||
</button>
|
||||
<button onclick="exportExcel()" class="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-200 text-sm font-medium text-gray-600 hover:bg-gray-50 transition">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M9 17H5a2 2 0 01-2-2V5a2 2 0 012-2h4m6 0h4a2 2 0 012 2v10a2 2 0 01-2 2h-4m-6-8l6 6m0-6l-6 6"/></svg>
|
||||
<button onclick="exportExcel()"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-200 text-sm font-medium text-gray-600 hover:bg-gray-50 transition">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path d="M9 17H5a2 2 0 01-2-2V5a2 2 0 012-2h4m6 0h4a2 2 0 012 2v10a2 2 0 01-2 2h-4m-6-8l6 6m0-6l-6 6" />
|
||||
</svg>
|
||||
导出 Excel
|
||||
</button>
|
||||
</div>
|
||||
@ -75,15 +165,25 @@ tailwind.config = {
|
||||
<!-- Tabs + Filters -->
|
||||
<div class="bg-white rounded-card shadow-card px-6 py-0 mb-5 flex items-center justify-between">
|
||||
<div class="flex gap-6">
|
||||
<button id="tab-overview" onclick="switchTab('overview')" class="tab-active py-4 text-sm font-display cursor-pointer transition-colors">运营概览</button>
|
||||
<button id="tab-review" onclick="switchTab('review')" class="tab-inactive py-4 text-sm font-display cursor-pointer transition-colors">评审分析</button>
|
||||
<button id="tab-overview" onclick="switchTab('overview')"
|
||||
class="tab-active py-4 text-sm font-display cursor-pointer transition-colors">运营概览</button>
|
||||
<button id="tab-review" onclick="switchTab('review')"
|
||||
class="tab-inactive py-4 text-sm font-display cursor-pointer transition-colors">评审分析</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<select class="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white text-gray-700 font-medium focus:outline-none focus:ring-2 focus:ring-primary-200">
|
||||
<option>本月</option><option>本季度</option><option>本年</option><option>全部</option>
|
||||
<select
|
||||
class="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white text-gray-700 font-medium focus:outline-none focus:ring-2 focus:ring-primary-200">
|
||||
<option>本月</option>
|
||||
<option>本季度</option>
|
||||
<option>本年</option>
|
||||
<option>全部</option>
|
||||
</select>
|
||||
<select class="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white text-gray-700 font-medium focus:outline-none focus:ring-2 focus:ring-primary-200">
|
||||
<option>全部活动</option><option>2026年少儿绘本创作大赛</option><option>第三届亲子阅读绘画展</option><option>寒假绘本阅读打卡活动</option>
|
||||
<select
|
||||
class="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white text-gray-700 font-medium focus:outline-none focus:ring-2 focus:ring-primary-200">
|
||||
<option>全部活动</option>
|
||||
<option>2026年少儿绘本创作大赛</option>
|
||||
<option>第三届亲子阅读绘画展</option>
|
||||
<option>寒假绘本阅读打卡活动</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@ -94,8 +194,12 @@ tailwind.config = {
|
||||
<div class="grid grid-cols-6 gap-4 mb-5">
|
||||
<div class="stat-card fade-in stagger-1 bg-white rounded-card shadow-card p-4 cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center text-lg" style="background:rgba(99,102,241,0.1);color:#6366f1">
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20"><path d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6z"/></svg>
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center text-lg"
|
||||
style="background:rgba(99,102,241,0.1);color:#6366f1">
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">6</div>
|
||||
@ -105,8 +209,12 @@ tailwind.config = {
|
||||
</div>
|
||||
<div class="stat-card fade-in stagger-2 bg-white rounded-card shadow-card p-4 cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center" style="background:rgba(59,130,246,0.1);color:#3b82f6">
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20"><path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"/></svg>
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center"
|
||||
style="background:rgba(59,130,246,0.1);color:#3b82f6">
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">12</div>
|
||||
@ -116,8 +224,13 @@ tailwind.config = {
|
||||
</div>
|
||||
<div class="stat-card fade-in stagger-3 bg-white rounded-card shadow-card p-4 cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center" style="background:rgba(16,185,129,0.1);color:#10b981">
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center"
|
||||
style="background:rgba(16,185,129,0.1);color:#10b981">
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">10</div>
|
||||
@ -127,8 +240,13 @@ tailwind.config = {
|
||||
</div>
|
||||
<div class="stat-card fade-in stagger-4 bg-white rounded-card shadow-card p-4 cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center" style="background:rgba(245,158,11,0.1);color:#f59e0b">
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clip-rule="evenodd"/></svg>
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center"
|
||||
style="background:rgba(245,158,11,0.1);color:#f59e0b">
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">8</div>
|
||||
@ -138,8 +256,14 @@ tailwind.config = {
|
||||
</div>
|
||||
<div class="stat-card fade-in stagger-5 bg-white rounded-card shadow-card p-4 cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center" style="background:rgba(20,184,166,0.1);color:#14b8a6">
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20"><path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"/><path fill-rule="evenodd" d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm9.707 5.707a1 1 0 00-1.414-1.414L9 12.586l-1.293-1.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center"
|
||||
style="background:rgba(20,184,166,0.1);color:#14b8a6">
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" />
|
||||
<path fill-rule="evenodd"
|
||||
d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm9.707 5.707a1 1 0 00-1.414-1.414L9 12.586l-1.293-1.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">5</div>
|
||||
@ -149,8 +273,13 @@ tailwind.config = {
|
||||
</div>
|
||||
<div class="stat-card fade-in stagger-6 bg-white rounded-card shadow-card p-4 cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center" style="background:rgba(239,68,68,0.1);color:#ef4444">
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5 2a2 2 0 00-2 2v14l3.5-2 3.5 2 3.5-2 3.5 2V4a2 2 0 00-2-2H5zm2.5 3a1.5 1.5 0 100 3 1.5 1.5 0 000-3zm6.207.293a1 1 0 00-1.414 0l-6 6a1 1 0 101.414 1.414l6-6a1 1 0 000-1.414zM12.5 10a1.5 1.5 0 100 3 1.5 1.5 0 000-3z" clip-rule="evenodd"/></svg>
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center"
|
||||
style="background:rgba(239,68,68,0.1);color:#ef4444">
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M5 2a2 2 0 00-2 2v14l3.5-2 3.5 2 3.5-2 3.5 2V4a2 2 0 00-2-2H5zm2.5 3a1.5 1.5 0 100 3 1.5 1.5 0 000-3zm6.207.293a1 1 0 00-1.414 0l-6 6a1 1 0 101.414 1.414l6-6a1 1 0 000-1.414zM12.5 10a1.5 1.5 0 100 3 1.5 1.5 0 000-3z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">3</div>
|
||||
@ -171,35 +300,56 @@ tailwind.config = {
|
||||
<span class="text-sm font-medium text-gray-700">报名</span>
|
||||
<span class="text-sm font-display font-bold text-gray-900">12</span>
|
||||
</div>
|
||||
<div class="h-8 bg-gray-100 rounded-lg overflow-hidden"><div class="funnel-bar h-full rounded-lg" style="width:100%;background:linear-gradient(90deg,#6366f1,#818cf8)"></div></div>
|
||||
<div class="h-8 bg-gray-100 rounded-lg overflow-hidden">
|
||||
<div class="funnel-bar h-full rounded-lg"
|
||||
style="width:100%;background:linear-gradient(90deg,#6366f1,#818cf8)"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class="text-sm font-medium text-gray-700">通过审核</span>
|
||||
<div class="flex items-center gap-2"><span class="rate-pill bg-green-50 text-green-600">83.3%</span><span class="text-sm font-display font-bold text-gray-900">10</span></div>
|
||||
<div class="flex items-center gap-2"><span
|
||||
class="rate-pill bg-green-50 text-green-600">83.3%</span><span
|
||||
class="text-sm font-display font-bold text-gray-900">10</span></div>
|
||||
</div>
|
||||
<div class="h-8 bg-gray-100 rounded-lg overflow-hidden">
|
||||
<div class="funnel-bar h-full rounded-lg"
|
||||
style="width:83.3%;background:linear-gradient(90deg,#10b981,#34d399)"></div>
|
||||
</div>
|
||||
<div class="h-8 bg-gray-100 rounded-lg overflow-hidden"><div class="funnel-bar h-full rounded-lg" style="width:83.3%;background:linear-gradient(90deg,#10b981,#34d399)"></div></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class="text-sm font-medium text-gray-700">提交作品</span>
|
||||
<div class="flex items-center gap-2"><span class="rate-pill bg-blue-50 text-blue-600">80.0%</span><span class="text-sm font-display font-bold text-gray-900">8</span></div>
|
||||
<div class="flex items-center gap-2"><span class="rate-pill bg-blue-50 text-blue-600">80.0%</span><span
|
||||
class="text-sm font-display font-bold text-gray-900">8</span></div>
|
||||
</div>
|
||||
<div class="h-8 bg-gray-100 rounded-lg overflow-hidden">
|
||||
<div class="funnel-bar h-full rounded-lg"
|
||||
style="width:66.7%;background:linear-gradient(90deg,#3b82f6,#60a5fa)"></div>
|
||||
</div>
|
||||
<div class="h-8 bg-gray-100 rounded-lg overflow-hidden"><div class="funnel-bar h-full rounded-lg" style="width:66.7%;background:linear-gradient(90deg,#3b82f6,#60a5fa)"></div></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class="text-sm font-medium text-gray-700">评审完成</span>
|
||||
<div class="flex items-center gap-2"><span class="rate-pill bg-amber-50 text-amber-600">62.5%</span><span class="text-sm font-display font-bold text-gray-900">5</span></div>
|
||||
<div class="flex items-center gap-2"><span
|
||||
class="rate-pill bg-amber-50 text-amber-600">62.5%</span><span
|
||||
class="text-sm font-display font-bold text-gray-900">5</span></div>
|
||||
</div>
|
||||
<div class="h-8 bg-gray-100 rounded-lg overflow-hidden">
|
||||
<div class="funnel-bar h-full rounded-lg"
|
||||
style="width:41.7%;background:linear-gradient(90deg,#f59e0b,#fbbf24)"></div>
|
||||
</div>
|
||||
<div class="h-8 bg-gray-100 rounded-lg overflow-hidden"><div class="funnel-bar h-full rounded-lg" style="width:41.7%;background:linear-gradient(90deg,#f59e0b,#fbbf24)"></div></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class="text-sm font-medium text-gray-700">获奖</span>
|
||||
<div class="flex items-center gap-2"><span class="rate-pill bg-red-50 text-red-500">60.0%</span><span class="text-sm font-display font-bold text-gray-900">3</span></div>
|
||||
<div class="flex items-center gap-2"><span class="rate-pill bg-red-50 text-red-500">60.0%</span><span
|
||||
class="text-sm font-display font-bold text-gray-900">3</span></div>
|
||||
</div>
|
||||
<div class="h-8 bg-gray-100 rounded-lg overflow-hidden">
|
||||
<div class="funnel-bar h-full rounded-lg"
|
||||
style="width:25%;background:linear-gradient(90deg,#ef4444,#f87171)"></div>
|
||||
</div>
|
||||
<div class="h-8 bg-gray-100 rounded-lg overflow-hidden"><div class="funnel-bar h-full rounded-lg" style="width:25%;background:linear-gradient(90deg,#ef4444,#f87171)"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -267,44 +417,62 @@ tailwind.config = {
|
||||
<div class="grid grid-cols-4 gap-4 mb-5">
|
||||
<div class="stat-card fade-in stagger-1 bg-white rounded-card shadow-card p-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-11 h-11 rounded-xl flex items-center justify-center" style="background:rgba(59,130,246,0.1);color:#3b82f6">
|
||||
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<div class="w-11 h-11 rounded-xl flex items-center justify-center"
|
||||
style="background:rgba(59,130,246,0.1);color:#3b82f6">
|
||||
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">3.2<span class="text-sm font-normal text-gray-400 ml-0.5">天</span></div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">3.2<span
|
||||
class="text-sm font-normal text-gray-400 ml-0.5">天</span></div>
|
||||
<div class="text-xs text-gray-400 mt-1">平均评审周期</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card fade-in stagger-2 bg-white rounded-card shadow-card p-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-11 h-11 rounded-xl flex items-center justify-center" style="background:rgba(16,185,129,0.1);color:#10b981">
|
||||
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
<div class="w-11 h-11 rounded-xl flex items-center justify-center"
|
||||
style="background:rgba(16,185,129,0.1);color:#10b981">
|
||||
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">1.5<span class="text-sm font-normal text-gray-400 ml-0.5">个/日</span></div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">1.5<span
|
||||
class="text-sm font-normal text-gray-400 ml-0.5">个/日</span></div>
|
||||
<div class="text-xs text-gray-400 mt-1">日均评审量</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card fade-in stagger-3 bg-white rounded-card shadow-card p-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-11 h-11 rounded-xl flex items-center justify-center" style="background:rgba(239,68,68,0.1);color:#ef4444">
|
||||
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M12 9v2m0 4h.01M5.07 19H19a2.18 2.18 0 001.9-3.2L13.9 4a2.18 2.18 0 00-3.8 0L3.17 15.8A2.18 2.18 0 005.07 19z"/></svg>
|
||||
<div class="w-11 h-11 rounded-xl flex items-center justify-center"
|
||||
style="background:rgba(239,68,68,0.1);color:#ef4444">
|
||||
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 9v2m0 4h.01M5.07 19H19a2.18 2.18 0 001.9-3.2L13.9 4a2.18 2.18 0 00-3.8 0L3.17 15.8A2.18 2.18 0 005.07 19z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">2<span class="text-sm font-normal text-gray-400 ml-0.5">个</span></div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">2<span
|
||||
class="text-sm font-normal text-gray-400 ml-0.5">个</span></div>
|
||||
<div class="text-xs text-gray-400 mt-1">待评审积压</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card fade-in stagger-4 bg-white rounded-card shadow-card p-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-11 h-11 rounded-xl flex items-center justify-center" style="background:rgba(99,102,241,0.1);color:#6366f1">
|
||||
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
||||
<div class="w-11 h-11 rounded-xl flex items-center justify-center"
|
||||
style="background:rgba(99,102,241,0.1);color:#6366f1">
|
||||
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">2.8<span class="text-sm font-normal text-gray-400 ml-0.5">分</span></div>
|
||||
<div class="text-2xl font-display font-bold text-gray-900 leading-none">2.8<span
|
||||
class="text-sm font-normal text-gray-400 ml-0.5">分</span></div>
|
||||
<div class="text-xs text-gray-400 mt-1">评分一致性</div>
|
||||
<div class="text-[10px] text-gray-300 mt-0.5">标准差越小越好</div>
|
||||
</div>
|
||||
@ -333,7 +501,9 @@ tailwind.config = {
|
||||
<tr class="border-b border-gray-50 hover:bg-primary-50/30 transition-colors">
|
||||
<td class="py-3.5 px-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center text-white text-xs font-bold">陈</div>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center text-white text-xs font-bold">
|
||||
陈</div>
|
||||
<span class="font-medium text-gray-900">陈评委</span>
|
||||
</div>
|
||||
</td>
|
||||
@ -347,7 +517,9 @@ tailwind.config = {
|
||||
<tr class="border-b border-gray-50 hover:bg-primary-50/30 transition-colors">
|
||||
<td class="py-3.5 px-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white text-xs font-bold">李</div>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white text-xs font-bold">
|
||||
李</div>
|
||||
<span class="font-medium text-gray-900">李评委</span>
|
||||
</div>
|
||||
</td>
|
||||
@ -361,7 +533,9 @@ tailwind.config = {
|
||||
<tr class="hover:bg-primary-50/30 transition-colors">
|
||||
<td class="py-3.5 px-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center text-white text-xs font-bold">王</div>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center text-white text-xs font-bold">
|
||||
王</div>
|
||||
<span class="font-medium text-gray-900">王评委</span>
|
||||
</div>
|
||||
</td>
|
||||
@ -385,21 +559,21 @@ tailwind.config = {
|
||||
</div>
|
||||
|
||||
<div class="h-8"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Tab switching
|
||||
function switchTab(tab) {
|
||||
<script>
|
||||
// Tab switching
|
||||
function switchTab(tab) {
|
||||
document.getElementById('content-overview').classList.toggle('hidden', tab !== 'overview');
|
||||
document.getElementById('content-review').classList.toggle('hidden', tab !== 'review');
|
||||
document.getElementById('tab-overview').className = tab === 'overview' ? 'tab-active py-4 text-sm font-display cursor-pointer transition-colors' : 'tab-inactive py-4 text-sm font-display cursor-pointer transition-colors';
|
||||
document.getElementById('tab-review').className = tab === 'review' ? 'tab-active py-4 text-sm font-display cursor-pointer transition-colors' : 'tab-inactive py-4 text-sm font-display cursor-pointer transition-colors';
|
||||
if (tab === 'review') { initAwardChart(); }
|
||||
}
|
||||
}
|
||||
|
||||
// Trend Chart
|
||||
const trendChart = echarts.init(document.getElementById('trendChart'));
|
||||
trendChart.setOption({
|
||||
// Trend Chart
|
||||
const trendChart = echarts.init(document.getElementById('trendChart'));
|
||||
trendChart.setOption({
|
||||
tooltip: { trigger: 'axis', backgroundColor: '#fff', borderColor: '#e5e7eb', borderWidth: 1, textStyle: { color: '#374151', fontSize: 13, fontFamily: 'DM Sans, Noto Sans SC' }, boxShadow: '0 4px 12px rgba(0,0,0,0.08)' },
|
||||
legend: { data: ['报名量', '作品量'], bottom: 0, textStyle: { fontSize: 12, color: '#9ca3af', fontFamily: 'Noto Sans SC' }, itemWidth: 16, itemHeight: 3, itemGap: 24 },
|
||||
grid: { left: 40, right: 16, top: 16, bottom: 40 },
|
||||
@ -409,10 +583,10 @@ trendChart.setOption({
|
||||
{ name: '报名量', type: 'line', data: [3, 5, 8, 6, 12, 15], smooth: true, symbol: 'circle', symbolSize: 6, lineStyle: { width: 3, color: '#6366f1' }, itemStyle: { color: '#6366f1', borderWidth: 2, borderColor: '#fff' }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: 'rgba(99,102,241,0.15)' }, { offset: 1, color: 'rgba(99,102,241,0)' }]) } },
|
||||
{ name: '作品量', type: 'line', data: [1, 3, 5, 4, 8, 10], smooth: true, symbol: 'circle', symbolSize: 6, lineStyle: { width: 3, color: '#f59e0b' }, itemStyle: { color: '#f59e0b', borderWidth: 2, borderColor: '#fff' }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: 'rgba(245,158,11,0.12)' }, { offset: 1, color: 'rgba(245,158,11,0)' }]) } }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
// Award Chart
|
||||
function initAwardChart() {
|
||||
// Award Chart
|
||||
function initAwardChart() {
|
||||
const el = document.getElementById('awardChart');
|
||||
if (!el) return;
|
||||
const chart = echarts.init(el);
|
||||
@ -432,14 +606,15 @@ function initAwardChart() {
|
||||
emphasis: { itemStyle: { shadowBlur: 12, shadowColor: 'rgba(0,0,0,0.12)' } }
|
||||
}]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Resize
|
||||
window.addEventListener('resize', () => { trendChart.resize(); });
|
||||
// Resize
|
||||
window.addEventListener('resize', () => { trendChart.resize(); });
|
||||
|
||||
// Export placeholders
|
||||
function exportPDF() { alert('PDF 导出功能将在开发时实现'); }
|
||||
function exportExcel() { alert('Excel 导出功能将在开发时实现'); }
|
||||
</script>
|
||||
// Export placeholders
|
||||
function exportPDF() { message.warning('PDF 导出功能将在开发时实现'); }
|
||||
function exportExcel() { message.warning('Excel 导出功能将在开发时实现'); }
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -47,9 +47,29 @@ export function createStory(params: CreateStoryParams) {
|
||||
return publicApi.post('/leai-proxy/create-story', body)
|
||||
}
|
||||
|
||||
/**
|
||||
* 乐读派 B2 作品详情:响应体常为 { code, data: Work },经 public 拦截器可能已剥一层,
|
||||
* 仍可能出现嵌套 data,统一解包为 Work 对象(与 CreatingView 原 detail.data 语义一致)。
|
||||
*/
|
||||
export function unwrapLeaiWorkDetail(raw: unknown): any {
|
||||
let cur: any = raw
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (!cur || typeof cur !== 'object') return cur
|
||||
if (cur.workId != null || Array.isArray(cur.pageList)) return cur
|
||||
if (cur.data != null && typeof cur.data === 'object') {
|
||||
cur = cur.data
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
/** 查询作品详情 */
|
||||
export function getWorkDetail(workId: string) {
|
||||
return publicApi.get(`/leai-proxy/work/${workId}`)
|
||||
return publicApi
|
||||
.get(`/leai-proxy/work/${workId}`)
|
||||
.then(unwrapLeaiWorkDetail)
|
||||
}
|
||||
|
||||
/** 额度校验 */
|
||||
|
||||
@ -47,7 +47,13 @@ publicApi.interceptors.response.use(
|
||||
// 后端返回格式:{ code: 200, message: "success", data: xxx }
|
||||
// 检查业务状态码,非 200 视为业务错误
|
||||
const resData = response.data;
|
||||
if (resData && resData.code !== undefined && resData.code !== 200) {
|
||||
// 后端统一 Result 为 200;乐读派 B2/B3 等原始体常用 0 表示成功(见 lesingle-aicreate-client)
|
||||
if (
|
||||
resData &&
|
||||
resData.code !== undefined &&
|
||||
resData.code !== 200 &&
|
||||
resData.code !== 0
|
||||
) {
|
||||
// 兼容后端 Result.message 和乐读派原始响应的 msg 字段
|
||||
const error: any = new Error(
|
||||
resData.message || resData.msg || "请求失败",
|
||||
@ -432,6 +438,8 @@ export type WorkStatus =
|
||||
export interface UserWork {
|
||||
id: number;
|
||||
userId: number;
|
||||
/** 乐读派 remote work id,与创作路由参数一致 */
|
||||
remoteWorkId?: string | null;
|
||||
title: string;
|
||||
coverUrl: string | null;
|
||||
description: string | null;
|
||||
|
||||
@ -4,81 +4,81 @@
|
||||
* 敏感信息(phone/orgId/appSecret)不再存储在 localStorage
|
||||
* orgId 仅存 sessionStorage(会话级),sessionToken 判断是否已初始化
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { clearExtractDraft } from '@/utils/aicreate/extractDraft'
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { clearExtractDraft } from "@/utils/aicreate/extractDraft";
|
||||
|
||||
export const useAicreateStore = defineStore('aicreate', () => {
|
||||
export const useAicreateStore = defineStore("aicreate", () => {
|
||||
// ─── 认证信息(不再存储敏感信息到 localStorage) ───
|
||||
const orgId = ref(sessionStorage.getItem('le_orgId') || '')
|
||||
const sessionToken = ref(sessionStorage.getItem('le_sessionToken') || '')
|
||||
const orgId = ref(sessionStorage.getItem("le_orgId") || "");
|
||||
const sessionToken = ref(sessionStorage.getItem("le_sessionToken") || "");
|
||||
|
||||
// ─── 创作流程数据 ───
|
||||
const imageUrl = ref('')
|
||||
const extractId = ref('')
|
||||
const characters = ref<any[]>([])
|
||||
const selectedCharacter = ref<any>(null)
|
||||
const selectedStyle = ref('')
|
||||
const storyData = ref<any>(null)
|
||||
const workId = ref('')
|
||||
const imageUrl = ref("");
|
||||
const extractId = ref("");
|
||||
const characters = ref<any[]>([]);
|
||||
const selectedCharacter = ref<any>(null);
|
||||
const selectedStyle = ref("");
|
||||
const storyData = ref<any>(null);
|
||||
const workId = ref("");
|
||||
/** extract 接口可能返回的 workId,供下游使用 */
|
||||
const originalWorkId = ref('')
|
||||
const workDetail = ref<any>(null)
|
||||
const originalWorkId = ref("");
|
||||
const workDetail = ref<any>(null);
|
||||
|
||||
// ─── Tab 切换状态保存 ───
|
||||
const lastCreateRoute = ref('')
|
||||
const lastCreateRoute = ref("");
|
||||
|
||||
// ─── 方法 ───
|
||||
function setSession(id: string, token: string) {
|
||||
orgId.value = id
|
||||
sessionToken.value = token
|
||||
sessionStorage.setItem('le_orgId', id)
|
||||
sessionStorage.setItem('le_sessionToken', token)
|
||||
orgId.value = id;
|
||||
sessionToken.value = token;
|
||||
sessionStorage.setItem("le_orgId", id);
|
||||
sessionStorage.setItem("le_sessionToken", token);
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
sessionToken.value = ''
|
||||
orgId.value = ''
|
||||
sessionStorage.removeItem('le_sessionToken')
|
||||
sessionStorage.removeItem('le_orgId')
|
||||
sessionToken.value = "";
|
||||
orgId.value = "";
|
||||
sessionStorage.removeItem("le_sessionToken");
|
||||
sessionStorage.removeItem("le_orgId");
|
||||
}
|
||||
|
||||
function setLastCreateRoute(path: string) {
|
||||
lastCreateRoute.value = path
|
||||
lastCreateRoute.value = path;
|
||||
}
|
||||
|
||||
function clearLastCreateRoute() {
|
||||
lastCreateRoute.value = ''
|
||||
lastCreateRoute.value = "";
|
||||
}
|
||||
|
||||
function reset() {
|
||||
imageUrl.value = ''
|
||||
extractId.value = ''
|
||||
characters.value = []
|
||||
selectedCharacter.value = null
|
||||
selectedStyle.value = ''
|
||||
storyData.value = null
|
||||
workId.value = ''
|
||||
originalWorkId.value = ''
|
||||
workDetail.value = null
|
||||
lastCreateRoute.value = ''
|
||||
imageUrl.value = "";
|
||||
extractId.value = "";
|
||||
characters.value = [];
|
||||
selectedCharacter.value = null;
|
||||
selectedStyle.value = "";
|
||||
storyData.value = null;
|
||||
workId.value = "";
|
||||
originalWorkId.value = "";
|
||||
workDetail.value = null;
|
||||
lastCreateRoute.value = "";
|
||||
// 只清除创作流程数据,保留认证信息
|
||||
localStorage.removeItem('le_workId')
|
||||
localStorage.removeItem("le_workId");
|
||||
// 清除 sessionStorage 中的恢复数据
|
||||
sessionStorage.removeItem('le_recovery')
|
||||
clearExtractDraft()
|
||||
sessionStorage.removeItem("le_recovery");
|
||||
clearExtractDraft();
|
||||
}
|
||||
|
||||
function saveRecoveryState() {
|
||||
const recovery = {
|
||||
path: window.location.pathname || '/',
|
||||
workId: workId.value || localStorage.getItem('le_workId') || '',
|
||||
imageUrl: imageUrl.value || '',
|
||||
extractId: extractId.value || '',
|
||||
selectedStyle: selectedStyle.value || '',
|
||||
savedAt: Date.now()
|
||||
}
|
||||
sessionStorage.setItem('le_recovery', JSON.stringify(recovery))
|
||||
path: window.location.pathname || "/",
|
||||
workId: workId.value || localStorage.getItem("le_workId") || "",
|
||||
imageUrl: imageUrl.value || "",
|
||||
extractId: extractId.value || "",
|
||||
selectedStyle: selectedStyle.value || "",
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
sessionStorage.setItem("le_recovery", JSON.stringify(recovery));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -89,28 +89,29 @@ export const useAicreateStore = defineStore('aicreate', () => {
|
||||
function fillMockData(count: number = 3) {
|
||||
// 纯渐变占位图(不带文字,模拟真实 AI 抠图返回的角色形象)
|
||||
const mockSvg = (hue: number) =>
|
||||
'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(
|
||||
"data:image/svg+xml;charset=utf-8," +
|
||||
encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">` +
|
||||
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
|
||||
`<stop offset="0" stop-color="hsl(${hue},75%,72%)"/>` +
|
||||
`<stop offset="1" stop-color="hsl(${(hue + 35) % 360},80%,58%)"/>` +
|
||||
`</linearGradient></defs>` +
|
||||
`<rect width="240" height="240" fill="url(#g)"/>` +
|
||||
`</svg>`
|
||||
)
|
||||
`</svg>`,
|
||||
);
|
||||
|
||||
imageUrl.value = mockSvg(250)
|
||||
extractId.value = 'mock-extract-' + Date.now()
|
||||
selectedCharacter.value = null
|
||||
imageUrl.value = mockSvg(250);
|
||||
extractId.value = "mock-extract-" + Date.now();
|
||||
selectedCharacter.value = null;
|
||||
|
||||
// 注意:真实 AI 接口不返回 name 字段,mock 数据也不写 name,由用户在 StoryInputView 自己起名
|
||||
const allChars = [
|
||||
{ charId: 'mock-c1', type: 'HERO', originalCropUrl: mockSvg(280) },
|
||||
{ charId: 'mock-c2', type: 'SIDEKICK', originalCropUrl: mockSvg(30) },
|
||||
{ charId: 'mock-c3', type: 'SIDEKICK', originalCropUrl: mockSvg(100) },
|
||||
]
|
||||
const n = Math.max(1, Math.min(count, allChars.length))
|
||||
characters.value = allChars.slice(0, n)
|
||||
{ charId: "mock-c1", type: "HERO", originalCropUrl: mockSvg(280) },
|
||||
{ charId: "mock-c2", type: "SIDEKICK", originalCropUrl: mockSvg(30) },
|
||||
{ charId: "mock-c3", type: "SIDEKICK", originalCropUrl: mockSvg(100) },
|
||||
];
|
||||
const n = Math.max(1, Math.min(count, allChars.length));
|
||||
characters.value = allChars.slice(0, n);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -120,83 +121,97 @@ export const useAicreateStore = defineStore('aicreate', () => {
|
||||
function fillMockWorkDetail() {
|
||||
// 16:9 渐变占位图(800x450),模拟真实绘本插画
|
||||
const mockPage = (hue: number) =>
|
||||
'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(
|
||||
"data:image/svg+xml;charset=utf-8," +
|
||||
encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="800" height="450" viewBox="0 0 800 450">` +
|
||||
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
|
||||
`<stop offset="0" stop-color="hsl(${hue},70%,75%)"/>` +
|
||||
`<stop offset="1" stop-color="hsl(${(hue + 45) % 360},75%,55%)"/>` +
|
||||
`</linearGradient></defs>` +
|
||||
`<rect width="800" height="450" fill="url(#g)"/>` +
|
||||
`</svg>`
|
||||
)
|
||||
`</svg>`,
|
||||
);
|
||||
|
||||
// 13 页(封面 + 12 内页),模拟真实绘本规模,便于测试横向胶卷式缩略图
|
||||
const pageTexts = [
|
||||
'', // 封面
|
||||
'一个阳光明媚的早晨,小主角推开窗,看见远处的森林闪着金光。',
|
||||
'它沿着小路走啊走,遇到了一只迷路的小鸟,羽毛湿漉漉的。',
|
||||
'小主角轻轻抱起小鸟,决定送它回家。',
|
||||
'路过一片野花田时,一只小狐狸从草丛里跳出来打招呼。',
|
||||
'小狐狸说它认识森林里所有的小路,愿意做大家的向导。',
|
||||
'三个朋友来到一条清澈的溪水边,溪里有一群闪闪发光的小鱼。',
|
||||
'小鱼们告诉他们,那棵会发光的大树就在前方不远处。',
|
||||
'森林深处真的有一棵会发光的大树,挂满了亮晶晶的果实。',
|
||||
'原来这就是小鸟的家,妈妈正在树枝上焦急地张望。',
|
||||
'小鸟欢快地飞回妈妈身边,鸟妈妈给大家每人一颗果实。',
|
||||
'夕阳下,小主角和小狐狸告别,小狐狸送他们到森林边缘。',
|
||||
'小主角带着这份美好回到家,心里也开出了一朵花。',
|
||||
]
|
||||
"", // 封面
|
||||
"一个阳光明媚的早晨,小主角推开窗,看见远处的森林闪着金光。",
|
||||
"它沿着小路走啊走,遇到了一只迷路的小鸟,羽毛湿漉漉的。",
|
||||
"小主角轻轻抱起小鸟,决定送它回家。",
|
||||
"路过一片野花田时,一只小狐狸从草丛里跳出来打招呼。",
|
||||
"小狐狸说它认识森林里所有的小路,愿意做大家的向导。",
|
||||
"三个朋友来到一条清澈的溪水边,溪里有一群闪闪发光的小鱼。",
|
||||
"小鱼们告诉他们,那棵会发光的大树就在前方不远处。",
|
||||
"森林深处真的有一棵会发光的大树,挂满了亮晶晶的果实。",
|
||||
"原来这就是小鸟的家,妈妈正在树枝上焦急地张望。",
|
||||
"小鸟欢快地飞回妈妈身边,鸟妈妈给大家每人一颗果实。",
|
||||
"夕阳下,小主角和小狐狸告别,小狐狸送他们到森林边缘。",
|
||||
"小主角带着这份美好回到家,心里也开出了一朵花。",
|
||||
];
|
||||
|
||||
const wid = 'mock-work-' + Date.now()
|
||||
workId.value = wid
|
||||
const wid = "mock-work-" + Date.now();
|
||||
workId.value = wid;
|
||||
workDetail.value = {
|
||||
workId: wid,
|
||||
status: 3, // COMPLETED
|
||||
title: storyData.value?.title || '森林大冒险',
|
||||
subtitle: '',
|
||||
author: '',
|
||||
title: storyData.value?.title || "森林大冒险",
|
||||
subtitle: "",
|
||||
author: "",
|
||||
coverUrl: mockPage(280),
|
||||
pageList: pageTexts.map((text, i) => ({
|
||||
pageNum: i,
|
||||
text,
|
||||
imageUrl: mockPage((280 + i * 27) % 360),
|
||||
})),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function restoreRecoveryState() {
|
||||
const raw = sessionStorage.getItem('le_recovery')
|
||||
if (!raw) return null
|
||||
const raw = sessionStorage.getItem("le_recovery");
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const recovery = JSON.parse(raw)
|
||||
const recovery = JSON.parse(raw);
|
||||
if (Date.now() - recovery.savedAt > 30 * 60 * 1000) {
|
||||
sessionStorage.removeItem('le_recovery')
|
||||
return null
|
||||
sessionStorage.removeItem("le_recovery");
|
||||
return null;
|
||||
}
|
||||
if (recovery.workId) workId.value = recovery.workId
|
||||
if (recovery.imageUrl) imageUrl.value = recovery.imageUrl
|
||||
if (recovery.extractId) extractId.value = recovery.extractId
|
||||
if (recovery.selectedStyle) selectedStyle.value = recovery.selectedStyle
|
||||
sessionStorage.removeItem('le_recovery')
|
||||
return recovery
|
||||
if (recovery.workId) workId.value = recovery.workId;
|
||||
if (recovery.imageUrl) imageUrl.value = recovery.imageUrl;
|
||||
if (recovery.extractId) extractId.value = recovery.extractId;
|
||||
if (recovery.selectedStyle) selectedStyle.value = recovery.selectedStyle;
|
||||
sessionStorage.removeItem("le_recovery");
|
||||
return recovery;
|
||||
} catch {
|
||||
sessionStorage.removeItem('le_recovery')
|
||||
return null
|
||||
sessionStorage.removeItem("le_recovery");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 认证
|
||||
orgId, sessionToken,
|
||||
setSession, clearSession,
|
||||
orgId,
|
||||
sessionToken,
|
||||
setSession,
|
||||
clearSession,
|
||||
// 创作流程
|
||||
imageUrl, extractId, characters, selectedCharacter,
|
||||
selectedStyle, storyData, workId, originalWorkId, workDetail,
|
||||
reset, saveRecoveryState, restoreRecoveryState,
|
||||
imageUrl,
|
||||
extractId,
|
||||
characters,
|
||||
selectedCharacter,
|
||||
selectedStyle,
|
||||
storyData,
|
||||
workId,
|
||||
originalWorkId,
|
||||
workDetail,
|
||||
reset,
|
||||
saveRecoveryState,
|
||||
restoreRecoveryState,
|
||||
// 开发模式
|
||||
fillMockData,
|
||||
fillMockWorkDetail,
|
||||
// Tab 切换状态
|
||||
lastCreateRoute, setLastCreateRoute, clearLastCreateRoute,
|
||||
}
|
||||
})
|
||||
lastCreateRoute,
|
||||
setLastCreateRoute,
|
||||
clearLastCreateRoute,
|
||||
};
|
||||
});
|
||||
|
||||
77
frontend/src/utils/aicreate/resumeLeaiWork.ts
Normal file
77
frontend/src/utils/aicreate/resumeLeaiWork.ts
Normal file
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 根据乐读派作品详情恢复创作环节(对应上游 B2 query/work,经 /leai-proxy/work/{id})
|
||||
*/
|
||||
import type { Router } from "vue-router";
|
||||
import { getWorkDetail } from "@/api/aicreate";
|
||||
import { STATUS, getRouteByStatus } from "@/utils/aicreate/status";
|
||||
import { clearExtractDraft } from "@/utils/aicreate/extractDraft";
|
||||
|
||||
type AicreateStoreLike = {
|
||||
workId: string;
|
||||
workDetail: any;
|
||||
};
|
||||
|
||||
function parseWorkPayload(res: unknown): Record<string, any> | null {
|
||||
if (!res || typeof res !== "object") return null;
|
||||
const r = res as Record<string, any>;
|
||||
const inner = r.data !== undefined ? r.data : r;
|
||||
if (!inner || typeof inner !== "object") return null;
|
||||
return inner as Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取作品详情、写入 store 与 le_workId,并按 status 跳转到对应子路由。
|
||||
* @returns 是否已成功发起跳转(失败时返回 false,调用方可继续其它恢复逻辑)
|
||||
*/
|
||||
export async function resumeLeaiWorkFromApi(
|
||||
workId: string,
|
||||
router: Router,
|
||||
store: AicreateStoreLike,
|
||||
): Promise<boolean> {
|
||||
const id = String(workId || "").trim();
|
||||
if (!id) return false;
|
||||
|
||||
try {
|
||||
const res = await getWorkDetail(id);
|
||||
const work = parseWorkPayload(res);
|
||||
if (!work) {
|
||||
localStorage.removeItem("le_workId");
|
||||
return false;
|
||||
}
|
||||
|
||||
const wid = String(work.workId ?? id);
|
||||
store.workId = wid;
|
||||
store.workDetail = work;
|
||||
localStorage.setItem("le_workId", wid);
|
||||
|
||||
const st = Number(work.status);
|
||||
if (st === STATUS.FAILED) {
|
||||
clearExtractDraft();
|
||||
await router.replace({
|
||||
name: "PublicCreateCreating",
|
||||
query: { workId: wid },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const route = getRouteByStatus(
|
||||
work.status as Parameters<typeof getRouteByStatus>[0],
|
||||
wid,
|
||||
);
|
||||
if (!route) {
|
||||
clearExtractDraft();
|
||||
await router.replace({
|
||||
name: "PublicCreateCreating",
|
||||
query: { workId: wid },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
clearExtractDraft();
|
||||
await router.replace(route);
|
||||
return true;
|
||||
} catch {
|
||||
localStorage.removeItem("le_workId");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -29,7 +29,7 @@ export function getRouteByStatus(status: StatusValue, workId: string): { name: s
|
||||
case STATUS.CATALOGED:
|
||||
return { name: 'PublicCreateDubbing', params: { workId } }
|
||||
case STATUS.DUBBED:
|
||||
return { name: 'PublicCreateRead', params: { workId } }
|
||||
return { name: 'PublicCreateEditInfo', params: { workId } }
|
||||
case STATUS.FAILED:
|
||||
return null
|
||||
default:
|
||||
|
||||
@ -62,6 +62,8 @@ const initToken = async () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 乐读派作品恢复(localStorage le_workId、路由 ?resumeWorkId=)在子页 WelcomeView 挂载后执行,
|
||||
// 须先完成 initToken,故不在此壳层重复拉取,避免与 loading 竞态。
|
||||
// 如果 store 中已有有效 token 且 orgId 已初始化,跳过加载
|
||||
if (store.sessionToken && store.orgId) {
|
||||
loading.value = false
|
||||
@ -103,7 +105,9 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
@ -122,7 +126,15 @@ onMounted(() => {
|
||||
.ai-slide-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.ai-slide-enter-from { opacity: 0; transform: translateX(30px); }
|
||||
.ai-slide-leave-to { opacity: 0; transform: translateX(-30px); }
|
||||
|
||||
.ai-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
|
||||
.ai-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -111,7 +111,6 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAicreateStore()
|
||||
|
||||
const isDev = import.meta.env.DEV
|
||||
const fromWorks = new URLSearchParams(window.location.search).get('from') === 'works'
|
||||
|| sessionStorage.getItem('le_from') === 'works'
|
||||
|
||||
@ -195,13 +194,6 @@ function applyWork(work: any) {
|
||||
onMounted(async () => {
|
||||
const workId = route.params.workId
|
||||
|
||||
// dev 兜底:mock workId 直接用 store.workDetail
|
||||
if (isDev && String(workId || '').startsWith('mock-')) {
|
||||
if (!store.workDetail) store.fillMockWorkDetail()
|
||||
if (store.workDetail) applyWork(store.workDetail)
|
||||
return
|
||||
}
|
||||
|
||||
if (!workId) return
|
||||
try {
|
||||
let work
|
||||
|
||||
@ -29,10 +29,8 @@ export default { name: 'CharactersView' }
|
||||
<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"
|
||||
@click.stop="characters[0].originalCropUrl && (previewImg = characters[0].originalCropUrl)"
|
||||
>
|
||||
<div class="zoom-hint"
|
||||
@click.stop="characters[0].originalCropUrl && (previewImg = characters[0].originalCropUrl)">
|
||||
<zoom-in-outlined />
|
||||
</div>
|
||||
</div>
|
||||
@ -52,13 +50,8 @@ export default { name: 'CharactersView' }
|
||||
</div>
|
||||
|
||||
<div class="char-grid">
|
||||
<div
|
||||
v-for="c in characters"
|
||||
:key="c.charId"
|
||||
class="char-card"
|
||||
:class="{ selected: selected === c.charId }"
|
||||
@click="selected = c.charId"
|
||||
>
|
||||
<div v-for="c in characters" :key="c.charId" class="char-card" :class="{ selected: selected === c.charId }"
|
||||
@click="selected = c.charId">
|
||||
<!-- 推荐角标 -->
|
||||
<div v-if="c.type === 'HERO'" class="hero-badge">
|
||||
<crown-filled />
|
||||
@ -72,10 +65,7 @@ export default { name: 'CharactersView' }
|
||||
<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"
|
||||
@click.stop="c.originalCropUrl && (previewImg = c.originalCropUrl)"
|
||||
>
|
||||
<div class="zoom-hint" @click.stop="c.originalCropUrl && (previewImg = c.originalCropUrl)">
|
||||
<zoom-in-outlined />
|
||||
</div>
|
||||
</div>
|
||||
@ -212,6 +202,7 @@ const goNext = () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 16px 20px;
|
||||
@ -228,16 +219,19 @@ const goNext = () => {
|
||||
justify-content: center;
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
font-size: 44px;
|
||||
color: var(--ai-primary);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.loading-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--ai-text);
|
||||
}
|
||||
|
||||
.loading-sub {
|
||||
font-size: 13px;
|
||||
color: var(--ai-text-sub);
|
||||
@ -254,16 +248,19 @@ const goNext = () => {
|
||||
gap: 12px;
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 48px;
|
||||
color: var(--ai-text-sub);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ai-text);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
max-width: 200px;
|
||||
margin-top: 8px;
|
||||
@ -279,6 +276,7 @@ const goNext = () => {
|
||||
gap: 24px;
|
||||
padding: 12px 0 24px;
|
||||
}
|
||||
|
||||
.single-card {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
@ -288,6 +286,7 @@ const goNext = () => {
|
||||
padding: 14px;
|
||||
box-shadow: 0 14px 36px rgba(99, 102, 241, 0.22);
|
||||
}
|
||||
|
||||
.single-img-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@ -299,18 +298,26 @@ const goNext = () => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover .zoom-hint { opacity: 1; }
|
||||
&:active { transform: scale(0.98); }
|
||||
&:hover .zoom-hint {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.single-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.single-placeholder {
|
||||
font-size: 72px;
|
||||
color: var(--ai-text-sub);
|
||||
}
|
||||
|
||||
.single-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -345,6 +352,7 @@ const goNext = () => {
|
||||
margin: 0 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
font-size: 18px;
|
||||
color: var(--ai-primary);
|
||||
@ -395,13 +403,17 @@ const goNext = () => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover .zoom-hint { opacity: 1; }
|
||||
&:hover .zoom-hint {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.char-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.char-placeholder {
|
||||
font-size: 36px;
|
||||
color: var(--ai-text-sub);
|
||||
@ -423,7 +435,9 @@ const goNext = () => {
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4);
|
||||
|
||||
:deep(.anticon) { font-size: 9px; }
|
||||
:deep(.anticon) {
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
.check-badge {
|
||||
@ -489,6 +503,7 @@ const goNext = () => {
|
||||
justify-content: center;
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
.preview-full-img {
|
||||
max-width: 90%;
|
||||
max-height: 80vh;
|
||||
@ -496,10 +511,12 @@ const goNext = () => {
|
||||
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-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
|
||||
@ -14,18 +14,8 @@ export default { name: 'CreatingView' }
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="90" cy="90" r="80" fill="none" stroke="rgba(99, 102, 241, 0.12)" stroke-width="8" />
|
||||
<circle
|
||||
cx="90"
|
||||
cy="90"
|
||||
r="80"
|
||||
fill="none"
|
||||
stroke="url(#ringGrad)"
|
||||
stroke-width="8"
|
||||
:stroke-dasharray="502"
|
||||
:stroke-dashoffset="502 - (502 * progress / 100)"
|
||||
stroke-linecap="round"
|
||||
class="ring-fill"
|
||||
/>
|
||||
<circle cx="90" cy="90" r="80" fill="none" stroke="url(#ringGrad)" stroke-width="8" :stroke-dasharray="502"
|
||||
:stroke-dashoffset="502 - (502 * progress / 100)" stroke-linecap="round" class="ring-fill" />
|
||||
</svg>
|
||||
<div class="ring-center">
|
||||
<div class="ring-pct">{{ progress }}%</div>
|
||||
@ -57,7 +47,8 @@ export default { name: 'CreatingView' }
|
||||
<button v-if="store.workId" class="btn-primary error-btn" @click="resumePolling">
|
||||
恢复查询进度
|
||||
</button>
|
||||
<button v-if="!isQuotaError" class="btn-primary error-btn" :class="{ 'btn-outline': !!store.workId }" @click="retry">
|
||||
<button v-if="!isQuotaError" class="btn-primary error-btn" :class="{ 'btn-outline': !!store.workId }"
|
||||
@click="retry">
|
||||
重新创作
|
||||
</button>
|
||||
</div>
|
||||
@ -264,8 +255,7 @@ const startPolling = (workId: string) => {
|
||||
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const detail = await getWorkDetail(workId)
|
||||
const work = detail.data
|
||||
const work = await getWorkDetail(workId)
|
||||
if (!work) return
|
||||
|
||||
if (consecutiveErrors > 0 || networkWarn.value) {
|
||||
@ -422,8 +412,15 @@ onUnmounted(() => {
|
||||
height: 180px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.ring-svg { transform: rotate(-90deg); }
|
||||
.ring-fill { transition: stroke-dashoffset 0.8s ease; }
|
||||
|
||||
.ring-svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.ring-fill {
|
||||
transition: stroke-dashoffset 0.8s ease;
|
||||
}
|
||||
|
||||
.ring-center {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@ -432,6 +429,7 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ring-pct {
|
||||
font-size: 38px;
|
||||
font-weight: 900;
|
||||
@ -441,6 +439,7 @@ onUnmounted(() => {
|
||||
background-clip: text;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.ring-label {
|
||||
font-size: 12px;
|
||||
color: var(--ai-text-sub);
|
||||
@ -464,6 +463,7 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.rotating-tip {
|
||||
font-size: 13px;
|
||||
color: var(--ai-text-sub);
|
||||
@ -471,12 +471,21 @@ onUnmounted(() => {
|
||||
text-align: center;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.tip-fade-enter-active,
|
||||
.tip-fade-leave-active {
|
||||
transition: opacity 0.5s ease, transform 0.5s ease;
|
||||
}
|
||||
.tip-fade-enter-from { opacity: 0; transform: translateY(8px); }
|
||||
.tip-fade-leave-to { opacity: 0; transform: translateY(-8px); }
|
||||
|
||||
.tip-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
.tip-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
/* ---------- 网络警告 ---------- */
|
||||
.network-warn {
|
||||
@ -491,7 +500,9 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
:deep(.anticon) { font-size: 13px; }
|
||||
:deep(.anticon) {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 错误状态 ---------- */
|
||||
@ -502,11 +513,13 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 44px;
|
||||
color: var(--ai-text-sub);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #ef4444;
|
||||
font-size: 14px;
|
||||
@ -514,6 +527,7 @@ onUnmounted(() => {
|
||||
line-height: 1.6;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -522,11 +536,13 @@ onUnmounted(() => {
|
||||
width: 100%;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.error-btn {
|
||||
font-size: 14px !important;
|
||||
padding: 12px 0 !important;
|
||||
border-radius: 24px !important;
|
||||
}
|
||||
|
||||
.error-btn.btn-outline {
|
||||
background: transparent !important;
|
||||
color: var(--ai-primary) !important;
|
||||
@ -544,6 +560,7 @@ onUnmounted(() => {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.task-hint-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -553,16 +570,19 @@ onUnmounted(() => {
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.task-icon {
|
||||
font-size: 15px;
|
||||
color: var(--ai-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-hint-sub {
|
||||
font-size: 11px;
|
||||
color: var(--ai-text-sub);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.leave-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@ -579,13 +599,18 @@ onUnmounted(() => {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
:deep(.anticon) { font-size: 15px; }
|
||||
:deep(.anticon) {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--ai-primary);
|
||||
background: rgba(99, 102, 241, 0.04);
|
||||
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
&:active { transform: scale(0.98); }
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -175,8 +175,6 @@ const route = useRoute()
|
||||
const store = useAicreateStore()
|
||||
const workId = computed(() => route.params.workId || store.workId)
|
||||
|
||||
const isDev = import.meta.env.DEV
|
||||
|
||||
const loading = ref(true)
|
||||
const submitting = ref(false)
|
||||
const pages = ref<any[]>([])
|
||||
@ -289,12 +287,6 @@ function togglePlay() {
|
||||
const src = currentAudioSrc.value
|
||||
if (!src) return
|
||||
|
||||
// dev mock 兜底:mock 音频直接 toast 不真实播放
|
||||
if (typeof src === 'string' && src.startsWith('mock-audio-')) {
|
||||
showToast('模拟音频暂不支持播放')
|
||||
return
|
||||
}
|
||||
|
||||
if (isPlaying.value) {
|
||||
audioEl?.pause()
|
||||
isPlaying.value = false
|
||||
@ -404,19 +396,6 @@ function autoAdvance() {
|
||||
async function voiceSingle() {
|
||||
voicingSingle.value = true
|
||||
try {
|
||||
// dev 兜底:mock workId 直接 mock 配音
|
||||
if (isDev && String(workId.value || '').startsWith('mock-')) {
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
const p = pages.value[idx.value]
|
||||
if (p) {
|
||||
p.audioUrl = 'mock-audio-' + p.pageNum
|
||||
p.localBlob = null
|
||||
p.isAiVoice = true
|
||||
}
|
||||
showToast('AI 配音完成')
|
||||
return
|
||||
}
|
||||
|
||||
const res = await voicePage({ workId: workId.value, voiceAll: false, pageNum: currentPage.value.pageNum })
|
||||
const data = res
|
||||
if (data.voicedPages?.length) {
|
||||
@ -449,19 +428,6 @@ async function voiceAllConfirm() {
|
||||
|
||||
voicingAll.value = true
|
||||
try {
|
||||
// dev 兜底:mock workId 直接 mock 全部配音
|
||||
if (isDev && String(workId.value || '').startsWith('mock-')) {
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
pages.value.forEach(p => {
|
||||
if (!p.audioUrl && !p.localBlob) {
|
||||
p.audioUrl = 'mock-audio-' + p.pageNum
|
||||
p.isAiVoice = true
|
||||
}
|
||||
})
|
||||
showToast('全部 AI 配音完成')
|
||||
return
|
||||
}
|
||||
|
||||
const res = await voicePage({ workId: workId.value, voiceAll: true })
|
||||
const data = res
|
||||
if (data.voicedPages) {
|
||||
@ -487,15 +453,6 @@ async function voiceAllConfirm() {
|
||||
async function finish() {
|
||||
submitting.value = true
|
||||
try {
|
||||
// dev 兜底:mock workId 跳过真实上传与提交
|
||||
if (isDev && String(workId.value || '').startsWith('mock-')) {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
store.workDetail = null
|
||||
showToast('配音完成')
|
||||
setTimeout(() => router.push(`/p/create/read/${workId.value}`), 600)
|
||||
return
|
||||
}
|
||||
|
||||
const pendingLocal = pages.value.filter(p => p.localBlob)
|
||||
|
||||
if (pendingLocal.length > 0) {
|
||||
@ -518,14 +475,28 @@ async function finish() {
|
||||
|
||||
store.workDetail = null
|
||||
showToast('配音完成')
|
||||
setTimeout(() => router.push(`/p/create/read/${workId.value}`), 800)
|
||||
setTimeout(
|
||||
() =>
|
||||
router.push({
|
||||
name: 'PublicCreateEditInfo',
|
||||
params: { workId: String(workId.value || '') },
|
||||
}),
|
||||
800,
|
||||
)
|
||||
} catch (e: any) {
|
||||
try {
|
||||
const check = await getWorkDetail(workId.value)
|
||||
if (check?.status >= 5) {
|
||||
store.workDetail = null
|
||||
showToast('配音已完成')
|
||||
setTimeout(() => router.push(`/p/create/read/${workId.value}`), 800)
|
||||
setTimeout(
|
||||
() =>
|
||||
router.push({
|
||||
name: 'PublicCreateEditInfo',
|
||||
params: { workId: String(workId.value || '') },
|
||||
}),
|
||||
800,
|
||||
)
|
||||
return
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
@ -539,24 +510,6 @@ async function finish() {
|
||||
async function loadWork() {
|
||||
loading.value = true
|
||||
try {
|
||||
const wid = String(workId.value || '')
|
||||
|
||||
// dev 兜底:mock workId 直接用 store.workDetail
|
||||
if (isDev && wid.startsWith('mock-')) {
|
||||
if (!store.workDetail) store.fillMockWorkDetail()
|
||||
const w = store.workDetail
|
||||
pages.value = (w.pageList || []).map((p: any) => ({
|
||||
pageNum: p.pageNum,
|
||||
text: p.text,
|
||||
imageUrl: p.imageUrl,
|
||||
audioUrl: p.audioUrl || null,
|
||||
localBlob: null,
|
||||
isAiVoice: p.audioUrl ? true : null,
|
||||
}))
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!store.workDetail || store.workDetail.workId !== workId.value) {
|
||||
store.workDetail = null
|
||||
const res = await getWorkDetail(workId.value)
|
||||
|
||||
@ -129,6 +129,7 @@ import {
|
||||
AudioOutlined,
|
||||
SendOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
||||
import { getWorkDetail, updateWork } from '@/api/aicreate'
|
||||
import { useAicreateStore } from '@/stores/aicreate'
|
||||
@ -140,8 +141,6 @@ const route = useRoute()
|
||||
const store = useAicreateStore()
|
||||
const workId = computed(() => route.params.workId || store.workId)
|
||||
|
||||
const isDev = import.meta.env.DEV
|
||||
|
||||
const loading = ref(true)
|
||||
const processing = ref(false)
|
||||
const coverUrl = ref('')
|
||||
@ -184,21 +183,6 @@ function confirmAddTag() {
|
||||
async function loadWork() {
|
||||
loading.value = true
|
||||
try {
|
||||
const wid = String(workId.value || '')
|
||||
|
||||
// dev 兜底:mock workId 直接用 store.workDetail
|
||||
if (isDev && wid.startsWith('mock-')) {
|
||||
if (!store.workDetail) store.fillMockWorkDetail()
|
||||
const w = store.workDetail
|
||||
form.value.author = w.author || ''
|
||||
form.value.subtitle = w.subtitle || ''
|
||||
form.value.intro = w.intro || ''
|
||||
selectedTags.value = Array.isArray(w.tags) && w.tags.length ? [...w.tags] : ['冒险']
|
||||
coverUrl.value = w.pageList?.[0]?.imageUrl || ''
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 缓存不匹配当前 workId 时重新请求
|
||||
if (!store.workDetail || store.workDetail.workId !== workId.value) {
|
||||
store.workDetail = null
|
||||
@ -207,7 +191,8 @@ async function loadWork() {
|
||||
}
|
||||
const w = store.workDetail
|
||||
|
||||
if (w.status > STATUS.CATALOGED) {
|
||||
// 已配音(DUBBED)仍可在本页编辑元数据/发布;仅当状态高于当前流程终态时再按 status 跳转
|
||||
if (w.status > STATUS.DUBBED) {
|
||||
const nextRoute = getRouteByStatus(w.status, w.workId)
|
||||
if (nextRoute) { router.replace(nextRoute); return }
|
||||
}
|
||||
@ -240,20 +225,6 @@ function validate() {
|
||||
* 不做跳转,由各 handler 决定下一步去哪
|
||||
*/
|
||||
async function saveFormToServer() {
|
||||
const wid = String(workId.value || '')
|
||||
|
||||
// dev 兜底:mock workId 直接写回 store,跳过真实接口
|
||||
if (isDev && wid.startsWith('mock-')) {
|
||||
if (store.workDetail) {
|
||||
store.workDetail.author = form.value.author.trim()
|
||||
store.workDetail.subtitle = form.value.subtitle.trim()
|
||||
store.workDetail.intro = form.value.intro.trim()
|
||||
store.workDetail.tags = [...selectedTags.value]
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const data = { tags: selectedTags.value }
|
||||
data.author = form.value.author.trim()
|
||||
@ -273,21 +244,24 @@ async function saveFormToServer() {
|
||||
// 容错:保存报错时检查实际状态,可能已经成功但重试导致 CAS 失败
|
||||
try {
|
||||
const check = await getWorkDetail(workId.value)
|
||||
if (check?.data?.status >= 4) return true
|
||||
if (check?.status >= 4) return true
|
||||
} catch { /* ignore */ }
|
||||
alert(e.message || '保存失败,请重试')
|
||||
message.error(e.message || '保存失败,请重试')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存(编目完成 → unpublished)→ 跳作品库未发布 tab */
|
||||
/** 保存(编目完成 → unpublished)→ 保存成功页,可继续配音或进作品库 */
|
||||
async function handleSave() {
|
||||
if (!validate()) return
|
||||
processing.value = true
|
||||
try {
|
||||
if (await saveFormToServer()) {
|
||||
store.workDetail = null
|
||||
router.push('/p/works?tab=unpublished')
|
||||
router.push({
|
||||
name: 'PublicCreateSaveSuccess',
|
||||
params: { workId: String(workId.value || '') },
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
processing.value = false
|
||||
@ -308,27 +282,21 @@ async function handleGoDubbing() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 发布作品 → 进入超管端待审核,跳作品库审核中 tab */
|
||||
/** 发布作品 → 进入超管端待审核;完成后留在本页并刷新数据、提示用户 */
|
||||
async function handlePublish() {
|
||||
if (!validate()) return
|
||||
processing.value = true
|
||||
try {
|
||||
if (!(await saveFormToServer())) return
|
||||
|
||||
const wid = String(workId.value || '')
|
||||
|
||||
// dev 兜底:mock workId 直接跳作品库
|
||||
if (isDev && wid.startsWith('mock-')) {
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
store.workDetail = null
|
||||
router.push('/p/works?tab=pending_review')
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 真实发布接口需要本地 DB 作品 id(leai workId 到本地 id 的映射),
|
||||
// 等后端联调 publicUserWorksApi.publish 完成后接入
|
||||
store.workDetail = null
|
||||
router.push('/p/works?tab=pending_review')
|
||||
message.success('提交成功,作品已进入审核,可在作品库「审核中」查看进度')
|
||||
router.push({
|
||||
name: 'PublicCreateSaveSuccess',
|
||||
params: { workId: String(workId.value || '') },
|
||||
});
|
||||
} finally {
|
||||
processing.value = false
|
||||
}
|
||||
|
||||
@ -99,8 +99,6 @@ const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAicreateStore()
|
||||
|
||||
const isDev = import.meta.env.DEV
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const pages = ref<any[]>([])
|
||||
@ -134,17 +132,8 @@ async function loadWork() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
// dev 兜底:mock workId 或 dev 模式无 workId 时使用 store.workDetail
|
||||
const wid = String(workId.value || '')
|
||||
if (isDev && (wid.startsWith('mock-') || !wid)) {
|
||||
if (!store.workDetail) store.fillMockWorkDetail()
|
||||
const work = store.workDetail
|
||||
pages.value = (work.pageList || []).map((p: any) => ({
|
||||
pageNum: p.pageNum,
|
||||
text: p.text,
|
||||
imageUrl: p.imageUrl,
|
||||
audioUrl: p.audioUrl,
|
||||
}))
|
||||
if (!workId.value) {
|
||||
error.value = '缺少作品信息'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ export default { name: 'SaveSuccessView' }
|
||||
</script>
|
||||
<template>
|
||||
<div class="success-page">
|
||||
<!-- 撒花装饰 -->
|
||||
<div class="confetti c1">🎊</div>
|
||||
<div class="confetti c2">🌟</div>
|
||||
<div class="confetti c3">✨</div>
|
||||
@ -12,12 +11,10 @@ export default { name: 'SaveSuccessView' }
|
||||
<div class="confetti c6">🎊</div>
|
||||
|
||||
<div class="success-content">
|
||||
<!-- 撒花大图标 -->
|
||||
<div class="celebration-icon">🎉</div>
|
||||
<div class="success-title">保存成功!</div>
|
||||
<div class="success-sub">太棒了,你的绘本已保存</div>
|
||||
<div class="success-title">{{ headline }}</div>
|
||||
<div class="success-sub">{{ subline }}</div>
|
||||
|
||||
<!-- 封面卡片 - 3D 微倾斜效果 -->
|
||||
<div class="cover-card-wrap" v-if="coverUrl">
|
||||
<div class="cover-card">
|
||||
<img :src="coverUrl" class="cover-img" />
|
||||
@ -28,15 +25,30 @@ export default { name: 'SaveSuccessView' }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-group">
|
||||
<button class="btn-primary action-btn" @click="goDubbing">
|
||||
<button
|
||||
v-if="showDubbingCta"
|
||||
class="btn-primary action-btn"
|
||||
@click="goDubbing"
|
||||
>
|
||||
<span class="action-icon">🎙️</span>
|
||||
<div class="action-text">
|
||||
<div class="action-main">给绘本配音</div>
|
||||
<div class="action-desc">为每一页添加AI语音</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="showWorksCta"
|
||||
class="btn-outline action-btn"
|
||||
@click="goWorks"
|
||||
>
|
||||
<span class="action-icon">📚</span>
|
||||
<div class="action-text">
|
||||
<div class="action-main">{{ worksCtaMain }}</div>
|
||||
<div class="action-desc">{{ worksCtaDesc }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -51,7 +63,34 @@ import { useAicreateStore } from '@/stores/aicreate'
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAicreateStore()
|
||||
const workId = computed(() => route.params.workId || store.workId)
|
||||
const workId = computed(() => String(route.params.workId || store.workId || ''))
|
||||
|
||||
const afterPublish = computed(() => route.query.after === 'publish')
|
||||
|
||||
const headline = computed(() => {
|
||||
if (afterPublish.value) return '提交成功'
|
||||
return '保存成功!'
|
||||
})
|
||||
|
||||
const subline = computed(() => {
|
||||
if (afterPublish.value) return '作品已进入审核,请耐心等待'
|
||||
return '太棒了,你的绘本已保存'
|
||||
})
|
||||
|
||||
/** 提交审核成功后不展示「去配音」 */
|
||||
const showDubbingCta = computed(() => !afterPublish.value)
|
||||
|
||||
const showWorksCta = computed(() => true)
|
||||
|
||||
const worksCtaMain = computed(() => {
|
||||
if (afterPublish.value) return '查看审核进度'
|
||||
return '查看作品库'
|
||||
})
|
||||
|
||||
const worksCtaDesc = computed(() => {
|
||||
if (afterPublish.value) return '在「审核中」查看状态'
|
||||
return '未发布作品在「未发布」分类'
|
||||
})
|
||||
|
||||
const coverUrl = ref('')
|
||||
const title = ref('')
|
||||
@ -75,7 +114,13 @@ function goDubbing() {
|
||||
router.push(`/p/create/dubbing/${workId.value}`)
|
||||
}
|
||||
|
||||
|
||||
function goWorks() {
|
||||
if (afterPublish.value) {
|
||||
router.push('/p/works?tab=pending_review')
|
||||
} else {
|
||||
router.push('/p/works?tab=unpublished')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadWork)
|
||||
</script>
|
||||
@ -91,7 +136,6 @@ onMounted(loadWork)
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 撒花动画 */
|
||||
.confetti {
|
||||
position: absolute;
|
||||
font-size: 24px;
|
||||
@ -148,7 +192,6 @@ onMounted(loadWork)
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
/* 封面卡片 3D 效果 */
|
||||
.cover-card-wrap {
|
||||
perspective: 600px;
|
||||
margin-bottom: 32px;
|
||||
@ -195,9 +238,24 @@ onMounted(loadWork)
|
||||
text-align: left;
|
||||
padding: 16px 20px;
|
||||
border-radius: var(--ai-radius);
|
||||
width: 100%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--ai-gradient, linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%));
|
||||
color: #fff;
|
||||
}
|
||||
.btn-outline {
|
||||
background: #fff;
|
||||
color: #4A3728;
|
||||
border: 1px solid rgba(99, 102, 241, 0.35);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
}
|
||||
.action-icon { font-size: 24px; flex-shrink: 0; }
|
||||
.action-text { flex: 1; }
|
||||
.action-main { font-size: 15px; font-weight: 700; }
|
||||
.action-desc { font-size: 12px; opacity: 0.7; margin-top: 2px; }
|
||||
.btn-outline .action-desc { opacity: 0.8; }
|
||||
</style>
|
||||
|
||||
@ -127,8 +127,8 @@ export default { name: 'WelcomeView' }
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { onMounted, onActivated, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
CameraOutlined,
|
||||
SmileOutlined,
|
||||
@ -143,19 +143,76 @@ import {
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useAicreateStore } from '@/stores/aicreate'
|
||||
import { loadExtractDraft } from '@/utils/aicreate/extractDraft'
|
||||
import { resumeLeaiWorkFromApi } from '@/utils/aicreate/resumeLeaiWork'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAicreateStore()
|
||||
|
||||
onMounted(async () => {
|
||||
// 检查恢复状态(短会话内 Tab 切换等)
|
||||
/** 作品库「编辑」传入的 resumeWorkId(query 可能已解码,容错二次 decode) */
|
||||
function parseResumeWorkIdFromQuery(raw: string): string {
|
||||
const t = String(raw || '').trim()
|
||||
if (!t) return ''
|
||||
try {
|
||||
return decodeURIComponent(t)
|
||||
} catch {
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
function getResumeWorkIdFromRoute(): string {
|
||||
const qResume = route.query.resumeWorkId
|
||||
const resumeFromQuery =
|
||||
typeof qResume === 'string'
|
||||
? qResume
|
||||
: Array.isArray(qResume) && qResume[0]
|
||||
? qResume[0]
|
||||
: ''
|
||||
return resumeFromQuery ? String(resumeFromQuery) : ''
|
||||
}
|
||||
|
||||
/** 欢迎页恢复逻辑:与 keep-alive 配合,onMounted 仅首次;再次进入需 onActivated + watch */
|
||||
let welcomeResumeRunning = false
|
||||
|
||||
async function runWelcomeEntry() {
|
||||
if (route.name !== 'PublicCreateWelcome') return
|
||||
if (welcomeResumeRunning) return
|
||||
welcomeResumeRunning = true
|
||||
|
||||
try {
|
||||
const resumeFromQuery = getResumeWorkIdFromRoute()
|
||||
|
||||
// 1) 作品库 ?resumeWorkId= 优先于短会话 recovery,避免错跳;无 token 时等待壳层/ watch 再试
|
||||
if (resumeFromQuery) {
|
||||
if (!store.sessionToken) {
|
||||
return
|
||||
}
|
||||
const decoded = parseResumeWorkIdFromQuery(resumeFromQuery)
|
||||
if (!decoded) {
|
||||
await router.replace({ name: 'PublicCreateWelcome', query: {} })
|
||||
} else {
|
||||
const ok = await resumeLeaiWorkFromApi(decoded, router, store)
|
||||
if (ok) return
|
||||
await router.replace({ name: 'PublicCreateWelcome', query: {} })
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 短会话恢复(Tab 切换等)
|
||||
const recovery = store.restoreRecoveryState()
|
||||
if (recovery && recovery.path && recovery.path !== '/') {
|
||||
const newPath = '/p/create' + recovery.path
|
||||
router.push(newPath)
|
||||
return
|
||||
}
|
||||
// 角色提取草稿(10 天内):继续到选角页
|
||||
|
||||
// 3) 本地 le_workId:乐读派已生成作品,按状态继续
|
||||
const storedWid = localStorage.getItem('le_workId')
|
||||
if (storedWid && store.sessionToken) {
|
||||
const ok = await resumeLeaiWorkFromApi(storedWid, router, store)
|
||||
if (ok) return
|
||||
}
|
||||
|
||||
// 4) 角色提取草稿(10 天内):继续到选角页
|
||||
const draft = loadExtractDraft()
|
||||
if (draft && store.sessionToken) {
|
||||
store.imageUrl = draft.imageUrl
|
||||
@ -169,8 +226,26 @@ onMounted(async () => {
|
||||
localStorage.removeItem('le_workId')
|
||||
router.replace('/p/create/characters')
|
||||
}
|
||||
} finally {
|
||||
welcomeResumeRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void runWelcomeEntry()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
void runWelcomeEntry()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [store.sessionToken, route.query.resumeWorkId] as const,
|
||||
() => {
|
||||
void runWelcomeEntry()
|
||||
},
|
||||
)
|
||||
|
||||
const handleStart = () => {
|
||||
if (!store.sessionToken) return
|
||||
store.reset()
|
||||
@ -204,18 +279,36 @@ $gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12px 32px rgba(99, 102, 241, 0.22);
|
||||
}
|
||||
|
||||
.hero-deco {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
|
||||
.deco {
|
||||
position: absolute;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
.deco-1 { top: 14px; right: 18px; font-size: 22px; }
|
||||
.deco-2 { top: 18px; left: 22px; font-size: 14px; }
|
||||
.deco-3 { bottom: 18px; right: 30%; font-size: 12px; }
|
||||
|
||||
.deco-1 {
|
||||
top: 14px;
|
||||
right: 18px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.deco-2 {
|
||||
top: 18px;
|
||||
left: 22px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.deco-3 {
|
||||
bottom: 18px;
|
||||
right: 30%;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@ -229,12 +322,14 @@ $gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
||||
color: #fff;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
@ -250,6 +345,7 @@ $gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
||||
border: 1px solid rgba(99, 102, 241, 0.06);
|
||||
box-shadow: 0 2px 12px rgba(99, 102, 241, 0.05);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0 0 14px;
|
||||
font-size: 15px;
|
||||
@ -258,8 +354,16 @@ $gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
||||
}
|
||||
|
||||
/* ---------- 创作流程 ---------- */
|
||||
.steps { display: flex; flex-direction: column; }
|
||||
.step { display: flex; gap: 12px; }
|
||||
.steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.step-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -267,6 +371,7 @@ $gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
||||
width: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
@ -280,6 +385,7 @@ $gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 10px rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
|
||||
.step-line {
|
||||
flex: 1;
|
||||
width: 2px;
|
||||
@ -287,19 +393,34 @@ $gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
||||
background: linear-gradient(180deg, rgba(99, 102, 241, 0.35), rgba(236, 72, 153, 0.18));
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.step-right {
|
||||
flex: 1;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
.step:last-child .step-right { padding-bottom: 0; }
|
||||
|
||||
.step:last-child .step-right {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.step-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.step-icon { color: $primary; font-size: 15px; }
|
||||
.step-title { font-size: 14px; font-weight: 700; color: $text-strong; }
|
||||
|
||||
.step-icon {
|
||||
color: $primary;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: $text-strong;
|
||||
}
|
||||
|
||||
.step-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
@ -309,6 +430,7 @@ $gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
||||
padding: 1px 6px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
@ -334,6 +456,7 @@ $gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
||||
bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.cta-btn {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
@ -353,22 +476,33 @@ $gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
||||
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.38);
|
||||
transition: all 0.2s;
|
||||
|
||||
:deep(.anticon) { font-size: 18px; }
|
||||
:deep(.anticon) {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 28px rgba(99, 102, 241, 0.44);
|
||||
}
|
||||
&:active { transform: scale(0.98); opacity: 0.95; }
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
background: #e5e7eb;
|
||||
color: #9ca3af;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
&:hover { transform: none; box-shadow: none; }
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.slogan {
|
||||
pointer-events: auto;
|
||||
margin: 8px 0 0;
|
||||
|
||||
@ -117,7 +117,7 @@
|
||||
<button v-if="work.status === 'unpublished'" class="op-btn primary" :disabled="actionLoading"
|
||||
@click="handlePublish">
|
||||
<send-outlined />
|
||||
<span>公开发布</span>
|
||||
<span>提交审核</span>
|
||||
</button>
|
||||
|
||||
<button v-else-if="work.status === 'rejected'" class="op-btn primary" :disabled="actionLoading"
|
||||
@ -126,9 +126,10 @@
|
||||
<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" :disabled="actionLoading"
|
||||
@click="handleContinue">
|
||||
<edit-outlined />
|
||||
<span>继续创作</span>
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
|
||||
<button v-else-if="work.status === 'pending_review'" class="op-btn outline" :disabled="actionLoading"
|
||||
@ -143,12 +144,6 @@
|
||||
<span>下架</span>
|
||||
</button>
|
||||
|
||||
<!-- 编辑信息(unpublished 状态)-->
|
||||
<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">
|
||||
<delete-outlined />
|
||||
@ -198,17 +193,15 @@ import {
|
||||
publicUserWorksApi,
|
||||
publicGalleryApi,
|
||||
publicInteractionApi,
|
||||
publicCreationApi,
|
||||
type UserWork,
|
||||
} from '@/api/public'
|
||||
import { getMockWorkDetail, isMockWorkId } from './_dev-mock'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const workId = Number(route.params.id)
|
||||
|
||||
const isDev = import.meta.env.DEV
|
||||
|
||||
const work = ref<UserWork | null>(null)
|
||||
const loading = ref(true)
|
||||
const currentPageIndex = ref(0)
|
||||
@ -220,12 +213,36 @@ const currentPageData = computed(() => work.value?.pages?.[currentPageIndex.valu
|
||||
|
||||
const isLoggedIn = computed(() => !!localStorage.getItem('public_token'))
|
||||
|
||||
/** 当前登录公众用户 ID(与 Login 写入的 public_user 一致) */
|
||||
function getPublicUserId(): number | null {
|
||||
const raw = localStorage.getItem('public_user')
|
||||
if (!raw || raw === 'undefined' || raw === 'null') return null
|
||||
try {
|
||||
const id = (JSON.parse(raw) as { id?: unknown }).id
|
||||
if (id == null) return null
|
||||
const n = Number(id)
|
||||
return Number.isFinite(n) ? n : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 作品作者 sys_user id。
|
||||
* 「我的作品库」详情经 normalize 后有顶层 userId;广场 GET /public/gallery/{id} 仅返回 creator/user,无 userId 字段。
|
||||
*/
|
||||
function resolveWorkOwnerUserId(w: UserWork): number | null {
|
||||
if (typeof w.userId === 'number' && !Number.isNaN(w.userId)) return w.userId
|
||||
const c = w.creator?.id
|
||||
if (typeof c === 'number' && !Number.isNaN(c)) return c
|
||||
return null
|
||||
}
|
||||
|
||||
const isOwner = computed(() => {
|
||||
// dev mock 模式:mock 作品默认是当前用户作品
|
||||
if (isDev && work.value && isMockWorkId(work.value.id)) return true
|
||||
const u = localStorage.getItem('public_user')
|
||||
if (!u || !work.value) return false
|
||||
try { return JSON.parse(u).id === work.value.userId } catch { return false }
|
||||
const uid = getPublicUserId()
|
||||
const oid = work.value ? resolveWorkOwnerUserId(work.value) : null
|
||||
if (uid == null || oid == null) return false
|
||||
return uid === oid
|
||||
})
|
||||
|
||||
const displayLikeCount = computed(() => work.value?.likeCount || 0)
|
||||
@ -242,6 +259,97 @@ const statusTextMap: Record<string, string> = {
|
||||
|
||||
const formatDate = (d: string) => dayjs(d).format('YYYY-MM-DD')
|
||||
|
||||
/** 从接口对象上解析乐读派 remoteWorkId(兼容 camelCase / snake_case) */
|
||||
function pickRemoteWorkId(obj: Record<string, unknown> | null | undefined): string | null {
|
||||
if (!obj) return null
|
||||
const v = obj.remoteWorkId ?? obj.remote_work_id
|
||||
if (v == null || v === '') return null
|
||||
const s = String(v).trim()
|
||||
return s || null
|
||||
}
|
||||
|
||||
/** 从 ai_meta 中解析乐读派作品 ID(与创作流程落库字段对齐) */
|
||||
function pickRemoteWorkIdFromAiMeta(aiMeta: unknown): string | null {
|
||||
if (aiMeta == null) return null
|
||||
let o: Record<string, unknown>
|
||||
if (typeof aiMeta === 'string') {
|
||||
try {
|
||||
o = JSON.parse(aiMeta) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
} else if (typeof aiMeta === 'object') {
|
||||
o = aiMeta as Record<string, unknown>
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
const v =
|
||||
o.remoteWorkId ??
|
||||
o.remote_work_id ??
|
||||
o.workId ??
|
||||
o.work_id ??
|
||||
o.leaiWorkId
|
||||
if (v == null || v === '') return null
|
||||
const s = String(v).trim()
|
||||
return s || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析乐读派 remoteWorkId(与创作页 resume 一致:必须为乐读派 workId,不能误用本地数字 id)。
|
||||
* 顺序:详情字段 → aiMeta → GET /public/creation/{本地id}/status
|
||||
*/
|
||||
async function resolveLeaiRemoteWorkId(w: UserWork): Promise<string | null> {
|
||||
const fromRow = pickRemoteWorkId(w as unknown as Record<string, unknown>)
|
||||
if (fromRow) return fromRow
|
||||
const fromMeta = pickRemoteWorkIdFromAiMeta(w.aiMeta)
|
||||
if (fromMeta) return fromMeta
|
||||
const localId = typeof w.id === 'number' && !Number.isNaN(w.id) ? w.id : null
|
||||
if (localId == null) return null
|
||||
try {
|
||||
const st = await publicCreationApi.getStatus(localId)
|
||||
const rw = st?.remoteWorkId
|
||||
if (rw != null && String(rw).trim()) return String(rw).trim()
|
||||
} catch {
|
||||
/* 忽略,由调用方提示 */
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 作品库详情接口可能返回 { work, pages },与广场扁平结构统一 */
|
||||
function normalizeMyWorkDetail(raw: unknown): UserWork | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const o = raw as Record<string, unknown>
|
||||
if (o.work && typeof o.work === 'object') {
|
||||
const w = o.work as Record<string, unknown>
|
||||
const pages = (Array.isArray(o.pages) ? o.pages : []).map((p: unknown) => {
|
||||
const row = p as Record<string, unknown>
|
||||
return {
|
||||
id: row.id as number,
|
||||
workId: (row.workId ?? w.id) as number,
|
||||
pageNo: row.pageNo as number,
|
||||
imageUrl: (row.imageUrl ?? null) as string | null,
|
||||
text: (row.text ?? null) as string | null,
|
||||
audioUrl: (row.audioUrl ?? null) as string | null,
|
||||
}
|
||||
})
|
||||
const rw = pickRemoteWorkId(w)
|
||||
const base = { ...(w as unknown as UserWork), pages }
|
||||
if (rw && !base.remoteWorkId) base.remoteWorkId = rw
|
||||
return base
|
||||
}
|
||||
return raw as UserWork
|
||||
}
|
||||
|
||||
/** 详情接口扁平或嵌套返回时,统一补齐 remoteWorkId */
|
||||
function ensureRemoteWorkIdOnWork(raw: unknown, w: UserWork | null) {
|
||||
if (!w || !raw || typeof raw !== 'object') return
|
||||
if (w.remoteWorkId) return
|
||||
const o = raw as Record<string, unknown>
|
||||
const nested = o.work && typeof o.work === 'object' ? (o.work as Record<string, unknown>) : null
|
||||
const rw = pickRemoteWorkId(nested) || pickRemoteWorkId(o)
|
||||
if (rw) w.remoteWorkId = rw
|
||||
}
|
||||
|
||||
const prevPage = () => { if (currentPageIndex.value > 0) currentPageIndex.value-- }
|
||||
const nextPage = () => { if (work.value?.pages && currentPageIndex.value < work.value.pages.length - 1) currentPageIndex.value++ }
|
||||
|
||||
@ -288,36 +396,54 @@ const handleFavorite = async () => {
|
||||
|
||||
// ─── 作者操作 ───
|
||||
|
||||
const isMock = computed(() => isDev && work.value && isMockWorkId(work.value.id))
|
||||
|
||||
/** 公开发布:unpublished → pending_review */
|
||||
/** 提交审核:unpublished → pending_review */
|
||||
async function handlePublish() {
|
||||
if (!work.value) return
|
||||
actionLoading.value = true
|
||||
try {
|
||||
if (isMock.value) {
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
} else {
|
||||
await publicUserWorksApi.publish(workId)
|
||||
}
|
||||
work.value.status = 'pending_review'
|
||||
message.success('已提交审核,等待超管确认')
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '发布失败')
|
||||
message.error(e.message || '提交审核失败')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 修改后重交:rejected → 跳到编辑信息页 */
|
||||
function handleResubmit() {
|
||||
// TODO: 真实场景需要 leai workId 跳到 EditInfoView,等后端 work.leaiWorkId 字段确认后接入
|
||||
message.info('编辑功能待后端联调,dev 模式暂无法跳转')
|
||||
/** 修改后重交:rejected → 编辑信息页 */
|
||||
async function handleResubmit() {
|
||||
if (!work.value) return
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const rw = await resolveLeaiRemoteWorkId(work.value)
|
||||
if (!rw) {
|
||||
message.warning('暂无乐读派作品信息,请从创作流程进入')
|
||||
return
|
||||
}
|
||||
work.value.remoteWorkId = rw
|
||||
router.push(`/p/create/edit-info/${encodeURIComponent(rw)}`)
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 继续创作:draft → 跳回创作流程 */
|
||||
function handleContinue() {
|
||||
router.push('/p/create')
|
||||
/** 草稿编辑:带乐读派 resumeWorkId 进入欢迎页,与 resumeLeaiWorkFromApi 对齐(先解析再跳转,避免把本地 id 当乐读派 id) */
|
||||
async function handleContinue() {
|
||||
if (!work.value) return
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const rw = await resolveLeaiRemoteWorkId(work.value)
|
||||
if (rw) {
|
||||
work.value.remoteWorkId = rw
|
||||
router.push({ name: 'PublicCreateWelcome', query: { resumeWorkId: rw } })
|
||||
return
|
||||
}
|
||||
message.warning('暂无乐读派作品信息,将打开创作首页')
|
||||
router.push({ name: 'PublicCreateWelcome' })
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 撤回审核:pending_review → unpublished */
|
||||
@ -330,15 +456,9 @@ function handleWithdraw() {
|
||||
if (!work.value) return
|
||||
actionLoading.value = true
|
||||
try {
|
||||
if (isMock.value) {
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
} else {
|
||||
// TODO: 后端需要新增 POST /public/works/{id}/withdraw 接口
|
||||
message.warning('撤回接口待后端联调')
|
||||
return
|
||||
}
|
||||
work.value.status = 'unpublished'
|
||||
message.success('已撤回审核')
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '撤回失败')
|
||||
} finally {
|
||||
@ -358,15 +478,9 @@ function handleUnpublish() {
|
||||
if (!work.value) return
|
||||
actionLoading.value = true
|
||||
try {
|
||||
if (isMock.value) {
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
} else {
|
||||
// TODO: 后端需要新增 POST /public/works/{id}/unpublish 接口
|
||||
message.warning('下架接口待后端联调')
|
||||
return
|
||||
}
|
||||
work.value.status = 'unpublished'
|
||||
message.success('已下架到「未发布」')
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '下架失败')
|
||||
} finally {
|
||||
@ -376,12 +490,6 @@ function handleUnpublish() {
|
||||
)
|
||||
}
|
||||
|
||||
/** 编辑信息:跳到 EditInfoView */
|
||||
function handleEditInfo() {
|
||||
// TODO: 真实场景需要 work.leaiWorkId 字段,等后端确认后接入
|
||||
message.info('编辑信息功能待后端联调')
|
||||
}
|
||||
|
||||
/** 删除作品 */
|
||||
function handleDelete() {
|
||||
showConfirm(
|
||||
@ -391,11 +499,7 @@ function handleDelete() {
|
||||
async () => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
if (isMock.value) {
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
} else {
|
||||
await publicUserWorksApi.delete(workId)
|
||||
}
|
||||
message.success('已删除')
|
||||
router.push('/p/works')
|
||||
} catch (e: any) {
|
||||
@ -437,22 +541,16 @@ function handleConfirmCancel() {
|
||||
const fetchWork = async () => {
|
||||
loading.value = true
|
||||
|
||||
// dev 兜底:mock id 直接用 mock 数据
|
||||
if (isDev && isMockWorkId(workId)) {
|
||||
const mock = getMockWorkDetail(workId)
|
||||
if (mock) {
|
||||
work.value = mock
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 优先尝试广场接口(公开作品),失败再尝试自己作品库
|
||||
try {
|
||||
work.value = await publicGalleryApi.detail(workId)
|
||||
const rawGallery = await publicGalleryApi.detail(workId)
|
||||
work.value = rawGallery as UserWork
|
||||
ensureRemoteWorkIdOnWork(rawGallery, work.value)
|
||||
} catch {
|
||||
work.value = await publicUserWorksApi.detail(workId)
|
||||
const raw = await publicUserWorksApi.detail(workId)
|
||||
work.value = normalizeMyWorkDetail(raw) ?? (raw as UserWork)
|
||||
ensureRemoteWorkIdOnWork(raw, work.value)
|
||||
}
|
||||
if (isLoggedIn.value) {
|
||||
try {
|
||||
@ -460,17 +558,7 @@ const fetchWork = async () => {
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
} catch {
|
||||
// dev 兜底:真实接口失败时尝试 mock 数据
|
||||
if (isDev) {
|
||||
const mock = getMockWorkDetail(workId) || getMockWorkDetail(101)
|
||||
if (mock) {
|
||||
work.value = mock
|
||||
} else {
|
||||
message.error('获取作品详情失败')
|
||||
}
|
||||
} else {
|
||||
message.error('获取作品详情失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user