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:
2026-10-31 14:26:28 +08:00
parent 8d094175fd
commit 11f992cdad
12 changed files with 173 additions and 72 deletions
+5 -4
View File
@@ -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`,恢复需停机替换文件。
+1 -1
View File
@@ -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 天);
+8
View File
@@ -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
View File
@@ -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, &section.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, &section.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
View File
@@ -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
View File
@@ -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, &section.view, index, entry, &mut report)?;
}
}
} else {
// 海报与图文没有分组,直接挂在分区上(node_key 就是分区自己)
for (index, entry) in section.entries.iter().enumerate() {
insert_entry(&tx, &section.key, index, entry, &mut report)?;
insert_entry(&tx, &section.key, &section.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
View File
@@ -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
View File
@@ -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))
+20 -20
View File
@@ -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(/[,,]/)
+5
View File
@@ -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[]
+1
View File
@@ -7,6 +7,7 @@ export type GroupFormat = 'apps' | 'posters' | 'articles'
export type GroupEntry = {
id: string
format?: GroupFormat
name?: string
title?: string
href?: string