feat(web): 开发账户修改、密码修改功能
This commit is contained in:
@@ -1 +1,88 @@
|
||||
/* 全局应用级样式补充(主题变量见 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;
|
||||
}
|
||||
|
||||
@@ -102,22 +102,30 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-user {
|
||||
/* 头像 + 用户名触发区:hover 有浅色背景反馈,暗示可下拉 */
|
||||
.header-user-trigger {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
line-height: 1.25;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
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-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.header-user-email {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.collapse-trigger,
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Breadcrumb, Button, Drawer, Layout } from 'antd';
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
|
||||
import { Avatar, Breadcrumb, Drawer, Dropdown, Layout } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
LockOutlined,
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import SidebarMenu from './SidebarMenu';
|
||||
import { getBreadcrumbItems } from './menuConfig';
|
||||
import { useSession } from '@/services/session';
|
||||
@@ -31,6 +38,9 @@ const MainLayout = () => {
|
||||
[location.pathname]
|
||||
);
|
||||
|
||||
const userDisplayName = session ? (session.user.display_name ?? session.user.email) : '';
|
||||
const userInitial = userDisplayName.charAt(0).toUpperCase();
|
||||
|
||||
const handleLogout = async () => {
|
||||
setLoggingOut(true);
|
||||
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(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
@@ -124,15 +156,21 @@ const MainLayout = () => {
|
||||
</div>
|
||||
{session && (
|
||||
<div className="header-right">
|
||||
<div className="header-user">
|
||||
<span className="header-user-workspace">
|
||||
{session.workspace.name}
|
||||
</span>
|
||||
<span className="header-user-email">{session.user.email}</span>
|
||||
</div>
|
||||
<Button size="small" loading={loggingOut} onClick={handleLogout}>
|
||||
退出登录
|
||||
</Button>
|
||||
<Dropdown
|
||||
menu={{ items: userMenuItems, onClick: handleUserMenuClick }}
|
||||
trigger={['hover']}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<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>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</Header>
|
||||
|
||||
@@ -4,8 +4,11 @@ import {
|
||||
EditOutlined,
|
||||
GlobalOutlined,
|
||||
InboxOutlined,
|
||||
LockOutlined,
|
||||
PictureOutlined,
|
||||
SettingOutlined,
|
||||
ShopOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
@@ -59,7 +62,7 @@ export interface BreadcrumbCrumb {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 页头面包屑:一级菜单页显示单级;商品 AI 编辑页不在菜单中,显示「商品 > 商品编辑」两级 */
|
||||
/** 页头面包屑:一级菜单页显示单级;不在菜单中的页面显示两级 */
|
||||
export const getBreadcrumbItems = (path: string): BreadcrumbCrumb[] => {
|
||||
if (path.startsWith('/products/')) {
|
||||
return [
|
||||
@@ -67,6 +70,18 @@ export const getBreadcrumbItems = (path: string): BreadcrumbCrumb[] => {
|
||||
{ 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];
|
||||
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,8 +5,10 @@ import SessionSplash from '@/components/SessionSplash';
|
||||
import CollectionPage from '@/pages/collection/CollectionPage';
|
||||
import LoginPage from '@/pages/auth/LoginPage';
|
||||
import OnlineProductsPage from '@/pages/online-products/OnlineProductsPage';
|
||||
import PasswordSettingsPage from '@/pages/settings/PasswordSettingsPage';
|
||||
import PricingPage from '@/pages/pricing/PricingPage';
|
||||
import ProductEditPage from '@/pages/products/ProductEditPage';
|
||||
import ProfileSettingsPage from '@/pages/settings/ProfileSettingsPage';
|
||||
import RegisterPage from '@/pages/auth/RegisterPage';
|
||||
import ShopsPage from '@/pages/shops/ShopsPage';
|
||||
import WatermarksPage from '@/pages/watermarks/WatermarksPage';
|
||||
@@ -53,6 +55,9 @@ export const router = createBrowserRouter([
|
||||
{ path: 'pricing', element: <PricingPage /> },
|
||||
// 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现
|
||||
{ path: 'products/:id', element: <ProductEditPage /> },
|
||||
// 个人中心设置页:从页头用户菜单进入,不在左侧菜单中出现
|
||||
{ path: 'settings/profile', element: <ProfileSettingsPage /> },
|
||||
{ path: 'settings/password', element: <PasswordSettingsPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 type { MeResult, RefreshResult } from '@/types/auth';
|
||||
|
||||
@@ -14,4 +19,13 @@ export const authApi = {
|
||||
logout: () => api.post<void>('/auth/logout'),
|
||||
|
||||
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);
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -26,6 +26,8 @@ interface SessionContextValue {
|
||||
session: SessionState | null;
|
||||
/** 登录 / 注册成功后写入会话 */
|
||||
applyAuthSession: (payload: AuthSessionResponse) => void;
|
||||
/** 修改资料成功后更新本地会话中的用户信息 */
|
||||
applyProfileUpdate: (user: SessionState['user']) => void;
|
||||
/** 撤销服务端会话并清除本地状态;网络失败也保证本地登出 */
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
@@ -74,6 +76,10 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
setStatus('authenticated');
|
||||
}, []);
|
||||
|
||||
const applyProfileUpdate = useCallback((user: SessionState['user']) => {
|
||||
setSession((prev) => (prev ? { ...prev, user } : prev));
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await authApi.logout();
|
||||
@@ -95,8 +101,8 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ status, session, applyAuthSession, logout }),
|
||||
[status, session, applyAuthSession, logout]
|
||||
() => ({ status, session, applyAuthSession, applyProfileUpdate, logout }),
|
||||
[status, session, applyAuthSession, applyProfileUpdate, logout]
|
||||
);
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||
|
||||
Reference in New Issue
Block a user