feat(web): 初始化前端项目
This commit is contained in:
+9
-9
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 /> },
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user