1use dioxus::prelude::*;
12
13#[cfg(target_arch = "wasm32")]
14use crate::api::friends::{
15 create_friend_link, delete_friend_link, list_all_friend_links, update_friend_link,
16};
17#[cfg(target_arch = "wasm32")]
18use crate::components::forms::{FormInput, FormLabel, FormSelect, INPUT_CLASS};
19#[cfg(target_arch = "wasm32")]
20use crate::components::ui::{
21 ADMIN_CARD_CLASS, BADGE_BASE, BTN_OUTLINE, BTN_PRIMARY, BTN_TEXT_ACCENT, BTN_TEXT_RED,
22};
23#[cfg(target_arch = "wasm32")]
24use crate::models::friend_link::FriendLink;
25
26#[cfg(target_arch = "wasm32")]
30#[derive(Clone, Copy, PartialEq)]
31struct FriendsPageState {
32 reload_gen: Signal<u32>,
34 toast: Signal<Option<(String, bool)>>,
36 editing: Signal<Option<FriendLink>>,
38}
39
40#[component]
42pub fn FriendsAdmin() -> Element {
43 #[cfg(target_arch = "wasm32")]
44 {
45 let state = FriendsPageState {
46 reload_gen: use_signal(|| 0),
47 toast: use_signal(|| None),
48 editing: use_signal(|| None),
49 };
50 use_context_provider(|| state);
51
52 rsx! {
53 div { class: "w-full max-w-7xl mx-auto space-y-8",
54 PageHeader {}
55 Toast {}
56 EditorCard {}
57 LinkList {}
58 }
59 }
60 }
61 #[cfg(not(target_arch = "wasm32"))]
62 {
63 rsx! {
65 p { class: "text-paper-secondary", "此页面仅在浏览器中可用。" }
66 }
67 }
68}
69
70#[component]
72fn PageHeader() -> Element {
73 rsx! {
74 div { class: "flex flex-col md:flex-row md:items-end justify-between gap-6 pb-8 border-b border-[var(--color-paper-border)]/50",
75 div {
76 h1 { class: "text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]",
77 "友链管理"
78 }
79 p { class: "text-base text-[var(--color-paper-secondary)] mt-2",
80 "维护前台 /friends 页展示的友链。"
81 }
82 }
83 }
84 }
85}
86
87#[cfg(target_arch = "wasm32")]
89#[component]
90fn Toast() -> Element {
91 let state: FriendsPageState = use_context();
92 let toast = state.toast;
93 if let Some((msg, is_err)) = toast() {
94 let cls = if is_err {
95 "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300"
96 } else {
97 "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
98 };
99 rsx! {
100 div { class: "text-sm rounded-lg px-3 py-2 {cls}", "{msg}" }
101 }
102 } else {
103 rsx! {}
104 }
105}
106
107#[cfg(target_arch = "wasm32")]
112#[component]
113fn EditorCard() -> Element {
114 let mut state: FriendsPageState = use_context();
115 let mut name = use_signal(String::new);
116 let mut url = use_signal(String::new);
117 let mut avatar = use_signal(String::new);
118 let mut desc = use_signal(String::new);
119 let mut sort_str = use_signal(String::new);
120 let mut is_active = use_signal(|| true);
121 let mut busy = use_signal(|| false);
122
123 let mut editing = state.editing;
124 let reload_gen = state.reload_gen;
125 let mut toast = state.toast;
126
127 use_effect(move || {
130 if let Some(link) = editing() {
131 name.set(link.name.clone());
132 url.set(link.url.clone());
133 avatar.set(link.avatar_url.clone().unwrap_or_default());
134 desc.set(link.description.clone());
135 sort_str.set(link.sort_order.to_string());
136 is_active.set(link.is_active);
137 }
138 });
139
140 let mut clear_drafts = move || {
141 name.set(String::new());
142 url.set(String::new());
143 avatar.set(String::new());
144 desc.set(String::new());
145 sort_str.set(String::new());
146 is_active.set(true);
147 };
148
149 let editing_target = editing();
150 let editing_mode = editing_target.is_some();
151
152 rsx! {
153 div { class: "{ADMIN_CARD_CLASS} p-8 flex flex-col gap-6",
154 h2 { class: "text-xl font-bold text-[var(--color-paper-primary)]",
155 if editing_mode {
156 "编辑友链"
157 } else {
158 "添加友链"
159 }
160 }
161
162 div { class: "grid grid-cols-1 md:grid-cols-2 gap-4",
163 div { class: "flex flex-col gap-2",
165 FormLabel { label: "名称" }
166 FormInput {
167 r#type: "text",
168 placeholder: "站点名称",
169 value: name(),
170 oninput: move |v: String| name.set(v),
171 }
172 }
173 div { class: "flex flex-col gap-2",
175 FormLabel { label: "URL" }
176 FormInput {
177 r#type: "url",
178 placeholder: "https://example.com",
179 value: url(),
180 oninput: move |v: String| url.set(v),
181 }
182 }
183 div { class: "flex flex-col gap-2",
185 FormLabel { label: "头像 URL(可留空)" }
186 FormInput {
187 r#type: "url",
188 placeholder: "https://example.com/avatar.png(可留空)",
189 value: avatar(),
190 oninput: move |v: String| avatar.set(v),
191 }
192 }
193 div { class: "flex flex-col gap-2",
195 FormLabel { label: "排序(越小越靠前)" }
196 FormInput {
197 r#type: "number",
198 placeholder: "0",
199 value: sort_str(),
200 oninput: move |v: String| sort_str.set(v),
201 }
202 }
203 div { class: "flex flex-col gap-2 md:col-span-2",
205 FormLabel { label: "描述" }
206 textarea {
207 class: "{INPUT_CLASS}",
208 rows: "2",
209 placeholder: "一句话介绍对方站点",
210 value: desc(),
211 oninput: move |e| desc.set(e.value()),
212 }
213 }
214 if editing_mode {
216 div { class: "flex flex-col gap-2",
217 FormLabel { label: "状态" }
218 FormSelect {
219 value: is_active(),
220 options: vec![(true, "启用"), (false, "停用")],
221 onchange: move |a: bool| is_active.set(a),
222 }
223 }
224 }
225 }
226
227 div { class: "flex items-center gap-3",
228 button {
229 class: "{BTN_PRIMARY}",
230 disabled: "{busy() || name().trim().is_empty() || url().trim().is_empty()}",
231 onclick: move |_| {
232 if busy() {
233 return;
234 }
235 let n = name().trim().to_string();
236 let u = url().trim().to_string();
237 if n.is_empty() || u.is_empty() {
238 return;
239 }
240 let av = {
241 let a = avatar().trim().to_string();
242 if a.is_empty() { None } else { Some(a) }
243 };
244 let d = desc().trim().to_string();
245 let sort = match sort_str().trim().parse::<i32>() {
246 Ok(s) => s,
247 Err(_) => {
248 toast.set(Some(("排序值必须是整数".to_string(), true)));
249 return;
250 }
251 };
252 let active = is_active();
253 let target = editing();
254 let was_edit = target.is_some();
255 busy.set(true);
256 spawn(async move {
257 let result = match target {
258 Some(link) => {
259 update_friend_link(link.id, n, u, av, d, sort, active).await
260 }
261 None => create_friend_link(n, u, av, d, sort).await,
262 };
263 match result {
264 Ok(_) => {
265 toast
266 .set(
267 Some((
268 if was_edit {
269 "已保存".to_string()
270 } else {
271 "已添加".to_string()
272 },
273 false,
274 )),
275 );
276 editing.set(None);
277 clear_drafts();
278 let g = reload_gen();
279 state.reload_gen.set(g + 1);
280 }
281 Err(e) => {
282 toast.set(Some((format!("保存失败:{e}"), true)));
283 }
284 }
285 busy.set(false);
286 });
287 },
288 if busy() {
289 "保存中…"
290 } else if editing_mode {
291 "保存修改"
292 } else {
293 "添加友链"
294 }
295 }
296 if editing_mode {
297 button {
298 class: "{BTN_OUTLINE}",
299 onclick: move |_| {
300 editing.set(None);
301 clear_drafts();
302 },
303 "取消"
304 }
305 }
306 }
307 }
308 }
309}
310
311#[cfg(target_arch = "wasm32")]
313#[component]
314fn LinkList() -> Element {
315 let mut state: FriendsPageState = use_context();
316 let mut links = use_signal(|| Vec::<FriendLink>::new());
317 let mut loaded_gen = use_signal(|| None::<u32>);
318 let mut loading = use_signal(|| true);
319
320 let reload_gen = state.reload_gen;
321 let mut toast = state.toast;
322
323 use_effect(move || {
325 let g = reload_gen();
326 if loaded_gen() != Some(g) {
327 loaded_gen.set(Some(g));
328 spawn(async move {
329 match list_all_friend_links().await {
330 Ok(list) => links.set(list),
331 Err(e) => toast.set(Some((format!("加载失败:{e}"), true))),
332 }
333 loading.set(false);
334 });
335 }
336 });
337
338 rsx! {
339 div { class: "{ADMIN_CARD_CLASS} p-8 flex flex-col gap-4",
340 h2 { class: "text-xl font-bold text-[var(--color-paper-primary)]", "友链列表" }
341
342 if loading() && links().is_empty() {
343 p { class: "text-[var(--color-paper-secondary)] text-sm py-4", "加载中…" }
344 } else if links().is_empty() {
345 p { class: "text-[var(--color-paper-secondary)] text-sm py-4 text-center",
346 "还没有友链,先添加一位伙伴吧。"
347 }
348 } else {
349 div { class: "flex flex-col divide-y divide-[var(--color-paper-border)]/50",
350 for link in links().iter() {
351 LinkRow { key: "{link.id}", link: link.clone(), state }
352 }
353 }
354 }
355 }
356 }
357}
358
359#[cfg(target_arch = "wasm32")]
361#[component]
362fn LinkRow(link: FriendLink, state: FriendsPageState) -> Element {
363 let initial: String = link
364 .name
365 .chars()
366 .next()
367 .map(|c| c.to_uppercase().collect())
368 .unwrap_or_else(|| "?".to_string());
369 let status_label = if link.is_active { "启用" } else { "停用" };
370 let status_class = if link.is_active {
371 "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300"
372 } else {
373 "bg-paper-code-block text-paper-tertiary"
374 };
375
376 let id_delete = link.id;
377 let link_for_edit = link.clone();
378 let mut editing = state.editing;
379 let mut toast = state.toast;
380 let reload_gen = state.reload_gen;
381
382 rsx! {
383 div { class: "flex items-center gap-4 py-4",
384 div { class: "relative h-8 w-8 shrink-0 rounded-2xl bg-[var(--color-paper-code-bg)] flex items-center justify-center overflow-hidden",
385 span { class: "text-sm font-semibold text-[var(--color-paper-primary)] select-none",
386 "{initial}"
387 }
388 }
389 div { class: "flex-1 min-w-0",
390 div { class: "flex items-baseline gap-2",
391 span { class: "font-medium text-[var(--color-paper-primary)] truncate",
392 "{link.name}"
393 }
394 span { class: "{BADGE_BASE} {status_class}", "{status_label}" }
395 }
396 div { class: "flex items-baseline gap-3 mt-0.5",
397 if !link.url.is_empty() {
398 span { class: "text-xs text-[var(--color-paper-tertiary)] font-mono truncate",
399 "{link.url}"
400 }
401 }
402 if !link.description.is_empty() {
403 span { class: "text-xs text-[var(--color-paper-secondary)] truncate",
404 "{link.description}"
405 }
406 }
407 }
408 }
409 span { class: "text-xs text-[var(--color-paper-tertiary)] whitespace-nowrap",
410 "排序 {link.sort_order}"
411 }
412 div { class: "flex items-center gap-3 whitespace-nowrap",
413 button {
414 class: "{BTN_TEXT_ACCENT}",
415 onclick: move |_| editing.set(Some(link_for_edit.clone())),
416 "编辑"
417 }
418 button {
419 class: "{BTN_TEXT_RED}",
420 onclick: move |_| {
421 let confirmed = web_sys::window()
422 .and_then(|w| {
423 w.confirm_with_message("确定要删除这条友链吗?").ok()
424 })
425 .unwrap_or(false);
426 if !confirmed {
427 return;
428 }
429 let id = id_delete;
430 spawn(async move {
431 match delete_friend_link(id).await {
432 Ok(()) => {
433 toast.set(Some(("已删除".to_string(), false)));
434 let g = reload_gen();
435 state.reload_gen.set(g + 1);
436 }
437 Err(e) => {
438 toast.set(Some((format!("删除失败:{e}"), true)));
439 }
440 }
441 });
442 },
443 "删除"
444 }
445 }
446 }
447 }
448}