🎨 铸渊图片工作室 - 小红书/即刻/海报配图生成工具

为冰朔定制专属配图生成系统,支持三种卡片类型:
- 小红书竖版卡片 (1080×1440, 3:4)
- 即刻方卡 (1080×1080, 1:1)
- 海报 (1080×1920, 9:16)

特性:多套配色方案、多种布局风格、
      Puppeteer + HTML/CSS 渲染引擎、
      可部署 Web 服务 + 预览页面

使用方式:冰朔在对话里告诉铸渊内容和类型,
         铸渊运行脚本生成,冰朔直接下载使用。
This commit is contained in:
铸渊 2026-05-22 23:49:02 +00:00
parent 7d8537baef
commit c1b3c02ef6
14 changed files with 2536 additions and 0 deletions

124
image-studio/config.js Normal file
View File

@ -0,0 +1,124 @@
/**
*
* 铸渊图片工作室 · 风格配置系统
*
*
* 冰朔这里所有的颜色字体间距你都可以改
* 告诉我想要的感觉我来调
*/
export const STYLES = {
/* ── 当前风格:光湖极简 ── */
name: '光湖极简',
colors: {
primary: '#1a1a2e', // 主色 · 深蓝黑
secondary: '#16213e', // 辅色 · 深蓝
accent: '#0f3460', // 强调色 · 普鲁士蓝
highlight: '#e94560', // 高亮 · 珊瑚红
gold: '#c9a96e', // 金色 · 点缀
warm: '#f5e6c8', // 暖白 · 背景
bg: '#faf8f5', // 页面背景
bgCard: '#ffffff', // 卡片背景
text: '#1a1a2e', // 正文
textMuted: '#6b7280', // 辅助文字
textLight: '#ffffff', // 浅色背景上的文字
border: '#e5e7eb', // 边框
divider: '#f0e6d3', // 分割线 · 暖色
},
fonts: {
title: '"Noto Serif SC", "Source Han Serif SC", serif',
body: '"Noto Sans SC", "Source Han Sans SC", "PingFang SC", sans-serif',
quote: '"ZCOOL QingKe HuangYou", cursive',
mono: '"JetBrains Mono", "Fira Code", monospace',
},
/* 卡片尺寸 */
sizes: {
xiaohongshu: { width: 1080, height: 1440 }, // 3:4
jike: { width: 1080, height: 1080 }, // 1:1
poster: { width: 1080, height: 1920 }, // 9:16 海报
},
/* 排版 */
typography: {
titleSize: 56,
subtitleSize: 32,
bodySize: 28,
smallSize: 22,
captionSize: 18,
lineHeight: 1.6,
},
/* 间距 */
spacing: {
paddingX: 64,
paddingY: 60,
gap: 32,
},
/* 水印/品牌标识 */
brand: {
show: true,
text: '光湖 · 铸渊',
size: 16,
opacity: 0.4,
},
}
/* ── 预先定义几套配色方案 ── */
export const COLOR_SCHEMES = {
/* 光湖极简(默认) */
guanghu: {
name: '光湖极简',
colors: {
primary: '#1a1a2e', bg: '#faf8f5', accent: '#0f3460',
highlight: '#e94560', gold: '#c9a96e', warm: '#f5e6c8',
}
},
/* 奶油暖调 */
cream: {
name: '奶油暖调',
colors: {
primary: '#5d4037', bg: '#fef7f0', accent: '#8d6e63',
highlight: '#e07a5f', gold: '#d4a373', warm: '#fae1dd',
}
},
/* 暗夜深蓝 */
night: {
name: '暗夜深蓝',
colors: {
primary: '#e0e0e0', bg: '#0d1117', accent: '#58a6ff',
highlight: '#f78166', gold: '#d4a373', warm: '#21262d',
}
},
/* 文艺绿植 */
green: {
name: '文艺绿植',
colors: {
primary: '#2d3e2f', bg: '#f5f9f2', accent: '#5a8f5a',
highlight: '#c78b5c', gold: '#b8a06e', warm: '#e8f0e0',
}
},
}
/**
* 切换到指定配色方案
*/
export function useScheme(name) {
const scheme = COLOR_SCHEMES[name]
if (!scheme) return false
Object.assign(STYLES.colors, scheme.colors)
STYLES.name = scheme.name
return true
}

48
image-studio/deploy/setup.sh Executable file
View File

@ -0,0 +1,48 @@
#!/bin/bash
# ═══════════════════════════════════════════════════
# 铸渊图片工作室 · 部署脚本
# ═══════════════════════════════════════════════════
# 用法: bash deploy/setup.sh
# 在目标服务器上运行
set -e
echo "🎨 铸渊图片工作室 · 部署开始"
# 1. 安装系统依赖
echo "📦 安装系统依赖..."
apt-get update -qq
apt-get install -y -qq \
ca-certificates fonts-liberation libappindicator3-1 \
libasound2 libatk-bridge2.0-0 libatk1.0-0 libcups2 \
libdbus-1-3 libgdk-pixbuf2.0-0 libnspr4 libnss3 \
libx11-xcb1 libxcomposite1 libxdamage1 libxrandr2 \
xdg-utils libgbm1 libxkbcommon0 > /dev/null 2>&1
echo " ✅ 系统依赖安装完成"
# 2. 安装 Node 依赖
echo "📦 安装 Node 依赖..."
cd "$(dirname "$0")/.."
npm install --production 2>&1 | tail -1
# 3. 创建输出目录
mkdir -p output
# 4. 启动服务
echo "🚀 启动图片工作室服务..."
pm2 delete zhuyuan-image-studio 2>/dev/null || true
pm2 start server.js --name zhuyuan-image-studio -- --port 3912
pm2 save
echo ""
echo "✅ 部署完成!"
echo " 服务端口: 3912"
echo " 预览地址: http://<服务器IP>:3912"
echo ""
echo " Nginx 配置参考:"
echo " location /images {"
echo " proxy_pass http://127.0.0.1:3912;"
echo " proxy_set_header Host \$host;"
echo " proxy_set_header X-Real-IP \$remote_addr;"
echo " }"

198
image-studio/generate.js Normal file
View File

@ -0,0 +1,198 @@
#!/usr/bin/env node
/**
*
* 铸渊图片工作室 · 主入口
*
*
* 冰朔你告诉我内容和想要的类型我帮你生成
*
* 用法
* node generate.js xiaohongshu --title "标题" --body "正文"
* node generate.js jike --title "观点" --body "内容"
* node generate.js poster --title "海报标题" --details "时间5月1日" "地点:广州"
*
* 也可以直接在代码里调用 API
* import { generate } from './generate.js'
* await generate({ type: 'xiaohongshu', title: '...', body: '...' })
*/
import { renderToImage, renderCarousel } from './renderer.js'
import { xiaohongshuCard, xiaohongshuCarousel } from './templates/xiaohongshu.js'
import { jikeCard } from './templates/jike.js'
import { posterCard } from './templates/poster.js'
import { STYLES, useScheme } from './config.js'
/**
* 统一生成入口
* @param {object} opts
* @param {'xiaohongshu'|'jike'|'poster'} opts.type - 卡片类型
* @param {string} opts.title - 标题
* @param {string} opts.body - 正文
* @param {string} opts.subtitle - 副标题
* @param {string} opts.tag - 标签
* @param {string} opts.quote - 金句小红书 quote 布局用
* @param {string} opts.emoji - Emoji即刻用
* @param {string} opts.footer - 底部文字
* @param {string[]} opts.details - 详情列表海报用
* @param {string} opts.cta - 行动号召海报用
* @param {'default'|'quote'|'list'|'minimal'} opts.layout - 布局
* @param {'default'|'notice'|'recruit'} opts.style - 海报风格
* @param {string} opts.scheme - 配色方案名
* @param {string} opts.output - 输出文件名不含扩展名
* @param {boolean} opts.carousel - 是否拆为多页轮播
* @param {string[]} opts.pages - 多页正文轮播用
* @returns {Promise<{files: string[], type: string}>}
*/
export async function generate(opts = {}) {
const {
type = 'xiaohongshu',
title = '',
body = '',
subtitle = '',
tag = '',
quote = '',
emoji = '',
footer = '',
details = [],
cta = '',
layout = 'default',
style = 'default',
scheme = '',
output = '',
carousel = false,
pages = [],
} = opts
// 切换配色
if (scheme) useScheme(scheme)
const baseName = output || `${type}_${Date.now()}`
const size = STYLES.sizes[type] || STYLES.sizes.xiaohongshu
let files = []
switch (type) {
case 'xiaohongshu': {
if (carousel && pages.length > 0) {
const htmls = xiaohongshuCarousel({ title, subtitle, tag, quote, layout }, pages)
files = await renderCarousel(htmls, { ...size, baseName })
} else {
const html = xiaohongshuCard({ title, body, subtitle, tag, quote, footer, layout })
files = [await renderToImage(html, { ...size, name: baseName })]
}
break
}
case 'jike': {
const html = jikeCard({ title, body, meta: subtitle, emoji, layout })
files = [await renderToImage(html, { ...size, name: baseName })]
break
}
case 'poster': {
const html = posterCard({ title, subtitle, body, details, cta, footer, tag, style })
files = [await renderToImage(html, { ...size, name: baseName })]
break
}
default:
throw new Error(`未知卡片类型: ${type}`)
}
return { files, type }
}
/* ── CLI 入口 ── */
async function cli() {
const args = process.argv.slice(2)
const type = args.find(a => !a.startsWith('--'))
if (!type || type === 'help') {
console.log(`
铸渊图片工作室 · 用法
node generate.js <类型> [选项]
类型:
xiaohongshu 小红书竖版卡片 (1080×1440)
jike 即刻方卡 (1080×1080)
poster 海报 (1080×1920)
选项:
--title 标题
--body 正文
--subtitle 副标题/元信息
--tag 标签
--quote 金句小红书 quote 布局
--emoji Emoji即刻
--details 详情海报可多个
--cta 行动号召海报
--footer 底部文字
--layout 布局: default|quote|list|minimal
--style 海报风格: default|notice|recruit
--scheme 配色: guanghu|cream|night|green
--output 输出文件名
示例:
node generate.js xiaohongshu --title "如何高效学习" --body "第一点\\n第二点\\n第三点"
node generate.js jike --title "一个思考" --body "今天想到的..." --emoji "💡"
node generate.js poster --title "放假通知" --style notice --details "时间5月1日" "地点:广州"
`)
process.exit(0)
}
function getArg(key) {
const idx = args.indexOf(key)
if (idx === -1) return undefined
// 收集后续所有非 -- 开头的参数,直到遇到下一个 --
const values = []
for (let i = idx + 1; i < args.length; i++) {
if (args[i].startsWith('--')) break
values.push(args[i])
}
return values.length === 0 ? undefined
: values.length === 1 ? values[0]
: values
}
const opts = {
type,
title: getArg('--title') || '',
body: (() => {
const b = getArg('--body')
const text = Array.isArray(b) ? b.join('\n') : (b || '')
// 将 \n 转义符转为真实换行
return text.replace(/\\n/g, '\n')
})(),
subtitle: getArg('--subtitle') || '',
tag: getArg('--tag') || '',
quote: getArg('--quote') || '',
emoji: getArg('--emoji') || '',
footer: getArg('--footer') || '',
details: (() => {
const d = getArg('--details')
return Array.isArray(d) ? d : (d ? [d] : [])
})(),
cta: getArg('--cta') || '',
layout: getArg('--layout') || 'default',
style: getArg('--style') || 'default',
scheme: getArg('--scheme') || '',
output: getArg('--output') || '',
}
console.log(`🎨 生成${type}卡片...`)
const result = await generate(opts)
console.log(`✅ 生成完成!`)
result.files.forEach(f => console.log(` 📄 ${f}`))
}
// 如果直接运行
if (process.argv[1] && (process.argv[1].includes('generate.js'))) {
cli().catch(err => {
console.error('❌ 生成失败:', err.message)
process.exit(1)
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

1136
image-studio/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
image-studio/package.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "zhuyuan-image-studio",
"version": "1.0.0",
"description": "铸渊图片工作室 — 小红书/即刻/海报配图生成工具",
"type": "module",
"main": "generate.js",
"scripts": {
"generate": "node generate.js",
"server": "node server.js",
"deploy": "pm2 start server.js --name zhuyuan-image-studio"
},
"dependencies": {
"express": "^4.21.0",
"puppeteer": "^24.0.0"
}
}

88
image-studio/renderer.js Normal file
View File

@ -0,0 +1,88 @@
/**
*
* 铸渊图片工作室 · Puppeteer 渲染引擎
*
*/
import puppeteer from 'puppeteer'
import { existsSync, mkdirSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const OUTPUT_DIR = join(__dirname, 'output')
/**
* 渲染 HTML 为图片
* @param {string} html - 完整 HTML 字符串
* @param {object} opts
* @param {number} opts.width - 图片宽度
* @param {number} opts.height - 图片高度
* @param {string} opts.name - 输出文件名不含扩展名
* @param {string} opts.format - 输出格式 png/webp/jpeg
* @param {number} opts.quality- 图片质量 webp/jpeg
* @returns {Promise<string>} 输出文件路径
*/
export async function renderToImage(html, opts = {}) {
const {
width = 1080,
height = 1440,
name = `card_${Date.now()}`,
format = 'png',
quality = 90,
} = opts
if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true })
const browser = await puppeteer.launch({
headless: true,
executablePath: '/usr/bin/google-chrome',
args: ['--no-sandbox', '--disable-setuid-sandbox'],
})
try {
const page = await browser.newPage()
await page.setViewport({ width, height })
// 注入完整的 HTML
await page.setContent(html, { waitUntil: 'networkidle0' })
// 等待字体加载
await page.evaluate(() => document.fonts.ready)
// 额外等一会儿让渲染稳定
await new Promise(r => setTimeout(r, 500))
const outputPath = join(OUTPUT_DIR, `${name}.${format}`)
await page.screenshot({
path: outputPath,
type: format,
clip: { x: 0, y: 0, width, height },
quality: format !== 'png' ? quality : undefined,
})
return outputPath
} finally {
await browser.close()
}
}
/**
* 批量生成多页轮播图
* @param {string[]} pages - 每页的 HTML
* @param {object} opts
* @returns {Promise<string[]>} 输出文件路径数组
*/
export async function renderCarousel(pages, opts = {}) {
const results = []
for (let i = 0; i < pages.length; i++) {
const name = opts.baseName
? `${opts.baseName}_p${i + 1}`
: `carousel_${Date.now()}_p${i + 1}`
const path = await renderToImage(pages[i], { ...opts, name })
results.push(path)
}
return results
}

104
image-studio/server.js Normal file
View File

@ -0,0 +1,104 @@
/**
*
* 铸渊图片工作室 · Web 服务
*
*
* 部署到服务器冰朔可以在浏览器预览已生成的图片
* 我在对话里生成图片后冰朔可以直接访问链接下载
*/
import express from 'express'
import { generate } from './generate.js'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import { readdirSync, existsSync } from 'fs'
const __dirname = dirname(fileURLToPath(import.meta.url))
const OUTPUT_DIR = join(__dirname, 'output')
const PORT = process.env.PORT || 3912
const app = express()
app.use(express.json())
/* ── 静态文件服务 ── */
app.use('/output', express.static(OUTPUT_DIR))
/* ── 首页 · 预览画廊 ── */
app.get('/', (req, res) => {
let files = []
try {
if (existsSync(OUTPUT_DIR)) {
files = readdirSync(OUTPUT_DIR)
.filter(f => /\.(png|webp|jpg)$/i.test(f))
.sort()
.reverse()
}
} catch {}
const galleryHtml = files.length > 0
? files.map(f =>
`<a href="/output/${f}" target="_blank"><img src="/output/${f}" loading="lazy"></a>`
).join('\n')
: '<div class="empty">还没有生成的图片<br>在对话里告诉铸渊你想做什么图</div>'
res.send(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>铸渊图片工作室</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, 'PingFang SC', 'Noto Sans SC', sans-serif;
background: #faf8f5; color: #1a1a2e; padding: 40px;
max-width: 1000px; margin: 0 auto;
}
h1 { font-size: 28px; margin-bottom: 4px; }
.sub { color: #6b7280; margin-bottom: 24px; font-size: 14px; }
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}
.gallery a {
display: block;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e5e7eb;
transition: transform 0.2s;
}
.gallery a:hover { transform: scale(1.02); }
.gallery img { width: 100%; display: block; }
.empty {
grid-column: 1 / -1;
text-align: center; padding: 80px 20px;
color: #9ca3af; font-size: 16px; line-height: 2;
}
</style>
</head>
<body>
<h1>🎨 铸渊图片工作室</h1>
<p class="sub">冰朔 · 你的配图我包了 · 在对话里告诉我内容就行</p>
<div class="gallery">${galleryHtml}</div>
</body>
</html>`)
})
/* ── 生成 API ── */
app.post('/api/generate', async (req, res) => {
try {
const result = await generate(req.body)
res.json({
success: true,
files: result.files,
urls: result.files.map(f => `/output/${f.split('/').pop()}`),
})
} catch (err) {
res.status(400).json({ success: false, error: err.message })
}
})
app.listen(PORT, () => {
console.log(`🎨 铸渊图片工作室运行在 http://localhost:${PORT}`)
})

View File

@ -0,0 +1,190 @@
/**
*
* 即刻卡片模板 · 1080×1080 (1:1)
*
*/
import { STYLES } from '../config.js'
const W = STYLES.sizes.jike.width
const H = STYLES.sizes.jike.height
/**
* 生成即刻卡片 HTML
* @param {object} data
* @param {string} data.title - 标题/核心观点
* @param {string} data.body - 正文
* @param {string} data.meta - 元信息2026-05 · 思考碎片
* @param {string} data.emoji - 封面 emoji可选
* @param {'default'|'quote'|'minimal'} data.layout - 布局
*/
export function jikeCard(data) {
const {
title = '',
body = '',
meta = '',
emoji = '',
layout = 'default',
} = data
const C = STYLES.colors
const F = STYLES.fonts
const T = STYLES.typography
const bodyLines = body.split('\n').filter(Boolean)
const bodyHtml = bodyLines.map(line => `<p>${line}</p>`).join('\n')
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap');
body {
width: ${W}px;
height: ${H}px;
overflow: hidden;
background: ${C.bg};
font-family: ${F.body};
display: flex;
flex-direction: column;
position: relative;
}
/* 顶部渐变装饰条 */
.accent-bar {
height: 6px;
background: linear-gradient(90deg, ${C.accent}, ${C.highlight}, ${C.gold});
}
.container {
flex: 1;
padding: 48px 56px;
display: flex;
flex-direction: column;
}
.meta {
font-size: ${T.captionSize}px;
color: ${C.textMuted};
letter-spacing: 2px;
margin-bottom: 16px;
}
.emoji-row {
font-size: 64px;
margin-bottom: 16px;
line-height: 1;
}
.title {
font-family: ${F.title};
font-size: 40px;
font-weight: 700;
line-height: 1.4;
color: ${C.primary};
margin-bottom: 20px;
letter-spacing: 1px;
}
.body {
flex: 1;
font-size: 26px;
line-height: 1.7;
color: ${C.text};
}
.body p {
margin-bottom: 10px;
}
/* 金句布局 */
.quote-section {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 0 32px;
}
.quote-emoji {
font-size: 72px;
margin-bottom: 20px;
}
.quote-line {
width: 40px;
height: 2px;
background: ${C.gold};
margin: 0 auto 24px;
}
.quote-title {
font-family: ${F.title};
font-size: 36px;
font-weight: 600;
line-height: 1.5;
color: ${C.primary};
letter-spacing: 2px;
}
.quote-body {
margin-top: 20px;
font-size: 24px;
color: ${C.textMuted};
line-height: 1.6;
}
/* 底部 */
.footer {
padding: 20px 56px 28px;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid ${C.divider};
font-size: ${T.captionSize}px;
color: ${C.textMuted};
}
.brand {
opacity: ${STYLES.brand.opacity};
}
</style>
</head>
<body>
<div class="accent-bar"></div>
<div class="container">
${meta ? `<div class="meta">${meta}</div>` : ''}
${layout === 'quote' ? `
<div class="quote-section">
${emoji ? `<div class="quote-emoji">${emoji}</div>` : ''}
<div class="quote-line"></div>
<div class="quote-title">${title}</div>
${body ? `<div class="quote-body">${body}</div>` : ''}
</div>
` : `
${emoji ? `<div class="emoji-row">${emoji}</div>` : ''}
<div class="title">${title}</div>
<div class="body">${bodyHtml}</div>
`}
</div>
<div class="footer">
<span class="brand">${STYLES.brand.text}</span>
<span>即刻 · Jike</span>
</div>
</body>
</html>`
}

View File

@ -0,0 +1,316 @@
/**
*
* 海报模板 · 1080×1920 (9:16)
*
*
* 适用于放假通知收稿海报活动公告等
*/
import { STYLES } from '../config.js'
const W = STYLES.sizes.poster.width
const H = STYLES.sizes.poster.height
/**
* 生成海报 HTML
* @param {object} data
* @param {string} data.title - 大标题
* @param {string} data.subtitle - 副标题可选
* @param {string} data.body - 正文内容
* @param {string[]} data.details - 详情列表如时间/地点/要求
* @param {string} data.cta - 行动号召欢迎投稿
* @param {string} data.footer - 底部信息如主办方/联系方式
* @param {string} data.tag - 角标标签通知
* @param {'default'|'notice'|'recruit'} data.style - 海报风格
*/
export function posterCard(data) {
const {
title = '',
subtitle = '',
body = '',
details = [],
cta = '',
footer = '',
tag = '',
style = 'default',
} = data
const C = STYLES.colors
const F = STYLES.fonts
const T = STYLES.typography
// 详情列表渲染
const detailsHtml = details.map((d, i) => {
// 支持 key: value 格式
const colonIdx = d.indexOf('')
if (colonIdx > 0 && colonIdx < 20) {
const key = d.slice(0, colonIdx)
const val = d.slice(colonIdx + 1)
return `
<div class="detail-item">
<span class="detail-key">${key}</span>
<span class="detail-colon"></span>
<span class="detail-val">${val}</span>
</div>`
}
return `<div class="detail-item">${d}</div>`
}).join('\n')
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600;700;900&family=Noto+Sans+SC:wght@300;400;500;700&display=swap');
body {
width: ${W}px;
height: ${H}px;
overflow: hidden;
background: ${C.bg};
font-family: ${F.body};
display: flex;
flex-direction: column;
position: relative;
}
/* ── 背景装饰 ── */
.bg-accent {
position: absolute;
top: 0;
right: 0;
width: 300px;
height: 300px;
background: radial-gradient(circle, ${C.accent}08 0%, transparent 70%);
}
.bg-accent-bl {
position: absolute;
bottom: 0;
left: 0;
width: 400px;
height: 400px;
background: radial-gradient(circle, ${C.highlight}06 0%, transparent 70%);
}
.container {
flex: 1;
padding: 72px 64px 48px;
display: flex;
flex-direction: column;
position: relative;
z-index: 1;
}
/* ── 标签 ── */
.tag {
display: inline-block;
padding: 6px 18px;
border: 1px solid ${C.accent};
color: ${C.accent};
font-size: ${T.captionSize}px;
border-radius: 4px;
margin-bottom: 32px;
align-self: flex-start;
letter-spacing: 3px;
}
/* ── 标题区 ── */
.title-section {
margin-bottom: 40px;
}
.title {
font-family: ${F.title};
font-size: 64px;
font-weight: 900;
line-height: 1.25;
color: ${C.primary};
letter-spacing: 4px;
}
.title-accent {
color: ${C.highlight};
}
.subtitle {
font-family: ${F.title};
font-size: ${T.subtitleSize}px;
font-weight: 300;
color: ${C.textMuted};
margin-top: 12px;
letter-spacing: 6px;
}
/* ── 分割线 ── */
.divider {
width: 80px;
height: 3px;
background: linear-gradient(90deg, ${C.gold}, ${C.highlight});
margin-bottom: 32px;
}
/* ── 正文 ── */
.body {
font-size: ${T.bodySize}px;
line-height: ${T.lineHeight};
color: ${C.text};
margin-bottom: 32px;
}
.body p {
margin-bottom: 12px;
}
/* ── 详情列表 ── */
.details {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 32px;
padding: 32px;
background: ${C.warm}30;
border-radius: 8px;
border-left: 4px solid ${C.accent};
}
.detail-item {
font-size: ${T.bodySize}px;
line-height: 1.5;
color: ${C.text};
}
.detail-key {
font-weight: 600;
color: ${C.accent};
}
.detail-colon { color: ${C.textMuted}; }
.detail-val { color: ${C.text}; }
/* ── CTA ── */
.cta-section {
text-align: center;
padding: 24px 0;
margin-bottom: 16px;
}
.cta {
display: inline-block;
padding: 16px 48px;
background: linear-gradient(135deg, ${C.accent}, ${C.primary});
color: ${C.textLight};
font-size: ${T.subtitleSize}px;
font-weight: 600;
border-radius: 8px;
letter-spacing: 4px;
}
/* ── 底部 ── */
.footer {
text-align: center;
padding-top: 16px;
border-top: 1px solid ${C.divider};
font-size: ${T.captionSize}px;
color: ${C.textMuted};
line-height: 1.6;
}
.brand-line {
opacity: ${STYLES.brand.opacity};
margin-top: 4px;
}
/* ── 通知风格:更正式 ── */
.notice-title {
font-family: ${F.title};
font-size: 56px;
font-weight: 700;
text-align: center;
letter-spacing: 8px;
color: ${C.primary};
margin-bottom: 16px;
}
.notice-sub {
text-align: center;
font-size: ${T.smallSize}px;
color: ${C.textMuted};
letter-spacing: 4px;
margin-bottom: 32px;
}
/* ── 收稿风格:更有活力 ── */
.recruit-badge {
display: inline-block;
padding: 8px 24px;
background: ${C.highlight};
color: white;
font-size: ${T.smallSize}px;
border-radius: 4px;
letter-spacing: 2px;
margin-bottom: 20px;
align-self: center;
}
.recruit-title {
font-family: ${F.title};
font-size: 60px;
font-weight: 900;
text-align: center;
line-height: 1.3;
color: ${C.primary};
letter-spacing: 3px;
margin-bottom: 8px;
}
</style>
</head>
<body>
<div class="bg-accent"></div>
<div class="bg-accent-bl"></div>
<div class="container">
${style === 'notice' ? `
${tag ? `<div class="tag" style="align-self:center;">${tag}</div>` : ''}
<div class="notice-title">${title}</div>
${subtitle ? `<div class="notice-sub">${subtitle}</div>` : ''}
<div class="divider" style="align-self:center;"></div>
` : style === 'recruit' ? `
<div class="recruit-badge">${tag || '征稿启事'}</div>
<div class="recruit-title">${title}</div>
${subtitle ? `<div style="text-align:center;font-size:${T.smallSize}px;color:${C.textMuted};letter-spacing:2px;margin-bottom:24px;">${subtitle}</div>` : ''}
` : `
${tag ? `<div class="tag">${tag}</div>` : ''}
<div class="title-section">
<div class="title">${title}</div>
${subtitle ? `<div class="subtitle">${subtitle}</div>` : ''}
</div>
<div class="divider"></div>
`}
${body ? `<div class="body"><p>${body}</p></div>` : ''}
${details.length > 0 ? `<div class="details">${detailsHtml}</div>` : ''}
<div style="flex:1;"></div>
${cta ? `
<div class="cta-section">
<div class="cta">${cta}</div>
</div>
` : ''}
<div class="footer">
${footer ? `<div>${footer}</div>` : ''}
<div class="brand-line">${STYLES.brand.text}</div>
</div>
</div>
</body>
</html>`
}

View File

@ -0,0 +1,316 @@
/**
*
* 小红书卡片模板 · 1080×1440 (3:4)
*
*
* 支持
* - 单页卡片封面/金句/干货
* - 多页轮播长内容自动分页
*/
import { STYLES } from '../config.js'
const W = STYLES.sizes.xiaohongshu.width
const H = STYLES.sizes.xiaohongshu.height
/**
* 生成小红书卡片 HTML
* @param {object} data
* @param {string} data.title - 标题
* @param {string} data.body - 正文支持 \n 换行
* @param {string} data.subtitle - 副标题可选
* @param {string} data.tag - 标签可选干货·教程
* @param {string} data.quote - 金句/引用可选
* @param {string} data.footer - 底部文字可选如账号名
* @param {string} data.layout - 布局: 'default' | 'quote' | 'list' | 'minimal'
* @param {number} data.pageNum - 当前页码多页时
* @param {number} data.totalPages - 总页数多页时
*/
export function xiaohongshuCard(data) {
const {
title = '',
body = '',
subtitle = '',
tag = '',
quote = '',
footer = '',
layout = 'default',
pageNum = 1,
totalPages = 1,
} = data
const C = STYLES.colors
const F = STYLES.fonts
const T = STYLES.typography
// 正文按换行分段
const bodyLines = body.split('\n').filter(Boolean)
const bodyHtml = bodyLines.map(line => {
// 检测是否以 - 开头(列表项)
if (line.trimStart().startsWith('- ')) {
return `<li>${line.trimStart().slice(2)}</li>`
}
// 检测是否以 # 开头(小标题)
if (line.trimStart().startsWith('#')) {
return `<h3 class="sub-heading">${line.trimStart().replace(/^#+\s*/, '')}</h3>`
}
return `<p>${line}</p>`
}).join('\n')
const hasList = bodyLines.some(l => l.trimStart().startsWith('- '))
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap');
body {
width: ${W}px;
height: ${H}px;
overflow: hidden;
background: ${C.bg};
font-family: ${F.body};
display: flex;
flex-direction: column;
position: relative;
}
/* ── 装饰角标 ── */
.corner-tl, .corner-br {
position: absolute;
width: 60px;
height: 60px;
border-color: ${C.accent};
opacity: 0.15;
}
.corner-tl {
top: 24px; left: 24px;
border-top: 2px solid;
border-left: 2px solid;
}
.corner-br {
bottom: 24px; right: 24px;
border-bottom: 2px solid;
border-right: 2px solid;
}
.container {
flex: 1;
padding: ${STYLES.spacing.paddingY}px ${STYLES.spacing.paddingX}px;
display: flex;
flex-direction: column;
justify-content: center;
}
/* ── 标签 ── */
.tag {
display: inline-block;
padding: 8px 20px;
background: ${C.accent};
color: ${C.textLight};
font-size: ${T.captionSize}px;
border-radius: 20px;
margin-bottom: 24px;
align-self: flex-start;
letter-spacing: 2px;
}
/* ── 标题 ── */
.title {
font-family: ${F.title};
font-size: ${T.titleSize}px;
font-weight: 700;
line-height: 1.35;
color: ${C.primary};
margin-bottom: ${subtitle ? 16 : 32}px;
letter-spacing: 2px;
}
.subtitle {
font-size: ${T.subtitleSize}px;
font-weight: 300;
color: ${C.textMuted};
margin-bottom: 32px;
letter-spacing: 1px;
}
/* ── 正文 ── */
.body {
flex: 1;
font-size: ${T.bodySize}px;
line-height: ${T.lineHeight};
color: ${C.text};
}
.body p {
margin-bottom: 12px;
}
.body ul, .body ol {
padding-left: 32px;
margin-bottom: 12px;
}
.body li {
font-size: ${T.bodySize}px;
line-height: ${T.lineHeight};
margin-bottom: 8px;
color: ${C.text};
list-style: none;
position: relative;
padding-left: 24px;
}
.body li::before {
content: "◆";
position: absolute;
left: 0;
color: ${C.highlight};
font-size: 14px;
top: 6px;
}
.sub-heading {
font-family: ${F.title};
font-size: ${T.subtitleSize}px;
font-weight: 600;
color: ${C.accent};
margin: 24px 0 12px 0;
letter-spacing: 1px;
}
/* ── 金句布局 ── */
.quote-section {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 0 40px;
}
.quote-mark {
font-size: 80px;
font-family: ${F.title};
color: ${C.highlight};
opacity: 0.3;
line-height: 1;
margin-bottom: 16px;
}
.quote-text {
font-family: ${F.title};
font-size: 40px;
font-weight: 600;
line-height: 1.5;
color: ${C.primary};
letter-spacing: 3px;
}
.quote-author {
margin-top: 32px;
font-size: ${T.smallSize}px;
color: ${C.textMuted};
}
/* ── 极简布局 ── */
.minimal-title {
font-family: ${F.title};
font-size: 48px;
font-weight: 300;
line-height: 1.4;
color: ${C.primary};
letter-spacing: 4px;
text-align: center;
}
.minimal-divider {
width: 60px;
height: 1px;
background: ${C.gold};
margin: 32px auto;
}
/* ── 底部 ── */
.footer {
padding: 24px ${STYLES.spacing.paddingX}px;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid ${C.divider};
font-size: ${T.captionSize}px;
color: ${C.textMuted};
}
.brand {
opacity: ${STYLES.brand.opacity};
}
.pagination {
font-size: ${T.captionSize}px;
color: ${C.textMuted};
}
</style>
</head>
<body>
<div class="corner-tl"></div>
<div class="corner-br"></div>
<div class="container">
${tag ? `<div class="tag">${tag}</div>` : ''}
${layout === 'quote' ? `
<div class="quote-section">
<div class="quote-mark">"</div>
<div class="quote-text">${quote || title}</div>
${subtitle ? `<div class="quote-author">${subtitle}</div>` : ''}
</div>
` : layout === 'minimal' ? `
<div style="flex:1;display:flex;flex-direction:column;justify-content:center;align-items:center;">
<div class="minimal-title">${title}</div>
<div class="minimal-divider"></div>
${body ? `<div style="font-size:${T.bodySize}px;color:${C.textMuted};text-align:center;max-width:80%;">${body}</div>` : ''}
</div>
` : `
<div class="title">${title}</div>
${subtitle ? `<div class="subtitle">${subtitle}</div>` : ''}
<div class="body">
${bodyHtml}
</div>
`}
</div>
<div class="footer">
<span class="brand">${STYLES.brand.text}</span>
${totalPages > 1 ? `<span class="pagination">${pageNum} / ${totalPages}</span>` : ''}
</div>
</body>
</html>`
}
/**
* 生成小红书轮播多页
* @param {object} data - xiaohongshuCard
* @param {string[]} pages - 每页正文内容数组
* @returns {string[]} HTML 数组
*/
export function xiaohongshuCarousel(data, pages) {
return pages.map((body, i) => xiaohongshuCard({
...data,
body,
pageNum: i + 1,
totalPages: pages.length,
}))
}