611 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>封面工作室 · 语言驱动排版设计</title>
<meta name="description" content="输入文字自动排版生成封面。开源免费无需GPU。">
<style>
:root {
--bg: #0a0e27;
--surface: #111636;
--border: #1e2a4a;
--text: #c8d6e5;
--text-muted: #6b7d99;
--accent: #4f8cff;
--accent-glow: #00d4ff;
--danger: #ff4757;
--success: #2ed573;
--radius: 12px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, 'PingFang SC', 'Noto Sans SC', sans-serif;
min-height: 100vh;
line-height: 1.6;
}
/* ── Header ── */
.header {
text-align: center;
padding: 48px 24px 32px;
}
.header h1 {
font-size: 36px;
font-weight: 800;
background: linear-gradient(135deg, var(--accent), var(--accent-glow));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: 2px;
margin-bottom: 8px;
}
.header .desc {
font-size: 16px;
color: var(--text-muted);
}
.header .badges {
margin-top: 16px;
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.badge {
display: inline-block;
padding: 4px 14px;
font-size: 12px;
border-radius: 20px;
border: 1px solid var(--border);
color: var(--text-muted);
letter-spacing: 1px;
}
/* ── Main Layout ── */
.main {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px 80px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
}
@media (max-width: 900px) {
.main {
grid-template-columns: 1fr;
}
}
/* ── Panel ── */
.panel {
background: var(--surface);
border-radius: var(--radius);
border: 1px solid var(--border);
padding: 28px;
}
.panel-title {
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--text-muted);
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
}
.panel-title::before {
content: '';
display: block;
width: 4px;
height: 16px;
background: var(--accent);
border-radius: 2px;
}
/* ── Form ── */
.form-group {
margin-bottom: 18px;
}
.form-group label {
display: block;
font-size: 13px;
color: var(--text-muted);
margin-bottom: 6px;
letter-spacing: 1px;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 12px 16px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 15px;
font-family: inherit;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(79, 140, 255, 0.1);
}
.form-group textarea {
min-height: 180px;
resize: vertical;
line-height: 1.7;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
/* ── Preset Selector ── */
.preset-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
margin-bottom: 18px;
}
.preset-btn {
padding: 10px 6px;
border: 2px solid transparent;
border-radius: 8px;
cursor: pointer;
text-align: center;
font-size: 12px;
color: var(--text-muted);
background: var(--bg);
transition: all 0.2s;
font-family: inherit;
}
.preset-btn:hover {
border-color: var(--border);
color: var(--text);
}
.preset-btn.active {
border-color: var(--accent);
color: var(--accent);
background: rgba(79, 140, 255, 0.08);
}
/* ── Generate Button ── */
.generate-btn {
width: 100%;
padding: 14px 24px;
background: linear-gradient(135deg, var(--accent), #3d6fd9);
color: #fff;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
letter-spacing: 2px;
transition: all 0.2s;
font-family: inherit;
}
.generate-btn:hover {
transform: translateY(-1px);
box-shadow: 0 8px 30px rgba(79, 140, 255, 0.3);
}
.generate-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.generate-btn .spinner {
display: none;
width: 16px;
height: 16px;
border: 2px solid #fff;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
margin-right: 8px;
vertical-align: middle;
}
.generate-btn.loading .spinner { display: inline-block; }
.generate-btn.loading .btn-text { display: none; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Preview ── */
.preview-area {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
}
.preview-placeholder {
text-align: center;
color: var(--text-muted);
padding: 60px 20px;
}
.preview-placeholder .icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.3;
}
.preview-placeholder .text {
font-size: 15px;
line-height: 1.8;
}
.preview-img {
max-width: 100%;
max-height: 500px;
border-radius: 8px;
border: 1px solid var(--border);
display: none;
}
.preview-img.show { display: block; }
/* ── Download Button ── */
.download-bar {
display: none;
margin-top: 16px;
width: 100%;
text-align: center;
}
.download-bar.show { display: block; }
.download-btn {
display: inline-block;
padding: 10px 28px;
background: var(--surface);
color: var(--accent);
border: 1px solid var(--accent);
border-radius: 8px;
text-decoration: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
letter-spacing: 1px;
transition: all 0.2s;
}
.download-btn:hover {
background: rgba(79, 140, 255, 0.1);
}
/* ── Loading ── */
.loading-dots {
display: flex;
gap: 8px;
justify-content: center;
padding: 40px;
}
.loading-dots span {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent);
animation: dot 1.2s ease-in-out infinite;
}
.loading-dots span:nth-child(2) { animation-delay: 0.2s; }
.loading-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes dot { 0%,80%,100% { transform: scale(0.6); opacity: 0.3; } 40% { transform: scale(1); opacity: 1; } }
/* ── Error ── */
.error-msg {
display: none;
padding: 12px 16px;
background: rgba(255, 71, 87, 0.1);
border: 1px solid var(--danger);
border-radius: 8px;
color: var(--danger);
font-size: 13px;
margin-top: 12px;
}
.error-msg.show { display: block; }
/* ── Footer ── */
.footer {
text-align: center;
padding: 32px 24px;
font-size: 13px;
color: var(--text-muted);
opacity: 0.5;
letter-spacing: 1px;
}
.footer a {
color: var(--accent);
text-decoration: none;
}
.footer .divider {
margin: 0 8px;
opacity: 0.3;
}
</style>
</head>
<body>
<header class="header">
<h1>封面工作室</h1>
<p class="desc">语言驱动排版设计 · 输入文字,自动生成封面</p>
<div class="badges">
<span class="badge">开源免费</span>
<span class="badge">无需GPU</span>
<span class="badge">一键部署</span>
<span class="badge">MIT协议</span>
</div>
</header>
<div class="main">
<!-- 左侧:输入面板 -->
<div class="panel" id="inputPanel">
<div class="panel-title">📝 内容输入</div>
<div class="form-group">
<label for="templateSelect">模板</label>
<select id="templateSelect"></select>
</div>
<div class="form-group">
<label>预设风格</label>
<div class="preset-grid" id="presetGrid"></div>
</div>
<div class="form-row">
<div class="form-group">
<label for="layoutSelect">布局</label>
<select id="layoutSelect">
<option value="default">标准排版</option>
<option value="hero">大标题封面</option>
<option value="quote">金句居中</option>
</select>
</div>
<div class="form-group">
<label for="tagInput">标签(可选)</label>
<input type="text" id="tagInput" placeholder="例如:教程 · 干货">
</div>
</div>
<div class="form-group">
<label for="titleInput">标题</label>
<input type="text" id="titleInput" placeholder="标题将作为封面大标题">
</div>
<div class="form-group">
<label for="bodyInput">正文(支持 Markdown 列表和加粗)</label>
<textarea id="bodyInput" placeholder="输入正文内容...&#10;&#10;支持格式:&#10;- 列表项用 - 或 • 开头&#10;## 小标题用 ## 开头&#10;**加粗文字** 用双星号包裹&#10;&#10;无需GPU · 语言驱动排版 · 开源免费&#10;一行命令部署到你的服务器&#10;支持多种风格和模板"></textarea>
</div>
<button class="generate-btn" id="genBtn" onclick="doGenerate()">
<span class="spinner"></span>
<span class="btn-text">⚡ 生成封面</span>
</button>
<div class="error-msg" id="errorMsg"></div>
</div>
<!-- 右侧:预览面板 -->
<div class="panel" id="previewPanel">
<div class="panel-title">👁️ 预览</div>
<div class="preview-area" id="previewArea">
<div class="preview-placeholder" id="placeholder">
<div class="icon">🎨</div>
<div class="text">
在左侧输入内容<br>
选择模板和风格<br>
点击「生成封面」
</div>
</div>
<div class="loading-dots" id="loadingDots" style="display:none">
<span></span><span></span><span></span>
</div>
<img class="preview-img" id="previewImg" alt="封面预览">
<div class="download-bar" id="downloadBar">
<a class="download-btn" id="downloadLink" href="#" download>💾 下载封面 (PNG)</a>
</div>
</div>
</div>
</div>
<footer class="footer">
<span>铸渊封面工作室 v2.0</span>
<span class="divider">·</span>
<span>开源协议 <a href="#" target="_blank">MIT</a></span>
<span class="divider">·</span>
<span>代码仓库 <a href="https://guanghulab.com/code/bingshuo/guanghulab" target="_blank">guanghulab.com</a></span>
</footer>
<script>
// ── State ──
let templates = []
let currentTemplate = null
let currentPreset = 'tech'
let currentLayout = 'default'
// ── Init ──
async function init() {
try {
const res = await fetch('/api/templates')
templates = await res.json()
renderTemplateSelect()
selectTemplate(templates[0]?.id)
// 快速填充示例内容
document.getElementById('titleInput').value = '零基础手搓AI封面生成器'
document.getElementById('bodyInput').value =
'不需要GPU · 不花一分钱 · 语言驱动自动排版\n\n' +
'- **零成本** 纯 HTML/CSS 排版渲染,无需 AI 绘画模型\n' +
'- **极轻量** 2GB 内存就能跑,一行命令部署\n' +
'- **语言驱动** 输入文字,自动分析内容类型和情绪,推荐配色和布局\n' +
'- **开源免费** MIT 协议,代码完全公开\n' +
'- **可扩展** 模板注册表机制,新增模板零改动核心代码'
} catch (e) {
console.error('加载模板失败:', e)
showError('无法加载模板列表,请检查服务器连接')
}
}
function renderTemplateSelect() {
const sel = document.getElementById('templateSelect')
sel.innerHTML = templates.map(t =>
`<option value="${t.id}">${t.icon} ${t.name}${t.description}</option>`
).join('')
sel.onchange = () => selectTemplate(sel.value)
}
function selectTemplate(id) {
currentTemplate = templates.find(t => t.id === id)
if (!currentTemplate) return
currentPreset = currentTemplate.presets?.[0]?.id || 'tech'
renderPresets()
document.getElementById('templateSelect').value = id
}
function renderPresets() {
const grid = document.getElementById('presetGrid')
const presets = currentTemplate?.presets || []
grid.innerHTML = presets.map(p =>
`<button class="preset-btn ${p.id === currentPreset ? 'active' : ''}"
onclick="selectPreset('${p.id}')">${p.name}</button>`
).join('')
}
function selectPreset(id) {
currentPreset = id
renderPresets()
}
// ── Generate ──
async function doGenerate() {
const title = document.getElementById('titleInput').value.trim()
const body = document.getElementById('bodyInput').value.trim()
const tag = document.getElementById('tagInput').value.trim()
const layout = document.getElementById('layoutSelect').value
if (!title && !body) {
showError('请至少输入标题或正文内容')
return
}
setLoading(true)
hideError()
try {
const res = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
templateId: currentTemplate?.id,
presetId: currentPreset,
title,
body,
tag,
layout,
}),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.error || '生成失败')
}
const data = await res.json()
showPreview(data.url || data.urls[0])
} catch (e) {
showError(e.message || '生成失败,请稍后重试')
setLoading(false)
}
}
function setLoading(loading) {
const btn = document.getElementById('genBtn')
const dots = document.getElementById('loadingDots')
const placeholder = document.getElementById('placeholder')
const img = document.getElementById('previewImg')
const bar = document.getElementById('downloadBar')
if (loading) {
btn.classList.add('loading')
btn.disabled = true
placeholder.style.display = 'none'
img.classList.remove('show')
bar.classList.remove('show')
dots.style.display = 'flex'
} else {
btn.classList.remove('loading')
btn.disabled = false
dots.style.display = 'none'
}
}
function showPreview(url) {
setLoading(false)
const placeholder = document.getElementById('placeholder')
const img = document.getElementById('previewImg')
const bar = document.getElementById('downloadBar')
const link = document.getElementById('downloadLink')
placeholder.style.display = 'none'
img.src = url
img.classList.add('show')
bar.classList.add('show')
link.href = url
}
function showError(msg) {
const el = document.getElementById('errorMsg')
el.textContent = msg
el.classList.add('show')
}
function hideError() {
document.getElementById('errorMsg').classList.remove('show')
}
// ── Keyboard shortcut ──
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
doGenerate()
}
})
// ── Start ──
init()
</script>
</body>
</html>