diff --git a/apps/web/src/App.css b/apps/web/src/App.css
index 74c6000..7cfce44 100644
--- a/apps/web/src/App.css
+++ b/apps/web/src/App.css
@@ -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;
+}
diff --git a/apps/web/src/layouts/MainLayout.css b/apps/web/src/layouts/MainLayout.css
index 40afeed..0657489 100644
--- a/apps/web/src/layouts/MainLayout.css
+++ b/apps/web/src/layouts/MainLayout.css
@@ -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,
diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx
index 8b96cb1..56f8400 100644
--- a/apps/web/src/layouts/MainLayout.tsx
+++ b/apps/web/src/layouts/MainLayout.tsx
@@ -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: , label: '修改资料' },
+ { key: 'password', icon: , label: '修改密码' },
+ { type: 'divider' },
+ {
+ key: 'logout',
+ icon: ,
+ label: loggingOut ? '退出中…' : '退出登录',
+ disabled: loggingOut,
+ },
+ ];
+
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
@@ -124,15 +156,21 @@ const MainLayout = () => {
{session && (
-
-
- {session.workspace.name}
-
- {session.user.email}
-
-
+
+
+
+ {userInitial}
+
+
{userDisplayName}
+
+
)}
diff --git a/apps/web/src/layouts/menuConfig.tsx b/apps/web/src/layouts/menuConfig.tsx
index 93222b4..315dbc3 100644
--- a/apps/web/src/layouts/menuConfig.tsx
+++ b/apps/web/src/layouts/menuConfig.tsx
@@ -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: , label: '商品编辑' },
];
}
+ if (path === '/settings/profile') {
+ return [
+ { icon: , label: '设置' },
+ { icon: , label: '修改资料' },
+ ];
+ }
+ if (path === '/settings/password') {
+ return [
+ { icon: , label: '设置' },
+ { icon: , label: '修改密码' },
+ ];
+ }
const config = routeMenuConfig.find((item) => path.startsWith(item.path)) ?? routeMenuConfig[0];
return [{ icon: config.icon, label: config.label }];
};
diff --git a/apps/web/src/pages/settings/PasswordSettingsPage.tsx b/apps/web/src/pages/settings/PasswordSettingsPage.tsx
new file mode 100644
index 0000000..0c5f169
--- /dev/null
+++ b/apps/web/src/pages/settings/PasswordSettingsPage.tsx
@@ -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 (
+
+
+
修改密码
+
+
+
+
+
+
+
+
({
+ validator(_, value) {
+ if (!value || getFieldValue('password') === value) {
+ return Promise.resolve();
+ }
+ return Promise.reject(new Error('两次输入的新密码不一致'));
+ },
+ }),
+ ]}
+ >
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default PasswordSettingsPage;
diff --git a/apps/web/src/pages/settings/ProfileSettingsPage.tsx b/apps/web/src/pages/settings/ProfileSettingsPage.tsx
new file mode 100644
index 0000000..6f0175d
--- /dev/null
+++ b/apps/web/src/pages/settings/ProfileSettingsPage.tsx
@@ -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();
+ const [saving, setSaving] = useState(false);
+ const [previewUrl, setPreviewUrl] = useState(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 (
+
+
+
修改个人信息
+
+
头像
+
+
+ {previewUrl ? (
+
+ ) : (
+
+ )}
+
+
+
+ 支持 JPG、PNG 格式,文件大小不超过 2MB
+
+ 建议尺寸:200x200 像素
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default ProfileSettingsPage;
diff --git a/apps/web/src/router/index.tsx b/apps/web/src/router/index.tsx
index 2fb3cf1..cbc278f 100644
--- a/apps/web/src/router/index.tsx
+++ b/apps/web/src/router/index.tsx
@@ -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: },
// 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现
{ path: 'products/:id', element: },
+ // 个人中心设置页:从页头用户菜单进入,不在左侧菜单中出现
+ { path: 'settings/profile', element: },
+ { path: 'settings/password', element: },
],
},
{
diff --git a/apps/web/src/services/auth.ts b/apps/web/src/services/auth.ts
index cec5f3f..6abd221 100644
--- a/apps/web/src/services/auth.ts
+++ b/apps/web/src/services/auth.ts
@@ -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('/auth/logout'),
me: () => api.get('/auth/me'),
+
+ /**
+ * 【临时 mock】修改资料:PATCH 类接口契约尚未提供(缺口已提报后端 AI)。
+ * 仅在本地模拟成功返回,数据不持久化,刷新即恢复;契约交付后替换为真实调用并删除本实现。
+ */
+ updateProfile: (payload: { display_name: string }): Promise<{ user: Partial }> =>
+ new Promise((resolve) => {
+ setTimeout(() => resolve({ user: { display_name: payload.display_name } }), 300);
+ }),
};
diff --git a/apps/web/src/services/session.tsx b/apps/web/src/services/session.tsx
index f48a2a2..53f2e95 100644
--- a/apps/web/src/services/session.tsx
+++ b/apps/web/src/services/session.tsx
@@ -26,6 +26,8 @@ interface SessionContextValue {
session: SessionState | null;
/** 登录 / 注册成功后写入会话 */
applyAuthSession: (payload: AuthSessionResponse) => void;
+ /** 修改资料成功后更新本地会话中的用户信息 */
+ applyProfileUpdate: (user: SessionState['user']) => void;
/** 撤销服务端会话并清除本地状态;网络失败也保证本地登出 */
logout: () => Promise;
}
@@ -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 {children};