第一次

This commit is contained in:
ZhuJW
2026-04-16 15:42:52 +08:00
commit fb785ca65e
34 changed files with 37774 additions and 0 deletions

1
.env Normal file
View File

@@ -0,0 +1 @@
REACT_APP_API_BASE_URL=http://127.0.0.1:5232/api

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules

23
Dockerfile Normal file
View 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
View File

@@ -0,0 +1,141 @@
# FST Editor archi 前端
一个基于 React 的「FST Editor 标签管理」前端原型,用于展示和增改 FST 标签列表。
## 技术栈
- React 18
- react-scriptsCreate 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
View 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

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

11
public/index.html Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

674
src/App.css Normal file
View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);

File diff suppressed because it is too large Load Diff

395
src/pages/FstTagPage.jsx Normal file
View 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;

File diff suppressed because it is too large Load Diff

View 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
View 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;

4
start.sh Normal file
View File

@@ -0,0 +1,4 @@
docker stop fst-front
docker rm fst-front
docker build -t fst-front:latest .
docker run -d --name fst-front -p 3003:80 fst-front:latest