feat: 初始化博客前台项目

This commit is contained in:
sunlei 2026-05-27 16:09:38 +08:00
commit 25b09ff8a3
70 changed files with 10198 additions and 0 deletions

8
.editorconfig Normal file
View File

@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

42
.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
test-results/
playwright-report/

5
.oxfmtrc.json Normal file
View File

@ -0,0 +1,5 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"semi": false,
"singleQuote": true
}

10
.oxlintrc.json Normal file
View File

@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}

10
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,10 @@
{
"recommendations": [
"Vue.volar",
"vitest.explorer",
"ms-playwright.playwright",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode"
]
}

26
README.md Normal file
View File

@ -0,0 +1,26 @@
# kt-blog-web
KT 博客前台 demo基于 Argon WordPress 主题的视觉资产重新实现,技术栈为 Vue 3、TSX、Vite、antdv-next。
## 技术约定
- 页面语法Vue TSX。
- 组件库antdv-next。
- 样式SCSS类名按 BEM 组织。
- 路由hash 模式,便于静态部署。
- 静态资产:只从 Argon 主题包抽取 demo 需要的图片,不引入 WordPress PHP 与主题脚本。
## 本地运行
```sh
pnpm install
pnpm dev
```
## 常用校验
```sh
pnpm run type-check
pnpm run build
pnpm test:unit
```

4
e2e/tsconfig.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": ["./**/*"]
}

7
e2e/vue.spec.ts Normal file
View File

@ -0,0 +1,7 @@
import { expect, test } from '@playwright/test';
test('visits the app root url', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: /从飞牛 NAS 到云原生/ })).toBeVisible();
await expect(page.getByText('最新文章')).toBeVisible();
});

1
env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

38
eslint.config.ts Normal file
View File

@ -0,0 +1,38 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginPlaywright from 'eslint-plugin-playwright'
import pluginVitest from '@vitest/eslint-plugin'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{vue,ts,mts,tsx}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
...pluginPlaywright.configs['flat/recommended'],
files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
},
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
)

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KwiTsukasa的小站</title>
</head>
<body>
<div class="kt-blog__mount"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

57
package.json Normal file
View File

@ -0,0 +1,57 @@
{
"name": "kt-blog-web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"test:e2e": "playwright test",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "run-s lint:*",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache",
"format": "oxfmt src/"
},
"dependencies": {
"@antdv-next/icons": "^1.0.8",
"antdv-next": "^1.3.1",
"mitt": "^3.0.1",
"pinia": "^3.0.4",
"vue": "^3.5.32",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^28.0.1",
"@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.6",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vitest/eslint-plugin": "^1.6.16",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.2.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.60.0",
"eslint-plugin-playwright": "^2.10.1",
"eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1",
"jsdom": "^29.0.2",
"npm-run-all2": "^8.0.4",
"oxfmt": "^0.45.0",
"oxlint": "~1.60.0",
"sass": "^1.100.0",
"typescript": "~6.0.0",
"vite": "^8.0.8",
"vitest": "^4.1.4",
"vue-tsc": "^3.2.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}

110
playwright.config.ts Normal file
View File

@ -0,0 +1,110 @@
import process from 'node:process'
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e',
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000,
},
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
/* Only on CI systems run the tests headless */
headless: !!process.env.CI,
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
webServer: {
/**
* Use the dev server by default for faster feedback loop.
* Use the preview server on CI for more realistic testing.
* Playwright will re-use the local server if there is already a dev-server running.
*/
command: process.env.CI ? 'npm run preview' : 'npm run dev',
port: process.env.CI ? 4173 : 5173,
reuseExistingServer: !process.env.CI,
},
})

5011
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

18
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,18 @@
overrides:
'vue': 'beta'
'@vue/compiler-core': 'beta'
'@vue/compiler-dom': 'beta'
'@vue/compiler-sfc': 'beta'
'@vue/compiler-ssr': 'beta'
'@vue/compiler-vapor': 'beta'
'@vue/reactivity': 'beta'
'@vue/runtime-core': 'beta'
'@vue/runtime-dom': 'beta'
'@vue/runtime-vapor': 'beta'
'@vue/server-renderer': 'beta'
'@vue/shared': 'beta'
'@vue/compat': 'beta'
peerDependencyRules:
allowAny:
- 'vue'

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

22
src/App.tsx Normal file
View File

@ -0,0 +1,22 @@
import { App as AntdApp, ConfigProvider } from 'antdv-next';
import { defineComponent } from 'vue';
import { RouterView } from 'vue-router';
import { useBlogTheme } from './hooks/useBlogTheme';
export default defineComponent({
name: 'KtBlogApp',
setup() {
const { themeConfig, themeRootClass } = useBlogTheme();
return () => (
<ConfigProvider theme={themeConfig.value}>
<AntdApp>
<div class={themeRootClass.value}>
<RouterView />
</div>
</AntdApp>
</ConfigProvider>
);
},
});

122
src/__tests__/App.spec.ts Normal file
View File

@ -0,0 +1,122 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import App from '../App';
import router from '../router';
vi.mock('antdv-next', async () => {
const { defineComponent, h } = await import('vue');
const createSlotStub = (name: string) =>
defineComponent({
name,
setup(_, { slots }) {
return () => h('div', slots.default?.());
},
});
const Checkbox = createSlotStub('AntdCheckbox') as any;
Checkbox.Group = createSlotStub('AntdCheckboxGroup');
return {
App: createSlotStub('AntdApp'),
Avatar: createSlotStub('AntdAvatar'),
Button: createSlotStub('AntdButton'),
Card: defineComponent({
name: 'AntdCard',
setup(_, { slots }) {
return () => h('section', [slots.title?.(), slots.default?.()]);
},
}),
Checkbox,
ColorPicker: createSlotStub('AntdColorPicker'),
ConfigProvider: createSlotStub('AntdConfigProvider'),
Divider: createSlotStub('AntdDivider'),
Empty: createSlotStub('AntdEmpty'),
Form: createSlotStub('AntdForm'),
Input: createSlotStub('AntdInput'),
Modal: defineComponent({
name: 'AntdModal',
props: {
open: Boolean,
},
setup(props, { slots }) {
return () => (props.open ? h('div', [slots.default?.()]) : null);
},
}),
Pagination: createSlotStub('AntdPagination'),
Progress: createSlotStub('AntdProgress'),
Space: createSlotStub('AntdSpace'),
Statistic: defineComponent({
name: 'AntdStatistic',
props: {
title: String,
value: [String, Number],
},
setup(props) {
return () => h('div', [props.title, props.value]);
},
}),
Switch: createSlotStub('AntdSwitch'),
Tag: createSlotStub('AntdTag'),
TextArea: createSlotStub('AntdTextArea'),
theme: {
darkAlgorithm: {},
defaultAlgorithm: {},
},
};
});
vi.mock('@antdv-next/icons', async () => {
const { defineComponent, h } = await import('vue');
const icon = defineComponent({
name: 'IconStub',
setup() {
return () => h('span');
},
});
return {
AppstoreOutlined: icon,
ArrowRightOutlined: icon,
BellOutlined: icon,
BgColorsOutlined: icon,
BookOutlined: icon,
CalendarOutlined: icon,
CloudServerOutlined: icon,
CommentOutlined: icon,
EyeOutlined: icon,
FileTextOutlined: icon,
FireOutlined: icon,
FolderOpenOutlined: icon,
GithubOutlined: icon,
HistoryOutlined: icon,
LinkOutlined: icon,
MenuOutlined: icon,
QqOutlined: icon,
ReadOutlined: icon,
SafetyCertificateOutlined: icon,
SearchOutlined: icon,
SettingOutlined: icon,
ShareAltOutlined: icon,
TagOutlined: icon,
TagsOutlined: icon,
UserOutlined: icon,
VerticalAlignTopOutlined: icon,
WechatOutlined: icon,
WeiboOutlined: icon,
};
});
describe('App', () => {
it('mounts renders properly', async () => {
await router.push('/');
await router.isReady();
const wrapper = mount(App, {
global: {
plugins: [router],
},
});
expect(wrapper.text()).toContain('KwiTsukasa的小站');
});
});

View File

@ -0,0 +1,78 @@
import {
CalendarOutlined,
CommentOutlined,
EyeOutlined,
ReadOutlined,
TagsOutlined,
} from '@antdv-next/icons';
import { defineComponent, type PropType } from 'vue';
import { RouterLink } from 'vue-router';
import { getTagSlugByLabel, type BlogArticle } from '@/data/blog';
export default defineComponent({
name: 'ArticleCard',
props: {
article: {
type: Object as PropType<BlogArticle>,
required: true,
},
},
setup(props) {
return () => (
<article class="kt-blog__post kt-blog__post--preview kt-blog__card">
<header class="kt-blog__post-header kt-blog__post-header--center">
<RouterLink class="kt-blog__post-title" to={`/post/${props.article.slug}`}>
{props.article.title}
</RouterLink>
<div class="kt-blog__post-meta">
<div class="kt-blog__post-meta-item kt-blog__post-meta-item--time">
<CalendarOutlined />
<span>{props.article.date}</span>
</div>
<div class="kt-blog__post-meta-divider">|</div>
<div class="kt-blog__post-meta-item kt-blog__post-meta-item--views">
<EyeOutlined />
<span>{props.article.views}</span>
</div>
<div class="kt-blog__post-meta-divider">|</div>
<div class="kt-blog__post-meta-item kt-blog__post-meta-item--comments">
<CommentOutlined />
<span>{props.article.comments}</span>
</div>
<div class="kt-blog__post-meta-divider">|</div>
<div class="kt-blog__post-meta-item kt-blog__post-meta-item--categories">
<RouterLink class="kt-blog__post-category-link" to={`/category/${props.article.categorySlug}`}>
{props.article.category}
</RouterLink>
</div>
<br />
<div class="kt-blog__post-meta-item kt-blog__post-meta-item--words">
<ReadOutlined />
<span>{props.article.words} </span>
</div>
<div class="kt-blog__post-meta-divider">|</div>
<div class="kt-blog__post-meta-item kt-blog__post-meta-item--reading-time">
<span>{props.article.readTime}</span>
</div>
</div>
</header>
<div class="kt-blog__post-content">{props.article.excerpt}</div>
<div class="kt-blog__post-tags">
<TagsOutlined />
{props.article.tags.map((tag) => (
<RouterLink
key={tag}
class="kt-blog__tag kt-blog__tag--secondary kt-blog__post-tag"
to={`/tag/${getTagSlugByLabel(tag)}`}
>
{tag}
</RouterLink>
))}
</div>
</article>
);
},
});

View File

@ -0,0 +1,150 @@
import { Empty } from 'antdv-next';
import {
computed,
defineComponent,
nextTick,
onBeforeUnmount,
onMounted,
ref,
TransitionGroup,
type ComponentPublicInstance,
type PropType,
watch,
} from 'vue';
import type { BlogArticle } from '@/data/blog';
import ArticleCard from './ArticleCard';
export default defineComponent({
name: 'ArticleList',
props: {
articles: {
type: Array as PropType<BlogArticle[]>,
required: true,
},
batchSize: {
type: Number,
default: 3,
},
initialCount: {
type: Number,
default: 3,
},
},
setup(props) {
const loadMoreTarget = ref<HTMLElement | null>(null);
const visibleCount = ref(0);
let observer: IntersectionObserver | null = null;
const visibleArticles = computed(() => props.articles.slice(0, visibleCount.value));
const hasMoreArticles = computed(() => visibleCount.value < props.articles.length);
/**
* /沿
*/
const resetVisibleCount = () => {
visibleCount.value = Math.min(props.initialCount, props.articles.length);
};
/**
* @param nextCount
*/
const loadMoreArticles = (nextCount = props.batchSize) => {
visibleCount.value = Math.min(props.articles.length, visibleCount.value + nextCount);
};
/**
*
*/
const observeLoadMoreTarget = () => {
observer?.disconnect();
observer = null;
if (!hasMoreArticles.value || !loadMoreTarget.value) {
return;
}
if (typeof window === 'undefined' || !('IntersectionObserver' in window)) {
visibleCount.value = props.articles.length;
return;
}
observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
loadMoreArticles();
}
},
{
rootMargin: '360px 0px',
threshold: 0,
},
);
observer.observe(loadMoreTarget.value);
};
/**
* @param element Vue ref DOM
*/
const setLoadMoreTarget = (element: Element | ComponentPublicInstance | null) => {
const nextElement = typeof HTMLElement !== 'undefined' && element instanceof HTMLElement ? element : null;
if (loadMoreTarget.value === nextElement) {
return;
}
loadMoreTarget.value = nextElement;
nextTick(observeLoadMoreTarget);
};
watch(
() => props.articles.map((article) => article.id).join('|'),
() => {
resetVisibleCount();
nextTick(observeLoadMoreTarget);
},
{
immediate: true,
},
);
watch(hasMoreArticles, () => {
nextTick(observeLoadMoreTarget);
});
onMounted(() => {
nextTick(observeLoadMoreTarget);
});
onBeforeUnmount(() => {
observer?.disconnect();
});
return () => (
<>
{props.articles.length > 0 ? (
<div class="kt-blog__post-transition">
<TransitionGroup name="kt-blog__post-transition" tag="div">
{visibleArticles.value.map((article) => (
<ArticleCard key={article.id} article={article} />
))}
</TransitionGroup>
{hasMoreArticles.value ? (
<div
ref={setLoadMoreTarget}
class="kt-blog__post-load-sentinel"
aria-hidden="true"
data-loaded={visibleCount.value}
data-total={props.articles.length}
/>
) : null}
</div>
) : (
<div class="kt-blog__post-empty kt-blog__card">
<Empty description="没有找到相关文章" />
</div>
)}
</>
);
},
});

View File

@ -0,0 +1,309 @@
import {
BgColorsOutlined,
CommentOutlined,
SettingOutlined,
VerticalAlignTopOutlined,
} from '@antdv-next/icons';
import { computed, defineComponent, onBeforeUnmount, onMounted, ref, Transition } from 'vue';
import { useBlogDomRefs } from '@/hooks/useBlogDomRefs';
import { onArgonScroll, smoothScrollTo } from '@/hooks/useArgonEffects';
import { useBlogTheme } from '@/hooks/useBlogTheme';
import { BlogButton, BlogColorPicker, BlogSwitch } from './antdvComponents';
const filterOptions = [
{ label: '关闭', value: 'off' },
{ label: '日落', value: 'sunset' },
{ label: '暗化', value: 'darkness' },
{ label: '灰度', value: 'grayscale' },
] as const;
const themeColors = ['#6f5f89', '#5e72e4', '#2dce89', '#fb6340'] as const;
export default defineComponent({
name: 'BlogFloatActions',
setup() {
const {
isDarkTheme,
preferences,
setFilterMode,
setFontMode,
setPrimaryColor,
setRadius,
setShadowMode,
setThemeMode,
} = useBlogTheme();
const { postArticleRef, postCommentInputRef, postCommentRef } = useBlogDomRefs();
const panelOpen = ref(false);
const floatLeft = ref(false);
const floatUnloaded = ref(false);
const showBackTop = ref(false);
const showComment = ref(false);
const readingProgress = ref(0);
let cleanupScroll: (() => void) | null = null;
const darkChecked = computed({
get: () => isDarkTheme.value,
set: (checked: boolean) => setThemeMode(checked ? 'dark' : 'light'),
});
/**
* @param color 便
*/
const updatePrimaryColor = (color: string) => {
if (!color) {
return;
}
const nextColor = color.startsWith('#') ? color : `#${color}`;
setPrimaryColor(nextColor.toUpperCase());
};
const rootClass = computed(() => [
'kt-blog__float-actions',
floatLeft.value && 'kt-blog__float-actions--left',
floatUnloaded.value && 'kt-blog__float-actions--unloaded',
panelOpen.value && 'kt-blog__float-actions--settings-open',
]);
/**
* @param target antdv-next
*/
const focusInput = (target: any) => {
target?.focus?.();
target?.input?.focus?.();
target?.$el?.querySelector?.('textarea,input')?.focus?.();
};
/**
* Argon
*/
const syncFabStatus = () => {
showComment.value = Boolean(postCommentRef.value);
showBackTop.value = window.scrollY >= 400;
const article = postArticleRef.value;
if (!article) {
readingProgress.value = 0;
return;
}
const articleTop = article.getBoundingClientRect().top + window.scrollY - 80;
const availableDistance = article.offsetHeight + 50 - window.innerHeight;
if (availableDistance <= 0) {
readingProgress.value = 0;
return;
}
const progress = (window.scrollY - articleTop) / availableDistance;
readingProgress.value = Number.isFinite(progress) ? Math.min(Math.max(progress, 0), 1) : 0;
};
/**
* Argon 300ms unloaded
*/
const toggleFloatSide = () => {
floatUnloaded.value = true;
window.setTimeout(() => {
floatLeft.value = !floatLeft.value;
window.localStorage.setItem('Argon_fabs_Floating_Status', floatLeft.value ? 'left' : 'right');
floatUnloaded.value = false;
}, 300);
};
/**
*
*/
const goToComment = () => {
const commentTarget = postCommentRef.value;
if (!commentTarget) {
return;
}
smoothScrollTo(commentTarget.getBoundingClientRect().top + window.scrollY - 90, 600);
window.setTimeout(() => focusInput(postCommentInputRef.value), 620);
};
onMounted(() => {
floatLeft.value = window.localStorage.getItem('Argon_fabs_Floating_Status') === 'left';
cleanupScroll = onArgonScroll(syncFabStatus);
});
onBeforeUnmount(() => {
cleanupScroll?.();
});
return () => (
<div class={rootClass.value}>
<BlogButton
aria-hidden="true"
class="kt-blog__float-action kt-blog__float-action--toggle-side kt-blog__button kt-blog__button--icon kt-blog__button--neutral"
tooltip-move-to-left="移至左侧"
tooltip-move-to-right="移至右侧"
onClick={toggleFloatSide}
>
<span aria-hidden="true"></span>
</BlogButton>
<BlogButton
aria-label="Back To Top"
class={['kt-blog__float-action kt-blog__float-action--back-top kt-blog__button kt-blog__button--icon kt-blog__button--neutral', !showBackTop.value && 'kt-blog__float-action--hidden']}
tooltip="回到顶部"
onClick={() => smoothScrollTo()}
>
<VerticalAlignTopOutlined />
</BlogButton>
<BlogButton
aria-label="Comment"
class={['kt-blog__float-action kt-blog__float-action--comment kt-blog__button kt-blog__button--icon kt-blog__button--neutral', !showComment.value && 'kt-blog__hidden']}
tooltip="评论"
onClick={goToComment}
>
<CommentOutlined />
</BlogButton>
<BlogButton
aria-label="Toggle Darkmode"
class="kt-blog__float-action kt-blog__float-action--theme kt-blog__button kt-blog__button--icon kt-blog__button--neutral"
tooltip-blackmode="暗黑模式"
tooltip-darkmode="夜间模式"
tooltip-lightmode="日间模式"
onClick={() => setThemeMode(isDarkTheme.value ? 'light' : 'dark')}
>
<BgColorsOutlined />
</BlogButton>
<BlogButton
aria-label="Open Blog Settings Menu"
class="kt-blog__float-action kt-blog__float-action--settings kt-blog__button kt-blog__button--icon kt-blog__button--neutral"
tooltip="设置"
onClick={() => {
panelOpen.value = !panelOpen.value;
}}
>
<SettingOutlined />
</BlogButton>
<Transition name="kt-blog__popover" appear>
{panelOpen.value ? (
<div class="kt-blog__settings-panel kt-blog__card" aria-hidden={!panelOpen.value}>
<div class="kt-blog__settings-close" onClick={() => {
panelOpen.value = false;
}}>
×
</div>
<div class="kt-blog__settings-item">
<span></span>
<BlogSwitch
class="kt-blog__settings-theme-switch"
checkedChildren="暗"
unCheckedChildren="亮"
v-model:checked={darkChecked.value}
/>
</div>
<div class="kt-blog__settings-item">
<span></span>
<BlogButton
class={['kt-blog__settings-button kt-blog__settings-button--font kt-blog__settings-button--left', preferences.font === 'sans' && 'kt-blog__settings-button--active']}
onClick={() => setFontMode('sans')}
>
Sans Serif
</BlogButton>
<BlogButton
class={['kt-blog__settings-button kt-blog__settings-button--font kt-blog__settings-button--right', preferences.font === 'serif' && 'kt-blog__settings-button--active']}
onClick={() => setFontMode('serif')}
>
Serif
</BlogButton>
</div>
<div class="kt-blog__settings-item">
<span></span>
<BlogButton
class={['kt-blog__settings-button kt-blog__settings-button--shadow kt-blog__settings-button--left', preferences.shadow === 'small' && 'kt-blog__settings-button--active']}
onClick={() => setShadowMode('small')}
>
</BlogButton>
<BlogButton
class={['kt-blog__settings-button kt-blog__settings-button--shadow kt-blog__settings-button--right', preferences.shadow === 'big' && 'kt-blog__settings-button--active']}
onClick={() => setShadowMode('big')}
>
</BlogButton>
</div>
<div class="kt-blog__settings-item kt-blog__settings-filter-row">
<span></span>
{filterOptions.map((item) => (
<BlogButton
key={item.value}
class={[
'kt-blog__settings-filter-button',
`kt-blog__settings-filter-button--${item.value}`,
preferences.filter === item.value && 'kt-blog__settings-filter-button--active',
]}
filter-name={item.value}
shape="circle"
onClick={() => setFilterMode(item.value)}
>
{item.label}
</BlogButton>
))}
</div>
<div class="kt-blog__settings-item">
<span></span>
<BlogButton class="kt-blog__settings-button" onClick={() => setRadius(0)}>0px</BlogButton>
<BlogButton class="kt-blog__settings-button" onClick={() => setRadius(4)}>4px</BlogButton>
<BlogButton class="kt-blog__settings-button" onClick={() => setRadius(12)}>12px</BlogButton>
</div>
<div class="kt-blog__settings-item">
<span></span>
<BlogColorPicker
class="kt-blog__settings-color-picker"
value={preferences.colorPrimary}
valueFormat="hex"
showText
presets={[
{
label: 'Argon',
colors: [...themeColors],
},
]}
onChange={(_value: unknown, cssColor: string) => updatePrimaryColor(cssColor)}
onUpdate:value={(color: string) => updatePrimaryColor(color)}
/>
<div class="kt-blog__settings-color-presets">
{themeColors.map((color) => (
<BlogButton
key={color}
aria-label={`主题色 ${color}`}
class={['kt-blog__settings-color', preferences.colorPrimary.toUpperCase() === color.toUpperCase() && 'kt-blog__settings-color--active']}
style={{ background: color }}
onClick={() => updatePrimaryColor(color)}
/>
))}
</div>
</div>
</div>
) : null}
</Transition>
<BlogButton
aria-hidden="true"
class={['kt-blog__float-action kt-blog__float-action--progress kt-blog__button kt-blog__button--icon kt-blog__button--neutral', !readingProgress.value && 'kt-blog__float-action--hidden']}
tooltip="阅读进度"
>
<div class="kt-blog__float-action-progress-bar" style={{ width: `${Math.round(readingProgress.value * 100)}%` }} />
<span class="kt-blog__float-action-progress-text">{Math.round(readingProgress.value * 100)}%</span>
</BlogButton>
</div>
);
},
});

View File

@ -0,0 +1,159 @@
import { SearchOutlined } from '@antdv-next/icons';
import { defineComponent, nextTick, type ComponentPublicInstance, type PropType, type Ref, ref } from 'vue';
import { RouterLink, useRouter } from 'vue-router';
import { useBlogEventBus } from '@/hooks/useBlogEventBus';
import { BlogButton, BlogInput } from './antdvComponents';
const navItems = [
{ label: '首页', to: '/' },
{ label: '归档', to: '/archives' },
{ label: 'NAS', to: '/category/nas' },
{ label: 'Vue', to: '/category/vue' },
{ label: 'Node', to: '/category/node' },
];
export default defineComponent({
name: 'BlogHeader',
props: {
toolbarRef: {
type: Object as PropType<Ref<HTMLElement | null>>,
required: true,
},
},
setup(props) {
const router = useRouter();
const eventBus = useBlogEventBus();
const keyword = ref('');
const navSearchOpen = ref(false);
const navSearchInputRef = ref<any>(null);
/**
* @param target antdv-next Input input
*/
const focusInput = (target: any) => {
target?.focus?.();
target?.input?.focus?.();
};
/**
* @param target nav DOM layout ref 使
*/
const setToolbarRef = (target: Element | ComponentPublicInstance | null) => {
props.toolbarRef.value = target instanceof HTMLElement ? target : null;
};
const submitSearch = () => {
const query = keyword.value.trim();
if (!query) {
return;
}
router.push({
name: 'BlogSearch',
query: { q: query },
});
navSearchOpen.value = false;
};
return () => (
<div class="kt-blog__header">
<header class="kt-blog__header-global">
<nav
ref={setToolbarRef}
class="kt-blog__header-navbar kt-blog__header-navbar--ontop"
>
<div class="kt-blog__header-container">
<BlogButton class="kt-blog__header-toggle kt-blog__button" aria-expanded="false" aria-label="Toggle sidebar">
<span class="kt-blog__header-toggle-icon" />
</BlogButton>
<div class="kt-blog__header-brand">
<RouterLink class="kt-blog__header-title" to="/">
KwiTsukasa的小站
</RouterLink>
</div>
<div class="kt-blog__header-collapse">
<div class="kt-blog__header-collapse-head">
<div class="kt-blog__header-mobile-search">
<div class="kt-blog__input-group">
<div class="kt-blog__input-addon-wrap">
<span class="kt-blog__input-addon">
<SearchOutlined />
</span>
</div>
<BlogInput
class="kt-blog__header-mobile-search-input kt-blog__input"
placeholder="搜索什么..."
autocomplete="off"
/>
</div>
</div>
</div>
<ul class="kt-blog__header-nav kt-blog__header-nav--hover">
{navItems.map((item) => (
<li key={item.label} class="kt-blog__header-nav-item">
<RouterLink class="kt-blog__header-nav-link" to={item.to}>
{item.label}
</RouterLink>
</li>
))}
</ul>
<ul class="kt-blog__header-nav kt-blog__header-nav--end">
<li class="kt-blog__header-search-item">
<div
class={['kt-blog__header-search', navSearchOpen.value && 'kt-blog__header-search--open']}
onClick={() => {
navSearchOpen.value = true;
nextTick(() => focusInput(navSearchInputRef.value));
}}
>
<div class="kt-blog__input-group">
<div class="kt-blog__input-addon-wrap">
<span class="kt-blog__input-addon">
<SearchOutlined />
</span>
</div>
<BlogInput
ref={navSearchInputRef}
class="kt-blog__header-search-input kt-blog__input"
placeholder="搜索什么..."
autocomplete="off"
v-model:value={keyword.value}
onClick={(event: MouseEvent) => event.stopPropagation()}
onBlur={() => {
navSearchOpen.value = false;
}}
onKeydown={(event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault();
submitSearch();
}
}}
/>
</div>
</div>
</li>
</ul>
</div>
<div class="kt-blog__header-menu-mask" />
<BlogButton
class="kt-blog__header-toggle kt-blog__header-mobile-search-toggle kt-blog__button"
aria-expanded="false"
aria-label="Toggle navigation"
onClick={() => eventBus.emit('blog:search:open', undefined)}
>
<span class="kt-blog__header-toggle-icon kt-blog__header-toggle-icon--search" />
</BlogButton>
</div>
</nav>
</header>
</div>
);
},
});

View File

@ -0,0 +1,141 @@
import { onBeforeUnmount, onMounted, type PropType } from 'vue';
import { defineComponent, ref } from 'vue';
import { articles, categories, tags } from '@/data/blog';
import { type BlogTaxonomyModal, useBlogEventBus } from '@/hooks/useBlogEventBus';
import { useArgonEffects } from '@/hooks/useArgonEffects';
import BlogFloatActions from './BlogFloatActions';
import BlogHeader from './BlogHeader';
import BlogRightbar from './BlogRightbar';
import BlogSearchModal from './BlogSearchModal';
import BlogSidebar from './BlogSidebar';
import BlogTaxonomyModals from './BlogTaxonomyModals';
import PageInfoCard from './PageInfoCard';
export default defineComponent({
name: 'BlogLayout',
props: {
pageTitle: {
type: String,
default: '',
},
pageDescription: {
type: String,
default: '',
},
pageMeta: {
type: String,
default: '',
},
mainClass: {
type: String as PropType<string>,
default: 'kt-blog__main--article-list kt-blog__main--home',
},
showPageInfo: {
type: Boolean,
default: true,
},
},
setup(props, { slots }) {
const activeModal = ref<BlogTaxonomyModal | null>(null);
const searchOpen = ref(false);
const toolbarRef = ref<HTMLElement | null>(null);
const bannerContainerRef = ref<HTMLElement | null>(null);
const contentRef = ref<HTMLElement | null>(null);
const leftbarPart1Ref = ref<HTMLElement | null>(null);
const leftbarPart2Ref = ref<HTMLElement | null>(null);
const eventBus = useBlogEventBus();
useArgonEffects({
bannerContainerRef,
contentRef,
leftbarPart1Ref,
leftbarPart2Ref,
toolbarRef,
});
/**
* @param modal mitt 线
*/
const openTaxonomyModal = (modal: BlogTaxonomyModal) => {
activeModal.value = modal;
};
const openSearchModal = () => {
searchOpen.value = true;
};
onMounted(() => {
eventBus.on('blog:search:open', openSearchModal);
eventBus.on('blog:taxonomy:open', openTaxonomyModal);
document.title = 'KwiTsukasa的小站';
});
onBeforeUnmount(() => {
eventBus.off('blog:search:open', openSearchModal);
eventBus.off('blog:taxonomy:open', openTaxonomyModal);
});
return () => (
<>
<BlogHeader toolbarRef={toolbarRef} />
<BlogSearchModal
open={searchOpen.value}
onClose={() => {
searchOpen.value = false;
}}
/>
<section class="kt-blog__banner">
<div
ref={bannerContainerRef}
class="kt-blog__banner-container"
aria-hidden="true"
/>
</section>
<BlogFloatActions />
<div ref={contentRef} class="kt-blog__content">
{props.showPageInfo && slots.pageInfo ? (
slots.pageInfo()
) : props.showPageInfo && props.pageTitle ? (
<PageInfoCard
title={props.pageTitle}
description={props.pageDescription}
meta={props.pageMeta}
/>
) : null}
<div class="kt-blog__sidebar-mask" />
<BlogSidebar
categories={categories}
tags={tags}
articles={articles}
part1Ref={leftbarPart1Ref}
part2Ref={leftbarPart2Ref}
/>
<BlogTaxonomyModals
active={activeModal.value}
categories={categories}
tags={tags}
onClose={() => {
activeModal.value = null;
}}
/>
<BlogRightbar articles={articles} categories={categories} />
<div class="kt-blog__primary">
<main class={['kt-blog__main', props.mainClass]} role="main">
{slots.default?.()}
<footer class="kt-blog__footer kt-blog__card">
<div class="kt-blog__footer-info">Theme Argon By solstice23</div>
</footer>
</main>
</div>
</div>
</>
);
},
});

View File

@ -0,0 +1,50 @@
import { defineComponent, type PropType } from 'vue';
import { Transition } from 'vue';
import { BlogModalComponent } from './antdvComponents';
export default defineComponent({
name: 'BlogModal',
props: {
className: {
type: String,
default: '',
},
title: {
type: String,
required: true,
},
open: {
type: Boolean,
default: false,
},
size: {
type: String as PropType<'sm' | 'md'>,
default: 'md',
},
},
emits: ['close'],
setup(props, { emit, slots }) {
return () => (
<div class={['kt-blog__modal-host', props.className]}>
<Transition name="kt-blog__modal" appear>
{props.open ? (
<BlogModalComponent
centered
class="kt-blog__modal"
footer={null}
getContainer={false}
open={props.open}
title={props.title}
width={props.size === 'sm' ? 420 : 620}
wrapClassName="kt-blog__modal-wrap"
onCancel={() => emit('close')}
>
<div class="kt-blog__modal-body">{slots.default?.()}</div>
</BlogModalComponent>
) : null}
</Transition>
</div>
);
},
});

View File

@ -0,0 +1,84 @@
import { CommentOutlined, FolderOpenOutlined, HistoryOutlined, ReadOutlined } from '@antdv-next/icons';
import { defineComponent, type PropType } from 'vue';
import { RouterLink } from 'vue-router';
import type { BlogArticle, BlogCategory } from '@/data/blog';
export default defineComponent({
name: 'BlogRightbar',
props: {
articles: {
type: Array as PropType<BlogArticle[]>,
required: true,
},
categories: {
type: Array as PropType<BlogCategory[]>,
required: true,
},
},
setup(props) {
const archiveMonths = Array.from(new Set(props.articles.map((article) => article.date.slice(0, 7))));
return () => (
<aside class="kt-blog__rightbar" role="complementary">
<div class="kt-blog__rightbar-widget kt-blog__card">
<h2 class="kt-blog__rightbar-title">
<ReadOutlined />
</h2>
<ul class="kt-blog__rightbar-list">
{props.articles.slice(0, 5).map((article) => (
<li key={article.slug} class="kt-blog__rightbar-list-item">
<RouterLink to={`/post/${article.slug}`}>{article.title}</RouterLink>
</li>
))}
</ul>
</div>
<div class="kt-blog__rightbar-widget kt-blog__card">
<h2 class="kt-blog__rightbar-title">
<CommentOutlined />
</h2>
<ol class="kt-blog__rightbar-list kt-blog__rightbar-list--comments">
{props.articles.slice(0, 4).map((article) => (
<li key={article.slug} class="kt-blog__rightbar-comment">
<span class="kt-blog__rightbar-comment-author">KT Admin</span>
<span> </span>
<RouterLink to={`/post/${article.slug}`}>{article.title}</RouterLink>
</li>
))}
</ol>
</div>
<div class="kt-blog__rightbar-widget kt-blog__card">
<h2 class="kt-blog__rightbar-title">
<HistoryOutlined />
</h2>
<ul class="kt-blog__rightbar-list">
{archiveMonths.map((month) => (
<li key={month} class="kt-blog__rightbar-list-item">
<RouterLink to="/archives">{month}</RouterLink>
</li>
))}
</ul>
</div>
<div class="kt-blog__rightbar-widget kt-blog__card">
<h2 class="kt-blog__rightbar-title">
<FolderOpenOutlined />
</h2>
<ul class="kt-blog__rightbar-list kt-blog__rightbar-list--categories">
{props.categories.map((category) => (
<li key={category.slug} class="kt-blog__rightbar-category">
<RouterLink to={`/category/${category.slug}`}>{category.label}</RouterLink>
</li>
))}
</ul>
</div>
</aside>
);
},
});

View File

@ -0,0 +1,74 @@
import { SearchOutlined } from '@antdv-next/icons';
import { defineComponent, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { BlogButton, BlogForm, BlogInput } from './antdvComponents';
import BlogModal from './BlogModal';
export default defineComponent({
name: 'BlogSearchModal',
props: {
open: {
type: Boolean,
default: false,
},
},
emits: ['close'],
setup(props, { emit }) {
const router = useRouter();
const keyword = ref('');
watch(
() => props.open,
(open) => {
if (!open) {
keyword.value = '';
}
},
);
const submitSearch = () => {
const query = keyword.value.trim();
if (!query) {
return;
}
router.push({
name: 'BlogSearch',
query: { q: query },
});
emit('close');
};
return () => (
<BlogModal className="kt-blog__search-modal" title="搜索" size="sm" open={props.open} onClose={() => emit('close')}>
<BlogForm
class="kt-blog__search-modal-form"
onFinish={submitSearch}
>
<div class="kt-blog__form-group kt-blog__form-group--spaced">
<div class="kt-blog__input-group">
<div class="kt-blog__input-addon-wrap">
<span class="kt-blog__input-addon">
<SearchOutlined />
</span>
</div>
<BlogInput
name="s"
class="kt-blog__input"
placeholder="搜索什么..."
autocomplete="off"
v-model:value={keyword.value}
/>
</div>
</div>
<div class="kt-blog__search-modal-actions">
<BlogButton class="kt-blog__button kt-blog__button--primary" htmlType="submit">
</BlogButton>
</div>
</BlogForm>
</BlogModal>
);
},
});

View File

@ -0,0 +1,67 @@
import {
LinkOutlined,
QqOutlined,
ShareAltOutlined,
WechatOutlined,
WeiboOutlined,
} from '@antdv-next/icons';
import { defineComponent, ref } from 'vue';
import { BlogButton } from './antdvComponents';
export default defineComponent({
name: 'BlogShare',
setup() {
const opened = ref(false);
const copyLink = async () => {
await navigator.clipboard?.writeText(window.location.href);
};
return () => (
<div class={['kt-blog__share', opened.value && 'kt-blog__share--opened']}>
<div class="kt-blog__share-panel" data-initialized="true">
<a class="kt-blog__share-link kt-blog__share-link--wechat" data-tooltip="分享到微信">
<BlogButton class="kt-blog__button kt-blog__button--icon kt-blog__button--success">
<span class="kt-blog__button-icon-inner">
<WechatOutlined />
</span>
</BlogButton>
</a>
<a class="kt-blog__share-link kt-blog__share-link--qq" data-tooltip="分享到 QQ">
<BlogButton class="kt-blog__button kt-blog__button--icon kt-blog__button--primary">
<span class="kt-blog__button-icon-inner">
<QqOutlined />
</span>
</BlogButton>
</a>
<a class="kt-blog__share-link kt-blog__share-link--weibo" data-tooltip="分享到微博">
<BlogButton class="kt-blog__button kt-blog__button--icon kt-blog__button--warning">
<span class="kt-blog__button-icon-inner">
<WeiboOutlined />
</span>
</BlogButton>
</a>
<a class="kt-blog__share-copy kt-blog__share-link kt-blog__share-link--copy" data-tooltip="复制链接" onClick={copyLink}>
<BlogButton class="kt-blog__button kt-blog__button--icon kt-blog__button--default">
<span class="kt-blog__button-icon-inner">
<LinkOutlined />
</span>
</BlogButton>
</a>
</div>
<BlogButton
class="kt-blog__share-toggle kt-blog__button kt-blog__button--icon kt-blog__button--primary"
data-tooltip="分享"
onClick={() => {
opened.value = true;
}}
>
<span class="kt-blog__button-icon-inner">
<ShareAltOutlined />
</span>
</BlogButton>
</div>
);
},
});

View File

@ -0,0 +1,151 @@
import { SearchOutlined } from '@antdv-next/icons';
import { defineComponent, nextTick, type PropType, type Ref, ref } from 'vue';
import { RouterLink, useRouter } from 'vue-router';
import type { BlogArticle, BlogCategory, BlogTag } from '@/data/blog';
import { useBlogEventBus } from '@/hooks/useBlogEventBus';
import { BlogButton, BlogInput } from './antdvComponents';
const menuItems = [
{ label: '首页', to: '/', icon: 'fa-home' },
{ label: '管理', to: '/category/node', icon: 'fa-user' },
];
export default defineComponent({
name: 'BlogSidebar',
props: {
categories: {
type: Array as PropType<BlogCategory[]>,
required: true,
},
tags: {
type: Array as PropType<BlogTag[]>,
required: true,
},
articles: {
type: Array as PropType<BlogArticle[]>,
required: true,
},
part1Ref: {
type: Object as PropType<Ref<HTMLElement | null>>,
required: true,
},
part2Ref: {
type: Object as PropType<Ref<HTMLElement | null>>,
required: true,
},
},
setup(props) {
const router = useRouter();
const eventBus = useBlogEventBus();
const keyword = ref('');
const leftbarSearchOpen = ref(false);
const leftbarSearchInputRef = ref<any>(null);
/**
* @param target antdv-next Input input
*/
const focusInput = (target: any) => {
target?.focus?.();
target?.input?.focus?.();
};
const submitSearch = () => {
const query = keyword.value.trim();
if (!query) {
return;
}
router.push({
name: 'BlogSearch',
query: { q: query },
});
};
return () => (
<aside class="kt-blog__sidebar" role="complementary">
<div ref={props.part1Ref} class="kt-blog__sidebar-panel kt-blog__sidebar-panel--menu kt-blog__card">
<div class="kt-blog__sidebar-banner kt-blog__card-body">
<span class="kt-blog__sidebar-banner-title">KwiTsukasa的小站</span>
</div>
<ul class="kt-blog__sidebar-menu">
{menuItems.map((item, index) => (
<li key={item.label} class={['kt-blog__sidebar-menu-item', index === 0 && 'kt-blog__sidebar-menu-item--current']}>
<RouterLink to={item.to}>
<i class="kt-blog__sidebar-menu-icon" data-icon={item.icon} />
{item.label}
</RouterLink>
</li>
))}
</ul>
<div class={['kt-blog__sidebar-search kt-blog__card-body', leftbarSearchOpen.value && 'kt-blog__sidebar-search--open']}>
<BlogButton
class="kt-blog__sidebar-search-trigger kt-blog__button kt-blog__button--secondary kt-blog__button--small kt-blog__button--block"
onClick={() => {
leftbarSearchOpen.value = true;
nextTick(() => focusInput(leftbarSearchInputRef.value));
}}
>
<SearchOutlined />
</BlogButton>
<BlogInput
ref={leftbarSearchInputRef}
placeholder="搜索什么..."
class="kt-blog__sidebar-search-input kt-blog__input"
autocomplete="off"
v-model:value={keyword.value}
onBlur={() => {
leftbarSearchOpen.value = false;
}}
onKeydown={(event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault();
submitSearch();
}
}}
/>
</div>
</div>
<div ref={props.part2Ref} class="kt-blog__sidebar-panel kt-blog__sidebar-panel--overview kt-blog__card">
<div class="kt-blog__sidebar-overview kt-blog__card-body">
<div class="kt-blog__sidebar-overview-content">
<div class="kt-blog__sidebar-overview-panel kt-blog__sidebar-overview-panel--active">
<div class="kt-blog__sidebar-author-image">
<div class="kt-blog__sidebar-author-avatar" />
</div>
<h6 class="kt-blog__sidebar-author-name">KwiTsukasa</h6>
<nav class="kt-blog__site-stats">
<div class="kt-blog__site-stats-item kt-blog__site-stats-item--posts">
<RouterLink to="/archives">
<span class="kt-blog__site-stats-count">{props.articles.length}</span>
<span class="kt-blog__site-stats-name"></span>
</RouterLink>
</div>
<div class="kt-blog__site-stats-item kt-blog__site-stats-item--categories">
<a
onClick={() => eventBus.emit('blog:taxonomy:open', 'categories')}
>
<span class="kt-blog__site-stats-count">{props.categories.length}</span>
<span class="kt-blog__site-stats-name"></span>
</a>
</div>
<div class="kt-blog__site-stats-item kt-blog__site-stats-item--tags">
<a onClick={() => eventBus.emit('blog:taxonomy:open', 'tags')}>
<span class="kt-blog__site-stats-count">{props.tags.length}</span>
<span class="kt-blog__site-stats-name"></span>
</a>
</div>
</nav>
</div>
</div>
</div>
</div>
</aside>
);
},
});

View File

@ -0,0 +1,70 @@
import { defineComponent, type PropType } from 'vue';
import { RouterLink } from 'vue-router';
import type { BlogCategory, BlogTag } from '@/data/blog';
import BlogModal from './BlogModal';
type ActiveModal = 'categories' | 'tags' | null;
export default defineComponent({
name: 'BlogTaxonomyModals',
props: {
active: {
type: String as PropType<ActiveModal>,
default: null,
},
categories: {
type: Array as PropType<BlogCategory[]>,
required: true,
},
tags: {
type: Array as PropType<BlogTag[]>,
required: true,
},
},
emits: ['close'],
setup(props, { emit }) {
return () => (
<>
<BlogModal
className="kt-blog__taxonomy-modal kt-blog__taxonomy-modal--categories"
title="分类"
open={props.active === 'categories'}
onClose={() => emit('close')}
>
{props.categories.map((category) => (
<RouterLink
key={category.slug}
class="kt-blog__tag kt-blog__tag--secondary"
to={`/category/${category.slug}`}
onClick={() => emit('close')}
>
{category.label}
<span class="kt-blog__tag-count">{category.count}</span>
</RouterLink>
))}
</BlogModal>
<BlogModal
className="kt-blog__taxonomy-modal kt-blog__taxonomy-modal--tags"
title="标签"
open={props.active === 'tags'}
onClose={() => emit('close')}
>
{props.tags.map((tag) => (
<RouterLink
key={tag.slug}
class="kt-blog__tag kt-blog__tag--secondary"
to={`/tag/${tag.slug}`}
onClick={() => emit('close')}
>
{tag.label}
<span class="kt-blog__tag-count">{tag.count}</span>
</RouterLink>
))}
</BlogModal>
</>
);
},
});

View File

@ -0,0 +1,39 @@
import { FileTextOutlined } from '@antdv-next/icons';
import { defineComponent } from 'vue';
export default defineComponent({
name: 'PageInfoCard',
props: {
title: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
meta: {
type: String,
default: '',
},
},
setup(props, { slots }) {
return () => (
<div class="kt-blog__page-info-wrap">
<div class="kt-blog__page-info kt-blog__card kt-blog__card--gradient-secondary kt-blog__card--large-shadow">
<div class="kt-blog__page-info-body kt-blog__card-body">
<h3 class="kt-blog__page-info-title">{props.title}</h3>
{props.description ? <p class="kt-blog__page-info-description">{props.description}</p> : null}
{props.meta ? (
<p class="kt-blog__page-info-meta">
<FileTextOutlined />
<span>{props.meta}</span>
</p>
) : null}
{slots.default?.()}
</div>
</div>
</div>
);
},
});

View File

@ -0,0 +1,20 @@
import {
Button,
Checkbox,
ColorPicker,
Form,
Input,
Modal,
Switch,
TextArea,
} from 'antdv-next';
// antdv-next 当前 TSX 类型未完整暴露 id/class/aria 等透传属性,运行时支持;这里集中做博客组件层适配。
export const BlogButton = Button as any;
export const BlogCheckbox = Checkbox as any;
export const BlogColorPicker = ColorPicker as any;
export const BlogForm = Form as any;
export const BlogInput = Input as any;
export const BlogModalComponent = Modal as any;
export const BlogSwitch = Switch as any;
export const BlogTextArea = TextArea as any;

257
src/data/blog.ts Normal file
View File

@ -0,0 +1,257 @@
export interface BlogArticle {
id: number;
slug: string;
title: string;
excerpt: string;
category: string;
categorySlug: string;
tags: string[];
cover: string;
date: string;
readTime: string;
author: string;
views: number;
comments: number;
words: number;
content: string[];
}
export interface BlogCategory {
slug: string;
label: string;
description: string;
count: number;
color: string;
}
export interface BlogTag {
slug: string;
label: string;
color: string;
count: number;
}
export const categories: BlogCategory[] = [
{
slug: 'nas',
label: 'NAS',
description: '飞牛 NAS、Docker、Jenkins、k3d/K8s 与公网访问方案。',
count: 3,
color: 'blue',
},
{
slug: 'vue',
label: 'Vue',
description: 'Vue 前端工程、在线文档集成与项目实践。',
count: 2,
color: 'purple',
},
{
slug: 'node',
label: 'Node',
description: 'NestJS、TypeORM 与 Node 服务搭建记录。',
count: 1,
color: 'green',
},
{
slug: 'mqtt',
label: 'MQTT',
description: 'MQTT、topic 与前端消息通信方案。',
count: 1,
color: 'orange',
},
];
export const tags: BlogTag[] = [
{ slug: 'nas', label: 'NAS', color: 'blue', count: 3 },
{ slug: 'vue', label: 'Vue', color: 'purple', count: 2 },
{ slug: 'node', label: 'Node', color: 'green', count: 1 },
{ slug: 'mqtt', label: 'MQTT', color: 'orange', count: 1 },
{ slug: 'jenkins', label: 'Jenkins', color: 'geekblue', count: 1 },
{ slug: 'k3d', label: 'k3d/K8s', color: 'cyan', count: 1 },
{ slug: 'docker', label: 'Docker', color: 'volcano', count: 1 },
{ slug: 'nestjs', label: 'NestJS', color: 'magenta', count: 1 },
];
export const articles: BlogArticle[] = [
{
id: 50,
slug: 'fnos-nas-docker-jenkins-k3d-k8s',
title: '飞牛 NAS Docker、Jenkins 与 k3d/K8s 一体化技术方案',
excerpt:
'说明:本文为脱敏版技术方案。项目名、仓库地址、端口、目录、域名、内网地址、主机名和密钥路径均使用通用占位符,落地时请替换为自己的实际环境。',
category: 'NAS',
categorySlug: 'nas',
tags: ['NAS', 'Docker', 'Jenkins', 'k3d/K8s'],
cover: '/argon/theme/img-2-1200x1000.jpg',
date: '2026-05-16 16:43',
readTime: '14 分钟',
author: 'KwiTsukasa',
views: 8,
comments: 0,
words: 3151,
content: [
'本方案面向小型私有化部署场景,目标是在飞牛 NAS 上把 Docker、Jenkins、前端静态发布和后端 API 容器发布整合成一套可持续迭代的标准流程。',
'飞牛 NAS 作为内网计算与数据承载节点Jenkins 负责编排流水线k3d/K8s 承载后端服务Nginx 承载前端静态站点与反向代理。',
'后续 kt-blog-web 对接 WordPress 后,这类文章会直接来自 WordPress 文章接口,前台只负责还原 Argon 的展示结构。',
],
},
{
id: 46,
slug: 'tencent-cloud-caddy-reverse-proxy',
title: '腾讯云 Caddy 反向代理部署方案',
excerpt:
'架构说明:公网用户通过 HTTPS 访问腾讯云轻量服务器 Caddy由 Caddy 反向代理到内网服务或隧道入口。',
category: 'NAS',
categorySlug: 'nas',
tags: ['NAS'],
cover: '/argon/theme/img-1-1200x1000.jpg',
date: '2026-05-12 12:00',
readTime: '16 分钟',
author: 'KwiTsukasa',
views: 18,
comments: 0,
words: 860,
content: [
'Caddy 适合快速处理 HTTPS、自动证书和简单反代场景。',
'当家庭网络中的服务不能直接暴露时,可以通过云服务器进行统一入口转发。',
],
},
{
id: 41,
slug: 'vps-home-nas-wireguard-ipv4',
title: 'VPS + 家宽NAS + WireGuard 公网IPV4方案',
excerpt:
'目标链路:公网用户访问腾讯云公网 IPv4腾讯云通过 WireGuard 隧道访问家宽飞牛 OS 服务。',
category: 'NAS',
categorySlug: 'nas',
tags: ['NAS'],
cover: '/argon/theme/landing.jpg',
date: '2026-05-12 11:58',
readTime: '14 分钟',
author: 'KwiTsukasa',
views: 14,
comments: 0,
words: 940,
content: [
'WireGuard 负责把云端入口和家庭 NAS 拉到同一个虚拟网络中。',
'Caddy 或 Nginx 再负责域名、HTTPS 和后端服务反向代理。',
],
},
{
id: 35,
slug: 'onlyoffice-online-docs-integration',
title: 'OnlyOffice在线文档集成方案',
excerpt:
'在 index.html 文件中使用 script 引入后端服务部署后生成的 onlyOffice js 文件,并创建公用配置。',
category: 'Vue',
categorySlug: 'vue',
tags: ['Vue'],
cover: '/argon/theme/promo-1.png',
date: '2025-10-31 16:16',
readTime: '11 分钟',
author: 'KwiTsukasa',
views: 19,
comments: 0,
words: 274,
content: [
'OnlyOffice 集成重点在于文档服务地址、回调地址、token 和编辑器配置。',
'前端只需要封装稳定的初始化组件,具体文档状态由后端统一托管。',
],
},
{
id: 20,
slug: 'mqtt-topic-guide',
title: '在项目中快速使用MQTT,以及topic详解',
excerpt:
'如何在项目中使用 MQTT安装 mqtt封装工具类和 Hooks 工具类,理解 topic 的层级与通配规则。',
category: 'MQTT',
categorySlug: 'mqtt',
tags: ['MQTT', 'Vue'],
cover: '/argon/theme/img-2-1200x1000.jpg',
date: '2025-10-29 16:50',
readTime: '17 分钟',
author: 'KwiTsukasa',
views: 22,
comments: 0,
words: 2483,
content: [
'MQTT 的核心在于轻量连接、主题订阅和消息分发。',
'在前端项目中需要把连接状态、订阅清理和消息回调都收敛到统一 hooks 中。',
],
},
{
id: 9,
slug: 'nestjs-typeorm-node-service',
title: '使用NestJs与TypeORM搭建Node服务',
excerpt:
'官方文档、TypeORM、项目 demo、环境准备、全局安装 CLI 并初始化服务。',
category: 'Node',
categorySlug: 'node',
tags: ['Node', 'NestJS'],
cover: '/argon/theme/img-1-1200x1000.jpg',
date: '2025-10-29 15:38',
readTime: '13 分钟',
author: 'KwiTsukasa',
views: 23,
comments: 0,
words: 598,
content: [
'NestJS 负责组织模块、控制器、服务和依赖注入。',
'TypeORM 负责实体映射、数据源配置和数据库查询。',
],
},
];
export const getArticleBySlug = (slug: string) => articles.find((article) => article.slug === slug);
export const getCategoryBySlug = (slug: string) =>
categories.find((category) => category.slug === slug);
export const getTagBySlug = (slug: string) => tags.find((tag) => tag.slug === slug);
export const getTagSlugByLabel = (label: string) =>
tags.find((tag) => tag.label === label)?.slug ?? label.toLowerCase().replace(/\s+/g, '-');
export const getArticlesByCategory = (slug: string) =>
articles.filter((article) => article.categorySlug === slug);
export const getArticlesByTag = (slug: string) => {
const tag = getTagBySlug(slug);
if (!tag) {
return [];
}
return articles.filter((article) => article.tags.includes(tag.label));
};
export const searchArticles = (keyword: string) => {
const normalizedKeyword = keyword.trim().toLowerCase();
if (!normalizedKeyword) {
return articles;
}
return articles.filter((article) => {
const haystack = [
article.title,
article.excerpt,
article.category,
...article.tags,
...article.content,
]
.join(' ')
.toLowerCase();
return haystack.includes(normalizedKeyword);
});
};
export const getRelatedArticles = (source: BlogArticle) =>
articles
.filter(
(article) =>
article.id !== source.id &&
(article.categorySlug === source.categorySlug ||
article.tags.some((tag) => source.tags.includes(tag))),
)
.slice(0, 3);

View File

@ -0,0 +1,185 @@
import { onBeforeUnmount, onMounted, type Ref } from 'vue';
type Cleanup = () => void;
interface ArgonEffectRefs {
bannerContainerRef: Ref<HTMLElement | null>;
contentRef: Ref<HTMLElement | null>;
leftbarPart1Ref: Ref<HTMLElement | null>;
leftbarPart2Ref: Ref<HTMLElement | null>;
toolbarRef: Ref<HTMLElement | null>;
}
/**
* @param refs DOM id
*/
export function useArgonEffects(refs: ArgonEffectRefs) {
let frameId = 0;
/**
* requestAnimationFrame resize
*/
const scheduleUpdate = () => {
if (frameId) {
return;
}
frameId = window.requestAnimationFrame(() => {
frameId = 0;
syncToolbar(refs);
syncLeftbar(refs);
});
};
onMounted(() => {
scheduleUpdate();
document.addEventListener('scroll', scheduleUpdate, { passive: true });
window.addEventListener('resize', scheduleUpdate, { passive: true });
});
onBeforeUnmount(() => {
if (frameId) {
window.cancelAnimationFrame(frameId);
}
document.removeEventListener('scroll', scheduleUpdate);
window.removeEventListener('resize', scheduleUpdate);
});
}
/**
* @param top
* @param duration Argon 800ms
*/
export function smoothScrollTo(top = 0, duration = 800) {
const start = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop;
const distance = top - start;
const startTime = performance.now();
/**
* @param progress 0 1
* @returns easeOutExpo
*/
const easeOutExpo = (progress: number) => (progress >= 1 ? 1 : 1 - 2 ** (-10 * progress));
/**
* @param now requestAnimationFrame
*/
const step = (now: number) => {
const progress = Math.min((now - startTime) / duration, 1);
window.scrollTo(0, start + distance * easeOutExpo(progress));
if (progress < 1) {
window.requestAnimationFrame(step);
}
};
window.requestAnimationFrame(step);
}
/**
* @param callback
* @returns
*/
export function onArgonScroll(callback: () => void): Cleanup {
let frameId = 0;
const scheduleUpdate = () => {
if (frameId) {
return;
}
frameId = window.requestAnimationFrame(() => {
frameId = 0;
callback();
});
};
scheduleUpdate();
document.addEventListener('scroll', scheduleUpdate, { passive: true });
window.addEventListener('resize', scheduleUpdate, { passive: true });
return () => {
if (frameId) {
window.cancelAnimationFrame(frameId);
}
document.removeEventListener('scroll', scheduleUpdate);
window.removeEventListener('resize', scheduleUpdate);
};
}
/**
* @param refs banner
*/
function syncToolbar(refs: ArgonEffectRefs) {
const toolbar = refs.toolbarRef.value;
if (!toolbar) {
return;
}
const themeRoot = toolbar.closest('.kt-blog');
const isNoBanner = themeRoot?.classList.contains('kt-blog--no-banner') ?? false;
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop || window.scrollY;
if (isNoBanner) {
toolbar.classList.toggle('kt-blog__header-navbar--no-blur', scrollTop < 30);
toolbar.classList.remove('kt-blog__header-navbar--ontop');
toolbar.style.removeProperty('background-color');
toolbar.style.removeProperty('box-shadow');
toolbar.style.removeProperty('backdrop-filter');
return;
}
const bannerContainer = refs.bannerContainerRef.value;
const content = refs.contentRef.value;
if (!bannerContainer || !content) {
return;
}
const startTransitionHeight = 30;
const endTransitionHeight = content.getBoundingClientRect().top + scrollTop - 75;
const maxOpacity = themeRoot?.classList.contains('kt-blog--toolbar-blur') ? 0.65 : 0.85;
if (scrollTop < startTransitionHeight) {
toolbar.style.setProperty('background-color', 'rgba(var(--toolbar-color), 0)', 'important');
toolbar.style.setProperty('box-shadow', 'none');
toolbar.style.setProperty('backdrop-filter', 'blur(0px)');
toolbar.classList.add('kt-blog__header-navbar--ontop');
return;
}
if (scrollTop > endTransitionHeight) {
toolbar.style.setProperty('background-color', `rgba(var(--toolbar-color), ${maxOpacity})`, 'important');
toolbar.style.removeProperty('box-shadow');
toolbar.style.setProperty('backdrop-filter', 'blur(16px)');
toolbar.classList.remove('kt-blog__header-navbar--ontop');
return;
}
const progress = (scrollTop - startTransitionHeight) / (endTransitionHeight - startTransitionHeight);
toolbar.style.setProperty('background-color', `rgba(var(--toolbar-color), ${progress * maxOpacity})`, 'important');
toolbar.style.removeProperty('box-shadow');
toolbar.style.setProperty('backdrop-filter', progress > 0.3 ? 'blur(16px)' : 'blur(0px)');
toolbar.classList.remove('kt-blog__header-navbar--ontop');
}
/**
* @param refs
*/
function syncLeftbar(refs: ArgonEffectRefs) {
const leftbarPart1 = refs.leftbarPart1Ref.value;
const leftbarPart2 = refs.leftbarPart2Ref.value;
if (!leftbarPart1 || !leftbarPart2) {
return;
}
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop || window.scrollY;
const part1Rect = leftbarPart1.getBoundingClientRect();
const part1OffsetTop = part1Rect.top + scrollTop;
const shouldStick = part1OffsetTop + leftbarPart1.offsetHeight + 10 - scrollTop <= 90;
const canHeadroom = part1OffsetTop + leftbarPart1.offsetHeight + 10 - scrollTop <= 20;
const themeRoot = leftbarPart1.closest('.kt-blog');
leftbarPart2.classList.toggle('kt-blog__sidebar-panel--sticky', shouldStick);
themeRoot?.classList.toggle('kt-blog--leftbar-can-headroom', canHeadroom);
}

View File

@ -0,0 +1,48 @@
import { shallowRef, type ComponentPublicInstance } from 'vue';
type FocusableTarget = HTMLElement | ComponentPublicInstance | null;
const postArticleRef = shallowRef<HTMLElement | null>(null);
const postCommentRef = shallowRef<HTMLElement | null>(null);
const postCommentInputRef = shallowRef<FocusableTarget>(null);
/**
* @param target DOM使
*/
export function setBlogPostArticleRef(target: HTMLElement | null) {
postArticleRef.value = target;
}
/**
* @param target DOM使
*/
export function setBlogPostCommentRef(target: HTMLElement | null) {
postCommentRef.value = target;
}
/**
* @param target DOM使
*/
export function setBlogPostCommentInputRef(target: FocusableTarget) {
postCommentInputRef.value = target;
}
/**
* @returns DOM ref
*/
export function useBlogDomRefs() {
return {
postArticleRef,
postCommentInputRef,
postCommentRef,
};
}
/**
* DOM ref
*/
export function clearBlogPostRefs() {
postArticleRef.value = null;
postCommentRef.value = null;
postCommentInputRef.value = null;
}

View File

@ -0,0 +1,17 @@
import mitt from 'mitt';
export type BlogTaxonomyModal = 'categories' | 'tags';
type BlogEventMap = {
'blog:search:open': undefined;
'blog:taxonomy:open': BlogTaxonomyModal;
};
const blogEventBus = mitt<BlogEventMap>();
/**
* @returns UI 线
*/
export function useBlogEventBus() {
return blogEventBus;
}

458
src/hooks/useBlogTheme.ts Normal file
View File

@ -0,0 +1,458 @@
import { theme } from 'antdv-next';
import { computed, reactive, watch } from 'vue';
type BlogThemeMode = 'dark' | 'light';
type BlogFontMode = 'sans' | 'serif';
type BlogShadowMode = 'small' | 'big';
type BlogFilterMode = 'off' | 'sunset' | 'darkness' | 'grayscale';
interface BlogThemePreferences {
colorPrimary: string;
filter: BlogFilterMode;
font: BlogFontMode;
mode: BlogThemeMode;
radius: number;
shadow: BlogShadowMode;
}
const STORAGE_KEY = 'KT_BLOG_THEME_PREFERENCES';
const THEME_STYLE_ID = 'kt-blog-theme-style';
const BLOG_THEME_BLOCK_CLASS = 'kt-blog';
const ARGON_SANS_FONT_FAMILY =
'Comfortaa, "Open Sans", -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Helvetica, Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", SimSun, sans-serif';
const ARGON_SERIF_FONT_FAMILY = 'Georgia, "Times New Roman", "Noto Serif SC", serif';
const ARGON_DEFAULT_COLOR_PRIMARY = '#6f5f89';
const ARGON_PRIMARY_SOFT = '#4a4058';
const ARGON_CARD_SHADOW = '0 2px 4px rgba(0, 0, 0, 0.075)';
const defaultPreferences: BlogThemePreferences = {
colorPrimary: ARGON_DEFAULT_COLOR_PRIMARY,
filter: 'off',
font: 'sans',
mode: 'dark',
radius: 4,
shadow: 'small',
};
const preferences = reactive<BlogThemePreferences>(loadPreferences());
let themeWatcherReady = false;
const isDarkTheme = computed(() => preferences.mode === 'dark');
const themeRootClass = computed(() => [
BLOG_THEME_BLOCK_CLASS,
`${BLOG_THEME_BLOCK_CLASS}--wp-argon`,
`${BLOG_THEME_BLOCK_CLASS}--home`,
`${BLOG_THEME_BLOCK_CLASS}--blog`,
`${BLOG_THEME_BLOCK_CLASS}--triple-column`,
`${BLOG_THEME_BLOCK_CLASS}--toolbar-blur`,
`${BLOG_THEME_BLOCK_CLASS}--article-header-default`,
`${BLOG_THEME_BLOCK_CLASS}--${preferences.mode}`,
preferences.font === 'serif' && `${BLOG_THEME_BLOCK_CLASS}--font-serif`,
preferences.shadow === 'big' && `${BLOG_THEME_BLOCK_CLASS}--shadow-big`,
preferences.filter !== 'off' && `${BLOG_THEME_BLOCK_CLASS}--filter-${preferences.filter}`,
isThemeColorTooDark(preferences.colorPrimary) && `${BLOG_THEME_BLOCK_CLASS}--theme-too-dark`,
].filter(Boolean).join(' '));
const themeConfig = computed(() => {
const palette = createThemePalette(preferences.colorPrimary, preferences.mode);
return {
algorithm: isDarkTheme.value ? theme.darkAlgorithm : theme.defaultAlgorithm,
components: {
Button: {
borderRadius: preferences.radius,
colorPrimary: preferences.colorPrimary,
controlHeightLG: 42,
},
Checkbox: {
colorPrimary: preferences.colorPrimary,
},
Input: {
activeBorderColor: preferences.colorPrimary,
hoverBorderColor: preferences.colorPrimary,
},
Modal: {
borderRadiusLG: preferences.radius,
colorBgElevated: palette.card,
},
Switch: {
colorPrimary: preferences.colorPrimary,
},
},
token: {
borderRadius: preferences.radius,
colorBgBase: palette.page,
colorBgContainer: palette.card,
colorBorder: palette.border,
colorPrimary: preferences.colorPrimary,
colorText: palette.text,
colorTextSecondary: palette.muted,
fontFamily: getThemeFontFamily(preferences.font),
},
};
});
/**
* @param nextMode Argon darkmode/lightmode
*/
function setThemeMode(nextMode: BlogThemeMode) {
preferences.mode = nextMode;
}
/**
* @param nextFont sans Argon 线serif use-serif
*/
function setFontMode(nextFont: BlogFontMode) {
preferences.font = nextFont;
}
/**
* @param nextShadow big Argon use-big-shadow
*/
function setShadowMode(nextShadow: BlogShadowMode) {
preferences.shadow = nextShadow;
}
/**
* @param nextFilter Argon off
*/
function setFilterMode(nextFilter: BlogFilterMode) {
preferences.filter = nextFilter;
}
/**
* @param nextRadius CSS antdv-next token
*/
function setRadius(nextRadius: number) {
preferences.radius = nextRadius;
}
/**
* @param nextColor Argon toolbar antdv-next
*/
function setPrimaryColor(nextColor: string) {
preferences.colorPrimary = nextColor;
}
/**
* @returns Blog Web Antdv token
*/
export function useBlogTheme() {
ensureThemeWatcher();
return {
isDarkTheme,
preferences,
setFilterMode,
setFontMode,
setPrimaryColor,
setRadius,
setShadowMode,
setThemeMode,
themeConfig,
themeRootClass,
};
}
/**
* @returns localStorage 退 Argon
*/
function loadPreferences(): BlogThemePreferences {
if (typeof window === 'undefined') {
return { ...defaultPreferences };
}
try {
const rawValue = window.localStorage.getItem(STORAGE_KEY);
if (!rawValue) {
return { ...defaultPreferences };
}
return {
...defaultPreferences,
...JSON.parse(rawValue),
};
} catch {
return { ...defaultPreferences };
}
}
/**
* kt-template-admin -> CSS -> Antdv token
*/
function ensureThemeWatcher() {
if (themeWatcherReady) {
return;
}
themeWatcherReady = true;
watch(
() => ({ ...preferences }),
(currentPreferences) => {
if (typeof document === 'undefined') {
return;
}
applyCssVariables(currentPreferences);
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(currentPreferences));
},
{
deep: true,
immediate: true,
},
);
}
/**
* @param currentPreferences CSS
*/
function applyCssVariables(currentPreferences: BlogThemePreferences) {
const primaryRgb = hexToRgb(currentPreferences.colorPrimary);
const palette = createThemePalette(currentPreferences.colorPrimary, currentPreferences.mode);
const primaryDark = shadeHexColor(currentPreferences.colorPrimary, -18);
const primaryDark2 = shadeHexColor(currentPreferences.colorPrimary, -30);
const fontFamily = getThemeFontFamily(currentPreferences.font);
const primaryHsl = rgbToHsl(primaryRgb.r, primaryRgb.g, primaryRgb.b);
document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')?.setAttribute('content', currentPreferences.colorPrimary);
document
.querySelector<HTMLMetaElement>('meta[name="theme-color-rgb"]')
?.setAttribute('content', `${primaryRgb.r}, ${primaryRgb.g}, ${primaryRgb.b}`);
updateThemeStyle(`
:root {
--kt-blog-scrollbar-track: ${palette.scrollbarTrack};
--kt-blog-scrollbar-thumb: ${palette.scrollbarThumb};
--kt-blog-scrollbar-thumb-hover: ${palette.scrollbarThumbHover};
--kt-blog-scrollbar-size: 10px;
}
.${BLOG_THEME_BLOCK_CLASS} {
--radius: ${currentPreferences.radius}px;
--card-radius: ${currentPreferences.radius}px;
--argon-font-family: ${fontFamily};
--argon-shadow: ${ARGON_CARD_SHADOW};
--argon-primary-soft: ${ARGON_PRIMARY_SOFT};
--themecolor: ${currentPreferences.colorPrimary};
--themecolor-R: ${primaryRgb.r};
--themecolor-G: ${primaryRgb.g};
--themecolor-B: ${primaryRgb.b};
--themecolor-H: ${primaryHsl.h};
--themecolor-S: ${primaryHsl.s};
--themecolor-L: ${primaryHsl.l};
--themecolor-dark0: hsl(${primaryHsl.h}, ${primaryHsl.s}%, ${Math.max(primaryHsl.l - 2.5, 0)}%);
--themecolor-dark: ${primaryDark};
--themecolor-dark2: ${primaryDark2};
--themecolor-dark3: hsl(${primaryHsl.h}, ${primaryHsl.s}%, ${Math.max(primaryHsl.l - 15, 0)}%);
--themecolor-light: hsl(${primaryHsl.h}, ${primaryHsl.s}%, ${Math.min(primaryHsl.l + 10, 100)}%);
--themecolor-gradient: linear-gradient(150deg, var(--themecolor-light) 15%, var(--themecolor) 70%, var(--themecolor-dark0) 94%);
--themecolor-rgbstr: ${primaryRgb.r}, ${primaryRgb.g}, ${primaryRgb.b};
--color-darkmode-toolbar: ${palette.toolbarRgb};
--argon-page: ${palette.page};
--argon-card: ${palette.card};
--argon-card-deep: ${palette.cardDeep};
--argon-card-soft: ${palette.cardSoft};
--argon-card-overlay-weak: ${palette.cardOverlayWeak};
--argon-card-overlay-strong: ${palette.cardOverlayStrong};
--argon-control: ${palette.control};
--argon-control-soft: ${palette.controlSoft};
--argon-pill: ${palette.pill};
--argon-text: ${palette.text};
--argon-muted: ${palette.muted};
--argon-title: ${palette.title};
--argon-border: ${palette.border};
--argon-meta: ${palette.meta};
--argon-widget-text: ${palette.widgetText};
--argon-subtle: ${palette.subtle};
--argon-faint: ${palette.faint};
--argon-placeholder: ${palette.placeholder};
--argon-scrollbar-track: var(--kt-blog-scrollbar-track);
--argon-scrollbar-thumb: var(--kt-blog-scrollbar-thumb);
--argon-scrollbar-thumb-hover: var(--kt-blog-scrollbar-thumb-hover);
--argon-scrollbar-thin-thumb: ${palette.scrollbarThinThumb};
--argon-scrollbar-size: var(--kt-blog-scrollbar-size);
}
`);
}
/**
* @param font
* @returns Argon 使
*/
function getThemeFontFamily(font: BlogFontMode) {
return font === 'serif' ? ARGON_SERIF_FONT_FAMILY : ARGON_SANS_FONT_FAMILY;
}
/**
* @param cssText 文档滚动条使用 :root .kt-blog
*/
function updateThemeStyle(cssText: string) {
let styleElement = document.querySelector<HTMLStyleElement>(`#${THEME_STYLE_ID}`);
if (!styleElement) {
styleElement = document.createElement('style');
styleElement.id = THEME_STYLE_ID;
document.head.appendChild(styleElement);
}
styleElement.textContent = cssText.trim();
}
/**
* @param colorPrimary
* @param mode
* @returns Argon 使
*/
function createThemePalette(colorPrimary: string, mode: BlogThemeMode) {
const { b, g, r } = hexToRgb(colorPrimary);
const { h, s } = rgbToHsl(r, g, b);
if (mode === 'light') {
const paleSaturation = Math.min(Math.max(Math.round(s * 0.38), 16), 42);
return {
border: 'rgba(15, 23, 42, 0.1)',
card: `hsl(${h}, ${paleSaturation}%, 98%)`,
cardDeep: `hsl(${h}, ${paleSaturation}%, 96%)`,
cardOverlayStrong: 'rgba(15, 23, 42, 0.56)',
cardOverlayWeak: 'rgba(15, 23, 42, 0.08)',
cardSoft: `hsl(${h}, ${paleSaturation}%, 94%)`,
control: `hsl(${h}, ${Math.min(paleSaturation + 8, 52)}%, 88%)`,
controlSoft: `hsl(${h}, ${Math.min(paleSaturation + 6, 50)}%, 92%)`,
faint: 'rgba(23, 32, 51, 0.28)',
meta: 'rgba(23, 32, 51, 0.56)',
muted: '#5b6472',
page: '#f7f8fb',
pill: `hsl(${h}, ${Math.min(paleSaturation + 10, 56)}%, 90%)`,
placeholder: 'rgba(23, 32, 51, 0.42)',
scrollbarThinThumb: 'rgba(0, 0, 0, 0.2)',
scrollbarThumb: 'rgba(0, 0, 0, 0.25)',
scrollbarThumbHover: `rgba(${r}, ${g}, ${b}, 0.7)`,
scrollbarTrack: 'transparent',
subtle: 'rgba(23, 32, 51, 0.48)',
text: '#172033',
title: '#263146',
toolbarRgb: '111, 95, 137',
widgetText: 'rgba(23, 32, 51, 0.66)',
};
}
const baseSaturation = Math.min(Math.max(Math.round(s * 0.22), 8), 24);
const textSaturation = Math.min(Math.max(Math.round(s * 0.42), 24), 70);
const mutedSaturation = Math.min(Math.max(Math.round(s * 0.16), 12), 32);
return {
border: 'rgba(255, 255, 255, 0.06)',
card: `hsl(${h}, ${baseSaturation + 2}%, 18%)`,
cardDeep: `hsl(${h}, ${baseSaturation + 2}%, 18%)`,
cardOverlayStrong: `hsla(${h}, ${baseSaturation + 6}%, 16%, 0.72)`,
cardOverlayWeak: `rgba(${r}, ${g}, ${b}, 0.1)`,
cardSoft: `hsl(${h}, ${baseSaturation + 2}%, 20%)`,
control: `hsl(${h}, ${baseSaturation + 12}%, 28%)`,
controlSoft: `hsl(${h}, ${baseSaturation + 10}%, 24%)`,
faint: 'rgba(238, 238, 238, 0.34)',
meta: 'rgba(238, 238, 238, 0.68)',
muted: `hsl(${h}, ${mutedSaturation}%, 72%)`,
page: `hsl(${h}, ${baseSaturation}%, 14%)`,
pill: `hsl(${h}, ${baseSaturation + 12}%, 26%)`,
placeholder: 'rgba(238, 238, 238, 0.45)',
scrollbarThinThumb: 'rgba(255, 255, 255, 0.2)',
scrollbarThumb: 'rgba(255, 255, 255, 0.25)',
scrollbarThumbHover: `rgba(${r}, ${g}, ${b}, 0.7)`,
scrollbarTrack: 'transparent',
subtle: 'rgba(238, 238, 238, 0.6)',
text: '#eeeeee',
title: `hsl(${h}, ${textSaturation}%, 86%)`,
toolbarRgb: `${r}, ${g}, ${b}`,
widgetText: 'rgba(238, 238, 238, 0.66)',
};
}
/**
* @param hexColor
* @returns RGB
*/
function hexToRgb(hexColor: string) {
const normalized = hexColor.replace('#', '');
const value =
normalized.length === 3
? normalized
.split('')
.map((item) => item + item)
.join('')
: normalized.padEnd(6, '0').slice(0, 6);
return {
b: Number.parseInt(value.slice(4, 6), 16),
g: Number.parseInt(value.slice(2, 4), 16),
r: Number.parseInt(value.slice(0, 2), 16),
};
}
/**
* @param hexColor
* @param percent
* @returns
*/
function shadeHexColor(hexColor: string, percent: number) {
const { b, g, r } = hexToRgb(hexColor);
const adjust = (channel: number) => {
const nextChannel = Math.round(channel + (percent / 100) * 255);
return Math.max(0, Math.min(255, nextChannel)).toString(16).padStart(2, '0');
};
return `#${adjust(r)}${adjust(g)}${adjust(b)}`;
}
/**
* @param r
* @param g 绿
* @param b
* @returns HSL Argon
*/
function rgbToHsl(r: number, g: number, b: number) {
const red = r / 255;
const green = g / 255;
const blue = b / 255;
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
const lightness = (max + min) / 2;
if (max === min) {
return { h: 0, l: Math.round(lightness * 100), s: 0 };
}
const delta = max - min;
const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
const hue =
max === red
? (green - blue) / delta + (green < blue ? 6 : 0)
: max === green
? (blue - red) / delta + 2
: (red - green) / delta + 4;
return {
h: Math.round(hue * 60),
l: Math.round(lightness * 100),
s: Math.round(saturation * 100),
};
}
/**
* @param r
* @param g 绿
* @param b
* @returns Argon
*/
function getGray(r: number, g: number, b: number) {
return r * 0.299 + g * 0.587 + b * 0.114;
}
/**
* @param hexColor
* @returns modifier
*/
function isThemeColorTooDark(hexColor: string) {
const { b, g, r } = hexToRgb(hexColor);
return getGray(r, g, b) < 50;
}

19
src/main.ts Normal file
View File

@ -0,0 +1,19 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import 'antdv-next/dist/reset.css';
import App from './App';
import router from './router';
import './styles/index.scss';
const app = createApp(App);
app.use(createPinia());
app.use(router);
const mountTarget = document.querySelector<HTMLElement>('.kt-blog__mount');
if (mountTarget) {
app.mount(mountTarget);
}

51
src/router/index.ts Normal file
View File

@ -0,0 +1,51 @@
import { createRouter, createWebHashHistory } from 'vue-router';
import ArchivePage from '@/views/blog/ArchivePage';
import BlogHomePage from '@/views/blog/HomePage';
import PostPage from '@/views/blog/PostPage';
import SearchPage from '@/views/blog/SearchPage';
import TermPage from '@/views/blog/TermPage';
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'BlogHome',
component: BlogHomePage,
},
{
path: '/post/:slug',
name: 'BlogPost',
component: PostPage,
},
{
path: '/category/:slug',
name: 'BlogCategory',
component: TermPage,
meta: {
termMode: 'category',
},
},
{
path: '/tag/:slug',
name: 'BlogTag',
component: TermPage,
meta: {
termMode: 'tag',
},
},
{
path: '/archives',
name: 'BlogArchive',
component: ArchivePage,
},
{
path: '/search',
name: 'BlogSearch',
component: SearchPage,
},
],
});
export default router;

20
src/styles/antdv.scss Normal file
View File

@ -0,0 +1,20 @@
@use './tokens' as blog;
@include blog.block {
.ant-input,
.ant-input-affix-wrapper,
.ant-checkbox-wrapper,
.ant-empty-description {
color: var(--argon-text);
}
.ant-input,
.ant-input-affix-wrapper {
border-color: var(--argon-border);
background: var(--argon-control-soft);
}
.ant-input::placeholder {
color: var(--argon-placeholder);
}
}

25
src/styles/banner.scss Normal file
View File

@ -0,0 +1,25 @@
@use './tokens' as blog;
@include blog.block {
&__banner {
position: relative;
min-height: 790px;
margin: 0 0 25px;
padding: 96px 0;
color: var(--argon-text);
background: transparent;
}
&__banner-container {
position: relative;
z-index: 1;
width: min(1180px, calc(100% - 40px));
margin: 0 auto;
padding-top: 34px;
text-align: center;
}
&--no-banner &__banner {
display: none;
}
}

243
src/styles/base.scss Normal file
View File

@ -0,0 +1,243 @@
@use './tokens' as blog;
html {
min-width: 320px;
background: transparent;
}
body {
min-height: 100vh;
margin: 0;
background: transparent;
}
* {
box-sizing: border-box;
}
@include blog.block {
position: relative;
z-index: 1;
min-height: 100vh;
color: var(--argon-text);
background: var(--argon-page);
font-family: var(--argon-font-family);
font-size: 16px;
&::before,
&::after {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
content: '';
}
&::before {
background: url('/argon/theme/img-2-1200x1000.jpg') center top / cover fixed no-repeat;
}
&--no-banner::before {
background: var(--argon-page);
}
&::after {
background: transparent;
backdrop-filter: blur(0.4px);
}
&--dark::after {
background: rgba(40, 38, 42, 0.34);
}
&--no-banner::after {
background: transparent;
}
&--shadow-big &__card {
box-shadow:
0 15px 35px rgba(50, 50, 93, 0.1),
0 5px 15px rgba(0, 0, 0, 0.07);
}
&--filter-sunset {
filter: sepia(30%);
}
&--filter-grayscale {
filter: grayscale(1);
}
&--filter-darkness &__primary::after {
position: fixed;
inset: 0;
z-index: 999999999;
width: 100vw;
height: 100vh;
pointer-events: none;
content: '';
background: rgba(0, 0, 0, 0.4);
}
a {
color: inherit;
text-decoration: none;
}
img {
display: block;
max-width: 100%;
}
button,
input,
textarea {
font: inherit;
}
&__card {
border: 0;
border-radius: var(--card-radius);
background: var(--argon-card);
animation: kt-blog-card-show 0.25s ease-out;
box-shadow: var(--argon-shadow);
transform-origin: center top;
.kt-blog__card {
animation: none;
}
}
&__card-body {
padding: 0;
}
&__card--gradient-secondary {
background: var(--argon-card-deep);
}
&__card--large-shadow {
box-shadow: 0 18px 45px rgba(15, 13, 18, 0.16);
}
&__button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: var(--card-radius);
cursor: pointer;
box-shadow: none;
&:hover,
&:focus {
color: #fff;
}
}
&__button--primary,
&__button--secondary {
color: #fff;
background: var(--themecolor);
}
&__button--success {
color: #fff;
background: #2dce89;
}
&__button--warning {
color: #fff;
background: #fb6340;
}
&__button--default,
&__button--neutral {
color: var(--argon-text);
background: var(--argon-control-soft);
}
&__button--icon {
width: 38px;
height: 38px;
}
&__button--small {
min-height: 32px;
padding: 0 12px;
}
&__button--block {
width: 100%;
}
&__button-icon-inner {
display: inline-flex;
}
&__tag {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 2px 10px;
border-radius: var(--card-radius);
font-size: 13px;
line-height: 1.2;
}
&__tag--secondary {
color: var(--argon-text);
background: var(--argon-pill);
}
&__hidden {
display: none !important;
}
&__input-group {
display: flex;
min-height: 36px;
align-items: center;
overflow: hidden;
border-radius: var(--card-radius);
background: var(--argon-control-soft);
}
&__input-group--spaced {
margin-bottom: 1.5rem;
}
&__input-addon {
display: inline-flex;
padding: 0 10px;
color: var(--argon-muted);
}
&__input {
width: 100%;
border: 0;
outline: 0;
color: var(--argon-text);
background: transparent;
&::placeholder {
color: var(--argon-placeholder);
}
}
&__form-group--spaced {
margin-bottom: 1rem;
}
}
@keyframes kt-blog-card-show {
0% {
opacity: 0;
transform: scale(0.8);
}
100% {
opacity: 1;
transform: none;
}
}

View File

@ -0,0 +1,282 @@
@use './tokens' as blog;
@include blog.block {
&__float-actions {
position: fixed;
right: 18px;
bottom: 28px;
z-index: 120;
display: block;
}
&__float-actions--left {
right: unset;
left: 18px;
}
&__float-action {
position: relative;
display: block;
width: 42px;
height: 42px;
margin-top: 8px;
overflow: visible;
color: var(--argon-text) !important;
background: var(--argon-control-soft) !important;
transition:
color 0.3s ease,
background-color 0.3s ease,
height 0.3s ease,
margin 0.3s ease,
opacity 0.3s ease,
transform 0.3s ease,
box-shadow 0.3s ease;
&:hover {
color: #fff !important;
background-color: var(--themecolor) !important;
}
&::before {
position: absolute;
top: 7px;
right: 50px;
width: max-content;
padding: 3px 10px;
color: #fff;
border-radius: 3px;
background: #32325d;
content: attr(tooltip);
font-size: 12px;
font-weight: 400;
line-height: 22px;
opacity: 0;
pointer-events: none;
text-transform: none;
transform: translateX(5px);
transition: all 0.3s ease;
}
&:hover::before {
opacity: 0.7;
transform: translateX(0);
}
}
&__float-action--hidden {
height: 0 !important;
margin-top: 0;
opacity: 0;
pointer-events: none;
box-shadow: none !important;
}
&__float-actions--left &__float-action::before {
right: unset;
left: 50px;
transform: translateX(-5px);
}
&__float-actions--left &__float-action:hover::before {
transform: translateX(0);
}
&__float-action--toggle-side {
height: 30px !important;
opacity: 0;
transform: translateY(8px);
&::before {
top: 1px;
content: attr(tooltip-move-to-left);
}
}
&__float-actions:hover &__float-action--toggle-side {
height: 30px !important;
opacity: 1;
transform: translateY(0);
}
&__float-actions--left &__float-action--toggle-side::before {
content: attr(tooltip-move-to-right);
}
&__float-action--theme::before {
content: attr(tooltip-darkmode);
}
&--dark &__float-action--theme::before {
content: attr(tooltip-lightmode);
}
&__float-actions--settings-open &__float-action {
overflow: hidden;
opacity: 0.25;
pointer-events: none;
}
&__float-actions--settings-open &__float-action--settings {
color: #fff !important;
opacity: 1 !important;
pointer-events: unset !important;
background-color: var(--themecolor-dark2) !important;
}
&__settings-panel {
position: fixed;
right: 85px;
bottom: 35px;
display: flex;
width: max-content;
min-width: 350px;
max-width: calc(100vw - 170px);
max-height: calc(100vh - 70px);
flex-direction: column;
gap: 14px;
padding: 10px 25px 18px;
color: var(--argon-text);
background: var(--argon-card) !important;
transform-origin: right bottom;
}
&__float-actions--left &__settings-panel {
right: unset;
left: 85px;
transform-origin: left bottom;
}
&__settings-close {
margin-right: -12px;
cursor: pointer;
font-size: 18px;
line-height: 1;
text-align: right;
}
&__settings-item {
display: flex;
align-items: center;
gap: 8px;
> span:first-child {
min-width: 62px;
color: var(--argon-title);
font-weight: 700;
}
}
&__settings-button--font,
&__settings-button--shadow {
padding: 3px 10px;
text-transform: none;
}
&__settings-button--active {
color: #fff !important;
background: var(--themecolor) !important;
}
&__settings-button--left {
margin-right: 0 !important;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
&__settings-button--right {
margin-left: 0 !important;
border-left: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
&__settings-filter-row {
align-items: center;
}
&__settings-filter-button {
width: 50px !important;
height: 50px !important;
margin-left: 5px;
border: 0 !important;
border-radius: 50% !important;
font-size: 14px;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 7px 14px rgba(50, 50, 93, 0.1), 0 3px 6px rgba(0, 0, 0, 0.08);
}
}
&__settings-filter-button--active {
border: 1px solid currentcolor !important;
}
&__settings-filter-button--off {
color: var(--themecolor);
background: var(--argon-control-soft) !important;
}
&__settings-filter-button--sunset {
color: #6e5a00;
background: rgb(255, 255, 200) !important;
}
&__settings-filter-button--darkness {
color: #eee;
background: rgb(80, 80, 80) !important;
}
&__settings-filter-button--grayscale {
color: #333;
background: rgb(200, 200, 200) !important;
}
&__settings-color {
width: 28px !important;
height: 28px !important;
border: 2px solid transparent !important;
}
&__settings-color--active {
border-color: #fff !important;
box-shadow: 0 0 0 2px var(--themecolor);
}
&__settings-color-picker {
min-width: 132px;
.ant-color-picker-trigger {
width: 100%;
color: var(--argon-text);
border-color: var(--argon-border);
background: var(--argon-control-soft);
}
}
&__settings-color-presets {
display: inline-flex;
align-items: center;
gap: 8px;
}
&__float-action--progress {
height: 30px;
}
&__float-action-progress-bar {
position: absolute;
top: 0;
left: 0;
height: 100%;
border-radius: var(--card-radius);
background: var(--themecolor);
opacity: 0.08;
}
&__float-action-progress-text {
position: relative;
z-index: 1;
font-size: 12px;
}
}

14
src/styles/index.scss Normal file
View File

@ -0,0 +1,14 @@
@use './tokens';
@use './base';
@use './scrollbar';
@use './toolbar';
@use './modal';
@use './banner';
@use './floatActions';
@use './layout';
@use './sidebar';
@use './rightbar';
@use './post';
@use './searchComments';
@use './antdv';
@use './responsive';

105
src/styles/layout.scss Normal file
View File

@ -0,0 +1,105 @@
@use './tokens' as blog;
@include blog.block {
&__content {
position: relative;
z-index: 2;
display: grid;
width: min(1440px, 100%);
grid-template-columns: 280px minmax(0, 860px) 280px;
gap: 0;
margin: -330px auto 0;
padding: 0 5px;
}
&--no-banner &__content {
margin: 85px auto 0;
}
&__page-info-wrap {
grid-column: 1 / -1;
min-height: 1px;
}
&__page-info {
width: 830px;
margin: 0 auto 25px;
background: var(--argon-card-deep);
}
&__page-info-body {
padding: 24px 30px;
}
&__page-info-title {
margin: 0;
color: var(--argon-title);
font-size: 26px;
line-height: 1.3;
}
&__page-info-description,
&__page-info-meta {
margin-top: 1rem;
color: var(--argon-muted);
line-height: 1.75;
}
&__page-info-meta {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 0;
opacity: 0.8;
}
&__sidebar {
grid-column: 1;
grid-row: 2;
width: 280px;
margin: 0 0 25px;
padding: 0 10px 0 20px;
}
&__rightbar {
grid-column: 3;
grid-row: 2;
width: 280px;
margin: 0 0 25px;
padding: 0 20px 0 10px;
}
&__primary {
grid-column: 2;
grid-row: 2;
width: 860px;
}
&__main {
width: 860px;
}
&__sidebar-panel {
width: 250px;
overflow: hidden;
}
&__sidebar-panel--menu {
margin: 0;
}
&__sidebar-panel--overview {
margin: 10px 0 0;
}
&__sidebar-panel--sticky {
position: fixed;
top: 90px;
width: 250px;
animation: none;
}
&--leftbar-can-headroom &__sidebar-panel--sticky {
top: 10px;
}
}

62
src/styles/modal.scss Normal file
View File

@ -0,0 +1,62 @@
@use './tokens' as blog;
@include blog.block {
&__modal-host {
position: relative;
z-index: 1000;
}
&__modal-wrap {
.ant-modal-content {
overflow: hidden;
color: var(--argon-text);
border-radius: var(--card-radius);
background: var(--argon-card);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.38);
}
.ant-modal-header {
margin: 0;
padding: 18px 22px;
border-bottom: 1px solid var(--argon-border);
background: transparent;
}
.ant-modal-title {
color: var(--argon-title);
font-size: 18px;
}
.ant-modal-close {
color: var(--argon-muted);
}
}
&__modal-body {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 20px 22px 24px;
}
&__modal-enter-active,
&__modal-leave-active,
&__popover-enter-active,
&__popover-leave-active {
transition:
opacity 0.25s ease,
transform 0.25s ease;
}
&__modal-enter-from,
&__modal-leave-to {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
&__popover-enter-from,
&__popover-leave-to {
opacity: 0;
transform: translateX(10px);
}
}

225
src/styles/post.scss Normal file
View File

@ -0,0 +1,225 @@
@use './tokens' as blog;
@include blog.block {
&__post,
&__post-empty,
&__post-navigation,
&__related-posts,
&__comments,
&__comment-form,
&__footer {
width: 820px;
margin: 0 20px 25px;
padding: 30px 30px 35px;
color: var(--argon-text);
background: var(--argon-card-deep) !important;
}
&__post-header {
margin-bottom: 22px;
}
&__post-header--center {
text-align: center;
}
&__post-title {
color: var(--argon-title);
font-size: 26px;
line-height: 1.35;
letter-spacing: 0;
&:hover {
color: var(--themecolor);
}
}
&__post--full &__post-title {
font-size: 30px;
}
&__post-meta {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
margin-top: 12px;
color: var(--argon-meta);
font-size: 14px;
}
&__post-meta-item {
display: inline-flex;
align-items: center;
gap: 5px;
}
&__post-meta-divider {
color: var(--argon-faint);
}
&__post-content {
margin: 6px 0 10px;
color: var(--argon-text);
font-size: 16px;
line-height: 1.9;
p {
margin: 0 0 18px;
}
}
&__post-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 22px;
color: var(--argon-meta);
}
&__post-tag {
color: var(--argon-text);
gap: 6px;
&:hover {
color: #fff;
background: var(--argon-control);
}
}
&__tag-count {
margin-left: 6px;
color: var(--argon-subtle);
}
&__post-load-sentinel {
width: 820px;
height: 1px;
margin: -1px 20px 24px;
pointer-events: none;
}
&__post-transition {
display: contents;
}
&__post-transition-enter-active,
&__post-transition-leave-active {
transition:
opacity 0.5s ease,
transform 0.5s ease;
}
&__post-transition-enter-from,
&__post-transition-leave-to {
opacity: 0;
transform: translateY(10px) scale(0.98);
}
&__post-page-transition-enter-active,
&__post-page-transition-leave-active {
transition:
opacity 0.34s ease,
transform 0.34s cubic-bezier(0.4, 0, 0, 1);
}
&__post-page-transition-enter-from {
opacity: 0;
transform: translateY(14px) scale(0.985);
}
&__post-page-transition-leave-to {
opacity: 0;
transform: translateY(-8px) scale(0.99);
}
&__post-navigation {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding: 20px 30px;
}
&__post-navigation-item {
display: flex;
flex-direction: column;
gap: 6px;
a {
color: var(--argon-title);
font-weight: 700;
}
}
&__post-navigation-item--next {
text-align: right;
}
&__post-navigation-label {
color: var(--argon-muted);
font-size: 13px;
}
&__related-posts-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
overflow-x: auto;
}
&__section-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 16px;
color: var(--argon-title);
font-size: 20px;
}
&__related-post {
position: relative;
min-height: 128px;
overflow: hidden;
border-radius: var(--card-radius);
background: var(--argon-control-soft);
}
&__related-post-inner {
position: relative;
z-index: 1;
display: flex;
min-height: 128px;
align-items: flex-end;
padding: 12px;
color: #fff;
background: linear-gradient(180deg, var(--argon-card-overlay-weak), var(--argon-card-overlay-strong));
}
&__related-post-title {
font-weight: 700;
line-height: 1.45;
}
&__related-post-thumbnail {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
&__search-filters {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin-top: 12px;
color: var(--argon-muted);
}
&__search-filter-group {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
}

View File

@ -0,0 +1,96 @@
@use './tokens' as blog;
@media (max-width: 1180px) {
@include blog.block {
&__content {
grid-template-columns: 280px minmax(0, 1fr);
}
&__rightbar {
display: none;
}
&__primary,
&__main,
&__post,
&__post-empty,
&__post-navigation,
&__related-posts,
&__comments,
&__comment-form,
&__footer,
&__post-load-sentinel {
width: 100%;
}
}
}
@media (max-width: 860px) {
@include blog.block {
&__header-navbar {
padding: 0 16px;
}
&__header-nav--hover,
&__header-search {
display: none;
}
&__header-toggle {
display: inline-flex;
width: 38px;
height: 38px;
align-items: center;
justify-content: center;
border: 0;
border-radius: var(--card-radius);
color: var(--argon-text);
background: var(--argon-card-soft);
}
&__header-brand {
min-width: 0;
flex: 1;
}
&__banner {
min-height: 300px;
}
&__content {
display: block;
margin-top: -180px;
padding: 0 12px;
}
&--no-banner &__content {
margin-top: 60px;
}
&__sidebar {
width: 100%;
padding: 0;
margin-bottom: 16px;
}
&__sidebar-panel,
&__primary,
&__main {
width: 100%;
}
&__post,
&__post-empty,
&__post-navigation,
&__related-posts,
&__comments,
&__comment-form,
&__footer {
padding: 24px 20px;
}
&__form-grid {
grid-template-columns: 1fr;
}
}
}

41
src/styles/rightbar.scss Normal file
View File

@ -0,0 +1,41 @@
@use './tokens' as blog;
@include blog.block {
&__rightbar-widget {
width: 250px;
margin: 0 0 18px;
padding: 20px;
background: var(--argon-card-soft);
}
&__rightbar-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
color: var(--argon-title);
font-size: 18px;
letter-spacing: 0;
}
&__rightbar-list {
display: flex;
flex-direction: column;
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
&__rightbar-list-item,
&__rightbar-comment,
&__rightbar-category,
&__rightbar-list a {
color: var(--argon-widget-text);
line-height: 1.55;
}
&__rightbar-list a:hover {
color: var(--argon-title);
}
}

91
src/styles/scrollbar.scss Normal file
View File

@ -0,0 +1,91 @@
@use './tokens' as blog;
html {
scrollbar-color: var(--kt-blog-scrollbar-thumb, rgba(0, 0, 0, 0.25))
var(--kt-blog-scrollbar-track, transparent);
scrollbar-gutter: stable;
scrollbar-width: thin;
}
body {
overflow-y: auto;
}
::-webkit-scrollbar {
width: var(--kt-blog-scrollbar-size, 10px);
height: calc(var(--kt-blog-scrollbar-size, 10px) - 2px);
background-color: transparent;
}
::-webkit-scrollbar-track {
background-color: var(--kt-blog-scrollbar-track, transparent);
}
::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 100px;
background-color: var(--kt-blog-scrollbar-thumb, rgba(0, 0, 0, 0.25));
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--kt-blog-scrollbar-thumb-hover, rgba(0, 0, 0, 0.7));
}
::-webkit-scrollbar-corner {
background-color: transparent;
}
@include blog.block {
scrollbar-color: var(--argon-scrollbar-thumb) var(--argon-scrollbar-track);
scrollbar-width: thin;
* {
scrollbar-color: var(--argon-scrollbar-thumb) var(--argon-scrollbar-track);
scrollbar-width: thin;
}
*::-webkit-scrollbar {
width: var(--argon-scrollbar-size);
height: calc(var(--argon-scrollbar-size) - 2px);
background-color: transparent;
}
*::-webkit-scrollbar-track {
background-color: var(--argon-scrollbar-track);
}
*::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 100px;
background-color: var(--argon-scrollbar-thumb);
background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover {
background-color: var(--argon-scrollbar-thumb-hover);
}
&__sidebar-panel {
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
border-width: 1px;
background-color: var(--argon-scrollbar-thin-thumb);
}
&::-webkit-scrollbar-button {
height: 5px;
pointer-events: none;
}
}
&__related-posts-list {
scrollbar-gutter: stable;
}
}

View File

@ -0,0 +1,119 @@
@use './tokens' as blog;
@include blog.block {
&__page-search-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
margin-top: 16px;
}
&__share {
position: fixed;
right: 18px;
bottom: 190px;
z-index: 79;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
&__share-panel {
display: none;
flex-direction: column;
gap: 8px;
}
&__share--opened &__share-panel {
display: flex;
}
&__comments,
&__comment-form {
textarea {
min-height: 88px;
resize: vertical;
}
}
&__form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
&__form-col--full {
grid-column: 1 / -1;
}
&__timeline {
position: relative;
padding-left: 22px;
&::before {
position: absolute;
top: 4px;
bottom: 4px;
left: 5px;
width: 2px;
content: '';
background: rgba(222, 204, 245, 0.22);
}
}
&__timeline-group,
&__timeline-node {
position: relative;
margin: 12px 0;
}
&__timeline-node::before {
position: absolute;
top: 12px;
left: -22px;
width: 12px;
height: 12px;
content: '';
border-radius: 50%;
background: var(--argon-title);
}
&__timeline-time,
&__timeline-month {
display: inline-flex;
align-items: center;
gap: 8px;
}
&__timeline-time {
color: var(--argon-muted);
}
&__timeline-month {
margin: 0 0 12px;
color: var(--argon-title);
}
&__timeline-card {
display: inline-flex;
margin-left: 14px;
padding: 9px 14px;
background: var(--argon-control-soft) !important;
a {
color: var(--argon-text);
}
}
&__footer {
padding: 25px 20px;
background: var(--themecolor-gradient) !important;
text-align: center;
}
&__footer-info {
color: #fff;
font-weight: 700;
}
}

164
src/styles/sidebar.scss Normal file
View File

@ -0,0 +1,164 @@
@use './tokens' as blog;
@include blog.block {
&__sidebar-banner {
display: flex;
min-height: 86px;
align-items: center;
justify-content: center;
padding: 24px;
background: var(--themecolor-gradient);
text-align: center;
}
&__sidebar-banner-title {
color: #fff;
font-size: 18px;
font-weight: 700;
}
&__sidebar-menu {
margin: 0;
padding: 0;
list-style: none;
}
&__sidebar-menu-item {
a {
display: flex;
align-items: center;
gap: 12px;
padding: 13px 20px;
color: var(--argon-text);
background: transparent;
}
&--current a,
a:hover {
background: var(--argon-control);
}
}
&__sidebar-menu-icon {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--argon-muted);
}
&__sidebar-search {
position: relative;
display: grid;
padding: 16px 20px 20px;
text-align: center;
}
&__sidebar-search-trigger {
grid-area: 1 / 1;
display: flex;
min-height: 36px;
align-items: center;
justify-content: center;
gap: 10px;
overflow: hidden;
border: 1px solid transparent;
transition:
border-color 0.32s ease,
background-color 0.32s ease,
opacity 0.28s ease,
transform 0.42s cubic-bezier(0.4, 0, 0, 1);
&:hover {
border-color: var(--argon-border);
transform: translateY(-1px);
}
}
&__sidebar-search-input {
grid-area: 1 / 1;
width: 100%;
height: 36px;
min-width: 0;
border: 1px solid var(--argon-border);
box-shadow: 0 10px 24px rgba(15, 13, 18, 0.14);
opacity: 0;
pointer-events: none;
transform: translateY(8px) scale(0.98);
transition:
border-color 0.32s ease,
box-shadow 0.32s ease,
opacity 0.3s ease,
transform 0.42s cubic-bezier(0.4, 0, 0, 1);
&:focus {
border-color: var(--themecolor);
box-shadow: 0 10px 28px rgba(var(--themecolor-rgbstr), 0.22);
}
}
&__sidebar-search--open {
.kt-blog__sidebar-search-trigger {
opacity: 0;
pointer-events: none;
transform: translateY(-8px) scale(0.98);
}
.kt-blog__sidebar-search-input {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
}
&__sidebar-overview {
padding: 18px 18px 22px;
}
&__sidebar-overview-panel {
text-align: center;
}
&__sidebar-author-image {
margin: 8px auto 12px;
}
&__sidebar-author-avatar {
width: 92px;
height: 92px;
margin: 0 auto;
border-radius: 50%;
background: url('/argon/theme/profile.jpg') center / cover no-repeat;
box-shadow: var(--argon-shadow);
}
&__sidebar-author-name {
margin: 0 0 14px;
color: var(--argon-text);
font-size: 18px;
}
&__site-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin: 0 0 18px;
a {
display: flex;
flex-direction: column;
gap: 4px;
cursor: pointer;
}
}
&__site-stats-count {
color: var(--argon-text);
font-size: 18px;
font-weight: 700;
}
&__site-stats-name {
color: var(--argon-muted);
font-size: 12px;
}
}

6
src/styles/tokens.scss Normal file
View File

@ -0,0 +1,6 @@
// Keep the blog selector in one namespace so every partial shares the same BEM root.
@mixin block {
.kt-blog {
@content;
}
}

196
src/styles/toolbar.scss Normal file
View File

@ -0,0 +1,196 @@
@use './tokens' as blog;
@include blog.block {
&__header {
position: relative;
z-index: 100;
height: 0;
color: var(--argon-text);
}
&__header-global {
width: 100%;
}
&__header-container {
display: flex;
width: 100%;
flex: 1;
align-items: center;
gap: 18px;
}
&__header-brand {
min-width: 180px;
}
&__header-title {
color: var(--argon-text);
font-weight: 700;
}
&__header-collapse {
display: flex;
flex: 1;
align-items: center;
justify-content: space-between;
}
&__header-nav {
display: flex;
align-items: center;
gap: 18px;
margin: 0;
padding: 0;
list-style: none;
}
&__header-nav-link {
color: rgba(238, 238, 238, 0.78);
font-size: 14px;
&:hover,
&[aria-current='page'] {
color: var(--argon-title);
}
}
&__header-search {
width: 42px;
margin: 0;
cursor: pointer;
transition:
width 0.42s cubic-bezier(0.4, 0, 0, 1),
transform 0.42s cubic-bezier(0.4, 0, 0, 1);
.kt-blog__input-group {
width: 100%;
height: 36px;
justify-content: flex-end;
border: 1px solid transparent;
box-shadow: none;
transition:
border-color 0.32s ease,
background-color 0.32s ease,
box-shadow 0.32s ease;
}
.kt-blog__input-addon,
.kt-blog__input {
transition:
opacity 0.32s ease,
transform 0.42s cubic-bezier(0.4, 0, 0, 1),
width 0.42s cubic-bezier(0.4, 0, 0, 1),
padding 0.42s cubic-bezier(0.4, 0, 0, 1);
}
.kt-blog__input {
width: 200px;
}
}
&__header-search--open {
width: 220px;
.kt-blog__input-group {
border-color: var(--argon-border);
background: var(--argon-control-soft);
box-shadow: 0 10px 26px rgba(15, 13, 18, 0.18);
}
}
&__header-search:not(.kt-blog__header-search--open) {
.kt-blog__input-group {
background: transparent;
}
.kt-blog__input-addon {
color: #fff;
background: transparent;
}
.kt-blog__input {
width: 0 !important;
min-width: 0 !important;
padding: 0 !important;
border-color: transparent !important;
background: transparent;
opacity: 0;
transform: translateX(10px);
}
}
&__header-search--open {
.kt-blog__input-addon {
color: var(--argon-text);
background: var(--argon-control-soft);
}
}
&__header-toggle,
&__header-mobile-search {
display: none;
}
&__header-navbar {
--toolbar-color: var(--themecolor-rgbstr);
position: fixed;
top: 0;
right: 0;
left: 0;
z-index: 100;
display: flex;
align-items: center;
min-height: 58px;
padding: 0.5rem 8%;
background-color: rgba(var(--toolbar-color), 0.85) !important;
box-shadow: 0 1px 10px rgba(130, 130, 134, 0.1);
transition:
background-color 0.3s ease,
backdrop-filter 0.3s ease,
padding 0.15s ease,
box-shadow 0.3s ease;
}
&--dark &__header-navbar {
--toolbar-color: var(--color-darkmode-toolbar);
}
&--no-banner &__header-navbar {
background-color: rgba(var(--themecolor-rgbstr), 0.85) !important;
}
&--no-banner.kt-blog--toolbar-blur &__header-navbar {
background-color: rgba(var(--themecolor-rgbstr), 0.65) !important;
backdrop-filter: blur(16px);
}
&--no-banner.kt-blog--toolbar-blur &__header-navbar--no-blur {
background-color: rgba(var(--themecolor-rgbstr), 0.85) !important;
backdrop-filter: blur(0);
}
&__header-navbar--ontop {
padding-top: 1rem;
padding-bottom: 1rem;
background-color: rgba(var(--toolbar-color), 0) !important;
box-shadow: none;
backdrop-filter: blur(0);
}
&__header-navbar--ontop,
&:not(.kt-blog--no-banner) &__header-navbar {
.kt-blog__header-title,
.kt-blog__header-nav-link,
.kt-blog__header-search:not(.kt-blog__header-search--open) .kt-blog__input-addon,
.kt-blog__header-search.kt-blog__header-search--open .kt-blog__input-addon {
color: rgba(255, 255, 255, 0.92);
text-shadow: 0 1px 2px rgba(30, 23, 42, 0.28);
}
}
&--leftbar-can-headroom &__header-navbar {
transform: translateY(-100%);
}
}

View File

@ -0,0 +1,53 @@
import { CalendarOutlined } from '@antdv-next/icons';
import { computed, defineComponent } from 'vue';
import { RouterLink } from 'vue-router';
import BlogLayout from '@/components/blog/BlogLayout';
import { articles } from '@/data/blog';
export default defineComponent({
name: 'BlogArchivePage',
setup() {
const groupedArticles = computed(() => {
const groups = new Map<string, typeof articles>();
articles.forEach((article) => {
const key = article.date.slice(0, 7);
groups.set(key, [...(groups.get(key) ?? []), article]);
});
return Array.from(groups.entries());
});
return () => (
<BlogLayout
mainClass="kt-blog__main--archive"
pageTitle="归档时间轴"
pageDescription="按月份回顾 KT 项目沉淀的文章记录。"
pageMeta={`${articles.length} 篇文章`}
>
<article class="kt-blog__post kt-blog__post--full kt-blog__card">
<div class="kt-blog__post-content kt-blog__post-content--full">
<div class="kt-blog__timeline kt-blog__timeline--archive">
{groupedArticles.value.map(([month, monthArticles]) => (
<section key={month} class="kt-blog__timeline-group">
<h3 class="kt-blog__timeline-month">
<CalendarOutlined />
<span>{month}</span>
</h3>
{monthArticles.map((article) => (
<div key={article.id} class="kt-blog__timeline-node">
<div class="kt-blog__timeline-time">{article.date.slice(5)}</div>
<div class="kt-blog__timeline-card kt-blog__card kt-blog__card--gradient-secondary">
<RouterLink to={`/post/${article.slug}`}>{article.title}</RouterLink>
</div>
</div>
))}
</section>
))}
</div>
</div>
</article>
</BlogLayout>
);
},
});

View File

@ -0,0 +1,16 @@
import { defineComponent } from 'vue';
import ArticleList from '@/components/blog/ArticleList';
import BlogLayout from '@/components/blog/BlogLayout';
import { articles } from '@/data/blog';
export default defineComponent({
name: 'BlogHomePage',
setup() {
return () => (
<BlogLayout>
<ArticleList articles={articles} />
</BlogLayout>
);
},
});

248
src/views/blog/PostPage.tsx Normal file
View File

@ -0,0 +1,248 @@
import { CalendarOutlined, CommentOutlined, EyeOutlined, ReadOutlined, TagsOutlined } from '@antdv-next/icons';
import { computed, defineComponent, onBeforeUnmount, ref, Transition } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import BlogLayout from '@/components/blog/BlogLayout';
import BlogShare from '@/components/blog/BlogShare';
import {
BlogButton,
BlogForm,
BlogInput,
BlogTextArea,
} from '@/components/blog/antdvComponents';
import { articles, getArticleBySlug, getRelatedArticles, getTagSlugByLabel } from '@/data/blog';
import {
clearBlogPostRefs,
setBlogPostArticleRef,
setBlogPostCommentInputRef,
setBlogPostCommentRef,
} from '@/hooks/useBlogDomRefs';
export default defineComponent({
name: 'BlogPostPage',
setup() {
const route = useRoute();
const article = computed(() => getArticleBySlug(String(route.params.slug)) ?? articles[0]);
const articleIndex = computed(() => articles.findIndex((item) => item.slug === article.value?.slug));
const previousArticle = computed(() => {
const index = articleIndex.value;
return articles[index > 0 ? index - 1 : articles.length - 1] ?? article.value;
});
const nextArticle = computed(() => {
const index = articleIndex.value;
return articles[index >= 0 && index < articles.length - 1 ? index + 1 : 0] ?? article.value;
});
const relatedArticles = computed(() => (article.value ? getRelatedArticles(article.value) : []));
const commentContent = ref('');
const commentEmail = ref('');
const commentName = ref('');
onBeforeUnmount(() => {
clearBlogPostRefs();
});
return () => {
const currentArticle = article.value;
if (!currentArticle) {
return (
<BlogLayout
mainClass="kt-blog__main--post"
pageTitle="文章不存在"
pageDescription="当前文章可能已经移动或被删除。"
pageMeta="0 个结果"
showPageInfo={false}
>
<article class="kt-blog__post kt-blog__post--full kt-blog__card">
<div class="kt-blog__post-content"></div>
</article>
</BlogLayout>
);
}
const currentPreviousArticle = previousArticle.value ?? currentArticle;
const currentNextArticle = nextArticle.value ?? currentArticle;
return (
<BlogLayout
mainClass="kt-blog__main--post"
pageTitle={currentArticle.title}
pageDescription={currentArticle.excerpt}
pageMeta={`${currentArticle.views} 次阅读`}
showPageInfo={false}
>
<Transition name="kt-blog__post-page-transition" mode="out-in" appear>
<article
key={currentArticle.slug}
ref={(target) => setBlogPostArticleRef(target as HTMLElement | null)}
class="kt-blog__post kt-blog__post--full kt-blog__card"
>
<header class="kt-blog__post-header kt-blog__post-header--center">
<RouterLink class="kt-blog__post-title" to={`/post/${currentArticle.slug}`}>
{currentArticle.title}
</RouterLink>
<div class="kt-blog__post-meta">
<div class="kt-blog__post-meta-item kt-blog__post-meta-item--time">
<CalendarOutlined />
<span>{currentArticle.date}</span>
</div>
<div class="kt-blog__post-meta-divider">|</div>
<div class="kt-blog__post-meta-item kt-blog__post-meta-item--views">
<EyeOutlined />
<span>{currentArticle.views}</span>
</div>
<div class="kt-blog__post-meta-divider">|</div>
<div class="kt-blog__post-meta-item kt-blog__post-meta-item--comments">
<CommentOutlined />
<span>{currentArticle.comments}</span>
</div>
<div class="kt-blog__post-meta-divider">|</div>
<div class="kt-blog__post-meta-item kt-blog__post-meta-item--categories">
<RouterLink to={`/category/${currentArticle.categorySlug}`}>
{currentArticle.category}
</RouterLink>
</div>
<br />
<div class="kt-blog__post-meta-item kt-blog__post-meta-item--words">
<ReadOutlined />
<span>{currentArticle.words} </span>
</div>
<div class="kt-blog__post-meta-divider">|</div>
<div class="kt-blog__post-meta-item kt-blog__post-meta-item--reading-time">
<span>{currentArticle.readTime}</span>
</div>
</div>
</header>
<div class="kt-blog__post-content kt-blog__post-content--full">
{currentArticle.content.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</div>
<div class="kt-blog__post-tags">
<TagsOutlined />
{currentArticle.tags.map((tag) => (
<RouterLink
key={tag}
class="kt-blog__tag kt-blog__tag--secondary kt-blog__post-tag"
to={`/tag/${getTagSlugByLabel(tag)}`}
>
{tag}
</RouterLink>
))}
</div>
</article>
</Transition>
<BlogShare />
<div class="kt-blog__post-navigation kt-blog__card">
<div class="kt-blog__post-navigation-item kt-blog__post-navigation-item--previous">
<span class="kt-blog__post-navigation-label"></span>
<RouterLink to={`/post/${currentPreviousArticle.slug}`}>
{currentPreviousArticle.title}
</RouterLink>
</div>
<div class="kt-blog__post-navigation-item kt-blog__post-navigation-item--next">
<span class="kt-blog__post-navigation-label"></span>
<RouterLink to={`/post/${currentNextArticle.slug}`}>
{currentNextArticle.title}
</RouterLink>
</div>
</div>
<div class="kt-blog__related-posts kt-blog__card">
<h2 class="kt-blog__section-title">
<ReadOutlined />
<span></span>
</h2>
<div class="kt-blog__related-posts-list">
{relatedArticles.value.map((relatedArticle) => (
<RouterLink key={relatedArticle.slug} class="kt-blog__related-post" to={`/post/${relatedArticle.slug}`}>
<div class="kt-blog__related-post-inner kt-blog__related-post-inner--has-thumbnail">
<div class="kt-blog__related-post-title">{relatedArticle.title}</div>
<i class="kt-blog__related-post-arrow" />
</div>
<img class="kt-blog__related-post-thumbnail" src={relatedArticle.cover} alt={relatedArticle.title} />
</RouterLink>
))}
</div>
</div>
<div class="kt-blog__comments kt-blog__card">
<div class="kt-blog__card-body">
<h2 class="kt-blog__section-title">
<CommentOutlined />
</h2>
<span></span>
</div>
</div>
<div
ref={(target) => setBlogPostCommentRef(target as HTMLElement | null)}
class="kt-blog__comment-form kt-blog__card"
>
<div class="kt-blog__card-body">
<h2 class="kt-blog__section-title">
<CommentOutlined />
<span class="kt-blog__comment-edit-hidden"></span>
</h2>
<BlogForm class="kt-blog__comment-form-body" layout="vertical">
<div class="kt-blog__form-grid">
<div class="kt-blog__form-col kt-blog__form-col--full">
<BlogTextArea
ref={(target: any) => setBlogPostCommentInputRef(target)}
class="kt-blog__comment-form-content kt-blog__input"
placeholder="评论内容"
name="comment"
v-model:value={commentContent.value}
/>
</div>
</div>
<div class="kt-blog__form-grid kt-blog__comment-edit-hidden">
<div class="kt-blog__form-col">
<div class="kt-blog__form-group">
<div class="kt-blog__input-group kt-blog__input-group--spaced">
<div class="kt-blog__input-addon-wrap">
<span class="kt-blog__input-addon"></span>
</div>
<BlogInput
class="kt-blog__comment-form-name kt-blog__input"
placeholder="昵称"
name="author"
v-model:value={commentName.value}
/>
</div>
</div>
</div>
<div class="kt-blog__form-col">
<div class="kt-blog__form-group">
<div class="kt-blog__input-group kt-blog__input-group--spaced">
<div class="kt-blog__input-addon-wrap">
<span class="kt-blog__input-addon"></span>
</div>
<BlogInput
class="kt-blog__comment-form-email kt-blog__input"
placeholder="邮箱"
name="email"
v-model:value={commentEmail.value}
/>
</div>
</div>
</div>
</div>
<BlogButton class="kt-blog__button kt-blog__button--primary">
</BlogButton>
</BlogForm>
</div>
</div>
</BlogLayout>
);
};
},
});

View File

@ -0,0 +1,100 @@
import { SearchOutlined } from '@antdv-next/icons';
import { computed, defineComponent, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import ArticleList from '@/components/blog/ArticleList';
import BlogLayout from '@/components/blog/BlogLayout';
import PageInfoCard from '@/components/blog/PageInfoCard';
import { BlogButton, BlogCheckbox, BlogForm, BlogInput } from '@/components/blog/antdvComponents';
import { searchArticles } from '@/data/blog';
export default defineComponent({
name: 'BlogSearchPage',
setup() {
const route = useRoute();
const router = useRouter();
const keyword = ref(String(route.query.q ?? ''));
const filters = ref(['post', 'page']);
const resultArticles = computed(() => searchArticles(keyword.value));
watch(
() => route.query.q,
(value) => {
keyword.value = String(value ?? '');
},
);
const submitSearch = () => {
router.push({
name: 'BlogSearch',
query: { q: keyword.value.trim() },
});
};
return () => (
<BlogLayout
mainClass="kt-blog__main--article-list kt-blog__main--search-result"
pageTitle={keyword.value ? `${keyword.value} 的搜索结果` : '搜索'}
pageDescription="搜索文章标题、摘要、分类、标签与正文内容。"
pageMeta={`${resultArticles.value.length} 个结果`}
v-slots={{
pageInfo: () => (
<PageInfoCard
title={keyword.value ? keyword.value : '搜索'}
description={keyword.value ? '的搜索结果' : '搜索文章标题、摘要、分类、标签与正文内容。'}
meta={`${resultArticles.value.length} 个结果`}
>
<div class="kt-blog__search-filters">
<div class="kt-blog__search-filter-group">
{[
{ label: '文章', value: 'post' },
{ label: '页面', value: 'page' },
{ label: '说说', value: 'shuoshuo' },
].map((item) => (
<BlogCheckbox
key={item.value}
checked={filters.value.includes(item.value)}
onChange={(event: { target: { checked: boolean } }) => {
const nextValues = filters.value.filter((value) => value !== item.value);
if (event.target.checked) {
nextValues.push(item.value);
}
filters.value = nextValues;
}}
>
{item.label}
</BlogCheckbox>
))}
</div>
</div>
<BlogForm
class="kt-blog__page-search-form"
onFinish={submitSearch}
>
<div class="kt-blog__input-group">
<div class="kt-blog__input-addon-wrap">
<span class="kt-blog__input-addon">
<SearchOutlined />
</span>
</div>
<BlogInput
name="s"
class="kt-blog__input"
placeholder="搜索什么..."
autocomplete="off"
v-model:value={keyword.value}
/>
</div>
<BlogButton class="kt-blog__button kt-blog__button--primary" htmlType="submit">
</BlogButton>
</BlogForm>
</PageInfoCard>
),
}}
>
<ArticleList articles={resultArticles.value} />
</BlogLayout>
);
},
});

View File

@ -0,0 +1,44 @@
import { computed, defineComponent } from 'vue';
import { useRoute } from 'vue-router';
import ArticleList from '@/components/blog/ArticleList';
import BlogLayout from '@/components/blog/BlogLayout';
import {
getArticlesByCategory,
getArticlesByTag,
getCategoryBySlug,
getTagBySlug,
} from '@/data/blog';
export default defineComponent({
name: 'BlogTermPage',
setup() {
const route = useRoute();
const mode = computed(() => String(route.meta.termMode ?? 'category'));
const slug = computed(() => String(route.params.slug ?? ''));
const category = computed(() => getCategoryBySlug(slug.value));
const tag = computed(() => getTagBySlug(slug.value));
const termArticles = computed(() =>
mode.value === 'tag' ? getArticlesByTag(slug.value) : getArticlesByCategory(slug.value),
);
const title = computed(() =>
mode.value === 'tag' ? `标签:${tag.value?.label ?? slug.value}` : `分类:${category.value?.label ?? slug.value}`,
);
const description = computed(() =>
mode.value === 'tag'
? `${tag.value?.label ?? slug.value} 相关的文章。`
: category.value?.description ?? '当前分类下的文章列表。',
);
return () => (
<BlogLayout
mainClass="kt-blog__main--article-list"
pageTitle={title.value}
pageDescription={description.value}
pageMeta={`${termArticles.value.length} 篇文章`}
>
<ArticleList articles={termArticles.value} />
</BlogLayout>
);
},
});

18
tsconfig.app.json Normal file
View File

@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": ["./src/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}

14
tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}

27
tsconfig.node.json Normal file
View File

@ -0,0 +1,27 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
// Most tools use transpilation instead of Node.js's native type-stripping.
// Bundler mode provides a smoother developer experience.
"module": "preserve",
"moduleResolution": "bundler",
// Include Node.js types and avoid accidentally including other `@types/*` packages.
"types": ["node"],
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}

19
tsconfig.vitest.json Normal file
View File

@ -0,0 +1,19 @@
{
"extends": "./tsconfig.app.json",
// Override to include only test files and clear exclusions.
// Application code imported in tests is automatically included via module resolution.
"include": ["src/**/__tests__/*", "env.d.ts"],
"exclude": [],
"compilerOptions": {
// Vitest runs in a different environment than the application code.
// Adjust lib and types accordingly.
"lib": [],
"types": ["node", "jsdom"],
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo"
}
}

18
vite.config.ts Normal file
View File

@ -0,0 +1,18 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueJsx(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})

20
vitest.config.ts Normal file
View File

@ -0,0 +1,20 @@
import { fileURLToPath } from 'node:url';
import { configDefaults, defineConfig, mergeConfig } from 'vitest/config';
import viteConfig from './vite.config';
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
server: {
deps: {
inline: [/^@v-c\//, 'antdv-next'],
},
},
},
}),
);