diff --git a/.env.example b/.env.example
index e0d54e8..996df4a 100644
--- a/.env.example
+++ b/.env.example
@@ -118,22 +118,17 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind
# 历史列表每次展示/加载条数(默认 25,与 desktop 一致)
# VITE_SESSION_PAGE_SIZE=25
-# Plaza 发现广场(Sprint 1)
+# Plaza 发现广场(本机为主,105 已停用)
# PLAZA_AUTO_APPROVE=true
-# 本地 Plaza(无端口,与线上一致):
-# sudo pnpm setup:plaza-dns # hosts + /etc/resolver(覆盖 Tailscale fake-ip)
-# pnpm dev:plaza-dns # 本地 DNS :5533(dev:plaza 会自动启动)
-# pnpm dev:plaza # 仅 Plaza + Portal
-# sudo pnpm dev:plaza-proxy # :443 -> :3001
-# 若浏览器仍走 Cloudflare:关闭「安全 DNS / Secure DNS」后重开浏览器
+# 文档:docs/plaza-local.md
+# 首次:sudo pnpm setup:plaza-dns # 仅纯本地 HTTPS 时需要;走 Tunnel 请用 setup:plaza-dns:remove
+# 日常:pnpm dev:plaza | 浏览器:pnpm open:plaza | 诊断:pnpm check:plaza
+# 公网 plaza.tkmind.cn 经 Cloudflare Tunnel → 本机 :3001(需 dev:plaza 常驻)
# PLAZA_LOCAL_HOST=plaza.tkmind.cn
-# PLAZA_PUBLIC_PORT=80
-# PLAZA_PUBLIC_BASE=http://plaza.tkmind.cn
-# VITE_PLAZA_BASE=http://plaza.tkmind.cn
-# 105 部署(Plaza 与 H5 分离):
-# pnpm deploy:105 # Memind H5 → 105(Plaza API + MindSpace + 登录)
-# pnpm deploy:plaza-105 # Plaza Next.js → 105 /root/plaza/web(独立前端)
-# 配置:deploy/plaza-105/plaza-105.env.example → plaza-105.env
+# PLAZA_PUBLIC_BASE=https://plaza.tkmind.cn
+# VITE_PLAZA_BASE=https://plaza.tkmind.cn
+# CLOUDFLARE_TUNNEL_CONFIG=/Users/john/Project/ollama/cloudflare/config.yml
+# CLOUDFLARE_TUNNEL_NAME=ollama-tkmind
# MindSpace(默认启用;子功能需按需开启)
# MINDSPACE_ENABLED=false
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a3071f7
--- /dev/null
+++ b/README.md
@@ -0,0 +1,49 @@
+# Memind (TKMind H5)
+
+Memind 主应用与 Plaza 发现广场。**当前以本机 Mac 为主服务**,105 服务器已停用。
+
+## 快速开始
+
+```bash
+pnpm install
+cp .env.example .env # 按需填写
+pnpm dev # MindSpace + Plaza + Ops
+```
+
+仅 Plaza:
+
+```bash
+pnpm start:plaza # 本机正式模式(Tunnel 回源)
+pnpm dev:plaza # 开发热更新
+pnpm open:plaza # 浏览器打开 https://plaza.tkmind.cn/plaza
+```
+
+## Plaza 本机为主
+
+完整说明见 **[docs/plaza-local.md](docs/plaza-local.md)**,摘要如下:
+
+| 步骤 | 命令 |
+|------|------|
+| 首次 DNS | `sudo pnpm setup:plaza-dns` |
+| 首次 TLS(可选) | `sudo pnpm setup:plaza-local` |
+| 公网 Tunnel | `pnpm setup:plaza-tunnel` |
+| 日常正式 | `pnpm start:plaza` |
+| 开发调试 | `pnpm dev:plaza` |
+| 诊断 | `pnpm check:plaza` |
+
+公网 `https://plaza.tkmind.cn` 经 Cloudflare Tunnel 回源本机 `:3001`,需保持 `pnpm start:plaza` 运行。
+
+## 常用脚本
+
+| 命令 | 说明 |
+|------|------|
+| `pnpm dev` | 全栈开发 |
+| `pnpm start:plaza` | Plaza 正式模式 + Portal |
+| `pnpm dev:plaza` | Plaza 开发模式 + Portal |
+| `pnpm open:plaza` | 浏览器强制本地解析 |
+| `pnpm check:plaza` | 本地 Plaza 诊断 |
+| `pnpm test` | 单元测试 |
+
+## 文档
+
+- [Plaza 本机部署与 Tunnel](docs/plaza-local.md)
diff --git a/docs/plaza-local.md b/docs/plaza-local.md
new file mode 100644
index 0000000..57e81e8
--- /dev/null
+++ b/docs/plaza-local.md
@@ -0,0 +1,111 @@
+# Plaza 本机为主
+
+105 服务器已停用。Plaza 与 Memind H5 均在本机 Mac 运行;公网 `plaza.tkmind.cn` 经 **Cloudflare Tunnel** 回源到本机。
+
+## 架构
+
+```
+浏览器 / 微信
+ ↓
+Cloudflare(plaza.tkmind.cn)
+ ↓ Cloudflare Tunnel
+本机 Mac
+ ├─ :3001 Plaza Next.js(/plaza、/_next)
+ │ └─ rewrites → :8081(/api、/auth、/u)
+ └─ :8081 Memind Portal(server.mjs)
+```
+
+Next.js 通过 `PLAZA_API_PROXY` 把 API 请求转发到 Portal,无需单独 nginx。
+
+## 首次配置(一次性)
+
+```bash
+# 1. 本地 DNS(覆盖 Tailscale / Clash fake-ip)
+sudo pnpm setup:plaza-dns
+
+# 2. 本地 HTTPS 证书(可选,供本机 https://plaza.tkmind.cn)
+sudo pnpm setup:plaza-local
+
+# 3. 公网隧道(plaza.tkmind.cn → 本机 :3001)
+pnpm setup:plaza-tunnel
+```
+
+## 日常启动
+
+```bash
+# 本机正式模式(next build + start,供 Tunnel 回源,推荐公网)
+pnpm start:plaza
+
+# 开发模式(热更新,仅本地调试)
+pnpm dev:plaza
+
+# 另开终端:本机 HTTPS 入口(可选,纯本地 HTTPS 时用)
+sudo pnpm dev:plaza-proxy
+
+# 或全栈(MindSpace + Plaza + Ops)
+pnpm dev
+```
+
+## 访问方式
+
+| 场景 | 地址 |
+|------|------|
+| 本机直连 | http://127.0.0.1:3001/plaza |
+| 本机域名(需 proxy) | https://plaza.tkmind.cn/plaza |
+| 浏览器强制本地 | `pnpm open:plaza` |
+| 公网(需 dev:plaza + 隧道) | https://plaza.tkmind.cn/plaza |
+
+### 浏览器提示「连接不是私密连接」?
+
+本机曾用 `setup:plaza-dns` 把域名指到 `127.0.0.1`,HTTPS 走自签证书。若已配置 Cloudflare Tunnel,应 **去掉本地劫持**,走公网证书(仍回源本机):
+
+```bash
+sudo pnpm setup:plaza-dns:remove
+```
+
+然后刷新 https://plaza.tkmind.cn/ 即可。
+
+### 浏览器打不开?
+
+Chrome / Cursor 内置浏览器默认走 **Secure DNS**,会绕过 `/etc/hosts` 连到 Cloudflare(105 停服时显示 502)。
+
+解决:
+
+1. `pnpm open:plaza`(推荐)
+2. 关闭浏览器「安全 DNS / Secure DNS」
+3. `pnpm check:plaza` 查看诊断
+
+## 公网 Tunnel
+
+隧道配置写在 `ollama-tkmind` 的 cloudflared config(默认 `~/Project/ollama/cloudflare/config.yml`)。
+
+```bash
+pnpm setup:plaza-tunnel # 写入 ingress + DNS 路由 + 重启 cloudflared
+```
+
+**前提:** `pnpm start:plaza`(或 `pnpm dev:plaza`)已在跑(:3001 可访问)。Mac 休眠或服务停掉时,外网会 502/503。
+
+自定义:
+
+```bash
+CLOUDFLARE_TUNNEL_CONFIG=/path/to/config.yml \
+CLOUDFLARE_TUNNEL_NAME=ollama-tkmind \
+PLAZA_TUNNEL_PORT=3001 \
+pnpm setup:plaza-tunnel
+```
+
+## 环境变量
+
+见 `.env.example` 中 Plaza 相关注释。常用:
+
+| 变量 | 默认 | 说明 |
+|------|------|------|
+| `PLAZA_PORT` | 3001 | Plaza Next.js 端口 |
+| `H5_PORT` | 8081 | Portal / API 端口 |
+| `PLAZA_LOCAL_HOST` | plaza.tkmind.cn | 本地域名 |
+| `PLAZA_PUBLIC_BASE` | https://plaza.tkmind.cn | 公开 URL 前缀 |
+
+## 已弃用
+
+- `pnpm deploy:105`、`pnpm deploy:plaza-105` — 105 不再作为主服务
+- 105 上 nginx / goose-h5 / goose-plaza-web — 已停服,公网不再回源 105
diff --git a/mindspace-visual-editor.test.mjs b/mindspace-visual-editor.test.mjs
new file mode 100644
index 0000000..67b3877
--- /dev/null
+++ b/mindspace-visual-editor.test.mjs
@@ -0,0 +1,28 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ MINDSPACE_PAGE_CONTENT_MESSAGE,
+ buildEditablePreviewDocument,
+} from './src/utils/mindspaceVisualEditor.ts';
+
+test('buildEditablePreviewDocument injects visual editor into body', () => {
+ const html = '
Hi
';
+ const result = buildEditablePreviewDocument(html);
+
+ assert.match(result, /mindspace-visual-editor-style/);
+ assert.match(result, /mindspace-visual-editor-script/);
+ assert.match(result, /data-mindspace-editing/);
+ assert.match(result, /Hi<\/p>/);
+ assert.match(result, new RegExp(MINDSPACE_PAGE_CONTENT_MESSAGE.replace(':', '\\:')));
+});
+
+test('buildEditablePreviewDocument wraps fragment html', () => {
+ const result = buildEditablePreviewDocument('');
+ assert.match(result, /块<\/section>/);
+ assert.match(result, /<\/body>/);
+});
+
+test('buildEditablePreviewDocument provides blank fallback', () => {
+ const result = buildEditablePreviewDocument('');
+ assert.match(result, /空白页面/);
+});
diff --git a/package.json b/package.json
index c5748fa..fe045d9 100644
--- a/package.json
+++ b/package.json
@@ -6,12 +6,15 @@
"scripts": {
"dev": "node scripts/dev.mjs",
"dev:plaza": "node scripts/dev-plaza.mjs",
+ "start:plaza": "node scripts/start-plaza-prod.mjs",
"dev:plaza-proxy": "node scripts/plaza-local-proxy.mjs",
"setup:plaza-dns": "node scripts/setup-plaza-local-dns.mjs",
+ "setup:plaza-dns:remove": "node scripts/setup-plaza-local-dns.mjs --remove",
"dev:plaza-dns": "node scripts/plaza-local-dns-server.mjs",
"open:plaza": "node scripts/open-plaza-local.mjs",
"check:plaza": "node scripts/check-plaza-local.mjs",
"setup:plaza-local": "node scripts/setup-plaza-local.mjs",
+ "setup:plaza-tunnel": "bash scripts/install-plaza-tunnel.sh",
"setup:plaza-tls": "node scripts/plaza-local-tls.mjs",
"dev:vite": "vite",
"dev:server": "node server.mjs",
@@ -22,7 +25,7 @@
"seed:plaza-covers": "node scripts/seed-plaza-covers.mjs",
"enrich:plaza": "node scripts/enrich-plaza-posts.mjs",
"build": "vite build",
- "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs",
+ "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs",
"test:mindspace-e2e": "node scripts/mindspace-e2e.mjs",
"test:mindspace-pages-e2e": "node scripts/mindspace-pages-e2e.mjs",
"test:mindspace-publications-e2e": "node scripts/mindspace-publications-e2e.mjs",
diff --git a/plaza-embed.mjs b/plaza-embed.mjs
index aea7988..d46cdec 100644
--- a/plaza-embed.mjs
+++ b/plaza-embed.mjs
@@ -27,7 +27,19 @@ const EMBED_BOOTSTRAP = ``;
+const EMBED_LAYOUT_OVERRIDES =
+ '';
+
+/** Remove inline CSP meta tags that block plaza-embed-bootstrap (HTTP CSP header applies instead). */
+export function stripPublicationHtmlCspMeta(html) {
+ return String(html ?? '').replace(
+ /]*>/gi,
+ '',
+ );
+}
+
+/** Prepare stored publication HTML for Plaza iframe embed. */
+export function preparePublicationHtmlForEmbed(html) {
+ let source = stripPublicationHtmlCspMeta(html);
+ if (!source.includes('id="plaza-embed-overrides"') && /]*>/i.test(source)) {
+ source = source.replace(/]*)>/i, `${EMBED_LAYOUT_OVERRIDES}`);
+ }
+ return injectPlazaEmbedBootstrap(source);
+}
+
export function injectPlazaEmbedBootstrap(html) {
const source = String(html ?? '');
if (source.includes('id="plaza-embed-bootstrap"')) return source;
diff --git a/plaza-embed.test.mjs b/plaza-embed.test.mjs
index acd2eaa..c4507c1 100644
--- a/plaza-embed.test.mjs
+++ b/plaza-embed.test.mjs
@@ -4,7 +4,9 @@ import {
appendPlazaEmbedQuery,
injectPlazaEmbedBootstrap,
isPlazaEmbedRequest,
+ preparePublicationHtmlForEmbed,
publishedPageCspForEmbed,
+ stripPublicationHtmlCspMeta,
} from './plaza-embed.mjs';
test('isPlazaEmbedRequest detects embed=plaza', () => {
@@ -34,3 +36,20 @@ test('injectPlazaEmbedBootstrap adds script before closing body', () => {
test('publishedPageCspForEmbed allows inline script', () => {
assert.match(publishedPageCspForEmbed(true), /script-src 'unsafe-inline'/);
});
+
+test('stripPublicationHtmlCspMeta removes inline CSP meta tags', () => {
+ const html =
+ '';
+ const out = stripPublicationHtmlCspMeta(html);
+ assert.doesNotMatch(out, /Content-Security-Policy/);
+});
+
+test('preparePublicationHtmlForEmbed strips CSP and injects bootstrap', () => {
+ const html = `
+
+`;
+ const out = preparePublicationHtmlForEmbed(html);
+ assert.doesNotMatch(out, /http-equiv="Content-Security-Policy"/);
+ assert.match(out, /plaza-embed-bootstrap/);
+ assert.match(out, /plaza-embed-overrides/);
+});
diff --git a/scripts/install-plaza-tunnel.sh b/scripts/install-plaza-tunnel.sh
new file mode 100755
index 0000000..bddca9b
--- /dev/null
+++ b/scripts/install-plaza-tunnel.sh
@@ -0,0 +1,47 @@
+#!/usr/bin/env bash
+# 将 plaza.tkmind.cn 经 Cloudflare Tunnel 指到本机 Plaza(:3001)
+#
+# 前置:
+# 1. cloudflared 已安装且 ollama-tkmind 隧道在跑
+# 2. pnpm dev:plaza 或 next start 监听 :3001
+#
+# 用法:pnpm setup:plaza-tunnel
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+CF_CONFIG="${CLOUDFLARE_TUNNEL_CONFIG:-/Users/john/Project/ollama/cloudflare/config.yml}"
+TUNNEL_NAME="${CLOUDFLARE_TUNNEL_NAME:-ollama-tkmind}"
+PLAZA_HOST="${PLAZA_LOCAL_HOST:-plaza.tkmind.cn}"
+PLAZA_PORT="${PLAZA_TUNNEL_PORT:-${PLAZA_PORT:-3001}}"
+GUI="gui/$(id -u)"
+
+chmod +x "${ROOT}/scripts/patch-plaza-tunnel-config.mjs"
+
+echo "==> 更新 cloudflared ingress(${PLAZA_HOST} → 127.0.0.1:${PLAZA_PORT})"
+CLOUDFLARE_TUNNEL_CONFIG="${CF_CONFIG}" PLAZA_TUNNEL_PORT="${PLAZA_PORT}" \
+ node "${ROOT}/scripts/patch-plaza-tunnel-config.mjs"
+
+echo "==> 注册 DNS 路由(${PLAZA_HOST} → 隧道 ${TUNNEL_NAME})"
+if cloudflared tunnel route dns list 2>/dev/null | grep -q "${PLAZA_HOST}"; then
+ echo "DNS 路由已存在,跳过"
+else
+ cloudflared tunnel route dns "${TUNNEL_NAME}" "${PLAZA_HOST}" || {
+ echo "⚠ DNS 路由失败(可能已在 Cloudflare 控制台手动配置)。继续重启 cloudflared…" >&2
+ }
+fi
+
+echo "==> 重启 cloudflared"
+launchctl kickstart -k "${GUI}/com.cloudflare.cloudflared" 2>/dev/null || {
+ echo "⚠ 未找到 LaunchAgent com.cloudflare.cloudflared,请手动:cloudflared tunnel --config ${CF_CONFIG} run" >&2
+}
+
+sleep 2
+echo ""
+echo "=== Plaza 隧道状态 ==="
+curl -s -o /dev/null -w "本机 Plaza :${PLAZA_PORT}/plaza → %{http_code}\n" \
+ "http://127.0.0.1:${PLAZA_PORT}/plaza" 2>/dev/null || echo "本机 Plaza 未运行(请先 pnpm dev:plaza)"
+curl -s -o /dev/null -w "外网 ${PLAZA_HOST}/plaza → %{http_code}\n" \
+ "https://${PLAZA_HOST}/plaza" 2>/dev/null || true
+echo ""
+echo "日志: ~/Library/Logs/com.cloudflare.cloudflared.log(或 launchctl 配置的路径)"
+echo "配置: ${CF_CONFIG}"
diff --git a/scripts/patch-plaza-tunnel-config.mjs b/scripts/patch-plaza-tunnel-config.mjs
new file mode 100755
index 0000000..01c5e74
--- /dev/null
+++ b/scripts/patch-plaza-tunnel-config.mjs
@@ -0,0 +1,60 @@
+#!/usr/bin/env node
+/**
+ * Add plaza.tkmind.cn ingress to cloudflared config (before catch-all 404).
+ *
+ * Usage:
+ * node scripts/patch-plaza-tunnel-config.mjs
+ * CLOUDFLARE_TUNNEL_CONFIG=/path/to/config.yml PLAZA_TUNNEL_PORT=3001 node ...
+ */
+import fs from 'node:fs';
+
+const configPath =
+ process.env.CLOUDFLARE_TUNNEL_CONFIG ??
+ '/Users/john/Project/ollama/cloudflare/config.yml';
+const plazaPort = Number(process.env.PLAZA_TUNNEL_PORT ?? process.env.PLAZA_PORT ?? 3001);
+const plazaHost = process.env.PLAZA_LOCAL_HOST ?? 'plaza.tkmind.cn';
+
+if (!fs.existsSync(configPath)) {
+ console.error(`错误: 未找到 cloudflared 配置:${configPath}`);
+ console.error('请设置 CLOUDFLARE_TUNNEL_CONFIG');
+ process.exit(1);
+}
+
+let content = fs.readFileSync(configPath, 'utf8');
+
+if (content.includes(`hostname: ${plazaHost}`)) {
+ const current = content.match(
+ new RegExp(`hostname: ${plazaHost.replace('.', '\\.')}[\\s\\S]*?service: (http://[^\\n]+)`),
+ );
+ const target = `http://127.0.0.1:${plazaPort}`;
+ if (current?.[1] === target) {
+ console.log(`cloudflared 已配置 ${plazaHost} → ${target}`);
+ process.exit(0);
+ }
+ content = content.replace(
+ new RegExp(
+ `(hostname: ${plazaHost.replace('.', '\\.')}[\\s\\S]*?service: )http://127\\.0\\.0\\.1:\\d+`,
+ ),
+ `$1${target}`,
+ );
+ fs.writeFileSync(configPath, content);
+ console.log(`已更新 ${plazaHost} → ${target}`);
+ process.exit(0);
+}
+
+const block = ` - hostname: ${plazaHost}
+ service: http://127.0.0.1:${plazaPort}
+ originRequest:
+ connectTimeout: 30s
+ keepAliveTimeout: 90s
+ disableChunkedEncoding: false
+`;
+
+if (!content.includes('- service: http_status:404')) {
+ console.error('错误: 配置中未找到 catch-all http_status:404 规则');
+ process.exit(1);
+}
+
+content = content.replace(/\n - service: http_status:404/, `\n${block}\n - service: http_status:404`);
+fs.writeFileSync(configPath, content);
+console.log(`已添加 ${plazaHost} → http://127.0.0.1:${plazaPort}`);
diff --git a/scripts/plaza-rich-content.mjs b/scripts/plaza-rich-content.mjs
index 3393651..897096e 100644
--- a/scripts/plaza-rich-content.mjs
+++ b/scripts/plaza-rich-content.mjs
@@ -183,7 +183,7 @@ export function buildJohnPage(opts) {
text-decoration: none;
box-shadow: 0 8px 28px ${accent}44;
}
- .section { max-width: 1100px; margin: 0 auto; padding: 72px 24px; content-visibility: auto; contain-intrinsic-size: auto 360px; }
+ .section { max-width: 1100px; margin: 0 auto; padding: 72px 24px; }
.section h2 { font-size: 26px; font-weight: 700; margin-bottom: 8px; }
.section .hint { color: rgba(255,255,255,0.38); font-size: 15px; margin-bottom: 36px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 20px; }
diff --git a/scripts/start-plaza-prod.mjs b/scripts/start-plaza-prod.mjs
new file mode 100644
index 0000000..eacf11e
--- /dev/null
+++ b/scripts/start-plaza-prod.mjs
@@ -0,0 +1,169 @@
+#!/usr/bin/env node
+/**
+ * Plaza 本机正式模式:Portal API + Next.js build/start(供 Cloudflare Tunnel 回源)。
+ *
+ * Usage:
+ * pnpm start:plaza
+ * pnpm start:plaza -- --skip-build # 跳过 next build(已有 .next 产物时)
+ */
+import { spawn, execSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
+const plazaDir = process.env.PLAZA_APP_DIR ?? path.join(root, '../tkmind_go/ui/plaza');
+const portalPort = Number(process.env.H5_PORT ?? 8081);
+const plazaPort = Number(process.env.PLAZA_PORT ?? 3001);
+const plazaHost = process.env.PLAZA_LOCAL_HOST ?? 'plaza.tkmind.cn';
+const plazaPublicBase = (
+ process.env.PLAZA_PUBLIC_BASE ?? 'https://plaza.tkmind.cn'
+).replace(/\/$/, '');
+const portalUrl = `http://127.0.0.1:${portalPort}`;
+const skipBuild = process.argv.includes('--skip-build');
+
+function loadEnvFile(filePath) {
+ if (!fs.existsSync(filePath)) return;
+ for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('#')) continue;
+ const eq = trimmed.indexOf('=');
+ if (eq < 0) continue;
+ const key = trimmed.slice(0, eq).trim();
+ const value = trimmed.slice(eq + 1).trim();
+ if (!process.env[key]) process.env[key] = value;
+ }
+}
+
+loadEnvFile(path.join(root, '../../.env.local'));
+loadEnvFile(path.join(root, '.env'));
+
+function freePort(port) {
+ try {
+ execSync(`lsof -ti TCP:${port} -sTCP:LISTEN | xargs kill -9`, { stdio: 'ignore' });
+ } catch {
+ // already free
+ }
+}
+
+async function portOpen(port) {
+ try {
+ const res = await fetch(`http://127.0.0.1:${port}/auth/status`);
+ return res.ok;
+ } catch {
+ return false;
+ }
+}
+
+async function waitFor(check, label, retries = 120) {
+ for (let i = 0; i < retries; i += 1) {
+ try {
+ if (await check()) return;
+ } catch {
+ // still starting
+ }
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ }
+ throw new Error(`${label} 启动超时`);
+}
+
+let portal;
+let plaza;
+let stopping = false;
+
+function spawnChild(command, args, label, cwd = root, extraEnv = {}) {
+ const child = spawn(command, args, {
+ cwd,
+ env: { ...process.env, NODE_ENV: 'production', ...extraEnv },
+ stdio: 'inherit',
+ });
+ child.on('exit', (code, signal) => {
+ if (signal || stopping) return;
+ if (code && code !== 0) {
+ console.error(`[${label}] exited with code ${code}`);
+ shutdown(code ?? 1);
+ }
+ });
+ return child;
+}
+
+function shutdown(code = 0) {
+ if (stopping) return;
+ stopping = true;
+ portal?.kill('SIGTERM');
+ plaza?.kill('SIGTERM');
+ setTimeout(() => process.exit(code), 300);
+}
+
+process.on('SIGINT', () => shutdown(0));
+process.on('SIGTERM', () => shutdown(0));
+
+if (!fs.existsSync(path.join(plazaDir, 'package.json'))) {
+ console.error(`未找到 Plaza Next.js:${plazaDir}`);
+ process.exit(1);
+}
+
+const mindSpacePublicBase = (
+ process.env.H5_PUBLIC_BASE_URL ??
+ process.env.VITE_MINDSPACE_BASE ??
+ 'https://g2.tkmind.cn'
+).replace(/\/$/, '');
+
+const plazaEnv = {
+ PLAZA_API_PROXY: portalUrl,
+ PLAZA_API_BASE: portalUrl,
+ PLAZA_PUBLIC_BASE: plazaPublicBase,
+ NEXT_PUBLIC_API_BASE: plazaPublicBase,
+ NEXT_PUBLIC_SITE_BASE: plazaPublicBase,
+ NEXT_PUBLIC_MINDSPACE_BASE: mindSpacePublicBase,
+ NEXT_PUBLIC_PLAZA_BASE: '/plaza',
+};
+
+console.log('==> Plaza 本机正式模式(next build + start)');
+console.log(` 公开地址 ${plazaPublicBase}/plaza`);
+console.log(` 内部端口 ${plazaPort}`);
+
+freePort(plazaPort);
+
+try {
+ if (!(await portOpen(portalPort))) {
+ console.log(`==> 启动 Portal @ ${portalUrl}`);
+ portal = spawnChild('node', ['server.mjs'], 'portal');
+ await waitFor(() => portOpen(portalPort), 'Portal');
+ } else {
+ console.log(`==> Portal 已在运行: ${portalUrl}`);
+ }
+
+ if (!skipBuild) {
+ console.log('==> 构建 Plaza Next.js(生产)…');
+ execSync('npm run build', { cwd: plazaDir, stdio: 'inherit', env: { ...process.env, ...plazaEnv } });
+ } else {
+ console.log('==> 跳过 next build(--skip-build)');
+ }
+
+ console.log(`==> 启动 Plaza Next.js 正式服务 (0.0.0.0:${plazaPort})`);
+ plaza = spawnChild(
+ 'npm',
+ ['run', 'start', '--', '-H', '0.0.0.0', '-p', String(plazaPort)],
+ 'plaza',
+ plazaDir,
+ plazaEnv,
+ );
+
+ await waitFor(async () => {
+ const res = await fetch(`http://127.0.0.1:${plazaPort}/plaza`, {
+ headers: { Host: plazaHost },
+ });
+ return res.ok || res.status === 307 || res.status === 308;
+ }, 'Plaza Next.js');
+
+ console.log('');
+ console.log('Plaza 正式模式已就绪:');
+ console.log(` 本机 http://127.0.0.1:${plazaPort}/plaza`);
+ console.log(` 公网 ${plazaPublicBase}/plaza (需 Cloudflare Tunnel 回源 :${plazaPort})`);
+ console.log('');
+ console.log('隧道未配置或外网 502 时:pnpm setup:plaza-tunnel');
+} catch (err) {
+ console.error(err instanceof Error ? err.message : err);
+ shutdown(1);
+}
diff --git a/server.mjs b/server.mjs
index e6c8c75..1fbb9cd 100644
--- a/server.mjs
+++ b/server.mjs
@@ -46,7 +46,7 @@ import { startPlazaTasks, writebackPublications } from './plaza-tasks.mjs';
import { createPlazaSeoService } from './plaza-seo.mjs';
import { createPlazaOpsService, hasOpsRole } from './plaza-ops.mjs';
import {
- injectPlazaEmbedBootstrap,
+ preparePublicationHtmlForEmbed,
isPlazaEmbedRequest,
publishedPageCspForEmbed,
} from './plaza-embed.mjs';
@@ -3213,7 +3213,7 @@ function publishedPageCsp(html, { embed = false } = {}) {
function sendPublishedPage(res, result, { embed = false } = {}) {
let html = result.html;
if (embed) {
- html = injectPlazaEmbedBootstrap(html);
+ html = preparePublicationHtmlForEmbed(html);
}
res.set('Content-Type', 'text/html; charset=utf-8');
res.set('Content-Security-Policy', publishedPageCsp(html, { embed }));
diff --git a/src/components/MindSpacePageDetail.tsx b/src/components/MindSpacePageDetail.tsx
index dd093bb..64cd0a6 100644
--- a/src/components/MindSpacePageDetail.tsx
+++ b/src/components/MindSpacePageDetail.tsx
@@ -31,7 +31,6 @@ import {
} from './MindSpacePageFullscreenPreview';
import { MindSpacePagePreviewPanel } from './MindSpacePagePreviewPanel';
import { MindSpaceModal } from './MindSpaceModal';
-import { mergeMindSpacePagePatch, type MindSpacePagePatch } from '../utils/mindspacePagePatch';
type AccessMode = MindSpacePublishCheck['accessMode'];
@@ -97,8 +96,6 @@ export function MindSpacePageDetail({
onContextUpdate,
autoOpenPlaza = false,
refreshTrigger = 0,
- incomingPagePatch = null,
- onPagePatchHandled,
overlayChat = null,
onFullscreenPreviewChange,
}: {
@@ -113,8 +110,6 @@ export function MindSpacePageDetail({
}) => void;
autoOpenPlaza?: boolean;
refreshTrigger?: number;
- incomingPagePatch?: { patch: MindSpacePagePatch; sourceMessageId: string } | null;
- onPagePatchHandled?: (sourceMessageId: string) => void;
overlayChat?: MindSpacePageFullscreenPreviewChat | null;
onFullscreenPreviewChange?: (open: boolean) => void;
}) {
@@ -134,6 +129,8 @@ export function MindSpacePageDetail({
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [previewKey, setPreviewKey] = useState(0);
+ const [previewContent, setPreviewContent] = useState('');
+ const [previewRefreshPending, setPreviewRefreshPending] = useState(false);
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
const [changeHighlight, setChangeHighlight] = useState(
null,
@@ -162,8 +159,6 @@ export function MindSpacePageDetail({
const [plazaPost, setPlazaPost] = useState(null);
const [pushToPlaza, setPushToPlaza] = useState(false);
const [fixNotice, setFixNotice] = useState(null);
- const [patchNotice, setPatchNotice] = useState(null);
- const [patchBusy, setPatchBusy] = useState(false);
const applyPageRecord = useCallback(
(next: MindSpacePage, options?: { recordHistory?: boolean }) => {
@@ -192,6 +187,9 @@ export function MindSpacePageDetail({
summary: next.summary,
content: next.content ?? '',
});
+ setPreviewContent(next.content ?? '');
+ setPreviewRefreshPending(false);
+ setPreviewKey((value) => value + 1);
setTemplateId(next.templateId);
setUrlSlug(next.publication?.urlSlug ?? slugFromTitle(next.title, next.id));
setAccessMode(next.publication?.accessMode ?? 'public');
@@ -419,6 +417,34 @@ export function MindSpacePageDetail({
});
}, []);
+ const markPreviewRefreshPending = useCallback(() => {
+ setPreviewRefreshPending(true);
+ }, []);
+
+ const handleManualPreviewRefresh = useCallback((nextContent?: string) => {
+ const html = nextContent ?? content;
+ setPreviewContent(html);
+ setPreviewRefreshPending(false);
+ setPreviewKey((value) => value + 1);
+ }, [content]);
+
+ const syncPreviewAfterHistory = useCallback(
+ (snapshot: PageDraftSnapshot) => {
+ if (previewRefreshPending) return;
+ setPreviewContent(snapshot.content);
+ setPreviewKey((value) => value + 1);
+ },
+ [previewRefreshPending],
+ );
+
+ const handlePreviewContentChange = useCallback(
+ (html: string) => {
+ setPreviewContent(html);
+ replaceDraft({ ...draft, content: html }, { recordHistory: true });
+ },
+ [draft, replaceDraft],
+ );
+
const refreshFromServer = useCallback(async () => {
const currentPage = page;
if (!currentPage) return;
@@ -438,52 +464,12 @@ export function MindSpacePageDetail({
}
applyPageRecord(next, { recordHistory: true });
if (draftChanged) {
- setPreviewKey((value) => value + 1);
+ markPreviewRefreshPending();
}
} catch (err) {
setError(err instanceof Error ? err.message : '页面刷新失败');
}
- }, [applyPageRecord, content, page, pageId, summary, title, triggerChangeHighlight]);
-
- const applyIncomingPatch = useCallback(
- async (patch: MindSpacePagePatch) => {
- if (!page) return;
- const merged = mergeMindSpacePagePatch({ title, summary, content }, patch);
- triggerChangeHighlight({ title, summary, content });
- setPatchBusy(true);
- setPatchNotice(null);
- setError(null);
- try {
- const updated = await updateMindSpacePage(page.id, {
- expectedVersion: page.versionNo,
- title: merged.title,
- summary: merged.summary,
- content: merged.content,
- templateId,
- changeNote: 'Agent 对话修改',
- });
- applyPageRecord(updated, { recordHistory: true });
- setPreviewKey((value) => value + 1);
- setPatchNotice('Agent 修改已同步到预览');
- } catch (err) {
- applyPageRecord(
- {
- ...page,
- title: merged.title,
- summary: merged.summary,
- content: merged.content,
- },
- { recordHistory: true },
- );
- setPreviewKey((value) => value + 1);
- setPatchNotice('预览已更新(保存到服务器失败,请稍后手动保存)');
- setError(err instanceof Error ? err.message : '保存 Agent 修改失败');
- } finally {
- setPatchBusy(false);
- }
- },
- [applyPageRecord, content, page, summary, templateId, title, triggerChangeHighlight],
- );
+ }, [applyPageRecord, content, markPreviewRefreshPending, page, pageId, summary, title, triggerChangeHighlight]);
const applyOneClickFix = async () => {
if (!page) return;
@@ -519,24 +505,6 @@ export function MindSpacePageDetail({
void refreshFromServer();
}, [refreshTrigger, refreshFromServer]);
- useEffect(() => {
- if (!incomingPagePatch || !page || patchBusy) return;
- let cancelled = false;
- void applyIncomingPatch(incomingPagePatch.patch).finally(() => {
- if (!cancelled) onPagePatchHandled?.(incomingPagePatch.sourceMessageId);
- });
- return () => {
- cancelled = true;
- };
- }, [
- incomingPagePatch?.sourceMessageId,
- incomingPagePatch?.patch,
- page?.id,
- patchBusy,
- applyIncomingPatch,
- onPagePatchHandled,
- ]);
-
useEffect(() => {
if (autoOpenPlaza && page?.publication && !plazaOpen) {
void openPlazaPanel();
@@ -556,16 +524,24 @@ export function MindSpacePageDetail({
return;
}
if (event.key.toLowerCase() === 'z' && event.shiftKey) {
- if (redo()) event.preventDefault();
+ const next = redo();
+ if (next) {
+ syncPreviewAfterHistory(next);
+ event.preventDefault();
+ }
return;
}
if (event.key.toLowerCase() === 'z') {
- if (undo()) event.preventDefault();
+ const previous = undo();
+ if (previous) {
+ syncPreviewAfterHistory(previous);
+ event.preventDefault();
+ }
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
- }, [redo, undo]);
+ }, [redo, syncPreviewAfterHistory, undo]);
useEffect(() => {
if (!page || !onContextUpdate) return;
@@ -581,17 +557,23 @@ export function MindSpacePageDetail({
const closeFullscreenPreview = () => {
setFullscreenPreviewOpen(false);
onFullscreenPreviewChange?.(false);
+ setPreviewKey((value) => value + 1);
+ setPreviewRefreshPending(false);
};
const handlePreviewUndo = () => {
- if (!undo()) return false;
- setPreviewKey((value) => value + 1);
+ const previous = undo();
+ if (!previous) return false;
+ if (previewRefreshPending) return true;
+ handleManualPreviewRefresh(previous.content);
return true;
};
const handlePreviewRedo = () => {
- if (!redo()) return false;
- setPreviewKey((value) => value + 1);
+ const next = redo();
+ if (!next) return false;
+ if (previewRefreshPending) return true;
+ handleManualPreviewRefresh(next.content);
return true;
};
@@ -649,7 +631,6 @@ export function MindSpacePageDetail({
{error && !deletePreviewOpen && {error}
}
- {patchNotice ? {patchNotice}
: null}
-
{page.contentFormat === 'html' ? '页面预览' : '安全预览'}
+
{page.contentFormat === 'html' ? '页面预览编辑' : '安全预览'}
- {patchBusy ? 同步 Agent 修改… : null}
-
@@ -1099,11 +1090,9 @@ export function MindSpacePageDetail({
pageId={page.id}
title={title}
summary={summary}
- content={content}
+ content={previewContent}
reloadKey={previewKey}
- onContentChange={(html) =>
- replaceDraft({ ...draft, content: html }, { recordHistory: true })
- }
+ onContentChange={handlePreviewContentChange}
/>
) : (
)}
{page.contentFormat === 'html' && (
@@ -1123,7 +1113,13 @@ export function MindSpacePageDetail({
className="mindspace-page-content-input"
value={content}
rows={22}
- onChange={(event) => updateDraft({ ...draft, content: event.target.value })}
+ onChange={(event) => {
+ const nextContent = event.target.value;
+ updateDraft({ ...draft, content: nextContent });
+ if (!previewRefreshPending) {
+ setPreviewContent(nextContent);
+ }
+ }}
/>
)}
@@ -1164,26 +1160,19 @@ export function MindSpacePageDetail({
pageTitle={title || page.title}
title={title}
summary={summary}
- content={content}
+ content={previewContent}
templateId={templateId}
reloadKey={previewKey}
- patchBusy={patchBusy}
changeHighlight={changeHighlight}
canUndo={canUndo}
canRedo={canRedo}
onUndo={handlePreviewUndo}
onRedo={handlePreviewRedo}
onClose={closeFullscreenPreview}
- onRefresh={() => setPreviewKey((value) => value + 1)}
- onAgentIdleSync={() => void refreshFromServer()}
- chat={
- overlayChat
- ? {
- ...overlayChat,
- onPagePatch: (patch) => void applyIncomingPatch(patch),
- }
- : null
- }
+ onRefresh={() => handleManualPreviewRefresh()}
+ refreshPending={previewRefreshPending}
+ onContentChange={handlePreviewContentChange}
+ chat={overlayChat}
/>
) : null}
>
diff --git a/src/components/MindSpacePageDraftPreviewFrame.tsx b/src/components/MindSpacePageDraftPreviewFrame.tsx
index c613c5a..37dbccd 100644
--- a/src/components/MindSpacePageDraftPreviewFrame.tsx
+++ b/src/components/MindSpacePageDraftPreviewFrame.tsx
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { fetchMindSpacePageDraftPreview } from '../api/client';
+import { bindIframeDocumentHeightSync } from '../utils/iframeContentHeight';
import { applyPageChangeHighlight } from '../utils/mindspacePageChangeHighlight';
export type MindSpacePreviewChangeHighlight = {
@@ -15,8 +16,9 @@ export function MindSpacePageDraftPreviewFrame({
content,
templateId,
reloadKey = 0,
+ liveUpdate = true,
minHeight = 680,
- maxHeight = 4800,
+ maxHeight = 12000,
className = 'mindspace-page-preview-frame',
changeHighlight = null,
}: {
@@ -26,6 +28,7 @@ export function MindSpacePageDraftPreviewFrame({
content: string;
templateId: string;
reloadKey?: number;
+ liveUpdate?: boolean;
minHeight?: number;
maxHeight?: number;
className?: string;
@@ -62,7 +65,9 @@ export function MindSpacePageDraftPreviewFrame({
}, hasRenderedRef.current ? 450 : 120);
return () => window.clearTimeout(timer);
- }, [pageId, title, summary, content, templateId, reloadKey]);
+ }, liveUpdate
+ ? [pageId, title, summary, content, templateId, reloadKey]
+ : [pageId, reloadKey, templateId]);
useEffect(() => {
setHeight(minHeight);
@@ -72,27 +77,16 @@ export function MindSpacePageDraftPreviewFrame({
const iframe = iframeRef.current;
if (!iframe || !srcdoc) return;
- const syncHeight = () => {
- try {
- const doc = iframe.contentDocument;
- if (!doc) return;
- const body = doc.body;
- const root = doc.documentElement;
- const next = Math.ceil(
- Math.max(
- body?.scrollHeight ?? 0,
- body?.offsetHeight ?? 0,
- root?.scrollHeight ?? 0,
- root?.offsetHeight ?? 0,
- minHeight,
- ),
- );
- setHeight(Math.min(next, maxHeight));
+ const applyHeight = (nextHeight: number) => {
+ setHeight(nextHeight);
- if (
- changeHighlight &&
- appliedHighlightRevisionRef.current !== changeHighlight.revision
- ) {
+ if (
+ changeHighlight &&
+ appliedHighlightRevisionRef.current !== changeHighlight.revision
+ ) {
+ try {
+ const doc = iframe.contentDocument;
+ if (!doc) return;
appliedHighlightRevisionRef.current = changeHighlight.revision;
applyPageChangeHighlight(doc, {
previousTitle: changeHighlight.previousTitle,
@@ -100,14 +94,17 @@ export function MindSpacePageDraftPreviewFrame({
previousContent: changeHighlight.previousContent,
nextContent: content,
});
+ } catch {
+ // ignore highlight errors in sandboxed preview
}
- } catch {
- setHeight(minHeight);
}
};
- iframe.addEventListener('load', syncHeight);
- return () => iframe.removeEventListener('load', syncHeight);
+ return bindIframeDocumentHeightSync(iframe, {
+ minHeight,
+ maxHeight,
+ onHeight: applyHeight,
+ });
}, [changeHighlight, content, maxHeight, minHeight, srcdoc, title]);
if (error) {
diff --git a/src/components/MindSpacePageEditablePreviewFrame.tsx b/src/components/MindSpacePageEditablePreviewFrame.tsx
new file mode 100644
index 0000000..9051ee3
--- /dev/null
+++ b/src/components/MindSpacePageEditablePreviewFrame.tsx
@@ -0,0 +1,113 @@
+import { useEffect, useRef, useState } from 'react';
+import { bindIframeDocumentHeightSync, measureDocumentHeight } from '../utils/iframeContentHeight';
+import {
+ buildEditablePreviewDocument,
+ MINDSPACE_PAGE_CONTENT_MESSAGE,
+} from '../utils/mindspaceVisualEditor';
+
+export function MindSpacePageEditablePreviewFrame({
+ title,
+ content,
+ reloadKey,
+ minHeight = 680,
+ maxHeight = 12000,
+ className = 'mindspace-page-preview-frame',
+ showHint = true,
+ onContentChange,
+}: {
+ title: string;
+ content: string;
+ reloadKey: number;
+ minHeight?: number;
+ maxHeight?: number;
+ className?: string;
+ showHint?: boolean;
+ onContentChange: (html: string) => void;
+}) {
+ const [previewFailed, setPreviewFailed] = useState(false);
+ const [srcdoc, setSrcdoc] = useState(() => buildEditablePreviewDocument(content));
+ const [height, setHeight] = useState(minHeight);
+ const iframeContentRef = useRef(content);
+ const iframeRef = useRef(null);
+ const syncHeightRef = useRef<(() => void) | null>(null);
+
+ useEffect(() => {
+ iframeContentRef.current = content;
+ setSrcdoc(buildEditablePreviewDocument(content));
+ setPreviewFailed(false);
+ setHeight(minHeight);
+ }, [reloadKey]);
+
+ useEffect(() => {
+ if (content === iframeContentRef.current) return;
+ iframeContentRef.current = content;
+ setSrcdoc(buildEditablePreviewDocument(content));
+ setPreviewFailed(false);
+ setHeight(minHeight);
+ }, [content, minHeight]);
+
+ useEffect(() => {
+ const onMessage = (event: MessageEvent) => {
+ if (event.source !== iframeRef.current?.contentWindow) return;
+ if (event.data?.type !== MINDSPACE_PAGE_CONTENT_MESSAGE) return;
+ const html = String(event.data.html ?? '');
+ if (!html.trim()) return;
+ iframeContentRef.current = html;
+ onContentChange(html);
+ syncHeightRef.current?.();
+ };
+
+ window.addEventListener('message', onMessage);
+ return () => window.removeEventListener('message', onMessage);
+ }, [onContentChange]);
+
+ useEffect(() => {
+ const iframe = iframeRef.current;
+ if (!iframe || !srcdoc) return;
+
+ const applyHeight = (nextHeight: number) => setHeight(nextHeight);
+ const unbind = bindIframeDocumentHeightSync(iframe, {
+ minHeight,
+ maxHeight,
+ onHeight: applyHeight,
+ });
+ syncHeightRef.current = () => {
+ try {
+ const doc = iframe.contentDocument;
+ if (!doc) return;
+ applyHeight(Math.min(measureDocumentHeight(doc, minHeight), maxHeight));
+ } catch {
+ applyHeight(minHeight);
+ }
+ };
+
+ return () => {
+ syncHeightRef.current = null;
+ unbind();
+ };
+ }, [maxHeight, minHeight, srcdoc]);
+
+ if (previewFailed) {
+ return 页面预览加载失败
;
+ }
+
+ return (
+ <>
+ {showHint ? (
+
+ 文字直接改 · 图片/音视频点击换 · 背景点空白区 · 链接双击改地址
+
+ ) : null}
+