Compare commits
10
Commits
989120cab8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b7d1e8f64 | ||
|
|
2336d345c0 | ||
|
|
dc4da05083 | ||
|
|
d07ada995f | ||
|
|
7cf86b3735 | ||
|
|
12c8a852a1 | ||
|
|
0edd4c8969 | ||
|
|
cbb5cfd167 | ||
|
|
d027eba044 | ||
|
|
cd5a385cda |
+7
-4
@@ -7,7 +7,7 @@ WORKDIR /app
|
|||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
# 安装依赖
|
# 安装依赖
|
||||||
RUN npm install
|
RUN npm install --registry=https://registry.npmmirror.com
|
||||||
|
|
||||||
# 复制源码
|
# 复制源码
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -21,9 +21,12 @@ FROM nginx:alpine
|
|||||||
# 将构建产物复制到 Nginx 默认静态目录
|
# 将构建产物复制到 Nginx 默认静态目录
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
# 自定义 Nginx 配置:支持 SPA 路由 fallback + gzip
|
# Nginx 配置模板(支持 envsubst 环境变量替换)
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/templates/default.conf.template
|
||||||
|
|
||||||
EXPOSE 80
|
# 默认后端 API 地址(运行时可通过 -e BACKEND_URL=... 覆盖)
|
||||||
|
ENV BACKEND_URL=http://10.100.154.100:8000
|
||||||
|
|
||||||
|
EXPOSE 8901
|
||||||
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# ── 配置项 ──────────────────────────────────────────
|
||||||
|
IMAGE_NAME="ai-platform:1.0.0"
|
||||||
|
CONTAINER_NAME="ai-platform"
|
||||||
|
PORT=8901
|
||||||
|
BACKEND_URL="${BACKEND_URL:-http://host.docker.internal:8000}"
|
||||||
|
# ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo ">>> 构建镜像 ${IMAGE_NAME} ..."
|
||||||
|
sudo docker build -t "$IMAGE_NAME" .
|
||||||
|
|
||||||
|
echo ">>> 停止并移除旧容器 ..."
|
||||||
|
sudo docker stop "$CONTAINER_NAME" 2>/dev/null || true
|
||||||
|
sudo docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ">>> 启动新容器 ..."
|
||||||
|
sudo docker run -d \
|
||||||
|
--name "$CONTAINER_NAME" \
|
||||||
|
-p "$PORT:8901" \
|
||||||
|
--add-host=host.docker.internal:host-gateway \
|
||||||
|
-e BACKEND_URL="$BACKEND_URL" \
|
||||||
|
"$IMAGE_NAME"
|
||||||
|
|
||||||
|
echo ">>> 等待 nginx 启动 ..."
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
echo ">>> 验证 ..."
|
||||||
|
sudo docker exec "$CONTAINER_NAME" curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:8901/
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ">>> 部署完成: http://localhost:$PORT"
|
||||||
|
echo ">>> 后端代理: $BACKEND_URL"
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# AI Platform 打包部署指南
|
||||||
|
|
||||||
|
## 项目概况
|
||||||
|
|
||||||
|
| 项目 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 项目名称 | ai-platform |
|
||||||
|
| 前端框架 | React 18 + TypeScript + Vite 8 |
|
||||||
|
| UI 库 | Ant Design 5 + Tailwind CSS 3 |
|
||||||
|
| 构建产物 | 静态文件(`dist/`),由 nginx 提供服务 |
|
||||||
|
| 容器化 | Docker 多阶段构建(builder → nginx:alpine) |
|
||||||
|
| 部署端口 | 8901 |
|
||||||
|
| 后端代理 | `http://host.docker.internal:8000`(可运行时覆盖) |
|
||||||
|
|
||||||
|
## 目录结构要点
|
||||||
|
|
||||||
|
```
|
||||||
|
ai-platform/
|
||||||
|
├── Dockerfile # 多阶段构建:node:20-alpine 构建 → nginx:alpine 运行
|
||||||
|
├── nginx.conf # nginx 配置模板(支持 ${BACKEND_URL} 环境变量替换)
|
||||||
|
├── .dockerignore # 排除 node_modules / dist / .git / *.md 等
|
||||||
|
├── package.json # build 脚本:tsc -b && vite build
|
||||||
|
├── vite.config.ts # 开发代理配置(/api/v1, /health, /bexapi, /iam-api)
|
||||||
|
├── index.html # 入口 HTML
|
||||||
|
├── src/ # 源码
|
||||||
|
├── public/ # 静态资源
|
||||||
|
└── docs/ # 文档(本文件所在目录)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker 镜像构建
|
||||||
|
|
||||||
|
### Dockerfile 构建流程
|
||||||
|
|
||||||
|
1. **Stage 1 — builder(node:20-alpine)**
|
||||||
|
- 复制 `package.json` + `package-lock.json`,执行 `npm install`
|
||||||
|
- 复制全部源码,执行 `npm run build`(即 `tsc -b && vite build`)
|
||||||
|
- 产物输出到 `/app/dist`
|
||||||
|
|
||||||
|
2. **Stage 2 — 运行时(nginx:alpine)**
|
||||||
|
- 将 builder 阶段的 `/app/dist` 复制到 `/usr/share/nginx/html`
|
||||||
|
- 复制 `nginx.conf` 到 `/etc/nginx/templates/default.conf.template`(nginx 入口脚本会通过 `envsubst` 替换其中的 `${BACKEND_URL}`)
|
||||||
|
- 默认环境变量 `BACKEND_URL=http://host.docker.internal:8000`
|
||||||
|
- 暴露端口 8901
|
||||||
|
|
||||||
|
### 构建命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker build -t ai-platform:1.0.0 .
|
||||||
|
```
|
||||||
|
|
||||||
|
构建过程利用了 Docker 缓存层:如果 `package.json` / `package-lock.json` 未变,`npm install` 层会命中缓存,仅重新执行源码复制和构建。
|
||||||
|
|
||||||
|
## 容器部署
|
||||||
|
|
||||||
|
### 启动命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker run -d \
|
||||||
|
--name ai-platform \
|
||||||
|
-p 8901:8901 \
|
||||||
|
--add-host=host.docker.internal:host-gateway \
|
||||||
|
-e BACKEND_URL=http://host.docker.internal:8000 \
|
||||||
|
ai-platform:1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数说明
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `-d` | 后台运行 |
|
||||||
|
| `--name ai-platform` | 容器名称 |
|
||||||
|
| `-p 8901:8901` | 宿主机 8901 端口映射到容器 8901 |
|
||||||
|
| `--add-host=host.docker.internal:host-gateway` | **必须添加**,否则 nginx 启动时无法解析 `host.docker.internal`,容器会立即退出(exit code 1) |
|
||||||
|
| `-e BACKEND_URL=...` | 后端 API 地址,运行时可覆盖。默认 `http://host.docker.internal:8000` |
|
||||||
|
|
||||||
|
> **踩坑记录**:Docker 29.x 版本中,`host.docker.internal` 不会自动解析,必须通过 `--add-host=host.docker.internal:host-gateway` 显式添加。如果不加此参数,nginx 启动报错:`host not found in upstream "host.docker.internal"`,容器退出码为 1。
|
||||||
|
|
||||||
|
### 重新部署(完整流程)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 构建新镜像
|
||||||
|
sudo docker build -t ai-platform:1.0.0 .
|
||||||
|
|
||||||
|
# 2. 停止并移除旧容器
|
||||||
|
sudo docker stop ai-platform
|
||||||
|
sudo docker rm ai-platform
|
||||||
|
|
||||||
|
# 3. 启动新容器
|
||||||
|
sudo docker run -d \
|
||||||
|
--name ai-platform \
|
||||||
|
-p 8901:8901 \
|
||||||
|
--add-host=host.docker.internal:host-gateway \
|
||||||
|
-e BACKEND_URL=http://host.docker.internal:8000 \
|
||||||
|
ai-platform:1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验证部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查容器运行状态
|
||||||
|
sudo docker ps --filter "name=ai-platform"
|
||||||
|
|
||||||
|
# 从容器内部验证 nginx 响应
|
||||||
|
sudo docker exec ai-platform curl -s -o /dev/null -w "HTTP %{http_code}" http://localhost:8901/
|
||||||
|
# 预期输出:HTTP 200
|
||||||
|
|
||||||
|
# 查看页面内容
|
||||||
|
sudo docker exec ai-platform curl -s http://localhost:8901/
|
||||||
|
|
||||||
|
# 查看 nginx 日志
|
||||||
|
sudo docker logs ai-platform
|
||||||
|
```
|
||||||
|
|
||||||
|
## nginx 配置说明
|
||||||
|
|
||||||
|
`nginx.conf` 作为模板文件放在 `/etc/nginx/templates/default.conf.template`,nginx 容器入口脚本会自动用 `envsubst` 将 `${BACKEND_URL}` 替换为环境变量值,输出到 `/etc/nginx/conf.d/default.conf`。
|
||||||
|
|
||||||
|
### 路由规则
|
||||||
|
|
||||||
|
| 路径 | 代理目标 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `/api/v1/` | `${BACKEND_URL}/api/v1/` | 后端 API,支持 SSE 长连接(关闭 buffering,300s 超时) |
|
||||||
|
| `/health` | `${BACKEND_URL}/health` | 健康检查 |
|
||||||
|
| `/bexapi/` | `https://bexell-scheduling.dongsk.top/api/` | Bexell 排程 API |
|
||||||
|
| `/iam-api/` | `https://iam.digiwincloud.com.cn/` | 鼎捷云 IAM 登录服务 |
|
||||||
|
| `/` | `try_files $uri $uri/ /index.html` | SPA 路由 fallback |
|
||||||
|
| `/assets/` | 本地静态文件 | 长缓存 1 年,`Cache-Control: public, immutable` |
|
||||||
|
| `*.svg/png/jpg/...` | 本地静态文件 | 缓存 30 天 |
|
||||||
|
|
||||||
|
### 开发环境 vs 生产环境代理对照
|
||||||
|
|
||||||
|
开发时由 Vite dev server 代理(`vite.config.ts`),生产时由 nginx 代理(`nginx.conf`),两者路径需保持一致:
|
||||||
|
|
||||||
|
| 路径 | Vite target | nginx proxy_pass |
|
||||||
|
|------|------------|-------------------|
|
||||||
|
| `/api/v1` | `http://localhost:8000` | `${BACKEND_URL}/api/v1/` |
|
||||||
|
| `/health` | `http://localhost:8000` | `${BACKEND_URL}/health` |
|
||||||
|
| `/bexapi` | `https://bexell-scheduling.dongsk.top`(rewrite → `/api`) | `https://bexell-scheduling.dongsk.top/api/` |
|
||||||
|
| `/iam-api` | `https://iam.digiwincloud.com.cn`(rewrite → 去前缀) | `https://iam.digiwincloud.com.cn/` |
|
||||||
|
|
||||||
|
## 常见问题排查
|
||||||
|
|
||||||
|
### 容器启动后立即退出(exit code 1)
|
||||||
|
|
||||||
|
**原因**:nginx 无法解析 `host.docker.internal`。
|
||||||
|
|
||||||
|
**解决**:启动时添加 `--add-host=host.docker.internal:host-gateway`。
|
||||||
|
|
||||||
|
**验证**:`sudo docker logs ai-platform` 查看是否有 `host not found in upstream` 错误。
|
||||||
|
|
||||||
|
### 构建时 TypeScript 编译失败
|
||||||
|
|
||||||
|
`npm run build` 先执行 `tsc -b` 类型检查,类型错误会导致构建中断。需修复所有 TS 错误后重新构建。
|
||||||
|
|
||||||
|
### 构建产物体积过大
|
||||||
|
|
||||||
|
当前主 chunk 约 1.9MB(gzip 后 581KB),Vite 会输出警告。如需优化:
|
||||||
|
|
||||||
|
- 使用动态 `import()` 对路由模块做懒加载(代码分割)
|
||||||
|
- 在 `vite.config.ts` 中配置 `build.rollupOptions.output.manualChunks` 手动分包
|
||||||
|
- 调整 `build.chunkSizeWarningLimit` 提高警告阈值(仅消除警告,不减小体积)
|
||||||
|
|
||||||
|
### 端口被占用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看占用 8901 端口的进程
|
||||||
|
sudo lsof -i :8901
|
||||||
|
|
||||||
|
# 停止旧容器后释放端口
|
||||||
|
sudo docker stop ai-platform
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速部署脚本
|
||||||
|
|
||||||
|
将以下内容保存为 `deploy.sh` 即可一键重新打包部署:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
IMAGE_NAME="ai-platform:1.0.0"
|
||||||
|
CONTAINER_NAME="ai-platform"
|
||||||
|
PORT=8901
|
||||||
|
BACKEND_URL="${BACKEND_URL:-http://host.docker.internal:8000}"
|
||||||
|
|
||||||
|
echo ">>> 构建镜像..."
|
||||||
|
sudo docker build -t "$IMAGE_NAME" .
|
||||||
|
|
||||||
|
echo ">>> 停止旧容器..."
|
||||||
|
sudo docker stop "$CONTAINER_NAME" 2>/dev/null || true
|
||||||
|
sudo docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ">>> 启动新容器..."
|
||||||
|
sudo docker run -d \
|
||||||
|
--name "$CONTAINER_NAME" \
|
||||||
|
-p "$PORT:8901" \
|
||||||
|
--add-host=host.docker.internal:host-gateway \
|
||||||
|
-e BACKEND_URL="$BACKEND_URL" \
|
||||||
|
"$IMAGE_NAME"
|
||||||
|
|
||||||
|
echo ">>> 验证..."
|
||||||
|
sleep 2
|
||||||
|
sudo docker exec "$CONTAINER_NAME" curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:8901/
|
||||||
|
|
||||||
|
echo ">>> 部署完成: http://localhost:$PORT"
|
||||||
|
```
|
||||||
+51
-1
@@ -1,5 +1,5 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 8901;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
@@ -10,6 +10,56 @@ server {
|
|||||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
|
||||||
gzip_min_length 256;
|
gzip_min_length 256;
|
||||||
|
|
||||||
|
# 后端 API 反向代理(支持 SSE 长连接)
|
||||||
|
location /api/v1/ {
|
||||||
|
proxy_pass ${BACKEND_URL}/api/v1/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# SSE 支持
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
|
||||||
|
# 禁止浏览器缓存 API 响应
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||||
|
add_header Pragma "no-cache" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
location /health {
|
||||||
|
proxy_pass ${BACKEND_URL}/health;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bexell 排程 API 反向代理
|
||||||
|
location /bexapi/ {
|
||||||
|
proxy_pass https://ai-workshop.digiwincloud.com.cn/bexell-backend/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 鼎捷云 IAM 登录服务反向代理(与 vite.config.ts 中 /iam-api 代理一致)
|
||||||
|
location /iam-api/ {
|
||||||
|
proxy_pass https://iam.digiwincloud.com.cn/;
|
||||||
|
proxy_set_header Host iam.digiwincloud.com.cn;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
|
||||||
|
# 禁止浏览器缓存 API 响应
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||||
|
add_header Pragma "no-cache" always;
|
||||||
|
}
|
||||||
|
|
||||||
# SPA 路由 fallback:所有非文件请求回退到 index.html
|
# SPA 路由 fallback:所有非文件请求回退到 index.html
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
+11
-10
@@ -2,6 +2,7 @@ import { Routes, Route } from 'react-router-dom';
|
|||||||
import HomePage from '@/pages/HomePage';
|
import HomePage from '@/pages/HomePage';
|
||||||
import SSOLogin from '@/pages/SSOLogin';
|
import SSOLogin from '@/pages/SSOLogin';
|
||||||
import LoginPage from '@/pages/LoginPage';
|
import LoginPage from '@/pages/LoginPage';
|
||||||
|
import RequireAuth from '@/shared/components/RequireAuth';
|
||||||
import AIMCockpit from '@/modules/ai-matching/pages/AIMCockpit';
|
import AIMCockpit from '@/modules/ai-matching/pages/AIMCockpit';
|
||||||
import CostCockpit from '@/modules/cost-analysis/pages/CostCockpit';
|
import CostCockpit from '@/modules/cost-analysis/pages/CostCockpit';
|
||||||
import KnowledgeGraphHome from '@/modules/knowledge-graph/pages/KnowledgeGraphHome';
|
import KnowledgeGraphHome from '@/modules/knowledge-graph/pages/KnowledgeGraphHome';
|
||||||
@@ -15,18 +16,18 @@ import SanruiAIDesignHome from '@/modules/sanrui-ai-design/pages/SanruiAIDesignH
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/sso-login" element={<SSOLogin />} />
|
<Route path="/sso-login" element={<SSOLogin />} />
|
||||||
<Route path="/ai-matching" element={<AIMCockpit />} />
|
<Route path="/" element={<RequireAuth><HomePage /></RequireAuth>} />
|
||||||
<Route path="/cost-analysis" element={<CostCockpit />} />
|
<Route path="/ai-matching" element={<RequireAuth><AIMCockpit /></RequireAuth>} />
|
||||||
<Route path="/knowledge-graph" element={<KnowledgeGraphHome />} />
|
<Route path="/cost-analysis" element={<RequireAuth><CostCockpit /></RequireAuth>} />
|
||||||
<Route path="/knowledge-graph/space" element={<SpaceCockpit />} />
|
<Route path="/knowledge-graph" element={<RequireAuth><KnowledgeGraphHome /></RequireAuth>} />
|
||||||
<Route path="/bomx-reuse-analyzer" element={<BomxReuseHome />} />
|
<Route path="/knowledge-graph/space" element={<RequireAuth><SpaceCockpit /></RequireAuth>} />
|
||||||
<Route path="/order-cost-analysis" element={<CostCockpitFull />} />
|
<Route path="/bomx-reuse-analyzer" element={<RequireAuth><BomxReuseHome /></RequireAuth>} />
|
||||||
<Route path="/order-cost-analysis/aim" element={<AIMCockpitFull />} />
|
<Route path="/order-cost-analysis" element={<RequireAuth><CostCockpitFull /></RequireAuth>} />
|
||||||
<Route path="/bexell-scheduling-ai/*" element={<BexellSchedulingApp />} />
|
<Route path="/order-cost-analysis/aim" element={<RequireAuth><AIMCockpitFull /></RequireAuth>} />
|
||||||
<Route path="/sanrui-ai-design" element={<SanruiAIDesignHome />} />
|
<Route path="/bexell-scheduling-ai/*" element={<RequireAuth><BexellSchedulingApp /></RequireAuth>} />
|
||||||
|
<Route path="/sanrui-ai-design" element={<RequireAuth><SanruiAIDesignHome /></RequireAuth>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import { APP_TOKEN, getUserToken } from '@/shared/services/iamAuth'
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: '/bexapi',
|
baseURL: '/bexapi',
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 请求拦截器:为 /bexapi 请求注入 IAM 鉴权头
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
config.headers['digi-middleware-auth-app'] = APP_TOKEN
|
||||||
|
const userToken = getUserToken()
|
||||||
|
if (userToken) {
|
||||||
|
config.headers['digi-middleware-auth-user'] = userToken
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
// 系统参数
|
// 系统参数
|
||||||
export const sysParameterApi = {
|
export const sysParameterApi = {
|
||||||
list: () => api.get('/sys-parameters/'),
|
list: () => api.get('/sys-parameters/'),
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function AIMHeader() {
|
|||||||
letterSpacing: '0.05em', margin: 0,
|
letterSpacing: '0.05em', margin: 0,
|
||||||
textShadow: '0 0 24px rgba(0,212,255,0.7), 0 0 48px rgba(0,212,255,0.3)',
|
textShadow: '0 0 24px rgba(0,212,255,0.7), 0 0 48px rgba(0,212,255,0.3)',
|
||||||
}}>
|
}}>
|
||||||
温州市“AI+制造”智创空间
|
AI+成本智能分析平台
|
||||||
</h1>
|
</h1>
|
||||||
<span style={{
|
<span style={{
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
|||||||
@@ -74,17 +74,27 @@ export default function CostHeader({ theme = 'dark', onToggleTheme, orders }: Co
|
|||||||
{/* Logo + 标题 */}
|
{/* Logo + 标题 */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flex: 1, position: 'relative', zIndex: 1 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flex: 1, position: 'relative', zIndex: 1 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
height: 50, borderRadius: 8,
|
height: 50, borderRadius: 10,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
background: isLight ? '#ffffff' : 'rgba(235,242,255,0.95)',
|
background: isLight ? 'rgba(255,255,255,0.55)' : 'rgba(255,255,255,0.08)',
|
||||||
|
border: `1px solid ${isLight ? 'rgba(255,255,255,0.7)' : 'rgba(255,255,255,0.15)'}`,
|
||||||
padding: '0 10px',
|
padding: '0 10px',
|
||||||
boxShadow: '0 2px 10px rgba(0,0,0,0.15)',
|
backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)',
|
||||||
|
boxShadow: isLight
|
||||||
|
? '0 2px 12px rgba(0,0,0,0.08)'
|
||||||
|
: '0 0 20px rgba(0,212,255,0.08), inset 0 0 12px rgba(255,255,255,0.04)',
|
||||||
}}>
|
}}>
|
||||||
<img
|
<img
|
||||||
src="/regal-rexnord-logo.png"
|
src="/regal-rexnord-logo.png"
|
||||||
alt="Regal Rexnord"
|
alt="Regal Rexnord"
|
||||||
style={{ height: '92%', objectFit: 'contain' }}
|
style={{
|
||||||
|
height: '86%', objectFit: 'contain',
|
||||||
|
filter: isLight
|
||||||
|
? 'brightness(0.25) contrast(1.1)'
|
||||||
|
: 'brightness(0) invert(1) drop-shadow(0 0 5px rgba(255,255,255,0.45))',
|
||||||
|
opacity: isLight ? 0.9 : 0.95,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -94,7 +104,7 @@ export default function CostHeader({ theme = 'dark', onToggleTheme, orders }: Co
|
|||||||
letterSpacing: '0.05em', margin: 0,
|
letterSpacing: '0.05em', margin: 0,
|
||||||
textShadow: tokens.titleGlow,
|
textShadow: tokens.titleGlow,
|
||||||
}}>
|
}}>
|
||||||
订单成本枢纽平台
|
AI+成本智能分析平台
|
||||||
</h1>
|
</h1>
|
||||||
<span style={{
|
<span style={{
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
@@ -116,48 +126,52 @@ export default function CostHeader({ theme = 'dark', onToggleTheme, orders }: Co
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 分隔线 */}
|
{/* 分隔线 */}
|
||||||
<div style={{ width: 1, height: 44, background: `linear-gradient(to bottom, transparent, ${tokens.accent}33, transparent)`, flexShrink: 0, marginRight: 24, position: 'relative', zIndex: 1 }} />
|
<div style={{ width: 1, height: 44, background: `linear-gradient(to bottom, transparent, ${tokens.accent}33, transparent)`, flexShrink: 0, marginRight: 20, position: 'relative', zIndex: 1 }} />
|
||||||
|
|
||||||
{/* 统计指标 */}
|
{/* 统计指标 */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flex: 1, justifyContent: 'center', position: 'relative', zIndex: 1, marginTop: 24 }}>
|
<div style={{
|
||||||
{stats.map((item, idx) => (
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
||||||
<div key={item.label} style={{ display: 'flex', alignItems: 'center' }}>
|
flex: 1, position: 'relative', zIndex: 1,
|
||||||
<div style={{
|
}}>
|
||||||
textAlign: 'center', padding: '6px 16px', borderRadius: 10,
|
{stats.map((item) => (
|
||||||
background: tokens.kpiCardBg, border: `1px solid ${tokens.kpiCardBorder}`,
|
<div key={item.label} style={{
|
||||||
backdropFilter: 'blur(8px)',
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
}}>
|
padding: '7px 14px', borderRadius: 20,
|
||||||
<div style={{ color: item.color, fontSize: 24, fontWeight: 800, lineHeight: 1, textShadow: isLight ? 'none' : `0 0 12px ${item.color}80` }}>
|
background: isLight ? 'rgba(255,255,255,0.42)' : 'rgba(0,20,50,0.32)',
|
||||||
|
border: `1px solid ${isLight ? 'rgba(0,0,0,0.06)' : tokens.accent + '18'}`,
|
||||||
|
backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)',
|
||||||
|
boxShadow: isLight ? '0 1px 4px rgba(0,0,0,0.04)' : '0 2px 10px rgba(0,0,0,0.15)',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: 7, height: 7, borderRadius: '50%', flexShrink: 0,
|
||||||
|
background: item.color,
|
||||||
|
boxShadow: `0 0 8px ${item.color}`,
|
||||||
|
}} />
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 5 }}>
|
||||||
|
<span style={{
|
||||||
|
color: item.color, fontSize: 17, fontWeight: 800, lineHeight: 1,
|
||||||
|
textShadow: isLight ? 'none' : `0 0 10px ${item.color}80`,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}>
|
||||||
{item.value}
|
{item.value}
|
||||||
</div>
|
</span>
|
||||||
<div style={{ color: isLight ? tokens.textSub : tokens.textMuted, fontSize: 13, marginTop: 4, letterSpacing: '0.04em', fontWeight: 600 }}>
|
<span style={{
|
||||||
|
color: isLight ? tokens.textSub : tokens.textMuted,
|
||||||
|
fontSize: 12, fontWeight: 500, whiteSpace: 'nowrap', letterSpacing: '0.02em',
|
||||||
|
}}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{idx < stats.length - 1 && <div style={{ width: 1, height: 36, background: `${tokens.accent}18`, margin: '0 3px' }} />}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右分隔线 */}
|
{/* 右分隔线 */}
|
||||||
<div style={{ width: 1, height: 44, background: `linear-gradient(to bottom, transparent, ${tokens.accent}33, transparent)`, flexShrink: 0, marginLeft: 24, position: 'relative', zIndex: 1 }} />
|
<div style={{ width: 1, height: 44, background: `linear-gradient(to bottom, transparent, ${tokens.accent}33, transparent)`, flexShrink: 0, marginLeft: 20, position: 'relative', zIndex: 1 }} />
|
||||||
|
|
||||||
{/* 时间 + 切换按钮 */}
|
{/* 时间 + 切换按钮 */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flex: 1, justifyContent: 'flex-end', position: 'relative', zIndex: 1 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flex: 1, justifyContent: 'flex-end', position: 'relative', zIndex: 1 }}>
|
||||||
<button
|
|
||||||
onClick={() => navigate('/')}
|
|
||||||
style={{
|
|
||||||
padding: '6px 12px', borderRadius: 8, cursor: 'pointer',
|
|
||||||
border: `1px solid ${tokens.accent}55`,
|
|
||||||
background: tokens.accentBg,
|
|
||||||
color: tokens.accent, fontSize: 12, fontWeight: 600,
|
|
||||||
transition: 'all 0.18s ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { e.currentTarget.style.background = `${tokens.accent}18`; e.currentTarget.style.borderColor = tokens.accent; }}
|
|
||||||
onMouseLeave={e => { e.currentTarget.style.background = tokens.accentBg; e.currentTarget.style.borderColor = `${tokens.accent}55`; }}
|
|
||||||
>
|
|
||||||
← 返回平台
|
|
||||||
</button>
|
|
||||||
<div style={{ textAlign: 'right' }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
<div style={{ color: tokens.accent, fontSize: 20, fontWeight: 700, fontVariantNumeric: 'tabular-nums', lineHeight: 1, textShadow: isLight ? 'none' : `0 0 16px ${tokens.accentGlow}`, letterSpacing: '0.05em' }}>
|
<div style={{ color: tokens.accent, fontSize: 20, fontWeight: 700, fontVariantNumeric: 'tabular-nums', lineHeight: 1, textShadow: isLight ? 'none' : `0 0 16px ${tokens.accentGlow}`, letterSpacing: '0.05em' }}>
|
||||||
{timeStr}
|
{timeStr}
|
||||||
@@ -170,6 +184,22 @@ export default function CostHeader({ theme = 'dark', onToggleTheme, orders }: Co
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 返回平台按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px', borderRadius: 8, cursor: 'pointer',
|
||||||
|
border: `1px solid ${isLight ? 'rgba(0,80,180,0.4)' : 'rgba(0,212,255,0.55)'}`,
|
||||||
|
background: isLight ? 'rgba(0,80,180,0.06)' : 'rgba(0,212,255,0.08)',
|
||||||
|
color: isLight ? '#1e3a6e' : '#00d4ff', fontSize: 12, fontWeight: 600,
|
||||||
|
transition: 'all 0.18s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = isLight ? 'rgba(0,80,180,0.12)' : 'rgba(0,212,255,0.18)'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = isLight ? 'rgba(0,80,180,0.06)' : 'rgba(0,212,255,0.08)'; }}
|
||||||
|
>
|
||||||
|
← 返回平台
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* 切换风格按钮 */}
|
{/* 切换风格按钮 */}
|
||||||
{onToggleTheme && (
|
{onToggleTheme && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { ModuleConfig } from '../registry';
|
|||||||
|
|
||||||
export const moduleConfig: ModuleConfig = {
|
export const moduleConfig: ModuleConfig = {
|
||||||
id: 'order-cost-analysis',
|
id: 'order-cost-analysis',
|
||||||
name: '订单成本枢纽平台',
|
name: 'AI+成本智能分析平台',
|
||||||
description: '雷勃动力传动订单成本智能分析:理论成本/实际模拟/成本对比/异常归因',
|
description: '雷勃动力传动订单成本智能分析:理论成本/实际模拟/成本对比/异常归因',
|
||||||
icon: 'ReceiptText',
|
icon: 'ReceiptText',
|
||||||
path: '/order-cost-analysis',
|
path: '/order-cost-analysis',
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export default function CostCockpit() {
|
|||||||
<div style={{ width: 20, flexShrink: 0 }} />
|
<div style={{ width: 20, flexShrink: 0 }} />
|
||||||
|
|
||||||
{/* 中央:智能成本分析 */}
|
{/* 中央:智能成本分析 */}
|
||||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', position: 'relative', marginTop: 36 }}>
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', position: 'relative', marginTop: 80 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
flex: 1, borderRadius: 16, overflow: 'hidden',
|
flex: 1, borderRadius: 16, overflow: 'hidden',
|
||||||
border: `1px solid ${tokens.border}`,
|
border: `1px solid ${tokens.border}`,
|
||||||
|
|||||||
@@ -24,14 +24,6 @@ export default function HomePage() {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [userInfo, setUserInfo] = useState<SessionUserInfo | null>(() => getUserInfo());
|
const [userInfo, setUserInfo] = useState<SessionUserInfo | null>(() => getUserInfo());
|
||||||
|
|
||||||
// 登录守卫:未登录跳转 /login
|
|
||||||
useEffect(() => {
|
|
||||||
const token = sessionStorage.getItem('userToken');
|
|
||||||
if (!token) {
|
|
||||||
navigate('/login', { replace: true });
|
|
||||||
}
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
// 首页模块多时需纵向滚动;离开后恢复全局 overflow:hidden(其他模块自管布局)
|
// 首页模块多时需纵向滚动;离开后恢复全局 overflow:hidden(其他模块自管布局)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.style.overflow = 'auto';
|
document.body.style.overflow = 'auto';
|
||||||
|
|||||||
+16
-9
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { User, Lock, LogIn, AlertCircle, Loader2 } from 'lucide-react';
|
import { User, Lock, LogIn, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
import StarBackground from '@/shared/components/StarBackground';
|
import StarBackground from '@/shared/components/StarBackground';
|
||||||
import { iamLogin, saveSession } from '@/shared/services/iamAuth';
|
import { iamLogin, saveSession } from '@/shared/services/iamAuth';
|
||||||
@@ -28,6 +28,7 @@ const iconStyle = (isDark: boolean): React.CSSProperties => ({
|
|||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
@@ -52,7 +53,9 @@ export default function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
const result = await iamLogin({ userId: userId.trim(), password });
|
const result = await iamLogin({ userId: userId.trim(), password });
|
||||||
saveSession(result.token, result.userInfo);
|
saveSession(result.token, result.userInfo);
|
||||||
navigate('/', { replace: true });
|
// 回跳原页面(RequireAuth 记录的 from),否则去首页
|
||||||
|
const from = (location.state as { from?: string })?.from ?? '/';
|
||||||
|
navigate(from, { replace: true });
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
setError(ex instanceof Error ? ex.message : '登录失败,请稍后重试');
|
setError(ex instanceof Error ? ex.message : '登录失败,请稍后重试');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -92,12 +95,16 @@ export default function LoginPage() {
|
|||||||
}}>
|
}}>
|
||||||
{/* 品牌区 */}
|
{/* 品牌区 */}
|
||||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||||
<img
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
|
||||||
src="/digiwin.svg"
|
<img
|
||||||
alt="Digiwin"
|
src="/digiwin.svg"
|
||||||
style={{ height: 36, objectFit: 'contain', filter: isDark ? 'drop-shadow(0 0 8px rgba(0,212,255,0.4))' : 'none' }}
|
alt="Digiwin"
|
||||||
/>
|
style={{ height: 36, objectFit: 'contain', filter: isDark ? 'drop-shadow(0 0 8px rgba(0,212,255,0.4))' : 'none' }}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10, marginTop: 14 }}>
|
/>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 26, fontWeight: 800, lineHeight: 1,
|
||||||
|
...(isDark ? { color: 'rgba(0,212,255,0.5)' } : { color: 'rgba(30,58,110,0.3)' }),
|
||||||
|
}}>·</span>
|
||||||
<h1 style={{
|
<h1 style={{
|
||||||
fontSize: 26, fontWeight: 800, letterSpacing: '0.04em', margin: 0,
|
fontSize: 26, fontWeight: 800, letterSpacing: '0.04em', margin: 0,
|
||||||
...(isDark
|
...(isDark
|
||||||
@@ -107,7 +114,7 @@ export default function LoginPage() {
|
|||||||
EIOSpace
|
EIOSpace
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: 13, marginTop: 6, ...(isDark ? { color: 'rgba(168,216,255,0.6)' } : { color: '#6b7280' }) }}>
|
<p style={{ fontSize: 13, marginTop: 14, ...(isDark ? { color: 'rgba(168,216,255,0.6)' } : { color: '#6b7280' }) }}>
|
||||||
企业智能 · 数据驱动 · 产业赋能
|
企业智能 · 数据驱动 · 产业赋能
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const SSOLogin: React.FC = () => {
|
|||||||
setError('URL 缺少 userToken 参数');
|
setError('URL 缺少 userToken 参数');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const routerLink = searchParams.get('routerLink');
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -34,7 +35,7 @@ const SSOLogin: React.FC = () => {
|
|||||||
const result = await iamSsoLogin(userToken);
|
const result = await iamSsoLogin(userToken);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
saveSession(result.token, result.userInfo);
|
saveSession(result.token, result.userInfo);
|
||||||
navigate('/', { replace: true });
|
navigate(routerLink || '/', { replace: true });
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setError(ex instanceof Error ? ex.message : 'SSO 登录失败');
|
setError(ex instanceof Error ? ex.message : 'SSO 登录失败');
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { ReactNode, useEffect, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { Spin } from 'antd';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录守卫:未登录跳转 /login
|
||||||
|
*
|
||||||
|
* 检查 sessionStorage 中的 userToken,若不存在则跳转登录页。
|
||||||
|
* 登录后会带上 from state,便于登录成功后回跳原页面。
|
||||||
|
*/
|
||||||
|
export default function RequireAuth({ children }: { children: ReactNode }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [checked, setChecked] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = sessionStorage.getItem('userToken');
|
||||||
|
if (!token) {
|
||||||
|
// 记录原路径,登录后可回跳
|
||||||
|
navigate('/login', { replace: true, state: { from: location.pathname + location.search } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setChecked(true);
|
||||||
|
}, [navigate, location]);
|
||||||
|
|
||||||
|
if (!checked) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100vw', height: '100vh',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: '#0a1628',
|
||||||
|
}}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
+1
-1
@@ -21,7 +21,7 @@ export default defineConfig({
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/bexapi': {
|
'/bexapi': {
|
||||||
target: 'https://bexell-scheduling.dongsk.top',
|
target: 'https://ai-workshop.digiwincloud.com.cn/bexell-backend',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (p) => p.replace(/^\/bexapi/, '/api'),
|
rewrite: (p) => p.replace(/^\/bexapi/, '/api'),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user