فهرست منبع

feat(归档): 新增归档详情页及相关功能

- 添加归档详情视图组件,支持按分类和标签查看文章列表
- 扩展模型接口以支持分类和标签文章列表查询
- 新增分类工具函数和图标映射
- 重构标签卡片组件使用主题匹配颜色
- 更新路由配置和API调用以支持归档功能
- 改进日期格式化函数,增加昨天显示
- 优化文章卡片组件,支持标签点击跳转
- 重构归档视图,使用网格布局展示分类
Sakulin 2 ماه پیش
والد
کامیت
74afa1c1fc

+ 7 - 3
src/components/PostCard.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import type { PostSketch } from '@/models'
+import type { PostSketch, Tag } from '@/models'
 import TagCloud from './TagCloud.vue'
 import { formateDateAccurateToDay } from '@/utils'
 import { router } from '@/router'
@@ -16,6 +16,10 @@ function handleRouteToDetail() {
     },
   })
 }
+
+function handleTagClick(tag: Tag) {
+  router.push(`/archive?tag=${tag.name}`)
+}
 </script>
 
 <template>
@@ -25,12 +29,12 @@ function handleRouteToDetail() {
     </h2>
     <div class="meta">
       <span class="date">{{ formateDateAccurateToDay(props.target.createdAt) }}</span>
-      <span class="cate">日志</span>
+      <span class="cate">{{ props.target.cate }}</span>
     </div>
     <p>
       {{ props.target.description }}
     </p>
-    <TagCloud :tags="props.target.tags" />
+    <TagCloud :tags="props.target.tags" :on-click="handleTagClick" />
   </div>
 </template>
 

+ 2 - 9
src/components/TagCard.vue

@@ -1,22 +1,15 @@
 <script setup lang="ts">
 
 import type { Tag } from '@/models'
-import { lightenHexColor } from '@/utils'
 import { computed } from 'vue'
+import { colorMatchTheme } from '@/utils'
 
 const props = defineProps<{
   target: Tag,
   onClick?: () => void
 }>();
 
-const color = computed(() => {
-  if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
-    return lightenHexColor(props.target.color);
-  }
-  return props.target.color;
-})
-
-
+const color = computed(() => colorMatchTheme(props.target.color))
 
 </script>
 

+ 14 - 1
src/models/index.ts

@@ -9,10 +9,18 @@ export interface AuthFailed {
 }
 
 export interface PostListSuccess {
-  code: number
+  code: 200
   data: PostSketch[]
 }
 
+export interface PostOfTagListSuccess{
+  code: 200,
+  data: {
+    posts: PostSketch[],
+    tag: Tag
+  }
+}
+
 export interface PostSketch {
   id: number
   title: string
@@ -44,6 +52,11 @@ export interface PostGetSuccess {
   data: PostDetail
 }
 
+export interface CatesGetSuccess {
+  code: 200,
+  data: string[]
+}
+
 export interface PostDetail {
   id: number
   title: string

+ 18 - 0
src/router/index.ts

@@ -58,6 +58,13 @@ export const modules: BlogModule[] = [
     routeUrl: "/editor",
     component: () => import('../views/PostEditView.vue'),
     showInMenu: false
+  },
+  {
+    routeName: "archive",
+    title: "归档详情",
+    routeUrl: "/archive",
+    component: () => import('../views/ArchiveDetailView.vue'),
+    showInMenu: false
   }
 ];
 
@@ -73,3 +80,14 @@ export const router = createRouter({
   history: createWebHistory(import.meta.env.BASE_URL),
   routes: modules.map(toRouteRecordRaw)
 });
+
+export function getQuery(q: string) {
+  let target = router.currentRoute.value.query[q]
+  if (Array.isArray(target) && target.length > 0) {
+    target = target[0]
+  }
+  if (typeof target == 'string') {
+    return target
+  }
+  return ''
+}

+ 2 - 0
src/utils/alert.ts

@@ -0,0 +1,2 @@
+
+

+ 10 - 1
src/utils/axios.ts

@@ -7,7 +7,7 @@ import type {
   TagList,
   PostBody,
   PushSuccess,
-  UpdateSuccess,
+  UpdateSuccess, CatesGetSuccess, PostOfTagListSuccess
 } from '@/models'
 import axios from 'axios'
 
@@ -58,6 +58,15 @@ export const api = {
   postGet(id: number) {
     return baseServer.get(`/post/get/${id}`).then(res => res.data) as Promise<PostGetSuccess | DefaultFailedResponse>
   },
+  postCates() {
+    return baseServer.get('/post/cates').then(res => res.data) as Promise<CatesGetSuccess>
+  },
+  postsOfCate(cate: string) {
+    return baseServer.get(`/post/ofCate/${cate}`).then(res => res.data) as Promise<PostListSuccess>
+  },
+  postsOfTag(tag: string) {
+    return baseServer.get(`/post/ofTag/${tag}`).then(res => res.data) as Promise<PostOfTagListSuccess>
+  },
   tagList() {
     return baseServer.get('/tag/list').then(res => res.data) as Promise<TagList>
   },

+ 25 - 0
src/utils/cates.ts

@@ -0,0 +1,25 @@
+import { codeIcon, diaryIcon, type Icon, othersIcon, writeIcon } from '@/assets/icons.ts'
+
+export interface CateOption {
+  label: string
+  value: string
+  svg: Icon
+}
+
+const iconMap: CateOption[] = [
+  { label: '技术', value: '技术', svg: codeIcon },
+  { label: '日志', value: '日志', svg: diaryIcon },
+  { label: '随笔', value: '随笔', svg: writeIcon },
+]
+
+export function optionOfCate(label: string): CateOption {
+  const target = iconMap.filter(e => e.label == label);
+  if (target.length > 0) {
+    return target[0];
+  }
+  return {
+    label,
+    value: label,
+    svg: othersIcon,
+  }
+}

+ 25 - 11
src/utils/index.ts

@@ -4,7 +4,7 @@
  * @param percent - 变亮的百分比,范围从 0 到 100,默认值为 20
  * @returns 变亮后的十六进制颜色字符串
  */
-export function lightenHexColor(hexColor: string, percent: number = 20): string {
+function lightenHexColor(hexColor: string, percent: number = 60): string {
   // 去除颜色字符串前面的 # 符号
   const color = hexColor.replace('#', '')
   // 将颜色字符串转换为 RGB 分量
@@ -26,20 +26,34 @@ export function lightenHexColor(hexColor: string, percent: number = 20): string
   return '#' + toHex(r) + toHex(g) + toHex(b)
 }
 
-export function formateDateAccurateToDay(date: Date | string | number): string {
+export function colorMatchTheme(hexColor: string) {
+  if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
+    return lightenHexColor(hexColor);
+  }
+  return hexColor;
+}
+
+export function formateDateAccurateToDay(date: Date | string | number, nick = true): string {
   const _date = new Date(date)
-  const now = new Date()
   const year = _date.getFullYear()
   const month = _date.getMonth() + 1
   const day = _date.getDate()
-  if (year == now.getFullYear()) {
-    if (month == now.getMonth() + 1) {
-      if (day == now.getDate()) {
-        return '今天'
+
+  if (nick) {
+    const now = new Date()
+    if (year == now.getFullYear()) {
+      if (month == now.getMonth() + 1) {
+        const nowDate = now.getDate()
+        if (day == nowDate) {
+          return '今天'
+        } else if (day == nowDate - 1) {
+          return '昨天'
+        }
       }
-      return '这个月'
+      return `${month} 月 ${day} 日`
+    } else {
+      return `${year} 年 ${month} 月 ${day} 日`
     }
-    return `${month} 月 ${day} 日`
   } else {
     return `${year} 年 ${month} 月 ${day} 日`
   }
@@ -47,6 +61,6 @@ export function formateDateAccurateToDay(date: Date | string | number): string {
 
 export async function delay(ms: number) {
   return new Promise((resolve) => {
-    setTimeout(resolve, ms);
-  });
+    setTimeout(resolve, ms)
+  })
 }

+ 206 - 0
src/views/ArchiveDetailView.vue

@@ -0,0 +1,206 @@
+<script setup lang="ts">
+import type { PostSketch, Tag } from '@/models'
+import { computed, onMounted, ref, watch } from 'vue'
+import { api } from '@/utils/axios'
+import { useTokenStore } from '@/stores/auth.ts'
+import { colorMatchTheme, delay, formateDateAccurateToDay } from '@/utils'
+import { writeIcon } from '@/assets/icons.ts'
+import { getQuery, router } from '@/router'
+import { optionOfCate } from '@/utils/cates.ts'
+import TagCard from '@/components/TagCard.vue'
+
+const data = ref<PostSketch[]>([])
+const targetTag = ref<Tag | null>(null)
+
+const tokenStore = useTokenStore()
+
+const archiveType = ref<'cate' | 'tag' | undefined>(undefined)
+
+const cate = ref('')
+let tag = ''
+
+onMounted(() => {
+  cate.value = getQuery('cate')
+  tag = getQuery('tag')
+  if ((!cate.value && !tag) || (cate.value && tag)) {
+    router.push('/archives')
+    return
+  }
+  if (cate.value) {
+    archiveType.value = 'cate'
+    api.postsOfCate(cate.value).then((res) => {
+      data.value = res.data
+    })
+  } else if (tag) {
+    archiveType.value = 'tag'
+    api.postsOfTag(tag).then((res) => {
+      data.value = res.data.posts
+      targetTag.value = res.data.tag
+    })
+  }
+})
+
+const tokenAvailable = ref(false)
+
+const floatBtnElementAnimationStyle = ref({
+  display: 'none',
+  transform: 'scale(0)',
+  transition: 'all .3s',
+})
+
+watch(
+  () => tokenStore.available,
+  async (newValue) => {
+    if (!tokenAvailable.value && newValue) {
+      tokenAvailable.value = true
+      floatBtnElementAnimationStyle.value.display = 'block'
+      floatBtnElementAnimationStyle.value.transform = 'scale(0)'
+      await delay(20)
+      floatBtnElementAnimationStyle.value.transform = 'scale(100%)'
+    } else if (tokenAvailable.value && !newValue) {
+      tokenAvailable.value = false
+      floatBtnElementAnimationStyle.value.transform = 'scale(0)'
+      await delay(320)
+      floatBtnElementAnimationStyle.value.display = 'none'
+    }
+  },
+  { immediate: true },
+)
+
+function handleWriteClick() {
+  if (archiveType.value == 'tag') router.push(`/editor?tag=${tag}`)
+  else if (archiveType.value == 'cate') router.push(`/editor?cate=${cate.value}`)
+}
+
+const cateOption = computed(() => optionOfCate(cate.value))
+
+const handlePostLineClick = (post: PostSketch) => {
+  router.push(`/detail?id=${post.id}`)
+}
+</script>
+
+<template>
+  <div style="position: relative">
+    <div class="float-btn" @click.stop="handleWriteClick" :style="floatBtnElementAnimationStyle">
+      <div class="float-btn-ico" v-html="writeIcon.template" />
+    </div>
+
+    <div class="post-item" style="display: flex; flex-direction: column; gap: 12px">
+      <div class="cate-title" v-if="archiveType == 'cate'">
+        <div class="cate-icon" v-html="cateOption.svg.template" />
+        <div class="cate-content">{{ cateOption.label }}</div>
+      </div>
+      <div class="tag-title" v-else-if="archiveType == 'tag' && targetTag">
+        <div class="tag-title-content">正在浏览话题包含</div>
+        <TagCard :target="targetTag" />
+        <div class="tag-title-content">的文章</div>
+      </div>
+      <div class="post-line" v-for="item of data" :key="item.id" @click="handlePostLineClick(item)">
+        <div class="post-line-title">{{ item.title }}</div>
+        <div class="post-line-date">{{ formateDateAccurateToDay(item.createdAt, false) }}</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.cate-title {
+  color: var(--text-color);
+  display: flex;
+  width: 100%;
+  align-items: center;
+  gap: 8px;
+  padding-bottom: 4px;
+  background: linear-gradient(to right, var(--secondary-text-color), var(--secondary-text-color)) no-repeat bottom left;
+  background-size: 100% 1px;
+}
+
+.cate-icon {
+  width: 24px;
+  height: 24px;
+  padding-left: 4px;
+  fill: var(--text-color);
+}
+
+.cate-content {
+  font-size: large;
+}
+
+.tag-title {
+  display: flex;
+  align-items: end;
+  gap: 12px;
+  color: var(--text-color);
+  width: 100%;
+  padding-bottom: 4px;
+  background: linear-gradient(to right, var(--secondary-text-color), var(--secondary-text-color)) no-repeat bottom left;
+  background-size: 100% 1px;
+}
+
+.tag-title-content {
+  font-size: large;
+}
+
+.post-line {
+  display: flex;
+  cursor: pointer;
+  justify-content: space-between;
+}
+
+.post-line-title {
+  color: var(--text-color);
+  background: linear-gradient(to right, var(--text-color), var(--text-color)) no-repeat left bottom;
+  background-size: 0 2px;
+  transition: background-size 0.2s;
+}
+
+.post-line:hover .post-line-title {
+  background-size: 100% 2px;
+}
+
+.post-line .post-line-date {
+  color: var(--secondary-text-color);
+  background: linear-gradient(to right, var(--secondary-text-color), var(--secondary-text-color))
+    no-repeat right bottom;
+  background-size: 0 1px;
+  transition: background-size 0.2s;
+}
+
+.post-line:hover .post-line-date {
+  background-size: 100% 1px;
+}
+
+.float-btn {
+  position: fixed;
+  right: 48px;
+  bottom: 32px;
+  padding: 8px;
+  width: 32px;
+  height: 32px;
+  border-radius: 50%;
+  overflow: hidden;
+  fill: var(--text-color);
+  background-color: var(--secondary-background-color);
+  box-shadow: rgba(0, 0, 0, 0.4) 0 0 6px;
+  cursor: pointer;
+  z-index: 10;
+}
+
+.float-btn:hover {
+  fill: var(--secondary-background-color);
+  background-color: var(--text-color);
+  width: 48px;
+  height: 48px;
+}
+
+.float-btn-ico {
+  width: 32px;
+  height: 32px;
+  transition: all 0.3s;
+}
+
+.float-btn:hover .float-btn-ico {
+  width: 48px;
+  height: 48px;
+}
+</style>

+ 64 - 43
src/views/ArchivesView.vue

@@ -3,74 +3,95 @@ import TagCloud from '@/components/TagCloud.vue'
 import { api } from '@/utils/axios.ts'
 import type { Tag } from '@/models'
 import { onMounted, ref } from 'vue'
+import { type CateOption, optionOfCate } from '@/utils/cates.ts'
+import { router } from '@/router'
 
+const tags = ref<Tag[]>([])
 
-const tags = ref<Tag[]>([]);
+const archiveLabels = ref<CateOption[]>([])
 
 onMounted(() => {
-  api.tagList().then(res => {
-    tags.value = res.data
-  });
-})
+  Promise.all([
+    api.tagList().then((res) => {
+      tags.value = res.data
+    }),
+    api.postCates().then((res) => {
+      archiveLabels.value = res.data.map(optionOfCate)
+    }),
+  ])
+});
 
+const handleCateClick = (cate: string) => {
+  router.push(`/archive?cate=${cate}`)
+}
 
+const handleTagClick = (tag: Tag) => {
+  router.push(`/archive?tag=${tag.name}`)
+}
 
 </script>
 
 <template>
   <div>
     <div class="post-item">
-      <h1 class="title">归档页面</h1>
-      <TagCloud :tags="tags"/>
-      <h2>枫林闯大祸</h2>
-      <div class="archive-item">
-        <a href="#">我把家门钥匙吃肚子里了</a>
-        <span class="time">2025-05-30</span>
-      </div>
-      <div class="archive-item">
-        <a href="#">我把同事的猫猫的尾巴用打火机点着了</a>
-        <span class="time">2025-05-30</span>
-      </div>
-      <div class="archive-item">
-        <a href="#">我奴役红磷致使红磷得了腱鞘炎</a>
-        <span class="time">2025-05-30</span>
-      </div>
-      <h2>枫林背大锅</h2>
-      <div class="archive-item">
-        <a href="#">不小心rm -rf/了服务器</a>
-        <span class="time">2025-05-30</span>
-      </div>
-      <div class="archive-item">
-        <a href="#">和甲方吃饭的时候我就喜欢转桌</a>
-        <span class="time">2025-05-30</span>
-      </div>
-      <div class="archive-item">
-        <a href="#">关电源的时候把整间办公室的电脑全都断了</a>
-        <span class="time">2025-05-30</span>
-      </div>
-      <div class="archive-item">
-        <a href="#">给猫猫喂了狗粮导致猫猫腹泻不止</a>
-        <span class="time">2025-05-30</span>
+      <h1 class="title">话题</h1>
+      <TagCloud :tags="tags" :on-click="handleTagClick" />
+      <h2>档案库</h2>
+      <div class="archive-items-grid">
+        <div class="archive-item-wrapper" v-for="item in archiveLabels" :key="item.value">
+          <div class="archive-item" @click="handleCateClick(item.value)">
+            <div class="prefix-icon" v-html="item.svg.template"/>
+            <div class="content">{{ item.label }}</div>
+          </div>
+        </div>
       </div>
+
     </div>
   </div>
 </template>
 
 <style scoped>
 
-.archive-item {
+.archive-items-grid {
+  width: 100%;
   display: flex;
-  justify-content: space-between;
   align-items: center;
-  margin: 10px 0;
+  justify-content: center;
+}
+
+.archive-item-wrapper {
+  display: inline-block;
+  width: 50%;
+}
+
+.archive-item .prefix-icon {
+  width: 32px;
+  height: 32px;
 }
 
-.archive-item a {
-  color: inherit;
+.archive-item {
+  display: flex;
+  justify-content: center;
+  gap: 24px;
+  background-color: var(--secondary-background-color);
+  color: var(--text-color);
+  font-weight: bold;
+  font-size: large;
+  fill: var(--text-color);
+  padding: 20px 0;
+  width: calc(100% - 40px);
+  border-radius: 12px;
+  align-items: center;
+  margin: 10px 0;
+  cursor: pointer;
+  transition: all .3s;
 }
 
-.archive-item .time {
-  opacity: 0.7;
+.archive-item:hover {
+  box-shadow: var(--secondary-background-color) 0 0 12px;
+  background-color: var(--text-color);
+  color: var(--secondary-background-color);
+  fill: var(--secondary-background-color);
 }
 
 </style>

+ 38 - 27
src/views/PostEditView.vue

@@ -4,8 +4,8 @@ import CateSelect from '@/components/CateSelect.vue'
 import TagCloud from '@/components/TagCloud.vue'
 import type { PostBody, Tag } from '@/models'
 import { api } from '@/utils/axios.ts'
-import { codeIcon, diaryIcon, othersIcon, writeIcon } from '@/assets/icons.ts'
-import { router } from '@/router'
+import { getQuery } from '@/router'
+import { optionOfCate } from '@/utils/cates.ts'
 
 const editorConfig = {
   leftToolbar: 'undo redo | image',
@@ -14,8 +14,8 @@ const editorConfig = {
 
 const tags = ref<Tag[] | null>(null)
 
-function freshData() {
-  api.tagList().then((res) => {
+async function freshData() {
+  await api.tagList().then((res) => {
     tags.value = res.data
   })
 }
@@ -44,7 +44,21 @@ const handleSelectTag = (tag: Tag) => {
 }
 
 onMounted(() => {
-  freshData()
+  const defaultCate = getQuery('cate')
+  if (cateOptions.map((e) => e.value).includes(defaultCate)) {
+    selectedCate.value = defaultCate
+  }
+  freshData().then(() => {
+    if (tags.value) {
+      const defaultTag = getQuery('tag')
+      if (!defaultTag) return
+      if (tags.value.map((e) => e.name).includes(defaultTag)) {
+        activeTags.value.push(defaultTag)
+      } else {
+        newTags.value.push(defaultTag)
+      }
+    }
+  })
 })
 
 const title = ref('')
@@ -62,21 +76,11 @@ print('Hello, World!')
 
 const selectedCate = ref('其他')
 
-onMounted(() => {
-  let defaultCate = router.currentRoute.value.query['cate'];
-  if (Array.isArray(defaultCate) && defaultCate.length) {
-    defaultCate = defaultCate[0]
-  }
-  if (typeof defaultCate == "string" && cateOptions.map(e => e.value).includes(defaultCate)) {
-    selectedCate.value = defaultCate;
-  }
-})
-
 const cateOptions = [
-  { label: '技术', value: '技术', svg: codeIcon },
-  { label: '日志', value: '日志', svg: diaryIcon },
-  { label: '随笔', value: '随笔', svg: writeIcon },
-  { label: '其他', value: '其他', svg: othersIcon },
+  optionOfCate('技术'),
+  optionOfCate('日志'),
+  optionOfCate('随笔'),
+  optionOfCate('其他'),
 ]
 
 const newTags = ref<string[]>([])
@@ -87,7 +91,7 @@ const validContent = computed(() => {
 
 function buildPost(): PostBody | undefined {
   if (!validContent.value) {
-    return undefined;
+    return undefined
   }
   return {
     content: text.value,
@@ -98,7 +102,7 @@ function buildPost(): PostBody | undefined {
 }
 
 function handleSubmit() {
-  const postBody = buildPost();
+  const postBody = buildPost()
   if (postBody) {
     api.postPush(postBody)
   }
@@ -109,12 +113,14 @@ function handleSubmit() {
   <div>
     <div class="post-item" style="padding: 12px 30px; overflow: hidden">
       <div style="display: flex; align-items: end; padding-bottom: 12px">
-        <cate-select
-          :options="cateOptions"
-          v-model="selectedCate"
-          placeholder="类型"
+        <cate-select :options="cateOptions" v-model="selectedCate" placeholder="类型" />
+        <input
+          style="margin-bottom: 0"
+          class="p-input"
+          type="text"
+          placeholder="博文标题"
+          v-model="title"
         />
-        <input style="margin-bottom: 0" class="p-input" type="text" placeholder="博文标题" v-model="title" />
       </div>
     </div>
     <div class="post-item" style="padding: 0; overflow: hidden">
@@ -139,7 +145,12 @@ function handleSubmit() {
 
     <div class="post-item footer" style="padding: 0 30px">
       <div class="p-button">保存草稿</div>
-      <div :class="validContent ? ['p-button-success'] : ['p-button-disabled']" @click="handleSubmit">发布博文</div>
+      <div
+        :class="validContent ? ['p-button-success'] : ['p-button-disabled']"
+        @click="handleSubmit"
+      >
+        发布博文
+      </div>
     </div>
   </div>
 </template>

+ 1 - 1
src/views/PostView.vue

@@ -62,7 +62,7 @@ watch(
   { immediate: true },
 )
 
-const tips: { content: string, value: string }[] = [
+const tips: { content: string; value: string }[] = [
   { content: '有新技术?', value: '技术' },
   { content: '有新鲜事?', value: '日志' },
   { content: '有新想法?', value: '随笔' },