feat: 承接本地博客主题和文章展示
This commit is contained in:
parent
9224e6d8a0
commit
3c22fc0809
48
src/App.tsx
48
src/App.tsx
@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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');",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
217
src/__tests__/useBlogArticles.spec.ts
Normal file
217
src/__tests__/useBlogArticles.spec.ts
Normal 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>正文 & 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 & 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>正文 & 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;
|
||||||
|
}
|
||||||
249
src/__tests__/useBlogTheme.spec.ts
Normal file
249
src/__tests__/useBlogTheme.spec.ts
Normal 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
86
src/api/blogArticles.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
<a
|
||||||
|
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}
|
{item.label}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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 的展示结构。',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
295
src/hooks/useBlogArticles.ts
Normal file
295
src/hooks/useBlogArticles.ts
Normal 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(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeSlug(value: string) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSlug(value: string) {
|
||||||
|
return value.trim().toLowerCase().replace(/\s+/g, '-');
|
||||||
|
}
|
||||||
@ -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) || '';
|
||||||
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
{currentArticle.contentHtml ? (
|
||||||
|
<div
|
||||||
|
class="kt-blog__post-content kt-blog__post-content--full"
|
||||||
|
innerHTML={currentArticle.contentHtml}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div class="kt-blog__post-content kt-blog__post-content--full">
|
<div class="kt-blog__post-content kt-blog__post-content--full">
|
||||||
{currentArticle.content.map((paragraph) => (
|
{currentArticle.content.map((paragraph) => (
|
||||||
<p key={paragraph}>{paragraph}</p>
|
<p key={paragraph}>{paragraph}</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div class="kt-blog__post-tags">
|
<div class="kt-blog__post-tags">
|
||||||
<TagsOutlined />
|
<TagsOutlined />
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user