refactor: 清理遗留设置模型与无用代码
- 删除 settings 的 sections 与 sidebar 六个旧排序/显隐字段,前后端类型与校验同步 - 删除旧分类模型 types、旧 /api/catalog 客户端、旧路由解析与 settings CRUD - 删除未使用的 AppProject::initial - 删除 27 条已无组件的 CSS 规则(159 行) - 保持现有首页/接口页渲染,cargo test 52 passed
This commit is contained in:
+9
-9
@@ -77,22 +77,22 @@ src/
|
|||||||
error.rs StashError(含 Conflict → 409)
|
error.rs StashError(含 Conflict → 409)
|
||||||
db/
|
db/
|
||||||
mod.rs Db 门面(所有方法)+ 迁移测试
|
mod.rs Db 门面(所有方法)+ 迁移测试
|
||||||
schema.rs 建表、种子、版本迁移(0/15→16→17)
|
schema.rs 建表、种子、版本迁移(0 / 15→…→23)
|
||||||
auth.rs 管理员、密码哈希、会话(token 只存 sha256)
|
auth.rs 管理员、密码哈希、会话(token 只存 sha256)
|
||||||
settings.rs settings JSON(header/sidebar/sections/profile)
|
settings.rs settings JSON(header/sidebar/profile/site)
|
||||||
entries.rs 条目读取/编辑、浏览数、点赞、审计
|
entries.rs 条目读取/编辑、浏览数、点赞、审计
|
||||||
categories.rs 分区新增/删除
|
groups.rs 分组树新增/移动/排序/删除
|
||||||
catalog.rs 查询组装(前端三种视图)
|
cards.rs 首页卡片与勾选分组
|
||||||
|
catalog.rs 分组树 + 条目查询组装
|
||||||
stash-web/src/
|
stash-web/src/
|
||||||
App.tsx 主页壳、登录弹窗、编辑器、设置应用
|
AppNext.tsx 前台壳(路由、卡片、侧栏、搜索)
|
||||||
main.tsx 入口分流(/admin 与 /login)
|
main.tsx 入口分流(/admin 与 /login)
|
||||||
auth.ts / settings.ts / items.ts 前端 API 客户端
|
api.ts / auth.ts / settings.ts / items.ts / manage.ts 前端 API 客户端
|
||||||
components/
|
components/
|
||||||
header/SiteHeader.tsx 品牌 + 滚动信息 + 操作图标
|
header/SiteHeader.tsx 品牌 + 滚动信息 + 操作图标
|
||||||
admin/AdminPage.tsx /admin 全部 Tab
|
admin/AdminNext.tsx /admin 全部 Tab
|
||||||
auth/LoginDialog.tsx Modal 登录
|
auth/LoginDialog.tsx Modal 登录
|
||||||
auth/PasswordInput.tsx 带显隐切换的密码框
|
content/ 首页、条目、文章、编辑器
|
||||||
content/ 首页、分区、条目、文章、编辑器
|
|
||||||
sidebar/ 资料卡、服务面板、导航
|
sidebar/ 资料卡、服务面板、导航
|
||||||
docs/ADMIN-PLAN.md 后台方案 + P0–P4 实施记录 + 未做项
|
docs/ADMIN-PLAN.md 后台方案 + P0–P4 实施记录 + 未做项
|
||||||
README.md 运行、路由、库结构、登录说明
|
README.md 运行、路由、库结构、登录说明
|
||||||
|
|||||||
@@ -92,13 +92,6 @@ impl Default for SidebarSettings {
|
|||||||
show_profile: true,
|
show_profile: true,
|
||||||
show_service: true,
|
show_service: true,
|
||||||
show_nav: true,
|
show_nav: true,
|
||||||
hidden: Vec::new(),
|
|
||||||
order: Vec::new(),
|
|
||||||
hidden_groups: Vec::new(),
|
|
||||||
shown_groups: Vec::new(),
|
|
||||||
group_order: std::collections::HashMap::new(),
|
|
||||||
tree_hidden: Vec::new(),
|
|
||||||
tree_order: std::collections::HashMap::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,33 +108,6 @@ pub struct SidebarSettings {
|
|||||||
/// 是否显示「分组导航」卡片(首页 / 分组树)
|
/// 是否显示「分组导航」卡片(首页 / 分组树)
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub show_nav: bool,
|
pub show_nav: bool,
|
||||||
/// 隐藏的分类 id
|
|
||||||
pub hidden: Vec<String>,
|
|
||||||
/// 分类显示顺序(id);未列出的排在后面,保持原有顺序
|
|
||||||
pub order: Vec<String>,
|
|
||||||
/// 隐藏的分组,键为 `分区id/分组id`
|
|
||||||
pub hidden_groups: Vec<String>,
|
|
||||||
/// 强制显示的分组(覆盖数据库里的 show_in_sidebar=0)
|
|
||||||
pub shown_groups: Vec<String>,
|
|
||||||
/// 每个分区下分组的顺序:分区 id → 分组 id 列表
|
|
||||||
pub group_order: std::collections::HashMap<String, Vec<String>>,
|
|
||||||
/// 新模型:侧栏隐藏的分组 key(任意层级)
|
|
||||||
pub tree_hidden: Vec<String>,
|
|
||||||
/// 新模型:每级分组的顺序,键是父分组 key(根级用空串)
|
|
||||||
pub tree_order: std::collections::HashMap<String, Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
||||||
#[serde(default)]
|
|
||||||
pub struct SectionsSettings {
|
|
||||||
/// 隐藏的首页卡片(分类 id)
|
|
||||||
pub hidden: Vec<String>,
|
|
||||||
/// 卡片顺序(分类 id)
|
|
||||||
pub order: Vec<String>,
|
|
||||||
/// 标题覆盖:id → 标题
|
|
||||||
pub titles: std::collections::HashMap<String, String>,
|
|
||||||
/// 预览条数覆盖:id → 条数
|
|
||||||
pub limits: std::collections::HashMap<String, u32>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -254,7 +220,6 @@ impl Default for SiteMetaSettings {
|
|||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub header: HeaderSettings,
|
pub header: HeaderSettings,
|
||||||
pub sidebar: SidebarSettings,
|
pub sidebar: SidebarSettings,
|
||||||
pub sections: SectionsSettings,
|
|
||||||
pub profile: ProfileSettings,
|
pub profile: ProfileSettings,
|
||||||
pub site: SiteMetaSettings,
|
pub site: SiteMetaSettings,
|
||||||
}
|
}
|
||||||
@@ -298,33 +263,6 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if settings.sidebar.hidden.len() > 100 || settings.sidebar.order.len() > 100 {
|
|
||||||
return Err(StashError::InvalidDirectory("侧栏配置过长".into()));
|
|
||||||
}
|
|
||||||
if settings.sidebar.hidden_groups.len() > 200
|
|
||||||
|| settings.sidebar.shown_groups.len() > 200
|
|
||||||
|| settings.sidebar.group_order.len() > 100
|
|
||||||
|| settings.sidebar.tree_hidden.len() > 500
|
|
||||||
|| settings.sidebar.tree_order.len() > 200
|
|
||||||
{
|
|
||||||
return Err(StashError::InvalidDirectory("侧栏分组配置过长".into()));
|
|
||||||
}
|
|
||||||
if settings.sections.hidden.len() > 100 || settings.sections.order.len() > 100 {
|
|
||||||
return Err(StashError::InvalidDirectory("首页卡片配置过长".into()));
|
|
||||||
}
|
|
||||||
if settings.sections.titles.len() > 100 || settings.sections.limits.len() > 100 {
|
|
||||||
return Err(StashError::InvalidDirectory("首页卡片配置过长".into()));
|
|
||||||
}
|
|
||||||
for title in settings.sections.titles.values() {
|
|
||||||
if title.chars().count() > 40 {
|
|
||||||
return Err(StashError::InvalidDirectory("卡片标题最多 40 个字".into()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for limit in settings.sections.limits.values() {
|
|
||||||
if *limit > 60 {
|
|
||||||
return Err(StashError::InvalidDirectory("卡片条数最多 60".into()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if settings.profile.name.chars().count() > 40 {
|
if settings.profile.name.chars().count() > 40 {
|
||||||
return Err(StashError::InvalidDirectory("名称最多 40 个字".into()));
|
return Err(StashError::InvalidDirectory("名称最多 40 个字".into()));
|
||||||
}
|
}
|
||||||
@@ -457,7 +395,6 @@ mod tests {
|
|||||||
let settings = Settings::default();
|
let settings = Settings::default();
|
||||||
assert!(settings.header.enabled);
|
assert!(settings.header.enabled);
|
||||||
assert_eq!(settings.header.items.len(), 3);
|
assert_eq!(settings.header.items.len(), 3);
|
||||||
assert!(settings.sections.hidden.is_empty());
|
|
||||||
assert!(validate(&settings).is_ok());
|
assert!(validate(&settings).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,17 +7,6 @@ pub struct AppProject {
|
|||||||
pub route_path: String,
|
pub route_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppProject {
|
|
||||||
pub fn initial(&self) -> String {
|
|
||||||
self.name
|
|
||||||
.chars()
|
|
||||||
.next()
|
|
||||||
.unwrap_or('A')
|
|
||||||
.to_uppercase()
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn scan_projects(root: &Path) -> Vec<AppProject> {
|
pub fn scan_projects(root: &Path) -> Vec<AppProject> {
|
||||||
let mut projects = Vec::new();
|
let mut projects = Vec::new();
|
||||||
|
|
||||||
|
|||||||
@@ -478,18 +478,6 @@
|
|||||||
color: var(--su-color-primary);
|
color: var(--su-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-cover--square {
|
|
||||||
width: 96px;
|
|
||||||
height: 96px;
|
|
||||||
border-radius: var(--su-radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-cover--portrait {
|
|
||||||
width: 179px;
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
border-radius: var(--su-radius-cover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-cover img {
|
.item-cover img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -803,21 +791,6 @@
|
|||||||
gap: var(--su-space-2);
|
gap: var(--su-space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.admin-nav-row {
|
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-section-row {
|
|
||||||
grid-template-columns: auto minmax(0, 1fr) 96px auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-item-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-item-row .su-switch {
|
.admin-item-row .su-switch {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
}
|
}
|
||||||
@@ -840,10 +813,6 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-info {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-toolbar {
|
.search-toolbar {
|
||||||
padding: var(--su-space-5) 0 var(--su-space-6);
|
padding: var(--su-space-5) 0 var(--su-space-6);
|
||||||
}
|
}
|
||||||
@@ -904,69 +873,20 @@
|
|||||||
margin-bottom: var(--su-space-5);
|
margin-bottom: var(--su-space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-create {
|
|
||||||
margin-top: var(--su-space-4);
|
|
||||||
padding-top: var(--su-space-4);
|
|
||||||
border-top: 1px dashed var(--su-border-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-create-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 160px auto;
|
|
||||||
gap: var(--su-space-2);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Select 自身有 160px 最小宽度,网格项必须允许收缩,否则会盖住「新增」按钮 */
|
/* Select 自身有 160px 最小宽度,网格项必须允许收缩,否则会盖住「新增」按钮 */
|
||||||
.admin-create-row > * {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 侧栏里的子分类(分组):缩进一层,弱化文字 */
|
/* 侧栏里的子分类(分组):缩进一层,弱化文字 */
|
||||||
.admin-nav-group-row {
|
|
||||||
padding-left: var(--su-space-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* 归类页:一行一个分区,标题 / 类型 / 图标 / 色条 / 条数 / 保存 / 删除 */
|
/* 归类页:一行一个分区,标题 / 类型 / 图标 / 色条 / 条数 / 保存 / 删除 */
|
||||||
.admin-category-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1.4fr) 150px 80px 110px 90px auto auto auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--su-space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--su-space-2);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 新后台:分组树行 / 新建行 / 卡片头,用弹性布局避免网格列数对不上 */
|
/* 新后台:分组树行 / 新建行 / 卡片头,用弹性布局避免网格列数对不上 */
|
||||||
.admin-node-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--su-space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-node-title {
|
.admin-node-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-node-format {
|
|
||||||
width: 170px;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-new-node-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--su-space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 分组管理:参考后台的树形表格 */
|
/* 分组管理:参考后台的树形表格 */
|
||||||
.group-toolbar {
|
.group-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1098,14 +1018,6 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-format {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1243,10 +1155,6 @@ a.entry-href:hover {
|
|||||||
|
|
||||||
/* 侧栏到内容区共用一条滚动虚化遮罩,同一水平高度 */
|
/* 侧栏到内容区共用一条滚动虚化遮罩,同一水平高度 */
|
||||||
|
|
||||||
.sidebar-fade.is-visible {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 左侧卡片半透明,露出页面背景 */
|
/* 左侧卡片半透明,露出页面背景 */
|
||||||
.sidebar-home .profile-card,
|
.sidebar-home .profile-card,
|
||||||
.sidebar-home .service-panel,
|
.sidebar-home .service-panel,
|
||||||
@@ -1261,11 +1169,6 @@ a.entry-href:hover {
|
|||||||
border-radius: var(--su-radius-md);
|
border-radius: var(--su-radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-divider {
|
|
||||||
margin: var(--su-space-2) 0;
|
|
||||||
border-top: 1px dashed var(--su-border-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 首页卡片:左侧卡片列表 + 右侧分组表格 */
|
/* 首页卡片:左侧卡片列表 + 右侧分组表格 */
|
||||||
.cards-manager {
|
.cards-manager {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1342,16 +1245,6 @@ a.entry-href:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 分组管理里状态为「关闭」的分组:卡片里只能看、不能勾选 */
|
/* 分组管理里状态为「关闭」的分组:卡片里只能看、不能勾选 */
|
||||||
.group-off-tag {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0 var(--su-space-2);
|
|
||||||
border-radius: var(--su-radius-pill);
|
|
||||||
background-color: var(--su-gray-2);
|
|
||||||
color: var(--su-text-muted);
|
|
||||||
font-size: var(--su-font-size-xs);
|
|
||||||
line-height: var(--su-line-height-sm);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 卡片表格的状态列:启用/已停用同一个胶囊,只换颜色 */
|
/* 卡片表格的状态列:启用/已停用同一个胶囊,只换颜色 */
|
||||||
.card-state {
|
.card-state {
|
||||||
@@ -1373,11 +1266,6 @@ a.entry-href:hover {
|
|||||||
color: var(--su-color-danger);
|
color: var(--su-color-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-state-on {
|
|
||||||
font-size: var(--su-font-size-xs);
|
|
||||||
color: var(--su-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-order-index {
|
.card-order-index {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -1396,45 +1284,7 @@ a.entry-href:hover {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-category-row > * {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 分区管理:一个分区一块,下面挂卡片预览与子分组 */
|
/* 分区管理:一个分区一块,下面挂卡片预览与子分组 */
|
||||||
.admin-section-block {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--su-space-3);
|
|
||||||
padding-bottom: var(--su-space-4);
|
|
||||||
border-bottom: 1px dashed var(--su-border-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-block-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--su-space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-groups {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--su-space-2);
|
|
||||||
padding-left: var(--su-space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-group-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--su-space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-new-group-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 190px minmax(0, 1fr) auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--su-space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 内容页:新增条目表单与条目列表 */
|
/* 内容页:新增条目表单与条目列表 */
|
||||||
.admin-content-form {
|
.admin-content-form {
|
||||||
@@ -1447,15 +1297,6 @@ a.entry-href:hover {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-entry-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) 140px auto auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--su-space-2);
|
|
||||||
padding: var(--su-space-2) 0;
|
|
||||||
border-bottom: 1px solid var(--su-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.admin-item-actions .admin-remove:disabled {
|
.admin-item-actions .admin-remove:disabled {
|
||||||
|
|||||||
@@ -275,15 +275,7 @@ export default function AppNext() {
|
|||||||
show_profile: true,
|
show_profile: true,
|
||||||
show_service: true,
|
show_service: true,
|
||||||
show_nav: true,
|
show_nav: true,
|
||||||
hidden: [],
|
|
||||||
order: [],
|
|
||||||
hidden_groups: [],
|
|
||||||
shown_groups: [],
|
|
||||||
group_order: {},
|
|
||||||
tree_hidden: [],
|
|
||||||
tree_order: {},
|
|
||||||
},
|
},
|
||||||
sections: { hidden: [], order: [], titles: {}, limits: {} },
|
|
||||||
profile: {
|
profile: {
|
||||||
name: '本地管理员',
|
name: '本地管理员',
|
||||||
role: '管理员',
|
role: '管理员',
|
||||||
|
|||||||
+1
-63
@@ -1,5 +1,3 @@
|
|||||||
import type { Category } from './data/types'
|
|
||||||
|
|
||||||
export type Project = {
|
export type Project = {
|
||||||
name: string
|
name: string
|
||||||
route_path: string
|
route_path: string
|
||||||
@@ -26,7 +24,7 @@ export async function fetchProjects(signal?: AbortSignal): Promise<Project[]> {
|
|||||||
return Array.isArray(payload.projects) ? payload.projects : []
|
return Array.isArray(payload.projects) ? payload.projects : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectEventsHandlers = {
|
type ProjectEventsHandlers = {
|
||||||
onProjects: (projects: Project[]) => void
|
onProjects: (projects: Project[]) => void
|
||||||
/** SSE 连接建立 / 断开时回调,浏览器的 EventSource 会自行重连。 */
|
/** SSE 连接建立 / 断开时回调,浏览器的 EventSource 会自行重连。 */
|
||||||
onConnectionChange?: (connected: boolean) => void
|
onConnectionChange?: (connected: boolean) => void
|
||||||
@@ -68,63 +66,3 @@ export function subscribeProjects(handlers: ProjectEventsHandlers): () => void {
|
|||||||
source.close()
|
source.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 内容目录:数据来自后端的 SQLite 内容库(data/stash.db),不再是前端静态数组
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const CATALOG_ENDPOINT = '/api/catalog'
|
|
||||||
const CATALOG_EVENTS_ENDPOINT = '/api/catalog/events'
|
|
||||||
|
|
||||||
type CatalogPayload = {
|
|
||||||
categories?: Category[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 内容目录的完整返回;侧栏由这份分类树派生 */
|
|
||||||
export type Catalog = {
|
|
||||||
categories: Category[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 拉取完整内容目录。
|
|
||||||
*
|
|
||||||
* 数据量只有几十 KB,一次全量取回即可;搜索仍然在前端按关键字过滤。
|
|
||||||
*/
|
|
||||||
export async function fetchCatalog(signal?: AbortSignal): Promise<Catalog> {
|
|
||||||
const response = await fetch(CATALOG_ENDPOINT, {
|
|
||||||
signal,
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`请求 ${CATALOG_ENDPOINT} 失败:HTTP ${response.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = (await response.json()) as CatalogPayload
|
|
||||||
|
|
||||||
return {
|
|
||||||
categories: Array.isArray(payload.categories) ? payload.categories : [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 订阅内容库变更。
|
|
||||||
*
|
|
||||||
* 服务端用 SQLite 的 data_version 检测「外部工具改了库」,事件里不带数据,
|
|
||||||
* 只表示「该重新拉一次了」,避免两套数据结构需要保持同步。
|
|
||||||
*/
|
|
||||||
export function subscribeCatalog(onChange: () => void): () => void {
|
|
||||||
if (typeof EventSource === 'undefined') {
|
|
||||||
return () => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = new EventSource(CATALOG_EVENTS_ENDPOINT)
|
|
||||||
const handleChange = () => onChange()
|
|
||||||
|
|
||||||
source.addEventListener('catalog', handleChange)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
source.removeEventListener('catalog', handleChange)
|
|
||||||
source.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-172
@@ -1,8 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 内容数据模型。
|
* 内容数据模型:应用 / 海报 / 图文三种条目,以及详情页的推荐条目。
|
||||||
*
|
|
||||||
* 首页把每个「分类」渲染成一张卡片,明细页按分类的 kind 渲染不同网格,
|
|
||||||
* 因此这里用可辨识联合把三种展示形态分开。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 一个可点击的应用 / 站点条目 */
|
/** 一个可点击的应用 / 站点条目 */
|
||||||
@@ -30,17 +27,6 @@ export type AppItem = {
|
|||||||
likes?: number
|
likes?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 应用分组:明细页按组展示,首页按展示块取其中一组做预览 */
|
|
||||||
export type ContentGroup<T> = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
/** 是否出现在侧栏;缺省视为 true */
|
|
||||||
showInSidebar?: boolean
|
|
||||||
items: T[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AppGroup = ContentGroup<AppItem>
|
|
||||||
|
|
||||||
/** 竖版海报条目(电影) */
|
/** 竖版海报条目(电影) */
|
||||||
export type PosterItem = {
|
export type PosterItem = {
|
||||||
id: string
|
id: string
|
||||||
@@ -79,160 +65,3 @@ export type ArticleItem = {
|
|||||||
body?: string
|
body?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 首页展示块:分区卡片展示哪个节点、显示几条 */
|
|
||||||
export type SectionBlock = {
|
|
||||||
/** 要展示的节点业务键:通常是分组;电影/文章这类无分组的填分区自己 */
|
|
||||||
nodeKey: string
|
|
||||||
/** 块标题,缺省用节点标题 */
|
|
||||||
title?: string
|
|
||||||
/** 卡片显示几条,缺省用分类的 previewLimit 或默认值 */
|
|
||||||
previewLimit?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 应用类分类:内部按分组组织 */
|
|
||||||
export type AppCategory = {
|
|
||||||
id: string
|
|
||||||
kind: 'apps'
|
|
||||||
title: string
|
|
||||||
/** 标题左侧色条颜色,各分类用不同颜色区分 */
|
|
||||||
barColor: string
|
|
||||||
/** 明细页 hero 区的字符图标 */
|
|
||||||
icon: string
|
|
||||||
groups: AppGroup[]
|
|
||||||
/** 直接挂在分区上的条目(未分组) */
|
|
||||||
items?: AppItem[]
|
|
||||||
/** 首页预览条数,默认 4 */
|
|
||||||
previewLimit?: number
|
|
||||||
/** 首页预览为空时的提示文案 */
|
|
||||||
emptyText?: string
|
|
||||||
/** 是否出现在侧栏;缺省视为 true */
|
|
||||||
showInSidebar?: boolean
|
|
||||||
/** 首页卡片展示哪一块内容 */
|
|
||||||
block?: SectionBlock
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 海报类分类:竖版海报网格 */
|
|
||||||
export type PosterCategory = {
|
|
||||||
id: string
|
|
||||||
kind: 'posters'
|
|
||||||
title: string
|
|
||||||
barColor: string
|
|
||||||
icon: string
|
|
||||||
groups: ContentGroup<PosterItem>[]
|
|
||||||
/** 直接挂在分区上的条目(未分组) */
|
|
||||||
items: PosterItem[]
|
|
||||||
/** 首页预览条数,默认 6 */
|
|
||||||
previewLimit?: number
|
|
||||||
/** 是否出现在侧栏;缺省视为 true */
|
|
||||||
showInSidebar?: boolean
|
|
||||||
/** 首页卡片展示哪一块内容 */
|
|
||||||
block?: SectionBlock
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 图文类分类:上图下文卡片网格 */
|
|
||||||
export type ArticleCategory = {
|
|
||||||
id: string
|
|
||||||
kind: 'articles'
|
|
||||||
title: string
|
|
||||||
barColor: string
|
|
||||||
icon: string
|
|
||||||
groups: ContentGroup<ArticleItem>[]
|
|
||||||
/** 直接挂在分区上的条目(未分组) */
|
|
||||||
items: ArticleItem[]
|
|
||||||
/** 首页预览条数,默认 4 */
|
|
||||||
previewLimit?: number
|
|
||||||
/** 是否出现在侧栏;缺省视为 true */
|
|
||||||
showInSidebar?: boolean
|
|
||||||
/** 首页卡片展示哪一块内容 */
|
|
||||||
block?: SectionBlock
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Category = AppCategory | PosterCategory | ArticleCategory
|
|
||||||
|
|
||||||
/** 分类内的条目总数(直属 + 分组的),用于角标与明细页计数 */
|
|
||||||
export function categoryCount(category: Category): number {
|
|
||||||
const direct = category.items?.length ?? 0
|
|
||||||
const grouped = category.groups.reduce((total, group) => total + group.items.length, 0)
|
|
||||||
return direct + grouped
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 把应用类分类的条目拍平(分组 + 直属),首页预览用 */
|
|
||||||
export function flattenAppItems(category: AppCategory): AppItem[] {
|
|
||||||
return [
|
|
||||||
...category.groups.flatMap((group) => group.items),
|
|
||||||
...(category.items ?? []),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按 id 在分类里查找条目;详情页用 */
|
|
||||||
export function findAppItem(category: Category, itemId: string): AppItem | 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) {
|
|
||||||
const found = group.items.find((item) => item.id === itemId)
|
|
||||||
if (found !== undefined) return found
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 取同分类下的其他条目,用于详情页的「推荐阅读」 */
|
|
||||||
export function relatedAppItems(
|
|
||||||
category: Category,
|
|
||||||
excludeId: string | undefined,
|
|
||||||
limit = 4,
|
|
||||||
): AppItem[] {
|
|
||||||
if (category.kind !== 'apps') return []
|
|
||||||
|
|
||||||
return flattenAppItems(category)
|
|
||||||
.filter((item) => item.id !== excludeId)
|
|
||||||
.slice(0, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按 id 查找海报条目 */
|
|
||||||
export function findPosterItem(
|
|
||||||
category: Category,
|
|
||||||
itemId: string,
|
|
||||||
): PosterItem | undefined {
|
|
||||||
if (category.kind !== 'posters') return undefined
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 取同分类下的其他海报,用于详情页的「推荐阅读」 */
|
|
||||||
export function relatedPosterItems(
|
|
||||||
category: Category,
|
|
||||||
excludeId: string | undefined,
|
|
||||||
limit = 4,
|
|
||||||
): PosterItem[] {
|
|
||||||
if (category.kind !== 'posters') return []
|
|
||||||
|
|
||||||
return [
|
|
||||||
...category.items,
|
|
||||||
...category.groups.flatMap((group) => group.items),
|
|
||||||
]
|
|
||||||
.filter((item) => item.id !== excludeId)
|
|
||||||
.slice(0, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按 id 查找图文条目 */
|
|
||||||
export function findArticleItem(
|
|
||||||
category: Category,
|
|
||||||
itemId: string,
|
|
||||||
): ArticleItem | undefined {
|
|
||||||
if (category.kind !== 'articles') return undefined
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
* 条目地址带上它自己所在的分组,URL 才是自解释的:刷新之后点「返回」
|
* 条目地址带上它自己所在的分组,URL 才是自解释的:刷新之后点「返回」
|
||||||
* 仍然落回条目所属的分组,而不是去猜一个返回目标。
|
* 仍然落回条目所属的分组,而不是去猜一个返回目标。
|
||||||
*
|
*
|
||||||
* 这里只做「地址 ↔ 视图」的纯转换,读写 history 的部分在 `useViewRoute.ts`。
|
* 这里只做「视图 → 地址」的纯转换,读写 history 的部分在 `AppNext.tsx`。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type DetailRoute = {
|
export type DetailRoute = {
|
||||||
@@ -45,15 +45,10 @@ export type View =
|
|||||||
| ItemRoute
|
| ItemRoute
|
||||||
|
|
||||||
/** 前端路由前缀;改动这里要同步 `src/server.rs` 的 `SPA_PREFIXES`。 */
|
/** 前端路由前缀;改动这里要同步 `src/server.rs` 的 `SPA_PREFIXES`。 */
|
||||||
const CATEGORY_PREFIX = '/c/'
|
const CATEGORY_SEGMENT = 'c'
|
||||||
/** 前缀去掉两侧斜杠后的那一段,拼地址时当第一个路径段用 */
|
|
||||||
const CATEGORY_SEGMENT = CATEGORY_PREFIX.slice(1, -1)
|
|
||||||
/** 条目段标记:`/c/<分区>[/<分组>]/i/<条目>` */
|
/** 条目段标记:`/c/<分区>[/<分组>]/i/<条目>` */
|
||||||
const ITEM_MARK = 'i'
|
const ITEM_MARK = 'i'
|
||||||
|
|
||||||
/** 首页视图;未知地址一律归到这里 */
|
|
||||||
export const HOME_VIEW: View = { type: 'home' }
|
|
||||||
|
|
||||||
/** 把视图转成地址。 */
|
/** 把视图转成地址。 */
|
||||||
export function viewPath(view: View): string {
|
export function viewPath(view: View): string {
|
||||||
if (view.type === 'home') return '/'
|
if (view.type === 'home') return '/'
|
||||||
@@ -65,60 +60,7 @@ export function viewPath(view: View): string {
|
|||||||
return `/${segments.join('/')}`
|
return `/${segments.join('/')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 把地址解析成视图;看不懂的地址(段数不对、百分号编码非法)按首页处理。
|
|
||||||
*
|
|
||||||
* 这里只做形状校验,不判断分类 / 条目是否真的存在——那是数据层的事,
|
|
||||||
* 而且首屏还没拿到内容库时,它们本来就「暂时不存在」。
|
|
||||||
*/
|
|
||||||
export function parseViewPath(pathname: string): View {
|
|
||||||
if (!pathname.startsWith(CATEGORY_PREFIX)) return HOME_VIEW
|
|
||||||
|
|
||||||
const segments = pathname
|
|
||||||
.slice(CATEGORY_PREFIX.length)
|
|
||||||
.split('/')
|
|
||||||
.filter((segment) => segment !== '')
|
|
||||||
|
|
||||||
const [categorySegment] = segments
|
|
||||||
if (categorySegment === undefined) return HOME_VIEW
|
|
||||||
|
|
||||||
const categoryId = decodeSegment(categorySegment)
|
|
||||||
if (categoryId === undefined) return HOME_VIEW
|
|
||||||
|
|
||||||
if (segments.length === 1) return { type: 'detail', categoryId }
|
|
||||||
|
|
||||||
if (segments.length === 2) {
|
|
||||||
const groupId = decodeSegment(segments[1])
|
|
||||||
if (groupId === undefined) return HOME_VIEW
|
|
||||||
return { type: 'detail', categoryId, groupId }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (segments.length === 3 && segments[1] === ITEM_MARK) {
|
|
||||||
const itemId = decodeSegment(segments[2])
|
|
||||||
if (itemId === undefined) return HOME_VIEW
|
|
||||||
return { type: 'item', categoryId, itemId }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (segments.length === 4 && segments[2] === ITEM_MARK) {
|
|
||||||
const groupId = decodeSegment(segments[1])
|
|
||||||
const itemId = decodeSegment(segments[3])
|
|
||||||
if (groupId === undefined || itemId === undefined) return HOME_VIEW
|
|
||||||
return { type: 'item', categoryId, itemId, groupId }
|
|
||||||
}
|
|
||||||
|
|
||||||
return HOME_VIEW
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 单个路径段编码:id 里可能带斜杠、空格、中文 */
|
/** 单个路径段编码:id 里可能带斜杠、空格、中文 */
|
||||||
function encodeSegment(value: string): string {
|
function encodeSegment(value: string): string {
|
||||||
return encodeURIComponent(value)
|
return encodeURIComponent(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 解码单个路径段;百分号编码非法时返回 undefined */
|
|
||||||
function decodeSegment(segment: string): string | undefined {
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(segment)
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-259
@@ -1,10 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* 站点设置:页头滚动信息 + 侧栏顺序/显隐。
|
* 站点设置:页头滚动信息 + 侧栏卡片显隐。
|
||||||
*
|
*
|
||||||
* 接口是公开读、登录后写;写的时候带 `version` 做乐观锁,
|
* 接口是公开读、登录后写;写的时候带 `version` 做乐观锁,
|
||||||
* 冲突(多标签页同时改)时返回 409,页面提示刷新。
|
* 冲突(多标签页同时改)时返回 409,页面提示刷新。
|
||||||
*/
|
*/
|
||||||
import type { Category } from './data/types'
|
|
||||||
|
|
||||||
export type SettingsTone = 'plain' | 'primary' | 'success' | 'neutral'
|
export type SettingsTone = 'plain' | 'primary' | 'success' | 'neutral'
|
||||||
|
|
||||||
@@ -14,13 +13,6 @@ export type HeaderItem = {
|
|||||||
tone: SettingsTone
|
tone: SettingsTone
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SectionSettings = {
|
|
||||||
hidden: string[]
|
|
||||||
order: string[]
|
|
||||||
titles: Record<string, string>
|
|
||||||
limits: Record<string, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProfileSettings = {
|
export type ProfileSettings = {
|
||||||
name: string
|
name: string
|
||||||
role: string
|
role: string
|
||||||
@@ -47,20 +39,7 @@ export type SiteSettings = {
|
|||||||
show_service: boolean
|
show_service: boolean
|
||||||
/** 是否显示「分组导航」卡片(首页 / 分组树) */
|
/** 是否显示「分组导航」卡片(首页 / 分组树) */
|
||||||
show_nav: boolean
|
show_nav: boolean
|
||||||
hidden: string[]
|
|
||||||
order: string[]
|
|
||||||
/** 隐藏的分组:`分区id/分组id` */
|
|
||||||
hidden_groups: string[]
|
|
||||||
/** 强制显示的分组(覆盖数据库 show_in_sidebar=0) */
|
|
||||||
shown_groups: string[]
|
|
||||||
/** 每个分区下的分组顺序:分区 id → 分组 id 列表 */
|
|
||||||
group_order: Record<string, string[]>
|
|
||||||
/** 新模型:侧栏隐藏的分组 key */
|
|
||||||
tree_hidden: string[]
|
|
||||||
/** 新模型:每级分组的顺序,键为父分组 key(根级用空串) */
|
|
||||||
tree_order: Record<string, string[]>
|
|
||||||
}
|
}
|
||||||
sections: SectionSettings
|
|
||||||
profile: ProfileSettings
|
profile: ProfileSettings
|
||||||
/** 站点级:标签标题 / favicon / 品牌 / 页头操作工具 */
|
/** 站点级:标签标题 / favicon / 品牌 / 页头操作工具 */
|
||||||
site: SiteMetaSettings
|
site: SiteMetaSettings
|
||||||
@@ -127,15 +106,7 @@ export function defaultSettings(): SiteSettings {
|
|||||||
show_profile: true,
|
show_profile: true,
|
||||||
show_service: true,
|
show_service: true,
|
||||||
show_nav: true,
|
show_nav: true,
|
||||||
hidden: [],
|
|
||||||
order: [],
|
|
||||||
hidden_groups: [],
|
|
||||||
shown_groups: [],
|
|
||||||
group_order: {},
|
|
||||||
tree_hidden: [],
|
|
||||||
tree_order: {},
|
|
||||||
},
|
},
|
||||||
sections: { hidden: [], order: [], titles: {}, limits: {} },
|
|
||||||
profile: {
|
profile: {
|
||||||
name: '本地管理员',
|
name: '本地管理员',
|
||||||
role: '管理员',
|
role: '管理员',
|
||||||
@@ -177,7 +148,8 @@ export async function fetchSettings(): Promise<SettingsDocument> {
|
|||||||
export async function saveSettings(
|
export async function saveSettings(
|
||||||
version: number,
|
version: number,
|
||||||
settings: SiteSettings,
|
settings: SiteSettings,
|
||||||
): Promise<SettingsResult> { try {
|
): Promise<SettingsResult> {
|
||||||
|
try {
|
||||||
const response = await fetch('/api/settings', {
|
const response = await fetch('/api/settings', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
@@ -203,231 +175,3 @@ export async function saveSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 按设置重排侧栏:先按 `order`,未列出的保持原顺序,`hidden` 过滤掉。
|
|
||||||
*/export function applySidebarSettings<T extends { id: string }>(
|
|
||||||
categories: T[],
|
|
||||||
sidebar: SiteSettings['sidebar'],
|
|
||||||
): T[] {
|
|
||||||
return orderAndFilter(categories, sidebar)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 首页卡片:过滤隐藏、按 order 排序(未列出的保持原顺序) */
|
|
||||||
export function applySectionSettings<T extends { id: string }>(
|
|
||||||
categories: T[],
|
|
||||||
sections: SectionSettings,
|
|
||||||
): T[] {
|
|
||||||
return orderAndFilter(categories, sections)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 侧栏子分类(分组):按设置过滤、排序。
|
|
||||||
*
|
|
||||||
* 数据库里的 `show_in_sidebar` 是默认值;`shown_groups` 可以把它强行打开,
|
|
||||||
* `hidden_groups` 则再隐藏一层。
|
|
||||||
*/
|
|
||||||
export function applySidebarGroups(
|
|
||||||
categories: Category[],
|
|
||||||
sidebar: SiteSettings['sidebar'],
|
|
||||||
): 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 shown = new Set(sidebar.shown_groups)
|
|
||||||
const ranks = new Map(
|
|
||||||
(sidebar.group_order[categoryId] ?? []).map((id, index) => [id, index] as const),
|
|
||||||
)
|
|
||||||
|
|
||||||
return groups
|
|
||||||
.filter((group) => {
|
|
||||||
const key = `${categoryId}/${group.id}`
|
|
||||||
const showInSidebar = (group as { showInSidebar?: boolean }).showInSidebar
|
|
||||||
return !hidden.has(key) && (showInSidebar !== false || shown.has(key))
|
|
||||||
})
|
|
||||||
.map((group, index) => ({
|
|
||||||
group,
|
|
||||||
index,
|
|
||||||
rank: ranks.get(group.id) ?? Number.MAX_SAFE_INTEGER,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank))
|
|
||||||
.map((entry) => entry.group)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 卡片标题:设置里的覆盖值优先 */
|
|
||||||
export function sectionTitle(
|
|
||||||
id: string,
|
|
||||||
fallback: string,
|
|
||||||
sections: SectionSettings,
|
|
||||||
): string {
|
|
||||||
const override = sections.titles[id]
|
|
||||||
return override !== undefined && override.trim() !== '' ? override : fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 卡片预览条数:设置里的覆盖值优先 */
|
|
||||||
export function sectionLimit(
|
|
||||||
id: string,
|
|
||||||
fallback: number,
|
|
||||||
sections: SectionSettings,
|
|
||||||
): number {
|
|
||||||
const override = sections.limits[id]
|
|
||||||
return typeof override === 'number' && override > 0 ? override : fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
function orderAndFilter<T extends { id: string }>( categories: T[],
|
|
||||||
config: { hidden: string[]; order: string[] },
|
|
||||||
): T[] {
|
|
||||||
const hidden = new Set(config.hidden)
|
|
||||||
const ranks = new Map(config.order.map((id, index) => [id, index]))
|
|
||||||
|
|
||||||
return categories
|
|
||||||
.filter((category) => !hidden.has(category.id))
|
|
||||||
.map((category, index) => ({
|
|
||||||
category,
|
|
||||||
index,
|
|
||||||
rank: ranks.get(category.id) ?? Number.MAX_SAFE_INTEGER,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank))
|
|
||||||
.map((entry) => entry.category)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 分区(首页卡片 / 侧栏项)的新增与删除 ----------
|
|
||||||
|
|
||||||
export type NewCategory = { id: string; title: string; kind: 'apps' | 'posters' | 'articles' }
|
|
||||||
|
|
||||||
export type SimpleResult = { ok: true } | { ok: false; error: string }
|
|
||||||
|
|
||||||
export async function createCategory(input: NewCategory): Promise<SimpleResult> {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/categories', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
})
|
|
||||||
if (response.ok) return { ok: true }
|
|
||||||
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
|
||||||
return { ok: false, error: payload?.error ?? `创建失败(${response.status})` }
|
|
||||||
} catch (error) {
|
|
||||||
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteCategory(id: string): Promise<SimpleResult> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/categories/${encodeURIComponent(id)}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
})
|
|
||||||
if (response.ok) return { ok: true }
|
|
||||||
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
|
||||||
return { ok: false, error: payload?.error ?? `删除失败(${response.status})` }
|
|
||||||
} catch (error) {
|
|
||||||
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 修改分区(归类、标题、图标、色条、预览条数) */
|
|
||||||
export type UpdateCategoryInput = {
|
|
||||||
title?: string
|
|
||||||
icon?: string
|
|
||||||
barColor?: string
|
|
||||||
kind?: 'apps' | 'posters' | 'articles'
|
|
||||||
previewLimit?: number
|
|
||||||
emptyText?: string
|
|
||||||
/** 首页卡片预览哪个节点(分区自己或其分组) */
|
|
||||||
blockNodeKey?: string
|
|
||||||
/** 是否允许子分组(与类型无关的开关) */
|
|
||||||
allowGroups?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateCategory(
|
|
||||||
id: string,
|
|
||||||
patch: UpdateCategoryInput,
|
|
||||||
): Promise<SimpleResult> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/categories/${encodeURIComponent(id)}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify(patch),
|
|
||||||
})
|
|
||||||
if (response.ok) return { ok: true }
|
|
||||||
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
|
||||||
return { ok: false, error: payload?.error ?? `保存失败(${response.status})` }
|
|
||||||
} catch (error) {
|
|
||||||
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 分组(子分类) ----------
|
|
||||||
|
|
||||||
export async function createGroup(
|
|
||||||
sectionId: string,
|
|
||||||
input: { id: string; title: string },
|
|
||||||
): Promise<SimpleResult> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/categories/${encodeURIComponent(sectionId)}/groups`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (response.ok) return { ok: true }
|
|
||||||
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
|
||||||
return { ok: false, error: payload?.error ?? `创建失败(${response.status})` }
|
|
||||||
} catch (error) {
|
|
||||||
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateGroup(
|
|
||||||
id: string,
|
|
||||||
patch: { title?: string; showInSidebar?: boolean },
|
|
||||||
): Promise<SimpleResult> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/groups/${encodeURIComponent(id)}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify(patch),
|
|
||||||
})
|
|
||||||
if (response.ok) return { ok: true }
|
|
||||||
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
|
||||||
return { ok: false, error: payload?.error ?? `保存失败(${response.status})` }
|
|
||||||
} catch (error) {
|
|
||||||
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteGroup(id: string): Promise<SimpleResult> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/groups/${encodeURIComponent(id)}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
})
|
|
||||||
if (response.ok) return { ok: true }
|
|
||||||
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
|
||||||
return { ok: false, error: payload?.error ?? `删除失败(${response.status})` }
|
|
||||||
} catch (error) {
|
|
||||||
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user