feat(web): 搭建应用骨架并完善开发环境配置

This commit is contained in:
2026-09-02 22:42:21 +08:00
parent 09e5207c83
commit 403f6cfa27
43 changed files with 2056 additions and 2356 deletions
+1
View File
@@ -9,3 +9,4 @@ MINIO_PORT=9000
MINIO_CONSOLE_PORT=9001 MINIO_CONSOLE_PORT=9001
API_PORT=8200 API_PORT=8200
WEB_PORT=8100 WEB_PORT=8100
EXTENSION_PORT=8110
+1
View File
@@ -11,6 +11,7 @@
# Common future runtime/build artifacts # Common future runtime/build artifacts
node_modules/ node_modules/
dist/ dist/
.output/
.venv/ .venv/
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
File diff suppressed because one or more lines are too long
@@ -1,36 +0,0 @@
{
"manifest_version": 3,
"name": "Atlas Seller Kit Collector",
"description": "将已确认的来源商品采集到 Atlas 工作空间。",
"version": "0.1.0",
"permissions": [
"storage",
"identity",
"tabs",
"scripting"
],
"host_permissions": [
"*://*.ozon.ru/*",
"*://*.ozon.kz/*",
"*://*.ozon.by/*",
"*://*.1688.com/*",
"*://*.taobao.com/*",
"*://*.tmall.com/*",
"http://localhost/*"
],
"commands": {
"wxt:reload-extension": {
"description": "Reload the extension during development",
"suggested_key": {
"default": "Alt+R"
}
}
},
"background": {
"service_worker": "background.js"
},
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval' http://localhost:3000; object-src 'self';",
"sandbox": "script-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:3000; sandbox allow-scripts allow-forms allow-popups allow-modals; child-src 'self';"
}
}
@@ -1 +0,0 @@
var background=(function(){function e(e){return e==null||typeof e==`function`?{main:e}:e}var t=e(()=>{console.info(`Atlas collector background started.`)});globalThis.browser?.runtime?.id?globalThis.browser:globalThis.chrome;var n={debug:(...e)=>([...e],void 0),log:(...e)=>([...e],void 0),warn:(...e)=>([...e],void 0),error:(...e)=>([...e],void 0)},r;try{r=t.main(),r instanceof Promise&&console.warn(`The background's main() function return a promise, but it must be synchronous`)}catch(e){throw n.error(`The background crashed on startup!`),e}return r})();
@@ -1 +0,0 @@
{"manifest_version":3,"name":"Atlas Seller Kit Collector","description":"将已确认的来源商品采集到 Atlas 工作空间。","version":"0.1.0","permissions":["storage","identity"],"host_permissions":["*://*.ozon.ru/*","*://*.ozon.kz/*","*://*.ozon.by/*","*://*.1688.com/*","*://*.taobao.com/*","*://*.tmall.com/*"],"background":{"service_worker":"background.js"}}
+7
View File
@@ -2,6 +2,13 @@ import { defineConfig } from 'wxt';
export default defineConfig({ export default defineConfig({
modules: ['@wxt-dev/module-react'], modules: ['@wxt-dev/module-react'],
// 扩展 dev server 固定 8110:默认取 3000–3010 第一个空闲端口,会漂移且易与常见服务冲突
dev: {
server: {
port: Number(process.env.EXTENSION_PORT ?? 8110),
strictPort: true,
},
},
manifest: { manifest: {
name: 'Atlas Seller Kit Collector', name: 'Atlas Seller Kit Collector',
description: '将已确认的来源商品采集到 Atlas 工作空间。', description: '将已确认的来源商品采集到 Atlas 工作空间。',
+2
View File
@@ -9,6 +9,7 @@
"typecheck": "tsc -b --pretty false" "typecheck": "tsc -b --pretty false"
}, },
"dependencies": { "dependencies": {
"@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",
@@ -16,6 +17,7 @@
"react-router-dom": "7.18.3" "react-router-dom": "7.18.3"
}, },
"devDependencies": { "devDependencies": {
"@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",
+1
View File
@@ -0,0 +1 @@
/* 全局应用级样式补充(主题变量见 index.css) */
+29
View File
@@ -0,0 +1,29 @@
import { ConfigProvider, App as AntdApp } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { RouterProvider } from 'react-router-dom';
import { router } from './router';
import './App.css';
function App() {
return (
<ConfigProvider
locale={zhCN}
theme={{
token: {
colorPrimary: '#8b5cf6',
borderRadius: 8,
// 全局默认字号 14px;输入类控件默认高度在 32px 基础上同步加高一点
fontSize: 14,
controlHeight: 34,
controlHeightSM: 26,
},
}}
>
<AntdApp>
<RouterProvider router={router} />
</AntdApp>
</ConfigProvider>
);
}
export default App;
@@ -0,0 +1,23 @@
import { Card, Empty } from 'antd';
interface PagePlaceholderProps {
title: string;
description: string;
}
/** 业务模块实现前的统一占位页 */
const PagePlaceholder = ({ title, description }: PagePlaceholderProps) => (
<Card>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<>
<div>{title}</div>
<div>{description}</div>
</>
}
/>
</Card>
);
export default PagePlaceholder;
+16
View File
@@ -0,0 +1,16 @@
/**
* 环境变量配置(Vite 仅暴露 VITE_ 前缀变量)
*/
interface EnvConfig {
/** API 基础路径:开发环境由 vite 代理转发到 NestJS API */
apiBaseUrl: string;
appName: string;
debug: boolean;
}
export const envConfig: EnvConfig = {
apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? '/api',
appName: import.meta.env.VITE_APP_NAME ?? 'Atlas Seller Kit',
debug: import.meta.env.VITE_DEBUG === 'true',
};
+37
View File
@@ -0,0 +1,37 @@
:root {
--primary-color: #8b5cf6;
--primary-light: #a78bfa;
--primary-dark: #7c3aed;
/* 侧边菜单背景(纯色,中间调;想再调直接改这里) */
--sider-bg: #6d28d9;
--text-primary: #1f2937;
--text-secondary: #6b7280;
--bg-color: #f9fafb;
--card-bg: #ffffff;
--border-color: #e5e7eb;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--bg-color);
color: var(--text-primary);
/* 全局默认字号 14px,与 antd 主题 token 的 fontSize 保持一致 */
font-size: 14px;
line-height: 1.6;
}
#root {
min-height: 100vh;
}
+71
View File
@@ -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;
}
}
+39
View File
@@ -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;
+115
View File
@@ -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;
}
}
+103
View File
@@ -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;
+134
View File
@@ -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;
}
+62
View File
@@ -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;
+80
View File
@@ -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('/');
};
+8 -19
View File
@@ -1,21 +1,10 @@
import { App as AntdApp, ConfigProvider, Typography } from 'antd'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
import 'antd/dist/reset.css'; createRoot(document.getElementById('root')!).render(
<StrictMode>
function Root() { <App />
return ( </StrictMode>,
<ConfigProvider> );
<AntdApp>
<main style={{ margin: '64px auto', maxWidth: 840, padding: '0 24px' }}>
<Typography.Title>Atlas Seller Kit</Typography.Title>
<Typography.Paragraph>
卖家商品工作台初始化完成。账户、平台上下文和门店模块将在此基础上实现。
</Typography.Paragraph>
</main>
</AntdApp>
</ConfigProvider>
);
}
createRoot(document.getElementById('root')!).render(<Root />);
+85
View File
@@ -0,0 +1,85 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { App as AntdApp, Button, Form, Input } from 'antd';
import { LockOutlined, MailOutlined } from '@ant-design/icons';
import axios from 'axios';
import AuthLayout from '@/layouts/AuthLayout';
import { apiErrorMessage } from '@/services/api';
import { authApi } from '@/services/auth';
import { authToken } from '@/services/authToken';
import { emailFormatRule } from '@/utils/validators';
interface LoginFormValues {
email: string;
password: string;
}
/** 登录页,契约见 docs/api/auth.md §3 */
const LoginPage = () => {
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);
}
};
return (
<AuthLayout
title="登录"
subtitle="登录 Atlas Seller Kit,继续管理你的商品"
footer={
<>
还没有账户?<Link to="/register">立即注册</Link>
</>
}
>
<Form<LoginFormValues> layout="vertical" requiredMark={false} onFinish={handleFinish}>
<Form.Item
name="email"
label="邮箱"
rules={[{ required: true, message: '请输入邮箱' }, emailFormatRule]}
>
<Input
prefix={<MailOutlined style={{ color: 'var(--text-secondary)' }} />}
placeholder="请输入邮箱"
autoComplete="email"
autoFocus
/>
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password
prefix={<LockOutlined style={{ color: 'var(--text-secondary)' }} />}
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;
+135
View File
@@ -0,0 +1,135 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { App as AntdApp, Button, Form, Input } from 'antd';
import { LockOutlined, MailOutlined, UserOutlined } from '@ant-design/icons';
import axios from 'axios';
import AuthLayout from '@/layouts/AuthLayout';
import { apiErrorMessage } from '@/services/api';
import { authApi } from '@/services/auth';
import { authToken } from '@/services/authToken';
import { emailFormatRule } from '@/utils/validators';
interface RegisterFormValues {
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<RegisterFormValues>();
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);
}
};
return (
<AuthLayout
title="注册"
subtitle="创建账户后自动创建个人工作空间,注册即登录"
footer={
<>
已有账户?<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
prefix={<UserOutlined style={{ color: 'var(--text-secondary)' }} />}
placeholder="选填"
maxLength={80}
autoComplete="nickname"
/>
</Form.Item>
<Form.Item
name="email"
label="邮箱"
rules={[{ required: true, message: '请输入邮箱' }, emailFormatRule]}
>
<Input
prefix={<MailOutlined style={{ color: 'var(--text-secondary)' }} />}
placeholder="请输入邮箱"
autoComplete="email"
/>
</Form.Item>
<Form.Item
name="password"
label="密码"
extra="长度与复杂度规则以服务端安全校验为准"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: 'var(--text-secondary)' }} />}
placeholder="请输入密码"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
name="password_confirmation"
label="确认密码"
dependencies={['password']}
rules={[
{ required: true, message: '请再次输入密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<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;
@@ -0,0 +1,8 @@
import PagePlaceholder from '@/components/PagePlaceholder';
/** 采集箱:查看已采集的商品,进入编辑上架(业务模块尚未实现) */
const CollectionPage = () => (
<PagePlaceholder title="采集箱" description="查看已采集的商品,进入编辑上架,模块开发中。" />
);
export default CollectionPage;
@@ -0,0 +1,11 @@
import PagePlaceholder from '@/components/PagePlaceholder';
/** 在线商品:按 SKU 查看平台已上架商品与同步状态(业务模块尚未实现) */
const OnlineProductsPage = () => (
<PagePlaceholder
title="在线商品"
description="按 SKU 查看平台已上架商品与同步状态,模块开发中。"
/>
);
export default OnlineProductsPage;
@@ -0,0 +1,8 @@
import PagePlaceholder from '@/components/PagePlaceholder';
/** 定价工具:独立试算与物流计价规则(业务模块尚未实现) */
const PricingPage = () => (
<PagePlaceholder title="定价工具" description="独立试算与物流计价规则,模块开发中。" />
);
export default PricingPage;
@@ -0,0 +1,16 @@
import { useParams } from 'react-router-dom';
import PagePlaceholder from '@/components/PagePlaceholder';
/** 商品 AI 编辑页:编辑 SPU/SKU、媒体、定价与首次发布(业务模块尚未实现) */
const ProductEditPage = () => {
const { id } = useParams<{ id: string }>();
return (
<PagePlaceholder
title={`商品编辑(${id ?? '未知 ID'})`}
description="商品 AI 编辑页:SPU/SKU、媒体、定价与发布,模块开发中。"
/>
);
};
export default ProductEditPage;
+8
View File
@@ -0,0 +1,8 @@
import PagePlaceholder from '@/components/PagePlaceholder';
/** 店铺管理:绑定平台店铺授权与水印(业务模块尚未实现) */
const ShopsPage = () => (
<PagePlaceholder title="店铺管理" description="绑定平台店铺授权与水印,模块开发中。" />
);
export default ShopsPage;
@@ -0,0 +1,8 @@
import PagePlaceholder from '@/components/PagePlaceholder';
/** 水印管理:维护水印模板并绑定到店铺(业务模块尚未实现) */
const WatermarksPage = () => (
<PagePlaceholder title="水印管理" description="维护水印模板并绑定到店铺,模块开发中。" />
);
export default WatermarksPage;
+31
View File
@@ -0,0 +1,31 @@
import { createBrowserRouter, Navigate } from 'react-router-dom';
import MainLayout from '@/layouts/MainLayout';
import CollectionPage from '@/pages/collection/CollectionPage';
import LoginPage from '@/pages/auth/LoginPage';
import OnlineProductsPage from '@/pages/online-products/OnlineProductsPage';
import PricingPage from '@/pages/pricing/PricingPage';
import ProductEditPage from '@/pages/products/ProductEditPage';
import RegisterPage from '@/pages/auth/RegisterPage';
import ShopsPage from '@/pages/shops/ShopsPage';
import WatermarksPage from '@/pages/watermarks/WatermarksPage';
export const router = createBrowserRouter([
{
path: '/',
element: <MainLayout />,
children: [
{ index: true, element: <Navigate to="/online-products" replace /> },
{ path: 'online-products', element: <OnlineProductsPage /> },
{ path: 'collection', element: <CollectionPage /> },
{ path: 'shops', element: <ShopsPage /> },
{ path: 'watermarks', element: <WatermarksPage /> },
{ path: 'pricing', element: <PricingPage /> },
// 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现
{ path: 'products/:id', element: <ProductEditPage /> },
],
},
// 认证页在主布局之外;API 侧会话体系实现后再加路由守卫
{ path: 'login', element: <LoginPage /> },
{ path: 'register', element: <RegisterPage /> },
{ path: '*', element: <Navigate to="/" replace /> },
]);
+102
View File
@@ -0,0 +1,102 @@
import axios from 'axios';
import type { AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
import { envConfig } from '@/config/env';
import { authToken } from '@/services/authToken';
declare module 'axios' {
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 头。
});
// 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 AUTH_PATHS = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/logout'];
let refreshTask: Promise<string | null> | null = null;
/** 并发 401 只触发一次刷新请求;刷新依赖 HttpOnly refresh cookie */
function refreshAccessToken(): Promise<string | null> {
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);
}
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: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
apiClient.get<T>(url, config).then((res) => res.data),
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
apiClient.post<T>(url, data, config).then((res) => res.data),
patch: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
apiClient.patch<T>(url, data, config).then((res) => res.data),
delete: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
apiClient.delete<T>(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);
}
export default apiClient;
+13
View File
@@ -0,0 +1,13 @@
import { api } from '@/services/api';
import type { AuthSession, LoginPayload, MeResult, RegisterPayload } from '@/types/auth';
/** 账户与会话接口,契约见 docs/api/auth.md */
export const authApi = {
register: (payload: RegisterPayload) => api.post<AuthSession>('/auth/register', payload),
login: (payload: LoginPayload) => api.post<AuthSession>('/auth/login', payload),
logout: () => api.post<void>('/auth/logout'),
me: () => api.get<MeResult>('/auth/me'),
};
+16
View File
@@ -0,0 +1,16 @@
/**
* access token 仅在内存中短暂持有(docs/api/conventions.md §1),
* 不得写入 localStorage 等持久化存储;页面刷新后由 /auth/refresh 通过
* HttpOnly cookie 恢复会话(接入会话恢复逻辑时实现)。
*/
let accessToken: string | null = null;
export const authToken = {
get: () => accessToken,
set: (token: string) => {
accessToken = token;
},
clear: () => {
accessToken = null;
},
};
+55
View File
@@ -0,0 +1,55 @@
/**
* 认证相关类型,形状对齐 docs/api/auth.md。
* 后续 @atlas/contracts 补充认证 schema 后可迁移为 zod 推导类型。
*/
export interface AuthUser {
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;
}
/** 注册 / 登录成功返回的会话(access token 短期有效,refresh token 在 HttpOnly cookie 中) */
export interface AuthSession {
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;
}
export interface LoginPayload {
email: string;
password: string;
}
export interface PlatformSummary {
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;
};
}
+9
View File
@@ -0,0 +1,9 @@
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('邮箱格式不正确')),
};
+10 -1
View File
@@ -3,10 +3,19 @@
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo", "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"], "lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx", "jsx": "react-jsx",
"noEmit": true, "noEmit": true,
"allowImportingTsExtensions": false "allowImportingTsExtensions": false,
"paths": {
"@/*": ["./src/*"]
}
}, },
"include": ["src", "vite.config.ts"] "include": ["src", "vite.config.ts"]
} }
+8
View File
@@ -1,10 +1,18 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
// 前端开发服务器:/api 代理到本地 NestJS API(apps/api,默认 8200)
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: {
alias: {
'@': 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,
proxy: { proxy: {
'/api': { '/api': {
target: process.env.API_ORIGIN ?? 'http://localhost:8200', target: process.env.API_ORIGIN ?? 'http://localhost:8200',
+2 -1
View File
@@ -10,7 +10,8 @@
}, },
"dependencies": { "dependencies": {
"@atlas/contracts": "workspace:*", "@atlas/contracts": "workspace:*",
"bullmq": "6.3.4" "bullmq": "6.3.4",
"ioredis": "6.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "26.3.0", "@types/node": "26.3.0",
+6 -1
View File
@@ -1,6 +1,11 @@
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import IORedis from 'ioredis';
const connection = { url: process.env.REDIS_URL ?? 'redis://localhost:6379' }; // 显式构造客户端实例传给 BullMQ:原生 ESM(pnpm start)下 BullMQ 无法按 connection
// 选项自动加载 ioredis;maxRetriesPerRequest: null 是 BullMQ Worker 的要求
const connection = new IORedis(process.env.REDIS_URL ?? 'redis://localhost:6379', {
maxRetriesPerRequest: null,
});
const dispatchQueue = new Queue('atlas-dispatch', { connection }); const dispatchQueue = new Queue('atlas-dispatch', { connection });
async function bootstrap() { async function bootstrap() {
+1 -1
View File
@@ -107,7 +107,7 @@ sequenceDiagram
## 5. 本地开发入口 ## 5. 本地开发入口
运行时基线:Node 24.20.0(见 `.nvmrc`)、pnpm 11.25.0、Web `8100`、API `8200`。Worker 是队列消费者,不对浏览器提供 HTTP 服务,因此没有端口号。 运行时基线:Node 24.20.0(见 `.nvmrc`)、pnpm 11.25.0、Web `8100`、API `8200`、扩展 dev server `8110`(仅本地开发用)。Worker 是队列消费者,不对浏览器提供 HTTP 服务,因此没有端口号。
```bash ```bash
cp .env.example .env cp .env.example .env
@@ -118,7 +118,7 @@ workspaces/{workspaceId}/
## 7. Docker 与首期部署 ## 7. Docker 与首期部署
当前仓库的 `infra/compose.yaml` 只编排本地基础依赖:`postgres`、`redis`、`minio`;`web`、`api`、`worker` 与 `extension` 通过 `pnpm dev` 在宿主机并行 watch。Web 默认监听 `8100`,API 默认监听 `8200`,Web 的 `/api` 代理目标为 `http://localhost:8200`;Worker 不监听 HTTP 端口。 当前仓库的 `infra/compose.yaml` 只编排本地基础依赖:`postgres`、`redis`、`minio`;`web`、`api`、`worker` 与 `extension` 通过 `pnpm dev` 在宿主机并行 watch。Web 默认监听 `8100`,API 默认监听 `8200`,Web 的 `/api` 代理目标为 `http://localhost:8200`;扩展的 WXT dev server 固定监听 `8110`(`EXTENSION_PORT` 可覆盖,仅本地开发用);Worker 不监听 HTTP 端口。
后续若将本地应用进程也容器化,Compose 至少应覆盖:`web`、`api`、`worker`、`postgres`、`redis`、`minio`。前端开发也可单独使用 Vite 热更新,并将 `/api` 代理至本地 API。 后续若将本地应用进程也容器化,Compose 至少应覆盖:`web`、`api`、`worker`、`postgres`、`redis`、`minio`。前端开发也可单独使用 Vite 热更新,并将 `/api` 代理至本地 API。
+29 -2
View File
@@ -6,9 +6,35 @@
这是一个全新的云端产品,不是 `ozon-seller-kit` 的本地版重构。目标用户可拥有多个门店,每个门店只能绑定一个平台;平台自身可支持多个市场、国家、语言和币种。因此语言、币种、类目和发布规则不能写死为 Ozon 或中文/俄文。 这是一个全新的云端产品,不是 `ozon-seller-kit` 的本地版重构。目标用户可拥有多个门店,每个门店只能绑定一个平台;平台自身可支持多个市场、国家、语言和币种。因此语言、币种、类目和发布规则不能写死为 Ozon 或中文/俄文。
当前已完成 pnpm monorepo 初始化:包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、共享 API 契约、数据库包、本地基础设施 Compose 与依赖锁文件。API 仅实现健康检查,Web/Worker/扩展仅有启动骨架;尚未实现业务模块、迁移执行器、数据库 schema、鉴权或实际部署。不要从旧项目复制 SQLite 模型、无鉴权 API、旧 `/trial/:id` 或商品编辑页组件。可选择性复用已经验证过的纯计价公式、采集扩展思路和 AI 任务经验,但必须先适配多租户和云端边界。 当前已完成 pnpm monorepo 初始化:包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、共享 API 契约、数据库包、本地基础设施 Compose 与依赖锁文件。API 仅实现健康检查;Web 已具备完整前端框架(布局、路由、主题)和登录/注册页面,业务页面均为占位;Worker 与扩展为可运行骨架。尚未实现业务模块、迁移执行器、数据库 schema、鉴权或实际部署。不要从旧项目复制 SQLite 模型、无鉴权 API、旧 `/trial/:id` 或商品编辑页组件。可选择性复用已经验证过的纯计价公式、采集扩展思路和 AI 任务经验,但必须先适配多租户和云端边界。
当前运行时基线:Node 24.20.0、pnpm 11.25.0、PostgreSQL 18.6、Redis 7.4.11、MinIO `RELEASE.2025-09-07T16-13-09Z`;Web 默认端口为 `8100`,API 默认端口为 `8200`,Worker 无 HTTP 端口。完整目录、依赖边界与版本表见 [`architecture/project-structure.md`](../architecture/project-structure.md) 和 [`architecture/technology-and-deployment.md`](../architecture/technology-and-deployment.md)。 当前运行时基线:Node 24.20.0、pnpm 11.25.0、PostgreSQL 18.6、Redis 7.4.11、MinIO `RELEASE.2025-09-07T16-13-09Z`;Web 默认端口为 `8100`,API 默认端口为 `8200`,扩展 dev server 默认端口为 `8110`(仅本地开发用),Worker 无 HTTP 端口。完整目录、依赖边界与版本表见 [`architecture/project-structure.md`](../architecture/project-structure.md) 和 [`architecture/technology-and-deployment.md`](../architecture/technology-and-deployment.md)。
### 1.1 开发进度(2026-09-02)
今日完成三项工作,全部通过 typecheck、生产构建,并经浏览器或启动实测:
**Web 前端框架(`apps/web`)**
- 参照 `ozon-seller-kit/studio` 搭建:紫色主题(`colorPrimary #8b5cf6`、圆角 8)、`index.css` CSS 变量、可折叠侧边栏 + 吸顶页头 + 移动端 Drawer 的 `MainLayout`、`@` 路径别名、`StrictMode` 入口。
- 主题显式 `fontSize: 14`;输入类控件默认高度 32→34(`controlHeight`)、小尺寸 24→26(`controlHeightSM`);body 字号同步 14px。
- 依赖使用最新稳定版:React 19.2.8、antd 6.6.2,新增 `@ant-design/icons` 6.3.4;沿用仓库精确版本号约定。
- 左侧菜单按 [`requirements/account-store-watermark.md`](../requirements/account-store-watermark.md) 的导航约定:在线商品(默认首页)、采集箱、店铺管理、水印管理、定价工具,均为占位页;另有菜单外的商品 AI 编辑页 `/products/:id`。
- 基础设施:`services/api.ts`(axios 封装 + 统一错误提取)、`config/env.ts`(`VITE_` 变量)、统一占位组件 `components/PagePlaceholder`。
- Web tsconfig 覆盖 base 的 `module: Node16` 为 `ESNext + bundler`(base 面向 NestJS;Node16 会要求相对导入带扩展名);TypeScript 7 已移除 `baseUrl`,`paths` 直接相对 tsconfig 解析为 `"@/*": ["./src/*"]`。
**注册与登录页面(契约:[`api/auth.md`](../api/auth.md))**
- `/login`、`/register` 两页共用 `AuthLayout`(品牌视觉与主布局一致)。注册字段:显示名称(选填 ≤80)、邮箱、密码、确认密码;登录失败统一 401 文案,注册重复邮箱 409 落到邮箱字段提示。
- 会话基础:access token 仅内存持有(`services/authToken.ts`),不进入任何持久化存储;`api.ts` 拦截器自动附加 Bearer 头,401 时用 refresh cookie 换新 token 并重放一次(并发去重),刷新失败整页跳转 `/login`。
- 实测发现并修复:antd v6 中空值会同时触发 `required` 与 `type: 'email'` 两条错误,已改为非空才校验格式的 `emailFormatRule`(`utils/validators.ts`)。
- 浏览器实测通过:表单校验、页面互链、API 未启动时的错误提示路径。密码复杂度按契约不在前端硬编码,仅提示以服务端校验为准。
- 留待 API 认证模块就绪后:接入真实 `/api/auth/*` 调用、主布局路由守卫、刷新页面后的会话恢复;auth 类型届时迁移至 `@atlas/contracts`。
**Worker 与扩展骨架修复**
- Worker:`ioredis` 是 BullMQ 的可选 peer 依赖,pnpm 严格隔离下必须由应用显式声明,已新增 `ioredis 6.0.0`;`main.ts` 改为显式构造 IORedis 实例并设置 `maxRetriesPerRequest: null`(原生 ESM 运行必需,也是后续 BullMQ Worker 的硬性要求)。`tsx` dev 与 `node dist/main.js` 两种运行方式均验证可连接本地 Redis。
- 扩展:WXT dev server 从默认的 3000–3010 漂移改为固定 `8110`(`EXTENSION_PORT` 可覆盖,`strictPort` 开启);`.output/` 构建产物已加入 `.gitignore` 并移出 git 跟踪。端口约定同步更新至交接、部署与项目结构三份文档及 `.env.example`。
## 2. 已确认产品决策 ## 2. 已确认产品决策
@@ -74,6 +100,7 @@ User → Workspace → Membership / Role
3. 建立 `operations`、幂等请求和 Outbox Dispatcher 的第一个集成测试闭环。 3. 建立 `operations`、幂等请求和 Outbox Dispatcher 的第一个集成测试闭环。
4. 在开始商品编辑/在线同步模块前,关闭评审文档中的 P1 事项:同商品归属约束、未映射属性持久化、乐观锁 Repository 规则、平台能力 Schema;并确认在线商品归档策略和 Client ID 编辑边界。 4. 在开始商品编辑/在线同步模块前,关闭评审文档中的 P1 事项:同商品归属约束、未映射属性持久化、乐观锁 Repository 规则、平台能力 Schema;并确认在线商品归档策略和 Client ID 编辑边界。
5. 优先实现账户、平台上下文、门店、素材上传的纵向最小闭环,再接入采集箱和商品编辑;浏览器扩展仅在采集 API 稳定后实现实际解析器。 5. 优先实现账户、平台上下文、门店、素材上传的纵向最小闭环,再接入采集箱和商品编辑;浏览器扩展仅在采集 API 稳定后实现实际解析器。
6. Web 侧:认证 API 就绪后把登录/注册接入真实 `/api/auth/*`,补主布局路由守卫与刷新后的会话恢复,auth 类型迁移至 `@atlas/contracts`。
## 7. 文档导航 ## 7. 文档导航
+765 -2011
View File
File diff suppressed because it is too large Load Diff