1use 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
22const POST_LIMIT: i32 = 6;
24
25#[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
62fn 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
86pub 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
116fn 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 .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#[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
204fn 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#[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 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 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}