feat: 封面工作室公开前端UI · 左右分栏布局 · 模板/预设选择器 · 实时预览 · 一键下载 · 响应式适配

This commit is contained in:
bingshuo 2026-05-27 18:33:05 +08:00
parent f348d89272
commit e4a529160b

View File

@ -0,0 +1,610 @@
<!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>