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 { ReadingReflectionTaskBatch } from './ReadingReflectionTaskBatch'
|
||||||
import { IReadingReflectionTaskItem } from '@shared/types/IReadingReflectionTask'
|
import { IReadingReflectionTaskItem } from '@shared/types/IReadingReflectionTask'
|
||||||
|
|
||||||
@@ -24,6 +24,10 @@ export class ReadingReflectionTaskItem implements IReadingReflectionTaskItem {
|
|||||||
|
|
||||||
@Column({ type: 'simple-json', nullable: true })
|
@Column({ type: 'simple-json', nullable: true })
|
||||||
keywords?: string[]
|
keywords?: string[]
|
||||||
|
|
||||||
|
@CreateDateColumn() // 增加这一行,TypeORM 会自动处理时间
|
||||||
|
createdAt: Date
|
||||||
|
|
||||||
// 多对一关联
|
// 多对一关联
|
||||||
@ManyToOne(() => ReadingReflectionTaskBatch, (batch) => batch.items)
|
@ManyToOne(() => ReadingReflectionTaskBatch, (batch) => batch.items)
|
||||||
batch!: ReadingReflectionTaskBatch
|
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 { trpc } from '@renderer/lib/trpc'
|
||||||
import { IUserReadingPersona } from '@shared/types/IUserReadingPersona'
|
import { IUserReadingPersona } from '@shared/types/IUserReadingPersona'
|
||||||
import useLoading from '@renderer/hooks/useLoading'
|
import useLoading from '@renderer/hooks/useLoading'
|
||||||
|
import ContributionWall from './components/ContributionWall.vue'
|
||||||
|
|
||||||
const chartRef = ref<HTMLElement | null>(null)
|
const chartRef = ref<HTMLElement | null>(null)
|
||||||
const personaData = ref<IUserReadingPersona | null>(null)
|
const personaData = ref<IUserReadingPersona | null>(null)
|
||||||
const { loading, setLoading } = useLoading()
|
const { loading, setLoading } = useLoading()
|
||||||
|
const contributionData = ref<{ date: string; count: number }[]>([])
|
||||||
let myChart: echarts.ECharts | null = null
|
let myChart: echarts.ECharts | null = null
|
||||||
|
|
||||||
// 核心方法:初始化或更新图表
|
// 核心方法:初始化或更新图表
|
||||||
@@ -66,17 +68,25 @@ const renderChart = (data: IUserReadingPersona) => {
|
|||||||
const fetchData = async (force = false) => {
|
const fetchData = async (force = false) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
if (force) {
|
if (force) await trpc.persona.forceRefreshUserPersona.mutate()
|
||||||
await trpc.persona.forceRefreshUserPersona.mutate()
|
|
||||||
}
|
// 并行请求:提高响应速度
|
||||||
const res = await trpc.persona.getUserPersona.query()
|
const [persona, contributions] = await Promise.all([
|
||||||
if (res) {
|
trpc.persona.getUserPersona.query(),
|
||||||
personaData.value = res
|
trpc.persona.getContributionData.query()
|
||||||
|
])
|
||||||
|
|
||||||
|
if (persona) {
|
||||||
|
personaData.value = persona
|
||||||
await nextTick()
|
await nextTick()
|
||||||
renderChart(res)
|
renderChart(persona)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contributions) {
|
||||||
|
contributionData.value = contributions
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取画像失败:', error)
|
console.error('数据同步失败:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -125,11 +135,13 @@ onMounted(() => {
|
|||||||
</div>
|
</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>
|
<div v-show="personaData" ref="chartRef" class="w-full h-[300px]"></div>
|
||||||
|
<ContributionWall v-if="contributionData.length > 0" :data="contributionData" />
|
||||||
|
|
||||||
<!--暂无数据提示-->
|
<!--暂无数据提示-->
|
||||||
<div
|
<div
|
||||||
v-if="!personaData && !isLoading"
|
v-if="!personaData && !loading"
|
||||||
class="h-[300px] flex flex-col items-center justify-center text-slate-400 gap-4"
|
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">
|
<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 { entityToUserReadingPersona, PersonaService } from '@main/services/persona.service'
|
||||||
import { ReadingReflectionTaskBatch } from '@main/db/entities/ReadingReflectionTaskBatch'
|
import { ReadingReflectionTaskBatch } from '@main/db/entities/ReadingReflectionTaskBatch'
|
||||||
import { ReadingReflectionTaskItem } from '@main/db/entities/ReadingReflectionTaskItem'
|
import { ReadingReflectionTaskItem } from '@main/db/entities/ReadingReflectionTaskItem'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
export const personaRouter = router({
|
export const personaRouter = router({
|
||||||
// 获取用户画像:直接从数据库读取缓存结果,极快
|
/**
|
||||||
|
* 获取用户画像:直接从数据库读取缓存结果,极快
|
||||||
|
* */
|
||||||
getUserPersona: t.procedure.query(async ({ ctx }) => {
|
getUserPersona: t.procedure.query(async ({ ctx }) => {
|
||||||
const entity = await ctx.db.getRepository(ReadingPersona).findOneBy({
|
const entity = await ctx.db.getRepository(ReadingPersona).findOneBy({
|
||||||
id: 'current_user_persona'
|
id: 'current_user_persona'
|
||||||
@@ -17,7 +20,9 @@ export const personaRouter = router({
|
|||||||
return entityToUserReadingPersona(entity)
|
return entityToUserReadingPersona(entity)
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// 手动强制重新刷新画像
|
/**
|
||||||
|
* 刷新用户画像
|
||||||
|
* */
|
||||||
forceRefreshUserPersona: t.procedure.mutation(async ({ ctx }) => {
|
forceRefreshUserPersona: t.procedure.mutation(async ({ ctx }) => {
|
||||||
const items = await ctx.db
|
const items = await ctx.db
|
||||||
.getRepository(ReadingReflectionTaskItem)
|
.getRepository(ReadingReflectionTaskItem)
|
||||||
@@ -26,5 +31,41 @@ export const personaRouter = router({
|
|||||||
|
|
||||||
const personaService = new PersonaService(ctx.db.getRepository(ReadingPersona))
|
const personaService = new PersonaService(ctx.db.getRepository(ReadingPersona))
|
||||||
return await personaService.refreshPersona(items, batches)
|
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 // 最常使用的阅读者身份
|
mostUsedOccupation: string // 最常使用的阅读者身份
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每日阅读贡献统计模型
|
||||||
|
* */
|
||||||
|
export interface IContributionDay {
|
||||||
|
date: string
|
||||||
|
count: number
|
||||||
|
level: number
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user