feat: 公众端绘本创作流程与作品展示优化,乐读派同步及封面回填迁移

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-10 17:09:15 +08:00
parent 430eba6bd6
commit 1862204ac5
19 changed files with 1412 additions and 850 deletions

View File

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

View File

@ -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) {

View File

@ -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) <> '';

View File

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

View File

@ -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)
}
/** 额度校验 */

View File

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

View File

@ -4,81 +4,81 @@
* phone/orgId/appSecret localStorage
* orgId sessionStoragesessionToken
*/
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,
};
});

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

View File

@ -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:

View File

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

View File

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

View File

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

View File

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

View File

@ -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)

View File

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

View File

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

View File

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

View File

@ -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
/** 作品库「编辑」传入的 resumeWorkIdquery 可能已解码,容错二次 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;

View File

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