Compare commits

..
12 Commits
Author SHA1 Message Date
dongsk 5b7d1e8f64 镜像源调整 2026-09-03 11:56:49 +08:00
dongsk 2336d345c0 更新bexapi 地址 2026-09-03 11:29:19 +08:00
dongsk dc4da05083 调整打包部署 2026-09-01 18:52:39 +08:00
dongsk d07ada995f /bexapi 接口调用带iam token鉴权 2026-09-01 17:36:56 +08:00
dongsk 7cf86b3735 添加 routerLink参数 2026-09-01 10:46:59 +08:00
dongsk 12c8a852a1 更新order-cost-analysis 2026-09-01 10:39:19 +08:00
dongsk 0edd4c8969 chore: add deploy script and deployment docs 2026-08-28 17:30:07 +08:00
dongsk cbb5cfd167 update 2026-08-28 16:07:18 +08:00
dongsk d027eba044 修改调整登录界面 2026-08-27 16:57:34 +08:00
dongsk cd5a385cda 所有模块添加路由守卫 2026-08-27 16:46:52 +08:00
dongsk 989120cab8 sso-login调整 2026-08-27 16:37:33 +08:00
dongsk 2d19c736a6 登录1 2026-08-27 16:06:23 +08:00
20 changed files with 1159 additions and 63 deletions
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmmirror.com
+7 -4
View File
@@ -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;"]
Executable
+34
View File
@@ -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"
+206
View File
@@ -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
View File
@@ -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;
+7
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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>
); );
} }
+5
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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>
+215
View File
@@ -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
View File
@@ -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>
); );
}; };
+39
View File
@@ -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}</>;
}
+375
View File
@@ -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
View File
@@ -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/, ''),
},
}, },
}, },
}) })