feat(desktop): 实现海报导出功能

This commit is contained in:
2026-01-11 01:04:25 +08:00
parent 36cf521851
commit 311aa59482
10 changed files with 467 additions and 150 deletions

View File

@@ -21,6 +21,7 @@ declare module 'vue' {
AInput: typeof import('@arco-design/web-vue')['Input']
AInputNumber: typeof import('@arco-design/web-vue')['InputNumber']
AInputPassword: typeof import('@arco-design/web-vue')['InputPassword']
AModal: typeof import('@arco-design/web-vue')['Modal']
AOption: typeof import('@arco-design/web-vue')['Option']
ASelect: typeof import('@arco-design/web-vue')['Select']
ASlider: typeof import('@arco-design/web-vue')['Slider']
@@ -29,6 +30,7 @@ declare module 'vue' {
ATabs: typeof import('@arco-design/web-vue')['Tabs']
ATag: typeof import('@arco-design/web-vue')['Tag']
ATextarea: typeof import('@arco-design/web-vue')['Textarea']
ATooltip: typeof import('@arco-design/web-vue')['Tooltip']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import useRouterHook from '@renderer/hooks/useRouterHook'
import { Copy, Left, Quote, FileCode, Share } from '@icon-park/vue-next'
import { trpc } from '@renderer/lib/trpc'
import { IReadingReflectionTaskItem } from '@shared/types/IReadingReflectionTask'
import { Message } from '@arco-design/web-vue'
const { getQuery, goBack, go } = useRouterHook()
const subTaskId = computed(() => getQuery('id') as string)
const readingData = ref<IReadingReflectionTaskItem>()
const isLoading = ref(true)
const fetchDetail = async () => {
if (!subTaskId.value) return
isLoading.value = true
try {
const data = await trpc.task.getItemDetail.query({ itemId: subTaskId.value })
console.log(data)
readingData.value = data as unknown as IReadingReflectionTaskItem
} finally {
isLoading.value = false
}
}
const handleCopyContent = async () => {
if (!readingData.value?.content) return
await navigator.clipboard.writeText(readingData.value.content)
Message.success('正文已成功复制')
}
const handleExportMD = () => {
if (!readingData.value) return
const md = `# ${readingData.value.title}\n\n${readingData.value.content}`
const blob = new Blob([md], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${readingData.value.title}.md`
a.click()
}
const handlePoster = () => {
go('/reflection/poster', {
itemId: subTaskId.value
})
}
onMounted(() => fetchDetail())
</script>
<template>
<div class="h-full w-full flex flex-col relative overflow-hidden">
<header
class="h-14 border-b border-slate-100/50 px-6 flex items-center justify-between relative z-20 backdrop-blur-md bg-white/70"
>
<div
class="flex items-center gap-3 cursor-pointer group border-b border-slate-50"
@click="goBack(1)"
>
<div
class="w-5 h-5 rounded-full bg-slate-50 flex items-center justify-center group-hover:bg-[#7816ff]/10 transition-all"
>
<left size="16" class="text-slate-400 group-hover:text-[#7816ff]" />
</div>
<span class="font-bold text-slate-800 text-sm">返回列表</span>
</div>
<div class="flex items-center gap-2">
<a-button
size="mini"
class="rounded-lg bg-[#7816ff] text-white border-none"
@click="handlePoster"
>
<template #icon><share theme="outline" size="14" /></template>
分享海报
</a-button>
<a-button
size="mini"
class="rounded-lg bg-[#7816ff] text-white border-none"
@click="handleCopyContent"
>
<template #icon><Copy theme="outline" size="14" /></template>
复制原文
</a-button>
<a-button
size="mini"
class="rounded-lg bg-[#7816ff] text-white border-none"
@click="handleExportMD"
>
<template #icon><FileCode theme="outline" size="14" /></template>
导出文档
</a-button>
</div>
</header>
<div class="flex-1 overflow-y-auto relative z-10 px-8 bg-white top-5 rounded-xl">
<div class="flex flex-col gap-xl">
<div class="flex-1 flex flex-col gap-2">
<h1 class="text-4xl font-black text-slate-900 leading-tight mb-2">
{{ readingData?.title }}
</h1>
<div class="flex flex-wrap gap-2">
<span
v-for="tag in readingData?.keywords"
:key="tag"
class="px-2 py-1 bg-[#7816ff]/10 text-[#7816ff] text-[10px] font-bold rounded-md"
># {{ tag }}</span
>
</div>
</div>
<span class="text-slate-700 text-[14px] leading-loose whitespace-pre-wrap font-serif">
{{ readingData?.content }}
</span>
</div>
<footer class="mt-8 bg-slate-50 rounded-2xl p-8 relative overflow-hidden">
<quote class="absolute top-2 right-10 text-slate-100" size="20" theme="filled" />
<div class="relative z-10">
<div class="text-xs font-black text-[#7816ff] uppercase tracking-[0.2em]">文章摘要</div>
<p class="text-sm text-slate-500 leading-relaxed italic font-medium">
{{ readingData?.summary }}
</p>
</div>
</footer>
</div>
</div>
</template>
<style scoped>
.custom-scroll::-webkit-scrollbar {
width: 6px;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 10px;
}
/* 字体优化:正文使用衬线体更有阅读感 */
.font-serif {
font-family: 'Optima', 'Simplified Chinese', 'STSong', serif;
}
</style>

View File

@@ -0,0 +1,247 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import useRouterHook from '@renderer/hooks/useRouterHook'
import { ApplicationEffect, Camera, FontSize, Left } from '@icon-park/vue-next'
import { trpc } from '@renderer/lib/trpc'
import html2canvas from 'html2canvas'
import { Message } from '@arco-design/web-vue'
const { getQuery, goBack } = useRouterHook()
const subTaskId = computed(() => getQuery('itemId') as string)
const readingData = ref<any>(null)
const posterRef = ref<HTMLElement | null>(null)
const isExporting = ref(false)
// 设计参数
const settings = ref({
themeColor: '#7816ff',
fontSize: 15
})
const themes = [
{ name: '经典紫', color: '#7816ff' },
{ name: '松石绿', color: '#00b42a' },
{ name: '夕阳橙', color: '#ff7d00' },
{ name: '深邃蓝', color: '#165dff' },
{ name: '酷感黑', color: '#1a1a1a' }
]
const fetchDetail = async () => {
try {
readingData.value = await trpc.task.getItemDetail.query({ itemId: subTaskId.value })
} catch (e) {
Message.error('获取心得内容失败')
}
}
const handleExport = async () => {
if (!posterRef.value) return
isExporting.value = true
try {
// 等待 DOM 渲染完全稳定
await new Promise((r) => setTimeout(r, 300))
const canvas = await html2canvas(posterRef.value, {
scale: 2,
useCORS: true,
backgroundColor: null,
scrollX: 0,
scrollY: 0
})
const link = document.createElement('a')
link.download = `Insight-Poster-${Date.now()}.png`
link.href = canvas.toDataURL('image/png')
link.click()
Message.success('高清海报已导出')
} catch (e) {
Message.error('导出失败,请尝试减小字号')
} finally {
isExporting.value = false
}
}
onMounted(fetchDetail)
</script>
<template>
<div class="h-screen w-full flex bg-[#F8F9FB] overflow-hidden">
<aside
class="w-60 h-full bg-white border-r border-slate-200 flex flex-col flex-shrink-0 z-30 shadow-xl"
>
<div
class="p-6 flex items-center gap-3 cursor-pointer group border-b border-slate-50"
@click="goBack(1)"
>
<div
class="w-8 h-8 rounded-full bg-slate-50 flex items-center justify-center group-hover:bg-[#7816ff]/10 transition-all"
>
<left size="16" class="text-slate-400 group-hover:text-[#7816ff]" />
</div>
<span class="font-bold text-slate-800 text-sm">返回详情</span>
</div>
<div class="flex-1 overflow-y-auto p-8 space-y-10 custom-scroll">
<section class="space-y-4">
<div class="flex items-center justify-between">
<span class="text-[11px] font-black text-slate-400 uppercase tracking-widest">
海报色彩
</span>
<ApplicationEffect size="14" class="text-[#7816ff]" />
</div>
<div class="grid grid-cols-5 gap-3">
<div
v-for="item in themes"
:key="item.color"
@click="settings.themeColor = item.color"
class="w-5 h-5 aspect-square rounded-full cursor-pointer transition-all hover:scale-110 flex items-center justify-center border-2 shadow-sm"
:style="{
backgroundColor: item.color,
borderColor: settings.themeColor === item.color ? '#fff' : 'transparent',
boxShadow: settings.themeColor === item.color ? `0 0 0 2px ${item.color}` : 'none'
}"
>
<div
v-if="settings.themeColor === item.color"
class="w-13px h-13px bg-white rounded-full"
></div>
</div>
</div>
</section>
<section class="space-y-4">
<div class="flex items-center justify-between">
<span class="text-[11px] font-black text-slate-400 uppercase tracking-widest">
文字排版
</span>
<FontSize size="14" class="text-[#7816ff]" />
</div>
<div class="flex justify-between text-[10px] font-bold text-slate-500 mb-2">
<span>正文字号</span>
<span class="text-[#7816ff]">{{ settings.fontSize }}px</span>
</div>
<a-slider v-model="settings.fontSize" :min="12" :max="20" :step="1" />
</section>
<div class="p-2 bg-purple-50 rounded-2xl border border-purple-100/50">
<p class="text-[10px] text-[#7816ff] leading-relaxed">
<strong>设计建议</strong> 14px-16px
是最佳的阅读字号如果内容较多适当减小字号可以获得更好的留白感
</p>
</div>
</div>
<div class="p-8 border-t border-slate-50">
<button
@click="handleExport"
:disabled="isExporting"
class="w-full h-10 bg-[#7816ff] text-white rounded-2xl font-bold flex items-center justify-center gap-3 hover:bg-[#6a12e6] transition-all shadow-xl disabled:opacity-50"
>
<camera v-if="!isExporting" size="20" />
<span v-else class="animate-spin text-lg"></span>
{{ isExporting ? '正在生成海报...' : '导出高清图片' }}
</button>
</div>
</aside>
<main class="flex-1 h-full overflow-auto bg-[#E5E7EB] canvas-pattern custom-scroll">
<div class="min-h-full p-20 flex justify-center items-start">
<div
ref="posterRef"
class="w-[600px] flex-shrink-0 bg-white shadow-[0_30px_60px_-12px_rgba(0,0,0,0.2)] rounded-[40px] overflow-hidden mb-20 transition-all duration-500"
>
<div
class="p-5 flex flex-col"
:style="{
background: `linear-gradient(135deg, ${settings.themeColor}, ${settings.themeColor}dd)`
}"
>
<h1 class="text-white text-xl font-black text-center leading-tight">
{{ readingData?.title || '书籍标题' }}
</h1>
</div>
<div class="p-10 bg-white">
<div class="flex items-center gap-2">
<div
class="w-1.5 h-5 rounded-full"
:style="{ backgroundColor: settings.themeColor }"
></div>
<h2 class="text-sm font-black text-slate-800 tracking-tight">
{{ readingData?.title }}
</h2>
</div>
<p
class="text-slate-600 leading-[2.1] text-justify whitespace-pre-wrap transition-all font-medium"
:style="{ fontSize: settings.fontSize + 'px' }"
>
{{ readingData?.content }}
</p>
<div class="pt-8 w-full flex items-center">
<div class="flex items-center gap-4 m-a">
<div
class="w-12 h-12 rounded-2xl flex items-center justify-center text-white font-black text-2xl italic shadow-lg"
:style="{
backgroundColor: settings.themeColor,
boxShadow: `0 8px 16px -4px ${settings.themeColor}66`
}"
>
Z
</div>
<div>
<div class="text-[13px] font-black text-slate-800 tracking-tight">
读书心得助手
</div>
<div class="text-[9px] text-slate-400 font-bold uppercase tracking-widest mt-0.5">
让每一篇阅读者享受阅读与思考的乐趣
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</template>
<style scoped>
/* 画布打点背景,增强设计感 */
.canvas-pattern {
background-image: radial-gradient(#d1d5db 1px, transparent 1px);
background-size: 24px 24px;
}
/* 彻底隐藏原生滚动条,使用自定义样式 */
.custom-scroll::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 10px;
}
.custom-scroll::-webkit-scrollbar-track {
background: transparent;
}
/* 强制锁定海报宽度,不让 flex 动态计算 */
.w-\[450px\] {
width: 450px !important;
min-width: 450px !important;
max-width: 450px !important;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1.2s linear infinite;
display: inline-block;
}
</style>

View File

@@ -1,145 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import useRouterHook from '@renderer/hooks/useRouterHook'
import { Copy, Left, Quote } from '@icon-park/vue-next'
import { trpc } from '@renderer/lib/trpc'
import { IReadingReflectionTaskItem } from '@shared/types/IReadingReflectionTask'
import { Message } from '@arco-design/web-vue'
const { getQuery, goBack } = useRouterHook()
const subTaskId = computed(() => getQuery('id') as string)
const batchId = computed(() => getQuery('batchId') as string)
const content = ref<IReadingReflectionTaskItem | null>(null)
const isLoading = ref(true)
const fetchDetail = async () => {
if (!subTaskId.value) return
isLoading.value = true
try {
// 从批次列表中筛选出当前心得项
const data = await trpc.task.getBatchItems.query({ batchId: batchId.value })
content.value = data.find((item) => item.id === subTaskId.value) || null
} finally {
isLoading.value = false
}
}
/**
* 仅复制正文内容
* 排除标题、关键词和摘要
*/
const handleCopyContent = async () => {
if (!content.value?.content) {
Message.warning('内容尚未加载,无法复制')
return
}
try {
await navigator.clipboard.writeText(content.value.content)
Message.success({
content: '正文已成功复制到剪贴板',
duration: 2000
})
} catch (err) {
Message.error('复制失败,请手动选择复制')
console.error('Clipboard Error:', err)
}
}
onMounted(() => fetchDetail())
</script>
<template>
<div class="h-full flex flex-col bg-white">
<header class="h-14 border-b border-slate-50 px-6 flex items-center justify-between">
<div class="flex items-center">
<a-button type="text" size="small" class="text-slate-400" @click="goBack(1)">
<template #icon><left theme="outline" size="18" /></template>
</a-button>
<span class="text-sm font-bold text-slate-800 tracking-tight">返回列表</span>
</div>
<a-button
size="mini"
class="rounded-lg text-slate-500"
@click="handleCopyContent"
:disabled="!content"
>
<template #icon><copy theme="outline" size="14" /></template>
复制正文
</a-button>
</header>
<div class="flex-1 overflow-y-auto custom-scroll bg-[#FAFAFB] py-12">
<article
v-if="content"
class="w-full max-w-3xl mx-auto bg-white rounded-2xl border border-slate-100 p-12 relative shadow-sm"
>
<div class="flex flex-col justify-between items-start mb-5 border-b border-slate-50">
<h1 class="text-2xl font-black text-slate-800 leading-tight flex-1">
{{ content.title }}
</h1>
<div class="flex flex-wrap gap-1.5">
<span
v-for="tag in content.keywords"
:key="tag"
class="px-2 py-0.5 bg-purple-50 text-[#7816ff] text-[10px] font-bold rounded border border-purple-100/50"
>
# {{ tag }}
</span>
</div>
</div>
<div class="text-slate-600 text-[15px] leading-loose whitespace-pre-wrap min-h-[300px]">
{{ content.content }}
</div>
<footer class="mt-16 bg-slate-50 rounded-xl p-6 relative">
<quote class="absolute top-4 right-4 text-slate-200" size="24" theme="filled" />
<div class="text-[15px] font-black uppercase tracking-widest mb-3">文章摘要</div>
<p class="text-xs text-slate-500 leading-relaxed italic">{{ content.summary }}</p>
</footer>
</article>
</div>
</div>
</template>
<style scoped>
/* 沉浸式阅读字体微调 */
.prose p {
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
}
/* 隐藏滚动条但保留功能 */
.custom-scroll::-webkit-scrollbar {
width: 5px;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #f1f5f9;
border-radius: 10px;
}
.custom-scroll::-webkit-scrollbar-track {
background: transparent;
}
/* 打印样式优化 */
@media print {
header,
.custom-scroll::-webkit-scrollbar {
display: none;
}
.bg-[#FAFAFB] {
background: white;
}
article {
border: none;
box-shadow: none;
}
}
</style>

View File

@@ -54,13 +54,19 @@ const handleDeleteTask = () => {
})
}
// 预览功能
/**
* 预览功能
* @param subTaskId 子任务ID
* */
const handlePreview = (subTaskId: string) => {
go('/task/detail', {
go('/reflection', {
id: subTaskId,
batchId: activeTaskId.value
})
}
/**
* 监听任务进度
* */
const statusSub = trpc.task.onReadingReflectionStatusUpdate.subscribe(undefined, {
onData(data) {
const index = bookSubTasks.value.findIndex((item) => item.id === data.taskId)
@@ -92,6 +98,9 @@ const statusSub = trpc.task.onReadingReflectionStatusUpdate.subscribe(undefined,
console.error('订阅流异常:', err)
}
})
/**
* 监听任务进度
* */
const batchSub = trpc.task.onBatchProgressUpdate.subscribe(undefined, {
onData(data) {
if (data.batchId === activeTaskId.value) {
@@ -101,6 +110,9 @@ const batchSub = trpc.task.onBatchProgressUpdate.subscribe(undefined, {
}
})
/**
* 获取当亲任务的详情
* */
const fetchCurrentTaskDetail = async () => {
if (!activeTaskId.value) return
const data = await trpc.task.getBatchDetail.query({ batchId: activeTaskId.value })

View File

@@ -16,10 +16,16 @@ const routes: RouteRecordRaw[] = [
component: () => import('@renderer/pages/task/create.vue')
},
{
path: '/task/detail',
name: 'TaskDetail',
component: () => import('@renderer/pages/task/detail.vue')
path: '/reflection',
name: 'Reflection',
component: () => import('@renderer/pages/reflection/index.vue')
},
{
path: '/reflection/poster',
name: 'ReflectionPoster',
component: () => import('@renderer/pages/reflection/views/poster/index.vue')
},
{
path: '/setting',
name: 'Setting',

View File

@@ -96,6 +96,18 @@ export const taskRouter = router({
return items as IReadingReflectionTaskItem[]
}),
/**
* 获取子任务详情
* */
getItemDetail: publicProcedure
.input(z.object({ itemId: z.string() }))
.query(async ({ input }) => {
const repo = AppDataSource.getRepository(ReadingReflectionTaskItem)
const item = await repo.findOne({
where: { id: input.itemId }
})
return item as IReadingReflectionTaskItem
}),
/**
* 获取批次详情及其关联的子任务
* 适用于点击左侧列表后,一次性初始化右侧所有内容