第一次
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