Skip to main content

yggdrasil/components/
admin_palette.rs

1//! 后台命令面板(⌘K):搜索文章、执行操作、跳转栏目。
2//!
3//! 输入、防抖请求、键盘选择与对话框结构由 [crate::components::palette::PaletteShell] 提供;数据与
4//! 动作是后台自己的。空查询显示常用操作与全部栏目;有查询时按「文章 · 操作 · 跳转」
5//! 分组,文章来自后台列表接口(含草稿,按标题匹配,最多 6 篇),回车进入编辑。
6
7use std::future::Future;
8use std::pin::Pin;
9
10use leptos::prelude::*;
11
12use crate::components::admin_layout::AdminSection;
13use crate::components::palette::{
14    contains_ci, use_palette_shortcut, use_palette_state, viewport_center, PaletteEntry,
15    PaletteGroup, PaletteShell, SearchPaletteHandle,
16};
17use crate::components::ui::IconName;
18use crate::models::post::{PostListItem, PostStatus};
19use crate::routes::Route;
20use crate::theme::{choose_theme, use_resolved_theme, use_theme, ResolvedTheme, Theme};
21
22/// 文章搜索结果的条数上限。
23const POST_LIMIT: i32 = 6;
24
25/// 面板里的动作。
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum Action {
28    WritePost,
29    NewNote,
30    Upload,
31    OpenSite,
32    Theme(Theme),
33    Logout,
34}
35
36impl Action {
37    fn label(self) -> &'static str {
38        match self {
39            Action::WritePost => "写文章",
40            Action::NewNote => "新建笔记",
41            Action::Upload => "上传素材",
42            Action::OpenSite => "查看前台",
43            Action::Theme(Theme::Dark) => "切换到深色",
44            Action::Theme(_) => "切换到浅色",
45            Action::Logout => "退出登录",
46        }
47    }
48
49    fn icon(self) -> IconName {
50        match self {
51            Action::WritePost => IconName::Pencil,
52            Action::NewNote => IconName::NotebookPen,
53            Action::Upload => IconName::Upload,
54            Action::OpenSite => IconName::ArrowUpRight,
55            Action::Theme(Theme::Dark) => IconName::Moon,
56            Action::Theme(_) => IconName::Sun,
57            Action::Logout => IconName::LogOut,
58        }
59    }
60}
61
62/// 可跳转的页面:侧栏栏目之外,再加回收站、笔记本与个人资料。
63fn jump_targets() -> Vec<(&'static str, IconName, Route)> {
64    let mut targets: Vec<_> = [
65        AdminSection::Dashboard,
66        AdminSection::Posts,
67        AdminSection::Notes,
68        AdminSection::Assets,
69        AdminSection::Comments,
70        AdminSection::Friends,
71        AdminSection::Mcp,
72        AdminSection::Settings,
73        AdminSection::System,
74        AdminSection::Logs,
75        AdminSection::Runner,
76    ]
77    .into_iter()
78    .map(|section| (section.label(), section.icon(), section.route()))
79    .collect();
80    targets.push(("回收站", IconName::Trash, Route::PostsTrash));
81    targets.push(("笔记本", IconName::BookOpen, Route::AdminNotebooks));
82    targets.push(("个人资料", IconName::User, Route::Profile));
83    targets
84}
85
86/// 文章检索结果;`None` 表示请求失败,面板保留上一批结果。
87pub type PostSearch = Pin<Box<dyn Future<Output = Option<Vec<PostListItem>>>>>;
88
89#[derive(Clone, Debug, PartialEq)]
90pub enum Item {
91    Post(PostListItem),
92    Action(Action),
93    Jump {
94        label: &'static str,
95        icon: IconName,
96        route: Route,
97    },
98}
99
100#[derive(Clone, Debug, PartialEq)]
101struct Group {
102    label: &'static str,
103    items: Vec<Item>,
104}
105
106impl Item {
107    pub fn title(&self) -> String {
108        match self {
109            Item::Post(post) => post.title.clone(),
110            Item::Action(action) => action.label().to_string(),
111            Item::Jump { label, .. } => label.to_string(),
112        }
113    }
114}
115
116/// 按查询组织面板内容。空查询:常用操作与全部栏目;有查询:命中的文章、操作与栏目。
117fn build_groups(query: &str, posts: &[PostListItem], dark: bool) -> Vec<Group> {
118    let q = query.trim();
119    let theme = Action::Theme(if dark { Theme::Light } else { Theme::Dark });
120    let actions = [
121        Action::WritePost,
122        Action::NewNote,
123        Action::Upload,
124        theme,
125        Action::OpenSite,
126        Action::Logout,
127    ];
128    let mut groups = Vec::new();
129    if !q.is_empty() && !posts.is_empty() {
130        groups.push(Group {
131            label: "文章",
132            items: posts.iter().cloned().map(Item::Post).collect(),
133        });
134    }
135    let actions: Vec<Item> = actions
136        .into_iter()
137        // 空查询时不把退出登录放进常用操作,避免误触。
138        .filter(|a| {
139            if q.is_empty() {
140                *a != Action::Logout
141            } else {
142                contains_ci(a.label(), q)
143            }
144        })
145        .map(Item::Action)
146        .collect();
147    if !actions.is_empty() {
148        groups.push(Group {
149            label: "操作",
150            items: actions,
151        });
152    }
153    let jumps: Vec<Item> = jump_targets()
154        .into_iter()
155        .filter(|(label, _, _)| contains_ci(label, q))
156        .map(|(label, icon, route)| Item::Jump { label, icon, route })
157        .collect();
158    if !jumps.is_empty() {
159        groups.push(Group {
160            label: "跳转",
161            items: jumps,
162        });
163    }
164    groups
165}
166
167/// 后台命令面板。挂在后台布局里一次,由 [`crate::components::palette::SearchPaletteHandle`] 打开。
168///
169/// Props:
170/// - `on_logout`:「退出登录」命令,与侧栏退出按钮同一处理
171#[component]
172pub fn AdminPalette(#[prop(into)] on_logout: Callback<()>) -> impl IntoView {
173    use_palette_shortcut(expect_context::<SearchPaletteHandle>(), true);
174    let theme = use_theme();
175    let search = Callback::new(|q: String| -> PostSearch {
176        Box::pin(async move {
177            crate::api::posts::list_posts(
178                1,
179                POST_LIMIT,
180                crate::api::posts::AdminPostQuery::search(q),
181            )
182            .await
183            .ok()
184            .map(|res| res.posts)
185        })
186    });
187    let on_activate = Callback::new(move |item: Item| match item {
188        Item::Post(post) => {
189            crate::navigation::push(&Route::WriteEdit { id: post.id }.to_string());
190        }
191        Item::Jump { route, .. } => crate::navigation::push(&route.to_string()),
192        Item::Action(action) => match action {
193            Action::WritePost => crate::navigation::push(&Route::Write.to_string()),
194            Action::NewNote => crate::navigation::push(&Route::NewNote.to_string()),
195            Action::Upload => crate::navigation::push(&Route::Assets.to_string()),
196            Action::OpenSite => crate::navigation::push(&Route::Home.to_string()),
197            Action::Theme(to) => choose_theme(theme, to, viewport_center()),
198            Action::Logout => on_logout.run(()),
199        },
200    });
201    view! { <AdminPaletteView search=search on_activate=on_activate/> }
202}
203
204/// 面板中的一行的展示内容。
205fn entry(item: &Item) -> PaletteEntry {
206    let (icon, title, meta) = match item {
207        Item::Post(post) => (
208            IconName::FileText,
209            post.title.clone(),
210            match post.status {
211                PostStatus::Draft => "草稿".to_string(),
212                PostStatus::Published => post
213                    .published_at
214                    .map(|d| crate::utils::time::ymd(&d, '.'))
215                    .unwrap_or_default(),
216            },
217        ),
218        Item::Action(action) => (action.icon(), action.label().to_string(), String::new()),
219        Item::Jump { label, icon, .. } => (*icon, label.to_string(), "跳转".to_string()),
220    };
221    PaletteEntry {
222        icon,
223        title,
224        summary: None,
225        meta,
226        mono_meta: false,
227        highlight_hits: true,
228    }
229}
230
231/// 命令面板视图:文章检索与条目执行由调用方提供,其余交给 [`PaletteShell`]。
232///
233/// Props:
234/// - `search`:按标题检索文章,返回 `None` 表示失败(面板保留上一批结果)
235/// - `on_activate`:选中条目;面板先关闭再回调
236#[component]
237pub(crate) fn AdminPaletteView(
238    #[prop(into)] search: Callback<String, PostSearch>,
239    #[prop(into)] on_activate: Callback<Item>,
240) -> impl IntoView {
241    let palette = use_palette_state("admin-palette");
242    let query = palette.query;
243
244    let posts = RwSignal::new(Vec::<PostListItem>::new());
245    let posts_query = RwSignal::new(String::new());
246    let resolved = use_resolved_theme();
247    // 只在「是否在屏上」翻转时重建面板:关闭淡出期间 `open` 与 `closing` 先后变化,
248    // 直接读 `palette.visible()` 会让面板在淡出开始时被重建,动画丢失。
249    let visible = Memo::new(move |_| palette.visible());
250
251    let close = move || {
252        palette.close(move || {
253            let _ = posts.try_set(Vec::new());
254            let _ = posts_query.try_set(String::new());
255        });
256    };
257
258    // 检索返回 `None` 时保留上一批结果。
259    let schedule_fetch = move || palette.schedule_fetch(posts, posts_query, move |q| search.run(q));
260
261    let groups = Memo::new(move |_| {
262        let q = query.get();
263        let dark = resolved.get() == ResolvedTheme::Dark;
264        posts.with(|posts| build_groups(&q, posts, dark))
265    });
266    let view_groups = Signal::derive(move || {
267        groups.with(|groups| {
268            groups
269                .iter()
270                .map(|g| PaletteGroup {
271                    label: g.label,
272                    entries: g.items.iter().map(entry).collect(),
273                })
274                .collect::<Vec<_>>()
275        })
276    });
277    let waiting = Signal::derive(move || posts_query.read().as_str() != query.read().trim());
278
279    move || {
280        visible.get().then(|| {
281            view! {
282                <PaletteShell
283                    state=palette
284                    label="命令面板"
285                    input_label="搜索文章或输入命令"
286                    placeholder="搜索文章,或输入命令…"
287                    list_label="命令与搜索结果"
288                    enter_hint="执行"
289                    empty_hint="文章标题、命令与栏目里都没有命中。"
290                    groups=view_groups
291                    waiting=waiting
292                    on_query_change=move |_| schedule_fetch()
293                    on_pick=move |(index, _): (usize, bool)| {
294                        let item = groups
295                            .with_untracked(|groups| {
296                                groups.iter().flat_map(|g| g.items.iter()).nth(index).cloned()
297                            });
298                        if let Some(item) = item {
299                            close();
300                            on_activate.run(item);
301                        }
302                    }
303                    on_close=move |_| close()
304                />
305            }
306        })
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    fn labels(groups: &[Group]) -> Vec<(&'static str, usize)> {
315        groups.iter().map(|g| (g.label, g.items.len())).collect()
316    }
317
318    #[test]
319    fn empty_query_lists_common_actions_without_logout() {
320        let groups = build_groups("", &[], false);
321        assert_eq!(labels(&groups), [("操作", 5), ("跳转", 14)]);
322        assert!(!groups[0].items.contains(&Item::Action(Action::Logout)));
323        assert!(groups[0]
324            .items
325            .contains(&Item::Action(Action::Theme(Theme::Dark))));
326        let dark = build_groups("", &[], true);
327        assert!(dark[0]
328            .items
329            .contains(&Item::Action(Action::Theme(Theme::Light))));
330    }
331
332    #[test]
333    fn query_filters_actions_and_jumps() {
334        let groups = build_groups("退出", &[], false);
335        assert_eq!(groups[0].items, [Item::Action(Action::Logout)]);
336        let groups = build_groups("笔记", &[], false);
337        assert_eq!(labels(&groups), [("操作", 1), ("跳转", 2)]);
338        assert!(build_groups("zzz", &[], false).is_empty());
339    }
340}