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 ( +
+
+
修改密码
+
+ + layout="vertical" + requiredMark={false} + onFinish={handleFinish} + className="profile-form" + > + + + + + + + ({ + 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 ? ( + + ) : ( +
+ + {initial} + +
+ +
上传头像
+
+
+ )} +
+
+
+ 支持 JPG、PNG 格式,文件大小不超过 2MB +
+ 建议尺寸:200x200 像素 +
+ + + form={form} + layout="vertical" + requiredMark={false} + initialValues={{ display_name: session.user.display_name ?? '' }} + onFinish={handleSave} + className="profile-form" + > + + + +
+ + +
+ +
+
+
+ ); +}; + +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};