feat(web): 搭建应用骨架并完善开发环境配置
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%),
|
||||
radial-gradient(900px 480px at 95% 110%, rgba(109, 40, 217, 0.1), transparent 55%),
|
||||
var(--bg-color);
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.auth-brand {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.auth-logo-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(17, 24, 39, 0.06);
|
||||
padding: 32px 28px;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.auth-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 4px 0 20px;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.auth-footer a {
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.auth-card {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import './AuthLayout.css';
|
||||
|
||||
interface AuthLayoutProps {
|
||||
/** 页面标题,如「登录」 */
|
||||
title: string;
|
||||
/** 标题下的一行说明 */
|
||||
subtitle: string;
|
||||
/** 卡片底部的切换入口 */
|
||||
footer: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** 注册/登录共用的居中卡片布局,品牌视觉与 MainLayout 一致 */
|
||||
const AuthLayout = ({ title, subtitle, footer, children }: AuthLayoutProps) => {
|
||||
useEffect(() => {
|
||||
document.title = `${title} - Atlas Seller Kit`;
|
||||
}, [title]);
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-panel">
|
||||
<div className="auth-brand">
|
||||
<div className="auth-logo">Atlas Kit</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>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthLayout;
|
||||
@@ -0,0 +1,115 @@
|
||||
.main-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-sider {
|
||||
background: var(--sider-bg) !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 24px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.logo-subtitle {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.main-header {
|
||||
background: white !important;
|
||||
padding: 0 24px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow:
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.1),
|
||||
0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
height: 64px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.collapse-trigger,
|
||||
.mobile-menu-trigger {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
color: #1f2937;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collapse-trigger:hover,
|
||||
.mobile-menu-trigger:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.page-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 24px;
|
||||
background: #f3f4f6;
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
padding: 0 16px !important;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { Drawer, Layout } from 'antd';
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
|
||||
import SidebarMenu from './SidebarMenu';
|
||||
import { getPageInfo } from './menuConfig';
|
||||
import './MainLayout.css';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
const MainLayout = () => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
const pageInfo = useMemo(() => getPageInfo(location.pathname), [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
if (window.innerWidth >= 768) setMobileMenuOpen(false);
|
||||
};
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<div className="collapse-trigger" onClick={() => setCollapsed(!collapsed)}>
|
||||
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
</div>
|
||||
)}
|
||||
<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">
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
@@ -0,0 +1,134 @@
|
||||
.sidebar-menu-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
background: transparent !important;
|
||||
border: none;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item {
|
||||
margin: 0 !important;
|
||||
padding: 12px 20px !important;
|
||||
height: auto !important;
|
||||
line-height: 1.5 !important;
|
||||
transition: all 0.3s !important;
|
||||
font-size: 15px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item .ant-menu-title-content {
|
||||
font-size: 15px !important;
|
||||
font-weight: 600 !important;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-selected {
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
border-left: 3px solid white;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-selected .ant-menu-title-content {
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-selected:hover {
|
||||
background: rgba(255, 255, 255, 0.25) !important;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-selected:hover .ant-menu-title-content {
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-group-title {
|
||||
padding: 12px 20px 8px !important;
|
||||
font-size: 12px !important;
|
||||
color: rgba(255, 255, 255, 0.7) !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-icon {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-size: 18px !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item:hover .ant-menu-item-icon {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-selected .ant-menu-item-icon {
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar-menu .ant-menu-item {
|
||||
padding: 14px 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 折叠态(60px):图标居中,覆盖展开态的左对齐 padding/位移 ── */
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item,
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-group-title {
|
||||
padding: 12px 0 !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-title-content,
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected .ant-menu-title-content {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-item-icon,
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item:hover .ant-menu-item-icon,
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected .ant-menu-item-icon {
|
||||
transform: none !important;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 折叠时去掉左侧 3px 选中边框(会挤偏图标),选中态用高亮背景区分 */
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected {
|
||||
border-left: none !important;
|
||||
}
|
||||
|
||||
/* 折叠时隐藏 label 占位(antd 折叠动画保留 opacity 占位,会把图标挤离中心) */
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-title-content {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 兼容:部分版本折叠态用 vertical 类渲染,同样居中 */
|
||||
.sidebar-menu.ant-menu-vertical .ant-menu-item,
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.sidebar-menu.ant-menu-vertical .ant-menu-item .ant-menu-title-content {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 折叠态 flex 子元素居中(display:flex 已生效,缺 justify-content) */
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
|
||||
justify-content: center !important;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Menu } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { routeMenuConfig } from './menuConfig';
|
||||
import './SidebarMenu.css';
|
||||
|
||||
interface SidebarMenuProps {
|
||||
collapsed: boolean;
|
||||
onMenuClick?: () => void;
|
||||
}
|
||||
|
||||
const SidebarMenu = ({ collapsed, onMenuClick }: SidebarMenuProps) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const menuItems: MenuProps['items'] = useMemo(() => {
|
||||
const children = routeMenuConfig.map((config) => ({
|
||||
key: config.key,
|
||||
icon: config.icon,
|
||||
label: config.label,
|
||||
}));
|
||||
return [
|
||||
{
|
||||
type: 'group' as const,
|
||||
label: collapsed ? '' : '主要功能',
|
||||
className: 'menu-section-title',
|
||||
children,
|
||||
},
|
||||
];
|
||||
}, [collapsed]);
|
||||
|
||||
const selectedKeys = useMemo(() => {
|
||||
const path = location.pathname;
|
||||
if (path === '/' || path === '') {
|
||||
return [routeMenuConfig[0].key];
|
||||
}
|
||||
const config = routeMenuConfig.find((item) => path.startsWith(item.path));
|
||||
return [config ? config.key : path];
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
navigate(key);
|
||||
if (onMenuClick) onMenuClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sidebar-menu-container">
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
inlineCollapsed={collapsed}
|
||||
selectedKeys={selectedKeys}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
className="sidebar-menu"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarMenu;
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
CalculatorOutlined,
|
||||
GlobalOutlined,
|
||||
InboxOutlined,
|
||||
PictureOutlined,
|
||||
ShopOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface RouteMenuConfig {
|
||||
/** 路由路径 */
|
||||
path: string;
|
||||
/** 菜单键值(与 path 相同) */
|
||||
key: string;
|
||||
/** 菜单图标 */
|
||||
icon: ReactNode;
|
||||
/** 菜单标签 */
|
||||
label: string;
|
||||
/** 页面标题 */
|
||||
title: string;
|
||||
/** 页面副标题 */
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
/** 左侧一级菜单,顺序与 docs/requirements/account-store-watermark.md 的导航约定一致 */
|
||||
export const routeMenuConfig: RouteMenuConfig[] = [
|
||||
{
|
||||
path: '/online-products',
|
||||
key: '/online-products',
|
||||
icon: <GlobalOutlined />,
|
||||
label: '在线商品',
|
||||
title: '在线商品',
|
||||
subtitle: '按 SKU 查看平台已上架商品与同步状态',
|
||||
},
|
||||
{
|
||||
path: '/collection',
|
||||
key: '/collection',
|
||||
icon: <InboxOutlined />,
|
||||
label: '采集箱',
|
||||
title: '采集箱',
|
||||
subtitle: '查看已采集的商品,进入编辑上架',
|
||||
},
|
||||
{
|
||||
path: '/shops',
|
||||
key: '/shops',
|
||||
icon: <ShopOutlined />,
|
||||
label: '店铺管理',
|
||||
title: '店铺管理',
|
||||
subtitle: '绑定平台店铺授权与水印',
|
||||
},
|
||||
{
|
||||
path: '/watermarks',
|
||||
key: '/watermarks',
|
||||
icon: <PictureOutlined />,
|
||||
label: '水印管理',
|
||||
title: '水印管理',
|
||||
subtitle: '维护水印模板并绑定到店铺',
|
||||
},
|
||||
{
|
||||
path: '/pricing',
|
||||
key: '/pricing',
|
||||
icon: <CalculatorOutlined />,
|
||||
label: '定价工具',
|
||||
title: '定价工具',
|
||||
subtitle: '独立试算与物流计价规则',
|
||||
},
|
||||
];
|
||||
|
||||
/** 根据路径获取页面标题信息 */
|
||||
export const getPageInfo = (path: string): { title: string; subtitle: string } => {
|
||||
if (path === '/' || path === '') {
|
||||
const first = routeMenuConfig[0];
|
||||
return { title: first.title, subtitle: first.subtitle };
|
||||
}
|
||||
if (path.startsWith('/products/')) {
|
||||
return { title: '商品编辑', subtitle: '商品 AI 编辑页:SPU/SKU、媒体、定价与发布' };
|
||||
}
|
||||
const config = routeMenuConfig.find((item) => path.startsWith(item.path));
|
||||
return config ? { title: config.title, subtitle: config.subtitle } : getPageInfo('/');
|
||||
};
|
||||
Reference in New Issue
Block a user