diff --git a/README.md b/README.md index 6e9950d..7ef6ba9 100644 --- a/README.md +++ b/README.md @@ -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`。 ## 部署说明 diff --git a/apps/web-antdv-next/.env.analyze b/apps/web-antdv-next/.env.analyze index ffafa8d..cc68ae4 100644 --- a/apps/web-antdv-next/.env.analyze +++ b/apps/web-antdv-next/.env.analyze @@ -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 diff --git a/apps/web-antdv-next/.env.development b/apps/web-antdv-next/.env.development index 3b8df81..9a811ea 100644 --- a/apps/web-antdv-next/.env.development +++ b/apps/web-antdv-next/.env.development @@ -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 diff --git a/apps/web-antdv-next/.env.example b/apps/web-antdv-next/.env.example index 9e80c19..3215149 100644 --- a/apps/web-antdv-next/.env.example +++ b/apps/web-antdv-next/.env.example @@ -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 diff --git a/apps/web-antdv-next/.env.production b/apps/web-antdv-next/.env.production index c1a6a11..01360b3 100644 --- a/apps/web-antdv-next/.env.production +++ b/apps/web-antdv-next/.env.production @@ -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 diff --git a/apps/web-antdv-next/src/api/blog/index.ts b/apps/web-antdv-next/src/api/blog/index.ts index cafd8b7..d7bf16b 100644 --- a/apps/web-antdv-next/src/api/blog/index.ts +++ b/apps/web-antdv-next/src/api/blog/index.ts @@ -1,2 +1,3 @@ export * from './asset'; +export * from './preview-url'; export * from './wordpress'; diff --git a/apps/web-antdv-next/src/api/blog/preview-url.spec.ts b/apps/web-antdv-next/src/api/blog/preview-url.spec.ts new file mode 100644 index 0000000..4e3705f --- /dev/null +++ b/apps/web-antdv-next/src/api/blog/preview-url.spec.ts @@ -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'); + }); +}); diff --git a/apps/web-antdv-next/src/api/blog/preview-url.ts b/apps/web-antdv-next/src/api/blog/preview-url.ts new file mode 100644 index 0000000..b064a99 --- /dev/null +++ b/apps/web-antdv-next/src/api/blog/preview-url.ts @@ -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; +} diff --git a/apps/web-antdv-next/src/api/core/menu.spec.ts b/apps/web-antdv-next/src/api/core/menu.spec.ts index d4170df..ce57825 100644 --- a/apps/web-antdv-next/src/api/core/menu.spec.ts +++ b/apps/web-antdv-next/src/api/core/menu.spec.ts @@ -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: '文章预览', + }, + }, + ], + }, + ]); + }); }); diff --git a/apps/web-antdv-next/src/api/core/menu.ts b/apps/web-antdv-next/src/api/core/menu.ts index ee00b13..8f1795f 100644 --- a/apps/web-antdv-next/src/api/core/menu.ts +++ b/apps/web-antdv-next/src/api/core/menu.ts @@ -10,6 +10,8 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([ 'BlogArticleDelete', 'BlogArticleEdit', 'BlogArticleImport', + 'BlogArticlePreview', + 'BlogArticlePreviewButton', 'BlogCategory', 'BlogCategoryCreate', 'BlogCategoryDelete', diff --git a/apps/web-antdv-next/src/router/routes/modules/blog.ts b/apps/web-antdv-next/src/router/routes/modules/blog.ts index d68b83e..ad73916 100644 --- a/apps/web-antdv-next/src/router/routes/modules/blog.ts +++ b/apps/web-antdv-next/src/router/routes/modules/blog.ts @@ -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: { diff --git a/apps/web-antdv-next/src/views/blog/article/preview/index.scss b/apps/web-antdv-next/src/views/blog/article/preview/index.scss new file mode 100644 index 0000000..57adfc5 --- /dev/null +++ b/apps/web-antdv-next/src/views/blog/article/preview/index.scss @@ -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; + } +} + diff --git a/apps/web-antdv-next/src/views/blog/article/preview/index.tsx b/apps/web-antdv-next/src/views/blog/article/preview/index.tsx new file mode 100644 index 0000000..59dda8a --- /dev/null +++ b/apps/web-antdv-next/src/views/blog/article/preview/index.tsx @@ -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); + const errorMessage = ref(''); + const state = ref('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 ( +
+
+ + {previewTitle.value} + + + {primaryStatusMeta.value.label} + +
+
+ 文章预览 + 运行态:{statusMeta.value.label} + {routeArticleId.value ? ( + ID:{routeArticleId.value} + ) : null} + {iframeHost.value ? Host:{iframeHost.value} : null} +
+ + + + 返回 + + + 刷新 + + + 新窗口 + + +
+ ); + }; + + /** + * 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 ( +
+