feat: 接入博客文章预览页面
This commit is contained in:
parent
b813af5af7
commit
f67e1a6a5f
@ -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`。
|
||||
|
||||
## 部署说明
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from './asset';
|
||||
export * from './preview-url';
|
||||
export * from './wordpress';
|
||||
|
||||
39
apps/web-antdv-next/src/api/blog/preview-url.spec.ts
Normal file
39
apps/web-antdv-next/src/api/blog/preview-url.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
52
apps/web-antdv-next/src/api/blog/preview-url.ts
Normal file
52
apps/web-antdv-next/src/api/blog/preview-url.ts
Normal 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;
|
||||
}
|
||||
@ -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: '文章预览',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -10,6 +10,8 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([
|
||||
'BlogArticleDelete',
|
||||
'BlogArticleEdit',
|
||||
'BlogArticleImport',
|
||||
'BlogArticlePreview',
|
||||
'BlogArticlePreviewButton',
|
||||
'BlogCategory',
|
||||
'BlogCategoryCreate',
|
||||
'BlogCategoryDelete',
|
||||
|
||||
@ -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: {
|
||||
|
||||
122
apps/web-antdv-next/src/views/blog/article/preview/index.scss
Normal file
122
apps/web-antdv-next/src/views/blog/article/preview/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
294
apps/web-antdv-next/src/views/blog/article/preview/index.tsx
Normal file
294
apps/web-antdv-next/src/views/blog/article/preview/index.tsx
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.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]
|
||||
);
|
||||
}
|
||||
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user