feat(desktop): 实现阅读墙展示阅读记录

This commit is contained in:
2026-01-11 15:13:33 +08:00
parent 48fb287aa7
commit 8fafd7d8ec
7 changed files with 285 additions and 13 deletions

BIN
db.sqlite

Binary file not shown.

View File

@@ -1,4 +1,4 @@
import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm'
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryColumn } from 'typeorm'
import { ReadingReflectionTaskBatch } from './ReadingReflectionTaskBatch'
import { IReadingReflectionTaskItem } from '@shared/types/IReadingReflectionTask'
@@ -24,6 +24,10 @@ export class ReadingReflectionTaskItem implements IReadingReflectionTaskItem {
@Column({ type: 'simple-json', nullable: true })
keywords?: string[]
@CreateDateColumn() // 增加这一行TypeORM 会自动处理时间
createdAt: Date
// 多对一关联
@ManyToOne(() => ReadingReflectionTaskBatch, (batch) => batch.items)
batch!: ReadingReflectionTaskBatch

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import dayjs from 'dayjs'
import { trpc } from '@renderer/lib/trpc'
import ReflectionDrawer from './ReflectionDrawer.vue'
import { IContributionDay } from '@shared/types/IUserReadingPersona'
const props = defineProps<{
data: { date: string; count: number }[]
}>()
const drawerVisible = ref(false)
const selectedDate = ref('')
const dailyReflections = ref<any[]>([])
const isDetailLoading = ref(false)
// 1. 核心计算逻辑:生成日期矩阵
const calendar = computed(() => {
const end = dayjs()
const start = end.subtract(1, 'year').startOf('week')
// 2. 关键修复:显式指定数组类型为 ICalendarDay[]
const days: IContributionDay[] = []
const dataMap = new Map<string, number>()
props.data.forEach((item) => {
if (item.date) {
dataMap.set(item.date.trim(), Number(item.count))
}
})
let current = start
while (current.isBefore(end) || current.isSame(end, 'day')) {
const dateStr = current.format('YYYY-MM-DD')
const count = dataMap.get(dateStr) || 0
// 3. 现在 push 操作是类型安全的
days.push({
date: dateStr,
count: count,
level: count === 0 ? 0 : count >= 4 ? 4 : Math.ceil(count)
})
current = current.add(1, 'day')
}
return days
})
const getLevelClass = (level: number) => {
const levels = [
'bg-slate-100', // 0
'bg-purple-200', // 1
'bg-purple-400', // 2
'bg-purple-600', // 3
'bg-[#7816ff]' // 4
]
return levels[level]
}
// 2. 点击小方块处理
const handleDayClick = async (day: { date: string; count: number }) => {
if (day.count === 0) return
selectedDate.value = day.date
drawerVisible.value = true
isDetailLoading.value = true
try {
dailyReflections.value = await trpc.persona.getReflectionsByDate.query({ date: day.date })
} catch (err) {
console.error('获取详情失败:', err)
} finally {
isDetailLoading.value = false
}
}
</script>
<template>
<div class="bg-white p-6 rounded-[24px] border border-slate-100 mt-6 shadow-sm">
<div class="flex items-center justify-between mb-4">
<div class="flex flex-col">
<h3 class="text-xs font-black text-slate-400 uppercase tracking-widest">阅读贡献墙</h3>
<p class="text-[10px] text-slate-400 font-medium">展示过去一年生成读书心得的频率</p>
</div>
<div class="flex items-center gap-1.5">
<span class="text-[10px] text-slate-400">Less</span>
<div
v-for="i in 5"
:key="i"
:class="getLevelClass(i - 1)"
class="w-2.5 h-2.5 rounded-[2px]"
></div>
<span class="text-[10px] text-slate-400">More</span>
</div>
</div>
<div class="overflow-x-auto custom-scroll pb-2">
<div class="grid grid-flow-col grid-rows-7 gap-1.5 w-max">
<div
v-for="day in calendar"
:key="day.date"
:title="`${day.date}: ${day.count} 篇心得`"
@click="handleDayClick(day)"
class="w-3.5 h-3.5 rounded-[2px] transition-all duration-200 hover:scale-125 hover:z-10"
:class="[
getLevelClass(day.level),
day.count > 0 ? 'cursor-pointer shadow-sm shadow-purple-200' : 'cursor-default'
]"
></div>
</div>
</div>
<Teleport to="body">
<ReflectionDrawer
:visible="drawerVisible"
:date="selectedDate"
:list="dailyReflections"
:loading="isDetailLoading"
@close="drawerVisible = false"
/>
</Teleport>
</div>
</template>
<style scoped>
.custom-scroll::-webkit-scrollbar {
height: 4px;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #f1f5f9;
border-radius: 10px;
}
</style>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import { Close, BookmarkOne } from '@icon-park/vue-next'
defineProps<{
visible: boolean
date: string
list: any[]
loading: boolean
}>()
const emit = defineEmits(['close', 'select'])
</script>
<template>
<div v-if="visible" class="fixed inset-0 z-[100] flex justify-end">
<div class="absolute inset-0 bg-slate-900/20 backdrop-blur-sm" @click="emit('close')"></div>
<div class="relative w-80 h-full bg-white shadow-2xl flex flex-col animate-slide-in">
<header class="p-6 border-b border-slate-50 flex items-center justify-between">
<div>
<h3 class="text-sm font-black text-slate-800">{{ date }}</h3>
<p class="text-[10px] text-slate-400 uppercase font-bold">当日阅读沉淀</p>
</div>
<a-button
@click="emit('close')"
class="p-2 hover:bg-slate-50 rounded-full transition-colors"
>
<close size="16" class="text-slate-400" />
</a-button>
</header>
<div class="flex-1 overflow-y-auto p-4 custom-scroll">
<div v-if="loading" class="flex justify-center py-10 text-slate-300">
<div class="animate-spin mr-2 opacity-50"><bookmark-one /></div>
<span class="text-xs">检索记忆中...</span>
</div>
<div v-else-if="list.length === 0" class="flex flex-col items-center py-20 text-slate-300">
<bookmark-one size="32" class="opacity-20 mb-2" />
<p class="text-xs">那天没有生成心得哦</p>
</div>
<div v-else class="space-y-3">
<div
v-for="item in list"
:key="item.id"
@click="emit('select', item.id)"
class="group p-4 rounded-xl border border-slate-50 bg-slate-50/30 hover:bg-white hover:border-purple-100 hover:shadow-md hover:shadow-purple-50 transition-all cursor-pointer"
>
<h4
class="text-xs font-bold text-slate-700 group-hover:text-[#7816ff] mb-1 line-clamp-1"
>
{{ item.title }}
</h4>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.animate-slide-in {
animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideIn {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
</style>

View File

@@ -6,10 +6,12 @@ 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'
import ContributionWall from './components/ContributionWall.vue'
const chartRef = ref<HTMLElement | null>(null)
const personaData = ref<IUserReadingPersona | null>(null)
const { loading, setLoading } = useLoading()
const contributionData = ref<{ date: string; count: number }[]>([])
let myChart: echarts.ECharts | null = null
// 核心方法:初始化或更新图表
@@ -66,17 +68,25 @@ const renderChart = (data: IUserReadingPersona) => {
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
if (force) await trpc.persona.forceRefreshUserPersona.mutate()
// 并行请求:提高响应速度
const [persona, contributions] = await Promise.all([
trpc.persona.getUserPersona.query(),
trpc.persona.getContributionData.query()
])
if (persona) {
personaData.value = persona
await nextTick()
renderChart(res)
renderChart(persona)
}
if (contributions) {
contributionData.value = contributions
}
} catch (error) {
console.error('获取画像失败:', error)
console.error('数据同步失败:', error)
} finally {
setLoading(false)
}
@@ -125,11 +135,13 @@ onMounted(() => {
</div>
<!--用户画像展示-->
<h3 class="text-xs font-black text-slate-400 uppercase tracking-widest">阅读用户画像</h3>
<div v-show="personaData" ref="chartRef" class="w-full h-[300px]"></div>
<ContributionWall v-if="contributionData.length > 0" :data="contributionData" />
<!--暂无数据提示-->
<div
v-if="!personaData && !isLoading"
v-if="!personaData && !loading"
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">

View File

@@ -3,9 +3,12 @@ import { router, t } from '@rpc/index'
import { entityToUserReadingPersona, PersonaService } from '@main/services/persona.service'
import { ReadingReflectionTaskBatch } from '@main/db/entities/ReadingReflectionTaskBatch'
import { ReadingReflectionTaskItem } from '@main/db/entities/ReadingReflectionTaskItem'
import { z } from 'zod'
export const personaRouter = router({
// 获取用户画像:直接从数据库读取缓存结果,极快
/**
* 获取用户画像:直接从数据库读取缓存结果,极快
* */
getUserPersona: t.procedure.query(async ({ ctx }) => {
const entity = await ctx.db.getRepository(ReadingPersona).findOneBy({
id: 'current_user_persona'
@@ -17,7 +20,9 @@ export const personaRouter = router({
return entityToUserReadingPersona(entity)
}),
// 手动强制重新刷新画像
/**
* 刷新用户画像
* */
forceRefreshUserPersona: t.procedure.mutation(async ({ ctx }) => {
const items = await ctx.db
.getRepository(ReadingReflectionTaskItem)
@@ -26,5 +31,41 @@ export const personaRouter = router({
const personaService = new PersonaService(ctx.db.getRepository(ReadingPersona))
return await personaService.refreshPersona(items, batches)
})
}),
/**
* 聚合统计:从数据库中聚合数据并计算画像分值
* */
getContributionData: t.procedure.query(async ({ ctx }) => {
const itemRepo = ctx.db.getRepository(ReadingReflectionTaskItem)
// 关键:关联 batch 获取时间,并强制格式化为 YYYY-MM-DD
const rawData = await itemRepo
.createQueryBuilder('item')
.leftJoin('reading_reflection_task_batches', 'batch', 'item.batchId = batch.id')
.select("strftime('%Y-%m-%d', batch.createdAt)", 'date')
.addSelect('COUNT(item.id)', 'count')
.where("item.status = 'COMPLETED'")
.groupBy('date')
.getRawMany()
return rawData as { date: string; count: number }[]
}),
/**
* 获取指定日期的心得
* */
getReflectionsByDate: t.procedure
.input(z.object({ date: z.string() }))
.query(async ({ ctx, input }) => {
const repo = ctx.db.getRepository(ReadingReflectionTaskItem)
// 关键修正:必须在 batch 表上进行日期过滤
return await repo
.createQueryBuilder('item')
// 这里的 'batchId' 必须是你数据库中 item 表关联 batch 的实际外键列名
.leftJoinAndSelect('reading_reflection_task_batches', 'batch', 'item.batchId = batch.id')
.where('date(batch.createdAt) = :date', { date: input.date })
.andWhere("item.status = 'COMPLETED'")
.getMany()
})
})

View File

@@ -34,3 +34,12 @@ export interface IUserReadingPersona {
mostUsedOccupation: string // 最常使用的阅读者身份
}
}
/**
* 每日阅读贡献统计模型
* */
export interface IContributionDay {
date: string
count: number
level: number
}