Skip to main content

yggdrasil/pages/admin/
profile.rs

1//! 后台「个人信息」页面(`/admin/profile`)。
2//!
3//! 管理当前登录账号的展示资料(显示名称 / 头像 / 邮箱)与登录密码;
4//! username 是唯一登录凭据,只读展示。
5//!
6//! 布局:单列居中卡片流(max-w-2xl)——身份卡(头像 + 展示名 + 角色徽章 +
7//! 注册时间)→「基本资料」卡 →「安全」卡。与站点设置页的 scroll-spy 分区
8//! 布局刻意不同:本页只有 2-3 个分区,纵向堆叠一次看完,无需左侧导航。
9//!
10//! 数据流(照 settings 分区的 wasm 门控模式,见 settings/mod.rs 模块文档):
11//! - 挂载后 `use_effect` 一次性拉取 `get_profile` 并回填草稿信号
12//!   (一次性「种子」回填,草稿随后与已存值分叉,非派生状态);
13//! - 保存成功后用响应里的 `PublicUser` 同步刷新 `UserContext`,
14//!   侧栏用户卡片即时更新,无需整页刷新;
15//! - 头像选择复用 `AssetPickerModal` 单选模式(上传新图 / 素材库选择同一弹窗)。
16//!
17//! 注意:`AssetPickerModal` 是 `position: fixed` 弹层,必须挂在
18//! `animate-page-enter` 动画树**之外**——CSS transform 会让 fixed 定位
19//! 以变换祖先为参照系而非视口(MCP 页已踩过,见 mcp.rs 修复记录)。
20//! 因此本页外层是无动画的布局 wrapper,动画类只加在内容列上。
21
22use dioxus::prelude::*;
23
24#[cfg(target_arch = "wasm32")]
25use std::sync::Arc;
26
27#[cfg(target_arch = "wasm32")]
28use crate::api::profile::{change_password, get_profile, update_profile};
29#[cfg(target_arch = "wasm32")]
30use crate::components::assets::{AssetPickerModal, AssetSelection};
31#[cfg(target_arch = "wasm32")]
32use crate::components::forms::{FormInput, FormLabel};
33#[cfg(target_arch = "wasm32")]
34use crate::components::skeletons::profile_skeleton::ProfileSkeleton;
35#[cfg(target_arch = "wasm32")]
36use crate::components::ui::{LoadingButton, StatusBadge, UserAvatar, ADMIN_CARD_CLASS, BTN_GHOST};
37#[cfg(target_arch = "wasm32")]
38use crate::context::UserContext;
39#[cfg(target_arch = "wasm32")]
40use crate::models::user::PublicUser;
41
42/// Material Symbols `photo_camera`(头像 hover 遮罩;`public/icons/` 有存档)。
43#[cfg(target_arch = "wasm32")]
44const ICON_CAMERA: &str = "M479.5-267q72.5 0 121.5-49t49-121.5q0-72.5-49-121T479.5-607q-72.5 0-121 48.5t-48.5 121q0 72.5 48.5 121.5t121 49Zm0-60q-47.5 0-78.5-31.5t-31-79q0-47.5 31-78.5t78.5-31q47.5 0 79 31t31.5 78.5q0 47.5-31.5 79t-79 31.5ZM140-120q-24 0-42-18t-18-42v-513q0-23 18-41.5t42-18.5h147l73-87h240l73 87h147q23 0 41.5 18.5T880-693v513q0 24-18.5 42T820-120H140Zm0-60h680v-513H645l-73-87H388l-73 87H140v513Zm340-257Z";
45/// Material Symbols `badge`(「基本资料」卡头图标;`public/icons/` 有存档)。
46#[cfg(target_arch = "wasm32")]
47const ICON_BADGE: &str = "M140-80q-24 0-42-18t-18-42v-480q0-24 18-42t42-18h250v-140q0-24 18-42t42-18h60q24 0 42 18t18 42v140h250q24 0 42 18t18 42v480q0 24-18 42t-42 18H140Zm0-60h680v-480H570v30q0 28-18 44t-42 16h-60q-24 0-42-16t-18-44v-30H140v480Zm92-107h239v-14q0-18-9-32t-23-19q-32-11-50-14.5t-35-3.5q-19 0-40.5 4.5T265-312q-15 5-24 19t-9 32v14Zm336-67h170v-50H568v50Zm-175.5-65.5Q408-395 408-418t-15.5-38.5Q377-472 354-472t-38.5 15.5Q300-441 300-418t15.5 38.5Q331-364 354-364t38.5-15.5ZM568-427h170v-50H568v50ZM450-590h60v-230h-60v230Zm30 210Z";
48/// Material Symbols `lock`(「安全」卡头图标;`public/icons/` 有存档)。
49#[cfg(target_arch = "wasm32")]
50const ICON_LOCK: &str = "M220-80q-24.75 0-42.37-17.63Q160-115.25 160-140v-434q0-24.75 17.63-42.38Q195.25-634 220-634h70v-96q0-78.85 55.61-134.42Q401.21-920 480.11-920q78.89 0 134.39 55.58Q670-808.85 670-730v96h70q24.75 0 42.38 17.62Q800-598.75 800-574v434q0 24.75-17.62 42.37Q764.75-80 740-80H220Zm0-60h520v-434H220v434Zm314.5-162.03Q557-324.06 557-355q0-30-22.67-54.5t-54.5-24.5q-31.83 0-54.33 24.5t-22.5 55q0 30.5 22.67 52.5t54.5 22q31.83 0 54.33-22.03ZM350-634h260v-96q0-54.17-37.88-92.08-37.88-37.92-92-37.92T388-822.08q-38 37.91-38 92.08v96ZM220-140v-434 434Z";
51
52/// 卡头图标气泡(与 settings 分区卡头同一结构:圆形软底气泡 + 24px 图标)。
53#[cfg(target_arch = "wasm32")]
54fn icon_bubble(path: &'static str) -> Element {
55    rsx! {
56        span { class: "inline-flex items-center justify-center w-10 h-10 rounded-full bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] border border-[var(--color-paper-border)] flex-shrink-0",
57            svg {
58                xmlns: "http://www.w3.org/2000/svg",
59                height: "24px",
60                view_box: "0 -960 960 960",
61                width: "24px",
62                fill: "currentColor",
63                path { d: "{path}" }
64            }
65        }
66    }
67}
68
69/// 后台个人信息页面组件。
70#[component]
71pub fn Profile() -> Element {
72    #[cfg(target_arch = "wasm32")]
73    {
74        let mut ctx: UserContext = use_context();
75
76        let mut loading = use_signal(|| true);
77        let mut saved: Signal<Option<PublicUser>> = use_signal(|| None);
78        let mut admin_env_active = use_signal(|| false);
79
80        // 基本资料草稿(一次性种子回填后为用户可编辑的独立状态)。
81        let mut email_draft = use_signal(String::new);
82        let mut display_name_draft = use_signal(String::new);
83        let mut avatar_draft: Signal<Option<String>> = use_signal(|| None);
84        let mut saving = use_signal(|| false);
85        let mut just_saved = use_signal(|| false);
86
87        // 安全卡草稿。
88        let mut current_pw = use_signal(String::new);
89        let mut new_pw = use_signal(String::new);
90        let mut confirm_pw = use_signal(String::new);
91        let mut changing = use_signal(|| false);
92
93        let mut picker_visible = use_signal(|| false);
94        let avatar_uploading = use_signal(|| false);
95
96        // toast:与 settings/mod.rs 同一实现(展示信号 + 3 秒自动消失)。
97        let mut toast_state: Signal<Option<(String, bool)>> = use_signal(|| None);
98        let mut display_msg: Signal<String> = use_signal(String::new);
99        let mut display_err: Signal<bool> = use_signal(|| false);
100
101        // 初始拉取并种子回填(合法的一次性 effect,见模块文档)。
102        use_effect(move || {
103            spawn(async move {
104                match get_profile().await {
105                    Ok(resp) => {
106                        email_draft.set(resp.user.email.clone());
107                        display_name_draft.set(resp.user.display_name.clone().unwrap_or_default());
108                        avatar_draft.set(resp.user.avatar_url.clone());
109                        admin_env_active.set(resp.admin_env_active);
110                        saved.set(Some(resp.user));
111                    }
112                    Err(e) => {
113                        toast_state.set(Some((format!("加载失败:{e}"), true)));
114                    }
115                }
116                loading.set(false);
117            });
118        });
119
120        use_effect(move || {
121            if let Some((msg, is_err)) = toast_state() {
122                display_msg.set(msg.clone());
123                display_err.set(is_err);
124                let key = msg.clone();
125                spawn(async move {
126                    crate::utils::time::sleep_ms(3000).await;
127                    // 仅当 toast 未被新消息覆盖时清除(避免误清后续 toast)。
128                    if toast_state().map(|(m, _)| m == key).unwrap_or(false) {
129                        toast_state.set(None);
130                    }
131                });
132            }
133        });
134
135        // 脏检查:任一草稿与已存值不同(多字段比较,用 memo 收敛)。
136        let dirty = use_memo(move || {
137            let Some(s) = saved() else {
138                return false;
139            };
140            email_draft().trim() != s.email
141                || display_name_draft().trim() != s.display_name.unwrap_or_default()
142                || avatar_draft() != s.avatar_url
143        });
144
145        rsx! {
146            // 外层布局 wrapper(无动画类):fixed 弹层挂在动画树外,见模块文档。
147            div { class: "w-full max-w-2xl mx-auto",
148                div { class: "animate-page-enter flex flex-col gap-8",
149                    // 页头(与站点设置页同一排版)
150                    div { class: "pb-6 border-b border-[var(--color-paper-border)]",
151                        h1 { class: "text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]",
152                            "个人信息"
153                        }
154                        p { class: "text-base text-[var(--color-paper-secondary)] mt-2",
155                            "管理当前登录账号的展示资料与登录密码"
156                        }
157                    }
158
159                    // 操作提示条(3 秒自动消失)
160                    div { class: if toast_state().is_some() { "ygg-toast is-open" } else { "ygg-toast" },
161                        div { class: if display_err() { "ygg-toast-inner text-sm rounded-lg px-3 py-2 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300" } else { "ygg-toast-inner text-sm rounded-lg px-3 py-2 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300" },
162                            "{display_msg()}"
163                        }
164                    }
165
166                    if loading() {
167                        div { class: "animate-pulse",
168                            ProfileSkeleton {}
169                        }
170                    } else if let Some(user) = saved() {
171                        {
172                            // 身份卡展示值:草稿优先(未保存的修改即时预览),
173                            // 纯派生,内联计算即可。
174                            let label = if display_name_draft().trim().is_empty() {
175                                user.username.clone()
176                            } else {
177                                display_name_draft().trim().to_string()
178                            };
179                            let created = user.created_at.format("%Y-%m-%d").to_string();
180                            rsx! {
181                                // 身份卡:头像 + 展示名 + 角色徽章 + 注册时间
182                                div { class: "{ADMIN_CARD_CLASS} p-6 md:p-8",
183                                    div { class: "flex flex-col sm:flex-row sm:items-center gap-6",
184                                        // 头像(点击打开素材选择弹窗;hover/focus 显示相机遮罩)
185                                        div { class: "relative group flex-shrink-0 self-center sm:self-start",
186                                            button {
187                                                class: "relative block w-24 h-24 rounded-full overflow-hidden border border-[var(--color-paper-border)] shadow-sm cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-paper-accent)]/40",
188                                                aria_label: "更换头像",
189                                                onclick: move |_| picker_visible.set(true),
190                                                UserAvatar {
191                                                    name: label.clone(),
192                                                    avatar_url: avatar_draft(),
193                                                    class: "w-24 h-24 rounded-full text-3xl",
194                                                }
195                                                span { class: "absolute inset-0 flex items-center justify-center bg-black/45 text-white opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity",
196                                                    svg {
197                                                        xmlns: "http://www.w3.org/2000/svg",
198                                                        height: "24px",
199                                                        view_box: "0 -960 960 960",
200                                                        width: "24px",
201                                                        fill: "currentColor",
202                                                        path { d: "{ICON_CAMERA}" }
203                                                    }
204                                                }
205                                            }
206                                        }
207                                        div { class: "flex-1 min-w-0 flex flex-col gap-1.5 text-center sm:text-left",
208                                            div { class: "flex items-center justify-center sm:justify-start gap-2.5 flex-wrap",
209                                                h2 { class: "text-2xl font-bold text-[var(--color-paper-primary)] truncate",
210                                                    "{label}"
211                                                }
212                                                StatusBadge {
213                                                    color_class: "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300",
214                                                    label: "管理员".to_string(),
215                                                }
216                                            }
217                                            p { class: "text-sm text-[var(--color-paper-secondary)] truncate",
218                                                "@{user.username}"
219                                            }
220                                            p { class: "text-xs text-[var(--color-paper-tertiary)]",
221                                                "注册于 {created}"
222                                            }
223                                            div { class: "flex items-center justify-center sm:justify-start gap-3 mt-1",
224                                                span { class: "text-xs text-[var(--color-paper-secondary)]",
225                                                    "点击头像可更换"
226                                                }
227                                                if avatar_draft().is_some() {
228                                                    button {
229                                                        class: "{BTN_GHOST}",
230                                                        onclick: move |_| {
231                                                            avatar_draft.set(None);
232                                                            just_saved.set(false);
233                                                        },
234                                                        "移除头像"
235                                                    }
236                                                }
237                                            }
238                                        }
239                                    }
240                                }
241
242                                // 基本资料卡
243                                div { class: "{ADMIN_CARD_CLASS} p-6 md:p-8 flex flex-col gap-6",
244                                    div { class: "flex items-center gap-3",
245                                        {icon_bubble(ICON_BADGE)}
246                                        div {
247                                            h2 { class: "text-xl font-bold text-[var(--color-paper-primary)]",
248                                                "基本资料"
249                                            }
250                                            p { class: "text-sm text-[var(--color-paper-secondary)] mt-0.5",
251                                                "展示在后台侧栏等位置;邮箱用于登录与找回会话。"
252                                            }
253                                        }
254                                    }
255
256                                    if admin_env_active() {
257                                        div { class: "rounded-xl border border-amber-300/60 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300",
258                                            "检测到 ADMIN_* 环境变量已启用:每次服务重启后,邮箱与密码将以环境变量为准。"
259                                        }
260                                    }
261
262                                    div { class: "flex flex-col gap-2 max-w-xl",
263                                        FormLabel {
264                                            label: "用户名".to_string(),
265                                            html_for: Some("profile-username".to_string()),
266                                        }
267                                        FormInput {
268                                            id: Some("profile-username".to_string()),
269                                            r#type: "text",
270                                            placeholder: "",
271                                            value: user.username.clone(),
272                                            disabled: true,
273                                            oninput: move |_| {},
274                                        }
275                                        p { class: "text-xs text-[var(--color-paper-secondary)]",
276                                            "用户名是唯一登录凭据,不可修改。"
277                                        }
278                                    }
279
280                                    div { class: "flex flex-col gap-2 max-w-xl",
281                                        FormLabel {
282                                            label: "显示名称".to_string(),
283                                            html_for: Some("profile-display-name".to_string()),
284                                        }
285                                        FormInput {
286                                            id: Some("profile-display-name".to_string()),
287                                            r#type: "text",
288                                            placeholder: "留空则显示用户名",
289                                            value: display_name_draft(),
290                                            oninput: move |v: String| {
291                                                display_name_draft.set(v);
292                                                just_saved.set(false);
293                                            },
294                                        }
295                                    }
296
297                                    div { class: "flex flex-col gap-2 max-w-xl",
298                                        FormLabel {
299                                            label: "邮箱".to_string(),
300                                            html_for: Some("profile-email".to_string()),
301                                        }
302                                        FormInput {
303                                            id: Some("profile-email".to_string()),
304                                            r#type: "email",
305                                            placeholder: "name@example.com",
306                                            value: email_draft(),
307                                            oninput: move |v: String| {
308                                                email_draft.set(v);
309                                                just_saved.set(false);
310                                            },
311                                        }
312                                    }
313
314                                    div { class: "flex items-center justify-between gap-4 pt-1",
315                                        if just_saved() {
316                                            span { class: "inline-flex items-center gap-1.5 text-xs text-[var(--color-paper-accent)]",
317                                                svg {
318                                                    class: "w-3.5 h-3.5",
319                                                    view_box: "0 0 24 24",
320                                                    fill: "none",
321                                                    stroke: "currentColor",
322                                                    stroke_width: "2.5",
323                                                    path {
324                                                        stroke_linecap: "round",
325                                                        stroke_linejoin: "round",
326                                                        d: "M5 13l4 4L19 7",
327                                                    }
328                                                }
329                                                "已保存"
330                                            }
331                                        } else if dirty() {
332                                            span { class: "text-xs text-[var(--color-paper-secondary)]",
333                                                "有未保存的更改"
334                                            }
335                                        } else {
336                                            span { class: "text-xs text-transparent select-none", "·" }
337                                        }
338                                        LoadingButton {
339                                            label: "保存资料".to_string(),
340                                            loading: saving(),
341                                            disabled: !dirty() || just_saved() || avatar_uploading(),
342                                            onclick: move |_| {
343                                                let email = email_draft().trim().to_string();
344                                                let display_name = display_name_draft().trim().to_string();
345                                                let avatar = avatar_draft();
346                                                saving.set(true);
347                                                spawn(async move {
348                                                    match update_profile(
349                                                            email,
350                                                            Some(display_name),
351                                                            avatar,
352                                                        )
353                                                        .await
354                                                    {
355                                                        Ok(resp) if resp.success => {
356                                                            if let Some(u) = resp.user {
357                                                                // 用服务端归一化后的值回填草稿,并同步
358                                                                // 全局上下文(侧栏用户卡片即时更新)。
359                                                                email_draft.set(u.email.clone());
360                                                                display_name_draft
361                                                                    .set(u.display_name.clone().unwrap_or_default());
362                                                                avatar_draft.set(u.avatar_url.clone());
363                                                                ctx.user.set(Some(Arc::new(u.clone())));
364                                                                saved.set(Some(u));
365                                                            }
366                                                            just_saved.set(true);
367                                                            toast_state.set(Some((resp.message, false)));
368                                                        }
369                                                        Ok(resp) => {
370                                                            toast_state.set(Some((resp.message, true)));
371                                                        }
372                                                        Err(e) => {
373                                                            toast_state
374                                                                .set(Some((format!("保存失败:{e}"), true)));
375                                                        }
376                                                    }
377                                                    saving.set(false);
378                                                });
379                                            },
380                                        }
381                                    }
382                                }
383
384                                // 安全卡
385                                div { class: "{ADMIN_CARD_CLASS} p-6 md:p-8 flex flex-col gap-6",
386                                    div { class: "flex items-center gap-3",
387                                        {icon_bubble(ICON_LOCK)}
388                                        div {
389                                            h2 { class: "text-xl font-bold text-[var(--color-paper-primary)]",
390                                                "安全"
391                                            }
392                                            p { class: "text-sm text-[var(--color-paper-secondary)] mt-0.5",
393                                                "修改登录密码;需要验证当前密码。"
394                                            }
395                                        }
396                                    }
397
398                                    div { class: "flex flex-col gap-2 max-w-xl",
399                                        FormLabel {
400                                            label: "当前密码".to_string(),
401                                            html_for: Some("profile-current-pw".to_string()),
402                                        }
403                                        FormInput {
404                                            id: Some("profile-current-pw".to_string()),
405                                            r#type: "password",
406                                            placeholder: "输入当前密码",
407                                            value: current_pw(),
408                                            oninput: move |v: String| current_pw.set(v),
409                                        }
410                                    }
411
412                                    div { class: "grid sm:grid-cols-2 gap-4 max-w-xl",
413                                        div { class: "flex flex-col gap-2",
414                                            FormLabel {
415                                                label: "新密码".to_string(),
416                                                html_for: Some("profile-new-pw".to_string()),
417                                            }
418                                            FormInput {
419                                                id: Some("profile-new-pw".to_string()),
420                                                r#type: "password",
421                                                placeholder: "至少 8 位",
422                                                value: new_pw(),
423                                                oninput: move |v: String| new_pw.set(v),
424                                            }
425                                        }
426                                        div { class: "flex flex-col gap-2",
427                                            FormLabel {
428                                                label: "确认新密码".to_string(),
429                                                html_for: Some("profile-confirm-pw".to_string()),
430                                            }
431                                            FormInput {
432                                                id: Some("profile-confirm-pw".to_string()),
433                                                r#type: "password",
434                                                placeholder: "再次输入新密码",
435                                                value: confirm_pw(),
436                                                oninput: move |v: String| confirm_pw.set(v),
437                                            }
438                                        }
439                                    }
440
441                                    if !confirm_pw().is_empty() && new_pw() != confirm_pw() {
442                                        p { class: "text-xs text-red-500 dark:text-red-400",
443                                            "两次输入的新密码不一致。"
444                                        }
445                                    }
446
447                                    p { class: "text-xs text-[var(--color-paper-secondary)] max-w-xl",
448                                        if admin_env_active() {
449                                            "修改成功后,其他设备的登录会话将自动退出,本端保持登录;已启用 ADMIN_* 环境变量时,重启后密码以环境变量为准。"
450                                        } else {
451                                            "修改成功后,其他设备的登录会话将自动退出,本端保持登录。"
452                                        }
453                                    }
454
455                                    div { class: "flex justify-end pt-1",
456                                        LoadingButton {
457                                            label: "修改密码".to_string(),
458                                            loading: changing(),
459                                            disabled: current_pw().is_empty()
460                                                || new_pw().len() < 8
461                                                || new_pw() != confirm_pw(),
462                                            onclick: move |_| {
463                                                let current = current_pw();
464                                                let new = new_pw();
465                                                changing.set(true);
466                                                spawn(async move {
467                                                    match change_password(current, new).await {
468                                                        Ok(resp) if resp.success => {
469                                                            current_pw.set(String::new());
470                                                            new_pw.set(String::new());
471                                                            confirm_pw.set(String::new());
472                                                            toast_state.set(Some((resp.message, false)));
473                                                        }
474                                                        Ok(resp) => {
475                                                            toast_state.set(Some((resp.message, true)));
476                                                        }
477                                                        Err(e) => {
478                                                            toast_state
479                                                                .set(Some((format!("修改失败:{e}"), true)));
480                                                        }
481                                                    }
482                                                    changing.set(false);
483                                                });
484                                            },
485                                        }
486                                    }
487                                }
488                            }
489                        }
490                    } else {
491                        div { class: "{ADMIN_CARD_CLASS} p-8 text-center text-sm text-[var(--color-paper-secondary)]",
492                            "加载失败,请刷新页面重试。"
493                        }
494                    }
495                }
496
497                // 素材选择弹窗(单选):fixed 定位,必须挂在动画树外(见模块文档)。
498                AssetPickerModal {
499                    visible: picker_visible,
500                    cover_uploading: avatar_uploading,
501                    title: "选择头像",
502                    on_select: move |picks: Vec<AssetSelection>| {
503                        // 单选模式:载荷恰含一个元素。
504                        if let Some(first) = picks.into_iter().next() {
505                            avatar_draft.set(Some(first.url));
506                            just_saved.set(false);
507                        }
508                    },
509                }
510            }
511        }
512    }
513    #[cfg(not(target_arch = "wasm32"))]
514    {
515        rsx! {
516            div { class: "p-8 text-[var(--color-paper-secondary)]", "个人信息(前端渲染)" }
517        }
518    }
519}