feat(web-auth): 前端修改资料、修改密码优化
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { App as AntdApp, Button, Form, Input } from 'antd';
|
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 {
|
interface PasswordFormValues {
|
||||||
old_password: string;
|
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 PasswordSettingsPage = () => {
|
||||||
const { message } = AntdApp.useApp();
|
const { message } = AntdApp.useApp();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [form] = Form.useForm<PasswordFormValues>();
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const handleFinish = async (_values: PasswordFormValues) => {
|
const handleFinish = async (values: PasswordFormValues) => {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
const result = await authApi.changePassword({
|
||||||
message.warning('修改密码接口尚未提供(契约缺口已提报后端 AI),暂无法保存');
|
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 {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -31,6 +62,7 @@ const PasswordSettingsPage = () => {
|
|||||||
<div className="settings-card-title">修改密码</div>
|
<div className="settings-card-title">修改密码</div>
|
||||||
<div className="settings-card-body">
|
<div className="settings-card-body">
|
||||||
<Form<PasswordFormValues>
|
<Form<PasswordFormValues>
|
||||||
|
form={form}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
requiredMark={false}
|
requiredMark={false}
|
||||||
onFinish={handleFinish}
|
onFinish={handleFinish}
|
||||||
|
|||||||
@@ -1,63 +1,58 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { App as AntdApp, Avatar, Button, Form, Input, Upload } from 'antd';
|
import { App as AntdApp, Avatar, Button, Form, Input } from 'antd';
|
||||||
import type { UploadFile } from 'antd';
|
import axios from 'axios';
|
||||||
import { PlusOutlined } from '@ant-design/icons';
|
|
||||||
import { authApi } from '@/services/auth';
|
import { authApi } from '@/services/auth';
|
||||||
import { useSession } from '@/services/session';
|
import { useSession } from '@/services/session';
|
||||||
import { apiErrorMessage } from '@/services/api';
|
import { apiErrorDetails, apiErrorMessage } from '@/services/api';
|
||||||
|
|
||||||
interface ProfileFormValues {
|
interface ProfileFormValues {
|
||||||
display_name: string;
|
display_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACCEPTED_TYPES = ['image/jpeg', 'image/png'];
|
|
||||||
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 修改资料页(docs/api/auth.md 尚无对应接口):
|
* 修改资料页,契约见 docs/api/auth.md(PATCH /api/auth/me,If-Match + Idempotency-Key)。
|
||||||
* 昵称保存走【临时 mock】仅本地生效;头像选择后仅本地预览,
|
* 头像持久化依赖素材预签名直传接口(尚未交付):当前仅展示默认头像的禁用态,不提供上传。
|
||||||
* 持久化依赖用户头像字段与素材上传接口(契约缺口已提报后端 AI)。
|
|
||||||
*/
|
*/
|
||||||
const ProfileSettingsPage = () => {
|
const ProfileSettingsPage = () => {
|
||||||
const { message } = AntdApp.useApp();
|
const { message } = AntdApp.useApp();
|
||||||
const { session, applyProfileUpdate } = useSession();
|
const { session, applyProfileUpdate } = useSession();
|
||||||
const [form] = Form.useForm<ProfileFormValues>();
|
const [form] = Form.useForm<ProfileFormValues>();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const displayName = session?.user.display_name ?? session?.user.email ?? '';
|
const displayName = session?.user.display_name ?? session?.user.email ?? '';
|
||||||
const initial = useMemo(() => displayName.charAt(0).toUpperCase(), [displayName]);
|
const initial = useMemo(() => displayName.charAt(0).toUpperCase(), [displayName]);
|
||||||
|
|
||||||
if (!session) return null;
|
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) => {
|
const handleSave = async (values: ProfileFormValues) => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
// 临时 mock:仅本地生效,接口交付后替换为真实调用
|
const user = await authApi.updateProfile(
|
||||||
const result = await authApi.updateProfile({
|
{ display_name: values.display_name.trim() },
|
||||||
display_name: values.display_name.trim(),
|
// 幂等键每次提交重新生成;以提交时的 revision 做乐观锁
|
||||||
});
|
{ revision: session.user.revision, idempotencyKey: crypto.randomUUID() }
|
||||||
applyProfileUpdate({ ...session.user, ...result.user });
|
);
|
||||||
message.success('资料已更新(当前为本地临时生效,持久化待接口交付)');
|
applyProfileUpdate(user);
|
||||||
|
message.success('资料已更新');
|
||||||
} catch (error) {
|
} 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 {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -65,7 +60,6 @@ const ProfileSettingsPage = () => {
|
|||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
form.setFieldValue('display_name', session.user.display_name ?? '');
|
form.setFieldValue('display_name', session.user.display_name ?? '');
|
||||||
setPreviewUrl(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -75,34 +69,11 @@ const ProfileSettingsPage = () => {
|
|||||||
<div className="settings-card-body">
|
<div className="settings-card-body">
|
||||||
<div className="settings-field-label">头像</div>
|
<div className="settings-field-label">头像</div>
|
||||||
<div className="avatar-upload-area">
|
<div className="avatar-upload-area">
|
||||||
<Upload
|
<Avatar size={160} style={{ backgroundColor: 'var(--sider-bg)' }}>
|
||||||
showUploadList={false}
|
{initial}
|
||||||
accept=".jpg,.jpeg,.png"
|
</Avatar>
|
||||||
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>
|
</div>
|
||||||
|
<div className="avatar-upload-hint">头像上传即将支持,敬请期待</div>
|
||||||
|
|
||||||
<Form<ProfileFormValues>
|
<Form<ProfileFormValues>
|
||||||
form={form}
|
form={form}
|
||||||
|
|||||||
@@ -28,12 +28,14 @@ function RequireSession({ children }: { children: ReactNode }) {
|
|||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 认证页互斥:已登录访问 /login、/register 时回到主布局 */
|
/** 认证页互斥:已登录访问 /login、/register 时回到来源页或主布局 */
|
||||||
function RedirectIfAuthenticated({ children }: { children: ReactNode }) {
|
function RedirectIfAuthenticated({ children }: { children: ReactNode }) {
|
||||||
const { status } = useSession();
|
const { status } = useSession();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
if (status === 'authenticated') {
|
if (status === 'authenticated') {
|
||||||
return <Navigate to="/" replace />;
|
const from = (location.state as { from?: string } | null)?.from;
|
||||||
|
return <Navigate to={from && from.startsWith('/') ? from : '/'} replace />;
|
||||||
}
|
}
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,48 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AuthMeResponse,
|
||||||
|
AuthRefreshResponse,
|
||||||
AuthSessionResponse,
|
AuthSessionResponse,
|
||||||
AuthUser,
|
AuthUser,
|
||||||
|
ChangePasswordRequest,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
|
UpdateCurrentUserRequest,
|
||||||
} from '@atlas/contracts';
|
} from '@atlas/contracts';
|
||||||
import { api } from '@/services/api';
|
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 = {
|
export const authApi = {
|
||||||
register: (payload: RegisterRequest) =>
|
register: (payload: RegisterRequest) =>
|
||||||
api.post<AuthSessionResponse>('/auth/register', payload),
|
api.post<AuthSessionResponse>('/auth/register', payload),
|
||||||
|
|
||||||
login: (payload: LoginRequest) => api.post<AuthSessionResponse>('/auth/login', payload),
|
login: (payload: LoginRequest) => api.post<AuthSessionResponse>('/auth/login', payload),
|
||||||
|
|
||||||
refresh: () => api.post<RefreshResult>('/auth/refresh'),
|
refresh: () => api.post<AuthRefreshResponse>('/auth/refresh'),
|
||||||
|
|
||||||
logout: () => api.post<void>('/auth/logout'),
|
logout: () => api.post<void>('/auth/logout'),
|
||||||
|
|
||||||
me: () => api.get<MeResult>('/auth/me'),
|
me: () => api.get<AuthMeResponse>('/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<AuthUser> }> =>
|
updateProfile: (payload: UpdateCurrentUserRequest, options: UpdateProfileOptions) =>
|
||||||
new Promise((resolve) => {
|
api.patch<AuthUser>('/auth/me', payload, {
|
||||||
setTimeout(() => resolve({ user: { display_name: payload.display_name } }), 300);
|
headers: {
|
||||||
|
'If-Match': `"${options.revision}"`,
|
||||||
|
'Idempotency-Key': options.idempotencyKey,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** 修改密码:成功后所有旧 session 失效;响应携带新 access token,必须立即替换内存值 */
|
||||||
|
changePassword: (payload: ChangePasswordRequest) =>
|
||||||
|
api.post<AuthRefreshResponse>('/auth/change-password', payload),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,16 +9,15 @@ import {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { AuthSessionResponse } from '@atlas/contracts';
|
import type { AuthMeResponse, AuthSessionResponse } from '@atlas/contracts';
|
||||||
import { authApi } from '@/services/auth';
|
import { authApi } from '@/services/auth';
|
||||||
import { authToken } from '@/services/authToken';
|
import { authToken } from '@/services/authToken';
|
||||||
import type { MeResult } from '@/types/auth';
|
|
||||||
|
|
||||||
export type SessionStatus = 'restoring' | 'authenticated' | 'anonymous';
|
export type SessionStatus = 'restoring' | 'authenticated' | 'anonymous';
|
||||||
|
|
||||||
export interface SessionState {
|
export interface SessionState {
|
||||||
user: MeResult['user'];
|
user: AuthMeResponse['user'];
|
||||||
workspace: MeResult['workspace'];
|
workspace: AuthMeResponse['workspace'];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionContextValue {
|
interface SessionContextValue {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user