chore: reorganize project into monorepo
This commit is contained in:
48
backend/assets/seeds/comments.yaml
Normal file
48
backend/assets/seeds/comments.yaml
Normal file
@@ -0,0 +1,48 @@
|
||||
- id: 1
|
||||
pid: 1
|
||||
author: "Alice"
|
||||
email: "alice@example.com"
|
||||
content: "Great introduction! Looking forward to more content."
|
||||
approved: true
|
||||
|
||||
- id: 2
|
||||
pid: 1
|
||||
author: "Bob"
|
||||
email: "bob@example.com"
|
||||
content: "The terminal UI looks amazing. Love the design!"
|
||||
approved: true
|
||||
|
||||
- id: 3
|
||||
pid: 2
|
||||
author: "Charlie"
|
||||
email: "charlie@example.com"
|
||||
content: "Thanks for the Rust tips! The ownership concept finally clicked for me."
|
||||
approved: true
|
||||
|
||||
- id: 4
|
||||
pid: 3
|
||||
author: "Diana"
|
||||
email: "diana@example.com"
|
||||
content: "Astro is indeed fast. I've been using it for my personal blog too."
|
||||
approved: true
|
||||
|
||||
- id: 5
|
||||
pid: 4
|
||||
author: "Eve"
|
||||
email: "eve@example.com"
|
||||
content: "The color palette you shared is perfect. Using it for my terminal theme now!"
|
||||
approved: true
|
||||
|
||||
- id: 6
|
||||
pid: 5
|
||||
author: "Frank"
|
||||
email: "frank@example.com"
|
||||
content: "Loco.rs looks promising. Might use it for my next project."
|
||||
approved: false
|
||||
|
||||
- id: 7
|
||||
pid: 2
|
||||
author: "Grace"
|
||||
email: "grace@example.com"
|
||||
content: "Would love to see more advanced Rust patterns in future posts."
|
||||
approved: true
|
||||
38
backend/assets/seeds/friend_links.yaml
Normal file
38
backend/assets/seeds/friend_links.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
- id: 1
|
||||
site_name: "Tech Blog Daily"
|
||||
site_url: "https://techblog.example.com"
|
||||
avatar_url: "https://techblog.example.com/avatar.png"
|
||||
description: "Daily tech news and tutorials"
|
||||
category: "tech"
|
||||
status: "approved"
|
||||
|
||||
- id: 2
|
||||
site_name: "Rustacean Station"
|
||||
site_url: "https://rustacean.example.com"
|
||||
avatar_url: "https://rustacean.example.com/logo.png"
|
||||
description: "All things Rust programming"
|
||||
category: "tech"
|
||||
status: "approved"
|
||||
|
||||
- id: 3
|
||||
site_name: "Design Patterns"
|
||||
site_url: "https://designpatterns.example.com"
|
||||
avatar_url: "https://designpatterns.example.com/icon.png"
|
||||
description: "UI/UX design inspiration"
|
||||
category: "design"
|
||||
status: "approved"
|
||||
|
||||
- id: 4
|
||||
site_name: "Code Snippets"
|
||||
site_url: "https://codesnippets.example.com"
|
||||
description: "Useful code snippets for developers"
|
||||
category: "dev"
|
||||
status: "pending"
|
||||
|
||||
- id: 5
|
||||
site_name: "Web Dev Weekly"
|
||||
site_url: "https://webdevweekly.example.com"
|
||||
avatar_url: "https://webdevweekly.example.com/favicon.png"
|
||||
description: "Weekly web development newsletter"
|
||||
category: "dev"
|
||||
status: "pending"
|
||||
191
backend/assets/seeds/posts.yaml
Normal file
191
backend/assets/seeds/posts.yaml
Normal file
@@ -0,0 +1,191 @@
|
||||
- id: 1
|
||||
pid: 1
|
||||
title: "Welcome to Termi Blog"
|
||||
slug: "welcome-to-termi"
|
||||
content: |
|
||||
# Welcome to Termi Blog
|
||||
|
||||
This is the first post on our new blog built with Astro and Loco.rs backend.
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 Fast performance with Astro
|
||||
- 🎨 Terminal-style UI design
|
||||
- 💬 Comments system
|
||||
- 🔗 Friend links
|
||||
- 🏷️ Tags and categories
|
||||
|
||||
## Code Example
|
||||
|
||||
```rust
|
||||
fn main() {
|
||||
println!("Hello, Termi!");
|
||||
}
|
||||
```
|
||||
|
||||
Stay tuned for more posts!
|
||||
excerpt: "Welcome to our new blog built with Astro and Loco.rs backend."
|
||||
category: "general"
|
||||
published: true
|
||||
pinned: true
|
||||
tags:
|
||||
- welcome
|
||||
- astro
|
||||
- loco-rs
|
||||
|
||||
- id: 2
|
||||
pid: 2
|
||||
title: "Rust Programming Tips"
|
||||
slug: "rust-programming-tips"
|
||||
content: |
|
||||
# Rust Programming Tips
|
||||
|
||||
Here are some essential tips for Rust developers:
|
||||
|
||||
## 1. Ownership and Borrowing
|
||||
|
||||
Understanding ownership is crucial in Rust. Every value has an owner, and there can only be one owner at a time.
|
||||
|
||||
## 2. Pattern Matching
|
||||
|
||||
Use `match` expressions for exhaustive pattern matching:
|
||||
|
||||
```rust
|
||||
match result {
|
||||
Ok(value) => println!("Success: {}", value),
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Error Handling
|
||||
|
||||
Use `Result` and `Option` types effectively with the `?` operator.
|
||||
|
||||
Happy coding!
|
||||
excerpt: "Essential tips for Rust developers including ownership, pattern matching, and error handling."
|
||||
category: "tech"
|
||||
published: true
|
||||
pinned: false
|
||||
tags:
|
||||
- rust
|
||||
- programming
|
||||
- tips
|
||||
|
||||
- id: 3
|
||||
pid: 3
|
||||
title: "Building a Blog with Astro"
|
||||
slug: "building-blog-with-astro"
|
||||
content: |
|
||||
# Building a Blog with Astro
|
||||
|
||||
Astro is a modern static site generator that delivers lightning-fast performance.
|
||||
|
||||
## Why Astro?
|
||||
|
||||
- **Zero JavaScript by default**: Ships less JavaScript to the client
|
||||
- **Island Architecture**: Hydrate only interactive components
|
||||
- **Framework Agnostic**: Use React, Vue, Svelte, or vanilla JS
|
||||
- **Great DX**: Excellent developer experience with hot module replacement
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
npm create astro@latest
|
||||
cd my-astro-project
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
Astro is perfect for content-focused websites like blogs.
|
||||
excerpt: "Learn why Astro is the perfect choice for building fast, content-focused blogs."
|
||||
category: "tech"
|
||||
published: true
|
||||
pinned: false
|
||||
tags:
|
||||
- astro
|
||||
- web-dev
|
||||
- static-site
|
||||
|
||||
- id: 4
|
||||
pid: 4
|
||||
title: "Terminal UI Design Principles"
|
||||
slug: "terminal-ui-design"
|
||||
content: |
|
||||
# Terminal UI Design Principles
|
||||
|
||||
Terminal-style interfaces are making a comeback in modern web design.
|
||||
|
||||
## Key Elements
|
||||
|
||||
1. **Monospace Fonts**: Use fonts like Fira Code, JetBrains Mono
|
||||
2. **Dark Themes**: Black or dark backgrounds with vibrant text colors
|
||||
3. **Command Prompts**: Use `$` or `>` as visual indicators
|
||||
4. **ASCII Art**: Decorative elements using text characters
|
||||
5. **Blinking Cursor**: The iconic terminal cursor
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Background: `#0d1117`
|
||||
- Text: `#c9d1d9`
|
||||
- Accent: `#58a6ff`
|
||||
- Success: `#3fb950`
|
||||
- Warning: `#d29922`
|
||||
- Error: `#f85149`
|
||||
|
||||
## Implementation
|
||||
|
||||
Use CSS to create the terminal aesthetic while maintaining accessibility.
|
||||
excerpt: "Learn the key principles of designing beautiful terminal-style user interfaces."
|
||||
category: "design"
|
||||
published: true
|
||||
pinned: false
|
||||
tags:
|
||||
- design
|
||||
- terminal
|
||||
- ui
|
||||
|
||||
- id: 5
|
||||
pid: 5
|
||||
title: "Loco.rs Backend Framework"
|
||||
slug: "loco-rs-framework"
|
||||
content: |
|
||||
# Introduction to Loco.rs
|
||||
|
||||
Loco.rs is a web and API framework for Rust inspired by Rails.
|
||||
|
||||
## Features
|
||||
|
||||
- **MVC Architecture**: Model-View-Controller pattern
|
||||
- **SeaORM Integration**: Powerful ORM for database operations
|
||||
- **Background Jobs**: Built-in job processing
|
||||
- **Authentication**: Ready-to-use auth system
|
||||
- **CLI Generator**: Scaffold resources quickly
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cargo install loco
|
||||
loco new myapp
|
||||
cd myapp
|
||||
cargo loco start
|
||||
```
|
||||
|
||||
## Why Loco.rs?
|
||||
|
||||
- Opinionated but flexible
|
||||
- Production-ready defaults
|
||||
- Excellent documentation
|
||||
- Active community
|
||||
|
||||
Perfect for building APIs and web applications in Rust.
|
||||
excerpt: "An introduction to Loco.rs, the Rails-inspired web framework for Rust."
|
||||
category: "tech"
|
||||
published: true
|
||||
pinned: false
|
||||
tags:
|
||||
- rust
|
||||
- loco-rs
|
||||
- backend
|
||||
- api
|
||||
59
backend/assets/seeds/reviews.yaml
Normal file
59
backend/assets/seeds/reviews.yaml
Normal file
@@ -0,0 +1,59 @@
|
||||
- id: 1
|
||||
title: "塞尔达传说:王国之泪"
|
||||
review_type: "game"
|
||||
rating: 5
|
||||
review_date: "2024-03-20"
|
||||
status: "completed"
|
||||
description: "开放世界的巅峰之作,究极手能力带来无限创意空间"
|
||||
tags: ["Switch", "开放世界", "冒险"]
|
||||
cover: "🎮"
|
||||
|
||||
- id: 2
|
||||
title: "进击的巨人"
|
||||
review_type: "anime"
|
||||
rating: 5
|
||||
review_date: "2023-11-10"
|
||||
status: "completed"
|
||||
description: "史诗级完结,剧情反转令人震撼"
|
||||
tags: ["热血", "悬疑", "神作"]
|
||||
cover: "🎭"
|
||||
|
||||
- id: 3
|
||||
title: "赛博朋克 2077"
|
||||
review_type: "game"
|
||||
rating: 4
|
||||
review_date: "2024-01-15"
|
||||
status: "completed"
|
||||
description: "夜之城的故事,虽然首发有问题但后续更新很棒"
|
||||
tags: ["PC", "RPG", "科幻"]
|
||||
cover: "🎮"
|
||||
|
||||
- id: 4
|
||||
title: "三体"
|
||||
review_type: "book"
|
||||
rating: 5
|
||||
review_date: "2023-08-05"
|
||||
status: "completed"
|
||||
description: "硬科幻巅峰,宇宙社会学的黑暗森林法则"
|
||||
tags: ["科幻", "经典", "雨果奖"]
|
||||
cover: "📚"
|
||||
|
||||
- id: 5
|
||||
title: "星际穿越"
|
||||
review_type: "movie"
|
||||
rating: 5
|
||||
review_date: "2024-02-14"
|
||||
status: "completed"
|
||||
description: "诺兰神作,五维空间和黑洞的视觉奇观"
|
||||
tags: ["科幻", "IMAX", "诺兰"]
|
||||
cover: "🎬"
|
||||
|
||||
- id: 6
|
||||
title: "博德之门3"
|
||||
review_type: "game"
|
||||
rating: 5
|
||||
review_date: "2024-04-01"
|
||||
status: "in-progress"
|
||||
description: "CRPG的文艺复兴,骰子决定命运"
|
||||
tags: ["PC", "CRPG", "多人"]
|
||||
cover: "🎮"
|
||||
39
backend/assets/seeds/tags.yaml
Normal file
39
backend/assets/seeds/tags.yaml
Normal file
@@ -0,0 +1,39 @@
|
||||
- id: 1
|
||||
name: "Welcome"
|
||||
slug: "welcome"
|
||||
|
||||
- id: 2
|
||||
name: "Astro"
|
||||
slug: "astro"
|
||||
|
||||
- id: 3
|
||||
name: "Rust"
|
||||
slug: "rust"
|
||||
|
||||
- id: 4
|
||||
name: "Programming"
|
||||
slug: "programming"
|
||||
|
||||
- id: 5
|
||||
name: "Tech"
|
||||
slug: "tech"
|
||||
|
||||
- id: 6
|
||||
name: "Design"
|
||||
slug: "design"
|
||||
|
||||
- id: 7
|
||||
name: "Terminal"
|
||||
slug: "terminal"
|
||||
|
||||
- id: 8
|
||||
name: "Loco.rs"
|
||||
slug: "loco-rs"
|
||||
|
||||
- id: 9
|
||||
name: "Backend"
|
||||
slug: "backend"
|
||||
|
||||
- id: 10
|
||||
name: "Web Dev"
|
||||
slug: "web-dev"
|
||||
3
backend/assets/static/404.html
Normal file
3
backend/assets/static/404.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<html><body>
|
||||
not found :-(
|
||||
</body></html>
|
||||
BIN
backend/assets/static/image.png
Normal file
BIN
backend/assets/static/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 298 KiB |
11
backend/assets/static/index.html
Normal file
11
backend/assets/static/index.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!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>
|
||||
682
backend/assets/views/admin/base.html
Normal file
682
backend/assets/views/admin/base.html
Normal file
@@ -0,0 +1,682 @@
|
||||
<!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 {
|
||||
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 {
|
||||
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();
|
||||
}
|
||||
</script>
|
||||
{% block page_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
85
backend/assets/views/admin/categories.html
Normal file
85
backend/assets/views/admin/categories.html
Normal file
@@ -0,0 +1,85 @@
|
||||
{% 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 %}
|
||||
63
backend/assets/views/admin/comments.html
Normal file
63
backend/assets/views/admin/comments.html
Normal file
@@ -0,0 +1,63 @@
|
||||
{% 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.author }}</strong>
|
||||
<span class="item-meta">{{ row.post_slug }}</span>
|
||||
{% if row.frontend_url %}
|
||||
<a href="{{ row.frontend_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">跳到前台文章</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ row.content }}</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>
|
||||
<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 %}
|
||||
64
backend/assets/views/admin/friend_links.html
Normal file
64
backend/assets/views/admin/friend_links.html
Normal file
@@ -0,0 +1,64 @@
|
||||
{% 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 %}
|
||||
29
backend/assets/views/admin/index.html
Normal file
29
backend/assets/views/admin/index.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{% 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 %}
|
||||
35
backend/assets/views/admin/login.html
Normal file
35
backend/assets/views/admin/login.html
Normal file
@@ -0,0 +1,35 @@
|
||||
{% 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 %}
|
||||
70
backend/assets/views/admin/post_editor.html
Normal file
70
backend/assets/views/admin/post_editor.html
Normal file
@@ -0,0 +1,70 @@
|
||||
{% 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 %}
|
||||
199
backend/assets/views/admin/posts.html
Normal file
199
backend/assets/views/admin/posts.html
Normal file
@@ -0,0 +1,199 @@
|
||||
{% 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 %}
|
||||
108
backend/assets/views/admin/reviews.html
Normal file
108
backend/assets/views/admin/reviews.html
Normal file
@@ -0,0 +1,108 @@
|
||||
{% 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="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="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>
|
||||
<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 %}
|
||||
143
backend/assets/views/admin/site_settings.html
Normal file
143
backend/assets/views/admin/site_settings.html
Normal file
@@ -0,0 +1,143 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block main_content %}
|
||||
<section class="form-panel">
|
||||
<div class="table-head">
|
||||
<div>
|
||||
<h2>站点资料</h2>
|
||||
<div class="table-note">保存后首页、关于页、页脚和友链页中的本站信息会直接读取这里的配置。</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">
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary">保存设置</button>
|
||||
</div>
|
||||
<div class="field-hint" style="margin-top: 10px;">保存后可直接点击顶部“预览首页 / 预览关于页 / 预览友链页”确认前台展示。</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");
|
||||
|
||||
function showNotice(message, kind) {
|
||||
notice.textContent = message;
|
||||
notice.className = "notice show " + (kind === "success" ? "notice-success" : "notice-error");
|
||||
}
|
||||
|
||||
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)
|
||||
};
|
||||
|
||||
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("站点信息已保存。", "success");
|
||||
} catch (error) {
|
||||
showNotice("保存失败:" + (error?.message || "unknown error"), "error");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
77
backend/assets/views/admin/tags.html
Normal file
77
backend/assets/views/admin/tags.html
Normal file
@@ -0,0 +1,77 @@
|
||||
{% 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 %}
|
||||
12
backend/assets/views/home/hello.html
Normal file
12
backend/assets/views/home/hello.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<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