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 { defineComponent } from 'vue';
|
||||
import { defineComponent, onMounted } from 'vue';
|
||||
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({
|
||||
name: 'KtBlogApp',
|
||||
setup() {
|
||||
const { themeConfig, themeRootClass } = useBlogTheme();
|
||||
const { applyWordpressThemeConfig, themeConfig, themeRootClass } = useBlogTheme();
|
||||
|
||||
onMounted(() => {
|
||||
applyInitialBlogThemeConfig(applyWordpressThemeConfig);
|
||||
});
|
||||
|
||||
return () => (
|
||||
<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 { describe, expect, it, vi } from 'vitest';
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import App from '../App';
|
||||
import router from '../router';
|
||||
@ -107,6 +108,11 @@ vi.mock('@antdv-next/icons', async () => {
|
||||
});
|
||||
|
||||
describe('App', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('mounts renders properly', async () => {
|
||||
await router.push('/');
|
||||
await router.isReady();
|
||||
@ -119,4 +125,244 @@ describe('App', () => {
|
||||
|
||||
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 { 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({
|
||||
name: 'ArticleCard',
|
||||
@ -19,6 +20,8 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { getTagSlugByLabel } = useBlogArticles();
|
||||
|
||||
return () => (
|
||||
<article class="kt-blog__post kt-blog__post--preview kt-blog__card">
|
||||
<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 { useBlogEventBus } from '@/hooks/useBlogEventBus';
|
||||
import { useBlogTheme } from '@/hooks/useBlogTheme';
|
||||
|
||||
import { BlogButton, BlogInput } from './antdvComponents';
|
||||
|
||||
const navItems = [
|
||||
{ label: '首页', to: '/' },
|
||||
{ label: '归档', to: '/archives' },
|
||||
{ label: 'NAS', to: '/category/nas' },
|
||||
{ label: 'Vue', to: '/category/vue' },
|
||||
{ label: 'Node', to: '/category/node' },
|
||||
];
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BlogHeader',
|
||||
props: {
|
||||
@ -25,6 +18,7 @@ export default defineComponent({
|
||||
setup(props) {
|
||||
const router = useRouter();
|
||||
const eventBus = useBlogEventBus();
|
||||
const { siteConfig } = useBlogTheme();
|
||||
const keyword = ref('');
|
||||
const navSearchOpen = ref(false);
|
||||
const navSearchInputRef = ref<any>(null);
|
||||
@ -71,7 +65,7 @@ export default defineComponent({
|
||||
|
||||
<div class="kt-blog__header-brand">
|
||||
<RouterLink class="kt-blog__header-title" to="/">
|
||||
KwiTsukasa的小站
|
||||
{siteConfig.value.title}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
@ -94,11 +88,22 @@ export default defineComponent({
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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}
|
||||
</RouterLink>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</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 { articles, categories, tags } from '@/data/blog';
|
||||
import { type BlogTaxonomyModal, useBlogEventBus } from '@/hooks/useBlogEventBus';
|
||||
import { useBlogArticles } from '@/hooks/useBlogArticles';
|
||||
import { useArgonEffects } from '@/hooks/useArgonEffects';
|
||||
import { useBlogTheme } from '@/hooks/useBlogTheme';
|
||||
|
||||
import BlogFloatActions from './BlogFloatActions';
|
||||
import BlogHeader from './BlogHeader';
|
||||
@ -46,6 +47,8 @@ export default defineComponent({
|
||||
const leftbarPart1Ref = ref<HTMLElement | null>(null);
|
||||
const leftbarPart2Ref = ref<HTMLElement | null>(null);
|
||||
const eventBus = useBlogEventBus();
|
||||
const { articles, categories, tags } = useBlogArticles();
|
||||
const { siteConfig } = useBlogTheme();
|
||||
|
||||
useArgonEffects({
|
||||
bannerContainerRef,
|
||||
@ -69,9 +72,16 @@ export default defineComponent({
|
||||
onMounted(() => {
|
||||
eventBus.on('blog:search:open', openSearchModal);
|
||||
eventBus.on('blog:taxonomy:open', openTaxonomyModal);
|
||||
document.title = 'KwiTsukasa的小站';
|
||||
});
|
||||
|
||||
watch(
|
||||
() => siteConfig.value.title,
|
||||
(title) => {
|
||||
document.title = title;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
eventBus.off('blog:search:open', openSearchModal);
|
||||
eventBus.off('blog:taxonomy:open', openTaxonomyModal);
|
||||
@ -110,21 +120,21 @@ export default defineComponent({
|
||||
|
||||
<div class="kt-blog__sidebar-mask" />
|
||||
<BlogSidebar
|
||||
categories={categories}
|
||||
tags={tags}
|
||||
articles={articles}
|
||||
categories={categories.value}
|
||||
tags={tags.value}
|
||||
articles={articles.value}
|
||||
part1Ref={leftbarPart1Ref}
|
||||
part2Ref={leftbarPart2Ref}
|
||||
/>
|
||||
<BlogTaxonomyModals
|
||||
active={activeModal.value}
|
||||
categories={categories}
|
||||
tags={tags}
|
||||
categories={categories.value}
|
||||
tags={tags.value}
|
||||
onClose={() => {
|
||||
activeModal.value = null;
|
||||
}}
|
||||
/>
|
||||
<BlogRightbar articles={articles} categories={categories} />
|
||||
<BlogRightbar articles={articles.value} categories={categories.value} />
|
||||
|
||||
<div class="kt-blog__primary">
|
||||
<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 { useBlogEventBus } from '@/hooks/useBlogEventBus';
|
||||
import { useBlogTheme } from '@/hooks/useBlogTheme';
|
||||
|
||||
import { BlogButton, BlogInput } from './antdvComponents';
|
||||
|
||||
const menuItems = [
|
||||
{ label: '首页', to: '/', icon: 'fa-home' },
|
||||
{ label: '管理', to: '/category/node', icon: 'fa-user' },
|
||||
];
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BlogSidebar',
|
||||
props: {
|
||||
@ -39,6 +35,7 @@ export default defineComponent({
|
||||
setup(props) {
|
||||
const router = useRouter();
|
||||
const eventBus = useBlogEventBus();
|
||||
const { siteConfig } = useBlogTheme();
|
||||
const keyword = ref('');
|
||||
const leftbarSearchOpen = ref(false);
|
||||
const leftbarSearchInputRef = ref<any>(null);
|
||||
@ -67,16 +64,23 @@ export default defineComponent({
|
||||
<aside class="kt-blog__sidebar" role="complementary">
|
||||
<div ref={props.part1Ref} class="kt-blog__sidebar-panel kt-blog__sidebar-panel--menu kt-blog__card">
|
||||
<div class="kt-blog__sidebar-banner kt-blog__card-body">
|
||||
<span class="kt-blog__sidebar-banner-title">KwiTsukasa的小站</span>
|
||||
<span class="kt-blog__sidebar-banner-title">{siteConfig.value.title}</span>
|
||||
</div>
|
||||
|
||||
<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']}>
|
||||
<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} />
|
||||
{item.label}
|
||||
</RouterLink>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@ -118,7 +122,7 @@ export default defineComponent({
|
||||
<div class="kt-blog__sidebar-author-image">
|
||||
<div class="kt-blog__sidebar-author-avatar" />
|
||||
</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">
|
||||
<div class="kt-blog__site-stats-item kt-blog__site-stats-item--posts">
|
||||
<RouterLink to="/archives">
|
||||
|
||||
@ -14,6 +14,7 @@ export interface BlogArticle {
|
||||
comments: number;
|
||||
words: number;
|
||||
content: string[];
|
||||
contentHtml?: string;
|
||||
}
|
||||
|
||||
export interface BlogCategory {
|
||||
@ -93,7 +94,7 @@ export const articles: BlogArticle[] = [
|
||||
content: [
|
||||
'本方案面向小型私有化部署场景,目标是在飞牛 NAS 上把 Docker、Jenkins、前端静态发布和后端 API 容器发布整合成一套可持续迭代的标准流程。',
|
||||
'飞牛 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 { computed, reactive, watch } from 'vue';
|
||||
|
||||
type BlogThemeMode = 'dark' | 'light';
|
||||
type BlogFontMode = 'sans' | 'serif';
|
||||
type BlogShadowMode = 'small' | 'big';
|
||||
type BlogFilterMode = 'off' | 'sunset' | 'darkness' | 'grayscale';
|
||||
export type BlogThemeMode = 'dark' | 'light';
|
||||
export type BlogFontMode = 'sans' | 'serif';
|
||||
export type BlogShadowMode = 'small' | 'big';
|
||||
export type BlogFilterMode = 'off' | 'sunset' | 'darkness' | 'grayscale';
|
||||
|
||||
interface BlogThemePreferences {
|
||||
export interface BlogThemePreferences {
|
||||
colorPrimary: string;
|
||||
filter: BlogFilterMode;
|
||||
font: BlogFontMode;
|
||||
@ -15,6 +15,87 @@ interface BlogThemePreferences {
|
||||
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 THEME_STYLE_ID = 'kt-blog-theme-style';
|
||||
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_PRIMARY_SOFT = '#4a4058';
|
||||
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 = {
|
||||
colorPrimary: ARGON_DEFAULT_COLOR_PRIMARY,
|
||||
@ -35,17 +129,62 @@ const defaultPreferences: BlogThemePreferences = {
|
||||
};
|
||||
|
||||
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 systemThemeMediaQuery: MediaQueryList | null = null;
|
||||
let systemThemeChangeHandler:
|
||||
| ((event: MediaQueryList | MediaQueryListEvent) => void)
|
||||
| null = null;
|
||||
|
||||
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(() => [
|
||||
BLOG_THEME_BLOCK_CLASS,
|
||||
`${BLOG_THEME_BLOCK_CLASS}--wp-argon`,
|
||||
`${BLOG_THEME_BLOCK_CLASS}--home`,
|
||||
`${BLOG_THEME_BLOCK_CLASS}--blog`,
|
||||
`${BLOG_THEME_BLOCK_CLASS}--triple-column`,
|
||||
`${BLOG_THEME_BLOCK_CLASS}--toolbar-blur`,
|
||||
`${BLOG_THEME_BLOCK_CLASS}--article-header-default`,
|
||||
runtimeConfig.tripleColumn && `${BLOG_THEME_BLOCK_CLASS}--triple-column`,
|
||||
runtimeConfig.immersionColor && `${BLOG_THEME_BLOCK_CLASS}--immersion-color`,
|
||||
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}`,
|
||||
preferences.font === 'serif' && `${BLOG_THEME_BLOCK_CLASS}--font-serif`,
|
||||
preferences.shadow === 'big' && `${BLOG_THEME_BLOCK_CLASS}--shadow-big`,
|
||||
@ -134,6 +273,87 @@ function setPrimaryColor(nextColor: string) {
|
||||
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 配置与偏好更新函数。
|
||||
*/
|
||||
@ -143,6 +363,8 @@ export function useBlogTheme() {
|
||||
return {
|
||||
isDarkTheme,
|
||||
preferences,
|
||||
applyWordpressThemeConfig,
|
||||
siteConfig,
|
||||
setFilterMode,
|
||||
setFontMode,
|
||||
setPrimaryColor,
|
||||
@ -151,6 +373,7 @@ export function useBlogTheme() {
|
||||
setThemeMode,
|
||||
themeConfig,
|
||||
themeRootClass,
|
||||
wordpressThemeConfig,
|
||||
};
|
||||
}
|
||||
|
||||
@ -187,8 +410,11 @@ function ensureThemeWatcher() {
|
||||
|
||||
themeWatcherReady = true;
|
||||
watch(
|
||||
() => ({ ...preferences }),
|
||||
(currentPreferences) => {
|
||||
() => ({
|
||||
preferences: { ...preferences },
|
||||
runtimeConfig: { ...runtimeConfig },
|
||||
}),
|
||||
({ preferences: currentPreferences }) => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
@ -212,6 +438,16 @@ function applyCssVariables(currentPreferences: BlogThemePreferences) {
|
||||
const primaryDark = shadeHexColor(currentPreferences.colorPrimary, -18);
|
||||
const primaryDark2 = shadeHexColor(currentPreferences.colorPrimary, -30);
|
||||
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);
|
||||
document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')?.setAttribute('content', currentPreferences.colorPrimary);
|
||||
@ -230,6 +466,14 @@ function applyCssVariables(currentPreferences: BlogThemePreferences) {
|
||||
.${BLOG_THEME_BLOCK_CLASS} {
|
||||
--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-shadow: ${ARGON_CARD_SHADOW};
|
||||
--argon-primary-soft: ${ARGON_PRIMARY_SOFT};
|
||||
@ -456,3 +700,186 @@ function isThemeColorTooDark(hexColor: string) {
|
||||
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@ -67,6 +67,88 @@
|
||||
p {
|
||||
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 {
|
||||
|
||||
@ -127,7 +127,7 @@
|
||||
height: 92px;
|
||||
margin: 0 auto;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -3,14 +3,15 @@ import { computed, defineComponent } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
|
||||
import BlogLayout from '@/components/blog/BlogLayout';
|
||||
import { articles } from '@/data/blog';
|
||||
import { useBlogArticles } from '@/hooks/useBlogArticles';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BlogArchivePage',
|
||||
setup() {
|
||||
const { articles } = useBlogArticles();
|
||||
const groupedArticles = computed(() => {
|
||||
const groups = new Map<string, typeof articles>();
|
||||
articles.forEach((article) => {
|
||||
const groups = new Map<string, typeof articles.value>();
|
||||
articles.value.forEach((article) => {
|
||||
const key = article.date.slice(0, 7);
|
||||
groups.set(key, [...(groups.get(key) ?? []), article]);
|
||||
});
|
||||
@ -23,7 +24,7 @@ export default defineComponent({
|
||||
mainClass="kt-blog__main--archive"
|
||||
pageTitle="归档时间轴"
|
||||
pageDescription="按月份回顾 KT 项目沉淀的文章记录。"
|
||||
pageMeta={`${articles.length} 篇文章`}
|
||||
pageMeta={`${articles.value.length} 篇文章`}
|
||||
>
|
||||
<article class="kt-blog__post kt-blog__post--full kt-blog__card">
|
||||
<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 BlogLayout from '@/components/blog/BlogLayout';
|
||||
import { articles } from '@/data/blog';
|
||||
import { useBlogArticles } from '@/hooks/useBlogArticles';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BlogHomePage',
|
||||
setup() {
|
||||
const { articles } = useBlogArticles();
|
||||
|
||||
return () => (
|
||||
<BlogLayout>
|
||||
<ArticleList articles={articles} />
|
||||
<ArticleList articles={articles.value} />
|
||||
</BlogLayout>
|
||||
);
|
||||
},
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 BlogLayout from '@/components/blog/BlogLayout';
|
||||
@ -10,7 +10,7 @@ import {
|
||||
BlogInput,
|
||||
BlogTextArea,
|
||||
} from '@/components/blog/antdvComponents';
|
||||
import { articles, getArticleBySlug, getRelatedArticles, getTagSlugByLabel } from '@/data/blog';
|
||||
import { useBlogArticles } from '@/hooks/useBlogArticles';
|
||||
import {
|
||||
clearBlogPostRefs,
|
||||
setBlogPostArticleRef,
|
||||
@ -22,17 +22,25 @@ export default defineComponent({
|
||||
name: 'BlogPostPage',
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const article = computed(() => getArticleBySlug(String(route.params.slug)) ?? articles[0]);
|
||||
const articleIndex = computed(() => articles.findIndex((item) => item.slug === article.value?.slug));
|
||||
const {
|
||||
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 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 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 commentContent = ref('');
|
||||
@ -43,6 +51,14 @@ export default defineComponent({
|
||||
clearBlogPostRefs();
|
||||
});
|
||||
|
||||
watch(
|
||||
slug,
|
||||
(value) => {
|
||||
if (value) void loadArticle(value);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return () => {
|
||||
const currentArticle = article.value;
|
||||
|
||||
@ -116,11 +132,18 @@ export default defineComponent({
|
||||
</div>
|
||||
</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">
|
||||
{currentArticle.content.map((paragraph) => (
|
||||
<p key={paragraph}>{paragraph}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="kt-blog__post-tags">
|
||||
<TagsOutlined />
|
||||
|
||||
@ -6,7 +6,7 @@ import ArticleList from '@/components/blog/ArticleList';
|
||||
import BlogLayout from '@/components/blog/BlogLayout';
|
||||
import PageInfoCard from '@/components/blog/PageInfoCard';
|
||||
import { BlogButton, BlogCheckbox, BlogForm, BlogInput } from '@/components/blog/antdvComponents';
|
||||
import { searchArticles } from '@/data/blog';
|
||||
import { useBlogArticles } from '@/hooks/useBlogArticles';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BlogSearchPage',
|
||||
@ -15,6 +15,7 @@ export default defineComponent({
|
||||
const router = useRouter();
|
||||
const keyword = ref(String(route.query.q ?? ''));
|
||||
const filters = ref(['post', 'page']);
|
||||
const { searchArticles } = useBlogArticles();
|
||||
const resultArticles = computed(() => searchArticles(keyword.value));
|
||||
|
||||
watch(
|
||||
|
||||
@ -3,17 +3,18 @@ import { useRoute } from 'vue-router';
|
||||
|
||||
import ArticleList from '@/components/blog/ArticleList';
|
||||
import BlogLayout from '@/components/blog/BlogLayout';
|
||||
import {
|
||||
getArticlesByCategory,
|
||||
getArticlesByTag,
|
||||
getCategoryBySlug,
|
||||
getTagBySlug,
|
||||
} from '@/data/blog';
|
||||
import { useBlogArticles } from '@/hooks/useBlogArticles';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BlogTermPage',
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const {
|
||||
getArticlesByCategory,
|
||||
getArticlesByTag,
|
||||
getCategoryBySlug,
|
||||
getTagBySlug,
|
||||
} = useBlogArticles();
|
||||
const mode = computed(() => String(route.meta.termMode ?? 'category'));
|
||||
const slug = computed(() => String(route.params.slug ?? ''));
|
||||
const category = computed(() => getCategoryBySlug(slug.value));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user