1
0
mirror of https://github.com/sendevia/website.git synced 2026-03-06 07:40:50 +08:00

36 Commits

Author SHA1 Message Date
206cf373a3 fix(ci): move platforms to docker build and push step 2026-03-06 00:09:25 +08:00
9677ac1367 feat(ci): disable build cache 2026-03-05 23:30:50 +08:00
b41cea0bed chore(package): update to version 26.3.5(323) 2026-03-05 23:19:08 +08:00
8345515977 fix(ci): remove buildkitd-flags 2026-03-05 23:14:32 +08:00
dd069771a8 feat(ci): add multi-platform support and buildkit flags to docker workflow 2026-03-05 23:12:00 +08:00
e4217512aa feat(NavBar): wrap components in ClientOnly 2026-03-05 23:11:36 +08:00
93a42ec037 feat: switch from nginx to caddy for static site hosting 2026-03-05 22:53:31 +08:00
d489bf24c4 feat: add Caddyfile 2026-03-05 22:53:08 +08:00
67a26def4f feat(Article): wrap post info in ClientOnly 2026-03-05 22:28:21 +08:00
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
37 changed files with 1036 additions and 501 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 }}
no-cache: true
platforms: linux/amd64,linux/arm64

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

@@ -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.webp" 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

@@ -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

@@ -159,7 +159,6 @@ if (isClient()) {
onMounted(() => {
screenWidthStore.init();
navStateStore.init();
themeStateStore.init();
nextTick(() => {
window.dispatchEvent(new Event("resize"));
@@ -175,7 +174,9 @@ if (isClient()) {
<template>
<nav class="NavBar" :class="navClass">
<div class="fab-container">
<MaterialButton color="text" :icon="navStateStore.isNavExpanded ? 'menu_open' : 'menu'" @click="toggleNav" />
<ClientOnly>
<MaterialButton color="text" :icon="navStateStore.isNavExpanded ? 'menu_open' : 'menu'" @click="toggleNav" />
</ClientOnly>
<button class="fab" @mousedown.prevent @click.stop="toggleSearch">
<span>{{ searchStateStore.isSearchActive ? "close" : "search" }}</span>
<p :ref="(el) => setLabelRef(el, '.fab')">搜索</p>
@@ -203,15 +204,17 @@ if (isClient()) {
</div>
<div class="actions">
<MaterialButton
class="theme-btn"
size="m"
color="text"
:title="themeStateStore.currentLabel"
:icon="themeStateStore.currentIcon"
@click="toggleTheme"
>
</MaterialButton>
<ClientOnly>
<MaterialButton
class="theme-btn"
size="m"
color="text"
:title="themeStateStore.currentLabel"
:icon="themeStateStore.currentIcon"
@click="toggleTheme"
>
</MaterialButton>
</ClientOnly>
</div>
</nav>
</template>

View File

@@ -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", ""),
};
});
};
/** 更新指示器(高亮边框)的位置和尺寸 */

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

@@ -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;
@@ -156,20 +137,20 @@ if (isClient()) {
<template>
<Header />
<main id="article-content" ref="articleContentRef" @click="handleAnchorClick">
<main id="article-content" ref="articleContentRef">
<Content />
<PrevNext />
</main>
<aside id="article-aside">
<div class="post-info">
<p v-if="frontmatter.description" class="description">{{ frontmatter.description }}</p>
<p v-if="formattedPublishDate" class="date-publish">发布于 {{ formattedPublishDate }}</p>
<ClientOnly>
<ClientOnly>
<div class="post-info">
<p v-if="frontmatter.description" class="description">{{ frontmatter.description }}</p>
<p v-if="formattedPublishDate" class="date-publish">发布于 {{ formattedPublishDate }}</p>
<p v-if="formattedLastUpdated" :title="lastUpdatedRawTime" class="date-update">
{{ formattedLastUpdated }}
</p>
</ClientOnly>
</div>
</div>
</ClientOnly>
<ButtonGroup v-if="frontmatter?.external_links" :links="frontmatter.external_links" size="m" layout="vertical" />
<PageIndicator />
</aside>

View File

@@ -3,88 +3,61 @@
*/
import { defineStore } from "pinia";
import { ref, computed, watch } from "vue";
import { usePreferredDark } from "@vueuse/core";
import { setCookie, getCookie } from "../utils/cookie";
import { isClient } from "../utils/env";
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 systemDark = usePreferredDark();
/** 用户当前的偏好设置 */
const preference = ref<ThemePreference>("auto");
/** 计算当前实际生效的颜色模式 */
const isDarkActive = computed(() => {
if (preference.value === "auto") {
return systemDark.value;
}
return preference.value === "dark";
});
/** 初始化颜色主题,从 Cookie 读取配置,默认为 auto */
const init = () => {
const stored = getCookie("theme_preference");
if (stored === "light" || stored === "dark" || stored === "auto") {
preference.value = stored;
}
};
/** 监听实际生效的深色状态变化,操作 DOM */
watch(
isDarkActive,
(isDark) => {
if (isClient()) {
document.documentElement.classList.toggle("dark", isDark);
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");
}
},
{ immediate: true },
);
});
/** 监听用户偏好变化,持久化到 Cookie */
watch(preference, (val) => {
setCookie("theme_preference", val);
/** 循环切换列表 */
const { next } = useCycleList(["auto", "light", "dark"] as ThemePreference[], {
initialValue: mode.value as ThemePreference,
});
/** 切换主题模式 */
const cycleTheme = () => {
const modes: ThemePreference[] = ["auto", "light", "dark"];
const nextIndex = (modes.indexOf(preference.value) + 1) % modes.length;
preference.value = modes[nextIndex];
mode.value = next();
};
/** 当前偏好设置 */
const preference = computed(() => mode.value as ThemePreference);
/** 当前状态对应的图标 */
const currentIcon = computed(() => {
switch (preference.value) {
case "light":
return "light_mode";
case "dark":
return "dark_mode";
default:
return "brightness_auto";
}
});
const currentIcon = computed(() => THEME_MAP[preference.value].icon);
/** 当前状态对应的文本标签 */
const currentLabel = computed(() => {
switch (preference.value) {
case "light":
return "亮色模式";
case "dark":
return "深色模式";
default:
return "跟随系统";
}
});
const currentLabel = computed(() => THEME_MAP[preference.value].label);
return {
preference,
currentIcon,
currentLabel,
init,
cycleTheme,
};
});

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

@@ -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

@@ -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

@@ -358,95 +358,13 @@
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;
}
}
}
}
}
}
@@ -625,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);
}
}
}
@@ -842,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

@@ -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 {

View File

@@ -195,73 +195,75 @@
// Colors scheme
html {
--md-sys-color-primary: var(--md-ref-palette-primary40);
--md-sys-color-primary-container: var(--md-ref-palette-primary90);
--md-sys-color-on-primary: var(--md-ref-palette-primary100);
--md-sys-color-on-primary-container: var(--md-ref-palette-primary10);
--md-sys-color-inverse-primary: var(--md-ref-palette-primary80);
--md-sys-color-secondary: var(--md-ref-palette-secondary40);
--md-sys-color-secondary-container: var(--md-ref-palette-secondary90);
--md-sys-color-on-secondary: var(--md-ref-palette-secondary100);
--md-sys-color-on-secondary-container: var(--md-ref-palette-secondary10);
--md-sys-color-tertiary: var(--md-ref-palette-tertiary40);
--md-sys-color-tertiary-container: var(--md-ref-palette-tertiary90);
--md-sys-color-on-tertiary: var(--md-ref-palette-tertiary100);
--md-sys-color-on-tertiary-container: var(--md-ref-palette-tertiary10);
--md-sys-color-surface: var(--md-ref-palette-neutral98);
--md-sys-color-surface-dim: var(--md-ref-palette-neutral87);
--md-sys-color-surface-bright: var(--md-ref-palette-neutral98);
--md-sys-color-surface-container-lowest: var(--md-ref-palette-neutral100);
--md-sys-color-surface-container-low: var(--md-ref-palette-neutral96);
--md-sys-color-surface-container: var(--md-ref-palette-neutral94);
--md-sys-color-surface-container-high: var(--md-ref-palette-neutral92);
--md-sys-color-surface-container-highest: var(--md-ref-palette-neutral90);
--md-sys-color-surface-variant: var(--md-ref-palette-neutral-variant90);
--md-sys-color-on-surface: var(--md-ref-palette-neutral10);
--md-sys-color-on-surface-variant: var(--md-ref-palette-neutral-variant30);
--md-sys-color-inverse-surface: var(--md-ref-palette-neutral20);
--md-sys-color-inverse-on-surface: var(--md-ref-palette-neutral95);
--md-sys-color-background: var(--md-ref-palette-neutral98);
--md-sys-color-on-background: var(--md-ref-palette-neutral10);
--md-sys-color-error: var(--md-ref-palette-error40);
--md-sys-color-error-container: var(--md-ref-palette-error90);
--md-sys-color-on-error: var(--md-ref-palette-error100);
--md-sys-color-on-error-container: var(--md-ref-palette-error10);
--md-sys-color-outline: var(--md-ref-palette-neutral-variant50);
--md-sys-color-outline-variant: var(--md-ref-palette-neutral-variant80);
--md-sys-color-shadow: var(--md-ref-palette-neutral0);
--md-sys-color-surface-tint-color: var(--md-sys-color-primary);
--md-sys-color-scrim: var(--md-ref-palette-neutral0);
&.light {
--md-sys-color-primary: var(--md-ref-palette-primary40);
--md-sys-color-primary-container: var(--md-ref-palette-primary90);
--md-sys-color-on-primary: var(--md-ref-palette-primary100);
--md-sys-color-on-primary-container: var(--md-ref-palette-primary10);
--md-sys-color-inverse-primary: var(--md-ref-palette-primary80);
--md-sys-color-secondary: var(--md-ref-palette-secondary40);
--md-sys-color-secondary-container: var(--md-ref-palette-secondary90);
--md-sys-color-on-secondary: var(--md-ref-palette-secondary100);
--md-sys-color-on-secondary-container: var(--md-ref-palette-secondary10);
--md-sys-color-tertiary: var(--md-ref-palette-tertiary40);
--md-sys-color-tertiary-container: var(--md-ref-palette-tertiary90);
--md-sys-color-on-tertiary: var(--md-ref-palette-tertiary100);
--md-sys-color-on-tertiary-container: var(--md-ref-palette-tertiary10);
--md-sys-color-surface: var(--md-ref-palette-neutral98);
--md-sys-color-surface-dim: var(--md-ref-palette-neutral87);
--md-sys-color-surface-bright: var(--md-ref-palette-neutral98);
--md-sys-color-surface-container-lowest: var(--md-ref-palette-neutral100);
--md-sys-color-surface-container-low: var(--md-ref-palette-neutral96);
--md-sys-color-surface-container: var(--md-ref-palette-neutral94);
--md-sys-color-surface-container-high: var(--md-ref-palette-neutral92);
--md-sys-color-surface-container-highest: var(--md-ref-palette-neutral90);
--md-sys-color-surface-variant: var(--md-ref-palette-neutral-variant90);
--md-sys-color-on-surface: var(--md-ref-palette-neutral10);
--md-sys-color-on-surface-variant: var(--md-ref-palette-neutral-variant30);
--md-sys-color-inverse-surface: var(--md-ref-palette-neutral20);
--md-sys-color-inverse-on-surface: var(--md-ref-palette-neutral95);
--md-sys-color-background: var(--md-ref-palette-neutral98);
--md-sys-color-on-background: var(--md-ref-palette-neutral10);
--md-sys-color-error: var(--md-ref-palette-error40);
--md-sys-color-error-container: var(--md-ref-palette-error90);
--md-sys-color-on-error: var(--md-ref-palette-error100);
--md-sys-color-on-error-container: var(--md-ref-palette-error10);
--md-sys-color-outline: var(--md-ref-palette-neutral-variant50);
--md-sys-color-outline-variant: var(--md-ref-palette-neutral-variant80);
--md-sys-color-shadow: var(--md-ref-palette-neutral0);
--md-sys-color-surface-tint-color: var(--md-sys-color-primary);
--md-sys-color-scrim: var(--md-ref-palette-neutral0);
--md-sys-color-red: var(--md-ref-palette-red40);
--md-sys-color-red-container: var(--md-ref-palette-red90);
--md-sys-color-on-red: var(--md-ref-palette-red95);
--md-sys-color-on-red-container: var(--md-ref-palette-red10);
--md-sys-color-inverse-red: var(--md-ref-palette-red80);
--md-sys-color-red: var(--md-ref-palette-red40);
--md-sys-color-red-container: var(--md-ref-palette-red90);
--md-sys-color-on-red: var(--md-ref-palette-red95);
--md-sys-color-on-red-container: var(--md-ref-palette-red10);
--md-sys-color-inverse-red: var(--md-ref-palette-red80);
--md-sys-color-blue: var(--md-ref-palette-blue40);
--md-sys-color-blue-container: var(--md-ref-palette-blue90);
--md-sys-color-on-blue: var(--md-ref-palette-blue95);
--md-sys-color-on-blue-container: var(--md-ref-palette-blue10);
--md-sys-color-inverse-blue: var(--md-ref-palette-blue80);
--md-sys-color-blue: var(--md-ref-palette-blue40);
--md-sys-color-blue-container: var(--md-ref-palette-blue90);
--md-sys-color-on-blue: var(--md-ref-palette-blue95);
--md-sys-color-on-blue-container: var(--md-ref-palette-blue10);
--md-sys-color-inverse-blue: var(--md-ref-palette-blue80);
--md-sys-color-green: var(--md-ref-palette-green40);
--md-sys-color-green-container: var(--md-ref-palette-green90);
--md-sys-color-on-green: var(--md-ref-palette-green95);
--md-sys-color-on-green-container: var(--md-ref-palette-green10);
--md-sys-color-inverse-green: var(--md-ref-palette-green80);
--md-sys-color-green: var(--md-ref-palette-green40);
--md-sys-color-green-container: var(--md-ref-palette-green90);
--md-sys-color-on-green: var(--md-ref-palette-green95);
--md-sys-color-on-green-container: var(--md-ref-palette-green10);
--md-sys-color-inverse-green: var(--md-ref-palette-green80);
--md-sys-color-yellow: var(--md-ref-palette-yellow40);
--md-sys-color-yellow-container: var(--md-ref-palette-yellow90);
--md-sys-color-on-yellow: var(--md-ref-palette-yellow95);
--md-sys-color-on-yellow-container: var(--md-ref-palette-yellow10);
--md-sys-color-inverse-yellow: var(--md-ref-palette-yellow80);
--md-sys-color-yellow: var(--md-ref-palette-yellow40);
--md-sys-color-yellow-container: var(--md-ref-palette-yellow90);
--md-sys-color-on-yellow: var(--md-ref-palette-yellow95);
--md-sys-color-on-yellow-container: var(--md-ref-palette-yellow10);
--md-sys-color-inverse-yellow: var(--md-ref-palette-yellow80);
--md-sys-color-purple: var(--md-ref-palette-purple40);
--md-sys-color-purple-container: var(--md-ref-palette-purple90);
--md-sys-color-on-purple: var(--md-ref-palette-purple95);
--md-sys-color-on-purple-container: var(--md-ref-palette-purple10);
--md-sys-color-inverse-purple: var(--md-ref-palette-purple80);
--md-sys-color-purple: var(--md-ref-palette-purple40);
--md-sys-color-purple-container: var(--md-ref-palette-purple90);
--md-sys-color-on-purple: var(--md-ref-palette-purple95);
--md-sys-color-on-purple-container: var(--md-ref-palette-purple10);
--md-sys-color-inverse-purple: var(--md-ref-palette-purple80);
}
&.dark {
--md-sys-color-primary: var(--md-ref-palette-primary80);

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;
};
};

37
Caddyfile Normal file
View File

@@ -0,0 +1,37 @@
:80 {
# 根目录指向构建产物
root * /app
# 关闭访问日志
log {
output discard
}
# 启用压缩
encode gzip zstd
# 文件服务器配置
try_files {path} {path}.html {path}/
file_server
# 错误页面处理
handle_errors {
@404 {
expression {http.error.status_code} == 404
}
@403 {
expression {http.error.status_code} == 403
}
rewrite @404 /404.html
rewrite @403 /404.html
file_server
}
# 静态资源缓存配置
@assets {
path /assets/*
}
header @assets {
Cache-Control "public, immutable, max-age=31536000"
}
}

View File

@@ -1,11 +1,27 @@
FROM node:lts-alpine
# 构建阶段
FROM node:trixie-slim AS builder
# 安装 git
RUN apt-get update && apt-get install -y git \
&& rm -rf /var/lib/apt/lists/*
# 设置工作目录
WORKDIR /app
COPY . /app
# 拉取项目代码
RUN git clone https://github.com/sendevia/website . --depth=1
RUN npm install
# 安装依赖并构建
RUN npm i && npm run docs:build
EXPOSE 4173
# 最终阶段
FROM caddy:alpine AS final
CMD ["npm", "run", "docs:preview"]
# 从构建阶段复制 dist 产物到工作目录
COPY --from=builder /app/.vitepress/dist /app
# 复制Caddyfile配置文件
COPY Caddyfile /etc/caddy/Caddyfile
# 启动Caddy服务器
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"]

View File

@@ -1,5 +1,5 @@
{
"version": "26.2.22(289)",
"version": "26.3.5(323)",
"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.2.0",
"@vueuse/core": "^14.2.1",
"pinia": "^3.0.4"
},
"devDependencies": {
"@mdit/plugin-align": "^0.23.1",
"@mdit/plugin-footnote": "^0.22.4",
"@mdit/plugin-tasklist": "^0.22.3",
"markdown-it-anchor": "^9.2.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.28"
"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,41 +0,0 @@
---
title: "关于这个主题的一些事"
description: ""
color: "#0084ff"
impression: "/assets/images/131559307_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' }
]"
/>

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: 1.1 KiB