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