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}