feat(web): 初始化前端项目

This commit is contained in:
R524809
2026-09-03 13:11:38 +08:00
parent 195b6d25e9
commit df40ddde8c
31 changed files with 948 additions and 848 deletions
+9 -9
View File
@@ -1,12 +1,12 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Atlas Seller Kit</title> <title>Atlas Seller Kit</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
+25 -25
View File
@@ -1,27 +1,27 @@
{ {
"name": "@atlas/web", "name": "@atlas/web",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"dev": "vite", "dev": "vite",
"typecheck": "tsc -b --pretty false" "typecheck": "tsc -b --pretty false"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "6.3.4", "@ant-design/icons": "6.3.4",
"antd": "6.6.2", "antd": "6.6.2",
"axios": "1.20.0", "axios": "1.20.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"react-router-dom": "7.18.3" "react-router-dom": "7.18.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "26.3.0", "@types/node": "26.3.0",
"@types/react": "19.2.18", "@types/react": "19.2.18",
"@types/react-dom": "19.2.5", "@types/react-dom": "19.2.5",
"@vitejs/plugin-react": "6.1.1", "@vitejs/plugin-react": "6.1.1",
"typescript": "7.0.2", "typescript": "7.0.2",
"vite": "8.2.2" "vite": "8.2.2"
} }
} }
+15
View File
@@ -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',
};
+19 -19
View File
@@ -5,25 +5,25 @@ import { router } from './router';
import './App.css'; import './App.css';
function App() { function App() {
return ( return (
<ConfigProvider <ConfigProvider
locale={zhCN} locale={zhCN}
theme={{ theme={{
token: { token: {
colorPrimary: '#8b5cf6', colorPrimary: '#8b5cf6',
borderRadius: 8, borderRadius: 8,
// 全局默认字号 14px;输入类控件默认高度在 32px 基础上同步加高一点 // 全局默认字号 14px;输入类控件默认高度在 32px 基础上同步加高一点
fontSize: 14, fontSize: 14,
controlHeight: 34, controlHeight: 34,
controlHeightSM: 26, controlHeightSM: 26,
}, },
}} }}
> >
<AntdApp> <AntdApp>
<RouterProvider router={router} /> <RouterProvider router={router} />
</AntdApp> </AntdApp>
</ConfigProvider> </ConfigProvider>
); );
} }
export default App; export default App;
+13 -13
View File
@@ -1,23 +1,23 @@
import { Card, Empty } from 'antd'; import { Card, Empty } from 'antd';
interface PagePlaceholderProps { interface PagePlaceholderProps {
title: string; title: string;
description: string; description: string;
} }
/** 业务模块实现前的统一占位页 */ /** 业务模块实现前的统一占位页 */
const PagePlaceholder = ({ title, description }: PagePlaceholderProps) => ( const PagePlaceholder = ({ title, description }: PagePlaceholderProps) => (
<Card> <Card>
<Empty <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}
description={ description={
<> <>
<div>{title}</div> <div>{title}</div>
<div>{description}</div> <div>{description}</div>
</> </>
} }
/> />
</Card> </Card>
); );
export default PagePlaceholder; export default PagePlaceholder;
+7 -7
View File
@@ -3,14 +3,14 @@
*/ */
interface EnvConfig { interface EnvConfig {
/** API 基础路径:开发环境由 vite 代理转发到 NestJS API */ /** API 基础路径:开发环境由 vite 代理转发到 NestJS API */
apiBaseUrl: string; apiBaseUrl: string;
appName: string; appName: string;
debug: boolean; debug: boolean;
} }
export const envConfig: EnvConfig = { export const envConfig: EnvConfig = {
apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? '/api', apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? '/api',
appName: import.meta.env.VITE_APP_NAME ?? 'Atlas Seller Kit', appName: import.meta.env.VITE_APP_NAME ?? 'Atlas Seller Kit',
debug: import.meta.env.VITE_DEBUG === 'true', debug: import.meta.env.VITE_DEBUG === 'true',
}; };
+26 -26
View File
@@ -1,37 +1,37 @@
:root { :root {
--primary-color: #8b5cf6; --primary-color: #8b5cf6;
--primary-light: #a78bfa; --primary-light: #a78bfa;
--primary-dark: #7c3aed; --primary-dark: #7c3aed;
/* 侧边菜单背景(纯色,中间调;想再调直接改这里) */ /* 侧边菜单背景(纯色,中间调;想再调直接改这里) */
--sider-bg: #6d28d9; --sider-bg: #6d28d9;
--text-primary: #1f2937; --text-primary: #1f2937;
--text-secondary: #6b7280; --text-secondary: #6b7280;
--bg-color: #f9fafb; --bg-color: #f9fafb;
--card-bg: #ffffff; --card-bg: #ffffff;
--border-color: #e5e7eb; --border-color: #e5e7eb;
} }
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
margin: 0; margin: 0;
font-family: font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Microsoft YaHei', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif; 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
background-color: var(--bg-color); background-color: var(--bg-color);
color: var(--text-primary); color: var(--text-primary);
/* 全局默认字号 14px,与 antd 主题 token 的 fontSize 保持一致 */ /* 全局默认字号 14px,与 antd 主题 token 的 fontSize 保持一致 */
font-size: 14px; font-size: 14px;
line-height: 1.6; line-height: 1.6;
} }
#root { #root {
min-height: 100vh; min-height: 100vh;
} }
+39 -39
View File
@@ -1,71 +1,71 @@
.auth-page { .auth-page {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 24px; padding: 24px;
background: background:
radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%), 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%), radial-gradient(900px 480px at 95% 110%, rgba(109, 40, 217, 0.1), transparent 55%),
var(--bg-color); var(--bg-color);
} }
.auth-panel { .auth-panel {
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
} }
.auth-brand { .auth-brand {
text-align: center; text-align: center;
margin-bottom: 24px; margin-bottom: 24px;
} }
.auth-logo { .auth-logo {
font-size: 26px; font-size: 26px;
font-weight: 700; font-weight: 700;
color: var(--primary-dark); color: var(--primary-dark);
} }
.auth-logo-subtitle { .auth-logo-subtitle {
font-size: 13px; font-size: 13px;
color: var(--text-secondary); color: var(--text-secondary);
margin-top: 2px; margin-top: 2px;
} }
.auth-card { .auth-card {
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 10px 30px rgba(17, 24, 39, 0.06); box-shadow: 0 10px 30px rgba(17, 24, 39, 0.06);
padding: 32px 28px; padding: 32px 28px;
} }
.auth-title { .auth-title {
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
} }
.auth-subtitle { .auth-subtitle {
font-size: 14px; font-size: 14px;
color: var(--text-secondary); color: var(--text-secondary);
margin: 4px 0 20px; margin: 4px 0 20px;
} }
.auth-footer { .auth-footer {
margin-top: 16px; margin-top: 16px;
text-align: center; text-align: center;
font-size: 14px; font-size: 14px;
color: var(--text-secondary); color: var(--text-secondary);
} }
.auth-footer a { .auth-footer a {
color: var(--primary-color); color: var(--primary-color);
font-weight: 500; font-weight: 500;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.auth-card { .auth-card {
padding: 24px 20px; padding: 24px 20px;
} }
} }
+25 -25
View File
@@ -3,37 +3,37 @@ import type { ReactNode } from 'react';
import './AuthLayout.css'; import './AuthLayout.css';
interface AuthLayoutProps { interface AuthLayoutProps {
/** 页面标题,如「登录」 */ /** 页面标题,如「登录」 */
title: string; title: string;
/** 标题下的一行说明 */ /** 标题下的一行说明 */
subtitle: string; subtitle: string;
/** 卡片底部的切换入口 */ /** 卡片底部的切换入口 */
footer: ReactNode; footer: ReactNode;
children: ReactNode; children: ReactNode;
} }
/** 注册/登录共用的居中卡片布局,品牌视觉与 MainLayout 一致 */ /** 注册/登录共用的居中卡片布局,品牌视觉与 MainLayout 一致 */
const AuthLayout = ({ title, subtitle, footer, children }: AuthLayoutProps) => { const AuthLayout = ({ title, subtitle, footer, children }: AuthLayoutProps) => {
useEffect(() => { useEffect(() => {
document.title = `${title} - Atlas Seller Kit`; document.title = `${title} - Atlas Seller Kit`;
}, [title]); }, [title]);
return ( return (
<div className="auth-page"> <div className="auth-page">
<div className="auth-panel"> <div className="auth-panel">
<div className="auth-brand"> <div className="auth-brand">
<div className="auth-logo">Atlas Kit</div> <div className="auth-logo">Atlas Kit</div>
<div className="auth-logo-subtitle">商品工作台</div> <div className="auth-logo-subtitle">商品工作台</div>
</div>
<div className="auth-card">
<div className="auth-title">{title}</div>
<div className="auth-subtitle">{subtitle}</div>
{children}
<div className="auth-footer">{footer}</div>
</div>
</div>
</div> </div>
<div className="auth-card"> );
<div className="auth-title">{title}</div>
<div className="auth-subtitle">{subtitle}</div>
{children}
<div className="auth-footer">{footer}</div>
</div>
</div>
</div>
);
}; };
export default AuthLayout; export default AuthLayout;
+116 -74
View File
@@ -1,115 +1,157 @@
.main-layout { .main-layout {
min-height: 100vh; min-height: 100vh;
} }
.main-sider { .main-sider {
background: var(--sider-bg) !important; background: var(--sider-bg) !important;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.sidebar-header { .sidebar-header {
padding: 24px 20px; padding: 24px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1); border-bottom: 1px solid rgba(255, 255, 255, 0.1);
text-align: center; text-align: center;
transition: padding 0.2s ease;
}
/* 折叠态收窄水平留白,与宽度动画同步 */
.ant-layout-sider-collapsed .sidebar-header {
padding: 20px 8px;
} }
.logo { .logo {
font-size: 22px; position: relative;
font-weight: 700; height: 31px;
color: white; line-height: 31px;
margin-bottom: 4px; 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 { .logo-subtitle {
font-size: 12px; max-height: 18px;
color: rgba(255, 255, 255, 0.8); 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 { .main-header {
background: white !important; background: white !important;
padding: 0 24px !important; padding: 0 24px !important;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
box-shadow: box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 3px 0 rgba(0, 0, 0, 0.1),
0 1px 2px 0 rgba(0, 0, 0, 0.06); 0 1px 2px 0 rgba(0, 0, 0, 0.06);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: 100;
height: 64px; height: 50px;
line-height: 1; line-height: 1;
} }
.header-left { .header-left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 12px;
} }
.collapse-trigger, .collapse-trigger,
.mobile-menu-trigger { .mobile-menu-trigger {
font-size: 18px; font-size: 16px;
cursor: pointer; cursor: pointer;
color: #1f2937; color: #1f2937;
border-radius: 4px; border-radius: 4px;
transition: background 0.2s; transition: background 0.2s;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 32px; width: 28px;
height: 32px; height: 28px;
flex-shrink: 0; flex-shrink: 0;
} }
.collapse-trigger:hover, .collapse-trigger:hover,
.mobile-menu-trigger:hover { .mobile-menu-trigger:hover {
background: #f9fafb; background: #f9fafb;
} }
.page-title-group { .page-breadcrumb {
display: flex; font-size: 14px;
flex-direction: column; line-height: 1;
justify-content: center; white-space: nowrap;
line-height: 1.4;
} }
.page-title { .page-breadcrumb .ant-breadcrumb-link {
font-size: 20px; color: #1f2937;
font-weight: 600;
color: #1f2937;
line-height: 1.2;
margin-bottom: 4px;
} }
.page-subtitle { .breadcrumb-crumb {
font-size: 14px; display: inline-flex;
color: #6b7280; align-items: center;
font-style: italic; gap: 6px;
line-height: 1.2; }
.breadcrumb-crumb .anticon {
font-size: 15px;
}
.page-breadcrumb .ant-breadcrumb-separator {
color: #9ca3af;
} }
.main-content { .main-content {
padding: 24px; padding: 20px;
background: #f3f4f6; background: #f3f4f6;
min-height: calc(100vh - 64px); min-height: calc(100vh - 50px);
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.main-content { .main-content {
padding: 16px; padding: 16px;
} }
.main-header { .main-header {
padding: 0 16px !important; padding: 0 16px !important;
} }
.page-title {
font-size: 18px;
}
.page-subtitle {
font-size: 12px;
}
} }
+103 -85
View File
@@ -1,103 +1,121 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation } from 'react-router-dom'; 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 { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
import SidebarMenu from './SidebarMenu'; import SidebarMenu from './SidebarMenu';
import { getPageInfo } from './menuConfig'; import { getBreadcrumbItems } from './menuConfig';
import './MainLayout.css'; import './MainLayout.css';
const { Header, Sider, Content } = Layout; const { Header, Sider, Content } = Layout;
const MainLayout = () => { const MainLayout = () => {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const location = useLocation(); const location = useLocation();
const pageInfo = useMemo(() => getPageInfo(location.pathname), [location.pathname]); const breadcrumbItems = useMemo(
() =>
getBreadcrumbItems(location.pathname).map((crumb) => ({
title: (
<span className="breadcrumb-crumb">
{crumb.icon}
<span>{crumb.label}</span>
</span>
),
})),
[location.pathname]
);
useEffect(() => { useEffect(() => {
const checkMobile = () => { const checkMobile = () => {
setIsMobile(window.innerWidth < 768); setIsMobile(window.innerWidth < 768);
if (window.innerWidth >= 768) setMobileMenuOpen(false); if (window.innerWidth >= 768) setMobileMenuOpen(false);
}; };
checkMobile(); checkMobile();
window.addEventListener('resize', checkMobile); window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile); return () => window.removeEventListener('resize', checkMobile);
}, []); }, []);
return ( return (
<Layout className="main-layout"> <Layout className="main-layout">
{!isMobile && (
<Sider
className="main-sider"
width={200}
collapsedWidth={60}
collapsed={collapsed}
theme="dark"
style={{
overflow: 'auto',
height: '100vh',
position: 'fixed',
left: 0,
top: 0,
bottom: 0,
}}
>
<div className="sidebar-header">
<div className="logo">{collapsed ? 'A' : 'Atlas Kit'}</div>
{!collapsed && <div className="logo-subtitle">商品工作台</div>}
</div>
<SidebarMenu collapsed={collapsed} />
</Sider>
)}
{isMobile && (
<Drawer
title="Atlas Kit"
placement="left"
onClose={() => setMobileMenuOpen(false)}
open={mobileMenuOpen}
styles={{ body: { padding: 0, background: 'var(--sider-bg)' } }}
width={200}
>
<SidebarMenu collapsed={false} onMenuClick={() => setMobileMenuOpen(false)} />
</Drawer>
)}
<Layout
className="main-content-layout"
style={{
marginLeft: isMobile ? 0 : collapsed ? 60 : 200,
transition: 'margin-left 0.2s',
}}
>
<Header className="main-header">
<div className="header-left">
{isMobile && (
<MenuFoldOutlined
className="mobile-menu-trigger"
onClick={() => setMobileMenuOpen(true)}
/>
)}
{!isMobile && ( {!isMobile && (
<div className="collapse-trigger" onClick={() => setCollapsed(!collapsed)}> <Sider
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />} className="main-sider"
</div> width={200}
collapsedWidth={60}
collapsed={collapsed}
theme="dark"
style={{
overflow: 'auto',
height: '100vh',
position: 'fixed',
left: 0,
top: 0,
bottom: 0,
}}
>
<div className="sidebar-header">
<div className="logo">
<span className="logo-full">Atlas Kit</span>
<span className="logo-short">A</span>
</div>
<div className="logo-subtitle">商品工作台</div>
</div>
<SidebarMenu collapsed={collapsed} />
</Sider>
)} )}
<div className="page-title-group">
<div className="page-title">{pageInfo.title}</div>
<div className="page-subtitle">{pageInfo.subtitle}</div>
</div>
</div>
</Header>
<Content className="main-content"> {isMobile && (
<Outlet /> <Drawer
</Content> title="Atlas Kit"
</Layout> placement="left"
</Layout> onClose={() => setMobileMenuOpen(false)}
); open={mobileMenuOpen}
styles={{ body: { padding: 0, background: 'var(--sider-bg)' } }}
width={200}
>
<SidebarMenu collapsed={false} onMenuClick={() => setMobileMenuOpen(false)} />
</Drawer>
)}
<Layout
className="main-content-layout"
style={{
marginLeft: isMobile ? 0 : collapsed ? 60 : 200,
transition: 'margin-left 0.2s',
}}
>
<Header className="main-header">
<div className="header-left">
{isMobile && (
<MenuFoldOutlined
className="mobile-menu-trigger"
onClick={() => setMobileMenuOpen(true)}
/>
)}
{!isMobile && (
<div
className="collapse-trigger"
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
)}
<Breadcrumb
className="page-breadcrumb"
separator=">"
items={breadcrumbItems}
/>
</div>
</Header>
<Content className="main-content">
<Outlet />
</Content>
</Layout>
</Layout>
);
}; };
export default MainLayout; export default MainLayout;
+91 -63
View File
@@ -1,134 +1,162 @@
.sidebar-menu-container { .sidebar-menu-container {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.sidebar-menu { .sidebar-menu {
background: transparent !important; background: transparent !important;
border: none; border: none;
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
} }
.sidebar-menu .ant-menu-item { .sidebar-menu .ant-menu-item {
margin: 0 !important; margin: 0 !important;
padding: 12px 20px !important; padding: 12px 20px !important;
height: auto !important; height: auto !important;
line-height: 1.5 !important; line-height: 1.5 !important;
transition: all 0.3s !important; transition: all 0.3s !important;
font-size: 15px !important; font-size: 15px !important;
font-weight: 600 !important; font-weight: 600 !important;
} }
.sidebar-menu .ant-menu-item .ant-menu-title-content { .sidebar-menu .ant-menu-item .ant-menu-title-content {
font-size: 15px !important; font-size: 15px !important;
font-weight: 600 !important; font-weight: 600 !important;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); /* antd 默认只过渡 color,会覆盖下面的 width/opacity 过渡;必须 !important 提权 */
display: inline-block; 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 { .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 { .sidebar-menu .ant-menu-item-selected {
background: rgba(255, 255, 255, 0.2) !important; background: rgba(255, 255, 255, 0.2) !important;
border-left: 3px solid white; border-left: 3px solid white;
} }
.sidebar-menu .ant-menu-item-selected .ant-menu-title-content { .sidebar-menu .ant-menu-item-selected .ant-menu-title-content {
transform: translateX(8px); transform: translateX(8px);
} }
.sidebar-menu .ant-menu-item-selected:hover { .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 { .sidebar-menu .ant-menu-item-selected:hover .ant-menu-title-content {
transform: translateX(8px); transform: translateX(8px);
} }
.sidebar-menu .ant-menu-item-group-title { .sidebar-menu .ant-menu-item-group-title {
padding: 12px 20px 8px !important; padding: 12px 20px 8px !important;
font-size: 12px !important; font-size: 12px !important;
color: rgba(255, 255, 255, 0.7) !important; color: rgba(255, 255, 255, 0.7) !important;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1px; letter-spacing: 1px;
font-weight: 500; 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 { .sidebar-menu .ant-menu-item-icon {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 18px !important; font-size: 18px !important;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
} }
.sidebar-menu .ant-menu-item:hover .ant-menu-item-icon { /* hover 放大只作用于非选中项;选中项图标固定 translateX(8px),hover 时不得变 transform 导致跳动 */
transform: scale(1.05); .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 { .sidebar-menu .ant-menu-item-selected .ant-menu-item-icon {
transform: translateX(8px); transform: translateX(8px);
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar-menu .ant-menu-item { .sidebar-menu .ant-menu-item {
padding: 14px 20px !important; padding: 14px 20px !important;
} }
} }
/* ── 折叠态(60px):图标居中,覆盖展开态的左对齐 padding/位移 ── */ /* ── 折叠态(60px):图标居中,覆盖展开态的左对齐 padding/位移 ── */
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item, .sidebar-menu.ant-menu-inline-collapsed .ant-menu-item,
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-group-title { .sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-group-title {
padding: 12px 0 !important; padding: 12px 0 !important;
text-align: center; 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 { .sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
} }
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-title-content, .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 { .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 .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:hover .ant-menu-item-icon,
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected .ant-menu-item-icon { .sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected .ant-menu-item-icon {
transform: none !important; transform: none !important;
margin: 0; margin: 0;
} }
/* 折叠时去掉左侧 3px 选中边框(会挤偏图标),选中态用高亮背景区分 */ /* 折叠时去掉左侧 3px 选中边框(会挤偏图标);只过渡宽度,避免 none 突变 */
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected { .sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected {
border-left: none !important; border-left-width: 0 !important;
} }
/* 折叠时隐藏 label 占位(antd 折叠动画保留 opacity 占位,会把图标挤离中心) */ /* 折叠时菜单文字平滑收起:width 100%→0 插值 + 裁剪 + 渐隐;
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-title-content { 不用 display:none(无法过渡,文字瞬间消失) */
display: none !important; .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 类渲染,同样居中 */ /* 兼容:部分版本折叠态用 vertical 类渲染,同样居中 */
.sidebar-menu.ant-menu-vertical .ant-menu-item, .sidebar-menu.ant-menu-vertical .ant-menu-item,
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item { .sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
padding-left: 0 !important; padding-left: 0 !important;
padding-right: 0 !important; padding-right: 0 !important;
}
.sidebar-menu.ant-menu-vertical .ant-menu-item .ant-menu-title-content {
display: none !important;
} }
/* 折叠态 flex 子元素居中(display:flex 已生效,缺 justify-content) */ /* 折叠态 flex 子元素居中(display:flex 已生效,缺 justify-content) */
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item { .sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
justify-content: center !important; justify-content: center !important;
} }
+45 -44
View File
@@ -6,57 +6,58 @@ import { routeMenuConfig } from './menuConfig';
import './SidebarMenu.css'; import './SidebarMenu.css';
interface SidebarMenuProps { interface SidebarMenuProps {
collapsed: boolean; collapsed: boolean;
onMenuClick?: () => void; onMenuClick?: () => void;
} }
const SidebarMenu = ({ collapsed, onMenuClick }: SidebarMenuProps) => { const SidebarMenu = ({ collapsed, onMenuClick }: SidebarMenuProps) => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const menuItems: MenuProps['items'] = useMemo(() => { // 分组标题保持常驻,折叠时的隐藏由 CSS 过渡完成,避免文字瞬间消失
const children = routeMenuConfig.map((config) => ({ const menuItems: MenuProps['items'] = useMemo(() => {
key: config.key, const children = routeMenuConfig.map((config) => ({
icon: config.icon, key: config.key,
label: config.label, icon: config.icon,
})); label: config.label,
return [ }));
{ return [
type: 'group' as const, {
label: collapsed ? '' : '主要功能', type: 'group' as const,
className: 'menu-section-title', label: '主要功能',
children, className: 'menu-section-title',
}, children,
]; },
}, [collapsed]); ];
}, []);
const selectedKeys = useMemo(() => { const selectedKeys = useMemo(() => {
const path = location.pathname; const path = location.pathname;
if (path === '/' || path === '') { if (path === '/' || path === '') {
return [routeMenuConfig[0].key]; return [routeMenuConfig[0].key];
} }
const config = routeMenuConfig.find((item) => path.startsWith(item.path)); const config = routeMenuConfig.find((item) => path.startsWith(item.path));
return [config ? config.key : path]; return [config ? config.key : path];
}, [location.pathname]); }, [location.pathname]);
const handleMenuClick: MenuProps['onClick'] = ({ key }) => { const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
navigate(key); navigate(key);
if (onMenuClick) onMenuClick(); if (onMenuClick) onMenuClick();
}; };
return ( return (
<div className="sidebar-menu-container"> <div className="sidebar-menu-container">
<Menu <Menu
theme="dark" theme="dark"
mode="inline" mode="inline"
inlineCollapsed={collapsed} inlineCollapsed={collapsed}
selectedKeys={selectedKeys} selectedKeys={selectedKeys}
items={menuItems} items={menuItems}
onClick={handleMenuClick} onClick={handleMenuClick}
className="sidebar-menu" className="sidebar-menu"
/> />
</div> </div>
); );
}; };
export default SidebarMenu; export default SidebarMenu;
+60 -68
View File
@@ -1,80 +1,72 @@
import { import {
CalculatorOutlined, CalculatorOutlined,
GlobalOutlined, CodeSandboxOutlined,
InboxOutlined, EditOutlined,
PictureOutlined, GlobalOutlined,
ShopOutlined, InboxOutlined,
PictureOutlined,
ShopOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
export interface RouteMenuConfig { export interface RouteMenuConfig {
/** 路由路径 */ /** 路由路径 */
path: string; path: string;
/** 菜单键值(与 path 相同) */ /** 菜单键值(与 path 相同) */
key: string; key: string;
/** 菜单图标 */ /** 菜单图标(菜单与页头面包屑共用) */
icon: ReactNode; icon: ReactNode;
/** 菜单标签 */ /** 菜单标签 */
label: string; label: string;
/** 页面标题 */
title: string;
/** 页面副标题 */
subtitle: string;
} }
/** 左侧一级菜单,顺序与 docs/requirements/account-store-watermark.md 的导航约定一致 */ /** 左侧一级菜单,顺序与 docs/requirements/account-store-watermark.md 的导航约定一致 */
export const routeMenuConfig: RouteMenuConfig[] = [ export const routeMenuConfig: RouteMenuConfig[] = [
{ {
path: '/online-products', path: '/online-products',
key: '/online-products', key: '/online-products',
icon: <GlobalOutlined />, icon: <GlobalOutlined />,
label: '在线商品', label: '在线商品',
title: '在线商品', },
subtitle: '按 SKU 查看平台已上架商品与同步状态', {
}, path: '/collection',
{ key: '/collection',
path: '/collection', icon: <InboxOutlined />,
key: '/collection', label: '采集箱',
icon: <InboxOutlined />, },
label: '采集箱', {
title: '采集箱', path: '/shops',
subtitle: '查看已采集的商品,进入编辑上架', key: '/shops',
}, icon: <ShopOutlined />,
{ label: '店铺管理',
path: '/shops', },
key: '/shops', {
icon: <ShopOutlined />, path: '/watermarks',
label: '店铺管理', key: '/watermarks',
title: '店铺管理', icon: <PictureOutlined />,
subtitle: '绑定平台店铺授权与水印', label: '水印管理',
}, },
{ {
path: '/watermarks', path: '/pricing',
key: '/watermarks', key: '/pricing',
icon: <PictureOutlined />, icon: <CalculatorOutlined />,
label: '水印管理', label: '定价工具',
title: '水印管理', },
subtitle: '维护水印模板并绑定到店铺',
},
{
path: '/pricing',
key: '/pricing',
icon: <CalculatorOutlined />,
label: '定价工具',
title: '定价工具',
subtitle: '独立试算与物流计价规则',
},
]; ];
/** 根据路径获取页面标题信息 */ export interface BreadcrumbCrumb {
export const getPageInfo = (path: string): { title: string; subtitle: string } => { icon: ReactNode;
if (path === '/' || path === '') { label: string;
const first = routeMenuConfig[0]; }
return { title: first.title, subtitle: first.subtitle };
} /** 页头面包屑:一级菜单页显示单级;商品 AI 编辑页不在菜单中,显示「商品 > 商品编辑」两级 */
if (path.startsWith('/products/')) { export const getBreadcrumbItems = (path: string): BreadcrumbCrumb[] => {
return { title: '商品编辑', subtitle: '商品 AI 编辑页:SPU/SKU、媒体、定价与发布' }; if (path.startsWith('/products/')) {
} return [
const config = routeMenuConfig.find((item) => path.startsWith(item.path)); { icon: <CodeSandboxOutlined />, label: '商品' },
return config ? { title: config.title, subtitle: config.subtitle } : getPageInfo('/'); { icon: <EditOutlined />, label: '商品编辑' },
];
}
const config = routeMenuConfig.find((item) => path.startsWith(item.path)) ?? routeMenuConfig[0];
return [{ icon: config.icon, label: config.label }];
}; };
+3 -3
View File
@@ -4,7 +4,7 @@ import './index.css';
import App from './App'; import App from './App';
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>
); );
+67 -63
View File
@@ -10,76 +10,80 @@ import { authToken } from '@/services/authToken';
import { emailFormatRule } from '@/utils/validators'; import { emailFormatRule } from '@/utils/validators';
interface LoginFormValues { interface LoginFormValues {
email: string; email: string;
password: string; password: string;
} }
/** 登录页,契约见 docs/api/auth.md §3 */ /** 登录页,契约见 docs/api/auth.md §3 */
const LoginPage = () => { const LoginPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { message } = AntdApp.useApp(); const { message } = AntdApp.useApp();
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const handleFinish = async (values: LoginFormValues) => { const handleFinish = async (values: LoginFormValues) => {
setSubmitting(true); setSubmitting(true);
try { try {
const session = await authApi.login({ const session = await authApi.login({
email: values.email.trim(), email: values.email.trim(),
password: values.password, password: values.password,
}); });
authToken.set(session.access_token); authToken.set(session.access_token);
message.success('登录成功'); message.success('登录成功');
navigate('/online-products', { replace: true }); navigate('/online-products', { replace: true });
} catch (error) { } catch (error) {
// 契约约定登录失败统一返回 401 INVALID_CREDENTIALS,不区分邮箱不存在或密码错误 // 契约约定登录失败统一返回 401 INVALID_CREDENTIALS,不区分邮箱不存在或密码错误
if (axios.isAxiosError(error) && error.response?.status === 401) { if (axios.isAxiosError(error) && error.response?.status === 401) {
message.error('邮箱或密码不正确'); message.error('邮箱或密码不正确');
} else { } else {
message.error(apiErrorMessage(error)); message.error(apiErrorMessage(error));
} }
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}; };
return ( return (
<AuthLayout <AuthLayout
title="登录" title="登录"
subtitle="登录 Atlas Seller Kit,继续管理你的商品" subtitle="登录 Atlas Seller Kit,继续管理你的商品"
footer={ footer={
<> <>
还没有账户?<Link to="/register">立即注册</Link> 还没有账户?<Link to="/register">立即注册</Link>
</> </>
} }
>
<Form<LoginFormValues> layout="vertical" requiredMark={false} onFinish={handleFinish}>
<Form.Item
name="email"
label="邮箱"
rules={[{ required: true, message: '请输入邮箱' }, emailFormatRule]}
> >
<Input <Form<LoginFormValues> layout="vertical" requiredMark={false} onFinish={handleFinish}>
prefix={<MailOutlined style={{ color: 'var(--text-secondary)' }} />} <Form.Item
placeholder="请输入邮箱" name="email"
autoComplete="email" label="邮箱"
autoFocus rules={[{ required: true, message: '请输入邮箱' }, emailFormatRule]}
/> >
</Form.Item> <Input
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}> prefix={<MailOutlined style={{ color: 'var(--text-secondary)' }} />}
<Input.Password placeholder="请输入邮箱"
prefix={<LockOutlined style={{ color: 'var(--text-secondary)' }} />} autoComplete="email"
placeholder="请输入密码" autoFocus
autoComplete="current-password" />
/> </Form.Item>
</Form.Item> <Form.Item
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}> name="password"
<Button type="primary" htmlType="submit" block loading={submitting}> label="密码"
登录 rules={[{ required: true, message: '请输入密码' }]}
</Button> >
</Form.Item> <Input.Password
</Form> prefix={<LockOutlined style={{ color: 'var(--text-secondary)' }} />}
</AuthLayout> placeholder="请输入密码"
); autoComplete="current-password"
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button type="primary" htmlType="submit" block loading={submitting}>
登录
</Button>
</Form.Item>
</Form>
</AuthLayout>
);
}; };
export default LoginPage; export default LoginPage;
+113 -113
View File
@@ -10,126 +10,126 @@ import { authToken } from '@/services/authToken';
import { emailFormatRule } from '@/utils/validators'; import { emailFormatRule } from '@/utils/validators';
interface RegisterFormValues { interface RegisterFormValues {
display_name?: string; display_name?: string;
email: string; email: string;
password: string; password: string;
password_confirmation: string; password_confirmation: string;
} }
/** 注册页,契约见 docs/api/auth.md §2;密码长度与复杂度由服务端唯一校验,前端不自行放宽或收窄 */ /** 注册页,契约见 docs/api/auth.md §2;密码长度与复杂度由服务端唯一校验,前端不自行放宽或收窄 */
const RegisterPage = () => { const RegisterPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { message } = AntdApp.useApp(); const { message } = AntdApp.useApp();
const [form] = Form.useForm<RegisterFormValues>(); const [form] = Form.useForm<RegisterFormValues>();
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const handleFinish = async (values: RegisterFormValues) => { const handleFinish = async (values: RegisterFormValues) => {
setSubmitting(true); setSubmitting(true);
try { try {
const session = await authApi.register({ const session = await authApi.register({
email: values.email.trim(), email: values.email.trim(),
password: values.password, password: values.password,
password_confirmation: values.password_confirmation, password_confirmation: values.password_confirmation,
display_name: values.display_name?.trim() || undefined, display_name: values.display_name?.trim() || undefined,
}); });
// 注册成功即在同一事务创建用户、个人工作空间和登录会话 // 注册成功即在同一事务创建用户、个人工作空间和登录会话
authToken.set(session.access_token); authToken.set(session.access_token);
message.success('注册成功,已进入你的工作空间'); message.success('注册成功,已进入你的工作空间');
navigate('/online-products', { replace: true }); navigate('/online-products', { replace: true });
} catch (error) { } catch (error) {
// 重复邮箱返回 409 EMAIL_ALREADY_REGISTERED,落到邮箱字段上提示 // 重复邮箱返回 409 EMAIL_ALREADY_REGISTERED,落到邮箱字段上提示
if (axios.isAxiosError(error) && error.response?.status === 409) { if (axios.isAxiosError(error) && error.response?.status === 409) {
form.setFields([{ name: 'email', errors: ['该邮箱已注册,请直接登录'] }]); form.setFields([{ name: 'email', errors: ['该邮箱已注册,请直接登录'] }]);
} else { } else {
message.error(apiErrorMessage(error)); message.error(apiErrorMessage(error));
} }
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}; };
return ( return (
<AuthLayout <AuthLayout
title="注册" title="注册"
subtitle="创建账户后自动创建个人工作空间,注册即登录" subtitle="创建账户后自动创建个人工作空间,注册即登录"
footer={ footer={
<> <>
已有账户?<Link to="/login">直接登录</Link> 已有账户?<Link to="/login">直接登录</Link>
</> </>
} }
>
<Form<RegisterFormValues>
form={form}
layout="vertical"
requiredMark={false}
onFinish={handleFinish}
>
<Form.Item
name="display_name"
label="显示名称"
extra="选填,最多 80 个字符"
rules={[{ max: 80, message: '显示名称最多 80 个字符' }]}
> >
<Input <Form<RegisterFormValues>
prefix={<UserOutlined style={{ color: 'var(--text-secondary)' }} />} form={form}
placeholder="选填" layout="vertical"
maxLength={80} requiredMark={false}
autoComplete="nickname" onFinish={handleFinish}
/> >
</Form.Item> <Form.Item
<Form.Item name="display_name"
name="email" label="显示名称"
label="邮箱" extra="选填,最多 80 个字符"
rules={[{ required: true, message: '请输入邮箱' }, emailFormatRule]} rules={[{ max: 80, message: '显示名称最多 80 个字符' }]}
> >
<Input <Input
prefix={<MailOutlined style={{ color: 'var(--text-secondary)' }} />} prefix={<UserOutlined style={{ color: 'var(--text-secondary)' }} />}
placeholder="请输入邮箱" placeholder="选填"
autoComplete="email" maxLength={80}
/> autoComplete="nickname"
</Form.Item> />
<Form.Item </Form.Item>
name="password" <Form.Item
label="密码" name="email"
extra="长度与复杂度规则以服务端安全校验为准" label="邮箱"
rules={[{ required: true, message: '请输入密码' }]} rules={[{ required: true, message: '请输入邮箱' }, emailFormatRule]}
> >
<Input.Password <Input
prefix={<LockOutlined style={{ color: 'var(--text-secondary)' }} />} prefix={<MailOutlined style={{ color: 'var(--text-secondary)' }} />}
placeholder="请输入密码" placeholder="请输入邮箱"
autoComplete="new-password" autoComplete="email"
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="password_confirmation" name="password"
label="确认密码" label="密码"
dependencies={['password']} extra="长度与复杂度规则以服务端安全校验为准"
rules={[ rules={[{ required: true, message: '请输入密码' }]}
{ required: true, message: '请再次输入密码' }, >
({ getFieldValue }) => ({ <Input.Password
validator(_, value) { prefix={<LockOutlined style={{ color: 'var(--text-secondary)' }} />}
if (!value || getFieldValue('password') === value) { placeholder="请输入密码"
return Promise.resolve(); autoComplete="new-password"
} />
return Promise.reject(new Error('两次输入的密码不一致')); </Form.Item>
}, <Form.Item
}), name="password_confirmation"
]} label="确认密码"
> dependencies={['password']}
<Input.Password rules={[
prefix={<LockOutlined style={{ color: 'var(--text-secondary)' }} />} { required: true, message: '请再次输入密码' },
placeholder="请再次输入密码" ({ getFieldValue }) => ({
autoComplete="new-password" validator(_, value) {
/> if (!value || getFieldValue('password') === value) {
</Form.Item> return Promise.resolve();
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}> }
<Button type="primary" htmlType="submit" block loading={submitting}> return Promise.reject(new Error('两次输入的密码不一致'));
创建账户 },
</Button> }),
</Form.Item> ]}
</Form> >
</AuthLayout> <Input.Password
); prefix={<LockOutlined style={{ color: 'var(--text-secondary)' }} />}
placeholder="请再次输入密码"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button type="primary" htmlType="submit" block loading={submitting}>
创建账户
</Button>
</Form.Item>
</Form>
</AuthLayout>
);
}; };
export default RegisterPage; export default RegisterPage;
@@ -2,7 +2,7 @@ import PagePlaceholder from '@/components/PagePlaceholder';
/** 采集箱:查看已采集的商品,进入编辑上架(业务模块尚未实现) */ /** 采集箱:查看已采集的商品,进入编辑上架(业务模块尚未实现) */
const CollectionPage = () => ( const CollectionPage = () => (
<PagePlaceholder title="采集箱" description="查看已采集的商品,进入编辑上架,模块开发中。" /> <PagePlaceholder title="采集箱" description="查看已采集的商品,进入编辑上架,模块开发中。" />
); );
export default CollectionPage; export default CollectionPage;
@@ -2,10 +2,10 @@ import PagePlaceholder from '@/components/PagePlaceholder';
/** 在线商品:按 SKU 查看平台已上架商品与同步状态(业务模块尚未实现) */ /** 在线商品:按 SKU 查看平台已上架商品与同步状态(业务模块尚未实现) */
const OnlineProductsPage = () => ( const OnlineProductsPage = () => (
<PagePlaceholder <PagePlaceholder
title="在线商品" title="在线商品"
description="按 SKU 查看平台已上架商品与同步状态,模块开发中。" description="按 SKU 查看平台已上架商品与同步状态,模块开发中。"
/> />
); );
export default OnlineProductsPage; export default OnlineProductsPage;
+1 -1
View File
@@ -2,7 +2,7 @@ import PagePlaceholder from '@/components/PagePlaceholder';
/** 定价工具:独立试算与物流计价规则(业务模块尚未实现) */ /** 定价工具:独立试算与物流计价规则(业务模块尚未实现) */
const PricingPage = () => ( const PricingPage = () => (
<PagePlaceholder title="定价工具" description="独立试算与物流计价规则,模块开发中。" /> <PagePlaceholder title="定价工具" description="独立试算与物流计价规则,模块开发中。" />
); );
export default PricingPage; export default PricingPage;
@@ -3,14 +3,14 @@ import PagePlaceholder from '@/components/PagePlaceholder';
/** 商品 AI 编辑页:编辑 SPU/SKU、媒体、定价与首次发布(业务模块尚未实现) */ /** 商品 AI 编辑页:编辑 SPU/SKU、媒体、定价与首次发布(业务模块尚未实现) */
const ProductEditPage = () => { const ProductEditPage = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
return ( return (
<PagePlaceholder <PagePlaceholder
title={`商品编辑(${id ?? '未知 ID'})`} title={`商品编辑(${id ?? '未知 ID'})`}
description="商品 AI 编辑页:SPU/SKU、媒体、定价与发布,模块开发中。" description="商品 AI 编辑页:SPU/SKU、媒体、定价与发布,模块开发中。"
/> />
); );
}; };
export default ProductEditPage; export default ProductEditPage;
+1 -1
View File
@@ -2,7 +2,7 @@ import PagePlaceholder from '@/components/PagePlaceholder';
/** 店铺管理:绑定平台店铺授权与水印(业务模块尚未实现) */ /** 店铺管理:绑定平台店铺授权与水印(业务模块尚未实现) */
const ShopsPage = () => ( const ShopsPage = () => (
<PagePlaceholder title="店铺管理" description="绑定平台店铺授权与水印,模块开发中。" /> <PagePlaceholder title="店铺管理" description="绑定平台店铺授权与水印,模块开发中。" />
); );
export default ShopsPage; export default ShopsPage;
@@ -2,7 +2,7 @@ import PagePlaceholder from '@/components/PagePlaceholder';
/** 水印管理:维护水印模板并绑定到店铺(业务模块尚未实现) */ /** 水印管理:维护水印模板并绑定到店铺(业务模块尚未实现) */
const WatermarksPage = () => ( const WatermarksPage = () => (
<PagePlaceholder title="水印管理" description="维护水印模板并绑定到店铺,模块开发中。" /> <PagePlaceholder title="水印管理" description="维护水印模板并绑定到店铺,模块开发中。" />
); );
export default WatermarksPage; export default WatermarksPage;
+18 -18
View File
@@ -10,22 +10,22 @@ import ShopsPage from '@/pages/shops/ShopsPage';
import WatermarksPage from '@/pages/watermarks/WatermarksPage'; import WatermarksPage from '@/pages/watermarks/WatermarksPage';
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ {
path: '/', path: '/',
element: <MainLayout />, element: <MainLayout />,
children: [ children: [
{ index: true, element: <Navigate to="/online-products" replace /> }, { index: true, element: <Navigate to="/online-products" replace /> },
{ path: 'online-products', element: <OnlineProductsPage /> }, { path: 'online-products', element: <OnlineProductsPage /> },
{ path: 'collection', element: <CollectionPage /> }, { path: 'collection', element: <CollectionPage /> },
{ path: 'shops', element: <ShopsPage /> }, { path: 'shops', element: <ShopsPage /> },
{ path: 'watermarks', element: <WatermarksPage /> }, { path: 'watermarks', element: <WatermarksPage /> },
{ path: 'pricing', element: <PricingPage /> }, { path: 'pricing', element: <PricingPage /> },
// 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现 // 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现
{ path: 'products/:id', element: <ProductEditPage /> }, { path: 'products/:id', element: <ProductEditPage /> },
], ],
}, },
// 认证页在主布局之外;API 侧会话体系实现后再加路由守卫 // 认证页在主布局之外;API 侧会话体系实现后再加路由守卫
{ path: 'login', element: <LoginPage /> }, { path: 'login', element: <LoginPage /> },
{ path: 'register', element: <RegisterPage /> }, { path: 'register', element: <RegisterPage /> },
{ path: '*', element: <Navigate to="/" replace /> }, { path: '*', element: <Navigate to="/" replace /> },
]); ]);
+63 -63
View File
@@ -4,26 +4,26 @@ import { envConfig } from '@/config/env';
import { authToken } from '@/services/authToken'; import { authToken } from '@/services/authToken';
declare module 'axios' { declare module 'axios' {
export interface AxiosRequestConfig { export interface AxiosRequestConfig {
/** 401 刷新重放标记,防止无限重试 */ /** 401 刷新重放标记,防止无限重试 */
_retried?: boolean; _retried?: boolean;
} }
} }
const apiClient: AxiosInstance = axios.create({ const apiClient: AxiosInstance = axios.create({
baseURL: envConfig.apiBaseUrl, baseURL: envConfig.apiBaseUrl,
timeout: 30000, timeout: 30000,
// 不设全局 Content-Type:axios 1.x 遇到「默认 JSON 头 + FormData」会把表单转成 JSON 发送, // 不设全局 Content-Type:axios 1.x 遇到「默认 JSON 头 + FormData」会把表单转成 JSON 发送,
// 导致素材上传等表单接口解析失败。对象请求体 axios 会自动补 JSON 头。 // 导致素材上传等表单接口解析失败。对象请求体 axios 会自动补 JSON 头。
}); });
// access token 仅在内存中短暂持有(docs/api/conventions.md §1),每次请求动态附加 Bearer 头 // access token 仅在内存中短暂持有(docs/api/conventions.md §1),每次请求动态附加 Bearer 头
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => { apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = authToken.get(); const token = authToken.get();
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }
return config; return config;
}); });
// 认证接口自身的失败不进入刷新流程 // 认证接口自身的失败不进入刷新流程
@@ -33,70 +33,70 @@ let refreshTask: Promise<string | null> | null = null;
/** 并发 401 只触发一次刷新请求;刷新依赖 HttpOnly refresh cookie */ /** 并发 401 只触发一次刷新请求;刷新依赖 HttpOnly refresh cookie */
function refreshAccessToken(): Promise<string | null> { function refreshAccessToken(): Promise<string | null> {
refreshTask ??= apiClient refreshTask ??= apiClient
.post<{ access_token: string }>('/auth/refresh') .post<{ access_token: string }>('/auth/refresh')
.then((res) => { .then((res) => {
authToken.set(res.data.access_token); authToken.set(res.data.access_token);
return res.data.access_token; return res.data.access_token;
}) })
.catch(() => { .catch(() => {
authToken.clear(); authToken.clear();
return null; return null;
}) })
.finally(() => { .finally(() => {
refreshTask = null; refreshTask = null;
}); });
return refreshTask; return refreshTask;
} }
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
async (error) => { async (error) => {
if (envConfig.debug) { if (envConfig.debug) {
console.error('请求失败:', error.response?.data || error.message); 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 = { export const api = {
get: <T = unknown>(url: string, config?: AxiosRequestConfig) => get: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
apiClient.get<T>(url, config).then((res) => res.data), apiClient.get<T>(url, config).then((res) => res.data),
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) => post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
apiClient.post<T>(url, data, config).then((res) => res.data), apiClient.post<T>(url, data, config).then((res) => res.data),
patch: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) => patch: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
apiClient.patch<T>(url, data, config).then((res) => res.data), apiClient.patch<T>(url, data, config).then((res) => res.data),
delete: <T = unknown>(url: string, config?: AxiosRequestConfig) => delete: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
apiClient.delete<T>(url, config).then((res) => res.data), apiClient.delete<T>(url, config).then((res) => res.data),
}; };
/** 统一错误格式为 { error: { code, message, request_id } }(docs/api/conventions.md),提取可展示信息 */ /** 统一错误格式为 { error: { code, message, request_id } }(docs/api/conventions.md),提取可展示信息 */
export function apiErrorMessage(error: unknown): string { export function apiErrorMessage(error: unknown): string {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const data = error.response?.data as const data = error.response?.data as
{ error?: { message?: unknown }; message?: unknown } | undefined; { error?: { message?: unknown }; message?: unknown } | undefined;
if (data?.error?.message) return String(data.error.message); if (data?.error?.message) return String(data.error.message);
if (data?.message) return String(data.message); if (data?.message) return String(data.message);
if (error.response) return `请求失败(HTTP ${error.response.status})`; if (error.response) return `请求失败(HTTP ${error.response.status})`;
return '网络连接失败,请稍后重试'; return '网络连接失败,请稍后重试';
} }
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }
export default apiClient; export default apiClient;
+4 -4
View File
@@ -3,11 +3,11 @@ import type { AuthSession, LoginPayload, MeResult, RegisterPayload } from '@/typ
/** 账户与会话接口,契约见 docs/api/auth.md */ /** 账户与会话接口,契约见 docs/api/auth.md */
export const authApi = { export const authApi = {
register: (payload: RegisterPayload) => api.post<AuthSession>('/auth/register', payload), register: (payload: RegisterPayload) => api.post<AuthSession>('/auth/register', payload),
login: (payload: LoginPayload) => api.post<AuthSession>('/auth/login', payload), login: (payload: LoginPayload) => api.post<AuthSession>('/auth/login', payload),
logout: () => api.post<void>('/auth/logout'), logout: () => api.post<void>('/auth/logout'),
me: () => api.get<MeResult>('/auth/me'), me: () => api.get<MeResult>('/auth/me'),
}; };
+7 -7
View File
@@ -6,11 +6,11 @@
let accessToken: string | null = null; let accessToken: string | null = null;
export const authToken = { export const authToken = {
get: () => accessToken, get: () => accessToken,
set: (token: string) => { set: (token: string) => {
accessToken = token; accessToken = token;
}, },
clear: () => { clear: () => {
accessToken = null; accessToken = null;
}, },
}; };
+28 -28
View File
@@ -4,52 +4,52 @@
*/ */
export interface AuthUser { export interface AuthUser {
id: string; id: string;
email: string; email: string;
display_name: string | null; display_name: string | null;
email_verified_at: string | null; email_verified_at: string | null;
created_at?: string; created_at?: string;
} }
export interface AuthWorkspace { export interface AuthWorkspace {
id: string; id: string;
name: string; name: string;
role: string; role: string;
} }
/** 注册 / 登录成功返回的会话(access token 短期有效,refresh token 在 HttpOnly cookie 中) */ /** 注册 / 登录成功返回的会话(access token 短期有效,refresh token 在 HttpOnly cookie 中) */
export interface AuthSession { export interface AuthSession {
access_token: string; access_token: string;
access_token_expires_in: number; access_token_expires_in: number;
user: AuthUser; user: AuthUser;
workspace: AuthWorkspace; workspace: AuthWorkspace;
} }
export interface RegisterPayload { export interface RegisterPayload {
email: string; email: string;
password: string; password: string;
password_confirmation: string; password_confirmation: string;
display_name?: string; display_name?: string;
} }
export interface LoginPayload { export interface LoginPayload {
email: string; email: string;
password: string; password: string;
} }
export interface PlatformSummary { export interface PlatformSummary {
id: string; id: string;
code: string; code: string;
name: string; name: string;
} }
/** GET /api/auth/me 的返回;default_store 摘要字段以后续门店接口文档为准 */ /** GET /api/auth/me 的返回;default_store 摘要字段以后续门店接口文档为准 */
export interface MeResult { export interface MeResult {
user: AuthUser; user: AuthUser;
workspace: AuthWorkspace; workspace: AuthWorkspace;
platforms: PlatformSummary[]; platforms: PlatformSummary[];
suggested_context: { suggested_context: {
platform_id: string; platform_id: string;
default_store: { id: string; name: string } | null; default_store: { id: string; name: string } | null;
}; };
} }
+4 -4
View File
@@ -2,8 +2,8 @@ import type { Rule } from 'antd/es/form';
/** 邮箱格式校验:仅在非空时校验,避免与 required 规则在空值时同时报错(antd v6 行为) */ /** 邮箱格式校验:仅在非空时校验,避免与 required 规则在空值时同时报错(antd v6 行为) */
export const emailFormatRule: Rule = { export const emailFormatRule: Rule = {
validator: (_, value: string) => validator: (_, value: string) =>
!value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
? Promise.resolve() ? Promise.resolve()
: Promise.reject(new Error('邮箱格式不正确')), : Promise.reject(new Error('邮箱格式不正确')),
}; };
+19 -19
View File
@@ -1,21 +1,21 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
// Web 走 Vite 打包,使用 bundler 解析;覆盖 base 中面向 NestJS 的 Node16 模块设置 // Web 走 Vite 打包,使用 bundler 解析;覆盖 base 中面向 NestJS 的 Node16 模块设置
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"moduleDetection": "force", "moduleDetection": "force",
"isolatedModules": true, "isolatedModules": true,
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"], "lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx", "jsx": "react-jsx",
"noEmit": true, "noEmit": true,
"allowImportingTsExtensions": false, "allowImportingTsExtensions": false,
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["src", "vite.config.ts"] "include": ["src", "vite.config.ts"]
} }
+14 -14
View File
@@ -4,20 +4,20 @@ import react from '@vitejs/plugin-react';
// 前端开发服务器:/api 代理到本地 NestJS API(apps/api,默认 8200) // 前端开发服务器:/api 代理到本地 NestJS API(apps/api,默认 8200)
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)), '@': fileURLToPath(new URL('./src', import.meta.url)),
},
}, },
}, server: {
server: { port: Number(process.env.WEB_PORT ?? 8100),
port: Number(process.env.WEB_PORT ?? 8100), strictPort: true,
strictPort: true, proxy: {
proxy: { '/api': {
'/api': { target: process.env.API_ORIGIN ?? 'http://localhost:8200',
target: process.env.API_ORIGIN ?? 'http://localhost:8200', changeOrigin: true,
changeOrigin: true, },
}, },
}, },
},
}); });