Compare commits
3 Commits
feat-0.1.0
...
403f6cfa27
| Author | SHA1 | Date | |
|---|---|---|---|
| 403f6cfa27 | |||
| 09e5207c83 | |||
| 1a2caba2b3 |
@@ -0,0 +1,12 @@
|
|||||||
|
POSTGRES_USER=atlas_app_login
|
||||||
|
POSTGRES_PASSWORD=change-me-local-only
|
||||||
|
POSTGRES_DB=atlas
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
MINIO_ROOT_USER=atlas_minio_local
|
||||||
|
MINIO_ROOT_PASSWORD=change-me-local-only
|
||||||
|
MINIO_PORT=9000
|
||||||
|
MINIO_CONSOLE_PORT=9001
|
||||||
|
API_PORT=8200
|
||||||
|
WEB_PORT=8100
|
||||||
|
EXTENSION_PORT=8110
|
||||||
+12
@@ -11,7 +11,19 @@
|
|||||||
# 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]
|
||||||
*.log
|
*.log
|
||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
.turbo/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
data/
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
.turbo/
|
||||||
|
dist/
|
||||||
|
apps/extension/.output/
|
||||||
|
apps/extension/.wxt/
|
||||||
|
docs/**
|
||||||
|
!docs/architecture/project-structure.md
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@atlas/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"dev": "tsx watch src/main.ts",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@atlas/contracts": "workspace:*",
|
||||||
|
"@nestjs/common": "12.0.1",
|
||||||
|
"@nestjs/core": "12.0.1",
|
||||||
|
"@nestjs/platform-express": "12.0.1",
|
||||||
|
"reflect-metadata": "0.2.2",
|
||||||
|
"rxjs": "7.8.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "26.3.0",
|
||||||
|
"tsx": "4.23.13",
|
||||||
|
"typescript": "7.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Controller, Get, Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
class HealthController {
|
||||||
|
@Get()
|
||||||
|
check() {
|
||||||
|
return { status: 'ok', service: 'api' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Module({ controllers: [HealthController] })
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
|
||||||
|
import { AppModule } from './app.module.js';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
app.enableCors({ origin: process.env.WEB_ORIGIN ?? 'http://localhost:8100' });
|
||||||
|
await app.listen(Number(process.env.API_PORT ?? 8200));
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"types": ["node"],
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": [
|
||||||
|
"ESNext",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"paths": {
|
||||||
|
"@": [
|
||||||
|
".."
|
||||||
|
],
|
||||||
|
"@/*": [
|
||||||
|
"../*"
|
||||||
|
],
|
||||||
|
"~": [
|
||||||
|
".."
|
||||||
|
],
|
||||||
|
"~/*": [
|
||||||
|
"../*"
|
||||||
|
],
|
||||||
|
"@@": [
|
||||||
|
".."
|
||||||
|
],
|
||||||
|
"@@/*": [
|
||||||
|
"../*"
|
||||||
|
],
|
||||||
|
"~~": [
|
||||||
|
".."
|
||||||
|
],
|
||||||
|
"~~/*": [
|
||||||
|
"../*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"../**/*",
|
||||||
|
"./wxt.d.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"../**/node_modules",
|
||||||
|
"../.output"
|
||||||
|
]
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
// Generated by wxt
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly MANIFEST_VERSION: 2 | 3;
|
||||||
|
readonly BROWSER: string;
|
||||||
|
readonly CHROME: boolean;
|
||||||
|
readonly FIREFOX: boolean;
|
||||||
|
readonly SAFARI: boolean;
|
||||||
|
readonly EDGE: boolean;
|
||||||
|
readonly OPERA: boolean;
|
||||||
|
readonly COMMAND: "build" | "serve";
|
||||||
|
readonly ENTRYPOINT: string;
|
||||||
|
}
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
Vendored
+81
@@ -0,0 +1,81 @@
|
|||||||
|
// Generated by wxt
|
||||||
|
import "wxt/browser";
|
||||||
|
|
||||||
|
declare module "wxt/browser" {
|
||||||
|
/**
|
||||||
|
* See https://developer.chrome.com/docs/extensions/reference/i18n/#method-getMessage
|
||||||
|
*/
|
||||||
|
interface GetMessageOptions {
|
||||||
|
/**
|
||||||
|
* See https://developer.chrome.com/docs/extensions/reference/i18n/#method-getMessage
|
||||||
|
*/
|
||||||
|
escapeLt?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WxtI18n extends I18n.Static {
|
||||||
|
/**
|
||||||
|
* The extension or app ID; you might use this string to construct URLs for resources inside the extension. Even non-localized extensions can use this message.
|
||||||
|
* Note: You can't use this message in a manifest file.
|
||||||
|
*
|
||||||
|
* "<browser.runtime.id>"
|
||||||
|
*/
|
||||||
|
getMessage(
|
||||||
|
messageName: "@@extension_id",
|
||||||
|
substitutions?: string | string[],
|
||||||
|
options?: GetMessageOptions,
|
||||||
|
): string;
|
||||||
|
/**
|
||||||
|
* "<browser.i18n.getUiLocale()>"
|
||||||
|
*/
|
||||||
|
getMessage(
|
||||||
|
messageName: "@@ui_locale",
|
||||||
|
substitutions?: string | string[],
|
||||||
|
options?: GetMessageOptions,
|
||||||
|
): string;
|
||||||
|
/**
|
||||||
|
* The text direction for the current locale, either "ltr" for left-to-right languages such as English or "rtl" for right-to-left languages such as Japanese.
|
||||||
|
*
|
||||||
|
* "<ltr|rtl>"
|
||||||
|
*/
|
||||||
|
getMessage(
|
||||||
|
messageName: "@@bidi_dir",
|
||||||
|
substitutions?: string | string[],
|
||||||
|
options?: GetMessageOptions,
|
||||||
|
): string;
|
||||||
|
/**
|
||||||
|
* If the @@bidi_dir is "ltr", then this is "rtl"; otherwise, it's "ltr".
|
||||||
|
*
|
||||||
|
* "<rtl|ltr>"
|
||||||
|
*/
|
||||||
|
getMessage(
|
||||||
|
messageName: "@@bidi_reversed_dir",
|
||||||
|
substitutions?: string | string[],
|
||||||
|
options?: GetMessageOptions,
|
||||||
|
): string;
|
||||||
|
/**
|
||||||
|
* If the @@bidi_dir is "ltr", then this is "left"; otherwise, it's "right".
|
||||||
|
*
|
||||||
|
* "<left|right>"
|
||||||
|
*/
|
||||||
|
getMessage(
|
||||||
|
messageName: "@@bidi_start_edge",
|
||||||
|
substitutions?: string | string[],
|
||||||
|
options?: GetMessageOptions,
|
||||||
|
): string;
|
||||||
|
/**
|
||||||
|
* If the @@bidi_dir is "ltr", then this is "right"; otherwise, it's "left".
|
||||||
|
*
|
||||||
|
* "<right|left>"
|
||||||
|
*/
|
||||||
|
getMessage(
|
||||||
|
messageName: "@@bidi_end_edge",
|
||||||
|
substitutions?: string | string[],
|
||||||
|
options?: GetMessageOptions,
|
||||||
|
): string;
|
||||||
|
getMessage(
|
||||||
|
messageName: "@@extension_id" | "@@ui_locale" | "@@bidi_dir" | "@@bidi_reversed_dir" | "@@bidi_start_edge" | "@@bidi_end_edge",
|
||||||
|
substitutions?: string | string[],
|
||||||
|
options?: GetMessageOptions,
|
||||||
|
): string;
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
// Generated by wxt
|
||||||
|
// Types for the #import virtual module
|
||||||
|
declare module '#imports' {
|
||||||
|
export { browser, Browser } from 'wxt/browser';
|
||||||
|
export { storage, WxtStorage, WxtStorageItem, StorageArea, StorageItemKey, StorageAreaChanges, MigrationError } from 'wxt/utils/storage';
|
||||||
|
export { getAppConfig, useAppConfig } from 'wxt/utils/app-config';
|
||||||
|
export { ContentScriptContext, WxtWindowEventMap } from 'wxt/utils/content-script-context';
|
||||||
|
export { createIframeUi, IframeContentScriptUi, IframeContentScriptUiOptions } from 'wxt/utils/content-script-ui/iframe';
|
||||||
|
export { createIntegratedUi, IntegratedContentScriptUi, IntegratedContentScriptUiOptions } from 'wxt/utils/content-script-ui/integrated';
|
||||||
|
export { createShadowRootUi, ShadowRootContentScriptUi, ShadowRootContentScriptUiOptions } from 'wxt/utils/content-script-ui/shadow-root';
|
||||||
|
export { ContentScriptUi, ContentScriptUiOptions, ContentScriptOverlayAlignment, ContentScriptAppendMode, ContentScriptInlinePositioningOptions, ContentScriptOverlayPositioningOptions, ContentScriptModalPositioningOptions, ContentScriptPositioningOptions, ContentScriptAnchoredOptions, AutoMountOptions, StopAutoMount, AutoMount } from 'wxt/utils/content-script-ui/types';
|
||||||
|
export { defineAppConfig, WxtAppConfig } from 'wxt/utils/define-app-config';
|
||||||
|
export { defineBackground } from 'wxt/utils/define-background';
|
||||||
|
export { defineContentScript } from 'wxt/utils/define-content-script';
|
||||||
|
export { defineUnlistedScript } from 'wxt/utils/define-unlisted-script';
|
||||||
|
export { defineWxtPlugin } from 'wxt/utils/define-wxt-plugin';
|
||||||
|
export { injectScript, ScriptPublicPath, InjectScriptOptions } from 'wxt/utils/inject-script';
|
||||||
|
export { InvalidMatchPattern, MatchPattern } from 'wxt/utils/match-patterns';
|
||||||
|
export { useState, useCallback, useMemo, useEffect, useRef, useContext, useReducer } from 'react';
|
||||||
|
export { fakeBrowser } from 'wxt/testing/fake-browser';
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
// Generated by wxt
|
||||||
|
export {}
|
||||||
|
declare global {
|
||||||
|
const ContentScriptContext: typeof import('wxt/utils/content-script-context').ContentScriptContext
|
||||||
|
const InvalidMatchPattern: typeof import('wxt/utils/match-patterns').InvalidMatchPattern
|
||||||
|
const MatchPattern: typeof import('wxt/utils/match-patterns').MatchPattern
|
||||||
|
const browser: typeof import('wxt/browser').browser
|
||||||
|
const createIframeUi: typeof import('wxt/utils/content-script-ui/iframe').createIframeUi
|
||||||
|
const createIntegratedUi: typeof import('wxt/utils/content-script-ui/integrated').createIntegratedUi
|
||||||
|
const createShadowRootUi: typeof import('wxt/utils/content-script-ui/shadow-root').createShadowRootUi
|
||||||
|
const defineAppConfig: typeof import('wxt/utils/define-app-config').defineAppConfig
|
||||||
|
const defineBackground: typeof import('wxt/utils/define-background').defineBackground
|
||||||
|
const defineContentScript: typeof import('wxt/utils/define-content-script').defineContentScript
|
||||||
|
const defineUnlistedScript: typeof import('wxt/utils/define-unlisted-script').defineUnlistedScript
|
||||||
|
const defineWxtPlugin: typeof import('wxt/utils/define-wxt-plugin').defineWxtPlugin
|
||||||
|
const fakeBrowser: typeof import('wxt/testing/fake-browser').fakeBrowser
|
||||||
|
const getAppConfig: typeof import('wxt/utils/app-config').getAppConfig
|
||||||
|
const injectScript: typeof import('wxt/utils/inject-script').injectScript
|
||||||
|
const storage: typeof import('wxt/utils/storage').storage
|
||||||
|
const useAppConfig: typeof import('wxt/utils/app-config').useAppConfig
|
||||||
|
const useCallback: typeof import('react').useCallback
|
||||||
|
const useContext: typeof import('react').useContext
|
||||||
|
const useEffect: typeof import('react').useEffect
|
||||||
|
const useMemo: typeof import('react').useMemo
|
||||||
|
const useReducer: typeof import('react').useReducer
|
||||||
|
const useRef: typeof import('react').useRef
|
||||||
|
const useState: typeof import('react').useState
|
||||||
|
}
|
||||||
|
// for type re-export
|
||||||
|
declare global {
|
||||||
|
// @ts-ignore
|
||||||
|
export type { Browser } from 'wxt/browser'
|
||||||
|
import('wxt/browser')
|
||||||
|
// @ts-ignore
|
||||||
|
export type { WxtStorage, WxtStorageItem, StorageArea, StorageItemKey, StorageAreaChanges, MigrationError } from 'wxt/utils/storage'
|
||||||
|
import('wxt/utils/storage')
|
||||||
|
// @ts-ignore
|
||||||
|
export type { WxtWindowEventMap } from 'wxt/utils/content-script-context'
|
||||||
|
import('wxt/utils/content-script-context')
|
||||||
|
// @ts-ignore
|
||||||
|
export type { IframeContentScriptUi, IframeContentScriptUiOptions } from 'wxt/utils/content-script-ui/iframe'
|
||||||
|
import('wxt/utils/content-script-ui/iframe')
|
||||||
|
// @ts-ignore
|
||||||
|
export type { IntegratedContentScriptUi, IntegratedContentScriptUiOptions } from 'wxt/utils/content-script-ui/integrated'
|
||||||
|
import('wxt/utils/content-script-ui/integrated')
|
||||||
|
// @ts-ignore
|
||||||
|
export type { ShadowRootContentScriptUi, ShadowRootContentScriptUiOptions } from 'wxt/utils/content-script-ui/shadow-root'
|
||||||
|
import('wxt/utils/content-script-ui/shadow-root')
|
||||||
|
// @ts-ignore
|
||||||
|
export type { ContentScriptUi, ContentScriptUiOptions, ContentScriptOverlayAlignment, ContentScriptAppendMode, ContentScriptInlinePositioningOptions, ContentScriptOverlayPositioningOptions, ContentScriptModalPositioningOptions, ContentScriptPositioningOptions, ContentScriptAnchoredOptions, AutoMountOptions, StopAutoMount, AutoMount } from 'wxt/utils/content-script-ui/types'
|
||||||
|
import('wxt/utils/content-script-ui/types')
|
||||||
|
// @ts-ignore
|
||||||
|
export type { WxtAppConfig } from 'wxt/utils/define-app-config'
|
||||||
|
import('wxt/utils/define-app-config')
|
||||||
|
// @ts-ignore
|
||||||
|
export type { ScriptPublicPath, InjectScriptOptions } from 'wxt/utils/inject-script'
|
||||||
|
import('wxt/utils/inject-script')
|
||||||
|
}
|
||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
// Generated by wxt
|
||||||
|
import "wxt/browser";
|
||||||
|
|
||||||
|
declare module "wxt/browser" {
|
||||||
|
export type PublicPath =
|
||||||
|
| ""
|
||||||
|
| "/"
|
||||||
|
| "/background.js"
|
||||||
|
type HtmlPublicPath = Extract<PublicPath, `${string}.html`>
|
||||||
|
export interface WxtRuntime {
|
||||||
|
getURL(path: PublicPath): string;
|
||||||
|
getURL(path: `${HtmlPublicPath}${string}`): string;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
// Generated by wxt
|
||||||
|
/// <reference types="wxt/vite-builder-env" />
|
||||||
|
/// <reference types="@wxt-dev/module-react" />
|
||||||
|
/// <reference path="./types/paths.d.ts" />
|
||||||
|
/// <reference path="./types/i18n.d.ts" />
|
||||||
|
/// <reference path="./types/globals.d.ts" />
|
||||||
|
/// <reference path="./types/imports-module.d.ts" />
|
||||||
|
/// <reference path="./types/imports.d.ts" />
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default defineBackground(() => {
|
||||||
|
console.info('Atlas collector background started.');
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "@atlas/extension",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "wxt build",
|
||||||
|
"dev": "wxt",
|
||||||
|
"typecheck": "wxt prepare && tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@atlas/contracts": "workspace:*",
|
||||||
|
"@wxt-dev/module-react": "1.2.2",
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.2.18",
|
||||||
|
"@types/react-dom": "19.2.5",
|
||||||
|
"typescript": "7.0.2",
|
||||||
|
"wxt": "0.21.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "**/*.tsx", ".wxt/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from 'wxt';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
modules: ['@wxt-dev/module-react'],
|
||||||
|
// 扩展 dev server 固定 8110:默认取 3000–3010 第一个空闲端口,会漂移且易与常见服务冲突
|
||||||
|
dev: {
|
||||||
|
server: {
|
||||||
|
port: Number(process.env.EXTENSION_PORT ?? 8110),
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manifest: {
|
||||||
|
name: 'Atlas Seller Kit Collector',
|
||||||
|
description: '将已确认的来源商品采集到 Atlas 工作空间。',
|
||||||
|
permissions: ['storage', 'identity'],
|
||||||
|
host_permissions: [
|
||||||
|
'*://*.ozon.ru/*',
|
||||||
|
'*://*.ozon.kz/*',
|
||||||
|
'*://*.ozon.by/*',
|
||||||
|
'*://*.1688.com/*',
|
||||||
|
'*://*.taobao.com/*',
|
||||||
|
'*://*.tmall.com/*',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Atlas Seller Kit</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@atlas/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"dev": "vite",
|
||||||
|
"typecheck": "tsc -b --pretty false"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "6.3.4",
|
||||||
|
"antd": "6.6.2",
|
||||||
|
"axios": "1.20.0",
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8",
|
||||||
|
"react-router-dom": "7.18.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "26.3.0",
|
||||||
|
"@types/react": "19.2.18",
|
||||||
|
"@types/react-dom": "19.2.5",
|
||||||
|
"@vitejs/plugin-react": "6.1.1",
|
||||||
|
"typescript": "7.0.2",
|
||||||
|
"vite": "8.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 全局应用级样式补充(主题变量见 index.css) */
|
||||||
@@ -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;
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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('/');
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 /> },
|
||||||
|
]);
|
||||||
@@ -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;
|
||||||
@@ -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'),
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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('邮箱格式不正确')),
|
||||||
|
};
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.css';
|
||||||
@@ -0,0 +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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
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)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: Number(process.env.WEB_PORT ?? 8100),
|
||||||
|
strictPort: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: process.env.API_ORIGIN ?? 'http://localhost:8200',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@atlas/worker",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"dev": "tsx watch src/main.ts",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@atlas/contracts": "workspace:*",
|
||||||
|
"bullmq": "6.3.4",
|
||||||
|
"ioredis": "6.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "26.3.0",
|
||||||
|
"tsx": "4.23.13",
|
||||||
|
"typescript": "7.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import IORedis from 'ioredis';
|
||||||
|
|
||||||
|
// 显式构造客户端实例传给 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 });
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
await dispatchQueue.waitUntilReady();
|
||||||
|
console.info(
|
||||||
|
'Atlas Worker is ready; it will consume transactional-outbox events after that module exists.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
await dispatchQueue.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.once('SIGINT', () => void shutdown());
|
||||||
|
process.once('SIGTERM', () => void shutdown());
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
| 产品需求 | [`requirements/`](./requirements/) | 页面、流程、字段和待确认事项 |
|
| 产品需求 | [`requirements/`](./requirements/) | 页面、流程、字段和待确认事项 |
|
||||||
| 基础架构 | [`architecture/`](./architecture/) | 多租户、平台、门店、水印、物流和定价的数据设计及 DDL |
|
| 基础架构 | [`architecture/`](./architecture/) | 多租户、平台、门店、水印、物流和定价的数据设计及 DDL |
|
||||||
| 商品领域设计 | [`product/`](./product/) | 已有的多 SKU 商品编辑页、领域模型与 API 草案 |
|
| 商品领域设计 | [`product/`](./product/) | 已有的多 SKU 商品编辑页、领域模型与 API 草案 |
|
||||||
|
| 项目结构 | [`architecture/project-structure.md`](./architecture/project-structure.md) | pnpm monorepo 的目录、依赖关系与本地运行边界。 |
|
||||||
| 外部平台资料 | [`integrations/`](./integrations/) | Ozon Seller API 的整理资料与适配依据 |
|
| 外部平台资料 | [`integrations/`](./integrations/) | Ozon Seller API 的整理资料与适配依据 |
|
||||||
| 学习指南 | [`guides/`](./guides/) | 项目所用技术概念与设计原则的入门说明 |
|
| 学习指南 | [`guides/`](./guides/) | 项目所用技术概念与设计原则的入门说明 |
|
||||||
| HTTP API 设计 | [`api/`](./api/) | 可评审的服务端接口契约,按模块逐步补全。 |
|
| HTTP API 设计 | [`api/`](./api/) | 可评审的服务端接口契约,按模块逐步补全。 |
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# 项目结构与运行边界
|
||||||
|
|
||||||
|
> 状态:初始化骨架已创建并完成版本基线锁定。本文描述仓库的代码归属、构建关系和本地运行边界;业务规则仍以项目交接、API 契约和数据模型文档为准。
|
||||||
|
|
||||||
|
## 1. 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
atlas-seller-kit/
|
||||||
|
├── apps/ # 可独立启动、构建和部署的入口
|
||||||
|
│ ├── web/ # React + Vite 卖家工作台
|
||||||
|
│ ├── api/ # NestJS:HTTP、鉴权、事务与任务创建
|
||||||
|
│ ├── worker/ # BullMQ:Outbox 投递、媒体、AI、同步、发布
|
||||||
|
│ └── extension/ # WXT 浏览器采集扩展
|
||||||
|
├── packages/ # 跨应用的稳定复用单元
|
||||||
|
│ ├── contracts/ # Zod 契约、DTO、共享类型
|
||||||
|
│ └── database/ # SQL migrations、迁移/应用连接基础设施
|
||||||
|
├── infra/
|
||||||
|
│ ├── compose.yaml # local PostgreSQL、Redis、MinIO
|
||||||
|
│ └── README.md # 本地基础设施启动说明
|
||||||
|
├── docs/ # 产品、架构、API 与评审记录
|
||||||
|
├── package.json # 根命令、Node/pnpm 版本约束
|
||||||
|
├── .nvmrc # 本地 Node 24 LTS 版本
|
||||||
|
├── pnpm-workspace.yaml # workspace 边界
|
||||||
|
├── turbo.json # 构建、检查、测试任务图
|
||||||
|
└── tsconfig.base.json # 最小共享 TypeScript 编译约定
|
||||||
|
```
|
||||||
|
|
||||||
|
`apps` 只能依赖明确的 `packages`,不能横向导入另一个应用的源码。这样 API、Worker 和扩展即使独立部署,也始终共享同一份契约。反过来,`packages` 不得依赖任何 `apps`。
|
||||||
|
|
||||||
|
## 2. 代码与服务关系图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Repository[Atlas Seller Kit · pnpm workspace]
|
||||||
|
subgraph Applications[apps:部署与运行入口]
|
||||||
|
WEB[web\nReact / Vite]
|
||||||
|
API[api\nNestJS HTTP API]
|
||||||
|
WORKER[worker\nBullMQ processors]
|
||||||
|
EXT[extension\nWXT Collector]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Shared[packages:跨应用稳定边界]
|
||||||
|
CONTRACTS[contracts\nZod · DTO · payload types]
|
||||||
|
DB[database\nSQL migrations · DB access]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph LocalInfra[infra/compose.yaml:本地依赖]
|
||||||
|
PG[(PostgreSQL)]
|
||||||
|
REDIS[(Redis)]
|
||||||
|
MINIO[(MinIO)]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
WEB -->|HTTPS /api| API
|
||||||
|
EXT -->|Bearer API request| API
|
||||||
|
API --> CONTRACTS
|
||||||
|
EXT --> CONTRACTS
|
||||||
|
API --> DB
|
||||||
|
WORKER --> DB
|
||||||
|
API -->|transactional outbox| PG
|
||||||
|
API --> REDIS
|
||||||
|
WORKER -->|consume / retry| REDIS
|
||||||
|
WORKER --> PG
|
||||||
|
WORKER --> MINIO
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 运行时责任与数据流
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as 用户
|
||||||
|
participant E as 浏览器扩展
|
||||||
|
participant W as Web
|
||||||
|
participant A as API
|
||||||
|
participant P as PostgreSQL
|
||||||
|
participant R as Redis / BullMQ
|
||||||
|
participant K as Worker
|
||||||
|
participant O as MinIO / Kodo
|
||||||
|
|
||||||
|
U->>E: 在来源商品页采集
|
||||||
|
E->>A: 共享契约校验后的采集载荷
|
||||||
|
A->>P: 同一事务写业务记录、operation、outbox event
|
||||||
|
A-->>E: 采集记录 / 编辑入口
|
||||||
|
K->>P: Dispatcher 领取未投递 outbox event
|
||||||
|
K->>R: 投递或消费可重试任务
|
||||||
|
K->>O: 下载、处理、转存素材
|
||||||
|
K->>P: 保存任务状态、结果与错误
|
||||||
|
U->>W: 查看进度、编辑、发布
|
||||||
|
W->>A: 鉴权后的 HTTP 请求
|
||||||
|
A->>P: SET LOCAL app.workspace_id 后读写
|
||||||
|
```
|
||||||
|
|
||||||
|
业务事实、审计、操作状态和 Outbox 只在 PostgreSQL 持久化;Redis/BullMQ 负责排队、限流和重试,不能成为唯一事实来源。local 使用 MinIO,staging/production 使用隔离的七牛 Kodo Bucket。
|
||||||
|
|
||||||
|
## 4. 依赖规则
|
||||||
|
|
||||||
|
| 位置 | 可以依赖 | 不应依赖 |
|
||||||
|
|---|---|---|
|
||||||
|
| `apps/web` | `packages/contracts`、前端库 | API/Worker 源码、数据库直连 |
|
||||||
|
| `apps/api` | `packages/contracts`、`packages/database` | Web/扩展源码 |
|
||||||
|
| `apps/worker` | `packages/contracts`、`packages/database` | Web/扩展源码、浏览器会话 |
|
||||||
|
| `apps/extension` | `packages/contracts` | API 内部模块、ERP localStorage token |
|
||||||
|
| `packages/contracts` | Zod 等无运行环境依赖库 | NestJS、React、数据库驱动 |
|
||||||
|
| `packages/database` | PostgreSQL 驱动、迁移工具 | HTTP/UI/平台适配逻辑 |
|
||||||
|
|
||||||
|
当前只抽取了已经确认会跨端复用的采集契约和数据库边界。认证、商品、定价或 Ozon 适配器尚不单独建共享包,等首个纵向闭环出现稳定复用关系后再提取,避免过早形成空壳“公共库”。
|
||||||
|
|
||||||
|
## 5. 本地开发入口
|
||||||
|
|
||||||
|
运行时基线:Node 24.20.0(见 `.nvmrc`)、pnpm 11.25.0、Web `8100`、API `8200`、扩展 dev server `8110`(仅本地开发用)。Worker 是队列消费者,不对浏览器提供 HTTP 服务,因此没有端口号。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
pnpm install
|
||||||
|
pnpm infra:up # PostgreSQL 18.6、Redis 7.4、MinIO
|
||||||
|
pnpm dev # Web、API、Worker、Extension 并行 watch
|
||||||
|
```
|
||||||
|
|
||||||
|
常用验证命令:`pnpm build`、`pnpm typecheck`、`pnpm format:check`。首次实现数据库迁移执行器时,必须将 `atlas_migrator` 和 `atlas_app_login` 的连接串分开配置,并在集成测试中验证 RLS 隔离。
|
||||||
|
|
||||||
|
本地 Docker 镜像使用明确标签而非 `latest`;完整依赖版本表见 [技术与部署方案](./technology-and-deployment.md)。
|
||||||
|
|
||||||
|
## 6. 接下来的实现顺序
|
||||||
|
|
||||||
|
1. 在 `packages/database` 实现只接受 migrator 连接串的可重复 SQL migration runner,并导入已评审的 `001_foundation.sql`、`002_product-domain.sql`。
|
||||||
|
2. 在 API 建立配置校验、认证、工作空间上下文和事务包装器;任何工作空间查询前固定 `SET LOCAL app.workspace_id`。
|
||||||
|
3. 建立 `operations`、幂等请求和 Outbox Dispatcher 的第一个集成测试闭环。
|
||||||
|
4. 再实现平台/门店、素材上传和 Web 对应页面;扩展仅在 API 采集入口稳定后接入实际解析器。
|
||||||
|
|
||||||
|
相关约束见:[技术与部署方案](./technology-and-deployment.md)、[数据库安全](./database-security.md)、[Outbox 与幂等](./transactional-outbox-and-idempotency.md)、[项目交接](../project/HANDOFF.md)。
|
||||||
@@ -4,18 +4,24 @@
|
|||||||
|
|
||||||
## 1. 已确认技术选型
|
## 1. 已确认技术选型
|
||||||
|
|
||||||
| 层级 | 选型 | 职责 |
|
| 层级 | 选型 | 当前版本 / 镜像 | 职责 |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| 前端 | React 19、TypeScript、Vite、Ant Design、React Router、Axios | 延续 `ozon-seller-kit/studio` 的前端技术路线,构建卖家工作台。 |
|
| 前端 | React、TypeScript、Vite、Ant Design、React Router、Axios | React 19.2.8;TypeScript 7.0.2;Vite 8.2.2;Ant Design 6.6.2;Router 7.18.3;Axios 1.20.0 | 延续 `ozon-seller-kit/studio` 的前端技术路线,构建卖家工作台。 |
|
||||||
| 主后端 | NestJS、TypeScript | 认证、门店、商品/SKU、定价、平台适配、任务编排和 HTTP API。 |
|
| 主后端 | NestJS、TypeScript | NestJS 12.0.1;TypeScript 7.0.2 | 认证、门店、商品/SKU、定价、平台适配、任务编排和 HTTP API。 |
|
||||||
| 关系数据库 | PostgreSQL | 业务事实来源:用户、隐藏工作空间、门店、商品、报价、任务记录和审计。 |
|
| 关系数据库 | PostgreSQL | `postgres:18.6-alpine`(local) | 业务事实来源:用户、隐藏工作空间、门店、商品、报价、任务记录和审计。 |
|
||||||
| 缓存与队列 | Redis、BullMQ | 待执行任务、延迟/重试、进度和限流;不作为业务事实来源。 |
|
| 缓存与队列 | Redis、BullMQ | `redis:7.4.11-alpine3.21`(local);BullMQ 6.3.4 | 待执行任务、延迟/重试、进度和限流;不作为业务事实来源。 |
|
||||||
| 对象存储(本地) | MinIO | 本地开发存放图片和文件,使用 S3 兼容接口。 |
|
| 对象存储(本地) | MinIO | `minio/minio:RELEASE.2025-09-07T16-13-09Z` | 本地开发存放图片和文件,使用 S3 兼容接口。 |
|
||||||
| 对象存储(生产) | 七牛云 Kodo | 生产及共享测试环境存放图片和文件。Kodo 的基础 S3 兼容能力用于上传、下载、删除和短时访问链接。 |
|
| 对象存储(生产) | 七牛云 Kodo | 生产及共享测试环境存放图片和文件。Kodo 的基础 S3 兼容能力用于上传、下载、删除和短时访问链接。 |
|
||||||
| 运行与编排 | Docker、Docker Compose | 本地一键启动依赖;首期服务器部署 API、Worker、Redis 和反向代理。 |
|
| 浏览器扩展 | WXT、React | WXT 0.21.4;`@wxt-dev/module-react` 1.2.2;React 19.2.8 | 在来源商品详情页运行采集器。 |
|
||||||
|
| 运行时与构建 | Node.js、pnpm、Turborepo、Prettier | Node 24.20.0;pnpm 11.25.0;Turbo 2.10.12;Prettier 3.9.6 | 固定本地/CI 运行时,管理 workspace、任务编排和格式校验。 |
|
||||||
|
| 运行与编排 | Docker、Docker Compose | Docker Compose local 基础依赖 | 本地一键启动依赖;首期服务器部署 API、Worker、Redis 和反向代理。 |
|
||||||
|
|
||||||
不将旧项目的 FastAPI 服务作为新项目运行时依赖;旧项目的 Ozon 资料、计价规则、采集经验和前端技术路线可作为参考或选择性复用。
|
不将旧项目的 FastAPI 服务作为新项目运行时依赖;旧项目的 Ozon 资料、计价规则、采集经验和前端技术路线可作为参考或选择性复用。
|
||||||
|
|
||||||
|
代码仓库采用 pnpm workspace monorepo:Web、API、Worker 与浏览器扩展位于独立 `apps/*`,跨端 API 契约和数据库迁移基础设施位于 `packages/*`。目录和依赖边界见 [项目结构与运行边界](./project-structure.md)。
|
||||||
|
|
||||||
|
版本由根目录 `package.json`、`.nvmrc` 与 `infra/compose.yaml` 锁定;镜像不使用 `latest`。pnpm 11 启用供应链保护:仅允许 `esbuild` 执行安装构建脚本,`msgpackr-extract` 保持禁用。
|
||||||
|
|
||||||
## 2. 服务边界
|
## 2. 服务边界
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -112,7 +118,9 @@ workspaces/{workspaceId}/
|
|||||||
|
|
||||||
## 7. Docker 与首期部署
|
## 7. Docker 与首期部署
|
||||||
|
|
||||||
本地 Docker Compose 至少编排:`web`、`api`、`worker`、`postgres`、`redis`、`minio`。前端开发也可单独使用 Vite 热更新,并将 `/api` 代理至本地 API。
|
当前仓库的 `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。
|
||||||
|
|
||||||
首期云端 Docker Compose 编排:`api`、`worker`、`redis`、`reverse-proxy`;PostgreSQL 使用云托管实例,图片使用七牛 Kodo。每个环境使用独立 `.env`/密钥管理配置,密钥不提交 Git、不进入前端构建产物和日志。
|
首期云端 Docker Compose 编排:`api`、`worker`、`redis`、`reverse-proxy`;PostgreSQL 使用云托管实例,图片使用七牛 Kodo。每个环境使用独立 `.env`/密钥管理配置,密钥不提交 Git、不进入前端构建产物和日志。
|
||||||
|
|
||||||
|
|||||||
+37
-8
@@ -6,7 +6,35 @@
|
|||||||
|
|
||||||
这是一个全新的云端产品,不是 `ozon-seller-kit` 的本地版重构。目标用户可拥有多个门店,每个门店只能绑定一个平台;平台自身可支持多个市场、国家、语言和币种。因此语言、币种、类目和发布规则不能写死为 Ozon 或中文/俄文。
|
这是一个全新的云端产品,不是 `ozon-seller-kit` 的本地版重构。目标用户可拥有多个门店,每个门店只能绑定一个平台;平台自身可支持多个市场、国家、语言和币种。因此语言、币种、类目和发布规则不能写死为 Ozon 或中文/俄文。
|
||||||
|
|
||||||
当前项目还没有业务代码、数据库、部署或依赖。不要从旧项目复制 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`,扩展 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. 已确认产品决策
|
||||||
|
|
||||||
@@ -54,7 +82,7 @@ User → Workspace → Membership / Role
|
|||||||
|
|
||||||
## 5. 已冻结的第一阶段 API 契约
|
## 5. 已冻结的第一阶段 API 契约
|
||||||
|
|
||||||
第一阶段 HTTP API 的接口范围已讨论完成,当前状态为**设计冻结、尚未实现**。后续编码以 [`docs/api/`](../api/README.md) 为唯一 HTTP 契约来源;旧 [`product/api.md`](../product/api.md) 仅作历史背景,不能作为实现依据。
|
第一阶段 HTTP API 的接口范围已讨论完成,当前状态为**设计冻结、业务接口尚未实现**。仓库已有 NestJS API 启动骨架及 `GET /health`,它不构成任何已实现的业务契约。后续编码以 [`docs/api/`](../api/README.md) 为唯一 HTTP 契约来源;旧 [`product/api.md`](../product/api.md) 仅作历史背景,不能作为实现依据。
|
||||||
|
|
||||||
- 覆盖账户认证、平台/门店、水印与素材、定价、浏览器采集/采集箱、商品编辑/首次发布、在线商品读取与同步。
|
- 覆盖账户认证、平台/门店、水印与素材、定价、浏览器采集/采集箱、商品编辑/首次发布、在线商品读取与同步。
|
||||||
- 所有接口遵循统一鉴权、工作空间隔离、页头平台上下文、金额字符串、错误格式、`Idempotency-Key`、`If-Match` 和异步 `operation` 约定。
|
- 所有接口遵循统一鉴权、工作空间隔离、页头平台上下文、金额字符串、错误格式、`Idempotency-Key`、`If-Match` 和异步 `operation` 约定。
|
||||||
@@ -65,13 +93,14 @@ User → Workspace → Membership / Role
|
|||||||
|
|
||||||
## 6. 下一步建议
|
## 6. 下一步建议
|
||||||
|
|
||||||
下一步进入项目初始化与实现准备,而不是继续扩大 API 范围:
|
项目初始化已完成;下一步进入实现准备,而不是继续扩大 API 范围:
|
||||||
|
|
||||||
1. 创建 NestJS 后端、React 前端、迁移工具、Redis/BullMQ 和本地 Docker Compose 骨架。
|
1. 以 `001_foundation.sql`、`002_product-domain.sql` 建立可重复执行的迁移执行器,并按数据库角色/RLS 约束验证本地环境。
|
||||||
2. 以 `001_foundation.sql`、`002_product-domain.sql` 建立可重复执行的迁移,并按数据库角色/RLS 约束验证本地环境。
|
2. 在 API 建立配置校验、认证、工作空间上下文和事务包装器;任何工作空间查询前固定 `SET LOCAL app.workspace_id`。
|
||||||
3. 在开始商品编辑/在线同步模块前,先关闭评审文档中的 P1 事项:同商品归属约束、未映射属性持久化、乐观锁 Repository 规则、平台能力 Schema;并确认在线商品归档策略和 Client ID 编辑边界。
|
3. 建立 `operations`、幂等请求和 Outbox Dispatcher 的第一个集成测试闭环。
|
||||||
4. 优先实现账户、平台上下文、门店、素材上传、操作任务、幂等/Outbox Dispatcher 的纵向最小闭环,再接入采集箱和商品编辑。
|
4. 在开始商品编辑/在线同步模块前,关闭评审文档中的 P1 事项:同商品归属约束、未映射属性持久化、乐观锁 Repository 规则、平台能力 Schema;并确认在线商品归档策略和 Client ID 编辑边界。
|
||||||
5. 为浏览器采集准备真实多 SKU 解析测试集,并继续限定 Ozon Seller 网页观察实验的字段口径,见 [`requirements/collector-browser-extension.md`](../requirements/collector-browser-extension.md)。
|
5. 优先实现账户、平台上下文、门店、素材上传的纵向最小闭环,再接入采集箱和商品编辑;浏览器扩展仅在采集 API 稳定后实现实际解析器。
|
||||||
|
6. Web 侧:认证 API 就绪后把登录/注册接入真实 `/api/auth/*`,补主布局路由守卫与刷新后的会话恢复,auth 类型迁移至 `@atlas/contracts`。
|
||||||
|
|
||||||
## 7. 文档导航
|
## 7. 文档导航
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 本地基础设施
|
||||||
|
|
||||||
|
复制根目录 `.env.example` 为 `.env` 后,执行 `pnpm infra:up` 启动 PostgreSQL、Redis 和 MinIO。镜像使用已验证的明确版本标签;升级前应先在本地或 staging 验证,再显式更新标签。
|
||||||
|
|
||||||
|
应用进程由 `pnpm dev` 在宿主机启动:Web 使用 Vite 热更新,API 与 Worker 使用各自的 watch 模式。生产环境的 API、Worker 和反向代理容器编排将在部署运行手册中补充;PostgreSQL 和对象存储继续遵守现有架构文档的托管服务边界。
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: atlas-seller-kit
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18.6-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-atlas_app_login}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me-local-only}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-atlas}
|
||||||
|
ports:
|
||||||
|
- '${POSTGRES_PORT:-5432}:5432'
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7.4.11-alpine3.21
|
||||||
|
ports:
|
||||||
|
- '6379:6379'
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'redis-cli', 'ping']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
|
||||||
|
command: server /data --console-address ':9001'
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-atlas_minio_local}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-change-me-local-only}
|
||||||
|
ports:
|
||||||
|
- '${MINIO_PORT:-9000}:9000'
|
||||||
|
- '${MINIO_CONSOLE_PORT:-9001}:9001'
|
||||||
|
volumes:
|
||||||
|
- minio-data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
minio-data:
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "atlas-seller-kit",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "pnpm@11.25.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.20.0 <25",
|
||||||
|
"pnpm": ">=11.25.0 <12"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "turbo run build",
|
||||||
|
"dev": "turbo run dev --parallel",
|
||||||
|
"lint": "turbo run lint",
|
||||||
|
"typecheck": "turbo run typecheck",
|
||||||
|
"test": "turbo run test",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"infra:up": "docker compose -f infra/compose.yaml up -d postgres redis minio",
|
||||||
|
"infra:down": "docker compose -f infra/compose.yaml down"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"prettier": "3.9.6",
|
||||||
|
"turbo": "2.10.12",
|
||||||
|
"typescript": "7.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "@atlas/contracts",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "4.5.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "7.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 浏览器扩展与 API 共享的采集载荷最小契约。
|
||||||
|
* 领域字段会随采集模块实现逐步补全,不能在扩展侧绕过此契约直接构造 API 请求。
|
||||||
|
*/
|
||||||
|
export const collectedProductPayloadSchema = z.object({
|
||||||
|
source: z.object({
|
||||||
|
platform: z.enum(['ozon', '1688', 'taobao', 'tmall']),
|
||||||
|
itemKey: z.string().min(1),
|
||||||
|
canonicalUrl: z.url(),
|
||||||
|
collectedAt: z.iso.datetime(),
|
||||||
|
parserVersion: z.string().min(1),
|
||||||
|
}),
|
||||||
|
common: z.object({
|
||||||
|
title: z.string().min(1).optional(),
|
||||||
|
}),
|
||||||
|
variantAxes: z.array(z.unknown()),
|
||||||
|
variants: z.array(z.unknown()),
|
||||||
|
assets: z.array(z.unknown()),
|
||||||
|
evidence: z.array(z.unknown()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CollectedProductPayload = z.infer<typeof collectedProductPayloadSchema>;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './collection';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# SQL migrations
|
||||||
|
|
||||||
|
此目录只保存按序号命名、不可修改历史的 SQL migration,例如 `001_foundation.sql`。
|
||||||
|
|
||||||
|
迁移执行器必须使用 `atlas_migrator` 连接;API 和 Worker 只允许使用 `atlas_app_login`。每个涉及工作空间数据的应用事务,都必须在查询前执行 `SET LOCAL app.workspace_id`。详细权限规则见 [`docs/architecture/database-security.md`](../../../docs/architecture/database-security.md)。
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "@atlas/database",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pg": "8.23.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "26.3.0",
|
||||||
|
"@types/pg": "8.23.1",
|
||||||
|
"typescript": "7.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* 数据库访问层的唯一入口。
|
||||||
|
* 后续在此封装迁移连接与应用连接,应用事务必须先设置 app.workspace_id。
|
||||||
|
*/
|
||||||
|
export const databasePackageName = '@atlas/database';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Generated
+4356
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
|||||||
|
packages:
|
||||||
|
- apps/*
|
||||||
|
- packages/*
|
||||||
|
|
||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
|
msgpackr-extract: false
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
singleQuote: true,
|
||||||
|
trailingComma: 'all',
|
||||||
|
printWidth: 100,
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://turbo.build/schema.json",
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": ["dist/**", ".output/**"]
|
||||||
|
},
|
||||||
|
"dev": {
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"dependsOn": ["^lint"]
|
||||||
|
},
|
||||||
|
"typecheck": {
|
||||||
|
"dependsOn": ["^typecheck"],
|
||||||
|
"outputs": []
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": ["coverage/**"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user