feat(web): 地址栏驱动视图,刷新详情页不再回首页

- 新增 data/view.ts 与 useViewRoute:视图与地址双向同步,支持前进后退
- 条目地址带上所属分组,刷新 / 收藏后「返回」有确定落点
- 服务端只把 /c/** 回落到 index.html,其余路径仍返回 404
- 明细页返回按钮去掉左侧箭头图标
This commit is contained in:
2026-10-24 17:18:30 +08:00
parent 41fed5e64d
commit 19ead7b716
22 changed files with 2098 additions and 1321 deletions
+279 -236
View File
@@ -1,13 +1,19 @@
//! 内容库的表结构与种子导入。
//!
//! 表结构刻意保持「薄」:分类和分组是关系表,条目除 id / 排序 / 计数外的
//! 全部字段都塞进 `entry.data` 的 JSON 列。原因是前端 `types.ts` 用的是
//! 可辨识联合(apps / posters / articles),三种形态字段差异很大,
//! 拆成三张表会让查询和后续加字段都变啰嗦;JSON 列则天然与前端结构 1:1。
//! 两条设计原则:
//!
//! 1. **一个容器树**:分区(section)与分组(group)是同一种东西——「装条目的容器」,
//! 合并在 `node` 一张自引用表里,用 `kind` 区分层级。
//! 2. **内容与展示分离**:`nav_item` 不存内容,只存侧栏的排列(哪个节点、叫什么、放在哪)。
//!
//! 命名规则:
//!
//! - 所有表用**业务编码 `code`** 做主键(`recommend` / `ui` / `github`),不再有数字 id;
//! - 外键统一写成 `<目标>_code`:`parent_code`、`section_code`、`node_code`、`entry_code`;
//! - 改编码时下游由 `ON UPDATE CASCADE` 自动跟随。
use rusqlite::{Connection, params};
use serde::Deserialize;
use serde_json::{Map, Value};
use crate::{db::map_error, error::StashError};
@@ -20,69 +26,114 @@ const SEED_JSON: &str = include_str!("seed.json");
/// 不实现版本迁移:需要改结构时删掉 `data/stash.db` 重启即可,
/// 种子会自动重新灌一份干净数据。
const SCHEMA_SQL: &str = r#"
-- 分类:首页的一张大卡片
CREATE TABLE IF NOT EXISTS category (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('apps', 'posters', 'articles')),
title TEXT NOT NULL,
bar_color TEXT NOT NULL,
icon TEXT NOT NULL,
preview_limit INTEGER,
empty_text TEXT,
sort INTEGER NOT NULL DEFAULT 0
-- 内容节点:分区与分组共用一张自引用树
-- kind = 'section' → 顶层分区(首页一张卡片 / 明细页 hero),parent_code 必须为 NULL
-- kind = 'group' → 分区下的分组,parent_code 指向所属分区
-- view_mode 只对分区有意义,决定首页与明细页怎么渲染
CREATE TABLE IF NOT EXISTS node (
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配,不用 AUTOINCREMENT)
key TEXT NOT NULL UNIQUE, -- 业务键,全局唯一(如 recommend / ui)
parent_key TEXT REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
-- 父节点业务键:分组指向分区,分区为 NULL
title TEXT NOT NULL, -- 展示名:分区标题 / 分组小标题
kind TEXT NOT NULL CHECK (kind IN ('section', 'group')),
-- 容器层级
view_mode TEXT CHECK (view_mode IN ('apps', 'posters', 'articles')),
-- 分区渲染形态;分组为 NULL
bar_color TEXT, -- 分区标题左侧色条颜色(分组为 NULL)
icon TEXT, -- 分区 hero 字符图标(分组为 NULL)
preview_limit INTEGER, -- 首页预览条数;NULL 时前端按类型取默认
empty_text TEXT, -- 分区为空时的提示文案
show_in_sidebar INTEGER NOT NULL DEFAULT 1 CHECK (show_in_sidebar IN (0, 1)),
-- 是否出现在侧栏:0 表示只当首页卡片/内容,不进导航
sort INTEGER NOT NULL DEFAULT 0, -- 同级顺序,越小越靠前
CHECK (
(kind = 'section' AND parent_key IS NULL AND view_mode IS NOT NULL)
OR (kind = 'group' AND parent_key IS NOT NULL AND view_mode IS NULL)
)
);
-- 应用类分类的分组:明细页按组展示,首页把各组前几项拼成预览
CREATE TABLE IF NOT EXISTS app_group (
category_id TEXT NOT NULL REFERENCES category(id) ON DELETE CASCADE,
id TEXT NOT NULL,
name TEXT NOT NULL,
sort INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (category_id, id)
);
CREATE INDEX IF NOT EXISTS node_parent_idx ON node(parent_key, sort);
-- 条目:应用 / 海报 / 图文共用一张表
-- name 展示名(应用是 name,海报与图文是 title)
-- group_id 只有应用类用得上,其余为 NULL
-- data 该条目其余字段的 JSON,键名与前端 types.ts 一致
-- 条目:网站 / 电影 / 文章都在这张表里,基础字段各占一列
CREATE TABLE IF NOT EXISTS entry (
category_id TEXT NOT NULL REFERENCES category(id) ON DELETE CASCADE,
id TEXT NOT NULL,
group_id TEXT,
name TEXT NOT NULL,
views INTEGER NOT NULL DEFAULT 0,
likes INTEGER NOT NULL DEFAULT 0,
sort INTEGER NOT NULL DEFAULT 0,
data TEXT NOT NULL,
PRIMARY KEY (category_id, id)
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配)
key TEXT NOT NULL UNIQUE, -- 业务键,全局唯一(如 github / movie-1)
node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
-- 直接所属容器:应用是分组,海报/图文是分区本身
-- 所属分区不单独存:由 node_key 推导(分组看它的 parent_key)
name TEXT NOT NULL, -- 名称:网站名 / 电影名 / 文章标题
description TEXT, -- 一句话简介,列表卡片的副标题
icon TEXT, -- 字符图标(封面缺失时的占位)
href TEXT, -- 点击后打开的页面;空表示暂无可跳转地址
cover TEXT, -- 封面图:应用头像 / 海报 / 文章配图
banner TEXT, -- 详情页横幅图
badge TEXT, -- 详情页标签行上的一句话标记(如「推荐好站」)
comment TEXT, -- 详情页的一句话评价(引用块)
summary TEXT, -- 详情页正文段落(电影简介这类长文本)
body TEXT, -- 文章正文(Markdown):一篇一段文本
hot INTEGER NOT NULL DEFAULT 0 CHECK (hot IN (0, 1)),-- 是否热门:列表里显示 HOT 徽标
views INTEGER NOT NULL DEFAULT 0, -- 浏览量,详情页操作条「眼睛」后的数字
likes INTEGER NOT NULL DEFAULT 0, -- 点赞数,详情页操作条「赞」后的数字
sort INTEGER NOT NULL DEFAULT 0 -- 条目顺序(组内或分区内),越小越靠前
);
CREATE INDEX IF NOT EXISTS entry_group_idx ON entry(category_id, group_id, sort);
CREATE INDEX IF NOT EXISTS entry_parent_idx ON entry(node_key, sort);
-- 侧栏导航:自引用树,一条表同时表达两种节点
-- parent_id 为 NULL → 顶层项
-- category_id 非 NULL → 叶子,点击后切到该分类(可带分组)
-- category_id 为 NULL → 可折叠分组,它的子节点通过 parent_id 指向它
-- 层级和标签必须单独存:侧栏的「素材资源 / 开发工具」分组名与分类名不同,
-- 同一个分组还能被多个叶子复用(如「前端调试工具」和「在线编辑器」都指向 recommend/dev)。
CREATE TABLE IF NOT EXISTS nav_entry (
id TEXT PRIMARY KEY,
parent_id TEXT REFERENCES nav_entry(id) ON DELETE CASCADE,
label TEXT NOT NULL,
category_id TEXT REFERENCES category(id) ON DELETE CASCADE,
group_id TEXT,
sort INTEGER NOT NULL DEFAULT 0
-- 条目的行内文本列表:海报的 tags、图文的 meta 都放这里(有序、一对多)
CREATE TABLE IF NOT EXISTS entry_tag (
entry_key TEXT NOT NULL REFERENCES entry(key) ON DELETE CASCADE ON UPDATE CASCADE,
-- 所属条目业务键
sort INTEGER NOT NULL DEFAULT 0, -- 展示顺序
label TEXT NOT NULL, -- 标签文字(如「推荐电影」「2 年前」)
PRIMARY KEY (entry_key, sort)
);
-- 首页卡片:一行一个卡片,说明展示哪个节点
-- node_key 指向要展示的节点(通常是分组;电影/文章这类无分组的填分区自己)
-- 「属于哪个分区」不单独存:分组看 parent_key,分区就是它自己,查询时推导
CREATE TABLE IF NOT EXISTS section_block (
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配)
node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
-- 要展示的节点业务键
title TEXT, -- 块标题;NULL 时用节点 title
preview_limit INTEGER, -- 卡片里显示几条;NULL 时用前端默认
sort INTEGER NOT NULL DEFAULT 0 -- 卡片顺序
);
"#;
/// 种子导入结果,仅用于启动日志。
#[derive(Debug, Default, PartialEq, Eq)]
pub struct SeedReport {
pub categories: usize,
pub sections: usize,
pub groups: usize,
pub entries: usize,
/// 侧栏导航节点数(顶层项 + 叶子)
pub nav_items: usize,
pub tags: usize,
/// 首页展示块数量(每个分区一个)
pub blocks: usize,
}
/// 当前表结构版本,写在 SQLite 的 `PRAGMA user_version` 里。
///
/// 开发阶段不做迁移:版本对不上时直接拒绝启动,让用户删库重建,
/// 比悄悄用旧表跑新查询(最终表现为 500)好排查得多。
/// 结构调整时把这个数字 +1。
pub const SCHEMA_VERSION: i64 = 15;
/// 校验库结构版本:0 表示全新库,允许;其它非当前版本一律拒绝。
pub fn check_version(conn: &Connection) -> Result<i64, StashError> {
let version: i64 = conn
.pragma_query_value(None, "user_version", |row| row.get(0))
.map_err(map_error)?;
if version != 0 && version != SCHEMA_VERSION {
return Err(StashError::Database(format!(
"内容库结构版本为 {version},当前程序需要 {SCHEMA_VERSION}。\
开发阶段不做自动迁移,请删除数据库后重启(种子会自动重新导入)。"
)));
}
Ok(version)
}
/// 建表(幂等):每次启动都跑一遍,缺表就补,已有数据不动。
@@ -92,8 +143,8 @@ pub fn apply(conn: &Connection) -> Result<(), StashError> {
/// 把内嵌种子写进空库。
///
/// 只在数据库文件是本次启动新建时调用,所以不存在「覆盖用户数据」的问题;
/// 用户删掉库以后重启会重新得到一份干净的内置内容,这也是开发期的重置手段。
/// 只用业务编码,不需要先插入再回查自增 id:父子关系在种子文件里已经写清楚,
/// 按「分区 → 分组 → 条目 → 侧栏」的顺序插入即可满足外键。
pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
let seed: SeedFile = serde_json::from_str(SEED_JSON)
.map_err(|error| StashError::Database(format!("种子文件不是合法 JSON: {error}")))?;
@@ -102,173 +153,144 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
// 单事务写入:避免中途失败留下半个内容库
let tx = conn.transaction().map_err(map_error)?;
for category in &seed.categories {
let kind = category.kind.as_str();
for section in &seed.sections {
let view = section.view.as_str();
// 只认识前端支持的三种形态,拼错时立刻报错而不是静默写脏数据
if !matches!(kind, "apps" | "posters" | "articles") {
if !matches!(view, "apps" | "posters" | "articles") {
return Err(StashError::Database(format!(
"种子分类 {} 的 kind 非法: {kind}",
category.id
"种子分区 {} 的 view 非法: {view}",
section.key
)));
}
// 只有 apps 分区才允许有分组:其余类型直接挂条目
if view != "apps" && !section.groups.is_empty() {
return Err(StashError::Database(format!(
"种子分区 {} 是 {view} 类型,不应包含 groups",
section.key
)));
}
tx.execute(
"INSERT INTO category (id, kind, title, bar_color, icon, preview_limit, empty_text, sort)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
"INSERT INTO node (key, parent_key, title, kind, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, sort)
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
category.id,
kind,
category.title,
category.bar_color,
category.icon,
category.preview_limit,
category.empty_text,
category.sort,
section.key,
section.title,
view,
section.bar_color,
section.icon,
section.preview_limit,
section.empty_text,
i64::from(section.show_in_sidebar),
section.sort,
],
)
.map_err(map_error)?;
report.categories += 1;
report.sections += 1;
if kind == "apps" {
for group in &category.groups {
if view == "apps" {
for group in &section.groups {
tx.execute(
"INSERT INTO app_group (category_id, id, name, sort) VALUES (?1, ?2, ?3, ?4)",
params![category.id, group.id, group.name, group.sort],
"INSERT INTO node (key, parent_key, title, kind, show_in_sidebar, sort)
VALUES (?1, ?2, ?3, 'group', ?4, ?5)",
params![
group.key,
section.key,
group.title,
i64::from(group.show_in_sidebar),
group.sort
],
)
.map_err(map_error)?;
report.groups += 1;
for (index, item) in group.items.iter().enumerate() {
insert_entry(&tx, &category.id, Some(&group.id), index, item)?;
report.entries += 1;
for (index, entry) in group.entries.iter().enumerate() {
insert_entry(&tx, &group.key, index, entry, &mut report)?;
}
}
continue;
} else {
// 海报与图文没有分组,直接挂在分区上(node_key 就是分区自己)
for (index, entry) in section.entries.iter().enumerate() {
insert_entry(&tx, &section.key, index, entry, &mut report)?;
}
}
// 海报与图文没有分组,直接挂在分类下
for (index, item) in category.items.iter().enumerate() {
insert_entry(&tx, &category.id, None, index, item)?;
report.entries += 1;
}
// 首页该分区展示哪个节点:节点必须已经插入(分组或分区本身)
tx.execute(
"INSERT INTO section_block (node_key, title, preview_limit, sort)
VALUES (?1, ?2, ?3, ?4)",
params![
section.block.node_key,
section.block.title,
section.block.preview_limit,
section.sort,
],
)
.map_err(|error| {
StashError::Database(format!(
"分区 {} 的展示节点 {} 无效: {error}",
section.key, section.block.node_key
))
})?;
report.blocks += 1;
}
// 侧栏导航必须放在分类之后插入:叶子对 category(id) 有外键依赖
report.nav_items = seed_nav(&tx, &seed.nav)?;
tx.commit().map_err(map_error)?;
Ok(report)
}
/// 导入侧栏导航。
/// 写入一个条目:基础字段进 entry,行内标签进 entry_tag。
///
/// 先插顶层项(parent_id 为空),再插各分组的叶子——自引用外键要求父节点先存在。
/// 返回写入的节点总数。
fn seed_nav(tx: &rusqlite::Transaction<'_>, nav: &SeedNav) -> Result<usize, StashError> {
let mut inserted = 0usize;
// 顶层直达项与折叠分组是同一层的兄弟节点,必须共用一个递增序号,
// 否则两组序号都从 0 开始、互相撞号,最终顺序会退化成按 id 排序。
let mut top_sort = 0usize;
// 顶层直达项:有 category_id,渲染成不带展开箭头的链接
for leaf in &nav.top_level {
insert_nav_node(tx, leaf, None, top_sort)?;
top_sort += 1;
inserted += 1;
}
// 折叠分组:本身没有 category_id,子节点才是真正的链接
for group in &nav.groups {
tx.execute(
"INSERT INTO nav_entry (id, parent_id, label, category_id, group_id, sort)
VALUES (?1, NULL, ?2, NULL, NULL, ?3)",
params![group.id, group.label, top_sort as i64],
)
.map_err(map_error)?;
top_sort += 1;
inserted += 1;
// 组内叶子单独编号:它们是另一个层级,序号从 0 起不会和顶层冲突
for (leaf_index, leaf) in group.leaves.iter().enumerate() {
insert_nav_node(tx, leaf, Some(&group.id), leaf_index)?;
inserted += 1;
}
}
Ok(inserted)
}
/// 写入一个导航叶子节点,指向「分类 + 可选分组」。
fn insert_nav_node(
tx: &rusqlite::Transaction<'_>,
leaf: &SeedNavLeaf,
parent_id: Option<&str>,
sort: usize,
) -> Result<(), StashError> {
tx.execute(
"INSERT INTO nav_entry (id, parent_id, label, category_id, group_id, sort)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
leaf.id,
parent_id,
leaf.label,
leaf.target.category_id,
leaf.target.group_id,
sort as i64,
],
)
.map_err(|error| {
// 外键失败通常意味着种子里的 target 写错了分类 id,把节点名带进报错方便定位
StashError::Database(format!("写入导航节点 {} 失败: {error}", leaf.id))
})?;
Ok(())
}
/// 写入一个条目,并把 `views` / `likes` 从 JSON 里提到独立列。
///
/// 计数放列上是为了让用户用任何 SQLite 工具都能直接改数,
/// 也避免将来自增时重写整个 JSON。
/// `node_key` 是直接容器的业务键;所属分区由它推导,不单独存。
fn insert_entry(
tx: &rusqlite::Transaction<'_>,
category_id: &str,
group_id: Option<&str>,
node_key: &str,
index: usize,
item: &SeedItem,
entry: &SeedEntry,
report: &mut SeedReport,
) -> Result<(), StashError> {
let mut extra = item.extra.clone();
let views = take_count(&mut extra, "views");
let likes = take_count(&mut extra, "likes");
let data = Value::Object(extra).to_string();
tx.execute(
"INSERT INTO entry (category_id, id, group_id, name, views, likes, sort, data)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
"INSERT INTO entry (
key, node_key, name, description, icon, href, cover,
banner, badge, comment, summary, body, hot, views, likes, sort
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
params![
category_id,
item.id,
group_id,
item.name,
views,
likes,
entry.key,
node_key,
entry.name,
entry.description,
entry.icon,
entry.href,
entry.cover,
entry.banner,
entry.badge,
entry.comment,
entry.summary,
entry.body,
i64::from(entry.hot),
entry.views,
entry.likes,
index as i64,
data,
],
)
.map_err(map_error)?;
Ok(())
}
report.entries += 1;
/// 从 JSON 对象里取出计数值并删除该键,缺省或类型不对时按 0 处理。
fn take_count(extra: &mut Map<String, Value>, key: &str) -> i64 {
match extra.remove(key) {
Some(Value::Number(number)) => number.as_i64().unwrap_or(0),
_ => 0,
// 标签:海报的 tags 与图文的 meta 共用一张子表,按出现顺序编号
for (sort, label) in entry.tags.iter().chain(entry.meta.iter()).enumerate() {
tx.execute(
"INSERT INTO entry_tag (entry_key, sort, label) VALUES (?1, ?2, ?3)",
params![entry.key, sort as i64, label],
)
.map_err(map_error)?;
report.tags += 1;
}
Ok(())
}
// ---------------------------------------------------------------------------
@@ -278,54 +300,16 @@ fn take_count(extra: &mut Map<String, Value>, key: &str) -> i64 {
/// 种子文件根节点
#[derive(Debug, Deserialize)]
struct SeedFile {
categories: Vec<SeedCategory>,
/// 侧栏导航;缺省时侧栏为空,但不影响分类内容
#[serde(default)]
nav: SeedNav,
sections: Vec<SeedSection>,
}
/// 侧栏导航:顶层直达项 + 可折叠分组
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedNav {
#[serde(default)]
top_level: Vec<SeedNavLeaf>,
#[serde(default)]
groups: Vec<SeedNavGroup>,
}
/// 一个可折叠分组,本身不可点击,只负责收纳叶子
#[derive(Debug, Deserialize)]
struct SeedNavGroup {
id: String,
label: String,
#[serde(default)]
leaves: Vec<SeedNavLeaf>,
}
/// 导航叶子:点击后切到 `target` 指定的分类 / 分组
#[derive(Debug, Deserialize)]
struct SeedNavLeaf {
id: String,
label: String,
target: SeedNavTarget,
}
/// 导航目标;`group_id` 为空表示直接打开整个分类
/// 一个分区(顶层节点)
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedNavTarget {
category_id: String,
#[serde(default)]
group_id: Option<String>,
}
/// 一个分类;`groups` 与 `items` 按 kind 二者取一
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedCategory {
id: String,
kind: String,
struct SeedSection {
key: String,
/// 渲染形态:apps / posters / articles
view: String,
title: String,
bar_color: String,
icon: String,
@@ -334,28 +318,87 @@ struct SeedCategory {
preview_limit: Option<i64>,
#[serde(default)]
empty_text: Option<String>,
/// 是否出现在侧栏;默认 true
#[serde(default = "default_true")]
show_in_sidebar: bool,
/// 仅 apps 分区使用
#[serde(default)]
groups: Vec<SeedGroup>,
/// 非 apps 分区直接挂条目
#[serde(default)]
items: Vec<SeedItem>,
entries: Vec<SeedEntry>,
/// 首页卡片展示哪个节点(分组编码,或分区自己的编码)
block: SeedBlockConfig,
}
/// 应用类分类下的一个分组
/// 首页分区的展示配置
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedBlockConfig {
/// 要展示的节点业务键;通常是分组,电影/文章填分区自己
node_key: String,
/// 块标题;空则用节点 title
#[serde(default)]
title: Option<String>,
/// 卡片显示几条;空则用前端默认
#[serde(default)]
preview_limit: Option<i64>,
}
/// 分区下的一个分组
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedGroup {
id: String,
name: String,
key: String,
title: String,
sort: i64,
items: Vec<SeedItem>,
/// 是否出现在侧栏;默认 true
#[serde(default = "default_true")]
show_in_sidebar: bool,
entries: Vec<SeedEntry>,
}
/// 一条内容;除 id / name 外全部字段原样落进 JSON 列。
/// serde 默认值:侧栏默认显示
fn default_true() -> bool {
true
}
/// 一条内容:字段与 `entry` 表的列一一对应。
///
/// 用 `flatten` 收集剩余键,这样种子文件里新增字段不需要同步改 Rust 结构。
#[derive(Debug, Deserialize)]
struct SeedItem {
id: String,
/// `tags` / `meta` 都写进 `entry_tag`(海报用 tags,图文用 meta);
/// `body` 是文章的 Markdown 正文,一篇一段文本。
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedEntry {
key: String,
name: String,
#[serde(flatten)]
extra: Map<String, Value>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
href: Option<String>,
#[serde(default)]
cover: Option<String>,
#[serde(default)]
banner: Option<String>,
#[serde(default)]
badge: Option<String>,
#[serde(default)]
comment: Option<String>,
#[serde(default)]
summary: Option<String>,
/// 文章正文(Markdown)
#[serde(default)]
body: Option<String>,
#[serde(default)]
hot: bool,
#[serde(default)]
views: i64,
#[serde(default)]
likes: i64,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
meta: Vec<String>,
}