feat(desktop): 实现一些功能

1. 实现了用户阅读画像

2. 实现了全局检索功能
This commit is contained in:
2026-01-11 14:40:31 +08:00
parent 75cc9dc06d
commit 48fb287aa7
25 changed files with 1059 additions and 145 deletions

View File

@@ -0,0 +1,19 @@
import { ref } from 'vue';
/**
* 加载状态Hook
*/
export default function useLoading(initValue = false) {
const loading = ref(initValue);
const setLoading = (value: boolean) => {
loading.value = value;
};
const toggle = () => {
loading.value = !loading.value;
};
return {
loading,
setLoading,
toggle,
};
}

View File

@@ -21,12 +21,12 @@ export const features = [
path: 'search'
},
{
id: 'statistics',
id: 'userPersona',
title: '阅读画像',
desc: '可视化你的知识边界与偏好',
icon: TrendTwo,
color: '#00b42a',
path: 'statistics'
path: 'userPersona'
}
]
},

View File

@@ -101,10 +101,6 @@ const { title } = toRefs(props)
transform: translateY(-10px);
}
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
@keyframes shine {
from {
transform: translateX(-100%);
@@ -130,4 +126,15 @@ const { title } = toRefs(props)
.animate-in {
animation: fadeIn 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed } from 'vue'
import { getHighlightedSegments } from '../utils/highlighter'
const props = defineProps<{
text: string
highlights?: { start: number; length: number }[]
}>()
const segments = computed(() => getHighlightedSegments(props.text, props.highlights || []))
</script>
<template>
<span>
<template v-for="(seg, index) in segments" :key="index">
<mark v-if="seg.isHighlight" class="highlight-item">{{ seg.text }}</mark>
<span v-else>{{ seg.text }}</span>
</template>
</span>
</template>
<style scoped>
.highlight-item {
background-color: #f5f3ff; /* 浅紫色背景 */
color: #7816ff; /* 你的品牌紫色 */
font-weight: 700;
border-radius: 2px;
padding: 0 1px;
}
</style>

View File

@@ -0,0 +1,191 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { Left, Search, Close, DocumentFolder, BookmarkOne } from '@icon-park/vue-next'
import useRouterHook from '@renderer/hooks/useRouterHook'
import { trpc } from '@renderer/lib/trpc'
import { ISearch } from '@shared/types/ISearch' // 假设你已定义了搜索结果接口
import HighlightText from './components/HighlightText.vue'
import BackPage from '@renderer/components/BackPage.vue'
const { goBack, go } = useRouterHook()
const searchQuery = ref('')
const searchResults = ref<ISearch[]>([])
const isLoading = ref(false)
// 实时搜索逻辑
let searchTimeout: NodeJS.Timeout | null = null
watch(searchQuery, (newVal) => {
if (searchTimeout) clearTimeout(searchTimeout)
if (newVal.trim() === '') {
searchResults.value = []
return
}
searchTimeout = setTimeout(() => {
performSearch(newVal)
}, 300) // 300ms 防抖
})
const performSearch = async (query: string) => {
isLoading.value = true
try {
// 假设 tRPC 提供了 search 接口
searchResults.value = await trpc.search.search.query(query)
} catch (error) {
console.error('搜索失败:', error)
searchResults.value = []
} finally {
isLoading.value = false
}
}
const clearSearch = () => {
searchQuery.value = ''
searchResults.value = []
}
const goToDetail = (result: ISearch) => {
// 假设心得详情页路由是 /reflection/:id
go('/reflection', {
id: result.id
})
}
// 模拟快捷键监听(实际需要 Electron 主进程处理)
onMounted(() => {})
</script>
<template>
<div class="h-full w-full flex flex-col bg-[#F8F9FB] overflow-hidden animate-in">
<header
class="h-16 px-8 flex items-center justify-between relative z-20 backdrop-blur-md bg-white/70 border-b border-slate-100"
>
<BackPage title="全局检索" />
</header>
<main class="flex-1 flex flex-col items-center p-8 pt-12 relative overflow-hidden">
<div
class="w-full max-w-2xl relative flex items-center bg-white rounded-2xl shadow-lg border border-slate-100 focus-within:border-[#7816ff] transition-all duration-200"
>
<search size="20" class="absolute left-5 text-slate-400" />
<input
id="search-input"
v-model="searchQuery"
type="text"
placeholder="搜索书名、心得、关键词..."
class="flex-1 px-14 py-4 text-sm font-medium text-slate-700 bg-transparent outline-none placeholder:text-slate-400"
@keydown.esc="clearSearch"
/>
<transition name="fade">
<button
v-if="searchQuery"
@click="clearSearch"
class="absolute right-5 w-6 h-6 rounded-full bg-slate-100 text-slate-500 flex items-center justify-center hover:bg-slate-200 transition-colors"
>
<close size="12" />
</button>
</transition>
</div>
<div
class="w-full max-w-2xl mt-8 flex flex-col gap-4 overflow-y-auto custom-scroll transition-all duration-300"
:class="{ 'opacity-50': isLoading }"
>
<div v-if="isLoading" class="flex justify-center items-center py-8 text-slate-400 text-sm">
<search size="20" class="animate-pulse mr-2" />
正在搜索...
</div>
<div
v-else-if="searchQuery && searchResults.length === 0"
class="flex flex-col items-center py-8 text-slate-400 text-sm gap-2"
>
<document-folder size="32" />
<p>没有找到相关的心得</p>
</div>
<div
v-else-if="!searchQuery"
class="flex flex-col items-center py-8 text-slate-400 text-sm gap-2"
>
<bookmark-one size="32" />
<p>请输入关键词开始搜索</p>
</div>
<div
v-for="result in searchResults"
:key="result.id"
@click="goToDetail(result)"
class="search-result-card group cursor-pointer"
>
<div class="flex items-start gap-4">
<div
class="w-10 h-10 rounded-lg flex-shrink-0 bg-purple-50 flex items-center justify-center"
>
<bookmark-one size="20" fill="#7816ff" />
</div>
<div class="flex-1 overflow-hidden">
<h3 class="text-sm font-bold text-slate-800 group-hover:text-[#7816ff]">
<HighlightText :text="result.title" :highlights="result.titleHighlights" />
</h3>
<p class="text-[11px] text-slate-500 mt-1 leading-normal line-clamp-2">
<HighlightText
:text="result.contentSnippet || result.content"
:highlights="result.contentHighlights"
/>
</p>
<p class="text-[10px] text-slate-400 mt-2">
<span class="font-semibold">{{ result.bookName }}</span> ·
<span class="ml-1">{{ new Date(result.createdAt).toLocaleDateString() }}</span>
</p>
</div>
</div>
</div>
</div>
</main>
</div>
</template>
<style scoped>
.search-result-card {
@apply bg-white p-4 rounded-xl shadow-sm border border-slate-100 cursor-pointer
transition-all duration-200 hover:shadow-md hover:border-[#7816ff]/30 active:scale-[0.99];
}
.custom-scroll::-webkit-scrollbar {
width: 4px;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 10px;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-in {
animation: fadeIn 0.4s ease-out forwards;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.highlight-item {
background-color: #f5f3ff; /* 浅紫色背景 */
color: #7816ff; /* 你的品牌紫色 */
font-weight: 700;
border-radius: 2px;
padding: 0 1px;
}
</style>

View File

@@ -0,0 +1,51 @@
// 定义高亮片段的结构
export interface HighlightSegment {
text: string
isHighlight: boolean
}
/**
* 将原始文本根据高亮索引转化为分段数组
*/
export function getHighlightedSegments(
text: string,
highlights: { start: number; length: number }[]
): HighlightSegment[] {
if (!highlights || highlights.length === 0) {
return [{ text, isHighlight: false }]
}
// 1. 按照起始位置排序,防止索引乱序导致切片失败
const sortedHighlights = [...highlights].sort((a, b) => a.start - b.start)
const segments: HighlightSegment[] = []
let lastIndex = 0
for (const { start, length } of sortedHighlights) {
// 处理匹配项之前的普通文本
if (start > lastIndex) {
segments.push({
text: text.substring(lastIndex, start),
isHighlight: false
})
}
// 处理匹配到的高亮文本
segments.push({
text: text.substring(start, start + length),
isHighlight: true
})
lastIndex = start + length
}
// 处理剩余的尾部文本
if (lastIndex < text.length) {
segments.push({
text: text.substring(lastIndex),
isHighlight: false
})
}
return segments
}

View File

@@ -1,92 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import * as echarts from 'echarts'
import { ChartGraph, TrendTwo, Local } from '@icon-park/vue-next'
import BackPage from '@renderer/components/BackPage.vue'
const chartRef = ref<HTMLElement | null>(null)
const initChart = () => {
if (!chartRef.value) return
const myChart = echarts.init(chartRef.value)
const option = {
radar: {
// 这里的指示器可以根据用户本地数据的标签动态生成
indicator: [
{ name: '认知深度', max: 100 },
{ name: '职场技能', max: 100 },
{ name: '人文素养', max: 100 },
{ name: '技术探索', max: 100 },
{ name: '生活哲学', max: 100 }
],
splitArea: {
areaStyle: {
color: ['#F8F9FB', '#fff'],
shadowColor: 'rgba(0, 0, 0, 0.05)',
shadowBlur: 10
}
},
axisLine: { lineStyle: { color: '#E5E7EB' } },
splitLine: { lineStyle: { color: '#E5E7EB' } }
},
series: [
{
type: 'radar',
data: [
{
value: [85, 72, 90, 65, 80], // 这里接入本地统计的真实数据
name: '阅读画像',
itemStyle: { color: '#7816ff' },
areaStyle: { color: 'rgba(120, 22, 255, 0.1)' },
lineStyle: { width: 3 }
}
]
}
]
}
myChart.setOption(option)
}
onMounted(() => initChart())
</script>
<template>
<div class="h-full w-full flex flex-col gap-xl relative overflow-hidden">
<header
class="h-16 border-b border-slate-100/50 px-6 flex items-center justify-between relative z-20 backdrop-blur-md bg-white/70"
>
<BackPage title="返回应用菜单" />
</header>
<main class="flex-1 bg-white overflow-y-auto relative z-10 p-4 bg-white rounded-xl">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-purple-50 flex items-center justify-center">
<chart-graph theme="outline" size="22" fill="#7816ff" />
</div>
<div class="flex flex-col">
<span class="text-sm font-bold text-slate-800">个人阅读画像</span>
<p class="text-[10px] text-slate-400 uppercase tracking-widest">你的阅读指导专家</p>
</div>
</div>
</div>
<div ref="chartRef" class="w-full h-[320px]"></div>
<div class="p-4 bg-[#7816ff]/5 rounded-2xl border border-[#7816ff]/10">
<div class="flex items-start gap-3 flex-col">
<div class="flex flex-row gap-sm">
<trend-two theme="outline" size="16" fill="#7816ff" />
<span class="text-sm font-bold text-[#7816ff]">你的阅读分享报告</span>
</div>
<span class="text-[11px] text-[#7816ff] leading-relaxed font-medium">
基于你最近生成的 12
篇心得分析你在<strong>人文素养</strong>领域积累深厚近期对<strong>认知科学</strong>的关注度上升了
24%
</span>
</div>
</div>
</main>
</div>
</template>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import { onMounted, ref, nextTick } from 'vue'
import * as echarts from 'echarts'
import { ChartGraph, TrendTwo, Refresh } from '@icon-park/vue-next'
import BackPage from '@renderer/components/BackPage.vue'
import { trpc } from '@renderer/lib/trpc'
import { IUserReadingPersona } from '@shared/types/IUserReadingPersona'
import useLoading from '@renderer/hooks/useLoading'
const chartRef = ref<HTMLElement | null>(null)
const personaData = ref<IUserReadingPersona | null>(null)
const { loading, setLoading } = useLoading()
let myChart: echarts.ECharts | null = null
// 核心方法:初始化或更新图表
const renderChart = (data: IUserReadingPersona) => {
if (!chartRef.value) return
if (!myChart) myChart = echarts.init(chartRef.value)
const option = {
radar: {
indicator: [
{ name: '认知深度', max: 100 },
{ name: '产出效率', max: 100 }, // 对应 efficiencyScore
{ name: '成熟度', max: 100 }, // 对应 maturityScore
{ name: '知识广度', max: 100 }, // 对应 breadthScore
{ name: '语言能力', max: 100 } // 对应 languageScore
],
shape: 'circle',
splitArea: {
areaStyle: {
color: ['#F8F9FB', '#fff'],
shadowColor: 'rgba(0, 0, 0, 0.05)',
shadowBlur: 10
}
},
axisLine: { lineStyle: { color: '#E5E7EB' } },
splitLine: { lineStyle: { color: '#E5E7EB' } }
},
series: [
{
type: 'radar',
data: [
{
value: [
// 按照 indicator 的顺序填入分值
data.domainDepth[0]?.score || 0,
data.efficiencyScore,
data.maturityScore,
data.breadthScore,
data.languageScore
],
name: '阅读画像',
itemStyle: { color: '#7816ff' },
areaStyle: { color: 'rgba(120, 22, 255, 0.15)' },
lineStyle: { width: 3 }
}
]
}
]
}
myChart.setOption(option)
}
// 获取数据
const fetchData = async (force = false) => {
setLoading(true)
try {
if (force) {
await trpc.persona.forceRefreshUserPersona.mutate()
}
const res = await trpc.persona.getUserPersona.query()
if (res) {
personaData.value = res
await nextTick()
renderChart(res)
}
} catch (error) {
console.error('获取画像失败:', error)
} finally {
setLoading(false)
}
}
onMounted(() => {
fetchData()
// 窗口缩放自适应
window.addEventListener('resize', () => myChart?.resize())
})
</script>
<template>
<div class="h-full w-full flex flex-col gap-xl relative overflow-hidden bg-[#F8F9FB]">
<header
class="h-16 border-b border-slate-100/50 px-6 flex items-center justify-between relative z-20 backdrop-blur-md bg-white/70"
>
<BackPage title="返回应用菜单" />
<a-button
@click="fetchData(true)"
:disabled="loading"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold transition-all active:scale-95"
:class="loading ? 'text-slate-300' : 'text-[#7816ff] hover:bg-purple-50'"
>
<refresh :class="{ 'animate-spin': loading }" />
{{ loading ? '同步中...' : '同步最新画像' }}
</a-button>
</header>
<main class="flex-1 overflow-y-auto relative z-10">
<div class="max-w-4xl mx-auto space-y-6">
<div class="bg-white p-8 rounded-xl shadow-sm border border-slate-100">
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-2xl bg-purple-50 flex items-center justify-center">
<chart-graph theme="outline" size="26" fill="#7816ff" />
</div>
<div class="flex flex-col">
<span class="text-lg font-black text-slate-800 tracking-tight">阅读画像</span>
<p class="text-[10px] text-slate-400 uppercase tracking-widest font-bold">
让每一篇阅读者享受阅读与思考的乐趣
</p>
</div>
</div>
</div>
<!--用户画像展示-->
<div v-show="personaData" ref="chartRef" class="w-full h-[300px]"></div>
<!--暂无数据提示-->
<div
v-if="!personaData && !isLoading"
class="h-[300px] flex flex-col items-center justify-center text-slate-400 gap-4"
>
<div class="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center">
<chart-graph size="32" />
</div>
<p class="text-sm font-medium">暂无画像数据请点击上方同步</p>
</div>
</div>
<div
v-if="personaData"
class="p-6 bg-[#7816ff] rounded-[32px] text-white shadow-xl shadow-purple-200/50 relative overflow-hidden"
>
<div class="absolute -right-10 -top-10 w-40 h-40 bg-white/10 rounded-full blur-3xl"></div>
<div class="relative z-10 flex flex-col">
<div class="flex flex-row items-center gap-2">
<trend-two theme="outline" size="20" fill="#fff" />
<span class="text-md font-black">智能阅读报告</span>
</div>
<p class="text-[13px] leading-relaxed opacity-90 font-medium">
基于你累计生成的
<span class="text-yellow-300 font-bold">{{ personaData.stats.totalWords }}</span>
字心得分析 你在
<span class="underline underline-offset-4 decoration-yellow-300">{{
personaData.domainDepth[0]?.name || '未知领域'
}}</span>
领域表现出极高的探索欲
</p>
<div class="flex flex-wrap gap-2 mt-2">
<span
v-for="kw in personaData.stats.topKeywords"
:key="kw"
class="text-[10px] bg-white/20 px-3 py-1 rounded-full backdrop-blur-md"
>
# {{ kw }}
</span>
</div>
</div>
</div>
</div>
</main>
</div>
</template>
<style scoped>
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>

View File

@@ -26,9 +26,14 @@ const routes: RouteRecordRaw[] = [
component: () => import('@renderer/pages/reflection/views/poster/index.vue')
},
{
path: '/menus/statistics',
name: 'ReadingStatistics',
component: () => import('@renderer/pages/menus/views/statistics/index.vue')
path: '/menus/userPersona',
name: 'ReadingUserPersona',
component: () => import('@renderer/pages/menus/views/userPersona/index.vue')
},
{
path: '/menus/search',
name: 'ReadingSearch',
component: () => import('@renderer/pages/menus/views/search/index.vue')
},
{
path: '/menus/:pathMatch(.*)*',