feat(mindspace): add SEO/GEO delivery, page template catalog, and admin hooks
Memind CI / Test, build, and release guards (push) Has been cancelled

Enable optional SEO/GEO injection and discovery routes for confirmed public pages while keeping private pages noindex. Add premium page template skills, portal catalog API, template shop UI, and Baidu push gated by memind_adm config.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-10 08:06:12 +08:00
parent 7c65540366
commit fde6503bdf
158 changed files with 9194 additions and 270 deletions
@@ -0,0 +1,876 @@
#!/usr/bin/env node
/**
* One-shot bootstrap: upgrade existing page-template HTML + add 7 premium templates.
* Run: node scripts/bootstrap-premium-page-templates.mjs
*/
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 skillsRoot = path.join(root, 'skills');
const previewRoot = path.join(root, 'public/assets/template-previews');
const FOOTER =
'<footer><p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p></footer>';
function writeSkillFile(skillName, relativePath, content) {
const full = path.join(skillsRoot, skillName, relativePath);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, `${content.trim()}\n`, 'utf8');
}
function writePreview(name, svg) {
fs.writeFileSync(path.join(previewRoot, name), `${svg.trim()}\n`, 'utf8');
}
const upgraded = {
'page-template-travel/templates/travel-hero.html': `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --accent2: {{ACCENT2}}; --ink: #f5f7fa; --muted: #a8b8cc; --bg: #060d18; }
* { box-sizing: border-box; }
body { margin: 0; font-family: "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", sans-serif; background: var(--bg); color: var(--ink); }
.hero { position: relative; min-height: 78vh; display: grid; align-items: end; padding: clamp(28px, 7vw, 80px); overflow: hidden; }
.hero::before { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(6,13,24,.15) 0%, rgba(6,13,24,.55) 45%, rgba(6,13,24,.94) 100%), url('{{HERO_IMAGE}}') center/cover no-repeat; transform: scale(1.02); }
.hero::after { content: ""; position: absolute; inset: 0; background: radial-gradient(ellipse 80% 50% at 20% 0%, rgba(245,158,11,.18), transparent 60%); pointer-events: none; }
.hero-inner { position: relative; z-index: 1; max-width: 920px; }
.eyebrow { display: inline-flex; align-items: center; gap: 8px; color: var(--accent2); font-size: 11px; letter-spacing: .22em; text-transform: uppercase; font-weight: 800; }
.eyebrow::before { content: ""; width: 28px; height: 1px; background: var(--accent2); opacity: .7; }
h1 { margin: 16px 0 12px; font-size: clamp(38px, 8.5vw, 68px); line-height: 1.02; font-weight: 800; letter-spacing: -.02em; }
.subtitle { max-width: 40rem; color: var(--muted); font-size: clamp(16px, 2.2vw, 19px); line-height: 1.75; }
main { padding: clamp(32px, 6vw, 80px) clamp(20px, 5vw, 48px); max-width: 920px; margin: 0 auto; }
.content { font-size: 18px; line-height: 1.9; color: #dbe4ef; }
.content h2 { color: #fff; margin: 2.5rem 0 1rem; font-size: clamp(22px, 4vw, 28px); letter-spacing: -.01em; }
.content p { margin: 0 0 1.2em; }
footer { padding: 40px 24px 56px; text-align: center; color: #6b8299; font-size: 13px; letter-spacing: .06em; }
</style>
</head>
<body>
<header class="hero">
<div class="hero-inner">
<div class="eyebrow">{{HERO_EYEBROW}}</div>
<h1>{{HERO_HEADLINE}}</h1>
<p class="subtitle">{{HERO_SUBTITLE}}</p>
</div>
</header>
<main><div class="content">{{MAIN_CONTENT_HTML}}</div></main>
${FOOTER}
</body>
</html>`,
'page-template-campaign/templates/campaign-cta.html': `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --accent2: {{ACCENT2}}; }
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; background: linear-gradient(165deg, #fff9f5 0%, #fff 40%, #fef3ec 100%); color: #1a2332; }
.hero { display: grid; gap: clamp(24px, 4vw, 48px); padding: clamp(32px, 7vw, 72px); grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); align-items: center; max-width: 1100px; margin: 0 auto; }
.hero-visual { position: relative; }
.hero-visual img { width: 100%; border-radius: 24px; object-fit: cover; aspect-ratio: 4/3; box-shadow: 0 32px 64px rgba(239,68,68,.15), 0 8px 24px rgba(31,41,51,.08); }
.hero-visual::after { content: ""; position: absolute; inset: -8px; border-radius: 28px; border: 1px solid rgba(239,68,68,.12); pointer-events: none; }
.tag { display: inline-block; padding: 6px 14px; border-radius: 999px; background: rgba(239,68,68,.08); color: var(--accent); font-weight: 800; letter-spacing: .14em; font-size: 11px; }
h1 { margin: 16px 0 12px; font-size: clamp(34px, 6.5vw, 56px); line-height: 1.06; font-weight: 800; letter-spacing: -.02em; }
.lead { color: #5c6b7a; font-size: 18px; line-height: 1.75; max-width: 36rem; }
.cta { display: inline-flex; align-items: center; gap: 8px; margin-top: 24px; padding: 16px 32px; border-radius: 999px; background: linear-gradient(135deg, var(--accent), var(--accent2)); color: #fff; text-decoration: none; font-weight: 700; font-size: 16px; box-shadow: 0 12px 32px rgba(239,68,68,.28); }
section { padding: 8px clamp(20px, 5vw, 64px) 32px; max-width: 960px; margin: 0 auto; }
.panel { background: #fff; border-radius: 24px; padding: clamp(24px, 4vw, 36px); box-shadow: 0 20px 60px rgba(31,41,51,.06); border: 1px solid rgba(31,41,51,.04); margin-bottom: 24px; }
.panel h2 { margin-top: 0; font-size: 22px; }
.cta-row { text-align: center; padding-bottom: 48px; }
footer { text-align: center; padding: 32px 16px 48px; color: #94a3b8; font-size: 13px; }
</style>
</head>
<body>
<header class="hero">
<div>
<div class="tag">{{PRODUCT_NAME}}</div>
<h1>{{HERO_HEADLINE}}</h1>
<p class="lead">{{HERO_SUBTITLE}}</p>
<a class="cta" href="{{CTA_URL}}">{{CTA_LABEL}}</a>
</div>
<div class="hero-visual"><img src="{{HERO_IMAGE}}" alt="{{PRODUCT_NAME}}"></div>
</header>
<section><div class="panel">{{SELLING_POINTS_HTML}}</div></section>
<section><div class="panel">{{OFFER_HTML}}</div></section>
<section class="cta-row"><a class="cta" href="{{CTA_URL}}">{{CTA_LABEL}}</a></section>
${FOOTER}
</body>
</html>`,
'page-template-survey/templates/survey-basic.html': `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<style>
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; background: radial-gradient(ellipse 120% 80% at 50% -20%, #e8f5ef, #f7f4ed 45%, #f0ebe3 100%); color: #1a2420; }
main { max-width: 640px; margin: 0 auto; padding: clamp(28px, 6vw, 64px) clamp(20px, 4vw, 32px); }
.card { background: rgba(255,255,255,.92); backdrop-filter: blur(12px); border-radius: 28px; padding: clamp(28px, 5vw, 44px); box-shadow: 0 24px 80px rgba(45,53,47,.1), 0 1px 0 rgba(255,255,255,.8) inset; border: 1px solid rgba(255,255,255,.6); }
h1 { margin: 0 0 12px; font-size: clamp(28px, 5.5vw, 40px); font-weight: 800; letter-spacing: -.02em; line-height: 1.15; }
.intro { color: #5c655f; line-height: 1.75; margin-bottom: 28px; font-size: 16px; }
label { display: block; font-weight: 600; margin: 20px 0 8px; font-size: 14px; color: #374151; }
input, textarea, select { width: 100%; padding: 14px 16px; border: 1px solid #e5e0d6; border-radius: 14px; font: inherit; background: #faf9f7; transition: border-color .2s, box-shadow .2s; }
input:focus, textarea:focus, select:focus { outline: none; border-color: #2f6f57; box-shadow: 0 0 0 3px rgba(47,111,87,.12); }
button[type="submit"] { margin-top: 28px; width: 100%; padding: 16px; border: 0; border-radius: 999px; background: linear-gradient(135deg, #2f6f57, #3d8b6e); color: #fff; font-size: 16px; font-weight: 700; cursor: pointer; box-shadow: 0 12px 32px rgba(47,111,87,.25); }
#status { margin-top: 16px; min-height: 1.2em; color: #2f6f57; font-weight: 600; text-align: center; }
footer { text-align: center; padding: 24px; color: #8a938d; font-size: 13px; }
</style>
</head>
<body>
<main>
<article class="card">
<h1>{{SURVEY_HEADLINE}}</h1>
<div class="intro">{{SURVEY_INTRO_HTML}}</div>
<form id="survey-form">
{{FORM_FIELDS_HTML}}
<button type="submit">{{SUBMIT_BUTTON_LABEL}}</button>
</form>
<div id="status" aria-live="polite"></div>
</article>
</main>
${FOOTER}
<script src="/assets/page-data-client.js"></script>
<script>
(function () {
var datasetId = '{{DATASET_ID}}';
var form = document.getElementById('survey-form');
var status = document.getElementById('status');
if (!window.PageDataClient || !datasetId) { status.textContent = 'Page Data 未就绪'; return; }
form.addEventListener('submit', function (event) {
event.preventDefault();
status.textContent = '提交中…';
var data = Object.fromEntries(new FormData(form).entries());
window.PageDataClient.insert(datasetId, data)
.then(function () { status.textContent = '提交成功,感谢填写!'; form.reset(); })
.catch(function () { status.textContent = '提交失败,请稍后重试'; });
});
})();
</script>
</body>
</html>`,
'page-template-restaurant/templates/restaurant-menu.html': `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --gold: #e8c896; --bg: #120c06; --card: rgba(255,248,240,.04); }
* { box-sizing: border-box; }
body { margin: 0; font-family: "Songti SC", Georgia, "Times New Roman", serif; background: var(--bg); color: #f5efe6; }
header { text-align: center; padding: clamp(48px, 10vw, 88px) 24px 40px; background: radial-gradient(ellipse 100% 80% at 50% 0%, #2a1a0c, var(--bg)); border-bottom: 1px solid rgba(232,200,150,.12); }
.tag { color: var(--accent); letter-spacing: .28em; font-size: 11px; font-weight: 700; font-family: system-ui, sans-serif; }
h1 { margin: 12px 0 8px; font-size: clamp(32px, 7vw, 52px); font-weight: 400; letter-spacing: .06em; }
.subtitle { color: #c4b5a0; font-size: 16px; font-style: italic; max-width: 28rem; margin: 0 auto; line-height: 1.6; }
main { max-width: 680px; margin: 0 auto; padding: clamp(32px, 6vw, 56px) 24px; }
.menu-section { background: var(--card); border: 1px solid rgba(232,200,150,.1); border-radius: 20px; padding: 28px 24px; margin-bottom: 20px; }
.menu-section h2 { margin: 0 0 16px; font-size: 20px; color: var(--gold); font-weight: 400; letter-spacing: .12em; text-transform: uppercase; font-family: system-ui, sans-serif; font-size: 13px; }
.menu-item { display: flex; justify-content: space-between; gap: 16px; padding: 10px 0; border-bottom: 1px dotted rgba(232,200,150,.15); font-size: 16px; }
.menu-item:last-child { border-bottom: 0; }
.menu-item .price { color: var(--accent); font-family: system-ui, sans-serif; font-weight: 700; white-space: nowrap; }
footer { text-align: center; padding: 40px 24px 56px; color: #7a6b58; font-size: 13px; font-family: system-ui, sans-serif; }
</style>
</head>
<body>
<header>
<div class="tag">{{VENUE_TAG}}</div>
<h1>{{VENUE_NAME}}</h1>
<p class="subtitle">{{VENUE_SUBTITLE}}</p>
</header>
<main>{{MENU_SECTIONS_HTML}}</main>
${FOOTER}
</body>
</html>`,
'page-template-portfolio/templates/portfolio-grid.html': `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --bg: #0a0c10; --card: #141820; --border: #252d3d; }
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; background: var(--bg); color: #e8ecf4; min-height: 100vh; }
.intro { text-align: center; padding: clamp(48px, 10vw, 88px) 24px 32px; }
h1 { margin: 0 0 10px; font-size: clamp(32px, 6vw, 48px); font-weight: 800; letter-spacing: -.03em; background: linear-gradient(135deg, #fff 30%, var(--accent)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.lead { color: #94a3b8; margin: 0; font-size: 17px; letter-spacing: .04em; }
.grid { display: grid; gap: 20px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); max-width: 1040px; margin: 0 auto; padding: 0 24px 64px; }
.card { background: var(--card); border-radius: 20px; padding: 24px; border: 1px solid var(--border); transition: transform .25s, border-color .25s, box-shadow .25s; }
.card h3 { margin: 0 0 10px; font-size: 18px; color: #fff; }
.card p { margin: 0; color: #94a3b8; line-height: 1.65; font-size: 15px; }
.card .tag { display: inline-block; margin-top: 14px; font-size: 11px; color: var(--accent); font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
footer { text-align: center; padding: 32px 24px 48px; color: #64748b; font-size: 13px; }
</style>
</head>
<body>
<header class="intro">
<h1>{{CREATOR_NAME}}</h1>
<p class="lead">{{CREATOR_TAGLINE}}</p>
</header>
<main><div class="grid">{{PROJECT_CARDS_HTML}}</div></main>
${FOOTER}
</body>
</html>`,
};
for (const [rel, html] of Object.entries(upgraded)) {
const [skill, ...rest] = rel.split('/');
writeSkillFile(skill, rest.join('/'), html);
}
console.log(`Upgraded ${Object.keys(upgraded).length} templates (batch 1/3)`);
const upgraded2 = {
'page-template-event/templates/event-invite.html': `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<style>
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; background: radial-gradient(ellipse 80% 60% at 50% -10%, #3d1f5c, #0f1419 55%); color: #fff; display: grid; place-items: center; padding: 40px 24px; }
.card { width: 100%; max-width: 520px; background: rgba(255,255,255,.07); backdrop-filter: blur(20px); border: 1px solid rgba(255,255,255,.12); border-radius: 32px; padding: clamp(36px, 6vw, 48px); box-shadow: 0 40px 80px rgba(0,0,0,.35); }
.meta { color: #f9a8d4; font-weight: 700; font-size: 13px; letter-spacing: .08em; margin: 0 0 16px; }
h1 { margin: 0 0 16px; font-size: clamp(28px, 5vw, 36px); line-height: 1.2; font-weight: 800; }
.intro { color: #cbd5e1; line-height: 1.75; margin: 0 0 24px; font-size: 16px; }
.agenda { border-top: 1px solid rgba(255,255,255,.1); padding-top: 20px; color: #e2e8f0; line-height: 1.8; }
.agenda h2 { font-size: 13px; letter-spacing: .14em; text-transform: uppercase; color: #f9a8d4; margin: 0 0 12px; }
footer { position: fixed; bottom: 0; left: 0; right: 0; text-align: center; padding: 16px; color: rgba(255,255,255,.35); font-size: 12px; }
</style>
</head>
<body>
<article class="card">
<p class="meta">{{EVENT_DATE}} · {{EVENT_CITY}}</p>
<h1>{{EVENT_TITLE}}</h1>
<p class="intro">{{EVENT_INTRO}}</p>
<div class="agenda">{{AGENDA_HTML}}</div>
</article>
${FOOTER}
</body>
</html>`,
'page-template-course/templates/course-landing.html': `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; }
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; background: #f8faff; color: #0f172a; }
.hero { padding: clamp(56px, 12vw, 96px) 24px; text-align: center; background: linear-gradient(160deg, var(--accent) 0%, #1e40af 100%); color: #fff; position: relative; overflow: hidden; }
.hero::before { content: ""; position: absolute; inset: 0; background: radial-gradient(circle at 80% 20%, rgba(255,255,255,.15), transparent 50%); }
.hero-inner { position: relative; max-width: 680px; margin: 0 auto; }
h1 { margin: 0 0 12px; font-size: clamp(32px, 6vw, 48px); font-weight: 800; letter-spacing: -.02em; }
.subtitle { margin: 0; font-size: 18px; opacity: .9; line-height: 1.65; }
.cta { display: inline-flex; margin-top: 28px; padding: 16px 36px; background: #fff; color: var(--accent); border-radius: 999px; text-decoration: none; font-weight: 800; box-shadow: 0 16px 40px rgba(0,0,0,.15); }
main { max-width: 720px; margin: 0 auto; padding: clamp(32px, 6vw, 56px) 24px; }
.syllabus { background: #fff; border-radius: 24px; padding: clamp(28px, 4vw, 36px); box-shadow: 0 20px 60px rgba(37,99,235,.08); border: 1px solid rgba(37,99,235,.06); line-height: 1.8; }
.syllabus h2 { margin-top: 0; color: var(--accent); }
footer { text-align: center; padding: 32px 24px 48px; color: #64748b; font-size: 13px; }
</style>
</head>
<body>
<header class="hero"><div class="hero-inner"><h1>{{COURSE_TITLE}}</h1><p class="subtitle">{{COURSE_SUBTITLE}}</p><a class="cta" href="{{CTA_URL}}">{{CTA_LABEL}}</a></div></header>
<main><div class="syllabus">{{SYLLABUS_HTML}}</div></main>
${FOOTER}
</body>
</html>`,
'page-template-realestate/templates/realestate-listing.html': `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; background: #f4f7f6; color: #0f2a24; }
.hero { min-height: 320px; background: linear-gradient(180deg, transparent 60%, rgba(15,42,36,.4)), url('{{HERO_IMAGE}}') center/cover; }
.wrap { max-width: 840px; margin: -56px auto 48px; background: #fff; border-radius: 24px; padding: clamp(28px, 5vw, 40px); box-shadow: 0 32px 80px rgba(15,42,36,.12); position: relative; }
h1 { margin: 0 0 8px; font-size: clamp(26px, 5vw, 36px); font-weight: 800; letter-spacing: -.02em; }
.location { color: #5a7a72; margin: 0 0 24px; font-size: 16px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 24px; }
td { padding: 14px 0; border-bottom: 1px solid #e8f0ed; font-size: 15px; }
td:first-child { color: #5a7a72; width: 35%; font-weight: 500; }
td:last-child { font-weight: 700; color: #0f2a24; }
.highlights { line-height: 1.85; color: #374151; }
.highlights h2 { color: #0f2a24; font-size: 20px; margin: 0 0 12px; }
footer { text-align: center; padding: 24px; color: #94a3b8; font-size: 13px; }
</style>
</head>
<body>
<div class="hero"></div>
<article class="wrap">
<h1>{{LISTING_TITLE}}</h1>
<p class="location">{{LISTING_LOCATION}}</p>
<table>{{SPECS_TABLE_HTML}}</table>
<div class="highlights">{{HIGHLIGHTS_HTML}}</div>
</article>
${FOOTER}
</body>
</html>`,
'page-template-blog/templates/blog-article.html': `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: "Songti SC", "Noto Serif SC", Georgia, serif; background: #fafaf8; color: #1c1917; line-height: 1.85; }
article { max-width: 680px; margin: 0 auto; padding: clamp(48px, 10vw, 88px) clamp(24px, 5vw, 40px); }
.meta { color: #78716c; font-size: 14px; margin: 0 0 20px; font-family: system-ui, sans-serif; letter-spacing: .02em; }
h1 { font-size: clamp(30px, 5.5vw, 44px); line-height: 1.25; font-weight: 700; margin: 0 0 32px; letter-spacing: -.01em; }
.content { font-size: 18px; }
.content h2 { margin: 2.5em 0 .75em; font-size: 24px; color: #292524; }
.content p { margin: 0 0 1.4em; }
.content blockquote { margin: 2em 0; padding-left: 20px; border-left: 3px solid #d6d3d1; color: #57534e; font-style: italic; }
footer { text-align: center; padding: 32px 24px 56px; color: #a8a29e; font-size: 13px; font-family: system-ui, sans-serif; }
</style>
</head>
<body>
<article>
<p class="meta">{{ARTICLE_DATE}} · {{ARTICLE_AUTHOR}}</p>
<h1>{{ARTICLE_TITLE}}</h1>
<div class="content">{{ARTICLE_BODY_HTML}}</div>
</article>
${FOOTER}
</body>
</html>`,
'page-template-resume/templates/resume-profile.html': `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; }
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; background: linear-gradient(135deg, #eef2ff, #f8fafc); color: #0f172a; min-height: 100vh; padding: 32px 20px; }
.sheet { max-width: 780px; margin: 0 auto; background: #fff; border-radius: 24px; padding: clamp(32px, 5vw, 48px); box-shadow: 0 24px 80px rgba(29,78,216,.1); border: 1px solid rgba(29,78,216,.06); }
.header { border-bottom: 2px solid var(--accent); padding-bottom: 24px; margin-bottom: 28px; }
h1 { margin: 0; font-size: clamp(32px, 5vw, 42px); font-weight: 800; letter-spacing: -.02em; }
.role { color: var(--accent); font-weight: 700; font-size: 18px; margin: 8px 0 16px; }
.summary { color: #475569; line-height: 1.75; margin: 0; font-size: 16px; }
.section { margin-bottom: 28px; }
.section h2 { font-size: 13px; letter-spacing: .14em; text-transform: uppercase; color: var(--accent); margin: 0 0 14px; font-weight: 800; }
.section p { margin: 0 0 10px; line-height: 1.7; color: #334155; }
footer { text-align: center; padding: 32px 24px; color: #94a3b8; font-size: 13px; }
</style>
</head>
<body>
<article class="sheet">
<header class="header">
<h1>{{FULL_NAME}}</h1>
<p class="role">{{JOB_TITLE}}</p>
<p class="summary">{{SUMMARY}}</p>
</header>
<div class="section">{{EXPERIENCE_HTML}}</div>
<div class="section">{{SKILLS_HTML}}</div>
</article>
${FOOTER}
</body>
</html>`,
'page-template-wedding/templates/wedding-invite.html': `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<style>
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; font-family: "Songti SC", "Noto Serif SC", serif; background: radial-gradient(ellipse 100% 80% at 50% 0%, #fff5f9, #fdf2f8 50%, #fce7f3); color: #701a56; display: grid; place-items: center; padding: 48px 24px; }
.card { width: 100%; max-width: 480px; text-align: center; border: 1px solid rgba(244,114,182,.25); border-radius: 32px; padding: clamp(44px, 8vw, 56px) clamp(28px, 5vw, 40px); background: rgba(255,255,255,.75); backdrop-filter: blur(8px); box-shadow: 0 32px 80px rgba(190,24,93,.08); }
.preface { font-size: 14px; letter-spacing: .35em; color: #be185d; margin: 0 0 20px; }
.names { font-size: clamp(28px, 6vw, 36px); margin: 0 0 24px; font-weight: 400; letter-spacing: .1em; line-height: 1.4; }
.date, .venue { margin: 0 0 12px; font-size: 16px; color: #9d174d; line-height: 1.6; }
.details { margin-top: 28px; padding-top: 24px; border-top: 1px solid rgba(244,114,182,.2); color: #831843; line-height: 1.85; font-size: 15px; }
footer { position: fixed; bottom: 0; left: 0; right: 0; text-align: center; padding: 16px; color: rgba(157,23,77,.4); font-size: 12px; font-family: system-ui, sans-serif; }
</style>
</head>
<body>
<article class="card">
<p class="preface">诚挚邀请</p>
<h1 class="names">{{COUPLE_NAMES}}</h1>
<p class="date">{{WEDDING_DATE}}</p>
<p class="venue">{{WEDDING_VENUE}}</p>
<div class="details">{{DETAILS_HTML}}</div>
</article>
${FOOTER}
</body>
</html>`,
'page-template-fitness/templates/fitness-club.html': `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; background: #030806; color: #eafff0; }
header { padding: clamp(48px, 10vw, 80px) 24px 40px; background: linear-gradient(135deg, #14532d 0%, #052e16 50%, #030806 100%); position: relative; overflow: hidden; }
header::after { content: ""; position: absolute; top: -50%; right: -20%; width: 60%; height: 200%; background: radial-gradient(circle, rgba(74,222,128,.12), transparent 60%); pointer-events: none; }
.header-inner { position: relative; max-width: 720px; margin: 0 auto; }
h1 { margin: 0 0 10px; font-size: clamp(34px, 7vw, 52px); font-weight: 900; letter-spacing: -.03em; text-transform: uppercase; }
.slogan { margin: 0; color: #86efac; font-size: 18px; font-weight: 600; letter-spacing: .04em; }
main { max-width: 720px; margin: 0 auto; padding: clamp(32px, 6vw, 48px) 24px 64px; }
.schedule-block { background: linear-gradient(145deg, rgba(255,255,255,.06), rgba(255,255,255,.02)); border: 1px solid rgba(134,239,172,.15); border-radius: 20px; padding: 28px 24px; }
.schedule-block h2 { margin: 0 0 16px; font-size: 13px; letter-spacing: .16em; text-transform: uppercase; color: #4ade80; }
.schedule-block p { margin: 0 0 10px; padding: 12px 16px; background: rgba(0,0,0,.25); border-radius: 12px; font-weight: 600; }
footer { text-align: center; padding: 32px 24px 48px; color: #4ade80; opacity: .5; font-size: 13px; }
</style>
</head>
<body>
<header><div class="header-inner"><h1>{{CLUB_NAME}}</h1><p class="slogan">{{CLUB_SLOGAN}}</p></div></header>
<main>{{SCHEDULE_HTML}}</main>
${FOOTER}
</body>
</html>`,
'page-template-product-grid/templates/product-grid.html': `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; }
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; background: linear-gradient(180deg, #fff7ed, #fff); color: #431407; }
.head { text-align: center; padding: clamp(40px, 8vw, 72px) 24px 24px; }
h1 { margin: 0 0 8px; font-size: clamp(28px, 5vw, 40px); font-weight: 800; letter-spacing: -.02em; }
.subtitle { margin: 0; color: #9a3412; font-size: 16px; opacity: .85; }
.grid { display: grid; gap: 20px; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); max-width: 1000px; margin: 0 auto; padding: 24px 24px 64px; }
.item { background: #fff; border-radius: 20px; padding: 20px; box-shadow: 0 12px 40px rgba(234,88,12,.1); border: 1px solid rgba(234,88,12,.08); transition: transform .2s, box-shadow .2s; }
.item h3 { margin: 0 0 8px; font-size: 17px; color: #431407; }
.item .price { color: var(--accent); font-weight: 800; font-size: 20px; margin: 0; }
.item .desc { color: #78716c; font-size: 14px; margin: 8px 0 0; line-height: 1.5; }
footer { text-align: center; padding: 32px 24px 48px; color: #a8a29e; font-size: 13px; }
</style>
</head>
<body>
<header class="head"><h1>{{COLLECTION_TITLE}}</h1><p class="subtitle">{{COLLECTION_SUBTITLE}}</p></header>
<main><div class="grid">{{PRODUCT_GRID_HTML}}</div></main>
${FOOTER}
</body>
</html>`,
};
for (const [rel, html] of Object.entries(upgraded2)) {
const [skill, ...rest] = rel.split('/');
writeSkillFile(skill, rest.join('/'), html);
}
console.log(`Upgraded ${Object.keys(upgraded2).length} templates (batch 2/3)`);
const newTemplates = {
'page-template-saas': {
templateFile: 'saas-landing.html',
label: 'SaaS 产品页',
description: '渐变 Hero + 功能特性 + 定价区,适合 B2B 软件落地页',
sortOrder: 140,
priceCents: 1290,
previewSvg: 'saas-landing',
html: `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title><meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --accent2: {{ACCENT2}}; }
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; background: #06080f; color: #e8edf5; }
.hero { padding: clamp(64px, 12vw, 100px) 24px; text-align: center; background: radial-gradient(ellipse 80% 50% at 50% -20%, rgba(99,102,241,.35), transparent), linear-gradient(180deg, #0f1424, #06080f); }
.badge { display: inline-block; padding: 6px 14px; border-radius: 999px; background: rgba(99,102,241,.15); color: #a5b4fc; font-size: 12px; font-weight: 700; letter-spacing: .1em; }
h1 { margin: 20px auto 16px; max-width: 720px; font-size: clamp(36px, 7vw, 56px); font-weight: 800; letter-spacing: -.03em; line-height: 1.08; }
.lead { max-width: 540px; margin: 0 auto 32px; color: #94a3b8; font-size: 18px; line-height: 1.7; }
.cta { display: inline-flex; padding: 16px 36px; border-radius: 12px; background: linear-gradient(135deg, var(--accent), var(--accent2)); color: #fff; text-decoration: none; font-weight: 700; box-shadow: 0 16px 48px rgba(99,102,241,.35); }
.hero-img { max-width: 960px; margin: 48px auto 0; padding: 0 24px; }
.hero-img img { width: 100%; border-radius: 16px; border: 1px solid rgba(255,255,255,.08); box-shadow: 0 40px 80px rgba(0,0,0,.5); }
section { max-width: 960px; margin: 0 auto; padding: clamp(48px, 8vw, 72px) 24px; }
.features { display: grid; gap: 20px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
.feature { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.08); border-radius: 16px; padding: 24px; }
.pricing { background: rgba(255,255,255,.03); border-radius: 24px; padding: 32px; border: 1px solid rgba(255,255,255,.06); }
footer { text-align: center; padding: 40px 24px 56px; color: #64748b; font-size: 13px; }
</style>
</head>
<body>
<header class="hero">
<span class="badge">{{PRODUCT_NAME}}</span>
<h1>{{HERO_HEADLINE}}</h1>
<p class="lead">{{HERO_SUBTITLE}}</p>
<a class="cta" href="{{CTA_URL}}">{{CTA_LABEL}}</a>
<div class="hero-img"><img src="{{HERO_IMAGE}}" alt="{{PRODUCT_NAME}}"></div>
</header>
<section><div class="features">{{FEATURES_HTML}}</div></section>
<section><div class="pricing">{{PRICING_HTML}}</div></section>
${FOOTER}
</body>
</html>`,
},
'page-template-hotel': {
templateFile: 'hotel-retreat.html',
label: '酒店民宿',
description: '大图 Hero + 设施亮点 + 房型卡片,适合精品酒店与民宿',
sortOrder: 150,
priceCents: 1190,
previewSvg: 'hotel-retreat',
html: `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title><meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --accent2: {{ACCENT2}}; }
* { box-sizing: border-box; }
body { margin: 0; font-family: "Songti SC", Georgia, serif; background: #faf8f5; color: #2c2419; }
.hero { min-height: 70vh; display: grid; align-items: end; padding: clamp(32px, 7vw, 72px); background: linear-gradient(180deg, transparent 30%, rgba(44,36,25,.85)), url('{{HERO_IMAGE}}') center/cover; color: #fff; }
.eyebrow { font-family: system-ui, sans-serif; font-size: 11px; letter-spacing: .25em; color: var(--accent2); font-weight: 700; }
h1 { margin: 12px 0 8px; font-size: clamp(36px, 7vw, 56px); font-weight: 400; letter-spacing: .04em; }
.tagline { font-family: system-ui, sans-serif; font-size: 17px; opacity: .9; max-width: 32rem; line-height: 1.65; }
main { max-width: 880px; margin: 0 auto; padding: clamp(40px, 8vw, 64px) 24px; }
.amenities { margin-bottom: 48px; line-height: 1.85; }
.amenities h2 { font-family: system-ui, sans-serif; font-size: 13px; letter-spacing: .16em; color: var(--accent); margin: 0 0 16px; }
.rooms { line-height: 1.8; }
.rooms h2 { font-family: system-ui, sans-serif; font-size: 22px; margin: 0 0 20px; }
.book { display: inline-block; margin-top: 32px; padding: 16px 40px; background: var(--accent); color: #fff; text-decoration: none; border-radius: 4px; font-family: system-ui, sans-serif; font-weight: 700; letter-spacing: .06em; }
footer { text-align: center; padding: 40px 24px; color: #a89882; font-size: 13px; font-family: system-ui, sans-serif; }
</style>
</head>
<body>
<header class="hero">
<div><div class="eyebrow">{{HOTEL_NAME}}</div><h1>{{HOTEL_TAGLINE}}</h1></div>
</header>
<main>
<div class="amenities">{{AMENITIES_HTML}}</div>
<div class="rooms">{{ROOMS_HTML}}</div>
<a class="book" href="{{BOOKING_CTA_URL}}">{{BOOKING_CTA_LABEL}}</a>
</main>
${FOOTER}
</body>
</html>`,
},
'page-template-agency': {
templateFile: 'agency-studio.html',
label: '创意机构',
description: '极简大标题 + 服务列表 + 案例展示,适合设计/广告工作室',
sortOrder: 160,
priceCents: 1090,
previewSvg: 'agency-studio',
html: `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title><meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; }
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; background: #fff; color: #111; }
header { padding: clamp(64px, 14vw, 120px) 24px 48px; max-width: 1100px; margin: 0 auto; border-bottom: 1px solid #eee; }
h1 { margin: 0 0 12px; font-size: clamp(40px, 9vw, 80px); font-weight: 900; letter-spacing: -.04em; line-height: .95; }
.tagline { margin: 0; font-size: clamp(18px, 3vw, 24px); color: #666; max-width: 36rem; line-height: 1.5; }
main { max-width: 1100px; margin: 0 auto; padding: clamp(48px, 8vw, 80px) 24px; }
.services { margin-bottom: 64px; }
.services h2, .showcase h2, .contact h2 { font-size: 13px; letter-spacing: .2em; text-transform: uppercase; color: var(--accent); margin: 0 0 24px; }
.showcase { margin-bottom: 64px; line-height: 1.75; }
.contact { color: #444; line-height: 1.8; }
footer { padding: 48px 24px; text-align: center; color: #999; font-size: 13px; border-top: 1px solid #eee; }
</style>
</head>
<body>
<header><h1>{{AGENCY_NAME}}</h1><p class="tagline">{{AGENCY_TAGLINE}}</p></header>
<main>
<div class="services">{{SERVICES_HTML}}</div>
<div class="showcase">{{SHOWCASE_HTML}}</div>
<div class="contact">{{CONTACT_HTML}}</div>
</main>
${FOOTER}
</body>
</html>`,
},
'page-template-photography': {
templateFile: 'photo-gallery.html',
label: '摄影画廊',
description: '全屏 Hero + 瀑布流作品区,适合摄影师与视觉艺术家',
sortOrder: 170,
priceCents: 990,
previewSvg: 'photo-gallery',
html: `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title><meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; }
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0a0a0a; color: #f5f5f5; }
.hero { min-height: 55vh; display: grid; place-items: center; text-align: center; padding: 48px 24px; background: linear-gradient(rgba(0,0,0,.4), rgba(0,0,0,.7)), url('{{HERO_IMAGE}}') center/cover; }
h1 { margin: 0 0 12px; font-size: clamp(32px, 6vw, 48px); font-weight: 300; letter-spacing: .15em; text-transform: uppercase; }
.bio { max-width: 480px; margin: 0 auto; color: #ccc; line-height: 1.75; font-size: 16px; font-weight: 300; }
main { max-width: 1200px; margin: 0 auto; padding: clamp(40px, 6vw, 64px) 20px 80px; }
.gallery { columns: 2; column-gap: 16px; }
@media (min-width: 768px) { .gallery { columns: 3; column-gap: 20px; } }
.gallery figure { break-inside: avoid; margin: 0 0 16px; border-radius: 4px; overflow: hidden; }
.gallery img { width: 100%; display: block; }
.gallery figcaption { padding: 10px 12px; background: #141414; font-size: 13px; color: #999; }
footer { text-align: center; padding: 32px; color: #555; font-size: 12px; letter-spacing: .1em; }
</style>
</head>
<body>
<header class="hero"><div><h1>{{PHOTOGRAPHER_NAME}}</h1><p class="bio">{{PHOTOGRAPHER_BIO}}</p></div></header>
<main><div class="gallery">{{GALLERY_HTML}}</div></main>
${FOOTER}
</body>
</html>`,
},
'page-template-podcast': {
templateFile: 'podcast-show.html',
label: '播客节目',
description: '封面 Hero + 单集列表 + 订阅引导,适合播客与音频栏目',
sortOrder: 180,
priceCents: 890,
previewSvg: 'podcast-show',
html: `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title><meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --accent2: {{ACCENT2}}; }
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; background: linear-gradient(165deg, #1a0a2e, #0f0f14); color: #f1f5f9; }
.hero { display: grid; grid-template-columns: auto 1fr; gap: clamp(24px, 4vw, 40px); align-items: center; padding: clamp(40px, 8vw, 72px) 24px; max-width: 880px; margin: 0 auto; }
.cover { width: clamp(140px, 28vw, 220px); aspect-ratio: 1; border-radius: 20px; object-fit: cover; box-shadow: 0 24px 64px rgba(139,92,246,.3); }
h1 { margin: 0 0 8px; font-size: clamp(28px, 5vw, 40px); font-weight: 800; }
.tagline { margin: 0; color: #a78bfa; font-size: 16px; font-weight: 600; }
main { max-width: 880px; margin: 0 auto; padding: 0 24px 64px; }
.episodes { margin-bottom: 40px; }
.episodes h2 { font-size: 13px; letter-spacing: .14em; text-transform: uppercase; color: var(--accent2); margin: 0 0 20px; }
.episode { padding: 20px; background: rgba(255,255,255,.05); border-radius: 16px; margin-bottom: 12px; border: 1px solid rgba(255,255,255,.06); }
.subscribe { text-align: center; padding: 32px; background: linear-gradient(135deg, rgba(139,92,246,.2), rgba(99,102,241,.1)); border-radius: 20px; }
footer { text-align: center; padding: 32px; color: #64748b; font-size: 13px; }
</style>
</head>
<body>
<header class="hero">
<img class="cover" src="{{COVER_IMAGE}}" alt="{{SHOW_NAME}}">
<div><h1>{{SHOW_NAME}}</h1><p class="tagline">{{SHOW_TAGLINE}}</p></div>
</header>
<main>
<div class="episodes">{{EPISODES_HTML}}</div>
<div class="subscribe">{{SUBSCRIBE_HTML}}</div>
</main>
${FOOTER}
</body>
</html>`,
},
'page-template-charity': {
templateFile: 'charity-cause.html',
label: '公益募捐',
description: '情感 Hero + 项目故事 + 捐赠 CTA,适合公益与 CSR 活动',
sortOrder: 190,
priceCents: 0,
previewSvg: 'charity-cause',
html: `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title><meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; }
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; background: #fefdfb; color: #1c1917; }
.hero { min-height: 60vh; display: grid; align-items: end; padding: clamp(32px, 7vw, 64px); background: linear-gradient(180deg, transparent 20%, rgba(28,25,23,.75)), url('{{HERO_IMAGE}}') center/cover; color: #fff; }
h1 { margin: 0 0 12px; font-size: clamp(32px, 6vw, 48px); font-weight: 800; line-height: 1.15; max-width: 640px; }
.subtitle { margin: 0; font-size: 18px; opacity: .92; max-width: 520px; line-height: 1.65; }
main { max-width: 720px; margin: 0 auto; padding: clamp(40px, 8vw, 64px) 24px; }
.impact { background: #fff; border-radius: 20px; padding: 28px; box-shadow: 0 16px 48px rgba(28,25,23,.06); margin-bottom: 32px; line-height: 1.85; }
.story { line-height: 1.9; color: #44403c; margin-bottom: 32px; }
.donate { display: block; text-align: center; padding: 18px 36px; background: var(--accent); color: #fff; text-decoration: none; border-radius: 999px; font-weight: 800; font-size: 17px; box-shadow: 0 12px 32px rgba(220,38,38,.25); }
footer { text-align: center; padding: 40px 24px; color: #a8a29e; font-size: 13px; }
</style>
</head>
<body>
<header class="hero"><div><h1>{{CAUSE_TITLE}}</h1><p class="subtitle">{{CAUSE_SUBTITLE}}</p></div></header>
<main>
<div class="impact">{{IMPACT_HTML}}</div>
<div class="story">{{STORY_HTML}}</div>
<a class="donate" href="{{DONATE_CTA_URL}}">{{DONATE_CTA_LABEL}}</a>
</main>
${FOOTER}
</body>
</html>`,
},
'page-template-startup': {
templateFile: 'startup-pitch.html',
label: '创业融资',
description: 'Pitch Deck 风格单页:愿景 + 数据指标 + 团队 + CTA',
sortOrder: 200,
priceCents: 1490,
previewSvg: 'startup-pitch',
html: `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title><meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --accent2: {{ACCENT2}}; }
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; background: #030712; color: #f8fafc; }
.hero { padding: clamp(64px, 12vw, 100px) 24px; text-align: center; background: radial-gradient(ellipse 70% 50% at 50% 0%, rgba(56,189,248,.15), transparent), #030712; }
.startup { font-size: 13px; letter-spacing: .2em; color: var(--accent); font-weight: 800; margin-bottom: 16px; }
h1 { margin: 0 auto 16px; max-width: 760px; font-size: clamp(34px, 7vw, 52px); font-weight: 800; letter-spacing: -.03em; line-height: 1.1; }
.pitch-sub { max-width: 560px; margin: 0 auto 36px; color: #94a3b8; font-size: 18px; line-height: 1.7; }
.cta { display: inline-flex; padding: 16px 32px; border-radius: 10px; background: linear-gradient(135deg, var(--accent), var(--accent2)); color: #fff; text-decoration: none; font-weight: 700; }
main { max-width: 960px; margin: 0 auto; padding: clamp(40px, 8vw, 72px) 24px; }
.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 48px; }
.metric { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.08); border-radius: 16px; padding: 24px; text-align: center; }
.section { margin-bottom: 48px; line-height: 1.8; }
.section h2 { font-size: 13px; letter-spacing: .16em; text-transform: uppercase; color: var(--accent); margin: 0 0 16px; }
footer { text-align: center; padding: 40px 24px; color: #475569; font-size: 13px; }
</style>
</head>
<body>
<header class="hero">
<p class="startup">{{STARTUP_NAME}}</p>
<h1>{{PITCH_HEADLINE}}</h1>
<p class="pitch-sub">{{PITCH_SUBTITLE}}</p>
<a class="cta" href="{{CTA_URL}}">{{CTA_LABEL}}</a>
</header>
<main>
<div class="metrics">{{METRICS_HTML}}</div>
<div class="section">{{VISION_HTML}}</div>
<div class="section">{{TEAM_HTML}}</div>
</main>
${FOOTER}
</body>
</html>`,
},
};
function skillYaml(name, meta) {
return `name: ${name}
version: 1.0.0
label: ${meta.label}
description: ${meta.description}
catalog:
label: ${meta.label}
description: ${meta.description}
previewImage: /assets/template-previews/${meta.previewSvg}.svg
priceCents: ${meta.priceCents}
billingMode: ${meta.priceCents > 0 ? 'one_time' : 'free'}
sortOrder: ${meta.sortOrder}
trigger:
keywords:
- ${meta.label}
router:
promptKey: ${name}
priority: 25
executors:
- goose
`;
}
function skillMd(name, meta, templateFile) {
return `---
name: ${name}
description: ${meta.description}
---
# ${meta.label}
1. load_skill → \`${name}\`
2. read_file → \`.agents/skills/${name}/templates/${templateFile}\`
3. 替换全部占位符后 write_file → \`public/*.html\`
4. 遵守 static-page-publish 回复 Markdown 链接
`;
}
function previewSvg(title, subtitle, color1, color2) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 640 360" role="img" aria-label="${title}预览">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="${color1}"/><stop offset="100%" stop-color="${color2}"/></linearGradient></defs>
<rect width="640" height="360" fill="url(#g)"/>
<rect x="40" y="200" width="360" height="100" rx="16" fill="rgba(255,255,255,0.12)"/>
<text x="56" y="240" fill="rgba(255,255,255,0.7)" font-family="system-ui,sans-serif" font-size="12" font-weight="700">${subtitle}</text>
<text x="56" y="278" fill="#fff" font-family="system-ui,sans-serif" font-size="26" font-weight="800">${title}</text>
</svg>`;
}
const previewColors = {
'saas-landing': ['#312e81', '#0f172a'],
'hotel-retreat': ['#78350f', '#1c1917'],
'agency-studio': ['#f5f5f5', '#e5e5e5'],
'photo-gallery': ['#171717', '#0a0a0a'],
'podcast-show': ['#4c1d95', '#0f0f14'],
'charity-cause': ['#fef3c7', '#fde68a'],
'startup-pitch': ['#0c4a6e', '#030712'],
};
for (const [skillName, meta] of Object.entries(newTemplates)) {
writeSkillFile(skillName, `templates/${meta.templateFile}`, meta.html);
writeSkillFile(skillName, 'skill.yaml', skillYaml(skillName, meta));
writeSkillFile(skillName, 'SKILL.md', skillMd(skillName, meta, meta.templateFile));
const [c1, c2] = previewColors[meta.previewSvg];
writePreview(`${meta.previewSvg}.svg`, previewSvg(meta.label, 'Premium Template', c1, c2));
}
console.log(`Created ${Object.keys(newTemplates).length} new template skills (batch 3/3)`);
console.log('Done. Update chat-skills.mjs, skills-registry.mjs, page-template-catalog.mjs demos, and test count.');
+24 -3
View File
@@ -26,6 +26,7 @@ function parseArgs(argv) {
let root = path.join(repoRoot, DEFAULT_PUBLISH_ROOT);
let userId = null;
let file = null;
let seo = false;
for (let i = 2; i < argv.length; i += 1) {
if (argv[i] === '--root' && argv[i + 1]) {
root = path.resolve(argv[i + 1]);
@@ -36,12 +37,14 @@ function parseArgs(argv) {
} else if (argv[i] === '--file' && argv[i + 1]) {
file = path.resolve(argv[i + 1]);
i += 1;
} else if (argv[i] === '--seo') {
seo = true;
} else if (argv[i] === '--help' || argv[i] === '-h') {
console.log(`Usage: node scripts/check-mindspace-cover.mjs [--root ${DEFAULT_PUBLISH_ROOT}] [--user <uuid>] [--file <html>]`);
console.log(`Usage: node scripts/check-mindspace-cover.mjs [--root ${DEFAULT_PUBLISH_ROOT}] [--user <uuid>] [--file <html>] [--seo]`);
process.exit(0);
}
}
return { root, userId, file };
return { root, userId, file, seo };
}
function hasCoverImageHint(html) {
@@ -106,6 +109,23 @@ function auditCoverHtml(htmlPath, html) {
return issues;
}
function auditSeoHtml(html) {
const issues = [];
const title = String(html).match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.replace(/\s+/g, ' ').trim() ?? '';
if (!title || title.length < 4) {
issues.push({ level: 'warn', code: 'weak_title', message: '缺少或过短的 <title>SEO/GEO 质量会下降' });
} else if (/^(页面|我的页面|未命名|demo|test|MindSpace 页面)$/i.test(title)) {
issues.push({ level: 'warn', code: 'generic_title', message: `title 过于泛化:${title}` });
}
if (!/<h1\b/i.test(html)) {
issues.push({ level: 'warn', code: 'missing_h1', message: '缺少 <h1>,搜索引擎与 AI 抽取质量会下降' });
}
if (!hasShareDescription(html)) {
issues.push({ level: 'warn', code: 'seo_missing_description', message: '缺少 descriptionSEO/GEO 摘要来源不足' });
}
return issues;
}
function collectHtmlFiles(root, userId) {
const files = [];
const walk = (dir) => {
@@ -135,13 +155,14 @@ function collectHtmlFiles(root, userId) {
return files;
}
const { root, userId, file } = parseArgs(process.argv);
const { root, userId, file, seo } = parseArgs(process.argv);
const targets = file ? [file] : collectHtmlFiles(root, userId);
const results = [];
for (const htmlPath of targets) {
const html = fs.readFileSync(htmlPath, 'utf8');
const issues = auditCoverHtml(htmlPath, html);
if (seo) issues.push(...auditSeoHtml(html));
if (issues.length) {
results.push({ htmlPath, issues });
}
+13 -10
View File
@@ -17,16 +17,6 @@ import {
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const opsDir = path.join(root, 'ops');
const nodeBin = process.execPath;
const portalPort = Number(process.env.H5_PORT ?? 8081);
const vitePort = Number(process.env.VITE_PORT ?? 5173);
const plazaPort = Number(process.env.PLAZA_PORT ?? 3001);
const opsPort = Number(process.env.OPS_PORT ?? 3002);
const adminPort = Number(process.env.ADMIN_PORT ?? 8082);
const portalUrl = `http://127.0.0.1:${portalPort}`;
const adminUrl = `http://127.0.0.1:${adminPort}`;
const plazaPublicBase = (
process.env.PLAZA_PUBLIC_BASE ?? `http://127.0.0.1:${plazaPort}`
).replace(/\/$/, '');
const logFile =
process.env.MEMIND_DEV_LOG ?? path.join(os.homedir(), 'Library/Logs/memind-dev.log');
@@ -40,10 +30,23 @@ function log(message) {
}
}
// Load .env before reading ports — otherwise ADMIN_PORT defaults to 8082 and
// split-service MindSpace (also on 8082) gets killed by freePort().
loadMemindEnvFiles(root);
applyMemindRuntimeProfile({ rootDir: root });
log(`Runtime profile: ${describeMemindRuntimeProfile()}`);
const portalPort = Number(process.env.H5_PORT ?? 8081);
const vitePort = Number(process.env.VITE_PORT ?? 5173);
const plazaPort = Number(process.env.PLAZA_PORT ?? 3001);
const opsPort = Number(process.env.OPS_PORT ?? 3002);
const adminPort = Number(process.env.ADMIN_PORT ?? 8082);
const portalUrl = `http://127.0.0.1:${portalPort}`;
const adminUrl = `http://127.0.0.1:${adminPort}`;
const plazaPublicBase = (
process.env.PLAZA_PUBLIC_BASE ?? `http://127.0.0.1:${plazaPort}`
).replace(/\/$/, '');
function freePort(port) {
try {
execSync(`lsof -ti TCP:${port} -sTCP:LISTEN | xargs kill -9`, { stdio: 'ignore' });
+6
View File
@@ -69,6 +69,9 @@ export function applyMemindRuntimeProfile({
if (!env.MINDSPACE_STORAGE_ROOT) {
env.MINDSPACE_STORAGE_ROOT = path.join(path.resolve(rootDir), 'data', 'mindspace');
}
if (!env.MEMIND_H5_HTML_FINISH_GUARD) {
env.MEMIND_H5_HTML_FINISH_GUARD = '1';
}
return {
profile: resolvedProfile,
mindspaceAdapter: 'local',
@@ -90,6 +93,9 @@ export function applyMemindRuntimeProfile({
if (!env.MINDSPACE_STORAGE_ROOT) {
env.MINDSPACE_STORAGE_ROOT = '/Users/john/MindSpace/data/mindspace';
}
if (!env.MEMIND_H5_HTML_FINISH_GUARD) {
env.MEMIND_H5_HTML_FINISH_GUARD = '1';
}
return {
profile: resolvedProfile,
mindspaceAdapter: 'remote',
+9
View File
@@ -29,6 +29,7 @@ test('applyMemindRuntimeProfile local forces monolith adapter and repo storage',
assert.equal(result.profile, 'local');
assert.equal(env.MINDSPACE_SERVER_ADAPTER, 'local');
assert.match(env.MINDSPACE_STORAGE_ROOT, /\/tmp\/memind\/data\/mindspace$/);
assert.equal(env.MEMIND_H5_HTML_FINISH_GUARD, '1');
});
test('applyMemindRuntimeProfile split-service expands auth token reference', () => {
@@ -39,6 +40,7 @@ test('applyMemindRuntimeProfile split-service expands auth token reference', ()
applyMemindRuntimeProfile({ env, profile: 'split-service' });
assert.equal(env.MINDSPACE_SERVER_ADAPTER, 'remote');
assert.equal(env.MINDSPACE_REMOTE_AUTH_TOKEN, 'local-dev-secret');
assert.equal(env.MEMIND_H5_HTML_FINISH_GUARD, '1');
});
test('applyMemindRuntimeProfile production does not override adapter', () => {
@@ -51,4 +53,11 @@ test('applyMemindRuntimeProfile production does not override adapter', () => {
assert.equal(result.profile, 'production');
assert.equal(env.MINDSPACE_SERVER_ADAPTER, 'remote');
assert.equal(env.MINDSPACE_REMOTE_AUTH_TOKEN, 'prod-token');
assert.equal(env.MEMIND_H5_HTML_FINISH_GUARD, undefined);
});
test('applyMemindRuntimeProfile local respects explicit finish guard override', () => {
const env = { MEMIND_H5_HTML_FINISH_GUARD: '0' };
applyMemindRuntimeProfile({ rootDir: '/tmp/memind', env, profile: 'local' });
assert.equal(env.MEMIND_H5_HTML_FINISH_GUARD, '0');
});
+3
View File
@@ -10,6 +10,9 @@ const routeSamples = [
{ path: '/status', method: 'GET' },
{ path: '/runtime/status', method: 'GET' },
{ path: '/config/blocked-words', method: 'GET' },
{ path: '/robots.txt', method: 'GET' },
{ path: '/sitemap.xml', method: 'GET' },
{ path: '/llms.txt', method: 'GET' },
{ path: '/internal/agent/jobs/job-1/claim', method: 'POST' },
{ path: '/internal/deep-search/llm', method: 'POST' },
{ path: '/agent/mindspace_page_patch', method: 'POST' },
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import express from 'express';
import { attachPortalSeoDiscoveryRoutes } from '../server/portal-seo-discovery-routes.mjs';
import { createMindspaceSeoDiscoveryService } from '../mindspace-seo-discovery-service.mjs';
const enabledConfig = {
seoGeo: {
enabled: true,
seo: { enabled: true, sitemap: true, robotsTxt: true },
geo: { enabled: true, llmsTxt: true, jsonLd: true },
},
};
const discovery = createMindspaceSeoDiscoveryService({
query: async () => [
[
{
id: 'pub-1',
page_id: 'page-1',
public_url: '/u/john/pages/demo',
access_mode: 'public',
status: 'online',
user_confirmed_at: Date.now(),
expires_at: null,
updated_at: Date.now(),
published_at: Date.now(),
title: 'Demo',
summary: '演示页面',
},
],
],
});
const app = express();
attachPortalSeoDiscoveryRoutes(app, {
getAuthPool: () => ({}),
getMindSpaceConfig: async () => enabledConfig,
getSeoDiscoveryService: () => discovery,
resolveRequestOrigin: () => 'https://m.tkmind.cn',
});
const server = app.listen(0);
try {
const { port } = server.address();
const base = `http://127.0.0.1:${port}`;
const robots = await fetch(`${base}/robots.txt`);
assert.equal(robots.status, 200);
const robotsBody = await robots.text();
assert.match(robotsBody, /Sitemap: https:\/\/m\.tkmind\.cn\/sitemap\.xml/);
const sitemap = await fetch(`${base}/sitemap.xml`);
assert.equal(sitemap.status, 200);
const sitemapBody = await sitemap.text();
assert.match(sitemapBody, /\/u\/john\/pages\/demo/);
const llms = await fetch(`${base}/llms.txt`);
assert.equal(llms.status, 200);
const llmsBody = await llms.text();
assert.match(llmsBody, /演示页面/);
console.log('verify-seo-discovery: ok');
} finally {
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env node
/**
* E2E: Portal template catalog balance purchase + WeChat checkout creation.
* Usage: node scripts/verify-template-catalog-e2e.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadH5Environment } from './load-env.mjs';
import { createReporter, loginViaApi } from './scenario-test-lib.mjs';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(scriptDir, '..');
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();
let value = trimmed.slice(eq + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (!(key in process.env)) process.env[key] = value;
}
}
loadH5Environment(scriptDir);
loadEnvFile(path.join(root, '../memind_adm/.env'));
const portalBase = process.env.PORTAL_API_BASE ?? `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`;
const adminBase = process.env.ADM_DEV_BACKEND ?? 'http://127.0.0.1:8085';
const adminUser = process.env.H5_ADMIN_USERNAME?.trim() || 'admin';
const adminPass =
process.env.H5_ADMIN_PASSWORD?.trim() ||
process.env.ADMIN_PASSWORD?.trim() ||
'';
const portalUser =
process.env.TEMPLATE_E2E_USERNAME?.trim() ||
process.env.MEMIND_E2E_USERNAME?.trim() ||
'john4';
const portalPass =
process.env.TEMPLATE_E2E_PASSWORD?.trim() ||
process.env.MEMIND_E2E_PASSWORD?.trim() ||
process.env.JOHN4_PASSWORD?.trim() ||
'888888';
const PURCHASE_SKILL = process.env.TEMPLATE_E2E_SKILL?.trim() || 'page-template-travel';
async function api(base, method, urlPath, { body, cookie } = {}) {
const res = await fetch(`${base}${urlPath}`, {
method,
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...(cookie ? { Cookie: cookie } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = { raw: text.slice(0, 400) };
}
return { status: res.status, json, text };
}
async function main() {
const reporter = createReporter();
console.log(`==> Portal ${portalBase}`);
console.log(`==> Admin ${adminBase}`);
console.log(`==> 购买目标 skill: ${PURCHASE_SKILL}`);
if (!adminPass) {
throw new Error('缺少 H5_ADMIN_PASSWORDmemind_adm/.env');
}
const adminAuth = await loginViaApi(adminBase, { username: adminUser, password: adminPass }, reporter);
let portalAuth;
try {
portalAuth = await loginViaApi(
portalBase,
{ username: portalUser, password: portalPass },
{
pass: (label, detail) => reporter.pass(`Portal ${label}`, detail),
fail: (label, detail) => reporter.fail(`Portal ${label}`, detail),
checks: reporter.checks,
issues: reporter.issues,
},
);
reporter.pass('Portal 测试账号', portalUser);
} catch (err) {
throw new Error(`Portal 测试账号 ${portalUser} 登录失败: ${err.message}`);
}
const rechargeRes = await api(adminBase, 'POST', `/admin-api/users/${portalAuth.user.id}/recharge`, {
cookie: adminAuth.cookie,
body: { amountCents: 5000, note: 'template-catalog-e2e' },
});
if (rechargeRes.status === 200) {
reporter.pass('Admin 充值', `+5000 分 → ${portalUser}`);
} else {
reporter.fail('Admin 充值', `HTTP ${rechargeRes.status}: ${JSON.stringify(rechargeRes.json)}`);
}
const catalogRes = await api(portalBase, 'GET', '/api/mindspace/v1/template-catalog', {
cookie: portalAuth.cookie,
});
if (catalogRes.status !== 200) {
throw new Error(`catalog HTTP ${catalogRes.status}: ${JSON.stringify(catalogRes.json)}`);
}
const items = catalogRes.json?.data?.items ?? [];
reporter.pass('模板列表', `${items.length}`);
const target = items.find((item) => item.skillName === PURCHASE_SKILL);
if (!target) {
throw new Error(`缺少 ${PURCHASE_SKILL}`);
}
const pickCheckoutSkill = () => {
const notOwned = items.filter((item) => !item.owned && item.priceCents > 0);
return notOwned[0]?.skillName ?? PURCHASE_SKILL;
};
if (target.owned) {
reporter.pass('余额购买', `跳过:${PURCHASE_SKILL} 已拥有`);
} else {
const meBefore = await api(portalBase, 'GET', '/auth/me', { cookie: portalAuth.cookie });
const balanceBefore = Number(meBefore.json?.user?.balance_cents ?? 0);
reporter.pass('购买前余额', `${balanceBefore}`);
const purchaseRes = await api(
portalBase,
'POST',
`/api/mindspace/v1/template-catalog/${encodeURIComponent(PURCHASE_SKILL)}/purchase`,
{ cookie: portalAuth.cookie },
);
if (purchaseRes.status === 402) {
reporter.fail('余额购买', `402 余额不足: ${JSON.stringify(purchaseRes.json)}`);
} else if (purchaseRes.status !== 200) {
reporter.fail('余额购买', `HTTP ${purchaseRes.status}: ${JSON.stringify(purchaseRes.json)}`);
} else {
const result = purchaseRes.json?.data ?? purchaseRes.json;
reporter.pass(
'余额购买',
`price=${result.priceCents ?? target.priceCents} balance=${result.balanceCents ?? '?'}`,
);
const skills = result.grantedSkills ?? [];
if (skills.includes(PURCHASE_SKILL)) {
reporter.pass('skill 开通', PURCHASE_SKILL);
} else {
reporter.fail('skill 开通', `grantedSkills 未含 ${PURCHASE_SKILL}: ${JSON.stringify(skills)}`);
}
}
}
const wechatSkill = pickCheckoutSkill();
const checkoutRes = await api(
portalBase,
'POST',
`/api/mindspace/v1/template-catalog/${encodeURIComponent(wechatSkill)}/checkout`,
{
cookie: portalAuth.cookie,
body: { payScene: 'native' },
},
);
const checkout = checkoutRes.json?.data ?? checkoutRes.json;
if (checkoutRes.status === 200 && checkout?.ok && checkout?.order) {
reporter.pass(
'微信 checkout',
`${wechatSkill} pending=${checkout.order.status} mode=${checkout.order.payMode}`,
);
if (checkout.order.codeUrl || checkout.order.jsapiParams || checkout.order.h5Url) {
reporter.pass('微信支付参数', checkout.order.codeUrl ? 'native codeUrl' : checkout.order.payMode);
}
} else if (checkoutRes.status === 400 && String(checkout?.error?.message ?? checkout?.message ?? '').includes('已拥有')) {
reporter.pass('微信 checkout', `${wechatSkill} 已拥有,跳过`);
} else if (
checkoutRes.status === 400 &&
String(checkout?.error?.message ?? checkout?.message ?? '').includes('微信支付尚未配置')
) {
reporter.pass('微信 checkout', '微信支付未配置(本地预期可接受)');
} else {
reporter.fail('微信 checkout', `HTTP ${checkoutRes.status}: ${JSON.stringify(checkoutRes.json)}`);
}
const previewRes = await api(
portalBase,
'GET',
`/api/mindspace/v1/template-catalog/page-template-travel/preview`,
{ cookie: portalAuth.cookie },
);
if (previewRes.status === 200 && previewRes.text.includes('周末去哪玩')) {
reporter.pass('预览 HTML', 'travel 示例文案正确');
} else {
reporter.fail('预览 HTML', `HTTP ${previewRes.status}`);
}
process.exitCode = reporter.summary();
}
main().catch((err) => {
console.error('\nE2E 失败:', err.message);
process.exit(1);
});
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* Local smoke test for Portal template catalog API (Memind 8081).
* Usage: node scripts/verify-template-catalog-portal.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadH5Environment } from './load-env.mjs';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(scriptDir, '..');
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();
let value = trimmed.slice(eq + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (!(key in process.env)) process.env[key] = value;
}
}
loadH5Environment(scriptDir);
loadEnvFile(path.join(root, '../memind_adm/.env'));
const base = process.env.PORTAL_API_BASE ?? `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`;
const username =
process.env.VERIFY_PORTAL_USERNAME?.trim() ||
process.env.H5_TEST_USERNAME?.trim() ||
process.env.MEMIND_E2E_USERNAME?.trim() ||
process.env.H5_ADMIN_USERNAME?.trim() ||
'admin';
const password =
process.env.VERIFY_PORTAL_PASSWORD?.trim() ||
process.env.H5_TEST_PASSWORD?.trim() ||
process.env.MEMIND_E2E_PASSWORD?.trim() ||
process.env.JOHN_PASSWORD?.trim() ||
process.env.VERIFY_PASSWORD?.trim() ||
process.env.H5_ADMIN_PASSWORD?.trim() ||
process.env.ADMIN_PASSWORD?.trim() ||
'';
async function request(method, urlPath, { body, cookie } = {}) {
const res = await fetch(`${base}${urlPath}`, {
method,
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...(cookie ? { Cookie: cookie } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = { raw: text.slice(0, 300) };
}
return { status: res.status, json, text, setCookie: res.headers.getSetCookie?.() ?? [] };
}
function cookieFromSetCookie(setCookie) {
return setCookie.map((c) => c.split(';')[0]).join('; ');
}
function assertOk(cond, msg) {
if (!cond) throw new Error(msg);
}
async function main() {
console.log(`==> Portal API base: ${base}`);
if (!password) {
console.error('缺少 Portal 登录密码(VERIFY_PORTAL_PASSWORD / H5_ADMIN_PASSWORD / memind_adm/.env');
process.exit(1);
}
const login = await request('POST', '/auth/login', {
body: { username, password },
});
assertOk(login.status === 200, `登录失败 HTTP ${login.status}: ${JSON.stringify(login.json)}`);
const cookie = cookieFromSetCookie(login.setCookie);
assertOk(cookie, '登录未返回 session cookie');
console.log(`✓ Portal 登录 (${username})`);
const catalog = await request('GET', '/api/mindspace/v1/template-catalog', { cookie });
assertOk(catalog.status === 200, `catalog HTTP ${catalog.status}: ${JSON.stringify(catalog.json)}`);
const items = catalog.json?.data?.items ?? catalog.json?.items ?? [];
assertOk(Array.isArray(items), 'catalog items 应为数组');
assertOk(items.length >= 13, `应至少 13 个模板,实际 ${items.length}`);
const travel = items.find((item) => item.skillName === 'page-template-travel');
assertOk(travel, '缺少 page-template-travel');
console.log(
`✓ GET template-catalog (${items.length} 项,travel owned=${Boolean(travel.owned)} price=${travel.priceCents})`,
);
const preview = await request('GET', '/api/mindspace/v1/template-catalog/page-template-travel/preview', {
cookie,
});
assertOk(preview.status === 200, `preview HTTP ${preview.status}`);
assertOk(String(preview.text).includes('周末去哪玩'), '预览 HTML 应含示例文案');
console.log('✓ GET template preview HTML');
if (travel.owned) {
console.log('• travel 已拥有,跳过余额购买测试');
} else if (travel.priceCents > 0) {
const purchase = await request('POST', '/api/mindspace/v1/template-catalog/page-template-travel/purchase', {
cookie,
});
if (purchase.status === 402) {
console.log('• 余额不足 (402)purchase 路由正常');
} else {
assertOk(purchase.status === 200, `purchase HTTP ${purchase.status}: ${JSON.stringify(purchase.json)}`);
console.log('✓ POST balance purchase 成功');
}
}
console.log('\n全部 Portal template-catalog API 联调通过。');
}
main().catch((err) => {
console.error('\n联调失败:', err.message);
process.exit(1);
});