feat: 承接本地博客主题和文章展示

This commit is contained in:
sunlei 2026-06-05 14:42:00 +08:00
parent 9224e6d8a0
commit 3c22fc0809
20 changed files with 1772 additions and 74 deletions

View File

@ -1,13 +1,24 @@
import { App as AntdApp, ConfigProvider } from 'antdv-next'; import { App as AntdApp, ConfigProvider } from 'antdv-next';
import { defineComponent } from 'vue'; import { defineComponent, onMounted } from 'vue';
import { RouterView } from 'vue-router'; import { RouterView } from 'vue-router';
import { useBlogTheme } from './hooks/useBlogTheme'; import { type WordpressArgonThemeConfig, useBlogTheme } from './hooks/useBlogTheme';
declare global {
interface Window {
__KT_BLOG_THEME_CONFIG__?: WordpressArgonThemeConfig;
__KT_BLOG_WORDPRESS_THEME_CONFIG__?: WordpressArgonThemeConfig;
}
}
export default defineComponent({ export default defineComponent({
name: 'KtBlogApp', name: 'KtBlogApp',
setup() { setup() {
const { themeConfig, themeRootClass } = useBlogTheme(); const { applyWordpressThemeConfig, themeConfig, themeRootClass } = useBlogTheme();
onMounted(() => {
applyInitialBlogThemeConfig(applyWordpressThemeConfig);
});
return () => ( return () => (
<ConfigProvider theme={themeConfig.value}> <ConfigProvider theme={themeConfig.value}>
@ -20,3 +31,34 @@ export default defineComponent({
); );
}, },
}); });
async function applyInitialBlogThemeConfig(
applyWordpressThemeConfig: (config: WordpressArgonThemeConfig) => void,
) {
const inlineConfig =
window.__KT_BLOG_THEME_CONFIG__ ||
window.__KT_BLOG_WORDPRESS_THEME_CONFIG__;
if (inlineConfig) {
applyWordpressThemeConfig(inlineConfig);
}
const primaryConfigUrl =
import.meta.env.VITE_BLOG_THEME_CONFIG_URL || '/api/blog/theme/config';
if (!primaryConfigUrl) {
return;
}
try {
const response = await fetch(primaryConfigUrl);
if (!response.ok) {
return;
}
const payload = await response.json();
applyWordpressThemeConfig(payload?.data || payload);
} catch {
// Theme config is progressive enhancement; local defaults keep the blog renderable.
}
}

View File

@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'; import { flushPromises, mount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest'; import { nextTick } from 'vue';
import { afterEach, describe, expect, it, vi } from 'vitest';
import App from '../App'; import App from '../App';
import router from '../router'; import router from '../router';
@ -107,6 +108,11 @@ vi.mock('@antdv-next/icons', async () => {
}); });
describe('App', () => { describe('App', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
it('mounts renders properly', async () => { it('mounts renders properly', async () => {
await router.push('/'); await router.push('/');
await router.isReady(); await router.isReady();
@ -119,4 +125,244 @@ describe('App', () => {
expect(wrapper.text()).toContain('KwiTsukasa的小站'); expect(wrapper.text()).toContain('KwiTsukasa的小站');
}); });
it('loads local blog theme config by default', async () => {
const fetchMock = vi.fn((url: string | URL | Request) => {
const target = `${url}`;
const body = target.includes('/api/blog/theme/config')
? {
data: {
backgroundDarkBrightness: 0.65,
backgroundDarkImage:
'https://s3.kwitsukasa.top/images/bg-冬滚滚.png',
backgroundDarkOpacity: 1,
backgroundImage:
'https://s3.kwitsukasa.top/images/bg-冬滚滚.png',
backgroundOpacity: 1,
darkmodeAutoSwitch: 'alwayson',
enableCustomThemeColor: true,
headerMenu: [
{
href: 'https://blog.kwitsukasa.top',
label: '首页',
},
],
htmlClass: [
'triple-column',
'immersion-color',
'toolbar-blur',
'article-header-style-default',
],
sidebarMenu: [
{
href: 'https://blog.kwitsukasa.top',
icon: 'fa-home',
label: '首页',
},
{
external: true,
href: 'http://blog.kwitsukasa.top/wp-admin/',
icon: 'fa-user',
label: '管理',
},
],
site: {
authorAvatar:
'http://s3.kwitsukasa.top/images/avatar-tsukasa-1.jpg',
authorName: 'KwiTsukasa',
home: 'https://blog.kwitsukasa.top',
title: '接口博客',
url: 'https://blog.kwitsukasa.top',
},
themeCardRadius: 6,
themeColor: '#c3a1ed',
themeVersion: '1.3.5',
},
}
: {
data: {
list: [],
total: 0,
},
};
return Promise.resolve(
new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status: 200,
}),
);
});
vi.stubGlobal('fetch', fetchMock);
await router.push('/');
await router.isReady();
const wrapper = mount(App, {
global: {
plugins: [router],
},
});
await flushPromises();
await nextTick();
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/blog/theme/config');
expect(wrapper.find('.kt-blog').classes()).toEqual(
expect.arrayContaining([
'kt-blog--triple-column',
'kt-blog--immersion-color',
'kt-blog--toolbar-blur',
'kt-blog--article-header-default',
'kt-blog--argon-1-3-5',
'kt-blog--dark',
]),
);
expect(wrapper.text()).toContain('接口博客');
expect(wrapper.text()).toContain('KwiTsukasa');
expect(wrapper.text()).toContain('管理');
const styleText =
document.querySelector('#kt-blog-theme-style')?.textContent || '';
expect(styleText).toContain('--themecolor: #C3A1ED;');
expect(styleText).toContain('--radius: 6px;');
expect(styleText).toContain(
"--argon-background-dark-image: url('https://s3.kwitsukasa.top/images/bg-冬滚滚.png');",
);
expect(styleText).toContain(
"--argon-author-avatar: url('http://s3.kwitsukasa.top/images/avatar-tsukasa-1.jpg');",
);
});
it('keeps local defaults without requesting WordPress theme fallback', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response('', {
status: 404,
}),
);
vi.stubGlobal('fetch', fetchMock);
await router.push('/');
await router.isReady();
mount(App, {
global: {
plugins: [router],
},
});
await flushPromises();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith('/api/blog/theme/config');
});
it('can load WordPress migration theme config through env override', async () => {
vi.stubEnv('VITE_BLOG_THEME_CONFIG_URL', '/api/wordpress/theme/config');
const fetchMock = vi.fn((url: string | URL | Request) => {
const target = `${url}`;
const body = target.includes('/api/wordpress/theme/config')
? {
code: 0,
data: {
backgroundDarkBrightness: 0.5,
backgroundDarkImage:
'https://s3.kwitsukasa.top/images/wp-dark.png',
backgroundDarkOpacity: 0.9,
backgroundImage: 'https://s3.kwitsukasa.top/images/wp-light.png',
backgroundOpacity: 0.8,
darkmodeAutoSwitch: 'alwaysoff',
enableCustomThemeColor: true,
headerMenu: [
{
href: 'https://blog.kwitsukasa.top',
label: '首页',
},
],
htmlClass:
'triple-column immersion-color toolbar-blur article-header-style-full use-serif',
sidebarMenu: [
{
href: 'https://blog.kwitsukasa.top',
icon: 'fa-home',
label: '首页',
},
{
external: true,
href: 'http://blog.kwitsukasa.top/wp-admin/',
icon: 'fa-user',
label: '管理',
},
],
site: {
authorAvatar:
'http://s3.kwitsukasa.top/images/wp-avatar.jpg',
authorName: 'WordPress 作者',
home: 'https://blog.kwitsukasa.top',
title: 'WordPress 主题接口',
url: 'https://blog.kwitsukasa.top',
},
themeCardRadius: 10,
themeColor: '#4f46e5',
themeVersion: '1.3.5',
},
message: 'ok',
}
: {
code: 0,
data: {
list: [],
total: 0,
},
message: 'ok',
};
return Promise.resolve(
new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status: 200,
}),
);
});
vi.stubGlobal('fetch', fetchMock);
await router.push('/');
await router.isReady();
const wrapper = mount(App, {
global: {
plugins: [router],
},
});
await flushPromises();
await nextTick();
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/wordpress/theme/config');
expect(fetchMock.mock.calls.map((item) => `${item[0]}`)).not.toContain(
'/api/blog/theme/config',
);
expect(wrapper.find('.kt-blog').classes()).toEqual(
expect.arrayContaining([
'kt-blog--triple-column',
'kt-blog--immersion-color',
'kt-blog--toolbar-blur',
'kt-blog--article-header-full',
'kt-blog--argon-1-3-5',
'kt-blog--light',
'kt-blog--font-serif',
]),
);
expect(wrapper.text()).toContain('WordPress 主题接口');
expect(wrapper.text()).toContain('WordPress 作者');
expect(wrapper.text()).toContain('管理');
const styleText =
document.querySelector('#kt-blog-theme-style')?.textContent || '';
expect(styleText).toContain('--themecolor: #4F46E5;');
expect(styleText).toContain('--radius: 10px;');
expect(styleText).toContain('--argon-background-opacity: 0.8;');
expect(styleText).toContain(
"--argon-background-dark-image: url('https://s3.kwitsukasa.top/images/wp-dark.png');",
);
expect(styleText).toContain(
"--argon-author-avatar: url('http://s3.kwitsukasa.top/images/wp-avatar.jpg');",
);
});
}); });

View File

@ -0,0 +1,217 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { articles as fallbackArticles } from '@/data/blog';
type FetchMock = ReturnType<typeof vi.fn>;
const encodedSlug = '%e6%b5%8b%e8%af%95-milkdown';
const decodedSlug = '测试-milkdown';
const publicArticle = {
authorName: '作者',
categoriesResolved: [
{
count: 2,
id: 10,
name: '技术',
slug: 'tech',
},
],
comment_status: 'open',
contentHtml: '<h2>标题</h2><p>正文 &amp; Milkdown</p>',
cover: 'https://img.demo/cover.jpg',
date: '2026-06-05T10:20:30',
excerptText: '摘要',
id: 100,
slug: encodedSlug,
tagsResolved: [
{
count: 1,
id: 20,
name: 'Milkdown',
slug: 'milkdown',
},
],
title: {
rendered: 'Milkdown &amp; Markdown',
},
};
describe('useBlogArticles', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
});
it('loads and normalizes public blog articles for Blog Web views', async () => {
const fetchMock = mockFetch([
{
body: {
code: 200,
data: {
list: [publicArticle],
total: 1,
},
},
status: 200,
},
]);
const { useBlogArticles } = await import('@/hooks/useBlogArticles');
const blogArticles = useBlogArticles();
await blogArticles.loadArticles();
const [article] = blogArticles.articles.value;
const listRequest = fetchMock.mock.calls[0]?.[0];
expect(listRequest).toBeDefined();
const requestUrl = new URL(`${listRequest}`);
expect(requestUrl.pathname).toBe('/api/blog/article/public/list');
expect(requestUrl.searchParams.get('pageNo')).toBe('1');
expect(requestUrl.searchParams.get('pageSize')).toBe('50');
expect(article).toMatchObject({
author: '作者',
category: '技术',
categorySlug: 'tech',
content: ['标题 正文 & Milkdown'],
contentHtml: '<h2>标题</h2><p>正文 &amp; Milkdown</p>',
cover: 'https://img.demo/cover.jpg',
date: '2026-06-05 10:20',
excerpt: '摘要',
readTime: '1 分钟',
slug: decodedSlug,
tags: ['Milkdown'],
title: 'Milkdown & Markdown',
});
expect(blogArticles.categories.value).toEqual([
{
color: 'blue',
count: 1,
description: '技术 分类下的文章。',
label: '技术',
slug: 'tech',
},
]);
expect(blogArticles.tags.value).toEqual([
{
color: 'blue',
count: 1,
label: 'Milkdown',
slug: 'milkdown',
},
]);
expect(blogArticles.searchArticles('正文')[0]?.slug).toBe(decodedSlug);
expect(blogArticles.loadedFromApi.value).toBe(true);
});
it('falls back to static articles when blog public list is unavailable', async () => {
mockFetch([
{
body: {
code: 502,
msg: '博客文章接口不可用',
},
status: 502,
},
]);
const { useBlogArticles } = await import('@/hooks/useBlogArticles');
const blogArticles = useBlogArticles();
await blogArticles.loadArticles();
expect(blogArticles.loadedFromApi.value).toBe(false);
expect(blogArticles.articles.value).toEqual(fallbackArticles);
});
it('keeps an empty list when blog public list is reachable but has no articles', async () => {
mockFetch([
{
body: {
code: 200,
data: {
list: [],
total: 0,
},
},
status: 200,
},
]);
const { useBlogArticles } = await import('@/hooks/useBlogArticles');
const blogArticles = useBlogArticles();
await blogArticles.loadArticles();
expect(blogArticles.loadedFromApi.value).toBe(true);
expect(blogArticles.articles.value).toEqual([]);
});
it('fetches public article detail when cached list data does not include html content', async () => {
const fetchMock = mockFetch([
{
body: {
code: 200,
data: {
list: [
{
...publicArticle,
contentHtml: '',
},
],
total: 1,
},
},
status: 200,
},
{
body: {
code: 200,
data: {
...publicArticle,
contentHtml: '<h1>详情标题</h1><p>详情正文</p>',
},
},
status: 200,
},
]);
const { useBlogArticles } = await import('@/hooks/useBlogArticles');
const blogArticles = useBlogArticles();
const article = await blogArticles.loadArticle(encodedSlug);
const detailRequest = fetchMock.mock.calls[1]?.[0];
expect(detailRequest).toBeDefined();
const detailUrl = new URL(`${detailRequest}`);
expect(detailUrl.pathname).toBe('/api/blog/article/public/detail');
expect(detailUrl.searchParams.get('slug')).toBe(decodedSlug);
expect(article?.contentHtml).toBe('<h1>详情标题</h1><p>详情正文</p>');
expect(blogArticles.getArticleBySlug(decodedSlug)?.content).toEqual([
'详情标题 详情正文',
]);
});
});
function mockFetch(
responses: Array<{
body: unknown;
status: number;
}>,
) {
const fetchMock: FetchMock = vi.fn(async () => {
const response = responses.shift();
if (!response) {
throw new Error('没有更多 mock response');
}
return new Response(JSON.stringify(response.body), {
headers: {
'Content-Type': 'application/json',
},
status: response.status,
});
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}

View File

@ -0,0 +1,249 @@
import { nextTick } from 'vue';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useBlogTheme } from '@/hooks/useBlogTheme';
vi.mock('antdv-next', () => ({
theme: {
darkAlgorithm: {},
defaultAlgorithm: {},
},
}));
describe('useBlogTheme', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('applies WordPress Argon theme config to runtime theme state', async () => {
const {
applyWordpressThemeConfig,
preferences,
siteConfig,
themeRootClass,
wordpressThemeConfig,
} = useBlogTheme();
applyWordpressThemeConfig({
argonConfig: {
codeHighlight: {
breakLine: true,
enable: true,
hideLinenumber: false,
transparentLinenumber: true,
},
dateFormat: 'YMD',
disablePjax: true,
foldLongComments: true,
foldLongShuoshuo: false,
headroom: 'headroom',
language: 'zh_CN',
lazyload: {
effect: 'fadeIn',
threshold: 800,
},
pangu: 'true',
pjaxAnimationDuration: 500,
waterflowColumns: '3',
wpPath: '/wp-content/themes/argon',
zoomify: true,
},
backgroundDarkBrightness: 0.65,
backgroundDarkImage: 'https://s3.kwitsukasa.top/images/bg-冬滚滚.png',
backgroundDarkOpacity: 1,
backgroundImage: 'https://s3.kwitsukasa.top/images/bg-冬滚滚.png',
backgroundOpacity: 1,
bodyClass: ['home', 'blog', 'wp-theme-argon'],
darkmodeAutoSwitch: 'alwayson',
enableCustomThemeColor: true,
htmlClass: 'no-js triple-column immersion-color toolbar-blur use-serif use-big-shadow article-header-style-default',
headerMenu: [
{
href: 'https://blog.kwitsukasa.top',
label: '首页',
},
],
sidebarMenu: [
{
external: true,
href: 'https://blog.kwitsukasa.top',
icon: 'fa-home',
label: '首页',
},
{
external: true,
href: 'http://blog.kwitsukasa.top/wp-admin/',
icon: 'fa-user',
label: '管理',
},
],
site: {
authorAvatar: 'http://s3.kwitsukasa.top/images/avatar-tsukasa-1.jpg',
authorName: 'KwiTsukasa',
description: '欢迎来到 KwiTsukasa 的小站',
home: 'https://blog.kwitsukasa.top',
title: 'KwiTsukasa的小站',
url: 'https://blog.kwitsukasa.top',
},
themeCardRadius: '4',
themeColor: '#c3a1ed',
themeColorRgb: '195,161,237',
themeVersion: '1.3.5',
});
await nextTick();
expect(preferences.colorPrimary).toBe('#C3A1ED');
expect(preferences.font).toBe('serif');
expect(preferences.mode).toBe('dark');
expect(preferences.radius).toBe(4);
expect(preferences.shadow).toBe('big');
expect(siteConfig.value).toMatchObject({
authorAvatar: 'http://s3.kwitsukasa.top/images/avatar-tsukasa-1.jpg',
authorName: 'KwiTsukasa',
description: '欢迎来到 KwiTsukasa 的小站',
headerMenu: [
{
external: false,
href: '/',
label: '首页',
},
],
home: 'https://blog.kwitsukasa.top',
sidebarMenu: [
{
external: false,
href: '/',
icon: 'fa-home',
label: '首页',
},
{
external: true,
href: 'http://blog.kwitsukasa.top/wp-admin/',
icon: 'fa-user',
label: '管理',
},
],
title: 'KwiTsukasa的小站',
url: 'https://blog.kwitsukasa.top',
});
expect(wordpressThemeConfig.value?.argonConfig?.codeHighlight?.enable).toBe(true);
expect(wordpressThemeConfig.value?.argonConfig?.foldLongComments).toBe(true);
expect(wordpressThemeConfig.value?.argonConfig?.lazyload?.threshold).toBe(800);
expect(wordpressThemeConfig.value?.bodyClass).toEqual(['home', 'blog', 'wp-theme-argon']);
expect(themeRootClass.value).toContain('kt-blog--triple-column');
expect(themeRootClass.value).toContain('kt-blog--immersion-color');
expect(themeRootClass.value).toContain('kt-blog--toolbar-blur');
expect(themeRootClass.value).toContain('kt-blog--article-header-default');
expect(themeRootClass.value).toContain('kt-blog--argon-1-3-5');
expect(themeRootClass.value).toContain('kt-blog--font-serif');
expect(themeRootClass.value).toContain('kt-blog--shadow-big');
expect(document.querySelector('#kt-blog-theme-style')?.textContent).toContain('--themecolor: #C3A1ED;');
expect(document.querySelector('#kt-blog-theme-style')?.textContent).toContain("url('https://s3.kwitsukasa.top/images/bg-冬滚滚.png')");
expect(document.querySelector('#kt-blog-theme-style')?.textContent).toContain('--argon-background-dark-image: url(\'https://s3.kwitsukasa.top/images/bg-冬滚滚.png\');');
expect(document.querySelector('#kt-blog-theme-style')?.textContent).toContain('--argon-background-filter: brightness(0.65);');
expect(document.querySelector('#kt-blog-theme-style')?.textContent).toContain('--argon-background-opacity: 1;');
expect(document.querySelector('#kt-blog-theme-style')?.textContent).toContain('--argon-background-dark-opacity: 1;');
expect(document.querySelector('#kt-blog-theme-style')?.textContent).toContain("url('http://s3.kwitsukasa.top/images/avatar-tsukasa-1.jpg')");
});
it('mirrors remote layout class switches instead of keeping stale defaults', async () => {
const { applyWordpressThemeConfig, themeRootClass, wordpressThemeConfig } = useBlogTheme();
applyWordpressThemeConfig({
darkmodeAutoSwitch: 'alwaysoff',
enableCustomThemeColor: false,
htmlClass: 'article-header-style-full',
site: {
title: '无三栏布局',
},
themeColor: '#111111',
});
await nextTick();
expect(themeRootClass.value).not.toContain('kt-blog--triple-column');
expect(themeRootClass.value).not.toContain('kt-blog--immersion-color');
expect(themeRootClass.value).not.toContain('kt-blog--toolbar-blur');
expect(themeRootClass.value).not.toContain('kt-blog--font-serif');
expect(themeRootClass.value).not.toContain('kt-blog--shadow-big');
expect(themeRootClass.value).toContain('kt-blog--article-header-full');
expect(themeRootClass.value).toContain('kt-blog--light');
expect(wordpressThemeConfig.value?.site?.title).toBe('无三栏布局');
});
it('mirrors empty remote menus instead of keeping default menu items', async () => {
const { applyWordpressThemeConfig, siteConfig } = useBlogTheme();
applyWordpressThemeConfig({
headerMenu: [],
sidebarMenu: [],
site: {
title: '远端空菜单',
},
});
await nextTick();
expect(siteConfig.value.headerMenu).toEqual([]);
expect(siteConfig.value.sidebarMenu).toEqual([]);
expect(siteConfig.value.title).toBe('远端空菜单');
});
it('mirrors WordPress darkmodeAutoSwitch false, system and time modes', async () => {
const { applyWordpressThemeConfig, preferences } = useBlogTheme();
applyWordpressThemeConfig({
darkmodeAutoSwitch: 'false',
});
await nextTick();
expect(preferences.mode).toBe('light');
let systemChangeHandler:
| ((event: { matches: boolean }) => void)
| undefined;
const addEventListener = vi.fn(
(_eventName: string, handler: (event: { matches: boolean }) => void) => {
systemChangeHandler = handler;
},
);
const removeEventListener = vi.fn();
vi.stubGlobal(
'matchMedia',
vi.fn().mockImplementation((query: string) => ({
addEventListener,
addListener: vi.fn(),
dispatchEvent: vi.fn(),
matches: query === '(prefers-color-scheme: dark)',
media: query,
onchange: null,
removeEventListener,
removeListener: vi.fn(),
})),
);
applyWordpressThemeConfig({
darkmodeAutoSwitch: 'system',
});
await nextTick();
expect(preferences.mode).toBe('dark');
expect(addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
systemChangeHandler?.({ matches: false });
await nextTick();
expect(preferences.mode).toBe('light');
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-05T14:00:00+08:00'));
applyWordpressThemeConfig({
darkmodeAutoSwitch: 'time',
});
await nextTick();
expect(removeEventListener).toHaveBeenCalledWith('change', expect.any(Function));
expect(preferences.mode).toBe('light');
vi.setSystemTime(new Date('2026-06-05T22:30:00+08:00'));
applyWordpressThemeConfig({
darkmodeAutoSwitch: 'time',
});
await nextTick();
expect(preferences.mode).toBe('dark');
});
});

86
src/api/blogArticles.ts Normal file
View File

@ -0,0 +1,86 @@
type VbenResponse<T> = {
code?: number;
data?: T;
msg?: string;
};
export type WordpressResolvedTerm = {
count?: number;
id?: number;
name?: string;
slug?: string;
};
export type WordpressPublicArticle = {
authorName?: string;
categoriesResolved?: WordpressResolvedTerm[];
comment_status?: string;
content?: string | { raw?: string; rendered?: string };
contentHtml?: string;
cover?: string;
date?: string;
excerpt?: string | { rendered?: string };
excerptText?: string;
id: number;
link?: string;
modified?: string;
slug: string;
tagsResolved?: WordpressResolvedTerm[];
title?: string | { raw?: string; rendered?: string };
};
export type WordpressPublicArticleList = {
list: WordpressPublicArticle[];
total: number;
};
export type BlogArticleListParams = {
pageNo?: number;
pageSize?: number;
search?: string;
};
export async function fetchBlogArticleList(params: BlogArticleListParams = {}) {
return requestWordpress<WordpressPublicArticleList>(
import.meta.env.VITE_BLOG_ARTICLE_LIST_URL ||
'/api/blog/article/public/list',
{
pageNo: params.pageNo ?? 1,
pageSize: params.pageSize ?? 50,
search: params.search,
},
);
}
export async function fetchBlogArticleDetail(slug: string) {
return requestWordpress<WordpressPublicArticle>(
import.meta.env.VITE_BLOG_ARTICLE_DETAIL_URL ||
'/api/blog/article/public/detail',
{
slug,
},
);
}
async function requestWordpress<T>(url: string, params: Record<string, unknown>) {
const target = new URL(url, window.location.origin);
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return;
target.searchParams.set(key, `${value}`);
});
const response = await fetch(target.toString());
if (!response.ok) {
throw new Error(`博客文章接口请求失败:${response.status}`);
}
const payload = (await response.json()) as VbenResponse<T> | T;
const data = 'data' in (payload as VbenResponse<T>) ? (payload as VbenResponse<T>).data : payload;
if (!data) {
throw new Error('博客文章接口没有返回数据');
}
return data as T;
}

View File

@ -8,7 +8,8 @@ import {
import { defineComponent, type PropType } from 'vue'; import { defineComponent, type PropType } from 'vue';
import { RouterLink } from 'vue-router'; import { RouterLink } from 'vue-router';
import { getTagSlugByLabel, type BlogArticle } from '@/data/blog'; import type { BlogArticle } from '@/data/blog';
import { useBlogArticles } from '@/hooks/useBlogArticles';
export default defineComponent({ export default defineComponent({
name: 'ArticleCard', name: 'ArticleCard',
@ -19,6 +20,8 @@ export default defineComponent({
}, },
}, },
setup(props) { setup(props) {
const { getTagSlugByLabel } = useBlogArticles();
return () => ( return () => (
<article class="kt-blog__post kt-blog__post--preview kt-blog__card"> <article class="kt-blog__post kt-blog__post--preview kt-blog__card">
<header class="kt-blog__post-header kt-blog__post-header--center"> <header class="kt-blog__post-header kt-blog__post-header--center">

View File

@ -3,17 +3,10 @@ import { defineComponent, nextTick, type ComponentPublicInstance, type PropType,
import { RouterLink, useRouter } from 'vue-router'; import { RouterLink, useRouter } from 'vue-router';
import { useBlogEventBus } from '@/hooks/useBlogEventBus'; import { useBlogEventBus } from '@/hooks/useBlogEventBus';
import { useBlogTheme } from '@/hooks/useBlogTheme';
import { BlogButton, BlogInput } from './antdvComponents'; 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({ export default defineComponent({
name: 'BlogHeader', name: 'BlogHeader',
props: { props: {
@ -25,6 +18,7 @@ export default defineComponent({
setup(props) { setup(props) {
const router = useRouter(); const router = useRouter();
const eventBus = useBlogEventBus(); const eventBus = useBlogEventBus();
const { siteConfig } = useBlogTheme();
const keyword = ref(''); const keyword = ref('');
const navSearchOpen = ref(false); const navSearchOpen = ref(false);
const navSearchInputRef = ref<any>(null); const navSearchInputRef = ref<any>(null);
@ -71,7 +65,7 @@ export default defineComponent({
<div class="kt-blog__header-brand"> <div class="kt-blog__header-brand">
<RouterLink class="kt-blog__header-title" to="/"> <RouterLink class="kt-blog__header-title" to="/">
KwiTsukasa的小站 {siteConfig.value.title}
</RouterLink> </RouterLink>
</div> </div>
@ -94,11 +88,22 @@ export default defineComponent({
</div> </div>
<ul class="kt-blog__header-nav kt-blog__header-nav--hover"> <ul class="kt-blog__header-nav kt-blog__header-nav--hover">
{navItems.map((item) => ( {siteConfig.value.headerMenu.map((item) => (
<li key={item.label} class="kt-blog__header-nav-item"> <li key={item.label} class="kt-blog__header-nav-item">
<RouterLink class="kt-blog__header-nav-link" to={item.to}> {item.external ? (
{item.label} <a
</RouterLink> class="kt-blog__header-nav-link"
href={item.href}
rel="noopener noreferrer"
target="_blank"
>
{item.label}
</a>
) : (
<RouterLink class="kt-blog__header-nav-link" to={item.href}>
{item.label}
</RouterLink>
)}
</li> </li>
))} ))}
</ul> </ul>

View File

@ -1,9 +1,10 @@
import { onBeforeUnmount, onMounted, type PropType } from 'vue'; import { onBeforeUnmount, onMounted, type PropType, watch } from 'vue';
import { defineComponent, ref } from 'vue'; import { defineComponent, ref } from 'vue';
import { articles, categories, tags } from '@/data/blog';
import { type BlogTaxonomyModal, useBlogEventBus } from '@/hooks/useBlogEventBus'; import { type BlogTaxonomyModal, useBlogEventBus } from '@/hooks/useBlogEventBus';
import { useBlogArticles } from '@/hooks/useBlogArticles';
import { useArgonEffects } from '@/hooks/useArgonEffects'; import { useArgonEffects } from '@/hooks/useArgonEffects';
import { useBlogTheme } from '@/hooks/useBlogTheme';
import BlogFloatActions from './BlogFloatActions'; import BlogFloatActions from './BlogFloatActions';
import BlogHeader from './BlogHeader'; import BlogHeader from './BlogHeader';
@ -46,6 +47,8 @@ export default defineComponent({
const leftbarPart1Ref = ref<HTMLElement | null>(null); const leftbarPart1Ref = ref<HTMLElement | null>(null);
const leftbarPart2Ref = ref<HTMLElement | null>(null); const leftbarPart2Ref = ref<HTMLElement | null>(null);
const eventBus = useBlogEventBus(); const eventBus = useBlogEventBus();
const { articles, categories, tags } = useBlogArticles();
const { siteConfig } = useBlogTheme();
useArgonEffects({ useArgonEffects({
bannerContainerRef, bannerContainerRef,
@ -69,9 +72,16 @@ export default defineComponent({
onMounted(() => { onMounted(() => {
eventBus.on('blog:search:open', openSearchModal); eventBus.on('blog:search:open', openSearchModal);
eventBus.on('blog:taxonomy:open', openTaxonomyModal); eventBus.on('blog:taxonomy:open', openTaxonomyModal);
document.title = 'KwiTsukasa的小站';
}); });
watch(
() => siteConfig.value.title,
(title) => {
document.title = title;
},
{ immediate: true },
);
onBeforeUnmount(() => { onBeforeUnmount(() => {
eventBus.off('blog:search:open', openSearchModal); eventBus.off('blog:search:open', openSearchModal);
eventBus.off('blog:taxonomy:open', openTaxonomyModal); eventBus.off('blog:taxonomy:open', openTaxonomyModal);
@ -110,21 +120,21 @@ export default defineComponent({
<div class="kt-blog__sidebar-mask" /> <div class="kt-blog__sidebar-mask" />
<BlogSidebar <BlogSidebar
categories={categories} categories={categories.value}
tags={tags} tags={tags.value}
articles={articles} articles={articles.value}
part1Ref={leftbarPart1Ref} part1Ref={leftbarPart1Ref}
part2Ref={leftbarPart2Ref} part2Ref={leftbarPart2Ref}
/> />
<BlogTaxonomyModals <BlogTaxonomyModals
active={activeModal.value} active={activeModal.value}
categories={categories} categories={categories.value}
tags={tags} tags={tags.value}
onClose={() => { onClose={() => {
activeModal.value = null; activeModal.value = null;
}} }}
/> />
<BlogRightbar articles={articles} categories={categories} /> <BlogRightbar articles={articles.value} categories={categories.value} />
<div class="kt-blog__primary"> <div class="kt-blog__primary">
<main class={['kt-blog__main', props.mainClass]} role="main"> <main class={['kt-blog__main', props.mainClass]} role="main">

View File

@ -4,14 +4,10 @@ import { RouterLink, useRouter } from 'vue-router';
import type { BlogArticle, BlogCategory, BlogTag } from '@/data/blog'; import type { BlogArticle, BlogCategory, BlogTag } from '@/data/blog';
import { useBlogEventBus } from '@/hooks/useBlogEventBus'; import { useBlogEventBus } from '@/hooks/useBlogEventBus';
import { useBlogTheme } from '@/hooks/useBlogTheme';
import { BlogButton, BlogInput } from './antdvComponents'; import { BlogButton, BlogInput } from './antdvComponents';
const menuItems = [
{ label: '首页', to: '/', icon: 'fa-home' },
{ label: '管理', to: '/category/node', icon: 'fa-user' },
];
export default defineComponent({ export default defineComponent({
name: 'BlogSidebar', name: 'BlogSidebar',
props: { props: {
@ -39,6 +35,7 @@ export default defineComponent({
setup(props) { setup(props) {
const router = useRouter(); const router = useRouter();
const eventBus = useBlogEventBus(); const eventBus = useBlogEventBus();
const { siteConfig } = useBlogTheme();
const keyword = ref(''); const keyword = ref('');
const leftbarSearchOpen = ref(false); const leftbarSearchOpen = ref(false);
const leftbarSearchInputRef = ref<any>(null); const leftbarSearchInputRef = ref<any>(null);
@ -67,16 +64,23 @@ export default defineComponent({
<aside class="kt-blog__sidebar" role="complementary"> <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 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"> <div class="kt-blog__sidebar-banner kt-blog__card-body">
<span class="kt-blog__sidebar-banner-title">KwiTsukasa的小站</span> <span class="kt-blog__sidebar-banner-title">{siteConfig.value.title}</span>
</div> </div>
<ul class="kt-blog__sidebar-menu"> <ul class="kt-blog__sidebar-menu">
{menuItems.map((item, index) => ( {siteConfig.value.sidebarMenu.map((item, index) => (
<li key={item.label} class={['kt-blog__sidebar-menu-item', index === 0 && 'kt-blog__sidebar-menu-item--current']}> <li key={item.label} class={['kt-blog__sidebar-menu-item', index === 0 && 'kt-blog__sidebar-menu-item--current']}>
<RouterLink to={item.to}> {item.external ? (
<a href={item.href} rel="noopener noreferrer" target="_blank">
<i class="kt-blog__sidebar-menu-icon" data-icon={item.icon || 'fa-circle'} />
{item.label}
</a>
) : (
<RouterLink to={item.href}>
<i class="kt-blog__sidebar-menu-icon" data-icon={item.icon} /> <i class="kt-blog__sidebar-menu-icon" data-icon={item.icon} />
{item.label} {item.label}
</RouterLink> </RouterLink>
)}
</li> </li>
))} ))}
</ul> </ul>
@ -118,7 +122,7 @@ export default defineComponent({
<div class="kt-blog__sidebar-author-image"> <div class="kt-blog__sidebar-author-image">
<div class="kt-blog__sidebar-author-avatar" /> <div class="kt-blog__sidebar-author-avatar" />
</div> </div>
<h6 class="kt-blog__sidebar-author-name">KwiTsukasa</h6> <h6 class="kt-blog__sidebar-author-name">{siteConfig.value.authorName}</h6>
<nav class="kt-blog__site-stats"> <nav class="kt-blog__site-stats">
<div class="kt-blog__site-stats-item kt-blog__site-stats-item--posts"> <div class="kt-blog__site-stats-item kt-blog__site-stats-item--posts">
<RouterLink to="/archives"> <RouterLink to="/archives">

View File

@ -14,6 +14,7 @@ export interface BlogArticle {
comments: number; comments: number;
words: number; words: number;
content: string[]; content: string[];
contentHtml?: string;
} }
export interface BlogCategory { export interface BlogCategory {
@ -93,7 +94,7 @@ export const articles: BlogArticle[] = [
content: [ content: [
'本方案面向小型私有化部署场景,目标是在飞牛 NAS 上把 Docker、Jenkins、前端静态发布和后端 API 容器发布整合成一套可持续迭代的标准流程。', '本方案面向小型私有化部署场景,目标是在飞牛 NAS 上把 Docker、Jenkins、前端静态发布和后端 API 容器发布整合成一套可持续迭代的标准流程。',
'飞牛 NAS 作为内网计算与数据承载节点Jenkins 负责编排流水线k3d/K8s 承载后端服务Nginx 承载前端静态站点与反向代理。', '飞牛 NAS 作为内网计算与数据承载节点Jenkins 负责编排流水线k3d/K8s 承载后端服务Nginx 承载前端静态站点与反向代理。',
'后续 kt-blog-web 对接 WordPress 后,这类文章会直接来自 WordPress 文章接口,前台只负责还原 Argon 的展示结构。', '后续 kt-blog-web 对接本地 Markdown 博客接口后,这类文章会直接来自本地文章接口,前台只负责还原 Argon 的展示结构。',
], ],
}, },
{ {

View File

@ -0,0 +1,295 @@
import { computed, getCurrentInstance, onMounted, ref } from 'vue';
import {
fetchBlogArticleDetail,
fetchBlogArticleList,
type WordpressPublicArticle,
type WordpressResolvedTerm,
} from '@/api/blogArticles';
import {
articles as fallbackArticles,
categories as fallbackCategories,
tags as fallbackTags,
type BlogArticle,
type BlogCategory,
type BlogTag,
} from '@/data/blog';
const coverPool = [
'/argon/theme/img-2-1200x1000.jpg',
'/argon/theme/img-1-1200x1000.jpg',
'/argon/theme/landing.jpg',
'/argon/theme/promo-1.png',
];
const defaultCover = coverPool[0] || '/argon/theme/img-2-1200x1000.jpg';
const colorPool = ['blue', 'purple', 'green', 'orange', 'geekblue', 'cyan', 'volcano', 'magenta'];
const blogArticles = ref<BlogArticle[]>(fallbackArticles);
const loading = ref(false);
const loadedFromApi = ref(false);
let loadPromise: Promise<void> | null = null;
export function useBlogArticles() {
const articles = computed(() => blogArticles.value);
const categories = computed(() => buildCategories(blogArticles.value));
const tags = computed(() => buildTags(blogArticles.value));
if (getCurrentInstance()) {
onMounted(() => {
void loadArticles();
});
}
const getArticleBySlug = (slug: string) => {
const normalizedSlug = decodeSlug(slug);
return blogArticles.value.find((article) => article.slug === normalizedSlug);
};
const getCategoryBySlug = (slug: string) => {
const normalizedSlug = decodeSlug(slug);
return categories.value.find((category) => category.slug === normalizedSlug);
};
const getTagBySlug = (slug: string) => {
const normalizedSlug = decodeSlug(slug);
return tags.value.find((tag) => tag.slug === normalizedSlug);
};
const getTagSlugByLabel = (label: string) =>
tags.value.find((tag) => tag.label === label)?.slug || toSlug(label);
const getArticlesByCategory = (slug: string) => {
const normalizedSlug = decodeSlug(slug);
return blogArticles.value.filter((article) => article.categorySlug === normalizedSlug);
};
const getArticlesByTag = (slug: string) => {
const tag = getTagBySlug(slug);
if (!tag) return [];
return blogArticles.value.filter((article) => article.tags.includes(tag.label));
};
const searchArticles = (keyword: string) => {
const normalizedKeyword = keyword.trim().toLowerCase();
if (!normalizedKeyword) return blogArticles.value;
return blogArticles.value.filter((article) => {
const haystack = [
article.title,
article.excerpt,
article.category,
...article.tags,
...article.content,
stripHtml(article.contentHtml),
]
.join(' ')
.toLowerCase();
return haystack.includes(normalizedKeyword);
});
};
const getRelatedArticles = (source: BlogArticle) =>
blogArticles.value
.filter(
(article) =>
article.id !== source.id &&
(article.categorySlug === source.categorySlug ||
article.tags.some((tag) => source.tags.includes(tag))),
)
.slice(0, 3);
return {
articles,
categories,
getArticleBySlug,
getArticlesByCategory,
getArticlesByTag,
getCategoryBySlug,
getRelatedArticles,
getTagBySlug,
getTagSlugByLabel,
loadArticle,
loadArticles,
loading,
searchArticles,
tags,
loadedFromApi,
};
}
async function loadArticles() {
if (loadedFromApi.value) return;
if (loadPromise) return loadPromise;
loading.value = true;
loadPromise = fetchBlogArticleList()
.then((result) => {
const nextArticles = result.list.map(normalizeWordpressArticle);
blogArticles.value = nextArticles;
loadedFromApi.value = true;
})
.catch(() => {
blogArticles.value = fallbackArticles;
})
.finally(() => {
loading.value = false;
loadPromise = null;
});
return loadPromise;
}
async function loadArticle(slug: string) {
const normalizedSlug = decodeSlug(slug);
await loadArticles();
const cachedArticle = blogArticles.value.find(
(article) => article.slug === normalizedSlug,
);
if (cachedArticle?.contentHtml) return cachedArticle;
try {
const article = normalizeWordpressArticle(
await fetchBlogArticleDetail(normalizedSlug),
);
const currentIndex = blogArticles.value.findIndex((item) => item.slug === article.slug);
if (currentIndex >= 0) {
blogArticles.value = blogArticles.value.map((item, index) =>
index === currentIndex ? article : item,
);
} else {
blogArticles.value = [article, ...blogArticles.value];
}
return article;
} catch {
return (
cachedArticle ||
fallbackArticles.find((article) => article.slug === normalizedSlug)
);
}
}
function normalizeWordpressArticle(article: WordpressPublicArticle): BlogArticle {
const category = getFirstTerm(article.categoriesResolved, {
name: '未分类',
slug: 'uncategorized',
});
const contentHtml =
article.contentHtml ||
(typeof article.content === 'object' ? article.content.rendered || article.content.raw : article.content) ||
'';
const contentText = stripHtml(contentHtml);
const excerpt =
article.excerptText ||
stripHtml(typeof article.excerpt === 'object' ? article.excerpt.rendered : article.excerpt) ||
contentText.slice(0, 120);
const words = Math.max(contentText.replace(/\s+/g, '').length, excerpt.length);
const tags = (article.tagsResolved || [])
.map((tag) => decodeHtml(tag.name || ''))
.filter(Boolean);
return {
author: article.authorName || 'KwiTsukasa',
category: decodeHtml(category.name || '未分类'),
categorySlug: decodeSlug(category.slug || 'uncategorized'),
comments: article.comment_status === 'open' ? 0 : 0,
content: contentText ? [contentText] : [],
contentHtml,
cover: article.cover || coverPool[article.id % coverPool.length] || defaultCover,
date: formatDate(article.date || article.modified),
excerpt,
id: article.id,
readTime: `${Math.max(1, Math.ceil(words / 500))} 分钟`,
slug: decodeSlug(article.slug),
tags,
title: getRenderedText(article.title) || '未命名文章',
views: 0,
words,
};
}
function buildCategories(articles: BlogArticle[]): BlogCategory[] {
const fallbackMap = new Map(fallbackCategories.map((category) => [category.slug, category]));
const groups = new Map<string, BlogCategory>();
articles.forEach((article) => {
const existing = groups.get(article.categorySlug);
const fallback = fallbackMap.get(article.categorySlug);
groups.set(article.categorySlug, {
slug: article.categorySlug,
label: article.category,
description: fallback?.description || `${article.category} 分类下的文章。`,
color: fallback?.color || colorPool[groups.size % colorPool.length] || 'blue',
count: (existing?.count || 0) + 1,
});
});
return Array.from(groups.values());
}
function buildTags(articles: BlogArticle[]): BlogTag[] {
const fallbackMap = new Map(fallbackTags.map((tag) => [tag.label, tag]));
const groups = new Map<string, BlogTag>();
articles.forEach((article) => {
article.tags.forEach((label) => {
const existing = groups.get(label);
const fallback = fallbackMap.get(label);
groups.set(label, {
slug: fallback?.slug || toSlug(label),
label,
color: fallback?.color || colorPool[groups.size % colorPool.length] || 'blue',
count: (existing?.count || 0) + 1,
});
});
});
return Array.from(groups.values());
}
function getFirstTerm(terms: WordpressResolvedTerm[] | undefined, fallback: WordpressResolvedTerm) {
return terms?.[0] || fallback;
}
function getRenderedText(value: WordpressPublicArticle['title']) {
if (typeof value === 'string') return decodeHtml(value);
return stripHtml(value?.raw || value?.rendered);
}
function formatDate(value?: string) {
if (!value) return '';
return value.replace('T', ' ').slice(0, 16);
}
function stripHtml(value?: unknown) {
return decodeHtml(`${value ?? ''}`)
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function decodeHtml(value: string) {
return value
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'")
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
}
function decodeSlug(value: string) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function toSlug(value: string) {
return value.trim().toLowerCase().replace(/\s+/g, '-');
}

View File

@ -1,12 +1,12 @@
import { theme } from 'antdv-next'; import { theme } from 'antdv-next';
import { computed, reactive, watch } from 'vue'; import { computed, reactive, watch } from 'vue';
type BlogThemeMode = 'dark' | 'light'; export type BlogThemeMode = 'dark' | 'light';
type BlogFontMode = 'sans' | 'serif'; export type BlogFontMode = 'sans' | 'serif';
type BlogShadowMode = 'small' | 'big'; export type BlogShadowMode = 'small' | 'big';
type BlogFilterMode = 'off' | 'sunset' | 'darkness' | 'grayscale'; export type BlogFilterMode = 'off' | 'sunset' | 'darkness' | 'grayscale';
interface BlogThemePreferences { export interface BlogThemePreferences {
colorPrimary: string; colorPrimary: string;
filter: BlogFilterMode; filter: BlogFilterMode;
font: BlogFontMode; font: BlogFontMode;
@ -15,6 +15,87 @@ interface BlogThemePreferences {
shadow: BlogShadowMode; shadow: BlogShadowMode;
} }
export interface BlogThemeMenuItem {
external?: boolean;
href: string;
icon?: string;
label: string;
}
export interface WordpressArgonThemeConfig {
argonConfig?: {
codeHighlight?: {
breakLine?: boolean;
enable?: boolean;
hideLinenumber?: boolean;
transparentLinenumber?: boolean;
};
dateFormat?: string;
disablePjax?: boolean;
foldLongComments?: boolean;
foldLongShuoshuo?: boolean;
headroom?: boolean | string;
language?: string;
lazyload?: {
effect?: string;
threshold?: number;
};
pangu?: string;
pjaxAnimationDuration?: number;
waterflowColumns?: number | string;
wpPath?: string;
zoomify?: boolean;
};
backgroundDarkBrightness?: number | string;
backgroundDarkImage?: string;
backgroundDarkOpacity?: number | string;
backgroundImage?: string;
backgroundOpacity?: number | string;
bodyClass?: string | string[];
darkmodeAutoSwitch?: 'alwaysoff' | 'alwayson' | 'system' | 'time' | string;
enableCustomThemeColor?: boolean;
headerMenu?: BlogThemeMenuItem[];
htmlClass?: string | string[];
site?: {
authorAvatar?: string;
authorName?: string;
description?: string;
home?: string;
title?: string;
url?: string;
};
sidebarMenu?: BlogThemeMenuItem[];
themeCardRadius?: number | string;
themeColor?: string;
themeColorRgb?: string;
themeVersion?: string;
[key: string]: unknown;
}
interface BlogRuntimeThemeConfig {
articleHeaderStyle: 'default' | string;
backgroundDarkBrightness: number;
backgroundDarkImage: string;
backgroundDarkOpacity: number;
backgroundImage: string;
backgroundOpacity: number;
bodyClass: string[];
headerMenu: BlogThemeMenuItem[];
siteAuthorAvatar: string;
htmlClass: string[];
immersionColor: boolean;
siteAuthorName: string;
siteDescription: string;
siteHome: string;
siteTitle: string;
siteUrl: string;
sidebarMenu: BlogThemeMenuItem[];
themeVersion: string;
toolbarBlur: boolean;
tripleColumn: boolean;
wordpressThemeConfig: WordpressArgonThemeConfig | null;
}
const STORAGE_KEY = 'KT_BLOG_THEME_PREFERENCES'; const STORAGE_KEY = 'KT_BLOG_THEME_PREFERENCES';
const THEME_STYLE_ID = 'kt-blog-theme-style'; const THEME_STYLE_ID = 'kt-blog-theme-style';
const BLOG_THEME_BLOCK_CLASS = 'kt-blog'; const BLOG_THEME_BLOCK_CLASS = 'kt-blog';
@ -24,6 +105,19 @@ const ARGON_SERIF_FONT_FAMILY = 'Georgia, "Times New Roman", "Noto Serif SC", se
const ARGON_DEFAULT_COLOR_PRIMARY = '#6f5f89'; const ARGON_DEFAULT_COLOR_PRIMARY = '#6f5f89';
const ARGON_PRIMARY_SOFT = '#4a4058'; const ARGON_PRIMARY_SOFT = '#4a4058';
const ARGON_CARD_SHADOW = '0 2px 4px rgba(0, 0, 0, 0.075)'; const ARGON_CARD_SHADOW = '0 2px 4px rgba(0, 0, 0, 0.075)';
const ARGON_DEFAULT_BACKGROUND = '/argon/theme/img-2-1200x1000.jpg';
const ARGON_DEFAULT_AUTHOR_AVATAR = '/argon/theme/profile.jpg';
const defaultHeaderMenu: BlogThemeMenuItem[] = [
{ href: '/', label: '首页' },
{ href: '/archives', label: '归档' },
{ href: '/category/nas', label: 'NAS' },
{ href: '/category/vue', label: 'Vue' },
{ href: '/category/node', label: 'Node' },
];
const defaultSidebarMenu: BlogThemeMenuItem[] = [
{ href: '/', icon: 'fa-home', label: '首页' },
{ href: '/category/node', icon: 'fa-user', label: '管理' },
];
const defaultPreferences: BlogThemePreferences = { const defaultPreferences: BlogThemePreferences = {
colorPrimary: ARGON_DEFAULT_COLOR_PRIMARY, colorPrimary: ARGON_DEFAULT_COLOR_PRIMARY,
@ -35,17 +129,62 @@ const defaultPreferences: BlogThemePreferences = {
}; };
const preferences = reactive<BlogThemePreferences>(loadPreferences()); const preferences = reactive<BlogThemePreferences>(loadPreferences());
const runtimeConfig = reactive<BlogRuntimeThemeConfig>({
articleHeaderStyle: 'default',
backgroundDarkBrightness: 0.65,
backgroundDarkImage: ARGON_DEFAULT_BACKGROUND,
backgroundDarkOpacity: 1,
backgroundImage: ARGON_DEFAULT_BACKGROUND,
backgroundOpacity: 1,
bodyClass: ['home', 'blog', 'wp-theme-argon'],
headerMenu: [...defaultHeaderMenu],
htmlClass: [
'triple-column',
'immersion-color',
'toolbar-blur',
'article-header-style-default',
],
immersionColor: true,
siteAuthorAvatar: ARGON_DEFAULT_AUTHOR_AVATAR,
siteAuthorName: 'KwiTsukasa',
siteDescription: '',
siteHome: '',
siteTitle: 'KwiTsukasa的小站',
siteUrl: '',
sidebarMenu: [...defaultSidebarMenu],
themeVersion: '',
toolbarBlur: true,
tripleColumn: true,
wordpressThemeConfig: null,
});
let themeWatcherReady = false; let themeWatcherReady = false;
let systemThemeMediaQuery: MediaQueryList | null = null;
let systemThemeChangeHandler:
| ((event: MediaQueryList | MediaQueryListEvent) => void)
| null = null;
const isDarkTheme = computed(() => preferences.mode === 'dark'); const isDarkTheme = computed(() => preferences.mode === 'dark');
const siteConfig = computed(() => ({
authorAvatar: runtimeConfig.siteAuthorAvatar,
authorName: runtimeConfig.siteAuthorName,
description: runtimeConfig.siteDescription,
headerMenu: runtimeConfig.headerMenu,
home: runtimeConfig.siteHome,
sidebarMenu: runtimeConfig.sidebarMenu,
title: runtimeConfig.siteTitle,
url: runtimeConfig.siteUrl,
}));
const wordpressThemeConfig = computed(() => runtimeConfig.wordpressThemeConfig);
const themeRootClass = computed(() => [ const themeRootClass = computed(() => [
BLOG_THEME_BLOCK_CLASS, BLOG_THEME_BLOCK_CLASS,
`${BLOG_THEME_BLOCK_CLASS}--wp-argon`, `${BLOG_THEME_BLOCK_CLASS}--wp-argon`,
`${BLOG_THEME_BLOCK_CLASS}--home`, `${BLOG_THEME_BLOCK_CLASS}--home`,
`${BLOG_THEME_BLOCK_CLASS}--blog`, `${BLOG_THEME_BLOCK_CLASS}--blog`,
`${BLOG_THEME_BLOCK_CLASS}--triple-column`, runtimeConfig.tripleColumn && `${BLOG_THEME_BLOCK_CLASS}--triple-column`,
`${BLOG_THEME_BLOCK_CLASS}--toolbar-blur`, runtimeConfig.immersionColor && `${BLOG_THEME_BLOCK_CLASS}--immersion-color`,
`${BLOG_THEME_BLOCK_CLASS}--article-header-default`, runtimeConfig.toolbarBlur && `${BLOG_THEME_BLOCK_CLASS}--toolbar-blur`,
`${BLOG_THEME_BLOCK_CLASS}--article-header-${runtimeConfig.articleHeaderStyle}`,
runtimeConfig.themeVersion && `${BLOG_THEME_BLOCK_CLASS}--argon-${runtimeConfig.themeVersion.replace(/\./g, '-')}`,
`${BLOG_THEME_BLOCK_CLASS}--${preferences.mode}`, `${BLOG_THEME_BLOCK_CLASS}--${preferences.mode}`,
preferences.font === 'serif' && `${BLOG_THEME_BLOCK_CLASS}--font-serif`, preferences.font === 'serif' && `${BLOG_THEME_BLOCK_CLASS}--font-serif`,
preferences.shadow === 'big' && `${BLOG_THEME_BLOCK_CLASS}--shadow-big`, preferences.shadow === 'big' && `${BLOG_THEME_BLOCK_CLASS}--shadow-big`,
@ -134,6 +273,87 @@ function setPrimaryColor(nextColor: string) {
preferences.colorPrimary = nextColor; preferences.colorPrimary = nextColor;
} }
function applyWordpressThemeConfig(config: WordpressArgonThemeConfig) {
const nextHtmlClass = normalizeClassList(config.htmlClass);
const nextBodyClass = normalizeClassList(config.bodyClass);
const nextBackgroundDarkBrightness = normalizePositiveNumber(config.backgroundDarkBrightness);
const nextBackgroundDarkOpacity = normalizeOpacity(config.backgroundDarkOpacity);
const nextBackgroundOpacity = normalizeOpacity(config.backgroundOpacity);
const nextColor = normalizeHexColor(config.themeColor);
const nextRadius = normalizeRadius(config.themeCardRadius);
const hasRemoteHtmlClass = nextHtmlClass.length > 0;
runtimeConfig.wordpressThemeConfig = config;
runtimeConfig.htmlClass = nextHtmlClass.length ? nextHtmlClass : runtimeConfig.htmlClass;
runtimeConfig.bodyClass = nextBodyClass.length ? nextBodyClass : runtimeConfig.bodyClass;
if (Array.isArray(config.headerMenu)) {
runtimeConfig.headerMenu = normalizeMenuItems(
config.headerMenu,
config.site?.home || config.site?.url || runtimeConfig.siteHome || runtimeConfig.siteUrl,
);
}
runtimeConfig.tripleColumn = hasRemoteHtmlClass
? nextHtmlClass.includes('triple-column')
: runtimeConfig.tripleColumn;
runtimeConfig.immersionColor = hasRemoteHtmlClass
? nextHtmlClass.includes('immersion-color')
: runtimeConfig.immersionColor;
runtimeConfig.toolbarBlur = hasRemoteHtmlClass
? nextHtmlClass.includes('toolbar-blur')
: runtimeConfig.toolbarBlur;
runtimeConfig.articleHeaderStyle =
getArticleHeaderStyle(nextHtmlClass) ||
(hasRemoteHtmlClass ? 'default' : runtimeConfig.articleHeaderStyle);
runtimeConfig.backgroundDarkBrightness =
typeof nextBackgroundDarkBrightness === 'number'
? nextBackgroundDarkBrightness
: runtimeConfig.backgroundDarkBrightness;
runtimeConfig.backgroundDarkImage = normalizeCssImage(config.backgroundDarkImage) || runtimeConfig.backgroundDarkImage;
runtimeConfig.backgroundDarkOpacity =
typeof nextBackgroundDarkOpacity === 'number'
? nextBackgroundDarkOpacity
: runtimeConfig.backgroundDarkOpacity;
runtimeConfig.backgroundImage = normalizeCssImage(config.backgroundImage) || runtimeConfig.backgroundImage;
runtimeConfig.backgroundOpacity =
typeof nextBackgroundOpacity === 'number'
? nextBackgroundOpacity
: runtimeConfig.backgroundOpacity;
runtimeConfig.siteAuthorAvatar =
normalizeThemeAsset(config.site?.authorAvatar) || runtimeConfig.siteAuthorAvatar;
runtimeConfig.siteDescription = config.site?.description ?? runtimeConfig.siteDescription;
runtimeConfig.siteAuthorName = config.site?.authorName || runtimeConfig.siteAuthorName;
runtimeConfig.siteHome = config.site?.home || runtimeConfig.siteHome;
runtimeConfig.siteTitle = config.site?.title || runtimeConfig.siteTitle;
runtimeConfig.siteUrl = config.site?.url || runtimeConfig.siteUrl;
if (Array.isArray(config.sidebarMenu)) {
runtimeConfig.sidebarMenu = normalizeMenuItems(
config.sidebarMenu,
config.site?.home || config.site?.url || runtimeConfig.siteHome || runtimeConfig.siteUrl,
);
}
runtimeConfig.themeVersion = config.themeVersion || runtimeConfig.themeVersion;
if (nextColor && config.enableCustomThemeColor !== false) {
preferences.colorPrimary = nextColor;
}
if (hasRemoteHtmlClass) {
preferences.font = nextHtmlClass.includes('use-serif') ? 'serif' : 'sans';
preferences.shadow = nextHtmlClass.includes('use-big-shadow') ? 'big' : 'small';
}
if (typeof nextRadius === 'number') {
preferences.radius = nextRadius;
}
syncSystemThemeMode(config);
const nextMode = getModeFromWordpressConfig(config);
if (nextMode) {
preferences.mode = nextMode;
}
}
/** /**
* @returns Blog Web Antdv token * @returns Blog Web Antdv token
*/ */
@ -143,6 +363,8 @@ export function useBlogTheme() {
return { return {
isDarkTheme, isDarkTheme,
preferences, preferences,
applyWordpressThemeConfig,
siteConfig,
setFilterMode, setFilterMode,
setFontMode, setFontMode,
setPrimaryColor, setPrimaryColor,
@ -151,6 +373,7 @@ export function useBlogTheme() {
setThemeMode, setThemeMode,
themeConfig, themeConfig,
themeRootClass, themeRootClass,
wordpressThemeConfig,
}; };
} }
@ -187,8 +410,11 @@ function ensureThemeWatcher() {
themeWatcherReady = true; themeWatcherReady = true;
watch( watch(
() => ({ ...preferences }), () => ({
(currentPreferences) => { preferences: { ...preferences },
runtimeConfig: { ...runtimeConfig },
}),
({ preferences: currentPreferences }) => {
if (typeof document === 'undefined') { if (typeof document === 'undefined') {
return; return;
} }
@ -212,6 +438,16 @@ function applyCssVariables(currentPreferences: BlogThemePreferences) {
const primaryDark = shadeHexColor(currentPreferences.colorPrimary, -18); const primaryDark = shadeHexColor(currentPreferences.colorPrimary, -18);
const primaryDark2 = shadeHexColor(currentPreferences.colorPrimary, -30); const primaryDark2 = shadeHexColor(currentPreferences.colorPrimary, -30);
const fontFamily = getThemeFontFamily(currentPreferences.font); const fontFamily = getThemeFontFamily(currentPreferences.font);
const backgroundImage =
normalizeCssImage(
currentPreferences.mode === 'dark'
? runtimeConfig.backgroundDarkImage || runtimeConfig.backgroundImage
: runtimeConfig.backgroundImage,
) || `url('${ARGON_DEFAULT_BACKGROUND}')`;
const backgroundOpacity =
currentPreferences.mode === 'dark'
? runtimeConfig.backgroundDarkOpacity
: runtimeConfig.backgroundOpacity;
const primaryHsl = rgbToHsl(primaryRgb.r, primaryRgb.g, primaryRgb.b); 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"]')?.setAttribute('content', currentPreferences.colorPrimary);
@ -230,6 +466,14 @@ function applyCssVariables(currentPreferences: BlogThemePreferences) {
.${BLOG_THEME_BLOCK_CLASS} { .${BLOG_THEME_BLOCK_CLASS} {
--radius: ${currentPreferences.radius}px; --radius: ${currentPreferences.radius}px;
--card-radius: ${currentPreferences.radius}px; --card-radius: ${currentPreferences.radius}px;
--argon-background-image: ${backgroundImage};
--argon-background-light-image: ${normalizeCssImage(runtimeConfig.backgroundImage) || `url('${ARGON_DEFAULT_BACKGROUND}')`};
--argon-background-dark-image: ${normalizeCssImage(runtimeConfig.backgroundDarkImage) || backgroundImage};
--argon-background-filter: ${currentPreferences.mode === 'dark' ? `brightness(${runtimeConfig.backgroundDarkBrightness})` : 'none'};
--argon-background-opacity: ${backgroundOpacity};
--argon-background-light-opacity: ${runtimeConfig.backgroundOpacity};
--argon-background-dark-opacity: ${runtimeConfig.backgroundDarkOpacity};
--argon-author-avatar: ${normalizeCssImage(runtimeConfig.siteAuthorAvatar) || `url('${ARGON_DEFAULT_AUTHOR_AVATAR}')`};
--argon-font-family: ${fontFamily}; --argon-font-family: ${fontFamily};
--argon-shadow: ${ARGON_CARD_SHADOW}; --argon-shadow: ${ARGON_CARD_SHADOW};
--argon-primary-soft: ${ARGON_PRIMARY_SOFT}; --argon-primary-soft: ${ARGON_PRIMARY_SOFT};
@ -456,3 +700,186 @@ function isThemeColorTooDark(hexColor: string) {
return getGray(r, g, b) < 50; return getGray(r, g, b) < 50;
} }
function normalizeClassList(value?: string | string[]): string[] {
if (Array.isArray(value)) {
return value.flatMap((item) => normalizeClassList(item));
}
return String(value || '')
.split(/\s+/)
.map((item) => item.trim())
.filter(Boolean);
}
function normalizeHexColor(value?: string) {
if (!value) return '';
const normalized = value.trim();
if (/^#[0-9a-f]{6}$/i.test(normalized)) return normalized.toUpperCase();
if (/^#[0-9a-f]{3}$/i.test(normalized)) {
return `#${normalized
.slice(1)
.split('')
.map((item) => item + item)
.join('')}`.toUpperCase();
}
return '';
}
function normalizeRadius(value?: number | string) {
if (value === undefined || value === null || value === '') return null;
const numericValue = Number(value);
return Number.isFinite(numericValue) ? Math.max(0, numericValue) : null;
}
function normalizeOpacity(value?: number | string) {
if (value === undefined || value === null || value === '') return null;
const numericValue = Number(value);
return Number.isFinite(numericValue)
? Math.min(Math.max(numericValue, 0), 1)
: null;
}
function normalizePositiveNumber(value?: number | string) {
if (value === undefined || value === null || value === '') return null;
const numericValue = Number(value);
return Number.isFinite(numericValue) ? Math.max(0, numericValue) : null;
}
function normalizeCssImage(value?: string) {
if (!value) return '';
const normalized = value.trim();
if (normalized.startsWith('url(')) return normalized;
if (/^https?:\/\//i.test(normalized) || normalized.startsWith('/')) {
return `url('${normalized.replace(/'/g, "\\'")}')`;
}
return '';
}
function normalizeThemeAsset(value?: string) {
if (!value) return '';
const normalized = value.trim();
const cssImage = /^url\((.*)\)$/i.exec(normalized)?.[1]?.trim();
const asset = cssImage
? cssImage.replace(/^['"]|['"]$/g, '')
: normalized;
return /^https?:\/\//i.test(asset) || asset.startsWith('/') ? asset : '';
}
function normalizeMenuItems(items: BlogThemeMenuItem[], siteHome = ''): BlogThemeMenuItem[] {
return items.reduce<BlogThemeMenuItem[]>((result, item) => {
const label = `${item.label || ''}`.trim();
const href = normalizeMenuHref(`${item.href || ''}`.trim(), siteHome);
if (!label || !href) return result;
const isInternalHref = href.startsWith('/') && !href.startsWith('//');
result.push({
external: !isInternalHref && (item.external || /^https?:\/\//i.test(href)),
href,
...(item.icon ? { icon: item.icon } : {}),
label,
});
return result;
}, []);
}
function normalizeMenuHref(href: string, siteHome = '') {
if (!href) return '';
const normalizedSiteHome = siteHome.replace(/\/+$/g, '');
if (normalizedSiteHome && href.replace(/\/+$/g, '') === normalizedSiteHome) {
return '/';
}
if (normalizedSiteHome && href.startsWith(`${normalizedSiteHome}/`)) {
return href.slice(normalizedSiteHome.length) || '/';
}
return href;
}
function getModeFromWordpressConfig(config: WordpressArgonThemeConfig): BlogThemeMode | '' {
const htmlClass = normalizeClassList(config.htmlClass);
const switchMode = `${config.darkmodeAutoSwitch || ''}`.toLowerCase();
if (htmlClass.includes('darkmode')) return 'dark';
if (switchMode === 'alwayson') return 'dark';
if (switchMode === 'alwaysoff' || switchMode === 'false') return 'light';
if (switchMode === 'system') return getSystemThemeMode();
if (switchMode === 'time') return getTimeThemeMode();
return '';
}
function getSystemThemeMode(): BlogThemeMode | '' {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return '';
}
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
function syncSystemThemeMode(config: WordpressArgonThemeConfig) {
stopSystemThemeModeSync();
if (
`${config.darkmodeAutoSwitch || ''}`.toLowerCase() !== 'system' ||
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return;
}
systemThemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
systemThemeChangeHandler = (event) => {
preferences.mode = event.matches ? 'dark' : 'light';
};
if (typeof systemThemeMediaQuery.addEventListener === 'function') {
systemThemeMediaQuery.addEventListener('change', systemThemeChangeHandler);
} else {
systemThemeMediaQuery.addListener(systemThemeChangeHandler);
}
}
function stopSystemThemeModeSync() {
if (!systemThemeMediaQuery || !systemThemeChangeHandler) {
return;
}
if (typeof systemThemeMediaQuery.removeEventListener === 'function') {
systemThemeMediaQuery.removeEventListener(
'change',
systemThemeChangeHandler,
);
} else {
systemThemeMediaQuery.removeListener(systemThemeChangeHandler);
}
systemThemeMediaQuery = null;
systemThemeChangeHandler = null;
}
function getTimeThemeMode(): BlogThemeMode {
const hour = new Date().getHours();
return hour < 7 || hour >= 22 ? 'dark' : 'light';
}
function getArticleHeaderStyle(htmlClass: string[]) {
const prefix = 'article-header-style-';
const className = htmlClass.find((item) => item.startsWith(prefix));
return className?.slice(prefix.length) || '';
}

View File

@ -34,7 +34,10 @@ body {
} }
&::before { &::before {
background: url('/argon/theme/img-2-1200x1000.jpg') center top / cover fixed no-repeat; background: var(--argon-background-image, url('/argon/theme/img-2-1200x1000.jpg')) center top / cover fixed no-repeat;
filter: var(--argon-background-filter, none);
opacity: var(--argon-background-opacity, 1);
transition: opacity 0.5s ease;
} }
&--no-banner::before { &--no-banner::before {

View File

@ -67,6 +67,88 @@
p { p {
margin: 0 0 18px; margin: 0 0 18px;
} }
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 28px 0 14px;
color: var(--argon-title);
line-height: 1.45;
letter-spacing: 0;
}
h1 {
font-size: 26px;
}
h2 {
font-size: 23px;
}
h3 {
font-size: 20px;
}
img {
max-width: 100%;
height: auto;
border-radius: var(--card-radius);
}
blockquote {
margin: 18px 0;
padding: 12px 16px;
border-left: 4px solid var(--themecolor);
color: var(--argon-muted);
background: var(--argon-control-soft);
}
pre {
max-width: 100%;
margin: 18px 0;
padding: 14px 16px;
overflow-x: auto;
border-radius: var(--card-radius);
background: var(--argon-control-soft);
}
code {
font-size: 14px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
pre code {
white-space: pre;
}
table {
display: block;
max-width: 100%;
margin: 18px 0;
overflow-x: auto;
border-collapse: collapse;
white-space: nowrap;
}
th,
td {
padding: 9px 12px;
border: 1px solid var(--argon-border);
}
ul,
ol {
margin: 0 0 18px 20px;
padding: 0;
}
li + li {
margin-top: 6px;
}
} }
&__post-tags { &__post-tags {

View File

@ -127,7 +127,7 @@
height: 92px; height: 92px;
margin: 0 auto; margin: 0 auto;
border-radius: 50%; border-radius: 50%;
background: url('/argon/theme/profile.jpg') center / cover no-repeat; background: var(--argon-author-avatar, url('/argon/theme/profile.jpg')) center / cover no-repeat;
box-shadow: var(--argon-shadow); box-shadow: var(--argon-shadow);
} }

View File

@ -3,14 +3,15 @@ import { computed, defineComponent } from 'vue';
import { RouterLink } from 'vue-router'; import { RouterLink } from 'vue-router';
import BlogLayout from '@/components/blog/BlogLayout'; import BlogLayout from '@/components/blog/BlogLayout';
import { articles } from '@/data/blog'; import { useBlogArticles } from '@/hooks/useBlogArticles';
export default defineComponent({ export default defineComponent({
name: 'BlogArchivePage', name: 'BlogArchivePage',
setup() { setup() {
const { articles } = useBlogArticles();
const groupedArticles = computed(() => { const groupedArticles = computed(() => {
const groups = new Map<string, typeof articles>(); const groups = new Map<string, typeof articles.value>();
articles.forEach((article) => { articles.value.forEach((article) => {
const key = article.date.slice(0, 7); const key = article.date.slice(0, 7);
groups.set(key, [...(groups.get(key) ?? []), article]); groups.set(key, [...(groups.get(key) ?? []), article]);
}); });
@ -23,7 +24,7 @@ export default defineComponent({
mainClass="kt-blog__main--archive" mainClass="kt-blog__main--archive"
pageTitle="归档时间轴" pageTitle="归档时间轴"
pageDescription="按月份回顾 KT 项目沉淀的文章记录。" pageDescription="按月份回顾 KT 项目沉淀的文章记录。"
pageMeta={`${articles.length} 篇文章`} pageMeta={`${articles.value.length} 篇文章`}
> >
<article class="kt-blog__post kt-blog__post--full kt-blog__card"> <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__post-content kt-blog__post-content--full">

View File

@ -2,14 +2,16 @@ import { defineComponent } from 'vue';
import ArticleList from '@/components/blog/ArticleList'; import ArticleList from '@/components/blog/ArticleList';
import BlogLayout from '@/components/blog/BlogLayout'; import BlogLayout from '@/components/blog/BlogLayout';
import { articles } from '@/data/blog'; import { useBlogArticles } from '@/hooks/useBlogArticles';
export default defineComponent({ export default defineComponent({
name: 'BlogHomePage', name: 'BlogHomePage',
setup() { setup() {
const { articles } = useBlogArticles();
return () => ( return () => (
<BlogLayout> <BlogLayout>
<ArticleList articles={articles} /> <ArticleList articles={articles.value} />
</BlogLayout> </BlogLayout>
); );
}, },

View File

@ -1,5 +1,5 @@
import { CalendarOutlined, CommentOutlined, EyeOutlined, ReadOutlined, TagsOutlined } from '@antdv-next/icons'; import { CalendarOutlined, CommentOutlined, EyeOutlined, ReadOutlined, TagsOutlined } from '@antdv-next/icons';
import { computed, defineComponent, onBeforeUnmount, ref, Transition } from 'vue'; import { computed, defineComponent, onBeforeUnmount, ref, Transition, watch } from 'vue';
import { RouterLink, useRoute } from 'vue-router'; import { RouterLink, useRoute } from 'vue-router';
import BlogLayout from '@/components/blog/BlogLayout'; import BlogLayout from '@/components/blog/BlogLayout';
@ -10,7 +10,7 @@ import {
BlogInput, BlogInput,
BlogTextArea, BlogTextArea,
} from '@/components/blog/antdvComponents'; } from '@/components/blog/antdvComponents';
import { articles, getArticleBySlug, getRelatedArticles, getTagSlugByLabel } from '@/data/blog'; import { useBlogArticles } from '@/hooks/useBlogArticles';
import { import {
clearBlogPostRefs, clearBlogPostRefs,
setBlogPostArticleRef, setBlogPostArticleRef,
@ -22,17 +22,25 @@ export default defineComponent({
name: 'BlogPostPage', name: 'BlogPostPage',
setup() { setup() {
const route = useRoute(); const route = useRoute();
const article = computed(() => getArticleBySlug(String(route.params.slug)) ?? articles[0]); const {
const articleIndex = computed(() => articles.findIndex((item) => item.slug === article.value?.slug)); articles,
getArticleBySlug,
getRelatedArticles,
getTagSlugByLabel,
loadArticle,
} = useBlogArticles();
const slug = computed(() => String(route.params.slug ?? ''));
const article = computed(() => getArticleBySlug(slug.value) ?? articles.value[0]);
const articleIndex = computed(() => articles.value.findIndex((item) => item.slug === article.value?.slug));
const previousArticle = computed(() => { const previousArticle = computed(() => {
const index = articleIndex.value; const index = articleIndex.value;
return articles[index > 0 ? index - 1 : articles.length - 1] ?? article.value; return articles.value[index > 0 ? index - 1 : articles.value.length - 1] ?? article.value;
}); });
const nextArticle = computed(() => { const nextArticle = computed(() => {
const index = articleIndex.value; const index = articleIndex.value;
return articles[index >= 0 && index < articles.length - 1 ? index + 1 : 0] ?? article.value; return articles.value[index >= 0 && index < articles.value.length - 1 ? index + 1 : 0] ?? article.value;
}); });
const relatedArticles = computed(() => (article.value ? getRelatedArticles(article.value) : [])); const relatedArticles = computed(() => (article.value ? getRelatedArticles(article.value) : []));
const commentContent = ref(''); const commentContent = ref('');
@ -43,6 +51,14 @@ export default defineComponent({
clearBlogPostRefs(); clearBlogPostRefs();
}); });
watch(
slug,
(value) => {
if (value) void loadArticle(value);
},
{ immediate: true },
);
return () => { return () => {
const currentArticle = article.value; const currentArticle = article.value;
@ -116,11 +132,18 @@ export default defineComponent({
</div> </div>
</header> </header>
<div class="kt-blog__post-content kt-blog__post-content--full"> {currentArticle.contentHtml ? (
{currentArticle.content.map((paragraph) => ( <div
<p key={paragraph}>{paragraph}</p> class="kt-blog__post-content kt-blog__post-content--full"
))} innerHTML={currentArticle.contentHtml}
</div> />
) : (
<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"> <div class="kt-blog__post-tags">
<TagsOutlined /> <TagsOutlined />

View File

@ -6,7 +6,7 @@ import ArticleList from '@/components/blog/ArticleList';
import BlogLayout from '@/components/blog/BlogLayout'; import BlogLayout from '@/components/blog/BlogLayout';
import PageInfoCard from '@/components/blog/PageInfoCard'; import PageInfoCard from '@/components/blog/PageInfoCard';
import { BlogButton, BlogCheckbox, BlogForm, BlogInput } from '@/components/blog/antdvComponents'; import { BlogButton, BlogCheckbox, BlogForm, BlogInput } from '@/components/blog/antdvComponents';
import { searchArticles } from '@/data/blog'; import { useBlogArticles } from '@/hooks/useBlogArticles';
export default defineComponent({ export default defineComponent({
name: 'BlogSearchPage', name: 'BlogSearchPage',
@ -15,6 +15,7 @@ export default defineComponent({
const router = useRouter(); const router = useRouter();
const keyword = ref(String(route.query.q ?? '')); const keyword = ref(String(route.query.q ?? ''));
const filters = ref(['post', 'page']); const filters = ref(['post', 'page']);
const { searchArticles } = useBlogArticles();
const resultArticles = computed(() => searchArticles(keyword.value)); const resultArticles = computed(() => searchArticles(keyword.value));
watch( watch(

View File

@ -3,17 +3,18 @@ import { useRoute } from 'vue-router';
import ArticleList from '@/components/blog/ArticleList'; import ArticleList from '@/components/blog/ArticleList';
import BlogLayout from '@/components/blog/BlogLayout'; import BlogLayout from '@/components/blog/BlogLayout';
import { import { useBlogArticles } from '@/hooks/useBlogArticles';
getArticlesByCategory,
getArticlesByTag,
getCategoryBySlug,
getTagBySlug,
} from '@/data/blog';
export default defineComponent({ export default defineComponent({
name: 'BlogTermPage', name: 'BlogTermPage',
setup() { setup() {
const route = useRoute(); const route = useRoute();
const {
getArticlesByCategory,
getArticlesByTag,
getCategoryBySlug,
getTagBySlug,
} = useBlogArticles();
const mode = computed(() => String(route.meta.termMode ?? 'category')); const mode = computed(() => String(route.meta.termMode ?? 'category'));
const slug = computed(() => String(route.params.slug ?? '')); const slug = computed(() => String(route.params.slug ?? ''));
const category = computed(() => getCategoryBySlug(slug.value)); const category = computed(() => getCategoryBySlug(slug.value));