Compare commits

...

2 Commits

Author SHA1 Message Date
li-chx 363eeb74dd 主页添加絮语卡片 归档部分初见雏形 2025-08-25 01:24:31 +08:00
li-chx 1fb97e1cc2 主页布局构建完成 2025-08-17 17:22:06 +08:00
36 changed files with 1825 additions and 10012 deletions

6
.gitignore vendored
View File

@ -22,3 +22,9 @@ logs
.env
.env.*
!.env.example
pnpm-lock.yaml
pnpm-workspace.yaml
/client_ chrome.run.xml
/nuxt.run.xml
/server_ nuxt.run.xml

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "content"]
path = content
url = https://git.lichx.top/li_chx/BlogArticles

View File

@ -1,36 +0,0 @@
## 功能清单 / TODO
- [ ] 主页/
- [ ] 网站标题
- [ ] 文章列表(时间排序)
- [ ] 文章标题
- [ ] 文章摘要
- [ ] 文章标签
- [ ] 个人信息展示
- [ ] RSS 订阅
- [ ] 登录管理页面
- [ ] 文章详情页/post
- [ ] 文章标题
- [ ] 文章内容
- [ ] 文章标签
- [ ] 评论区 ??
- [ ] 评论列表
- [ ] 评论表单
- [ ] 评论回复
- [ ] 评论点赞
- [ ] 评论删除
- [ ] 上一篇/下一篇文章链接
- [ ] 归档页/archive
- [ ] 按时间归档
- [ ] 按标签归档
- [ ] 搜索
- [ ] 友链/friend
- [ ] 关于
- [ ] 后台管理/admin security??
- [ ] 文章编辑
- [ ] 新建文章
- [ ] 编辑文章
- [ ] 删除文章
- [ ] 文章预览
- [ ] markdown mermaid ... 支持
- [ ] 友链管理
- [ ] 个人页面管理

36
app.vue
View File

@ -1,6 +1,36 @@
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtPage />
<NuxtRouteAnnouncer/>
<NuxtPage/>
</div>
</template>;
</template>
<script setup lang="ts">
import useColorModeStore from '~/stores/colorModeStore';
if (typeof window !== 'undefined') {
const htmlClass = document.documentElement.classList;
if (htmlClass.contains('dark')) useColorModeStore().setColorMode('dark');
else if (htmlClass.contains('light')) useColorModeStore().setColorMode('light');
else {
// fallback
const systemThemeMode = localStorage.getItem('system-theme-mode');
if (systemThemeMode) useColorModeStore().setColorMode(systemThemeMode as 'light' | 'dark');
}
}
onMounted(() => {
const systemThemeMode = localStorage.getItem('system-theme-mode');
if (systemThemeMode && (systemThemeMode === 'light' || systemThemeMode === 'dark')) {
useColorModeStore().setColorMode(systemThemeMode);
}
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', (e) => {
if (e.matches)
useColorModeStore().setColorMode('dark');
else
useColorModeStore().setColorMode('light');
});
});
</script>

0
articleDefault.ts Normal file
View File

View File

@ -1,2 +1,11 @@
@import "@nuxt/ui";
@import "tailwindcss";
@import "tailwindcss";
/*html, body {*/
/* transition: background-color 5s;*/
/*}*/
html {
--light-text-color: #1e2939; /*gray-800*/
--light-text-secondary-color: #4a5565; /*gray-600*/
--dark-text-color: #e5e7eb; /*gray-200*/
}

View File

@ -0,0 +1,62 @@
<script setup lang="ts">
const containerRef = ref<HTMLDivElement>();
const hoverContentRef = ref<HTMLDivElement>();
const position = ref({
top: true, // true: , false:
left: true, // true: , false:
});
const updatePosition = () => {
if (!containerRef.value || !hoverContentRef.value) return;
const container = containerRef.value.getBoundingClientRect();
const hoverContent = hoverContentRef.value.getBoundingClientRect();
const viewport = {
width: window.innerWidth,
height: window.innerHeight,
};
//
const spaceBelow = viewport.height - container.bottom;
const spaceAbove = container.top;
position.value.top = spaceBelow >= hoverContent.height || spaceBelow >= spaceAbove;
//
const spaceRight = viewport.width - container.left;
position.value.left = spaceRight >= hoverContent.width;
};
const handleMouseEnter = () => {
//
requestAnimationFrame(updatePosition);
};
onMounted(() => {
window.addEventListener('resize', updatePosition);
});
onUnmounted(() => {
window.removeEventListener('resize', updatePosition);
});
</script>
<template>
<div
ref="containerRef"
class="relative group"
@mouseenter="handleMouseEnter"
>
<slot name="content" />
<div
ref="hoverContentRef"
:class="[
'absolute hidden rounded bg-old-neutral-700 text-white text-sm group-hover:block z-10',
position.top ? 'top-full' : 'bottom-full',
position.left ? 'left-0' : 'right-0'
]"
>
<slot name="hoverContent"/>
</div>
</div>
</template>

View File

@ -1,5 +1,4 @@
<script setup lang="ts">
</script>
<template>

View File

@ -0,0 +1,43 @@
<script setup lang="ts">
import { MdPreview } from 'md-editor-v3';
import 'md-editor-v3/lib/preview.css';
import useColorModeStore from '~/stores/colorModeStore';
const props = withDefaults(defineProps<{
editorId: string;
markdown?: string;
}>(), {
markdown: () => '## Hello World!',
});
console.log(props.markdown);
const eraseHeaderMarkdown = computed(() => props.markdown.replace(/^---[\s\S]*?---\n?/, ''));
const { colorMode } = storeToRefs(useColorModeStore());
</script>
<template>
<div class="p-5 pt-0 bg-old-neutral-200 dark:bg-old-neutral-800">
<MdPreview :editor-id="editorId" :theme="colorMode" :model-value="eraseHeaderMarkdown" class="transition-all duration-500"/>
</div>
</template>
<style scoped>
:global(.md-editor) {
--md-bk-color: #e5e5e5;
--md-theme-heading-1-color: #fff;
transition-property: all;
transition-duration: 500ms;
--tw-ease: cubic-bezier(0.4, 0, 0.2, 1);
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
}
:global(.dark .md-editor) {
--md-bk-color: #262626;
--md-theme-heading-1-color: #fff;
}
:global(.dark .md-editor-preview) {
--md-color: var(--ui-text);
}
</style>

View File

@ -0,0 +1,243 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import Highcharts from 'highcharts';
import { DataAnomaly } from '~/types/PostMetaData';
import 'overlayscrollbars/overlayscrollbars.css';
import useColorModeStore from '~/stores/colorModeStore';
import useIconStore from '~/stores/iconStore';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-vue';
import type { OverflowBehavior, ScrollbarsAutoHideBehavior, ScrollbarsVisibilityBehavior } from 'overlayscrollbars';
const props = withDefaults(defineProps<{
asyncKey: string;
techStack?: string[] | DataAnomaly;
techStackPercent?: number[] | DataAnomaly;
techStackIconNames?: string[] | DataAnomaly;
techStackThemeColors?: string[] | DataAnomaly;
}>(), {
techStack: () => ['Vue', 'Nuxt', 'TypeScript', 'JavaScript', 'CSS', 'HTML', 'Node.js', 'Python', 'Java', 'C#'],
techStackPercent: () => [5, 3, 4, 2, 1, 1, 2, 3, 2, 1],
techStackIconNames: () => ['mdi:vuejs', 'lineicons:nuxt', 'mdi:language-typescript', 'mdi:language-javascript', 'material-symbols:css', 'mdi:language-html5', 'mdi:nodejs', 'mdi:language-python', 'mdi:language-java', 'mdi:language-csharp'],
techStackThemeColors: () => ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#FFB347', '#98D8C8', '#F7DC6F', '#BB8FCE'],
});
const chartRef = ref<HTMLDivElement | null>(null);
const techStackLightIconSVG = ref<string[]>([]);
const techStackDarkIconSVG = ref<string[]>([]);
const { colorMode } = storeToRefs(useColorModeStore());
// asyncKey
const { pending } = useAsyncData(props.asyncKey, async () => {
if (props.techStackIconNames === DataAnomaly.DataNotFound || props.techStackIconNames === DataAnomaly.Invalid)
return [];
const iconStore = useIconStore();
for (const iconName of props.techStackIconNames) {
await iconStore.setIconInfo(iconName);
}
for (const iconName of props.techStackIconNames) {
techStackLightIconSVG.value.push(iconStore.getColoredIcon(iconName, '#666'));
techStackDarkIconSVG.value.push(iconStore.getColoredIcon(iconName, '#aaa'));
}
return [];
});
function toPercent(num: number | undefined): string {
if (num === undefined || isNaN(num)) return '0%';
num *= 100;
const rounded = Math.round(num * 100) / 100;
return (rounded === 0 ? '0' : rounded.toFixed(2).replace(/\.?0+$/, '')) + '%';
}
const noDataAvailable = ref(false);
const renderChart = () => {
const techStack = props.techStack as string[];
const techStackPercent = props.techStackPercent as number[];
if (!chartRef.value) return;
const sum = techStackPercent.reduce((acc, val) => acc + val, 0);
const fullArr: [string, number, string, string, string][] = techStack.map((name, index) => [name, techStackPercent[index] / sum, techStackLightIconSVG.value[index] || '', techStackDarkIconSVG.value[index] || '', props.techStackThemeColors[index]] as [string, number, string, string, string]).sort((a, b) => b[1] - a[1]);
const dataArr: [string, number][] = fullArr.map((x) => [x[0], x[1]]);
const barHeight = 20;
const gap = 10;
const axisColor = '#aaa';
chartRef.value.style.height = `${dataArr.length * (barHeight + gap) + 25}px`;
Highcharts.chart(chartRef.value, {
chart: {
type: 'bar',
backgroundColor: 'transparent',
},
credits: {
enabled: false,
},
title: {
text: undefined,
},
legend: {
enabled: false,
},
xAxis: {
categories: dataArr.map(([name]) => name),
title: {
text: undefined,
},
labels: {
useHTML: true,
formatter: function () {
return `<div style="width: 25px; height: 25px;" title="${fullArr[this.pos][0]}">
${colorMode.value === 'light' ? fullArr[this.pos][2] : fullArr[this.pos][3]}
</div>`;
},
},
lineColor: axisColor, // x 线
tickColor: axisColor, // x
},
yAxis: {
min: 0,
title: {
text: undefined, // null
},
labels: {
enabled: false, // y
},
gridLineWidth: 0, // 线
lineWidth: 0, // y 线
tickWidth: 0, // 线
},
series: [
{
name: 'Tech Stack',
data: dataArr.map(([, value]) => value),
type: 'bar',
colorByPoint: true,
colors: fullArr.map((x) => x[4]),
},
],
tooltip: {
formatter: function () {
return `${fullArr[this.x][0]} ${toPercent(this.y)}`;
},
},
plotOptions: {
bar: {
borderRadius: 5,
pointWidth: 20,
groupPadding: 0.1,
pointPadding: 0.05,
borderWidth: 0,
dataLabels: {
enabled: true,
style: {
color: '#fff',
},
formatter: function () {
return toPercent(this.y); //
},
},
},
},
}, () => {
});
};
let colorModeCallBackKey = '';
onMounted(() => {
if (props.techStack === DataAnomaly.DataNotFound || props.techStack === DataAnomaly.Invalid
|| props.techStackPercent === DataAnomaly.DataNotFound || props.techStackPercent === DataAnomaly.Invalid
|| props.techStackThemeColors === DataAnomaly.DataNotFound || props.techStackThemeColors === DataAnomaly.Invalid
|| props.techStack.length === 0 || props.techStackPercent.length === 0 || props.techStackThemeColors.length === 0) {
noDataAvailable.value = true;
return;
}
// window.addEventListener('resize', renderChart);
colorModeCallBackKey = useColorModeStore().registerCallBack(renderChart);
watch(pending, (isPending) => {
if (!isPending) {
//
renderChart();
}
});
});
onUnmounted(() => {
// window.removeEventListener('resize', renderChart);
useColorModeStore().unregisterCallBack(colorModeCallBackKey);
});
const scrollbarOptions = {
scrollbars: {
theme: 'os-theme-thin-dark',
visibility: 'auto' as ScrollbarsVisibilityBehavior,
autoHide: 'move' as ScrollbarsAutoHideBehavior,
autoHideDelay: 1300,
dragScroll: true,
clickScroll: true,
},
overflow: {
x: 'hidden' as OverflowBehavior,
y: 'scroll' as OverflowBehavior,
},
};
</script>
<template>
<div class="h-full">
<div v-if="noDataAvailable" class="flex items-center justify-center h-full p-8">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 112.01">
<g id="_图层_1" data-name="图层 1">
<polyline
class="fill-none stroke-old-neutral-400 dark:stroke-old-neutral-500 [stroke-miterlimit:10]"
points="1.5 28.63 1.5 1.5 63.5 1.59"/>
<polyline
class="fill-none stroke-old-neutral-400 dark:stroke-old-neutral-500 [stroke-miterlimit:10]"
points="254.5 83.38 254.5 110.5 192.5 110.41"/>
<polyline
class="fill-none stroke-old-neutral-400 dark:stroke-old-neutral-500 [stroke-miterlimit:10] stroke-3"
points="254.5 28.63 254.5 1.5 192.5 1.59"/>
<polyline
class="fill-none stroke-old-neutral-400 dark:stroke-old-neutral-500 [stroke-miterlimit:10] stroke-3"
points="1.5 83.38 1.5 110.5 63.5 110.41"/>
</g>
<g id="_图层_2" data-name="图层 2">
<text
class="[font-family:ArialMT,Arial] text-xl stroke-old-neutral-600 dark:stroke-old-neutral-400 fill-old-neutral-600 dark:fill-old-neutral-400"
transform="translate(89.48 61.61)">
<tspan x="0" y="0">No Data</tspan>
</text>
</g>
<g id="_图层_3" data-name="图层 3">
<line
class="fill-none stroke-old-neutral-400 dark:stroke-old-neutral-500 [stroke-miterlimit:10]" x1="16"
y1="11" x2="90" y2="39.8"/>
<line
class="fill-none stroke-old-neutral-400 dark:stroke-old-neutral-500 [stroke-miterlimit:10]" x1="166"
y1="72.21" x2="240" y2="101"/>
</g>
</svg>
</div>
<overlay-scrollbars-component v-else class="max-h-full" :options="scrollbarOptions">
<div ref="chartRef" class="w-full"/>
</overlay-scrollbars-component>
</div>
</template>
<style scoped>
:deep(.os-scrollbar) {
--os-size: 4px;
--os-padding-perpendicular: 0px;
--os-padding-axis: 0px;
--os-track-border-radius: 2px;
--os-handle-border-radius: 2px;
--os-handle-bg: rgba(156, 163, 175, 0.5);
--os-handle-bg-hover: rgba(156, 163, 175, 0.8);
--os-handle-bg-active: rgba(107, 114, 128, 1);
}
/* 暗色模式 */
.dark :deep(.os-scrollbar) {
--os-handle-bg: rgba(75, 85, 99, 0.5);
--os-handle-bg-hover: rgba(75, 85, 99, 0.8);
--os-handle-bg-active: rgba(107, 114, 128, 1);
}
</style>

9
configs/breakpoints.ts Normal file
View File

@ -0,0 +1,9 @@
const breakpoints = {
'sm': '640px',
'md': '768px',
'lg': '1024px',
'xl': '1280px',
'2xl': '1536px',
'hidden-logo': '1736px',
};
export default breakpoints;

1
content Submodule

@ -0,0 +1 @@
Subproject commit a7cd0a03602ed93da797632e5616211f40868125

29
content.config.ts Normal file
View File

@ -0,0 +1,29 @@
import { defineContentConfig, defineCollection, z } from '@nuxt/content';
const schema = z.object({
id: z.string(),
title: z.string(),
description: z.string().optional(),
category: z.string().optional(),
created_at: z.string().datetime(),
published_at: z.string().datetime().optional(),
draft: z.boolean().default(false),
updated_at: z.array(z.string().datetime()).default([]),
tags: z.array(z.string()).default([]),
type: z.enum(['article', 'rambling']).default('article'),
tech_stack: z.array(z.string()).default([]),
tech_stack_percent: z.array(z.number()).default([]),
tech_stack_icon_names: z.array(z.string()).default([]),
tech_stack_theme_colors: z.array(z.string()).default([]),
rawbody: z.string(),
});
export default defineContentConfig({
collections: {
content: defineCollection({
type: 'page',
source: '**/*.md',
schema: schema,
}),
},
});

166
layouts/UserLayout.vue Normal file
View File

@ -0,0 +1,166 @@
<script setup lang="ts">
import type { NavigationMenuItem } from '@nuxt/ui';
import useColorModeStore from '~/stores/colorModeStore';
import { useWindowScroll } from '@vueuse/core';
const { colorMode } = storeToRefs(useColorModeStore());
const isHome = computed(() => useRoute().path === '/');
const items = ref<NavigationMenuItem[]>([
{
label: '首页',
icon: 'i-lucide-home',
to: '/',
// 使isHome.value activeboolean undefined
// 使as unknown as boolean (quq)
// ComputedRef
// ts,
active: isHome as unknown as boolean,
},
{
label: '归档',
icon: 'i-lucide-paperclip',
to: '/archive',
},
{
label: '友链',
icon: 'i-lucide-link',
to: '/friends',
},
{
label: '关于',
icon: 'i-lucide-info',
to: '/about',
},
]);
const collapsed = ref(false);
onMounted(() => {
setTimeout(() => {
collapsed.value = true;
}, 2000);
});
const scrollY = useWindowScroll().y;
const isScrollDown = ref(false);
// gsap.registerPlugin(ScrollTrigger);
watch(scrollY, (newY) => {
if (newY > 0 && !collapsed.value) {
collapsed.value = true;
}
isScrollDown.value = newY > 215;
});
const isLoading = ref(false);
useRouter().beforeEach(() => {
isLoading.value = true;
});
useRouter().afterEach(() => {
isLoading.value = false;
});
</script>
<template>
<div class="w-full min-h-[100vh] h-full">
<UApp>
<div
:class=" (collapsed ? 'h-[20vh]': 'h-[40vh]')"
class="flex flex-col relative transition-all duration-500 ease-in-out" @mouseenter="() => {
if(scrollY === 0) {
collapsed = false;
}
}"
@mouseleave="collapsed = true">
<!-- header -->
<Transition
enter-active-class="transition-opacity duration-500 ease-in-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-500 ease-in-out"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="colorMode === 'light'"
class="flex h-full w-full absolute bg-[url('/79d52228c770808810a310115567e6790380823a.png')] bg-cover bg-top ">
<slot name="header"/>
</div>
<div
v-else
class="flex h-full w-full absolute bg-[url('/anime-8788959.jpg')] bg-cover bg-center">
<slot name="header"/>
</div>
</Transition>
<!-- header picture -->
<Transition
enter-active-class="transition-opacity duration-500 ease-in-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-500 ease-in-out"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="isScrollDown">
<div
v-if="colorMode === 'light'"
class="opacity-80 max-h-[48px] flex w-full h-full fixed bg-[url('/79d52228c770808810a310115567e6790380823a.png')] bg-cover bg-top"/>
<div
v-else
class="opacity-20 max-h-[48px] flex w-full h-full fixed bg-[url('/anime-8788959.jpg')] bg-cover bg-center"/>
</div>
</Transition>
<!-- navbar -->
<div
class="fixed z-10 w-full transition-all duration-500 dark:bg-gray-800/60 bg-old-neutral-50/40 backdrop-blur-sm dark:backdrop-blur-md">
<div class="flex justify-center items-center h-full">
<div class="flex-1 overflow-hidden">
<slot name="navbarLeft" :is-scroll-down="isScrollDown"/>
</div>
<div
class="transition-all duration-500 flex 2xl:w-[1240px] xl:w-[1020px] lg:w-[964px] md:w-[708px] sm:w-[580px] w-10/12">
<UNavigationMenu :items="items" :class="colorMode" class="w-full"/>
</div>
<div class="flex-1 overflow-hidden">
<slot name="navbarRight" :is-scroll-down="isScrollDown"/>
</div>
</div>
<Transition
enter-active-class="transition-opacity duration-500 ease-in-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-500 ease-in-out"
leave-from-class="opacity-100"
leave-to-class="opacity-0">
<UProgress v-if="isLoading" :size="'sm'" class="fixed z-20"/>
</Transition>
</div>
</div>
<div class="flex justify-center items-center duration-500 bg-white dark:bg-[#16191b] h-full">
<div
:class="collapsed ? 'min-h-[80vh]' : 'min-h-[60vh]'"
class="transition-all duration-500 ease-in-out 2xl:w-[1240px] xl:w-[1020px] lg:w-[964px] md:w-[708px] sm:w-[580px] w-11/12">
<slot name="content"/>
</div>
</div>
<slot name="footer"/>
</UApp>
</div>
</template>
<style scoped lang="less">
:deep(.light .text-muted:not(:hover)), :deep(.light .text-dimmed:not(:hover)) {
--ui-text-muted: var(--light-text-secondary-color);
--ui-text-dimmed: var(--light-text-secondary-color);
}
:deep(.dark .text-muted) {
--ui-text-muted: var(--dark-text-color);
--ui-text-dimmed: var(--dark-text-color);
}
:deep(a::before) {
transition: background-color 0.3s ease-in-out, color 0.3s ease-in-out;
}
</style>

View File

@ -2,6 +2,7 @@
import tailwindcss from '@tailwindcss/vite';
export default defineNuxtConfig({
ssr: false,
compatibilityDate: '2025-05-15',
devtools: { enabled: false },
vite: {
@ -11,10 +12,24 @@ export default defineNuxtConfig({
},
modules: [
'@nuxt/eslint',
'@nuxt/icon',
'@nuxt/ui',
'@pinia/nuxt',
'@nuxtjs/color-mode',
'@nuxt/content',
],
css: ['~/assets/css/main.css'],
ui: {
colorMode: false,
},
app: {
head: {
htmlAttrs: {
lang: 'zh-CN',
},
script: [{ src: '/darkVerify.js' }],
},
},
sourcemap: {
server: true,
client: true,
},
});

View File

@ -10,27 +10,37 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxt/content": "^3.6.3",
"@nuxt/eslint": "1.5.2",
"@nuxt/icon": "1.15.0",
"@nuxt/icon": "^1.15.0",
"@nuxt/ui": "3.2.0",
"@nuxtjs/color-mode": "3.5.2",
"@pinia/nuxt": "^0.11.1",
"@tailwindcss/vite": "^4.1.11",
"@vue/eslint-config-prettier": "^10.2.0",
"@vueuse/core": "^13.6.0",
"better-sqlite3": "^12.2.0",
"eslint": "^9.0.0",
"gsap": "^3.13.0",
"highcharts": "^12.3.0",
"md-editor-v3": "^5.8.4",
"nuxt": "^3.17.6",
"overlayscrollbars-vue": "^0.5.9",
"tailwind-scrollbar": "^4.0.2",
"tailwindcss": "^4.1.11",
"typescript": "^5.6.3",
"vue": "^3.5.17",
"vue-router": "^4.5.1"
"vue-router": "^4.5.1",
"word-count": "^0.3.1"
},
"packageManager": "pnpm@10.7.1+sha512.2d92c86b7928dc8284f53494fb4201f983da65f0fb4f0d40baafa5cf628fa31dae3e5968f12466f17df7e97310e30f343a648baea1b9b350685dafafffdf5808",
"packageManager": "pnpm@10.14.0",
"devDependencies": {
"@stylistic/eslint-plugin": "^5.1.0",
"@stylistic/eslint-plugin-jsx": "^4.4.1",
"@vue/eslint-config-typescript": "^14.6.0",
"eslint-plugin-vue": "^10.3.0",
"globals": "^16.3.0",
"less": "^4.4.0",
"overlayscrollbars": "^2.11.5",
"typescript-eslint": "^8.35.1",
"vue-eslint-parser": "^10.2.0"
}

View File

@ -3,12 +3,10 @@
</script>
<template>
<div>
{{useRoute().params.articleID}}
</div>
admin
</template>
<style scoped>
</style>
</style>

84
pages/index.vue Normal file
View File

@ -0,0 +1,84 @@
<script setup lang="ts">
import useColorModeStore from '~/stores/colorModeStore';
import breakpointsHelper from '~/utils/BreakpointsHelper';
</script>
<template>
<NuxtLayout :name="'user-layout'">
<template #navbarLeft="{ isScrollDown } : { isScrollDown: boolean }">
<Transition
enter-active-class="transition-opacity duration-500 ease-in-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-500 ease-in-out"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isScrollDown && breakpointsHelper.greaterOrEqual('xl').value"
class="pl-5 text-xl h-12 leading-11 flex">
随机存取
</div>
</Transition>
</template>
<template #navbarRight>
<div class="flex items-center h-full">
<div class="flex-1"/>
<div class="flex-1 flex items-center justify-end duration500 ease-in-out">
<Transition
mode="out-in"
enter-active-class="transition-opacity duration-300 ease-in-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300 ease-in-out"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<Icon
v-if="useColorModeStore().colorMode === 'dark'"
key="dark"
name="material-symbols:dark-mode"
class="text-2xl cursor-pointer mr-5"
@click="() => useColorModeStore().toggleColorMode()"
/>
<Icon
v-else
key="light"
name="material-symbols:clear-day-rounded"
class="text-2xl cursor-pointer mr-5"
@click="() => useColorModeStore().toggleColorMode()"
/>
</Transition>
</div>
</div>
</template>
<template #header>
<div class="w-full flex-1 justify-center flex items-center">
<p class="text-8xl mt-12">
随机存取
</p>
</div>
</template>
<template #content>
<div>
<NuxtRouteAnnouncer/>
<NuxtPage/>
</div>
</template>
<template #footer>
<div class="w-full flex flex-col justify-center items-center p-10 text-old-neutral-500">
<div>
© 2025 随机存取. 由Lichx制作
</div>
<div>
蒙ICP备2025022865号
</div>
</div>
</template>
</NuxtLayout>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,115 @@
<script setup lang="ts">
import { DataAnomaly } from '~/types/PostMetaData';
import type { PostMetaData } from '~/types/PostMetaData';
const articles = defineModel<PostMetaData[]>('articles', {
default: () => [],
});
const props = withDefaults(defineProps<{
currentChoice?: 'time' | 'category';
}>(),
{
currentChoice: 'time',
});
watch(() => props.currentChoice, (newChoice) => {
if (newChoice === 'time') {
articles.value.sort((a, b) => new Date(b.published_at || '2000-01-01').getTime() - new Date(a.published_at || '2000-01-01').getTime());
} else {
articles.value.sort((a, b) => {
if (a.category === b.category) {
return new Date(b.published_at || '2000-01-01').getTime() - new Date(a.published_at || '2000-01-01').getTime();
}
return a.category.localeCompare(b.category);
});
}
});
function toArticlePage(article: PostMetaData) {
navigateTo(`/article/${encodeURIComponent(article.id)}`);
}
function getYear(article: PostMetaData) {
return new Date(article.published_at).getFullYear();
}
function dateFormatToTime(date: Date | DataAnomaly) {
if (date === DataAnomaly.DataNotFound || date === DataAnomaly.Invalid) {
return date;
}
return new Date(date).toLocaleDateString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function dateFormatToDate(date: Date | DataAnomaly) {
if (date === DataAnomaly.DataNotFound || date === DataAnomaly.Invalid) {
return date;
}
return new Date(date).toLocaleDateString('zh-CN', {
month: '2-digit',
day: '2-digit',
}).replace(/\//g, '-');
}
</script>
<template>
<div>
<div
v-for="(article,index) of articles" :key="article.id"
class="border-l-2 border-l-old-neutral-400 dark:border-l-old-neutral-500 pl-4">
<Transition
enter-active-class="transition-opacity duration-500 ease-in-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-500 ease-in-out"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="currentChoice==='time' && index == 0 || getYear(article) != getYear(articles[index-1])"
class="year-marker relative text-indigo-300 text-2xl pt-3 pb-3">
{{ getYear(article) }}
</div>
<div
v-else-if="currentChoice==='category' && (index == 0 || article.category != articles[index-1].category)"
class="year-marker relative text-indigo-300 text-2xl pt-3 pb-3">
{{ article.category }}
</div>
</Transition>
<div class="flex items-center" @click="toArticlePage(article)">
<div :title="dateFormatToTime(article.published_at)" class="text-sm w-12">
{{ dateFormatToDate(article.published_at) }}
</div>
<div :title="dateFormatToTime(article.published_at)" class="text-md pl-5">
{{ article.title }}
</div>
</div>
</div>
</div>
</template>
<style scoped lang="less">
.year-marker::before {
content: '';
position: absolute;
width: 12px; // w-3
height: 12px; // h-3
background-color: #000;
border-radius: 9999px; // rounded-full
border: 2px solid black; // border-2 border-black
left: -17px; // pl-3 12px + 1px border
top: 50%;
transform: translateY(-50%) translateX(-50%);
transition: background-color 0.5s, border-color 0.5s;
}
.dark .year-marker::before {
background-color: #fff;
border: 2px solid white; // border-2 border-white
}
</style>

View File

@ -0,0 +1,39 @@
<script setup lang="ts">
import { toMetaDataType } from '~/types/PostMetaData';
import TimeLine from '~/pages/index/archive/components/TimeLine.vue';
const { data: articles } = useAsyncData(async () => (await queryCollection('content').order('published_at', 'DESC').all()).map((article) => toMetaDataType(article)));
const currentChoice = ref('时间');
// onMounted(() => {
// setTimeout(() => {
// console.log(articles.value);
// }, 2000);
// });
</script>
<template>
<div>
<div class="table w-full mt-6">
<div class="sticky top-16 float-left bg-old-neutral-200 dark:bg-old-neutral-800 max-h-[calc(100vh-4rem)]">
<div class="relative duration-500 transition-all xl:w-80 w-0 mr-2/3 overflow-hidden">
<div class="w-80 top-0 left-0 text-gray-800 dark:text-white p-5">
test123456
</div>
</div>
</div>
<div
class="transition-all duration-500 float-right xl:w-[calc(100%-20rem-40px)] w-full bg-old-neutral-200 dark:bg-old-neutral-800 p-5">
<URadioGroup
v-model="currentChoice" orientation="horizontal" variant="table" :items="['时间', '类别']" size="sm"
class="mb-5"/>
<TimeLine v-if="articles" v-model:articles="articles!" :current-choice="currentChoice=== '时间'? 'time' : 'category'"/>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,156 @@
<script setup lang="ts">
import { DataAnomaly, defaultMetaData } from '~/types/PostMetaData';
import type { PostMetaData } from '~/types/PostMetaData';
import breakpointsHelper from '~/utils/BreakpointsHelper';
const props = withDefaults(defineProps<{
article?: PostMetaData;
}>(), {
article: () => defaultMetaData,
});
function dateFormat(date: Date | DataAnomaly) {
if (date === DataAnomaly.DataNotFound || date === DataAnomaly.Invalid) {
return date;
}
return new Date(date).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function getCostTime(length: number | DataAnomaly) {
if (length === DataAnomaly.DataNotFound || length === DataAnomaly.Invalid) {
return length;
}
let time = length / 250;
time = Math.ceil(time);
const hours = Math.floor(time / 60);
const minutes = time % 60;
if (hours > 0) {
return `${hours}小时${minutes}分钟`;
} else {
return `${minutes}分钟`;
}
}
const open = ref(false);
onMounted(() => {
setTimeout(() => {
open.value = true;
}, 100);
setTimeout(() => {
open.value = false;
}, 4000);
});
</script>
<template>
<div class="p-6 pb-0 light:bg-old-neutral-200 dark:bg-old-neutral-800">
<UCollapsible v-model:open="open" :unmount-on-hide="false" class="flex flex-col gap-2 w-full">
<div class="text-4xl flex justify-between items-center w-full">
<div class="mb-0 pb-0">
{{ props.article.title }}
</div>
<Icon
name="lucide:chevron-down" class="text-2xl transition-transform duration-300 mr-5"
:class="{ 'rotate-180': open }"/>
</div>
<template #content>
<div class="flex mt-2 justify-between">
<div>
<div class="flex items-center mt-2 max-w-96 overflow-hidden">
<div title="发布时间" class="flex items-center">
<Icon name="lucide:clock-arrow-up"/>
<div class="ml-1">
{{ dateFormat(props.article.published_at) }}
</div>
</div>
<div title="分类" class="flex items-center ml-2">
<Icon name="material-symbols:category"/>
<div class="ml-1 inline">
{{ props.article.category }}
</div>
</div>
</div>
<div class="flex items-center max-w-96 overflow-hidden">
<div title="字数" class="flex items-center">
<Icon name="fluent:text-word-count-20-filled"/>
<div class="ml-1 inline">
{{ props.article.word_count }}
</div>
</div>
<div title="预计阅读时间" class="flex items-center ml-2">
<Icon name="octicon:stopwatch-16"/>
<div class="ml-1 inline">
{{ getCostTime(props.article.word_count) }}
</div>
</div>
</div>
<div class="flex">
<div title="创建时间" class="flex items-center">
<Icon name="lucide:file-clock"/>
<div class="ml-1">{{ dateFormat(props.article.created_at) }}</div>
</div>
<div v-if="Array.isArray(props.article.updated_at)" class="flex items-center ml-2">
<Icon name="lucide:clock-alert" title="上次更新时间"/>
<HoverContent>
<template #content>
<div class="ml-1">
{{ dateFormat(props.article.updated_at[props.article.updated_at.length - 1]) }}
</div>
</template>
<template #hoverContent>
<div class="p-1 pr-2">
<div v-for="(date,index) of props.article.updated_at" :key="index">
<div class="block whitespace-nowrap">
&nbsp;{{ '第' + index + '次更新' + dateFormat(date) }}
</div>
</div>
</div>
</template>
</HoverContent>
</div>
</div>
<div v-if="Array.isArray(props.article.tags)" class="flex items-center">
<Icon name="clarity:tags-solid"/>
<div v-for="(tag,index) of props.article.tags" :key="index">
<div class="ml-1">{{ tag }}</div>
</div>
</div>
</div>
<Transition
enter-active-class="transition-opacity duration-500 ease-in-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-500 ease-in-out"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<TechStackCard
v-if="breakpointsHelper.greater('lg').value"
:async-key="'stack:' + props.article.id"
:tech-stack="props.article.tech_stack"
:tech-stack-icon-names="props.article.tech_stack_icon_names"
:tech-stack-theme-colors="props.article.tech_stack_theme_colors"
:tech-stack-percent="props.article.tech_stack_percent"
class="w-64"
/>
</Transition>
</div>
</template>
</UCollapsible>
</div>
</template>

View File

@ -0,0 +1,34 @@
<script setup lang="ts">
import { toMetaDataType } from '~/types/PostMetaData';
import { MdCatalog } from 'md-editor-v3';
import ArticleHeader from '~/pages/index/article/[articleID]/components/ArticleHeader.vue';
const articleId = useRoute().params.articleID as string;
const { data: article } = useAsyncData(async () => await queryCollection('content').where('id', '=', articleId).first());
const editorId = 'article-previewer';
</script>
<template>
<div>
<div class="table w-full mt-6">
<div class="sticky top-16 float-left bg-old-neutral-200 dark:bg-old-neutral-800 max-h-[calc(100vh-4rem)]">
<div class="relative duration-500 transition-all xl:w-80 w-0 mr-2/3 overflow-hidden">
<div class="w-80 top-0 left-0 text-gray-800 dark:text-white p-5">
<MdCatalog :editor-id="editorId" :scroll-element="'html'"/>
</div>
</div>
</div>
<div class="transition-all duration-500 float-right xl:w-[calc(100%-20rem-40px)] w-full">
<ArticleHeader v-if="article" class="w-full" :article="toMetaDataType(article)"/>
<!-- <ArticleCard v-if="article" class=" w-full" :article="toArticleMetaDataType(article)"/>-->
<ReadonlyMdEditor :editor-id="editorId" :markdown="article?.rawbody"/>
</div>
</div>
</div>
</template>
<style scoped lang="less">
</style>

View File

@ -0,0 +1,142 @@
<script setup lang="ts">
import { DataAnomaly, defaultMetaData } from '~/types/PostMetaData';
import type { PostMetaData } from '~/types/PostMetaData';
import breakpointsHelper from '~/utils/BreakpointsHelper';
const props = withDefaults(defineProps<{
article?: PostMetaData;
}>(),
{
article: () => defaultMetaData,
});
function dateFormat(date: Date | DataAnomaly) {
if (date === DataAnomaly.DataNotFound || date === DataAnomaly.Invalid) {
return date;
}
return new Date(date).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function getCostTime(length: number | DataAnomaly) {
if (length === DataAnomaly.DataNotFound || length === DataAnomaly.Invalid) {
return length;
}
let time = length / 250;
time = Math.ceil(time);
const hours = Math.floor(time / 60);
const minutes = time % 60;
if (hours > 0) {
return `${hours}小时${minutes}分钟`;
} else {
return `${minutes}分钟`;
}
}
</script>
<template>
<div class="p-5 light:bg-old-neutral-200 dark:bg-old-neutral-800 min-h-64 transition-all duration-500">
<div class="text-4xl">
{{ props.article.title }}
</div>
<div class="flex items-center mt-2 max-w-96 overflow-hidden">
<div title="发布时间" class="flex items-center">
<Icon name="lucide:clock-arrow-up"/>
<div class="ml-1 text-nowrap">
{{ dateFormat(props.article.published_at) }}
</div>
</div>
<div title="分类" class="flex items-center ml-2">
<Icon name="material-symbols:category"/>
<div class="ml-1 text-nowrap">
{{ props.article.category }}
</div>
</div>
<div title="字数" class="flex items-center ml-2">
<Icon name="fluent:text-word-count-20-filled"/>
<div class="ml-1 text-nowrap">
{{ props.article.word_count }}
</div>
</div>
<div title="预计阅读时间" class="flex items-center ml-2">
<Icon name="octicon:stopwatch-16"/>
<div class="ml-1 text-nowrap">
{{ getCostTime(props.article.word_count) }}
</div>
</div>
</div>
<div class="flex mt-2 justify-between h-28">
<div>
<div class="">
{{ props.article.description }}
</div>
</div>
<Transition
enter-active-class="transition-opacity duration-500 ease-in-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-500 ease-in-out"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<TechStackCard
v-if="breakpointsHelper.greater('lg').value"
:async-key="'stack:' + props.article.id"
:tech-stack="props.article.tech_stack"
:tech-stack-icon-names="props.article.tech_stack_icon_names"
:tech-stack-theme-colors="props.article.tech_stack_theme_colors"
:tech-stack-percent="props.article.tech_stack_percent"
class="w-64"
/>
</Transition>
</div>
<hr/>
<div class="flex mt-2">
<div title="创建时间" class="flex items-center">
<Icon name="lucide:file-clock"/>
<div class="ml-1">{{ dateFormat(props.article.created_at) }}</div>
</div>
<div v-if="Array.isArray(props.article.updated_at)" class="flex items-center ml-2">
<Icon name="lucide:clock-alert" title="上次更新时间"/>
<HoverContent>
<template #content>
<div class="ml-1">
{{ dateFormat(props.article.updated_at[props.article.updated_at.length - 1]) }}
</div>
</template>
<template #hoverContent>
<div class="p-1 pr-2">
<div v-for="(date,index) of props.article.updated_at" :key="index">
<div class="block whitespace-nowrap">
&nbsp;{{ '第' + index + '次更新' + dateFormat(date) }}
</div>
</div>
</div>
</template>
</HoverContent>
</div>
</div>
<div v-if="Array.isArray(props.article.tags)" class="flex items-top">
<Icon name="clarity:tags-solid" class="mt-1"/>
<div>
<div v-for="(tag,index) of props.article.tags" :key="index" class="inline-block">
<div class="ml-1 inline">{{ tag }}</div>
<div v-if="index !== props.article.tags.length - 1" class="inline">,</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,176 @@
<script setup lang="ts">
import { DataAnomaly, defaultMetaData } from '~/types/PostMetaData';
import type { PostMetaData } from '~/types/PostMetaData';
import useColorModeStore from '~/stores/colorModeStore';
const props = withDefaults(defineProps<{
rambling?: PostMetaData;
}>(),
{
rambling: () => defaultMetaData,
});
const { data: rawbody } = useAsyncData(async () => (await queryCollection('content').where('id', '=', props.rambling.id).first())?.rawbody);
const collapsed = ref(false);
function dateFormat(date: Date | DataAnomaly) {
if (date === DataAnomaly.DataNotFound || date === DataAnomaly.Invalid) {
return date;
}
return new Date(date).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function getCostTime(length: number | DataAnomaly) {
if (length === DataAnomaly.DataNotFound || length === DataAnomaly.Invalid) {
return length;
}
let time = length / 250;
time = Math.ceil(time);
const hours = Math.floor(time / 60);
const minutes = time % 60;
if (hours > 0) {
return `${hours}小时${minutes}分钟`;
} else {
return `${minutes}分钟`;
}
}
const safeEditorId = computed(() => {
const encoded = btoa(encodeURIComponent(props.rambling.id))
.replace(/[+/=]/g, '_'); // Base64
return `rambling_${encoded}`;
});
const showLightShadow = ref(false);
const showDarkShadow = ref(false);
function reverseCollapsed() {
if (collapsed.value) {
collapsed.value = false;
return;
}
const showLight = showLightShadow.value;
const showDark = showDarkShadow.value;
showLightShadow.value = false;
showDarkShadow.value = false;
collapsed.value = true;
setTimeout(() => {
showLightShadow.value = showLight;
showDarkShadow.value = showDark;
}, 500);
}
const colorModeStore = useColorModeStore();
let colorModeCallBackKey = '';
onMounted(() => {
if (colorModeStore.colorMode === 'light') {
showLightShadow.value = true;
} else {
showDarkShadow.value = true;
}
colorModeCallBackKey = colorModeStore.registerCallBack(() => {
if (colorModeStore.colorMode === 'light') {
setTimeout(() => {
showLightShadow.value = true;
}, 500);
showDarkShadow.value = false;
} else {
showLightShadow.value = false;
setTimeout(() => {
showDarkShadow.value = true;
}, 500);
}
});
});
onUnmounted(() => {
colorModeStore.unregisterCallBack(colorModeCallBackKey);
});
</script>
<template>
<div
class="p-5 light:bg-old-neutral-200 dark:bg-old-neutral-800 min-h-64 transition-all duration-500"
@click="reverseCollapsed">
<div class="text-4xl">
絮语:{{ props.rambling.title }}
</div>
<div class="flex items-center mt-2 max-w-96 overflow-hidden">
<div title="发布时间" class="flex items-center">
<Icon name="lucide:clock-arrow-up"/>
<div class="ml-1 text-nowrap">
{{ dateFormat(props.rambling.published_at) }}
</div>
</div>
<div title="分类" class="flex items-center ml-2">
<Icon name="material-symbols:category"/>
<div class="ml-1 text-nowrap">
{{ props.rambling.category }}
</div>
</div>
<div title="字数" class="flex items-center ml-2">
<Icon name="fluent:text-word-count-20-filled"/>
<div class="ml-1 text-nowrap">
{{ props.rambling.word_count }}
</div>
</div>
<div title="预计阅读时间" class="flex items-center ml-2">
<Icon name="octicon:stopwatch-16"/>
<div class="ml-1 text-nowrap">
{{ getCostTime(props.rambling.word_count) }}
</div>
</div>
</div>
<div
class="relative flex mt-2 justify-between overflow-hidden transition-all duration-300 ease-in-out dura"
:class="{'max-h-[8.5rem]' : collapsed, 'max-h-[100vh]':!collapsed}">
<ReadonlyMdEditor
v-if="rawbody" :editor-id="safeEditorId" :markdown="rawbody!"
class="transition-all duration-500 w-full"/>
<div
class="absolute bottom-0 left-0 right-0 h-14 bg-gradient-to-t from-old-neutral-200 to-transparent pointer-events-none transition-opacity duration-300"
:class="collapsed&&showLightShadow?'opacity-100':'opacity-0'"
/>
<div
class="absolute bottom-0 left-0 right-0 h-14 bg-gradient-to-t from-old-neutral-800 to-transparent pointer-events-none transition-opacity duration-300"
:class="collapsed&&showDarkShadow?'opacity-100':'opacity-0'"
/>
</div>
<hr/>
<div class="flex mt-2">
<div title="创建时间" class="flex items-center">
<Icon name="lucide:file-clock"/>
<div class="ml-1">{{ dateFormat(props.rambling.created_at) }}</div>
</div>
<div v-if="Array.isArray(props.rambling.updated_at)" class="flex items-center ml-2">
<Icon name="lucide:clock-alert" title="上次更新时间"/>
<HoverContent>
<template #content>
<div class="ml-1">
{{ dateFormat(props.rambling.updated_at[props.rambling.updated_at.length - 1]) }}
</div>
</template>
<template #hoverContent>
<div class="p-1 pr-2">
<div v-for="(date,index) of props.rambling.updated_at" :key="index">
<div class="block whitespace-nowrap">
&nbsp;{{ '第' + index + '次更新' + dateFormat(date) }}
</div>
</div>
</div>
</template>
</HoverContent>
</div>
</div>
</div>
</template>

View File

@ -1,45 +1,46 @@
<script setup lang="ts">
import type { NavigationMenuItem } from '@nuxt/ui';
import { toMetaDataType } from '~/types/PostMetaData';
import RamblingCard from '~/pages/index/components/RamblingCard.vue';
import ArticleCard from '~/pages/index/components/ArticleCard.vue';
const { data: posts } = useAsyncData(async () => await queryCollection('content').order('published_at', 'DESC').all());
// onMounted(() => {
// setTimeout(() => {
// console.log(articles.value);
// }, 2000);
// });
type PostItem = NonNullable<typeof posts.value>[number];
function toArticlePage(article: PostItem) {
navigateTo(`/article/${encodeURIComponent(article.id)}`);
}
const items = ref<NavigationMenuItem[]>([
{
label: '首页',
icon: 'i-lucide-home',
to: '/',
},
{
label: '归档',
icon: 'i-lucide-paperclip',
to: '/archive',
},
{
label: '关于',
icon: 'i-lucide-info',
to: '/about',
},
{
label: 'Contact',
to: '/contact',
},
]);
useColorMode().preference = 'light';
</script>
<template>
<div>
<NuxtRouteAnnouncer />
<UApp>
<div class="w-full flex justify-center">
<div class="xl:w-[1220px] lg:w-[964px] md:w-[708px]">
<UNavigationMenu :items="items" class="w-full"/>
<NuxtPage />
<div class="table w-full mt-6">
<div class="sticky top-16 float-left bg-old-neutral-200 dark:bg-old-neutral-800 max-h-[calc(100vh-4rem)]">
<div class="relative duration-500 transition-all xl:w-80 w-0 mr-2/3 overflow-hidden">
<div class="w-80 top-0 left-0 text-gray-800 dark:text-white p-5">
test123456
</div>
</div>
</div>
</UApp>
<div class="transition-all duration-500 float-right xl:w-[calc(100%-20rem-40px)] w-full">
<ArticleCard class="mb-6 w-full transition-shadow duration-300 shadow-lg hover:shadow-old-neutral-600"/>
<div v-for="article in posts" :key="article.id" class="mb-6 w-full transition-shadow duration-300 shadow-lg hover:shadow-old-neutral-600 hover:cursor-pointer">
<ArticleCard
v-if="!article.draft && article.type === 'article'"
:article="toMetaDataType(article)"
@click="toArticlePage(article)"/>
<RamblingCard
v-else-if="!article.draft && article.type === 'rambling'"
:rambling="toMetaDataType(article)"/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

BIN
public/anime-8788959.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

14
public/darkVerify.js Normal file
View File

@ -0,0 +1,14 @@
// darkVerify.js
if (
localStorage.getItem('system-theme-mode') === "dark" ||
(!localStorage.getItem('system-theme-mode') &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
) {
document.querySelector('html').classList.add('dark');
document.querySelector('html').classList.remove('light');
localStorage.setItem('system-theme-mode', 'dark');
} else {
document.querySelector('html').classList.add('light');
document.querySelector('html').classList.remove('dark');
localStorage.setItem('system-theme-mode', 'light');
}

58
stores/colorModeStore.ts Normal file
View File

@ -0,0 +1,58 @@
const getInitialMode = () => {
if (typeof window !== 'undefined') {
// 优先用 html 的 class
if (document.documentElement.classList.contains('dark')) return 'dark';
if (document.documentElement.classList.contains('light')) return 'light';
// 其次用 localStorage
return localStorage.getItem('system-theme-mode') || 'light';
}
return 'light'; // SSR 默认
};
const useColorModeStore = defineStore('colorMode', {
state: () => ({
colorMode: getInitialMode() as 'light' | 'dark',
callBackFunctions: new Map<string, () => void>(),
callBackId: 0,
}),
getters: {
isDarkMode: (state) => state.colorMode === 'dark',
},
actions: {
registerCallBack(func: () => void) {
const id = `callback-${this.callBackId++}`;
this.callBackFunctions.set(id, func);
return id;
},
unregisterCallBack(id: string) {
this.callBackFunctions.delete(id);
},
notifyCallBacks() {
this.callBackFunctions.forEach((func) => {
try {
func();
} catch (error) {
console.error('Error in color mode callback:', error);
}
});
},
toggleColorMode() {
this.setColorMode(this.colorMode === 'dark' ? 'light' : 'dark');
},
setColorMode(mode: 'light' | 'dark') {
if (mode !== 'light' && mode !== 'dark') {
throw new Error('Invalid color mode. Use "light" or "dark".');
}
this.colorMode = mode;
if (mode === 'dark') {
document.querySelector('html')!.classList.remove('light');
document.querySelector('html')!.classList.add('dark');
} else {
document.querySelector('html')!.classList.remove('dark');
document.querySelector('html')!.classList.add('light');
}
this.notifyCallBacks();
},
},
});
export default useColorModeStore;

View File

@ -1,4 +0,0 @@
export const store = defineStore('counter', {
});
export default store;

69
stores/iconStore.ts Normal file
View File

@ -0,0 +1,69 @@
const useIconStore = defineStore('icon', {
state: () => ({
iconCache: new Map<string, string>(),
addingSet: new Set<string>(),
waitingCallbackFunctions: new Map<string, (() => void)[]>(),
}),
actions: {
getIcon(iconName: string): string {
if (this.iconCache.has(iconName)) {
return this.iconCache.get(iconName) as string;
}
return '';
},
getColoredIcon(iconName: string, color: string): string {
const icon = this.getIcon(iconName);
if (icon === '')
return '';
return icon.replace('<svg', `<svg style="color:${color};"`);
},
async setIconInfo(iconName: string, iconData?: string) {
if (this.iconCache.has(iconName)) {
return;
}
if (this.addingSet.has(iconName)) {
return new Promise<void>((resolve) => {
if (!this.waitingCallbackFunctions.has(iconName)) {
this.waitingCallbackFunctions.set(iconName, [resolve]);
} else
this.waitingCallbackFunctions.get(iconName)!.push(resolve);
});
}
this.addingSet.add(iconName);
if (iconData === undefined || iconData === null || iconData === '') {
this.iconCache.set(iconName, await fetchSvg(iconName));
} else
this.iconCache.set(iconName, iconData as string);
this.addingSet.delete(iconName);
this.waitingCallbackFunctions.get(iconName)?.forEach((callback) => callback());
this.waitingCallbackFunctions.delete(iconName);
},
},
});
export default useIconStore;
export async function fetchSvg(svgName: string, maxRetries = 3) {
const iconifyUrl = 'https://api.iconify.design/';
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await (await $fetch<Blob>(iconifyUrl + svgName + '.svg', {
method: 'GET',
params: {
width: '100%',
},
})).text();
} catch (error) {
console.warn(`Attempt ${attempt} failed for ${svgName}:`, error);
if (attempt === maxRetries) {
console.error(`All ${maxRetries} attempts failed for ${svgName}`);
}
// 等待一段时间后重试(可选)
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
}
}
return '<svg></svg>';
}

View File

@ -1,5 +1,7 @@
// tailwind config file
import plugin from 'tailwindcss/plugin';
import tailwindScrollbar from 'tailwind-scrollbar';
import breakpoints from '~/configs/breakpoints';
export default {
mode: 'jit',
@ -11,13 +13,7 @@ export default {
doublewidest: '.2em',
},
},
screens: {
'sm': '640px',
'md': '768px',
'lg': '1024px',
'xl': '1280px',
'2xl': '1536px',
},
screens: breakpoints,
},
plugins: [
plugin(function ({ addUtilities }) {
@ -36,6 +32,7 @@ export default {
},
});
}),
tailwindScrollbar,
],
content: [
'./app.vue',

146
types/PostMetaData.ts Normal file
View File

@ -0,0 +1,146 @@
import wordCount from 'word-count';
export enum DataAnomaly {
DataNotFound = 'DataNotFound',
Invalid = 'DataInvalid',
}
export type PostMetaData = {
id: string;
title: string;
description: string;
created_at: Date | DataAnomaly;
category: string;
published_at: Date | DataAnomaly;
draft: boolean;
updated_at: Date[] | DataAnomaly;
tags: string[];
type: string;
tech_stack: string[] | DataAnomaly;
tech_stack_percent: number[] | DataAnomaly;
tech_stack_icon_names: string[] | DataAnomaly;
tech_stack_theme_colors: string[] | DataAnomaly;
word_count: number | DataAnomaly;
};
/*
* Nuxt Content也能做值处理ts传参的自动化生成类型
*
*/
function getDate(data: Record<string, unknown>, key: string): Date | DataAnomaly {
if (data[key] === undefined)
return DataAnomaly.DataNotFound;
if (typeof data[key] !== 'string')
return DataAnomaly.Invalid;
const date = new Date(data[key] as string);
if (isNaN(date.getTime()))
return DataAnomaly.Invalid;
return date;
}
export function toMetaDataType(src: unknown): PostMetaData {
if (typeof src !== 'object' || src === null) {
console.log(src);
throw new TypeError('Expected an object');
}
const data = src as Record<string, unknown>;
const created_at: Date | DataAnomaly = getDate(data, 'created_at');
const published_at: Date | DataAnomaly = getDate(data, 'published_at');
let updated_at: Date[] | DataAnomaly;
if (data.updated_at === undefined) {
updated_at = DataAnomaly.DataNotFound;
} else if (!Array.isArray(data.updated_at)) {
updated_at = DataAnomaly.Invalid;
} else {
updated_at = data.updated_at.map((x: string) => new Date(x));
updated_at = updated_at.reduce((last, cur) => last || isNaN(cur.getTime()), false) ? DataAnomaly.Invalid : updated_at;
}
let tech_stack: string[] | DataAnomaly;
if (data.tech_stack === undefined) {
tech_stack = DataAnomaly.DataNotFound;
} else if (!Array.isArray(data.tech_stack)) {
tech_stack = DataAnomaly.Invalid;
} else {
tech_stack = data.tech_stack;
}
let tech_stack_percent: number[] | DataAnomaly;
if (data.tech_stack_percent === undefined) {
tech_stack_percent = DataAnomaly.DataNotFound;
} else if (!Array.isArray(data.tech_stack_percent)) {
tech_stack_percent = DataAnomaly.Invalid;
} else {
tech_stack_percent = data.tech_stack_percent;
}
let tech_stack_icon_names: string[] | DataAnomaly;
if (data.tech_stack_icon_names === undefined) {
tech_stack_icon_names = DataAnomaly.DataNotFound;
} else if (!Array.isArray(data.tech_stack_icon_names)) {
tech_stack_icon_names = DataAnomaly.Invalid;
} else {
tech_stack_icon_names = data.tech_stack_icon_names;
}
let tech_stack_theme_colors: string[] | DataAnomaly;
if (data.tech_stack_theme_colors === undefined) {
tech_stack_theme_colors = DataAnomaly.DataNotFound;
} else if (!Array.isArray(data.tech_stack_theme_colors)) {
tech_stack_theme_colors = DataAnomaly.Invalid;
} else {
tech_stack_theme_colors = data.tech_stack_theme_colors;
}
let wordCountResult;
if (data.rawbody === undefined)
wordCountResult = DataAnomaly.DataNotFound;
else if (typeof data.rawbody !== 'string')
wordCountResult = DataAnomaly.Invalid;
else {
const contentWithoutMetaData = data.rawbody.replace(/^---[\s\S]*?---\n?/, '');
wordCountResult = wordCount(contentWithoutMetaData);
}
return {
id: String(data.id),
title: String(data.title),
description: String(data.description ?? ''),
category: String(data.category ?? '无'),
type: String(data.type ?? 'article'),
created_at: created_at,
published_at: published_at,
draft: Boolean(data.draft ?? false),
updated_at: updated_at,
tags: Array.isArray(data.tags) ? data.tags.map(String) : [],
tech_stack: tech_stack,
tech_stack_percent: tech_stack_percent,
tech_stack_icon_names: tech_stack_icon_names,
tech_stack_theme_colors: tech_stack_theme_colors,
word_count: wordCountResult,
};
}
export const defaultMetaData = toMetaDataType({
id: 'default ID',
title: 'ApiFox / Postman 使用WebSocket连接SignalR进行测试需要注意的小问题',
description: 'default Description',
created_at: new Date('2025-01-01T00:00:00Z'), // 默认创建时间
published_at: new Date('2025-01-01T00:01:00Z'), // 默认发布时间
draft: true,
updated_at: [new Date('2025-01-01T00:02:00Z'), new Date('2025-01-01T00:03:00Z')], // 默认更新时间
tags: ['C#', 'TS', 'Windows Professional version with Webstorm 2025', 'Windows Professional version with Visual Studio 2022'],
tech_stack: new Map([
['Vue', 5],
['Nuxt', 3],
['TypeScript', 4],
['JavaScript', 2],
['CSS', 1],
['HTML', 1],
['Node.js', 2],
['Python', 3],
['Java', 2],
['C#', 1],
]),
});

View File

@ -0,0 +1,5 @@
import { useBreakpoints } from '@vueuse/core';
import breakpoints from '~/configs/breakpoints';
const breakpointsHelper = useBreakpoints(breakpoints);
export default breakpointsHelper;

110
utils/ColorHelper.ts Normal file
View File

@ -0,0 +1,110 @@
// RGB 转 HSL
export function rgbToHsl(rgb: number[]) {
if (rgb.length !== 3) {
throw new Error('Input must be an array of three numbers representing RGB values.');
}
let [r, g, b] = rgb;
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h = 0, s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
}
h /= 6;
}
return [h * 360, s * 100, l * 100];
}
// HSL 转 RGB
export function hslToRgb(hsl: number[]) {
if (hsl.length !== 3) {
throw new Error('Input must be an array of three numbers representing HSL values.');
}
let [h, s, l] = hsl;
h /= 360;
s /= 100;
l /= 100;
let r, g, b;
if (s === 0) {
r = g = b = l;
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}
export function toRGBArray(color: string): number[] {
// 处理 rgb/rgba
const rgbMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)/);
if (rgbMatch) {
return [parseInt(rgbMatch[1]), parseInt(rgbMatch[2]), parseInt(rgbMatch[3])];
}
// 处理 #fff 或 #ffffff
const hexMatch = color.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
if (hexMatch) {
let hex = hexMatch[1];
if (hex.length === 3) {
hex = hex.split('').map((x) => x + x).join('');
}
const num = parseInt(hex, 16);
return [
(num >> 16) & 255,
(num >> 8) & 255,
num & 255,
];
}
throw new Error('Invalid color format');
}
export function toHexString(rgb: number[]): string {
let ans = '#';
for (const value of rgb) {
if (value < 0 || value > 255) {
throw new Error('RGB values must be in the range 0-255.');
}
ans += value.toString(16).padStart(2, '0');
}
return ans.toUpperCase();
}
export function toLightColor(rgb: number[]): number[] {
const hsl = rgbToHsl(rgb);
if (hsl[2] < 50)
hsl[2] = hsl[2] / 5 + 50; // 增加亮度
return hslToRgb(hsl);
}
export function toDarkColor(rgb: number[]): number[] {
const hsl = rgbToHsl(rgb);
if (hsl[2] > 50)
hsl[2] = 50 - (hsl[2] - 50) / 5; // 减少亮度
return hslToRgb(hsl);
}