Compare commits
12
Commits
4de68f78cd
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b7d1e8f64 | ||
|
|
2336d345c0 | ||
|
|
dc4da05083 | ||
|
|
d07ada995f | ||
|
|
7cf86b3735 | ||
|
|
12c8a852a1 | ||
|
|
0edd4c8969 | ||
|
|
cbb5cfd167 | ||
|
|
d027eba044 | ||
|
|
cd5a385cda | ||
|
|
989120cab8 | ||
|
|
2d19c736a6 |
+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;
|
||||||
|
|||||||
Generated
+7
@@ -14,6 +14,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"jsencrypt": "^3.5.4",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -1633,6 +1634,12 @@
|
|||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/jsencrypt": {
|
||||||
|
"version": "3.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsencrypt/-/jsencrypt-3.5.4.tgz",
|
||||||
|
"integrity": "sha512-kNjfYEMNASxrDGsmcSQh/rUTmcoRfSUkxnAz+MMywM8jtGu+fFEZ3nJjHM58zscVnwR0fYmG9sGkTDjqUdpiwA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/json2mq": {
|
"node_modules/json2mq": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"jsencrypt": "^3.5.4",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
+13
-10
@@ -1,6 +1,8 @@
|
|||||||
import { Routes, Route } from 'react-router-dom';
|
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 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';
|
||||||
@@ -14,17 +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="/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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,11 @@
|
|||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes glow-pulse {
|
@keyframes glow-pulse {
|
||||||
0%, 100% { box-shadow: 0 0 10px rgba(0,212,255,0.2); }
|
0%, 100% { box-shadow: 0 0 10px rgba(0,212,255,0.2); }
|
||||||
50% { box-shadow: 0 0 30px rgba(0,212,255,0.5); }
|
50% { box-shadow: 0 0 30px rgba(0,212,255,0.5); }
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
+55
-4
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { BrainCircuit, Calculator, Network, Boxes, ReceiptText, CalendarClock, Sparkles } from 'lucide-react';
|
import { BrainCircuit, Calculator, Network, Boxes, ReceiptText, CalendarClock, Sparkles, LogOut } from 'lucide-react';
|
||||||
import { useTheme } from '@/shared/context/ThemeContext';
|
import { useTheme } from '@/shared/context/ThemeContext';
|
||||||
import StarBackground from '@/shared/components/StarBackground';
|
import StarBackground from '@/shared/components/StarBackground';
|
||||||
import ThemeToggle from '@/shared/components/ThemeToggle';
|
import ThemeToggle from '@/shared/components/ThemeToggle';
|
||||||
import { modules } from '@/modules/registry';
|
import { modules } from '@/modules/registry';
|
||||||
|
import { getUserInfo, clearSession, type SessionUserInfo } from '@/shared/services/iamAuth';
|
||||||
|
|
||||||
const ICON_MAP: Record<string, React.ComponentType<{ size?: number; color?: string }>> = {
|
const ICON_MAP: Record<string, React.ComponentType<{ size?: number; color?: string }>> = {
|
||||||
BrainCircuit,
|
BrainCircuit,
|
||||||
@@ -21,6 +22,7 @@ export default function HomePage() {
|
|||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
const [userInfo, setUserInfo] = useState<SessionUserInfo | null>(() => getUserInfo());
|
||||||
|
|
||||||
// 首页模块多时需纵向滚动;离开后恢复全局 overflow:hidden(其他模块自管布局)
|
// 首页模块多时需纵向滚动;离开后恢复全局 overflow:hidden(其他模块自管布局)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -36,6 +38,12 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
clearSession();
|
||||||
|
setUserInfo(null);
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '100vw', minHeight: '100vh',
|
width: '100vw', minHeight: '100vh',
|
||||||
@@ -112,8 +120,51 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右上角主题切换 */}
|
{/* 右上角:用户信息 + 主题切换 */}
|
||||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center' }}>
|
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
{userInfo && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 28, height: 28, borderRadius: '50%',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 13, fontWeight: 700,
|
||||||
|
color: '#04121f',
|
||||||
|
background: 'linear-gradient(135deg, #00d4ff, #4f9eff)',
|
||||||
|
}}>
|
||||||
|
{(userInfo.userName ?? userInfo.userId).charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 13,
|
||||||
|
...(isDark ? { color: 'rgba(168,216,255,0.85)' } : { color: '#334155' }),
|
||||||
|
}}>
|
||||||
|
{userInfo.userName ?? userInfo.userId}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
title="退出登录"
|
||||||
|
style={{
|
||||||
|
marginLeft: 4,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 4,
|
||||||
|
padding: '5px 12px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: `1px solid ${isDark ? 'rgba(0,212,255,0.3)' : 'rgba(0,0,0,0.12)'}`,
|
||||||
|
background: isDark ? 'rgba(0,212,255,0.06)' : '#fff',
|
||||||
|
color: isDark ? 'rgba(168,216,255,0.8)' : '#475569',
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'border-color 0.2s, color 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.color = '#ef4444'; }}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = isDark ? 'rgba(0,212,255,0.3)' : 'rgba(0,0,0,0.12)';
|
||||||
|
e.currentTarget.style.color = isDark ? 'rgba(168,216,255,0.8)' : '#475569';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogOut size={13} />
|
||||||
|
退出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { User, Lock, LogIn, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
|
import StarBackground from '@/shared/components/StarBackground';
|
||||||
|
import { iamLogin, saveSession } from '@/shared/services/iamAuth';
|
||||||
|
import { useTheme } from '@/shared/context/ThemeContext';
|
||||||
|
|
||||||
|
const inputStyle = (isDark: boolean): React.CSSProperties => ({
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
padding: '12px 14px 12px 42px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${isDark ? 'rgba(0,212,255,0.25)' : 'rgba(0,0,0,0.12)'}`,
|
||||||
|
background: isDark ? 'rgba(0,212,255,0.06)' : '#fff',
|
||||||
|
color: isDark ? '#e8f4ff' : '#1e293b',
|
||||||
|
fontSize: 14,
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'border-color 0.2s, box-shadow 0.2s',
|
||||||
|
});
|
||||||
|
|
||||||
|
const iconStyle = (isDark: boolean): React.CSSProperties => ({
|
||||||
|
position: 'absolute',
|
||||||
|
left: 14,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
color: isDark ? 'rgba(0,212,255,0.6)' : '#94a3b8',
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
|
const [userId, setUserId] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.style.overflow = 'auto';
|
||||||
|
return () => { document.body.style.overflow = ''; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!userId.trim() || !password) {
|
||||||
|
setError('请输入账号和密码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await iamLogin({ userId: userId.trim(), password });
|
||||||
|
saveSession(result.token, result.userInfo);
|
||||||
|
// 回跳原页面(RequireAuth 记录的 from),否则去首页
|
||||||
|
const from = (location.state as { from?: string })?.from ?? '/';
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} catch (ex) {
|
||||||
|
setError(ex instanceof Error ? ex.message : '登录失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100vw',
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
fontFamily: "-apple-system, 'SF Pro Display', 'SF Pro Text', 'PingFang SC', 'Helvetica Neue', Arial, sans-serif",
|
||||||
|
color: isDark ? '#a8d8ff' : '#1e3a6e',
|
||||||
|
}}>
|
||||||
|
{isDark ? <StarBackground theme="dark" /> : (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', top: 0, left: 0, width: '100%', height: '100%',
|
||||||
|
background: 'linear-gradient(160deg, #f8f8fb 0%, #f2f3f7 50%, #eef1f7 100%)',
|
||||||
|
zIndex: 0,
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
position: 'relative', zIndex: 1,
|
||||||
|
width: 400, maxWidth: '92vw',
|
||||||
|
padding: '40px 36px 32px',
|
||||||
|
borderRadius: 20,
|
||||||
|
background: isDark ? 'rgba(10,22,40,0.75)' : 'rgba(255,255,255,0.9)',
|
||||||
|
backdropFilter: 'blur(16px)',
|
||||||
|
border: `1px solid ${isDark ? 'rgba(0,212,255,0.2)' : 'rgba(0,0,0,0.06)'}`,
|
||||||
|
boxShadow: isDark ? '0 24px 64px rgba(0,0,0,0.5), 0 0 48px rgba(0,212,255,0.08)' : '0 24px 64px rgba(30,58,110,0.12)',
|
||||||
|
}}>
|
||||||
|
{/* 品牌区 */}
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
|
||||||
|
<img
|
||||||
|
src="/digiwin.svg"
|
||||||
|
alt="Digiwin"
|
||||||
|
style={{ height: 36, objectFit: 'contain', filter: isDark ? 'drop-shadow(0 0 8px rgba(0,212,255,0.4))' : 'none' }}
|
||||||
|
/>
|
||||||
|
<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={{
|
||||||
|
fontSize: 26, fontWeight: 800, letterSpacing: '0.04em', margin: 0,
|
||||||
|
...(isDark
|
||||||
|
? { color: '#e8f4ff', textShadow: '0 0 16px rgba(0,212,255,0.5)' }
|
||||||
|
: { color: '#111827' }),
|
||||||
|
}}>
|
||||||
|
EIOSpace
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 13, marginTop: 14, ...(isDark ? { color: 'rgba(168,216,255,0.6)' } : { color: '#6b7280' }) }}>
|
||||||
|
企业智能 · 数据驱动 · 产业赋能
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 登录表单 */}
|
||||||
|
<form onSubmit={handleLogin}>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={{ display: 'block', fontSize: 13, marginBottom: 6, color: isDark ? 'rgba(168,216,255,0.8)' : '#475569' }}>
|
||||||
|
账号(用户ID / 手机号 / 邮箱)
|
||||||
|
</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<User size={16} style={iconStyle(isDark)} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userId}
|
||||||
|
onChange={(e) => setUserId(e.target.value)}
|
||||||
|
placeholder="请输入账号"
|
||||||
|
autoComplete="username"
|
||||||
|
style={inputStyle(isDark)}
|
||||||
|
onFocus={(e) => { e.currentTarget.style.borderColor = '#00d4ff'; }}
|
||||||
|
onBlur={(e) => { e.currentTarget.style.borderColor = isDark ? 'rgba(0,212,255,0.25)' : 'rgba(0,0,0,0.12)'; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<label style={{ display: 'block', fontSize: 13, marginBottom: 6, color: isDark ? 'rgba(168,216,255,0.8)' : '#475569' }}>
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Lock size={16} style={iconStyle(isDark)} />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
autoComplete="current-password"
|
||||||
|
style={inputStyle(isDark)}
|
||||||
|
onFocus={(e) => { e.currentTarget.style.borderColor = '#00d4ff'; }}
|
||||||
|
onBlur={(e) => { e.currentTarget.style.borderColor = isDark ? 'rgba(0,212,255,0.25)' : 'rgba(0,0,0,0.12)'; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '10px 12px', marginBottom: 16,
|
||||||
|
borderRadius: 8, fontSize: 13,
|
||||||
|
background: 'rgba(239,68,68,0.12)',
|
||||||
|
border: '1px solid rgba(239,68,68,0.4)',
|
||||||
|
color: '#fca5a5',
|
||||||
|
}}>
|
||||||
|
<AlertCircle size={15} style={{ flexShrink: 0 }} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '13px 0',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: 'none',
|
||||||
|
cursor: loading ? 'wait' : 'pointer',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
color: '#04121f',
|
||||||
|
background: 'linear-gradient(90deg, #00d4ff, #4f9eff)',
|
||||||
|
boxShadow: '0 8px 24px rgba(0,212,255,0.35)',
|
||||||
|
transition: 'transform 0.15s, box-shadow 0.15s, opacity 0.15s',
|
||||||
|
opacity: loading ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { if (!loading) e.currentTarget.style.transform = 'translateY(-1px)'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.transform = 'none'; }}
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 size={17} style={{ animation: 'spin 1s linear infinite' }} /> : <LogIn size={17} />}
|
||||||
|
{loading ? '登录中...' : '登 录'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p style={{
|
||||||
|
textAlign: 'center', fontSize: 12, marginTop: 24,
|
||||||
|
...(isDark ? { color: 'rgba(168,216,255,0.45)' } : { color: '#94a3b8' }),
|
||||||
|
}}>
|
||||||
|
鼎捷云统一身份认证 · IAM
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+65
-6
@@ -1,21 +1,48 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { Spin } from 'antd';
|
import { Spin } from 'antd';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
import { iamSsoLogin, saveSession } from '@/shared/services/iamAuth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SSO 登录回调入口
|
* SSO 登录回调入口
|
||||||
* 接收 URL 参数 userToken,写入 sessionStorage 后跳转首页
|
*
|
||||||
|
* 流程:
|
||||||
|
* 1. 从 URL 参数获取 userToken
|
||||||
|
* 2. 调用 iamSsoLogin 完成:
|
||||||
|
* - POST /identity/token/refresh/app 刷新应用 token + 用户信息
|
||||||
|
* - POST /identity/login/info 获取登录详情
|
||||||
|
* - POST /tenant?appId=APPID 拉取租户列表,选默认租户
|
||||||
|
* - POST /identity/token/refresh/tenant 切换租户刷新 token
|
||||||
|
* 3. userInfo 写入 sessionStorage,跳转首页
|
||||||
*/
|
*/
|
||||||
const SSOLogin: React.FC = () => {
|
const SSOLogin: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const userToken = searchParams.get('userToken');
|
const userToken = searchParams.get('userToken');
|
||||||
if (userToken) {
|
if (!userToken) {
|
||||||
sessionStorage.setItem('userToken', userToken);
|
setError('URL 缺少 userToken 参数');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
navigate('/', { replace: true });
|
const routerLink = searchParams.get('routerLink');
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const result = await iamSsoLogin(userToken);
|
||||||
|
if (cancelled) return;
|
||||||
|
saveSession(result.token, result.userInfo);
|
||||||
|
navigate(routerLink || '/', { replace: true });
|
||||||
|
} catch (ex) {
|
||||||
|
if (cancelled) return;
|
||||||
|
setError(ex instanceof Error ? ex.message : 'SSO 登录失败');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
}, [searchParams, navigate]);
|
}, [searchParams, navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -26,8 +53,40 @@ const SSOLogin: React.FC = () => {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
background: '#0a1628',
|
background: '#0a1628',
|
||||||
|
color: '#a8d8ff',
|
||||||
|
fontFamily: "-apple-system, 'SF Pro Display', 'PingFang SC', 'Helvetica Neue', Arial, sans-serif",
|
||||||
}}>
|
}}>
|
||||||
<Spin size="large" tip="正在登录..." />
|
{error ? (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16,
|
||||||
|
padding: '32px 40px',
|
||||||
|
borderRadius: 16,
|
||||||
|
background: 'rgba(239,68,68,0.08)',
|
||||||
|
border: '1px solid rgba(239,68,68,0.4)',
|
||||||
|
maxWidth: 460,
|
||||||
|
}}>
|
||||||
|
<AlertCircle size={36} color="#fca5a5" />
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 600, color: '#fca5a5' }}>SSO 登录失败</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'rgba(168,216,255,0.7)', textAlign: 'center', wordBreak: 'break-all' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/login', { replace: true })}
|
||||||
|
style={{
|
||||||
|
marginTop: 8, padding: '8px 20px', borderRadius: 8,
|
||||||
|
border: '1px solid rgba(0,212,255,0.4)', background: 'rgba(0,212,255,0.1)',
|
||||||
|
color: '#00d4ff', fontSize: 13, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
前往登录页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
<div style={{ fontSize: 14, color: 'rgba(168,216,255,0.7)' }}>正在登录...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import { JSEncrypt } from 'jsencrypt';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 鼎捷云 IAM 登录服务(纯前端实现)
|
||||||
|
*
|
||||||
|
* 完整登录流程:
|
||||||
|
* 1. RSA+AES 加密链路获取 userToken(/api/iam/v2/identity/login)
|
||||||
|
* 2. 拉取用户授权租户列表(POST /api/iam/v2/tenant?appId=APPID),默认选第一个
|
||||||
|
* 3. 切换租户刷新 token(POST /api/iam/v2/identity/token/refresh/tenant)
|
||||||
|
* 4. 将完整用户信息(含 authoredUser)写入 sessionStorage
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 代理路径(vite.config.ts 中 /iam-api → https://iam.digiwincloud.com.cn)
|
||||||
|
const IAM_API_BASE = '/iam-api/api/iam/v2';
|
||||||
|
const IAM_IDENTITY_BASE = `${IAM_API_BASE}/identity`;
|
||||||
|
|
||||||
|
// 应用 apptoken(digi-middleware-auth-app)
|
||||||
|
export const APP_TOKEN =
|
||||||
|
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImRhdGEtYnVzaW5lc3MtZGVtbyIsInNpZCI6MH0.Spo64LstbWxjYNefVFAbEbgfjzZoQGNcqKSGuYUOCRk';
|
||||||
|
|
||||||
|
// 应用 ID(用于租户列表查询)
|
||||||
|
export const APPID = 'data-business-demo';
|
||||||
|
|
||||||
|
// IAM AES 加密固定 IV(16 字节)
|
||||||
|
const AES_IV = 'ghUb#er57HBh(u%g';
|
||||||
|
|
||||||
|
/** PEM 包装/剥离工具 */
|
||||||
|
function wrapPem(base64Key: string): string {
|
||||||
|
if (base64Key.includes('BEGIN')) return base64Key;
|
||||||
|
const body = base64Key.replace(/-----(BEGIN|END)[^-]+-----/g, '').replace(/\s+/g, '');
|
||||||
|
const lines = body.match(/.{1,64}/g) || [];
|
||||||
|
return `-----BEGIN PUBLIC KEY-----\n${lines.join('\n')}\n-----END PUBLIC KEY-----`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripPem(pem: string): string {
|
||||||
|
return pem.replace(/-----(BEGIN|END)[^-]+-----/g, '').replace(/\s+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AES-CBC/PKCS7 加密,输出 base64(与 Java AES/CBC/PKCS5Padding 等价) */
|
||||||
|
async function aesEncryptToBase64(plainText: string, aesKey: string): Promise<string> {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const keyData = enc.encode(aesKey);
|
||||||
|
const ivData = enc.encode(AES_IV);
|
||||||
|
const cryptoKey = await crypto.subtle.importKey('raw', keyData, { name: 'AES-CBC' }, false, ['encrypt']);
|
||||||
|
const cipherBuf = await crypto.subtle.encrypt({ name: 'AES-CBC', iv: ivData }, cryptoKey, enc.encode(plainText));
|
||||||
|
const bytes = new Uint8Array(cipherBuf);
|
||||||
|
let bin = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||||
|
return btoa(bin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应用层请求头(含 apptoken) */
|
||||||
|
function appHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'digi-middleware-auth-app': APP_TOKEN,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户鉴权请求头(含 apptoken + usertoken) */
|
||||||
|
function userHeaders(userToken: string, extra?: Record<string, string>): Record<string, string> {
|
||||||
|
return appHeaders({
|
||||||
|
'digi-middleware-auth-user': userToken,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从任意对象中尝试提取 token 字符串 */
|
||||||
|
function pickToken(obj: Record<string, unknown>): string | undefined {
|
||||||
|
if (typeof obj.token === 'string') return obj.token;
|
||||||
|
if (typeof obj.userToken === 'string') return obj.userToken;
|
||||||
|
const data = obj.data as Record<string, unknown> | undefined;
|
||||||
|
if (data && typeof data.token === 'string') return data.token;
|
||||||
|
const result = obj.result as Record<string, unknown> | undefined;
|
||||||
|
if (result && typeof result.token === 'string') return result.token;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IamLoginParams {
|
||||||
|
userId: string;
|
||||||
|
password: string;
|
||||||
|
tenantId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IamLoginResult {
|
||||||
|
/** 最终 userToken(经 refresh/tenant 刷新后) */
|
||||||
|
token: string;
|
||||||
|
/** userId */
|
||||||
|
userId: string;
|
||||||
|
/** 完整用户信息(含 login 原始返回 + authoredUser + 默认租户) */
|
||||||
|
userInfo: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IAM 普通登录(identityType: query)
|
||||||
|
* 完整流程:加密登录 → 拉取租户列表 → 切换默认租户刷新 token
|
||||||
|
*/
|
||||||
|
export async function iamLogin({ userId, password, tenantId }: IamLoginParams): Promise<IamLoginResult> {
|
||||||
|
// 1. 客户端生成 RSA 密钥对(1024)
|
||||||
|
const client = new JSEncrypt({ default_key_size: '1024' });
|
||||||
|
client.getKey();
|
||||||
|
const clientPrivateKeyPem = client.getPrivateKey();
|
||||||
|
const clientPublicKeyB64 = stripPem(client.getPublicKey());
|
||||||
|
|
||||||
|
// 2. 获取服务端公钥
|
||||||
|
const pkRes = await fetch(`${IAM_IDENTITY_BASE}/publickey`, { headers: appHeaders() });
|
||||||
|
if (!pkRes.ok) throw new Error(`获取服务端公钥失败 (HTTP ${pkRes.status})`);
|
||||||
|
const pkJson = await pkRes.json();
|
||||||
|
const serverPublicKey: string = pkJson.publicKey;
|
||||||
|
if (!serverPublicKey) throw new Error('服务端公钥为空');
|
||||||
|
|
||||||
|
// 3. 服务端公钥加密客户端公钥
|
||||||
|
const server = new JSEncrypt();
|
||||||
|
server.setPublicKey(wrapPem(serverPublicKey));
|
||||||
|
const clientEncryptPublicKey = server.encrypt(clientPublicKeyB64);
|
||||||
|
if (!clientEncryptPublicKey) throw new Error('加密客户端公钥失败');
|
||||||
|
|
||||||
|
// 4. 获取加密的 AES 密钥
|
||||||
|
const aesRes = await fetch(`${IAM_IDENTITY_BASE}/aeskey`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: appHeaders(),
|
||||||
|
body: JSON.stringify({ clientEncryptPublicKey }),
|
||||||
|
});
|
||||||
|
if (!aesRes.ok) throw new Error(`获取 AES 密钥失败 (HTTP ${aesRes.status})`);
|
||||||
|
const aesJson = await aesRes.json();
|
||||||
|
const encryptAesKey: string = aesJson.encryptAesKey;
|
||||||
|
if (!encryptAesKey) throw new Error(`获取 AES 密钥失败: ${JSON.stringify(aesJson)}`);
|
||||||
|
|
||||||
|
// 5. 客户端私钥解密 AES 密钥
|
||||||
|
client.setPrivateKey(clientPrivateKeyPem);
|
||||||
|
const aesKey = client.decrypt(encryptAesKey);
|
||||||
|
if (!aesKey) throw new Error('解密 AES 密钥失败');
|
||||||
|
|
||||||
|
// 6. AES 加密密码
|
||||||
|
const passwordHash = await aesEncryptToBase64(password, aesKey);
|
||||||
|
|
||||||
|
// 7. 登录
|
||||||
|
const loginBody: Record<string, string> = {
|
||||||
|
userId,
|
||||||
|
passwordHash,
|
||||||
|
clientEncryptPublicKey,
|
||||||
|
identityType: 'query',
|
||||||
|
};
|
||||||
|
if (tenantId) loginBody.tenantId = tenantId;
|
||||||
|
|
||||||
|
const loginRes = await fetch(`${IAM_IDENTITY_BASE}/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: appHeaders(),
|
||||||
|
body: JSON.stringify(loginBody),
|
||||||
|
});
|
||||||
|
const loginJson = (await loginRes.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const initialToken = pickToken(loginJson);
|
||||||
|
if (!loginRes.ok || !initialToken) {
|
||||||
|
const msg = loginJson.message || loginJson.msg || loginJson.error || `HTTP ${loginRes.status}`;
|
||||||
|
throw new Error(`登录失败: ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 拉取用户授权租户列表 + 切换默认租户
|
||||||
|
const tenantCtx = await switchDefaultTenant(initialToken);
|
||||||
|
|
||||||
|
// 9. 组装完整 userInfo:
|
||||||
|
// - authoredUser 内容平铺到顶层(sid/userName/email/currTenantList 等)
|
||||||
|
// - token 使用最终刷新后的 token
|
||||||
|
// - 保留 isLoggedin 标识
|
||||||
|
const userInfo: Record<string, unknown> = {
|
||||||
|
...(tenantCtx.authoredUser ?? {}),
|
||||||
|
...(loginJson ?? {}),
|
||||||
|
userId,
|
||||||
|
token: tenantCtx.token,
|
||||||
|
isLoggedin: true,
|
||||||
|
...(tenantCtx.currTenantList ? { currTenantList: tenantCtx.currTenantList } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: tenantCtx.token,
|
||||||
|
userId,
|
||||||
|
userInfo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换默认租户(公用流程)
|
||||||
|
*
|
||||||
|
* 1. POST /api/iam/v2/tenant?appId=APPID 拉取租户列表
|
||||||
|
* 2. 优先选 isDefault=true 的租户,否则取第一个
|
||||||
|
* 3. POST /api/iam/v2/identity/token/refresh/tenant body={tenantSid}
|
||||||
|
* 4. 返回刷新后的 token + authoredUser + currTenantList
|
||||||
|
*
|
||||||
|
* 异常不抛出,回退到传入的 userToken
|
||||||
|
*/
|
||||||
|
export async function switchDefaultTenant(
|
||||||
|
userToken: string,
|
||||||
|
): Promise<{
|
||||||
|
token: string;
|
||||||
|
authoredUser?: Record<string, unknown>;
|
||||||
|
currTenantList?: unknown[];
|
||||||
|
}> {
|
||||||
|
let finalToken = userToken;
|
||||||
|
let authoredUser: Record<string, unknown> | undefined;
|
||||||
|
let currTenantList: unknown[] | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tenantRes = await fetch(`${IAM_API_BASE}/tenant?appId=${encodeURIComponent(APPID)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: userHeaders(userToken),
|
||||||
|
});
|
||||||
|
if (!tenantRes.ok) throw new Error(`获取租户列表失败 (HTTP ${tenantRes.status})`);
|
||||||
|
const tenantJson = (await tenantRes.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
|
|
||||||
|
// 兼容数组 / {data:[]} / {list:[]} / {result:[]}
|
||||||
|
let tenants: unknown[] = [];
|
||||||
|
if (Array.isArray(tenantJson)) {
|
||||||
|
tenants = tenantJson;
|
||||||
|
} else if (Array.isArray(tenantJson.data)) {
|
||||||
|
tenants = tenantJson.data as unknown[];
|
||||||
|
} else if (Array.isArray(tenantJson.list)) {
|
||||||
|
tenants = tenantJson.list as unknown[];
|
||||||
|
} else if (Array.isArray(tenantJson.result)) {
|
||||||
|
tenants = tenantJson.result as unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tenants.length > 0) {
|
||||||
|
currTenantList = tenants;
|
||||||
|
// 优先选 isDefault=true 的租户,否则取第一个
|
||||||
|
const defaultTenant = (tenants.find((t) => (t as Record<string, unknown>)?.isDefault === true)
|
||||||
|
?? tenants[0]) as Record<string, unknown>;
|
||||||
|
const tenantSid = (defaultTenant.sid as number | string | undefined)
|
||||||
|
?? (defaultTenant.tenantSid as number | string | undefined)
|
||||||
|
?? (defaultTenant.id as number | string | undefined);
|
||||||
|
|
||||||
|
if (tenantSid !== undefined && tenantSid !== null) {
|
||||||
|
// 切换默认租户,刷新 token
|
||||||
|
const refreshRes = await fetch(`${IAM_IDENTITY_BASE}/token/refresh/tenant`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: userHeaders(userToken),
|
||||||
|
body: JSON.stringify({ tenantSid }),
|
||||||
|
});
|
||||||
|
if (refreshRes.ok) {
|
||||||
|
const refreshJson = (await refreshRes.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
|
const refreshedToken = pickToken(refreshJson);
|
||||||
|
if (refreshedToken) finalToken = refreshedToken;
|
||||||
|
// authoredUser 平铺到 userInfo 顶层
|
||||||
|
if (refreshJson.authoredUser && typeof refreshJson.authoredUser === 'object') {
|
||||||
|
authoredUser = refreshJson.authoredUser as Record<string, unknown>;
|
||||||
|
} else if (refreshJson.data && typeof refreshJson.data === 'object'
|
||||||
|
&& (refreshJson.data as Record<string, unknown>).authoredUser) {
|
||||||
|
authoredUser = (refreshJson.data as Record<string, unknown>).authoredUser as Record<string, unknown>;
|
||||||
|
} else {
|
||||||
|
// 整个 refresh 返回作为 authoredUser(兼容字段直接在顶层)
|
||||||
|
authoredUser = refreshJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
// 租户切换失败不阻断登录,仍使用原 token
|
||||||
|
console.warn('[IAM] 租户切换流程异常,将使用原 token', ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token: finalToken, authoredUser, currTenantList };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSO 登录(基于外部传入的 userToken)
|
||||||
|
*
|
||||||
|
* 流程:
|
||||||
|
* 1. POST /api/iam/v2/identity/token/refresh/app 刷新应用 token + 用户信息
|
||||||
|
* 2. POST /api/iam/v2/identity/login/info 获取登录详情
|
||||||
|
* 3. 调用 switchDefaultTenant 拉取租户列表 + 切换默认租户
|
||||||
|
* 4. 组装完整 userInfo(authoredUser 平铺到顶层)
|
||||||
|
*/
|
||||||
|
export async function iamSsoLogin(initialUserToken: string): Promise<IamLoginResult> {
|
||||||
|
// 1. token/refresh/app:刷新应用 token
|
||||||
|
const refreshAppRes = await fetch(`${IAM_IDENTITY_BASE}/token/refresh/app`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: userHeaders(initialUserToken),
|
||||||
|
});
|
||||||
|
if (!refreshAppRes.ok) throw new Error(`SSO token 刷新失败 (HTTP ${refreshAppRes.status})`);
|
||||||
|
const refreshAppJson = (await refreshAppRes.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
|
const appRefreshedToken = pickToken(refreshAppJson) ?? initialUserToken;
|
||||||
|
|
||||||
|
// 2. login/info:获取登录详情
|
||||||
|
let loginInfoJson: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
const infoRes = await fetch(`${IAM_IDENTITY_BASE}/login/info`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: userHeaders(appRefreshedToken),
|
||||||
|
});
|
||||||
|
if (infoRes.ok) {
|
||||||
|
loginInfoJson = (await infoRes.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
console.warn('[IAM] login/info 调用异常', ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 切换默认租户
|
||||||
|
const tenantCtx = await switchDefaultTenant(appRefreshedToken);
|
||||||
|
|
||||||
|
// 4. 组装完整 userInfo
|
||||||
|
// 优先级:tenantCtx.authoredUser > refreshAppJson > loginInfoJson
|
||||||
|
// userId 从 loginInfo/refreshApp 中提取
|
||||||
|
const userId = (loginInfoJson.userId as string)
|
||||||
|
?? (refreshAppJson.userId as string)
|
||||||
|
?? (tenantCtx.authoredUser?.userId as string)
|
||||||
|
?? '';
|
||||||
|
|
||||||
|
const userInfo: Record<string, unknown> = {
|
||||||
|
...(tenantCtx.authoredUser ?? {}),
|
||||||
|
...(refreshAppJson ?? {}),
|
||||||
|
...(loginInfoJson ?? {}),
|
||||||
|
userId,
|
||||||
|
token: tenantCtx.token,
|
||||||
|
isLoggedin: true,
|
||||||
|
...(tenantCtx.currTenantList ? { currTenantList: tenantCtx.currTenantList } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: tenantCtx.token,
|
||||||
|
userId,
|
||||||
|
userInfo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- sessionStorage 会话管理 ---------------- */
|
||||||
|
|
||||||
|
const KEY_USER_TOKEN = 'userToken';
|
||||||
|
const KEY_USER_INFO = 'userInfo';
|
||||||
|
const KEY_APP_TOKEN = 'digi-middleware-auth-app';
|
||||||
|
|
||||||
|
export interface SessionUserInfo {
|
||||||
|
userId: string;
|
||||||
|
userName?: string;
|
||||||
|
token: string;
|
||||||
|
tenantId?: string;
|
||||||
|
tenantName?: string;
|
||||||
|
tenantSid?: number;
|
||||||
|
sid?: number;
|
||||||
|
email?: string;
|
||||||
|
telephone?: string;
|
||||||
|
isLoggedin?: boolean;
|
||||||
|
currTenantList?: unknown[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存登录会话 */
|
||||||
|
export function saveSession(token: string, info: Record<string, unknown>): void {
|
||||||
|
sessionStorage.setItem(KEY_USER_TOKEN, token);
|
||||||
|
sessionStorage.setItem(KEY_USER_INFO, JSON.stringify(info));
|
||||||
|
sessionStorage.setItem(KEY_APP_TOKEN, APP_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前 userToken */
|
||||||
|
export function getUserToken(): string | null {
|
||||||
|
return sessionStorage.getItem(KEY_USER_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前用户信息 */
|
||||||
|
export function getUserInfo(): SessionUserInfo | null {
|
||||||
|
const raw = sessionStorage.getItem(KEY_USER_INFO);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as SessionUserInfo;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 退出登录,清空会话 */
|
||||||
|
export function clearSession(): void {
|
||||||
|
sessionStorage.removeItem(KEY_USER_TOKEN);
|
||||||
|
sessionStorage.removeItem(KEY_USER_INFO);
|
||||||
|
sessionStorage.removeItem(KEY_APP_TOKEN);
|
||||||
|
}
|
||||||
+7
-1
@@ -21,10 +21,16 @@ 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'),
|
||||||
},
|
},
|
||||||
|
// 鼎捷云 IAM 登录服务代理(避免 CORS / 网络问题)
|
||||||
|
'/iam-api': {
|
||||||
|
target: 'https://iam.digiwincloud.com.cn',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (p) => p.replace(/^\/iam-api/, ''),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user