feat(desktop): ✨ 实现海报导出功能
This commit is contained in:
@@ -41,6 +41,7 @@
|
||||
"electron-store": "^6.0.1",
|
||||
"electron-trpc": "^0.7.1",
|
||||
"electron-updater": "^6.3.9",
|
||||
"html2canvas": "^1.4.1",
|
||||
"langchain": "^1.2.4",
|
||||
"mitt": "^3.0.1",
|
||||
"p-limit": "^2.2.0",
|
||||
|
||||
39
pnpm-lock.yaml
generated
39
pnpm-lock.yaml
generated
@@ -47,6 +47,9 @@ importers:
|
||||
electron-updater:
|
||||
specifier: ^6.3.9
|
||||
version: 6.6.2
|
||||
html2canvas:
|
||||
specifier: ^1.4.1
|
||||
version: 1.4.1
|
||||
langchain:
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.4(@langchain/core@1.1.11(openai@6.15.0(zod@4.3.5)))(openai@6.15.0(zod@4.3.5))
|
||||
@@ -1457,6 +1460,10 @@ packages:
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
base64-arraybuffer@1.0.2:
|
||||
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
@@ -1764,6 +1771,9 @@ packages:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
css-line-break@2.1.0:
|
||||
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
|
||||
|
||||
css-tree@3.1.0:
|
||||
resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==}
|
||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
||||
@@ -2418,6 +2428,10 @@ packages:
|
||||
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
html2canvas@1.4.1:
|
||||
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
http-cache-semantics@4.2.0:
|
||||
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
||||
|
||||
@@ -3616,6 +3630,9 @@ packages:
|
||||
resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
text-segmentation@1.0.3:
|
||||
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
|
||||
|
||||
through@2.3.8:
|
||||
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
|
||||
|
||||
@@ -3854,6 +3871,9 @@ packages:
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
utrie@1.0.2:
|
||||
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
|
||||
|
||||
uuid@10.0.0:
|
||||
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
||||
hasBin: true
|
||||
@@ -5417,6 +5437,8 @@ snapshots:
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
base64-arraybuffer@1.0.2: {}
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
baseline-browser-mapping@2.9.11: {}
|
||||
@@ -5784,6 +5806,10 @@ snapshots:
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
css-line-break@2.1.0:
|
||||
dependencies:
|
||||
utrie: 1.0.2
|
||||
|
||||
css-tree@3.1.0:
|
||||
dependencies:
|
||||
mdn-data: 2.12.2
|
||||
@@ -6599,6 +6625,11 @@ snapshots:
|
||||
dependencies:
|
||||
lru-cache: 6.0.0
|
||||
|
||||
html2canvas@1.4.1:
|
||||
dependencies:
|
||||
css-line-break: 2.1.0
|
||||
text-segmentation: 1.0.3
|
||||
|
||||
http-cache-semantics@4.2.0: {}
|
||||
|
||||
http-proxy-agent@5.0.0:
|
||||
@@ -7765,6 +7796,10 @@ snapshots:
|
||||
|
||||
text-extensions@2.4.0: {}
|
||||
|
||||
text-segmentation@1.0.3:
|
||||
dependencies:
|
||||
utrie: 1.0.2
|
||||
|
||||
through@2.3.8: {}
|
||||
|
||||
tiny-async-pool@1.3.0:
|
||||
@@ -7995,6 +8030,10 @@ snapshots:
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
utrie@1.0.2:
|
||||
dependencies:
|
||||
base64-arraybuffer: 1.0.2
|
||||
|
||||
uuid@10.0.0: {}
|
||||
|
||||
uuid@11.1.0: {}
|
||||
|
||||
2
src/renderer/components.d.ts
vendored
2
src/renderer/components.d.ts
vendored
@@ -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']
|
||||
}
|
||||
|
||||
143
src/renderer/src/pages/reflection/index.vue
Normal file
143
src/renderer/src/pages/reflection/index.vue
Normal 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>
|
||||
247
src/renderer/src/pages/reflection/views/poster/index.vue
Normal file
247
src/renderer/src/pages/reflection/views/poster/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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 })
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
/**
|
||||
* 获取批次详情及其关联的子任务
|
||||
* 适用于点击左侧列表后,一次性初始化右侧所有内容
|
||||
|
||||
Reference in New Issue
Block a user