feat(desktop): ✨ 实现海报导出功能
This commit is contained in:
@@ -41,6 +41,7 @@
|
|||||||
"electron-store": "^6.0.1",
|
"electron-store": "^6.0.1",
|
||||||
"electron-trpc": "^0.7.1",
|
"electron-trpc": "^0.7.1",
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"langchain": "^1.2.4",
|
"langchain": "^1.2.4",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"p-limit": "^2.2.0",
|
"p-limit": "^2.2.0",
|
||||||
|
|||||||
39
pnpm-lock.yaml
generated
39
pnpm-lock.yaml
generated
@@ -47,6 +47,9 @@ importers:
|
|||||||
electron-updater:
|
electron-updater:
|
||||||
specifier: ^6.3.9
|
specifier: ^6.3.9
|
||||||
version: 6.6.2
|
version: 6.6.2
|
||||||
|
html2canvas:
|
||||||
|
specifier: ^1.4.1
|
||||||
|
version: 1.4.1
|
||||||
langchain:
|
langchain:
|
||||||
specifier: ^1.2.4
|
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))
|
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:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
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:
|
base64-js@1.5.1:
|
||||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||||
|
|
||||||
@@ -1764,6 +1771,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
css-line-break@2.1.0:
|
||||||
|
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
|
||||||
|
|
||||||
css-tree@3.1.0:
|
css-tree@3.1.0:
|
||||||
resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==}
|
resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==}
|
||||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
||||||
@@ -2418,6 +2428,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
|
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
html2canvas@1.4.1:
|
||||||
|
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
||||||
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
http-cache-semantics@4.2.0:
|
http-cache-semantics@4.2.0:
|
||||||
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
||||||
|
|
||||||
@@ -3616,6 +3630,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==}
|
resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
text-segmentation@1.0.3:
|
||||||
|
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
|
||||||
|
|
||||||
through@2.3.8:
|
through@2.3.8:
|
||||||
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
|
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
|
||||||
|
|
||||||
@@ -3854,6 +3871,9 @@ packages:
|
|||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
|
utrie@1.0.2:
|
||||||
|
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
|
||||||
|
|
||||||
uuid@10.0.0:
|
uuid@10.0.0:
|
||||||
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -5417,6 +5437,8 @@ snapshots:
|
|||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
|
base64-arraybuffer@1.0.2: {}
|
||||||
|
|
||||||
base64-js@1.5.1: {}
|
base64-js@1.5.1: {}
|
||||||
|
|
||||||
baseline-browser-mapping@2.9.11: {}
|
baseline-browser-mapping@2.9.11: {}
|
||||||
@@ -5784,6 +5806,10 @@ snapshots:
|
|||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
|
css-line-break@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
utrie: 1.0.2
|
||||||
|
|
||||||
css-tree@3.1.0:
|
css-tree@3.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
mdn-data: 2.12.2
|
mdn-data: 2.12.2
|
||||||
@@ -6599,6 +6625,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
lru-cache: 6.0.0
|
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-cache-semantics@4.2.0: {}
|
||||||
|
|
||||||
http-proxy-agent@5.0.0:
|
http-proxy-agent@5.0.0:
|
||||||
@@ -7765,6 +7796,10 @@ snapshots:
|
|||||||
|
|
||||||
text-extensions@2.4.0: {}
|
text-extensions@2.4.0: {}
|
||||||
|
|
||||||
|
text-segmentation@1.0.3:
|
||||||
|
dependencies:
|
||||||
|
utrie: 1.0.2
|
||||||
|
|
||||||
through@2.3.8: {}
|
through@2.3.8: {}
|
||||||
|
|
||||||
tiny-async-pool@1.3.0:
|
tiny-async-pool@1.3.0:
|
||||||
@@ -7995,6 +8030,10 @@ snapshots:
|
|||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
|
utrie@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
base64-arraybuffer: 1.0.2
|
||||||
|
|
||||||
uuid@10.0.0: {}
|
uuid@10.0.0: {}
|
||||||
|
|
||||||
uuid@11.1.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']
|
AInput: typeof import('@arco-design/web-vue')['Input']
|
||||||
AInputNumber: typeof import('@arco-design/web-vue')['InputNumber']
|
AInputNumber: typeof import('@arco-design/web-vue')['InputNumber']
|
||||||
AInputPassword: typeof import('@arco-design/web-vue')['InputPassword']
|
AInputPassword: typeof import('@arco-design/web-vue')['InputPassword']
|
||||||
|
AModal: typeof import('@arco-design/web-vue')['Modal']
|
||||||
AOption: typeof import('@arco-design/web-vue')['Option']
|
AOption: typeof import('@arco-design/web-vue')['Option']
|
||||||
ASelect: typeof import('@arco-design/web-vue')['Select']
|
ASelect: typeof import('@arco-design/web-vue')['Select']
|
||||||
ASlider: typeof import('@arco-design/web-vue')['Slider']
|
ASlider: typeof import('@arco-design/web-vue')['Slider']
|
||||||
@@ -29,6 +30,7 @@ declare module 'vue' {
|
|||||||
ATabs: typeof import('@arco-design/web-vue')['Tabs']
|
ATabs: typeof import('@arco-design/web-vue')['Tabs']
|
||||||
ATag: typeof import('@arco-design/web-vue')['Tag']
|
ATag: typeof import('@arco-design/web-vue')['Tag']
|
||||||
ATextarea: typeof import('@arco-design/web-vue')['Textarea']
|
ATextarea: typeof import('@arco-design/web-vue')['Textarea']
|
||||||
|
ATooltip: typeof import('@arco-design/web-vue')['Tooltip']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
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) => {
|
const handlePreview = (subTaskId: string) => {
|
||||||
go('/task/detail', {
|
go('/reflection', {
|
||||||
id: subTaskId,
|
id: subTaskId,
|
||||||
batchId: activeTaskId.value
|
batchId: activeTaskId.value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 监听任务进度
|
||||||
|
* */
|
||||||
const statusSub = trpc.task.onReadingReflectionStatusUpdate.subscribe(undefined, {
|
const statusSub = trpc.task.onReadingReflectionStatusUpdate.subscribe(undefined, {
|
||||||
onData(data) {
|
onData(data) {
|
||||||
const index = bookSubTasks.value.findIndex((item) => item.id === data.taskId)
|
const index = bookSubTasks.value.findIndex((item) => item.id === data.taskId)
|
||||||
@@ -92,6 +98,9 @@ const statusSub = trpc.task.onReadingReflectionStatusUpdate.subscribe(undefined,
|
|||||||
console.error('订阅流异常:', err)
|
console.error('订阅流异常:', err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
/**
|
||||||
|
* 监听任务进度
|
||||||
|
* */
|
||||||
const batchSub = trpc.task.onBatchProgressUpdate.subscribe(undefined, {
|
const batchSub = trpc.task.onBatchProgressUpdate.subscribe(undefined, {
|
||||||
onData(data) {
|
onData(data) {
|
||||||
if (data.batchId === activeTaskId.value) {
|
if (data.batchId === activeTaskId.value) {
|
||||||
@@ -101,6 +110,9 @@ const batchSub = trpc.task.onBatchProgressUpdate.subscribe(undefined, {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当亲任务的详情
|
||||||
|
* */
|
||||||
const fetchCurrentTaskDetail = async () => {
|
const fetchCurrentTaskDetail = async () => {
|
||||||
if (!activeTaskId.value) return
|
if (!activeTaskId.value) return
|
||||||
const data = await trpc.task.getBatchDetail.query({ batchId: activeTaskId.value })
|
const data = await trpc.task.getBatchDetail.query({ batchId: activeTaskId.value })
|
||||||
|
|||||||
@@ -16,10 +16,16 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@renderer/pages/task/create.vue')
|
component: () => import('@renderer/pages/task/create.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/task/detail',
|
path: '/reflection',
|
||||||
name: 'TaskDetail',
|
name: 'Reflection',
|
||||||
component: () => import('@renderer/pages/task/detail.vue')
|
component: () => import('@renderer/pages/reflection/index.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/reflection/poster',
|
||||||
|
name: 'ReflectionPoster',
|
||||||
|
component: () => import('@renderer/pages/reflection/views/poster/index.vue')
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/setting',
|
path: '/setting',
|
||||||
name: 'Setting',
|
name: 'Setting',
|
||||||
|
|||||||
@@ -96,6 +96,18 @@ export const taskRouter = router({
|
|||||||
return items as IReadingReflectionTaskItem[]
|
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