feat(db): 内容库迁入 SQLite,分类与侧栏导航改为数据库驱动
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
//! 内容库的查询组装:把关系表还原成前端 `types.ts` 里的 `Category[]`。
|
||||
//!
|
||||
//! 这里输出的是 `serde_json::Value` 而不是一堆 Rust DTO,理由是:
|
||||
//! 条目的可变字段本来就存在 JSON 列里,直接用 Value 拼装可以保证
|
||||
//! 「用户往 JSON 里加什么字段,前端就能读到什么字段」,不需要每加一个
|
||||
//! 字段就改一次 Rust 结构体。
|
||||
|
||||
use rusqlite::Connection;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::{
|
||||
db::{Catalog, map_error},
|
||||
error::StashError,
|
||||
};
|
||||
|
||||
/// 分类行:与 `category` 表一一对应
|
||||
struct CategoryRow {
|
||||
id: String,
|
||||
kind: String,
|
||||
title: String,
|
||||
bar_color: String,
|
||||
icon: String,
|
||||
preview_limit: Option<i64>,
|
||||
empty_text: Option<String>,
|
||||
}
|
||||
|
||||
/// 导航行:侧栏自引用树的原始形态
|
||||
struct NavRow {
|
||||
id: String,
|
||||
parent_id: Option<String>,
|
||||
label: String,
|
||||
category_id: Option<String>,
|
||||
group_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 条目行:`data` 里是尚未展开的 JSON 文本
|
||||
struct EntryRow {
|
||||
id: String,
|
||||
group_id: Option<String>,
|
||||
name: String,
|
||||
views: i64,
|
||||
likes: i64,
|
||||
data: String,
|
||||
}
|
||||
|
||||
/// 读出整个内容目录:分类树 + 侧栏导航。
|
||||
pub fn load(conn: &Connection) -> Result<Catalog, StashError> {
|
||||
Ok(Catalog {
|
||||
categories: load_categories(conn)?,
|
||||
nav: load_nav(conn)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 读出全部分类,按 `sort` 排序;顺序即首页分区顺序。
|
||||
fn load_categories(conn: &Connection) -> Result<Vec<Value>, StashError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, kind, title, bar_color, icon, preview_limit, empty_text
|
||||
FROM category
|
||||
ORDER BY sort, id",
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
let categories = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(CategoryRow {
|
||||
id: row.get(0)?,
|
||||
kind: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
bar_color: row.get(3)?,
|
||||
icon: row.get(4)?,
|
||||
preview_limit: row.get(5)?,
|
||||
empty_text: row.get(6)?,
|
||||
})
|
||||
})
|
||||
.map_err(map_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(map_error)?;
|
||||
|
||||
// 先释放语句再发后续查询,避免同一连接上的借用冲突
|
||||
drop(stmt);
|
||||
|
||||
categories
|
||||
.iter()
|
||||
.map(|category| build_category(conn, category))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 按 kind 分派到不同的组装方式。
|
||||
fn build_category(conn: &Connection, category: &CategoryRow) -> Result<Value, StashError> {
|
||||
let mut object = Map::new();
|
||||
object.insert("id".into(), json!(category.id));
|
||||
object.insert("kind".into(), json!(category.kind));
|
||||
object.insert("title".into(), json!(category.title));
|
||||
object.insert("barColor".into(), json!(category.bar_color));
|
||||
object.insert("icon".into(), json!(category.icon));
|
||||
|
||||
// 可选字段:没配就不下发,交给前端用 `?? 默认值` 处理
|
||||
if let Some(limit) = category.preview_limit {
|
||||
object.insert("previewLimit".into(), json!(limit));
|
||||
}
|
||||
if let Some(empty_text) = &category.empty_text {
|
||||
object.insert("emptyText".into(), json!(empty_text));
|
||||
}
|
||||
|
||||
match category.kind.as_str() {
|
||||
"apps" => object.insert("groups".into(), build_groups(conn, category)?),
|
||||
"posters" | "articles" => object.insert("items".into(), build_items(conn, category)?),
|
||||
other => {
|
||||
return Err(StashError::Database(format!(
|
||||
"分类 {} 的 kind 非法: {other}",
|
||||
category.id
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Value::Object(object))
|
||||
}
|
||||
|
||||
/// 应用类:先取出分组,再把条目按 `group_id` 分派进去。
|
||||
///
|
||||
/// 只用两条查询(分组一条、条目一条)而不是每组一条,避免 N+1。
|
||||
fn build_groups(conn: &Connection, category: &CategoryRow) -> Result<Value, StashError> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, name FROM app_group WHERE category_id = ?1 ORDER BY sort, id")
|
||||
.map_err(map_error)?;
|
||||
|
||||
let groups = stmt
|
||||
.query_map([&category.id], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.map_err(map_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(map_error)?;
|
||||
|
||||
drop(stmt);
|
||||
|
||||
let entries = load_entries(conn, &category.id)?;
|
||||
|
||||
let mut rendered_groups = Vec::with_capacity(groups.len());
|
||||
|
||||
for (group_id, group_name) in groups {
|
||||
let mut items = Vec::new();
|
||||
|
||||
// 保持 entry.sort 决定的顺序;group_id 为空的条目不属于任何分组
|
||||
for entry in entries.iter().filter(|entry| {
|
||||
entry.group_id.as_deref() == Some(group_id.as_str())
|
||||
}) {
|
||||
items.push(Value::Object(entry_object(entry, &category.id, "name")?));
|
||||
}
|
||||
|
||||
rendered_groups.push(json!({
|
||||
"id": group_id,
|
||||
"name": group_name,
|
||||
"items": items,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Value::Array(rendered_groups))
|
||||
}
|
||||
|
||||
/// 海报 / 图文类:没有分组,直接输出条目数组。
|
||||
fn build_items(conn: &Connection, category: &CategoryRow) -> Result<Value, StashError> {
|
||||
let entries = load_entries(conn, &category.id)?;
|
||||
|
||||
let items = entries
|
||||
.iter()
|
||||
.map(|entry| entry_object(entry, &category.id, "title").map(Value::Object))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(Value::Array(items))
|
||||
}
|
||||
|
||||
/// 取某个分类下的全部条目,按 `sort` 升序。
|
||||
fn load_entries(conn: &Connection, category_id: &str) -> Result<Vec<EntryRow>, StashError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, group_id, name, views, likes, data
|
||||
FROM entry
|
||||
WHERE category_id = ?1
|
||||
ORDER BY sort, id",
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
let entries = stmt
|
||||
.query_map([category_id], |row| {
|
||||
Ok(EntryRow {
|
||||
id: row.get(0)?,
|
||||
group_id: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
views: row.get(3)?,
|
||||
likes: row.get(4)?,
|
||||
data: row.get(5)?,
|
||||
})
|
||||
})
|
||||
.map_err(map_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(map_error)?;
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// 把一条条目展开成对象。
|
||||
///
|
||||
/// `name_key` 决定展示名落在哪个字段:应用用 `name`,海报与图文用 `title`。
|
||||
/// 数据库列优先于 JSON,用户即便在 `data` 里写了同名键也不会顶掉真实值。
|
||||
fn entry_object(
|
||||
entry: &EntryRow,
|
||||
category_id: &str,
|
||||
name_key: &str,
|
||||
) -> Result<Map<String, Value>, StashError> {
|
||||
let parsed: Value = serde_json::from_str(&entry.data).map_err(|error| {
|
||||
// 把位置写进报错里,用户改坏 JSON 时能立刻定位到是哪一行
|
||||
StashError::Database(format!(
|
||||
"条目 {category_id}/{} 的 data 列不是合法 JSON: {error}",
|
||||
entry.id
|
||||
))
|
||||
})?;
|
||||
|
||||
let Value::Object(mut object) = parsed else {
|
||||
return Err(StashError::Database(format!(
|
||||
"条目 {category_id}/{} 的 data 列必须是 JSON 对象",
|
||||
entry.id
|
||||
)));
|
||||
};
|
||||
|
||||
object.insert("id".into(), json!(entry.id));
|
||||
object.insert(name_key.into(), json!(entry.name));
|
||||
object.insert("views".into(), json!(entry.views));
|
||||
object.insert("likes".into(), json!(entry.likes));
|
||||
|
||||
Ok(object)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 侧栏导航
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 读出侧栏导航,组装成 `{ topLevel, groups }`,结构与 frontend 的 `nav.ts` 一致。
|
||||
///
|
||||
/// 侧栏只有两层(顶层项 / 分组内的叶子),所以不需要通用递归:
|
||||
/// 顶层里带 `category_id` 的是直达链接,不带的是可折叠分组,其子节点即叶子。
|
||||
fn load_nav(conn: &Connection) -> Result<Value, StashError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, parent_id, label, category_id, group_id
|
||||
FROM nav_entry
|
||||
ORDER BY sort, id",
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(NavRow {
|
||||
id: row.get(0)?,
|
||||
parent_id: row.get(1)?,
|
||||
label: row.get(2)?,
|
||||
category_id: row.get(3)?,
|
||||
group_id: row.get(4)?,
|
||||
})
|
||||
})
|
||||
.map_err(map_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(map_error)?;
|
||||
|
||||
drop(stmt);
|
||||
|
||||
let mut top_level = Vec::new();
|
||||
let mut groups = Vec::new();
|
||||
|
||||
for row in &rows {
|
||||
// 有 parent_id 的都是分组内的叶子,在这里由分组统一收集
|
||||
if row.parent_id.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if row.category_id.is_some() {
|
||||
// 顶层直达项
|
||||
top_level.push(nav_leaf(row));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 可折叠分组:收集它的子节点
|
||||
let leaves = rows
|
||||
.iter()
|
||||
.filter(|child| child.parent_id.as_deref() == Some(row.id.as_str()))
|
||||
.filter(|child| child.category_id.is_some())
|
||||
.map(nav_leaf)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
groups.push(json!({
|
||||
"id": row.id,
|
||||
"label": row.label,
|
||||
"leaves": leaves,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"topLevel": top_level,
|
||||
"groups": groups,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 把一个导航叶子转成前端结构;`groupId` 为空时不下发该键。
|
||||
fn nav_leaf(row: &NavRow) -> Value {
|
||||
let mut target = Map::new();
|
||||
target.insert(
|
||||
"categoryId".into(),
|
||||
json!(row.category_id.clone().unwrap_or_default()),
|
||||
);
|
||||
|
||||
if let Some(group_id) = &row.group_id {
|
||||
target.insert("groupId".into(), json!(group_id));
|
||||
}
|
||||
|
||||
json!({
|
||||
"id": row.id,
|
||||
"label": row.label,
|
||||
"target": Value::Object(target),
|
||||
})
|
||||
}
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
//! 内容库(SQLite)。
|
||||
//!
|
||||
//! 三条设计约束:
|
||||
//!
|
||||
//! 1. **单文件**:库固定是 `data/stash.db`,用 `journal_mode=DELETE`,
|
||||
//! 不会像 WAL 那样在磁盘上多出 `-wal` / `-shm` 两个旁文件。
|
||||
//! 2. **可编辑**:用户可以用任何 SQLite 工具直接改库;服务只读,不独占文件。
|
||||
//! 3. **开发期不做迁移**:表结构还在变,改结构就删掉 `data/stash.db` 重启,
|
||||
//! 启动时会重新建表并灌入内嵌种子。
|
||||
|
||||
mod catalog;
|
||||
mod schema;
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use serde_json::Value;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{config::Config, error::StashError};
|
||||
|
||||
pub use schema::SeedReport;
|
||||
|
||||
/// 一次查询得到的完整内容目录:分类树 + 侧栏导航。
|
||||
///
|
||||
/// 两者总是一起下发:首页与侧栏同时需要,拆成两个接口只会多一次往返。
|
||||
#[derive(Debug)]
|
||||
pub struct Catalog {
|
||||
/// 分类树,结构对应前端 `types.ts` 的 `Category[]`
|
||||
pub categories: Vec<Value>,
|
||||
/// 侧栏导航,结构对应前端 `nav.ts` 的 `{ topLevel, groups }`
|
||||
pub nav: Value,
|
||||
}
|
||||
|
||||
/// 内容库句柄。
|
||||
///
|
||||
/// rusqlite 的连接是 `!Sync` 的同步对象,这里用一把互斥锁包起来:
|
||||
/// 本地工具并发极低,单连接足够,没必要引入连接池。
|
||||
/// 所有耗时操作都应放在 `spawn_blocking` 里调用(见 server.rs)。
|
||||
pub struct Db {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// 打开(必要时创建)内容库。
|
||||
///
|
||||
/// 顺序很重要:先建目录,再连库,再设 PRAGMA,最后建表 / 判断是否需要灌种子。
|
||||
pub fn open(config: &Config) -> Result<Self, StashError> {
|
||||
std::fs::create_dir_all(&config.data_dir)?;
|
||||
|
||||
let mut conn = Connection::open(&config.db_path).map_err(map_error)?;
|
||||
|
||||
// busy_timeout 让用户用外部工具改库时的短暂写锁变成等待而不是立刻失败
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode = DELETE;
|
||||
PRAGMA synchronous = FULL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA busy_timeout = 5000;",
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
schema::apply(&conn)?;
|
||||
|
||||
// 结构版本:开发期只写不校验,等结构稳定后再拿它做迁移判断
|
||||
conn.pragma_update(None, "user_version", 1)
|
||||
.map_err(map_error)?;
|
||||
|
||||
// 空库才灌种子:用户删掉内置条目后重启不会被重新塞回来
|
||||
let existing: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM category", [], |row| row.get(0))
|
||||
.map_err(map_error)?;
|
||||
|
||||
if existing == 0 {
|
||||
let report = schema::seed(&mut conn)?;
|
||||
info!(
|
||||
"内容库为空,已导入内嵌种子:{} 分类 / {} 分组 / {} 条目 / {} 导航节点",
|
||||
report.categories, report.groups, report.entries, report.nav_items
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"内容库已存在,沿用现有数据:{}",
|
||||
crate::config::display_path(&config.db_path)
|
||||
);
|
||||
|
||||
// 开发阶段新增过表(比如侧栏导航),旧库不会被补数据。
|
||||
// 这里给一条明确提示,避免用户对着空侧栏猜原因。
|
||||
let nav_nodes: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM nav_entry", [], |row| row.get(0))
|
||||
.map_err(map_error)?;
|
||||
|
||||
if nav_nodes == 0 {
|
||||
warn!(
|
||||
"内容库有分类但侧栏导航为空:如果这是旧版本留下的库,删掉 {} 后重启即可重新导入种子",
|
||||
crate::config::display_path(&config.db_path)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
/// 读出完整的分类树与侧栏导航,供 `/api/catalog` 直接序列化。
|
||||
pub fn load_catalog(&self) -> Result<Catalog, StashError> {
|
||||
let conn = self.lock()?;
|
||||
catalog::load(&conn)
|
||||
}
|
||||
|
||||
/// SQLite 的 `data_version`:**其它连接**提交后该值会变,本连接自己的写入不变。
|
||||
///
|
||||
/// 正好用来发现「用户用 DB Browser 改了库」,SSE 靠轮询它来推送刷新。
|
||||
pub fn data_version(&self) -> Result<i64, StashError> {
|
||||
let conn = self.lock()?;
|
||||
conn.pragma_query_value(None, "data_version", |row| row.get::<_, i64>(0))
|
||||
.map_err(map_error)
|
||||
}
|
||||
|
||||
/// 取连接;锁中毒说明此前某个线程 panic 过,此时继续用库并不安全。
|
||||
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Connection>, StashError> {
|
||||
self.conn
|
||||
.lock()
|
||||
.map_err(|_| StashError::Database("数据库连接锁已损坏,请重启进程".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 rusqlite 的错误统一收敛成 `StashError::Database`。
|
||||
pub(crate) fn map_error(error: rusqlite::Error) -> StashError {
|
||||
StashError::Database(error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 建一个内存库并灌种子;内存库不落盘,测试不受临时目录权限影响。
|
||||
fn seeded() -> Connection {
|
||||
let mut conn = Connection::open_in_memory().expect("内存库应能打开");
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")
|
||||
.expect("PRAGMA 应能设置");
|
||||
schema::apply(&conn).expect("建表应成功");
|
||||
schema::seed(&mut conn).expect("种子导入应成功");
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_fills_every_category_group_and_entry() {
|
||||
let conn = seeded();
|
||||
|
||||
let categories: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM category", [], |row| row.get(0))
|
||||
.expect("应能统计分类");
|
||||
let groups: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM app_group", [], |row| row.get(0))
|
||||
.expect("应能统计分组");
|
||||
let entries: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0))
|
||||
.expect("应能统计条目");
|
||||
let nav_items: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM nav_entry", [], |row| row.get(0))
|
||||
.expect("应能统计导航节点");
|
||||
|
||||
assert_eq!(categories, 3, "种子应含 常用推荐 / 推荐电影 / CES 三个分类");
|
||||
assert_eq!(groups, 5, "常用推荐应有 5 个分组");
|
||||
assert_eq!(entries, 42, "18 应用 + 12 电影 + 12 图文");
|
||||
assert_eq!(nav_items, 8, "1 个顶层直达 + 2 个折叠分组 + 5 个叶子");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_matches_the_frontend_model() {
|
||||
let conn = seeded();
|
||||
let catalog = catalog::load(&conn).expect("应能组装内容目录");
|
||||
let categories = &catalog.categories;
|
||||
|
||||
assert_eq!(categories.len(), 3);
|
||||
|
||||
let apps = &categories[0];
|
||||
assert_eq!(apps["id"], "recommend");
|
||||
assert_eq!(apps["kind"], "apps");
|
||||
assert_eq!(apps["barColor"], "#3858f6");
|
||||
assert_eq!(apps["groups"].as_array().expect("应有分组").len(), 5);
|
||||
|
||||
let first_app = &apps["groups"][0]["items"][0];
|
||||
assert_eq!(first_app["name"], "GitHub");
|
||||
assert_eq!(first_app["hot"], true);
|
||||
// 计数从 JSON 提到了独立列,这里确认读取时又合并回来了
|
||||
assert_eq!(first_app["views"], 429);
|
||||
|
||||
let posters = &categories[1];
|
||||
assert_eq!(posters["kind"], "posters");
|
||||
// 海报与图文用 title 而不是 name
|
||||
assert_eq!(
|
||||
posters["items"][0].get("name"),
|
||||
None,
|
||||
"海报不应同时下发 name 字段"
|
||||
);
|
||||
assert!(posters["items"][0]["title"].is_string());
|
||||
|
||||
let articles = &categories[2];
|
||||
assert_eq!(articles["kind"], "articles");
|
||||
assert!(articles["items"][0]["blocks"].is_array(), "图文正文应是块数组");
|
||||
|
||||
// ---- 侧栏导航 ----
|
||||
let nav = &catalog.nav;
|
||||
let top_level = nav["topLevel"].as_array().expect("应有顶层项");
|
||||
assert_eq!(top_level.len(), 1);
|
||||
assert_eq!(top_level[0]["label"], "常用推荐");
|
||||
assert_eq!(top_level[0]["target"]["categoryId"], "recommend");
|
||||
assert!(
|
||||
top_level[0]["target"].get("groupId").is_none(),
|
||||
"整分类直达时不应下发 groupId"
|
||||
);
|
||||
|
||||
let nav_groups = nav["groups"].as_array().expect("应有折叠分组");
|
||||
assert_eq!(nav_groups.len(), 2);
|
||||
assert_eq!(nav_groups[0]["label"], "素材资源");
|
||||
assert_eq!(nav_groups[0]["leaves"].as_array().expect("应有叶子").len(), 3);
|
||||
assert_eq!(nav_groups[1]["label"], "开发工具");
|
||||
assert_eq!(nav_groups[1]["leaves"][0]["label"], "前端调试工具");
|
||||
assert_eq!(nav_groups[1]["leaves"][0]["target"]["groupId"], "dev");
|
||||
}
|
||||
|
||||
/// 删除分类时,指向它的导航叶子必须一起消失(外键级联),
|
||||
/// 否则侧栏会留下点了没反应的死链接。
|
||||
#[test]
|
||||
fn nav_leaves_cascade_when_their_category_is_removed() {
|
||||
let conn = seeded();
|
||||
conn.execute("DELETE FROM category WHERE id = 'recommend'", [])
|
||||
.expect("应能删除分类");
|
||||
|
||||
let catalog = catalog::load(&conn).expect("应能组装内容目录");
|
||||
|
||||
assert!(
|
||||
catalog.nav["topLevel"]
|
||||
.as_array()
|
||||
.expect("应有顶层数组")
|
||||
.is_empty(),
|
||||
"指向已删分类的顶层链接应被级联删除"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_json_names_the_offending_entry() {
|
||||
let conn = seeded();
|
||||
conn.execute(
|
||||
"UPDATE entry SET data = '{oops' WHERE category_id = 'recommend' AND id = 'github'",
|
||||
[],
|
||||
)
|
||||
.expect("应能写入坏数据");
|
||||
|
||||
let error = catalog::load(&conn).expect_err("坏 JSON 应报错");
|
||||
let message = error.to_string();
|
||||
|
||||
assert!(message.contains("recommend/github"), "报错要能定位到具体条目: {message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_version_is_readable() {
|
||||
let db = Db {
|
||||
conn: Mutex::new(seeded()),
|
||||
};
|
||||
assert!(db.data_version().expect("应能读取 data_version") >= 0);
|
||||
}
|
||||
|
||||
/// 回归测试:外部连接提交后,服务端连接读到的 data_version 必须变化。
|
||||
///
|
||||
/// 这是「用户用 DB Browser 改库,页面自动刷新」整条链路的地基;
|
||||
/// 用文件库而不是内存库,因为内存库无法被第二个连接看到。
|
||||
#[test]
|
||||
fn data_version_changes_when_another_connection_commits() {
|
||||
use crate::config::Config;
|
||||
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/db-version");
|
||||
std::fs::create_dir_all(&dir).expect("应能创建测试目录");
|
||||
let path = dir.join("probe.db");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let config = Config {
|
||||
root: dir.clone(),
|
||||
data_dir: dir.clone(),
|
||||
db_path: path.clone(),
|
||||
..Config::default()
|
||||
};
|
||||
|
||||
let db = Db::open(&config).expect("应能打开文件库");
|
||||
let before = db.data_version().expect("应能读取初始版本");
|
||||
|
||||
// 模拟外部工具:另开一个连接提交一次写事务
|
||||
let external = Connection::open(&path).expect("外部连接应能打开");
|
||||
external
|
||||
.execute(
|
||||
"UPDATE entry SET name = '改过的名字' WHERE category_id = 'recommend' AND id = 'github'",
|
||||
[],
|
||||
)
|
||||
.expect("外部写入应成功");
|
||||
drop(external);
|
||||
|
||||
let after = db.data_version().expect("应能读取新版本");
|
||||
assert_ne!(before, after, "外部提交后 data_version 必须变化");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
//! 内容库的表结构与种子导入。
|
||||
//!
|
||||
//! 表结构刻意保持「薄」:分类和分组是关系表,条目除 id / 排序 / 计数外的
|
||||
//! 全部字段都塞进 `entry.data` 的 JSON 列。原因是前端 `types.ts` 用的是
|
||||
//! 可辨识联合(apps / posters / articles),三种形态字段差异很大,
|
||||
//! 拆成三张表会让查询和后续加字段都变啰嗦;JSON 列则天然与前端结构 1:1。
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::{db::map_error, error::StashError};
|
||||
|
||||
/// 种子文件在编译期内嵌,分发时不需要额外带数据文件。
|
||||
const SEED_JSON: &str = include_str!("seed.json");
|
||||
|
||||
/// 建表语句。
|
||||
///
|
||||
/// 开发阶段结构还会调整,所以这里只做 `IF NOT EXISTS` 式的«建库»,
|
||||
/// 不实现版本迁移:需要改结构时删掉 `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
|
||||
);
|
||||
|
||||
-- 应用类分类的分组:明细页按组展示,首页把各组前几项拼成预览
|
||||
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)
|
||||
);
|
||||
|
||||
-- 条目:应用 / 海报 / 图文共用一张表
|
||||
-- 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)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entry_group_idx ON entry(category_id, group_id, 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
|
||||
);
|
||||
"#;
|
||||
|
||||
/// 种子导入结果,仅用于启动日志。
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct SeedReport {
|
||||
pub categories: usize,
|
||||
pub groups: usize,
|
||||
pub entries: usize,
|
||||
/// 侧栏导航节点数(顶层项 + 叶子)
|
||||
pub nav_items: usize,
|
||||
}
|
||||
|
||||
/// 建表(幂等):每次启动都跑一遍,缺表就补,已有数据不动。
|
||||
pub fn apply(conn: &Connection) -> Result<(), StashError> {
|
||||
conn.execute_batch(SCHEMA_SQL).map_err(map_error)
|
||||
}
|
||||
|
||||
/// 把内嵌种子写进空库。
|
||||
///
|
||||
/// 只在数据库文件是本次启动新建时调用,所以不存在「覆盖用户数据」的问题;
|
||||
/// 用户删掉库以后重启会重新得到一份干净的内置内容,这也是开发期的重置手段。
|
||||
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}")))?;
|
||||
|
||||
let mut report = SeedReport::default();
|
||||
// 单事务写入:避免中途失败留下半个内容库
|
||||
let tx = conn.transaction().map_err(map_error)?;
|
||||
|
||||
for category in &seed.categories {
|
||||
let kind = category.kind.as_str();
|
||||
|
||||
// 只认识前端支持的三种形态,拼错时立刻报错而不是静默写脏数据
|
||||
if !matches!(kind, "apps" | "posters" | "articles") {
|
||||
return Err(StashError::Database(format!(
|
||||
"种子分类 {} 的 kind 非法: {kind}",
|
||||
category.id
|
||||
)));
|
||||
}
|
||||
|
||||
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)",
|
||||
params![
|
||||
category.id,
|
||||
kind,
|
||||
category.title,
|
||||
category.bar_color,
|
||||
category.icon,
|
||||
category.preview_limit,
|
||||
category.empty_text,
|
||||
category.sort,
|
||||
],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
report.categories += 1;
|
||||
|
||||
if kind == "apps" {
|
||||
for group in &category.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],
|
||||
)
|
||||
.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;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 海报与图文没有分组,直接挂在分类下
|
||||
for (index, item) in category.items.iter().enumerate() {
|
||||
insert_entry(&tx, &category.id, None, index, item)?;
|
||||
report.entries += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 侧栏导航必须放在分类之后插入:叶子对 category(id) 有外键依赖
|
||||
report.nav_items = seed_nav(&tx, &seed.nav)?;
|
||||
|
||||
tx.commit().map_err(map_error)?;
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// 导入侧栏导航。
|
||||
///
|
||||
/// 先插顶层项(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。
|
||||
fn insert_entry(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
category_id: &str,
|
||||
group_id: Option<&str>,
|
||||
index: usize,
|
||||
item: &SeedItem,
|
||||
) -> 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)",
|
||||
params![
|
||||
category_id,
|
||||
item.id,
|
||||
group_id,
|
||||
item.name,
|
||||
views,
|
||||
likes,
|
||||
index as i64,
|
||||
data,
|
||||
],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从 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,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 种子文件的反序列化结构
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 种子文件根节点
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SeedFile {
|
||||
categories: Vec<SeedCategory>,
|
||||
/// 侧栏导航;缺省时侧栏为空,但不影响分类内容
|
||||
#[serde(default)]
|
||||
nav: SeedNav,
|
||||
}
|
||||
|
||||
/// 侧栏导航:顶层直达项 + 可折叠分组
|
||||
#[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,
|
||||
title: String,
|
||||
bar_color: String,
|
||||
icon: String,
|
||||
sort: i64,
|
||||
#[serde(default)]
|
||||
preview_limit: Option<i64>,
|
||||
#[serde(default)]
|
||||
empty_text: Option<String>,
|
||||
#[serde(default)]
|
||||
groups: Vec<SeedGroup>,
|
||||
#[serde(default)]
|
||||
items: Vec<SeedItem>,
|
||||
}
|
||||
|
||||
/// 应用类分类下的一个分组
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SeedGroup {
|
||||
id: String,
|
||||
name: String,
|
||||
sort: i64,
|
||||
items: Vec<SeedItem>,
|
||||
}
|
||||
|
||||
/// 一条内容;除 id / name 外全部字段原样落进 JSON 列。
|
||||
///
|
||||
/// 用 `flatten` 收集剩余键,这样种子文件里新增字段不需要同步改 Rust 结构。
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SeedItem {
|
||||
id: String,
|
||||
name: String,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
@@ -0,0 +1,986 @@
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"id": "recommend",
|
||||
"kind": "apps",
|
||||
"title": "常用推荐",
|
||||
"barColor": "#3858f6",
|
||||
"icon": "🔖",
|
||||
"sort": 1,
|
||||
"groups": [
|
||||
{
|
||||
"id": "common",
|
||||
"name": "常用站点",
|
||||
"sort": 1,
|
||||
"items": [
|
||||
{
|
||||
"id": "github",
|
||||
"name": "GitHub",
|
||||
"desc": "代码托管与开源社区",
|
||||
"icon": "G",
|
||||
"href": "#",
|
||||
"cover": "https://picsum.photos/seed/github/200/200",
|
||||
"hot": true,
|
||||
"comment": "收录在本地导航里的常用站点,点开就能直达。",
|
||||
"banner": "https://picsum.photos/seed/github-banner/800/300",
|
||||
"badge": "推荐好站",
|
||||
"views": 429,
|
||||
"likes": 38
|
||||
},
|
||||
{
|
||||
"id": "zhihu",
|
||||
"name": "知乎",
|
||||
"desc": "深度内容问答社区",
|
||||
"icon": "Z",
|
||||
"href": "#",
|
||||
"cover": "https://picsum.photos/seed/zhihu/200/200",
|
||||
"hot": false,
|
||||
"comment": "收录在本地导航里的常用站点,点开就能直达。",
|
||||
"banner": "https://picsum.photos/seed/zhihu-banner/800/300",
|
||||
"badge": "推荐好站",
|
||||
"views": 294,
|
||||
"likes": 73
|
||||
},
|
||||
{
|
||||
"id": "bilibili",
|
||||
"name": "哔哩哔哩",
|
||||
"desc": "弹幕视频分享网站",
|
||||
"icon": "B",
|
||||
"href": "#",
|
||||
"cover": "https://picsum.photos/seed/bilibili/200/200",
|
||||
"hot": true,
|
||||
"comment": "收录在本地导航里的常用站点,点开就能直达。",
|
||||
"banner": "https://picsum.photos/seed/bilibili-banner/800/300",
|
||||
"badge": "推荐好站",
|
||||
"views": 280,
|
||||
"likes": 9
|
||||
},
|
||||
{
|
||||
"id": "v2ex",
|
||||
"name": "V2EX",
|
||||
"desc": "创意工作者讨论社区",
|
||||
"icon": "V",
|
||||
"href": "#",
|
||||
"cover": "https://picsum.photos/seed/v2ex/200/200",
|
||||
"hot": false,
|
||||
"comment": "收录在本地导航里的常用站点,点开就能直达。",
|
||||
"banner": "https://picsum.photos/seed/v2ex-banner/800/300",
|
||||
"badge": "推荐好站",
|
||||
"views": 387,
|
||||
"likes": 72
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ui",
|
||||
"name": "UI 设计资源",
|
||||
"sort": 2,
|
||||
"items": [
|
||||
{
|
||||
"id": "wallhaven",
|
||||
"name": "Wallhaven",
|
||||
"desc": "WallBase 创办人失踪后推出的精选壁纸站",
|
||||
"icon": "W",
|
||||
"href": "#",
|
||||
"cover": "https://picsum.photos/seed/wallhaven/200/200",
|
||||
"hot": true,
|
||||
"comment": "收录在本地导航里的常用站点,点开就能直达。",
|
||||
"banner": "https://picsum.photos/seed/wallhaven-banner/800/300",
|
||||
"badge": "推荐好站",
|
||||
"views": 608,
|
||||
"likes": 51
|
||||
},
|
||||
{
|
||||
"id": "500px",
|
||||
"name": "500px",
|
||||
"desc": "有着世界上最好的照片分享社区",
|
||||
"icon": "5",
|
||||
"href": "#",
|
||||
"cover": "https://picsum.photos/seed/500px/200/200",
|
||||
"hot": false,
|
||||
"comment": "收录在本地导航里的常用站点,点开就能直达。",
|
||||
"banner": "https://picsum.photos/seed/500px-banner/800/300",
|
||||
"badge": "推荐好站",
|
||||
"views": 573,
|
||||
"likes": 68
|
||||
},
|
||||
{
|
||||
"id": "chuangyiren",
|
||||
"name": "创意人",
|
||||
"desc": "去掉了形式主义描述的原创社区",
|
||||
"icon": "C",
|
||||
"href": "#",
|
||||
"cover": "https://picsum.photos/seed/chuangyiren/200/200",
|
||||
"hot": false,
|
||||
"comment": "收录在本地导航里的常用站点,点开就能直达。",
|
||||
"banner": "https://picsum.photos/seed/chuangyiren-banner/800/300",
|
||||
"badge": "推荐好站",
|
||||
"views": 364,
|
||||
"likes": 20
|
||||
},
|
||||
{
|
||||
"id": "topys",
|
||||
"name": "顶尖文案",
|
||||
"desc": "全球顶尖创意分享平台 OPEN YOUR EYES",
|
||||
"icon": "T",
|
||||
"href": "#",
|
||||
"cover": "https://picsum.photos/seed/topys/200/200",
|
||||
"hot": false,
|
||||
"comment": "收录在本地导航里的常用站点,点开就能直达。",
|
||||
"banner": "https://picsum.photos/seed/topys-banner/800/300",
|
||||
"badge": "推荐好站",
|
||||
"views": 745,
|
||||
"likes": 68
|
||||
},
|
||||
{
|
||||
"id": "uichina",
|
||||
"name": "UI中国",
|
||||
"desc": "前身为 iconfans,国内专业的 UI 设计平台",
|
||||
"icon": "U",
|
||||
"href": "#",
|
||||
"cover": "https://picsum.photos/seed/uichina/200/200",
|
||||
"hot": false,
|
||||
"comment": "收录在本地导航里的常用站点,点开就能直达。",
|
||||
"banner": "https://picsum.photos/seed/uichina-banner/800/300",
|
||||
"badge": "推荐好站",
|
||||
"views": 743,
|
||||
"likes": 4
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "vector",
|
||||
"name": "矢量图标",
|
||||
"sort": 3,
|
||||
"items": [
|
||||
{
|
||||
"id": "iconfont",
|
||||
"name": "Iconfont",
|
||||
"desc": "阿里妈妈矢量图标库",
|
||||
"icon": "I",
|
||||
"href": "#",
|
||||
"cover": "https://picsum.photos/seed/iconfont/200/200",
|
||||
"hot": false,
|
||||
"comment": "收录在本地导航里的常用站点,点开就能直达。",
|
||||
"banner": "https://picsum.photos/seed/iconfont-banner/800/300",
|
||||
"badge": "推荐好站",
|
||||
"views": 585,
|
||||
"likes": 41
|
||||
},
|
||||
{
|
||||
"id": "feather",
|
||||
"name": "Feather Icons",
|
||||
"desc": "简单漂亮的开源图标库",
|
||||
"icon": "F",
|
||||
"href": "#",
|
||||
"cover": "https://picsum.photos/seed/feather/200/200",
|
||||
"hot": false,
|
||||
"comment": "收录在本地导航里的常用站点,点开就能直达。",
|
||||
"banner": "https://picsum.photos/seed/feather-banner/800/300",
|
||||
"badge": "推荐好站",
|
||||
"views": 390,
|
||||
"likes": 48
|
||||
},
|
||||
{
|
||||
"id": "fontawesome",
|
||||
"name": "FontAwesome",
|
||||
"desc": "图标字体与 CSS 框架",
|
||||
"icon": "A",
|
||||
"href": "#",
|
||||
"cover": "https://picsum.photos/seed/fontawesome/200/200",
|
||||
"hot": false,
|
||||
"comment": "收录在本地导航里的常用站点,点开就能直达。",
|
||||
"banner": "https://picsum.photos/seed/fontawesome-banner/800/300",
|
||||
"badge": "推荐好站",
|
||||
"views": 317,
|
||||
"likes": 17
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wallpaper",
|
||||
"name": "免费壁纸",
|
||||
"sort": 4,
|
||||
"items": [
|
||||
{
|
||||
"id": "unsplash",
|
||||
"name": "Unsplash",
|
||||
"desc": "免费高清无版权摄影图片",
|
||||
"icon": "U",
|
||||
"href": "#",
|
||||
"cover": "https://picsum.photos/seed/unsplash/200/200",
|
||||
"hot": false,
|
||||
"comment": "收录在本地导航里的常用站点,点开就能直达。",
|
||||
"banner": "https://picsum.photos/seed/unsplash-banner/800/300",
|
||||
"badge": "推荐好站",
|
||||
"views": 807,
|
||||
"likes": 5
|
||||
},
|
||||
{
|
||||
"id": "pexels",
|
||||
"name": "Pexels",
|
||||
"desc": "才华横溢的摄影师分享的素材",
|
||||
"icon": "P",
|
||||
"href": "#",
|
||||
"cover": "https://picsum.photos/seed/pexels/200/200",
|
||||
"hot": false,
|
||||
"comment": "收录在本地导航里的常用站点,点开就能直达。",
|
||||
"banner": "https://picsum.photos/seed/pexels-banner/800/300",
|
||||
"badge": "推荐好站",
|
||||
"views": 380,
|
||||
"likes": 12
|
||||
},
|
||||
{
|
||||
"id": "pixabay",
|
||||
"name": "Pixabay",
|
||||
"desc": "超过 2.7 百万张优质图片和视频",
|
||||
"icon": "X",
|
||||
"href": "#",
|
||||
"cover": "https://picsum.photos/seed/pixabay/200/200",
|
||||
"hot": false,
|
||||
"comment": "收录在本地导航里的常用站点,点开就能直达。",
|
||||
"banner": "https://picsum.photos/seed/pixabay-banner/800/300",
|
||||
"badge": "推荐好站",
|
||||
"views": 771,
|
||||
"likes": 83
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dev",
|
||||
"name": "开发工具",
|
||||
"sort": 5,
|
||||
"items": [
|
||||
{
|
||||
"id": "codepen",
|
||||
"name": "CodePen",
|
||||
"desc": "前端在线代码演示和练习场",
|
||||
"icon": "C",
|
||||
"href": "#",
|
||||
"cover": "https://picsum.photos/seed/codepen/200/200",
|
||||
"hot": false,
|
||||
"comment": "收录在本地导航里的常用站点,点开就能直达。",
|
||||
"banner": "https://picsum.photos/seed/codepen-banner/800/300",
|
||||
"badge": "推荐好站",
|
||||
"views": 266,
|
||||
"likes": 13
|
||||
},
|
||||
{
|
||||
"id": "stackoverflow",
|
||||
"name": "Stack Overflow",
|
||||
"desc": "全球程序员技术问答社区",
|
||||
"icon": "S",
|
||||
"href": "#",
|
||||
"cover": "https://picsum.photos/seed/stackoverflow/200/200",
|
||||
"hot": false,
|
||||
"comment": "收录在本地导航里的常用站点,点开就能直达。",
|
||||
"banner": "https://picsum.photos/seed/stackoverflow-banner/800/300",
|
||||
"badge": "推荐好站",
|
||||
"views": 112,
|
||||
"likes": 25
|
||||
},
|
||||
{
|
||||
"id": "github-dev",
|
||||
"name": "GitHub",
|
||||
"desc": "代码托管与开源协作社区",
|
||||
"icon": "G",
|
||||
"href": "#",
|
||||
"cover": "https://picsum.photos/seed/github-dev/200/200",
|
||||
"hot": false,
|
||||
"comment": "收录在本地导航里的常用站点,点开就能直达。",
|
||||
"banner": "https://picsum.photos/seed/github-dev-banner/800/300",
|
||||
"badge": "推荐好站",
|
||||
"views": 587,
|
||||
"likes": 32
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "movie",
|
||||
"kind": "posters",
|
||||
"title": "推荐电影",
|
||||
"barColor": "#ff6034",
|
||||
"icon": "🎬",
|
||||
"sort": 2,
|
||||
"items": [
|
||||
{
|
||||
"id": "movie-1",
|
||||
"name": "千与千寻 千と千尋の神隠し (2001)",
|
||||
"cover": "https://picsum.photos/seed/m1/300/450",
|
||||
"tags": [
|
||||
"推荐电影",
|
||||
"豆瓣电影 Top250"
|
||||
],
|
||||
"comment": "收藏在本地片单里,闲下来就重看一遍。",
|
||||
"summary": "少女千寻误入神灵世界,为救回变成猪的父母,在汤屋打工并逐渐学会担当。",
|
||||
"href": "#",
|
||||
"views": 896,
|
||||
"likes": 51
|
||||
},
|
||||
{
|
||||
"id": "movie-2",
|
||||
"name": "美丽人生 La vita è bella (1997)",
|
||||
"cover": "https://picsum.photos/seed/m2/300/450",
|
||||
"tags": [
|
||||
"推荐电影",
|
||||
"豆瓣电影 Top250"
|
||||
],
|
||||
"comment": "收藏在本地片单里,闲下来就重看一遍。",
|
||||
"summary": "二战集中营里,父亲用一场「游戏」保护儿子,让他相信世界仍然美好。",
|
||||
"href": "#",
|
||||
"views": 897,
|
||||
"likes": 20
|
||||
},
|
||||
{
|
||||
"id": "movie-3",
|
||||
"name": "这个杀手不太冷 Léon (1994)",
|
||||
"cover": "https://picsum.photos/seed/m3/300/450",
|
||||
"tags": [
|
||||
"推荐电影",
|
||||
"豆瓣电影 Top250"
|
||||
],
|
||||
"comment": "收藏在本地片单里,闲下来就重看一遍。",
|
||||
"summary": "孤独的职业杀手莱昂收留了邻居少女玛蒂尔达,两个边缘人相互依靠。",
|
||||
"href": "#",
|
||||
"views": 898,
|
||||
"likes": 55
|
||||
},
|
||||
{
|
||||
"id": "movie-4",
|
||||
"name": "阿甘正传 Forrest Gump (1994)",
|
||||
"cover": "https://picsum.photos/seed/m4/300/450",
|
||||
"tags": [
|
||||
"推荐电影",
|
||||
"豆瓣电影 Top250"
|
||||
],
|
||||
"comment": "收藏在本地片单里,闲下来就重看一遍。",
|
||||
"summary": "智商不高的阿甘凭着一股单纯的执着,意外见证并参与了几十年美国历史。",
|
||||
"href": "#",
|
||||
"views": 899,
|
||||
"likes": 32
|
||||
},
|
||||
{
|
||||
"id": "movie-5",
|
||||
"name": "霸王别姬 (1993)",
|
||||
"cover": "https://picsum.photos/seed/m5/300/450",
|
||||
"tags": [
|
||||
"推荐电影",
|
||||
"豆瓣电影 Top250"
|
||||
],
|
||||
"comment": "收藏在本地片单里,闲下来就重看一遍。",
|
||||
"summary": "两位京剧伶人半个世纪的悲欢离合,与时代洪流交织在一起。",
|
||||
"href": "#",
|
||||
"views": 900,
|
||||
"likes": 59
|
||||
},
|
||||
{
|
||||
"id": "movie-6",
|
||||
"name": "肖申克的救赎 The Shawshank Redemption",
|
||||
"cover": "https://picsum.photos/seed/m6/300/450",
|
||||
"tags": [
|
||||
"推荐电影",
|
||||
"豆瓣电影 Top250"
|
||||
],
|
||||
"comment": "收藏在本地片单里,闲下来就重看一遍。",
|
||||
"summary": "蒙冤入狱的银行家安迪,在漫长牢狱中守住希望并完成自救。",
|
||||
"href": "#",
|
||||
"views": 901,
|
||||
"likes": 36
|
||||
},
|
||||
{
|
||||
"id": "movie-7",
|
||||
"name": "泰坦尼克号 Titanic (1997)",
|
||||
"cover": "https://picsum.photos/seed/m7/300/450",
|
||||
"tags": [
|
||||
"推荐电影",
|
||||
"豆瓣电影 Top250"
|
||||
],
|
||||
"comment": "收藏在本地片单里,闲下来就重看一遍。",
|
||||
"summary": "沉船之夜,来自不同阶层的杰克与露丝相爱,也被人性的选择所考验。",
|
||||
"href": "#",
|
||||
"views": 902,
|
||||
"likes": 13
|
||||
},
|
||||
{
|
||||
"id": "movie-8",
|
||||
"name": "盗梦空间 Inception (2010)",
|
||||
"cover": "https://picsum.photos/seed/m8/300/450",
|
||||
"tags": [
|
||||
"推荐电影",
|
||||
"豆瓣电影 Top250"
|
||||
],
|
||||
"comment": "收藏在本地片单里,闲下来就重看一遍。",
|
||||
"summary": "盗梦团队潜入层层梦境,试图在目标的潜意识里植入一个念头。",
|
||||
"href": "#",
|
||||
"views": 903,
|
||||
"likes": 48
|
||||
},
|
||||
{
|
||||
"id": "movie-9",
|
||||
"name": "星际穿越 Interstellar (2014)",
|
||||
"cover": "https://picsum.photos/seed/m9/300/450",
|
||||
"tags": [
|
||||
"推荐电影",
|
||||
"豆瓣电影 Top250"
|
||||
],
|
||||
"comment": "收藏在本地片单里,闲下来就重看一遍。",
|
||||
"summary": "地球濒临枯竭,父亲穿越虫洞寻找新家园,与女儿跨越时间重逢。",
|
||||
"href": "#",
|
||||
"views": 904,
|
||||
"likes": 17
|
||||
},
|
||||
{
|
||||
"id": "movie-10",
|
||||
"name": "楚门的世界 The Truman Show (1998)",
|
||||
"cover": "https://picsum.photos/seed/m10/300/450",
|
||||
"tags": [
|
||||
"推荐电影",
|
||||
"豆瓣电影 Top250"
|
||||
],
|
||||
"comment": "收藏在本地片单里,闲下来就重看一遍。",
|
||||
"summary": "楚门渐渐发现,自己的整个人生其实是一档被全世界围观的真人秀。",
|
||||
"href": "#",
|
||||
"views": 608,
|
||||
"likes": 45
|
||||
},
|
||||
{
|
||||
"id": "movie-11",
|
||||
"name": "疯狂动物城 Zootopia (2016)",
|
||||
"cover": "https://picsum.photos/seed/m11/300/450",
|
||||
"tags": [
|
||||
"推荐电影",
|
||||
"豆瓣电影 Top250"
|
||||
],
|
||||
"comment": "收藏在本地片单里,闲下来就重看一遍。",
|
||||
"summary": "兔子警官与狐狸搭档,在动物城里侦破一桩失踪案,也打破偏见。",
|
||||
"href": "#",
|
||||
"views": 609,
|
||||
"likes": 22
|
||||
},
|
||||
{
|
||||
"id": "movie-12",
|
||||
"name": "大话西游之大圣娶亲 (1995)",
|
||||
"cover": "https://picsum.photos/seed/m12/300/450",
|
||||
"tags": [
|
||||
"推荐电影",
|
||||
"豆瓣电影 Top250"
|
||||
],
|
||||
"comment": "收藏在本地片单里,闲下来就重看一遍。",
|
||||
"summary": "至尊宝在爱情与宿命之间反复抉择,最终戴上金箍踏上取经路。",
|
||||
"href": "#",
|
||||
"views": 610,
|
||||
"likes": 57
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ces",
|
||||
"kind": "articles",
|
||||
"title": "CES 现场报道",
|
||||
"barColor": "#ffba3a",
|
||||
"icon": "🏷️",
|
||||
"sort": 3,
|
||||
"items": [
|
||||
{
|
||||
"id": "ces-1",
|
||||
"name": "可塑性极强的玻璃,在设计师手里有了更多可能 | 这个设计了不起",
|
||||
"desc": "前沿科技",
|
||||
"cover": "https://picsum.photos/seed/a1/400/250",
|
||||
"meta": [
|
||||
"2 年前",
|
||||
"CES 现场报道"
|
||||
],
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「前沿科技」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"text": "设计细节"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "《可塑性极强的玻璃,在设计师手里有了更多可能》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"src": "https://picsum.photos/seed/a1/400/250"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ces-2",
|
||||
"name": "废旧共享单车如何变成微型共享书屋? | 这个设计了不起",
|
||||
"desc": "环保设计",
|
||||
"cover": "https://picsum.photos/seed/a2/400/250",
|
||||
"meta": [
|
||||
"2 年前",
|
||||
"CES 现场报道"
|
||||
],
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「环保设计」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"text": "设计细节"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "《废旧共享单车如何变成微型共享书屋?》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"src": "https://picsum.photos/seed/a2/400/250"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ces-3",
|
||||
"name": "荧光灯管与混凝土块的组合,可以玩出多少花样? | 这个设计了不起",
|
||||
"desc": "材料探索",
|
||||
"cover": "https://picsum.photos/seed/a3/400/250",
|
||||
"meta": [
|
||||
"2 年前",
|
||||
"CES 现场报道"
|
||||
],
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「材料探索」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"text": "设计细节"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "《荧光灯管与混凝土块的组合,可以玩出多少花样?》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"src": "https://picsum.photos/seed/a3/400/250"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ces-4",
|
||||
"name": "混凝土做的模块化收纳框,还能改成搁架用 | 这个设计了不起",
|
||||
"desc": "家居模块",
|
||||
"cover": "https://picsum.photos/seed/a4/400/250",
|
||||
"meta": [
|
||||
"2 年前",
|
||||
"CES 现场报道"
|
||||
],
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「家居模块」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"text": "设计细节"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "《混凝土做的模块化收纳框,还能改成搁架用》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"src": "https://picsum.photos/seed/a4/400/250"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ces-5",
|
||||
"name": "这个用拉伸材料做的头盔,还能帮你呼叫救护车 | 这个设计了不起",
|
||||
"desc": "智能穿戴",
|
||||
"cover": "https://picsum.photos/seed/a5/400/250",
|
||||
"meta": [
|
||||
"2 年前",
|
||||
"CES 现场报道"
|
||||
],
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「智能穿戴」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"text": "设计细节"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "《这个用拉伸材料做的头盔,还能帮你呼叫救护车》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"src": "https://picsum.photos/seed/a5/400/250"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ces-6",
|
||||
"name": "你如何选择字体,简单易读还是好看有趣? | 这个设计了不起",
|
||||
"desc": "视觉体验",
|
||||
"cover": "https://picsum.photos/seed/a6/400/250",
|
||||
"meta": [
|
||||
"2 年前",
|
||||
"CES 现场报道"
|
||||
],
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「视觉体验」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"text": "设计细节"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "《你如何选择字体,简单易读还是好看有趣?》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"src": "https://picsum.photos/seed/a6/400/250"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ces-7",
|
||||
"name": "家里空间小?你需要几把可以悬挂在墙上的椅子 | 这个设计了不起",
|
||||
"desc": "空间收纳",
|
||||
"cover": "https://picsum.photos/seed/a7/400/250",
|
||||
"meta": [
|
||||
"2 年前",
|
||||
"CES 现场报道"
|
||||
],
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「空间收纳」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"text": "设计细节"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "《家里空间小?你需要几把可以悬挂在墙上的椅子》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"src": "https://picsum.photos/seed/a7/400/250"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ces-8",
|
||||
"name": "宜家厨房家具又被改造了,色彩是亮点 | 这个设计了不起",
|
||||
"desc": "色彩灵感",
|
||||
"cover": "https://picsum.photos/seed/a8/400/250",
|
||||
"meta": [
|
||||
"2 年前",
|
||||
"CES 现场报道"
|
||||
],
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「色彩灵感」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"text": "设计细节"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "《宜家厨房家具又被改造了,色彩是亮点》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"src": "https://picsum.photos/seed/a8/400/250"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ces-9",
|
||||
"name": "这款诞生 50 年的扶手椅,推出了特别版配色 | 这个设计了不起",
|
||||
"desc": "经典产品",
|
||||
"cover": "https://picsum.photos/seed/a9/400/250",
|
||||
"meta": [
|
||||
"2 年前",
|
||||
"CES 现场报道"
|
||||
],
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「经典产品」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"text": "设计细节"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "《这款诞生 50 年的扶手椅,推出了特别版配色》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"src": "https://picsum.photos/seed/a9/400/250"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ces-10",
|
||||
"name": "让家和办公室界限变得模糊的一组家具 | 这个设计了不起",
|
||||
"desc": "办公家具",
|
||||
"cover": "https://picsum.photos/seed/a10/400/250",
|
||||
"meta": [
|
||||
"2 年前",
|
||||
"CES 现场报道"
|
||||
],
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「办公家具」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"text": "设计细节"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "《让家和办公室界限变得模糊的一组家具》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"src": "https://picsum.photos/seed/a10/400/250"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ces-11",
|
||||
"name": "这个酒店设计奖,挖掘过去一年体验最好的酒店 | 这个设计了不起",
|
||||
"desc": "空间建筑",
|
||||
"cover": "https://picsum.photos/seed/a11/400/250",
|
||||
"meta": [
|
||||
"2 年前",
|
||||
"CES 现场报道"
|
||||
],
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「空间建筑」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"text": "设计细节"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "《这个酒店设计奖,挖掘过去一年体验最好的酒店》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"src": "https://picsum.photos/seed/a11/400/250"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ces-12",
|
||||
"name": "一款可移动办公桌,挂墙上当作白板就能写写画画 | 这个设计了不起",
|
||||
"desc": "移动办公",
|
||||
"cover": "https://picsum.photos/seed/a12/400/250",
|
||||
"meta": [
|
||||
"2 年前",
|
||||
"CES 现场报道"
|
||||
],
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「移动办公」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"text": "设计细节"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "《一款可移动办公桌,挂墙上当作白板就能写写画画》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"src": "https://picsum.photos/seed/a12/400/250"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"nav": {
|
||||
"topLevel": [
|
||||
{
|
||||
"id": "nav-recommend",
|
||||
"label": "常用推荐",
|
||||
"target": {
|
||||
"categoryId": "recommend"
|
||||
}
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": "nav-assets",
|
||||
"label": "素材资源",
|
||||
"leaves": [
|
||||
{
|
||||
"id": "nav-ui",
|
||||
"label": "UI 设计资源",
|
||||
"target": {
|
||||
"categoryId": "recommend",
|
||||
"groupId": "ui"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nav-vector",
|
||||
"label": "矢量图标",
|
||||
"target": {
|
||||
"categoryId": "recommend",
|
||||
"groupId": "vector"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nav-wallpaper",
|
||||
"label": "免费壁纸",
|
||||
"target": {
|
||||
"categoryId": "recommend",
|
||||
"groupId": "wallpaper"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "nav-tools",
|
||||
"label": "开发工具",
|
||||
"leaves": [
|
||||
{
|
||||
"id": "nav-debug",
|
||||
"label": "前端调试工具",
|
||||
"target": {
|
||||
"categoryId": "recommend",
|
||||
"groupId": "dev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nav-editor",
|
||||
"label": "在线编辑器",
|
||||
"target": {
|
||||
"categoryId": "recommend",
|
||||
"groupId": "dev"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user