feat: ship blog platform admin and deploy stack
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
<html><body>
|
||||
not found :-(
|
||||
</body></html>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user