✨ 主页布局构建完成
This commit is contained in:
parent
b8e2f4282d
commit
1fb97e1cc2
|
@ -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
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "content"]
|
||||
path = content
|
||||
url = https://git.lichx.top/li_chx/BlogArticles
|
36
app.vue
36
app.vue
|
@ -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>
|
||||
|
|
|
@ -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*/
|
||||
}
|
||||
|
|
|
@ -0,0 +1,148 @@
|
|||
<script setup lang="ts">
|
||||
|
||||
import { DataAnomaly, toArticleMetaDataType } from '~/types/ArticleMetaData';
|
||||
import type { ArticleMetaData } from '~/types/ArticleMetaData';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
article?: ArticleMetaData;
|
||||
}>(),
|
||||
{
|
||||
article: () => toArticleMetaDataType({
|
||||
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'],
|
||||
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],
|
||||
]),
|
||||
}),
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
// const { colorMode } = storeToRefs(useColorModeStore());
|
||||
// console.log(props.article.tech_stack)
|
||||
|
||||
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">
|
||||
<div class="text-4xl">
|
||||
{{ props.article.title }}
|
||||
</div>
|
||||
<div class="flex items-center mt-2">
|
||||
|
||||
<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="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 mt-2 justify-between h-28">
|
||||
<div>
|
||||
<div class="">
|
||||
{{ props.article.description }}
|
||||
</div>
|
||||
</div>
|
||||
<TechStackCard
|
||||
:async-key="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"
|
||||
/>
|
||||
</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">
|
||||
{{ '第' + 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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
|
@ -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>
|
|
@ -10,4 +10,4 @@
|
|||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
|
@ -0,0 +1,226 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import Highcharts from 'highcharts';
|
||||
import { DataAnomaly } from '~/types/ArticleMetaData';
|
||||
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<{
|
||||
techStack?: string[] | DataAnomaly;
|
||||
techStackPercent?: number[] | DataAnomaly;
|
||||
techStackIconNames?: string[] | DataAnomaly;
|
||||
techStackThemeColors?: string[] | DataAnomaly;
|
||||
asyncKey: string;
|
||||
}>(), {
|
||||
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());
|
||||
|
||||
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>
|
|
@ -0,0 +1,9 @@
|
|||
const breakpoints = {
|
||||
'sm': '640px',
|
||||
'md': '768px',
|
||||
'lg': '1024px',
|
||||
'xl': '1280px',
|
||||
'2xl': '1536px',
|
||||
'hidden-logo': '1736px',
|
||||
};
|
||||
export default breakpoints;
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 58eedf0bd1299ee92b64d0cc97f2f884a2b77ad2
|
|
@ -0,0 +1,25 @@
|
|||
import { defineContentConfig, defineCollection, z } from '@nuxt/content';
|
||||
|
||||
export default defineContentConfig({
|
||||
collections: {
|
||||
content: defineCollection({
|
||||
type: 'page',
|
||||
source: '**/*.md',
|
||||
schema: z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
description: 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([]),
|
||||
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(),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
|
@ -0,0 +1,151 @@
|
|||
<script setup lang="ts">
|
||||
import type { NavigationMenuItem } from '@nuxt/ui';
|
||||
import useColorModeStore from '~/stores/colorModeStore';
|
||||
import { useWindowScroll } from '@vueuse/core';
|
||||
// import gsap from 'gsap';
|
||||
// import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
|
||||
const { colorMode } = storeToRefs(useColorModeStore());
|
||||
const isHome = computed(() => useRoute().path === '/');
|
||||
const items = ref<NavigationMenuItem[]>([
|
||||
{
|
||||
label: '首页',
|
||||
icon: 'i-lucide-home',
|
||||
to: '/',
|
||||
// 如果使用isHome.value 将失去响应性 但是active只接受boolean 或 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 headerHeight = ref(0);
|
||||
const isScrollDown = ref(false);
|
||||
// gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
watch(scrollY, (newY) => {
|
||||
if (newY > 0 && !collapsed.value) {
|
||||
collapsed.value = true;
|
||||
headerHeight.value = 0;
|
||||
}
|
||||
isScrollDown.value = newY > 215;
|
||||
});
|
||||
|
||||
</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 w-full h-full absolute bg-[url('/79d52228c770808810a310115567e6790380823a.png')] bg-cover bg-top">
|
||||
<slot name="header"/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex w-full h-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 flex justify-center transition-all duration-500 dark:bg-gray-800/60 bg-old-neutral-50/40 backdrop-blur-sm dark:backdrop-blur-md">
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div class="not-xl:hidden">
|
||||
<slot name="navbarLeft" :is-scroll-down="isScrollDown"/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="transition-all duration-500 flex 2xl:w-[1240px] xl:w-[1020px] lg:w-[964px] md:w-[708px] sm:w-[580px] w-[400px]">
|
||||
<UNavigationMenu :items="items" :class="colorMode" class="w-full"/>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<slot name="navbarRight" :is-scroll-down="isScrollDown"/>
|
||||
</div>
|
||||
</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-[400px]">
|
||||
<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>
|
|
@ -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,21 @@ 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: {
|
||||
script: [{ src: '/darkVerify.js' }],
|
||||
},
|
||||
},
|
||||
sourcemap: {
|
||||
server: true,
|
||||
client: true
|
||||
}
|
||||
});
|
||||
|
|
16
package.json
16
package.json
|
@ -10,27 +10,35 @@
|
|||
"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",
|
||||
"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",
|
||||
"typescript-eslint": "^8.35.1",
|
||||
"vue-eslint-parser": "^10.2.0"
|
||||
}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
admin
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
|
@ -0,0 +1,90 @@
|
|||
<script setup lang="ts">
|
||||
// onMounted(() =>
|
||||
// useColorMode().preference = 'dark',
|
||||
// );
|
||||
// function clickHandler() {
|
||||
// console.log('Button clicked');
|
||||
// useColorMode().preference = useColorMode().preference === 'light'? 'dark' : 'light';
|
||||
// }
|
||||
|
||||
import useColorModeStore from '~/stores/colorModeStore';
|
||||
|
||||
// const { colorMode } = storeToRefs(useColorModeStore());
|
||||
</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" 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">
|
||||
随机存取
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div>
|
||||
<NuxtRouteAnnouncer/>
|
||||
<NuxtPage/>
|
||||
<button
|
||||
@click="() => {
|
||||
useColorModeStore().toggleColorMode();
|
||||
}
|
||||
">swap Theme
|
||||
</button>
|
||||
<div class="min-h-[100vh]"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="w-full flex-1 justify-center flex items-center"></div>
|
||||
</template>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
|
@ -0,0 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
|
@ -0,0 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
Article
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
|
@ -1,45 +1,57 @@
|
|||
<script setup lang="ts">
|
||||
|
||||
import type { NavigationMenuItem } from '@nuxt/ui';
|
||||
import { toArticleMetaDataType } from '~/types/ArticleMetaData';
|
||||
|
||||
const { data: articles } = useAsyncData(async () => await queryCollection('content').all());
|
||||
|
||||
onMounted(() => {
|
||||
console.log(articles.value);
|
||||
});
|
||||
|
||||
// // 监听断点变化并设置延迟策略
|
||||
// watch(showLeftDetailInfo, (newValue, oldValue) => {
|
||||
// if (newValue && !oldValue) {
|
||||
// // xl -> 2xl (显示) - 需要延迟
|
||||
// shouldDelay.value = true;
|
||||
// isTransitioning.value = true;
|
||||
// } else if (!newValue && oldValue) {
|
||||
// // 2xl -> xl (隐藏) - 不延迟
|
||||
// shouldDelay.value = false;
|
||||
// isTransitioning.value = true;
|
||||
// }
|
||||
//
|
||||
// // 动画完成后重置状态
|
||||
// setTimeout(() => {
|
||||
// isTransitioning.value = false;
|
||||
// }, 400); // 根据你的动画时长调整
|
||||
// });
|
||||
|
||||
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="mt-4">
|
||||
<div class="table w-full">
|
||||
<div class="sticky top-16 float-left bg-old-neutral-500 h-[100vh]">
|
||||
<div class="relative duration-500 transition-all xl:w-80 w-0 mr-2/3 h-full overflow-hidden">
|
||||
<div class="absolute top-0 left-0 w-80 text-white z-50">
|
||||
<div class="">
|
||||
test123456
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transition-all duration-500 float-right xl:w-[calc(100%-20rem-40px)] w-full">
|
||||
<ArticleCard
|
||||
v-for="article in articles" :key="article.id"
|
||||
class="mb-4 w-full"
|
||||
:article="toArticleMetaDataType(article)"/>
|
||||
<ArticleCard/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</UApp>
|
||||
test
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
|
9916
pnpm-lock.yaml
9916
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Binary file not shown.
After Width: | Height: | Size: 3.9 MiB |
Binary file not shown.
After Width: | Height: | Size: 2.5 MiB |
|
@ -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');
|
||||
}
|
|
@ -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;
|
|
@ -1,4 +0,0 @@
|
|||
export const store = defineStore('counter', {
|
||||
|
||||
});
|
||||
export default store;
|
|
@ -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>';
|
||||
}
|
|
@ -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',
|
|
@ -0,0 +1,118 @@
|
|||
import wordCount from 'word-count';
|
||||
|
||||
export enum DataAnomaly {
|
||||
DataNotFound = 'DataNotFound',
|
||||
Invalid = 'DataInvalid',
|
||||
}
|
||||
|
||||
export type ArticleMetaData = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
created_at: Date | DataAnomaly;
|
||||
published_at: Date | DataAnomaly;
|
||||
draft: boolean;
|
||||
updated_at: Date[] | DataAnomaly;
|
||||
tags: 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 toArticleMetaDataType(src: unknown): ArticleMetaData {
|
||||
if (typeof src !== 'object' || src === null) {
|
||||
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 ?? ''),
|
||||
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,
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
}
|
Loading…
Reference in New Issue