第一次
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/node:18-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG REACT_APP_API_BASE_URL
|
||||||
|
ENV REACT_APP_API_BASE_URL=${REACT_APP_API_BASE_URL}
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/nginx:alpine3.22-slim
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/build /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
141
README.md
Normal file
141
README.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# FST Editor archi 前端
|
||||||
|
|
||||||
|
一个基于 React 的「FST Editor 标签管理」前端原型,用于展示和增改 FST 标签列表。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- React 18
|
||||||
|
- react-scripts(Create React App 工具链)
|
||||||
|
- axios(调用后端接口)
|
||||||
|
- lucide-react(图标库)
|
||||||
|
|
||||||
|
## 目录结构(关键文件)
|
||||||
|
|
||||||
|
- `package.json`:项目依赖和脚本
|
||||||
|
- `.env`:环境变量(后端 API 地址)
|
||||||
|
- `public/index.html`:HTML 模板
|
||||||
|
- `src/index.js`:React 入口文件
|
||||||
|
- `App.jsx`:主应用组件,整体布局与页面逻辑
|
||||||
|
- `App.css`:布局、侧边栏、标签页、表格等样式
|
||||||
|
- `services/api.js`:封装 FST 标签相关接口
|
||||||
|
|
||||||
|
## 环境准备
|
||||||
|
|
||||||
|
1. 安装 Node.js(建议 16+,18+ 更佳)
|
||||||
|
2. 安装 npm(随 Node 一起安装即可)
|
||||||
|
|
||||||
|
## 安装依赖
|
||||||
|
|
||||||
|
在项目根目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## 启动开发环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
默认会在浏览器打开:
|
||||||
|
|
||||||
|
- `http://localhost:3003/`
|
||||||
|
|
||||||
|
如未自动打开,可手动在浏览器输入上述地址访问。
|
||||||
|
|
||||||
|
## 构建生产包
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
构建结果会输出到 `build/` 目录,可用于部署。
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
后端接口地址通过 `.env` 配置(生产环境使用),开发环境通过代理转发:
|
||||||
|
|
||||||
|
```env
|
||||||
|
REACT_APP_API_BASE_URL=http://115.190.223.209:5232/api
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `services/api.js` 中:
|
||||||
|
|
||||||
|
- 开发环境:axios `baseURL` 为 `/api`,由 `package.json` 的 `proxy` 配置转发到 `http://115.190.223.209:5232`。
|
||||||
|
- 生产环境:读取 `.env` 的 `REACT_APP_API_BASE_URL` 作为 axios `baseURL`。
|
||||||
|
|
||||||
|
你可以根据实际后端地址进行修改。
|
||||||
|
|
||||||
|
## 功能说明
|
||||||
|
|
||||||
|
- 左侧侧边栏
|
||||||
|
- FST Editor 标签管理导航
|
||||||
|
- 「增改Fst Editor标签」「Version List」菜单入口
|
||||||
|
- 顶部
|
||||||
|
- 右侧操作图标(夜间模式、全屏、用户)
|
||||||
|
- 标签页区域
|
||||||
|
- 多个标签 Tab(增改Fst Editor标签、Version List)
|
||||||
|
- 内容区域
|
||||||
|
- 标签表格展示:Name、Description、Createtime、Updatetime、Operation
|
||||||
|
- 支持「编辑」「新增下级」「删除」「release」
|
||||||
|
- Name/Description 列缩窄并悬浮显示全称;Name 按后端 level 缩进展示层级
|
||||||
|
- 页面加载自动从 `/api/fst/print_tree` 接口获取数据
|
||||||
|
|
||||||
|
## 版本与 JSON 导入导出
|
||||||
|
|
||||||
|
- 版本快照存储
|
||||||
|
- Release 时,会先创建版本(`POST /versions`),再把当前前端表格中未删除的标签,以 JSON 格式保存到 `fst_stash` 表(`POST /fst/stash`)。
|
||||||
|
- 保存结构示例:
|
||||||
|
- `nodes`: 扁平化的标签节点数组,包含 `name`、`level`、`createtime`、`updateTime`、`parentId`、`treeLevel`、`deleted` 等字段
|
||||||
|
- `meta`: 版本备注、发布时间等元数据(如 `remark`、`released_at`)
|
||||||
|
|
||||||
|
- 版本导出(Version List)
|
||||||
|
- 在 Version List 页面点击导出,会调用 `GET /fst/stash/{version}`。
|
||||||
|
- 将 `fst_stash` 中对应版本的 `content` 字段以 JSON 文件形式下载,例如:`Version_1.0.0.json`。
|
||||||
|
|
||||||
|
- JSON 导入(FstTagPage)
|
||||||
|
- 在「增改Fst Editor标签」页面点击「导入」,选择 `.json` 文件:
|
||||||
|
- 支持两种结构:
|
||||||
|
- 直接数组:`[{...}, {...}]`
|
||||||
|
- 带包装:`{ "version": "1.0.0", "nodes": [...], "meta": {...} }`
|
||||||
|
- 导入后:
|
||||||
|
- 会将 `nodes` 解析并追加到当前前端表格中,恢复层级(依赖 `treeLevel` 顺序计算 `parentId`)。
|
||||||
|
- 默认不会直接修改正式 FST 表,只更新前端数据。
|
||||||
|
- 同时会尝试将该 JSON 写入 `fst_stash`:
|
||||||
|
- 版本号来源优先级:`parsed.version` > 文件名(`Version_xxx.json`)> 用户输入。
|
||||||
|
- 若版本不存在,则自动调用 `POST /versions` 创建版本,再调用 `POST /fst/stash` 保存 JSON。
|
||||||
|
- 如果写入 `fst_stash` 失败,前端会弹出后端返回的错误信息,便于排查。
|
||||||
|
|
||||||
|
- Release 与正式 FST 表
|
||||||
|
- 导入/临时编辑只影响前端状态和 `fst_stash`(通过 Release 或导入逻辑写入),不会立即写入正式 FST 标签表。
|
||||||
|
- 点击 Release 时:
|
||||||
|
- 创建版本记录。
|
||||||
|
- 将当前前端标签快照保存到 `fst_stash`。
|
||||||
|
- 之后再按现有逻辑逐条写入暂存表(通过 `/fst/stash/update`),由后端持久化。
|
||||||
|
|
||||||
|
### 临时编辑版本与正式版本
|
||||||
|
|
||||||
|
- 前端在编辑标签时,会使用一个「临时编辑版本」保存当前工作状态(例如 `v1_editing_temp`)。
|
||||||
|
- 临时编辑版本只存在于 `versions` 与 `fst_stash` 表中,不会在 Version List 页面展示。
|
||||||
|
- 这样可以反复编辑 / 导入 JSON,而不影响已发布的正式版本。
|
||||||
|
- 当用户在前端点击 Release:
|
||||||
|
- 会根据当前临时编辑版本的内容创建一个新的正式版本(例如 `v1.0.0`)。
|
||||||
|
- 将当前标签快照写入对应正式版本的 `fst_stash`。
|
||||||
|
- 临时编辑版本及其 `fst_stash` 会在发布成功后被清理掉。
|
||||||
|
- 如果后端已删除对应的临时编辑版本,而前端仍在使用:
|
||||||
|
- 前端在保存/发布时会收到后端错误。
|
||||||
|
- 界面会提示「当前编辑版本已被删除」,引导用户刷新后重新进入编辑流程。
|
||||||
|
|
||||||
|
### Version List 自动刷新
|
||||||
|
|
||||||
|
- Version List 页面展示所有正式版本(隐藏所有 `*_editing_*` 或 `_editing_temp` 的临时版本)。
|
||||||
|
- 刷新时机:
|
||||||
|
- 首次打开 Version List 页面时,会自动请求一次版本列表。
|
||||||
|
- 从其他页面切换回 Version List 标签页时,会再次刷新列表,保证看到的是最新版本。
|
||||||
|
- 当用户在「增改Fst Editor标签」页面完成 Release 后,会触发一个浏览器事件 `fst:release_success`:
|
||||||
|
- 如果此时 Version List 正在当前标签页,会立即刷新列表。
|
||||||
|
- 如果当时在别的标签页,切回 Version List 时也会自动刷新。
|
||||||
|
- 通过这种方式,用户在发布新版本后,无需手动刷新浏览器即可看到最新的版本记录。
|
||||||
|
|
||||||
|
你可以在 `App.jsx`、`services/api.js` 以及相关自定义 hooks 中按需要继续扩展实际的交互逻辑和接口调用。
|
||||||
29
nginx.conf
Normal file
29
nginx.conf
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://10.204.136.164:5232;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
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;
|
||||||
|
|
||||||
|
add_header Access-Control-Allow-Origin *;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS";
|
||||||
|
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
|
||||||
|
|
||||||
|
if ($request_method = OPTIONS) {
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
17280
package-lock.json
generated
Normal file
17280
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "fst-data-factory",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"lucide-react": "^0.294.0",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"proxy": "http://127.0.0.1:5232",
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
11994
pnpm-lock.yaml
generated
Normal file
11994
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
11
public/index.html
Normal file
11
public/index.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>FST Editor</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
674
src/App.css
Normal file
674
src/App.css
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
/* App.css */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 240px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #111827;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo-img {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-header:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-header svg {
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-header svg.expanded {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-header svg:not(.expanded) {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-submenu {
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-subitem {
|
||||||
|
padding: 10px 16px 10px calc(16px + 2ch);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-subitem:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-subitem.active {
|
||||||
|
background: #e5f1ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-header {
|
||||||
|
height: 50px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-profile {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-tooltip {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 30px;
|
||||||
|
right: -10px;
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-tooltip::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: 14px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: #333;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-profile:hover .user-tooltip {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions svg {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-container {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-right: 1px solid #e8e8e8;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: #e6f7ff;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close {
|
||||||
|
margin-left: 4px;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-dropdown,
|
||||||
|
.tabs-settings {
|
||||||
|
padding: 10px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-left: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
flex: 1;
|
||||||
|
background: #fff;
|
||||||
|
margin: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-scroll {
|
||||||
|
height: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-header h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-right-sticky {
|
||||||
|
position: sticky;
|
||||||
|
right: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: 16px;
|
||||||
|
background: #fff;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-subtext {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-subtext {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-subtext .link {
|
||||||
|
color: #1890ff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-subtext .link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover {
|
||||||
|
color: #1890ff;
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #40a9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
/* 让外层 page-scroll 负责滚动,这里不再额外横向滚动 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
table-layout: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table thead th,
|
||||||
|
.data-table tbody td {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.data-table thead {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody td:first-child {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table thead th:nth-child(4),
|
||||||
|
.data-table thead th:nth-child(5),
|
||||||
|
.data-table thead th:nth-child(6),
|
||||||
|
.data-table tbody td:nth-child(4),
|
||||||
|
.data-table tbody td:nth-child(5),
|
||||||
|
.data-table tbody td:nth-child(6) {
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table thead th:nth-child(3),
|
||||||
|
.data-table tbody td:nth-child(3) {
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table thead th:nth-child(2),
|
||||||
|
.data-table tbody td:nth-child(2) {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table thead th:nth-child(4),
|
||||||
|
.data-table tbody td:nth-child(4),
|
||||||
|
.data-table thead th:nth-child(5),
|
||||||
|
.data-table tbody td:nth-child(5) {
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table thead th:nth-child(6),
|
||||||
|
.data-table tbody td:nth-child(6) {
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-table thead th:nth-child(5),
|
||||||
|
.version-table tbody td:nth-child(5) {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:hover {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon,
|
||||||
|
.expand-placeholder {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon:hover {
|
||||||
|
background: #e6f7ff;
|
||||||
|
color: #1890ff;
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-placeholder {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #1890ff;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-table tbody td:first-child {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-table .action-buttons {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px 32px 28px;
|
||||||
|
width: 520px;
|
||||||
|
max-width: 90%;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-field {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-field label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-field input,
|
||||||
|
.modal-field textarea,
|
||||||
|
.modal-field select {
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-field .scene-select-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-field .scene-select {
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-dot-l2 {
|
||||||
|
left: 10px;
|
||||||
|
background-color: #36b37e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-dot-l3 {
|
||||||
|
left: 20px;
|
||||||
|
background-color: #ffab00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-select option[value='L2'] {
|
||||||
|
color: #36b37e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-select option[value='L3'] {
|
||||||
|
color: #ffab00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-multi-select {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 28px 4px 8px;
|
||||||
|
min-height: 34px;
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-multi-values {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-multi-placeholder {
|
||||||
|
color: #999999;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-multi-arrow {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-multi-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
min-width: 100%;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-multi-option {
|
||||||
|
padding: 6px 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-multi-option:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-multi-check {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-field input[readonly],
|
||||||
|
.modal-field textarea[readonly] {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #888;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
219
src/App.jsx
Normal file
219
src/App.jsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
// App.jsx
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
User,
|
||||||
|
ChevronDown,
|
||||||
|
X
|
||||||
|
} from 'lucide-react';
|
||||||
|
import './App.css';
|
||||||
|
import FstTagPage from './pages/FstTagPage';
|
||||||
|
import VersionListPage from './pages/VersionListPage';
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
// 页面组件注册表
|
||||||
|
const pages = {
|
||||||
|
'增改Fst Editor标签': FstTagPage,
|
||||||
|
'Version List': VersionListPage,
|
||||||
|
// 将来如果有新页面,只需在这里注册即可,例如:
|
||||||
|
// 'New Page Name': NewPageComponent,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 页面名称到 URL slug 的映射
|
||||||
|
const pageToSlug = {
|
||||||
|
'增改Fst Editor标签': 'editor',
|
||||||
|
'Version List': 'version-list'
|
||||||
|
};
|
||||||
|
|
||||||
|
// URL slug 到页面名称的映射 (反向)
|
||||||
|
const slugToPage = Object.fromEntries(
|
||||||
|
Object.entries(pageToSlug).map(([name, slug]) => [slug, name])
|
||||||
|
);
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState(() => {
|
||||||
|
// 初始化时从 URL 读取 activeTab
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const tabSlug = params.get('tab');
|
||||||
|
if (tabSlug && slugToPage[tabSlug]) {
|
||||||
|
return slugToPage[tabSlug];
|
||||||
|
}
|
||||||
|
return '增改Fst Editor标签';
|
||||||
|
});
|
||||||
|
|
||||||
|
const [expandedMenu, setExpandedMenu] = useState({
|
||||||
|
FST标签管理: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const [tabs, setTabs] = useState(() => {
|
||||||
|
// 初始化 tabs,确保 activeTab 在列表中
|
||||||
|
const initialTabName = activeTab; // 此时 activeTab 已经是根据 URL 算出的初始值
|
||||||
|
const defaultTabs = [
|
||||||
|
{ id: 1, name: '增改Fst Editor标签', closable: true },
|
||||||
|
{ id: 2, name: 'Version List', closable: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
// 如果 URL 指定的页面不在默认列表里(虽然当前场景都在),可以追加
|
||||||
|
// 这里简单起见,如果初始 activeTab 不在默认里,就追加进去
|
||||||
|
if (!defaultTabs.some(t => t.name === initialTabName)) {
|
||||||
|
return [...defaultTabs, { id: Date.now(), name: initialTabName, closable: true }];
|
||||||
|
}
|
||||||
|
return defaultTabs;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听 activeTab 变化,同步到 URL
|
||||||
|
useEffect(() => {
|
||||||
|
const slug = pageToSlug[activeTab];
|
||||||
|
if (slug) {
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.set('tab', slug);
|
||||||
|
window.history.pushState({}, '', url);
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
const openTab = (name) => {
|
||||||
|
setTabs((prev) => {
|
||||||
|
if (prev.some((tab) => tab.name === name)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const maxId = prev.reduce((max, tab) => (tab.id > max ? tab.id : max), 0);
|
||||||
|
return [...prev, { id: maxId + 1, name, closable: true }];
|
||||||
|
});
|
||||||
|
setActiveTab(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMenu = (menuName) => {
|
||||||
|
setExpandedMenu((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[menuName]: !prev[menuName]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeTab = (tabId, e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setTabs((prevTabs) => {
|
||||||
|
const closingTab = prevTabs.find((tab) => tab.id === tabId);
|
||||||
|
const remainingTabs = prevTabs.filter((tab) => tab.id !== tabId);
|
||||||
|
|
||||||
|
if (closingTab && closingTab.name === activeTab) {
|
||||||
|
if (remainingTabs.length > 0) {
|
||||||
|
const closingIndex = prevTabs.findIndex((tab) => tab.id === tabId);
|
||||||
|
const next =
|
||||||
|
prevTabs[closingIndex + 1] ||
|
||||||
|
prevTabs[closingIndex - 1] ||
|
||||||
|
remainingTabs[0];
|
||||||
|
setActiveTab(next.name);
|
||||||
|
} else {
|
||||||
|
setActiveTab('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return remainingTabs;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-container">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar-header">
|
||||||
|
<img src="/logo.png" alt="logo" className="sidebar-logo-img" />
|
||||||
|
<span>FST Editor</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="sidebar-nav">
|
||||||
|
<div className="nav-item">
|
||||||
|
<div
|
||||||
|
className="nav-item-header"
|
||||||
|
onClick={() => {
|
||||||
|
toggleMenu('FST标签管理');
|
||||||
|
openTab('增改Fst Editor标签');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={expandedMenu.FST标签管理 ? 'expanded' : ''}
|
||||||
|
/>
|
||||||
|
<span>FST Editor标签管理</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedMenu.FST标签管理 && (
|
||||||
|
<div className="nav-submenu">
|
||||||
|
<div
|
||||||
|
className={`nav-subitem ${
|
||||||
|
activeTab === '增改Fst Editor标签' ? 'active' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => openTab('增改Fst Editor标签')}
|
||||||
|
>
|
||||||
|
增改Fst Editor标签
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`nav-subitem ${
|
||||||
|
activeTab === 'Version List' ? 'active' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => openTab('Version List')}
|
||||||
|
>
|
||||||
|
Version List
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="main-content">
|
||||||
|
<header className="top-header">
|
||||||
|
<div className="header-actions">
|
||||||
|
<div className="user-profile">
|
||||||
|
<User size={20} />
|
||||||
|
<div className="user-tooltip">用户中心</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="tabs-container">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
className={`tab ${activeTab === tab.name ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab(tab.name)}
|
||||||
|
>
|
||||||
|
<span className="tab-icon">📄</span>
|
||||||
|
<span className="tab-name">{tab.name}</span>
|
||||||
|
{tab.closable && (
|
||||||
|
<X
|
||||||
|
size={14}
|
||||||
|
className="tab-close"
|
||||||
|
onClick={(e) => closeTab(tab.id, e)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="tabs-dropdown">
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="content-area">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const Component = pages[tab.name];
|
||||||
|
if (!Component) return null;
|
||||||
|
const isActive = activeTab === tab.name;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
style={{
|
||||||
|
display: isActive ? 'block' : 'none',
|
||||||
|
height: '100%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="page-scroll">
|
||||||
|
<Component isActive={isActive} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
95
src/components/fst/ChildTagModal.jsx
Normal file
95
src/components/fst/ChildTagModal.jsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { SceneMultiSelect, TagGroupMultiSelect } from './EditTagModal';
|
||||||
|
|
||||||
|
const ChildTagModal = ({
|
||||||
|
open,
|
||||||
|
form,
|
||||||
|
parentName,
|
||||||
|
level,
|
||||||
|
onChangeForm,
|
||||||
|
onCancel,
|
||||||
|
onConfirm
|
||||||
|
}) => {
|
||||||
|
if (!open) return null;
|
||||||
|
const safeForm = form || { name: '', desc: '', scene: '', taggroup: '' };
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop">
|
||||||
|
<div className="modal">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>新建子标签</h3>
|
||||||
|
<button className="modal-close" onClick={onCancel}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>新标签名称</label>
|
||||||
|
<input
|
||||||
|
value={safeForm.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChangeForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
name: e.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>父标签名称</label>
|
||||||
|
<input value={parentName || ''} readOnly />
|
||||||
|
</div>
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>级别</label>
|
||||||
|
<input value={level} readOnly />
|
||||||
|
</div>
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>场景</label>
|
||||||
|
<SceneMultiSelect
|
||||||
|
value={safeForm.scene}
|
||||||
|
onChange={(val) =>
|
||||||
|
onChangeForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
scene: val
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>TagGroup</label>
|
||||||
|
<TagGroupMultiSelect
|
||||||
|
value={safeForm.taggroup}
|
||||||
|
onChange={(val) =>
|
||||||
|
onChangeForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
taggroup: val
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>备注</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={safeForm.desc}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChangeForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
desc: e.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="btn btn-outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={onConfirm}>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChildTagModal;
|
||||||
32
src/components/fst/DeleteConfirmModal.jsx
Normal file
32
src/components/fst/DeleteConfirmModal.jsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
const DeleteConfirmModal = ({ open, title, message, onCancel, onConfirm }) => {
|
||||||
|
if (!open) return null;
|
||||||
|
const header = title || '确认删除';
|
||||||
|
const content =
|
||||||
|
message || '删除该标签会一并删除其所有子标签,确认删除吗?';
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop">
|
||||||
|
<div className="modal">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>{header}</h3>
|
||||||
|
<button className="modal-close" onClick={onCancel}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<p>{content}</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="btn btn-outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={onConfirm}>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteConfirmModal;
|
||||||
|
|
||||||
229
src/components/fst/EditTagModal.jsx
Normal file
229
src/components/fst/EditTagModal.jsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
const MultiTagSelect = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
getColor
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef(null);
|
||||||
|
const raw = typeof value === 'string' ? value : '';
|
||||||
|
const parts = raw
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
if (!ref.current.contains(event.target)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const togglePart = (target) => {
|
||||||
|
const exists = parts.includes(target);
|
||||||
|
const next = exists ? parts.filter((p) => p !== target) : [...parts, target];
|
||||||
|
onChange(next.join(','));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
onChange('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const tagOrder = Array.isArray(options) ? options : [];
|
||||||
|
const orderedParts = tagOrder.filter((p) => parts.includes(p));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`scene-multi-select ${open ? 'scene-multi-open' : ''}`}
|
||||||
|
ref={ref}
|
||||||
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<div className="scene-multi-values">
|
||||||
|
{orderedParts.length === 0 ? (
|
||||||
|
<span className="scene-multi-placeholder">{placeholder}</span>
|
||||||
|
) : (
|
||||||
|
orderedParts.map((tag) => {
|
||||||
|
const color = getColor(tag);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0 6px',
|
||||||
|
marginRight: 4,
|
||||||
|
marginTop: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: '18px',
|
||||||
|
backgroundColor: `${color}20`,
|
||||||
|
color,
|
||||||
|
border: `1px solid ${color}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="scene-multi-arrow">{open ? '▴' : '▾'}</span>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="scene-multi-dropdown"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="scene-multi-option"
|
||||||
|
onClick={() => {
|
||||||
|
clearAll();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{placeholder}</span>
|
||||||
|
{parts.length === 0 && <span className="scene-multi-check">✓</span>}
|
||||||
|
</div>
|
||||||
|
{tagOrder.map((tag) => {
|
||||||
|
const color = getColor(tag);
|
||||||
|
const active = parts.includes(tag);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tag}
|
||||||
|
className="scene-multi-option"
|
||||||
|
onClick={() => {
|
||||||
|
togglePart(tag);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color,
|
||||||
|
fontWeight: active ? 600 : 400
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
{active && <span className="scene-multi-check">✓</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSceneColor = (scene) => {
|
||||||
|
if (scene === 'L2') return '#36B37E';
|
||||||
|
if (scene === 'L3') return '#FFAB00';
|
||||||
|
return '#64748b';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTagGroupColor = (tagGroup) => {
|
||||||
|
if (tagGroup === 'Parking') return '#0052CC';
|
||||||
|
if (tagGroup === 'Driving') return '#FF5630';
|
||||||
|
return '#64748b';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SceneMultiSelect = ({ value, onChange }) => (
|
||||||
|
<MultiTagSelect
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
options={['L2', 'L3']}
|
||||||
|
placeholder="无场景"
|
||||||
|
getColor={getSceneColor}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TagGroupMultiSelect = ({ value, onChange }) => (
|
||||||
|
<MultiTagSelect
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
options={['Parking', 'Driving']}
|
||||||
|
placeholder="无标签组"
|
||||||
|
getColor={getTagGroupColor}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const EditTagModal = ({
|
||||||
|
open,
|
||||||
|
form,
|
||||||
|
treeLevel,
|
||||||
|
parentName,
|
||||||
|
onChangeField,
|
||||||
|
onCancel,
|
||||||
|
onSave
|
||||||
|
}) => {
|
||||||
|
if (!open) return null;
|
||||||
|
const safeForm = form || { stsName: '', level: '', scene: 'L2', taggroup: '' };
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop">
|
||||||
|
<div className="modal">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>修改标签</h3>
|
||||||
|
<button className="modal-close" onClick={onCancel}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>名称</label>
|
||||||
|
<input
|
||||||
|
value={safeForm.stsName}
|
||||||
|
onChange={(e) => onChangeField('stsName', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>级别</label>
|
||||||
|
<input value={treeLevel} readOnly />
|
||||||
|
</div>
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>父标签名称</label>
|
||||||
|
<input value={parentName || ''} readOnly />
|
||||||
|
</div>
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>场景</label>
|
||||||
|
<SceneMultiSelect
|
||||||
|
value={safeForm.scene}
|
||||||
|
onChange={(val) => onChangeField('scene', val)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>TagGroup</label>
|
||||||
|
<TagGroupMultiSelect
|
||||||
|
value={safeForm.taggroup}
|
||||||
|
onChange={(val) => onChangeField('taggroup', val)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>备注</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={safeForm.level}
|
||||||
|
onChange={(e) => onChangeField('level', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="btn btn-outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={onSave}>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditTagModal;
|
||||||
249
src/components/fst/FstTable.jsx
Normal file
249
src/components/fst/FstTable.jsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
const FstTable = ({
|
||||||
|
rows,
|
||||||
|
expandedIds,
|
||||||
|
onToggleExpand,
|
||||||
|
onAddChild,
|
||||||
|
onEdit,
|
||||||
|
onDelete
|
||||||
|
}) => {
|
||||||
|
const data = Array.isArray(rows) ? rows : [];
|
||||||
|
return (
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{[
|
||||||
|
{ key: 'name', title: 'Name' },
|
||||||
|
{ key: 'scene', title: 'Scene' },
|
||||||
|
{ key: 'taggroup', title: 'TagGroup' },
|
||||||
|
{ key: 'levelnum', title: 'Level' },
|
||||||
|
{ key: 'desc', title: 'Description' },
|
||||||
|
{ key: 'create', title: 'Createtime' },
|
||||||
|
{ key: 'update', title: 'Updatetime' },
|
||||||
|
{ key: 'action', title: 'Operation' }
|
||||||
|
].map((col) => (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
style={
|
||||||
|
col.key === 'name' || col.key === 'action'
|
||||||
|
? { textAlign: 'left' }
|
||||||
|
: col.key === 'update'
|
||||||
|
? { whiteSpace: 'nowrap' }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{col.title}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((row) => {
|
||||||
|
if (row.deleted === 1) return null;
|
||||||
|
let parentId = row.parentId;
|
||||||
|
let visible = true;
|
||||||
|
while (parentId) {
|
||||||
|
const parent = data.find((r) => r.id === parentId);
|
||||||
|
if (!parent) break;
|
||||||
|
if (!expandedIds.includes(parent.id)) {
|
||||||
|
visible = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
parentId = parent.parentId;
|
||||||
|
}
|
||||||
|
if (!visible) return null;
|
||||||
|
const hasChildren = data.some(
|
||||||
|
(r) => r.parentId === row.id && r.deleted === 0
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<tr key={row.id}>
|
||||||
|
{[
|
||||||
|
'name',
|
||||||
|
'scene',
|
||||||
|
'taggroup',
|
||||||
|
'levelnum',
|
||||||
|
'desc',
|
||||||
|
'create',
|
||||||
|
'update',
|
||||||
|
'action'
|
||||||
|
].map((key) => {
|
||||||
|
if (key === 'name') {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key="name"
|
||||||
|
className="text-primary"
|
||||||
|
style={{
|
||||||
|
textAlign: 'left',
|
||||||
|
...(row.treeLevel
|
||||||
|
? { paddingLeft: 16 * Number(row.treeLevel) }
|
||||||
|
: {})
|
||||||
|
}}
|
||||||
|
title={row.stsName}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
hasChildren ? 'expand-icon' : 'expand-placeholder'
|
||||||
|
}
|
||||||
|
onClick={
|
||||||
|
hasChildren
|
||||||
|
? (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleExpand(row.id);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hasChildren
|
||||||
|
? expandedIds.includes(row.id)
|
||||||
|
? '▾'
|
||||||
|
: '▸'
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
{row.stsName}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (key === 'scene') {
|
||||||
|
const raw = row.scene || '';
|
||||||
|
const parts = raw
|
||||||
|
.split(',')
|
||||||
|
.map((s) => String(s).trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const getColor = (value) => {
|
||||||
|
if (value === 'L2') return '#36B37E';
|
||||||
|
if (value === 'L3') return '#FFAB00';
|
||||||
|
return '#64748b';
|
||||||
|
};
|
||||||
|
if (!parts.length) {
|
||||||
|
return <td key="scene"></td>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<td key="scene">
|
||||||
|
{parts.map((value, index) => (
|
||||||
|
<span
|
||||||
|
key={`${value}-${index}`}
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '0 6px',
|
||||||
|
marginRight: 4,
|
||||||
|
borderRadius: 999,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: '18px',
|
||||||
|
backgroundColor: `${getColor(value)}20`,
|
||||||
|
color: getColor(value),
|
||||||
|
border: `1px solid ${getColor(value)}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (key === 'taggroup') {
|
||||||
|
const raw = row.taggroup || '';
|
||||||
|
const parts = raw
|
||||||
|
.split(',')
|
||||||
|
.map((s) => String(s).trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const getColor = (value) => {
|
||||||
|
if (value === 'Parking') return '#0052CC';
|
||||||
|
if (value === 'Driving') return '#FF5630';
|
||||||
|
return '#64748b';
|
||||||
|
};
|
||||||
|
if (!parts.length) {
|
||||||
|
return <td key="taggroup"></td>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<td key="taggroup">
|
||||||
|
{parts.map((value, index) => (
|
||||||
|
<span
|
||||||
|
key={`${value}-${index}`}
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '0 6px',
|
||||||
|
marginRight: 4,
|
||||||
|
borderRadius: 999,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: '18px',
|
||||||
|
backgroundColor: `${getColor(value)}20`,
|
||||||
|
color: getColor(value),
|
||||||
|
border: `1px solid ${getColor(value)}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (key === 'levelnum') {
|
||||||
|
return <td key="levelnum">{row.treeLevel || 1}</td>;
|
||||||
|
}
|
||||||
|
if (key === 'desc') {
|
||||||
|
return (
|
||||||
|
<td key="desc" title={row.level}>
|
||||||
|
{row.level}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (key === 'create') {
|
||||||
|
return <td key="create">{row.createtime}</td>;
|
||||||
|
}
|
||||||
|
if (key === 'update') {
|
||||||
|
return <td key="update" style={{ whiteSpace: 'nowrap' }}>{row.updateTime}</td>;
|
||||||
|
}
|
||||||
|
if (key === 'action') {
|
||||||
|
return (
|
||||||
|
<td key="action" style={{ textAlign: 'left' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
overflowX: 'auto',
|
||||||
|
maxWidth: '100%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="action-buttons"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
gap: 12,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
minWidth: 220,
|
||||||
|
padding: '2px 0'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="btn-link"
|
||||||
|
onClick={() => onAddChild(row.id)}
|
||||||
|
>
|
||||||
|
新增下级
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-link"
|
||||||
|
onClick={() => onEdit(row.id)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-link btn-danger"
|
||||||
|
onClick={() => onDelete(row.id)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FstTable;
|
||||||
44
src/components/fst/FstToolbar.jsx
Normal file
44
src/components/fst/FstToolbar.jsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
const FstToolbar = ({
|
||||||
|
onCreateNew,
|
||||||
|
onImportClick,
|
||||||
|
onReleaseClick,
|
||||||
|
onShowImportExample,
|
||||||
|
noVersionTip
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="content-header">
|
||||||
|
<h2>Fst Editor标签</h2>
|
||||||
|
<div className="toolbar-right-sticky">
|
||||||
|
<div className="content-actions toolbar-actions">
|
||||||
|
<button className="btn btn-outline" onClick={onCreateNew}>
|
||||||
|
新建一级标签
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={onImportClick}>
|
||||||
|
导入
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={onReleaseClick}>
|
||||||
|
Release
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="content-subtext toolbar-subtext">
|
||||||
|
无标签数据?
|
||||||
|
<a
|
||||||
|
href="javascript:;"
|
||||||
|
className="link"
|
||||||
|
onClick={onShowImportExample}
|
||||||
|
>
|
||||||
|
导入示例数据
|
||||||
|
</a>
|
||||||
|
{noVersionTip && (
|
||||||
|
<div style={{ marginTop: '4px', color: '#f97316' }}>
|
||||||
|
当前没有任何版本,请先通过“导入”创建一个版本
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FstToolbar;
|
||||||
|
|
||||||
38
src/components/fst/InfoModal.jsx
Normal file
38
src/components/fst/InfoModal.jsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const InfoModal = ({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
bodyStyle,
|
||||||
|
closable = true
|
||||||
|
}) => {
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop">
|
||||||
|
<div className="modal">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
{closable && (
|
||||||
|
<button className="modal-close" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="modal-body" style={bodyStyle}>
|
||||||
|
{content ? <p>{content}</p> : children}
|
||||||
|
</div>
|
||||||
|
{closable && (
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="btn btn-primary" onClick={onClose}>
|
||||||
|
确定
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InfoModal;
|
||||||
|
|
||||||
86
src/components/fst/ReleaseDiffPanel.jsx
Normal file
86
src/components/fst/ReleaseDiffPanel.jsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
const ReleaseDiffPanel = ({ diff, expanded, onToggleSection }) => {
|
||||||
|
if (!diff) return null;
|
||||||
|
const summary = diff.summary || {};
|
||||||
|
const added = Array.isArray(diff.added) ? diff.added : [];
|
||||||
|
const removed = Array.isArray(diff.removed) ? diff.removed : [];
|
||||||
|
const changed = Array.isArray(diff.changed) ? diff.changed : [];
|
||||||
|
const addedCount =
|
||||||
|
typeof summary.added_count === 'number' ? summary.added_count : added.length;
|
||||||
|
const removedCount =
|
||||||
|
typeof summary.removed_count === 'number'
|
||||||
|
? summary.removed_count
|
||||||
|
: removed.length;
|
||||||
|
const changedCount =
|
||||||
|
typeof summary.changed_count === 'number'
|
||||||
|
? summary.changed_count
|
||||||
|
: changed.length;
|
||||||
|
const maxItems = 5;
|
||||||
|
const displayFromVersion = (() => {
|
||||||
|
const from = summary.from_version;
|
||||||
|
if (!from) return '无';
|
||||||
|
const lower = String(from).toLowerCase();
|
||||||
|
if (
|
||||||
|
lower === 'fst' ||
|
||||||
|
lower.startsWith('fst_') ||
|
||||||
|
lower.endsWith('_editing_temp')
|
||||||
|
) {
|
||||||
|
return '无';
|
||||||
|
}
|
||||||
|
return from;
|
||||||
|
})();
|
||||||
|
const formatPath = (item) => {
|
||||||
|
if (!item) return '';
|
||||||
|
if (typeof item === 'string') return item;
|
||||||
|
if (item.path) return item.path;
|
||||||
|
if (item.node && item.node.path) return item.node.path;
|
||||||
|
if (item.node && item.node.name) return item.node.name;
|
||||||
|
if (item.before && item.after && item.before.name && item.after.name) {
|
||||||
|
return `${item.before.name} -> ${item.after.name}`;
|
||||||
|
}
|
||||||
|
return JSON.stringify(item);
|
||||||
|
};
|
||||||
|
const renderList = (title, key, items, total) => {
|
||||||
|
if (!items.length && !total) return null;
|
||||||
|
const displayTotal = typeof total === 'number' ? total : items.length;
|
||||||
|
const isExpanded = !!(expanded && expanded[key]);
|
||||||
|
const displayItems = isExpanded ? items : items.slice(0, maxItems);
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<div>{`${title}(${displayTotal}):`}</div>
|
||||||
|
<ul>
|
||||||
|
{displayItems.map((item, index) => (
|
||||||
|
<li key={`${title}-${index}`}>{formatPath(item)}</li>
|
||||||
|
))}
|
||||||
|
{displayTotal > maxItems && (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-link"
|
||||||
|
onClick={() => onToggleSection && onToggleSection(key)}
|
||||||
|
>
|
||||||
|
{isExpanded ? '收起' : `显示更多(共 ${displayTotal} 条)`}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: '12px', textAlign: 'left' }}>
|
||||||
|
<h4 style={{ marginBottom: '8px' }}>与上一版本的差异:</h4>
|
||||||
|
<ul>
|
||||||
|
<li>对比版本:{displayFromVersion} → {summary.to_version}</li>
|
||||||
|
<li>新增标签:{addedCount}</li>
|
||||||
|
<li>删除标签:{removedCount}</li>
|
||||||
|
<li>修改标签:{changedCount}</li>
|
||||||
|
</ul>
|
||||||
|
{renderList('新增标签', 'added', added, addedCount)}
|
||||||
|
{renderList('删除标签', 'removed', removed, removedCount)}
|
||||||
|
{renderList('修改标签', 'changed', changed, changedCount)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReleaseDiffPanel;
|
||||||
|
|
||||||
78
src/components/fst/ReleaseModal.jsx
Normal file
78
src/components/fst/ReleaseModal.jsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
const ReleaseModal = ({
|
||||||
|
open,
|
||||||
|
noVersionTip,
|
||||||
|
releaseForm,
|
||||||
|
onChangeReleaseForm,
|
||||||
|
onCancel,
|
||||||
|
onConfirm
|
||||||
|
}) => {
|
||||||
|
if (!open) return null;
|
||||||
|
const safeForm = releaseForm || { versionName: '', remark: '' };
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop">
|
||||||
|
<div className="modal">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>发布版本</h3>
|
||||||
|
<button className="modal-close" onClick={onCancel}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
{noVersionTip && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '12px',
|
||||||
|
padding: '8px 10px',
|
||||||
|
backgroundColor: '#FFFBEB',
|
||||||
|
border: '1px solid #FBBF24',
|
||||||
|
color: '#92400E',
|
||||||
|
fontSize: '13px',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚠️ 当前系统中无历史版本,本次发布将作为首个正式版本。
|
||||||
|
{safeForm.versionName && safeForm.versionName.trim() && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
差异统计:无 → {safeForm.versionName.trim()}
|
||||||
|
(新增全部标签)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>版本号</label>
|
||||||
|
<input
|
||||||
|
value={safeForm.versionName}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>备注</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={safeForm.remark}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChangeReleaseForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
remark: e.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="btn btn-outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={onConfirm}>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReleaseModal;
|
||||||
|
|
||||||
83
src/components/fst/RootTagModal.jsx
Normal file
83
src/components/fst/RootTagModal.jsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { SceneMultiSelect, TagGroupMultiSelect } from './EditTagModal';
|
||||||
|
|
||||||
|
const RootTagModal = ({ open, form, onChangeForm, onCancel, onConfirm }) => {
|
||||||
|
if (!open) return null;
|
||||||
|
const safeForm = form || { name: '', desc: '', scene: '', taggroup: '' };
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop">
|
||||||
|
<div className="modal">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>新建一级标签</h3>
|
||||||
|
<button className="modal-close" onClick={onCancel}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>新标签名称</label>
|
||||||
|
<input
|
||||||
|
value={safeForm.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChangeForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
name: e.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>级别</label>
|
||||||
|
<input value={1} readOnly />
|
||||||
|
</div>
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>场景</label>
|
||||||
|
<SceneMultiSelect
|
||||||
|
value={safeForm.scene}
|
||||||
|
onChange={(val) =>
|
||||||
|
onChangeForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
scene: val
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>TagGroup</label>
|
||||||
|
<TagGroupMultiSelect
|
||||||
|
value={safeForm.taggroup}
|
||||||
|
onChange={(val) =>
|
||||||
|
onChangeForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
taggroup: val
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>备注</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={safeForm.desc}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChangeForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
desc: e.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="btn btn-outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={onConfirm}>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RootTagModal;
|
||||||
407
src/components/version/VersionListView.jsx
Normal file
407
src/components/version/VersionListView.jsx
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import useVersionList from '../../hooks/useVersionList';
|
||||||
|
import InfoModal from '../fst/InfoModal';
|
||||||
|
|
||||||
|
// const FEISHU_WIKI_URL =
|
||||||
|
// 'https://mercedes-benz.feishu.cn/wiki/KoZswPdy4ickj5kM0CucLa7FnKh';
|
||||||
|
|
||||||
|
const VersionListView = ({ isActive }) => {
|
||||||
|
const { versions, refreshVersions } = useVersionList(isActive);
|
||||||
|
const [exportTarget, setExportTarget] = useState(null);
|
||||||
|
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||||
|
const [activateTarget, setActivateTarget] = useState(null);
|
||||||
|
const [isActivateModalOpen, setIsActivateModalOpen] = useState(false);
|
||||||
|
const [activatingVersionId, setActivatingVersionId] = useState(null);
|
||||||
|
const [isFeishuSyncDoneOpen, setIsFeishuSyncDoneOpen] = useState(false);
|
||||||
|
const [watchReleaseVersionName, setWatchReleaseVersionName] = useState('');
|
||||||
|
|
||||||
|
const sleep = (ms) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
window.setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isFeishuSynced = (status) => {
|
||||||
|
const normalized = String(status || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
return (
|
||||||
|
normalized === 'synced' ||
|
||||||
|
normalized === 'success' ||
|
||||||
|
normalized === 'done' ||
|
||||||
|
normalized === '1' ||
|
||||||
|
normalized === '已同步'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isReleaseRowSynced = (row) => {
|
||||||
|
if (!row) return false;
|
||||||
|
const label = String(row.feishuSyncLabel || '').trim();
|
||||||
|
return row.status === '已生效' && (label === '已同步' || isFeishuSynced(row.feishuSyncStatus));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleReleaseSuccess = (event) => {
|
||||||
|
const releasedVersion = String(event?.detail?.version || '').trim();
|
||||||
|
if (!releasedVersion) return;
|
||||||
|
setWatchReleaseVersionName(releasedVersion);
|
||||||
|
setIsFeishuSyncDoneOpen(false);
|
||||||
|
refreshVersions();
|
||||||
|
};
|
||||||
|
window.addEventListener('fst:release_success', handleReleaseSuccess);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('fst:release_success', handleReleaseSuccess);
|
||||||
|
};
|
||||||
|
}, [refreshVersions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const targetVersion = watchReleaseVersionName.trim();
|
||||||
|
if (!isActive || !targetVersion) return;
|
||||||
|
|
||||||
|
const matched = (versions || []).find(
|
||||||
|
(row) => String(row.versionName || '').trim() === targetVersion
|
||||||
|
);
|
||||||
|
if (isReleaseRowSynced(matched)) {
|
||||||
|
setIsFeishuSyncDoneOpen(true);
|
||||||
|
setWatchReleaseVersionName('');
|
||||||
|
}
|
||||||
|
}, [versions, isActive, watchReleaseVersionName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const targetVersion = watchReleaseVersionName.trim();
|
||||||
|
if (!isActive || !targetVersion) return;
|
||||||
|
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
refreshVersions();
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [isActive, watchReleaseVersionName, refreshVersions]);
|
||||||
|
|
||||||
|
const waitFeishuSyncDone = async (id) => {
|
||||||
|
const maxRounds = 40;
|
||||||
|
const intervalMs = 3000;
|
||||||
|
for (let i = 0; i < maxRounds; i += 1) {
|
||||||
|
const latest = await refreshVersions();
|
||||||
|
const target = (latest || []).find((item) => item.id === id);
|
||||||
|
if (target && isFeishuSynced(target.feishuSyncStatus)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (i < maxRounds - 1) {
|
||||||
|
await sleep(intervalMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVersionExport = async (version) => {
|
||||||
|
try {
|
||||||
|
const stash = await api.getFstStash(
|
||||||
|
version.versionName || version.version || ''
|
||||||
|
);
|
||||||
|
const content =
|
||||||
|
stash && typeof stash.content !== 'undefined' ? stash.content : stash;
|
||||||
|
let exportContent = content;
|
||||||
|
if (exportContent && Array.isArray(exportContent.nodes)) {
|
||||||
|
const nodes = exportContent.nodes || [];
|
||||||
|
const hasNested = nodes.some(
|
||||||
|
(node) => Array.isArray(node.children) && node.children.length > 0
|
||||||
|
);
|
||||||
|
const hasIdOrParent = nodes.some(
|
||||||
|
(node) =>
|
||||||
|
typeof node.id !== 'undefined' ||
|
||||||
|
typeof node.parentId !== 'undefined'
|
||||||
|
);
|
||||||
|
if (!hasNested && hasIdOrParent) {
|
||||||
|
const map = {};
|
||||||
|
const roots = [];
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
if (typeof n.id === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
map[n.id] = {
|
||||||
|
...n,
|
||||||
|
Description: n.Description || n.level || '',
|
||||||
|
children: []
|
||||||
|
};
|
||||||
|
});
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
if (typeof n.id === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const node = map[n.id];
|
||||||
|
if (n.parentId && map[n.parentId]) {
|
||||||
|
map[n.parentId].children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
exportContent = {
|
||||||
|
...exportContent,
|
||||||
|
nodes: roots
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const jsonText = JSON.stringify(exportContent, null, 2);
|
||||||
|
const blob = new Blob([jsonText], {
|
||||||
|
type: 'application/json'
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `Version_${version.versionName || version.id || 'export'}.json`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导出版本文件失败', error);
|
||||||
|
window.alert('导出版本文件失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openExportModal = (version) => {
|
||||||
|
setExportTarget(version);
|
||||||
|
setIsExportModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportCancel = () => {
|
||||||
|
setIsExportModalOpen(false);
|
||||||
|
setExportTarget(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportConfirm = async () => {
|
||||||
|
if (!exportTarget) {
|
||||||
|
handleExportCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleVersionExport(exportTarget);
|
||||||
|
handleExportCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVersionActivate = async (id) => {
|
||||||
|
const currentList = versions || [];
|
||||||
|
const target = currentList.find((item) => item.id === id);
|
||||||
|
if (!target || activatingVersionId !== null) return;
|
||||||
|
if (target.status === '已生效') {
|
||||||
|
window.alert('当前版本已生效');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActivatingVersionId(id);
|
||||||
|
try {
|
||||||
|
await api.activateVersion(id, {});
|
||||||
|
const latest = await refreshVersions();
|
||||||
|
const stillActiveOthers = (latest || []).filter(
|
||||||
|
(item) => item.id !== id && item.status === '已生效'
|
||||||
|
);
|
||||||
|
if (stillActiveOthers.length > 0) {
|
||||||
|
await Promise.all(
|
||||||
|
stillActiveOthers.map((item) =>
|
||||||
|
api.updateVersion(item.id, { status: '未生效' }).catch(() => null)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await refreshVersions();
|
||||||
|
setExportTarget(null);
|
||||||
|
setActivateTarget(null);
|
||||||
|
setIsExportModalOpen(false);
|
||||||
|
setIsActivateModalOpen(false);
|
||||||
|
|
||||||
|
window.alert('版本已生效,飞书同步任务已开始。');
|
||||||
|
const synced = await waitFeishuSyncDone(id);
|
||||||
|
if (synced) {
|
||||||
|
setIsFeishuSyncDoneOpen(true);
|
||||||
|
} else {
|
||||||
|
window.alert('版本已生效,飞书同步仍在进行中,请稍后查看。');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('版本生效失败', error);
|
||||||
|
window.alert('版本生效失败');
|
||||||
|
} finally {
|
||||||
|
setActivatingVersionId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openActivateModal = (version) => {
|
||||||
|
setActivateTarget(version);
|
||||||
|
setIsActivateModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActivateCancel = () => {
|
||||||
|
if (activatingVersionId !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsActivateModalOpen(false);
|
||||||
|
setActivateTarget(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActivateConfirm = async () => {
|
||||||
|
if (!activateTarget) {
|
||||||
|
handleActivateCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleVersionActivate(activateTarget.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="content-header">
|
||||||
|
<h2>Version 列表</h2>
|
||||||
|
</div>
|
||||||
|
<div className="table-container">
|
||||||
|
{isExportModalOpen && (
|
||||||
|
<div className="modal-backdrop">
|
||||||
|
<div className="modal">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>导出版本</h3>
|
||||||
|
<button className="modal-close" onClick={handleExportCancel}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<p>
|
||||||
|
确认导出版本{' '}
|
||||||
|
{exportTarget ? exportTarget.versionName || exportTarget.id : ''}
|
||||||
|
的标签快照吗?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="btn-link" onClick={handleExportCancel}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button className="btn-link" onClick={handleExportConfirm}>
|
||||||
|
确认导出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isActivateModalOpen && (
|
||||||
|
<div className="modal-backdrop">
|
||||||
|
<div className="modal">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>版本生效</h3>
|
||||||
|
<button
|
||||||
|
className="modal-close"
|
||||||
|
onClick={handleActivateCancel}
|
||||||
|
disabled={activatingVersionId !== null}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<p>
|
||||||
|
确认将版本{' '}
|
||||||
|
{activateTarget
|
||||||
|
? activateTarget.versionName || activateTarget.id
|
||||||
|
: ''}
|
||||||
|
标记为已生效吗?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button
|
||||||
|
className="btn-link"
|
||||||
|
onClick={handleActivateCancel}
|
||||||
|
disabled={activatingVersionId !== null}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-link"
|
||||||
|
onClick={handleActivateConfirm}
|
||||||
|
disabled={activatingVersionId !== null}
|
||||||
|
>
|
||||||
|
{activatingVersionId !== null ? '生效中...' : '确认生效'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isFeishuSyncDoneOpen && (
|
||||||
|
<InfoModal
|
||||||
|
open={isFeishuSyncDoneOpen}
|
||||||
|
title="飞书同步"
|
||||||
|
content="飞书同步完成"
|
||||||
|
onClose={() => setIsFeishuSyncDoneOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<table className="data-table version-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Version Name</th>
|
||||||
|
<th>Create Time</th>
|
||||||
|
<th>Update Time</th>
|
||||||
|
<th>Status</th>
|
||||||
|
{/* <th>Feishu Sync</th> */}
|
||||||
|
<th style={{ textAlign: 'left' }}>Operation</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{versions.map((row) => (
|
||||||
|
<tr key={row.id || row.versionName}>
|
||||||
|
<td>{row.versionName}</td>
|
||||||
|
<td>{row.create_time}</td>
|
||||||
|
<td>{row.update_time}</td>
|
||||||
|
<td>{row.status}</td>
|
||||||
|
{/*
|
||||||
|
<td>
|
||||||
|
{(() => {
|
||||||
|
const label = String(row.feishuSyncLabel || '').trim();
|
||||||
|
const synced = label === '已同步' || isFeishuSynced(row.feishuSyncStatus);
|
||||||
|
const showLink = synced && row.status === '已生效';
|
||||||
|
const displayLabel = label || '-';
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span>{displayLabel}</span>
|
||||||
|
{showLink && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<a
|
||||||
|
href={FEISHU_WIKI_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
链接
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</td>
|
||||||
|
*/}
|
||||||
|
<td style={{ textAlign: 'left' }}>
|
||||||
|
<div className="action-buttons">
|
||||||
|
{(row.status === '已生效' || row.status === '未生效') && (
|
||||||
|
<button
|
||||||
|
className="btn-link"
|
||||||
|
onClick={() => openExportModal(row)}
|
||||||
|
>
|
||||||
|
导出
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn-link"
|
||||||
|
disabled={activatingVersionId !== null || row.status === '已生效'}
|
||||||
|
onClick={() => openActivateModal(row)}
|
||||||
|
>
|
||||||
|
{row.status === '已生效'
|
||||||
|
? '已生效'
|
||||||
|
: activatingVersionId === row.id
|
||||||
|
? '生效中...'
|
||||||
|
: '生效'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VersionListView;
|
||||||
400
src/hooks/useFstImport.js
Normal file
400
src/hooks/useFstImport.js
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
import { useState, useRef, useCallback } from 'react';
|
||||||
|
import api from '../services/api';
|
||||||
|
import {
|
||||||
|
buildNextVersionName,
|
||||||
|
collectChangedTreeLevelsFromBaseAndRows
|
||||||
|
} from './useFstRelease';
|
||||||
|
|
||||||
|
const formatDateTime = () => {
|
||||||
|
const date = new Date();
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeSceneValue = (value) => {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map((item) => String(item || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(',');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeTagGroupValue = (value) => {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map((item) => String(item || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(',');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const useFstImport = ({
|
||||||
|
setRows,
|
||||||
|
setExpandedIds,
|
||||||
|
setEditingId,
|
||||||
|
getCurrentVersion,
|
||||||
|
onImportError
|
||||||
|
}) => {
|
||||||
|
const [isImportSuccessOpen, setIsImportSuccessOpen] = useState(false);
|
||||||
|
const [isImportExampleOpen, setIsImportExampleOpen] = useState(false);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
const handleImportClick = useCallback(() => {
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
fileInputRef.current.click();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openImportExample = useCallback(() => {
|
||||||
|
setIsImportExampleOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeImportExample = useCallback(() => {
|
||||||
|
setIsImportExampleOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeImportSuccess = useCallback(() => {
|
||||||
|
setIsImportSuccessOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileChange = useCallback(
|
||||||
|
(event) => {
|
||||||
|
const file = event.target.files && event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
try {
|
||||||
|
const text = String(e.target.result || '');
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
let nodes = Array.isArray(parsed)
|
||||||
|
? parsed
|
||||||
|
: Array.isArray(parsed.nodes)
|
||||||
|
? parsed.nodes
|
||||||
|
: [];
|
||||||
|
if (
|
||||||
|
Array.isArray(nodes) &&
|
||||||
|
nodes.some(
|
||||||
|
(node) => Array.isArray(node.children) && node.children.length > 0
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const flat = [];
|
||||||
|
const traverse = (items, level) => {
|
||||||
|
items.forEach((node) => {
|
||||||
|
const treeLevel =
|
||||||
|
typeof node.treeLevel === 'number' && node.treeLevel > 0
|
||||||
|
? node.treeLevel
|
||||||
|
: level;
|
||||||
|
const createTime =
|
||||||
|
node.createtime ||
|
||||||
|
node.create_time ||
|
||||||
|
node.created_time ||
|
||||||
|
node.createTime ||
|
||||||
|
'';
|
||||||
|
const updateTime =
|
||||||
|
node.updateTime ||
|
||||||
|
node.updated_time ||
|
||||||
|
node.update_time ||
|
||||||
|
node.updateTime ||
|
||||||
|
createTime ||
|
||||||
|
'';
|
||||||
|
flat.push({
|
||||||
|
name: node.name || node.stsName || '',
|
||||||
|
level: node.Description || node.level || '',
|
||||||
|
scene: normalizeSceneValue(node.scene || node.Scene),
|
||||||
|
taggroup: normalizeTagGroupValue(
|
||||||
|
node.taggroup || node.tagGroup || node.TagGroup
|
||||||
|
),
|
||||||
|
createtime: createTime,
|
||||||
|
updateTime,
|
||||||
|
deleted:
|
||||||
|
typeof node.deleted === 'number'
|
||||||
|
? node.deleted
|
||||||
|
: Number(node.deleted) || 0,
|
||||||
|
treeLevel
|
||||||
|
});
|
||||||
|
if (Array.isArray(node.children) && node.children.length > 0) {
|
||||||
|
traverse(node.children, treeLevel + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
traverse(nodes, 1);
|
||||||
|
nodes = flat;
|
||||||
|
}
|
||||||
|
if (!nodes.length) {
|
||||||
|
if (onImportError) {
|
||||||
|
onImportError('JSON 中没有有效的节点数据');
|
||||||
|
} else {
|
||||||
|
window.alert('JSON 中没有有效的节点数据');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = formatDateTime();
|
||||||
|
const importedRows = [];
|
||||||
|
const lastIdForLevel = {};
|
||||||
|
let nextId = 0;
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
const name = node.name || node.stsName || '';
|
||||||
|
if (!name) return;
|
||||||
|
const treeLevel =
|
||||||
|
typeof node.treeLevel === 'number' && node.treeLevel > 0
|
||||||
|
? node.treeLevel
|
||||||
|
: 1;
|
||||||
|
const parentRowId =
|
||||||
|
treeLevel > 1 && lastIdForLevel[treeLevel - 1]
|
||||||
|
? lastIdForLevel[treeLevel - 1]
|
||||||
|
: null;
|
||||||
|
nextId += 1;
|
||||||
|
const createTime =
|
||||||
|
node.createtime ||
|
||||||
|
node.create_time ||
|
||||||
|
node.created_time ||
|
||||||
|
node.createTime ||
|
||||||
|
now;
|
||||||
|
const updateTime =
|
||||||
|
node.updateTime ||
|
||||||
|
node.updated_time ||
|
||||||
|
node.update_time ||
|
||||||
|
node.updateTime ||
|
||||||
|
createTime ||
|
||||||
|
now;
|
||||||
|
const row = {
|
||||||
|
id: nextId,
|
||||||
|
stsName: String(name),
|
||||||
|
level: node.Description || node.level || '',
|
||||||
|
scene: normalizeSceneValue(node.scene || node.Scene),
|
||||||
|
taggroup: normalizeTagGroupValue(
|
||||||
|
node.taggroup || node.tagGroup || node.TagGroup
|
||||||
|
),
|
||||||
|
createtime: createTime,
|
||||||
|
updateTime,
|
||||||
|
parentId: parentRowId,
|
||||||
|
deleted:
|
||||||
|
typeof node.deleted === 'number'
|
||||||
|
? node.deleted
|
||||||
|
: Number(node.deleted) || 0,
|
||||||
|
treeLevel
|
||||||
|
};
|
||||||
|
importedRows.push(row);
|
||||||
|
lastIdForLevel[treeLevel] = row.id;
|
||||||
|
});
|
||||||
|
let versionForStash = '';
|
||||||
|
try {
|
||||||
|
const baseVersionName =
|
||||||
|
(typeof getCurrentVersion === 'function'
|
||||||
|
? await getCurrentVersion()
|
||||||
|
: '') || '';
|
||||||
|
let changedLevels = new Set();
|
||||||
|
if (baseVersionName) {
|
||||||
|
try {
|
||||||
|
const baseStash = await api.getFstStash(baseVersionName);
|
||||||
|
changedLevels = collectChangedTreeLevelsFromBaseAndRows(
|
||||||
|
baseStash,
|
||||||
|
importedRows
|
||||||
|
);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
versionForStash = buildNextVersionName(baseVersionName, changedLevels);
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// 兜底:如果自动生成失败,再回退到导入文件自带版本号。
|
||||||
|
if (
|
||||||
|
!versionForStash &&
|
||||||
|
parsed &&
|
||||||
|
typeof parsed.version === 'string' &&
|
||||||
|
parsed.version.trim()
|
||||||
|
) {
|
||||||
|
versionForStash = parsed.version.trim();
|
||||||
|
}
|
||||||
|
let stashWritten = false;
|
||||||
|
if (versionForStash) {
|
||||||
|
const stashContent =
|
||||||
|
Array.isArray(parsed) || Array.isArray(parsed.nodes)
|
||||||
|
? {
|
||||||
|
nodes:
|
||||||
|
Array.isArray(parsed.nodes) && parsed.nodes.length > 0
|
||||||
|
? parsed.nodes
|
||||||
|
: nodes,
|
||||||
|
meta:
|
||||||
|
(parsed &&
|
||||||
|
parsed.meta &&
|
||||||
|
typeof parsed.meta === 'object'
|
||||||
|
? parsed.meta
|
||||||
|
: {}) || {}
|
||||||
|
}
|
||||||
|
: parsed;
|
||||||
|
if (!Array.isArray(stashContent.nodes)) {
|
||||||
|
stashContent.nodes = nodes;
|
||||||
|
}
|
||||||
|
stashContent.meta = {
|
||||||
|
...(stashContent.meta || {}),
|
||||||
|
imported_at: now
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const versionsRes = await api.getVersions();
|
||||||
|
const list = Array.isArray(versionsRes)
|
||||||
|
? versionsRes
|
||||||
|
: versionsRes &&
|
||||||
|
versionsRes.data &&
|
||||||
|
Array.isArray(versionsRes.data)
|
||||||
|
? versionsRes.data
|
||||||
|
: [];
|
||||||
|
const exists = list.some((item) => {
|
||||||
|
const name =
|
||||||
|
(item && (item.version || item.versionName)) || '';
|
||||||
|
return (
|
||||||
|
typeof name === 'string' &&
|
||||||
|
name.trim() === versionForStash
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (exists) {
|
||||||
|
const msg = `版本 ${versionForStash} 已存在,请修改导入文件中的版本号后重试。`;
|
||||||
|
if (onImportError) {
|
||||||
|
onImportError(msg);
|
||||||
|
} else {
|
||||||
|
window.alert(`导入失败:${msg}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.createVersion({
|
||||||
|
version: versionForStash,
|
||||||
|
description:
|
||||||
|
(stashContent.meta && stashContent.meta.remark) || '导入版本',
|
||||||
|
type: 'production',
|
||||||
|
release_date: now,
|
||||||
|
created_time: now,
|
||||||
|
update_time: now,
|
||||||
|
status: '已生效'
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const versionsRes2 = await api.getVersions();
|
||||||
|
const list2 = Array.isArray(versionsRes2)
|
||||||
|
? versionsRes2
|
||||||
|
: versionsRes2 &&
|
||||||
|
versionsRes2.data &&
|
||||||
|
Array.isArray(versionsRes2.data)
|
||||||
|
? versionsRes2.data
|
||||||
|
: [];
|
||||||
|
const targets = list2.filter((item) => {
|
||||||
|
const name =
|
||||||
|
(item && (item.version || item.versionName)) || '';
|
||||||
|
return (
|
||||||
|
typeof name === 'string' &&
|
||||||
|
name.trim() === versionForStash
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const targetIds = targets
|
||||||
|
.map((item) => item && item.id)
|
||||||
|
.filter((id) => typeof id !== 'undefined');
|
||||||
|
const othersToDeactivate = list2.filter((item) => {
|
||||||
|
if (!item || typeof item.id === 'undefined') return false;
|
||||||
|
const name =
|
||||||
|
(item.version || item.versionName || '').trim();
|
||||||
|
return (
|
||||||
|
name !== versionForStash && item.status === '已生效'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const updates = [];
|
||||||
|
targetIds.forEach((id) => {
|
||||||
|
updates.push(
|
||||||
|
api.updateVersion(id, {
|
||||||
|
status: '已生效'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
othersToDeactivate.forEach((item) => {
|
||||||
|
updates.push(
|
||||||
|
api.updateVersion(item.id, {
|
||||||
|
status: '未生效'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (updates.length > 0) {
|
||||||
|
try {
|
||||||
|
await Promise.all(updates);
|
||||||
|
} catch (e3) {}
|
||||||
|
}
|
||||||
|
} catch (e2) {}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('导入时创建版本失败(可能已存在)', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.upsertFstStash({
|
||||||
|
version: versionForStash,
|
||||||
|
content: stashContent
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'fst_current_version',
|
||||||
|
versionForStash
|
||||||
|
);
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'fst_data_sync_at',
|
||||||
|
String(Date.now())
|
||||||
|
);
|
||||||
|
} catch (e2) {}
|
||||||
|
stashWritten = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('写入 fst_stash 失败', err);
|
||||||
|
const msg =
|
||||||
|
(err &&
|
||||||
|
err.response &&
|
||||||
|
(err.response.data?.message || err.response.data)) ||
|
||||||
|
err.message ||
|
||||||
|
'写入 fst_stash 失败,请检查网络或后端服务';
|
||||||
|
if (onImportError) {
|
||||||
|
onImportError(String(msg));
|
||||||
|
} else {
|
||||||
|
window.alert(String(msg));
|
||||||
|
}
|
||||||
|
// 写入失败时,不更新前端数据,保持原有状态
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有版本号(仅本地导入),或已经成功写入 fst_stash 时,再更新前端数据
|
||||||
|
setRows(importedRows);
|
||||||
|
setExpandedIds(importedRows.map((row) => row.id));
|
||||||
|
if (setEditingId) {
|
||||||
|
setEditingId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsImportSuccessOpen(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导入 JSON 失败', error);
|
||||||
|
window.alert('导入 JSON 失败,请检查文件格式是否正确');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file, 'utf-8');
|
||||||
|
},
|
||||||
|
[setRows, setExpandedIds, setEditingId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileInputRef,
|
||||||
|
isImportSuccessOpen,
|
||||||
|
isImportExampleOpen,
|
||||||
|
handleImportClick,
|
||||||
|
openImportExample,
|
||||||
|
closeImportExample,
|
||||||
|
closeImportSuccess,
|
||||||
|
handleFileChange
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useFstImport;
|
||||||
648
src/hooks/useFstRelease.js
Normal file
648
src/hooks/useFstRelease.js
Normal file
@@ -0,0 +1,648 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
const VERSION_SEGMENT_COUNT = 5;
|
||||||
|
|
||||||
|
const toFiveVersionSegments = (versionName) => {
|
||||||
|
const cleaned = String(versionName || '')
|
||||||
|
.replace(/_editing_temp$/i, '')
|
||||||
|
.trim();
|
||||||
|
const numericPartMatch = cleaned.match(/\d+(?:\.\d+)*/);
|
||||||
|
const numericPart = numericPartMatch ? numericPartMatch[0] : '';
|
||||||
|
const parts = numericPart
|
||||||
|
? numericPart
|
||||||
|
.split('.')
|
||||||
|
.map((part) => Number.parseInt(part, 10))
|
||||||
|
.filter((n) => Number.isFinite(n) && n >= 0)
|
||||||
|
: [];
|
||||||
|
const normalized = parts.slice(0, VERSION_SEGMENT_COUNT);
|
||||||
|
while (normalized.length < VERSION_SEGMENT_COUNT) {
|
||||||
|
normalized.push(0);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeTreeLevel = (value) => {
|
||||||
|
const n = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 业务要求:treeLevel 超过 5 统一按第 5 段处理。
|
||||||
|
return Math.min(n, VERSION_SEGMENT_COUNT);
|
||||||
|
};
|
||||||
|
|
||||||
|
const inferTreeLevelFromPath = (path) => {
|
||||||
|
if (typeof path !== 'string' || !path.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const text = path.trim();
|
||||||
|
const separators = ['->', '>', '/', '|'];
|
||||||
|
let bestDepth = 0;
|
||||||
|
separators.forEach((sep) => {
|
||||||
|
if (!text.includes(sep)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const depth = text
|
||||||
|
.split(sep)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean).length;
|
||||||
|
if (depth > bestDepth) {
|
||||||
|
bestDepth = depth;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (bestDepth <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalizeTreeLevel(bestDepth);
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractLevelFromDiffItem = (item) => {
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof item === 'number' || typeof item === 'string') {
|
||||||
|
return normalizeTreeLevel(item);
|
||||||
|
}
|
||||||
|
const candidates = [
|
||||||
|
item.treeLevel,
|
||||||
|
item.node && item.node.treeLevel,
|
||||||
|
item.before && item.before.treeLevel,
|
||||||
|
item.after && item.after.treeLevel,
|
||||||
|
item.old && item.old.treeLevel,
|
||||||
|
item.new && item.new.treeLevel,
|
||||||
|
item.new && item.new.treeLevel
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const normalized = normalizeTreeLevel(candidate);
|
||||||
|
if (normalized) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pathCandidates = [
|
||||||
|
item.path,
|
||||||
|
item.node && item.node.path,
|
||||||
|
item.before && item.before.path,
|
||||||
|
item.after && item.after.path
|
||||||
|
];
|
||||||
|
for (const path of pathCandidates) {
|
||||||
|
const inferred = inferTreeLevelFromPath(path);
|
||||||
|
if (inferred) {
|
||||||
|
return inferred;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const diffHasAnyChanges = (diff) => {
|
||||||
|
if (!diff || typeof diff !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const summary = diff.summary || {};
|
||||||
|
const summaryTotal =
|
||||||
|
Number(summary.added_count || 0) +
|
||||||
|
Number(summary.removed_count || 0) +
|
||||||
|
Number(summary.changed_count || 0);
|
||||||
|
if (summaryTotal > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const added = Array.isArray(diff.added) ? diff.added.length : 0;
|
||||||
|
const removed = Array.isArray(diff.removed) ? diff.removed.length : 0;
|
||||||
|
const changed = Array.isArray(diff.changed) ? diff.changed.length : 0;
|
||||||
|
return added + removed + changed > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectChangedTreeLevels = (diff) => {
|
||||||
|
const levelSet = new Set();
|
||||||
|
if (!diff || typeof diff !== 'object') {
|
||||||
|
return levelSet;
|
||||||
|
}
|
||||||
|
const summary = diff.summary || {};
|
||||||
|
if (Array.isArray(summary.changed_tree_levels)) {
|
||||||
|
summary.changed_tree_levels.forEach((level) => {
|
||||||
|
const normalized = normalizeTreeLevel(level);
|
||||||
|
if (normalized) {
|
||||||
|
levelSet.add(normalized);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
['added', 'removed', 'changed'].forEach((key) => {
|
||||||
|
const list = Array.isArray(diff[key]) ? diff[key] : [];
|
||||||
|
list.forEach((item) => {
|
||||||
|
const level = extractLevelFromDiffItem(item);
|
||||||
|
if (level) {
|
||||||
|
levelSet.add(level);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return levelSet;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractNodesFromStashResponse = (raw) => {
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
if (raw && Array.isArray(raw.data)) {
|
||||||
|
return raw.data;
|
||||||
|
}
|
||||||
|
if (raw && Array.isArray(raw.nodes)) {
|
||||||
|
return raw.nodes;
|
||||||
|
}
|
||||||
|
if (raw && raw.content && Array.isArray(raw.content.nodes)) {
|
||||||
|
return raw.content.nodes;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeDeleted = (node) => {
|
||||||
|
if (!node) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (typeof node.deleted === 'number') {
|
||||||
|
return node.deleted;
|
||||||
|
}
|
||||||
|
return Number(node.deleted) || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flattenStashNodes = (nodes, parentPath = '', depth = 1, map = new Map()) => {
|
||||||
|
if (!Array.isArray(nodes)) {
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (!node || normalizeDeleted(node) === 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = String(node.name || node.label || node.id || '').trim() || 'unknown';
|
||||||
|
const level =
|
||||||
|
normalizeTreeLevel(node.treeLevel || node.tree_level || depth) ||
|
||||||
|
normalizeTreeLevel(depth) ||
|
||||||
|
1;
|
||||||
|
const path = parentPath ? `${parentPath}>${name}` : name;
|
||||||
|
map.set(path, {
|
||||||
|
level,
|
||||||
|
description: String(node.Description || node.description || '').trim(),
|
||||||
|
scene: String(node.scene || '').trim(),
|
||||||
|
taggroup: String(node.taggroup || node.tagGroup || '').trim()
|
||||||
|
});
|
||||||
|
const children = Array.isArray(node.children) ? node.children : [];
|
||||||
|
flattenStashNodes(children, path, depth + 1, map);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flattenActiveRows = (rows) => {
|
||||||
|
const list = Array.isArray(rows) ? rows : [];
|
||||||
|
const activeRows = list.filter((row) => normalizeDeleted(row) !== 1);
|
||||||
|
const idToRow = new Map();
|
||||||
|
activeRows.forEach((row) => {
|
||||||
|
idToRow.set(row.id, row);
|
||||||
|
});
|
||||||
|
const pathMemo = new Map();
|
||||||
|
const buildPath = (row) => {
|
||||||
|
if (!row) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (pathMemo.has(row.id)) {
|
||||||
|
return pathMemo.get(row.id);
|
||||||
|
}
|
||||||
|
const name = String(row.stsName || row.name || row.id || '').trim() || 'unknown';
|
||||||
|
const parent = row.parentId != null ? idToRow.get(row.parentId) : null;
|
||||||
|
const parentPath = parent ? buildPath(parent) : '';
|
||||||
|
const path = parentPath ? `${parentPath}>${name}` : name;
|
||||||
|
pathMemo.set(row.id, path);
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
|
||||||
|
const map = new Map();
|
||||||
|
activeRows.forEach((row) => {
|
||||||
|
const path = buildPath(row);
|
||||||
|
const level = normalizeTreeLevel(row.treeLevel) || 1;
|
||||||
|
map.set(path, {
|
||||||
|
level,
|
||||||
|
description: String(row.level || row.Description || '').trim(),
|
||||||
|
scene: String(row.scene || '').trim(),
|
||||||
|
taggroup: String(row.taggroup || row.tagGroup || '').trim()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectChangedTreeLevelsBetweenMaps = (beforeMap, afterMap) => {
|
||||||
|
const levels = new Set();
|
||||||
|
const allKeys = new Set([...(beforeMap ? beforeMap.keys() : []), ...(afterMap ? afterMap.keys() : [])]);
|
||||||
|
|
||||||
|
allKeys.forEach((key) => {
|
||||||
|
const before = beforeMap ? beforeMap.get(key) : null;
|
||||||
|
const after = afterMap ? afterMap.get(key) : null;
|
||||||
|
if (!before && after) {
|
||||||
|
levels.add(after.level);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (before && !after) {
|
||||||
|
levels.add(before.level);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (before && after) {
|
||||||
|
if (
|
||||||
|
before.description !== after.description ||
|
||||||
|
before.scene !== after.scene ||
|
||||||
|
before.taggroup !== after.taggroup
|
||||||
|
) {
|
||||||
|
levels.add(after.level || before.level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return levels;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const collectChangedTreeLevelsFromBaseAndRows = (baseStash, rows) => {
|
||||||
|
const baseNodes = extractNodesFromStashResponse(baseStash);
|
||||||
|
const baseMap = flattenStashNodes(baseNodes);
|
||||||
|
const currentMap = flattenActiveRows(rows);
|
||||||
|
return collectChangedTreeLevelsBetweenMaps(baseMap, currentMap);
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectChangedTreeLevelsFromStash = (baseStash, editStash) => {
|
||||||
|
const levels = new Set();
|
||||||
|
const baseNodes = extractNodesFromStashResponse(baseStash);
|
||||||
|
const editNodes = extractNodesFromStashResponse(editStash);
|
||||||
|
const baseMap = flattenStashNodes(baseNodes);
|
||||||
|
const editMap = flattenStashNodes(editNodes);
|
||||||
|
const allKeys = new Set([...baseMap.keys(), ...editMap.keys()]);
|
||||||
|
|
||||||
|
allKeys.forEach((key) => {
|
||||||
|
const before = baseMap.get(key);
|
||||||
|
const after = editMap.get(key);
|
||||||
|
if (!before && after) {
|
||||||
|
levels.add(after.level);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (before && !after) {
|
||||||
|
levels.add(before.level);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (before && after) {
|
||||||
|
if (
|
||||||
|
before.description !== after.description ||
|
||||||
|
before.scene !== after.scene ||
|
||||||
|
before.taggroup !== after.taggroup
|
||||||
|
) {
|
||||||
|
levels.add(after.level || before.level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return levels;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildNextVersionName = (baseVersionName, changedLevels) => {
|
||||||
|
const hasBaseVersion = String(baseVersionName || '').trim().length > 0;
|
||||||
|
if (!hasBaseVersion) {
|
||||||
|
return '0.0.0.0.0';
|
||||||
|
}
|
||||||
|
// 在上一个版本基础上按层级递增。
|
||||||
|
const segments = toFiveVersionSegments(baseVersionName);
|
||||||
|
if (!changedLevels || changedLevels.size === 0) {
|
||||||
|
// 未识别到差异层级时,默认递增第 5 段。
|
||||||
|
segments[VERSION_SEGMENT_COUNT - 1] += 1;
|
||||||
|
return segments.join('.');
|
||||||
|
}
|
||||||
|
changedLevels.forEach((level) => {
|
||||||
|
const index = Math.min(Math.max(level, 1), VERSION_SEGMENT_COUNT) - 1;
|
||||||
|
segments[index] += 1;
|
||||||
|
});
|
||||||
|
return segments.join('.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const useFstRelease = ({
|
||||||
|
rows,
|
||||||
|
getCurrentVersion,
|
||||||
|
ensureEditingVersion,
|
||||||
|
setCurrentVersion,
|
||||||
|
setEditingVersion,
|
||||||
|
onReleaseError
|
||||||
|
}) => {
|
||||||
|
const [isReleaseModalOpen, setIsReleaseModalOpen] = useState(false);
|
||||||
|
const [isReleaseSyncingOpen, setIsReleaseSyncingOpen] = useState(false);
|
||||||
|
const [isReleaseSuccessOpen, setIsReleaseSuccessOpen] = useState(false);
|
||||||
|
const [releaseForm, setReleaseForm] = useState({
|
||||||
|
versionName: '',
|
||||||
|
remark: ''
|
||||||
|
});
|
||||||
|
const [releaseDiff, setReleaseDiff] = useState(null);
|
||||||
|
const [releaseDiffExpanded, setReleaseDiffExpanded] = useState({
|
||||||
|
added: false,
|
||||||
|
removed: false,
|
||||||
|
changed: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const openRelease = useCallback(async () => {
|
||||||
|
let nextVersionName = '0.0.0.0.0';
|
||||||
|
try {
|
||||||
|
const baseVersionName = await getCurrentVersion();
|
||||||
|
await ensureEditingVersion();
|
||||||
|
let changedLevels = new Set();
|
||||||
|
if (baseVersionName) {
|
||||||
|
try {
|
||||||
|
const baseStash = await api.getFstStash(baseVersionName);
|
||||||
|
changedLevels = collectChangedTreeLevelsFromBaseAndRows(baseStash, rows);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
nextVersionName = buildNextVersionName(baseVersionName, changedLevels);
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.info('[release-version]', {
|
||||||
|
baseVersionName,
|
||||||
|
changedLevels: Array.from(changedLevels),
|
||||||
|
nextVersionName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
// 打开弹窗时强制覆盖版本号,避免沿用历史输入。
|
||||||
|
setReleaseForm({
|
||||||
|
versionName: nextVersionName,
|
||||||
|
remark: `这是新生成的版本号{${nextVersionName}}`
|
||||||
|
});
|
||||||
|
setIsReleaseModalOpen(true);
|
||||||
|
}, [getCurrentVersion, ensureEditingVersion, rows]);
|
||||||
|
|
||||||
|
const closeRelease = useCallback(() => {
|
||||||
|
setIsReleaseModalOpen(false);
|
||||||
|
setReleaseForm({ versionName: '', remark: '' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeReleaseSuccess = useCallback(() => {
|
||||||
|
setIsReleaseSuccessOpen(false);
|
||||||
|
setReleaseDiff(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleDiffSection = useCallback((key) => {
|
||||||
|
setReleaseDiffExpanded((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: !prev[key]
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleReleaseConfirm = useCallback(async () => {
|
||||||
|
if (!releaseForm.versionName || !releaseForm.versionName.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsReleaseModalOpen(false);
|
||||||
|
setIsReleaseSyncingOpen(true);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const versionName = releaseForm.versionName.trim();
|
||||||
|
let releasedVersionId = null;
|
||||||
|
try {
|
||||||
|
const baseVersionName = await getCurrentVersion();
|
||||||
|
const editVersionName = await ensureEditingVersion();
|
||||||
|
const activeRows = rows;
|
||||||
|
const nodes = activeRows.map((row) => ({
|
||||||
|
name: row.stsName,
|
||||||
|
Description: row.level,
|
||||||
|
createtime: row.createtime,
|
||||||
|
updateTime: row.updateTime,
|
||||||
|
deleted: row.deleted,
|
||||||
|
treeLevel: row.treeLevel,
|
||||||
|
scene: row.scene || '',
|
||||||
|
taggroup: row.taggroup || '',
|
||||||
|
children: []
|
||||||
|
}));
|
||||||
|
const idToIndex = {};
|
||||||
|
activeRows.forEach((row, index) => {
|
||||||
|
if (row.id == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(idToIndex, row.id)) {
|
||||||
|
idToIndex[row.id] = index;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const roots = [];
|
||||||
|
activeRows.forEach((row, index) => {
|
||||||
|
const node = nodes[index];
|
||||||
|
if (
|
||||||
|
row.parentId != null &&
|
||||||
|
Object.prototype.hasOwnProperty.call(idToIndex, row.parentId)
|
||||||
|
) {
|
||||||
|
const parentNode = nodes[idToIndex[row.parentId]];
|
||||||
|
parentNode.children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const createRes = await api.createVersion({
|
||||||
|
version: versionName,
|
||||||
|
description: releaseForm.remark || '',
|
||||||
|
type: 'production',
|
||||||
|
release_date: now,
|
||||||
|
created_time: now,
|
||||||
|
update_time: now,
|
||||||
|
status: '已生效'
|
||||||
|
});
|
||||||
|
if (createRes && typeof createRes.id !== 'undefined') {
|
||||||
|
releasedVersionId = createRes.id;
|
||||||
|
} else if (
|
||||||
|
createRes &&
|
||||||
|
createRes.data &&
|
||||||
|
typeof createRes.data.id !== 'undefined'
|
||||||
|
) {
|
||||||
|
releasedVersionId = createRes.data.id;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const versionsRes = await api.getVersions();
|
||||||
|
const list = Array.isArray(versionsRes)
|
||||||
|
? versionsRes
|
||||||
|
: versionsRes &&
|
||||||
|
versionsRes.data &&
|
||||||
|
Array.isArray(versionsRes.data)
|
||||||
|
? versionsRes.data
|
||||||
|
: [];
|
||||||
|
const targets = list.filter((item) => {
|
||||||
|
const name =
|
||||||
|
(item && (item.version || item.versionName)) || '';
|
||||||
|
return typeof name === 'string' && name.trim() === versionName;
|
||||||
|
});
|
||||||
|
const targetIds = targets
|
||||||
|
.map((item) => item && item.id)
|
||||||
|
.filter((id) => typeof id !== 'undefined');
|
||||||
|
if (targetIds.length > 0) {
|
||||||
|
releasedVersionId = targetIds[0];
|
||||||
|
}
|
||||||
|
const othersToDeactivate = list.filter((item) => {
|
||||||
|
if (!item || typeof item.id === 'undefined') return false;
|
||||||
|
const name =
|
||||||
|
(item.version || item.versionName || '').trim();
|
||||||
|
return (
|
||||||
|
name !== versionName && item.status === '已生效'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const updates = [];
|
||||||
|
targetIds.forEach((id) => {
|
||||||
|
updates.push(
|
||||||
|
api.updateVersion(id, {
|
||||||
|
status: '已生效'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
othersToDeactivate.forEach((item) => {
|
||||||
|
updates.push(
|
||||||
|
api.updateVersion(item.id, {
|
||||||
|
status: '未生效'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (updates.length > 0) {
|
||||||
|
try {
|
||||||
|
await Promise.all(updates);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
const stashContent = {
|
||||||
|
nodes: roots,
|
||||||
|
meta: {
|
||||||
|
remark: releaseForm.remark || '',
|
||||||
|
released_at: now
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await api.upsertFstStash({
|
||||||
|
version: versionName,
|
||||||
|
content: stashContent
|
||||||
|
});
|
||||||
|
|
||||||
|
// Release 时版本默认已生效:主动触发一次 activate,启动后端飞书异步同步任务。
|
||||||
|
if (releasedVersionId !== null && typeof releasedVersionId !== 'undefined') {
|
||||||
|
try {
|
||||||
|
await api.activateVersion(releasedVersionId, {});
|
||||||
|
} catch (syncError) {
|
||||||
|
console.error('触发飞书同步失败', syncError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let diffResult = null;
|
||||||
|
if (editVersionName) {
|
||||||
|
try {
|
||||||
|
await api.upsertFstStash({
|
||||||
|
version: editVersionName,
|
||||||
|
content: stashContent
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
if (baseVersionName && editVersionName) {
|
||||||
|
try {
|
||||||
|
diffResult = await api.getFstStashDiff(
|
||||||
|
baseVersionName,
|
||||||
|
editVersionName
|
||||||
|
);
|
||||||
|
if (diffResult && diffResult.summary) {
|
||||||
|
diffResult = {
|
||||||
|
...diffResult,
|
||||||
|
summary: {
|
||||||
|
...diffResult.summary,
|
||||||
|
from_version: baseVersionName,
|
||||||
|
to_version: versionName
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取版本差异失败', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem('fst_current_version', versionName);
|
||||||
|
window.localStorage.removeItem('fst_edit_version');
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'fst_data_sync_at',
|
||||||
|
String(Date.now())
|
||||||
|
);
|
||||||
|
if (editVersionName) {
|
||||||
|
try {
|
||||||
|
const versionsRes = await api.getVersions();
|
||||||
|
const list = Array.isArray(versionsRes)
|
||||||
|
? versionsRes
|
||||||
|
: (versionsRes &&
|
||||||
|
versionsRes.data &&
|
||||||
|
Array.isArray(versionsRes.data)
|
||||||
|
? versionsRes.data
|
||||||
|
: []);
|
||||||
|
const editItem = list.find((v) => {
|
||||||
|
const n = v.version || v.versionName || '';
|
||||||
|
return n === editVersionName;
|
||||||
|
});
|
||||||
|
if (editItem && typeof editItem.id !== 'undefined') {
|
||||||
|
await api.deleteVersion(editItem.id);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
if (setCurrentVersion) {
|
||||||
|
setCurrentVersion(versionName);
|
||||||
|
}
|
||||||
|
if (setEditingVersion) {
|
||||||
|
setEditingVersion('');
|
||||||
|
}
|
||||||
|
setReleaseDiff(diffResult);
|
||||||
|
setIsReleaseSyncingOpen(false);
|
||||||
|
setReleaseForm({ versionName: '', remark: '' });
|
||||||
|
setIsReleaseSuccessOpen(true);
|
||||||
|
try {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('fst:release_success', {
|
||||||
|
detail: {
|
||||||
|
version: versionName
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (e) {}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Release 失败', error);
|
||||||
|
const status =
|
||||||
|
(error && error.response && error.response.status) || 0;
|
||||||
|
const raw =
|
||||||
|
(error &&
|
||||||
|
error.response &&
|
||||||
|
(error.response.data?.message || error.response.data)) ||
|
||||||
|
error.message;
|
||||||
|
let msg = '发布失败,保存标签或创建版本时出错,请稍后重试。';
|
||||||
|
if (typeof raw === 'string' && raw.trim()) {
|
||||||
|
msg = `创建版本失败:${raw.trim()}`;
|
||||||
|
} else if (status === 400 || status === 404) {
|
||||||
|
msg =
|
||||||
|
'刚刚有其他人发布了最新版本,当前发布操作未生效。请刷新页面后,在最新版本上重新发布。';
|
||||||
|
}
|
||||||
|
// 出错时关闭 Release 弹窗
|
||||||
|
setIsReleaseModalOpen(false);
|
||||||
|
setIsReleaseSyncingOpen(false);
|
||||||
|
setReleaseForm({ versionName: '', remark: '' });
|
||||||
|
if (onReleaseError) {
|
||||||
|
onReleaseError(msg);
|
||||||
|
} else {
|
||||||
|
window.alert(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
releaseForm.versionName,
|
||||||
|
releaseForm.remark,
|
||||||
|
rows,
|
||||||
|
getCurrentVersion,
|
||||||
|
ensureEditingVersion,
|
||||||
|
setCurrentVersion,
|
||||||
|
setEditingVersion,
|
||||||
|
onReleaseError
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isReleaseModalOpen,
|
||||||
|
isReleaseSyncingOpen,
|
||||||
|
isReleaseSuccessOpen,
|
||||||
|
releaseForm,
|
||||||
|
releaseDiff,
|
||||||
|
releaseDiffExpanded,
|
||||||
|
setReleaseForm,
|
||||||
|
openRelease,
|
||||||
|
closeRelease,
|
||||||
|
closeReleaseSuccess,
|
||||||
|
toggleDiffSection,
|
||||||
|
handleReleaseConfirm
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useFstRelease;
|
||||||
781
src/hooks/useFstTree.js
Normal file
781
src/hooks/useFstTree.js
Normal file
@@ -0,0 +1,781 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
const ROOT_NAME = 'MB Driving FST V1';
|
||||||
|
const nameIdCache = {};
|
||||||
|
|
||||||
|
const formatDateTime = () => {
|
||||||
|
const date = new Date();
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildStashContentFromRows = (rows) => {
|
||||||
|
const activeRows = rows.filter((row) => row.deleted === 0);
|
||||||
|
const nodes = activeRows.map((row) => ({
|
||||||
|
...(row.dbId ? { id: row.dbId } : {}),
|
||||||
|
name: row.stsName,
|
||||||
|
Description: row.level,
|
||||||
|
createtime: row.createtime,
|
||||||
|
updateTime: row.updateTime,
|
||||||
|
deleted: row.deleted,
|
||||||
|
treeLevel: row.treeLevel,
|
||||||
|
scene: row.scene || '',
|
||||||
|
taggroup: row.taggroup || '',
|
||||||
|
children: []
|
||||||
|
}));
|
||||||
|
const idToIndex = {};
|
||||||
|
activeRows.forEach((row, index) => {
|
||||||
|
if (row.id == null) return;
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(idToIndex, row.id)) {
|
||||||
|
idToIndex[row.id] = index;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const roots = [];
|
||||||
|
activeRows.forEach((row, index) => {
|
||||||
|
const node = nodes[index];
|
||||||
|
if (
|
||||||
|
row.parentId != null &&
|
||||||
|
Object.prototype.hasOwnProperty.call(idToIndex, row.parentId)
|
||||||
|
) {
|
||||||
|
const parentNode = nodes[idToIndex[row.parentId]];
|
||||||
|
parentNode.children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
nodes: roots,
|
||||||
|
meta: {
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDbIdByName = async (name) => {
|
||||||
|
if (!name) return 0;
|
||||||
|
const key = String(name).trim();
|
||||||
|
if (nameIdCache[key]) return nameIdCache[key];
|
||||||
|
try {
|
||||||
|
const res = await api.getFstByName(key);
|
||||||
|
let id = 0;
|
||||||
|
if (res && typeof res.id !== 'undefined') id = res.id;
|
||||||
|
else if (res && res.data && typeof res.data.id !== 'undefined')
|
||||||
|
id = res.data.id;
|
||||||
|
else if (Array.isArray(res) && res[0] && typeof res[0].id !== 'undefined')
|
||||||
|
id = res[0].id;
|
||||||
|
if (!id) {
|
||||||
|
const list = await api.getFstAllNodes();
|
||||||
|
const arr = Array.isArray(list)
|
||||||
|
? list
|
||||||
|
: list && list.data && Array.isArray(list.data)
|
||||||
|
? list.data
|
||||||
|
: [];
|
||||||
|
const found = arr.find((item) => {
|
||||||
|
const n = String(
|
||||||
|
item.name || item.stsName || item.label || item.id || ''
|
||||||
|
).trim();
|
||||||
|
return n === key;
|
||||||
|
});
|
||||||
|
if (found) {
|
||||||
|
id =
|
||||||
|
Number(
|
||||||
|
found.id ||
|
||||||
|
found.pk ||
|
||||||
|
found.node_id ||
|
||||||
|
found.feature_id ||
|
||||||
|
found.fst_id ||
|
||||||
|
0
|
||||||
|
) || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nameIdCache[key] = id || 0;
|
||||||
|
return nameIdCache[key];
|
||||||
|
} catch (e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const useFstTree = ({
|
||||||
|
getCurrentVersion,
|
||||||
|
ensureEditingVersion,
|
||||||
|
setEditingVersion,
|
||||||
|
onEditVersionInvalid
|
||||||
|
}) => {
|
||||||
|
const [rows, setRows] = useState([]);
|
||||||
|
const [expandedIds, setExpandedIds] = useState([]);
|
||||||
|
const [isChildModalOpen, setIsChildModalOpen] = useState(false);
|
||||||
|
const [childParentId, setChildParentId] = useState(null);
|
||||||
|
const [childForm, setChildForm] = useState({
|
||||||
|
name: '',
|
||||||
|
desc: '',
|
||||||
|
scene: '',
|
||||||
|
taggroup: ''
|
||||||
|
});
|
||||||
|
const [isRootModalOpen, setIsRootModalOpen] = useState(false);
|
||||||
|
const [rootForm, setRootForm] = useState({
|
||||||
|
name: '',
|
||||||
|
desc: '',
|
||||||
|
scene: '',
|
||||||
|
taggroup: ''
|
||||||
|
});
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
stsName: '',
|
||||||
|
level: '',
|
||||||
|
scene: 'L2',
|
||||||
|
taggroup: ''
|
||||||
|
});
|
||||||
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [deleteTargetId, setDeleteTargetId] = useState(null);
|
||||||
|
const hasInitializedRef = useRef(false);
|
||||||
|
|
||||||
|
const validateChildParentOnConfirm = async (parentRow) => {
|
||||||
|
if (!parentRow || parentRow.deleted === 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const parentDbId = Number(parentRow.dbId || 0);
|
||||||
|
if (!parentDbId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const list = await api.getFstAllNodes();
|
||||||
|
const arr = Array.isArray(list)
|
||||||
|
? list
|
||||||
|
: list && list.data && Array.isArray(list.data)
|
||||||
|
? list.data
|
||||||
|
: [];
|
||||||
|
const remoteNode = arr.find((item) => {
|
||||||
|
const id =
|
||||||
|
Number(
|
||||||
|
item.id ||
|
||||||
|
item.pk ||
|
||||||
|
item.node_id ||
|
||||||
|
item.feature_id ||
|
||||||
|
item.fst_id ||
|
||||||
|
0
|
||||||
|
) || 0;
|
||||||
|
return id === parentDbId;
|
||||||
|
});
|
||||||
|
if (!remoteNode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const remoteDeleted =
|
||||||
|
typeof remoteNode.deleted === 'number'
|
||||||
|
? remoteNode.deleted
|
||||||
|
: Number(remoteNode.deleted) || 0;
|
||||||
|
if (remoteDeleted === 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const remoteName = String(
|
||||||
|
remoteNode.name || remoteNode.stsName || remoteNode.label || ''
|
||||||
|
).trim();
|
||||||
|
const localName = String(parentRow.stsName || '').trim();
|
||||||
|
if (remoteName && localName && remoteName !== localName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateEditingRowOnConfirm = async (editingRow) => {
|
||||||
|
if (!editingRow || editingRow.deleted === 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const rowDbId = Number(editingRow.dbId || 0);
|
||||||
|
if (!rowDbId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const list = await api.getFstAllNodes();
|
||||||
|
const arr = Array.isArray(list)
|
||||||
|
? list
|
||||||
|
: list && list.data && Array.isArray(list.data)
|
||||||
|
? list.data
|
||||||
|
: [];
|
||||||
|
const remoteNode = arr.find((item) => {
|
||||||
|
const id =
|
||||||
|
Number(
|
||||||
|
item.id ||
|
||||||
|
item.pk ||
|
||||||
|
item.node_id ||
|
||||||
|
item.feature_id ||
|
||||||
|
item.fst_id ||
|
||||||
|
0
|
||||||
|
) || 0;
|
||||||
|
return id === rowDbId;
|
||||||
|
});
|
||||||
|
if (!remoteNode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const remoteDeleted =
|
||||||
|
typeof remoteNode.deleted === 'number'
|
||||||
|
? remoteNode.deleted
|
||||||
|
: Number(remoteNode.deleted) || 0;
|
||||||
|
return remoteDeleted !== 1;
|
||||||
|
} catch (e) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (id) => {
|
||||||
|
const item = rows.find((row) => row.id === id);
|
||||||
|
if (!item) return;
|
||||||
|
setEditingId(id);
|
||||||
|
setEditForm({
|
||||||
|
stsName: item.stsName,
|
||||||
|
level: item.level,
|
||||||
|
scene: typeof item.scene === 'string' ? item.scene : 'L2',
|
||||||
|
taggroup: typeof item.taggroup === 'string' ? item.taggroup : ''
|
||||||
|
});
|
||||||
|
setIsEditModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditChange = (field, value) => {
|
||||||
|
setEditForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditCancel = () => {
|
||||||
|
setIsEditModalOpen(false);
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSave = async () => {
|
||||||
|
if (!editingId) {
|
||||||
|
setIsEditModalOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = formatDateTime();
|
||||||
|
const currentRow = rows.find((row) => row.id === editingId);
|
||||||
|
const rowValid = await validateEditingRowOnConfirm(currentRow);
|
||||||
|
if (!rowValid) {
|
||||||
|
window.alert('该标签已被其他人删除或不存在,请刷新页面后重试。');
|
||||||
|
setIsEditModalOpen(false);
|
||||||
|
setEditingId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newName =
|
||||||
|
(editForm.stsName || '').trim() ||
|
||||||
|
(currentRow ? currentRow.stsName : '');
|
||||||
|
const newRows = rows.map((row) => {
|
||||||
|
if (row.id !== editingId) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
let nextScene;
|
||||||
|
if (typeof editForm.scene === 'string') {
|
||||||
|
nextScene = editForm.scene;
|
||||||
|
} else if (typeof row.scene === 'string') {
|
||||||
|
nextScene = row.scene;
|
||||||
|
} else {
|
||||||
|
nextScene = 'L2';
|
||||||
|
}
|
||||||
|
let nextTagGroup;
|
||||||
|
if (typeof editForm.taggroup === 'string') {
|
||||||
|
nextTagGroup = editForm.taggroup;
|
||||||
|
} else if (typeof row.taggroup === 'string') {
|
||||||
|
nextTagGroup = row.taggroup;
|
||||||
|
} else {
|
||||||
|
nextTagGroup = '';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
stsName: newName,
|
||||||
|
level: editForm.level,
|
||||||
|
scene: nextScene,
|
||||||
|
taggroup: nextTagGroup,
|
||||||
|
updateTime: now
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setRows(newRows);
|
||||||
|
setIsEditModalOpen(false);
|
||||||
|
setEditingId(null);
|
||||||
|
try {
|
||||||
|
const versionName = await ensureEditingVersion();
|
||||||
|
if (!versionName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stashContent = buildStashContentFromRows(newRows);
|
||||||
|
await api.upsertFstStash({
|
||||||
|
version: versionName,
|
||||||
|
content: stashContent
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const status =
|
||||||
|
(error && error.response && error.response.status) || 0;
|
||||||
|
if (status === 400 || status === 404) {
|
||||||
|
if (onEditVersionInvalid) {
|
||||||
|
onEditVersionInvalid();
|
||||||
|
} else {
|
||||||
|
window.alert(
|
||||||
|
'当前编辑版本已被其他人发布或删除,请刷新页面后重新编辑。'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.alert('保存标签失败,请稍后重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id) => {
|
||||||
|
setDeleteTargetId(id);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!deleteTargetId) {
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const idsToDelete = [];
|
||||||
|
const stack = [deleteTargetId];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop();
|
||||||
|
idsToDelete.push(current);
|
||||||
|
rows.forEach((row) => {
|
||||||
|
if (row.parentId === current) {
|
||||||
|
stack.push(row.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const newRows = rows.map((row) =>
|
||||||
|
idsToDelete.includes(row.id) ? { ...row, deleted: 1 } : row
|
||||||
|
);
|
||||||
|
setRows(newRows);
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setDeleteTargetId(null);
|
||||||
|
try {
|
||||||
|
const versionName = await ensureEditingVersion();
|
||||||
|
if (!versionName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stashContent = buildStashContentFromRows(newRows);
|
||||||
|
await api.upsertFstStash({
|
||||||
|
version: versionName,
|
||||||
|
content: stashContent
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const status =
|
||||||
|
(error && error.response && error.response.status) || 0;
|
||||||
|
if (status === 400 || status === 404) {
|
||||||
|
window.alert(
|
||||||
|
'当前编辑版本已被其他人发布或删除,请刷新页面后重新编辑。'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
window.alert('删除标签失败,请稍后重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCancel = () => {
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setDeleteTargetId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateNew = () => {
|
||||||
|
setRootForm({ name: '', desc: '', scene: '', taggroup: '' });
|
||||||
|
setIsRootModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddChild = (parentId) => {
|
||||||
|
setChildParentId(parentId);
|
||||||
|
setChildForm({
|
||||||
|
name: '',
|
||||||
|
desc: '',
|
||||||
|
scene: '',
|
||||||
|
taggroup: ''
|
||||||
|
});
|
||||||
|
setIsChildModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRootConfirm = async () => {
|
||||||
|
if (!rootForm.name || !rootForm.name.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = formatDateTime();
|
||||||
|
const name = rootForm.name.trim();
|
||||||
|
const desc = rootForm.desc || '';
|
||||||
|
const scene =
|
||||||
|
typeof rootForm.scene === 'string' ? rootForm.scene : '';
|
||||||
|
const taggroup =
|
||||||
|
typeof rootForm.taggroup === 'string' ? rootForm.taggroup : '';
|
||||||
|
const nextId = (rows[rows.length - 1]?.id || 0) + 1;
|
||||||
|
const newRow = {
|
||||||
|
id: nextId,
|
||||||
|
stsName: name,
|
||||||
|
level: desc,
|
||||||
|
scene,
|
||||||
|
taggroup,
|
||||||
|
createtime: now,
|
||||||
|
updateTime: now,
|
||||||
|
parentId: null,
|
||||||
|
deleted: 0,
|
||||||
|
treeLevel: 1
|
||||||
|
};
|
||||||
|
const newRows = [...rows, newRow];
|
||||||
|
setRows(newRows);
|
||||||
|
setIsRootModalOpen(false);
|
||||||
|
setRootForm({ name: '', desc: '', scene: '', taggroup: '' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const versionName = await ensureEditingVersion();
|
||||||
|
if (!versionName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stashContent = buildStashContentFromRows(newRows);
|
||||||
|
await api.upsertFstStash({
|
||||||
|
version: versionName,
|
||||||
|
content: stashContent
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const status =
|
||||||
|
(error && error.response && error.response.status) || 0;
|
||||||
|
if (status === 400 || status === 404) {
|
||||||
|
window.alert(
|
||||||
|
'当前编辑版本已被其他人发布或删除,请刷新页面后重新编辑。'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
window.alert('新建一级标签失败,请稍后重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRootCancel = () => {
|
||||||
|
setIsRootModalOpen(false);
|
||||||
|
setRootForm({ name: '', desc: '', scene: '', taggroup: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChildConfirm = async () => {
|
||||||
|
if (!childParentId) {
|
||||||
|
setIsChildModalOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parent = rows.find((row) => row.id === childParentId);
|
||||||
|
const parentValid = await validateChildParentOnConfirm(parent);
|
||||||
|
if (!parentValid) {
|
||||||
|
window.alert('父标签不存在或已被其他人修改,请刷新页面后重试。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = formatDateTime();
|
||||||
|
const nextId = (rows[rows.length - 1]?.id || 0) + 1;
|
||||||
|
const treeLevel =
|
||||||
|
parent && typeof parent.treeLevel === 'number'
|
||||||
|
? parent.treeLevel + 1
|
||||||
|
: 1;
|
||||||
|
const name =
|
||||||
|
childForm.name && childForm.name.trim().length > 0
|
||||||
|
? childForm.name.trim()
|
||||||
|
: `NEW-${nextId}`;
|
||||||
|
const desc = childForm.desc || '';
|
||||||
|
const scene = childForm.scene || '';
|
||||||
|
const taggroup = childForm.taggroup || '';
|
||||||
|
const newItem = {
|
||||||
|
id: nextId,
|
||||||
|
stsName: name,
|
||||||
|
level: desc,
|
||||||
|
scene,
|
||||||
|
taggroup,
|
||||||
|
createtime: now,
|
||||||
|
updateTime: now,
|
||||||
|
parentId: childParentId,
|
||||||
|
deleted: 0,
|
||||||
|
treeLevel
|
||||||
|
};
|
||||||
|
const newRows = (() => {
|
||||||
|
const next = [...rows];
|
||||||
|
const parentIdx = next.findIndex((row) => row.id === childParentId);
|
||||||
|
let insertPos = parentIdx + 1;
|
||||||
|
while (
|
||||||
|
insertPos < next.length &&
|
||||||
|
next[insertPos].parentId === childParentId
|
||||||
|
) {
|
||||||
|
insertPos += 1;
|
||||||
|
}
|
||||||
|
next.splice(insertPos, 0, newItem);
|
||||||
|
return next;
|
||||||
|
})();
|
||||||
|
setRows(newRows);
|
||||||
|
try {
|
||||||
|
const versionName = await ensureEditingVersion();
|
||||||
|
if (versionName) {
|
||||||
|
const stashContent = buildStashContentFromRows(newRows);
|
||||||
|
await api.upsertFstStash({
|
||||||
|
version: versionName,
|
||||||
|
content: stashContent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const status =
|
||||||
|
(error && error.response && error.response.status) || 0;
|
||||||
|
if (status === 400 || status === 404) {
|
||||||
|
if (onEditVersionInvalid) {
|
||||||
|
onEditVersionInvalid();
|
||||||
|
} else {
|
||||||
|
window.alert(
|
||||||
|
'当前编辑版本已被其他人发布或删除,请刷新页面后重新编辑。'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.alert('新建下级标签失败,请稍后重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setExpandedIds((prev) =>
|
||||||
|
prev.includes(childParentId) ? prev : [...prev, childParentId]
|
||||||
|
);
|
||||||
|
setIsChildModalOpen(false);
|
||||||
|
setChildParentId(null);
|
||||||
|
setChildForm({ name: '', desc: '', scene: '', taggroup: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChildCancel = () => {
|
||||||
|
setIsChildModalOpen(false);
|
||||||
|
setChildParentId(null);
|
||||||
|
setChildForm({ name: '', desc: '', scene: '', taggroup: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpand = (id) => {
|
||||||
|
setExpandedIds((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only run initial data load once; avoid reloading stale remote data
|
||||||
|
// after local edits due to function identity changes from parent hooks.
|
||||||
|
if (hasInitializedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hasInitializedRef.current = true;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
let versionName = '';
|
||||||
|
let raw;
|
||||||
|
let edit = '';
|
||||||
|
try {
|
||||||
|
const storedEdit = window.localStorage.getItem('fst_edit_version');
|
||||||
|
if (storedEdit && storedEdit.trim()) {
|
||||||
|
edit = storedEdit.trim();
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
if (edit) {
|
||||||
|
try {
|
||||||
|
raw = await api.getFstStash(edit);
|
||||||
|
versionName = edit;
|
||||||
|
if (setEditingVersion) {
|
||||||
|
setEditingVersion(edit);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem('fst_edit_version');
|
||||||
|
} catch (e2) {}
|
||||||
|
edit = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!versionName) {
|
||||||
|
try {
|
||||||
|
const versionsRes = await api.getVersions();
|
||||||
|
const list = Array.isArray(versionsRes)
|
||||||
|
? versionsRes
|
||||||
|
: (versionsRes &&
|
||||||
|
versionsRes.data &&
|
||||||
|
Array.isArray(versionsRes.data)
|
||||||
|
? versionsRes.data
|
||||||
|
: []);
|
||||||
|
const isEditVersion = (name) =>
|
||||||
|
typeof name === 'string' && /_editing_temp$/.test(name.trim());
|
||||||
|
const editItem = list.find((v) => {
|
||||||
|
const n = v.version || v.versionName || '';
|
||||||
|
return isEditVersion(n);
|
||||||
|
});
|
||||||
|
if (editItem) {
|
||||||
|
const name =
|
||||||
|
editItem.version || editItem.versionName || editItem.id || '';
|
||||||
|
if (name) {
|
||||||
|
try {
|
||||||
|
raw = await api.getFstStash(name);
|
||||||
|
versionName = name;
|
||||||
|
if (setEditingVersion) {
|
||||||
|
setEditingVersion(name);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem('fst_edit_version', name);
|
||||||
|
} catch (e) {}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
if (!versionName) {
|
||||||
|
const base = await getCurrentVersion();
|
||||||
|
versionName = base;
|
||||||
|
if (versionName) {
|
||||||
|
try {
|
||||||
|
raw = await api.getFstStash(versionName);
|
||||||
|
} catch (e) {
|
||||||
|
raw = await api.getFstPrintTree();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
raw = await api.getFstPrintTree();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let nodes = raw;
|
||||||
|
if (!Array.isArray(nodes) && raw && Array.isArray(raw.data)) {
|
||||||
|
nodes = raw.data;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(nodes) && raw && Array.isArray(raw.nodes)) {
|
||||||
|
nodes = raw.nodes;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Array.isArray(nodes) &&
|
||||||
|
raw &&
|
||||||
|
raw.content &&
|
||||||
|
Array.isArray(raw.content.nodes)
|
||||||
|
) {
|
||||||
|
nodes = raw.content.nodes;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(nodes)) {
|
||||||
|
console.error('print_tree 返回内容异常', raw);
|
||||||
|
window.alert('接口返回数据格式不正确');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = formatDateTime();
|
||||||
|
const list = [];
|
||||||
|
const traverse = (items, parentRowId = null, parentLevel = 0) => {
|
||||||
|
items.forEach((node) => {
|
||||||
|
const name =
|
||||||
|
node.name || node.label || String(node.id || '');
|
||||||
|
const description =
|
||||||
|
node.Description ||
|
||||||
|
(node.reserved_json &&
|
||||||
|
(node.reserved_json.desc ||
|
||||||
|
node.reserved_json.description)) ||
|
||||||
|
node.label ||
|
||||||
|
'';
|
||||||
|
const deleted =
|
||||||
|
typeof node.deleted === 'number'
|
||||||
|
? node.deleted
|
||||||
|
: Number(node.deleted) || 0;
|
||||||
|
if (name === ROOT_NAME) {
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
traverse(node.children, parentRowId, parentLevel);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (deleted === 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const createTime =
|
||||||
|
node.createtime ||
|
||||||
|
node.create_time ||
|
||||||
|
node.created_time ||
|
||||||
|
node.createTime ||
|
||||||
|
now;
|
||||||
|
const updateTime =
|
||||||
|
node.updateTime ||
|
||||||
|
node.updated_time ||
|
||||||
|
node.update_time ||
|
||||||
|
node.updateTime ||
|
||||||
|
createTime ||
|
||||||
|
now;
|
||||||
|
const rowId = list.length + 1;
|
||||||
|
const treeLevel = parentLevel + 1;
|
||||||
|
const dbId =
|
||||||
|
Number(
|
||||||
|
node.id ||
|
||||||
|
node.pk ||
|
||||||
|
node.node_id ||
|
||||||
|
node.feature_id ||
|
||||||
|
node.fst_id ||
|
||||||
|
0
|
||||||
|
) || 0;
|
||||||
|
let scene = '';
|
||||||
|
if (typeof node.scene === 'string') {
|
||||||
|
scene = node.scene.trim();
|
||||||
|
} else if (
|
||||||
|
node.scene == null ||
|
||||||
|
(typeof node.scene === 'number' && Number.isNaN(node.scene))
|
||||||
|
) {
|
||||||
|
scene = 'L2';
|
||||||
|
}
|
||||||
|
let taggroup = '';
|
||||||
|
if (typeof node.taggroup === 'string') {
|
||||||
|
taggroup = node.taggroup.trim();
|
||||||
|
} else if (typeof node.tagGroup === 'string') {
|
||||||
|
taggroup = node.tagGroup.trim();
|
||||||
|
}
|
||||||
|
list.push({
|
||||||
|
id: rowId,
|
||||||
|
stsName: name,
|
||||||
|
level: description,
|
||||||
|
createtime: createTime,
|
||||||
|
updateTime,
|
||||||
|
parentId: parentRowId,
|
||||||
|
deleted,
|
||||||
|
treeLevel,
|
||||||
|
dbId,
|
||||||
|
scene,
|
||||||
|
taggroup
|
||||||
|
});
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
traverse(node.children, rowId, treeLevel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
traverse(nodes, null, 0);
|
||||||
|
setRows(list);
|
||||||
|
setExpandedIds(list.map((row) => row.id));
|
||||||
|
setEditingId(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('从接口加载数据失败', error);
|
||||||
|
window.alert(
|
||||||
|
`从接口加载数据失败: ${error.message || '请检查网络或后端服务'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
expandedIds,
|
||||||
|
isChildModalOpen,
|
||||||
|
childParentId,
|
||||||
|
childForm,
|
||||||
|
setChildForm,
|
||||||
|
isRootModalOpen,
|
||||||
|
rootForm,
|
||||||
|
setRootForm,
|
||||||
|
editingId,
|
||||||
|
editForm,
|
||||||
|
isEditModalOpen,
|
||||||
|
isDeleteModalOpen,
|
||||||
|
deleteTargetId,
|
||||||
|
handleEdit,
|
||||||
|
handleEditChange,
|
||||||
|
handleEditCancel,
|
||||||
|
handleEditSave,
|
||||||
|
handleDelete,
|
||||||
|
handleDeleteConfirm,
|
||||||
|
handleDeleteCancel,
|
||||||
|
handleCreateNew,
|
||||||
|
handleAddChild,
|
||||||
|
handleRootConfirm,
|
||||||
|
handleRootCancel,
|
||||||
|
handleChildConfirm,
|
||||||
|
handleChildCancel,
|
||||||
|
toggleExpand,
|
||||||
|
setRows,
|
||||||
|
setExpandedIds,
|
||||||
|
setEditingId
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useFstTree;
|
||||||
159
src/hooks/useFstVersion.js
Normal file
159
src/hooks/useFstVersion.js
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
const useFstVersion = () => {
|
||||||
|
const [currentVersion, setCurrentVersion] = useState('');
|
||||||
|
const [editingVersion, setEditingVersion] = useState('');
|
||||||
|
const [noVersionTip, setNoVersionTip] = useState(false);
|
||||||
|
|
||||||
|
const formatDateTime = () => {
|
||||||
|
const date = new Date();
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentVersion = useCallback(async () => {
|
||||||
|
if (currentVersion) {
|
||||||
|
return currentVersion;
|
||||||
|
}
|
||||||
|
let storedName = '';
|
||||||
|
try {
|
||||||
|
storedName = window.localStorage.getItem('fst_current_version') || '';
|
||||||
|
} catch (e) {}
|
||||||
|
let versionName = '';
|
||||||
|
try {
|
||||||
|
const versionsRes = await api.getVersions();
|
||||||
|
const list = Array.isArray(versionsRes)
|
||||||
|
? versionsRes
|
||||||
|
: versionsRes && versionsRes.data && Array.isArray(versionsRes.data)
|
||||||
|
? versionsRes.data
|
||||||
|
: [];
|
||||||
|
const isEditVersion = (name) =>
|
||||||
|
typeof name === 'string' && /_editing_temp$/.test(name.trim());
|
||||||
|
const normalList = list.filter((v) => {
|
||||||
|
const n = v.version || v.versionName || '';
|
||||||
|
return !isEditVersion(n);
|
||||||
|
});
|
||||||
|
if (!normalList.length) {
|
||||||
|
setNoVersionTip(true);
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem('fst_current_version');
|
||||||
|
window.localStorage.removeItem('fst_edit_version');
|
||||||
|
window.localStorage.removeItem('fst_edit_version_id');
|
||||||
|
} catch (e) {}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const sorted = [...normalList].sort((a, b) => {
|
||||||
|
const ta = new Date(
|
||||||
|
a.update_time ||
|
||||||
|
a.updateTime ||
|
||||||
|
a.created_time ||
|
||||||
|
a.create_time ||
|
||||||
|
0
|
||||||
|
).getTime();
|
||||||
|
const tb = new Date(
|
||||||
|
b.update_time ||
|
||||||
|
b.updateTime ||
|
||||||
|
b.created_time ||
|
||||||
|
b.create_time ||
|
||||||
|
0
|
||||||
|
).getTime();
|
||||||
|
return tb - ta;
|
||||||
|
});
|
||||||
|
const first = sorted[0];
|
||||||
|
versionName = first.version || first.versionName || '';
|
||||||
|
} catch (e) {}
|
||||||
|
if (versionName) {
|
||||||
|
setCurrentVersion(versionName);
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem('fst_current_version', versionName);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
return versionName;
|
||||||
|
}, [currentVersion]);
|
||||||
|
|
||||||
|
const ensureEditingVersion = useCallback(async () => {
|
||||||
|
const isEditVersion = (name) =>
|
||||||
|
typeof name === 'string' && /_editing_temp$/.test(name.trim());
|
||||||
|
let base = (await getCurrentVersion()) || 'fst';
|
||||||
|
if (isEditVersion(base)) {
|
||||||
|
base = base.replace(/_editing_temp$/i, '') || 'fst';
|
||||||
|
}
|
||||||
|
const targetName = `${base}_editing_temp`;
|
||||||
|
if (editingVersion && editingVersion.trim() === targetName) {
|
||||||
|
return targetName;
|
||||||
|
}
|
||||||
|
let stored = '';
|
||||||
|
try {
|
||||||
|
stored = window.localStorage.getItem('fst_edit_version') || '';
|
||||||
|
} catch (e) {}
|
||||||
|
if (stored && stored.trim() && stored.trim() === targetName) {
|
||||||
|
const v = stored.trim();
|
||||||
|
setEditingVersion(v);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
let targetItem = null;
|
||||||
|
try {
|
||||||
|
const versionsRes = await api.getVersions();
|
||||||
|
const list = Array.isArray(versionsRes)
|
||||||
|
? versionsRes
|
||||||
|
: versionsRes && versionsRes.data && Array.isArray(versionsRes.data)
|
||||||
|
? versionsRes.data
|
||||||
|
: [];
|
||||||
|
const editItems = list.filter((v) => {
|
||||||
|
const n = v.version || v.versionName || '';
|
||||||
|
return isEditVersion(n);
|
||||||
|
});
|
||||||
|
targetItem = editItems.find((v) => {
|
||||||
|
const n = v.version || v.versionName || '';
|
||||||
|
return n === targetName;
|
||||||
|
});
|
||||||
|
const toDelete = editItems.filter((v) => v !== targetItem);
|
||||||
|
for (const item of toDelete) {
|
||||||
|
if (typeof item.id === 'undefined') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.deleteVersion(item.id);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
if (!targetItem) {
|
||||||
|
const now = formatDateTime();
|
||||||
|
try {
|
||||||
|
await api.createVersion({
|
||||||
|
version: targetName,
|
||||||
|
description: `临时编辑版本,基于 ${base || '无'}`,
|
||||||
|
type: 'production',
|
||||||
|
release_date: now,
|
||||||
|
created_time: now,
|
||||||
|
update_time: now,
|
||||||
|
status: '未生效'
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem('fst_edit_version', targetName);
|
||||||
|
} catch (e) {}
|
||||||
|
setEditingVersion(targetName);
|
||||||
|
return targetName;
|
||||||
|
}, [editingVersion, getCurrentVersion]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentVersion,
|
||||||
|
editingVersion,
|
||||||
|
noVersionTip,
|
||||||
|
getCurrentVersion,
|
||||||
|
ensureEditingVersion,
|
||||||
|
setCurrentVersion,
|
||||||
|
setEditingVersion,
|
||||||
|
setNoVersionTip
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useFstVersion;
|
||||||
173
src/hooks/useVersionList.js
Normal file
173
src/hooks/useVersionList.js
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
const useVersionList = (isActive) => {
|
||||||
|
const [versions, setVersions] = useState([]);
|
||||||
|
|
||||||
|
const normalizeFeishuSyncStatus = (item) => {
|
||||||
|
const raw =
|
||||||
|
item.feishu_sync_status ??
|
||||||
|
item.feishuSyncStatus ??
|
||||||
|
item.feishu_sync ??
|
||||||
|
item.feishuSync;
|
||||||
|
|
||||||
|
if (typeof raw === 'boolean') {
|
||||||
|
return raw ? 'synced' : 'unsynced';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof raw === 'number') {
|
||||||
|
if (raw === 1) return 'synced';
|
||||||
|
if (raw === 0) return 'unsynced';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item.is_feishu_synced === 'boolean') {
|
||||||
|
return item.is_feishu_synced ? 'synced' : 'unsynced';
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = String(raw || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (!normalized) return 'unknown';
|
||||||
|
if (
|
||||||
|
normalized === 'synced' ||
|
||||||
|
normalized === 'success' ||
|
||||||
|
normalized === 'done' ||
|
||||||
|
normalized === '1' ||
|
||||||
|
normalized === '已同步'
|
||||||
|
) {
|
||||||
|
return 'synced';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === 'unsynced' ||
|
||||||
|
normalized === 'pending' ||
|
||||||
|
normalized === '0' ||
|
||||||
|
normalized === '未同步'
|
||||||
|
) {
|
||||||
|
return 'unsynced';
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFeishuSyncLabel = (status) => {
|
||||||
|
if (status === 'synced') return '已同步';
|
||||||
|
if (status === 'unsynced') return '未同步';
|
||||||
|
if (status === 'unknown') return '-';
|
||||||
|
return status || '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeVersionStatus = (value) => {
|
||||||
|
const normalized = String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (!normalized) return '';
|
||||||
|
if (normalized === 'active' || normalized === '已生效') {
|
||||||
|
return '已生效';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === 'inactive' ||
|
||||||
|
normalized === 'in-active' ||
|
||||||
|
normalized === '未生效'
|
||||||
|
) {
|
||||||
|
return '未生效';
|
||||||
|
}
|
||||||
|
return String(value || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateTime = (value) => {
|
||||||
|
if (!value) return '';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return String(value);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeVersions = (rawList) => {
|
||||||
|
const normalizedList = (rawList || [])
|
||||||
|
.map((item) => {
|
||||||
|
const feishuSyncStatus = normalizeFeishuSyncStatus(item);
|
||||||
|
return {
|
||||||
|
feishuSyncStatus,
|
||||||
|
feishuSyncLabel: getFeishuSyncLabel(feishuSyncStatus),
|
||||||
|
id: item.id,
|
||||||
|
versionName: item.version || item.versionName || '',
|
||||||
|
create_time: formatDateTime(
|
||||||
|
item.created_time || item.release_date || item.create_time || ''
|
||||||
|
),
|
||||||
|
update_time: formatDateTime(item.update_time || item.updateTime || ''),
|
||||||
|
status: normalizeVersionStatus(
|
||||||
|
item.status != null ? item.status : item.type || ''
|
||||||
|
)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item) => {
|
||||||
|
const name = item.versionName || '';
|
||||||
|
return (
|
||||||
|
name &&
|
||||||
|
!name.includes('_editing_') &&
|
||||||
|
!name.endsWith('_editing_temp')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeItems = normalizedList.filter(
|
||||||
|
(item) => item.status === '已生效'
|
||||||
|
);
|
||||||
|
if (activeItems.length <= 1) {
|
||||||
|
return normalizedList;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepActiveId = activeItems[0] ? activeItems[0].id : null;
|
||||||
|
return normalizedList.map((item) => {
|
||||||
|
if (item.status === '已生效' && item.id !== keepActiveId) {
|
||||||
|
return { ...item, status: '未生效' };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchVersions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.getVersions();
|
||||||
|
const list = Array.isArray(res)
|
||||||
|
? res
|
||||||
|
: (res && res.data && Array.isArray(res.data) ? res.data : []);
|
||||||
|
const normalized = normalizeVersions(list);
|
||||||
|
setVersions(normalized);
|
||||||
|
return normalized;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取版本列表失败', error);
|
||||||
|
setVersions([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchVersions();
|
||||||
|
}, [fetchVersions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive) {
|
||||||
|
fetchVersions();
|
||||||
|
}
|
||||||
|
}, [isActive, fetchVersions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
if (isActive) {
|
||||||
|
fetchVersions();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('fst:release_success', handler);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('fst:release_success', handler);
|
||||||
|
};
|
||||||
|
}, [isActive, fetchVersions]);
|
||||||
|
|
||||||
|
return { versions, setVersions, refreshVersions: fetchVersions };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useVersionList;
|
||||||
13
src/index.js
Normal file
13
src/index.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
const root = ReactDOM.createRoot(container);
|
||||||
|
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
1704
src/pages/FstTagPage copy.jsx_20260203
Normal file
1704
src/pages/FstTagPage copy.jsx_20260203
Normal file
File diff suppressed because it is too large
Load Diff
395
src/pages/FstTagPage.jsx
Normal file
395
src/pages/FstTagPage.jsx
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
// React 基础与 Hooks
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
// 接口封装与业务 Hook
|
||||||
|
import api from '../services/api';
|
||||||
|
import useFstVersion from '../hooks/useFstVersion';
|
||||||
|
import useFstTree from '../hooks/useFstTree';
|
||||||
|
import useFstRelease from '../hooks/useFstRelease';
|
||||||
|
import useFstImport from '../hooks/useFstImport';
|
||||||
|
|
||||||
|
// 页面布局类组件
|
||||||
|
import FstToolbar from '../components/fst/FstToolbar';
|
||||||
|
import FstTable from '../components/fst/FstTable';
|
||||||
|
|
||||||
|
// 各类业务弹窗组件
|
||||||
|
import ReleaseModal from '../components/fst/ReleaseModal';
|
||||||
|
import RootTagModal from '../components/fst/RootTagModal';
|
||||||
|
import ChildTagModal from '../components/fst/ChildTagModal';
|
||||||
|
import EditTagModal from '../components/fst/EditTagModal';
|
||||||
|
import DeleteConfirmModal from '../components/fst/DeleteConfirmModal';
|
||||||
|
import ReleaseDiffPanel from '../components/fst/ReleaseDiffPanel';
|
||||||
|
import InfoModal from '../components/fst/InfoModal';
|
||||||
|
|
||||||
|
const FstTagPage = () => {
|
||||||
|
const [isEditErrorOpen, setIsEditErrorOpen] = useState(false);
|
||||||
|
const [editErrorMessage, setEditErrorMessage] = useState('');
|
||||||
|
const [isReleaseErrorOpen, setIsReleaseErrorOpen] = useState(false);
|
||||||
|
const [releaseErrorMessage, setReleaseErrorMessage] = useState('');
|
||||||
|
const [isImportErrorOpen, setIsImportErrorOpen] = useState(false);
|
||||||
|
const [importErrorMessage, setImportErrorMessage] = useState('');
|
||||||
|
const {
|
||||||
|
currentVersion,
|
||||||
|
editingVersion,
|
||||||
|
noVersionTip,
|
||||||
|
getCurrentVersion,
|
||||||
|
ensureEditingVersion,
|
||||||
|
setCurrentVersion,
|
||||||
|
setEditingVersion
|
||||||
|
} = useFstVersion();
|
||||||
|
const {
|
||||||
|
rows,
|
||||||
|
expandedIds,
|
||||||
|
isChildModalOpen,
|
||||||
|
childParentId,
|
||||||
|
childForm,
|
||||||
|
setChildForm,
|
||||||
|
isRootModalOpen,
|
||||||
|
rootForm,
|
||||||
|
setRootForm,
|
||||||
|
editingId,
|
||||||
|
editForm,
|
||||||
|
isEditModalOpen,
|
||||||
|
isDeleteModalOpen,
|
||||||
|
deleteTargetId,
|
||||||
|
handleEdit,
|
||||||
|
handleEditChange,
|
||||||
|
handleEditCancel,
|
||||||
|
handleEditSave,
|
||||||
|
handleDelete,
|
||||||
|
handleDeleteConfirm,
|
||||||
|
handleDeleteCancel,
|
||||||
|
handleCreateNew,
|
||||||
|
handleAddChild,
|
||||||
|
handleRootConfirm,
|
||||||
|
handleRootCancel,
|
||||||
|
handleChildConfirm,
|
||||||
|
handleChildCancel,
|
||||||
|
toggleExpand,
|
||||||
|
setRows,
|
||||||
|
setExpandedIds,
|
||||||
|
setEditingId
|
||||||
|
} = useFstTree({
|
||||||
|
getCurrentVersion,
|
||||||
|
ensureEditingVersion,
|
||||||
|
setEditingVersion,
|
||||||
|
onEditVersionInvalid: () => {
|
||||||
|
setEditErrorMessage(
|
||||||
|
'刚刚有其他人发布了最新版本,当前编辑版本已失效,本次保存未生效。为避免覆盖他人的修改,请刷新页面后,在最新版本上重新编辑。'
|
||||||
|
);
|
||||||
|
setIsEditErrorOpen(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
isReleaseModalOpen,
|
||||||
|
isReleaseSyncingOpen,
|
||||||
|
isReleaseSuccessOpen,
|
||||||
|
releaseForm,
|
||||||
|
releaseDiff,
|
||||||
|
releaseDiffExpanded,
|
||||||
|
setReleaseForm,
|
||||||
|
openRelease,
|
||||||
|
closeRelease,
|
||||||
|
closeReleaseSuccess,
|
||||||
|
toggleDiffSection,
|
||||||
|
handleReleaseConfirm
|
||||||
|
} = useFstRelease({
|
||||||
|
rows,
|
||||||
|
getCurrentVersion,
|
||||||
|
ensureEditingVersion,
|
||||||
|
setCurrentVersion,
|
||||||
|
setEditingVersion,
|
||||||
|
onReleaseError: (msg) => {
|
||||||
|
setReleaseErrorMessage(
|
||||||
|
msg ||
|
||||||
|
'发布失败,保存标签或创建版本时出错,请稍后重试或联系管理员。'
|
||||||
|
);
|
||||||
|
setIsReleaseErrorOpen(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
fileInputRef,
|
||||||
|
isImportSuccessOpen,
|
||||||
|
isImportExampleOpen,
|
||||||
|
handleImportClick,
|
||||||
|
openImportExample,
|
||||||
|
closeImportExample,
|
||||||
|
closeImportSuccess,
|
||||||
|
handleFileChange
|
||||||
|
} = useFstImport({
|
||||||
|
setRows,
|
||||||
|
setExpandedIds,
|
||||||
|
setEditingId,
|
||||||
|
getCurrentVersion,
|
||||||
|
onImportError: (msg) => {
|
||||||
|
setImportErrorMessage(
|
||||||
|
msg || '导入失败,请检查导入文件中的版本号或数据格式。'
|
||||||
|
);
|
||||||
|
setIsImportErrorOpen(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 当前正在新增下级标签时,找到父节点和其层级,用于弹框里展示和计算 treeLevel
|
||||||
|
const parentRow = rows.find((row) => row.id === childParentId);
|
||||||
|
const childLevel =
|
||||||
|
parentRow && typeof parentRow.treeLevel === 'number'
|
||||||
|
? parentRow.treeLevel + 1
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
// 当前正在编辑的行及其层级、父节点信息,用于“修改标签”弹框展示
|
||||||
|
const editingRow = rows.find((row) => row.id === editingId);
|
||||||
|
const editTreeLevel =
|
||||||
|
editingRow && typeof editingRow.treeLevel === 'number'
|
||||||
|
? editingRow.treeLevel
|
||||||
|
: 1;
|
||||||
|
const editParentRow =
|
||||||
|
editingRow && editingRow.parentId
|
||||||
|
? rows.find((row) => row.id === editingRow.parentId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleShowImportExample = () => {
|
||||||
|
openImportExample();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRelease = () => {
|
||||||
|
if (editingId !== null) {
|
||||||
|
window.alert('请先保存正在编辑的标签,再执行发布');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openRelease();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const forceReload = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
const handleStorage = (event) => {
|
||||||
|
const key = event && event.key ? event.key : '';
|
||||||
|
if (
|
||||||
|
key === 'fst_current_version' ||
|
||||||
|
key === 'fst_edit_version' ||
|
||||||
|
key === 'fst_data_sync_at'
|
||||||
|
) {
|
||||||
|
forceReload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const checkVersionMismatch = () => {
|
||||||
|
let stored = '';
|
||||||
|
try {
|
||||||
|
stored = window.localStorage.getItem('fst_current_version') || '';
|
||||||
|
} catch (e) {}
|
||||||
|
const localVersion = (currentVersion || '').trim();
|
||||||
|
const storedVersion = stored.trim();
|
||||||
|
if (storedVersion && localVersion && storedVersion !== localVersion) {
|
||||||
|
forceReload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleVisible = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
checkVersionMismatch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('storage', handleStorage);
|
||||||
|
window.addEventListener('focus', checkVersionMismatch);
|
||||||
|
document.addEventListener('visibilitychange', handleVisible);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('storage', handleStorage);
|
||||||
|
window.removeEventListener('focus', checkVersionMismatch);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisible);
|
||||||
|
};
|
||||||
|
}, [currentVersion]);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
accept=".json,application/json"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
<FstToolbar
|
||||||
|
onCreateNew={handleCreateNew}
|
||||||
|
onImportClick={handleImportClick}
|
||||||
|
onReleaseClick={handleRelease}
|
||||||
|
onShowImportExample={handleShowImportExample}
|
||||||
|
noVersionTip={noVersionTip}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="table-container">
|
||||||
|
<DeleteConfirmModal
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
title="确认删除"
|
||||||
|
message="删除该标签会一并删除其所有子标签,确认删除吗?"
|
||||||
|
onCancel={handleDeleteCancel}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
/>
|
||||||
|
{isImportExampleOpen && (
|
||||||
|
<InfoModal
|
||||||
|
open={isImportExampleOpen}
|
||||||
|
title="导入 JSON 示例"
|
||||||
|
onClose={closeImportExample}
|
||||||
|
bodyStyle={{
|
||||||
|
maxHeight: '300px',
|
||||||
|
overflow: 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>请参考以下示例编写导入文件:</p>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`{
|
||||||
|
"meta": {
|
||||||
|
"remark": "示例:请根据需要修改版本号、备注、场景(scene)和标签组(taggroup)"
|
||||||
|
},
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"name": "Level1-示例标签",
|
||||||
|
"Description": "P1",
|
||||||
|
"scene": "L2",
|
||||||
|
"taggroup": "Driving",
|
||||||
|
"deleted": 0,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"name": "Level2-示例子标签",
|
||||||
|
"Description": "P2",
|
||||||
|
"scene": "L3",
|
||||||
|
"taggroup": "Parking",
|
||||||
|
"deleted": 0,
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Level2-示例子标签-多场景",
|
||||||
|
"Description": "P2-多场景",
|
||||||
|
"scene": "L2,L3",
|
||||||
|
"taggroup": "Parking,Driving",
|
||||||
|
"deleted": 0,
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Level2-示例子标签-无场景",
|
||||||
|
"Description": "P3-无场景",
|
||||||
|
"scene": "",
|
||||||
|
"taggroup": "",
|
||||||
|
"deleted": 0,
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`}
|
||||||
|
</pre>
|
||||||
|
</InfoModal>
|
||||||
|
)}
|
||||||
|
{isReleaseSuccessOpen && (
|
||||||
|
<InfoModal
|
||||||
|
open={isReleaseSuccessOpen}
|
||||||
|
title="发布版本"
|
||||||
|
onClose={closeReleaseSuccess}
|
||||||
|
bodyStyle={{ maxHeight: '60vh', overflowY: 'auto' }}
|
||||||
|
>
|
||||||
|
<p>新版本已发布,当前标签快照已保存。</p>
|
||||||
|
<ReleaseDiffPanel
|
||||||
|
diff={releaseDiff}
|
||||||
|
expanded={releaseDiffExpanded}
|
||||||
|
onToggleSection={toggleDiffSection}
|
||||||
|
/>
|
||||||
|
</InfoModal>
|
||||||
|
)}
|
||||||
|
{isReleaseSyncingOpen && (
|
||||||
|
<InfoModal
|
||||||
|
open={isReleaseSyncingOpen}
|
||||||
|
title="发布版本"
|
||||||
|
content="新版本正在更新中,请稍候..."
|
||||||
|
onClose={() => {}}
|
||||||
|
closable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isReleaseErrorOpen && (
|
||||||
|
<InfoModal
|
||||||
|
open={isReleaseErrorOpen}
|
||||||
|
title="发布失败"
|
||||||
|
content={
|
||||||
|
releaseErrorMessage ||
|
||||||
|
'发布失败,保存标签或创建版本时出错,请稍后重试。'
|
||||||
|
}
|
||||||
|
onClose={() => setIsReleaseErrorOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isEditErrorOpen && (
|
||||||
|
<InfoModal
|
||||||
|
open={isEditErrorOpen}
|
||||||
|
title="编辑版本已失效"
|
||||||
|
content={editErrorMessage || '编辑标签时写入数据库失败'}
|
||||||
|
onClose={() => setIsEditErrorOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isImportSuccessOpen && (
|
||||||
|
<InfoModal
|
||||||
|
open={isImportSuccessOpen}
|
||||||
|
title="导入成功"
|
||||||
|
content="JSON 已导入完成,数据已更新并写入 fst_stash。"
|
||||||
|
onClose={closeImportSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isImportErrorOpen && (
|
||||||
|
<InfoModal
|
||||||
|
open={isImportErrorOpen}
|
||||||
|
title="导入失败"
|
||||||
|
content={importErrorMessage}
|
||||||
|
onClose={() => setIsImportErrorOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ReleaseModal
|
||||||
|
open={isReleaseModalOpen}
|
||||||
|
noVersionTip={noVersionTip}
|
||||||
|
releaseForm={releaseForm}
|
||||||
|
onChangeReleaseForm={setReleaseForm}
|
||||||
|
onCancel={closeRelease}
|
||||||
|
onConfirm={handleReleaseConfirm}
|
||||||
|
/>
|
||||||
|
<RootTagModal
|
||||||
|
open={isRootModalOpen}
|
||||||
|
form={rootForm}
|
||||||
|
onChangeForm={setRootForm}
|
||||||
|
onCancel={handleRootCancel}
|
||||||
|
onConfirm={handleRootConfirm}
|
||||||
|
/>
|
||||||
|
<ChildTagModal
|
||||||
|
open={isChildModalOpen}
|
||||||
|
form={childForm}
|
||||||
|
parentName={parentRow ? parentRow.stsName : ''}
|
||||||
|
level={childLevel}
|
||||||
|
onChangeForm={setChildForm}
|
||||||
|
onCancel={handleChildCancel}
|
||||||
|
onConfirm={handleChildConfirm}
|
||||||
|
/>
|
||||||
|
<EditTagModal
|
||||||
|
open={isEditModalOpen}
|
||||||
|
form={editForm}
|
||||||
|
treeLevel={editTreeLevel}
|
||||||
|
parentName={editParentRow ? editParentRow.stsName : ''}
|
||||||
|
onChangeField={handleEditChange}
|
||||||
|
onCancel={handleEditCancel}
|
||||||
|
onSave={handleEditSave}
|
||||||
|
/>
|
||||||
|
<FstTable
|
||||||
|
rows={rows}
|
||||||
|
expandedIds={expandedIds}
|
||||||
|
onToggleExpand={toggleExpand}
|
||||||
|
onAddChild={handleAddChild}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FstTagPage;
|
||||||
1640
src/pages/FstTagPage.jsx_20260227
Normal file
1640
src/pages/FstTagPage.jsx_20260227
Normal file
File diff suppressed because it is too large
Load Diff
8
src/pages/VersionListPage.jsx
Normal file
8
src/pages/VersionListPage.jsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import VersionListView from '../components/version/VersionListView';
|
||||||
|
|
||||||
|
const VersionListPage = ({ isActive }) => {
|
||||||
|
return <VersionListView isActive={isActive} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VersionListPage;
|
||||||
103
src/services/api.js
Normal file
103
src/services/api.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
|
const baseURL = isDev
|
||||||
|
? '/api'
|
||||||
|
: process.env.REACT_APP_API_BASE_URL || '/api';
|
||||||
|
|
||||||
|
const instance = axios.create({
|
||||||
|
baseURL,
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
);
|
||||||
|
|
||||||
|
instance.interceptors.response.use(
|
||||||
|
(response) => response.data,
|
||||||
|
(error) => {
|
||||||
|
if (error.response) {
|
||||||
|
switch (error.response.status) {
|
||||||
|
case 401:
|
||||||
|
console.error('未授权,请登录');
|
||||||
|
break;
|
||||||
|
case 403:
|
||||||
|
console.error('没有权限');
|
||||||
|
break;
|
||||||
|
case 404:
|
||||||
|
console.error('请求的资源不存在');
|
||||||
|
break;
|
||||||
|
case 500:
|
||||||
|
console.error('服务器错误');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error('请求失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
// FST 管理相关接口,对应你截图里的 apidocs
|
||||||
|
getFstAllNodes: () => instance.get('/fst/all_nodes'),
|
||||||
|
getFstBagList: () => instance.get('/fst/baglist'),
|
||||||
|
getFstByName: (name) => instance.get(`/fst/${encodeURIComponent(name)}`),
|
||||||
|
getFstBagsByName: (name) =>
|
||||||
|
instance.get(`/fst/${encodeURIComponent(name)}/bags`),
|
||||||
|
getFstPrintTree: () => instance.get('/fst/print_tree'),
|
||||||
|
updateFstBags: (data) => instance.post('/fst/bags/update', data),
|
||||||
|
createOrUpdateFstNode: (data) => instance.post('/fst/stash/update', data),
|
||||||
|
deleteFstByName: (name) =>
|
||||||
|
instance.delete(`/fst/${encodeURIComponent(name)}`),
|
||||||
|
|
||||||
|
// 以前示例里用到的封装,内部直接复用上面这些接口
|
||||||
|
getFstTags: () => instance.get('/fst/all_nodes'),
|
||||||
|
createFstTag: (data) => instance.post('/fst/stash/update', data),
|
||||||
|
updateFstTag: (data) => instance.post('/fst/stash/update', data),
|
||||||
|
deleteFstTag: (name) =>
|
||||||
|
instance.delete(`/fst/${encodeURIComponent(name)}`),
|
||||||
|
|
||||||
|
// FST 暂存与导入
|
||||||
|
upsertFstStash: (data) => instance.post('/fst/stash/update', data),
|
||||||
|
getFstStash: (version) =>
|
||||||
|
instance.get(`/fst/stash/${encodeURIComponent(version)}`),
|
||||||
|
getFstStashPrintTree: (version) =>
|
||||||
|
instance.get('/fst/stash/print_tree', {
|
||||||
|
params: { version }
|
||||||
|
}),
|
||||||
|
getFstStashDiff: (fromVersion, toVersion) =>
|
||||||
|
instance.get('/fst/stash/diff', {
|
||||||
|
params: {
|
||||||
|
from_version: fromVersion,
|
||||||
|
to_version: toVersion
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 版本管理(你后端若有对应接口,可以再对齐)
|
||||||
|
createVersion: (data) => instance.post('/versions', data),
|
||||||
|
getVersions: () => instance.get('/versions'),
|
||||||
|
activateVersion: (id, data = {}) =>
|
||||||
|
instance.post(`/versions/stash/${id}/activate`, data || {}),
|
||||||
|
updateVersion: (id, data) => instance.patch(`/versions/${id}/status`, data),
|
||||||
|
deleteVersion: (id) => instance.delete(`/versions/${id}`),
|
||||||
|
getVersionTags: (versionId) => instance.get(`/versions/${versionId}/tags`),
|
||||||
|
exportVersion: (versionId) =>
|
||||||
|
instance.get('/versions/export', {
|
||||||
|
params: { version_id: versionId },
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
export default api;
|
||||||
Reference in New Issue
Block a user