feat(desktop): ✨ 实现阅读墙展示阅读记录
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,3 +34,12 @@ export interface IUserReadingPersona {
|
||||
mostUsedOccupation: string // 最常使用的阅读者身份
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每日阅读贡献统计模型
|
||||
* */
|
||||
export interface IContributionDay {
|
||||
date: string
|
||||
count: number
|
||||
level: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user