feat(web): 开发账户修改、密码修改功能
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
<div className="header-user-trigger" title={userDisplayName}>
|
||||||
<Button size="small" loading={loggingOut} onClick={handleLogout}>
|
<Avatar
|
||||||
退出登录
|
size={28}
|
||||||
</Button>
|
style={{ backgroundColor: 'var(--sider-bg)' }}
|
||||||
|
>
|
||||||
|
{userInitial}
|
||||||
|
</Avatar>
|
||||||
|
<span className="header-user-name">{userDisplayName}</span>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -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,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 /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user