feat(web): 开发账户修改、密码修改功能

This commit is contained in:
R524809
2026-09-03 16:56:53 +08:00
parent 46d98ab11e
commit 06a3a4da75
9 changed files with 435 additions and 25 deletions
+87
View File
@@ -1 +1,88 @@
/* 全局应用级样式补充(主题变量见 index.css) */ /* 全局应用级样式补充(主题变量见 index.css) */
/* ── 设置类页面(修改资料 / 修改密码):内容区居中卡片 ── */
.settings-page {
display: flex;
justify-content: center;
padding: 24px 0;
}
.settings-card {
width: 100%;
max-width: 720px;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.08);
}
.settings-card-title {
padding: 20px 32px;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
border-bottom: 1px solid var(--border-color);
}
.settings-card-body {
padding: 32px;
}
.settings-field-label {
font-size: 14px;
color: var(--text-primary);
margin-bottom: 12px;
}
.avatar-upload-area {
display: flex;
justify-content: center;
}
.avatar-upload-placeholder {
position: relative;
cursor: pointer;
border-radius: 50%;
}
.avatar-upload-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
color: #6b7280;
font-size: 13px;
border-radius: 50%;
background: transparent;
transition: background 0.2s;
}
.avatar-upload-placeholder:hover .avatar-upload-overlay {
background: rgba(255, 255, 255, 0.55);
}
.avatar-preview {
cursor: pointer;
}
.avatar-upload-hint {
text-align: center;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.8;
margin: 12px 0 24px;
}
.profile-form {
max-width: 420px;
margin: 0 auto;
}
.settings-actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 8px;
}
+18 -10
View File
@@ -102,22 +102,30 @@
gap: 12px; gap: 12px;
} }
.header-user { /* 头像 + 用户名触发区:hover 有浅色背景反馈,暗示可下拉 */
.header-user-trigger {
display: flex; display: flex;
flex-direction: column; align-items: center;
align-items: flex-end; gap: 8px;
line-height: 1.25; padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
max-width: 240px;
} }
.header-user-workspace { .header-user-trigger:hover {
background: #f3f4f6;
}
/* 用户名超长省略 */
.header-user-name {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #1f2937; color: #1f2937;
} white-space: nowrap;
overflow: hidden;
.header-user-email { text-overflow: ellipsis;
font-size: 12px;
color: #6b7280;
} }
.collapse-trigger, .collapse-trigger,
+48 -10
View File
@@ -1,7 +1,14 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Breadcrumb, Button, Drawer, Layout } from 'antd'; import { Avatar, Breadcrumb, Drawer, Dropdown, Layout } from 'antd';
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons'; import type { MenuProps } from 'antd';
import {
LockOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
UserOutlined,
} from '@ant-design/icons';
import SidebarMenu from './SidebarMenu'; import SidebarMenu from './SidebarMenu';
import { getBreadcrumbItems } from './menuConfig'; import { getBreadcrumbItems } from './menuConfig';
import { useSession } from '@/services/session'; import { useSession } from '@/services/session';
@@ -31,6 +38,9 @@ const MainLayout = () => {
[location.pathname] [location.pathname]
); );
const userDisplayName = session ? (session.user.display_name ?? session.user.email) : '';
const userInitial = userDisplayName.charAt(0).toUpperCase();
const handleLogout = async () => { const handleLogout = async () => {
setLoggingOut(true); setLoggingOut(true);
try { try {
@@ -41,6 +51,28 @@ const MainLayout = () => {
} }
}; };
const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => {
if (key === 'profile') {
navigate('/settings/profile');
} else if (key === 'password') {
navigate('/settings/password');
} else if (key === 'logout') {
void handleLogout();
}
};
const userMenuItems: MenuProps['items'] = [
{ key: 'profile', icon: <UserOutlined />, label: '修改资料' },
{ key: 'password', icon: <LockOutlined />, label: '修改密码' },
{ type: 'divider' },
{
key: 'logout',
icon: <LogoutOutlined />,
label: loggingOut ? '退出中…' : '退出登录',
disabled: loggingOut,
},
];
useEffect(() => { useEffect(() => {
const checkMobile = () => { const checkMobile = () => {
setIsMobile(window.innerWidth < 768); setIsMobile(window.innerWidth < 768);
@@ -124,15 +156,21 @@ const MainLayout = () => {
</div> </div>
{session && ( {session && (
<div className="header-right"> <div className="header-right">
<div className="header-user"> <Dropdown
<span className="header-user-workspace"> menu={{ items: userMenuItems, onClick: handleUserMenuClick }}
{session.workspace.name} trigger={['hover']}
</span> placement="bottomRight"
<span className="header-user-email">{session.user.email}</span> >
<div className="header-user-trigger" title={userDisplayName}>
<Avatar
size={28}
style={{ backgroundColor: 'var(--sider-bg)' }}
>
{userInitial}
</Avatar>
<span className="header-user-name">{userDisplayName}</span>
</div> </div>
<Button size="small" loading={loggingOut} onClick={handleLogout}> </Dropdown>
退出登录
</Button>
</div> </div>
)} )}
</Header> </Header>
+16 -1
View File
@@ -4,8 +4,11 @@ import {
EditOutlined, EditOutlined,
GlobalOutlined, GlobalOutlined,
InboxOutlined, InboxOutlined,
LockOutlined,
PictureOutlined, PictureOutlined,
SettingOutlined,
ShopOutlined, ShopOutlined,
UserOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
@@ -59,7 +62,7 @@ export interface BreadcrumbCrumb {
label: string; label: string;
} }
/** 页头面包屑:一级菜单页显示单级;商品 AI 编辑页不在菜单中,显示「商品 > 商品编辑」两级 */ /** 页头面包屑:一级菜单页显示单级;不在菜单中的页面显示两级 */
export const getBreadcrumbItems = (path: string): BreadcrumbCrumb[] => { export const getBreadcrumbItems = (path: string): BreadcrumbCrumb[] => {
if (path.startsWith('/products/')) { if (path.startsWith('/products/')) {
return [ return [
@@ -67,6 +70,18 @@ export const getBreadcrumbItems = (path: string): BreadcrumbCrumb[] => {
{ icon: <EditOutlined />, label: '商品编辑' }, { icon: <EditOutlined />, label: '商品编辑' },
]; ];
} }
if (path === '/settings/profile') {
return [
{ icon: <SettingOutlined />, label: '设置' },
{ icon: <UserOutlined />, label: '修改资料' },
];
}
if (path === '/settings/password') {
return [
{ icon: <SettingOutlined />, label: '设置' },
{ icon: <LockOutlined />, label: '修改密码' },
];
}
const config = routeMenuConfig.find((item) => path.startsWith(item.path)) ?? routeMenuConfig[0]; const config = routeMenuConfig.find((item) => path.startsWith(item.path)) ?? routeMenuConfig[0];
return [{ icon: config.icon, label: config.label }]; return [{ icon: config.icon, label: config.label }];
}; };
@@ -0,0 +1,96 @@
import { useState } from 'react';
import { App as AntdApp, Button, Form, Input } from 'antd';
interface PasswordFormValues {
old_password: string;
password: string;
password_confirmation: string;
}
/**
* 修改密码页:接口契约尚未提供(缺口已提报后端 AI,需「原密码 + 新密码」的修改密码接口)。
* 提交时不做任何本地伪成功,明确提示暂不可用;契约交付后接入真实接口。
*/
const PasswordSettingsPage = () => {
const { message } = AntdApp.useApp();
const [submitting, setSubmitting] = useState(false);
const handleFinish = async (_values: PasswordFormValues) => {
setSubmitting(true);
try {
await new Promise((resolve) => setTimeout(resolve, 300));
message.warning('修改密码接口尚未提供(契约缺口已提报后端 AI),暂无法保存');
} finally {
setSubmitting(false);
}
};
return (
<div className="settings-page">
<div className="settings-card">
<div className="settings-card-title">修改密码</div>
<div className="settings-card-body">
<Form<PasswordFormValues>
layout="vertical"
requiredMark={false}
onFinish={handleFinish}
className="profile-form"
>
<Form.Item
name="old_password"
label="原密码"
rules={[{ required: true, message: '请输入原密码' }]}
>
<Input.Password
placeholder="请输入原密码"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item
name="password"
label="新密码"
extra="至少 12 个字符;长度与复杂度以服务端安全校验为准"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 12, message: '新密码至少需要 12 个字符' },
]}
>
<Input.Password
placeholder="请输入新密码"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
name="password_confirmation"
label="确认新密码"
dependencies={['password']}
rules={[
{ required: true, message: '请再次输入新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的新密码不一致'));
},
}),
]}
>
<Input.Password
placeholder="请再次输入新密码"
autoComplete="new-password"
/>
</Form.Item>
<div className="settings-actions">
<Button type="primary" htmlType="submit" loading={submitting}>
保存修改
</Button>
</div>
</Form>
</div>
</div>
</div>
);
};
export default PasswordSettingsPage;
@@ -0,0 +1,141 @@
import { useMemo, useState } from 'react';
import { App as AntdApp, Avatar, Button, Form, Input, Upload } from 'antd';
import type { UploadFile } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { authApi } from '@/services/auth';
import { useSession } from '@/services/session';
import { apiErrorMessage } from '@/services/api';
interface ProfileFormValues {
display_name: string;
}
const ACCEPTED_TYPES = ['image/jpeg', 'image/png'];
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
/**
* 修改资料页(docs/api/auth.md 尚无对应接口):
* 昵称保存走【临时 mock】仅本地生效;头像选择后仅本地预览,
* 持久化依赖用户头像字段与素材上传接口(契约缺口已提报后端 AI)。
*/
const ProfileSettingsPage = () => {
const { message } = AntdApp.useApp();
const { session, applyProfileUpdate } = useSession();
const [form] = Form.useForm<ProfileFormValues>();
const [saving, setSaving] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const displayName = session?.user.display_name ?? session?.user.email ?? '';
const initial = useMemo(() => displayName.charAt(0).toUpperCase(), [displayName]);
if (!session) return null;
const handleBeforeUpload = (file: UploadFile): boolean => {
const blobType = file.type ?? '';
if (!ACCEPTED_TYPES.includes(blobType)) {
message.error('仅支持 JPG、PNG 格式图片');
return false;
}
if ((file.size ?? 0) > MAX_AVATAR_BYTES) {
message.error('图片大小不能超过 2MB');
return false;
}
if (file.originFileObj) {
setPreviewUrl(URL.createObjectURL(file.originFileObj));
}
// 阻止 antd 自动上传:当前无头像上传接口,仅本地预览
return false;
};
const handleSave = async (values: ProfileFormValues) => {
setSaving(true);
try {
// 临时 mock:仅本地生效,接口交付后替换为真实调用
const result = await authApi.updateProfile({
display_name: values.display_name.trim(),
});
applyProfileUpdate({ ...session.user, ...result.user });
message.success('资料已更新(当前为本地临时生效,持久化待接口交付)');
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
setSaving(false);
}
};
const handleReset = () => {
form.setFieldValue('display_name', session.user.display_name ?? '');
setPreviewUrl(null);
};
return (
<div className="settings-page">
<div className="settings-card">
<div className="settings-card-title">修改个人信息</div>
<div className="settings-card-body">
<div className="settings-field-label">头像</div>
<div className="avatar-upload-area">
<Upload
showUploadList={false}
accept=".jpg,.jpeg,.png"
beforeUpload={handleBeforeUpload}
>
{previewUrl ? (
<Avatar size={160} src={previewUrl} className="avatar-preview" />
) : (
<div className="avatar-upload-placeholder">
<Avatar
size={160}
style={{ backgroundColor: 'var(--sider-bg)' }}
>
{initial}
</Avatar>
<div className="avatar-upload-overlay">
<PlusOutlined />
<div>上传头像</div>
</div>
</div>
)}
</Upload>
</div>
<div className="avatar-upload-hint">
支持 JPG、PNG 格式,文件大小不超过 2MB
<br />
建议尺寸:200x200 像素
</div>
<Form<ProfileFormValues>
form={form}
layout="vertical"
requiredMark={false}
initialValues={{ display_name: session.user.display_name ?? '' }}
onFinish={handleSave}
className="profile-form"
>
<Form.Item
name="display_name"
label="昵称"
required
rules={[{ required: true, message: '请输入昵称' }]}
>
<Input
maxLength={80}
showCount
placeholder="请输入昵称"
autoComplete="nickname"
/>
</Form.Item>
<div className="settings-actions">
<Button type="primary" htmlType="submit" loading={saving}>
保存修改
</Button>
<Button onClick={handleReset}>重 置</Button>
</div>
</Form>
</div>
</div>
</div>
);
};
export default ProfileSettingsPage;
+5
View File
@@ -5,8 +5,10 @@ import SessionSplash from '@/components/SessionSplash';
import CollectionPage from '@/pages/collection/CollectionPage'; import CollectionPage from '@/pages/collection/CollectionPage';
import LoginPage from '@/pages/auth/LoginPage'; import LoginPage from '@/pages/auth/LoginPage';
import OnlineProductsPage from '@/pages/online-products/OnlineProductsPage'; import OnlineProductsPage from '@/pages/online-products/OnlineProductsPage';
import PasswordSettingsPage from '@/pages/settings/PasswordSettingsPage';
import PricingPage from '@/pages/pricing/PricingPage'; import PricingPage from '@/pages/pricing/PricingPage';
import ProductEditPage from '@/pages/products/ProductEditPage'; import ProductEditPage from '@/pages/products/ProductEditPage';
import ProfileSettingsPage from '@/pages/settings/ProfileSettingsPage';
import RegisterPage from '@/pages/auth/RegisterPage'; import RegisterPage from '@/pages/auth/RegisterPage';
import ShopsPage from '@/pages/shops/ShopsPage'; import ShopsPage from '@/pages/shops/ShopsPage';
import WatermarksPage from '@/pages/watermarks/WatermarksPage'; import WatermarksPage from '@/pages/watermarks/WatermarksPage';
@@ -53,6 +55,9 @@ export const router = createBrowserRouter([
{ path: 'pricing', element: <PricingPage /> }, { path: 'pricing', element: <PricingPage /> },
// 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现 // 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现
{ path: 'products/:id', element: <ProductEditPage /> }, { path: 'products/:id', element: <ProductEditPage /> },
// 个人中心设置页:从页头用户菜单进入,不在左侧菜单中出现
{ path: 'settings/profile', element: <ProfileSettingsPage /> },
{ path: 'settings/password', element: <PasswordSettingsPage /> },
], ],
}, },
{ {
+15 -1
View File
@@ -1,4 +1,9 @@
import type { AuthSessionResponse, LoginRequest, RegisterRequest } from '@atlas/contracts'; import type {
AuthSessionResponse,
AuthUser,
LoginRequest,
RegisterRequest,
} from '@atlas/contracts';
import { api } from '@/services/api'; import { api } from '@/services/api';
import type { MeResult, RefreshResult } from '@/types/auth'; import type { MeResult, RefreshResult } from '@/types/auth';
@@ -14,4 +19,13 @@ export const authApi = {
logout: () => api.post<void>('/auth/logout'), logout: () => api.post<void>('/auth/logout'),
me: () => api.get<MeResult>('/auth/me'), me: () => api.get<MeResult>('/auth/me'),
/**
* 【临时 mock】修改资料:PATCH 类接口契约尚未提供(缺口已提报后端 AI)。
* 仅在本地模拟成功返回,数据不持久化,刷新即恢复;契约交付后替换为真实调用并删除本实现。
*/
updateProfile: (payload: { display_name: string }): Promise<{ user: Partial<AuthUser> }> =>
new Promise((resolve) => {
setTimeout(() => resolve({ user: { display_name: payload.display_name } }), 300);
}),
}; };
+8 -2
View File
@@ -26,6 +26,8 @@ interface SessionContextValue {
session: SessionState | null; session: SessionState | null;
/** 登录 / 注册成功后写入会话 */ /** 登录 / 注册成功后写入会话 */
applyAuthSession: (payload: AuthSessionResponse) => void; applyAuthSession: (payload: AuthSessionResponse) => void;
/** 修改资料成功后更新本地会话中的用户信息 */
applyProfileUpdate: (user: SessionState['user']) => void;
/** 撤销服务端会话并清除本地状态;网络失败也保证本地登出 */ /** 撤销服务端会话并清除本地状态;网络失败也保证本地登出 */
logout: () => Promise<void>; logout: () => Promise<void>;
} }
@@ -74,6 +76,10 @@ export function SessionProvider({ children }: { children: ReactNode }) {
setStatus('authenticated'); setStatus('authenticated');
}, []); }, []);
const applyProfileUpdate = useCallback((user: SessionState['user']) => {
setSession((prev) => (prev ? { ...prev, user } : prev));
}, []);
const logout = useCallback(async () => { const logout = useCallback(async () => {
try { try {
await authApi.logout(); await authApi.logout();
@@ -95,8 +101,8 @@ export function SessionProvider({ children }: { children: ReactNode }) {
}, []); }, []);
const value = useMemo( const value = useMemo(
() => ({ status, session, applyAuthSession, logout }), () => ({ status, session, applyAuthSession, applyProfileUpdate, logout }),
[status, session, applyAuthSession, logout] [status, session, applyAuthSession, applyProfileUpdate, logout]
); );
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>; return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;