From df40ddde8c1101a676d689083269859e933bc772 Mon Sep 17 00:00:00 2001 From: R524809 Date: Thu, 3 Sep 2026 13:11:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/index.html | 18 +- apps/web/package.json | 50 ++-- apps/web/prettier.config.mjs | 15 ++ apps/web/src/App.tsx | 38 +-- apps/web/src/components/PagePlaceholder.tsx | 26 +- apps/web/src/config/env.ts | 14 +- apps/web/src/index.css | 52 ++-- apps/web/src/layouts/AuthLayout.css | 78 +++--- apps/web/src/layouts/AuthLayout.tsx | 50 ++-- apps/web/src/layouts/MainLayout.css | 190 +++++++++------ apps/web/src/layouts/MainLayout.tsx | 188 ++++++++------- apps/web/src/layouts/SidebarMenu.css | 154 +++++++----- apps/web/src/layouts/SidebarMenu.tsx | 89 +++---- apps/web/src/layouts/menuConfig.tsx | 128 +++++----- apps/web/src/main.tsx | 6 +- apps/web/src/pages/auth/LoginPage.tsx | 130 +++++----- apps/web/src/pages/auth/RegisterPage.tsx | 226 +++++++++--------- .../src/pages/collection/CollectionPage.tsx | 2 +- .../online-products/OnlineProductsPage.tsx | 8 +- apps/web/src/pages/pricing/PricingPage.tsx | 2 +- .../src/pages/products/ProductEditPage.tsx | 14 +- apps/web/src/pages/shops/ShopsPage.tsx | 2 +- .../src/pages/watermarks/WatermarksPage.tsx | 2 +- apps/web/src/router/index.tsx | 36 +-- apps/web/src/services/api.ts | 126 +++++----- apps/web/src/services/auth.ts | 8 +- apps/web/src/services/authToken.ts | 14 +- apps/web/src/types/auth.ts | 56 ++--- apps/web/src/utils/validators.ts | 8 +- apps/web/tsconfig.json | 38 +-- apps/web/vite.config.ts | 28 +-- 31 files changed, 948 insertions(+), 848 deletions(-) create mode 100644 apps/web/prettier.config.mjs diff --git a/apps/web/index.html b/apps/web/index.html index 6181cfd..9c5b5cd 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -1,12 +1,12 @@ - - - - Atlas Seller Kit - - -
- - + + + + Atlas Seller Kit + + +
+ + diff --git a/apps/web/package.json b/apps/web/package.json index 196a49d..15752f3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,27 +1,27 @@ { - "name": "@atlas/web", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "build": "tsc -b && vite build", - "dev": "vite", - "typecheck": "tsc -b --pretty false" - }, - "dependencies": { - "@ant-design/icons": "6.3.4", - "antd": "6.6.2", - "axios": "1.20.0", - "react": "19.2.8", - "react-dom": "19.2.8", - "react-router-dom": "7.18.3" - }, - "devDependencies": { - "@types/node": "26.3.0", - "@types/react": "19.2.18", - "@types/react-dom": "19.2.5", - "@vitejs/plugin-react": "6.1.1", - "typescript": "7.0.2", - "vite": "8.2.2" - } + "name": "@atlas/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -b && vite build", + "dev": "vite", + "typecheck": "tsc -b --pretty false" + }, + "dependencies": { + "@ant-design/icons": "6.3.4", + "antd": "6.6.2", + "axios": "1.20.0", + "react": "19.2.8", + "react-dom": "19.2.8", + "react-router-dom": "7.18.3" + }, + "devDependencies": { + "@types/node": "26.3.0", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.5", + "@vitejs/plugin-react": "6.1.1", + "typescript": "7.0.2", + "vite": "8.2.2" + } } diff --git a/apps/web/prettier.config.mjs b/apps/web/prettier.config.mjs new file mode 100644 index 0000000..ac9001e --- /dev/null +++ b/apps/web/prettier.config.mjs @@ -0,0 +1,15 @@ +export default { + printWidth: 100, + tabWidth: 4, + useTabs: false, + semi: true, + singleQuote: true, + quoteProps: 'as-needed', + jsxSingleQuote: false, + trailingComma: 'es5', + bracketSpacing: true, + bracketSameLine: false, + arrowParens: 'always', + endOfLine: 'lf', + embeddedLanguageFormatting: 'auto', +}; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 94f2831..537bf7b 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -5,25 +5,25 @@ import { router } from './router'; import './App.css'; function App() { - return ( - - - - - - ); + return ( + + + + + + ); } export default App; diff --git a/apps/web/src/components/PagePlaceholder.tsx b/apps/web/src/components/PagePlaceholder.tsx index 7e54da1..0f8d5e8 100644 --- a/apps/web/src/components/PagePlaceholder.tsx +++ b/apps/web/src/components/PagePlaceholder.tsx @@ -1,23 +1,23 @@ import { Card, Empty } from 'antd'; interface PagePlaceholderProps { - title: string; - description: string; + title: string; + description: string; } /** 业务模块实现前的统一占位页 */ const PagePlaceholder = ({ title, description }: PagePlaceholderProps) => ( - - -
{title}
-
{description}
- - } - /> -
+ + +
{title}
+
{description}
+ + } + /> +
); export default PagePlaceholder; diff --git a/apps/web/src/config/env.ts b/apps/web/src/config/env.ts index d6e68ea..ee068f5 100644 --- a/apps/web/src/config/env.ts +++ b/apps/web/src/config/env.ts @@ -3,14 +3,14 @@ */ interface EnvConfig { - /** API 基础路径:开发环境由 vite 代理转发到 NestJS API */ - apiBaseUrl: string; - appName: string; - debug: boolean; + /** API 基础路径:开发环境由 vite 代理转发到 NestJS API */ + apiBaseUrl: string; + appName: string; + debug: boolean; } export const envConfig: EnvConfig = { - apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? '/api', - appName: import.meta.env.VITE_APP_NAME ?? 'Atlas Seller Kit', - debug: import.meta.env.VITE_DEBUG === 'true', + apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? '/api', + appName: import.meta.env.VITE_APP_NAME ?? 'Atlas Seller Kit', + debug: import.meta.env.VITE_DEBUG === 'true', }; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 0e339df..e53a567 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,37 +1,37 @@ :root { - --primary-color: #8b5cf6; - --primary-light: #a78bfa; - --primary-dark: #7c3aed; - /* 侧边菜单背景(纯色,中间调;想再调直接改这里) */ - --sider-bg: #6d28d9; - --text-primary: #1f2937; - --text-secondary: #6b7280; - --bg-color: #f9fafb; - --card-bg: #ffffff; - --border-color: #e5e7eb; + --primary-color: #8b5cf6; + --primary-light: #a78bfa; + --primary-dark: #7c3aed; + /* 侧边菜单背景(纯色,中间调;想再调直接改这里) */ + --sider-bg: #6d28d9; + --text-primary: #1f2937; + --text-secondary: #6b7280; + --bg-color: #f9fafb; + --card-bg: #ffffff; + --border-color: #e5e7eb; } * { - margin: 0; - padding: 0; - box-sizing: border-box; + margin: 0; + padding: 0; + box-sizing: border-box; } body { - margin: 0; - font-family: - -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', - 'Microsoft YaHei', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', - 'Helvetica Neue', sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - background-color: var(--bg-color); - color: var(--text-primary); - /* 全局默认字号 14px,与 antd 主题 token 的 fontSize 保持一致 */ - font-size: 14px; - line-height: 1.6; + margin: 0; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', + 'Microsoft YaHei', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', + 'Helvetica Neue', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: var(--bg-color); + color: var(--text-primary); + /* 全局默认字号 14px,与 antd 主题 token 的 fontSize 保持一致 */ + font-size: 14px; + line-height: 1.6; } #root { - min-height: 100vh; + min-height: 100vh; } diff --git a/apps/web/src/layouts/AuthLayout.css b/apps/web/src/layouts/AuthLayout.css index 210e239..283110d 100644 --- a/apps/web/src/layouts/AuthLayout.css +++ b/apps/web/src/layouts/AuthLayout.css @@ -1,71 +1,71 @@ .auth-page { - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - padding: 24px; - background: - radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%), - radial-gradient(900px 480px at 95% 110%, rgba(109, 40, 217, 0.1), transparent 55%), - var(--bg-color); + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: + radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%), + radial-gradient(900px 480px at 95% 110%, rgba(109, 40, 217, 0.1), transparent 55%), + var(--bg-color); } .auth-panel { - width: 100%; - max-width: 400px; + width: 100%; + max-width: 400px; } .auth-brand { - text-align: center; - margin-bottom: 24px; + text-align: center; + margin-bottom: 24px; } .auth-logo { - font-size: 26px; - font-weight: 700; - color: var(--primary-dark); + font-size: 26px; + font-weight: 700; + color: var(--primary-dark); } .auth-logo-subtitle { - font-size: 13px; - color: var(--text-secondary); - margin-top: 2px; + font-size: 13px; + color: var(--text-secondary); + margin-top: 2px; } .auth-card { - background: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 12px; - box-shadow: 0 10px 30px rgba(17, 24, 39, 0.06); - padding: 32px 28px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: 0 10px 30px rgba(17, 24, 39, 0.06); + padding: 32px 28px; } .auth-title { - font-size: 20px; - font-weight: 600; - color: var(--text-primary); + font-size: 20px; + font-weight: 600; + color: var(--text-primary); } .auth-subtitle { - font-size: 14px; - color: var(--text-secondary); - margin: 4px 0 20px; + font-size: 14px; + color: var(--text-secondary); + margin: 4px 0 20px; } .auth-footer { - margin-top: 16px; - text-align: center; - font-size: 14px; - color: var(--text-secondary); + margin-top: 16px; + text-align: center; + font-size: 14px; + color: var(--text-secondary); } .auth-footer a { - color: var(--primary-color); - font-weight: 500; + color: var(--primary-color); + font-weight: 500; } @media (max-width: 768px) { - .auth-card { - padding: 24px 20px; - } + .auth-card { + padding: 24px 20px; + } } diff --git a/apps/web/src/layouts/AuthLayout.tsx b/apps/web/src/layouts/AuthLayout.tsx index 55c9103..4ece55d 100644 --- a/apps/web/src/layouts/AuthLayout.tsx +++ b/apps/web/src/layouts/AuthLayout.tsx @@ -3,37 +3,37 @@ import type { ReactNode } from 'react'; import './AuthLayout.css'; interface AuthLayoutProps { - /** 页面标题,如「登录」 */ - title: string; - /** 标题下的一行说明 */ - subtitle: string; - /** 卡片底部的切换入口 */ - footer: ReactNode; - children: ReactNode; + /** 页面标题,如「登录」 */ + title: string; + /** 标题下的一行说明 */ + subtitle: string; + /** 卡片底部的切换入口 */ + footer: ReactNode; + children: ReactNode; } /** 注册/登录共用的居中卡片布局,品牌视觉与 MainLayout 一致 */ const AuthLayout = ({ title, subtitle, footer, children }: AuthLayoutProps) => { - useEffect(() => { - document.title = `${title} - Atlas Seller Kit`; - }, [title]); + useEffect(() => { + document.title = `${title} - Atlas Seller Kit`; + }, [title]); - return ( -
-
-
-
Atlas Kit
-
商品工作台
+ return ( +
+
+
+
Atlas Kit
+
商品工作台
+
+
+
{title}
+
{subtitle}
+ {children} +
{footer}
+
+
-
-
{title}
-
{subtitle}
- {children} -
{footer}
-
-
-
- ); + ); }; export default AuthLayout; diff --git a/apps/web/src/layouts/MainLayout.css b/apps/web/src/layouts/MainLayout.css index 0878bea..b36afea 100644 --- a/apps/web/src/layouts/MainLayout.css +++ b/apps/web/src/layouts/MainLayout.css @@ -1,115 +1,157 @@ .main-layout { - min-height: 100vh; + min-height: 100vh; } .main-sider { - background: var(--sider-bg) !important; - display: flex; - flex-direction: column; + background: var(--sider-bg) !important; + display: flex; + flex-direction: column; } .sidebar-header { - padding: 24px 20px; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - text-align: center; + padding: 24px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + text-align: center; + transition: padding 0.2s ease; +} + +/* 折叠态收窄水平留白,与宽度动画同步 */ +.ant-layout-sider-collapsed .sidebar-header { + padding: 20px 8px; } .logo { - font-size: 22px; - font-weight: 700; - color: white; - margin-bottom: 4px; + position: relative; + height: 31px; + line-height: 31px; + font-size: 22px; + font-weight: 700; + color: white; + margin-bottom: 4px; } +/* 全称与缩写叠加存放,折叠时交叉淡入淡出 */ +.logo-full, +.logo-short { + display: block; + white-space: nowrap; + transition: + opacity 0.2s ease, + transform 0.2s ease; +} + +.logo-short { + position: absolute; + inset: 0; + opacity: 0; + transform: scale(0.5); +} + +.ant-layout-sider-collapsed .logo-full { + opacity: 0; + transform: scale(0.5); +} + +.ant-layout-sider-collapsed .logo-short { + opacity: 1; + transform: scale(1); +} + +/* 副标题折叠时渐隐并收起占位高度 */ .logo-subtitle { - font-size: 12px; - color: rgba(255, 255, 255, 0.8); + max-height: 18px; + overflow: hidden; + opacity: 1; + font-size: 12px; + color: rgba(255, 255, 255, 0.8); + transition: + opacity 0.2s ease, + max-height 0.2s ease; +} + +.ant-layout-sider-collapsed .logo-subtitle { + max-height: 0; + opacity: 0; } .main-header { - background: white !important; - padding: 0 24px !important; - display: flex; - align-items: center; - justify-content: space-between; - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06); - position: sticky; - top: 0; - z-index: 100; - height: 64px; - line-height: 1; + background: white !important; + padding: 0 24px !important; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: + 0 1px 3px 0 rgba(0, 0, 0, 0.1), + 0 1px 2px 0 rgba(0, 0, 0, 0.06); + position: sticky; + top: 0; + z-index: 100; + height: 50px; + line-height: 1; } .header-left { - display: flex; - align-items: center; - gap: 16px; + display: flex; + align-items: center; + gap: 12px; } .collapse-trigger, .mobile-menu-trigger { - font-size: 18px; - cursor: pointer; - color: #1f2937; - border-radius: 4px; - transition: background 0.2s; - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - flex-shrink: 0; + font-size: 16px; + cursor: pointer; + color: #1f2937; + border-radius: 4px; + transition: background 0.2s; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + flex-shrink: 0; } .collapse-trigger:hover, .mobile-menu-trigger:hover { - background: #f9fafb; + background: #f9fafb; } -.page-title-group { - display: flex; - flex-direction: column; - justify-content: center; - line-height: 1.4; +.page-breadcrumb { + font-size: 14px; + line-height: 1; + white-space: nowrap; } -.page-title { - font-size: 20px; - font-weight: 600; - color: #1f2937; - line-height: 1.2; - margin-bottom: 4px; +.page-breadcrumb .ant-breadcrumb-link { + color: #1f2937; } -.page-subtitle { - font-size: 14px; - color: #6b7280; - font-style: italic; - line-height: 1.2; +.breadcrumb-crumb { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.breadcrumb-crumb .anticon { + font-size: 15px; +} + +.page-breadcrumb .ant-breadcrumb-separator { + color: #9ca3af; } .main-content { - padding: 24px; - background: #f3f4f6; - min-height: calc(100vh - 64px); + padding: 20px; + background: #f3f4f6; + min-height: calc(100vh - 50px); } @media (max-width: 768px) { - .main-content { - padding: 16px; - } + .main-content { + padding: 16px; + } - .main-header { - padding: 0 16px !important; - } - - .page-title { - font-size: 18px; - } - - .page-subtitle { - font-size: 12px; - } + .main-header { + padding: 0 16px !important; + } } diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index 589f1e3..35ba049 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -1,103 +1,121 @@ import { useEffect, useMemo, useState } from 'react'; import { Outlet, useLocation } from 'react-router-dom'; -import { Drawer, Layout } from 'antd'; +import { Breadcrumb, Drawer, Layout } from 'antd'; import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons'; import SidebarMenu from './SidebarMenu'; -import { getPageInfo } from './menuConfig'; +import { getBreadcrumbItems } from './menuConfig'; import './MainLayout.css'; const { Header, Sider, Content } = Layout; const MainLayout = () => { - const [collapsed, setCollapsed] = useState(false); - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - const [isMobile, setIsMobile] = useState(false); - const location = useLocation(); + const [collapsed, setCollapsed] = useState(false); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [isMobile, setIsMobile] = useState(false); + const location = useLocation(); - const pageInfo = useMemo(() => getPageInfo(location.pathname), [location.pathname]); + const breadcrumbItems = useMemo( + () => + getBreadcrumbItems(location.pathname).map((crumb) => ({ + title: ( + + {crumb.icon} + {crumb.label} + + ), + })), + [location.pathname] + ); - useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth < 768); - if (window.innerWidth >= 768) setMobileMenuOpen(false); - }; - checkMobile(); - window.addEventListener('resize', checkMobile); - return () => window.removeEventListener('resize', checkMobile); - }, []); + useEffect(() => { + const checkMobile = () => { + setIsMobile(window.innerWidth < 768); + if (window.innerWidth >= 768) setMobileMenuOpen(false); + }; + checkMobile(); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, []); - return ( - - {!isMobile && ( - -
-
{collapsed ? 'A' : 'Atlas Kit'}
- {!collapsed &&
商品工作台
} -
- -
- )} - - {isMobile && ( - setMobileMenuOpen(false)} - open={mobileMenuOpen} - styles={{ body: { padding: 0, background: 'var(--sider-bg)' } }} - width={200} - > - setMobileMenuOpen(false)} /> - - )} - - -
-
- {isMobile && ( - setMobileMenuOpen(true)} - /> - )} + return ( + {!isMobile && ( -
setCollapsed(!collapsed)}> - {collapsed ? : } -
+ +
+
+ Atlas Kit + A +
+
商品工作台
+
+ +
)} -
-
{pageInfo.title}
-
{pageInfo.subtitle}
-
-
-
- - - -
-
- ); + {isMobile && ( + setMobileMenuOpen(false)} + open={mobileMenuOpen} + styles={{ body: { padding: 0, background: 'var(--sider-bg)' } }} + width={200} + > + setMobileMenuOpen(false)} /> + + )} + + +
+
+ {isMobile && ( + setMobileMenuOpen(true)} + /> + )} + {!isMobile && ( +
setCollapsed(!collapsed)} + > + {collapsed ? : } +
+ )} + +
+
+ + + + +
+ + ); }; export default MainLayout; diff --git a/apps/web/src/layouts/SidebarMenu.css b/apps/web/src/layouts/SidebarMenu.css index fbfd0b6..0b9fbe1 100644 --- a/apps/web/src/layouts/SidebarMenu.css +++ b/apps/web/src/layouts/SidebarMenu.css @@ -1,134 +1,162 @@ .sidebar-menu-container { - flex: 1; - overflow: hidden; - display: flex; - flex-direction: column; + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; } .sidebar-menu { - background: transparent !important; - border: none; - height: 100%; - overflow-y: auto; - overflow-x: hidden; + background: transparent !important; + border: none; + height: 100%; + overflow-y: auto; + overflow-x: hidden; } .sidebar-menu .ant-menu-item { - margin: 0 !important; - padding: 12px 20px !important; - height: auto !important; - line-height: 1.5 !important; - transition: all 0.3s !important; - font-size: 15px !important; - font-weight: 600 !important; + margin: 0 !important; + padding: 12px 20px !important; + height: auto !important; + line-height: 1.5 !important; + transition: all 0.3s !important; + font-size: 15px !important; + font-weight: 600 !important; } .sidebar-menu .ant-menu-item .ant-menu-title-content { - font-size: 15px !important; - font-weight: 600 !important; - transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); - display: inline-block; + font-size: 15px !important; + font-weight: 600 !important; + /* antd 默认只过渡 color,会覆盖下面的 width/opacity 过渡;必须 !important 提权 */ + transition: + transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), + width 0.2s ease, + opacity 0.2s ease, + margin 0.3s, + color 0.3s !important; + display: inline-block; + /* 显式宽度让折叠时 width→0 可插值,配合裁剪+渐隐平滑收起(auto→0 无法动画) */ + width: 100%; + opacity: 1; + overflow: hidden; + white-space: nowrap; + vertical-align: middle; } .sidebar-menu .ant-menu-item:hover { - background: rgba(255, 255, 255, 0.1) !important; + background: rgba(255, 255, 255, 0.1) !important; } .sidebar-menu .ant-menu-item-selected { - background: rgba(255, 255, 255, 0.2) !important; - border-left: 3px solid white; + background: rgba(255, 255, 255, 0.2) !important; + border-left: 3px solid white; } .sidebar-menu .ant-menu-item-selected .ant-menu-title-content { - transform: translateX(8px); + transform: translateX(8px); } .sidebar-menu .ant-menu-item-selected:hover { - background: rgba(255, 255, 255, 0.25) !important; + background: rgba(255, 255, 255, 0.25) !important; } .sidebar-menu .ant-menu-item-selected:hover .ant-menu-title-content { - transform: translateX(8px); + transform: translateX(8px); } .sidebar-menu .ant-menu-item-group-title { - padding: 12px 20px 8px !important; - font-size: 12px !important; - color: rgba(255, 255, 255, 0.7) !important; - text-transform: uppercase; - letter-spacing: 1px; - font-weight: 500; + padding: 12px 20px 8px !important; + font-size: 12px !important; + color: rgba(255, 255, 255, 0.7) !important; + text-transform: uppercase; + letter-spacing: 1px; + font-weight: 500; + /* 折叠时渐隐并收起占位高度(!important 防止被 antd 默认 transition 覆盖) */ + max-height: 40px; + opacity: 1; + overflow: hidden; + transition: + opacity 0.2s ease, + max-height 0.2s ease, + padding 0.2s ease !important; } .sidebar-menu .ant-menu-item-icon { - transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); - font-size: 18px !important; - display: inline-flex; - align-items: center; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + font-size: 18px !important; + display: inline-flex; + align-items: center; } -.sidebar-menu .ant-menu-item:hover .ant-menu-item-icon { - transform: scale(1.05); +/* hover 放大只作用于非选中项;选中项图标固定 translateX(8px),hover 时不得变 transform 导致跳动 */ +.sidebar-menu .ant-menu-item:hover:not(.ant-menu-item-selected) .ant-menu-item-icon { + transform: scale(1.05); } .sidebar-menu .ant-menu-item-selected .ant-menu-item-icon { - transform: translateX(8px); + transform: translateX(8px); } @media (max-width: 768px) { - .sidebar-menu .ant-menu-item { - padding: 14px 20px !important; - } + .sidebar-menu .ant-menu-item { + padding: 14px 20px !important; + } } /* ── 折叠态(60px):图标居中,覆盖展开态的左对齐 padding/位移 ── */ .sidebar-menu.ant-menu-inline-collapsed .ant-menu-item, .sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-group-title { - padding: 12px 0 !important; - text-align: center; + padding: 12px 0 !important; + text-align: center; +} + +/* 分组标题折叠时渐隐并完全收起,不占纵向空间 */ +.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-group-title { + max-height: 0; + opacity: 0; + padding-top: 0 !important; + padding-bottom: 0 !important; } .sidebar-menu.ant-menu-inline-collapsed .ant-menu-item { - display: flex; - align-items: center; - justify-content: center; - width: 100%; + display: flex; + align-items: center; + justify-content: center; + width: 100%; } .sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-title-content, .sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected .ant-menu-title-content { - transform: none !important; + transform: none !important; } .sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-item-icon, .sidebar-menu.ant-menu-inline-collapsed .ant-menu-item:hover .ant-menu-item-icon, .sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected .ant-menu-item-icon { - transform: none !important; - margin: 0; + transform: none !important; + margin: 0; } -/* 折叠时去掉左侧 3px 选中边框(会挤偏图标),选中态用高亮背景区分 */ +/* 折叠时去掉左侧 3px 选中边框(会挤偏图标);只过渡宽度,避免 none 突变 */ .sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected { - border-left: none !important; + border-left-width: 0 !important; } -/* 折叠时隐藏 label 占位(antd 折叠动画保留 opacity 占位,会把图标挤离中心) */ -.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-title-content { - display: none !important; +/* 折叠时菜单文字平滑收起:width 100%→0 插值 + 裁剪 + 渐隐; + 不用 display:none(无法过渡,文字瞬间消失) */ +.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-title-content, +.sidebar-menu.ant-menu-vertical .ant-menu-item .ant-menu-title-content { + width: 0 !important; + opacity: 0 !important; } /* 兼容:部分版本折叠态用 vertical 类渲染,同样居中 */ .sidebar-menu.ant-menu-vertical .ant-menu-item, .sidebar-menu.ant-menu-inline-collapsed .ant-menu-item { - padding-left: 0 !important; - padding-right: 0 !important; -} - -.sidebar-menu.ant-menu-vertical .ant-menu-item .ant-menu-title-content { - display: none !important; + padding-left: 0 !important; + padding-right: 0 !important; } /* 折叠态 flex 子元素居中(display:flex 已生效,缺 justify-content) */ .sidebar-menu.ant-menu-inline-collapsed .ant-menu-item { - justify-content: center !important; + justify-content: center !important; } diff --git a/apps/web/src/layouts/SidebarMenu.tsx b/apps/web/src/layouts/SidebarMenu.tsx index 4f69933..0518293 100644 --- a/apps/web/src/layouts/SidebarMenu.tsx +++ b/apps/web/src/layouts/SidebarMenu.tsx @@ -6,57 +6,58 @@ import { routeMenuConfig } from './menuConfig'; import './SidebarMenu.css'; interface SidebarMenuProps { - collapsed: boolean; - onMenuClick?: () => void; + collapsed: boolean; + onMenuClick?: () => void; } const SidebarMenu = ({ collapsed, onMenuClick }: SidebarMenuProps) => { - const location = useLocation(); - const navigate = useNavigate(); + const location = useLocation(); + const navigate = useNavigate(); - const menuItems: MenuProps['items'] = useMemo(() => { - const children = routeMenuConfig.map((config) => ({ - key: config.key, - icon: config.icon, - label: config.label, - })); - return [ - { - type: 'group' as const, - label: collapsed ? '' : '主要功能', - className: 'menu-section-title', - children, - }, - ]; - }, [collapsed]); + // 分组标题保持常驻,折叠时的隐藏由 CSS 过渡完成,避免文字瞬间消失 + const menuItems: MenuProps['items'] = useMemo(() => { + const children = routeMenuConfig.map((config) => ({ + key: config.key, + icon: config.icon, + label: config.label, + })); + return [ + { + type: 'group' as const, + label: '主要功能', + className: 'menu-section-title', + children, + }, + ]; + }, []); - const selectedKeys = useMemo(() => { - const path = location.pathname; - if (path === '/' || path === '') { - return [routeMenuConfig[0].key]; - } - const config = routeMenuConfig.find((item) => path.startsWith(item.path)); - return [config ? config.key : path]; - }, [location.pathname]); + const selectedKeys = useMemo(() => { + const path = location.pathname; + if (path === '/' || path === '') { + return [routeMenuConfig[0].key]; + } + const config = routeMenuConfig.find((item) => path.startsWith(item.path)); + return [config ? config.key : path]; + }, [location.pathname]); - const handleMenuClick: MenuProps['onClick'] = ({ key }) => { - navigate(key); - if (onMenuClick) onMenuClick(); - }; + const handleMenuClick: MenuProps['onClick'] = ({ key }) => { + navigate(key); + if (onMenuClick) onMenuClick(); + }; - return ( -
- -
- ); + return ( +
+ +
+ ); }; export default SidebarMenu; diff --git a/apps/web/src/layouts/menuConfig.tsx b/apps/web/src/layouts/menuConfig.tsx index 79dd7de..93222b4 100644 --- a/apps/web/src/layouts/menuConfig.tsx +++ b/apps/web/src/layouts/menuConfig.tsx @@ -1,80 +1,72 @@ import { - CalculatorOutlined, - GlobalOutlined, - InboxOutlined, - PictureOutlined, - ShopOutlined, + CalculatorOutlined, + CodeSandboxOutlined, + EditOutlined, + GlobalOutlined, + InboxOutlined, + PictureOutlined, + ShopOutlined, } from '@ant-design/icons'; import type { ReactNode } from 'react'; export interface RouteMenuConfig { - /** 路由路径 */ - path: string; - /** 菜单键值(与 path 相同) */ - key: string; - /** 菜单图标 */ - icon: ReactNode; - /** 菜单标签 */ - label: string; - /** 页面标题 */ - title: string; - /** 页面副标题 */ - subtitle: string; + /** 路由路径 */ + path: string; + /** 菜单键值(与 path 相同) */ + key: string; + /** 菜单图标(菜单与页头面包屑共用) */ + icon: ReactNode; + /** 菜单标签 */ + label: string; } /** 左侧一级菜单,顺序与 docs/requirements/account-store-watermark.md 的导航约定一致 */ export const routeMenuConfig: RouteMenuConfig[] = [ - { - path: '/online-products', - key: '/online-products', - icon: , - label: '在线商品', - title: '在线商品', - subtitle: '按 SKU 查看平台已上架商品与同步状态', - }, - { - path: '/collection', - key: '/collection', - icon: , - label: '采集箱', - title: '采集箱', - subtitle: '查看已采集的商品,进入编辑上架', - }, - { - path: '/shops', - key: '/shops', - icon: , - label: '店铺管理', - title: '店铺管理', - subtitle: '绑定平台店铺授权与水印', - }, - { - path: '/watermarks', - key: '/watermarks', - icon: , - label: '水印管理', - title: '水印管理', - subtitle: '维护水印模板并绑定到店铺', - }, - { - path: '/pricing', - key: '/pricing', - icon: , - label: '定价工具', - title: '定价工具', - subtitle: '独立试算与物流计价规则', - }, + { + path: '/online-products', + key: '/online-products', + icon: , + label: '在线商品', + }, + { + path: '/collection', + key: '/collection', + icon: , + label: '采集箱', + }, + { + path: '/shops', + key: '/shops', + icon: , + label: '店铺管理', + }, + { + path: '/watermarks', + key: '/watermarks', + icon: , + label: '水印管理', + }, + { + path: '/pricing', + key: '/pricing', + icon: , + label: '定价工具', + }, ]; -/** 根据路径获取页面标题信息 */ -export const getPageInfo = (path: string): { title: string; subtitle: string } => { - if (path === '/' || path === '') { - const first = routeMenuConfig[0]; - return { title: first.title, subtitle: first.subtitle }; - } - if (path.startsWith('/products/')) { - return { title: '商品编辑', subtitle: '商品 AI 编辑页:SPU/SKU、媒体、定价与发布' }; - } - const config = routeMenuConfig.find((item) => path.startsWith(item.path)); - return config ? { title: config.title, subtitle: config.subtitle } : getPageInfo('/'); +export interface BreadcrumbCrumb { + icon: ReactNode; + label: string; +} + +/** 页头面包屑:一级菜单页显示单级;商品 AI 编辑页不在菜单中,显示「商品 > 商品编辑」两级 */ +export const getBreadcrumbItems = (path: string): BreadcrumbCrumb[] => { + if (path.startsWith('/products/')) { + 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/main.tsx b/apps/web/src/main.tsx index cc14d83..9c0ae47 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -4,7 +4,7 @@ import './index.css'; import App from './App'; createRoot(document.getElementById('root')!).render( - - - , + + + ); diff --git a/apps/web/src/pages/auth/LoginPage.tsx b/apps/web/src/pages/auth/LoginPage.tsx index 1415800..2bb6062 100644 --- a/apps/web/src/pages/auth/LoginPage.tsx +++ b/apps/web/src/pages/auth/LoginPage.tsx @@ -10,76 +10,80 @@ import { authToken } from '@/services/authToken'; import { emailFormatRule } from '@/utils/validators'; interface LoginFormValues { - email: string; - password: string; + email: string; + password: string; } /** 登录页,契约见 docs/api/auth.md §3 */ const LoginPage = () => { - const navigate = useNavigate(); - const { message } = AntdApp.useApp(); - const [submitting, setSubmitting] = useState(false); + const navigate = useNavigate(); + const { message } = AntdApp.useApp(); + const [submitting, setSubmitting] = useState(false); - const handleFinish = async (values: LoginFormValues) => { - setSubmitting(true); - try { - const session = await authApi.login({ - email: values.email.trim(), - password: values.password, - }); - authToken.set(session.access_token); - message.success('登录成功'); - navigate('/online-products', { replace: true }); - } catch (error) { - // 契约约定登录失败统一返回 401 INVALID_CREDENTIALS,不区分邮箱不存在或密码错误 - if (axios.isAxiosError(error) && error.response?.status === 401) { - message.error('邮箱或密码不正确'); - } else { - message.error(apiErrorMessage(error)); - } - } finally { - setSubmitting(false); - } - }; + const handleFinish = async (values: LoginFormValues) => { + setSubmitting(true); + try { + const session = await authApi.login({ + email: values.email.trim(), + password: values.password, + }); + authToken.set(session.access_token); + message.success('登录成功'); + navigate('/online-products', { replace: true }); + } catch (error) { + // 契约约定登录失败统一返回 401 INVALID_CREDENTIALS,不区分邮箱不存在或密码错误 + if (axios.isAxiosError(error) && error.response?.status === 401) { + message.error('邮箱或密码不正确'); + } else { + message.error(apiErrorMessage(error)); + } + } finally { + setSubmitting(false); + } + }; - return ( - - 还没有账户?立即注册 - - } - > - layout="vertical" requiredMark={false} onFinish={handleFinish}> - + 还没有账户?立即注册 + + } > - } - placeholder="请输入邮箱" - autoComplete="email" - autoFocus - /> - - - } - placeholder="请输入密码" - autoComplete="current-password" - /> - - - - - - - ); + layout="vertical" requiredMark={false} onFinish={handleFinish}> + + } + placeholder="请输入邮箱" + autoComplete="email" + autoFocus + /> + + + } + placeholder="请输入密码" + autoComplete="current-password" + /> + + + + + + + ); }; export default LoginPage; diff --git a/apps/web/src/pages/auth/RegisterPage.tsx b/apps/web/src/pages/auth/RegisterPage.tsx index df7644f..7a2b330 100644 --- a/apps/web/src/pages/auth/RegisterPage.tsx +++ b/apps/web/src/pages/auth/RegisterPage.tsx @@ -10,126 +10,126 @@ import { authToken } from '@/services/authToken'; import { emailFormatRule } from '@/utils/validators'; interface RegisterFormValues { - display_name?: string; - email: string; - password: string; - password_confirmation: string; + display_name?: string; + email: string; + password: string; + password_confirmation: string; } /** 注册页,契约见 docs/api/auth.md §2;密码长度与复杂度由服务端唯一校验,前端不自行放宽或收窄 */ const RegisterPage = () => { - const navigate = useNavigate(); - const { message } = AntdApp.useApp(); - const [form] = Form.useForm(); - const [submitting, setSubmitting] = useState(false); + const navigate = useNavigate(); + const { message } = AntdApp.useApp(); + const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); - const handleFinish = async (values: RegisterFormValues) => { - setSubmitting(true); - try { - const session = await authApi.register({ - email: values.email.trim(), - password: values.password, - password_confirmation: values.password_confirmation, - display_name: values.display_name?.trim() || undefined, - }); - // 注册成功即在同一事务创建用户、个人工作空间和登录会话 - authToken.set(session.access_token); - message.success('注册成功,已进入你的工作空间'); - navigate('/online-products', { replace: true }); - } catch (error) { - // 重复邮箱返回 409 EMAIL_ALREADY_REGISTERED,落到邮箱字段上提示 - if (axios.isAxiosError(error) && error.response?.status === 409) { - form.setFields([{ name: 'email', errors: ['该邮箱已注册,请直接登录'] }]); - } else { - message.error(apiErrorMessage(error)); - } - } finally { - setSubmitting(false); - } - }; + const handleFinish = async (values: RegisterFormValues) => { + setSubmitting(true); + try { + const session = await authApi.register({ + email: values.email.trim(), + password: values.password, + password_confirmation: values.password_confirmation, + display_name: values.display_name?.trim() || undefined, + }); + // 注册成功即在同一事务创建用户、个人工作空间和登录会话 + authToken.set(session.access_token); + message.success('注册成功,已进入你的工作空间'); + navigate('/online-products', { replace: true }); + } catch (error) { + // 重复邮箱返回 409 EMAIL_ALREADY_REGISTERED,落到邮箱字段上提示 + if (axios.isAxiosError(error) && error.response?.status === 409) { + form.setFields([{ name: 'email', errors: ['该邮箱已注册,请直接登录'] }]); + } else { + message.error(apiErrorMessage(error)); + } + } finally { + setSubmitting(false); + } + }; - return ( - - 已有账户?直接登录 - - } - > - - form={form} - layout="vertical" - requiredMark={false} - onFinish={handleFinish} - > - + 已有账户?直接登录 + + } > - } - placeholder="选填" - maxLength={80} - autoComplete="nickname" - /> - - - } - placeholder="请输入邮箱" - autoComplete="email" - /> - - - } - placeholder="请输入密码" - autoComplete="new-password" - /> - - ({ - validator(_, value) { - if (!value || getFieldValue('password') === value) { - return Promise.resolve(); - } - return Promise.reject(new Error('两次输入的密码不一致')); - }, - }), - ]} - > - } - placeholder="请再次输入密码" - autoComplete="new-password" - /> - - - - - - - ); + + form={form} + layout="vertical" + requiredMark={false} + onFinish={handleFinish} + > + + } + placeholder="选填" + maxLength={80} + autoComplete="nickname" + /> + + + } + placeholder="请输入邮箱" + autoComplete="email" + /> + + + } + placeholder="请输入密码" + autoComplete="new-password" + /> + + ({ + validator(_, value) { + if (!value || getFieldValue('password') === value) { + return Promise.resolve(); + } + return Promise.reject(new Error('两次输入的密码不一致')); + }, + }), + ]} + > + } + placeholder="请再次输入密码" + autoComplete="new-password" + /> + + + + + + + ); }; export default RegisterPage; diff --git a/apps/web/src/pages/collection/CollectionPage.tsx b/apps/web/src/pages/collection/CollectionPage.tsx index 55cdd3b..e86fdaf 100644 --- a/apps/web/src/pages/collection/CollectionPage.tsx +++ b/apps/web/src/pages/collection/CollectionPage.tsx @@ -2,7 +2,7 @@ import PagePlaceholder from '@/components/PagePlaceholder'; /** 采集箱:查看已采集的商品,进入编辑上架(业务模块尚未实现) */ const CollectionPage = () => ( - + ); export default CollectionPage; diff --git a/apps/web/src/pages/online-products/OnlineProductsPage.tsx b/apps/web/src/pages/online-products/OnlineProductsPage.tsx index b017d53..77dc6de 100644 --- a/apps/web/src/pages/online-products/OnlineProductsPage.tsx +++ b/apps/web/src/pages/online-products/OnlineProductsPage.tsx @@ -2,10 +2,10 @@ import PagePlaceholder from '@/components/PagePlaceholder'; /** 在线商品:按 SKU 查看平台已上架商品与同步状态(业务模块尚未实现) */ const OnlineProductsPage = () => ( - + ); export default OnlineProductsPage; diff --git a/apps/web/src/pages/pricing/PricingPage.tsx b/apps/web/src/pages/pricing/PricingPage.tsx index c2ce77d..928dd8a 100644 --- a/apps/web/src/pages/pricing/PricingPage.tsx +++ b/apps/web/src/pages/pricing/PricingPage.tsx @@ -2,7 +2,7 @@ import PagePlaceholder from '@/components/PagePlaceholder'; /** 定价工具:独立试算与物流计价规则(业务模块尚未实现) */ const PricingPage = () => ( - + ); export default PricingPage; diff --git a/apps/web/src/pages/products/ProductEditPage.tsx b/apps/web/src/pages/products/ProductEditPage.tsx index 1898016..a053335 100644 --- a/apps/web/src/pages/products/ProductEditPage.tsx +++ b/apps/web/src/pages/products/ProductEditPage.tsx @@ -3,14 +3,14 @@ import PagePlaceholder from '@/components/PagePlaceholder'; /** 商品 AI 编辑页:编辑 SPU/SKU、媒体、定价与首次发布(业务模块尚未实现) */ const ProductEditPage = () => { - const { id } = useParams<{ id: string }>(); + const { id } = useParams<{ id: string }>(); - return ( - - ); + return ( + + ); }; export default ProductEditPage; diff --git a/apps/web/src/pages/shops/ShopsPage.tsx b/apps/web/src/pages/shops/ShopsPage.tsx index ad11521..0d2ff1f 100644 --- a/apps/web/src/pages/shops/ShopsPage.tsx +++ b/apps/web/src/pages/shops/ShopsPage.tsx @@ -2,7 +2,7 @@ import PagePlaceholder from '@/components/PagePlaceholder'; /** 店铺管理:绑定平台店铺授权与水印(业务模块尚未实现) */ const ShopsPage = () => ( - + ); export default ShopsPage; diff --git a/apps/web/src/pages/watermarks/WatermarksPage.tsx b/apps/web/src/pages/watermarks/WatermarksPage.tsx index 81f01f1..3676ba2 100644 --- a/apps/web/src/pages/watermarks/WatermarksPage.tsx +++ b/apps/web/src/pages/watermarks/WatermarksPage.tsx @@ -2,7 +2,7 @@ import PagePlaceholder from '@/components/PagePlaceholder'; /** 水印管理:维护水印模板并绑定到店铺(业务模块尚未实现) */ const WatermarksPage = () => ( - + ); export default WatermarksPage; diff --git a/apps/web/src/router/index.tsx b/apps/web/src/router/index.tsx index 8b65f67..456cefb 100644 --- a/apps/web/src/router/index.tsx +++ b/apps/web/src/router/index.tsx @@ -10,22 +10,22 @@ import ShopsPage from '@/pages/shops/ShopsPage'; import WatermarksPage from '@/pages/watermarks/WatermarksPage'; export const router = createBrowserRouter([ - { - path: '/', - element: , - children: [ - { index: true, element: }, - { path: 'online-products', element: }, - { path: 'collection', element: }, - { path: 'shops', element: }, - { path: 'watermarks', element: }, - { path: 'pricing', element: }, - // 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现 - { path: 'products/:id', element: }, - ], - }, - // 认证页在主布局之外;API 侧会话体系实现后再加路由守卫 - { path: 'login', element: }, - { path: 'register', element: }, - { path: '*', element: }, + { + path: '/', + element: , + children: [ + { index: true, element: }, + { path: 'online-products', element: }, + { path: 'collection', element: }, + { path: 'shops', element: }, + { path: 'watermarks', element: }, + { path: 'pricing', element: }, + // 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现 + { path: 'products/:id', element: }, + ], + }, + // 认证页在主布局之外;API 侧会话体系实现后再加路由守卫 + { path: 'login', element: }, + { path: 'register', element: }, + { path: '*', element: }, ]); diff --git a/apps/web/src/services/api.ts b/apps/web/src/services/api.ts index afa628d..627cf87 100644 --- a/apps/web/src/services/api.ts +++ b/apps/web/src/services/api.ts @@ -4,26 +4,26 @@ import { envConfig } from '@/config/env'; import { authToken } from '@/services/authToken'; declare module 'axios' { - export interface AxiosRequestConfig { - /** 401 刷新重放标记,防止无限重试 */ - _retried?: boolean; - } + export interface AxiosRequestConfig { + /** 401 刷新重放标记,防止无限重试 */ + _retried?: boolean; + } } const apiClient: AxiosInstance = axios.create({ - baseURL: envConfig.apiBaseUrl, - timeout: 30000, - // 不设全局 Content-Type:axios 1.x 遇到「默认 JSON 头 + FormData」会把表单转成 JSON 发送, - // 导致素材上传等表单接口解析失败。对象请求体 axios 会自动补 JSON 头。 + baseURL: envConfig.apiBaseUrl, + timeout: 30000, + // 不设全局 Content-Type:axios 1.x 遇到「默认 JSON 头 + FormData」会把表单转成 JSON 发送, + // 导致素材上传等表单接口解析失败。对象请求体 axios 会自动补 JSON 头。 }); // access token 仅在内存中短暂持有(docs/api/conventions.md §1),每次请求动态附加 Bearer 头 apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => { - const token = authToken.get(); - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; + const token = authToken.get(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; }); // 认证接口自身的失败不进入刷新流程 @@ -33,70 +33,70 @@ let refreshTask: Promise | null = null; /** 并发 401 只触发一次刷新请求;刷新依赖 HttpOnly refresh cookie */ function refreshAccessToken(): Promise { - refreshTask ??= apiClient - .post<{ access_token: string }>('/auth/refresh') - .then((res) => { - authToken.set(res.data.access_token); - return res.data.access_token; - }) - .catch(() => { - authToken.clear(); - return null; - }) - .finally(() => { - refreshTask = null; - }); - return refreshTask; + refreshTask ??= apiClient + .post<{ access_token: string }>('/auth/refresh') + .then((res) => { + authToken.set(res.data.access_token); + return res.data.access_token; + }) + .catch(() => { + authToken.clear(); + return null; + }) + .finally(() => { + refreshTask = null; + }); + return refreshTask; } apiClient.interceptors.response.use( - (response) => response, - async (error) => { - if (envConfig.debug) { - console.error('请求失败:', error.response?.data || error.message); + (response) => response, + async (error) => { + if (envConfig.debug) { + console.error('请求失败:', error.response?.data || error.message); + } + const config = error.config as AxiosRequestConfig | undefined; + const url = config?.url ?? ''; + const isAuthPath = AUTH_PATHS.some((path) => url.includes(path)); + // access token 过期时用 refresh cookie 换新 token 并重放一次; + // 刷新失败说明会话已失效,整页跳转登录页(后续可换成路由内跳转并保留来源) + if (error.response?.status === 401 && config && !config._retried && !isAuthPath) { + config._retried = true; + const token = await refreshAccessToken(); + if (token) { + return apiClient.request(config); + } + window.location.assign('/login'); + } + return Promise.reject(error); } - const config = error.config as AxiosRequestConfig | undefined; - const url = config?.url ?? ''; - const isAuthPath = AUTH_PATHS.some((path) => url.includes(path)); - // access token 过期时用 refresh cookie 换新 token 并重放一次; - // 刷新失败说明会话已失效,整页跳转登录页(后续可换成路由内跳转并保留来源) - if (error.response?.status === 401 && config && !config._retried && !isAuthPath) { - config._retried = true; - const token = await refreshAccessToken(); - if (token) { - return apiClient.request(config); - } - window.location.assign('/login'); - } - return Promise.reject(error); - }, ); export const api = { - get: (url: string, config?: AxiosRequestConfig) => - apiClient.get(url, config).then((res) => res.data), + get: (url: string, config?: AxiosRequestConfig) => + apiClient.get(url, config).then((res) => res.data), - post: (url: string, data?: unknown, config?: AxiosRequestConfig) => - apiClient.post(url, data, config).then((res) => res.data), + post: (url: string, data?: unknown, config?: AxiosRequestConfig) => + apiClient.post(url, data, config).then((res) => res.data), - patch: (url: string, data?: unknown, config?: AxiosRequestConfig) => - apiClient.patch(url, data, config).then((res) => res.data), + patch: (url: string, data?: unknown, config?: AxiosRequestConfig) => + apiClient.patch(url, data, config).then((res) => res.data), - delete: (url: string, config?: AxiosRequestConfig) => - apiClient.delete(url, config).then((res) => res.data), + delete: (url: string, config?: AxiosRequestConfig) => + apiClient.delete(url, config).then((res) => res.data), }; /** 统一错误格式为 { error: { code, message, request_id } }(docs/api/conventions.md),提取可展示信息 */ export function apiErrorMessage(error: unknown): string { - if (axios.isAxiosError(error)) { - const data = error.response?.data as - { error?: { message?: unknown }; message?: unknown } | undefined; - if (data?.error?.message) return String(data.error.message); - if (data?.message) return String(data.message); - if (error.response) return `请求失败(HTTP ${error.response.status})`; - return '网络连接失败,请稍后重试'; - } - return error instanceof Error ? error.message : String(error); + if (axios.isAxiosError(error)) { + const data = error.response?.data as + { error?: { message?: unknown }; message?: unknown } | undefined; + if (data?.error?.message) return String(data.error.message); + if (data?.message) return String(data.message); + if (error.response) return `请求失败(HTTP ${error.response.status})`; + return '网络连接失败,请稍后重试'; + } + return error instanceof Error ? error.message : String(error); } export default apiClient; diff --git a/apps/web/src/services/auth.ts b/apps/web/src/services/auth.ts index d0679d0..bf1a22d 100644 --- a/apps/web/src/services/auth.ts +++ b/apps/web/src/services/auth.ts @@ -3,11 +3,11 @@ import type { AuthSession, LoginPayload, MeResult, RegisterPayload } from '@/typ /** 账户与会话接口,契约见 docs/api/auth.md */ export const authApi = { - register: (payload: RegisterPayload) => api.post('/auth/register', payload), + register: (payload: RegisterPayload) => api.post('/auth/register', payload), - login: (payload: LoginPayload) => api.post('/auth/login', payload), + login: (payload: LoginPayload) => api.post('/auth/login', payload), - logout: () => api.post('/auth/logout'), + logout: () => api.post('/auth/logout'), - me: () => api.get('/auth/me'), + me: () => api.get('/auth/me'), }; diff --git a/apps/web/src/services/authToken.ts b/apps/web/src/services/authToken.ts index d0b8241..c8afc8b 100644 --- a/apps/web/src/services/authToken.ts +++ b/apps/web/src/services/authToken.ts @@ -6,11 +6,11 @@ let accessToken: string | null = null; export const authToken = { - get: () => accessToken, - set: (token: string) => { - accessToken = token; - }, - clear: () => { - accessToken = null; - }, + get: () => accessToken, + set: (token: string) => { + accessToken = token; + }, + clear: () => { + accessToken = null; + }, }; diff --git a/apps/web/src/types/auth.ts b/apps/web/src/types/auth.ts index f68f230..6b9c4eb 100644 --- a/apps/web/src/types/auth.ts +++ b/apps/web/src/types/auth.ts @@ -4,52 +4,52 @@ */ export interface AuthUser { - id: string; - email: string; - display_name: string | null; - email_verified_at: string | null; - created_at?: string; + id: string; + email: string; + display_name: string | null; + email_verified_at: string | null; + created_at?: string; } export interface AuthWorkspace { - id: string; - name: string; - role: string; + id: string; + name: string; + role: string; } /** 注册 / 登录成功返回的会话(access token 短期有效,refresh token 在 HttpOnly cookie 中) */ export interface AuthSession { - access_token: string; - access_token_expires_in: number; - user: AuthUser; - workspace: AuthWorkspace; + access_token: string; + access_token_expires_in: number; + user: AuthUser; + workspace: AuthWorkspace; } export interface RegisterPayload { - email: string; - password: string; - password_confirmation: string; - display_name?: string; + email: string; + password: string; + password_confirmation: string; + display_name?: string; } export interface LoginPayload { - email: string; - password: string; + email: string; + password: string; } export interface PlatformSummary { - id: string; - code: string; - name: string; + id: string; + code: string; + name: string; } /** GET /api/auth/me 的返回;default_store 摘要字段以后续门店接口文档为准 */ export interface MeResult { - user: AuthUser; - workspace: AuthWorkspace; - platforms: PlatformSummary[]; - suggested_context: { - platform_id: string; - default_store: { id: string; name: string } | null; - }; + user: AuthUser; + workspace: AuthWorkspace; + platforms: PlatformSummary[]; + suggested_context: { + platform_id: string; + default_store: { id: string; name: string } | null; + }; } diff --git a/apps/web/src/utils/validators.ts b/apps/web/src/utils/validators.ts index bebd5e9..804fa4d 100644 --- a/apps/web/src/utils/validators.ts +++ b/apps/web/src/utils/validators.ts @@ -2,8 +2,8 @@ import type { Rule } from 'antd/es/form'; /** 邮箱格式校验:仅在非空时校验,避免与 required 规则在空值时同时报错(antd v6 行为) */ export const emailFormatRule: Rule = { - validator: (_, value: string) => - !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) - ? Promise.resolve() - : Promise.reject(new Error('邮箱格式不正确')), + validator: (_, value: string) => + !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) + ? Promise.resolve() + : Promise.reject(new Error('邮箱格式不正确')), }; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 71623a9..5e9dc56 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,21 +1,21 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "composite": true, - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo", - // Web 走 Vite 打包,使用 bundler 解析;覆盖 base 中面向 NestJS 的 Node16 模块设置 - "module": "ESNext", - "moduleResolution": "bundler", - "moduleDetection": "force", - "isolatedModules": true, - "useDefineForClassFields": true, - "lib": ["ES2023", "DOM", "DOM.Iterable"], - "jsx": "react-jsx", - "noEmit": true, - "allowImportingTsExtensions": false, - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["src", "vite.config.ts"] + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo", + // Web 走 Vite 打包,使用 bundler 解析;覆盖 base 中面向 NestJS 的 Node16 模块设置 + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "isolatedModules": true, + "useDefineForClassFields": true, + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "noEmit": true, + "allowImportingTsExtensions": false, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src", "vite.config.ts"] } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 131dc4a..d7e84db 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -4,20 +4,20 @@ import react from '@vitejs/plugin-react'; // 前端开发服务器:/api 代理到本地 NestJS API(apps/api,默认 8200) export default defineConfig({ - plugins: [react()], - resolve: { - alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)), + plugins: [react()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, }, - }, - server: { - port: Number(process.env.WEB_PORT ?? 8100), - strictPort: true, - proxy: { - '/api': { - target: process.env.API_ORIGIN ?? 'http://localhost:8200', - changeOrigin: true, - }, + server: { + port: Number(process.env.WEB_PORT ?? 8100), + strictPort: true, + proxy: { + '/api': { + target: process.env.API_ORIGIN ?? 'http://localhost:8200', + changeOrigin: true, + }, + }, }, - }, });