Compare commits
13
Commits
b8e2f4282d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dacc3d5146 | ||
|
|
2ab2bba546 | ||
|
|
ef224f1326 | ||
|
|
886fcfde70 | ||
|
|
0b83b687d9 | ||
|
|
9043cf53fd | ||
|
|
6c963257cf | ||
|
|
1cefa4c661 | ||
|
|
51a39d498b | ||
|
|
75ba2bf5c8 | ||
|
|
c1be9ebdbb | ||
|
|
363eeb74dd | ||
|
|
1fb97e1cc2 |
@@ -22,3 +22,11 @@ logs
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
pnpm-lock.yaml
|
||||||
|
pnpm-workspace.yaml
|
||||||
|
/client_ chrome.run.xml
|
||||||
|
/nuxt.run.xml
|
||||||
|
/server_ nuxt.run.xml
|
||||||
|
/output.tar.gz
|
||||||
|
.pnpm-store
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "content"]
|
||||||
|
path = content
|
||||||
|
url = https://git.lichx.top/li_chx/BlogArticles
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
FROM cnbcool/default-build-env:latest
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y wget unzip lsof nload htop net-tools dnsutils openssh-server zsh openssh-server command-not-found && \
|
||||||
|
apt-get clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /root
|
||||||
|
RUN git clone https://git.lichx.top/li_chx/shell-config.git && \
|
||||||
|
cp -r shell-config/.* . && \
|
||||||
|
wget https://download.jetbrains.com/webstorm/WebStorm-2025.2.5.tar.gz && \
|
||||||
|
mkdir -p /ide_cnb && \
|
||||||
|
tar -zxvf WebStorm-2025.2.5.tar.gz -C /ide_cnb && \
|
||||||
|
rm WebStorm-2025.2.5.tar.gz && \
|
||||||
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash && \
|
||||||
|
source ~/.bashrc && \
|
||||||
|
nvm install 24 && \
|
||||||
|
npm install --global corepack@latest && \
|
||||||
|
corepack enable pnpm && \
|
||||||
|
apt update
|
||||||
|
|
||||||
|
# 单独处理 zsh 配置
|
||||||
|
RUN zsh -c "source ~/.zshrc" && \
|
||||||
|
echo "zsh" >> ~/.bashrc
|
||||||
|
|
||||||
|
RUN curl -fsSL https://code-server.dev/install.sh | sh \
|
||||||
|
&& code-server --install-extension nuxtr.nuxt-vscode-extentions \
|
||||||
|
&& code-server --install-extension bradlc.vscode-tailwindcss \
|
||||||
|
&& code-server --install-extension tencent-cloud.coding-copilot \
|
||||||
|
&& code-server --install-extension ms-vscode.vs-keybindings
|
||||||
|
|
||||||
|
ENV LANG C.UTF-8
|
||||||
|
ENV LANGUAGE C.UTF-8
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"vue.volar",
|
||||||
|
"vue.vscode-typescript-vue-plugin",
|
||||||
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"ms-vscode.vscode-typescript-next",
|
||||||
|
"nuxtr.nuxt-vscode-extentions"
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+26
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"typescript.preferences.includePackageJsonAutoImports": "on",
|
||||||
|
"typescript.suggest.autoImports": true,
|
||||||
|
"typescript.updateImportsOnFileMove.enabled": "always",
|
||||||
|
"vue.codeActions.enabled": true,
|
||||||
|
"vue.complete.casing.tags": "kebab",
|
||||||
|
"vue.complete.casing.props": "camel",
|
||||||
|
"vetur.validation.template": false,
|
||||||
|
"vetur.validation.script": false,
|
||||||
|
"vetur.validation.style": false,
|
||||||
|
"emmet.includeLanguages": {
|
||||||
|
"vue": "html"
|
||||||
|
},
|
||||||
|
"files.associations": {
|
||||||
|
"*.vue": "vue"
|
||||||
|
},
|
||||||
|
"editor.quickSuggestions": {
|
||||||
|
"strings": true
|
||||||
|
},
|
||||||
|
"editor.tabSize": 2,
|
||||||
|
"editor.insertSpaces": true,
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit"
|
||||||
|
}
|
||||||
|
}
|
||||||
-36
@@ -1,36 +0,0 @@
|
|||||||
## 功能清单 / TODO
|
|
||||||
- [ ] 主页/
|
|
||||||
- [ ] 网站标题
|
|
||||||
- [ ] 文章列表(时间排序)
|
|
||||||
- [ ] 文章标题
|
|
||||||
- [ ] 文章摘要
|
|
||||||
- [ ] 文章标签
|
|
||||||
- [ ] 个人信息展示
|
|
||||||
- [ ] RSS 订阅
|
|
||||||
- [ ] 登录管理页面
|
|
||||||
- [ ] 文章详情页/post
|
|
||||||
- [ ] 文章标题
|
|
||||||
- [ ] 文章内容
|
|
||||||
- [ ] 文章标签
|
|
||||||
- [ ] 评论区 ??
|
|
||||||
- [ ] 评论列表
|
|
||||||
- [ ] 评论表单
|
|
||||||
- [ ] 评论回复
|
|
||||||
- [ ] 评论点赞
|
|
||||||
- [ ] 评论删除
|
|
||||||
- [ ] 上一篇/下一篇文章链接
|
|
||||||
- [ ] 归档页/archive
|
|
||||||
- [ ] 按时间归档
|
|
||||||
- [ ] 按标签归档
|
|
||||||
- [ ] 搜索
|
|
||||||
- [ ] 友链/friend
|
|
||||||
- [ ] 关于
|
|
||||||
- [ ] 后台管理/admin security??
|
|
||||||
- [ ] 文章编辑
|
|
||||||
- [ ] 新建文章
|
|
||||||
- [ ] 编辑文章
|
|
||||||
- [ ] 删除文章
|
|
||||||
- [ ] 文章预览
|
|
||||||
- [ ] markdown mermaid ... 支持
|
|
||||||
- [ ] 友链管理
|
|
||||||
- [ ] 个人页面管理
|
|
||||||
@@ -1,6 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<NuxtRouteAnnouncer />
|
<NuxtRouteAnnouncer/>
|
||||||
<NuxtPage />
|
<NuxtPage/>
|
||||||
</div>
|
</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 "@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,216 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PostMetaData } from '~/types/PostMetaData';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
postsMetaData?: PostMetaData[];
|
||||||
|
}>(), {
|
||||||
|
postsMetaData: () => [],
|
||||||
|
});
|
||||||
|
const emits = defineEmits<{
|
||||||
|
(event: 'filterRuleChange', rule: (data: PostMetaData) => boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const typeGroup = computed(() => {
|
||||||
|
const articleCount = computed(() => props.postsMetaData?.filter((post) => !post.draft && post.type === 'article').length || 0);
|
||||||
|
const announcementCount = computed(() => props.postsMetaData?.filter((post) => !post.draft && post.type === 'announcement').length || 0);
|
||||||
|
const ramblingCount = computed(() => props.postsMetaData?.filter((post) => !post.draft && post.type === 'rambling').length || 0);
|
||||||
|
return [
|
||||||
|
{ name: '文章', count: articleCount, type: 'article' },
|
||||||
|
{ name: '絮语', count: ramblingCount, type: 'rambling' },
|
||||||
|
{ name: '公告', count: announcementCount, type: 'announcement' },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const typeEnableStatus: Ref<boolean[]> = ref(Array(typeGroup.value.length).fill(true));
|
||||||
|
|
||||||
|
const categories = computed(() => {
|
||||||
|
const categoryMap = new Map<string, number>();
|
||||||
|
props.postsMetaData?.forEach((post) => {
|
||||||
|
if (post.category) {
|
||||||
|
categoryMap.set(post.category, (categoryMap.get(post.category) || 0) + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let categoryArray = Array.from(categoryMap.entries());
|
||||||
|
categoryArray = categoryArray.sort((a, b) => b[1] - a[1]);
|
||||||
|
return categoryArray;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tags = computed(() => {
|
||||||
|
const tagMap = new Map<string, number>();
|
||||||
|
props.postsMetaData?.forEach((post) => {
|
||||||
|
post.tags?.forEach((tag) => {
|
||||||
|
tagMap.set(tag, (tagMap.get(tag) || 0) + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let tagArray = Array.from(tagMap.entries());
|
||||||
|
tagArray = tagArray.sort((a, b) => b[1] - a[1]);
|
||||||
|
return tagArray;
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoriesEnableStatus: Ref<boolean[]> = ref(new Array(categories.value.length).fill(true));
|
||||||
|
const tagsEnableStatus: Ref<boolean[]> = ref(new Array(tags.value.length).fill(true));
|
||||||
|
|
||||||
|
function updateTypeEnableStatus(index: number) {
|
||||||
|
if (typeEnableStatus.value.reduce((last, cur) => last && cur, true)) {
|
||||||
|
for (let i = 0; i < typeEnableStatus.value.length; i++) {
|
||||||
|
if (i !== index) {
|
||||||
|
typeEnableStatus.value[i] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!typeEnableStatus.value.reduce((last, cur, localIndex) => last || (localIndex === index ? false : cur), false)) {
|
||||||
|
for (let i = 0; i < typeEnableStatus.value.length; i++) {
|
||||||
|
typeEnableStatus.value[i] = true;
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
typeEnableStatus.value[index] = !typeEnableStatus.value[index];
|
||||||
|
updateRule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCategoryEnableStatus(index: number) {
|
||||||
|
if (categoriesEnableStatus.value.reduce((last, cur) => last && cur, true)) {
|
||||||
|
for (let i = 0; i < categoriesEnableStatus.value.length; i++) {
|
||||||
|
if (i !== index) {
|
||||||
|
categoriesEnableStatus.value[i] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (categoriesEnableStatus.value[index] && !categoriesEnableStatus.value.reduce((last, cur, localIndex) => last || (localIndex === index ? false : cur), false)) {
|
||||||
|
for (let i = 0; i < categoriesEnableStatus.value.length; i++) {
|
||||||
|
categoriesEnableStatus.value[i] = true;
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
categoriesEnableStatus.value[index] = !categoriesEnableStatus.value[index];
|
||||||
|
updateRule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTagEnableStatus(index: number) {
|
||||||
|
if (tagsEnableStatus.value.reduce((last, cur) => last && cur, true)) {
|
||||||
|
for (let i = 0; i < tagsEnableStatus.value.length; i++) {
|
||||||
|
if (i !== index) {
|
||||||
|
tagsEnableStatus.value[i] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (tagsEnableStatus.value[index] && !tagsEnableStatus.value.reduce((last, cur, localIndex) => last || (localIndex === index ? false : cur), false)) {
|
||||||
|
for (let i = 0; i < tagsEnableStatus.value.length; i++) {
|
||||||
|
tagsEnableStatus.value[i] = true;
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
tagsEnableStatus.value[index] = !tagsEnableStatus.value[index];
|
||||||
|
updateRule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRule() {
|
||||||
|
const enabledCategories = categoriesEnableStatus.value.reduce((last, cur, localIndex) => {
|
||||||
|
if (cur) last.add(categories.value[localIndex]![0]);
|
||||||
|
return last;
|
||||||
|
}, new Set<string>());
|
||||||
|
const enabledTags = tagsEnableStatus.value.reduce((last, cur, localIndex) => {
|
||||||
|
if (cur) last.add(tags.value[localIndex]![0]);
|
||||||
|
return last;
|
||||||
|
}, new Set<string>());
|
||||||
|
// const enable
|
||||||
|
emits('filterRuleChange', (post) => {
|
||||||
|
// type check
|
||||||
|
let tempAns = false;
|
||||||
|
for (let i = 0; i < typeEnableStatus.value.length; i++) {
|
||||||
|
if (typeEnableStatus.value[i] && post.type === typeGroup.value[i]!.type) {
|
||||||
|
tempAns = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// category check
|
||||||
|
if (tempAns)
|
||||||
|
tempAns = false;
|
||||||
|
else
|
||||||
|
return false;
|
||||||
|
if (post.category && enabledCategories.has(post.category))
|
||||||
|
tempAns = true;
|
||||||
|
else tempAns = !post.category && enabledCategories.size === categories.value.length;
|
||||||
|
// tag check
|
||||||
|
if (tempAns)
|
||||||
|
tempAns = false;
|
||||||
|
else
|
||||||
|
return false;
|
||||||
|
if (tagsEnableStatus.value.length === enabledTags.size)
|
||||||
|
tempAns = true;
|
||||||
|
else for (const tag of post.tags || []) {
|
||||||
|
if (enabledTags.has(tag)) {
|
||||||
|
tempAns = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tempAns;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="bg-old-neutral-200 dark:bg-old-neutral-800 p-5">
|
||||||
|
<div class="text-2xl ml-1 flex items-center">
|
||||||
|
<Icon class="mr-2" name="material-symbols:category"/>
|
||||||
|
类型
|
||||||
|
</div>
|
||||||
|
<hr class="border-0 h-[1px] bg-old-neutral-600 mt-3 mb-1"/>
|
||||||
|
<div class="flex mt-4">
|
||||||
|
<div
|
||||||
|
v-for="(data, index) of typeGroup"
|
||||||
|
:key="data.name"
|
||||||
|
class="flex items-center flex-col flex-1 text-xl cursor-pointer hover:text-sky-400 dark:hover:text-[#cccaff] transition-colors duration-300"
|
||||||
|
:class="{'text-old-neutral-400': !typeEnableStatus[index]}"
|
||||||
|
@click="updateTypeEnableStatus(index)"
|
||||||
|
>
|
||||||
|
<div>{{ data.name }}</div>
|
||||||
|
<div>{{ data.count }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-old-neutral-200 dark:bg-old-neutral-800 p-5 mt-4">
|
||||||
|
<div class="text-2xl ml-1 flex items-center">
|
||||||
|
<Icon class="mr-2" name="material-symbols:book"/>
|
||||||
|
分类
|
||||||
|
</div>
|
||||||
|
<hr class="border-0 h-[1px] bg-old-neutral-600 mt-3 mb-1"/>
|
||||||
|
<div
|
||||||
|
v-for="([name,count],index) of categories" :key="index"
|
||||||
|
class="flex justify-between pl-4 pr-4 hover:text-sky-400 dark:hover:text-[#cccaff] transition-colors duration-300"
|
||||||
|
:class="{'text-old-neutral-400': !categoriesEnableStatus[index]}"
|
||||||
|
@click="updateCategoryEnableStatus(index)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Icon
|
||||||
|
name="material-symbols:book-outline"
|
||||||
|
size="17"
|
||||||
|
class="mt-0.5 mr-1"
|
||||||
|
/>
|
||||||
|
<div>{{ name }}</div>
|
||||||
|
</div>
|
||||||
|
<div>{{ count }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-old-neutral-200 dark:bg-old-neutral-800 p-5 mt-4">
|
||||||
|
<div class="text-2xl ml-1 flex items-center">
|
||||||
|
<Icon class="mr-2" name="material-symbols:bookmarks"/>
|
||||||
|
标签
|
||||||
|
</div>
|
||||||
|
<hr class="border-0 h-[1px] bg-old-neutral-600 mt-3 mb-1"/>
|
||||||
|
<div class="flex flex-wrap">
|
||||||
|
<div
|
||||||
|
v-for="([name,count],index) of tags" :key="index"
|
||||||
|
class="flex items-center justify-between text-[15px] pl-2 pr-2 m-1 rounded-2xl shadow-[0_0_0_1px_#888] hover:text-sky-400 dark:hover:text-[#cccaff] hover:shadow-[0_0_0_1px_#00bcff] dark:hover:shadow-[0_0_0_1px_#cccaff] transition-shadow duration-300"
|
||||||
|
:class="{'text-old-neutral-400': !tagsEnableStatus[index]}"
|
||||||
|
@click="updateTagEnableStatus(index)"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="clarity:hashtag-solid"
|
||||||
|
size="17"
|
||||||
|
class="mr-1 "
|
||||||
|
/>
|
||||||
|
<div class="mr-1">{{ name }}</div>
|
||||||
|
<div class="">{{ count }}</div>
|
||||||
|
</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>
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="bg-old-neutral-200 dark:bg-old-neutral-800 p-5">
|
||||||
|
Author: Lichx
|
||||||
|
<div>
|
||||||
|
Contact me:
|
||||||
|
<a href="mailto:li_chx@qq.com" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<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!',
|
||||||
|
});
|
||||||
|
const eraseHeaderMarkdown = computed(() => props.markdown.replace(/^---[\s\S]*?---\n?/, ''));
|
||||||
|
|
||||||
|
const { colorMode } = storeToRefs(useColorModeStore());
|
||||||
|
|
||||||
|
const mounted = ref(false);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
mounted.value = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="pt-0 bg-old-neutral-200 dark:bg-old-neutral-800">
|
||||||
|
<MdPreview
|
||||||
|
v-if="mounted"
|
||||||
|
:key="editorId + '-' + colorMode"
|
||||||
|
:editor-id="editorId"
|
||||||
|
:theme="colorMode"
|
||||||
|
:model-value="eraseHeaderMarkdown"
|
||||||
|
class="max-w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.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));
|
||||||
|
}
|
||||||
|
:deep(.md-editor-preview blockquote) {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.md-editor-preview .md-editor-code .md-editor-code-head) {
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(ul) {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(ol) {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
: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>
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
<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',
|
||||||
|
reflow: false,
|
||||||
|
},
|
||||||
|
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: colorMode.value === 'light' ? '#4e4d55' : '#fff',
|
||||||
|
textOutline: 'none',
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mounted = ref(false);
|
||||||
|
onMounted(() => {
|
||||||
|
mounted.value = true;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full">
|
||||||
|
<div v-if="!mounted||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,8 @@
|
|||||||
|
const breakpoints = {
|
||||||
|
'sm': '640px',
|
||||||
|
'md': '768px',
|
||||||
|
'lg': '1024px',
|
||||||
|
'xl': '1280px',
|
||||||
|
'2xl': '1536px',
|
||||||
|
};
|
||||||
|
export default breakpoints;
|
||||||
Submodule
+1
Submodule content added at 2865b12ed9
@@ -0,0 +1,30 @@
|
|||||||
|
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', 'announcement']).default('article'),
|
||||||
|
isPinned: z.boolean().default(false),
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<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 将失去响应性 但是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 isScrollDown = ref(false);
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mounted = ref(false);
|
||||||
|
onMounted(() => {
|
||||||
|
mounted.value = true;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-old-neutral-50 dark:bg-[#0b0d0d] w-full min-h-[100vh] h-full">
|
||||||
|
<UApp>
|
||||||
|
<div
|
||||||
|
:class=" (collapsed ? 'h-[20vh]': 'h-[40vh]')"
|
||||||
|
class="flex flex-col relative transition-[height] duration-500 max-h-80">
|
||||||
|
<!-- header -->
|
||||||
|
<div
|
||||||
|
v-if="mounted"
|
||||||
|
@mouseenter="() => {
|
||||||
|
if(scrollY === 0) {
|
||||||
|
collapsed = false;
|
||||||
|
}
|
||||||
|
}" @mouseleave="collapsed = true">
|
||||||
|
<div
|
||||||
|
v-if="colorMode === 'light'"
|
||||||
|
class="flex bg-top absolute w-full h-full bg-[url('/79d52228c770808810a310115567e6790380823a.webp')] ">
|
||||||
|
<slot name="header"/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex bg-top absolute w-full h-full bg-[url('/anime-8788959.webp')]">
|
||||||
|
<slot name="header"/>
|
||||||
|
</div>
|
||||||
|
<!-- header picture -->
|
||||||
|
<div v-if="isScrollDown">
|
||||||
|
<div
|
||||||
|
v-if="colorMode === 'light'"
|
||||||
|
class="opacity-80 max-h-[48px] flex w-full h-full fixed bg-[url('/79d52228c770808810a310115567e6790380823a.webp')] bg-cover bg-top"/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="opacity-20 max-h-[48px] flex w-full h-full fixed bg-[url('/anime-8788959.webp')] bg-cover bg-center"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 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-[width] duration-500 flex 2xl:w-[1240px] xl:w-[1020px] lg:w-[964px] md:w-[708px] sm:w-[580px] w-10/12">
|
||||||
|
<UNavigationMenu v-if="mounted" :items="items" :class="colorMode" class="w-full"/>
|
||||||
|
<div v-else class="w-full h-12 animate-pulse"></div>
|
||||||
|
</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>
|
||||||
|
<!-- content -->
|
||||||
|
<div class="flex justify-center items-center bg-white dark:bg-[#16191b] h-full">
|
||||||
|
<div
|
||||||
|
:class="collapsed ? 'min-h-[80vh]' : 'min-h-[60vh]'"
|
||||||
|
class="transition-[width] 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>
|
||||||
+21
-2
@@ -11,10 +11,29 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
modules: [
|
modules: [
|
||||||
'@nuxt/eslint',
|
'@nuxt/eslint',
|
||||||
'@nuxt/icon',
|
|
||||||
'@nuxt/ui',
|
'@nuxt/ui',
|
||||||
'@pinia/nuxt',
|
'@pinia/nuxt',
|
||||||
'@nuxtjs/color-mode',
|
'@nuxt/content',
|
||||||
],
|
],
|
||||||
css: ['~/assets/css/main.css'],
|
css: ['~/assets/css/main.css'],
|
||||||
|
ui: {
|
||||||
|
colorMode: false,
|
||||||
|
fonts: false,
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
title: '随机存取',
|
||||||
|
htmlAttrs: {
|
||||||
|
lang: 'zh-CN',
|
||||||
|
},
|
||||||
|
meta: [
|
||||||
|
{ name: 'description', content: 'Lichx 个人博客' },
|
||||||
|
],
|
||||||
|
script: [{ src: '/darkVerify.js' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// sourcemap: {
|
||||||
|
// server: true,
|
||||||
|
// client: true,
|
||||||
|
// },
|
||||||
});
|
});
|
||||||
|
|||||||
+40
-14
@@ -10,28 +10,54 @@
|
|||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/eslint": "1.5.2",
|
"@nuxt/content": "^3.7.1",
|
||||||
"@nuxt/icon": "1.15.0",
|
"@nuxt/eslint": "1.9.0",
|
||||||
"@nuxt/ui": "3.2.0",
|
"@nuxt/icon": "^2.0.0",
|
||||||
"@nuxtjs/color-mode": "3.5.2",
|
"@nuxt/ui": "4.0.0",
|
||||||
"@pinia/nuxt": "^0.11.1",
|
"@pinia/nuxt": "^0.11.2",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"eslint": "^9.0.0",
|
"@vueuse/core": "^13.9.0",
|
||||||
"nuxt": "^3.17.6",
|
"better-sqlite3": "^12.4.1",
|
||||||
|
"eslint": "^9.36.0",
|
||||||
|
"gsap": "^3.13.0",
|
||||||
|
"highcharts": "^12.4.0",
|
||||||
|
"md-editor-v3": "^6.0.1",
|
||||||
|
"nuxt": "^4.1.2",
|
||||||
|
"overlayscrollbars-vue": "^0.5.9",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
|
"tailwind-scrollbar": "^4.0.2",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vue": "^3.5.17",
|
"vue": "^3.5.21",
|
||||||
"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.22.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@stylistic/eslint-plugin": "^5.1.0",
|
"@iconify-json/clarity": "^1.2.4",
|
||||||
|
"@iconify-json/codicon": "^1.2.32",
|
||||||
|
"@iconify-json/fluent": "^1.2.34",
|
||||||
|
"@iconify-json/lucide": "^1.2.68",
|
||||||
|
"@iconify-json/material-symbols": "^1.2.40",
|
||||||
|
"@iconify-json/octicon": "^1.2.19",
|
||||||
|
"@stylistic/eslint-plugin": "^5.4.0",
|
||||||
"@stylistic/eslint-plugin-jsx": "^4.4.1",
|
"@stylistic/eslint-plugin-jsx": "^4.4.1",
|
||||||
"@vue/eslint-config-typescript": "^14.6.0",
|
"@vue/eslint-config-typescript": "^14.6.0",
|
||||||
"eslint-plugin-vue": "^10.3.0",
|
"eslint-plugin-vue": "^10.5.0",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.4.0",
|
||||||
"typescript-eslint": "^8.35.1",
|
"less": "^4.4.1",
|
||||||
|
"overlayscrollbars": "^2.12.0",
|
||||||
|
"typescript-eslint": "^8.44.1",
|
||||||
"vue-eslint-parser": "^10.2.0"
|
"vue-eslint-parser": "^10.2.0"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"@parcel/watcher",
|
||||||
|
"better-sqlite3",
|
||||||
|
"esbuild",
|
||||||
|
"unrs-resolver",
|
||||||
|
"vue-demi"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,8 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
{{useRoute().params.articleID}}
|
admin
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import breakpointsHelper from '~/utils/BreakpointsHelper';
|
||||||
|
import ThemeChange from '~/pages/index/components/ThemeChange.vue';
|
||||||
|
|
||||||
|
const hitokoto = ref('加载中...');
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const src = await $fetch<{ hitokoto: string; from_who?: string; from?: string }>('https://v1.hitokoto.cn?c=k');
|
||||||
|
hitokoto.value = `${src.hitokoto} —— ${src.from_who ? src.from_who : '佚名'}${src.from ? `,${src.from}` : '未知来源'}`;
|
||||||
|
});
|
||||||
|
</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 ease-in-out">
|
||||||
|
<ThemeChange />
|
||||||
|
</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 class="max-w-full">
|
||||||
|
<NuxtRouteAnnouncer/>
|
||||||
|
<NuxtPage class="max-w-full"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="w-full flex flex-col justify-center items-center p-10 text-old-neutral-500">
|
||||||
|
<div>
|
||||||
|
{{ hitokoto }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
© 2025 随机存取. 由Lichx制作
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
蒙ICP备2025022865号
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="tsx">
|
||||||
|
const techStack = ['Vue', 'Nuxt', 'TypeScript', 'Python', 'Java', 'C#', 'Rust'];
|
||||||
|
const techStackPercent = [86, 80, 92, 75, 60, 90, 50];
|
||||||
|
const techStackIconNames = ['mdi:vuejs', 'lineicons:nuxt', 'mdi:language-typescript', 'mdi:language-python', 'mdi:language-java', 'mdi:language-csharp', 'mdi:language-rust'];
|
||||||
|
const techStackThemeColors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#FFB347', '#98D8C8', '#F7DC6F', '#BB8FCE'];
|
||||||
|
const mounted = ref(false);
|
||||||
|
onMounted(() => {
|
||||||
|
mounted.value = true;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="light:bg-old-neutral-200 dark:bg-old-neutral-800 p-5 mt-3">
|
||||||
|
<div class="text-2xl pb-2">关于作者</div>
|
||||||
|
lichx,目前就读于武汉理工大学,技术涉猎广泛但不精,仍在持续学习中 <br/>
|
||||||
|
</div>
|
||||||
|
<div class="light:bg-old-neutral-200 dark:bg-old-neutral-800 p-5 mt-3">
|
||||||
|
<div class="text-2xl pb-2">技术栈(相对熟练度)</div>
|
||||||
|
<TechStackCard
|
||||||
|
v-if="mounted" async-key="about page" :tech-stack="techStack"
|
||||||
|
:tech-stack-percent="techStackPercent" :tech-stack-icon-names="techStackIconNames"
|
||||||
|
:tech-stack-theme-colors="techStackThemeColors"/>
|
||||||
|
</div>
|
||||||
|
<div class="light:bg-old-neutral-200 dark:bg-old-neutral-800 p-5 mt-3">
|
||||||
|
<div class="text-2xl pb-2">在其他渠道关注 / 联系我</div>
|
||||||
|
<a href="https://github.com/li-chx" title="github,不常用">
|
||||||
|
<icon name="mdi:github" class="inline-block w-10 h-10 mr-4"/>
|
||||||
|
</a>
|
||||||
|
<a href="https://git.lichx.top/li_chx" title="gitea,主要代码托管平台">
|
||||||
|
<icon name="pajamas:gitea" class="inline-block w-10 h-10 mr-4"/>
|
||||||
|
</a>
|
||||||
|
<a href="https://leetcode.cn/u/gallant-paynekan/" title="leetcode,刷点水题骗骗自己">
|
||||||
|
<icon name="tabler:brand-leetcode" class="inline-block w-10 h-10 mr-4"/>
|
||||||
|
</a>
|
||||||
|
<a href="mailto:751176501@qq.com" title="能不能看见全看运气">
|
||||||
|
<icon name="material-symbols:mail-rounded" class="inline-block w-10 h-10"/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<div class="light:bg-old-neutral-200 dark:bg-old-neutral-800 p-5">
|
||||||
|
<div class="text-2xl pb-2">关于本站</div>
|
||||||
|
本站,随机存取,起这个名字纯粹是因为作者想不出来了。最后基于我对该网站的需求,决定叫随机存取<br/>
|
||||||
|
这个网站最重要的还是记录我认为有趣的 学习 / 调试 过程,便于我随时取用,所以写的时候完全就是梦到哪写到哪<br/>
|
||||||
|
但是,如果本人闲暇之余写的文章能帮到你,当是莫大的荣幸<br/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import AuthorDescription from '~/pages/index/about/compoents/AuthorDescription.vue';
|
||||||
|
import SiteDescription from '~/pages/index/about/compoents/SiteDescription.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="mt-6 mb-6 w-full">
|
||||||
|
<SiteDescription />
|
||||||
|
<AuthorDescription />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { DataAnomaly, sortMetaData } from '~/types/PostMetaData';
|
||||||
|
import type { PostMetaData } from '~/types/PostMetaData';
|
||||||
|
|
||||||
|
const articles = defineModel<PostMetaData[]>('metadata', {
|
||||||
|
default: () => [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
currentChoice?: 'time' | 'category';
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
currentChoice: 'time' as ('time' | 'category'),
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.currentChoice, (newChoice) => {
|
||||||
|
if (newChoice === 'time') {
|
||||||
|
sortMetaData(articles.value, 'published_at');
|
||||||
|
} else {
|
||||||
|
sortMetaData(articles.value, 'category');
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
function toArticlePage(article: PostMetaData) {
|
||||||
|
navigateTo(`/article/${encodeURIComponent(article.id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getYear(article: PostMetaData) {
|
||||||
|
return new Date(article?.published_at || 0).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">
|
||||||
|
<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>
|
||||||
|
<div class="flex items-center" @click="toArticlePage(article)">
|
||||||
|
<div :title="dateFormatToTime(article.published_at)" class="text-sm min-w-12">
|
||||||
|
{{ dateFormatToDate(article.published_at) }}
|
||||||
|
</div>
|
||||||
|
<div :title="dateFormatToTime(article.published_at)" class="text-md ml-10">
|
||||||
|
{{ 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>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { sortMetaData, toMetaDataType } from '~/types/PostMetaData';
|
||||||
|
import type { PostMetaData } from '~/types/PostMetaData';
|
||||||
|
import TimeLine from '~/pages/index/archive/components/TimeLine.vue';
|
||||||
|
import type { RadioGroupItem } from '@nuxt/ui';
|
||||||
|
|
||||||
|
const srcPostsMetaData = ref<PostMetaData[]>([]);
|
||||||
|
const postsMetaData = ref<PostMetaData[]>([]);
|
||||||
|
|
||||||
|
async function loadPostsMetaData() {
|
||||||
|
srcPostsMetaData.value = sortMetaData((await queryCollection('content').all()).map((x) => toMetaDataType(x)), 'published_at', true) || [];
|
||||||
|
srcPostsMetaData.value = srcPostsMetaData.value.filter((x) => !x.draft);
|
||||||
|
postsMetaData.value = srcPostsMetaData.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadPostsMetaData();
|
||||||
|
|
||||||
|
watch(srcPostsMetaData, () => {
|
||||||
|
postsMetaData.value = srcPostsMetaData.value || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentChoice = ref<'time' | 'category'>('time');
|
||||||
|
|
||||||
|
const choiceItems = ref<RadioGroupItem>([
|
||||||
|
{ label: '时间', value: 'time' },
|
||||||
|
{ label: '类别', value: 'category' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="table w-full mt-6 mb-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-[width] 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">
|
||||||
|
这里还没想好放什么
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="transition-[width] 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="choiceItems as any[]" size="sm"
|
||||||
|
class="mb-5"/>
|
||||||
|
<TimeLine v-if="postsMetaData" v-model:metadata="postsMetaData" :current-choice="currentChoice"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
metaData?: PostMetaData;
|
||||||
|
}>(), {
|
||||||
|
metaData: () => 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">
|
||||||
|
{{ metaData.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(metaData.published_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div title="分类" class="flex items-center ml-2">
|
||||||
|
<Icon name="material-symbols:category"/>
|
||||||
|
<div class="ml-1 inline">
|
||||||
|
{{ metaData.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">
|
||||||
|
{{ metaData.word_count }}字
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div title="预计阅读时间" class="flex items-center ml-2">
|
||||||
|
<Icon name="octicon:stopwatch-16"/>
|
||||||
|
<div class="ml-1 inline">
|
||||||
|
{{ getCostTime(metaData.word_count) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<div title="创建时间" class="flex items-center">
|
||||||
|
<Icon name="lucide:file-clock"/>
|
||||||
|
<div class="ml-1">{{ dateFormat(metaData.created_at) }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="Array.isArray(metaData.updated_at)" class="flex items-center ml-2">
|
||||||
|
<Icon name="lucide:clock-alert" title="上次更新时间"/>
|
||||||
|
<HoverContent>
|
||||||
|
<template #content>
|
||||||
|
<div class="ml-1">
|
||||||
|
{{ dateFormat(metaData.updated_at[metaData.updated_at.length - 1]) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #hoverContent>
|
||||||
|
<div class="p-1 pr-2">
|
||||||
|
<div v-for="(date,index) of metaData.updated_at" :key="index">
|
||||||
|
<div class="block whitespace-nowrap">
|
||||||
|
{{ '第' + index + '次更新' + dateFormat(date) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</HoverContent>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="Array.isArray(metaData.tags)" class="flex items-center">
|
||||||
|
<Icon name="clarity:tags-solid"/>
|
||||||
|
<div v-for="(tag,index) of metaData.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:' + metaData.id"
|
||||||
|
:tech-stack="metaData.tech_stack"
|
||||||
|
:tech-stack-icon-names="metaData.tech_stack_icon_names"
|
||||||
|
:tech-stack-theme-colors="metaData.tech_stack_theme_colors"
|
||||||
|
:tech-stack-percent="metaData.tech_stack_percent"
|
||||||
|
class="w-64"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCollapsible>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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 mb-6 table-fixed">
|
||||||
|
<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-[width] 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">
|
||||||
|
<div class="text-3xl mb-2">目录</div>
|
||||||
|
<MdCatalog :editor-id="editorId" :scroll-element="'html'" class=""/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="transition-[width] duration-500 float-right xl:w-[calc(100%-20rem-40px)] w-full max-w-full">
|
||||||
|
<ArticleHeader v-if="article" class="w-full" :meta-data="toMetaDataType(article)"/>
|
||||||
|
<!-- <ArticleCard v-if="article" class=" w-full" :article="toArticleMetaDataType(article)"/>-->
|
||||||
|
<ReadonlyMdEditor :editor-id="editorId" :markdown="article?.rawbody" class="p-5 max-w-full"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import { DataAnomaly, defaultMetaData } from '~/types/PostMetaData';
|
||||||
|
import type { PostMetaData } from '~/types/PostMetaData';
|
||||||
|
import { OverlayScrollbarsComponent } from 'overlayscrollbars-vue';
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
metaData?: PostMetaData;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
metaData: () => defaultMetaData,
|
||||||
|
});
|
||||||
|
|
||||||
|
function dateFormat(date: Date | DataAnomaly | undefined) {
|
||||||
|
if (!date) {
|
||||||
|
return 'date undefined';
|
||||||
|
}
|
||||||
|
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 | undefined) {
|
||||||
|
if (!length) {
|
||||||
|
return 'length undefined';
|
||||||
|
}
|
||||||
|
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 mounted = ref(false);
|
||||||
|
onMounted(() => {
|
||||||
|
mounted.value = true;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-5 light:bg-old-neutral-200 dark:bg-old-neutral-800 min-h-64">
|
||||||
|
<div class="text-4xl">
|
||||||
|
{{ metaData?.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(metaData?.published_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div title="分类" class="flex items-center ml-2">
|
||||||
|
<Icon name="material-symbols:category"/>
|
||||||
|
<div class="ml-1 text-nowrap">
|
||||||
|
{{ metaData?.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">
|
||||||
|
{{ metaData?.word_count }}字
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div title="预计阅读时间" class="flex items-center ml-2">
|
||||||
|
<Icon name="octicon:stopwatch-16"/>
|
||||||
|
<div class="ml-1 text-nowrap">
|
||||||
|
{{ getCostTime(metaData?.word_count) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="metaData?.isPinned" class="flex items-center ml-2">
|
||||||
|
<Icon name="codicon:pinned"/>
|
||||||
|
<div class="ml-1 text-nowrap">
|
||||||
|
置顶
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="flex mt-2 justify-between h-28">
|
||||||
|
<overlay-scrollbars-component>
|
||||||
|
{{ metaData?.description }}
|
||||||
|
</overlay-scrollbars-component>
|
||||||
|
<div v-if="mounted" class="">
|
||||||
|
<TechStackCard
|
||||||
|
:async-key="'stack:' + metaData?.id"
|
||||||
|
:tech-stack="metaData?.tech_stack"
|
||||||
|
:tech-stack-icon-names="metaData?.tech_stack_icon_names"
|
||||||
|
:tech-stack-theme-colors="metaData?.tech_stack_theme_colors"
|
||||||
|
:tech-stack-percent="metaData?.tech_stack_percent"
|
||||||
|
class="lg:w-64 w-0 transition-[width] duration-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="min-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(metaData?.created_at) }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="Array.isArray(metaData?.updated_at)" class="flex items-center ml-2">
|
||||||
|
<Icon name="lucide:clock-alert" title="上次更新时间"/>
|
||||||
|
<HoverContent>
|
||||||
|
<template #content>
|
||||||
|
<div class="ml-1">
|
||||||
|
{{ dateFormat(metaData?.updated_at[metaData?.updated_at.length - 1]) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #hoverContent>
|
||||||
|
<div class="p-1 pr-2">
|
||||||
|
<div v-for="(date,index) of metaData?.updated_at" :key="index">
|
||||||
|
<div class="block whitespace-nowrap">
|
||||||
|
{{ '第' + index + '次更新' + dateFormat(date) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</HoverContent>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="Array.isArray(metaData?.tags)" class="flex items-top">
|
||||||
|
<Icon name="clarity:tags-solid" class="mt-1"/>
|
||||||
|
<div>
|
||||||
|
<div v-for="(tag,index) of metaData?.tags" :key="index" class="inline-block">
|
||||||
|
<div class="ml-1 inline">{{ tag }}</div>
|
||||||
|
<div v-if="index !== metaData?.tags.length - 1" class="inline">,</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import { DataAnomaly, defaultMetaData } from '~/types/PostMetaData';
|
||||||
|
import type { PostMetaData } from '~/types/PostMetaData';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
metaData?: PostMetaData;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
metaData: () => defaultMetaData,
|
||||||
|
});
|
||||||
|
const { data: rawbody } = useAsyncData('simpleCard:' + props.metaData.id, async () => (await queryCollection('content').where('id', '=', props.metaData.id).first())?.rawbody);
|
||||||
|
const collapsed = ref(true);
|
||||||
|
const typeChinese = new Map<string | undefined, string>([
|
||||||
|
['rambling', '絮语'],
|
||||||
|
['announcement', '公告'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function dateFormat(date: Date | DataAnomaly | undefined) {
|
||||||
|
if (!date) {
|
||||||
|
return 'date undefined';
|
||||||
|
}
|
||||||
|
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.metaData.id))
|
||||||
|
.replace(/[+/=]/g, '_'); // 替换 Base64 中的特殊字符
|
||||||
|
return `rambling_${encoded}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const showShadow = ref(true);
|
||||||
|
|
||||||
|
function reverseCollapsed() {
|
||||||
|
if (collapsed.value) {
|
||||||
|
collapsed.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
collapsed.value = true;
|
||||||
|
showShadow.value = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
showShadow.value = true;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="p-5 light:bg-old-neutral-200 dark:bg-old-neutral-800 min-h-64"
|
||||||
|
@click="reverseCollapsed">
|
||||||
|
<div class="text-4xl">
|
||||||
|
{{ (typeChinese.get(metaData?.type) || 'unknown Type') + ':' }}{{ props.metaData.title }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mt-2 max-w-[400px] overflow-hidden">
|
||||||
|
|
||||||
|
<div title="发布时间" class="flex items-center">
|
||||||
|
<Icon name="lucide:clock-arrow-up"/>
|
||||||
|
<div class="ml-1 text-nowrap">
|
||||||
|
{{ dateFormat(props.metaData.published_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div title="分类" class="flex items-center ml-2">
|
||||||
|
<Icon name="material-symbols:category"/>
|
||||||
|
<div class="ml-1 text-nowrap">
|
||||||
|
{{ props.metaData.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.metaData.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.metaData.word_count) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="metaData?.isPinned" class="flex items-center ml-2">
|
||||||
|
<Icon name="codicon:pinned"/>
|
||||||
|
<div class="ml-1 text-nowrap">
|
||||||
|
置顶
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="relative flex mt-2 justify-between overflow-hidden duration-300 ease-in-out min-h-[8.5rem]"
|
||||||
|
:class="{'max-h-[8.5rem]' : collapsed, 'max-h-[100vh]':!collapsed}">
|
||||||
|
<ReadonlyMdEditor
|
||||||
|
v-if="rawbody" :editor-id="safeEditorId" :markdown="rawbody!"
|
||||||
|
class="w-full"/>
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 left-0 right-0 h-14 bg-gradient-to-t from-old-neutral-200 dark:from-old-neutral-800 to-transparent pointer-events-none transition-opacity duration-300"
|
||||||
|
:class="collapsed && showShadow?'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.metaData.created_at) }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="Array.isArray(props.metaData.updated_at)" class="flex items-center ml-2">
|
||||||
|
<Icon name="lucide:clock-alert" title="上次更新时间"/>
|
||||||
|
<HoverContent>
|
||||||
|
<template #content>
|
||||||
|
<div class="ml-1">
|
||||||
|
{{ dateFormat(props.metaData?.updated_at[props.metaData.updated_at.length - 1]) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #hoverContent>
|
||||||
|
<div class="p-1 pr-2">
|
||||||
|
<div v-for="(date,index) of props.metaData.updated_at" :key="index">
|
||||||
|
<div class="block whitespace-nowrap">
|
||||||
|
{{ '第' + index + '次更新' + dateFormat(date) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</HoverContent>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import useColorModeStore from '~/stores/colorModeStore';
|
||||||
|
|
||||||
|
const colorModeStore = useColorModeStore();
|
||||||
|
|
||||||
|
function revealToggle(e?: MouseEvent) {
|
||||||
|
if (typeof document === 'undefined' || !e || !('startViewTransition' in document)) {
|
||||||
|
// SSR 或无事件时直接切换或不支持时直接切换
|
||||||
|
colorModeStore.toggleColorMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transition = document.startViewTransition(() => {
|
||||||
|
colorModeStore.toggleColorMode();
|
||||||
|
});
|
||||||
|
transition.ready.then(() => {
|
||||||
|
// 获取鼠标的坐标
|
||||||
|
const { clientX, clientY } = e!;
|
||||||
|
|
||||||
|
// 计算最大半径
|
||||||
|
const radius = Math.hypot(
|
||||||
|
Math.max(clientX, innerWidth - clientX),
|
||||||
|
Math.max(clientY, innerHeight - clientY),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 圆形动画扩散开始
|
||||||
|
document.documentElement.animate(
|
||||||
|
{
|
||||||
|
clipPath: [
|
||||||
|
`circle(0% at ${clientX}px ${clientY}px)`,
|
||||||
|
`circle(${radius}px at ${clientX}px ${clientY}px)`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// 设置时间,已经目标伪元素
|
||||||
|
{
|
||||||
|
duration: 500,
|
||||||
|
pseudoElement: '::view-transition-new(root)',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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="colorModeStore.colorMode === 'dark'"
|
||||||
|
key="dark"
|
||||||
|
name="material-symbols:dark-mode"
|
||||||
|
class="text-2xl cursor-pointer mr-5"
|
||||||
|
@click="revealToggle($event)"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
v-else
|
||||||
|
key="light"
|
||||||
|
name="material-symbols:clear-day-rounded"
|
||||||
|
class="text-2xl cursor-pointer mr-5"
|
||||||
|
@click="revealToggle($event)"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
::view-transition-new(root),
|
||||||
|
::view-transition-old(root) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
+68
-34
@@ -1,45 +1,79 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
import type { NavigationMenuItem } from '@nuxt/ui';
|
import { sortMetaData, toMetaDataType } from '~/types/PostMetaData';
|
||||||
|
import type { PostMetaData } from '~/types/PostMetaData';
|
||||||
|
import SimpleCard from '~/pages/index/components/SimpleCard.vue';
|
||||||
|
import ArticleCard from '~/pages/index/components/ArticleCard.vue';
|
||||||
|
|
||||||
const items = ref<NavigationMenuItem[]>([
|
const srcPostsMetaData = ref<PostMetaData[]>([]);
|
||||||
{
|
const postsMetaData = ref<PostMetaData[]>([]);
|
||||||
label: '首页',
|
|
||||||
icon: 'i-lucide-home',
|
async function loadPostsMetaData() {
|
||||||
to: '/',
|
srcPostsMetaData.value = sortMetaData((await queryCollection('content').all()).map((x) => toMetaDataType(x)), 'published_at', true) || [];
|
||||||
},
|
srcPostsMetaData.value = srcPostsMetaData.value.filter((x) => !x.draft);
|
||||||
{
|
postsMetaData.value = srcPostsMetaData.value;
|
||||||
label: '归档',
|
}
|
||||||
icon: 'i-lucide-paperclip',
|
|
||||||
to: '/archive',
|
await loadPostsMetaData();
|
||||||
},
|
|
||||||
{
|
function toArticlePage(article: PostMetaData) {
|
||||||
label: '关于',
|
navigateTo(`/article/${encodeURIComponent(article.id)}`);
|
||||||
icon: 'i-lucide-info',
|
}
|
||||||
to: '/about',
|
|
||||||
},
|
watch(srcPostsMetaData, () => {
|
||||||
{
|
postsMetaData.value = srcPostsMetaData.value || [];
|
||||||
label: 'Contact',
|
});
|
||||||
to: '/contact',
|
|
||||||
},
|
function filterRuleChange(rule: (data: PostMetaData) => boolean) {
|
||||||
]);
|
postsMetaData.value = (srcPostsMetaData.value || []).filter(rule);
|
||||||
useColorMode().preference = 'light';
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<NuxtRouteAnnouncer />
|
<div class="table w-full mt-6 table-fixed">
|
||||||
<UApp>
|
<div class="sticky top-16 float-left max-h-[calc(100vh-4rem)]">
|
||||||
<div class="w-full flex justify-center">
|
<div class="relative duration-500 transition-[width] xl:w-80 w-0 overflow-hidden">
|
||||||
<div class="xl:w-[1220px] lg:w-[964px] md:w-[708px]">
|
<div class="w-80 top-0 left-0 text-gray-800 dark:text-white">
|
||||||
<UNavigationMenu :items="items" class="w-full"/>
|
<ArticleDescriptionCards
|
||||||
<NuxtPage />
|
v-if="postsMetaData"
|
||||||
|
class="mb-5" :posts-meta-data="srcPostsMetaData!"
|
||||||
|
@filter-rule-change="filterRuleChange"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="transition-[width] 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="post in postsMetaData" :key="post.id"
|
||||||
|
class="w-full transition-shadow duration-300 shadow-lg hover:shadow-old-neutral-600 hover:cursor-pointer">
|
||||||
|
<ArticleCard
|
||||||
|
v-if="!post.draft && post.type === 'article'"
|
||||||
|
class="mb-6 w-full"
|
||||||
|
:meta-data="post"
|
||||||
|
@click="toArticlePage(post)"/>
|
||||||
|
<SimpleCard
|
||||||
|
v-else-if="!post.draft && (post.type === 'rambling' || post.type === 'announcement')"
|
||||||
|
class="mb-6 w-full"
|
||||||
|
:meta-data="post"/>
|
||||||
|
<div v-else-if="post.draft">
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
{{post}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="postsMetaData.length === 0" class="w-full">
|
||||||
|
<div
|
||||||
|
class="w-full light:bg-old-neutral-200 dark:bg-old-neutral-800 transition-shadow duration-300 shadow-lg hover:shadow-old-neutral-600 hover:cursor-pointer">
|
||||||
|
<div class="pt-5 text-center text-2xl">
|
||||||
|
没有找到符合条件的文章
|
||||||
|
</div>
|
||||||
|
<div class="pt-3 pb-3 text-center text-sm text-old-neutral-500">
|
||||||
|
tips:类型、分类、标签间的关系为且,一类筛选下各个选项间的关系为或
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UApp>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|||||||
Generated
-9916
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
// darkVerify.js
|
||||||
|
if (
|
||||||
|
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,61 @@
|
|||||||
|
function getInitialMode(): 'light' | 'dark' {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// 优先用 html 的 class
|
||||||
|
if (document.documentElement.classList.contains('dark')) return 'dark';
|
||||||
|
if (document.documentElement.classList.contains('light')) return 'light';
|
||||||
|
// 其次用 localStorage
|
||||||
|
const val = localStorage.getItem('system-theme-mode');
|
||||||
|
if (val === 'dark') return 'dark';
|
||||||
|
if (val === 'light') return 'light';
|
||||||
|
return '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,46 +0,0 @@
|
|||||||
// tailwind config file
|
|
||||||
import plugin from 'tailwindcss/plugin';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
mode: 'jit',
|
|
||||||
darkMode: 'class',
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {},
|
|
||||||
letterSpacing: {
|
|
||||||
doublewidest: '.2em',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
screens: {
|
|
||||||
'sm': '640px',
|
|
||||||
'md': '768px',
|
|
||||||
'lg': '1024px',
|
|
||||||
'xl': '1280px',
|
|
||||||
'2xl': '1536px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
plugin(function ({ addUtilities }) {
|
|
||||||
addUtilities({
|
|
||||||
'.scrollbar-hide': {
|
|
||||||
/* IE and Edge */
|
|
||||||
'-ms-overflow-style': 'none',
|
|
||||||
|
|
||||||
/* Firefox */
|
|
||||||
'scrollbar-width': 'none',
|
|
||||||
|
|
||||||
/* Safari and Chrome */
|
|
||||||
'&::-webkit-scrollbar': {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
content: [
|
|
||||||
'./app.vue',
|
|
||||||
'./components/**/*.{vue,js,ts}',
|
|
||||||
'./layouts/**/*.vue',
|
|
||||||
'./pages/**/*.vue',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import wordCount from 'word-count';
|
||||||
|
|
||||||
|
export enum DataAnomaly {
|
||||||
|
DataNotFound = 'DataNotFound',
|
||||||
|
Invalid = 'DataInvalid',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function equalToDataAnomaly(value: unknown): value is DataAnomaly {
|
||||||
|
return value === DataAnomaly.DataNotFound || value === DataAnomaly.Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PostMetaData = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
created_at: Date | DataAnomaly;
|
||||||
|
category: string;
|
||||||
|
published_at: Date | DataAnomaly;
|
||||||
|
draft: boolean;
|
||||||
|
isPinned: 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),
|
||||||
|
isPinned: Boolean(data.isPinned ?? 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,
|
||||||
|
isPinned: false,
|
||||||
|
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],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function sortMetaData(metaData: PostMetaData[], key: keyof PostMetaData, considerPining = false, descending = true) {
|
||||||
|
if (key === 'published_at') {
|
||||||
|
return metaData.sort((a, b) => {
|
||||||
|
let aKey: Date | DataAnomaly = a[key] || new Date(0);
|
||||||
|
let bKey: Date | DataAnomaly = b[key] || new Date(0);
|
||||||
|
if (equalToDataAnomaly(aKey)) aKey = new Date(0);
|
||||||
|
if (equalToDataAnomaly(bKey)) bKey = new Date(0);
|
||||||
|
if (considerPining) {
|
||||||
|
if (a.isPinned && !b.isPinned) return -1;
|
||||||
|
if (!a.isPinned && b.isPinned) return 1;
|
||||||
|
}
|
||||||
|
return descending ? bKey.getTime() - aKey.getTime() : aKey.getTime() - bKey.getTime();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (key === 'category') {
|
||||||
|
return metaData.sort((a, b) => {
|
||||||
|
const aKey = String(a[key]);
|
||||||
|
const bKey = String(b[key]);
|
||||||
|
if (considerPining) {
|
||||||
|
if (a.isPinned && !b.isPinned) return -1;
|
||||||
|
if (!a.isPinned && b.isPinned) return 1;
|
||||||
|
}
|
||||||
|
return descending ? bKey.localeCompare(aKey) : aKey.localeCompare(bKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { useBreakpoints } from '@vueuse/core';
|
||||||
|
import breakpoints from '~/configs/breakpoints';
|
||||||
|
|
||||||
|
const breakpointsHelper = useBreakpoints(breakpoints);
|
||||||
|
export default breakpointsHelper;
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// 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 = r!;
|
||||||
|
b = b!;
|
||||||
|
g = g!;
|
||||||
|
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 = h!;
|
||||||
|
s = s!;
|
||||||
|
l = l!;
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user