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