feat(content): 分组对所有内容类型生效,统一分区/分组/条目模型
- 分区=容器,分组=任意类型的可选子容器,条目可挂分区或分组(直属分区) - catalog 所有类型统一输出 groups + items,条目查询按节点取 - 详情/侧栏/首页卡片预览支持所有类型的分组;多组显示小标题 + 未分组 - 后台「分区」「侧栏导航」对所有类型可建组;「内容」弹窗分组可选直属分区,编辑可换组 - 修复首页卡片配置为「整个分区」时预览为空 - 测试 59 passed
This commit is contained in:
+51
-57
@@ -30,7 +30,6 @@ struct NodeRow {
|
|||||||
/// 条目行:与 `entry` 表的列一一对应
|
/// 条目行:与 `entry` 表的列一一对应
|
||||||
struct EntryRow {
|
struct EntryRow {
|
||||||
key: String,
|
key: String,
|
||||||
node_key: String,
|
|
||||||
name: String,
|
name: String,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
icon: Option<String>,
|
icon: Option<String>,
|
||||||
@@ -173,22 +172,48 @@ fn build_section(
|
|||||||
}
|
}
|
||||||
|
|
||||||
match view_mode {
|
match view_mode {
|
||||||
"apps" => object.insert("groups".into(), build_groups(conn, section)?),
|
"apps" | "posters" | "articles" => {}
|
||||||
"posters" => object.insert("items".into(), build_poster_items(conn, section)?),
|
|
||||||
"articles" => object.insert("items".into(), build_article_items(conn, section)?),
|
|
||||||
other => {
|
other => {
|
||||||
return Err(StashError::Database(format!(
|
return Err(StashError::Database(format!(
|
||||||
"分区 {} 的 view_mode 非法: {other}",
|
"分区 {} 的 view_mode 非法: {other}",
|
||||||
section.key
|
section.key
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
// 所有类型统一:分组是可选子容器,条目可以挂在分组,也可以直属分区
|
||||||
|
let tags = load_tags(conn, §ion.key)?;
|
||||||
|
object.insert(
|
||||||
|
"groups".into(),
|
||||||
|
build_groups(conn, section, view_mode, &tags)?,
|
||||||
|
);
|
||||||
|
|
||||||
|
let direct = load_node_entries(conn, §ion.key)?;
|
||||||
|
let items = direct
|
||||||
|
.iter()
|
||||||
|
.map(|entry| item_for(entry, view_mode, &tags))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
object.insert("items".into(), json!(items));
|
||||||
|
|
||||||
Ok(Value::Object(object))
|
Ok(Value::Object(object))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 应用类:取出分组节点,再把条目按 `node_key` 分派进去。
|
/// 按条目类型组装列表项。
|
||||||
fn build_groups(conn: &Connection, section: &NodeRow) -> Result<Value, StashError> {
|
fn item_for(entry: &EntryRow, view_mode: &str, tags: &HashMap<String, Vec<String>>) -> Value {
|
||||||
|
match view_mode {
|
||||||
|
"posters" => poster_item(entry, tags.get(&entry.key)),
|
||||||
|
"articles" => article_item(entry, tags.get(&entry.key)),
|
||||||
|
_ => app_item(entry),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取出某个分区下的分组,并把条目按 `node_key` 分派进去。
|
||||||
|
fn build_groups(
|
||||||
|
conn: &Connection,
|
||||||
|
section: &NodeRow,
|
||||||
|
view_mode: &str,
|
||||||
|
tags: &HashMap<String, Vec<String>>,
|
||||||
|
) -> Result<Value, StashError> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT key, title, show_in_sidebar FROM node
|
"SELECT key, title, show_in_sidebar FROM node
|
||||||
@@ -211,15 +236,12 @@ fn build_groups(conn: &Connection, section: &NodeRow) -> Result<Value, StashErro
|
|||||||
|
|
||||||
drop(stmt);
|
drop(stmt);
|
||||||
|
|
||||||
let entries = load_entries(conn, §ion.key)?;
|
|
||||||
let mut rendered_groups = Vec::with_capacity(groups.len());
|
let mut rendered_groups = Vec::with_capacity(groups.len());
|
||||||
|
|
||||||
for (group_key, group_title, show_in_sidebar) in groups {
|
for (group_key, group_title, show_in_sidebar) in groups {
|
||||||
|
let entries = load_node_entries(conn, &group_key)?;
|
||||||
let items = entries
|
let items = entries
|
||||||
.iter()
|
.iter()
|
||||||
// 保持 entry.sort 决定的顺序
|
.map(|entry| item_for(entry, view_mode, tags))
|
||||||
.filter(|entry| entry.node_key == group_key)
|
|
||||||
.map(app_item)
|
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
rendered_groups.push(json!({
|
rendered_groups.push(json!({
|
||||||
@@ -234,32 +256,6 @@ fn build_groups(conn: &Connection, section: &NodeRow) -> Result<Value, StashErro
|
|||||||
Ok(Value::Array(rendered_groups))
|
Ok(Value::Array(rendered_groups))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 海报类:竖版卡片要 `title` / `cover` / `tags`。
|
|
||||||
fn build_poster_items(conn: &Connection, section: &NodeRow) -> Result<Value, StashError> {
|
|
||||||
let entries = load_entries(conn, §ion.key)?;
|
|
||||||
let tags = load_tags(conn, §ion.key)?;
|
|
||||||
|
|
||||||
let items = entries
|
|
||||||
.iter()
|
|
||||||
.map(|entry| poster_item(entry, tags.get(&entry.key)))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
Ok(Value::Array(items))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 图文类:需要 `title` / `desc` / `cover` / `meta` / `body`。
|
|
||||||
fn build_article_items(conn: &Connection, section: &NodeRow) -> Result<Value, StashError> {
|
|
||||||
let entries = load_entries(conn, §ion.key)?;
|
|
||||||
let tags = load_tags(conn, §ion.key)?;
|
|
||||||
|
|
||||||
let items = entries
|
|
||||||
.iter()
|
|
||||||
.map(|entry| article_item(entry, tags.get(&entry.key)))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
Ok(Value::Array(items))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 应用条目:`name` / `desc` / `icon` / `href` 是必填,其余按需下发。
|
/// 应用条目:`name` / `desc` / `icon` / `href` 是必填,其余按需下发。
|
||||||
fn app_item(entry: &EntryRow) -> Value {
|
fn app_item(entry: &EntryRow) -> Value {
|
||||||
let mut object = Map::new();
|
let mut object = Map::new();
|
||||||
@@ -345,36 +341,34 @@ fn insert_optional(object: &mut Map<String, Value>, key: &str, value: &Option<St
|
|||||||
///
|
///
|
||||||
/// 条目只存 `node_key`,所属分区由父节点推导:
|
/// 条目只存 `node_key`,所属分区由父节点推导:
|
||||||
/// 父节点是分组就取它的 `parent_key`,父节点是分区就取它自己。
|
/// 父节点是分组就取它的 `parent_key`,父节点是分区就取它自己。
|
||||||
fn load_entries(conn: &Connection, section_key: &str) -> Result<Vec<EntryRow>, StashError> {
|
fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>, StashError> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT e.key, e.node_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
|
||||||
FROM entry e
|
FROM entry e
|
||||||
JOIN node p ON p.key = e.node_key
|
WHERE e.node_key = ?1
|
||||||
WHERE COALESCE(p.parent_key, p.key) = ?1
|
|
||||||
ORDER BY e.sort, e.key",
|
ORDER BY e.sort, e.key",
|
||||||
)
|
)
|
||||||
.map_err(map_error)?;
|
.map_err(map_error)?;
|
||||||
|
|
||||||
let entries = stmt
|
let entries = stmt
|
||||||
.query_map([section_key], |row| {
|
.query_map([node_key], |row| {
|
||||||
Ok(EntryRow {
|
Ok(EntryRow {
|
||||||
key: row.get(0)?,
|
key: row.get(0)?,
|
||||||
node_key: row.get(1)?,
|
name: row.get(1)?,
|
||||||
name: row.get(2)?,
|
description: row.get(2)?,
|
||||||
description: row.get(3)?,
|
icon: row.get(3)?,
|
||||||
icon: row.get(4)?,
|
href: row.get(4)?,
|
||||||
href: row.get(5)?,
|
cover: row.get(5)?,
|
||||||
cover: row.get(6)?,
|
banner: row.get(6)?,
|
||||||
banner: row.get(7)?,
|
badge: row.get(7)?,
|
||||||
badge: row.get(8)?,
|
comment: row.get(8)?,
|
||||||
comment: row.get(9)?,
|
summary: row.get(9)?,
|
||||||
summary: row.get(10)?,
|
body: row.get(10)?,
|
||||||
body: row.get(11)?,
|
hot: row.get(11)?,
|
||||||
hot: row.get(12)?,
|
views: row.get(12)?,
|
||||||
views: row.get(13)?,
|
likes: row.get(13)?,
|
||||||
likes: row.get(14)?,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(map_error)?
|
.map_err(map_error)?
|
||||||
|
|||||||
+7
-15
@@ -361,22 +361,17 @@ pub fn create_group(conn: &mut Connection, section_id: &str, new: NewGroup) -> R
|
|||||||
return Err(StashError::InvalidDirectory("分组标题需为 1–40 个字".into()));
|
return Err(StashError::InvalidDirectory("分组标题需为 1–40 个字".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let view_mode: Option<String> = conn
|
let section_exists: Option<String> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT view_mode FROM node WHERE key = ?1 AND kind = 'section'",
|
"SELECT key FROM node WHERE key = ?1 AND kind = 'section'",
|
||||||
[section_id],
|
[section_id],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
.optional()
|
.optional()
|
||||||
.map_err(map_error)?;
|
.map_err(map_error)?;
|
||||||
|
|
||||||
let Some(view_mode) = view_mode else {
|
if section_exists.is_none() {
|
||||||
return Err(StashError::NotFound(format!("分区不存在: {section_id}")));
|
return Err(StashError::NotFound(format!("分区不存在: {section_id}")));
|
||||||
};
|
|
||||||
if view_mode != "apps" {
|
|
||||||
return Err(StashError::InvalidDirectory(
|
|
||||||
"只有应用类分区可以添加分组".into(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let exists: i64 = conn
|
let exists: i64 = conn
|
||||||
@@ -712,21 +707,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn groups_only_under_apps_sections() {
|
fn groups_under_any_kind_of_section() {
|
||||||
let mut conn = db();
|
let mut conn = db();
|
||||||
create(&mut conn, section("films", "电影", "posters")).expect("应能创建");
|
create(&mut conn, section("films", "电影", "posters")).expect("应能创建");
|
||||||
assert!(
|
|
||||||
create_group(
|
create_group(
|
||||||
&mut conn,
|
&mut conn,
|
||||||
"films",
|
"films",
|
||||||
NewGroup {
|
NewGroup {
|
||||||
id: "g1".into(),
|
id: "g1".into(),
|
||||||
title: "分组".into()
|
title: "片单".into(),
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
.is_err(),
|
.expect("海报分区也应能建分组");
|
||||||
"海报分区不能建分组"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+17
-21
@@ -223,32 +223,29 @@ pub fn load(conn: &Connection, key: &str) -> Result<Option<EntryRecord>, StashEr
|
|||||||
Ok(Some(entry))
|
Ok(Some(entry))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 条目能挂到哪:非应用分区本身,或应用分区下的分组。
|
/// 条目能挂到哪:分区本身,或分区下的分组(任意类型都支持分组)。
|
||||||
fn validate_target(conn: &Connection, node_key: &str) -> Result<(), StashError> {
|
fn validate_target(conn: &Connection, node_key: &str) -> Result<(), StashError> {
|
||||||
let row: Option<(String, Option<String>, Option<String>)> = conn
|
let row: Option<(String, Option<String>)> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT n.kind, n.view_mode, p.view_mode
|
"SELECT n.kind, p.kind
|
||||||
FROM node n LEFT JOIN node p ON p.key = n.parent_key
|
FROM node n LEFT JOIN node p ON p.key = n.parent_key
|
||||||
WHERE n.key = ?1",
|
WHERE n.key = ?1",
|
||||||
[node_key],
|
[node_key],
|
||||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
)
|
)
|
||||||
.optional()
|
.optional()
|
||||||
.map_err(map_error)?;
|
.map_err(map_error)?;
|
||||||
|
|
||||||
let Some((kind, view_mode, parent_mode)) = row else {
|
let Some((kind, parent_kind)) = row else {
|
||||||
return Err(StashError::NotFound(format!("节点不存在: {node_key}")));
|
return Err(StashError::NotFound(format!("节点不存在: {node_key}")));
|
||||||
};
|
};
|
||||||
|
|
||||||
match kind.as_str() {
|
match (kind.as_str(), parent_kind.as_deref()) {
|
||||||
"group" if parent_mode.as_deref() == Some("apps") => Ok(()),
|
("section", _) => Ok(()),
|
||||||
"group" => Err(StashError::InvalidDirectory(
|
("group", Some("section")) => Ok(()),
|
||||||
"分组只能挂在应用类分区下".into(),
|
("group", _) => Err(StashError::InvalidDirectory(
|
||||||
|
"分组的父节点必须是分区".into(),
|
||||||
)),
|
)),
|
||||||
"section" if view_mode.as_deref() == Some("apps") => Err(StashError::InvalidDirectory(
|
|
||||||
"应用类条目必须选择一个分组".into(),
|
|
||||||
)),
|
|
||||||
"section" => Ok(()),
|
|
||||||
_ => Err(StashError::InvalidDirectory(
|
_ => Err(StashError::InvalidDirectory(
|
||||||
"条目只能挂在分区或分组下".into(),
|
"条目只能挂在分区或分组下".into(),
|
||||||
)),
|
)),
|
||||||
@@ -634,18 +631,17 @@ mod tests {
|
|||||||
.expect("应能查");
|
.expect("应能查");
|
||||||
assert_eq!(node, "g1");
|
assert_eq!(node, "g1");
|
||||||
|
|
||||||
// 应用分区不能直接挂条目,必须先选分组
|
// 任何类型的分区都允许条目直属分区
|
||||||
assert!(
|
let direct = create(
|
||||||
create(
|
|
||||||
&mut conn,
|
&mut conn,
|
||||||
NewEntry {
|
NewEntry {
|
||||||
key: "bad-site".into(),
|
key: "direct-site".into(),
|
||||||
node_key: "apps1".into(),
|
node_key: "apps1".into(),
|
||||||
item: patch("错误"),
|
item: patch("直属条目"),
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
.is_err()
|
.expect("应能创建");
|
||||||
);
|
assert_eq!(direct.key, "direct-site");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export default function App() {
|
|||||||
const detailViewFor = (categoryId: string): View => {
|
const detailViewFor = (categoryId: string): View => {
|
||||||
const category = categories.find((item) => item.id === categoryId)
|
const category = categories.find((item) => item.id === categoryId)
|
||||||
|
|
||||||
if (category?.kind === 'apps' && category.block !== undefined) {
|
if (category?.block !== undefined) {
|
||||||
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
|
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
|
||||||
if (group !== undefined) {
|
if (group !== undefined) {
|
||||||
return { type: 'detail', categoryId, groupId: group.id }
|
return { type: 'detail', categoryId, groupId: group.id }
|
||||||
@@ -189,9 +189,8 @@ export default function App() {
|
|||||||
|
|
||||||
const openDetail = (categoryId: string) => go(detailViewFor(categoryId))
|
const openDetail = (categoryId: string) => go(detailViewFor(categoryId))
|
||||||
|
|
||||||
/** 条目所在的分组:应用按组组织,海报 / 图文直接挂在分区下 */
|
/** 条目所在的分组:应用 / 海报 / 图文都可能有分组 */
|
||||||
const groupOfItem = (category: Category, itemId: string): string | undefined => {
|
const groupOfItem = (category: Category, itemId: string): string | undefined => {
|
||||||
if (category.kind !== 'apps') return undefined
|
|
||||||
return category.groups.find((group) => group.items.some((item) => item.id === itemId))?.id
|
return category.groups.find((group) => group.items.some((item) => item.id === itemId))?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -503,7 +503,7 @@ function SidebarForm({
|
|||||||
|
|
||||||
{ordered.map((category, index) => {
|
{ordered.map((category, index) => {
|
||||||
const visible = !value.hidden.includes(category.id)
|
const visible = !value.hidden.includes(category.id)
|
||||||
const groups = category.kind === 'apps' ? orderedGroups(category) : []
|
const groups = orderedGroups(category)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-nav-block" key={category.id}>
|
<div className="admin-nav-block" key={category.id}>
|
||||||
@@ -539,7 +539,7 @@ function SidebarForm({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{category.kind === 'apps' && (
|
{(
|
||||||
<div className="admin-nav-group-row">
|
<div className="admin-nav-group-row">
|
||||||
<NewGroupRow sectionId={category.id} onReload={onReload} />
|
<NewGroupRow sectionId={category.id} onReload={onReload} />
|
||||||
</div>
|
</div>
|
||||||
@@ -762,12 +762,8 @@ function CategoryManager({
|
|||||||
onReload={onReload}
|
onReload={onReload}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
{category.kind === 'apps' && (
|
|
||||||
<>
|
|
||||||
<BlockSelect section={category} onReload={onReload} />
|
<BlockSelect section={category} onReload={onReload} />
|
||||||
<GroupManager section={category} onReload={onReload} />
|
<GroupManager section={category} onReload={onReload} />
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -1094,20 +1090,21 @@ function ContentManager({
|
|||||||
const entries: EntryRow[] =
|
const entries: EntryRow[] =
|
||||||
section === undefined
|
section === undefined
|
||||||
? []
|
? []
|
||||||
: section.kind === 'apps'
|
: [
|
||||||
? (section.groups ?? []).flatMap((group) =>
|
...(section.groups ?? []).flatMap((group) =>
|
||||||
(group.items ?? []).map((item) => ({
|
(group.items ?? []).map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
groupName: group.name,
|
groupName: group.name,
|
||||||
sectionId: section.id,
|
sectionId: section.id,
|
||||||
groupId: group.id,
|
groupId: group.id,
|
||||||
})),
|
})),
|
||||||
)
|
),
|
||||||
: (section.items ?? []).map((item) => ({
|
...(section.items ?? []).map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
groupName: undefined,
|
groupName: undefined,
|
||||||
sectionId: section.id,
|
sectionId: section.id,
|
||||||
}))
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
const removeEntry = async () => {
|
const removeEntry = async () => {
|
||||||
if (pendingEntry === null) return
|
if (pendingEntry === null) return
|
||||||
@@ -1274,7 +1271,7 @@ function EntryDialog({
|
|||||||
|
|
||||||
const section =
|
const section =
|
||||||
categories.find((category) => category.id === sectionId) ?? categories[0]
|
categories.find((category) => category.id === sectionId) ?? categories[0]
|
||||||
const groups = section?.kind === 'apps' ? (section.groups ?? []) : []
|
const groups = section?.groups ?? []
|
||||||
|
|
||||||
// 打开时重置:新建用当前选择,编辑先拉完整条目
|
// 打开时重置:新建用当前选择,编辑先拉完整条目
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1284,7 +1281,11 @@ function EntryDialog({
|
|||||||
const startSection =
|
const startSection =
|
||||||
defaultLocation?.sectionId ?? categories[0]?.id ?? ''
|
defaultLocation?.sectionId ?? categories[0]?.id ?? ''
|
||||||
setSectionId(startSection)
|
setSectionId(startSection)
|
||||||
setGroupId(defaultLocation?.groupId ?? '')
|
setGroupId(
|
||||||
|
defaultLocation?.groupId ??
|
||||||
|
categories.find((category) => category.id === startSection)?.groups?.[0]?.id ??
|
||||||
|
'',
|
||||||
|
)
|
||||||
|
|
||||||
if (entryKey === null) {
|
if (entryKey === null) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -1321,19 +1322,16 @@ function EntryDialog({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, entryKey])
|
}, [open, entryKey])
|
||||||
|
|
||||||
// 分区切换后保证分组仍然合法
|
// 分区切换后保证分组仍然合法(空字符串 = 直属分区)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (section?.kind !== 'apps') {
|
if (section === undefined) return
|
||||||
setGroupId('')
|
if (groupId !== '' && !groups.some((group) => group.id === groupId)) {
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!groups.some((group) => group.id === groupId)) {
|
|
||||||
setGroupId(groups[0]?.id ?? '')
|
setGroupId(groups[0]?.id ?? '')
|
||||||
}
|
}
|
||||||
}, [section, groups, groupId])
|
}, [section, groups, groupId])
|
||||||
|
|
||||||
const nodeKey =
|
const nodeKey =
|
||||||
section === undefined ? '' : section.kind === 'apps' ? groupId : section.id
|
section === undefined ? '' : groupId !== '' ? groupId : section.id
|
||||||
|
|
||||||
const update = (patch: Partial<EntryForm>) =>
|
const update = (patch: Partial<EntryForm>) =>
|
||||||
setForm((previous) => ({ ...previous, ...patch }))
|
setForm((previous) => ({ ...previous, ...patch }))
|
||||||
@@ -1430,13 +1428,16 @@ function EntryDialog({
|
|||||||
onChange={setSectionId}
|
onChange={setSectionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{section?.kind === 'apps' && (
|
{section !== undefined && (
|
||||||
<div style={{ width: 220 }}>
|
<div style={{ width: 240 }}>
|
||||||
<Select
|
<Select
|
||||||
options={groups.map((group) => ({
|
options={[
|
||||||
|
{ value: '', label: '直属分区(不分组)' },
|
||||||
|
...groups.map((group) => ({
|
||||||
value: group.id,
|
value: group.id,
|
||||||
label: group.name,
|
label: group.name,
|
||||||
}))}
|
})),
|
||||||
|
]}
|
||||||
value={groupId}
|
value={groupId}
|
||||||
onChange={setGroupId}
|
onChange={setGroupId}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { Breadcrumb, Card, Empty, Space, Title } from '@stash/ui'
|
import { Breadcrumb, Card, Empty, Space, Title } from '@stash/ui'
|
||||||
import { categoryCount, type Category } from '../../data/types'
|
import {
|
||||||
|
categoryCount,
|
||||||
|
type AppItem,
|
||||||
|
type ArticleItem,
|
||||||
|
type Category,
|
||||||
|
type PosterItem,
|
||||||
|
} from '../../data/types'
|
||||||
import { viewPath } from '../../data/view'
|
import { viewPath } from '../../data/view'
|
||||||
import { AppGrid } from './AppGrid'
|
import { AppGrid } from './AppGrid'
|
||||||
import { CesCardGrid } from './CesCardGrid'
|
import { CesCardGrid } from './CesCardGrid'
|
||||||
@@ -25,7 +31,13 @@ type DetailViewProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DetailView({ category, groupId, onNavigate, onOpenItem }: DetailViewProps) {
|
export function DetailView({ category, groupId, onNavigate, onOpenItem }: DetailViewProps) {
|
||||||
const groups = category.kind === 'apps' ? category.groups : []
|
// 三种类型共用「分区 → 分组 → 条目」结构;这里抹平联合类型,渲染时再按 kind 分派
|
||||||
|
type RenderItem = AppItem | PosterItem | ArticleItem
|
||||||
|
const groups = category.groups as {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
items: RenderItem[]
|
||||||
|
}[]
|
||||||
const activeGroup =
|
const activeGroup =
|
||||||
groupId !== undefined ? groups.find((group) => group.id === groupId) : undefined
|
groupId !== undefined ? groups.find((group) => group.id === groupId) : undefined
|
||||||
|
|
||||||
@@ -43,32 +55,48 @@ export function DetailView({ category, groupId, onNavigate, onOpenItem }: Detail
|
|||||||
{ label: category.title, href: viewPath({ type: 'detail', categoryId: category.id }) },
|
{ label: category.title, href: viewPath({ type: 'detail', categoryId: category.id }) },
|
||||||
]
|
]
|
||||||
|
|
||||||
function renderBody() {
|
/** 按类型渲染一组条目 */
|
||||||
|
function renderItems(items: RenderItem[]) {
|
||||||
if (category.kind === 'posters') {
|
if (category.kind === 'posters') {
|
||||||
return <PosterGrid items={category.items} variant="detail" onOpen={onOpenItem} />
|
return <PosterGrid items={items as PosterItem[]} variant="detail" onOpen={onOpenItem} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (category.kind === 'articles') {
|
if (category.kind === 'articles') {
|
||||||
return <CesCardGrid items={category.items} onOpen={onOpenItem} />
|
return <CesCardGrid items={items as ArticleItem[]} onOpen={onOpenItem} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return <AppGrid items={items as AppItem[]} variant="detail" onOpen={onOpenItem} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBody() {
|
||||||
if (activeGroup !== undefined) {
|
if (activeGroup !== undefined) {
|
||||||
return <AppGrid items={activeGroup.items} variant="detail" onOpen={onOpenItem} />
|
return renderItems(activeGroup.items)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (category.groups.length === 1) {
|
const direct = (category.items ?? []) as RenderItem[]
|
||||||
return <AppGrid items={category.groups[0].items} variant="detail" onOpen={onOpenItem} />
|
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return direct.length > 0 ? (
|
||||||
|
renderItems(direct)
|
||||||
|
) : (
|
||||||
|
<Empty text={'emptyText' in category ? (category.emptyText ?? '暂无内容') : '暂无内容'} />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (category.groups.length === 0) {
|
if (groups.length === 1 && direct.length === 0) {
|
||||||
return <Empty text={category.emptyText ?? '暂无内容'} />
|
return renderItems(groups[0].items)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space direction="column" gap="lg" align="stretch">
|
<Space direction="column" gap="lg" align="stretch">
|
||||||
{category.groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<GroupBlock key={group.id} group={group} onOpen={onOpenItem} />
|
<GroupBlock key={group.id} name={group.name}>
|
||||||
|
{renderItems(group.items)}
|
||||||
|
</GroupBlock>
|
||||||
))}
|
))}
|
||||||
|
{direct.length > 0 && (
|
||||||
|
<GroupBlock name="未分组">{renderItems(direct)}</GroupBlock>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
import { Space, Title } from '@stash/ui'
|
import { Space, Title } from '@stash/ui'
|
||||||
import type { AppGroup } from '../../data/types'
|
import type { ReactNode } from 'react'
|
||||||
import { AppGrid } from './AppGrid'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 明细页的分组块:小标题 + 该组的应用网格。
|
* 明细页的分组块:小标题 + 该组的内容网格。
|
||||||
* 分类被拆成多个分组时重复使用,单个分组时明细页不渲染标题。
|
*
|
||||||
|
* 具体网格由调用方传入(应用 / 海报 / 图文共用同一层外壳),
|
||||||
|
* 这样三种类型的「分组」渲染方式保持一致。
|
||||||
*/
|
*/
|
||||||
type GroupBlockProps = {
|
type GroupBlockProps = {
|
||||||
group: AppGroup
|
name: string
|
||||||
onOpen?: (itemId: string) => void
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GroupBlock({ group, onOpen }: GroupBlockProps) {
|
export function GroupBlock({ name, children }: GroupBlockProps) {
|
||||||
return (
|
return (
|
||||||
<Space direction="column" gap="sm" align="stretch" className="group-block">
|
<Space direction="column" gap="sm" align="stretch" className="group-block">
|
||||||
<Title level={4} as="h3">
|
<Title level={4} as="h3">
|
||||||
{group.name}
|
{name}
|
||||||
</Title>
|
</Title>
|
||||||
<AppGrid items={group.items} variant="detail" onOpen={onOpen} />
|
{children}
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function HomeView({ categories, sections, onOpenDetail, onOpenItem }: Hom
|
|||||||
* 否则(电影 / 文章 / 本地服务)数整个分区。
|
* 否则(电影 / 文章 / 本地服务)数整个分区。
|
||||||
*/
|
*/
|
||||||
function cardCount(category: Category): number {
|
function cardCount(category: Category): number {
|
||||||
if (category.kind === 'apps' && category.block !== undefined) {
|
if (category.block !== undefined) {
|
||||||
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
|
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
|
||||||
if (group !== undefined) return group.items.length
|
if (group !== undefined) return group.items.length
|
||||||
}
|
}
|
||||||
@@ -65,6 +65,12 @@ function cardCount(category: Category): number {
|
|||||||
return categoryCount(category)
|
return categoryCount(category)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 某个分区配的「首页预览分组」;没配或找不到返回 undefined */
|
||||||
|
function blockGroup<T>(category: { block?: { nodeKey?: string }; groups: { id: string; items: T[] }[] }) {
|
||||||
|
if (category.block?.nodeKey === undefined) return undefined
|
||||||
|
return category.groups.find((group) => group.id === category.block?.nodeKey)
|
||||||
|
}
|
||||||
|
|
||||||
/** 按分类类型输出对应的预览网格;应用类为空时给出提示文案 */
|
/** 按分类类型输出对应的预览网格;应用类为空时给出提示文案 */
|
||||||
function renderPreview(
|
function renderPreview(
|
||||||
category: Category,
|
category: Category,
|
||||||
@@ -72,13 +78,9 @@ function renderPreview(
|
|||||||
onOpenItem: (categoryId: string, itemId: string) => void,
|
onOpenItem: (categoryId: string, itemId: string) => void,
|
||||||
) {
|
) {
|
||||||
if (category.kind === 'apps') {
|
if (category.kind === 'apps') {
|
||||||
// 配了展示块就只展示那个分组;没有配置(如「本地服务」)则退回全部条目
|
// 配了展示块且指向分组时只展示该组;展示块指向分区本身(「整个分区」)时展示全部条目
|
||||||
const blockGroup =
|
const group = blockGroup(category)
|
||||||
category.block !== undefined
|
const source = group !== undefined ? group.items : flattenAppItems(category)
|
||||||
? category.groups.find((group) => group.id === category.block?.nodeKey)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const source = category.block !== undefined ? (blockGroup?.items ?? []) : flattenAppItems(category)
|
|
||||||
const fallback = category.block?.previewLimit ?? category.previewLimit ?? 4
|
const fallback = category.block?.previewLimit ?? category.previewLimit ?? 4
|
||||||
const limit = sectionLimit(category.id, fallback, sections)
|
const limit = sectionLimit(category.id, fallback, sections)
|
||||||
const items = source.slice(0, limit)
|
const items = source.slice(0, limit)
|
||||||
@@ -91,17 +93,25 @@ function renderPreview(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (category.kind === 'posters') {
|
if (category.kind === 'posters') {
|
||||||
|
const group = blockGroup(category)
|
||||||
|
const source = group
|
||||||
|
? group.items
|
||||||
|
: [...category.items, ...category.groups.flatMap((item) => item.items)]
|
||||||
return (
|
return (
|
||||||
<PosterGrid
|
<PosterGrid
|
||||||
items={category.items.slice(0, sectionLimit(category.id, category.previewLimit ?? 6, sections))}
|
items={source.slice(0, sectionLimit(category.id, category.previewLimit ?? 6, sections))}
|
||||||
onOpen={(itemId) => onOpenItem(category.id, itemId)}
|
onOpen={(itemId) => onOpenItem(category.id, itemId)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const group = blockGroup(category)
|
||||||
|
const source = group
|
||||||
|
? group.items
|
||||||
|
: [...category.items, ...category.groups.flatMap((item) => item.items)]
|
||||||
return (
|
return (
|
||||||
<ArticleGrid
|
<ArticleGrid
|
||||||
items={category.items.slice(0, sectionLimit(category.id, category.previewLimit ?? 4, sections))}
|
items={source.slice(0, sectionLimit(category.id, category.previewLimit ?? 4, sections))}
|
||||||
onOpen={(itemId) => onOpenItem(category.id, itemId)}
|
onOpen={(itemId) => onOpenItem(category.id, itemId)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,8 +38,9 @@ export function CategoryNav({
|
|||||||
const items: MenuItem[] = [
|
const items: MenuItem[] = [
|
||||||
{ key: HOME_KEY, label: '首页' },
|
{ key: HOME_KEY, label: '首页' },
|
||||||
...categories.map((category) => {
|
...categories.map((category) => {
|
||||||
// 分组已由 App 按 /admin 的侧栏设置过滤、排序(含 show_in_sidebar 覆盖)
|
// 分组已由 App 按 /admin 的侧栏设置过滤、排序(含 show_in_sidebar 覆盖);
|
||||||
const groups = category.kind === 'apps' ? category.groups : []
|
// 应用 / 海报 / 图文都支持分组
|
||||||
|
const groups = category.groups
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: categoryKey(category.id),
|
key: categoryKey(category.id),
|
||||||
|
|||||||
+42
-12
@@ -31,14 +31,16 @@ export type AppItem = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 应用分组:明细页按组展示,首页按展示块取其中一组做预览 */
|
/** 应用分组:明细页按组展示,首页按展示块取其中一组做预览 */
|
||||||
export type AppGroup = {
|
export type ContentGroup<T> = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
/** 是否出现在侧栏;缺省视为 true */
|
/** 是否出现在侧栏;缺省视为 true */
|
||||||
showInSidebar?: boolean
|
showInSidebar?: boolean
|
||||||
items: AppItem[]
|
items: T[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AppGroup = ContentGroup<AppItem>
|
||||||
|
|
||||||
/** 竖版海报条目(电影) */
|
/** 竖版海报条目(电影) */
|
||||||
export type PosterItem = {
|
export type PosterItem = {
|
||||||
id: string
|
id: string
|
||||||
@@ -97,6 +99,8 @@ export type AppCategory = {
|
|||||||
/** 明细页 hero 区的字符图标 */
|
/** 明细页 hero 区的字符图标 */
|
||||||
icon: string
|
icon: string
|
||||||
groups: AppGroup[]
|
groups: AppGroup[]
|
||||||
|
/** 直接挂在分区上的条目(未分组) */
|
||||||
|
items?: AppItem[]
|
||||||
/** 首页预览条数,默认 4 */
|
/** 首页预览条数,默认 4 */
|
||||||
previewLimit?: number
|
previewLimit?: number
|
||||||
/** 首页预览为空时的提示文案 */
|
/** 首页预览为空时的提示文案 */
|
||||||
@@ -114,6 +118,8 @@ export type PosterCategory = {
|
|||||||
title: string
|
title: string
|
||||||
barColor: string
|
barColor: string
|
||||||
icon: string
|
icon: string
|
||||||
|
groups: ContentGroup<PosterItem>[]
|
||||||
|
/** 直接挂在分区上的条目(未分组) */
|
||||||
items: PosterItem[]
|
items: PosterItem[]
|
||||||
/** 首页预览条数,默认 6 */
|
/** 首页预览条数,默认 6 */
|
||||||
previewLimit?: number
|
previewLimit?: number
|
||||||
@@ -130,6 +136,8 @@ export type ArticleCategory = {
|
|||||||
title: string
|
title: string
|
||||||
barColor: string
|
barColor: string
|
||||||
icon: string
|
icon: string
|
||||||
|
groups: ContentGroup<ArticleItem>[]
|
||||||
|
/** 直接挂在分区上的条目(未分组) */
|
||||||
items: ArticleItem[]
|
items: ArticleItem[]
|
||||||
/** 首页预览条数,默认 4 */
|
/** 首页预览条数,默认 4 */
|
||||||
previewLimit?: number
|
previewLimit?: number
|
||||||
@@ -141,23 +149,28 @@ export type ArticleCategory = {
|
|||||||
|
|
||||||
export type Category = AppCategory | PosterCategory | ArticleCategory
|
export type Category = AppCategory | PosterCategory | ArticleCategory
|
||||||
|
|
||||||
/** 分类内的条目总数,用于角标与明细页计数 */
|
/** 分类内的条目总数(直属 + 分组的),用于角标与明细页计数 */
|
||||||
export function categoryCount(category: Category): number {
|
export function categoryCount(category: Category): number {
|
||||||
if (category.kind === 'apps') {
|
const direct = category.items?.length ?? 0
|
||||||
return category.groups.reduce((total, group) => total + group.items.length, 0)
|
const grouped = category.groups.reduce((total, group) => total + group.items.length, 0)
|
||||||
}
|
return direct + grouped
|
||||||
return category.items.length
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把应用类分类的分组拍平,首页预览用 */
|
/** 把应用类分类的条目拍平(分组 + 直属),首页预览用 */
|
||||||
export function flattenAppItems(category: AppCategory): AppItem[] {
|
export function flattenAppItems(category: AppCategory): AppItem[] {
|
||||||
return category.groups.flatMap((group) => group.items)
|
return [
|
||||||
|
...category.groups.flatMap((group) => group.items),
|
||||||
|
...(category.items ?? []),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按 id 在分类里查找条目;详情页用 */
|
/** 按 id 在分类里查找条目;详情页用 */
|
||||||
export function findAppItem(category: Category, itemId: string): AppItem | undefined {
|
export function findAppItem(category: Category, itemId: string): AppItem | undefined {
|
||||||
if (category.kind !== 'apps') return undefined
|
if (category.kind !== 'apps') return undefined
|
||||||
|
|
||||||
|
const direct = (category.items ?? []).find((item) => item.id === itemId)
|
||||||
|
if (direct !== undefined) return direct
|
||||||
|
|
||||||
for (const group of category.groups) {
|
for (const group of category.groups) {
|
||||||
const found = group.items.find((item) => item.id === itemId)
|
const found = group.items.find((item) => item.id === itemId)
|
||||||
if (found !== undefined) return found
|
if (found !== undefined) return found
|
||||||
@@ -184,7 +197,13 @@ export function findPosterItem(
|
|||||||
itemId: string,
|
itemId: string,
|
||||||
): PosterItem | undefined {
|
): PosterItem | undefined {
|
||||||
if (category.kind !== 'posters') return undefined
|
if (category.kind !== 'posters') return undefined
|
||||||
return category.items.find((item) => item.id === itemId)
|
const direct = category.items.find((item) => item.id === itemId)
|
||||||
|
if (direct !== undefined) return direct
|
||||||
|
for (const group of category.groups) {
|
||||||
|
const found = group.items.find((item) => item.id === itemId)
|
||||||
|
if (found !== undefined) return found
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取同分类下的其他海报,用于详情页的「推荐阅读」 */
|
/** 取同分类下的其他海报,用于详情页的「推荐阅读」 */
|
||||||
@@ -195,7 +214,12 @@ export function relatedPosterItems(
|
|||||||
): PosterItem[] {
|
): PosterItem[] {
|
||||||
if (category.kind !== 'posters') return []
|
if (category.kind !== 'posters') return []
|
||||||
|
|
||||||
return category.items.filter((item) => item.id !== excludeId).slice(0, limit)
|
return [
|
||||||
|
...category.items,
|
||||||
|
...category.groups.flatMap((group) => group.items),
|
||||||
|
]
|
||||||
|
.filter((item) => item.id !== excludeId)
|
||||||
|
.slice(0, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按 id 查找图文条目 */
|
/** 按 id 查找图文条目 */
|
||||||
@@ -204,5 +228,11 @@ export function findArticleItem(
|
|||||||
itemId: string,
|
itemId: string,
|
||||||
): ArticleItem | undefined {
|
): ArticleItem | undefined {
|
||||||
if (category.kind !== 'articles') return undefined
|
if (category.kind !== 'articles') return undefined
|
||||||
return category.items.find((item) => item.id === itemId)
|
const direct = category.items.find((item) => item.id === itemId)
|
||||||
|
if (direct !== undefined) return direct
|
||||||
|
for (const group of category.groups) {
|
||||||
|
const found = group.items.find((item) => item.id === itemId)
|
||||||
|
if (found !== undefined) return found
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-11
@@ -175,19 +175,34 @@ export function applySidebarGroups(
|
|||||||
categories: Category[],
|
categories: Category[],
|
||||||
sidebar: SiteSettings['sidebar'],
|
sidebar: SiteSettings['sidebar'],
|
||||||
): Category[] {
|
): Category[] {
|
||||||
|
return categories.map((category) => {
|
||||||
|
if (category.kind === 'apps') {
|
||||||
|
return { ...category, groups: filterGroups(category.groups, category.id, sidebar) }
|
||||||
|
}
|
||||||
|
if (category.kind === 'posters') {
|
||||||
|
return { ...category, groups: filterGroups(category.groups, category.id, sidebar) }
|
||||||
|
}
|
||||||
|
return { ...category, groups: filterGroups(category.groups, category.id, sidebar) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按设置过滤、排序某个分区下的分组 */
|
||||||
|
function filterGroups<T extends { id: string }>(
|
||||||
|
groups: T[],
|
||||||
|
categoryId: string,
|
||||||
|
sidebar: SiteSettings['sidebar'],
|
||||||
|
): T[] {
|
||||||
const hidden = new Set(sidebar.hidden_groups)
|
const hidden = new Set(sidebar.hidden_groups)
|
||||||
const shown = new Set(sidebar.shown_groups)
|
const shown = new Set(sidebar.shown_groups)
|
||||||
|
|
||||||
return categories.map((category) => {
|
|
||||||
if (category.kind !== 'apps') return category
|
|
||||||
|
|
||||||
const ranks = new Map(
|
const ranks = new Map(
|
||||||
(sidebar.group_order[category.id] ?? []).map((id, index) => [id, index] as const),
|
(sidebar.group_order[categoryId] ?? []).map((id, index) => [id, index] as const),
|
||||||
)
|
)
|
||||||
const groups = category.groups
|
|
||||||
|
return groups
|
||||||
.filter((group) => {
|
.filter((group) => {
|
||||||
const key = `${category.id}/${group.id}`
|
const key = `${categoryId}/${group.id}`
|
||||||
return !hidden.has(key) && (group.showInSidebar !== false || shown.has(key))
|
const showInSidebar = (group as { showInSidebar?: boolean }).showInSidebar
|
||||||
|
return !hidden.has(key) && (showInSidebar !== false || shown.has(key))
|
||||||
})
|
})
|
||||||
.map((group, index) => ({
|
.map((group, index) => ({
|
||||||
group,
|
group,
|
||||||
@@ -196,9 +211,6 @@ export function applySidebarGroups(
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank))
|
.sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank))
|
||||||
.map((entry) => entry.group)
|
.map((entry) => entry.group)
|
||||||
|
|
||||||
return { ...category, groups }
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 卡片标题:设置里的覆盖值优先 */
|
/** 卡片标题:设置里的覆盖值优先 */
|
||||||
|
|||||||
Reference in New Issue
Block a user