feat: 格式从分组下沉到条目(v21)
- entry 新增 format(apps/posters/articles),老数据按根分组 view_mode 回填;seed 按分区写入 - 分组管理移除格式字段/显示,根分组只作容器 - 内容弹窗新增逐条格式选择,列表增加格式列;ItemEditor 保存时保留原格式 - 前台按 entry.format 渲染:卡片/分组页支持同组混合格式,详情按条目格式选模板 - 迁移后 live 20 应用 / 13 海报;cargo test 59 passed;Playwright 验证混合展示
This commit is contained in:
+5
-4
@@ -97,8 +97,8 @@ README.md 运行、路由、库结构、登录说明
|
||||
### `/admin`(新原子模型)
|
||||
| Tab | 能力 |
|
||||
| --- | --- |
|
||||
| 分组管理 | 树形表格:新建根分组(左侧)、加子分组、编辑(名称/上级/格式/图标/排序/状态)、删除;行内状态开关即时生效;图标可留空 |
|
||||
| 内容 | 选分组以列表展示条目(选主分组时包含所有子分组);列:标题 / 跳转链接 / 所属分组 / 操作;弹窗新增/编辑,编辑时显示不可改的「标识」 |
|
||||
| 分组管理 | 树形表格:新建根分组(左侧)、加子分组、编辑(名称/上级/图标/排序/状态)、删除;格式已下沉到条目,不再在分组上设置 |
|
||||
| 内容 | 选分组以列表展示条目(选主分组时包含所有子分组);列:标题 / 格式 / 跳转链接 / 所属分组 / 操作;每条内容单独选格式(应用/海报/图文),同一分组可混合 |
|
||||
| 侧边栏 | 分组树逐项勾选,开关在右侧、**拨动即保存**(无单独保存/撤销按钮);子分组可单独勾选 |
|
||||
| 首页卡片 | 左侧卡片列表 + 右侧分组表格;新建卡片弹窗输入标题;卡片可拖动排序(影响首页顺序);表格内开关/条数即时保存,标题用「保存」按钮 |
|
||||
| 页头 | 启用开关、速度、条目、标签与色调 |
|
||||
@@ -164,8 +164,9 @@ GET /api/backup SQLite 快照附件 需登录
|
||||
## 5. 数据库与迁移
|
||||
|
||||
- 文件:开发态在 `target/debug/data/stash.db`;可用 `--root` / 数据目录覆盖(见 `config.rs`)。
|
||||
- 当前 `PRAGMA user_version = 20`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19→20、更高版本拒绝启动。
|
||||
- `node` 是统一分组树(根节点 `kind='section'` 带 `view_mode` 格式,子节点 `kind='group'` 可多层);`enabled` 控制启停;`icon` 可为空。
|
||||
- 当前 `PRAGMA user_version = 21`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19→20→21、更高版本拒绝启动。
|
||||
- 格式在 `entry.format`(apps / posters / articles),同组可混合;`node` 的旧 `view_mode` 仅保留兼容,不再决定渲染。
|
||||
- `node` 是统一分组树(根节点 `kind='section'`,子节点 `kind='group'` 可多层);`enabled` 控制启停;`icon` 可为空。
|
||||
- 主要表:`node`、`entry`、`entry_tag`、`section_block`、`admin`、`session`、`setting`、`audit`。
|
||||
- `entry.updated_at` 用于编辑乐观锁;`setting` 表 `key='site'` 存站点 JSON。
|
||||
- **设置/内容都是 SQLite**:备份用 `/api/backup`,恢复需停机替换文件。
|
||||
|
||||
@@ -34,7 +34,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
|
||||
### 登录与设置(v0.1.1 起)
|
||||
|
||||
- 登录是**页内弹窗**:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位);
|
||||
- 登录后点「站点设置」进入 `/admin`:分组管理(树形表格,可建根/子分组、图标可空、排序权重、启停、删除)、内容(选分组以列表查看/编辑条目,主分组含子分组)、侧边栏(勾选分组树)、首页卡片(建卡 + 勾选分组,可混合格式),以及页头滚动信息、资料与系统设置;
|
||||
- 登录后点「站点设置」进入 `/admin`:分组管理(树形表格,可建根/子分组、图标可空、排序权重、启停、删除)、内容(选分组以列表查看/编辑条目,每条可单独设格式,主分组含子分组)、侧边栏(勾选分组树)、首页卡片(左侧卡片列表 + 分组表格),以及页头滚动信息、资料与系统设置;
|
||||
- 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁;
|
||||
- `/admin` 的「系统」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
|
||||
- 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天);
|
||||
|
||||
@@ -147,6 +147,14 @@ DELETE /api/items/:id
|
||||
|
||||
## 11. 实施记录
|
||||
|
||||
### P16(2026-10-31,格式下沉到条目)
|
||||
|
||||
- 数据库 v21:`entry.format`(apps / posters / articles),老数据按所属根分组的 `view_mode` 自动回填;新库 seed 按分区写入;
|
||||
- 分组管理移除「格式」字段与显示,根分组只作为容器;
|
||||
- 内容弹窗新增「格式」选择,列表增加「格式」列;同一条目可单独设为应用/海报/图文;
|
||||
- 前台改为按 `entry.format` 渲染:首页卡片、分组页同一分组内可同时出现应用网格与海报/图文网格;详情页按条目格式选择模板;
|
||||
- 验证:迁移后 20 应用 / 13 海报;在应用分组内新建海报条目后,内容列表、首页卡片、分组页均能混合展示。
|
||||
|
||||
### P15(2026-10-31,卡片新建弹窗、标题保存/删除、拖动排序)
|
||||
|
||||
- 新建卡片改为弹窗:输入标题 → 确认新建 / 取消关闭;创建后自动选中新表;
|
||||
|
||||
+19
-11
@@ -31,6 +31,7 @@ struct NodeRow {
|
||||
/// 条目行:与 `entry` 表的列一一对应
|
||||
struct EntryRow {
|
||||
key: String,
|
||||
format: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
icon: Option<String>,
|
||||
@@ -140,7 +141,7 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
|
||||
let entries = load_node_entries(conn, &node.key)?;
|
||||
let items = entries
|
||||
.iter()
|
||||
.map(|entry| item_for(entry, format, tags))
|
||||
.map(|entry| item_for(entry, tags))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let child_values = children
|
||||
@@ -338,12 +339,12 @@ fn build_section(
|
||||
if section.allow_groups {
|
||||
object.insert(
|
||||
"groups".into(),
|
||||
build_groups(conn, section, view_mode, &tags)?,
|
||||
build_groups(conn, section, &tags)?,
|
||||
);
|
||||
let direct = load_node_entries(conn, §ion.key)?;
|
||||
let items = direct
|
||||
.iter()
|
||||
.map(|entry| item_for(entry, view_mode, &tags))
|
||||
.map(|entry| item_for(entry, &tags))
|
||||
.collect::<Vec<_>>();
|
||||
object.insert("items".into(), json!(items));
|
||||
} else {
|
||||
@@ -352,7 +353,7 @@ fn build_section(
|
||||
let entries = load_section_entries(conn, §ion.key)?;
|
||||
let items = entries
|
||||
.iter()
|
||||
.map(|entry| item_for(entry, view_mode, &tags))
|
||||
.map(|entry| item_for(entry, &tags))
|
||||
.collect::<Vec<_>>();
|
||||
object.insert("items".into(), json!(items));
|
||||
}
|
||||
@@ -360,20 +361,23 @@ fn build_section(
|
||||
Ok(Value::Object(object))
|
||||
}
|
||||
|
||||
/// 按条目类型组装列表项。
|
||||
fn item_for(entry: &EntryRow, view_mode: &str, tags: &HashMap<String, Vec<String>>) -> Value {
|
||||
match view_mode {
|
||||
/// 按条目自身的格式组装列表项。
|
||||
fn item_for(entry: &EntryRow, tags: &HashMap<String, Vec<String>>) -> Value {
|
||||
let mut value = match entry.format.as_str() {
|
||||
"posters" => poster_item(entry, tags.get(&entry.key)),
|
||||
"articles" => article_item(entry, tags.get(&entry.key)),
|
||||
_ => app_item(entry),
|
||||
};
|
||||
if let Some(object) = value.as_object_mut() {
|
||||
object.insert("format".into(), json!(entry.format));
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
/// 取出某个分区下的分组,并把条目按 `node_key` 分派进去。
|
||||
fn build_groups(
|
||||
conn: &Connection,
|
||||
section: &NodeRow,
|
||||
view_mode: &str,
|
||||
tags: &HashMap<String, Vec<String>>,
|
||||
) -> Result<Value, StashError> {
|
||||
let mut stmt = conn
|
||||
@@ -403,7 +407,7 @@ fn build_groups(
|
||||
let entries = load_node_entries(conn, &group_key)?;
|
||||
let items = entries
|
||||
.iter()
|
||||
.map(|entry| item_for(entry, view_mode, tags))
|
||||
.map(|entry| item_for(entry, tags))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
rendered_groups.push(json!({
|
||||
@@ -512,7 +516,8 @@ fn load_section_entries(
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT e.key, e.name, e.description, e.icon, e.href, e.cover,
|
||||
e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes
|
||||
e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes,
|
||||
COALESCE(e.format, 'apps')
|
||||
FROM entry e
|
||||
JOIN node p ON p.key = e.node_key
|
||||
WHERE COALESCE(p.parent_key, p.key) = ?1
|
||||
@@ -537,6 +542,7 @@ fn load_section_entries(
|
||||
hot: row.get(11)?,
|
||||
views: row.get(12)?,
|
||||
likes: row.get(13)?,
|
||||
format: row.get(14)?,
|
||||
})
|
||||
})
|
||||
.map_err(map_error)?
|
||||
@@ -549,7 +555,8 @@ fn load_section_entries(
|
||||
fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>, StashError> { let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT e.key, e.name, e.description, e.icon, e.href, e.cover,
|
||||
e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes
|
||||
e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes,
|
||||
COALESCE(e.format, 'apps')
|
||||
FROM entry e
|
||||
WHERE e.node_key = ?1
|
||||
ORDER BY e.sort, e.key",
|
||||
@@ -573,6 +580,7 @@ fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>,
|
||||
hot: row.get(11)?,
|
||||
views: row.get(12)?,
|
||||
likes: row.get(13)?,
|
||||
format: row.get(14)?,
|
||||
})
|
||||
})
|
||||
.map_err(map_error)?
|
||||
|
||||
+33
-16
@@ -16,6 +16,7 @@ const MAX_TAG_LEN: usize = 40;
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct EntryRecord {
|
||||
pub key: String,
|
||||
pub format: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub icon: String,
|
||||
@@ -57,6 +58,9 @@ pub struct EntryPatch {
|
||||
/// 可选:把条目移动到另一个分区 / 分组(不传则保持原位)
|
||||
#[serde(default)]
|
||||
pub node_key: Option<String>,
|
||||
/// 条目自身的展示格式:apps / posters / articles;不传默认 apps
|
||||
#[serde(default)]
|
||||
pub format: Option<String>,
|
||||
}
|
||||
|
||||
/// 新建条目的请求体:`key` 是业务键,`nodeKey` 决定挂在哪个分区 / 分组。
|
||||
@@ -151,6 +155,11 @@ fn looks_like_domain(value: &str) -> bool {
|
||||
}
|
||||
|
||||
fn validate(patch: &EntryPatch) -> Result<(), StashError> {
|
||||
if let Some(format) = patch.format.as_deref() {
|
||||
if !matches!(format, "apps" | "posters" | "articles") {
|
||||
return Err(StashError::InvalidDirectory("格式不合法".into()));
|
||||
}
|
||||
}
|
||||
if patch.name.trim().is_empty() {
|
||||
return Err(StashError::InvalidDirectory("标题不能为空".into()));
|
||||
}
|
||||
@@ -181,7 +190,7 @@ fn validate(patch: &EntryPatch) -> Result<(), StashError> {
|
||||
pub fn load(conn: &Connection, key: &str) -> Result<Option<EntryRecord>, StashError> {
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT key, name, COALESCE(description, ''), COALESCE(icon, ''), COALESCE(href, ''),
|
||||
"SELECT key, COALESCE(format, 'apps'), name, COALESCE(description, ''), COALESCE(icon, ''), COALESCE(href, ''),
|
||||
COALESCE(cover, ''), COALESCE(banner, ''), COALESCE(badge, ''),
|
||||
COALESCE(comment, ''), COALESCE(summary, ''), COALESCE(body, ''), updated_at
|
||||
FROM entry WHERE key = ?1",
|
||||
@@ -189,18 +198,19 @@ pub fn load(conn: &Connection, key: &str) -> Result<Option<EntryRecord>, StashEr
|
||||
|row| {
|
||||
Ok(EntryRecord {
|
||||
key: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
icon: row.get(3)?,
|
||||
href: row.get(4)?,
|
||||
cover: row.get(5)?,
|
||||
banner: row.get(6)?,
|
||||
badge: row.get(7)?,
|
||||
comment: row.get(8)?,
|
||||
summary: row.get(9)?,
|
||||
body: row.get(10)?,
|
||||
format: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
description: row.get(3)?,
|
||||
icon: row.get(4)?,
|
||||
href: row.get(5)?,
|
||||
cover: row.get(6)?,
|
||||
banner: row.get(7)?,
|
||||
badge: row.get(8)?,
|
||||
comment: row.get(9)?,
|
||||
summary: row.get(10)?,
|
||||
body: row.get(11)?,
|
||||
tags: Vec::new(),
|
||||
updated_at: row.get(11)?,
|
||||
updated_at: row.get(12)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -285,6 +295,7 @@ pub fn create(conn: &mut Connection, new: NewEntry) -> Result<EntryRecord, Stash
|
||||
|
||||
let patch = new.item;
|
||||
validate(&patch)?;
|
||||
let format = patch.format.clone().unwrap_or_else(|| "apps".into());
|
||||
let href = normalize_href(&patch.href)?;
|
||||
validate_target(conn, &new.node_key)?;
|
||||
|
||||
@@ -316,12 +327,13 @@ pub fn create(conn: &mut Connection, new: NewEntry) -> Result<EntryRecord, Stash
|
||||
.map_err(map_error)?;
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO entry (key, node_key, name, description, icon, href, cover, banner,
|
||||
"INSERT INTO entry (key, node_key, format, name, description, icon, href, cover, banner,
|
||||
badge, comment, summary, body, hot, views, likes, sort, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0, 0, 0, ?13, ?14)",
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0, 0, 0, ?14, ?15)",
|
||||
params![
|
||||
key,
|
||||
new.node_key,
|
||||
format,
|
||||
patch.name.trim(),
|
||||
patch.description,
|
||||
patch.icon,
|
||||
@@ -370,6 +382,7 @@ pub fn update( conn: &mut Connection,
|
||||
expected_updated_at: i64,
|
||||
) -> Result<EntryRecord, StashError> {
|
||||
validate(&patch)?;
|
||||
let format = patch.format.clone().unwrap_or_else(|| "apps".into());
|
||||
// 跳转地址先规范化:补 https、拒绝 javascript: 等危险协议
|
||||
let href = normalize_href(&patch.href)?;
|
||||
if let Some(node_key) = patch.node_key.as_deref() {
|
||||
@@ -392,7 +405,8 @@ pub fn update( conn: &mut Connection,
|
||||
name = ?1, description = ?2, icon = ?3, href = ?4,
|
||||
cover = ?5, banner = ?6, badge = ?7, comment = ?8,
|
||||
summary = ?9, body = ?10, updated_at = ?11,
|
||||
node_key = COALESCE(?14, node_key)
|
||||
node_key = COALESCE(?14, node_key),
|
||||
format = ?15
|
||||
WHERE key = ?12 AND updated_at = ?13",
|
||||
params![
|
||||
patch.name.trim(),
|
||||
@@ -408,7 +422,8 @@ pub fn update( conn: &mut Connection,
|
||||
timestamp,
|
||||
key,
|
||||
expected_updated_at,
|
||||
patch.node_key
|
||||
patch.node_key,
|
||||
format
|
||||
],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
@@ -488,6 +503,7 @@ mod tests {
|
||||
"CREATE TABLE entry (
|
||||
key TEXT PRIMARY KEY,
|
||||
node_key TEXT NOT NULL,
|
||||
format TEXT NOT NULL DEFAULT 'apps',
|
||||
name TEXT NOT NULL,
|
||||
description TEXT, icon TEXT, href TEXT, cover TEXT, banner TEXT,
|
||||
badge TEXT, comment TEXT, summary TEXT, body TEXT,
|
||||
@@ -539,6 +555,7 @@ mod tests {
|
||||
body: "## 标题".into(),
|
||||
tags: vec!["推荐电影".into(), "动画".into()],
|
||||
node_key: None,
|
||||
format: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+30
-5
@@ -66,6 +66,7 @@ CREATE TABLE IF NOT EXISTS entry (
|
||||
node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
-- 直接所属容器:应用是分组,海报/图文是分区本身
|
||||
-- 所属分区不单独存:由 node_key 推导(分组看它的 parent_key)
|
||||
format TEXT NOT NULL DEFAULT 'apps', -- 条目自身的展示格式:apps / posters / articles
|
||||
name TEXT NOT NULL, -- 名称:网站名 / 电影名 / 文章标题
|
||||
description TEXT, -- 一句话简介,列表卡片的副标题
|
||||
icon TEXT, -- 字符图标(封面缺失时的占位)
|
||||
@@ -217,6 +218,23 @@ const V20_SQL: &str = r#"
|
||||
ALTER TABLE node ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1;
|
||||
"#;
|
||||
|
||||
/// v20 → v21:格式从分组下沉到条目;老数据按所属根分组的 view_mode 回填。
|
||||
const V21_SQL: &str = r#"
|
||||
ALTER TABLE entry ADD COLUMN format TEXT NOT NULL DEFAULT 'apps';
|
||||
|
||||
WITH RECURSIVE roots(key, root_key, view_mode) AS (
|
||||
SELECT key, key, view_mode FROM node WHERE parent_key IS NULL
|
||||
UNION ALL
|
||||
SELECT n.key, r.root_key, r.view_mode
|
||||
FROM node n JOIN roots r ON n.parent_key = r.key
|
||||
)
|
||||
UPDATE entry
|
||||
SET format = COALESCE(
|
||||
(SELECT view_mode FROM roots WHERE roots.key = entry.node_key),
|
||||
'apps'
|
||||
);
|
||||
"#;
|
||||
|
||||
/// v18 → v19:首页卡片改为「卡片 + 勾选分组」。
|
||||
const V19_SQL: &str = r#"
|
||||
CREATE TABLE IF NOT EXISTS card (
|
||||
@@ -276,7 +294,7 @@ pub struct SeedReport {
|
||||
///
|
||||
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
|
||||
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
|
||||
pub const SCHEMA_VERSION: i64 = 20;
|
||||
pub const SCHEMA_VERSION: i64 = 21;
|
||||
|
||||
fn current_version(conn: &Connection) -> Result<i64, StashError> {
|
||||
conn.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||
@@ -336,6 +354,11 @@ pub fn migrate(conn: &Connection) -> Result<i64, StashError> {
|
||||
version = 20;
|
||||
}
|
||||
|
||||
if version == 20 {
|
||||
conn.execute_batch(V21_SQL).map_err(map_error)?;
|
||||
version = 21;
|
||||
}
|
||||
|
||||
conn.pragma_update(None, "user_version", version)
|
||||
.map_err(map_error)?;
|
||||
|
||||
@@ -415,13 +438,13 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
|
||||
report.groups += 1;
|
||||
|
||||
for (index, entry) in group.entries.iter().enumerate() {
|
||||
insert_entry(&tx, &group.key, index, entry, &mut report)?;
|
||||
insert_entry(&tx, &group.key, §ion.view, index, entry, &mut report)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 海报与图文没有分组,直接挂在分区上(node_key 就是分区自己)
|
||||
for (index, entry) in section.entries.iter().enumerate() {
|
||||
insert_entry(&tx, §ion.key, index, entry, &mut report)?;
|
||||
insert_entry(&tx, §ion.key, §ion.view, index, entry, &mut report)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,18 +482,20 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
|
||||
fn insert_entry(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
node_key: &str,
|
||||
format: &str,
|
||||
index: usize,
|
||||
entry: &SeedEntry,
|
||||
report: &mut SeedReport,
|
||||
) -> Result<(), StashError> {
|
||||
tx.execute(
|
||||
"INSERT INTO entry (
|
||||
key, node_key, name, description, icon, href, cover,
|
||||
key, node_key, format, 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)",
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
|
||||
params![
|
||||
entry.key,
|
||||
node_key,
|
||||
format,
|
||||
entry.name,
|
||||
entry.description,
|
||||
entry.icon,
|
||||
|
||||
+18
-1
@@ -348,6 +348,10 @@
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.mixed-format-block + .mixed-format-block {
|
||||
margin-top: var(--su-space-4);
|
||||
}
|
||||
|
||||
.group-crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1231,7 +1235,20 @@
|
||||
|
||||
/* 内容页:与分组管理一致的列表 */
|
||||
.entry-table .entry-col-href {
|
||||
width: 40%;
|
||||
width: 36%;
|
||||
}
|
||||
|
||||
.entry-table .entry-col-format {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.entry-format {
|
||||
display: inline-block;
|
||||
padding: 0 var(--su-space-2);
|
||||
border-radius: var(--su-radius-xs);
|
||||
background-color: var(--su-bg-subtle);
|
||||
color: var(--su-text-muted);
|
||||
font-size: var(--su-font-size-sm);
|
||||
}
|
||||
|
||||
.entry-table .entry-col-group {
|
||||
|
||||
+32
-14
@@ -144,8 +144,12 @@ function collectEntries(group: GroupNode): Record<string, unknown>[] {
|
||||
}
|
||||
|
||||
function defaultLimit(group: GroupNode): number {
|
||||
const fallback = group.format === 'apps' ? 4 : group.format === 'posters' ? 6 : 4
|
||||
return group.previewLimit ?? fallback
|
||||
return group.previewLimit ?? 6
|
||||
}
|
||||
|
||||
function entryFormat(entry: Record<string, unknown>): 'apps' | 'posters' | 'articles' {
|
||||
const value = entry.format
|
||||
return value === 'posters' || value === 'articles' ? value : 'apps'
|
||||
}
|
||||
|
||||
function matches(entry: Record<string, unknown>, keyword: string): boolean {
|
||||
@@ -273,13 +277,25 @@ export default function AppNext() {
|
||||
const items = filtered.slice(0, limit)
|
||||
const open = (itemId: string) => navigate(`/g/${encodeURIComponent(group.key)}/i/${encodeURIComponent(itemId)}`)
|
||||
|
||||
if (group.format === 'posters') {
|
||||
return <PosterGrid items={items as unknown as PosterItem[]} onOpen={open} />
|
||||
}
|
||||
if (group.format === 'articles') {
|
||||
return <CesCardGrid items={items as unknown as ArticleItem[]} onOpen={open} />
|
||||
}
|
||||
return <AppGrid items={items as unknown as AppItem[]} onOpen={open} />
|
||||
return (
|
||||
<>
|
||||
{(['apps', 'posters', 'articles'] as const).map((format) => {
|
||||
const subset = items.filter((entry) => entryFormat(entry) === format)
|
||||
if (subset.length === 0) return null
|
||||
return (
|
||||
<div className="mixed-format-block" key={format}>
|
||||
{format === 'posters' ? (
|
||||
<PosterGrid items={subset as unknown as PosterItem[]} onOpen={open} />
|
||||
) : format === 'articles' ? (
|
||||
<CesCardGrid items={subset as unknown as ArticleItem[]} onOpen={open} />
|
||||
) : (
|
||||
<AppGrid items={subset as unknown as AppItem[]} onOpen={open} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderGroupBlock(group: GroupNode, limit: number, showTitle: boolean) {
|
||||
@@ -423,7 +439,9 @@ export default function AppNext() {
|
||||
{ label: String(entry.name ?? entry.title ?? entry.id) },
|
||||
]
|
||||
|
||||
if (group.format === 'articles') {
|
||||
const format = entryFormat(entry)
|
||||
|
||||
if (format === 'articles') {
|
||||
const article = entry as unknown as ArticleItem
|
||||
return (
|
||||
<ArticleDetailView
|
||||
@@ -438,18 +456,18 @@ export default function AppNext() {
|
||||
}
|
||||
|
||||
const model: ItemDetailModel =
|
||||
group.format === 'posters'
|
||||
format === 'posters'
|
||||
? posterDetailModel(entry as unknown as PosterItem)
|
||||
: appDetailModel(entry as unknown as AppItem)
|
||||
|
||||
const related: RelatedItem[] =
|
||||
group.format === 'posters'
|
||||
format === 'posters'
|
||||
? group.entries
|
||||
.filter((item) => item.id !== entry.id)
|
||||
.filter((item) => item.id !== entry.id && entryFormat(item as unknown as Record<string, unknown>) === format)
|
||||
.slice(0, 4)
|
||||
.map((item) => posterToRelatedItem(item as unknown as PosterItem))
|
||||
: group.entries
|
||||
.filter((item) => item.id !== entry.id)
|
||||
.filter((item) => item.id !== entry.id && entryFormat(item as unknown as Record<string, unknown>) === format)
|
||||
.slice(0, 4)
|
||||
.map((item) => toRelatedItem(item as unknown as AppItem))
|
||||
|
||||
|
||||
@@ -499,7 +499,6 @@ function GroupsPanel({
|
||||
)}
|
||||
<GroupMark icon={node.icon} />
|
||||
<span className="group-name">{node.title}</span>
|
||||
<span className="group-format">{FORMAT_LABEL[node.format]}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="group-sort">{node.sort}</td>
|
||||
@@ -583,7 +582,6 @@ function GroupDialog({
|
||||
const [key, setKey] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [parentKey, setParentKey] = useState('')
|
||||
const [format, setFormat] = useState<GroupFormat>('apps')
|
||||
const [icon, setIcon] = useState('')
|
||||
const [sort, setSort] = useState('')
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
@@ -599,7 +597,6 @@ function GroupDialog({
|
||||
setKey(state.group.key)
|
||||
setTitle(state.group.title)
|
||||
setParentKey(state.group.parentKey ?? '')
|
||||
setFormat(state.group.format)
|
||||
setIcon(state.group.icon)
|
||||
setSort(String(state.group.sort))
|
||||
setEnabled(state.group.enabled)
|
||||
@@ -608,7 +605,6 @@ function GroupDialog({
|
||||
|
||||
setKey('')
|
||||
setTitle('')
|
||||
setFormat('apps')
|
||||
setIcon('')
|
||||
setSort('')
|
||||
setEnabled(true)
|
||||
@@ -625,7 +621,6 @@ function GroupDialog({
|
||||
|
||||
const rootMode = parentKey === '' || isRootEdit
|
||||
const showKey = editing === undefined
|
||||
|
||||
const submit = async () => {
|
||||
if (title.trim() === '') {
|
||||
setError('分组名称不能为空')
|
||||
@@ -645,9 +640,7 @@ function GroupDialog({
|
||||
icon,
|
||||
sort: sort.trim() === '' ? undefined : Number(sort),
|
||||
enabled,
|
||||
...(isRootEdit
|
||||
? { format }
|
||||
: { parentKey: parentKey }),
|
||||
...(isRootEdit ? {} : { parentKey }),
|
||||
})
|
||||
setBusy(false)
|
||||
if (!result.ok) {
|
||||
@@ -663,7 +656,6 @@ function GroupDialog({
|
||||
key: key.trim(),
|
||||
title: title.trim(),
|
||||
parentKey: rootMode ? undefined : parentKey,
|
||||
format: rootMode ? format : undefined,
|
||||
icon,
|
||||
sort: sort.trim() === '' ? undefined : Number(sort),
|
||||
enabled,
|
||||
@@ -750,16 +742,6 @@ function GroupDialog({
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{rootMode && (
|
||||
<FormField label="格式" htmlFor="group-format" hint="根分组决定这一棵树的渲染格式">
|
||||
<Select
|
||||
options={FORMAT_OPTIONS}
|
||||
value={format}
|
||||
onChange={(next) => setFormat(next as GroupFormat)}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField label="图标样式" htmlFor="group-icon" hint="留空则不显示图标;可填 iconfont 图标名或一个字符">
|
||||
<div className="group-icon-field">
|
||||
<span className="group-icon-preview">
|
||||
@@ -877,6 +859,7 @@ function ContentPanel({ groups, onReload }: { groups: GroupNode[]; onReload: ()
|
||||
<thead>
|
||||
<tr>
|
||||
<th>标题</th>
|
||||
<th className="entry-col-format">格式</th>
|
||||
<th className="entry-col-href">跳转链接</th>
|
||||
<th className="entry-col-group">所属分组</th>
|
||||
<th className="group-col-actions">操作</th>
|
||||
@@ -888,6 +871,11 @@ function ContentPanel({ groups, onReload }: { groups: GroupNode[]; onReload: ()
|
||||
<td>
|
||||
<span className="entry-title">{entry.name ?? entry.title ?? entry.id}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="entry-format">
|
||||
{FORMAT_LABEL[(entry.format ?? 'apps') as GroupFormat]}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<EntryLink href={entry.href} />
|
||||
</td>
|
||||
@@ -925,7 +913,7 @@ function ContentPanel({ groups, onReload }: { groups: GroupNode[]; onReload: ()
|
||||
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="group-empty">
|
||||
<td colSpan={5} className="group-empty">
|
||||
该分组下还没有内容
|
||||
</td>
|
||||
</tr>
|
||||
@@ -972,6 +960,7 @@ function EntryDialog({
|
||||
const [error, setError] = useState('')
|
||||
const [form, setForm] = useState({
|
||||
key: '',
|
||||
format: 'apps' as GroupFormat,
|
||||
name: '',
|
||||
href: '',
|
||||
icon: '',
|
||||
@@ -994,6 +983,7 @@ function EntryDialog({
|
||||
setUpdatedAt(0)
|
||||
setForm({
|
||||
key: '',
|
||||
format: 'apps',
|
||||
name: '',
|
||||
href: '',
|
||||
icon: '',
|
||||
@@ -1016,6 +1006,7 @@ function EntryDialog({
|
||||
setUpdatedAt(result.entry.updated_at)
|
||||
setForm({
|
||||
key: result.entry.key,
|
||||
format: result.entry.format,
|
||||
name: result.entry.name,
|
||||
href: result.entry.href,
|
||||
icon: result.entry.icon,
|
||||
@@ -1044,6 +1035,7 @@ function EntryDialog({
|
||||
setBusy(true)
|
||||
setError('')
|
||||
const item = {
|
||||
format: form.format,
|
||||
name: form.name.trim(),
|
||||
description: form.description,
|
||||
icon: form.icon,
|
||||
@@ -1111,6 +1103,14 @@ function EntryDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField label="格式" htmlFor="entry-format" hint="每条内容单独选择格式,同一个分组下可以混合">
|
||||
<Select
|
||||
options={FORMAT_OPTIONS}
|
||||
value={form.format}
|
||||
onChange={(next) => update({ format: next as GroupFormat })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="标识"
|
||||
htmlFor="entry-key"
|
||||
|
||||
@@ -91,6 +91,7 @@ export function ItemEditor({ open, itemKey, onClose, onSaved }: ItemEditorProps)
|
||||
|
||||
const result = await saveItem(entry.key, entry.updated_at, {
|
||||
...form,
|
||||
format: entry.format,
|
||||
icon: entry.icon,
|
||||
tags: form.tags
|
||||
.split(/[,,]/)
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
*
|
||||
* 读取是公开的(编辑表单要能预览),写入必须登录,并用 `updatedAt` 做乐观锁。
|
||||
*/
|
||||
export type EntryFormat = 'apps' | 'posters' | 'articles'
|
||||
|
||||
export type EntryRecord = {
|
||||
key: string
|
||||
format: EntryFormat
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
@@ -21,6 +24,8 @@ export type EntryRecord = {
|
||||
|
||||
export type EntryInput = {
|
||||
name: string
|
||||
/** 条目的展示格式;不传由后端按 apps 处理 */
|
||||
format?: EntryFormat
|
||||
description: string
|
||||
icon: string
|
||||
tags: string[]
|
||||
|
||||
@@ -7,6 +7,7 @@ export type GroupFormat = 'apps' | 'posters' | 'articles'
|
||||
|
||||
export type GroupEntry = {
|
||||
id: string
|
||||
format?: GroupFormat
|
||||
name?: string
|
||||
title?: string
|
||||
href?: string
|
||||
|
||||
Reference in New Issue
Block a user