1
0
mirror of https://github.com/sendevia/website.git synced 2026-03-07 16:22:34 +08:00

54 Commits

Author SHA1 Message Date
a7e0528c24 chore(package): update to version 26.3.4(316) 2026-03-04 22:50:40 +08:00
29885ae607 feat(Footer): 更新备案信息 2026-03-04 22:50:30 +08:00
dabb2f6164 feat(Dockerfile): simplify Dockerfile by removing self-signed SSL and nginx config 2026-03-04 22:08:52 +08:00
1247943a5e feat(.gitignore): update 2026-03-04 22:08:17 +08:00
b822fe6227 feat(ci): simplify docker image tagging and extract version from package.json 2026-03-04 21:15:43 +08:00
f01232241f feat(workflow): add GitHub Actions workflow for building and pushing Docker image 2026-03-04 21:07:12 +08:00
c8b917bda5 feat(Dockerfile): add Dockerfile for building and serving the application with Nginx 2026-03-04 20:44:01 +08:00
aa3cdbc695 fix(PageIndicator): remove inline anchors from headings collection 2026-03-04 15:36:53 +08:00
2bc889a388 chore(package): update to version 26.3.4(308) 2026-03-04 00:23:36 +08:00
c093e395bd feat(anchor): implement custom anchor links 2026-03-04 00:22:09 +08:00
2d879372d2 fix(Table.scss): adjust padding and min-width for table header and cells 2026-02-28 23:32:51 +08:00
0493426eaa chore(package): update to version 26.2.28(305) 2026-02-28 20:59:32 +08:00
042342f923 article(Jekylmt): update table formatting 2026-02-28 20:59:22 +08:00
13548b222e feat(mdTable): add a simple markdown-it table plugin 2026-02-28 20:58:45 +08:00
adb804f249 fix(mdSectionWrapper): remove invalid instance check for MarkdownIt 2026-02-28 20:30:47 +08:00
d91f8b90aa rename: sectionWrapper to mdSectionWrapper 2026-02-28 20:28:41 +08:00
69d117152a remove: Dockerfile 2026-02-27 21:28:48 +08:00
b6e7649a4f remove: 公安备案 2026-02-27 21:27:24 +08:00
1bb864cf24 chore(package): update to version 26.2.25(298) 2026-02-25 23:24:57 +08:00
68448d29fe feat(ImageViewer): enhance image viewer with button group navigation 2026-02-25 23:24:46 +08:00
cad4130789 feat(ButtonGroup): enhance button group component with new props and event handling 2026-02-25 23:24:07 +08:00
b4df562522 style(tokens): update color scheme for light mode 2026-02-25 23:22:26 +08:00
0a4f340e88 chore(NavBar): remove theme state initialization on mount 2026-02-25 23:17:22 +08:00
6a0cc5f5cb style(Article): optimize image display 2026-02-25 23:17:02 +08:00
008160b3c9 article(Jekylmt): add new article 2026-02-25 23:15:53 +08:00
416426d769 refactor(theme): simplify theme management using useColorMode 2026-02-25 23:12:08 +08:00
e07acc8e35 chore(config): update markdown-it plugins and add markdown-it imgMark 2026-02-25 23:11:41 +08:00
b1f8d6f15b chore(package): update to version 26.2.22(289) 2026-02-22 21:09:12 +08:00
c7af2eeb0f feat(Dockerfile): add initial Dockerfile 2026-02-22 21:08:50 +08:00
1fbac018f9 article: update color scheme and replace impression image 2026-02-21 21:31:39 +08:00
cbd3979476 fix(images): replace PNG images with WEBP format 2026-02-09 23:44:31 +08:00
5bcec415b3 fix(dependencies): downgrade @material/material-color-utilities to version 0.3.0 and update vue to version 3.5.28 2026-02-09 23:35:49 +08:00
1ada9579ce style(layout): improve transition effects 2026-02-09 23:30:43 +08:00
1215d1ca22 style(Header): enhance header transition effects 2026-02-09 23:29:33 +08:00
56264d3504 chore(package): update dependencies to latest versions 2026-02-09 16:20:19 +08:00
251b22f1b5 chore(package): update to version 26.2.8(281) 2026-02-08 23:43:53 +08:00
2c5cbfad01 style(update-version): improve output formatting for version update process 2026-02-08 23:43:43 +08:00
42fedf3c3d article(Markdown 扩展示例): add color and impression 2026-02-08 23:40:16 +08:00
ecd4fb2886 style(Header): enhance title with gradient background and overlay effect 2026-02-08 23:39:39 +08:00
fff5f3ba2b fix(Header): hide SVG element 2026-02-08 21:25:26 +08:00
5faa7e4220 article(设置开机自启动的 Jekyll 服务): add color and impression 2026-02-08 21:18:32 +08:00
568c7a7427 style: update text style for improved visibility 2026-02-08 21:16:34 +08:00
bef3d5ce77 style: update link decoration 2026-02-08 21:15:42 +08:00
00b034f02b fix(PageIndicator): correct syntax and update text for short link display 2026-02-08 21:14:09 +08:00
f27da216e0 chore(package): update to version 26.2.6(272) 2026-02-06 16:17:35 +08:00
051aadc565 feat(update-version): enhance version update script with remote configuration and improved logging 2026-02-06 16:16:59 +08:00
26ad7fed76 feat(Header): add gradient image handling and improve layout structure 2026-02-06 16:11:41 +08:00
73a1b4044d feat(images): add new gradient images and remove obsolete image 2026-02-06 15:13:35 +08:00
1a6a60d5cc chore(package): update to version 26.1.30(268) 2026-01-30 22:05:21 +08:00
8cdbd059bb fix(navState): update cookie name 2026-01-30 22:04:07 +08:00
9ec1a05160 feat(NavBar): add theme toggle button 2026-01-30 22:03:03 +08:00
07ad900625 chore(package): update @mdit/plugin-align to version 0.23.0 2026-01-21 21:19:51 +08:00
3a986b01eb style(ArticleMasonry): improve panel layout and responsiveness, enhance transition effects 2026-01-21 14:54:06 +08:00
f9203e5815 project: add MIT License 2026-01-19 14:38:50 +08:00
64 changed files with 1627 additions and 687 deletions

65
.github/workflows/docker-build.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
name: Build and Push Docker Image
on:
push:
branches:
- master
paths:
- "package.json"
- "Dockerfile"
- ".github/workflows/docker-build.yml"
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Extract version from package.json
id: version
run: |
VERSION=$(grep '"version"' package.json | sed 's/.*"\([^"]*\)".*/\1/' | sed 's/(.*)//g')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/website
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest
type=raw,value=${{ steps.version.outputs.version }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

2
.gitignore vendored
View File

@@ -5,3 +5,5 @@ node_modules
package-lock.json
cache/
dist/
.agents
nginx-config.conf

View File

@@ -2,15 +2,17 @@ import { defineConfig } from "vitepress";
import packageJson from "../package.json";
// markdown-it plugins
// https://mdit-plugins.github.io/zh/align.html
// https://mdit-plugins.github.io/align.html
import { align } from "@mdit/plugin-align";
// https://github.com/valeriangalliat/markdown-it-anchor
import anchor from "markdown-it-anchor";
// https://mdit-plugins.github.io/footnote.html
import { footnote } from "@mdit/plugin-footnote";
// https://mdit-plugins.github.io/tasklist.html
import { tasklist } from "@mdit/plugin-tasklist";
import { wrapHeadingsAsSections } from "./theme/utils/sectionWrapper";
// https://mdit-plugins.github.io/img-mark.html
import { imgMark } from "@mdit/plugin-img-mark";
import { sectionWrapper } from "./theme/utils/mdSectionWrapper";
import { table } from "./theme/utils/mdTable";
import { anchor } from "./theme/utils/mdCustomAnchor";
export default defineConfig({
base: "/",
@@ -20,18 +22,6 @@ export default defineConfig({
titleTemplate: ":title",
description: "随便写写的博客",
markdown: {
anchor: {
permalink: anchor.permalink.linkAfterHeader({
style: "visually-hidden",
symbol: "link",
class: "title-anchor",
assistiveText: () => "复制链接",
visuallyHiddenClass: "visually-hidden",
wrapper: ['<div class="title-with-achor">', "</div>"],
placement: "before",
space: false,
}),
},
attrs: {
allowedAttributes: ["id", "class"],
},
@@ -39,8 +29,13 @@ export default defineConfig({
codeCopyButtonTitle: "复制代码",
config(md) {
md.use(align);
md.use(anchor, {
levels: [1, 2, 3, 4],
});
md.use(footnote);
md.use(wrapHeadingsAsSections);
md.use(imgMark);
md.use(sectionWrapper);
md.use(table);
md.use(tasklist, { label: true });
},
image: {
@@ -94,6 +89,7 @@ export default defineConfig({
navSegment: [
{ text: "首页", icon: "home", link: "/" },
{ text: "AincradMix", icon: "borg", link: "/posts/AincradMix" },
{ text: "组件", icon: "code_blocks", link: "/posts/组件" },
{
text: "作品集",
icon: "auto_awesome_mosaic",

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { ref } from "vue";
import { useAchorLink } from "../composables/useAnchorLink";
interface Props {
href: string;
/** 锚点显示的字符串 */
symbol?: string;
/** 锚点的 class 名称 */
className?: string;
}
const props = withDefaults(defineProps<Props>(), {
symbol: "",
className: "",
});
const isCopied = ref(false);
const { handleCopy } = useAchorLink(props.href, () => {
isCopied.value = true;
setTimeout(() => {
isCopied.value = false;
}, 1000);
});
/** 处理锚点点击事件 */
const handleClick = (event: MouseEvent) => {
event.preventDefault();
handleCopy();
};
</script>
<template>
<span :class="className">
<a :href="href" class="symbol" @click="handleClick">{{ symbol }}</a>
<span v-if="isCopied" class="feedback">已复制</span>
</span>
</template>
<style lang="scss">
@use "sass:meta";
@include meta.load-css("../styles/components/AnchorLink");
</style>

View File

@@ -253,93 +253,95 @@ const clearTags = () => {
</div>
<Transition name="expand" mode="out-in">
<aside v-if="isSettingsOpen" ref="settingsPanelRef" class="panel">
<div class="section">
<div class="section-header">
<h6>排序</h6>
</div>
<div class="page-size-options">
<MaterialButton
:color="sortField === 'date' ? 'filled' : 'tonal'"
size="s"
class="group horizontal"
icon="acute"
@click="sortField = 'date'"
>
时间
</MaterialButton>
<MaterialButton
:color="sortField === 'title' ? 'filled' : 'tonal'"
size="s"
class="group horizontal"
icon="match_case"
@click="sortField = 'title'"
>
标题
</MaterialButton>
<div>
<aside v-if="isSettingsOpen" class="panel">
<div ref="settingsPanelRef" class="container">
<div class="section">
<div class="section-header">
<h6>排序</h6>
</div>
<div class="page-size-options">
<MaterialButton
:icon="sortOrder === 'asc' ? 'arrow_upward' : 'arrow_downward'"
color="tonal"
:color="sortField === 'date' ? 'filled' : 'tonal'"
size="s"
@click="sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'"
class="group horizontal"
icon="acute"
@click="sortField = 'date'"
>
{{ sortOrder === "asc" ? "正序" : "倒序" }}
时间
</MaterialButton>
<MaterialButton
:color="sortField === 'title' ? 'filled' : 'tonal'"
size="s"
class="group horizontal"
icon="match_case"
@click="sortField = 'title'"
>
标题
</MaterialButton>
<div>
<MaterialButton
:icon="sortOrder === 'asc' ? 'arrow_upward' : 'arrow_downward'"
color="tonal"
size="s"
@click="sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'"
>
{{ sortOrder === "asc" ? "正序" : "倒序" }}
</MaterialButton>
</div>
</div>
</div>
<div class="section">
<div class="section-header">
<h6>每页显示</h6>
</div>
<div class="page-size-options">
<MaterialButton
v-for="opt in pageSizeOptions"
:key="opt"
:color="pageSize === opt ? 'filled' : 'tonal'"
:icon="pageSize === opt ? 'check' : ''"
class="group horizontal"
size="s"
@click="pageSize = opt"
>
{{ opt }}
</MaterialButton>
</div>
</div>
</div>
<div class="section">
<div class="section-header">
<h6>每页显示</h6>
<div class="section">
<div class="section-header">
<h6>分类 <span v-if="selectedCategory" @click="selectedCategory = ''">clear</span></h6>
</div>
<div class="chip-container">
<MaterialChip
v-for="cat in postsStore.allCategories"
:key="cat"
:color="selectedCategory === cat ? 'tonal' : 'outlined'"
:icon="selectedCategory === cat ? 'check' : ''"
@click="toggleCategory(cat)"
>
{{ cat }}
</MaterialChip>
</div>
</div>
<div class="page-size-options">
<MaterialButton
v-for="opt in pageSizeOptions"
:key="opt"
:color="pageSize === opt ? 'filled' : 'tonal'"
:icon="pageSize === opt ? 'check' : ''"
class="group horizontal"
size="s"
@click="pageSize = opt"
>
{{ opt }}
</MaterialButton>
</div>
</div>
<div class="section">
<div class="section-header">
<h6>分类 <span v-if="selectedCategory" @click="selectedCategory = ''">clear</span></h6>
</div>
<div class="chip-container">
<MaterialChip
v-for="cat in postsStore.allCategories"
:key="cat"
:color="selectedCategory === cat ? 'tonal' : 'outlined'"
:icon="selectedCategory === cat ? 'check' : ''"
@click="toggleCategory(cat)"
>
{{ cat }}
</MaterialChip>
</div>
</div>
<div class="section">
<div class="section-header">
<h6>标签 <span v-if="selectedTags.length" @click="clearTags">clear</span></h6>
</div>
<div class="chip-container">
<MaterialChip
v-for="tag in postsStore.allTags"
:key="tag"
:color="selectedTags.includes(tag) ? 'tonal' : 'outlined'"
:icon="selectedTags.includes(tag) ? 'check' : ''"
@click="toggleTag(tag)"
>
{{ tag }}
</MaterialChip>
<div class="section">
<div class="section-header">
<h6>标签 <span v-if="selectedTags.length" @click="clearTags">clear</span></h6>
</div>
<div class="chip-container">
<MaterialChip
v-for="tag in postsStore.allTags"
:key="tag"
:color="selectedTags.includes(tag) ? 'tonal' : 'outlined'"
:icon="selectedTags.includes(tag) ? 'check' : ''"
@click="toggleTag(tag)"
>
{{ tag }}
</MaterialChip>
</div>
</div>
</div>
</aside>

View File

@@ -1,33 +1,62 @@
<script setup lang="ts">
/** 外部链接类型定义 */
interface ExternalLink {
type: string;
label: string;
link: string;
ariaLabel?: string;
color?: string;
icon?: string;
label?: string;
link?: string;
size?: "xs" | "s" | "m" | "l" | "xl";
target?: string;
type?: string;
onClick?: (e?: Event) => void;
}
/** 组件属性定义 */
interface Props {
ariaLabel?: string;
color?: string;
icon?: string;
layout?: "horizontal" | "vertical";
links?: ExternalLink[];
size?: "xs" | "s" | "m" | "l" | "xl";
layout?: "horizontal" | "vertical";
target?: string;
}
/** 组件属性默认值 */
const props = withDefaults(defineProps<Props>(), {
layout: "horizontal",
links: () => [],
size: "s",
layout: "horizontal",
});
const getButtonColor = (type: string) => {
/** 组件事件定义 */
const emit = defineEmits<{
(e: "click", event: Event, item: ExternalLink, index: number): void;
}>();
/**
* 根据按钮类型获取对应的颜色
* @param type 按钮类型
* @returns 对应的颜色
*/
const getButtonColor = (type?: string): string => {
switch (type) {
case "download":
return "tonal";
return "filled";
case "normal":
default:
return "tonal";
default:
return "text";
}
};
const getButtonIcon = (type: string) => {
/**
* 根据按钮类型获取对应的图标
* @param type 按钮类型
* @returns 对应的图标名称
*/
const getButtonIcon = (type?: string): string => {
switch (type) {
case "download":
return "download";
@@ -37,22 +66,37 @@ const getButtonIcon = (type: string) => {
return "";
}
};
/**
* 处理按钮点击事件
* @param e 事件对象
* @param item 链接对象
* @param index 索引
*/
const handleClick = (e: Event, item: ExternalLink, index: number) => {
if (item.onClick) {
item.onClick(e);
}
emit("click", e, item, index);
};
</script>
<template>
<div v-if="links && links.length > 0" class="ButtonGroup" :class="[props.size, props.layout]">
<div class="ButtonGroup" :class="[props.size, props.layout]" :aria-label="props.ariaLabel">
<MaterialButton
v-for="(item, index) in links"
:key="index"
class="group"
:class="props.layout"
:key="index"
:href="item.link"
:size="props.size"
:color="getButtonColor(item.type)"
:icon="getButtonIcon(item.type)"
:target="'_blank'"
:size="item.size || props.size"
:color="item.color || props.color || getButtonColor(item.type)"
:icon="item.icon || props.icon || getButtonIcon(item.type)"
:target="item.target || props.target || (item.link ? '_blank' : undefined)"
:aria-label="item.ariaLabel || props.ariaLabel || item.label"
@click="handleClick($event, item, index)"
>
{{ item.label }}
<template v-if="item.label">{{ item.label }}</template>
</MaterialButton>
</div>
</template>

View File

@@ -27,14 +27,8 @@ const siteVersion = theme.value.siteVersion;
>
</div>
<div class="beian-info">
<div class="beian-gongan">
<img src="/assets/images/beian.png" loading="eager" />
<a href="https://beian.mps.gov.cn/#/query/webSearch?code=23020002230215" target="_blank"
>黑公网安备23020002230215</a
>
</div>
<div>
<a href="https://beian.miit.gov.cn/" target="_blank">黑ICP备2024016516号-1</a>
<a href="https://beian.miit.gov.cn/" target="_blank">黑ICP备2024016516号</a>
</div>
</div>
</div>

View File

@@ -21,7 +21,7 @@ const config = reactive({
animFast: 300,
});
const { frontmatter, theme } = useGlobalData();
const { frontmatter, theme, page } = useGlobalData();
const headerRef = ref<HTMLElement | null>(null);
const isHovering = useElementHover(headerRef);
@@ -56,6 +56,16 @@ const currentRealIndex = computed(() => {
return ((virtualIndex.value % totalCount.value) + totalCount.value) % totalCount.value;
});
/**
* 获取带 _mesh-gradient 后缀的图片地址
* @param url 原始图片地址
*/
const getGradientUrl = (url: string): string => {
if (!url) return "";
// 在扩展名前插入 _mesh-gradient
return url.replace(/(\.[^.]+)$/, "_mesh-gradient$1");
};
/**
* 解析 CSS 变量中的时间值
* @param cssVar CSS 变量名
@@ -72,11 +82,16 @@ const parseTimeToken = (cssVar: string, defaultVal: number): number => {
/**
* 并行加载图片并存入 Blob 缓存以消除闪烁
* 同时缓存原图和梯度背景图
* @param urls 图片地址列表
*/
const cacheImages = async (urls: string[]) => {
if (!isClient()) return;
const uncached = urls.filter((url) => !blobCache.has(url));
// 生成所有需要缓存的 URL原图 + 梯度图)
const allUrls = urls.flatMap((url) => [url, getGradientUrl(url)]);
const uncached = allUrls.filter((url) => !blobCache.has(url));
await Promise.all(
uncached.map(async (url) => {
try {
@@ -84,9 +99,9 @@ const cacheImages = async (urls: string[]) => {
const blob = await res.blob();
blobCache.set(url, URL.createObjectURL(blob));
} catch {
blobCache.set(url, url);
blobCache.set(url, url); // 失败时回退到原始 URL
}
})
}),
);
};
@@ -118,8 +133,17 @@ const slotStates = computed(() => {
];
const { cls, order, offset } = stateMap[relativePos];
const imgIndex = (((currentRealIndex.value + offset) % totalCount.value) + totalCount.value) % totalCount.value;
const rawUrl = rawImgList.value[imgIndex];
return { id: slotId, className: cls, imgUrl: blobCache.get(rawUrl) || rawUrl, order };
const rawGradientUrl = getGradientUrl(rawUrl);
return {
id: slotId,
className: cls,
imgUrl: blobCache.get(rawUrl) || rawUrl,
gradientUrl: blobCache.get(rawGradientUrl) || rawGradientUrl,
order,
};
});
});
@@ -132,7 +156,7 @@ const { pause, resume } = useRafFn(
step(1).then(() => (remainingTime.value = config.duration));
}
},
{ immediate: false }
{ immediate: false },
);
/**
@@ -173,7 +197,7 @@ watch(
await cacheImages(newList);
if (hasMultiple.value && isClient()) resume();
},
{ immediate: true }
{ immediate: true },
);
onMounted(() => {
@@ -193,56 +217,87 @@ onUnmounted(() => {
</script>
<template>
<header ref="headerRef" class="Header">
<div class="carousel-container" :impression-color="frontmatter.color">
<template v-if="hasMultiple">
<div class="stage" :style="{ '--carousel-duration': `${animDuration}ms` }">
<div
v-for="slot in slotStates"
:key="slot.id"
class="item"
:class="slot.className"
:style="{ backgroundImage: `url('${slot.imgUrl}')`, order: slot.order }"
></div>
</div>
<div class="progress-ring">
<svg width="24" height="24" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" fill="none" stroke="var(--md-sys-color-tertiary-container)" stroke-width="5" />
<circle
cx="12"
cy="12"
r="9"
fill="none"
stroke="var(--md-sys-color-tertiary)"
stroke-width="5"
stroke-linecap="round"
:style="{
strokeDasharray: `${2 * Math.PI * 9}`,
strokeDashoffset: `${2 * Math.PI * 9 * (1 - progress / 100)}`,
transition: isFastForwarding ? 'none' : 'stroke-dashoffset 100ms linear',
}"
/>
</svg>
</div>
<div class="controls">
<div class="prev" title="上一张" @click="handleNav(-1)"></div>
<div class="next" title="下一张" @click="handleNav(1)"></div>
</div>
<div class="indicators">
<button
v-for="(_, idx) in rawImgList"
:key="idx"
class="dot"
:class="{ active: currentRealIndex === idx }"
@click="jumpTo(idx)"
></button>
</div>
</template>
<template v-else>
<div class="single" :style="{ backgroundImage: `url('${blobCache.get(rawImgList[0]) || rawImgList[0]}')` }"></div>
</template>
</div>
</header>
<Transition name="header" mode="in-out" :duration="10000" appear>
<header ref="headerRef" class="Header">
<div class="carousel-container" :impression-color="frontmatter.color">
<template v-if="hasMultiple">
<div class="stage" :style="{ '--carousel-duration': `${animDuration}ms` }">
<div
v-for="slot in slotStates"
:key="slot.id"
class="item"
:class="slot.className"
:style="{ order: slot.order }"
>
<img :src="slot.imgUrl" />
</div>
</div>
<div class="progress-ring">
<svg width="24" height="24" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" fill="none" stroke="var(--md-sys-color-tertiary-container)" stroke-width="5" />
<circle
cx="12"
cy="12"
r="9"
fill="none"
stroke="var(--md-sys-color-tertiary)"
stroke-width="5"
stroke-linecap="round"
:style="{
strokeDasharray: `${2 * Math.PI * 9}`,
strokeDashoffset: `${2 * Math.PI * 9 * (1 - progress / 100)}`,
transition: isFastForwarding ? 'none' : 'stroke-dashoffset 100ms linear',
}"
/>
</svg>
</div>
<div class="controls">
<div class="prev" title="上一张" @click="handleNav(-1)"></div>
<div class="next" title="下一张" @click="handleNav(1)"></div>
</div>
<div class="indicators">
<button
v-for="(_, idx) in rawImgList"
:key="idx"
class="dot"
:class="{ active: currentRealIndex === idx }"
@click="jumpTo(idx)"
></button>
</div>
</template>
<template v-else>
<ClientOnly>
<svg width="0" height="0" style="display: none">
<defs>
<filter id="noise-filter" x="0" y="0" width="100%" height="100%">
<feTurbulence
:seed="frontmatter.date ? new Date(frontmatter.date).getTime() : 0"
type="turbulence"
baseFrequency="0.15"
numOctaves="2"
stitchTiles="stitch"
></feTurbulence>
<feColorMatrix type="saturate" values="1"></feColorMatrix>
<feComponentTransfer>
<feFuncA type="discrete" tableValues="0 0.1"></feFuncA>
</feComponentTransfer>
<feBlend mode="multiply" in2="SourceGraphic"></feBlend>
</filter>
</defs>
</svg>
</ClientOnly>
<div class="single">
<h1 class="overlay">{{ frontmatter.title || page.title }}</h1>
<h1 :style="`background-image: url(${getGradientUrl(rawImgList[0])})`">
{{ frontmatter.title || page.title }}
</h1>
<img :src="getGradientUrl(rawImgList[0])" />
<img :src="rawImgList[0]" />
</div>
</template>
</div>
</header>
</Transition>
</template>
<style lang="scss" scoped>

View File

@@ -86,7 +86,7 @@ const show = () => {
// 延迟一帧触发动画,确保 CSS 过渡生效
isAnimating.value = true;
// 将焦点转移到查看器内部的关闭按钮
const closeBtn = document.querySelector<HTMLElement>(".btn-close");
const closeBtn = document.querySelector<HTMLElement>(".ImageViewer .close");
closeBtn?.focus();
});
};
@@ -113,8 +113,32 @@ const hide = () => {
const prevImage = () => hasPrevious.value && activeIndex.value--;
const nextImage = () => hasNext.value && activeIndex.value++;
/** 键盘交互处理 */
useEventListener("keydown", (e: KeyboardEvent) => {
/** 导航 ButtonGroup 按钮配置 */
const BUTTONS_NAV_CONFIG = computed(() => [
{ id: "prev", icon: "chevron_left", ariaLabel: "上一张", type: "normal" },
{
id: "index",
label: `${activeIndex.value + 1} / ${props.images.length}`,
color: "tonal",
ariaLabel: "当前页码",
},
{ id: "next", icon: "chevron_right", ariaLabel: "下一张", type: "normal" },
]);
/** 处理按钮组点击事件 */
const handleButtonGroupClick = (e: Event, item: any) => {
switch (item.id) {
case "prev":
prevImage();
break;
case "next":
nextImage();
break;
}
};
/** 处理键盘快捷键 */
const handleKeyboardShortcuts = (e: KeyboardEvent) => {
if (!isVisible.value) return;
// 陷阱焦点逻辑
@@ -146,7 +170,10 @@ useEventListener("keydown", (e: KeyboardEvent) => {
imageScale.value = Math.max(ZOOM_CONFIG.MIN, imageScale.value - ZOOM_CONFIG.STEP);
break;
}
});
};
/** 键盘交互处理 */
useEventListener("keydown", handleKeyboardShortcuts);
/** 滚轮缩放与翻页 */
const handleWheel = (e: WheelEvent) => {
@@ -202,7 +229,7 @@ const handleTouchMove = (e: TouchEvent) => {
const dist = Math.sqrt(dx * dx + dy * dy);
imageScale.value = Math.min(
Math.max((dist / initialTouchDist) * initialTouchScale, ZOOM_CONFIG.MIN),
ZOOM_CONFIG.MAX_TOUCH
ZOOM_CONFIG.MAX_TOUCH,
);
} else if (e.touches.length === 1) {
const touch = e.touches[0];
@@ -234,23 +261,15 @@ defineExpose({ show, hide });
aria-modal="true"
tabindex="-1"
>
<MaterialButton @click="hide" class="btn-close" icon="close" color="text" size="m" aria-label="关闭" />
<MaterialButton
v-if="hasPrevious"
@click="prevImage"
class="btn-nav prev"
icon="chevron_left"
size="l"
aria-label="上一张"
/>
<MaterialButton
v-if="hasNext"
@click="nextImage"
class="btn-nav next"
icon="chevron_right"
size="l"
aria-label="下一张"
<ButtonGroup
:links="BUTTONS_NAV_CONFIG"
layout="horizontal"
size="m"
class="nav-group"
@click="handleButtonGroupClick"
/>
<MaterialButton color="tonal" icon="close" aria-label="关闭" class="close" @click="hide"></MaterialButton>
<div
class="content"
@click.self="hide"
@@ -280,18 +299,6 @@ defineExpose({ show, hide });
}"
/>
</div>
<p class="index-text">{{ activeIndex + 1 }} / {{ images.length }}</p>
<div class="thumbnails" v-if="images.length > 1">
<button
v-for="(img, idx) in images"
:key="idx"
class="thumbnail"
:class="{ active: idx === activeIndex }"
@click="activeIndex = idx"
>
<img :src="img" alt="thumbnail" />
</button>
</div>
</div>
</template>

View File

@@ -6,14 +6,19 @@ import { useGlobalData } from "../composables/useGlobalData";
import { useNavStateStore } from "../stores/navState";
import { useScreenWidthStore } from "../stores/screenWidth";
import { useSearchStateStore } from "../stores/searchState";
import { useThemeStateStore } from "../stores/themeState";
const { page, theme } = useGlobalData();
const screenWidthStore = useScreenWidthStore();
const searchStateStore = useSearchStateStore();
const navStateStore = useNavStateStore();
const themeStateStore = useThemeStateStore();
/** 标签动画状态 */
const isLabelAnimating = ref(false);
/** 清理函数列表 */
const cleanupFunctions: Array<() => void> = [];
/** 已观察元素集合 */
const observedElements = new WeakSet<HTMLElement>();
/**
@@ -32,17 +37,18 @@ function observeWidth(el: HTMLElement, parentSelector: string) {
parentSelector,
ignoreParentLimit: true, // 允许撑开父级
},
[el]
[el],
);
cleanupFunctions.push(cleanup);
}
// 计算 Segments
/** 计算导航分段数据 */
const navSegment = computed(() => {
const items = theme.value.navSegment;
return Array.isArray(items) && items.length > 0 ? items : [];
});
/** 计算导航栏容器类名 */
const navClass = computed(() => {
let baseClass = "";
if (screenWidthStore.screenWidth > 840) {
@@ -56,14 +62,15 @@ const navClass = computed(() => {
return `${baseClass} ${expansionClass}`;
});
/** 计算标签类名 */
const labelClass = computed(() => [
navStateStore.isNavExpanded ? "right" : "bottom",
isLabelAnimating.value ? "animating" : "",
]);
/**
* 规范化路径,去除 /index.md、.md、.html 后缀及末尾斜杠
* @param path
* 规范化路径,去除后缀及末尾斜杠
* @param path 原始路径
*/
function normalizePath(path: string): string {
return path.replace(/(\/index)?\.(md|html)$/, "").replace(/\/$/, "");
@@ -71,7 +78,7 @@ function normalizePath(path: string): string {
/**
* 检查链接是否为当前活动页面的链接
* @param link
* @param link 目标链接
*/
function isActive(link: string): boolean {
const currentPath = normalizePath(page.value.relativePath);
@@ -81,7 +88,7 @@ function isActive(link: string): boolean {
/**
* 检查链接是否为外部链接
* @param link
* @param link 目标链接
*/
function isExternalLink(link: string): boolean {
return /^https?:\/\//.test(link);
@@ -89,7 +96,7 @@ function isExternalLink(link: string): boolean {
/**
* 切换搜索栏状态
* @param event
* @param event 鼠标事件
*/
function toggleSearch(event: MouseEvent) {
event.stopPropagation();
@@ -97,21 +104,29 @@ function toggleSearch(event: MouseEvent) {
}
/**
* 切换导航栏状态
* @param event
* 切换导航栏展开状态
* @param event 鼠标事件
*/
function toggleNav(event: MouseEvent) {
event.stopPropagation();
// 暂时只有在屏幕宽度大于断点时才能切换导航栏状态
if (screenWidthStore.isAboveBreakpoint) {
navStateStore.toggle();
}
}
/**
* 切换颜色偏好 (自动/亮/暗)
* @param event 鼠标事件
*/
function toggleTheme(event: MouseEvent) {
event.stopPropagation();
themeStateStore.cycleTheme();
}
/**
* 设置 label 引用并初始化观察器
* @param el
* @param parentSelector
* @param el DOM 元素
* @param parentSelector 父级选择器
*/
function setLabelRef(el: any, parentSelector: string) {
if (el instanceof HTMLElement) {
@@ -120,8 +135,8 @@ function setLabelRef(el: any, parentSelector: string) {
}
/**
* 处理动画结束
* @param el
* 处理动画结束事件
* @param el 事件目标
*/
function onAnimationEnd(el: EventTarget | null) {
if (el) {
@@ -129,7 +144,7 @@ function onAnimationEnd(el: EventTarget | null) {
}
}
/** 监听状态变化,触发宽度计算 */
/** 监听导航栏展开状态,触发重绘和动画 */
watch(
() => [navStateStore.isNavExpanded],
() => {
@@ -137,7 +152,7 @@ watch(
nextTick(() => {
window.dispatchEvent(new Event("resize"));
});
}
},
);
if (isClient()) {
@@ -185,6 +200,18 @@ if (isClient()) {
</a>
</div>
</div>
<div class="actions">
<MaterialButton
class="theme-btn"
size="m"
color="text"
:title="themeStateStore.currentLabel"
:icon="themeStateStore.currentIcon"
@click="toggleTheme"
>
</MaterialButton>
</div>
</nav>
</template>

View File

@@ -25,7 +25,7 @@ const { start: lockTimer } = useTimeoutFn(
isLocked.value = false;
},
1200,
{ immediate: false }
{ immediate: false },
);
/** 计算文章 ID 与短链 */
@@ -48,11 +48,21 @@ const copyShortLink = async () => {
const collectHeadings = () => {
if (!isClient()) return;
const nodes = Array.from(document.querySelectorAll("h1[id], h2[id]")) as HTMLElement[];
headings.value = nodes.map((n) => ({
id: n.id,
text: n.textContent?.trim() || n.id,
level: +n.tagName.replace("H", ""),
}));
headings.value = nodes.map((n) => {
// 克隆节点并移除行内锚点
const clone = n.cloneNode(true) as HTMLElement;
const inlineAnchors = Array.from(clone.querySelectorAll(".AnchorLink.inline")) as HTMLElement[];
inlineAnchors.forEach((el) => el.remove());
const text = clone.textContent?.trim() || n.id;
return {
id: n.id,
text,
level: +n.tagName.replace("H", ""),
};
});
};
/** 更新指示器(高亮边框)的位置和尺寸 */
@@ -126,11 +136,11 @@ if (isClient()) {
headingsActiveId.value = bestId;
}
},
{ rootMargin: "-20% 0px -60% 0px", threshold: [0, 0.1, 0.5, 1] }
{ rootMargin: "-20% 0px -60% 0px", threshold: [0, 0.1, 0.5, 1] },
);
});
},
{ immediate: true }
{ immediate: true },
);
/** 监听容器及其子元素尺寸变化 */
@@ -153,7 +163,7 @@ watch(
} else {
indicator.value.opacity = 0;
}
}
},
);
watch(headingsActiveId, () => {
@@ -174,7 +184,7 @@ onMounted(() => {
<template>
<div ref="pageIndicator" class="PageIndicator">
<div class="label">
<p class="text">在此页上</p>
<p class="text">文章短链</p>
<p class="icon">link</p>
<p class="article-id" :title="isCopied ? '已复制' : '复制短链'" v-if="articleId" @click="copyShortLink">
{{ isCopied ? "已复制" : articleId }}

View File

@@ -0,0 +1,20 @@
import { useClipboard } from "@vueuse/core";
/**
* 复制锚点链接到剪贴板
* @param href - 锚点 URL
* @param onCopied - 复制成功后的回调函数
*/
export const useAchorLink = (href: string, onCopied?: () => void) => {
const { copy: copyToClipboard } = useClipboard();
const handleCopy = async () => {
const anchorId = href.startsWith("#") ? href : `#${href}`;
const fullUrl = `${window.location.origin}${window.location.pathname}${anchorId}`;
await copyToClipboard(fullUrl);
onCopied?.();
};
return { handleCopy };
};

View File

@@ -6,6 +6,7 @@ import Default from "./layouts/Default.vue";
import NotFound from "./layouts/NotFound.vue";
// Components
import AnchorLink from "./components/AnchorLink.vue";
import AppBar from "./components/AppBar.vue";
import ArticleMasonry from "./components/ArticleMasonry.vue";
import Button from "./components/Button.vue";
@@ -33,6 +34,7 @@ export default {
app.component("MainLayout", Default);
app.component("AnchorLink", AnchorLink);
app.component("AppBar", AppBar);
app.component("ArticleMasonry", ArticleMasonry);
app.component("ButtonGroup", ButtonGroup);

View File

@@ -12,10 +12,10 @@ const { copy: copyToClipboard } = useClipboard();
const publishTime = computed(() => frontmatter.value?.date);
const lastUpdatedTime = computed(() => page.value?.lastUpdated);
const formattedPublishDate = computed(() =>
publishTime.value ? useDateFormat(publishTime.value, "YYYY年M月D日").value : ""
publishTime.value ? useDateFormat(publishTime.value, "YYYY年M月D日").value : "",
);
const lastUpdatedRawTime = computed(() =>
lastUpdatedTime.value ? useDateFormat(lastUpdatedTime.value, "YYYY-MM-DD HH:mm:ss").value : ""
lastUpdatedTime.value ? useDateFormat(lastUpdatedTime.value, "YYYY-MM-DD HH:mm:ss").value : "",
);
/** 相对时间显示配置 */
@@ -35,7 +35,7 @@ const timeAgo = useTimeAgo(
minute: (n: number) => `${n}分钟`,
second: (n: number) => `${n}`,
} as any,
}
},
);
/** 计算最终显示的编辑时间文本 */
@@ -85,25 +85,6 @@ const openImageViewer = (index: number, event: MouseEvent) => {
showImageViewer.value = true;
};
/** 复制锚点链接到剪贴板 */
const handleAnchorClick = (event: MouseEvent) => {
const anchor = (event.target as HTMLElement).closest("a.title-anchor") as HTMLAnchorElement;
if (!anchor) return;
const href = anchor.getAttribute("href");
const fullUrl = `${window.location.origin}${window.location.pathname}${href}`;
copyToClipboard(fullUrl);
const label = anchor.querySelector("span.visually-hidden");
if (label) {
const originalText = label.textContent;
label.textContent = "已复制";
setTimeout(() => {
label.textContent = originalText;
}, 1000);
}
};
/** 处理列表 Bullet 旋转和有序列表对齐 */
const enhanceDomStyles = () => {
if (!articleContentRef.value) return;
@@ -142,7 +123,7 @@ if (isClient()) {
enhanceDomStyles();
bindImageEvents();
},
{ childList: true, subtree: true, characterData: true }
{ childList: true, subtree: true, characterData: true },
);
onMounted(() => {
@@ -156,22 +137,16 @@ if (isClient()) {
<template>
<Header />
<main id="article-content" ref="articleContentRef" @click="handleAnchorClick">
<hgroup>
<h1>{{ frontmatter.title || page.title }}</h1>
<div v-if="frontmatter.description">
<hr />
<h6>{{ frontmatter.description }}</h6>
</div>
</hgroup>
<main id="article-content" ref="articleContentRef">
<Content />
<PrevNext />
</main>
<aside id="article-aside">
<div class="post-info">
<p class="date-publish" v-if="formattedPublishDate">发布于 {{ formattedPublishDate }}</p>
<p v-if="frontmatter.description" class="description">{{ frontmatter.description }}</p>
<p v-if="formattedPublishDate" class="date-publish">发布于 {{ formattedPublishDate }}</p>
<ClientOnly>
<p class="date-update" :title="lastUpdatedRawTime" v-if="formattedLastUpdated">
<p v-if="formattedLastUpdated" :title="lastUpdatedRawTime" class="date-update">
{{ formattedLastUpdated }}
</p>
</ClientOnly>

View File

@@ -158,7 +158,7 @@ onMounted(() => {
<h3>
{{ randomGreeting }}
</h3>
<img src="/assets/images/avatar_transparent.png" alt="" />
<img src="/assets/images/avatar_transparent.webp" alt="" />
<span></span>
</div>
</ClientOnly>

View File

@@ -11,7 +11,7 @@ import { useScreenWidthStore } from "./screenWidth";
/** 导出 */
export const useNavStateStore = defineStore("navState", () => {
const isNavExpanded = ref<boolean>(false);
const cookieName = "nav-expanded";
const cookieName = "navbar_expanded";
const screenWidthStore = useScreenWidthStore();
/** 初始从 Cookie 读取状态 */
@@ -36,7 +36,7 @@ export const useNavStateStore = defineStore("navState", () => {
isNavExpanded.value = true;
stop();
}
}
},
);
}
} else {
@@ -91,7 +91,7 @@ export const useNavStateStore = defineStore("navState", () => {
if (!isAbove) {
isNavExpanded.value = false;
}
}
},
);
return {

View File

@@ -0,0 +1,63 @@
/**
* 颜色主题偏好管理
*/
import { defineStore } from "pinia";
import { computed } from "vue";
import { useColorMode, useCycleList } from "@vueuse/core";
import { setCookie, getCookie, deleteCookie } from "../utils/cookie";
export type ThemePreference = "auto" | "light" | "dark";
/** 偏好映射配置 */
const THEME_MAP = {
auto: { icon: "brightness_auto", label: "跟随系统" },
light: { icon: "light_mode", label: "亮色模式" },
dark: { icon: "dark_mode", label: "深色模式" },
} as const;
export const useThemeStateStore = defineStore("themeState", () => {
const mode = useColorMode({
emitAuto: true,
storageKey: "theme_preference",
storage: {
getItem: (key) => getCookie(key) || "auto",
setItem: (key, value) => setCookie(key, value),
removeItem: (key) => deleteCookie(key),
},
onChanged: (val, defaultHandler) => {
defaultHandler(val);
// 确保同时切换 light/dark 类
if (typeof document !== "undefined") {
document.documentElement.classList.toggle("dark", val === "dark");
document.documentElement.classList.toggle("light", val === "light");
}
},
});
/** 循环切换列表 */
const { next } = useCycleList(["auto", "light", "dark"] as ThemePreference[], {
initialValue: mode.value as ThemePreference,
});
/** 切换主题模式 */
const cycleTheme = () => {
mode.value = next();
};
/** 当前偏好设置 */
const preference = computed(() => mode.value as ThemePreference);
/** 当前状态对应的图标 */
const currentIcon = computed(() => THEME_MAP[preference.value].icon);
/** 当前状态对应的文本标签 */
const currentLabel = computed(() => THEME_MAP[preference.value].label);
return {
preference,
currentIcon,
currentLabel,
cycleTheme,
};
});

View File

@@ -1,20 +1,3 @@
.layout-enter-active {
transition: opacity var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect),
transform var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial)
var(--md-sys-motion-duration-short1);
}
.layout-leave-active {
transition: opacity var(--md-sys-motion-spring-default-effect-duration) var(--md-sys-motion-spring-default-effect),
transform var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
}
.layout-enter-from,
.layout-leave-to {
opacity: 0;
transform: translateY(10px);
}
@keyframes fade-out {
0% {
opacity: 0;

View File

@@ -0,0 +1,102 @@
@use "../mixin";
.AnchorLink {
@include mixin.typescale-style("body-large");
transition: var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect);
user-select: none;
-moz-user-select: none;
&.normal {
display: flex;
align-items: start;
flex-direction: column;
gap: 3px;
position: absolute;
left: -60px;
top: -6px;
margin-block-start: 0px !important;
height: calc(100% + 6px);
width: 60px;
border-radius: var(--md-sys-shape-corner-full);
opacity: 0;
z-index: 999;
.symbol {
@include mixin.material-symbols($size: 24);
display: block;
position: sticky;
top: 0px;
height: 54px;
width: 54px;
color: var(--md-sys-color-on-surface);
line-height: 54px;
text-align: center;
text-decoration: none !important;
border-radius: var(--md-sys-shape-corner-full);
background-color: transparent;
opacity: 0;
&:hover {
background-color: var(--md-sys-color-surface-container-low);
}
&:focus-visible {
opacity: 1;
}
}
.feedback {
@include mixin.typescale-style("label-medium");
position: sticky;
top: 57px;
padding-block: 3px;
padding-inline: 9px;
word-break: keep-all;
border-radius: var(--md-sys-shape-corner-small);
background-color: var(--md-sys-color-surface-container-low);
}
&:focus-within {
opacity: 1;
}
}
&.inline {
@include mixin.material-symbols($size: 24);
display: none;
color: var(--md-sys-color-on-surface);
text-decoration: none !important;
opacity: 0.2;
}
@media screen and (max-width: 1600px) {
&.normal {
display: none;
}
&.inline {
display: inline-block;
}
}
}

View File

@@ -20,28 +20,32 @@
position: relative;
.panel {
display: flex;
flex-direction: column;
gap: 24px;
position: absolute;
left: -12px;
top: -12px;
padding: 24px;
max-width: 520px;
min-width: 340px;
border-radius: var(--md-sys-shape-corner-extra-large);
background-color: var(--md-sys-color-surface-container-low);
box-shadow: 0px 1px 6px -3px var(--md-sys-color-shadow);
overflow-y: overlay;
transform-origin: 0px 0px;
transform-origin: top left;
z-index: 100;
.container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 520px;
min-width: 340px;
border-radius: var(--md-sys-shape-corner-extra-large);
background-color: var(--md-sys-color-surface-container-low);
box-shadow: 0px 1px 6px -3px var(--md-sys-color-shadow);
overflow-y: overlay;
transform-origin: top left;
}
.section {
h6 {
display: inline-flex;
@@ -52,6 +56,9 @@
margin-block-end: 12px;
user-select: none;
-moz-user-select: none;
span {
@include mixin.material-symbols($size: 16);
@@ -73,6 +80,36 @@
gap: 4px;
}
}
@media screen and (max-width: 1600px) {
left: 0px;
top: 0px;
}
@media screen and (max-width: 840px) {
position: fixed;
left: 0px;
top: 0px;
height: 100%;
width: 100%;
backdrop-filter: brightness(0.5);
transform-origin: center center;
z-index: 999;
.container {
position: absolute;
left: 50%;
top: 50%;
max-width: 380px;
min-width: 330px;
transform-origin: center center;
translate: -50% -50%;
}
}
}
}
}
@@ -148,20 +185,27 @@
.expand-enter-active,
.expand-leave-active {
transition:
transform var(--md-sys-motion-spring-default-spatial-duration) var(--md-sys-motion-spring-default-spatial),
opacity var(--md-sys-motion-spring-slow-effect-duration) var(--md-sys-motion-spring-slow-effect);
transition: opacity var(--md-sys-motion-spring-slow-effect-duration) var(--md-sys-motion-spring-slow-effect);
.container {
transition: transform var(--md-sys-motion-spring-default-spatial-duration) var(--md-sys-motion-spring-default-spatial);
}
}
.expand-enter-from,
.expand-leave-to {
opacity: 0;
pointer-events: none;
transform: scale(0.8);
.container {
transform: scale(0.8);
}
}
.expand-enter-to,
.expand-leave-from {
transform: scale(1);
.container {
transform: scale(1);
}
}
}

View File

@@ -16,6 +16,12 @@
z-index: 0;
.single {
display: grid;
align-items: center;
grid-template-columns: 50% 50%;
position: relative;
height: 100%;
width: 100%;
@@ -24,7 +30,116 @@
background-size: cover;
overflow: hidden;
z-index: 1;
h1 {
@include mixin.typescale-style(
"display-large",
$font-size: 72rem,
$line-height: 78rem,
$font-variation-settings: "wght" 900
);
grid-column: 1 / 2;
grid-row: 1;
position: relative;
margin-inline-start: 48px;
color: transparent;
background-clip: text;
background-position: 0% 0%;
background-repeat: no-repeat;
background-size: 100%;
animation: title-gradient 5s var(--md-sys-motion-spring-slow-effect) infinite alternate-reverse;
z-index: 2;
&.overlay {
color: var(--md-ref-palette-primary20);
background: none;
animation: none;
mix-blend-mode: luminosity;
z-index: 3;
}
@keyframes title-gradient {
0% {
background-position: 0% 0%;
background-size: 100%;
}
50% {
background-position: 100% 0%;
}
100% {
background-position: 100% 100%;
background-size: 200%;
}
}
@media screen and (max-width: 1200px) {
@include mixin.typescale-style(
"display-large",
$font-size: 72rem,
$line-height: 78rem,
$font-variation-settings: "wght" 800
);
margin-inline: 36px;
}
@media screen and (max-width: 840px) {
@include mixin.typescale-style(
"display-large",
$font-size: 48rem,
$line-height: 54rem,
$font-variation-settings: "wght" 900
);
grid-column: 1 / 3;
margin-inline: 24px;
}
}
img {
grid-column: 1 / 3;
grid-row: 1;
height: 100%;
width: 100%;
object-fit: cover;
overflow: hidden;
pointer-events: none;
user-select: none;
-moz-user-select: none;
&:nth-of-type(1) {
backdrop-filter: blur(10px);
filter: url(#noise-filter);
mask-image: linear-gradient(to right, black 0%, transparent 60%);
mix-blend-mode: screen;
z-index: 1;
}
&:nth-of-type(2) {
z-index: 0;
}
@media screen and (max-width: 840px) {
&:nth-of-type(1) {
mask-image: linear-gradient(to right, black 0%, transparent 100%);
}
}
}
}
.stage {
@@ -54,6 +169,13 @@
overflow: hidden;
transition: var(--carousel-duration) var(--md-sys-motion-spring-slow-effect);
img {
height: 100%;
width: 100%;
object-fit: cover;
}
&.current {
width: 90%;
@@ -89,6 +211,30 @@
opacity: 0;
z-index: 0;
}
@media screen and (max-width: 840px) {
&.current {
width: 85%;
}
&.next {
width: calc(15% - 12px);
}
}
@media screen and (max-width: 600px) {
&.current {
width: 100%;
border-radius: var(--md-sys-shape-corner-large);
}
&.next {
width: 0%;
border-radius: var(--md-sys-shape-corner-large);
}
}
}
}
@@ -169,56 +315,79 @@
}
}
}
}
@media screen and (max-width: 1600px) {
.Header {
@media screen and (max-width: 1600px) {
grid-column: span 12;
}
}
@media screen and (max-width: 1200px) {
.Header {
@media screen and (max-width: 1200px) {
grid-column: span 8;
height: 46vw;
min-height: 270px;
}
}
@media screen and (max-width: 840px) {
.Header {
@media screen and (max-width: 840px) {
grid-column: span 6;
min-height: 270px;
.carousel-container .stage .item {
&.current {
width: 85%;
}
&.next {
width: calc(15% - 12px);
}
}
}
}
@media screen and (max-width: 600px) {
.Header {
@media screen and (max-width: 600px) {
grid-column: span 4;
}
}
.carousel-container .stage .item {
&.current {
width: 100%;
.header-enter-active {
transition:
opacity var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect),
transform var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial)
var(--md-sys-motion-duration-short1);
border-radius: var(--md-sys-shape-corner-large);
.carousel-container .single {
h1 {
transition: var(--md-sys-motion-spring-slow-effect-duration) var(--md-sys-motion-spring-slow-effect);
}
img {
&:nth-of-type(1) {
transition: 5s var(--md-sys-motion-spring-slow-effect);
}
&.next {
width: 0%;
border-radius: var(--md-sys-shape-corner-large);
&:nth-of-type(2) {
transition: var(--md-sys-motion-spring-slow-effect-duration) var(--md-sys-motion-spring-default-effect)
var(--md-sys-motion-spring-fast-effect-duration);
}
}
}
}
.header-leave-active {
transition:
opacity var(--md-sys-motion-spring-default-effect-duration) var(--md-sys-motion-spring-default-effect),
transform var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
}
.header-enter-from,
.header-leave-to {
opacity: 0;
transform: scale(0.985);
.carousel-container .single {
h1 {
opacity: 0;
transform: translateX(5%);
}
img {
&:nth-of-type(1) {
opacity: 0;
transform: translateX(-25%);
}
&:nth-of-type(2) {
opacity: 0;
transform: scale(1.005);
}
}
}

View File

@@ -22,47 +22,26 @@
-moz-user-select: none;
z-index: 9999;
.index-text {
padding-block: 3px;
padding-inline: 9px;
.nav-group {
position: absolute;
bottom: 0px;
color: var(--md-sys-color-surface-variant);
border-radius: var(--md-sys-shape-corner-medium);
background-color: var(--md-sys-color-on-surface-variant);
margin-block-end: 12px;
z-index: 3;
}
.btn-close,
.btn-nav {
display: flex;
align-items: center;
justify-content: center;
.close {
position: absolute;
right: 0px;
top: 0px;
position: absolute !important;
margin-inline-end: 12px;
margin-block-start: 12px;
z-index: 3;
}
.btn-close {
right: 20px;
top: 20px;
}
.btn-nav {
top: 50%;
&.prev {
left: 20px;
}
&.next {
right: 20px;
}
}
.content {
display: flex;
align-items: center;
@@ -74,70 +53,21 @@
padding-block-start: 5vh;
z-index: 2;
}
.content-image {
border-radius: var(--md-sys-shape-corner-full);
.content-image {
background-color: var(--md-sys-color-surface-variant);
background-color: var(--md-sys-color-surface-variant);
clip-path: circle(10%);
object-fit: contain;
transition: var(--md-sys-motion-spring-slow-spatial-duration) var(--md-sys-motion-spring-slow-spatial);
clip-path: circle(10%);
object-fit: contain;
transition: var(--md-sys-motion-spring-slow-spatial-duration) var(--md-sys-motion-spring-slow-spatial);
&.transitioning {
opacity: 0.6;
}
&.transitioning {
opacity: 0.6;
}
&.notransition {
transition: none !important;
}
}
.thumbnails {
display: flex;
gap: 8px;
max-width: calc(100% - 66px);
padding: 24px;
overflow-x: auto;
overflow-y: hidden;
z-index: 3;
}
.thumbnail {
flex-shrink: 0;
width: 66px;
height: 66px;
padding: 0px;
border: 0px;
border-radius: var(--md-sys-shape-corner-large);
cursor: pointer;
overflow: hidden;
transition: transform var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
&.active {
@include mixin.focus-ring($thickness: 1, $offset: 2);
outline-color: var(--md-sys-color-on-surface-variant) !important;
transition: transform var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
}
&:hover {
transform: scale(0.9);
}
img {
height: 100%;
width: 100%;
object-fit: cover;
&.notransition {
transition: none !important;
}
}
}
@@ -146,8 +76,6 @@
opacity: 1;
.content-image {
border-radius: var(--md-sys-shape-corner-large);
clip-path: circle(100%);
}
}

View File

@@ -44,7 +44,8 @@
background-color: var(--md-sys-color-secondary-container);
cursor: pointer;
transition: grid var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial),
transition:
grid var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial),
gap var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
overflow: hidden;
@@ -57,7 +58,9 @@
width: 56px;
text-align: center;
font-variation-settings: "FILL" 1, "wght" 300;
font-variation-settings:
"FILL" 1,
"wght" 300;
transition: font-variation-settings var(--md-sys-motion-spring-fast-spatial-duration)
var(--md-sys-motion-spring-fast-spatial);
@@ -77,11 +80,15 @@
}
&:hover span {
font-variation-settings: "FILL" 1, "wght" 600;
font-variation-settings:
"FILL" 1,
"wght" 600;
}
&:active span {
font-variation-settings: "FILL" 1, "wght" 200;
font-variation-settings:
"FILL" 1,
"wght" 200;
}
}
}
@@ -104,7 +111,8 @@
position: relative;
transition: grid var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial),
transition:
grid var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial),
gap var(--md-sys-motion-spring-default-effect-duration) var(--md-sys-motion-spring-default-effect);
}
@@ -122,7 +130,8 @@
border-radius: var(--md-sys-shape-corner-full);
overflow: hidden;
transition: height var(--md-sys-motion-spring-default-effect-duration) var(--md-sys-motion-spring-default-effect),
transition:
height var(--md-sys-motion-spring-default-effect-duration) var(--md-sys-motion-spring-default-effect),
width var(--md-sys-motion-spring-default-effect-duration) var(--md-sys-motion-spring-default-effect);
z-index: 1;
@@ -135,7 +144,9 @@
span {
@include mixin.material-symbols();
font-variation-settings: "FILL" 1, "wght" 300;
font-variation-settings:
"FILL" 1,
"wght" 300;
transition: font-variation-settings var(--md-sys-motion-spring-fast-spatial-duration)
var(--md-sys-motion-spring-fast-spatial);
@@ -195,7 +206,8 @@
border-radius: var(--md-sys-shape-corner-full);
pointer-events: none;
transition: background-color var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect),
transition:
background-color var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect),
height var(--md-sys-motion-spring-default-effect-duration) var(--md-sys-motion-spring-default-effect),
width var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
z-index: 0;
@@ -220,19 +232,40 @@
}
&.inactive .accent .icon span {
font-variation-settings: "FILL" 0, "wght" 200;
font-variation-settings:
"FILL" 0,
"wght" 200;
}
&:hover .accent .icon span {
font-variation-settings: "FILL" 1, "wght" 400;
font-variation-settings:
"FILL" 1,
"wght" 400;
}
&:active .accent .icon span {
font-variation-settings: "FILL" 1, "wght" 200;
font-variation-settings:
"FILL" 1,
"wght" 200;
}
}
}
.actions {
display: flex;
align-items: center;
flex-direction: column;
flex-wrap: nowrap;
margin-block: 24px;
width: 100%;
@media screen and (max-width: 840px) {
display: none;
}
}
&.bar {
flex-direction: row;
@@ -310,6 +343,12 @@
height: 40px;
width: 100%;
}
@media screen and (max-width: 840px) {
.label {
@include mixin.typescale-style("label-large");
}
}
}
}
}
@@ -474,11 +513,9 @@
}
}
}
}
}
@media screen and (max-width: 840px) {
.NavBar.rail {
display: none;
@media screen and (max-width: 840px) {
display: none;
}
}
}

View File

@@ -30,10 +30,6 @@ table {
tr {
th {
min-width: 10ch;
padding: 16px 24px;
text-transform: capitalize;
background-color: var(--md-sys-color-surface-variant);
@@ -52,8 +48,6 @@ table {
vertical-align: top;
td {
padding: 16px 24px;
vertical-align: inherit;
code {
@@ -63,9 +57,18 @@ table {
}
}
:is(td, th) {
td,
th {
min-width: 16ch;
padding: 16px 24px;
border: 1px solid var(--md-sys-color-outline);
&:empty {
display: none;
}
code {
padding-block: 0px !important;
}

View File

@@ -13,59 +13,6 @@
margin-block-start: 0px;
}
}
> hgroup {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 12px;
position: relative;
width: 100%;
margin-block-end: 24px;
h1 {
@include mixin.typescale-style("display-large");
text-align: center;
}
div {
display: flex;
align-items: center;
flex-direction: row;
flex-wrap: wrap;
gap: 6px;
width: 100%;
hr {
flex-grow: 1;
margin: 0px;
}
h6 {
display: grid;
align-items: start;
gap: 6px;
grid-template-columns: max-content auto;
width: max-content;
line-height: 18px;
text-align: justify;
&::before {
@include mixin.material-symbols($name: "message", $size: 18);
vertical-align: middle;
}
}
}
}
}
*[class^="language-"] {
@@ -96,7 +43,8 @@
cursor: pointer;
opacity: 0;
transition: border-radius var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial),
transition:
border-radius var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial),
opacity var(--md-sys-motion-spring-default-effect-duration) var(--md-sys-motion-spring-default-effect);
visibility: hidden;
z-index: 2;
@@ -389,7 +337,7 @@
}
&:first-child .title-with-achor {
margin-block-start: 0px;
margin-block-start: 24px;
}
.title-with-achor {
@@ -402,6 +350,7 @@
display: inline-block;
line-height: 54px;
font-variation-settings: "wght" 600;
border-radius: var(--md-sys-shape-corner-medium);
@@ -409,101 +358,24 @@
text-decoration: none;
}
}
.title-anchor {
@include mixin.typescale-style("body-large");
display: inline-flex;
align-items: center;
flex-direction: column;
gap: 3px;
position: absolute;
left: -60px;
top: 0px;
height: 54px;
width: 54px;
color: var(--md-sys-color-on-surface);
text-decoration: none;
border-radius: var(--md-sys-shape-corner-full);
opacity: 0;
transition: var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect);
user-select: none;
-moz-user-select: none;
&:focus-visible {
opacity: 1;
z-index: 2;
span:nth-of-type(1) {
opacity: 1;
}
}
span:nth-of-type(1) {
@include mixin.material-symbols($size: 24);
display: block;
height: 54px;
width: 54px;
line-height: 54px;
text-align: center;
border-radius: var(--md-sys-shape-corner-full);
background-color: transparent;
opacity: 0;
}
span.visually-hidden {
@include mixin.typescale-style("label-medium");
padding-block: 3px;
padding-inline: 9px;
word-break: keep-all;
border-radius: var(--md-sys-shape-corner-small);
background-color: var(--md-sys-color-surface-container-low);
opacity: 0;
visibility: hidden;
}
}
}
&:hover {
.title-anchor {
&:hover .AnchorLink {
opacity: 1;
.symbol {
opacity: 1;
span:nth-of-type(1) {
opacity: 1;
}
&:hover {
span:nth-of-type(1):hover {
background-color: var(--md-sys-color-surface-container-low);
+ span.visually-hidden {
opacity: 1;
visibility: visible;
}
}
}
}
}
}
a {
text-decoration: underline solid;
text-decoration: underline 2px dotted;
text-underline-offset: 3px;
&:hover {
text-decoration-style: solid;
}
}
blockquote {
@@ -671,24 +543,18 @@
font-variation-settings: "wght" 700;
}
&:has(img) {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
img {
display: inline-block;
img {
display: inline-block;
width: 100%;
width: 100%;
border-radius: var(--md-sys-shape-corner-medium);
border-radius: var(--md-sys-shape-corner-medium);
cursor: pointer;
transition: border-radius var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
cursor: pointer;
transition: border-radius var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
&:hover {
border-radius: var(--md-sys-shape-corner-extra-large);
}
&:hover {
border-radius: var(--md-sys-shape-corner-extra-large);
}
}
}
@@ -821,6 +687,12 @@
&::before {
@include mixin.material-symbols($size: 14);
margin-block: 1px auto;
}
&.description::before {
content: "message";
}
&.date-publish::before {
@@ -882,10 +754,6 @@
@media screen and (max-width: 600px) {
#article-content {
grid-column: 1 / 5;
p:has(img) {
grid-template-columns: 1fr;
}
}
#article-aside {

View File

@@ -66,7 +66,8 @@
animation: avatar-box-h3 var(--md-sys-motion-spring-slow-spatial-duration) var(--md-sys-motion-spring-slow-spatial)
var(--md-sys-motion-spring-fast-spatial-duration) both;
overflow: hidden;
transition: color var(--md-sys-motion-spring-slow-effect-duration) var(--md-sys-motion-spring-slow-effect),
transition:
color var(--md-sys-motion-spring-slow-effect-duration) var(--md-sys-motion-spring-slow-effect),
background-color var(--md-sys-motion-spring-slow-effect-duration) var(--md-sys-motion-spring-slow-effect);
transform-origin: 0px 0px;
z-index: 2;
@@ -119,7 +120,9 @@
width: 200px;
animation: avatar-box-span 60s linear infinite;
mask: linear-gradient(45deg, black, transparent), var(--via-svg-mask) 0 / 100% no-repeat;
mask:
linear-gradient(45deg, black, transparent),
var(--via-svg-mask) 0 / 100% no-repeat;
mask-composite: intersect;
mask-mode: alpha;
opacity: 0.5;
@@ -169,6 +172,7 @@
h1 {
@include mixin.typescale-style("display-large");
font-variation-settings: "wght" 600;
text-align: center;
word-break: keep-all;
}
@@ -254,3 +258,22 @@
}
}
}
.layout-enter-active {
transition:
opacity var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect),
transform var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial)
var(--md-sys-motion-duration-short1);
}
.layout-leave-active {
transition:
opacity var(--md-sys-motion-spring-default-effect-duration) var(--md-sys-motion-spring-default-effect),
transform var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
}
.layout-enter-from,
.layout-leave-to {
opacity: 0;
transform: scale(0.99);
}

View File

@@ -30,8 +30,6 @@
h2,
a {
@include mixin.typescale-style("display-small");
text-decoration: none;
}
}

View File

@@ -52,6 +52,18 @@ body {
background-color: var(--md-sys-color-on-surface-variant);
}
&.light {
img[data-mode="darkmode-only"] {
display: none !important;
}
}
&.dark {
img[data-mode="lightmode-only"] {
display: none !important;
}
}
}
#app {
@@ -122,7 +134,8 @@ a {
color: var(--md-sys-color-primary);
letter-spacing: 0px;
text-underline-offset: 5px;
text-decoration: underline solid;
text-underline-offset: 6px;
code {
color: var(--md-sys-color-inverse-primary) !important;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,82 @@
import type MarkdownIt from "markdown-it";
/** 锚点配置项 */
interface AnchorOptions {
/** 锚点层级,例如 [1, 2, 3] 代表 h1, h2, h3 */
levels?: number[];
/** 锚点显示的字符串 */
symbol?: string;
/** 锚点的类名称 */
class?: string;
}
/**
* 为标题添加可复制的锚点链接,将同时生成两种锚点:
* 1. 标题行内锚点
* 2. 组件锚点
* @param mdit - MarkdownIt 实例
* @param options - 锚点配置项
*/
export function anchor(mdit: MarkdownIt, options: AnchorOptions = {}): void {
const { levels = [1, 2, 3, 4, 5, 6], symbol = "link", class: className = "AnchorLink" } = options;
// 在 core 规则中拦截 tokens在每个 heading 后添加 AnchorLink 组件以及行内锚点
mdit.core.ruler.push("add_anchor_links", (state) => {
const tokens = state.tokens;
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
// 在 heading_close 处进行处理,此时 heading_open 和 inline 都在前面
if (token.type === "heading_close") {
const level = parseInt(token.tag.slice(1));
if (!levels.includes(level)) continue;
const headingOpenToken = tokens[i - 2];
const inlineToken = tokens[i - 1];
const idAttr = headingOpenToken?.attrGet("id");
if (idAttr) {
// 移除默认渲染的锚点VitePress 默认会添加 header-anchor
if (inlineToken && inlineToken.children) {
let anchorStartIndex = -1;
let anchorEndIndex = -1;
for (let j = 0; j < inlineToken.children.length; j++) {
const child = inlineToken.children[j];
// 查找带有 header-anchor 类名的 link_open
if (child.type === "link_open" && child.attrGet("class")?.includes("header-anchor")) {
anchorStartIndex = j;
}
// 找到对应的 link_close
if (anchorStartIndex !== -1 && child.type === "link_close") {
anchorEndIndex = j;
break;
}
}
// 如果找到了默认锚点,将其从 children 中移除
if (anchorStartIndex !== -1 && anchorEndIndex !== -1) {
inlineToken.children.splice(anchorStartIndex, anchorEndIndex - anchorStartIndex + 1);
}
}
// 添加标题行内锚点
if (inlineToken && inlineToken.children) {
const inlineAnchor = new state.Token("html_inline", "", 0);
inlineAnchor.content = `<a href="#${idAttr}" class="${className} inline">${symbol}</a>`;
// 将行内锚点添加到标题文本的末端
inlineToken.children.push(inlineAnchor);
}
// 添加 AnchorLink 组件
const anchorToken = new state.Token("html_block", "", 0);
anchorToken.content = `<AnchorLink href="#${idAttr}" symbol="${symbol}" className="${className} normal" />`;
// 将组件插入到 heading_close 之后
tokens.splice(i + 1, 0, anchorToken);
i++; // 跳过新插入的 token
}
}
}
});
}

View File

@@ -4,12 +4,7 @@ import type MarkdownIt from "markdown-it";
* sectionheadline-block便
* @param mdit - MarkdownIt
*/
export function wrapHeadingsAsSections(mdit: MarkdownIt): void {
if (!mdit || !mdit.core || !mdit.core.ruler) {
console.warn("Invalid MarkdownIt instance provided");
return;
}
export function sectionWrapper(mdit: MarkdownIt): void {
mdit.core.ruler.before("inline", "group_sections", (state) => {
const tokens = state.tokens;
const newTokens: any[] = [];

View File

@@ -0,0 +1,131 @@
import type MarkdownIt from "markdown-it";
type StateBlock = any;
/**
* 一个简易的 markdown-it 表格插件
* @param mdit MarkdownIt 实例
*/
export const table = (mdit: MarkdownIt) => {
// 禁用原有的表格规则
mdit.disable("table");
/** 表格块解析规则 */
const tableBlock = (state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean => {
let nextLine = startLine;
let lineText = state.getLines(nextLine, nextLine + 1, 0, false).trim();
// 检查是否包含表格分隔符
if (!lineText.includes("|")) return false;
const rows: string[] = [];
while (nextLine < endLine) {
lineText = state.getLines(nextLine, nextLine + 1, 0, false).trim();
if (!lineText.includes("|") && lineText !== "") break;
if (lineText !== "") rows.push(lineText);
nextLine++;
}
if (rows.length < 1) return false;
if (silent) return true;
const token = state.push("simple_table", "table", 0);
token.map = [startLine, nextLine];
token.content = rows.join("\n");
state.line = nextLine;
return true;
};
mdit.block.ruler.before("paragraph", "simple_table", tableBlock);
// 渲染逻辑
mdit.renderer.rules.simple_table = (tokens, idx) => {
const content = tokens[idx].content;
const lines = content.split("\n").filter((l) => l.trim() !== "");
if (lines.length === 0) return "";
let html = "<table>\n";
// 检查第二行是否是分隔行
const hasSeparator = lines.length > 1 && /^[|:\-\s]+$/.test(lines[1]) && lines[1].includes("-");
let headerLines: string[] = [];
let bodyLines: string[] = [];
if (hasSeparator) {
headerLines = [lines[0]];
bodyLines = lines.slice(2);
} else {
// 如果没有分隔行,用第一行作为表头
headerLines = [lines[0]];
bodyLines = lines.slice(1);
}
// 分割单元格并清理首尾空的装饰管线
const getCells = (line: string) => {
const cells = line.split("|");
if (cells.length > 0 && cells[0].trim() === "") cells.shift();
if (cells.length > 0 && cells[cells.length - 1].trim() === "") cells.pop();
return cells.map((c) => c.trim());
};
// 解析单元格中的属性
const parseAttributes = (cellContent: string) => {
const attrRegex = /\{([^{}]+)\}\s*$/;
const match = cellContent.match(attrRegex);
const attrs: Record<string, string> = {};
let cleanedContent = cellContent;
if (match) {
cleanedContent = cellContent.replace(attrRegex, "").trim();
const attrString = match[1];
const parts = attrString.split(/\s+/);
parts.forEach((part) => {
const [key, value] = part.split("=");
if (key && (key === "colspan" || key === "rowspan")) {
attrs[key] = value || "1";
}
});
}
return { cleanedContent, attrs };
};
// 渲染表头
if (headerLines.length > 0) {
html += "<thead>\n";
headerLines.forEach((line) => {
html += "<tr>\n";
getCells(line).forEach((cell) => {
const { cleanedContent, attrs } = parseAttributes(cell);
const attrStr = Object.entries(attrs)
.map(([k, v]) => ` ${k}="${v}"`)
.join("");
html += `<th${attrStr}>${mdit.renderInline(cleanedContent)}</th>\n`;
});
html += "</tr>\n";
});
html += "</thead>\n";
}
// 渲染表体
if (bodyLines.length > 0) {
html += "<tbody>\n";
bodyLines.forEach((line) => {
html += "<tr>\n";
getCells(line).forEach((cell) => {
const { cleanedContent, attrs } = parseAttributes(cell);
const attrStr = Object.entries(attrs)
.map(([k, v]) => ` ${k}="${v}"`)
.join("");
html += `<td${attrStr}>${mdit.renderInline(cleanedContent)}</td>\n`;
});
html += "</tr>\n";
});
html += "</tbody>\n";
}
html += "</table>";
return html;
};
};

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
# 构建阶段
FROM node:trixie-slim AS builder
# 安装 git
RUN apt-get update && apt-get install -y git \
&& rm -rf /var/lib/apt/lists/*
# 设置工作目录
WORKDIR /app
# 拉取项目代码
RUN git clone https://github.com/sendevia/website .
# 安装依赖并构建
RUN npm i && npm run docs:build
# 最终阶段
FROM nginx:stable-perl
# 从构建阶段复制 dist 产物到 workdir
COPY --from=builder /app/.vitepress/dist /app/dist
# 暴露端口80 HTTP 和 443 HTTPS
EXPOSE 80 443
# 启动 nginx
CMD ["nginx", "-g", "daemon off;"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 sendevia
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,5 +1,5 @@
{
"version": "26.1.18(262)",
"version": "26.3.4(316)",
"scripts": {
"update-version": "bash ./scripts/update-version.sh",
"docs:dev": "vitepress dev",
@@ -9,16 +9,17 @@
},
"dependencies": {
"@material/material-color-utilities": "^0.3.0",
"@vueuse/core": "^14.1.0",
"@vueuse/core": "^14.2.1",
"pinia": "^3.0.4"
},
"devDependencies": {
"@mdit/plugin-align": "^0.22.2",
"@mdit/plugin-footnote": "^0.22.3",
"@mdit/plugin-tasklist": "^0.22.2",
"markdown-it-anchor": "^9.2.0",
"sass-embedded": "^1.93.0",
"vitepress": "^2.0.0-alpha.15",
"vue": "^3.5.0"
"@mdit/plugin-align": "^0.24.0",
"@mdit/plugin-footnote": "^0.23.0",
"@mdit/plugin-img-mark": "^0.23.0",
"@mdit/plugin-tasklist": "^0.23.0",
"@waline/client": "^3.13.0",
"sass-embedded": "^1.97.3",
"vitepress": "^2.0.0-alpha.16",
"vue": "^3.5.29"
}
}

75
posts/Jekylmt.md Normal file
View File

@@ -0,0 +1,75 @@
---
title: "关于 Jekylmt"
description: "一个简洁美观的 Jekyll 主题"
color: "#0084ff"
impression: "/assets/images/131559307_p0.webp"
categories:
- 博客主题
tags:
- readme
date: 2026-02-25T21:44:00Z
external_links:
- type: download
icon: rocket_launch
label: 使用主题模板
link: https://github.com/new?template_name=jekylmt&template_owner=sendevia
- type: normal
icon: code
label: Github 仓库
link: https://github.com/sendevia/jekylmt
- type: normal
icon: arrow_outward
label: 在线 Demo
link: https://jekylmt.sendevia.top
---
# 关于主题
不太擅长介绍,既然这个页面存在了,就简单写一下吧。 这是一个遵循 Material3 设计,并且使用了 Material Web 项目,轻量化的 Jekyll 主题。
由于 M3 的设计太好看了,一直想亲自动手设计一款使用这种设计的前端项目,作为练手,这个主题便诞生了。
我借助 Material Foundation 的 [material-color-utilities](https://github.com/material-foundation/material-color-utilities) 实现了 monet 取色。
你可以在每篇文章的头信息 `impression` 配置中指定头图,并通过 `color` 配置指定颜色来让主题生成调色板。
这个主题的参考来源主要是 [material.io](https://material.io) 官网,其次是 Google 提供的设计规范。
代码高亮支持的语言可以在 [rouge-ruby](https://rouge-ruby.github.io/docs/file.Languages.html) 的网站上找到。
# 主题截图
![桌面端第一张亮色截图](/assets/images/26/jekylmt_1.webp#light)
![桌面端第一张暗色截图](/assets/images/26/jekylmt_2.webp#dark)
![桌面端第二张亮色截图](/assets/images/26/jekylmt_3.webp#light)
![桌面端第二张暗色截图](/assets/images/26/jekylmt_4.webp#dark)
![移动端第一张亮色截图](/assets/images/26/jekylmt_5.webp#light)
![移动端第一张暗色截图](/assets/images/26/jekylmt_6.webp#dark)
![移动端第二张亮色截图](/assets/images/26/jekylmt_7.webp#light)
![移动端第二张暗色截图](/assets/images/26/jekylmt_8.webp#dark)
# 主要功能
1. Material 3 风格;
2. 支持根据提供的 HEX 颜色动态生成调色板并应用颜色主题;
3. 支持多种 Material 3 样式的组件;
4. 响应式布局。
# 头信息
下面是所有头信息的详解:
| name { colspan=2 } | | description | type | default |
| -------------------------- | ------------- | ---------------- | --------------------- | ----------------------------------------- |
| 文章相关 { rowspan=9 } | title | 文章标题 | text { rowspan=5 } | 使用 `_config.yml` 中的配置 { rowspan=5 } |
| | description | 文章简介 | | |
| | author | 文章作者 | | |
| | color | 文章主题颜色 | | |
| | impression | 文章头图 | | |
| | categories | 目录分类 | list { rowspan=2 } | 未定义 { rowspan=2 } |
| | tags | 文章标签 | | |
| | published | 是否发布文章 | boolean { rowspan=2 } | true { rowspan=2 } |
| | toc | 是否生成文章目录 | | |
| 页面导航相关 { rowspan=3 } | segment_icon | 导航栏中的图标 | text { rowspan=2 } | - { rowspan=3 } |
| | segment_title | 导航栏中的标题 | | |
| | navigation | 是否在导航中显示 | boolean | |

View File

@@ -1,8 +1,8 @@
---
title: "Markdown 扩展示例"
description: "本页面展示了 VitePress 提供的一些内置 markdown 扩展功能。"
color: ""
impression: ""
color: "#f53283"
impression: "/assets/images/138124971_p0.webp"
categories:
tags:
- markdown 语法

View File

@@ -1,41 +0,0 @@
---
title: "关于这个主题的一些事"
description: ""
color: "#59f3b3"
impression: "/assets/images/133925125_p0.webp"
categories:
- 随笔
tags:
- readme
date: 2023-05-28T04:00:00Z
---
# 关于主题
由于 M3 的设计太好看了,一直想亲自动手设计一款使用这种设计的前端项目,作为练手,这个主题便诞生了。
我借助 Material Foundation 的 [material-color-utilities](https://github.com/material-foundation/material-color-utilities) 实现了 monet 取色。
你可以在每篇文章的头信息 `impression` 配置中指定头图,并通过 `color` 配置指定颜色来让主题生成调色板。
这个主题的参考来源主要是 [material.io](https://material.io) 官网,其次是 Google 提供的设计规范。
# 主要功能
1. Material 3 Expressive 设计风格;
1. 支持根据提供的 HEX 颜色/输入的图片动态生成调色板并应用颜色主题;
1. 响应式布局。
# 头信息
下面是所有头信息的详解:
| name || description | type | default |
| ---------- | ------------- | ---------------- | ------- | --------------------------- |
| 文章相关 | title | 文章标题 | text | 使用 `_config.yml` 中的配置 |
| ^^ | description | 文章简介 | ^^ | ^^ |
| ^^ | author | 文章作者 | ^^ | ^^ |
| ^^ | color | 文章主题颜色 | ^^ | ^^ |
| ^^ | impression | 文章头图 | ^^ | ^^ |
| ^^ | categories | 目录分类 | list | 未定义 |
| ^^ | tags | 文章标签 | ^^ | ^^ |

144
posts/组件/ButtonGroup.md Normal file
View File

@@ -0,0 +1,144 @@
---
title: "Button group"
description: "按钮组组件"
color: "#42b883"
categories:
- 开发文档
tags:
- Vue 3
- 组件
- UI
date: 2026-02-25T21:20:00Z
---
# ButtonGroup 按钮组组件
`ButtonGroup` 是一个用于聚合多个 `MaterialButton` 的容器组件。它支持灵活的布局切换、统一的属性默认值配置以及全局事件委托监听。
## 核心特性
- **布局自适应**支持水平Horizontal和垂直Vertical两种排列方式。
- **属性继承与覆盖**:可以在组级别设置默认的尺寸、颜色、图标等,也可以在具体的按钮项中进行个性化覆盖。
- **智能类型识别**:内置了对 `download``normal` 类型的样式及图标自动匹配逻辑。
- **事件委托**:支持统一监听 `@click` 事件,便捷获取点击的项信息及索引。
## 组件属性 (Props)
| 属性名 | 类型 | 默认值 | 可选值 | 说明 |
| :--- | :--- | :--- | :--- | :--- |
| `links` | `ExternalLink[]` | `[]` | - | 按钮配置数组 |
| `layout` | `string` | `"horizontal"` | `"horizontal"`, `"vertical"` | 布局方向 |
| `size` | `string` | `"s"` | `"xs"`, `"s"`, `"m"`, `"l"`, `"xl"` | 默认按钮尺寸 |
| `color` | `string` | - | - | 默认按钮颜色样式 |
| `icon` | `string` | - | - | 默认按钮图标 |
| `target` | `string` | - | - | 默认链接打开方式 |
| `ariaLabel` | `string` | - | - | 组的无障碍标签 |
## 组件事件 (Emits)
| 事件名 | 回调参数 | 说明 |
| :--- | :--- | :--- |
| `@click` | `(event: Event, item: ExternalLink, index: number)` | 当组内任意按钮被点击时触发 |
## 按钮配置项 (ExternalLink)
每一项 `links` 中的对象支持以下配置:
| 属性名 | 类型 | 说明 |
| :--- | :--- | :--- |
| `label` | `string` | 按钮显示的文本内容 |
| `id` | `string` | (可选) 自定义标识符,方便在事件处理中识别 |
| `link` | `string` | (可选) 点击跳转的链接地址 |
| `type` | `string` | (可选) 预设类型:`download` (充满色), `normal` (色调色) |
| `icon` | `string` | (可选) 覆盖组设置的图标 |
| `color` | `string` | (可选) 覆盖组设置的颜色 |
| `size` | `string` | (可选) 覆盖组设置的尺寸 |
| `target` | `string` | (可选) 覆盖组设置的打开方式 |
| `ariaLabel` | `string` | (可选) 按钮的无障碍标签 |
| `onClick` | `Function` | (可选) 单体独立的点击回调函数 |
## 使用示例
### 1. 基础用法 (水平排列)
```vue
<ButtonGroup
:links="[
{ label: '查看详情', id: 'detail' },
{ label: '下载资源', id: 'download', type: 'download' }
]"
@click="(e, item) => console.log('点击了:', item.id)"
/>
```
<ButtonGroup
:links="[
{ label: '查看详情', id: 'detail' },
{ label: '下载资源', id: 'download', type: 'download' }
]"
@click="(e, item) => console.log('点击了:', item.id)"
/>
### 2. 垂直排列与尺寸控制
```vue
<ButtonGroup
layout="vertical"
size="l"
:links="[
{ label: '选项一', icon: 'settings' },
{ label: '选项二', icon: 'person' }
]"
/>
```
<ButtonGroup
layout="vertical"
size="l"
:links="[
{ label: '选项一', icon: 'settings' },
{ label: '选项二', icon: 'person' }
]"
/>
### 3. 混合图标与文本
```vue
<ButtonGroup
size="m"
:links="[
{ id: 'prev', icon: 'chevron_left', ariaLabel: '上一页' },
{ id: 'index', label: '1 / 5', color: 'tonal' },
{ id: 'next', icon: 'chevron_right', ariaLabel: '下一页' }
]"
/>
```
<ButtonGroup
size="m"
:links="[
{ id: 'prev', icon: 'chevron_left', ariaLabel: '上一页' },
{ id: 'index', label: '1 / 5', color: 'tonal' },
{ id: 'next', icon: 'chevron_right', ariaLabel: '下一页' }
]"
/>
### 4. 链接跳转
```vue
<ButtonGroup
target="_blank"
:links="[
{ label: 'GitHub', link: 'https://github.com', icon: 'code' },
{ label: '首页', link: '/', icon: 'home' }
]"
/>
```
<ButtonGroup
target="_blank"
:links="[
{ label: 'GitHub', link: 'https://github.com', icon: 'code' },
{ label: '首页', link: '/', icon: 'home' }
]"
/>

View File

@@ -1,8 +1,8 @@
---
title: "设置开机自启动的 Jekyll 服务"
description: "通过 systemd 实现一个开机自启的 Jekyll 服务,通常来说,这对使用 Jekyll 作为服务后端的网站很有用。"
color: ""
impression: ""
color: "#aa0c2b"
impression: "/assets/images/120678678_p0.webp"
categories:
- 随笔
tags:

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 KiB

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 624 KiB

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 653 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,5 +1,15 @@
#!/bin/bash
set -e
# 远程仓库配置
REMOTE="${1:-origin}"
BRANCH="${2:-master}"
echo "🚀 开始版本更新流程..."
echo "远程: ${REMOTE}/${BRANCH}"
echo "::>------------------------"
# 获取当前提交数
COMMIT_COUNT=$(git rev-list --count HEAD)
@@ -13,16 +23,36 @@ DAY=$(date +%-d)
NEW_VERSION="${YEAR}.${MONTH}.${DAY}(${NEXT_COMMIT_COUNT})"
echo "📝 更新版本号..."
# 使用 sed 更新 package.json 中的版本号
# 匹配 "version": "..." 模式
sed -i "s/\"version\": \".*\"/\"version\": \"${NEW_VERSION}\"/" package.json
echo "Version updated to: ${NEW_VERSION}"
echo "版本将更新到: ${NEW_VERSION}"
echo "::>------------------------"
# Git 操作
echo "📦 提交更改..."
git add package.json
git commit -m "chore(package): update to version ${NEW_VERSION}"
echo "Committed: chore(package): update to version ${NEW_VERSION}"
echo "已提交: chore(package): update to version ${NEW_VERSION}"
echo "::>------------------------"
echo "🏷️ 创建标签..."
git tag "${NEW_VERSION}"
echo "Tagged: ${NEW_VERSION}"
echo "已创建标签: ${NEW_VERSION}"
echo "::>------------------------"
echo "🌐 推送更改..."
git push "${REMOTE}" "${BRANCH}"
echo "已推送提交到 ${REMOTE}/${BRANCH}"
echo "::>------------------------"
echo "🌐 推送标签..."
git push "${REMOTE}" "${NEW_VERSION}"
echo "已推送标签: ${NEW_VERSION}"
echo "::>------------------------"
echo "==========================="
echo "✅ 版本更新完成!"
echo "新版本: ${NEW_VERSION}"
echo "==========================="