Skip to main content

yggdrasil/pages/admin/
profile.rs

1//! 后台「个人资料」页面(`/admin/profile`)。
2//!
3//! 三个分区纵向排列:个人信息(头像、只读用户名、显示名称、邮箱,改动后才能保存)、
4//! 修改密码(当前密码、新密码与强度条、确认)、退出登录。username 是唯一登录凭据,
5//! 只读展示。启用 ADMIN_* 环境变量时顶部提示重启后以环境变量为准。保存成功后用
6//! 响应里的 `PublicUser` 刷新 `UserContext`,侧栏即时更新。密码强度条只在浏览器端
7//! 提示,不改变服务端校验。样式见 styles/pages/admin-profile.css。
8
9use std::sync::Arc;
10
11use leptos::prelude::*;
12use leptos::task::spawn_local;
13use leptos_router::{lazy_route, LazyRoute};
14
15use crate::components::admin_layout::{AdminActions, AdminPageHead};
16use crate::components::assets::{AssetPickerModal, AssetSelection};
17use crate::components::forms::FormInput;
18use crate::components::skeletons::profile_skeleton::ProfileSkeleton;
19use crate::components::ui::{
20    button_class, use_toast, ButtonSize, ButtonVariant, Checkbox, Icon, IconName, LoadError,
21};
22use crate::context::UserContext;
23use crate::models::user::PublicUser;
24use crate::utils::error::failure_message;
25
26/// 新密码最短字符数,与服务端校验一致。
27const MIN_PASSWORD: usize = 8;
28
29/// 密码强度 0–4:空、不足 8 位、8–11 位、12 位以上、12 位以上且含符号。
30fn password_level(pw: &str) -> u8 {
31    let len = pw.chars().count();
32    if len == 0 {
33        0
34    } else if len < MIN_PASSWORD {
35        1
36    } else if len < 12 {
37        2
38    } else if pw.chars().any(|c| !c.is_ascii_alphanumeric()) {
39        4
40    } else {
41        3
42    }
43}
44
45/// 强度条下方的说明。
46fn password_hint(pw: &str) -> String {
47    let len = pw.chars().count();
48    match password_level(pw) {
49        0 => format!("至少 {MIN_PASSWORD} 位。"),
50        1 => format!("还差 {} 位。", MIN_PASSWORD - len),
51        4 => "强度不错。".into(),
52        _ => "可以再长一些,或加入符号。".into(),
53    }
54}
55
56/// 个人资料路由(无 SSR 数据,资料在客户端读取)。
57pub struct ProfileRoute;
58
59#[lazy_route]
60impl LazyRoute for ProfileRoute {
61    fn data() -> Self {
62        Self
63    }
64
65    fn view(_this: Self) -> AnyView {
66        view! { <Profile/> }.into_any()
67    }
68}
69
70/// 后台个人资料页面。
71#[component]
72fn Profile() -> impl IntoView {
73    let user = RwSignal::new(None::<PublicUser>);
74    let env_admin = RwSignal::new(false);
75    let error = RwSignal::new(false);
76    let reload = RwSignal::new(0_u32);
77    let generation = StoredValue::new(0_u64);
78    let actions = use_context::<AdminActions>();
79
80    // 仅客户端读取(Effect 不在服务端运行);世代号丢弃过期响应。
81    Effect::new(move |_| {
82        reload.track();
83        let mut id = 0;
84        generation.update_value(|g| {
85            *g += 1;
86            id = *g;
87        });
88        spawn_local(async move {
89            let result = crate::api::profile::get_profile().await;
90            if generation.try_get_value() != Some(id) {
91                return;
92            }
93            match result {
94                Ok(resp) => {
95                    let _ = env_admin.try_set(resp.admin_env_active);
96                    let _ = user.try_set(Some(resp.user));
97                    let _ = error.try_set(false);
98                }
99                Err(_) => {
100                    let _ = error.try_set(true);
101                }
102            }
103        });
104    });
105
106    // 按用户 id 重建分区:保存资料后 `user` 更新,但表单与密码输入保持原样。
107    let user_id = Memo::new(move |_| user.with(|u| u.as_ref().map(|u| u.id)));
108
109    view! {
110        <div class="admin-profile">
111            <AdminPageHead title="个人资料">"当前登录账号的展示资料与登录密码。"</AdminPageHead>
112            {move || {
113                env_admin
114                    .get()
115                    .then(|| {
116                        view! {
117                            <p class="admin-profile-note" role="note">
118                                <Icon name=IconName::TriangleAlert size=16/>
119                                "已启用 ADMIN_* 环境变量:每次服务重启后,邮箱与密码会以环境变量为准。"
120                            </p>
121                        }
122                    })
123            }}
124            {move || {
125                if error.get() {
126                    view! {
127                        <LoadError
128                            title="个人资料加载失败"
129                            on_retry=move |()| reload.update(|n| *n += 1)
130                        />
131                    }
132                        .into_any()
133                } else if user_id.get().is_some() {
134                    view! {
135                        <InfoSection user=user on_saved=move |u: PublicUser| user.set(Some(u))/>
136                        <PasswordSection/>
137                        <section class="admin-profile-logout" aria-labelledby="profile-out-h">
138                            <div>
139                                <h2 id="profile-out-h">"退出登录"</h2>
140                                <p>"只退出当前设备。"</p>
141                            </div>
142                            <button
143                                class=button_class(ButtonVariant::Outline, ButtonSize::Md)
144                                data-danger="true"
145                                type="button"
146                                on:click=move |_| {
147                                    if let Some(actions) = actions {
148                                        actions.logout.run(());
149                                    }
150                                }
151                            >
152                                <Icon name=IconName::LogOut size=15/>
153                                "退出登录"
154                            </button>
155                        </section>
156                    }
157                        .into_any()
158                } else {
159                    view! { <ProfileSkeleton/> }.into_any()
160                }
161            }}
162        </div>
163    }
164}
165
166/// 个人信息:头像、用户名、显示名称、邮箱。
167#[component]
168fn InfoSection(
169    user: RwSignal<Option<PublicUser>>,
170    #[prop(into)] on_saved: Callback<PublicUser>,
171) -> impl IntoView {
172    let toast = use_toast();
173    let ctx = expect_context::<UserContext>();
174    let base_display = Memo::new(move |_| {
175        user.with(|u| {
176            u.as_ref()
177                .and_then(|u| u.display_name.clone())
178                .unwrap_or_default()
179        })
180    });
181    let base_email =
182        Memo::new(move |_| user.with(|u| u.as_ref().map(|u| u.email.clone()).unwrap_or_default()));
183    let base_avatar =
184        Memo::new(move |_| user.with(|u| u.as_ref().and_then(|u| u.avatar_url.clone())));
185    let username = Memo::new(move |_| {
186        user.with(|u| u.as_ref().map(|u| u.username.clone()).unwrap_or_default())
187    });
188    let display = RwSignal::new(base_display.get_untracked());
189    let email = RwSignal::new(base_email.get_untracked());
190    let avatar = RwSignal::new(base_avatar.get_untracked());
191    let saving = RwSignal::new(false);
192    let picker = RwSignal::new(false);
193    let uploading = RwSignal::new(false);
194    let avatar_failed = RwSignal::new(false);
195
196    let dirty = Memo::new(move |_| {
197        display.with(|d| d.trim() != base_display.get())
198            || email.with(|e| e.trim() != base_email.get())
199            || avatar.get() != base_avatar.get()
200    });
201    let initial = Memo::new(move |_| {
202        let label = display.with(|d| {
203            if d.trim().is_empty() {
204                username.get()
205            } else {
206                d.trim().to_string()
207            }
208        });
209        label
210            .chars()
211            .next()
212            .map(|c| c.to_uppercase().to_string())
213            .unwrap_or_default()
214    });
215
216    let save = move |e: leptos::ev::SubmitEvent| {
217        e.prevent_default();
218        if saving.get_untracked() {
219            return;
220        }
221        saving.set(true);
222        let (mail, name, av) = (
223            email.get_untracked().trim().to_string(),
224            display.get_untracked().trim().to_string(),
225            avatar.get_untracked(),
226        );
227        spawn_local(async move {
228            match crate::api::profile::update_profile(
229                mail,
230                Some(name).filter(|n| !n.is_empty()),
231                av,
232            )
233            .await
234            {
235                Ok(resp) if resp.success => {
236                    if let Some(u) = resp.user {
237                        let _ = ctx.user.try_set(Some(Arc::new(u.clone())));
238                        on_saved.try_run(u);
239                    }
240                    toast.success("资料已保存");
241                }
242                Ok(resp) => toast.error(resp.message),
243                Err(e) => toast.error(failure_message("保存失败", &e)),
244            }
245            let _ = saving.try_set(false);
246        });
247    };
248    let upload = move |evt: leptos::ev::Event| {
249        #[cfg(target_arch = "wasm32")]
250        {
251            let Some(file) = event_target::<web_sys::HtmlInputElement>(&evt)
252                .files()
253                .and_then(|list| list.item(0))
254            else {
255                return;
256            };
257            if let Err(msg) = crate::components::assets::upload_pool::validate_file(
258                &file.type_(),
259                file.size() as u64,
260            ) {
261                toast.error(msg);
262                return;
263            }
264            uploading.set(true);
265            spawn_local(async move {
266                match crate::bridges::tiptap::upload_image_file(file).await {
267                    Ok(url) => {
268                        let _ = avatar.try_set(Some(url));
269                        let _ = avatar_failed.try_set(false);
270                    }
271                    Err(msg) => toast.error(format!("上传失败:{msg}")),
272                }
273                let _ = uploading.try_set(false);
274            });
275        }
276        #[cfg(not(target_arch = "wasm32"))]
277        let _ = (evt, toast);
278    };
279
280    // 带泛型的闭包参数类型放在 `view!` 外,避免宏把 `<` 当作标签。
281    let on_pick = move |picks: Vec<AssetSelection>| {
282        if let Some(first) = picks.into_iter().next() {
283            avatar.set(Some(first.url));
284            avatar_failed.set(false);
285        }
286    };
287
288    view! {
289        <form
290            class="admin-panel admin-profile-section"
291            aria-labelledby="profile-info-h"
292            on:submit=save
293        >
294            <div class="admin-profile-section-head">
295                <h2 id="profile-info-h">"个人信息"</h2>
296                <p>"显示名称会出现在后台侧栏和文章署名;邮箱用于登录。"</p>
297            </div>
298            <div class="admin-profile-rows">
299                <div class="admin-profile-row">
300                    <span class="admin-profile-label">"头像"</span>
301                    <div class="admin-profile-avatar-row">
302                        <span class="admin-profile-avatar" aria-hidden="true">
303                            {move || match avatar.get().filter(|_| !avatar_failed.get()) {
304                                Some(url) => {
305                                    view! {
306                                        <img src=url alt="" on:error=move |_| avatar_failed.set(true)/>
307                                    }
308                                        .into_any()
309                                }
310                                None => initial.get().into_any(),
311                            }}
312                        </span>
313                        <button
314                            class=button_class(ButtonVariant::Secondary, ButtonSize::Sm)
315                            type="button"
316                            on:click=move |_| picker.set(true)
317                        >
318                            "从素材库选择"
319                        </button>
320                        <label
321                            class=format!(
322                                "{} admin-profile-upload",
323                                button_class(ButtonVariant::Secondary, ButtonSize::Sm),
324                            )
325                            aria-disabled=move || uploading.get().then_some("true")
326                        >
327                            {move || if uploading.get() { "上传中…" } else { "上传" }}
328                            <input
329                                type="file"
330                                class="sr-only"
331                                accept="image/jpeg,image/png,image/gif,image/webp"
332                                disabled=move || uploading.get()
333                                on:change=upload
334                            />
335                        </label>
336                        {move || {
337                            avatar
338                                .with(Option::is_some)
339                                .then(|| {
340                                    view! {
341                                        <button
342                                            class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
343                                            type="button"
344                                            on:click=move |_| avatar.set(None)
345                                        >
346                                            "移除"
347                                        </button>
348                                    }
349                                })
350                        }}
351                    </div>
352                </div>
353                <div class="admin-profile-row">
354                    <span class="admin-profile-label">"用户名"</span>
355                    <div>
356                        <div class="admin-profile-readonly">{move || username.get()}</div>
357                        <p class="admin-profile-hint">"用户名是唯一的登录凭据,不能修改。"</p>
358                    </div>
359                </div>
360                <div class="admin-profile-row">
361                    <label class="admin-profile-label" for="profile-display">
362                        "显示名称"
363                    </label>
364                    <FormInput
365                        id="profile-display"
366                        placeholder="留空则显示用户名"
367                        maxlength=50
368                        value=display
369                        oninput=move |v: String| display.set(v)
370                    />
371                </div>
372                <div class="admin-profile-row">
373                    <label class="admin-profile-label" for="profile-email">
374                        "邮箱"
375                    </label>
376                    <FormInput
377                        id="profile-email"
378                        class="field admin-profile-mono"
379                        r#type="email"
380                        placeholder="name@example.com"
381                        value=email
382                        oninput=move |v: String| email.set(v)
383                    />
384                </div>
385            </div>
386            <div class="admin-profile-foot">
387                <button
388                    class=button_class(ButtonVariant::Primary, ButtonSize::Md)
389                    type="submit"
390                    disabled=move || !dirty.get() || saving.get()
391                >
392                    {move || if saving.get() { "保存中…" } else { "保存" }}
393                </button>
394            </div>
395        </form>
396        <AssetPickerModal
397            visible=picker
398            cover_uploading=uploading
399            title="选择头像"
400            on_select=on_pick
401        />
402    }
403}
404
405/// 修改密码:当前密码、新密码(强度条)、确认新密码。
406#[component]
407fn PasswordSection() -> impl IntoView {
408    let toast = use_toast();
409    let current = RwSignal::new(String::new());
410    let next = RwSignal::new(String::new());
411    let confirm = RwSignal::new(String::new());
412    let busy = RwSignal::new(false);
413    let revoke_mcp = RwSignal::new(false);
414
415    let level = Memo::new(move |_| next.with(|n| password_level(n)));
416    let mismatch = Memo::new(move |_| confirm.with(|c| next.with(|n| !c.is_empty() && c != n)));
417    let can_submit = Memo::new(move |_| {
418        !busy.get()
419            && current.with(|c| !c.is_empty())
420            && next.with(|n| n.chars().count() >= MIN_PASSWORD)
421            && confirm.with(|c| next.with(|n| c == n))
422    });
423
424    let submit = move |e: leptos::ev::SubmitEvent| {
425        e.prevent_default();
426        if !can_submit.get_untracked() {
427            return;
428        }
429        busy.set(true);
430        let (old, new) = (current.get_untracked(), next.get_untracked());
431        let revoke = revoke_mcp.get_untracked();
432        spawn_local(async move {
433            match crate::api::profile::change_password(old, new, revoke).await {
434                Ok(resp) if resp.success => {
435                    let _ = current.try_set(String::new());
436                    let _ = next.try_set(String::new());
437                    let _ = confirm.try_set(String::new());
438                    let _ = revoke_mcp.try_set(false);
439                    toast.success(resp.message);
440                }
441                Ok(resp) => toast.error(resp.message),
442                Err(e) => toast.error(failure_message("修改失败", &e)),
443            }
444            let _ = busy.try_set(false);
445        });
446    };
447
448    view! {
449        <form
450            class="admin-panel admin-profile-section"
451            aria-labelledby="profile-pw-h"
452            on:submit=submit
453        >
454            <div class="admin-profile-section-head">
455                <h2 id="profile-pw-h">"修改密码"</h2>
456                <p>"修改成功后,其他设备上的登录会自动退出,当前设备保持登录。"</p>
457            </div>
458            <div class="admin-profile-rows">
459                <div class="admin-profile-row">
460                    <label class="admin-profile-label" for="profile-pw0">
461                        "当前密码"
462                    </label>
463                    <FormInput
464                        id="profile-pw0"
465                        r#type="password"
466                        autocomplete="current-password"
467                        value=current
468                        oninput=move |v: String| current.set(v)
469                    />
470                </div>
471                <div class="admin-profile-row">
472                    <label class="admin-profile-label" for="profile-pw1">
473                        "新密码"
474                    </label>
475                    <div>
476                        <FormInput
477                            id="profile-pw1"
478                            r#type="password"
479                            autocomplete="new-password"
480                            aria_describedby="profile-pw1-hint"
481                            value=next
482                            oninput=move |v: String| next.set(v)
483                        />
484                        <div
485                            class="admin-profile-meter"
486                            data-level=move || level.get().to_string()
487                            aria-hidden="true"
488                        >
489                            {(1..=4u8)
490                                .map(|i| {
491                                    view! { <span data-on=move || (i <= level.get()).then_some("true")></span> }
492                                })
493                                .collect_view()}
494                        </div>
495                        <p id="profile-pw1-hint" class="admin-profile-hint">
496                            {move || next.with(|n| password_hint(n))}
497                        </p>
498                    </div>
499                </div>
500                <div class="admin-profile-row">
501                    <label class="admin-profile-label" for="profile-pw2">
502                        "确认新密码"
503                    </label>
504                    <div>
505                        <FormInput
506                            id="profile-pw2"
507                            r#type="password"
508                            autocomplete="new-password"
509                            invalid=mismatch
510                            value=confirm
511                            oninput=move |v: String| confirm.set(v)
512                        />
513                        {move || {
514                            mismatch
515                                .get()
516                                .then(|| {
517                                    view! {
518                                        <p class="admin-profile-hint" data-error="true" role="alert">
519                                            "两次输入的新密码不一致。"
520                                        </p>
521                                    }
522                                })
523                        }}
524                    </div>
525                </div>
526                <div class="admin-profile-row">
527                    <span class="admin-profile-label">"MCP 令牌"</span>
528                    <div>
529                        <label class="admin-profile-check">
530                            <Checkbox
531                                checked=revoke_mcp
532                                onchange=move |v: bool| revoke_mcp.set(v)
533                            />
534                            "同时吊销我的全部 MCP 令牌"
535                        </label>
536                        <p class="admin-profile-hint">
537                            "令牌默认不随改密失效。怀疑密码泄露时勾选,AI 客户端需重新配置令牌。"
538                        </p>
539                    </div>
540                </div>
541            </div>
542            <div class="admin-profile-foot">
543                <button
544                    class=button_class(ButtonVariant::Secondary, ButtonSize::Md)
545                    type="submit"
546                    disabled=move || !can_submit.get()
547                >
548                    {move || if busy.get() { "修改中…" } else { "修改密码" }}
549                </button>
550            </div>
551        </form>
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    #[test]
560    fn password_level_grows_with_length_and_symbols() {
561        assert_eq!(password_level(""), 0);
562        assert_eq!(password_level("abc"), 1);
563        assert_eq!(password_level("abcdefgh"), 2);
564        assert_eq!(password_level("abcdefghijkl"), 3);
565        assert_eq!(password_level("abcdefghijk!"), 4);
566        // 中文按字符计。
567        assert_eq!(password_level("一二三四五六七八"), 2);
568    }
569
570    #[test]
571    fn password_hint_counts_missing_characters() {
572        assert_eq!(password_hint(""), "至少 8 位。");
573        assert_eq!(password_hint("abcde"), "还差 3 位。");
574        assert_eq!(password_hint("abcdefghijk!"), "强度不错。");
575    }
576}