From cc61967e3bd7fa7a628618f88a21395e77114bc6 Mon Sep 17 00:00:00 2001 From: R524809 Date: Thu, 3 Sep 2026 17:12:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(web-auth):=20=E5=89=8D=E7=AB=AF=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E8=B5=84=E6=96=99=E3=80=81=E4=BF=AE=E6=94=B9=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/settings/PasswordSettingsPage.tsx | 42 +++++++- .../pages/settings/ProfileSettingsPage.tsx | 97 +++++++------------ apps/web/src/router/index.tsx | 6 +- apps/web/src/services/auth.ts | 35 +++++-- apps/web/src/services/session.tsx | 7 +- apps/web/src/types/auth.ts | 29 ------ 6 files changed, 104 insertions(+), 112 deletions(-) delete mode 100644 apps/web/src/types/auth.ts diff --git a/apps/web/src/pages/settings/PasswordSettingsPage.tsx b/apps/web/src/pages/settings/PasswordSettingsPage.tsx index 0c5f169..574df6a 100644 --- a/apps/web/src/pages/settings/PasswordSettingsPage.tsx +++ b/apps/web/src/pages/settings/PasswordSettingsPage.tsx @@ -1,5 +1,10 @@ import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { App as AntdApp, Button, Form, Input } from 'antd'; +import axios from 'axios'; +import { authApi } from '@/services/auth'; +import { authToken } from '@/services/authToken'; +import { apiErrorDetails, apiErrorMessage } from '@/services/api'; interface PasswordFormValues { old_password: string; @@ -8,18 +13,44 @@ interface PasswordFormValues { } /** - * 修改密码页:接口契约尚未提供(缺口已提报后端 AI,需「原密码 + 新密码」的修改密码接口)。 - * 提交时不做任何本地伪成功,明确提示暂不可用;契约交付后接入真实接口。 + * 修改密码页,契约见 docs/api/auth.md(POST /api/auth/change-password)。 + * 成功后所有旧 session 失效:立即用响应中的新 access token 替换内存值 + * (本设备经新的 refresh cookie 保持登录),清理表单状态后回到首页。 */ const PasswordSettingsPage = () => { const { message } = AntdApp.useApp(); + const navigate = useNavigate(); + const [form] = Form.useForm(); const [submitting, setSubmitting] = useState(false); - const handleFinish = async (_values: PasswordFormValues) => { + const handleFinish = async (values: PasswordFormValues) => { setSubmitting(true); try { - await new Promise((resolve) => setTimeout(resolve, 300)); - message.warning('修改密码接口尚未提供(契约缺口已提报后端 AI),暂无法保存'); + const result = await authApi.changePassword({ + old_password: values.old_password, + password: values.password, + password_confirmation: values.password_confirmation, + }); + authToken.set(result.access_token); + form.resetFields(); + message.success('密码已修改'); + navigate('/online-products', { replace: true }); + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 401) { + message.error('原密码不正确'); + } else { + const details = apiErrorDetails(error); + if (details.length > 0) { + form.setFields( + details.map((item) => ({ + name: item.field as keyof PasswordFormValues, + errors: [item.message], + })) + ); + } else { + message.error(apiErrorMessage(error)); + } + } } finally { setSubmitting(false); } @@ -31,6 +62,7 @@ const PasswordSettingsPage = () => {
修改密码
+ form={form} layout="vertical" requiredMark={false} onFinish={handleFinish} diff --git a/apps/web/src/pages/settings/ProfileSettingsPage.tsx b/apps/web/src/pages/settings/ProfileSettingsPage.tsx index 6f0175d..69a3621 100644 --- a/apps/web/src/pages/settings/ProfileSettingsPage.tsx +++ b/apps/web/src/pages/settings/ProfileSettingsPage.tsx @@ -1,63 +1,58 @@ 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 { App as AntdApp, Avatar, Button, Form, Input } from 'antd'; +import axios from 'axios'; import { authApi } from '@/services/auth'; import { useSession } from '@/services/session'; -import { apiErrorMessage } from '@/services/api'; +import { apiErrorDetails, 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)。 + * 修改资料页,契约见 docs/api/auth.md(PATCH /api/auth/me,If-Match + Idempotency-Key)。 + * 头像持久化依赖素材预签名直传接口(尚未交付):当前仅展示默认头像的禁用态,不提供上传。 */ 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('资料已更新(当前为本地临时生效,持久化待接口交付)'); + const user = await authApi.updateProfile( + { display_name: values.display_name.trim() }, + // 幂等键每次提交重新生成;以提交时的 revision 做乐观锁 + { revision: session.user.revision, idempotencyKey: crypto.randomUUID() } + ); + applyProfileUpdate(user); + message.success('资料已更新'); } catch (error) { - message.error(apiErrorMessage(error)); + // 乐观锁版本过期:服务端返回 409 与当前 revision,提示刷新后重试 + if (axios.isAxiosError(error) && error.response?.status === 409) { + message.error('资料已被其他操作更新,请刷新页面获取最新资料后重试'); + } else { + const details = apiErrorDetails(error); + if (details.length > 0) { + form.setFields( + details + .filter((item) => item.field !== 'If-Match') + .map((item) => ({ + name: item.field as keyof ProfileFormValues, + errors: [item.message], + })) + ); + } else { + message.error(apiErrorMessage(error)); + } + } } finally { setSaving(false); } @@ -65,7 +60,6 @@ const ProfileSettingsPage = () => { const handleReset = () => { form.setFieldValue('display_name', session.user.display_name ?? ''); - setPreviewUrl(null); }; return ( @@ -75,34 +69,11 @@ const ProfileSettingsPage = () => {
头像
- - {previewUrl ? ( - - ) : ( -
- - {initial} - -
- -
上传头像
-
-
- )} -
-
-
- 支持 JPG、PNG 格式,文件大小不超过 2MB -
- 建议尺寸:200x200 像素 + + {initial} +
+
头像上传即将支持,敬请期待
form={form} diff --git a/apps/web/src/router/index.tsx b/apps/web/src/router/index.tsx index cbc278f..31b388b 100644 --- a/apps/web/src/router/index.tsx +++ b/apps/web/src/router/index.tsx @@ -28,12 +28,14 @@ function RequireSession({ children }: { children: ReactNode }) { return children; } -/** 认证页互斥:已登录访问 /login、/register 时回到主布局 */ +/** 认证页互斥:已登录访问 /login、/register 时回到来源页或主布局 */ function RedirectIfAuthenticated({ children }: { children: ReactNode }) { const { status } = useSession(); + const location = useLocation(); if (status === 'authenticated') { - return ; + const from = (location.state as { from?: string } | null)?.from; + return ; } return children; } diff --git a/apps/web/src/services/auth.ts b/apps/web/src/services/auth.ts index 6abd221..8c90b7b 100644 --- a/apps/web/src/services/auth.ts +++ b/apps/web/src/services/auth.ts @@ -1,31 +1,48 @@ import type { + AuthMeResponse, + AuthRefreshResponse, AuthSessionResponse, AuthUser, + ChangePasswordRequest, LoginRequest, RegisterRequest, + UpdateCurrentUserRequest, } from '@atlas/contracts'; import { api } from '@/services/api'; -import type { MeResult, RefreshResult } from '@/types/auth'; -/** 账户与会话接口,契约见 docs/api/auth.md;类型来自 @atlas/contracts(me/refresh 暂为本地临时类型) */ +export interface UpdateProfileOptions { + /** 提交时的乐观锁版本,取自当前会话 user.revision */ + revision: number; + /** 幂等键,重试安全;成功响应携带新 revision */ + idempotencyKey: string; +} + +/** 账户与会话接口,契约见 docs/api/auth.md;类型全部来自 @atlas/contracts */ export const authApi = { register: (payload: RegisterRequest) => api.post('/auth/register', payload), login: (payload: LoginRequest) => api.post('/auth/login', payload), - refresh: () => api.post('/auth/refresh'), + refresh: () => api.post('/auth/refresh'), logout: () => api.post('/auth/logout'), - me: () => api.get('/auth/me'), + me: () => api.get('/auth/me'), /** - * 【临时 mock】修改资料:PATCH 类接口契约尚未提供(缺口已提报后端 AI)。 - * 仅在本地模拟成功返回,数据不持久化,刷新即恢复;契约交付后替换为真实调用并删除本实现。 + * 修改资料:If-Match 乐观锁 + Idempotency-Key 幂等(docs/api/conventions.md §5)。 + * 版本过期返回 409 VERSION_CONFLICT(details.current_revision 为服务端当前版本)。 */ - updateProfile: (payload: { display_name: string }): Promise<{ user: Partial }> => - new Promise((resolve) => { - setTimeout(() => resolve({ user: { display_name: payload.display_name } }), 300); + updateProfile: (payload: UpdateCurrentUserRequest, options: UpdateProfileOptions) => + api.patch('/auth/me', payload, { + headers: { + 'If-Match': `"${options.revision}"`, + 'Idempotency-Key': options.idempotencyKey, + }, }), + + /** 修改密码:成功后所有旧 session 失效;响应携带新 access token,必须立即替换内存值 */ + changePassword: (payload: ChangePasswordRequest) => + api.post('/auth/change-password', payload), }; diff --git a/apps/web/src/services/session.tsx b/apps/web/src/services/session.tsx index 53f2e95..3dcbf29 100644 --- a/apps/web/src/services/session.tsx +++ b/apps/web/src/services/session.tsx @@ -9,16 +9,15 @@ import { } from 'react'; import type { ReactNode } from 'react'; import axios from 'axios'; -import type { AuthSessionResponse } from '@atlas/contracts'; +import type { AuthMeResponse, AuthSessionResponse } from '@atlas/contracts'; import { authApi } from '@/services/auth'; import { authToken } from '@/services/authToken'; -import type { MeResult } from '@/types/auth'; export type SessionStatus = 'restoring' | 'authenticated' | 'anonymous'; export interface SessionState { - user: MeResult['user']; - workspace: MeResult['workspace']; + user: AuthMeResponse['user']; + workspace: AuthMeResponse['workspace']; } interface SessionContextValue { diff --git a/apps/web/src/types/auth.ts b/apps/web/src/types/auth.ts deleted file mode 100644 index a8acf7e..0000000 --- a/apps/web/src/types/auth.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * 临时类型:@atlas/contracts 目前只提供认证请求与会话 schema, - * refresh / me 的响应 schema 尚未收录(契约缺口已按协作约定第 3 节提报后端 AI)。 - * 形状对齐 docs/api/auth.md §4/§5;contracts 补齐后删除本文件并改用共享类型。 - */ -import type { AuthUser, Workspace } from '@atlas/contracts'; - -/** POST /api/auth/refresh 成功响应(信封 { data } 已在 api 层解包) */ -export interface RefreshResult { - access_token: string; - access_token_expires_in: number; -} - -export interface PlatformSummary { - id: string; - code: string; - name: string; -} - -/** GET /api/auth/me 成功响应;default_store 摘要字段以后续门店接口文档为准 */ -export interface MeResult { - user: AuthUser; - workspace: Workspace; - platforms: PlatformSummary[]; - suggested_context: { - platform_id: string; - default_store: { id: string; name: string } | null; - }; -}