feat: 接入博客文章预览页面

This commit is contained in:
sunlei 2026-07-01 18:15:28 +08:00
parent b813af5af7
commit f67e1a6a5f
14 changed files with 799 additions and 0 deletions

View File

@ -41,6 +41,7 @@ pnpm run build:antdv-next
- `VITE_BASE`Vite base默认 `/`
- `VITE_ROUTER_HISTORY`:路由模式,可选 `hash``html5`
- `VITE_COMPRESS`:构建压缩方式,可选 `none`、`gzip`、`brotli`
- `VITE_KT_BLOG_WEB_BASE_URL`:博客文章预览 iframe 打开的 KT Blog Web 公共地址,本地默认 `http://127.0.0.1:5173/`,生产必须指向部署后的 KT Blog Web而不是 WordPress 后台或 WordPress 基准站点。
真实环境变量不提交,示例配置以 `.env.example` 为准。
@ -50,6 +51,7 @@ pnpm run build:antdv-next
- 系统管理 / 站内信是日志级通知列表,只展示 API 错误、QQBot 下线、NapCat 离线等后端自动捕获事件;页面提供筛选、处理/重新打开、置顶和删除,不提供人工新增或编辑。
- QQBot / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / captcha / new-device / qrcode 每步中文进度,密码登录触发 QQ 安全验证时在弹窗内完成腾讯验证码并回交 API新设备验证二维码和腾讯验证码分开展示行操作“运行态”打开只读抽屉展示 NapCat runtime/protocol/session behavior profile、风险模式和登录事件证据。
- QQBot / 插件平台页保留在线命令能力表,并提供 manifest 校验、本地插件安装、安装记录、运行事件和账号绑定抽屉,接口走 `/qqbot/plugin-platform/*`
- 博客管理 / 文章管理提供“预览”行操作,打开隐藏二级路由 `/blog/article/:articleId/preview`;预览页按 NapCat WebUI 的微服务嵌入形态实现iframe 独占容器,文章标题、状态、预览 Host 和返回/刷新/新窗口操作放在右下角悬浮卡片,不占用 iframe 布局空间。新增隐藏路由和按钮权限需要同步 API `blog-menu.sql` / `vben-admin-init.sql` 中的 `BlogArticlePreview``BlogArticlePreviewButton`
- 博客管理 / 文章表单支持 Markdown、富文本 HTML、源码 HTML 三种编辑模式Markdown 继续使用 Milkdown 并保存 `contentFormat=markdown`;富文本 HTML 使用 Tiptap 并保存 `contentFormat=html`;源码 HTML 用于保留 WordPress/Argon 运行时 DOM同样保存 `contentFormat=html`
## 部署说明

View File

@ -4,4 +4,7 @@ VITE_BASE=/
# Basic interface address SPA
VITE_GLOB_API_URL=/api
# Admin Blog article preview iframe target
VITE_KT_BLOG_WEB_BASE_URL=https://blog.kwitsukasa.top/
VITE_VISUALIZER=true

View File

@ -7,6 +7,9 @@ VITE_ROUTER_HISTORY=hash
# 接口地址
VITE_GLOB_API_URL=/api
# Admin 博客文章预览 iframe 目标,开发环境指向本地 KT Blog Web
VITE_KT_BLOG_WEB_BASE_URL=http://127.0.0.1:5173/
# 是否开启 Nitro Mock服务true 为开启false 为关闭
VITE_NITRO_MOCK=false

View File

@ -13,6 +13,9 @@ VITE_BASE=/
# 后端 API 前缀
VITE_GLOB_API_URL=/api
# Admin 博客文章预览 iframe 目标;开发默认本地 KT Blog Web生产改为部署后的 Blog Web 公共地址
VITE_KT_BLOG_WEB_BASE_URL=http://127.0.0.1:5173/
# vue-router 模式,可选 hash / html5
VITE_ROUTER_HISTORY=hash

View File

@ -3,6 +3,9 @@ VITE_BASE=/
# 接口地址
VITE_GLOB_API_URL=/api
# Admin 博客文章预览 iframe 目标,生产环境指向已部署 KT Blog Web
VITE_KT_BLOG_WEB_BASE_URL=https://blog.kwitsukasa.top/
# 是否开启压缩,可以设置为 none, brotli, gzip
VITE_COMPRESS=none

View File

@ -1,2 +1,3 @@
export * from './asset';
export * from './preview-url';
export * from './wordpress';

View File

@ -0,0 +1,39 @@
/* @vitest-environment happy-dom */
import { describe, expect, it } from 'vitest';
import { buildKtBlogPreviewUrl, resolveKtBlogWebBaseUrl } from './preview-url';
describe('blog preview url helpers', () => {
it('builds a KT Blog Web preview URL with encoded slug and trace params', () => {
const url = buildKtBlogPreviewUrl(
{
id: '61',
slug: '中文 Slug',
},
'61',
);
expect(url).toBe(
'http://127.0.0.1:5173/#/post/%E4%B8%AD%E6%96%87%20Slug?adminPreview=1&articleId=61',
);
});
it('uses the configured production Blog Web origin', () => {
expect(
resolveKtBlogWebBaseUrl({
PROD: true,
VITE_KT_BLOG_WEB_BASE_URL: 'https://blog.kwitsukasa.top/',
}),
).toBe('https://blog.kwitsukasa.top/');
});
it('throws in production when the Blog Web origin is not configured', () => {
expect(() =>
resolveKtBlogWebBaseUrl({
PROD: true,
VITE_KT_BLOG_WEB_BASE_URL: '',
}),
).toThrow('VITE_KT_BLOG_WEB_BASE_URL is required in production');
});
});

View File

@ -0,0 +1,52 @@
const LOCAL_BLOG_WEB_BASE_URL = 'http://127.0.0.1:5173/';
type BlogPreviewArticle = {
id?: string;
slug?: string;
};
type BlogPreviewEnv = {
PROD?: boolean;
VITE_KT_BLOG_WEB_BASE_URL?: string;
};
/**
* Builds the public KT Blog Web URL used by the Admin preview iframe.
* @param article Article data whose slug is the preferred public post route key.
* @param articleId Admin article id retained in the query string for preview traceability.
* @returns Absolute or same-origin iframe URL for the KT Blog Web post route.
*/
export function buildKtBlogPreviewUrl(
article: BlogPreviewArticle,
articleId: string,
) {
const origin = resolveKtBlogWebBaseUrl(import.meta.env);
const url = new URL(origin, window.location.origin);
const slugOrId = article.slug || article.id || articleId;
const params = new URLSearchParams({
adminPreview: '1',
articleId,
});
url.hash = `/post/${encodeURIComponent(slugOrId)}?${params.toString()}`;
return url.toString();
}
/**
* Resolves the Blog Web preview origin and refuses silent localhost fallback in production.
* @param env Vite import meta environment; tests pass a small object to cover production guards.
* @returns Configured Blog Web base URL, or the local development default outside production.
*/
export function resolveKtBlogWebBaseUrl(env: BlogPreviewEnv) {
const configured = env.VITE_KT_BLOG_WEB_BASE_URL?.trim();
if (configured) {
return configured;
}
if (env.PROD) {
throw new Error('VITE_KT_BLOG_WEB_BASE_URL is required in production');
}
return LOCAL_BLOG_WEB_BASE_URL;
}

View File

@ -157,4 +157,71 @@ describe('core menu api', () => {
expect(menus.map((menu) => menu.meta?.order)).toEqual([200, 1, 0]);
expect(menus[1]?.children?.[0]?.meta?.order).toBe(1);
});
it('keeps blog article preview hidden route and row action permission from backend menus', async () => {
requestClientGet.mockResolvedValue([
{
name: 'Blog',
path: '/blog',
children: [
{
name: 'BlogArticle',
path: '/blog/article',
component: '/blog/article/list',
children: [
{
name: 'BlogArticlePreviewButton',
authCode: 'Blog:Article:Preview',
type: 'button',
},
],
},
{
name: 'BlogArticlePreview',
path: '/blog/article/:articleId/preview',
component: '/blog/article/preview/index',
meta: {
activePath: '/blog/article',
hideInMenu: true,
title: '文章预览',
},
},
],
},
]);
const { getAllMenusApi } = await import('./menu');
const menus = await getAllMenusApi();
expect(menus).toEqual([
{
name: 'Blog',
path: '/blog',
children: [
{
name: 'BlogArticle',
path: '/blog/article',
component: '/blog/article/list',
children: [
{
name: 'BlogArticlePreviewButton',
authCode: 'Blog:Article:Preview',
type: 'button',
},
],
},
{
name: 'BlogArticlePreview',
path: '/blog/article/:articleId/preview',
component: '/blog/article/preview/index',
meta: {
activePath: '/blog/article',
hideInMenu: true,
title: '文章预览',
},
},
],
},
]);
});
});

View File

@ -10,6 +10,8 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([
'BlogArticleDelete',
'BlogArticleEdit',
'BlogArticleImport',
'BlogArticlePreview',
'BlogArticlePreviewButton',
'BlogCategory',
'BlogCategoryCreate',
'BlogCategoryDelete',

View File

@ -20,6 +20,16 @@ const routes: RouteRecordRaw[] = [
name: 'BlogArticle',
path: '/blog/article',
},
{
component: () => import('#/views/blog/article/preview'),
meta: {
activePath: '/blog/article',
hideInMenu: true,
title: '文章预览',
},
name: 'BlogArticlePreview',
path: '/blog/article/:articleId/preview',
},
{
component: () => import('#/views/blog/category/list'),
meta: {

View File

@ -0,0 +1,122 @@
.blog-article-preview-page {
width: 100%;
height: var(--vben-content-height, 100%);
min-height: 0;
overflow: hidden;
color: hsl(var(--foreground));
background: hsl(var(--background));
}
.blog-article-preview {
position: relative;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
color: inherit;
background: inherit;
&__content {
position: absolute;
inset: 0;
display: flex;
min-width: 0;
min-height: 0;
overflow: hidden;
}
&__center,
&__message {
display: flex;
flex: 1 1 0;
flex-direction: column;
gap: 12px;
align-items: center;
justify-content: center;
min-height: 0;
padding: 24px;
}
&__message {
align-items: stretch;
max-width: 560px;
margin: 0 auto;
}
&__iframe-shell {
flex: 1 1 0;
min-width: 0;
min-height: 0;
overflow: hidden;
background: hsl(var(--background));
}
&__iframe {
display: block;
width: 100%;
height: 100%;
background: hsl(var(--background));
border: 0;
}
&__floating-card {
position: absolute;
right: 16px;
bottom: 16px;
z-index: 2;
display: flex;
flex-direction: column;
gap: 8px;
max-width: min(360px, calc(100% - 32px));
padding: 10px 12px;
color: hsl(var(--foreground));
pointer-events: auto;
background: hsl(var(--background) / 92%);
border: 1px solid hsl(var(--border));
border-radius: 8px;
box-shadow: 0 10px 30px hsl(var(--foreground) / 12%);
backdrop-filter: blur(10px);
}
&__floating-head {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
min-width: 0;
}
&__floating-title {
min-width: 0;
overflow: hidden;
font-size: 13px;
font-weight: 600;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
&__floating-meta {
display: flex;
flex-wrap: wrap;
gap: 6px 10px;
min-width: 0;
font-size: 12px;
line-height: 16px;
color: hsl(var(--muted-foreground));
}
&__floating-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
&__back-icon {
width: 14px;
height: 14px;
margin-right: 4px;
vertical-align: -2px;
}
}

View File

@ -0,0 +1,294 @@
import type { WordpressBlogApi } from '#/api/blog';
import { computed, defineComponent, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ArrowLeft } from '@vben/icons';
import { Alert, Button, Space, Spin, Tag } from 'antdv-next';
import { buildKtBlogPreviewUrl, getArticleDetail } from '#/api/blog';
import './index.scss';
type PreviewState = 'error' | 'loading' | 'ready';
const AAlert = Alert as any;
const AButton = Button as any;
const ASpace = Space as any;
const ASpin = Spin as any;
const ATag = Tag as any;
const articleStatusOptions = [
{ color: 'success', label: '已发布', value: 'publish' },
{ color: 'default', label: '草稿', value: 'draft' },
{ color: 'warning', label: '待审核', value: 'pending' },
{ color: 'processing', label: '私有', value: 'private' },
] as const;
export default defineComponent({
name: 'BlogArticlePreview',
/**
* Wires the current article route id to a full-bleed Blog Web iframe preview.
*/
setup() {
const route = useRoute();
const router = useRouter();
const article = ref<null | WordpressBlogApi.Article>(null);
const errorMessage = ref('');
const state = ref<PreviewState>('loading');
const routeArticleId = computed(() =>
normalizeRouteParam(route.params.articleId),
);
const previewTitle = computed(
() => getArticleTitle(article.value) || '文章预览',
);
const iframeUrl = computed(() =>
article.value
? buildKtBlogPreviewUrl(article.value, routeArticleId.value)
: '',
);
const iframeHost = computed(() => getPreviewHost(iframeUrl.value));
const statusMeta = computed(() => getStatusMeta(state.value));
const articleStatusMeta = computed(() =>
getArticleStatusMeta(article.value?.status),
);
const primaryStatusMeta = computed(() =>
article.value ? articleStatusMeta.value : statusMeta.value,
);
watch(
routeArticleId,
() => {
void loadArticlePreview();
},
{ immediate: true },
);
/**
* Navigates back to the Blog article list.
*/
function goBack() {
void router.push({ name: 'BlogArticle' });
}
/**
* Opens the current public KT Blog Web preview in a browser tab for direct inspection.
*/
function openPreviewInNewWindow() {
if (!iframeUrl.value) {
return;
}
window.open(iframeUrl.value, '_blank', 'noopener,noreferrer');
}
/**
* Reloads the article detail and rebuilds the iframe URL for the current route id.
*/
async function loadArticlePreview() {
const articleId = routeArticleId.value;
article.value = null;
errorMessage.value = '';
if (!articleId) {
state.value = 'error';
errorMessage.value = '缺少文章 ID';
return;
}
state.value = 'loading';
try {
article.value = await getArticleDetail(articleId);
state.value = 'ready';
} catch (error) {
state.value = 'error';
errorMessage.value =
error instanceof Error ? error.message : '文章预览加载失败';
}
}
/**
* Renders the floating status card without taking layout space from the preview iframe.
*
* @returns Overlay card with article metadata and navigation actions.
*/
const renderFloatingCard = () => {
return (
<div class="blog-article-preview__floating-card">
<div class="blog-article-preview__floating-head">
<span class="blog-article-preview__floating-title">
{previewTitle.value}
</span>
<ATag color={primaryStatusMeta.value.color}>
{primaryStatusMeta.value.label}
</ATag>
</div>
<div class="blog-article-preview__floating-meta">
<span></span>
<span>{statusMeta.value.label}</span>
{routeArticleId.value ? (
<span>ID{routeArticleId.value}</span>
) : null}
{iframeHost.value ? <span>Host{iframeHost.value}</span> : null}
</div>
<ASpace class="blog-article-preview__floating-actions" size={6}>
<AButton onClick={goBack} size="small" type="text">
<ArrowLeft class="blog-article-preview__back-icon" />
</AButton>
<AButton
disabled={state.value === 'loading'}
onClick={loadArticlePreview}
size="small"
>
</AButton>
<AButton
data-testid="blog-preview-open"
disabled={state.value !== 'ready' || !iframeUrl.value}
onClick={openPreviewInNewWindow}
size="small"
>
</AButton>
</ASpace>
</div>
);
};
/**
* Renders loading, error, and ready iframe states inside the fixed preview canvas.
*
* @returns Current preview body content.
*/
const renderBody = () => {
if (state.value === 'ready' && iframeUrl.value) {
return (
<div class="blog-article-preview__iframe-shell">
<iframe
class="blog-article-preview__iframe"
src={iframeUrl.value}
title={`文章预览 ${previewTitle.value}`}
/>
</div>
);
}
if (state.value === 'error') {
return (
<div class="blog-article-preview__message">
<AAlert
message={errorMessage.value || '文章预览加载失败'}
showIcon
type="error"
/>
<AButton onClick={loadArticlePreview} type="primary">
</AButton>
</div>
);
}
return (
<div class="blog-article-preview__center">
<ASpin spinning />
</div>
);
};
/**
* Renders the stable page root required by Vben route transitions.
*
* @returns Single-root preview page shell.
*/
const renderPage = () => {
return (
<div class="blog-article-preview-page">
<div class="blog-article-preview">
<div class="blog-article-preview__content">{renderBody()}</div>
{renderFloatingCard()}
</div>
</div>
);
};
return renderPage;
},
});
/**
* @param value Raw vue-router route param value.
* @returns A single trimmed article id string.
*/
function normalizeRouteParam(value: unknown) {
if (Array.isArray(value)) return `${value[0] || ''}`.trim();
return `${value || ''}`.trim();
}
/**
* @param article Blog article detail returned by the Admin API.
* @returns Plain article title for metadata and iframe title attributes.
*/
function getArticleTitle(article?: null | WordpressBlogApi.Article) {
const value = article?.title;
if (typeof value === 'string') return stripHtml(value);
return stripHtml(value?.raw || value?.rendered || '');
}
/**
* @param previewUrl Iframe URL built from the KT Blog Web base URL.
* @returns Host shown in the floating card so operators can verify the embedded target.
*/
function getPreviewHost(previewUrl: string) {
if (!previewUrl) {
return '';
}
try {
const url = new URL(previewUrl, window.location.origin);
return url.host || url.pathname;
} catch {
return '';
}
}
/**
* @param value HTML-ish rendered text from WordPress-compatible article fields.
* @returns Plain text safe for compact Admin metadata.
*/
function stripHtml(value: string) {
return value
.replaceAll(/<[^>]*>/g, ' ')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll(/\s+/g, ' ')
.trim();
}
/**
* @param state Current preview page lifecycle state.
* @returns Ant Design tag color and Chinese label for the floating card.
*/
function getStatusMeta(state: PreviewState) {
const statusMap = {
error: { color: 'error', label: '异常' },
loading: { color: 'processing', label: '加载中' },
ready: { color: 'success', label: '已就绪' },
} as const;
return statusMap[state];
}
/**
* @param status Article publish status returned by the Admin API.
* @returns Ant Design tag color and Chinese label for article metadata.
*/
function getArticleStatusMeta(status?: string) {
return (
articleStatusOptions.find((item) => item.value === status) ||
articleStatusOptions[1]
);
}

View File

@ -0,0 +1,198 @@
/* @vitest-environment happy-dom */
/* eslint-disable vue/one-component-per-file */
import { flushPromises, mount } from '@vue/test-utils';
import { defineComponent, h } from 'vue';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getArticleDetail } from '#/api/blog';
import BlogArticlePreview from './index';
const testState = vi.hoisted(() => ({
pushSpy: vi.fn(),
route: {
params: {
articleId: '50',
} as Record<string, unknown>,
},
}));
const { pushSpy, route } = testState;
vi.mock('vue-router', () => ({
useRoute: () => route,
useRouter: () => ({
push: pushSpy,
}),
}));
vi.mock('@vben/icons', () => ({
ArrowLeft: defineComponent({
name: 'MockArrowLeft',
setup() {
return () => h('i', { 'data-testid': 'arrow-left' });
},
}),
}));
vi.mock('antdv-next', () => ({
Alert: defineComponent({
name: 'MockAlert',
props: {
message: { default: '', type: String },
type: { default: '', type: String },
},
setup(props) {
return () =>
h('div', { role: 'alert' }, [props.message, props.type as string]);
},
}),
Button: defineComponent({
name: 'MockButton',
props: {
disabled: Boolean,
type: { default: '', type: String },
},
emits: ['click'],
setup(props, { attrs, emit, slots }) {
return () =>
h(
'button',
{
...attrs,
disabled: props.disabled,
type: 'button',
onClick: () => emit('click'),
},
slots.default?.(),
);
},
}),
Space: defineComponent({
name: 'MockSpace',
setup(_, { slots }) {
return () => h('div', slots.default?.());
},
}),
Spin: defineComponent({
name: 'MockSpin',
props: {
spinning: Boolean,
},
setup(props) {
return () => h('div', { 'data-spinning': String(props.spinning) });
},
}),
Tag: defineComponent({
name: 'MockTag',
props: {
color: { default: '', type: String },
},
setup(_, { slots }) {
return () => h('span', slots.default?.());
},
}),
}));
vi.mock('#/api/blog', async () => {
const actual =
await vi.importActual<typeof import('#/api/blog')>('#/api/blog');
return {
...actual,
getArticleDetail: vi.fn(),
};
});
describe('blog article preview page', () => {
beforeEach(() => {
route.params.articleId = '50';
pushSpy.mockReset();
vi.clearAllMocks();
vi.mocked(getArticleDetail).mockResolvedValue({
id: '50',
slug: 'fnos-nas-docker-jenkins-k3d-k8s',
status: 'publish',
title: {
rendered: '飞牛 NAS Docker',
},
});
});
it('loads article detail and renders an iframe-only preview surface', async () => {
const wrapper = mount(BlogArticlePreview);
await flushPromises();
expect(getArticleDetail).toHaveBeenCalledWith('50');
expect(wrapper.find('iframe').attributes('src')).toBe(
'http://127.0.0.1:5173/#/post/fnos-nas-docker-jenkins-k3d-k8s?adminPreview=1&articleId=50',
);
expect(wrapper.find('.blog-article-preview__header').exists()).toBe(false);
expect(wrapper.find('.blog-article-preview__floating-card').exists()).toBe(
true,
);
expect(wrapper.text()).toContain('飞牛 NAS Docker');
expect(wrapper.text()).toContain('文章预览');
expect(wrapper.text()).toContain('已发布');
expect(wrapper.text()).toContain('运行态:已就绪');
expect(wrapper.text()).toContain('127.0.0.1:5173');
});
it('does not call detail API when route article id is missing', async () => {
route.params.articleId = '';
const wrapper = mount(BlogArticlePreview);
await flushPromises();
expect(getArticleDetail).not.toHaveBeenCalled();
expect(wrapper.find('iframe').exists()).toBe(false);
expect(wrapper.text()).toContain('缺少文章 ID');
});
it('routes back to article list from the floating card', async () => {
const wrapper = mount(BlogArticlePreview);
await flushPromises();
await wrapper.find('button').trigger('click');
expect(pushSpy).toHaveBeenCalledWith({ name: 'BlogArticle' });
});
it('reloads article detail from the floating card refresh action', async () => {
const wrapper = mount(BlogArticlePreview);
await flushPromises();
await wrapper.findAll('button')[1]?.trigger('click');
await flushPromises();
expect(getArticleDetail).toHaveBeenCalledTimes(2);
});
it('shows the API error message when article detail loading fails', async () => {
vi.mocked(getArticleDetail).mockRejectedValueOnce(
new Error('detail failed'),
);
const wrapper = mount(BlogArticlePreview);
await flushPromises();
expect(wrapper.find('iframe').exists()).toBe(false);
expect(wrapper.text()).toContain('detail failed');
expect(wrapper.text()).toContain('异常');
});
it('opens the current KT Blog Web preview in a new window', async () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
const wrapper = mount(BlogArticlePreview);
await flushPromises();
await wrapper.find('[data-testid="blog-preview-open"]').trigger('click');
expect(openSpy).toHaveBeenCalledWith(
'http://127.0.0.1:5173/#/post/fnos-nas-docker-jenkins-k3d-k8s?adminPreview=1&articleId=50',
'_blank',
'noopener,noreferrer',
);
});
});