feat: ship blog platform admin and deploy stack

This commit is contained in:
2026-03-31 21:48:39 +08:00
parent a9a05aa105
commit 313f174fbc
210 changed files with 25476 additions and 5803 deletions

View File

@@ -1,3 +0,0 @@
<html><body>
not found :-(
</body></html>

View File

@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0; url=/admin">
<title>Redirecting...</title>
</head>
<body>
<p>Redirecting to <a href="/admin">Admin Dashboard</a>...</p>
</body>
</html>

View File

@@ -1,705 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page_title | default(value="Termi Admin") }} · Termi Admin</title>
<style>
:root {
--bg: #f4f4f5;
--bg-panel: rgba(255, 255, 255, 0.88);
--bg-panel-strong: rgba(255, 255, 255, 0.98);
--line: rgba(24, 24, 27, 0.09);
--line-strong: rgba(24, 24, 27, 0.16);
--text: #09090b;
--text-soft: #52525b;
--text-mute: #71717a;
--accent: #18181b;
--accent-2: #2563eb;
--accent-3: #dc2626;
--accent-4: #16a34a;
--shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
--radius-xl: 24px;
--radius-lg: 18px;
--radius-md: 12px;
--font-sans: "Inter", "Segoe UI", "PingFang SC", sans-serif;
--font-mono: "JetBrains Mono", "Cascadia Code", monospace;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: var(--font-sans);
color: var(--text);
background:
radial-gradient(circle at top, rgba(37, 99, 235, 0.08), transparent 30%),
linear-gradient(180deg, #fafafa 0%, #f4f4f5 100%);
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
textarea,
select {
font: inherit;
}
.shell {
display: grid;
grid-template-columns: 290px minmax(0, 1fr);
min-height: 100vh;
gap: 24px;
padding: 24px;
}
.sidebar,
.surface,
.stat,
.table-panel,
.hero-card,
.form-panel,
.login-panel {
border: 1px solid var(--line);
border-radius: var(--radius-xl);
background: var(--bg-panel);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
}
.sidebar {
padding: 28px 22px;
position: sticky;
top: 24px;
height: calc(100vh - 48px);
display: flex;
flex-direction: column;
gap: 24px;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: 16px;
background: #111827;
border: 1px solid #111827;
font-family: var(--font-mono);
font-weight: 700;
color: #fafafa;
}
.brand-title {
margin: 14px 0 6px;
font-size: 1.35rem;
}
.brand-copy {
margin: 0;
color: var(--text-soft);
line-height: 1.6;
font-size: 0.95rem;
}
.nav-group {
display: grid;
gap: 10px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 18px;
color: var(--text-soft);
border: 1px solid transparent;
transition: 160ms ease;
}
.nav-item:hover,
.nav-item.active {
background: rgba(255, 255, 255, 0.98);
border-color: var(--line);
color: var(--text);
transform: translateX(2px);
}
.nav-item.active {
box-shadow: inset 0 0 0 1px rgba(24, 24, 27, 0.06);
}
.nav-kicker {
margin-top: auto;
padding: 18px;
border-radius: 22px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid var(--line);
}
.nav-kicker strong {
display: block;
margin-bottom: 6px;
font-size: 0.98rem;
}
.nav-kicker p {
margin: 0;
color: var(--text-soft);
line-height: 1.55;
font-size: 0.92rem;
}
.content-shell {
display: flex;
flex-direction: column;
gap: 18px;
}
.surface {
padding: 26px 28px;
}
.topbar {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: flex-start;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(24, 24, 27, 0.05);
color: var(--text-soft);
font-size: 0.84rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.page-title {
margin: 12px 0 8px;
font-size: clamp(1.7rem, 2.2vw, 2.5rem);
line-height: 1.1;
}
.page-description {
margin: 0;
max-width: 760px;
color: var(--text-soft);
line-height: 1.7;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 0 16px;
border-radius: 14px;
border: 1px solid transparent;
cursor: pointer;
transition: 160ms ease;
font-weight: 600;
text-decoration: none;
}
.btn:hover {
transform: translateY(-1px);
}
.btn-primary {
background: var(--accent);
color: #fafafa;
box-shadow: 0 10px 24px rgba(24, 24, 27, 0.16);
}
.btn-ghost {
background: rgba(255, 255, 255, 0.98);
border-color: var(--line);
color: var(--text);
}
.btn-danger {
background: rgba(220, 38, 38, 0.08);
border-color: rgba(220, 38, 38, 0.14);
color: var(--accent-3);
}
.btn-success {
background: rgba(22, 163, 74, 0.08);
border-color: rgba(22, 163, 74, 0.14);
color: var(--accent-4);
}
.btn-warning {
background: rgba(245, 158, 11, 0.08);
border-color: rgba(245, 158, 11, 0.16);
color: #b45309;
}
.content-grid {
display: grid;
gap: 18px;
}
.stats-grid,
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.stat,
.hero-card,
.table-panel,
.form-panel {
padding: 22px;
border-radius: var(--radius-lg);
background: var(--bg-panel-strong);
}
.stat-label,
.muted,
.table-note,
.field-hint,
.badge-soft {
color: var(--text-mute);
}
.stat-value {
margin: 10px 0 6px;
font-size: 2rem;
font-weight: 700;
line-height: 1;
}
.tone-blue .stat-value { color: var(--accent-2); }
.tone-gold .stat-value { color: var(--accent); }
.tone-green .stat-value { color: var(--accent-4); }
.tone-pink .stat-value { color: var(--accent-3); }
.tone-violet .stat-value { color: #7a5ef4; }
.table-panel {
overflow: hidden;
}
.table-head {
display: flex;
justify-content: space-between;
gap: 14px;
align-items: flex-end;
margin-bottom: 14px;
}
.table-head h2,
.hero-card h2 {
margin: 0 0 6px;
font-size: 1.15rem;
}
.table-wrap {
overflow: auto;
border-radius: 16px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.98);
}
table {
width: 100%;
border-collapse: collapse;
min-width: 880px;
}
th,
td {
padding: 16px;
text-align: left;
vertical-align: top;
border-bottom: 1px solid rgba(93, 76, 56, 0.1);
}
th {
position: sticky;
top: 0;
background: rgba(250, 250, 250, 0.98);
color: var(--text-soft);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
tr:last-child td {
border-bottom: 0;
}
.item-title {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.item-title strong {
font-size: 0.98rem;
}
.item-meta,
.mono {
color: var(--text-soft);
font-family: var(--font-mono);
font-size: 0.84rem;
word-break: break-all;
}
.badge,
.chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
font-size: 0.78rem;
border: 1px solid transparent;
white-space: nowrap;
}
.badge-success {
color: var(--accent-4);
background: rgba(93, 122, 45, 0.1);
border-color: rgba(93, 122, 45, 0.14);
}
.badge-warning {
color: var(--accent);
background: rgba(202, 94, 45, 0.1);
border-color: rgba(202, 94, 45, 0.14);
}
.badge-danger {
color: var(--accent-3);
background: rgba(156, 61, 84, 0.1);
border-color: rgba(156, 61, 84, 0.14);
}
.chip {
background: rgba(241, 245, 249, 0.95);
color: var(--text-soft);
border-color: rgba(148, 163, 184, 0.18);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.inline-links {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.inline-link {
color: var(--accent-2);
font-size: 0.88rem;
font-weight: 600;
}
.inline-link:hover {
text-decoration: underline;
}
.empty {
padding: 40px 18px;
text-align: center;
color: var(--text-soft);
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
}
.field-wide {
grid-column: 1 / -1;
}
.field label {
display: block;
margin-bottom: 8px;
font-size: 0.9rem;
color: var(--text-soft);
font-weight: 600;
}
.field input,
.field textarea,
.field select {
width: 100%;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255, 255, 255, 0.98);
color: var(--text);
padding: 14px 16px;
}
.field textarea {
resize: vertical;
min-height: 132px;
}
.inline-form {
display: grid;
gap: 10px;
}
.inline-form.compact {
gap: 8px;
}
.compact-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
align-items: end;
}
.compact-grid textarea,
.compact-grid input,
.compact-grid select {
width: 100%;
min-height: 40px;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.98);
color: var(--text);
padding: 10px 12px;
}
.compact-grid textarea {
min-height: 84px;
resize: vertical;
}
.compact-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.notice {
display: none;
margin-top: 14px;
padding: 14px 16px;
border-radius: 14px;
border: 1px solid transparent;
}
.notice.show {
display: block;
}
.notice-success {
color: var(--accent-4);
background: rgba(22, 163, 74, 0.08);
border-color: rgba(22, 163, 74, 0.14);
}
.notice-error {
color: var(--accent-3);
background: rgba(220, 38, 38, 0.08);
border-color: rgba(220, 38, 38, 0.14);
}
.login-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.login-panel {
width: min(520px, 100%);
padding: 34px;
}
.login-panel h1 {
margin: 18px 0 10px;
font-size: 2rem;
}
.login-panel p {
margin: 0;
color: var(--text-soft);
line-height: 1.7;
}
.login-error {
display: none;
margin-top: 18px;
padding: 14px 16px;
border-radius: 14px;
background: rgba(220, 38, 38, 0.08);
border: 1px solid rgba(220, 38, 38, 0.14);
color: var(--accent-3);
}
.login-error.show {
display: block;
}
@media (max-width: 1100px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
height: auto;
}
}
@media (max-width: 760px) {
.shell,
.surface {
padding: 16px;
}
.topbar {
flex-direction: column;
}
.toolbar {
width: 100%;
justify-content: flex-start;
}
table {
min-width: 760px;
}
}
</style>
</head>
<body>
{% block body %}
<div class="shell">
<aside class="sidebar">
<div>
<div class="brand-mark">/></div>
<h1 class="brand-title">Termi Admin</h1>
<p class="brand-copy">后台数据直接联动前台页面。你可以在这里审核评论和友链、检查分类标签,并跳到对应前台页面确认效果。</p>
</div>
<nav class="nav-group">
<a href="/admin" class="nav-item {% if active_nav == 'dashboard' %}active{% endif %}">概览面板</a>
<a href="/admin/posts" class="nav-item {% if active_nav == 'posts' %}active{% endif %}">文章管理</a>
<a href="/admin/comments" class="nav-item {% if active_nav == 'comments' %}active{% endif %}">评论审核</a>
<a href="/admin/categories" class="nav-item {% if active_nav == 'categories' %}active{% endif %}">分类管理</a>
<a href="/admin/tags" class="nav-item {% if active_nav == 'tags' %}active{% endif %}">标签管理</a>
<a href="/admin/reviews" class="nav-item {% if active_nav == 'reviews' %}active{% endif %}">评价管理</a>
<a href="/admin/friend_links" class="nav-item {% if active_nav == 'friend_links' %}active{% endif %}">友链申请</a>
<a href="/admin/site-settings" class="nav-item {% if active_nav == 'site_settings' %}active{% endif %}">站点设置</a>
</nav>
<div class="nav-kicker">
<strong>前台联调入口</strong>
<p>所有管理页都带了前台直达链接,处理完数据后可以立刻跳转验证。</p>
</div>
</aside>
<div class="content-shell">
<header class="surface topbar">
<div>
<span class="eyebrow">Unified Admin</span>
<h1 class="page-title">{{ page_title | default(value="Termi Admin") }}</h1>
<p class="page-description">{{ page_description | default(value="统一处理后台数据与前台联调。") }}</p>
</div>
<div class="toolbar">
{% for item in header_actions | default(value=[]) %}
<a
href="{{ item.href }}"
class="btn btn-{{ item.variant }}"
{% if item.external %}target="_blank" rel="noreferrer noopener"{% endif %}
>
{{ item.label }}
</a>
{% endfor %}
<a href="/admin/logout" class="btn btn-danger">退出后台</a>
</div>
</header>
<main class="content-grid">
{% block main_content %}{% endblock %}
</main>
</div>
</div>
{% endblock %}
<script>
async function adminPatch(url, payload, successMessage) {
const response = await fetch(url, {
method: "PATCH",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(await response.text() || "request failed");
}
if (successMessage) {
alert(successMessage);
}
location.reload();
}
async function adminDelete(url, successMessage) {
const confirmed = confirm("确认删除这条记录吗?此操作无法撤销。");
if (!confirmed) {
return;
}
const response = await fetch(url, {
method: "DELETE"
});
if (!response.ok) {
throw new Error(await response.text() || "request failed");
}
if (successMessage) {
alert(successMessage);
}
location.reload();
}
</script>
{% block page_scripts %}{% endblock %}
</body>
</html>

View File

@@ -1,85 +0,0 @@
{% extends "admin/base.html" %}
{% block main_content %}
<section class="form-panel">
<div class="table-head">
<div>
<h2>新增分类</h2>
<div class="table-note">这里维护分类字典。文章 Markdown 导入时会优先复用这里的分类,不存在才自动创建。</div>
</div>
</div>
<form method="post" action="/admin/categories" class="inline-form">
<div class="compact-grid">
<input type="text" name="name" placeholder="分类名,例如 Technology" value="{{ create_form.name }}" required>
<input type="text" name="slug" placeholder="slug可留空自动生成" value="{{ create_form.slug }}">
</div>
<div class="compact-actions">
<button type="submit" class="btn btn-primary">创建分类</button>
</div>
</form>
</section>
<section class="table-panel">
<div class="table-head">
<div>
<h2>分类列表</h2>
<div class="table-note">分类名称会作为文章展示名称使用,文章数来自当前已同步的真实内容。</div>
</div>
</div>
{% if rows | length > 0 %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>分类</th>
<th>文章数</th>
<th>最近文章</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td class="mono">#{{ row.id }}</td>
<td>
<form method="post" action="/admin/categories/{{ row.id }}/update" class="inline-form compact">
<div class="compact-grid">
<input type="text" name="name" value="{{ row.name }}" required>
<input type="text" name="slug" value="{{ row.slug }}" required>
</div>
<div class="compact-actions">
<button type="submit" class="btn btn-success">保存</button>
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
</div>
</form>
</td>
<td><span class="chip">{{ row.count }} 篇</span></td>
<td>
{% if row.latest_frontend_url %}
<a href="{{ row.latest_frontend_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">{{ row.latest_title }}</a>
{% else %}
<span class="badge-soft">{{ row.latest_title }}</span>
{% endif %}
</td>
<td>
<div class="actions">
<a href="{{ row.frontend_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">前台分类页</a>
<a href="{{ row.articles_url }}" class="btn btn-primary" target="_blank" rel="noreferrer noopener">前台筛选</a>
<form method="post" action="/admin/categories/{{ row.id }}/delete">
<button type="submit" class="btn btn-danger">删除</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">暂无分类数据。</div>
{% endif %}
</section>
{% endblock %}

View File

@@ -1,147 +0,0 @@
{% extends "admin/base.html" %}
{% block main_content %}
<section class="form-panel">
<div class="table-head">
<div>
<h2>评论筛选</h2>
<div class="table-note">按 scope、审核状态、文章 slug 或关键词快速定位评论,尤其适合处理段落评论和垃圾留言。</div>
</div>
</div>
<form method="get" action="/admin/comments" class="inline-form compact">
<div class="compact-grid">
<div class="field">
<label for="scope">评论类型</label>
<select id="scope" name="scope">
<option value="" {% if filters.scope == "" %}selected{% endif %}>全部</option>
<option value="article" {% if filters.scope == "article" %}selected{% endif %}>全文评论</option>
<option value="paragraph" {% if filters.scope == "paragraph" %}selected{% endif %}>段落评论</option>
</select>
</div>
<div class="field">
<label for="approved">审核状态</label>
<select id="approved" name="approved">
<option value="" {% if filters.approved == "" %}selected{% endif %}>全部</option>
<option value="true" {% if filters.approved == "true" %}selected{% endif %}>已审核</option>
<option value="false" {% if filters.approved == "false" %}selected{% endif %}>待审核</option>
</select>
</div>
<div class="field">
<label for="post_slug">文章</label>
<select id="post_slug" name="post_slug">
<option value="" {% if filters.post_slug == "" %}selected{% endif %}>全部文章</option>
{% for slug in post_options %}
<option value="{{ slug }}" {% if filters.post_slug == slug %}selected{% endif %}>{{ slug }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="q">关键词</label>
<input
id="q"
type="text"
name="q"
value="{{ filters.q }}"
placeholder="作者 / 内容 / 段落 key"
/>
</div>
</div>
<div class="compact-actions">
<button type="submit" class="btn btn-primary">应用筛选</button>
<a href="/admin/comments" class="btn btn-ghost">清空</a>
</div>
</form>
<div class="inline-links" style="margin-top: 14px;">
{% for stat in stats %}
<span class="chip">{{ stat.label }} · {{ stat.value }}</span>
{% endfor %}
</div>
</section>
<section class="table-panel">
<div class="table-head">
<div>
<h2>评论队列</h2>
<div class="table-note">处理前台真实评论,并能一键跳到对应文章或段落核对上下文。</div>
</div>
</div>
{% if rows | length > 0 %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>作者 / 文章</th>
<th>内容与上下文</th>
<th>状态</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td class="mono">#{{ row.id }}</td>
<td>
<div class="item-title">
<strong>{{ row.author }}</strong>
<span class="item-meta">{{ row.post_slug }}</span>
{% if row.scope == "paragraph" %}
<span class="badge badge-warning">{{ row.scope_label }}</span>
{% else %}
<span class="badge">{{ row.scope_label }}</span>
{% endif %}
{% if row.frontend_url %}
<a href="{{ row.frontend_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">
{% if row.scope == "paragraph" %}跳到前台段落{% else %}跳到前台文章{% endif %}
</a>
{% endif %}
</div>
</td>
<td>
<div class="item-title">
<strong>{{ row.content }}</strong>
{% if row.reply_target != "-" %}
<span class="item-meta">回复目标:{{ row.reply_target }}</span>
{% endif %}
{% if row.scope == "paragraph" and row.paragraph_excerpt != "-" %}
<span class="item-meta">段落上下文:{{ row.paragraph_excerpt }}</span>
{% endif %}
{% if row.scope == "paragraph" and row.paragraph_key != "-" %}
<span class="item-meta">段落 key{{ row.paragraph_key }}</span>
{% endif %}
</div>
</td>
<td>
{% if row.approved %}
<span class="badge badge-success">已审核</span>
{% else %}
<span class="badge badge-warning">待审核</span>
{% endif %}
</td>
<td class="mono">{{ row.created_at }}</td>
<td>
<div class="actions">
<button class="btn btn-success" onclick='adminPatch("{{ row.api_url }}", {"approved": true}, "评论状态已更新")'>通过</button>
<button class="btn btn-warning" onclick='adminPatch("{{ row.api_url }}", {"approved": false}, "评论状态已更新")'>待审</button>
<button class="btn btn-danger" onclick='adminDelete("{{ row.api_url }}", "评论已删除")'>删除</button>
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">当前筛选条件下暂无评论数据。</div>
{% endif %}
</section>
{% endblock %}

View File

@@ -1,64 +0,0 @@
{% extends "admin/base.html" %}
{% block main_content %}
<section class="table-panel">
<div class="table-head">
<div>
<h2>友链审核</h2>
<div class="table-note">前台提交后会进入这里,你可以审核状态,再跳去前台友链页确认展示。</div>
</div>
</div>
{% if rows | length > 0 %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>站点</th>
<th>分类</th>
<th>状态</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td class="mono">#{{ row.id }}</td>
<td>
<div class="item-title">
<strong>{{ row.site_name }}</strong>
<a href="{{ row.site_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">{{ row.site_url }}</a>
<span class="item-meta">{{ row.description }}</span>
</div>
</td>
<td>{{ row.category_name }}</td>
<td>
{% if row.status == "已通过" %}
<span class="badge badge-success">{{ row.status }}</span>
{% elif row.status == "已拒绝" %}
<span class="badge badge-danger">{{ row.status }}</span>
{% else %}
<span class="badge badge-warning">{{ row.status }}</span>
{% endif %}
</td>
<td class="mono">{{ row.created_at }}</td>
<td>
<div class="actions">
<button class="btn btn-success" onclick='adminPatch("{{ row.api_url }}", {"status": "approved"}, "友链状态已更新")'>通过</button>
<button class="btn btn-warning" onclick='adminPatch("{{ row.api_url }}", {"status": "pending"}, "友链状态已更新")'>待审</button>
<button class="btn btn-danger" onclick='adminPatch("{{ row.api_url }}", {"status": "rejected"}, "友链状态已更新")'>拒绝</button>
<a href="{{ row.frontend_page_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">前台友链页</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">暂无友链申请数据。</div>
{% endif %}
</section>
{% endblock %}

View File

@@ -1,29 +0,0 @@
{% extends "admin/base.html" %}
{% block main_content %}
<section class="stats-grid">
{% for stat in stats %}
<article class="stat tone-{{ stat.tone }}">
<div class="stat-label">{{ stat.label }}</div>
<div class="stat-value">{{ stat.value }}</div>
<div class="muted">{{ stat.note }}</div>
</article>
{% endfor %}
</section>
<section class="hero-card">
<h2>{{ site_profile.site_name }}</h2>
<p class="page-description" style="margin-bottom: 10px;">{{ site_profile.site_description }}</p>
<a href="{{ site_profile.site_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">{{ site_profile.site_url }}</a>
</section>
<section class="card-grid">
{% for card in nav_cards %}
<a href="{{ card.href }}" class="hero-card">
<h2>{{ card.title }}</h2>
<p class="page-description" style="margin-bottom: 10px;">{{ card.description }}</p>
<span class="chip">{{ card.meta }}</span>
</a>
{% endfor %}
</section>
{% endblock %}

View File

@@ -1,35 +0,0 @@
{% extends "admin/base.html" %}
{% block body %}
<div class="login-shell">
<section class="login-panel">
<span class="eyebrow">Termi Admin</span>
<div class="brand-mark" style="margin-top: 18px;">/></div>
<h1>后台管理入口</h1>
<p>评论审核、友链申请、分类标签检查和站点设置都在这里统一处理。当前后台界面已经走 Tera 模板,不再在 Rust 里硬拼整页 HTML。</p>
<div class="login-error {% if show_error %}show{% endif %}">
用户名或密码错误,请重试。
</div>
<form method="POST" action="/admin/login" class="form-grid" style="margin-top: 22px;">
<div class="field field-wide">
<label>用户名</label>
<input name="username" placeholder="admin" required>
</div>
<div class="field field-wide">
<label>密码</label>
<input type="password" name="password" placeholder="admin123" required>
</div>
<div class="field field-wide">
<button type="submit" class="btn btn-primary" style="width: 100%;">进入后台</button>
</div>
</form>
<div class="hero-card" style="margin-top: 18px;">
<h2>默认测试账号</h2>
<p class="mono">admin / admin123</p>
</div>
</section>
</div>
{% endblock %}

View File

@@ -1,70 +0,0 @@
{% extends "admin/base.html" %}
{% block main_content %}
<section class="form-panel">
<div class="table-head">
<div>
<h2>{{ editor.title }}</h2>
<div class="table-note">当前源文件:<span class="mono">{{ editor.file_path }}</span></div>
</div>
</div>
<form id="markdown-editor-form" class="form-grid">
<div class="field field-wide">
<label>Slug</label>
<input value="{{ editor.slug }}" readonly>
</div>
<div class="field field-wide">
<label>Markdown 文件内容</label>
<textarea id="markdown-content" name="markdown" style="min-height: 65vh; font-family: var(--font-mono); line-height: 1.65;">{{ editor.markdown }}</textarea>
</div>
<div class="field field-wide">
<div class="actions">
<button type="submit" class="btn btn-primary">保存 Markdown</button>
</div>
<div class="field-hint" style="margin-top: 10px;">这里保存的是服务器上的原始 Markdown 文件。你也可以直接在服务器用编辑器打开这个路径修改。</div>
<div id="notice" class="notice"></div>
</div>
</form>
</section>
{% endblock %}
{% block page_scripts %}
<script>
const markdownForm = document.getElementById("markdown-editor-form");
const markdownField = document.getElementById("markdown-content");
const markdownNotice = document.getElementById("notice");
const markdownSlug = "{{ editor.slug }}";
function showMarkdownNotice(message, kind) {
markdownNotice.textContent = message;
markdownNotice.className = "notice show " + (kind === "success" ? "notice-success" : "notice-error");
}
markdownForm?.addEventListener("submit", async (event) => {
event.preventDefault();
try {
const response = await fetch(`/api/posts/slug/${markdownSlug}/markdown`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
markdown: markdownField.value
})
});
if (!response.ok) {
throw new Error(await response.text() || "save failed");
}
const payload = await response.json();
markdownField.value = payload.markdown;
showMarkdownNotice("Markdown 文件已保存并同步到数据库。", "success");
} catch (error) {
showMarkdownNotice("保存失败:" + (error?.message || "unknown error"), "error");
}
});
</script>
{% endblock %}

View File

@@ -1,199 +0,0 @@
{% extends "admin/base.html" %}
{% block main_content %}
<section class="form-panel">
<div class="table-head">
<div>
<h2>新建 Markdown 文章</h2>
<div class="table-note">直接生成 `content/posts/*.md` 文件,后端会自动解析 frontmatter、同步分类和标签。</div>
</div>
</div>
<form method="post" action="/admin/posts" class="form-grid">
<div class="field">
<label>标题</label>
<input type="text" name="title" value="{{ create_form.title }}" required>
</div>
<div class="field">
<label>Slug</label>
<input type="text" name="slug" value="{{ create_form.slug }}" placeholder="可留空自动生成">
</div>
<div class="field">
<label>分类</label>
<input type="text" name="category" value="{{ create_form.category }}" placeholder="例如 tech">
</div>
<div class="field">
<label>标签</label>
<input type="text" name="tags" value="{{ create_form.tags }}" placeholder="逗号分隔">
</div>
<div class="field">
<label>文章类型</label>
<input type="text" name="post_type" value="{{ create_form.post_type }}">
</div>
<div class="field">
<label>封面图</label>
<input type="text" name="image" value="{{ create_form.image }}" placeholder="可选">
</div>
<div class="field field-wide">
<label>摘要</label>
<textarea name="description">{{ create_form.description }}</textarea>
</div>
<div class="field field-wide">
<label>正文 Markdown</label>
<textarea name="content" style="min-height: 22rem; font-family: var(--font-mono); line-height: 1.65;">{{ create_form.content }}</textarea>
</div>
<div class="field field-wide">
<div class="actions">
<label class="chip"><input type="checkbox" name="published" checked style="margin-right: 8px;">发布</label>
<label class="chip"><input type="checkbox" name="pinned" style="margin-right: 8px;">置顶</label>
<button type="submit" class="btn btn-primary">创建文章</button>
</div>
</div>
</form>
</section>
<section class="form-panel">
<div class="table-head">
<div>
<h2>导入 Markdown 文件</h2>
<div class="table-note">支持选择单个 `.md/.markdown` 文件,也支持直接选择一个本地 Markdown 文件夹批量导入。</div>
</div>
</div>
<form id="markdown-import-form" class="form-grid">
<div class="field">
<label>选择文件</label>
<input id="markdown-files" type="file" accept=".md,.markdown" multiple>
</div>
<div class="field">
<label>选择文件夹</label>
<input id="markdown-folder" type="file" accept=".md,.markdown" webkitdirectory directory multiple>
</div>
<div class="field field-wide">
<div class="actions">
<button id="import-submit" type="submit" class="btn btn-success">导入 Markdown</button>
</div>
<div class="field-hint" style="margin-top: 10px;">导入时会从 frontmatter 和正文里提取标题、slug、摘要、分类、标签与内容并写入服务器 `content/posts`。</div>
<div id="import-notice" class="notice"></div>
</div>
</form>
</section>
<section class="table-panel">
<div class="table-head">
<div>
<h2>内容列表</h2>
<div class="table-note">直接跳到前台文章、分类筛选和 API 明细。</div>
</div>
</div>
{% if rows | length > 0 %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>文章</th>
<th>分类</th>
<th>标签</th>
<th>时间</th>
<th>跳转</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td class="mono">#{{ row.id }}</td>
<td>
<div class="item-title">
<strong>{{ row.title }}</strong>
<span class="item-meta">{{ row.slug }}</span>
<span class="item-meta">{{ row.file_path }}</span>
</div>
</td>
<td>
<div class="item-title">
<strong>{{ row.category_name }}</strong>
<a href="{{ row.category_frontend_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">查看该分类文章</a>
</div>
</td>
<td>
<div class="inline-links">
{% if row.tags | length > 0 %}
{% for tag in row.tags %}
<span class="chip">#{{ tag }}</span>
{% endfor %}
{% else %}
<span class="badge-soft">暂无标签</span>
{% endif %}
</div>
</td>
<td class="mono">{{ row.created_at }}</td>
<td>
<div class="actions">
<a href="{{ row.edit_url }}" class="btn btn-success">编辑 Markdown</a>
<a href="{{ row.frontend_url }}" class="btn btn-primary" target="_blank" rel="noreferrer noopener">前台详情</a>
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">当前没有可管理的文章数据。</div>
{% endif %}
</section>
{% endblock %}
{% block page_scripts %}
<script>
const importForm = document.getElementById("markdown-import-form");
const importFiles = document.getElementById("markdown-files");
const importFolder = document.getElementById("markdown-folder");
const importNotice = document.getElementById("import-notice");
function showImportNotice(message, kind) {
importNotice.textContent = message;
importNotice.className = "notice show " + (kind === "success" ? "notice-success" : "notice-error");
}
importForm?.addEventListener("submit", async (event) => {
event.preventDefault();
const selectedFiles = [
...(importFiles?.files ? Array.from(importFiles.files) : []),
...(importFolder?.files ? Array.from(importFolder.files) : []),
].filter((file) => file.name.endsWith(".md") || file.name.endsWith(".markdown"));
if (!selectedFiles.length) {
showImportNotice("请先选择要导入的 Markdown 文件或文件夹。", "error");
return;
}
const payload = new FormData();
selectedFiles.forEach((file) => {
const uploadName = file.webkitRelativePath || file.name;
payload.append("files", file, uploadName);
});
try {
const response = await fetch("/admin/posts/import", {
method: "POST",
body: payload,
});
if (!response.ok) {
throw new Error(await response.text() || "import failed");
}
const result = await response.json();
showImportNotice(`已导入 ${result.count} 个 Markdown 文件,正在刷新列表。`, "success");
setTimeout(() => window.location.reload(), 900);
} catch (error) {
showImportNotice("导入失败:" + (error?.message || "unknown error"), "error");
}
});
</script>
{% endblock %}

View File

@@ -1,113 +0,0 @@
{% extends "admin/base.html" %}
{% block main_content %}
<section class="form-panel">
<div class="table-head">
<div>
<h2>新增评价</h2>
<div class="table-note">这里创建的评价会立刻出现在前台 `/reviews` 页面。</div>
</div>
</div>
<form method="post" action="/admin/reviews" class="inline-form">
<div class="compact-grid">
<input type="text" name="title" placeholder="标题" value="{{ create_form.title }}" required>
<select name="review_type">
<option value="game" {% if create_form.review_type == "game" %}selected{% endif %}>游戏</option>
<option value="anime" {% if create_form.review_type == "anime" %}selected{% endif %}>动画</option>
<option value="music" {% if create_form.review_type == "music" %}selected{% endif %}>音乐</option>
<option value="book" {% if create_form.review_type == "book" %}selected{% endif %}>书籍</option>
<option value="movie" {% if create_form.review_type == "movie" %}selected{% endif %}>影视</option>
</select>
<input type="number" name="rating" min="0" max="5" value="{{ create_form.rating }}" required>
<input type="date" name="review_date" value="{{ create_form.review_date }}">
<select name="status">
<option value="completed" {% if create_form.status == "completed" %}selected{% endif %}>已完成</option>
<option value="in-progress" {% if create_form.status == "in-progress" %}selected{% endif %}>进行中</option>
<option value="dropped" {% if create_form.status == "dropped" %}selected{% endif %}>已弃坑</option>
</select>
<input type="text" name="cover" value="{{ create_form.cover }}" placeholder="封面图标或 emoji">
<input type="url" name="link_url" value="{{ create_form.link_url }}" placeholder="跳转链接,可选">
<input type="text" name="tags" value="{{ create_form.tags }}" placeholder="标签,逗号分隔">
<textarea name="description" placeholder="评价描述">{{ create_form.description }}</textarea>
</div>
<div class="compact-actions">
<button type="submit" class="btn btn-primary">创建评价</button>
</div>
</form>
</section>
<section class="table-panel">
<div class="table-head">
<div>
<h2>评价列表</h2>
<div class="table-note">这里的每一行都可以直接编辑,保存后前台评价页会读取最新数据。</div>
</div>
</div>
{% if rows | length > 0 %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>评价内容</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td class="mono">#{{ row.id }}</td>
<td>
<form method="post" action="/admin/reviews/{{ row.id }}/update" class="inline-form compact">
<div class="compact-grid">
<input type="text" name="title" value="{{ row.title }}" required>
<select name="review_type">
<option value="game" {% if row.review_type == "game" %}selected{% endif %}>游戏</option>
<option value="anime" {% if row.review_type == "anime" %}selected{% endif %}>动画</option>
<option value="music" {% if row.review_type == "music" %}selected{% endif %}>音乐</option>
<option value="book" {% if row.review_type == "book" %}selected{% endif %}>书籍</option>
<option value="movie" {% if row.review_type == "movie" %}selected{% endif %}>影视</option>
</select>
<input type="number" name="rating" min="0" max="5" value="{{ row.rating }}" required>
<input type="date" name="review_date" value="{{ row.review_date }}">
<select name="status">
<option value="completed" {% if row.status == "completed" %}selected{% endif %}>已完成</option>
<option value="in-progress" {% if row.status == "in-progress" %}selected{% endif %}>进行中</option>
<option value="dropped" {% if row.status == "dropped" %}selected{% endif %}>已弃坑</option>
</select>
<input type="text" name="cover" value="{{ row.cover }}" placeholder="封面图标或 emoji">
<input type="url" name="link_url" value="{{ row.link_url }}" placeholder="跳转链接,可选">
<input type="text" name="tags" value="{{ row.tags_input }}" placeholder="标签,逗号分隔">
<textarea name="description" placeholder="评价描述">{{ row.description }}</textarea>
</div>
<div class="compact-actions">
<button type="submit" class="btn btn-success">保存</button>
{% if row.link_url %}
<a href="{{ row.link_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">跳转</a>
{% endif %}
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
</div>
</form>
</td>
<td><span class="chip">{{ row.status }}</span></td>
<td>
<div class="actions">
<a href="http://localhost:4321/reviews" class="btn btn-primary" target="_blank" rel="noreferrer noopener">前台查看</a>
<form method="post" action="/admin/reviews/{{ row.id }}/delete">
<button type="submit" class="btn btn-danger">删除</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">暂无评价数据。</div>
{% endif %}
</section>
{% endblock %}

View File

@@ -1,225 +0,0 @@
{% extends "admin/base.html" %}
{% block main_content %}
<section class="form-panel">
<div class="table-head">
<div>
<h2>站点资料</h2>
<div class="table-note">保存后首页、关于页、页脚和友链页中的本站信息会直接读取这里的配置。AI 问答也在这里统一开启和配置。</div>
</div>
</div>
<form id="site-settings-form" class="form-grid">
<div class="field">
<label>站点名称</label>
<input name="site_name" value="{{ form.site_name }}">
</div>
<div class="field">
<label>短名称</label>
<input name="site_short_name" value="{{ form.site_short_name }}">
</div>
<div class="field">
<label>站点链接</label>
<input name="site_url" value="{{ form.site_url }}">
</div>
<div class="field field-wide">
<label>站点标题</label>
<input name="site_title" value="{{ form.site_title }}">
</div>
<div class="field field-wide">
<label>站点简介</label>
<textarea name="site_description">{{ form.site_description }}</textarea>
</div>
<div class="field">
<label>首页主标题</label>
<input name="hero_title" value="{{ form.hero_title }}">
</div>
<div class="field">
<label>首页副标题</label>
<input name="hero_subtitle" value="{{ form.hero_subtitle }}">
</div>
<div class="field">
<label>个人名称</label>
<input name="owner_name" value="{{ form.owner_name }}">
</div>
<div class="field">
<label>个人头衔</label>
<input name="owner_title" value="{{ form.owner_title }}">
</div>
<div class="field">
<label>头像 URL</label>
<input name="owner_avatar_url" value="{{ form.owner_avatar_url }}">
</div>
<div class="field">
<label>所在地</label>
<input name="location" value="{{ form.location }}">
</div>
<div class="field">
<label>GitHub</label>
<input name="social_github" value="{{ form.social_github }}">
</div>
<div class="field">
<label>Twitter / X</label>
<input name="social_twitter" value="{{ form.social_twitter }}">
</div>
<div class="field field-wide">
<label>Email / mailto</label>
<input name="social_email" value="{{ form.social_email }}">
</div>
<div class="field field-wide">
<label>个人简介</label>
<textarea name="owner_bio">{{ form.owner_bio }}</textarea>
</div>
<div class="field field-wide">
<label>技术栈(每行一个)</label>
<textarea name="tech_stack">{{ form.tech_stack }}</textarea>
</div>
<div class="field field-wide" style="border-top: 1px solid rgba(148, 163, 184, 0.18); padding-top: 18px; margin-top: 10px;">
<label style="display:flex; align-items:center; gap:10px;">
<input type="checkbox" name="ai_enabled" {% if form.ai_enabled %}checked{% endif %}>
<span>启用前台 AI 问答</span>
</label>
<div class="field-hint">关闭后,前台导航不会显示 AI 页面公开接口也不会对外提供回答。Embedding 已改为后端本地生成,并使用 PostgreSQL 的 pgvector 存储与检索。</div>
</div>
<div class="field">
<label>接入类型 / 协议</label>
<input name="ai_provider" value="{{ form.ai_provider }}" placeholder="newapi">
<div class="field-hint">这里是后端适配器类型,不是模型厂商名。`newapi` 表示走 NewAPI 兼容的 Responses 接口;厂商和型号建议写在你的通道备注与模型名里。</div>
</div>
<div class="field">
<label>聊天 API Base</label>
<input name="ai_api_base" value="{{ form.ai_api_base }}" placeholder="https://91code.jiangnight.com/v1">
</div>
<div class="field field-wide">
<label>聊天 API Key</label>
<input name="ai_api_key" value="{{ form.ai_api_key }}" placeholder="sk-...">
<div class="field-hint">这里只保存在后端数据库里,前台公开接口不会返回这个字段。当前默认接入 91code.jiangnight.com 的 NewAPI 兼容接口,未配置时前台仍可做本地检索,但不会生成完整聊天回答。</div>
</div>
<div class="field">
<label>聊天模型</label>
<input name="ai_chat_model" value="{{ form.ai_chat_model }}" placeholder="gpt-5.4">
</div>
<div class="field">
<label>本地 Embedding</label>
<input value="{{ form.ai_local_embedding }}" disabled>
</div>
<div class="field">
<label>Top K</label>
<input type="number" min="1" max="12" name="ai_top_k" value="{{ form.ai_top_k }}">
</div>
<div class="field">
<label>Chunk Size</label>
<input type="number" min="400" max="4000" step="50" name="ai_chunk_size" value="{{ form.ai_chunk_size }}">
</div>
<div class="field field-wide">
<label>系统提示词</label>
<textarea name="ai_system_prompt">{{ form.ai_system_prompt }}</textarea>
</div>
<div class="field field-wide">
<div class="table-note">AI 索引状态:已索引 {{ form.ai_chunks_count }} 个片段,最近建立时间 {{ form.ai_last_indexed_at }}。</div>
<div class="actions">
<button type="submit" class="btn btn-primary">保存设置</button>
<button type="button" id="reindex-btn" class="btn">重建 AI 索引</button>
</div>
<div class="field-hint" style="margin-top: 10px;">文章内容变化后建议手动重建一次 AI 索引。本地 embedding 使用后端内置 `fastembed` 生成,向量会写入 PostgreSQL 的 `pgvector` 列,并通过 HNSW 索引做相似度检索;聊天回答默认走 `newapi -> /responses -> gpt-5.4`。前台用户提交过的搜索词和 AI 问题会单独写入分析日志,方便在新版后台里查看。</div>
<div id="notice" class="notice"></div>
</div>
</form>
</section>
{% endblock %}
{% block page_scripts %}
<script>
const form = document.getElementById("site-settings-form");
const notice = document.getElementById("notice");
const reindexBtn = document.getElementById("reindex-btn");
function showNotice(message, kind) {
notice.textContent = message;
notice.className = "notice show " + (kind === "success" ? "notice-success" : "notice-error");
}
function numericOrNull(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
form?.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(form);
const payload = {
siteName: data.get("site_name"),
siteShortName: data.get("site_short_name"),
siteUrl: data.get("site_url"),
siteTitle: data.get("site_title"),
siteDescription: data.get("site_description"),
heroTitle: data.get("hero_title"),
heroSubtitle: data.get("hero_subtitle"),
ownerName: data.get("owner_name"),
ownerTitle: data.get("owner_title"),
ownerAvatarUrl: data.get("owner_avatar_url"),
location: data.get("location"),
socialGithub: data.get("social_github"),
socialTwitter: data.get("social_twitter"),
socialEmail: data.get("social_email"),
ownerBio: data.get("owner_bio"),
techStack: String(data.get("tech_stack") || "")
.split("\n")
.map((item) => item.trim())
.filter(Boolean),
aiEnabled: data.get("ai_enabled") === "on",
aiProvider: data.get("ai_provider"),
aiApiBase: data.get("ai_api_base"),
aiApiKey: data.get("ai_api_key"),
aiChatModel: data.get("ai_chat_model"),
aiTopK: numericOrNull(data.get("ai_top_k")),
aiChunkSize: numericOrNull(data.get("ai_chunk_size")),
aiSystemPrompt: data.get("ai_system_prompt")
};
try {
const response = await fetch("/api/site_settings", {
method: "PATCH",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(await response.text() || "save failed");
}
showNotice("站点信息与 AI 配置已保存。", "success");
} catch (error) {
showNotice("保存失败:" + (error?.message || "unknown error"), "error");
}
});
reindexBtn?.addEventListener("click", async () => {
reindexBtn.disabled = true;
reindexBtn.textContent = "正在重建...";
try {
const response = await fetch("/api/ai/reindex", {
method: "POST"
});
if (!response.ok) {
throw new Error(await response.text() || "reindex failed");
}
const data = await response.json();
showNotice(`AI 索引已重建,当前共有 ${data.indexed_chunks} 个片段。`, "success");
window.setTimeout(() => window.location.reload(), 900);
} catch (error) {
showNotice("重建失败:" + (error?.message || "unknown error"), "error");
} finally {
reindexBtn.disabled = false;
reindexBtn.textContent = "重建 AI 索引";
}
});
</script>
{% endblock %}

View File

@@ -1,77 +0,0 @@
{% extends "admin/base.html" %}
{% block main_content %}
<section class="form-panel">
<div class="table-head">
<div>
<h2>新增标签</h2>
<div class="table-note">这里维护标签字典。文章 Markdown 导入时会优先复用这里的标签,不存在才自动创建。</div>
</div>
</div>
<form method="post" action="/admin/tags" class="inline-form">
<div class="compact-grid">
<input type="text" name="name" placeholder="标签名,例如 Rust" value="{{ create_form.name }}" required>
<input type="text" name="slug" placeholder="slug可留空自动生成" value="{{ create_form.slug }}">
</div>
<div class="compact-actions">
<button type="submit" class="btn btn-primary">创建标签</button>
</div>
</form>
</section>
<section class="table-panel">
<div class="table-head">
<div>
<h2>标签映射</h2>
<div class="table-note">标签名称会作为文章展示名称使用,使用次数来自当前已同步的真实文章内容。</div>
</div>
</div>
{% if rows | length > 0 %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>标签</th>
<th>使用次数</th>
<th>跳转</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td class="mono">#{{ row.id }}</td>
<td>
<form method="post" action="/admin/tags/{{ row.id }}/update" class="inline-form compact">
<div class="compact-grid">
<input type="text" name="name" value="{{ row.name }}" required>
<input type="text" name="slug" value="{{ row.slug }}" required>
</div>
<div class="compact-actions">
<button type="submit" class="btn btn-success">保存</button>
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
</div>
</form>
</td>
<td><span class="chip">{{ row.usage_count }} 篇文章</span></td>
<td>
<div class="actions">
<a href="{{ row.frontend_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">前台标签页</a>
<a href="{{ row.articles_url }}" class="btn btn-primary" target="_blank" rel="noreferrer noopener">前台筛选</a>
<form method="post" action="/admin/tags/{{ row.id }}/delete">
<button type="submit" class="btn btn-danger">删除</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">暂无标签数据。</div>
{% endif %}
</section>
{% endblock %}

View File

@@ -1,12 +0,0 @@
<html><body>
<img src="/static/image.png" width="200"/>
<br/>
find this tera template at <code>assets/views/home/hello.html</code>:
<br/>
<br/>
{{ t(key="hello-world", lang="en-US") }},
<br/>
{{ t(key="hello-world", lang="de-DE") }}
</body></html>