Compare commits

...

24 Commits

Author SHA1 Message Date
meilin.huang
847f5c7c90 feat: v1.11.0 2026-05-13 20:01:05 +08:00
meilin.huang
f23b243fc5 refactor: 移除antlr4减小包体积&ai助手优化 2026-05-08 20:45:13 +08:00
meilin.huang
3768cef62d feat: ai助手优化等
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 22:37:10 +08:00
zongyangleo
1e1ded4db8 !153 fix:还原达梦驱动,修复数据库多级 ssh 跳 bug
* fix:还原达梦驱动,修复数据库多级 ssh 跳 bug
* feat: 支持milvus操作
2026-04-27 05:13:40 +00:00
meilin.huang
13f76f4b35 fix: 数据库导出问题
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 00:28:58 +08:00
希有
c796e05232 !151 fix(db-data-sync): 优化数据同步排序逻辑,避免首次未同步完后续增量同步时漏数据或重复数据
* fix(db-data-sync): 优化数据同步排序逻辑,避免首次未同步完后续增量同步时漏数据或重复数据
* ```
2026-04-21 09:25:26 +00:00
meilin.huang
f207517d35 feat: ai助手优化、请求库优化 2026-04-21 17:22:21 +08:00
meilin.huang
6f5069567e feat: 初步新增ai助手 2026-04-15 12:47:10 +08:00
Coder慌
13ce0e9396 !150 增加clickhouse支持
Merge pull request !150 from 希有/dev
2026-04-15 03:55:09 +00:00
xiyou
e0144f7310 增加clickhouse支持 2026-04-15 10:09:17 +08:00
zongyangleo
8ba87c1895 !149 fix:连接池修复和guacd自动重连
* 连接池修复和guacd自动重连
2026-04-07 13:29:31 +00:00
meilin.huang
414e4b0b36 refactor: 代码优化&依赖升级 2026-03-18 20:58:41 +08:00
zongyangleo
bfa41c3621 !148 refactor: 支持kafka操作
* fix: 达梦连接问题修复
* refactor: 支持kafka操作
2026-03-18 09:00:55 +00:00
meilin.huang
84ab496308 refactor: 前端生产路由改为history、依赖版本升级 2026-03-05 20:31:57 +08:00
fanzhouqi
1f27283ab7 !147 fix:过滤状态不是online的数据库
* fix:过滤状态不是online的数据库
2026-02-26 12:16:38 +00:00
meilin.huang
f91b89f38a refactor: 注释优化 2026-02-10 18:12:41 +08:00
meilin.huang
9bb9861d88 refactor: 参数绑定等优化 2026-02-07 13:12:07 +08:00
meilin.huang
403d1c45e5 refactor: sshtunnel等 2026-02-01 13:35:23 +08:00
meilin.huang
400db0402a refactor: 包优化&其他问题修复 2026-01-25 14:16:16 +08:00
fanzhouqi
f0ae178183 !143 fix:mysql查询时,如果出现列名一样,会覆盖数据,github issues #124
* fix:mysql查询时,如果出现列名一样,会覆盖数据,github issues #124
2026-01-23 09:13:14 +00:00
meilin.huang
4641e448d2 fix: 数据库迁移、同步保存时定时任务未清除问题 2026-01-21 12:22:54 +08:00
meilin.huang
f0de65b7ce refactor: 协程启动优化、tagviews调整 2026-01-20 19:45:46 +08:00
meilin.huang
0472c5101f feat: 数据库迁移至文件支持zip格式、go启动协程时panic优化、菜单资源优化 2026-01-18 20:40:11 +08:00
meilin.huang
185cd6f82b fix: postgres导出调整等 2026-01-14 20:38:06 +08:00
651 changed files with 46303 additions and 399440 deletions

6
.gitignore vendored
View File

@@ -18,9 +18,15 @@
**/vendor/
.idea
.vscode
.qoder
out
server/docs/docker-compose
server/config.yml
server/ip2region.xdb
mayfly-go.log
mayfly-go-linux-amd64/
.DS_Store
__debug_*

47
AGENTS.md Normal file
View File

@@ -0,0 +1,47 @@
# Mayfly-Go
你是一位全栈开发工程师,参与 Mayfly-Go 项目的开发。
## 技术栈
- **后端**: Go 1.26+, GORM, Gin, 自定义 IOC 依赖注入框架
- **前端**: Vue 3 (Composition API) + TypeScript 6.x + Vite 8.x + Element Plus + Tailwind CSS 4.x + Pinia
## 常用命令
```bash
# 后端
cd server && go run main.go
cd server && go test ./...
# 前端
cd frontend && pnpm dev
cd frontend && pnpm build
cd frontend && pnpm lint
```
## 全局边界
-**Always**: 后端遵循 Clean Architecture 分层api → application → domain → infra
-**Always**: 所有错误必须处理,禁止 `result, _ := doSomething()`
-**Always**: 前端所有展示文本使用 i18n`$t()` / `t()`),禁止硬编码
- ⚠️ **Ask first**: 修改 pkg/ 或 common/ 下的公共接口
- 🚫 **Never**: 在 application/domain/infra 层使用 `biz.ErrIsNil`,必须返回 error
- 🚫 **Never**: 前端直接调用 axios必须通过 API 封装
## 详细规范
- @./docs/server/architecture.md — 分层架构与目录规范
- @./docs/server/api.md — API 层规范
- @./docs/server/application.md — Application 层规范
- @./docs/server/domain.md — Domain 层规范
- @./docs/server/infrastructure.md — Infrastructure 层规范
- @./docs/server/concurrent.md — 并发与 Panic 处理
- @./docs/server/security.md — 安全与权限
- @./docs/server/quality.md — 代码质量与 Git 提交
- @./docs/server/i18n.md — 后端国际化规范
- @./docs/frontend/overview.md — 前端综合示例与技术栈
- @./docs/frontend/component.md — 组件开发规范
- @./docs/frontend/api.md — API 定义与调用
- @./docs/frontend/i18n.md — 国际化规范
- @./docs/frontend/style.md — 样式与 UI 规范

View File

@@ -1,16 +1,16 @@
# 构建前端资源
FROM m.daocloud.io/docker.io/node:18-bookworm-slim AS fe-builder
FROM m.daocloud.io/docker.io/node:22-bookworm-slim AS fe-builder
WORKDIR /mayfly
COPY frontend .
RUN yarn config set registry 'https://registry.npmmirror.com' && \
yarn install && \
yarn build
RUN npm config set registry 'https://registry.npmmirror.com' && \
npm install && \
npm run build
# 构建后端资源
FROM m.daocloud.io/docker.io/golang:1.23 AS be-builder
FROM m.daocloud.io/docker.io/golang:1.26 AS be-builder
ENV GOPROXY https://goproxy.cn
WORKDIR /mayfly

View File

@@ -31,7 +31,7 @@
## 前言
Web 版 **统一管理操作平台**,集成了对 Linux 系统的全面操作支持(包括终端管理[终端回放、命令过滤]、文件管理、脚本执行、进程监控及计划任务设置),同时提供了多种数据库(如 MySQL、PostgreSQL、Oracle、SQL Server、达梦、高斯、SQLite 等)的数据操作、数据同步与数据迁移功能。此外,还支持 Redis单机、哨兵、集群模式 MongoDB 、Es 的操作管理,并结合工单流程审批功能,为企业提供一站式的运维与管理解决方案。
Web 版 **统一管理操作平台**,集成了对 Linux 系统的全面操作支持(包括终端管理[终端回放、命令过滤]、文件管理、脚本执行、进程监控及计划任务设置),同时提供了多种数据库(如 MySQL、PostgreSQL、Oracle、SQL Server、达梦、高斯、SQLite、ClickHouse 等)的数据操作、数据同步与数据迁移功能。此外,还支持 Redis单机、哨兵、集群模式、MongoDB、Elasticsearch、Kafka、Milvus 的操作管理,并结合工单流程审批功能,为企业提供一站式的运维与管理解决方案。
## 开发语言与主要框架
@@ -65,16 +65,12 @@ http://go.mayfly.run
![文件查看](https://foruda.gitee.com/images/1714378482611638688/7753faf6_1240250.png "屏幕截图")
![sql编辑器](https://foruda.gitee.com/images/1757164386318836686/c3b17a52_1240250.png)
![选表查数据](https://foruda.gitee.com/images/1757164281011401749/5109485f_1240250.png)
![redis操作](https://foruda.gitee.com/images/1757164442298752845/4af1b296_1240250.png)
![mongo操作](https://foruda.gitee.com/images/1714378916425714642/77fc0ed9_1240250.png "屏幕截图")
![es操作](https://foruda.gitee.com/images/1757164553845346963/b5b70381_1240250.png)

View File

@@ -28,7 +28,7 @@
## Preface
Web-based **Unified Management and Operation Platform**, integrating comprehensive operation support for Linux systems (including terminal management [terminal playback, command filtering], file management, script execution, process monitoring, and cronjob settings). It also provides data operation, data synchronization, and data migration for multiple databases (such as MySQL, PostgreSQL, Oracle, SQL Server, Dameng, Gauss, SQLite, etc.). Additionally, it supports Redis operations (standalone, sentinel, and cluster modes) and MongoDB、Es management, combined with work order process approval functionality to offer enterprises an all-in-one solution for operations and management.
Web-based **Unified Management and Operation Platform**, integrating comprehensive operation support for Linux systems (including terminal management [terminal playback, command filtering], file management, script execution, process monitoring, and cronjob settings). It also provides data operation, data synchronization, and data migration for multiple databases (such as MySQL, PostgreSQL, Oracle, SQL Server, Dameng, Gauss, SQLite, ClickHouse, etc.). Additionally, it supports Redis operations (standalone, sentinel, and cluster modes) and MongoDB, Elasticsearch, Kafka, Milvus management, combined with work order process approval functionality to offer enterprises an all-in-one solution for operations and management.
## Development languages and major frameworks
@@ -58,23 +58,18 @@ account/passwordtest/test123.
![文件查看](https://foruda.gitee.com/images/1714378482611638688/7753faf6_1240250.png "屏幕截图")
![sql编辑器](https://foruda.gitee.com/images/1757164386318836686/c3b17a52_1240250.png)
![选表查数据](https://foruda.gitee.com/images/1757164281011401749/5109485f_1240250.png)
![redis操作](https://foruda.gitee.com/images/1757164442298752845/4af1b296_1240250.png)
![mongo操作](https://foruda.gitee.com/images/1714378916425714642/77fc0ed9_1240250.png "屏幕截图")
![es操作](https://foruda.gitee.com/images/1757164553845346963/b5b70381_1240250.png)
![容器操作](https://foruda.gitee.com/images/1757164625186816754/2b195e25_1240250.png)
#### Work order process approval
![流程审批](https://foruda.gitee.com/images/1714379057627690037/ad136862_1240250.png "屏幕截图")

View File

@@ -1,201 +1,312 @@
#bin/bash
#!/bin/bash
#==============================================
# Mayfly-Go Release Build Tool
# 前后端打包编译至指定目录,快速制作发行版
#==============================================
set -e # 遇到错误立即退出
#----------------------------------------------
# 前后端打包编译至指定目录,即快速制作发行版
# 全局配置
#----------------------------------------------
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SERVER_DIR="${PROJECT_ROOT}/server"
FRONTEND_DIR="${PROJECT_ROOT}/frontend"
BINARY_NAME="mayfly-go"
project_path=`pwd`
# 构建后的二进制执行文件名
exec_file_name="mayfly-go"
# web项目目录
web_folder="${project_path}/frontend"
# server目录
server_folder="${project_path}/server"
# 构建目标配置:名称|操作系统|架构
BUILD_TARGETS=(
"linux-amd64|linux|amd64"
"linux-arm64|linux|arm64"
"windows|windows|amd64"
"mac|darwin|amd64"
)
function echo_red() {
echo -e "\033[1;31m$1\033[0m"
}
function echo_green() {
echo -e "\033[1;32m$1\033[0m"
}
function echo_yellow() {
#----------------------------------------------
# 工具函数
#----------------------------------------------
print_header() {
echo -e "\033[1;33m$1\033[0m"
}
function buildWeb() {
cd ${web_folder}
copy2Server=$1
echo_yellow "-------------------Start bundling frontends-------------------"
yarn run build
if [ "${copy2Server}" == "2" ] ; then
echo_green 'Copy the packaged static files to server/static/static'
rm -rf ${server_folder}/static/static && mkdir -p ${server_folder}/static/static && cp -r ${web_folder}/dist/* ${server_folder}/static/static
fi
echo_yellow ">>>>>>>>>>>>>>>>>>>End of packaging frontend<<<<<<<<<<<<<<<<<<<<\n"
print_success() {
echo -e "\033[1;32m$1\033[0m"
}
function build() {
cd ${project_path}
print_error() {
echo -e "\033[1;31m$1\033[0m" >&2
}
# 打包产物的输出目录
toFolder=$1
os=$2
arch=$3
copyDocScript=$4
print_info() {
echo -e "\033[1;34m$1\033[0m"
}
echo_yellow "-------------------Start a bundle build - ${os}-${arch}-------------------"
to_lower() {
echo "$1" | tr '[:upper:]' '[:lower:]'
}
cd ${server_folder}
echo_green "Package build executables..."
#----------------------------------------------
# 构建函数
#----------------------------------------------
build_frontend() {
print_header "\n>>> Building frontend..."
cd "${FRONTEND_DIR}"
npm run build
# 拷贝到 server 静态目录
print_success ">>> Copying frontend assets to server/static/static"
rm -rf "${SERVER_DIR}/static/static"
mkdir -p "${SERVER_DIR}/static/static"
cp -r "${FRONTEND_DIR}/dist/"* "${SERVER_DIR}/static/static/"
cd "${PROJECT_ROOT}"
}
execFileName=${exec_file_name}
# 如果是windows系统,可执行文件需要添加.exe结尾
if [ "${os}" == "windows" ];then
execFileName="${execFileName}.exe"
build_backend() {
local output_dir="$1"
local os_name="$2"
local arch="$3"
local copy_resources="$4"
local binary_file="${BINARY_NAME}"
local target_name="${os_name}-${arch}"
print_header "\n>>> Building backend: ${target_name}"
# Windows 需要 .exe 后缀
if [ "${os_name}" = "windows" ]; then
binary_file="${BINARY_NAME}.exe"
fi
# 编译
cd "${SERVER_DIR}"
go mod tidy
CGO_ENABLE=0 GOOS=${os} GOARCH=${arch} go build -ldflags=-w -o ${execFileName} main.go
if [ -d ${toFolder} ] ; then
echo_green "The desired folder already exists. Clear the folder"
sudo rm -rf ${toFolder}
CGO_ENABLED=0 GOOS="${os_name}" GOARCH="${arch}" \
go build -trimpath -ldflags="-w" -o "${binary_file}" main.go
# 准备输出目录
local bin_dir="${output_dir}/bin"
if [ -d "${output_dir}" ]; then
print_info " Output directory exists, cleaning..."
rm -rf "${output_dir}"
fi
echo_green "Create '${toFolder}' Directory"
mkdir ${toFolder}
echo_green "Move binary to '${toFolder}'"
mv ${server_folder}/${execFileName} ${toFolder}
# if [ "${copy2Server}" == "1" ] ; then
# echo_green "拷贝前端静态页面至'${toFolder}/static'"
# mkdir -p ${toFolder}/static && cp -r ${web_folder}/dist/* ${toFolder}/static
# fi
if [ "${copyDocScript}" == "1" ] ; then
echo_green "Copy resources such as scripts [config.yml.example、readme.txt、startup.sh、shutdown.sh]"
cp ${server_folder}/config.yml.example ${toFolder}
mv ${toFolder}/config.yml.example ${toFolder}/config.yml
cp ${server_folder}/readme.txt ${toFolder}
cp ${server_folder}/readme_en.txt ${toFolder}
cp ${server_folder}/resources/script/startup.sh ${toFolder}
cp ${server_folder}/resources/script/shutdown.sh ${toFolder}
mkdir -p "${bin_dir}"
# 移动二进制文件到 bin 目录
mv "${SERVER_DIR}/${binary_file}" "${bin_dir}/"
# 拷贝资源文件
if [ "${copy_resources}" = "1" ]; then
print_info " Copying config and scripts..."
cp "${SERVER_DIR}/config.yml.example" "${output_dir}/config.yml"
cp "${SERVER_DIR}/readme.txt" "${output_dir}/"
cp "${SERVER_DIR}/readme_en.txt" "${output_dir}/"
cp "${SERVER_DIR}/resources/script/mayfly-go.sh" "${output_dir}/"
chmod +x "${output_dir}/mayfly-go.sh"
fi
echo_yellow ">>>>>>>>>>>>>>>>>>> ${os}-${arch} - Bundle build complete <<<<<<<<<<<<<<<<<<<<\n"
print_success ">>> Build complete: ${target_name}"
cd "${PROJECT_ROOT}"
}
function buildLinuxAmd64() {
build "$1/mayfly-go-linux-amd64" "linux" "amd64" $2
}
function buildLinuxArm64() {
build "$1/mayfly-go-linux-arm64" "linux" "arm64" $2
}
function buildWindows() {
build "$1/mayfly-go-windows" "windows" "amd64" $2
}
function buildMac() {
build "$1/mayfly-go-mac" "darwin" "amd64" $2
}
function buildDocker() {
echo_yellow "-------------------Start building the docker image-------------------"
imageVersion=$1
imageName="mayfly/mayfly-go:${imageVersion}"
docker build --no-cache --platform linux/amd64 --build-arg MAYFLY_GO_VERSION="${imageVersion}" -t "${imageName}" .
echo_green "The docker image is built -> [${imageName}]"
echo_yellow "-------------------Finished building the docker image-------------------"
}
function buildxDocker() {
echo_yellow "-------------------The docker buildx build image starts-------------------"
imageVersion=$1
imageName="ccr.ccs.tencentyun.com/mayfly/mayfly-go:${imageVersion}"
docker buildx build --no-cache --push --platform linux/amd64,linux/arm64 --build-arg MAYFLY_GO_VERSION="${imageVersion}" -t "${imageName}" .
echo_green "The docker multi-architecture version image is built -> [${imageName}]"
echo_yellow "-------------------The docker buildx image is finished-------------------"
}
function runBuild() {
read -p "Select build version [0 | Other->Other than docker image 1->linux-amd64 2->linux-arm64 3->windows 4->mac 5->docker 6->docker buildx]: " buildType
toPath="."
imageVersion="latest"
copyDocScript="1"
if [[ "${buildType}" != "5" ]] && [[ "${buildType}" != "6" ]] ; then
# 构建结果的目的路径
read -p "Please enter the build product output directory [default current path]: " toPath
if [ ! -d ${toPath} ] ; then
echo_red "Build product output directory does not exist!"
exit;
fi
if [ "${toPath}" == "" ] ; then
toPath="."
fi
read -p "Whether to copy documents & Scripts [0-> No 1-> Yes][Default yes]: " copyDocScript
if [ "${copyDocScript}" == "" ] ; then
copyDocScript="1"
fi
# 进入目标路径,并赋值全路径
cd ${toPath}
toPath=`pwd`
# read -p "是否构建前端[0|其他->否 1->是 2->构建并拷贝至server/static/static]: " runBuildWeb
runBuildWeb="2"
# 编译web前端
buildWeb ${runBuildWeb}
build_docker() {
local version="$1"
local use_buildx="$2"
local image_name
local build_cmd
if [ "${use_buildx}" = "1" ]; then
image_name="ccr.ccs.tencentyun.com/mayfly/mayfly-go:${version}"
build_cmd="docker buildx build --no-cache --push --platform linux/amd64,linux/arm64"
print_header "\n>>> Building Docker image (multi-arch): ${image_name}"
else
image_name="mayfly/mayfly-go:${version}"
build_cmd="docker build --no-cache --platform linux/amd64"
print_header "\n>>> Building Docker image: ${image_name}"
fi
${build_cmd} --build-arg MAYFLY_GO_VERSION="${version}" -t "${image_name}" "${PROJECT_ROOT}"
print_success ">>> Docker image built: ${image_name}"
}
if [[ "${buildType}" == "5" ]] || [[ "${buildType}" == "6" ]] ; then
read -p "Please enter the docker image version (default latest) : " imageVersion
cleanup_frontend() {
print_info "\n>>> Cleaning up temporary frontend assets..."
rm -rf "${SERVER_DIR}/static/static/"{assets,config.js,index.html}
print_success ">>> Cleanup complete"
}
if [ "${imageVersion}" == "" ] ; then
imageVersion="latest"
fi
compress_package() {
local source_dir="$1"
local output_dir="$2"
local package_name
package_name="$(basename "${source_dir}")"
print_header "\n>>> Compressing package: ${package_name}"
cd "${output_dir}"
# 统一使用 zip 格式,跨平台兼容性最好
zip -r "${package_name}.zip" "${package_name}"/
rm -rf "${package_name}"
print_success ">>> Compressed: ${package_name}.zip"
cd "${PROJECT_ROOT}"
}
#----------------------------------------------
# 主流程
#----------------------------------------------
main() {
# 显示菜单
print_header "========================================"
print_header " Mayfly-Go Release Build Tool"
print_header "========================================"
echo ""
echo "Build Options:"
echo " [0] All Platforms (linux-amd64, linux-arm64, windows, mac)"
echo " [1] Linux AMD64"
echo " [2] Linux ARM64"
echo " [3] Windows"
echo " [4] macOS"
echo " [5] Docker Image"
echo " [6] Docker Multi-arch (buildx)"
echo ""
read -p "Select build option [0-6] (default: 0): " build_type
build_type=${build_type:-0}
# 验证输入
if ! [[ "${build_type}" =~ ^[0-6]$ ]]; then
print_error "Error: Invalid option. Please enter a number between 0 and 6."
exit 1
fi
case ${buildType} in
"1")
buildLinuxAmd64 ${toPath} ${copyDocScript}
;;
"2")
buildLinuxArm64 ${toPath} ${copyDocScript}
;;
"3")
buildWindows ${toPath} ${copyDocScript}
;;
"4")
buildMac ${toPath} ${copyDocScript}
;;
# 初始化配置
local output_dir="."
local docker_version="latest"
local copy_resources="1"
local compress_output="0"
local is_docker=0
# Docker 构建
if [[ "${build_type}" == "5" || "${build_type}" == "6" ]]; then
is_docker=1
echo ""
read -p "Enter Docker image version (default: latest): " docker_version
docker_version=${docker_version:-latest}
else
# 二进制构建
echo ""
read -p "Enter output directory (default: current): " output_dir
output_dir=${output_dir:-.}
# 验证并获取绝对路径
if [ "${output_dir}" != "." ] && [ ! -d "${output_dir}" ]; then
print_error "Error: Directory '${output_dir}' does not exist."
exit 1
fi
output_dir="$(cd "${output_dir}" && pwd)"
echo ""
read -p "Copy config & scripts? [Y/n] (default: Y): " copy_input
if [ "$(to_lower "${copy_input}")" = "n" ]; then
copy_resources="0"
fi
echo ""
read -p "Compress package? [y/N] (default: N): " compress_input
if [ "$(to_lower "${compress_input}")" = "y" ]; then
compress_output="1"
fi
# 构建前端
echo ""
build_frontend
fi
# 显示配置摘要
echo ""
print_header "Build Configuration:"
# 获取构建类型名称
local type_names=("All Platforms" "Linux AMD64" "Linux ARM64" "Windows" "macOS" "Docker Image" "Docker Multi-arch")
echo " Type: ${type_names[${build_type}]}"
if [ "${is_docker}" = "1" ]; then
echo " Version: ${docker_version}"
else
echo " Output: ${output_dir}"
echo " Resources: $([ "${copy_resources}" = "1" ] && echo "Yes" || echo "No")"
echo " Compress: $([ "${compress_output}" = "1" ] && echo "Yes" || echo "No")"
fi
echo ""
# 确认构建
read -p "Continue? [Y/n] (default: Y): " confirm
if [ "$(to_lower "${confirm}")" = "n" ]; then
print_info "Build cancelled."
exit 0
fi
# 执行构建
echo ""
print_header "Starting build..."
case "${build_type}" in
"1"|"2"|"3"|"4")
# 单个平台构建
local target="${BUILD_TARGETS[$((build_type-1))]}"
IFS='|' read -r name os arch <<< "${target}"
build_backend "${output_dir}/mayfly-go-${name}" "${os}" "${arch}" "${copy_resources}"
;;
"5")
buildDocker ${imageVersion}
;;
build_docker "${docker_version}" "0"
;;
"6")
buildxDocker ${imageVersion}
;;
build_docker "${docker_version}" "1"
;;
*)
buildLinuxAmd64 ${toPath} ${copyDocScript}
buildLinuxArm64 ${toPath} ${copyDocScript}
buildWindows ${toPath} ${copyDocScript}
buildMac ${toPath} ${copyDocScript}
;;
# 构建所有平台
print_info "Building all platforms..."
for target in "${BUILD_TARGETS[@]}"; do
IFS='|' read -r name os arch <<< "${target}"
build_backend "${output_dir}/mayfly-go-${name}" "${os}" "${arch}" "${copy_resources}"
done
;;
esac
if [[ "${buildType}" != "5" ]] && [[ "${buildType}" != "6" ]] ; then
echo_green "Delete static assets under ['${server_folder}/static/static']."
# 删除静态资源文件保留一个favicon.ico否则后端启动会报错
rm -rf ${server_folder}/static/static/assets
rm -rf ${server_folder}/static/static/config.js
rm -rf ${server_folder}/static/static/index.html
# 清理临时文件
if [ "${is_docker}" = "0" ]; then
cleanup_frontend
fi
# 压缩输出
if [ "${compress_output}" = "1" ] && [ "${is_docker}" = "0" ]; then
case "${build_type}" in
"1"|"2"|"3"|"4")
local target="${BUILD_TARGETS[$((build_type-1))]}"
IFS='|' read -r name os arch <<< "${target}"
compress_package "${output_dir}/mayfly-go-${name}" "${output_dir}"
;;
*)
print_info "Compressing all packages..."
for target in "${BUILD_TARGETS[@]}"; do
IFS='|' read -r name os arch <<< "${target}"
compress_package "${output_dir}/mayfly-go-${name}" "${output_dir}"
done
;;
esac
fi
# 完成
echo ""
print_success "========================================"
print_success " Build Completed Successfully!"
print_success "========================================"
}
runBuild
# 执行主函数
main

37
docs/frontend/api.md Normal file
View File

@@ -0,0 +1,37 @@
---
trigger: always_on
---
# API 定义与调用规范
## API 定义
```typescript
import Api from '@/common/Api';
export const accountApi = {
list: Api.newGet('/sys/accounts'),
save: Api.newPost('/sys/accounts'),
update: Api.newPut('/sys/accounts/{id}'),
del: Api.newDelete('/sys/accounts/{id}'),
changeStatus: Api.newPut('/sys/accounts/change-status/{id}/{status}'),
};
```
## 调用模式
```typescript
// 简单请求
await accountApi.del.request({ id: row.id });
// 响应式(用于 loading 状态)
const { execute, isFetching } = accountApi.list.useApi();
// 表格集成
<page-table :page-api="accountApi.list" />
```
## 边界
-**Always**: API 定义集中放在 `api.ts`
- 🚫 **Never**: 直接调用 axios必须通过 API 封装

164
docs/frontend/component.md Normal file
View File

@@ -0,0 +1,164 @@
---
trigger: always_on
---
# 组件开发规范
## 代码组织顺序
```
Imports
Props/Emits
常量定义 (as const)
类型定义
响应式数据
计算属性
监听器
工具函数
事件处理方法 (on 开头)
```
## 命名规范
- **事件方法**: 必须以 `on` 开头(`onSubmit`, `onDelete`, `onEdit`
- **变量/函数**: camelCase
- **常量**: UPPER_SNAKE_CASE + `as const`
- **组件**: PascalCase
- **文件**: 组件用 PascalCase其他用小写
## Props & Emits
```vue
<script lang="ts" setup>
interface Props {
visible?: boolean;
data?: any;
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
data: null,
});
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'success'): void;
}>();
</script>
```
## 双向绑定规范
### 使用 defineModel (Vue 3.4+)
**必须使用 `defineModel` 实现双向绑定**,替代旧的 `computed` + `emit('update:xxx')` 模式。
#### 基本用法
```vue
<script lang="ts" setup>
// 单个 v-model
const modelValue = defineModel<string>('modelValue', {
default: '',
});
// 命名 v-model
const authCertName = defineModel<string>('authCertName');
const machineId = defineModel<number>('machineId');
</script>
```
#### 内部字段联动更新
当组件内部有多个字段,需要联动更新外部的 `modelValue` 时,使用 `watch` 监听:
```vue
<script lang="ts" setup>
import { watch } from 'vue';
const authCertName = defineModel<string>('authCertName');
const machineName = defineModel<string>('machineName');
const selectNode = defineModel<string>('modelValue', { default: '' });
// 监听内部字段变化,自动更新 selectNode
watch(
[authCertName, machineName],
() => {
selectNode.value = authCertName.value
? `${machineName.value} > ${authCertName.value}`
: '';
},
{ immediate: true }
);
</script>
```
#### 规范要点
-**Always**: 使用 `defineModel` 替代 `computed` + `emit('update:xxx')`
-**Always**: 为 `defineModel` 提供合适的 `default`
- 🚫 **Never**: 使用旧的 `computed` getter/setter 模式实现双向绑定
## 图标使用规范
### 统一使用 SvgIcon 组件
**所有图标必须使用 `SvgIcon` 组件**,禁止使用 `<el-icon>` 配合导入图标组件。
```vue
<!-- 正确使用 SvgIcon -->
<SvgIcon name="Monitor" :size="20" />
<SvgIcon name="check" class="text-success" />
<!-- 错误使用 el-icon + 导入 -->
<el-icon><Check /></el-icon>
```
**规范要点**
- ✅ 使用 `name` 属性指定图标,`size` 属性控制大小
- ✅ 图标名称使用 PascalCase 或 kebab-case
- 🚫 禁止使用 `<el-icon>` 和导入 `@element-plus/icons-vue`
- 🚫 禁止通过 class 设置图标大小
### 自定义 SVG 图标
项目支持在 `assets/icon` 目录下添加自定义 SVG 图标。
#### 目录结构
```
frontend/src/assets/icon/
├── db/ # 数据库图标mysql.svg, postgres.svg...
├── machine/ # 机器图标
└── ...
```
#### 使用方法
**格式**: `name="icon {目录}/{文件名}"`(不含 .svg
```vue
<SvgIcon name="icon db/mysql" :size="20" />
```
#### 添加步骤
1. 将 SVG 文件放到 `frontend/src/assets/icon/` 对应子目录
2. 文件名使用小写 + 连字符(如 `mysql.svg`
3. 使用 `name="icon db/mysql"` 引用
#### 注意事项
- ✅ SVG 必须有 `viewBox` 属性
- ✅ 使用 `size` 属性控制大小
- ✅ 图标颜色继承当前元素的 `color`
- 🚫 不要在 SVG 中硬编码颜色值
- 🚫 文件名不要使用大写或下划线
## 边界
-**Always**: 使用 Composition API + `<script setup>`
-**Always**: 事件方法以 `on` 开头
-**Always**: 移除无用的导入import和无用的字段、变量、函数
- 🚫 **Never**: 保留未使用的代码或注释掉的代码
- 🚫 **Never**: 使用固定高度计算,优先用 Flexbox

45
docs/frontend/i18n.md Normal file
View File

@@ -0,0 +1,45 @@
---
trigger: always_on
---
# 国际化规范
## 文件组织
```
src/i18n/
├── zh-cn/
│ ├── common.ts
│ ├── system.ts
│ ├── ai.ts
│ └── ...
└── en/
├── common.ts
├── system.ts
└── ...
```
- 按模块拆分,每个业务模块一个文件
- 命名空间以模块名为根 key`system.account.name`
## 使用方式
```vue
<template>
<h1>{{ $t('system.account.name') }}</h1>
<el-button>{{ $t('common.save') }}</el-button>
</template>
<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const message = t('common.success');
</script>
```
## 边界
-**Always**: 所有展示文本通过 `$t()``t()` 获取
-**Always**: 枚举的 label 必须是国际化 key
-**Always**: 新增模块时在 `zh-cn/``en/` 下同时创建语言文件
- 🚫 **Never**: 在组件中直接写中文/英文文本

134
docs/frontend/overview.md Normal file
View File

@@ -0,0 +1,134 @@
---
trigger: always_on
---
# 前端开发规范
## 技术栈
Vue 3 (Composition API) + TypeScript 5.x + Vite 5.x + Element Plus + Tailwind CSS 3.x + Pinia
## 综合示例:列表页 + 编辑对话框
### 枚举定义
```typescript
// src/views/system/enums.ts
import { EnumValue } from '@/common/Enum';
export const AccountStatusEnum = {
Enable: EnumValue.of(1, 'system.account.statusEnable').tagTypeSuccess(),
Disable: EnumValue.of(-1, 'system.account.statusDisable').tagTypeDanger(),
};
```
### API 定义
```typescript
// src/views/system/api.ts
import Api from '@/common/Api';
export const accountApi = {
list: Api.newGet('/sys/accounts'),
save: Api.newPost('/sys/accounts'),
update: Api.newPut('/sys/accounts/{id}'),
del: Api.newDelete('/sys/accounts/{id}'),
};
```
### 列表页
```vue
<template>
<page-table ref="pageTableRef" :page-api="accountApi.list" :search-items="searchItems" v-model:query-form="query" :columns="columns">
<template #tableHeader>
<el-button v-auth="'account:add'" type="primary" @click="onAdd">
{{ $t('common.create') }}
</el-button>
</template>
<template #action="{ data }">
<el-button link v-auth="'account:edit'" @click="onEdit(data)">
{{ $t('common.edit') }}
</el-button>
</template>
</page-table>
<AccountEdit v-model:visible="editVisible" :data="editData" @success="onEditSuccess" />
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { accountApi } from '../api';
import { AccountStatusEnum } from '../enums';
import PageTable from '@/components/pagetable/PageTable.vue';
import { SearchItem, TableColumn } from '@/components/pagetable';
import AccountEdit from './AccountEdit.vue';
const pageTableRef = ref();
const editVisible = ref(false);
const editData = ref<any>(null);
const query = ref({ username: '', status: null as number | null, pageNum: 1, pageSize: 10 });
const searchItems = [SearchItem.input('username', 'common.username'), SearchItem.select('status', 'common.status', AccountStatusEnum)];
const columns = [
TableColumn.new('username', 'common.username'),
TableColumn.new('status', 'common.status').typeTag(AccountStatusEnum),
TableColumn.new('action', 'common.operation').isSlot().fixedRight(),
];
const onAdd = () => { editData.value = null; editVisible.value = true; };
const onEdit = (row: any) => { editData.value = row; editVisible.value = true; };
const onEditSuccess = () => { editVisible.value = false; pageTableRef.value?.search(); };
</script>
```
### 编辑对话框
```vue
<template>
<el-dialog v-model="visible" :title="dialogTitle" width="500px" @close="onDialogClose">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item :label="$t('common.username')" prop="username">
<el-input v-model="form.username" :disabled="!!form.id" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="onCancel">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ $t('common.confirm') }}</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { accountApi } from '../api';
import { useI18nOperateSuccessMsg } from '@/hooks/useI18n';
const props = defineProps<{ visible?: boolean; data?: any }>();
const emit = defineEmits(['update:visible', 'success']);
const formRef = ref();
const submitting = ref(false);
const form = reactive({ id: undefined, username: '', name: '', status: 1 });
const visible = computed({ get: () => props.visible, set: (val) => emit('update:visible', val) });
const { t } = useI18n();
const dialogTitle = computed(() => (form.id ? t('system.account.editAccount') : t('system.account.addAccount')));
watch(() => props.data, (newVal) => { newVal ? Object.assign(form, newVal) : resetForm(); }, { immediate: true });
const resetForm = () => { form.id = undefined; form.username = ''; form.name = ''; form.status = 1; formRef.value?.clearValidate(); };
const onSubmit = async () => {
await formRef.value?.validate();
submitting.value = true;
try {
form.id ? await accountApi.update.request(form) : await accountApi.save.request(form);
useI18nOperateSuccessMsg();
visible.value = false;
emit('success');
} finally { submitting.value = false; }
};
const onCancel = () => { visible.value = false; };
const onDialogClose = () => { resetForm(); };
</script>
```

35
docs/frontend/style.md Normal file
View File

@@ -0,0 +1,35 @@
---
trigger: always_on
---
# 样式与 UI 规范
## Tailwind CSS
优先使用 Tailwind 工具类,支持 `dark:` 前缀:
```vue
<template>
<div class="flex items-center justify-between p-4 bg-white dark:bg-gray-800">
<span class="text-sm text-gray-600 dark:text-gray-300">Label</span>
</div>
</template>
```
## 权限控制
按钮权限使用 `v-auth` 指令:
```vue
<el-button v-auth="'account:add'" type="primary" @click="onAdd">新增</el-button>
```
## 类型安全
- 避免 `any`,使用可选链 `?.`
- 使用 TypeScript 严格模式
## 边界
-**Always**: 优先使用 Tailwind CSS
- 🚫 **Never**: 使用固定高度计算,优先用 Flexbox

59
docs/server/api.md Normal file
View File

@@ -0,0 +1,59 @@
---
trigger: always_on
---
# API 层规范
## Handler 标准结构
```go
type Db struct {
dbApp application.Db `inject:"T"`
tagApp tagapp.TagTree `inject:"T"`
}
// @router /api/dbs [get]
func (d *Db) Dbs(rc *req.Ctx) {
queryCond := req.BindQuery[entity.DbQuery](rc) // 1. 绑定参数
loginAccount := rc.GetLoginAccount() // 2. 获取上下文
result, err := d.dbApp.GetPageList(queryCond) // 3. 调用应用层
biz.ErrIsNil(err) // 4. 断言错误仅API层
rc.ResData = result // 5. 返回结果
}
```
## 路由配置
```go
func (d *Db) ReqConfs() *req.Confs {
return req.NewConfs("/dbs",
req.NewGet("", d.Dbs),
req.NewPost("", d.Save).Log(req.NewLogSaveI(imsg.LogDbSave)),
req.NewDelete(":dbId", d.DeleteDb).Log(req.NewLogSaveI(imsg.LogDbDelete)),
)
}
```
## 断言边界
**✅ API 层可用断言**
```go
func (d *Db) Save(rc *req.Ctx) {
form := req.BindFormAndValid[form.DbForm](rc)
biz.IsTrue(form.InstanceId > 0, "实例ID不能为空")
biz.ErrIsNil(d.dbApp.SaveDb(rc, &entity.Db{Name: form.Name}))
rc.ResData = "保存成功"
}
```
**🚫 Application 层禁止断言,必须返回 error**
```go
func (d *dbAppImpl) SaveDb(ctx context.Context, db *entity.Db) error {
if db.Name == "" {
return errorx.NewBiz("名称不能为空")
}
return d.Save(ctx, db)
}
```

View File

@@ -0,0 +1,51 @@
---
trigger: always_on
---
# Application 层规范
## 接口与实现
```go
type Db interface {
base.App[*entity.Db]
GetPageList(condition *entity.DbQuery, orderBy ...string) (*model.PageResult[*entity.DbListPO], error)
SaveDb(ctx context.Context, entity *entity.Db) error
}
type dbAppImpl struct {
base.AppImpl[*entity.Db, repository.Db]
dbInstanceApp Instance `inject:"T"`
tagApp tagapp.TagTree `inject:"T"`
}
var _ Db = (*dbAppImpl)(nil)
func (d *dbAppImpl) SaveDb(ctx context.Context, dbEntity *entity.Db) error {
// 1. 参数校验返回error
if dbEntity.Name == "" {
return errorx.NewBiz("名称不能为空")
}
// 2. 业务检查
oldDb := &entity.Db{Name: dbEntity.Name, InstanceId: dbEntity.InstanceId}
if dbEntity.Id == 0 && d.GetByCond(oldDb) == nil {
return errorx.NewBizI(ctx, imsg.ErrDbNameExist)
}
// 3. 持久化
return d.Save(ctx, dbEntity)
}
```
## 错误处理
```go
// 普通业务错误
return errorx.NewBiz("数据库名称已存在")
// 国际化错误
return errorx.NewBizI(ctx, imsg.ErrDbNameExist)
```
## 边界
-**Always**: 参数校验后返回 error禁止 panic
- 🚫 **Never**: 在 application 层使用 `biz.ErrIsNil``biz.IsTrue`

View File

@@ -0,0 +1,59 @@
---
trigger: always_on
---
# Go 分层架构与目录规范
## 分层目录
```
internal/{module}/
├── api/ # HTTP请求处理、参数绑定、响应返回
│ ├── form/ # 请求表单结构体
│ └── vo/ # 响应视图对象
├── application/ # 业务逻辑编排、事务控制
│ └── dto/ # 数据传输对象
├── domain/ # 核心业务逻辑、实体定义
│ ├── entity/ # 领域实体
│ └── repository/ # 仓储接口定义
├── infra/ # 数据持久化、外部服务调用
│ └── persistence/ # 仓储实现
├── imsg/ # 国际化消息定义
└── init/ # 模块初始化(依赖注册、路由注册)
```
## 命名规范
- **模块/包名**: 小写无分隔符(`machine`, `dbinstance`
- **文件名**: 小写+下划线(`db.go`, `db_sql_exec.go`
- **结构体/常量**: PascalCase
- **接口**: 以 `er` 结尾或名词(`Reader`, `Repository`
- **变量/函数**: camelCase
## IOC 依赖注入
```go
// 1. 定义接口
type Db interface {
base.App[*entity.Db]
GetPageList(condition *entity.DbQuery, orderBy ...string) (*model.PageResult[*entity.DbListPO], error)
}
// 2. 实现接口并注入依赖
type dbAppImpl struct {
base.AppImpl[*entity.Db, repository.Db]
dbInstanceApp Instance `inject:"T"` // T=按类型注入
tagApp tagapp.TagTree `inject:"T"`
}
var _ Db = (*dbAppImpl)(nil)
// 3. 模块初始化时注册
func init() {
ioc.Register(&dbAppImpl{})
}
```
## 边界
-**Always**: 依赖单向流动,上层依赖下层接口,禁止反向依赖
- 🚫 **Never**: 跨层直接调用具体实现,必须通过接口

69
docs/server/concurrent.md Normal file
View File

@@ -0,0 +1,69 @@
---
trigger: always_on
---
# 并发与 Panic 处理规范
## 统一 Panic 捕获gox.Recover
**核心原则**:严禁手动编写 `defer func() { recover() }`,必须使用 `gox.Recover()`
### 场景1仅记录日志
```go
func (s *Service) ProcessData(data []byte) {
defer gox.Recover()
result := parseData(data)
saveToDB(result)
}
```
### 场景2Panic 转 Error 返回
```go
func (s *Service) SaveUser(ctx context.Context, user *entity.User) (err error) {
defer gox.Recover(func(e error) {
err = fmt.Errorf("保存用户失败: %w", e)
})
if err := validateUser(user); err != nil {
return err
}
return s.repo.Insert(ctx, user)
}
```
### 场景3Goroutine 安全启动
```go
// ✅ 推荐
gox.Go(func() {
sendNotification(userId, message)
})
// 🚫 禁止
go func() {
sendNotification(userId, message)
}()
```
## Context 传递
所有阻塞操作必须接受 `context.Context`
```go
func (d *dbAppImpl) SaveDb(ctx context.Context, entity *entity.Db) error {
return d.GetRepo().Insert(ctx, entity)
}
```
## 错误组使用
```go
eg, ctx := errgroup.WithContext(context.Background())
for _, task := range tasks {
eg.Go(func() error {
return process(ctx, task)
})
}
err := eg.Wait()
```

44
docs/server/domain.md Normal file
View File

@@ -0,0 +1,44 @@
---
trigger: always_on
---
# Domain 层规范
## 实体定义
```go
package entity
import "mayfly-go/pkg/model"
type Db struct {
model.Model // 必须嵌入基础模型
model.ExtraData // 辅助字段(展示用、非查询条件)
Code string `json:"code" gorm:"size:32;not null;index:idx_db_code"`
Name string `json:"name" gorm:"size:255;not null;"`
InstanceId uint64 `json:"instanceId" gorm:"not null;"`
}
type Status int8
const (
StatusActive Status = 1
StatusInactive Status = 0
)
```
## ExtraData 使用原则
-**使用 ExtraData**: 前端展示字段、关联名称、状态文本、可选扩展信息
- 🚫 **必须独立字段**: 查询条件、排序字段、分组统计、索引字段、核心业务字段
## Repository 接口
```go
package repository
type Db interface {
base.Repo[*entity.Db]
GetDbList(condition *entity.DbQuery, orderBy ...string) (*model.PageResult[*entity.DbListPO], error)
}
```

113
docs/server/i18n.md Normal file
View File

@@ -0,0 +1,113 @@
---
trigger: always_on
---
# 后端国际化i18n规范
## 文件组织
每个业务模块在 `internal/{module}/imsg/` 目录下维护国际化消息:
```
internal/{module}/imsg/
├── imsg.go # 消息ID常量定义MsgId
├── zh_cn.go # 中文语言包
└── en.go # 英文语言包
```
### imsg.go — 常量定义
```go
package imsg
import (
"mayfly-go/internal/pkg/consts"
"mayfly-go/pkg/i18n"
)
func init() {
i18n.AppendLangMsg(i18n.Zh_CN, Zh_CN)
i18n.AppendLangMsg(i18n.En, En)
}
const (
LogDbSave = iota + consts.ImsgNumDb
LogDbDelete
ErrDbNameExist
)
```
### zh_cn.go — 中文语言包
```go
package imsg
import "mayfly-go/pkg/i18n"
var Zh_CN = map[i18n.MsgId]string{
LogDbSave: "保存数据库配置",
LogDbDelete: "删除数据库配置",
ErrDbNameExist: "该实例下数据库名已存在",
}
```
### en.go — 英文语言包
```go
package imsg
import "mayfly-go/pkg/i18n"
var En = map[i18n.MsgId]string{
LogDbSave: "Save database configuration",
LogDbDelete: "Delete database configuration",
ErrDbNameExist: "The database name already exists in this instance",
}
```
## 消息ID编号规则
各模块起始编号定义在 `internal/pkg/consts/consts.go`,新增模块需注册唯一起始值:
```go
const (
ImsgNumSys = 10000
ImsgNumAuth = 20000
ImsgNumDb = 60000
ImsgNumAi = 140000
// ...
)
```
模块内使用 `iota + consts.ImsgNum{Xxx}` 自增,避免全局冲突。
## 使用方式
### 国际化业务错误
```go
return errorx.NewBizI(ctx, imsg.ErrDbNameExist)
```
### 国际化操作日志
```go
req.NewPost("", d.Save).Log(req.NewLogSaveI(imsg.LogDbSave))
```
### 模板变量替换
```go
// 定义ErrDbNotAccess = "未配置数据库【{{.dbName}}】的操作权限"
errorx.NewBizI(ctx, imsg.ErrDbNotAccess, "dbName", dbName)
// 或直接使用 i18n 包
i18n.T(imsg.DataSyncSuccessMsg, "count", 100)
i18n.TC(ctx, imsg.DataSyncSuccessMsg, "count", 100)
```
## 边界
-**Always**: 新增模块时必须同步创建 `imsg.go``zh_cn.go``en.go` 三个文件
-**Always**: 操作日志消息以 `Log` 开头,业务错误以 `Err` 开头
- 🚫 **Never**: 在 `errorx.NewBiz("硬编码中文")` 中直接使用硬编码文本,必须走国际化

View File

@@ -0,0 +1,45 @@
---
trigger: always_on
---
# Infrastructure 层规范
## Repository 实现
```go
package persistence
type dbRepoImpl struct {
base.RepoImpl[*entity.Db]
}
func newDbRepo() repository.Db {
return &dbRepoImpl{}
}
func (d *dbRepoImpl) GetDbList(condition *entity.DbQuery, orderBy ...string) (*model.PageResult[*entity.DbListPO], error) {
pd := model.NewCond().
Eq("instance_id", condition.InstanceId).
In("code", condition.Codes).
Like("name", condition.Name)
list := []*entity.DbListPO{}
return gormx.PageByCond(d.GetModel(), pd, condition.PageParam, list)
}
```
## GORMX 常用操作
```go
// 条件构建
pd := model.NewCond().Eq("status", 1).In("id", ids).Like("name", keyword)
// 分页查询
result, err := gormx.PageByCond(repo.GetModel(), pd, pageParam, &list)
// 单条查询
err := gormx.GetByCond(repo.GetModel(), pd, &entity)
// 更新
err := gormx.UpdateByCond(repo.GetModel(), values, pd)
```

62
docs/server/quality.md Normal file
View File

@@ -0,0 +1,62 @@
---
trigger: always_on
---
# 代码质量与 Git 规范
## 函数长度
- 单个函数不超过 100 行
- 复杂逻辑拆分为私有方法
## Error 处理
```go
// ✅ 完整处理
result, err := doSomething()
if err != nil {
logx.Errorf("操作失败: %v", err)
return errorx.NewBiz("操作失败")
}
// 🚫 忽略错误
result, _ := doSomething()
```
## 资源释放
```go
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
```
## 魔法数字
```go
const MaxRetryCount = 3
if retry > MaxRetryCount { ... } // ✅
if retry > 3 { ... } // 🚫
```
## Git 提交格式
```
<type>(<scope>): <subject>
<body>
```
**Type 类型**: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`
**示例**:
```
feat(db): 添加数据库备份功能
- 实现定时备份任务
- 支持增量备份和全量备份
Closes #123
```

26
docs/server/security.md Normal file
View File

@@ -0,0 +1,26 @@
---
trigger: always_on
---
# 安全与权限规范
## 权限控制
```go
// 路由级别
req.NewPost(":dbId/exec-sql", d.ExecSql).RequiredPermissionCode("db:sqlscript:run")
// 代码级别
biz.IsTrue(account.HasPermission("db:sqlscript:run"), "无权限执行SQL")
```
## 敏感信息
- 资源密码使用 AES 加密存储
- `aes.key``jwt.key` 必须使用随机字符串
## OWASP 安全准则
- 防范 SQL 注入:使用参数化查询
- 防范 XSS输出转义
- 防范 CSRF配合前端同源策略

View File

@@ -5,7 +5,7 @@ VITE_PORT = 8889
VITE_OPEN = false
# public path 配置线上环境路径(打包)
VITE_PUBLIC_PATH = ''
VITE_PUBLIC_PATH = './'
VITE_EDITOR=idea

View File

@@ -3,3 +3,5 @@ ENV = 'production'
# 线上环境接口地址
VITE_API_URL = '/api'
VITE_ROUTER_MODE = history

View File

@@ -1,76 +0,0 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
},
parser: 'vue-eslint-parser',
parserOptions: {
ecmaVersion: 12,
parser: '@typescript-eslint/parser',
sourceType: 'module',
},
extends: ['plugin:vue/essential', 'eslint:recommended'],
plugins: ['vue', '@typescript-eslint'],
overrides: [
{
files: ['*.ts', '*.tsx', '*.vue'],
rules: {
'no-undef': 'off',
},
},
],
rules: {
// http://eslint.cn/docs/rules/
// https://eslint.vuejs.org/rules/
// https://typescript-eslint.io/rules/no-unused-vars/
'@typescript-eslint/ban-ts-ignore': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'vue/custom-event-name-casing': 'off',
'vue/attributes-order': 'off',
'vue/one-component-per-file': 'off',
'vue/html-closing-bracket-newline': 'off',
'vue/max-attributes-per-line': 'off',
'vue/multiline-html-element-content-newline': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/attribute-hyphenation': 'off',
'vue/html-self-closing': 'off',
'vue/no-multiple-template-root': 'off',
'vue/require-default-prop': 'off',
'vue/no-v-model-argument': 'off',
'vue/no-arrow-functions-in-watch': 'off',
'vue/no-template-key': 'off',
'vue/no-v-html': 'off',
'vue/no-unused-vars': 'off',
'vue/comment-directive': 'off',
'vue/no-parsing-error': 'off',
'vue/no-deprecated-v-on-native-modifier': 'off',
'vue/multi-word-component-names': 'off',
'no-useless-escape': 'off',
'no-sparse-arrays': 'off',
'no-prototype-builtins': 'off',
'no-constant-condition': 'off',
'no-use-before-define': 'off',
'no-restricted-globals': 'off',
'no-restricted-syntax': 'off',
'generator-star-spacing': 'off',
'no-unreachable': 'off',
'no-multiple-template-root': 'off',
'no-unused-vars': 'off',
'no-v-model-argument': 'off',
'no-case-declarations': 'off',
// 'no-console': 'error',
'no-redeclare': 'off',
},
};

106
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,106 @@
// import js from '@eslint/js';
// import tseslint from 'typescript-eslint';
// import vuePlugin from 'eslint-plugin-vue';
// import vueParser from 'vue-eslint-parser';
// export default tseslint.config(
// {
// ignores: [
// '*.sh',
// 'node_modules',
// 'lib',
// '*.md',
// '*.scss',
// '*.woff',
// '*.ttf',
// '.vscode',
// '.idea',
// 'dist',
// 'mock',
// 'public',
// 'bin',
// 'build',
// 'config',
// 'index.html',
// 'src/assets',
// ],
// },
// js.configs.recommended,
// ...tseslint.configs.recommended,
// ...vuePlugin.configs['flat/recommended'],
// {
// files: ['**/*.{js,ts,tsx,vue}'],
// languageOptions: {
// ecmaVersion: 2021,
// sourceType: 'module',
// parser: vueParser,
// parserOptions: {
// parser: tseslint.parser,
// },
// globals: {
// browser: true,
// es2021: true,
// node: true,
// console: true,
// window: true,
// document: true,
// setTimeout: true,
// },
// },
// plugins: {
// vue: vuePlugin,
// },
// rules: {
// '@typescript-eslint/ban-ts-ignore': 'off',
// '@typescript-eslint/explicit-function-return-type': 'off',
// '@typescript-eslint/no-explicit-any': 'off',
// '@typescript-eslint/no-var-requires': 'off',
// '@typescript-eslint/no-empty-function': 'off',
// '@typescript-eslint/no-use-before-define': 'off',
// '@typescript-eslint/ban-ts-comment': 'off',
// '@typescript-eslint/ban-types': 'off',
// '@typescript-eslint/no-non-null-assertion': 'off',
// '@typescript-eslint/explicit-module-boundary-types': 'off',
// '@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
// '@typescript-eslint/no-unused-vars': 'off',
// // Vue rules
// 'vue/html-indent': ['error', 4],
// 'vue/script-indent': ['error', 4],
// 'vue/custom-event-name-casing': 'off',
// 'vue/attributes-order': 'off',
// 'vue/one-component-per-file': 'off',
// 'vue/html-closing-bracket-newline': 'off',
// 'vue/max-attributes-per-line': 'off',
// 'vue/multiline-html-element-content-newline': 'off',
// 'vue/singleline-html-element-content-newline': 'off',
// 'vue/attribute-hyphenation': 'off',
// 'vue/html-self-closing': 'off',
// 'vue/no-multiple-template-root': 'off',
// 'vue/require-default-prop': 'off',
// 'vue/no-v-model-argument': 'off',
// 'vue/no-arrow-functions-in-watch': 'off',
// 'vue/no-template-key': 'off',
// 'vue/no-v-html': 'off',
// 'vue/no-unused-vars': 'off',
// 'vue/comment-directive': 'off',
// 'vue/no-parsing-error': 'off',
// 'vue/no-deprecated-v-on-native-modifier': 'off',
// 'vue/multi-word-component-names': 'off',
// // JavaScript rules
// 'no-useless-escape': 'off',
// 'no-sparse-arrays': 'off',
// 'no-prototype-builtins': 'off',
// 'no-constant-condition': 'off',
// 'no-use-before-define': 'off',
// 'no-restricted-globals': 'off',
// 'no-restricted-syntax': 'off',
// 'generator-star-spacing': 'off',
// 'no-unreachable': 'off',
// 'no-unused-vars': 'off',
// 'no-case-declarations': 'off',
// 'no-redeclare': 'off',
// },
// }
// );

View File

@@ -1,7 +1,10 @@
<!DOCTYPE html>
<html lang="zh_CN">
<app-config />
<head>
<base href="/" />
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -14,7 +17,7 @@
<body>
<div id="app"></div>
<script type="application/javascript" src="./config.js"></script>
<script type="application/javascript" src="/config.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>

View File

@@ -11,60 +11,68 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@logicflow/core": "^2.1.7",
"@logicflow/extension": "^2.1.9",
"@vueuse/core": "^14.1.0",
"@logicflow/core": "^2.2.1",
"@logicflow/extension": "^2.2.1",
"@vueuse/core": "^14.3.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"asciinema-player": "^3.14.0",
"axios": "^1.6.2",
"asciinema-player": "^3.15.1",
"axios": "^1.16.0",
"clipboard": "^2.0.11",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.19",
"dayjs": "^1.11.20",
"echarts": "^6.0.0",
"element-plus": "^2.13.0",
"element-plus": "^2.14.0",
"js-base64": "^3.7.8",
"jsencrypt": "^3.5.4",
"json-bigint": "^1.0.0",
"mermaid": "^11.15.0",
"monaco-editor": "^0.55.1",
"monaco-sql-languages": "^0.15.1",
"monaco-sql-languages": "^1.0.0",
"nprogress": "^0.2.0",
"pinia": "^3.0.4",
"qrcode.vue": "^3.6.0",
"qrcode.vue": "^3.9.0",
"screenfull": "^6.0.2",
"sortablejs": "^1.15.6",
"sql-formatter": "^15.6.12",
"trzsz": "^1.1.5",
"uuid": "^13.0.0",
"vue": "^v3.6.0-beta.2",
"vue-i18n": "^11.2.8",
"vue-router": "^4.6.4",
"shiki": "^4.0.2",
"shiki-stream": "^0.1.4",
"sortablejs": "^1.15.7",
"sql-formatter": "^15.7.3",
"trzsz": "^1.1.6",
"uuid": "^13.0.2",
"vue": "3.6.0-beta.11",
"vue-element-plus-x": "^2.0.2",
"vue-i18n": "^11.4.2",
"vue-router": "^5.0.6",
"vuedraggable": "^4.1.0",
"x-markdown-vue": "0.0.200",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.0",
"@types/crypto-js": "^4.2.2",
"@types/node": "^22.13.14",
"@types/nprogress": "^0.2.0",
"@types/sortablejs": "^1.15.8",
"@typescript-eslint/eslint-plugin": "^8.35.0",
"@typescript-eslint/parser": "^8.35.0",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/compiler-sfc": "^3.5.22",
"autoprefixer": "^10.4.21",
"code-inspector-plugin": "^1.0.4",
"eslint": "^9.39.2",
"eslint-plugin-vue": "^10.6.2",
"postcss": "^8.5.6",
"prettier": "^3.7.4",
"sass": "^1.97.1",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "npm:rolldown-vite@latest",
"@types/node": "^22.19.18",
"@types/nprogress": "^0.2.3",
"@types/sortablejs": "^1.15.9",
"@typescript-eslint/eslint-plugin": "^8.59.2",
"@typescript-eslint/parser": "^8.59.2",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/compiler-sfc": "^3.5.34",
"autoprefixer": "^10.5.0",
"code-inspector-plugin": "^1.5.1",
"eslint": "^10.3.0",
"eslint-plugin-vue": "^10.9.1",
"postcss": "^8.5.14",
"prettier": "^3.8.3",
"sass": "^1.99.0",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"vite-plugin-progress": "0.0.7",
"vue-eslint-parser": "^10.2.0"
"vue-eslint-parser": "^10.4.0"
},
"browserslist": [
"> 1%",

View File

@@ -1,4 +1,4 @@
module.exports = {
export default {
// 一行最多多少个字符
printWidth: 160,
// 指定每个缩进级别的空格数

View File

@@ -1,5 +1,10 @@
<template>
<el-config-provider :size="getGlobalComponentSize" :locale="getGlobalI18n">
<el-config-provider
:size="getGlobalComponentSize"
:locale="getGlobalI18n"
:button="{ autoInsertSpace: false, round: true }"
:dialog="{ alignCenter: true, transition: 'dialog-bounce' }"
>
<el-watermark
:zIndex="100000"
:width="210"

View File

@@ -0,0 +1 @@
<svg t="1775984689718" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9497" width="48" height="48"><path d="M710.8 98.1H318.5c-120 0-217.6 97.6-217.6 217.6V708c0 120 97.6 217.6 217.6 217.6h392.3c120 0 217.6-97.6 217.6-217.6V315.7c0.1-120-97.6-217.6-217.6-217.6z m-30 57.4L652.6 214c-8.8 18.3-27.7 30.2-48.1 30.2H424.9c-20.4 0-39.2-11.9-48.1-30.2l-28.2-58.6h332.2zM871.1 708c0 88.4-71.9 160.3-160.3 160.3H318.5c-88.4 0-160.3-71.9-160.3-160.3V315.7c0-77.4 55.2-142.2 128.2-157l38.6 80.2c18.3 38.1 57.5 62.7 99.7 62.7h179.6c42.3 0 81.4-24.6 99.7-62.7l38.6-80.2c73.1 14.9 128.2 79.6 128.2 157V708z" p-id="9498"></path><path d="M486.9 408.2c-4.6-9.9-14.1-15.8-24.3-16.4-0.6 0-1.3-0.1-1.9-0.1-0.7 0-1.4 0.1-2.1 0.1-10.1 0.7-19.6 6.5-24.2 16.4l-142.7 306c-6.7 14.4-0.5 31.4 13.9 38.1 3.9 1.8 8 2.7 12.1 2.7 10.8 0 21.1-6.1 26-16.6l34.4-73.8h165.1l34.4 73.8c4.9 10.4 15.2 16.6 26 16.6 4.1 0 8.2-0.9 12.1-2.7 14.4-6.7 20.6-23.8 13.9-38.1l-142.7-306z m-82 199.1l55.8-119.7 55.8 119.7H404.9zM683.1 391.2c-15.8 0-28.7 12.8-28.7 28.7v306.9c0 15.8 12.8 28.7 28.7 28.7s28.7-12.8 28.7-28.7V419.9c0-15.9-12.8-28.7-28.7-28.7z" p-id="9499"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<svg t="1775984826390" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11872" width="48" height="48"><path d="M762.112 325.632h-225.536v-65.792c49.408-11.264 86.528-55.296 86.528-108.288 0-61.184-49.92-110.848-111.104-110.848s-111.104 49.664-111.104 110.848c0 52.736 37.12 97.024 86.528 108.288v65.792h-225.536c-87.808 0-158.976 71.424-158.976 158.976V706.56c0 87.808 71.424 158.976 158.976 158.976h500.224c87.808 0 158.976-71.424 158.976-158.976v-221.696c0-87.808-71.424-159.232-158.976-159.232z m-312.064-174.08c0-34.048 27.904-61.952 61.952-61.952s61.952 27.904 61.952 61.952-27.904 61.952-61.952 61.952-61.952-27.904-61.952-61.952zM872.192 706.56c0 60.672-49.408 110.08-110.08 110.08H261.888c-60.672 0-110.08-49.408-110.08-110.08v-221.696c0-60.672 49.408-110.08 110.08-110.08h500.224c60.672 0 110.08 49.408 110.08 110.08V706.56zM724.224 934.4H299.776c-13.568 0-24.576 11.008-24.576 24.576s11.008 24.576 24.576 24.576h424.192c13.568 0 24.576-11.008 24.576-24.576s-10.752-24.576-24.32-24.576zM29.696 478.464c-13.568 0-24.576 11.008-24.576 24.576v185.088c0 13.568 11.008 24.576 24.576 24.576s24.576-11.008 24.576-24.576v-185.088c0-13.568-11.008-24.576-24.576-24.576zM994.304 478.464c-13.568 0-24.576 11.008-24.576 24.576v185.088c0 13.568 11.008 24.576 24.576 24.576s24.576-11.008 24.576-24.576v-185.088c0-13.568-11.008-24.576-24.576-24.576z" p-id="11873"></path><path d="M349.184 467.968c-70.4 0-127.488 57.088-127.488 127.488 0 70.4 57.344 127.488 127.488 127.488s127.744-57.088 127.744-127.488c-0.256-70.144-57.344-127.488-127.744-127.488z m0 206.08c-43.264 0-78.592-35.328-78.592-78.592s35.328-78.592 78.592-78.592 78.592 35.328 78.592 78.592-35.328 78.592-78.592 78.592zM674.816 467.968c-70.4 0-127.744 57.088-127.744 127.488 0 70.4 57.344 127.488 127.744 127.488s127.488-57.088 127.488-127.488c0.256-70.144-57.088-127.488-127.488-127.488z m0 206.08c-43.264 0-78.592-35.328-78.592-78.592s35.328-78.592 78.592-78.592 78.592 35.328 78.592 78.592-35.328 78.592-78.592 78.592z" p-id="11874"></path></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 150 150">
<defs>
<style>
.cls-1 {
fill: #161616;
stroke-width: 0px;
}
</style>
</defs>
<path class="cls-1" d="M27,25.7c0-.6.5-1.2,1.2-1.2h8.8c.6,0,1.2.5,1.2,1.2v97.7c0,.6-.5,1.2-1.2,1.2h-8.8c-.6,0-1.2-.5-1.2-1.2V25.7Z"/>
<path class="cls-1" d="M49.2,25.7c0-.6.5-1.2,1.2-1.2h8.8c.6,0,1.2.5,1.2,1.2v97.7c0,.6-.5,1.2-1.2,1.2h-8.8c-.6,0-1.2-.5-1.2-1.2V25.7Z"/>
<path class="cls-1" d="M71.4,25.7c0-.6.5-1.2,1.2-1.2h8.8c.6,0,1.2.5,1.2,1.2v97.7c0,.6-.5,1.2-1.2,1.2h-8.8c-.6,0-1.2-.5-1.2-1.2V25.7Z"/>
<path class="cls-1" d="M93.6,25.7c0-.6.5-1.2,1.2-1.2h8.8c.6,0,1.2.5,1.2,1.2v97.7c0,.6-.5,1.2-1.2,1.2h-8.8c-.6,0-1.2-.5-1.2-1.2V25.7Z"/>
<path class="cls-1" d="M115.9,64.6c0-.6.5-1.2,1.2-1.2h8.8c.6,0,1.2.5,1.2,1.2v19.9c0,.6-.5,1.2-1.2,1.2h-8.8c-.6,0-1.2-.5-1.2-1.2v-19.9Z"/>
</svg>

After

Width:  |  Height:  |  Size: 962 B

View File

@@ -47,6 +47,7 @@ function convertSvgToSymbol(svgString, symbolId) {
iconNames.push(`icon ${name}`);
svgsymbols += convertSvgToSymbol(allSvgIcons[path].default, name);
}
svgsymbols += '</svg>';
var t = (t = document.getElementsByTagName('script'))[t.length - 1],

View File

@@ -0,0 +1 @@
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="64" height="64"><path d="M254.816 286.4a317.504 317.504 0 0 1 450.144 0c124.512 124.704 124.512 326.688 0 451.392a317.76 317.76 0 0 1-450.144-0.192l-203.136-203.84a30.848 30.848 0 0 1 0-43.584L254.816 286.4z m401.92 59.84a233.28 233.28 0 0 0-330.656 0L176.864 496a22.784 22.784 0 0 0 0 32l149.44 149.92a233.312 233.312 0 0 0 330.688 0 234.88 234.88 0 0 0-0.192-331.648h-0.064zM972.384 492.64l-90.592-91.008a7.552 7.552 0 0 0-12.8 7.104 481.984 481.984 0 0 1 0 211.2c-1.664 7.584 7.488 12.48 12.8 7.136l90.592-90.944a30.912 30.912 0 0 0 0-43.52z" fill="#0077FF"></path><path d="M493.28 354.784c86.784 0 157.216 71.456 157.216 159.52 0 88.16-70.336 159.616-157.216 159.616-86.848 0-157.28-71.456-157.28-159.616 0-88.064 70.4-159.52 157.28-159.52z" fill="#A6D0FF"></path></svg>

After

Width:  |  Height:  |  Size: 872 B

View File

@@ -0,0 +1 @@
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="64" height="64"><path d="M668.8 460.8c64 0 118.4-54.4 118.4-118.4S732.8 224 668.8 224s-118.4 54.4-118.4 118.4c0 12.8 3.2 22.4 6.4 35.2L502.4 416c-22.4-28.8-57.6-48-92.8-57.6v-64c54.4-12.8 92.8-60.8 92.8-115.2 0-64-54.4-118.4-118.4-118.4s-118.4 54.4-118.4 118.4c0 54.4 38.4 102.4 92.8 115.2v67.2c-80 16-134.4 96-118.4 176 12.8 60.8 57.6 105.6 118.4 118.4v70.4c-54.4 12.8-92.8 57.6-92.8 115.2 0 64 54.4 118.4 118.4 118.4s118.4-54.4 118.4-118.4c0-54.4-38.4-102.4-92.8-115.2v-70.4c35.2-6.4 70.4-22.4 92.8-54.4l54.4 41.6c-3.2 9.6-6.4 22.4-6.4 35.2 3.2 64 54.4 115.2 121.6 115.2 64-3.2 115.2-54.4 115.2-121.6-3.2-64-54.4-115.2-118.4-115.2-35.2 0-67.2 16-89.6 41.6l-54.4-41.6c6.4-16 9.6-35.2 9.6-54.4 0-16-3.2-35.2-9.6-48l54.4-38.4c19.2 32 54.4 48 89.6 44.8z m0-176c32 0 54.4 25.6 54.4 54.4s-25.6 54.4-54.4 54.4c-32 0-54.4-25.6-54.4-54.4-3.2-28.8 22.4-54.4 54.4-54.4z m-345.6-105.6c0-32 25.6-54.4 54.4-54.4 32 0 54.4 25.6 54.4 54.4s-25.6 54.4-54.4 54.4c-28.8 3.2-54.4-22.4-54.4-54.4z m115.2 662.4c0 32-22.4 57.6-54.4 57.6s-57.6-22.4-57.6-54.4 22.4-57.6 54.4-57.6 57.6 25.6 57.6 54.4z m-54.4-249.6c-44.8-3.2-76.8-41.6-73.6-86.4 3.2-38.4 35.2-70.4 73.6-73.6 44.8 0 80 35.2 80 80 0 41.6-38.4 76.8-80 80z m284.8 32c28.8 0 54.4 22.4 54.4 54.4s-22.4 54.4-51.2 54.4h-3.2c-32 0-54.4-25.6-54.4-57.6s25.6-54.4 54.4-54.4v3.2z" fill="#0171F1"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1 @@
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="64" height="64"><path d="M750.08 657.92c-16.384 0-30.208 2.56-44.032 8.192l-44.032-52.224c33.28-38.4 55.296-90.624 55.296-145.92s-19.456-101.888-52.224-140.288l27.648-24.576c16.384 8.192 33.28 13.824 52.224 13.824 63.488 0 115.712-52.224 115.712-115.712s-52.224-115.712-115.712-115.712c-63.488 0-115.712 52.224-115.712 115.712 0 13.824 2.56 30.208 8.192 41.472l-35.84 30.208c-34.304-19.968-73.216-30.208-112.64-30.208-44.032 0-85.504 13.824-121.344 32.768l-41.472-52.224c0-5.632 2.56-13.824 2.56-19.456 0-40.96-32.768-74.24-73.728-74.24h-0.512c-40.96 0-74.24 32.768-74.24 73.728v0.512c0 40.96 32.768 74.24 73.728 74.24H262.656L306.688 332.8c-27.648 38.4-46.592 85.504-46.592 134.656 0 35.84 8.192 71.68 24.576 101.888l-35.84 30.208c-8.192-5.632-19.456-5.632-30.208-5.632-49.664 0-90.624 41.472-90.624 90.624 0 49.664 41.472 90.624 90.624 90.624s90.624-41.472 90.624-90.624c0-8.192 0-16.384-2.56-24.576l30.208-27.648c41.472 35.84 93.696 57.856 154.112 57.856 33.28 0 63.488-5.632 90.624-19.456l46.592 57.856c-11.264 19.456-19.456 44.032-19.456 68.608 0 77.312 63.488 140.288 143.36 140.288s143.36-63.488 143.36-140.288c0.512-75.776-65.536-139.264-145.408-139.264z m-261.632-11.264c-99.328 0-181.76-79.872-181.76-178.688C306.688 368.64 389.12 289.28 488.448 289.28s181.76 79.872 181.76 178.688c0 99.328-82.432 178.688-181.76 178.688zM430.592 465.408c0.512 18.432-13.824 33.28-32.256 33.792-18.432 0.512-33.28-13.824-33.792-32.256v-1.536c0-18.432 14.848-32.768 33.28-32.768 18.432-0.512 32.768 14.336 32.768 32.768z m88.064 0c0 18.432-14.848 33.28-32.768 33.28-18.432 0-33.28-14.848-33.28-32.768 0-18.432 14.848-32.768 33.28-32.768s32.768 13.824 32.768 32.256z m91.136 0c0 18.432-14.848 33.28-32.768 33.28s-33.28-14.848-33.28-32.768c0-18.432 14.848-32.768 33.28-32.768 17.92-1.024 32.768 13.824 32.768 32.256z" fill="#2B85FB"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -3,8 +3,10 @@ import { RequestOptions, useApiFetch } from '@/hooks/useRequest';
/**
* 可用于各模块定义各自api请求
* T 请求返回的数据类型
* P 请求参数类型
*/
class Api {
class Api<T = any, P = any> {
/**
* 请求url
*/
@@ -45,29 +47,30 @@ class Api {
/**
* 响应式使用该api
* @param params 响应式params
* @param param 请求参数
* @param reqOptions 其他可选值
* @returns
*/
useApi<T>(params: any = null, reqOptions?: RequestOptions) {
return useApiFetch<T>(this, params, reqOptions);
useApi(param?: P, reqOptions?: RequestOptions) {
return useApiFetch<T, P>(this, param, reqOptions);
}
/**
* fetch 请求对应的该api
* @param {Object} param 请求该api的参数
* @param options options
*/
async request(param: any = null, options: any = {}): Promise<any> {
async request(param?: P, options: any = {}): Promise<T> {
const { execute, data } = this.useApi(param, options);
const res = await execute();
return data.value || res;
return (data.value as T) || (res as T);
}
/**
* xhr 请求对应的该api
* @param {Object} param 请求该api的参数
*/
async xhrReq(param: any = null, options: any = {}): Promise<any> {
async xhrReq(param: any = null, options: any = {}): Promise<T> {
if (this.beforeHandler) {
await this.beforeHandler(param);
}
@@ -81,40 +84,40 @@ class Api {
* @param url url
* @param method 请求方法(get,post,put,delete...)
*/
static create(url: string, method: string): Api {
return new Api(url, method);
static create<T = any, P = any>(url: string, method: string): Api<T> {
return new Api<T, P>(url, method);
}
/**
* 创建get api
* @param url url
*/
static newGet(url: string): Api {
return Api.create(url, 'get');
static newGet<T = any, P = any>(url: string): Api<T, P> {
return Api.create<T, P>(url, 'get');
}
/**
* new post api
* @param url url
*/
static newPost(url: string): Api {
return Api.create(url, 'post');
static newPost<T = any, P = any>(url: string): Api<T, P> {
return Api.create<T, P>(url, 'post');
}
/**
* new put api
* @param url url
*/
static newPut(url: string): Api {
return Api.create(url, 'put');
static newPut<T = any, P = any>(url: string): Api<T, P> {
return Api.create<T, P>(url, 'put');
}
/**
* new delete api
* @param url url
*/
static newDelete(url: string): Api {
return Api.create(url, 'delete');
static newDelete<T = any, P = any>(url: string): Api<T, P> {
return Api.create<T, P>(url, 'delete');
}
}

View File

@@ -32,7 +32,7 @@ export function isTrue(condition: boolean, msgOrI18nKey: string) {
* @param msg 错误消息
*/
export function notBlank(obj: any, msg: string) {
if (obj == null || obj == undefined || obj == '') {
if (obj == null || obj == undefined || !obj) {
throw new AssertError(msg);
}
if (Array.isArray(obj) && obj.length == 0) {

View File

@@ -23,6 +23,8 @@ export const ResourceTypeEnum = {
AuthCert: EnumValue.of(5, 'ac.ac').setExtra({ icon: 'Ticket', iconColor: 'var(--el-color-success)' }),
Es: EnumValue.of(6, 'tag.es').setExtra({ icon: 'icon es/es-color', iconColor: 'var(--el-color-warning)' }).tagTypeWarning(),
Container: EnumValue.of(7, 'tag.container').setExtra({ icon: 'icon docker/docker', iconColor: 'var(--el-color-primary)' }),
MqKafka: EnumValue.of(8, 'tag.mq.kafka').setExtra({ icon: 'icon mq/kafka', iconColor: 'var(--el-color-primary)' }),
Milvus: EnumValue.of(9, 'tag.milvus').setExtra({ icon: 'icon milvus/milvus', iconColor: 'var(--el-color-primary)' }),
};
// 标签关联的资源类型
@@ -38,6 +40,10 @@ export const TagResourceTypeEnum = {
AuthCert: ResourceTypeEnum.AuthCert,
Container: ResourceTypeEnum.Container,
MqKafka: ResourceTypeEnum.MqKafka,
Milvus: ResourceTypeEnum.Milvus,
Db: EnumValue.of(22, '数据库').setExtra({ icon: 'icon db/db' }),
};

View File

@@ -1,8 +1,28 @@
/**
* 获取应用配置。
* 需要后端将index.html文件中的<app-config />标签替换为script标签并将配置项挂载到全局变量window.__APP_CONFIG__ 上
* @returns 应用配置
*/
export function getAppConfig() {
return (window as any)?.__APP_CONFIG__;
}
export function getBaseApiUrl() {
const config = getAppConfig();
console.log('app config: ', config);
if (config) {
if (!config.CTX_PATH) {
return window.location.host;
}
return window.location.host + config.CTX_PATH;
}
let path = window.location.pathname;
if (path == '/') {
return window.location.host;
}
if (path.endsWith('/')) {
// 去除最后一个/
return window.location.host + path.replace(/\/$/, '');
@@ -13,9 +33,6 @@ export function getBaseApiUrl() {
const config = {
baseApiUrl: `${(window as any).globalConfig.BaseApiUrl || location.protocol + '//' + getBaseApiUrl()}/api`,
baseWsUrl: `${(window as any).globalConfig.BaseWsUrl || `${location.protocol == 'https:' ? 'wss:' : 'ws:'}//${getBaseApiUrl()}`}/api`,
// 系统版本
version: 'v1.10.5',
};
export default config;

View File

@@ -4,9 +4,14 @@ import { getClientId, getToken } from './utils/storage';
import { templateResolve } from './utils/string';
import { ElMessage } from 'element-plus';
import axios from 'axios';
import JSONBig from 'json-bigint';
import { useApiFetch } from '../hooks/useRequest';
import Api from './Api';
// 配置 JSONBig将大数int64/uint64转为字符串避免精度丢失
// storeAsString: 将大数存储为字符串,而不是 BigNumber 对象
const JSONBigString = JSONBig({ storeAsString: true });
export default {
request,
xhrReq,
@@ -58,6 +63,22 @@ function notifyErrorMsg(msg: string) {
const axiosInst = axios.create({
baseURL: baseUrl, // url = base url + request url
timeout: 60000, // request timeout
// 使用 json-bigint 处理响应数据,解决 int64/uint64 精度丢失问题
transformResponse: [
function (data) {
// 对响应数据进行转换
if (typeof data === 'string') {
try {
// 使用 JSONBigString 解析,大数会被转为字符串
return JSONBigString.parse(data);
} catch (err) {
// 如果解析失败,返回原始数据
return data;
}
}
return data;
},
],
});
// request interceptor

View File

@@ -6,7 +6,7 @@ import dayjs from 'dayjs';
* @param format 格式化格式 默认 YYYY-MM-DD HH:mm:ss
* @returns 格式化后内容
*/
export function formatDate(date: any, format: string = 'YYYY-MM-DD HH:mm:ss') {
export function formatDate(date?: string | number | Date, format: string = 'YYYY-MM-DD HH:mm:ss') {
if (!date) {
return '';
}
@@ -126,3 +126,47 @@ export function formatAxis(param: any) {
else if (hour < 22) return '晚上好';
else return '夜里好';
}
/**
* 格式化数据为美观的 JSON 字符串
*
* - 如果输入是对象,直接格式化为缩进 JSON
* - 如果输入是 JSON 字符串,先解析为对象再格式化
* - 如果解析失败,返回原始值的字符串形式
* - 如果输入为空值,返回空字符串
*
* @param val - 要格式化的数据(对象或 JSON 字符串)
* @returns 格式化后的 JSON 字符串,带 2 空格缩进
*
* @example
* ```ts
* // 格式化对象
* formatJson({ name: 'test', value: 123 })
* // 输出: '{\n "name": "test",\n "value": 123\n}'
*
* // 格式化 JSON 字符串
* formatJson('{"name":"test"}')
* // 输出: '{\n "name": "test"\n}'
*
* // 处理无效输入
* formatJson(null)
* // 输出: ''
* ```
*/
export function formatJson(val: any) {
if (!val) {
return '';
}
try {
// 如果val是字符串尝试解析为对象后再格式化
let data = val;
if (typeof val === 'string') {
data = JSON.parse(val);
}
return JSON.stringify(data, null, 2);
} catch {
// 如果解析失败,直接返回原始值的字符串形式
return String(val);
}
}

View File

@@ -106,7 +106,7 @@ export function deepClone(
result = Array.isArray(obj) ? [] : {};
hash.set(obj, result);
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
let value = obj[key];
value = callback(key, value);
result[key] = deepClone(value, callback, hash);

View File

@@ -59,7 +59,7 @@ watch(
);
const fileSize = computed(() => {
return fileDetail.value.size ? formatByteSize(fileDetail.value.size) : '';
return fileDetail.value?.size ? formatByteSize(fileDetail.value.size) : '';
});
const fileDetail: any = ref({});

View File

@@ -1,7 +1,7 @@
<template>
<div class="monaco-editor-custom relative h-full">
<div class="monaco-editor-content" ref="monacoTextareaRef" :style="{ height: height }"></div>
<el-select v-if="canChangeMode" class="code-mode-select" v-model="languageMode" @change="changeLanguage" filterable>
<el-select v-if="canChangeMode" class="code-mode-select" v-model="languageMode" @change="changeLanguage" filterable size="small">
<el-option v-for="mode in languageArr" :key="mode.value" :label="mode.label" :value="mode.value" />
</el-select>
</div>
@@ -34,6 +34,7 @@ import 'monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestInlineComplet
import { editor, languages } from 'monaco-editor';
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker.js?worker';
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
import HtmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
import SolarizedLight from './themes/Solarized-light.json';
import SolarizedDark from './themes/Solarized-dark.json';
import { language as shellLan } from 'monaco-editor/esm/vs/basic-languages/shell/shell.js';
@@ -89,7 +90,11 @@ const languageArr = [
},
{
value: 'html',
label: 'XML/HTML',
label: 'Html',
},
{
value: 'xml',
label: 'Xml',
},
{
value: 'python',
@@ -161,6 +166,9 @@ self.MonacoEnvironment = {
if (label === 'json') {
return new JsonWorker();
}
if (label === 'html') {
return new HtmlWorker();
}
return new EditorWorker();
},
};
@@ -225,10 +233,12 @@ const initMonacoEditorIns = () => {
let options = Object.assign(defaultOptions, props.options as any);
monacoEditorIns = monaco.editor.create(monacoTextareaRef.value, options);
// 监听内容改变,双向绑定
monacoEditorIns.onDidChangeModelContent(() => {
modelValue.value = monacoEditorIns.getModel()?.getValue();
});
if (!options.readOnly) {
// 监听内容改变,双向绑定
monacoEditorIns.onDidChangeModelContent(() => {
modelValue.value = monacoEditorIns.getModel()?.getValue();
});
}
};
const changeLanguage = (value: any) => {
@@ -317,7 +327,7 @@ defineExpose({ getEditor, format, focus });
z-index: 2;
right: 10px;
top: 10px;
max-width: 130px;
max-width: 100px;
}
border: 1px solid var(--el-border-color-light, #ebeef5);

View File

@@ -9,16 +9,18 @@ export type MonacoEditorDialogProps = {
language: string;
height?: string;
width?: string;
options?: any; // 可选项如字体大小等
options?: any; // 可选项,如字体大小等
canChangeLang?: boolean; // 是否可以切换语言
showConfirmButton?: boolean;
confirmFn?: Function; // 点击确认的回调函数入参editor value
confirmFn?: Function; // 点击确认的回调函数,入参editor value
closeFn?: Function; // 点击取消 或 关闭弹窗的回调函数
completionItemProvider?: monaco.languages.CompletionItemProvider; // 自定义补全项
useDrawer?: boolean; // 是否使用drawer而不是dialog,默认false
drawerSize?: string | number; // drawer尺寸,默认'50%'
};
const MonacoEditorBox = (props: MonacoEditorDialogProps): void => {
const boxId = 'monaco-editor-dialog-id';
const boxId = props.useDrawer ? 'monaco-editor-drawer-id' : 'monaco-editor-dialog-id';
let boxInstance: VNode;
const container = document.getElementById(boxId);
@@ -35,6 +37,9 @@ const MonacoEditorBox = (props: MonacoEditorDialogProps): void => {
if (props.content === undefined) {
props.content = '';
}
if (props.useDrawer === undefined) {
props.useDrawer = false;
}
// 创建 虚拟dom
boxInstance = h(MonacoEditorDialog, {
@@ -53,6 +58,7 @@ const MonacoEditorBox = (props: MonacoEditorDialogProps): void => {
}
// 移除 container DOM 元素
document.body.removeChild(container);
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
props.closeFn && props.closeFn();
},
onConfirm: () => {
@@ -72,6 +78,7 @@ const MonacoEditorBox = (props: MonacoEditorDialogProps): void => {
// 压缩json字符串
value = JSON.stringify(val);
}
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
props.confirmFn && props.confirmFn(value);
},
});

View File

@@ -1,6 +1,7 @@
<template>
<div>
<el-dialog :title="props.title" v-model="dialogVisible" :width="props.width" @close="close">
<!-- Dialog 模式 -->
<el-dialog :title="props.title" v-model="dialogVisible" :width="props.width" @close="close" v-if="!props.useDrawer">
<monaco-editor
ref="editorRef"
:height="props.height"
@@ -17,12 +18,40 @@
</span>
</template>
</el-dialog>
<!-- Drawer 模式 -->
<el-drawer
:title="props.title"
v-model="dialogVisible"
:size="props.drawerSize || '50%'"
@close="close"
:destroy-on-close="true"
:close-on-click-modal="true"
class="monaco-editor-drawer"
v-else
>
<monaco-editor
ref="editorRef"
:height="props.height || 'calc(100vh - 120px)'"
class="editor"
:language="props.language"
v-model="modelValue"
:options="props.options"
:can-change-mode="props.canChangeLang"
/>
<template #footer>
<div class="drawer-footer">
<el-button @click="dialogVisible = false">{{ i18n.global.t('common.cancel') }}</el-button>
<el-button v-if="props.showConfirmButton" @click="confirm" type="primary">{{ i18n.global.t('common.confirm') }}</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { ElDialog, ElButton, ElMessage } from 'element-plus';
import { ElDialog, ElDrawer, ElButton, ElMessage } from 'element-plus';
// import base style
import MonacoEditor from '@/components/monaco/MonacoEditor.vue';
import { MonacoEditorDialogProps } from './MonacoEditorBox';
@@ -45,6 +74,18 @@ const dialogVisible = defineModel<boolean>('visible', {
const emit = defineEmits(['close', 'confirm']);
const formatXML = function (xml: string, tab?: string) {
let formatted = '',
indent = '';
tab = tab || ' ';
xml.split(/>\s*</).forEach(function (node) {
if (node.match(/^\/\w/)) indent = indent.substring(tab!.length);
formatted += indent + '<' + node + '>\r\n';
if (node.match(/^<?\w[^>]*[^\/]$/)) indent += tab;
});
return formatted.substring(1, formatted.length - 3);
};
watch(
() => props.language,
() => {
@@ -103,26 +144,11 @@ const close = () => {
}, 200);
};
const formatXML = function (xml: string, tab?: string) {
let formatted = '',
indent = '';
tab = tab || ' ';
xml.split(/>\s*</).forEach(function (node) {
if (node.match(/^\/\w/)) indent = indent.substring(tab!.length);
formatted += indent + '<' + node + '>\r\n';
if (node.match(/^<?\w[^>]*[^\/]$/)) indent += tab;
});
return formatted.substring(1, formatted.length - 3);
};
function compressHTML(html: string) {
return (
html
.replace(/[\r\n\t]+/g, ' ') // 移除换行符和制表符
// .replace(/<!--[\s\S]*?-->/g, '') // 移除注释
.replace(/\s{2,}/g, ' ') // 合并多个空格为一个空格
.replace(/>\s+</g, '><')
); // 移除标签之间的空格
return html
.replace(/[\r\n\t]+/g, ' ') // 移除换行符和制表符
.replace(/\s{2,}/g, ' ') // 合并多个空格为一个空格
.replace(/>\s+</g, '><'); // 移除标签之间的空格
}
</script>
<style lang="scss" scoped>
@@ -130,4 +156,23 @@ function compressHTML(html: string) {
font-size: 9pt;
font-weight: 600;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 10px 0;
}
:deep(.monaco-editor-drawer) {
.el-drawer__header {
margin-bottom: 20px;
}
.el-drawer__body {
padding: 0;
}
.el-drawer__footer {
padding: 0 20px;
}
}
</style>

View File

@@ -34,7 +34,7 @@ const websocketUrl = ref(props.wsUrl);
const { data } = useWebSocket(websocketUrl);
const editorRef: any = useTemplateRef('editorRef');
const editorRef = useTemplateRef<InstanceType<typeof MonacoEditor>>('editorRef');
const modelValue = defineModel<string>('modelValue', {
type: String,
@@ -56,9 +56,9 @@ const reload = (wsUrl: string) => {
};
const revealLastLine = () => {
const editor = editorRef.value.getEditor();
const lineCount = editor?.getModel().getLineCount();
editor.revealLine(lineCount);
const editor = editorRef.value?.getEditor();
const lineCount = editor?.getModel()?.getLineCount();
editor?.revealLine(lineCount || 0);
};
defineExpose({

View File

@@ -225,13 +225,19 @@ export class TableColumn {
let maxData;
// 获取该列中最长的数据(内容)
for (let i = 0; i < tableData.length; i++) {
let nowData = tableData[i];
let nowValue = getValueByPath(nowData, prop);
const nowData = tableData[i];
const nowValue = getValueByPath(nowData, prop);
if (!nowValue) {
continue;
}
// 转为字符串比较长度
let nowText = nowValue + '';
let nowText;
if (typeof nowValue === 'object') {
nowText = JSON.stringify(nowValue);
} else {
nowText = nowValue + '';
}
if (nowText.length > maxWidthText.length) {
maxWidthText = nowText;
maxWidthValue = nowValue;

View File

@@ -66,7 +66,7 @@
import Guacamole from './guac/guacamole-common';
import { getMachineRdpSocketUrl } from '@/views/ops/machine/api';
import clipboard from './guac/clipboard';
import { reactive, ref } from 'vue';
import { onUnmounted, reactive, ref } from 'vue';
import { TerminalStatus } from '@/components/terminal/common';
import ClipboardDialog from '@/components/terminal-rdp/guac/ClipboardDialog.vue';
import { TerminalExpose } from '@/components/terminal-rdp/index';
@@ -77,6 +77,7 @@ import { useDebounceFn, useEventListener } from '@vueuse/core';
import { ClientState, TunnelState } from '@/components/terminal-rdp/guac/states';
import { ElMessage } from 'element-plus';
import { joinClientParams } from '@/common/request';
import { MachineProtocolEnum } from '@/views/ops/machine/enums';
const viewportRef = ref({} as any);
const displayRef = ref({} as any);
@@ -91,6 +92,10 @@ const props = defineProps({
type: String,
required: true,
},
protocol: {
type: Number,
default: 2, // 2=RDP, 3=VNC
},
clipboardList: {
type: Array,
default: () => [],
@@ -189,7 +194,7 @@ const installClipboard = () => {
};
const installResize = () => {
// 在resize事件结束后300毫秒执行
// 在 resize 事件结束后 300 毫秒执行,使用防抖
useEventListener('resize', useDebounceFn(resize, 300));
};
@@ -333,19 +338,21 @@ const resize = () => {
const width = parseInt(String(box.clientWidth));
const height = parseInt(String(box.clientHeight));
// VNC 协议只发送尺寸不重连RDP 协议在连接状态下发送尺寸,未连接时重连
if (state.display.getWidth() !== width || state.display.getHeight() !== height) {
if (state.status !== TerminalStatus.Connected) {
connect(width, height);
} else {
// VNC 协议protocol=3只发送尺寸变化不触发重连
if (props.protocol === MachineProtocolEnum.Vnc.value) {
// VNC: 仅发送尺寸
state.client.sendSize(width, height);
} else {
// RDP: 未连接时重连,已连接时发送尺寸
if (state.status !== TerminalStatus.Connected) {
connect(width, height);
} else {
state.client.sendSize(width, height);
}
}
}
// setting timeout so display has time to get the correct size
// setTimeout(() => {
// const scale = Math.min(box.clientWidth / Math.max(state.display.getWidth(), 1), box.clientHeight / Math.max(state.display.getHeight(), 1));
// state.display.scale(scale);
// console.log(state.size, scale);
// }, 100);
};
const handleMouseState = (mouseState: any, showCursor = false) => {
@@ -477,6 +484,10 @@ const exposes = {
setRemoteClipboard: onsubmitClipboard,
} as TerminalExpose;
onUnmounted(() => {
disconnect();
});
defineExpose(exposes);
</script>

View File

@@ -18,8 +18,8 @@ import { useThemeConfig } from '@/store/themeConfig';
import { ref, nextTick, reactive, onMounted, onBeforeUnmount, watch } from 'vue';
import TerminalSearch from './TerminalSearch.vue';
import { TerminalStatus } from './common';
import { useDebounceFn, useEventListener, useIntervalFn } from '@vueuse/core';
import themes from './themes';
import { useDebounceFn, useEventListener } from '@vueuse/core';
import themes from './themes.js';
import { TrzszFilter } from 'trzsz';
import { useI18n } from 'vue-i18n';
import { createWebSocket } from '@/common/request';
@@ -54,6 +54,7 @@ const { themeConfig } = storeToRefs(useThemeConfig());
// 终端实例
let term: Terminal;
let socket: WebSocket;
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
const state = reactive({
// 插件
@@ -154,7 +155,7 @@ const initSocket = async () => {
}
// 注册心跳
useIntervalFn(sendPing, 15000);
startHeartbeat();
state.status = TerminalStatus.Connected;
@@ -172,6 +173,22 @@ const initSocket = async () => {
};
};
const startHeartbeat = () => {
stopHeartbeat();
console.log('terminal start heartbeat');
heartbeatTimer = setInterval(() => {
sendPing();
}, 10000);
};
const stopHeartbeat = () => {
if (heartbeatTimer) {
console.log('terminal stop heartbeat');
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
};
const loadAddon = () => {
// 注册搜索组件
const searchAddon = new SearchAddon();
@@ -280,6 +297,7 @@ const sendData = (key: any) => {
};
const closeSocket = () => {
stopHeartbeat();
// 关闭 websocket
socket && socket.readyState === 1 && socket.close();
};

View File

@@ -10,9 +10,13 @@ import { ref, unref } from 'vue';
import { URL_401 } from '@/router/staticRouter';
import openApi from '@/common/openApi';
import { useThemeConfig } from '@/store/themeConfig';
import JSONBig from 'json-bigint';
const baseUrl: string = config.baseApiUrl;
// 配置 JSONBig将大数int64/uint64转为字符串避免精度丢失
const JSONBigString = JSONBig({ storeAsString: true });
const useCustomFetch = createFetch({
baseUrl: baseUrl,
combination: 'chain',
@@ -20,7 +24,7 @@ const useCustomFetch = createFetch({
immediate: false,
timeout: 600000,
// beforeFetch in pre-configured instance will only run when the newly spawned instance do not pass beforeFetch
async beforeFetch({ options }) {
async beforeFetch({ url, options }) {
const token = getToken();
const headers = new Headers(options.headers || {});
@@ -31,38 +35,50 @@ const useCustomFetch = createFetch({
const themeConfig = useThemeConfig().themeConfig;
headers.set('Content-Type', 'application/json');
// 如果不是 FormData才设置 Content-Type
if (!(options.body instanceof FormData)) {
headers.set('Content-Type', 'application/json');
}
headers.set('Accept-Language', themeConfig?.globalI18n);
options.headers = headers;
return { options };
return { url, options };
},
async afterFetch(ctx) {
ctx.data = await ctx.response.json();
async afterFetch(ctx: any) {
// 使用 json-bigint 解析响应数据,解决 int64/uint64 精度丢失问题
const responseText = await ctx.response.text();
try {
ctx.data = JSONBigString.parse(responseText);
} catch (err) {
// 如果解析失败,尝试使用原生 JSON.parse
try {
ctx.data = JSON.parse(responseText);
} catch {
ctx.data = responseText;
}
}
return ctx;
},
},
});
interface EsReq {
esProxyReq: boolean;
esProxyReq?: boolean;
}
export interface RequestOptions extends RequestInit, EsReq {}
export function useApiFetch<T>(api: Api, params: any = null, reqOptions?: RequestOptions) {
const uaf = useCustomFetch<T>(api.url, {
export function useApiFetch<T, P = any>(api: Api, params?: P, reqOptions?: RequestOptions) {
const currentParam = ref(params);
const uaf: any = useCustomFetch<T>(api.url, {
async beforeFetch({ url, options }) {
options.method = api.method;
if (!params) {
return;
}
let paramsValue = unref(params);
let paramsValue = unref(currentParam);
let apiUrl = url;
// 简单判断该url是否是restful风格
if (apiUrl.indexOf('{') != -1) {
if (apiUrl.indexOf('{') != -1 && paramsValue) {
apiUrl = templateResolve(apiUrl, paramsValue);
}
@@ -70,44 +86,75 @@ export function useApiFetch<T>(api: Api, params: any = null, reqOptions?: Reques
paramsValue = await api.beforeHandler(paramsValue);
}
if (paramsValue) {
const method = options.method?.toLowerCase();
// post和put使用json格式传参
if (method === 'post' || method === 'put') {
options.body = JSON.stringify(paramsValue);
// post和put使用json格式传参如果是FormData则直接使用
const method = options.method?.toLowerCase();
if ((method === 'post' || method === 'put') && paramsValue) {
if (paramsValue instanceof FormData) {
options.body = paramsValue;
// 对于 FormData删除 Content-Type header让浏览器自动设置 multipart/form-data 和 boundary
if (options.headers instanceof Headers) {
options.headers.delete('Content-Type');
} else if (options.headers && typeof options.headers === 'object') {
delete (options.headers as any)['Content-Type'];
}
} else {
const searchParam = new URLSearchParams();
Object.keys(paramsValue).forEach((key) => {
const val = paramsValue[key];
if (val) {
searchParam.append(key, val);
}
});
apiUrl = `${apiUrl}?${searchParam.toString()}`;
options.body = JSON.stringify(paramsValue);
}
} else if (paramsValue && method !== 'post' && method !== 'put') {
const searchParam = new URLSearchParams();
Object.keys(paramsValue).forEach((key) => {
const val = paramsValue[key];
if (val) {
searchParam.append(key, val);
}
});
apiUrl = `${apiUrl}?${searchParam.toString()}`;
}
// 确保 FormData 的 body 不被 reqOptions 覆盖
const finalOptions = {
...options,
...reqOptions,
};
// 如果原始 options.body 是 FormData优先保留
if (options.body instanceof FormData) {
finalOptions.body = options.body;
// 对于 FormData不要设置 Content-Type让浏览器自动设置 multipart/form-data 和 boundary
if (finalOptions.headers instanceof Headers) {
finalOptions.headers.delete('Content-Type');
} else if (finalOptions.headers && typeof finalOptions.headers === 'object') {
delete (finalOptions.headers as any)['Content-Type'];
}
}
return {
url: apiUrl,
options: {
...options,
...reqOptions,
},
options: finalOptions,
};
},
onFetchError: (ctx) => {
onFetchError: (ctx: { data: any }) => {
if (reqOptions?.esProxyReq) {
uaf.data = { value: JSON.parse(ctx.data) };
// 使用 json-bigint 解析错误响应
try {
const errorText = typeof ctx.data === 'string' ? ctx.data : JSON.stringify(ctx.data);
uaf.data = { value: JSONBigString.parse(errorText) };
} catch {
uaf.data = { value: ctx.data };
}
return Promise.resolve(uaf.data);
}
return ctx;
},
}) as any;
});
// 统一处理后的返回结果如果直接使用uaf.data则数据会出现由{code: x, data: {}} -> data 的变化导致某些结果绑定报错
const data = ref<T | null>(null);
return {
execute: async function () {
execute: async function (executeParam?: P) {
if (executeParam !== undefined) {
currentParam.value = executeParam;
}
await execCustomFetch(uaf, reqOptions);
data.value = uaf.data.value;
},

131
frontend/src/i18n/en/ai.ts Normal file
View File

@@ -0,0 +1,131 @@
export default {
ai: {
assistant: {
newSession: 'New Session',
startNewConversation: 'Start a New Conversation',
selectOrCreateSession: 'Select a session or create a new one',
},
chat: {
connectionFailed: 'Connection failed, please check your network or contact the administrator',
connectionDisconnected: 'Connection disconnected, please refresh the page to retry',
thinking: 'Thinking',
toolCall: 'Tool Call',
toolCallResult: 'Tool Call Result',
type: 'Type',
processed: 'Processed',
interrupts: ' interrupts',
submitting: '(Submitting...)',
reconnecting: 'Reconnecting to server, please wait...',
connectionLost: 'Connection lost, trying to reconnect...',
customTriggerDialogTitle: 'Custom Trigger Symbol Selection',
},
interrupt: {
approval: {
title: 'Approval Interrupt',
toolInfo: 'Tool Info',
toolName: 'Tool Name',
description: 'Description',
executionParams: 'Execution Parameters',
interruptId: 'Interrupt ID',
operationRecord: 'Operation Record',
operationType: 'Operation Type',
operationTime: 'Operation Time',
additionalData: 'Additional Data',
approve: 'Approve',
reject: 'Reject',
approved: 'Approved',
rejected: 'Rejected',
pendingApproval: 'Pending Approval',
pendingSubmit: 'Pending Submit',
selected: 'Selected: ',
rejectTitle: 'Rejection Reason',
rejectReasonPlaceholder: 'Please enter the reason for rejection',
rejectReasonRequired: 'Rejection reason is required',
},
confirmation: {
title: 'Confirmation',
pleaseSelect: 'Please Select',
interruptId: 'Interrupt ID',
operationRecord: 'Operation Record',
operationType: 'Operation Type',
operationTime: 'Operation Time',
selectionResult: 'Selection Result',
confirm: 'Confirm',
cancel: 'Cancel',
confirmed: 'Confirmed',
cancelled: 'Cancelled',
pendingConfirmation: 'Pending Confirmation',
},
generic: {
interrupt: 'Interrupt',
operationInterrupted: 'Operation Interrupted',
details: 'Details',
interruptId: 'Interrupt ID',
operationRecord: 'Operation Record',
operationType: 'Operation Type',
operationTime: 'Operation Time',
additionalData: 'Additional Data',
confirm: 'Confirm',
reject: 'Reject',
processed: 'Processed',
rejected: 'Rejected',
pending: 'Pending',
rejectTitle: 'Rejection Reason',
rejectReasonPlaceholder: 'Please enter the reason for rejection',
rejectReasonRequired: 'Rejection reason is required',
},
assetSelection: {
title: 'Asset Selection',
selectAsset: 'Select Asset',
selected: 'Selected',
pending: 'Pending',
toolInfo: 'Tool Info',
toolName: 'Tool Name',
toolDesc: 'Tool Description',
availableAssets: 'Available Assets ({count})',
noAssets: 'No available assets',
operationRecord: 'Operation Record',
selectedAsset: 'Selected Asset',
operationTime: 'Operation Time',
confirm: 'Confirm Selection',
cancel: 'Cancel',
dbType: 'Database Type',
protocol: 'Protocol',
sshTunnel: 'SSH Tunnel',
authCert: 'Auth Certificate',
selectDbHint: 'Please select database instance and specific database',
},
paramCompletion: {
title: 'Complete Parameters',
completeParam: 'Complete Parameters',
completed: 'Completed',
pending: 'Pending',
toolInfo: 'Tool Info',
toolName: 'Tool Name',
confirm: 'Confirm',
cancel: 'Cancel',
dbType: 'Database Type',
selectDbHint: 'Please select database instance and specific database',
selectMachineHint: 'Please select the machine to operate',
machineIp: 'Machine IP',
authCert: 'Auth Certificate',
missingParams: 'Missing Parameters',
unsupportedType: 'Unsupported parameter type',
enterParamsHint: 'Please enter the following parameters',
confirmTriggered: 'Selected, please submit all',
cancelTriggered: 'Selection cancelled',
},
status: {
interrupted: 'Interrupted',
resumed: 'Resumed',
},
action: {
approve: 'Approve',
reject: 'Reject',
confirm: 'Confirmed',
cancel: 'Cancelled',
},
batchSubmit: 'Submit All ({count})',
},
},
};

View File

@@ -5,6 +5,7 @@ export default {
edit: 'Edit',
delete: 'Delete',
detail: 'Details',
apply: 'Apply',
add: 'Add',
save: 'Save',
close: 'Close',
@@ -49,10 +50,13 @@ export default {
reset: 'Reset',
success: 'Success',
fail: 'Fail',
requestFail: 'Request Fail',
previousStep: 'Previous Step',
nextStep: 'Next Step',
copy: 'Copy',
copySuccess: 'Copy Success',
copyCell: 'Copy Cell',
search: 'Search',
pleaseInput: 'Please enter {label}',
pleaseSelect: 'Please select {label}',
@@ -70,6 +74,7 @@ export default {
selectAll: 'Select all',
MultiPlaceholder: 'Multiple are separated by commas',
appSlogan: 'Simple, efficient and secure',
preview: 'Preview',
},
layout: {
user: {

View File

@@ -169,6 +169,7 @@ export default {
transfer2Db: 'Transfer to DB',
transfer2File: 'Transfer to File',
fileSaveDays: 'File retention days',
fileType: 'File Type',
transferStrategy: 'Transfer Strategy',
day: 'Day',
transferFull: 'Full',

View File

@@ -23,6 +23,7 @@ export default {
openTerminal: 'Open Terminal',
newTabOpenTerminal: 'Open Terminal(New TAB)',
fileManage: 'File Manage',
fileTabPrefix: 'File-',
scriptManage: 'Script Manage',
machineState: 'Machine State',
remoteFileDesktopManage: 'Remote desktop file management', // Remote desktop file management

View File

@@ -47,6 +47,7 @@ export default {
machineSecurityCmdSvae: 'Cmd Config-Save',
machineSecurityCmdDelete: 'Cmd Config-Delete',
db: 'Database',
dbms: 'DBMS',
dbDataOp: 'Data Operation',
dbDataOpBase: 'DB-Base Permission',
@@ -78,6 +79,8 @@ export default {
dbTransferFileRun: 'Transfer File-Run',
redis: 'Redis',
redisSave: 'Save Redis',
redisDel: 'Delete Redis',
redisDataOp: 'Redis - Data Operation',
redisDataOpBase: 'Redis - Base Permission',
redisDataOpSave: 'Redis - Save Data',
@@ -86,13 +89,18 @@ export default {
redisManageBase: 'Redis - Base Permission',
mongo: 'Mongo',
mongoSave: 'Save Mongo',
mongoDel: 'Delete Mongo',
mongoDataOp: 'Mongo - Data Operation',
mongoDataOpBase: 'Mongo - Base Permission',
mongoDataOpSave: 'Mongo - Save Data',
mongoDataOpDelete: 'Mongo - Delete Data',
mongoManage: 'Mongo Manage',
mongoManageBase: 'Mongo - Base Permission',
containerManageBase: 'Container Manage - Base Permission',
container: 'Container',
containerSave: 'Save Container',
containerDel: 'Delete Container',
flow: 'Flow',
myTask: 'My Task',
@@ -140,5 +148,7 @@ export default {
noPagePermission: 'No Page Permission',
authcertShowciphertext: 'Show Ciphertext',
aiAssistant: 'AI Assistant',
},
};

View File

@@ -0,0 +1,253 @@
export default {
milvus: {
createResourceGroup: 'Create Resource Group',
createUser: 'Create User',
createRole: 'Create Role',
dataOperation: 'Data Operation',
resourceGroupName: 'Resource Group Name',
resourceGroupNamePlaceholder: 'Please enter resource group name',
resourceGroupDetail: 'Resource Group Detail',
descriptionPlaceholder: 'Please enter description',
collectionDetail: 'Collection Detail',
createPartition: 'Create Partition',
createCollection: 'Create Collection',
collectionNamePlaceholder: 'Please enter collection name',
description: 'Description',
shardsNum: 'Shards Number',
consistencyLevel: 'Consistency Level',
createDatabase: 'Create Database',
databaseName: 'Database Name',
databaseNamePlaceholder: 'Please enter database name',
partitionName: 'Partition Name',
partitionNamePlaceholder: 'Please enter partition name',
createIndex: 'Create Index',
fieldName: 'Field Name',
fieldNamePlaceholder: 'Please enter field name',
indexName: 'Index Name',
indexNamePlaceholder: 'Please enter index name',
indexType: 'Index Type',
indexTypeMem: 'Mem Index',
indexTypeDisk: 'Disk Index',
indexTypeGpu: 'GPU Index',
metricType: 'Metric Type',
roleName: 'Role Name',
roleNamePlaceholder: 'Please enter role name',
grantPrivilege: 'Grant',
host: 'Host',
database: 'Database',
code: 'Code',
loadStatus: 'Load Status',
loaded: 'Loaded',
unloaded: 'Unloaded',
detail: 'Detail',
load: 'Load',
release: 'Release',
confirmDeleteCollection: 'Are you sure you want to delete collection {name}?',
confirmDeleteDatabase: 'Are you sure you want to delete database {name}?',
confirmDeletePartition: 'Are you sure you want to delete partition {name}?',
confirmDeleteIndex: 'Are you sure you want to delete index {name}?',
confirmDeleteRole: 'Are you sure you want to delete role {name}?',
confirmDeleteUser: 'Are you sure you want to delete user {name}?',
confirmDeleteResourceGroup: 'Are you sure you want to delete resource group {name}?',
partitionExists: 'Partition exists',
partitionNotExists: 'Partition does not exist',
createdSuccess: 'Created successfully',
updatedSuccess: 'Updated successfully',
deletedSuccess: 'Deleted successfully',
loadedSuccess: 'Loaded successfully',
releasedSuccess: 'Released successfully',
connSuccess: 'Connected successfully',
savedSuccess: 'Saved successfully',
querySuccess: 'Query successful, total {count} records',
searchSuccess: 'Search successful, total {count} results',
insertSuccess: 'Inserted successfully',
insertCount: 'Insert count: {count}',
versionInfo: 'Version Information',
healthStatus: 'Health Status',
healthy: 'Healthy',
unhealthy: 'Unhealthy',
notFetched: 'Not fetched',
notChecked: 'Not checked',
systemInfo: 'System Information',
healthDetail: 'Health Details',
dbId: 'DB ID',
dbName: 'Database Name',
createTime: 'Create Time',
apply: 'Apply',
checkHealth: 'Check Health',
getVersion: 'Get Version',
config: 'Config',
databaseProperties: 'Database Properties',
timezone: 'Timezone',
timezonePlaceholder: 'Please select timezone',
dataQuery: 'Data Query',
vectorSearch: 'Vector Search',
dataInsert: 'Data Insert',
collectionNameLabel: 'Collection Name',
queryExpr: 'Query Expression',
queryExprPlaceholder: 'e.g., id > 0',
outputFields: 'Output Fields',
outputFieldsPlaceholder: 'Comma separated, e.g., id,name,age',
query: 'Query',
queryResults: 'Query Results',
vectorField: 'Vector Field',
vectorFieldPlaceholder: 'Vector field name',
vectorData: 'Vector Data',
vectorDataPlaceholder: 'Please enter vector data, JSON array format, e.g., [[0.1, 0.2, 0.3, ...]]',
topK: 'Top K',
search: 'Search',
searchResults: 'Search Results',
similarityScore: 'Similarity Score',
otherFields: 'Other Fields',
vector: 'Vector',
inputData: 'Data',
inputDataPlaceholder: 'Please enter JSON array',
insert: 'Insert',
insertResult: 'Insert Result',
objectType: 'Object Type',
objectName: 'Object Name',
objectNamePlaceholder: 'Object name, e.g., Collection name',
privilege: 'Privilege',
pleaseSelect: 'Please select',
pleaseInput: 'Please enter',
connAddress: 'Please enter connection address, format: host:port',
dbNamePlaceholder: 'Please enter database name, default is default',
checkExists: 'Check Exists',
databaseManagement: 'Database Management',
collectionManagement: 'Collection Management',
partitionManagement: 'Partition Management',
userPermission: 'User Permission',
roleManagement: 'Role Management',
resourceGroup: 'Resource Group',
fields: 'Fields',
field: 'Field',
properties: 'Properties',
dataType: 'Data Type',
dim: 'Dimension',
maxLength: 'Max Length',
elementType: 'Element Type',
maxCapacity: 'Max Capacity',
attributes: 'Attributes',
primaryKey: 'Primary Key',
autoID: 'Auto ID',
nullable: 'Nullable',
partitionKey: 'Partition Key',
clusteringKey: 'Clustering Key',
mmap: 'Field Mmap',
defaultValue: 'Default Value',
defaultValuePlaceholder: 'Please enter default value',
index: 'Index',
noIndexSupport: 'This field type does not support indexing',
noIndexCreated: 'No index created',
deleteIndex: 'Delete index',
currentIndex: 'Current index',
primaryKeyOnlyInt64OrVarChar: 'PrimaryKey only Int64 or VarChar',
selectFieldToConfig: 'Please select a field to configure',
cannotDeletePrimaryKey: 'Cannot delete auto-generated primary key field',
addFieldRequired: 'Please add at least one field',
primaryKeyRequired: 'Please set a primary key field',
vectorFieldIndexRequired: 'All vector fields must have an index. Please add index for the following fields first: {fields}',
editCollection: 'Edit Collection',
copyCollection: 'Copy Collection',
readonly: 'Readonly',
noIndex: 'No Index',
indexes: 'indexes',
dynamicField: 'Dynamic Field',
enableDynamicField: 'Enable Dynamic Field',
disableDynamicField: 'Disable Dynamic Field',
dynamicFieldHint: 'Dynamic field allows storing JSON data not defined in the Schema',
dynamicFieldIndexes: 'Dynamic Field Indexes',
addIndex: 'Add Index',
jsonPath: 'JSON Path',
jsonPathPlaceholder: 'e.g., $meta["key_name"]',
jsonPathTip: 'JSON path expression to specify the JSON field part to index. Example: json["field_name"] or json["nested"]["field"]',
jsonCastType: 'JSON Cast Type',
jsonCastTypeTip: 'Convert JSON value to data type for indexing. Options: bool, double, varchar, array_bool, array_double, array_varchar',
jsonCastFunction: 'JSON Cast Function',
jsonCastFunctionPlaceholder: 'e.g., STRING_TO_DOUBLE',
jsonCastFunctionTip:
'Function to convert JSON value to specified data type. Options: STRING_TO_DOUBLE (convert string to numeric). Example: "99.99" -> 99.99',
dynamicFieldNameTip:
'Field name for dynamic field index. Specify the dynamic field to index. Required for dynamic field index. Example: "dynamic_field_name"',
reset: 'Reset',
allPartitions: 'All',
importFile: 'Import File',
insertSampleData: 'Insert Sample Data',
clearData: 'Clear Data',
viewData: 'View Data',
export: 'Export',
noData: 'No Data',
selectCollectionFirst: 'Please select a Collection first',
queryExprRequired: 'Please enter query expression',
confirmInsertSample: 'Are you sure you want to insert sample data?',
confirmClearData: 'Are you sure you want to clear all data? This action cannot be undone.',
noPrimaryKey: 'Primary key field not found, cannot delete data',
paginationInfo: 'Total {total} records, page {current} / {pages}',
pageSize10: '10/page',
pageSize20: '20/page',
pageSize50: '50/page',
pageSize100: '100/page',
prevPage: 'Previous',
nextPage: 'Next',
jumpTo: 'Jump to',
editRole: 'Edit Role',
availableRoles: 'Available Roles',
selectedRoles: 'Assigned Roles',
authorized: 'Authorized',
privilegeGroupType: 'Privilege Group Type',
clusterPrivilege: 'Cluster Privilege',
databasePrivilege: 'Database Privilege',
collectionPrivilege: 'Collection Privilege',
customPrivilegeGroup: 'Custom Privilege Group',
privilegeGroupManagement: 'Privilege Groups',
addPrivilegeGroup: 'Privilege Group',
editPrivilegeGroup: 'Edit Privilege Group',
deletePrivilegeGroup: 'Delete Privilege Group',
privilegeGroupName: 'Group Name',
builtinPrivilegeGroup: 'Built-in Privilege Group',
customPrivilegeGroupTip: 'Only custom privilege groups can be edited. Built-in groups are read-only.',
selectPrivileges: 'Select Privileges',
confirmDeletePrivilegeGroup: 'Are you sure to delete privilege group [{name}]? This action cannot be undone.',
privilegeGroupSaveSuccess: 'Privilege group saved successfully',
privilegeGroupDeleteSuccess: 'Privilege group deleted successfully',
selectAll: 'Select All',
privileges: 'Privileges',
// System Info
databaseCount: 'Database Count',
collectionCount: 'Collection Count',
total: 'Total',
capacity: 'Capacity',
nodeCount: 'Node Count',
allNormal: 'All Normal',
issuesFound: 'issues found',
unknown: 'Unknown',
getVersionFailed: 'Failed to get version',
checkHealthFailed: 'Failed to check health',
systemConfig: 'System Configuration',
availableNodes: 'Available Nodes',
nodeConfig: 'Node Config',
requestNodeNum: 'Request Node Num',
limitNodeNum: 'Limit Node Num',
nodeDetails: 'Node Details',
nodeId: 'Node ID',
nodeAddress: 'Node Address',
nodeHostname: 'Hostname',
loadedCollections: 'Loaded Collections',
replica: 'replica',
// Alias Management
aliases: 'Aliases',
addAlias: 'Add Alias',
aliasName: 'Alias Name',
aliasPlaceholder: 'Please enter alias name',
confirmDeleteAlias: 'Are you sure you want to delete alias {name}?',
addedAliasSuccess: 'Alias added successfully',
deletedAliasSuccess: 'Alias deleted successfully',
// Load Status Confirmation
loading: 'Loading',
confirmLoadCollection: 'Are you sure you want to load collection {name}?',
confirmReleaseCollection: 'You are attempting to release {name} with data. Please note that the data will no longer be available for search.',
},
};

View File

@@ -0,0 +1,89 @@
export default {
mq: {
kafka: {
hosts: 'Hosts',
username: 'Username',
sasl_mechanism: 'SASL Mechanism',
sasl_mechanism_placeholder: 'please select SASL Mechanism',
key: 'Key',
keywordPlaceholder: 'ip/name',
nodeManage: 'Node Management',
topicManage: 'Topic Management',
produceMessage: 'Produce Message',
consumeMessage: 'Consume Message',
nodes: 'Nodes',
config: 'Config',
nodeId: 'Node ID',
addr: 'Addr',
rack: 'Rack',
viewConfig: 'View Config',
brokerConfig: 'Broker Config',
topicConfig: 'topic Config',
selectTopic: 'select topic',
selectTopicPlaceholder: 'select topic',
selectTopicWarning: 'Please select topic',
selectBrokerViewConfig: 'Please select a broker to view config',
configName: 'Config Name',
configValue: 'Config Value',
configSource: 'Config Source',
configSensitive: 'Sensitive',
searchTopic: 'Enter topic name',
createTopic: 'Create Topic',
createPartitions: 'Create Partitions',
topicName: 'Topic Name',
topicNamePlaceholder: 'Please enter topic name',
partitions: 'Partitions',
topicPartitions: 'Topic Partitions',
replicationFactor: 'Replication Factor',
topicStatus: 'Status',
topicIsInternal: 'IsInternal',
viewPartitions: 'View Partitions',
delete: 'Delete',
messageKey: 'Message Key',
messageKeyPlaceholder: 'Optional: Enter message key',
partition: 'Partition',
partitionPlaceholder: 'Optional: Specify partition number, -1 for auto',
messageBody: 'Message Body',
messageHeaders: 'Message Headers',
addHeader: 'Add Header',
headerKey: 'Header Key',
headerValue: 'Header Value',
sendTimes: 'Send Times',
compression: 'Compression',
compressionPlaceholder: 'Please select compression',
sendMessage: 'Send Message',
messageNumber: 'Number',
consumerGroup: 'Consumer Group',
consumerGroupPlaceholder: 'Enter consumer group, empty for auto generate',
consumerOnlyTip: 'Note: Only effective when Group is first consumed, subsequent changes are invalid',
pullTimeout: 'Pull Timeout (s)',
decompression: 'Decompression',
decompressionPlaceholder: 'Please select decompression',
decode: 'Decode',
decodePlaceholder: 'Please select decode',
isolationLevel: 'Isolation Level',
isolationLevelPlaceholder: 'Please select isolation level',
readUncommitted: 'Read Uncommitted',
readCommitted: 'Read Committed',
commitOffset: 'Commit Offset',
loadOffsets: 'Load Offsets',
defaultConsumePosition: 'Default Consume Position',
earliest: 'Earliest',
latest: 'Latest',
defaultConsumeStartTime: 'Default Consume Start Time',
selectDateTime: 'Select date time',
offset: 'Offset',
timestamp: 'Timestamp',
headers: 'Headers',
messageDetail: 'Message Detail',
groupId: 'Group ID',
coordinator: 'Coordinator',
state: 'State',
protocolType: 'Protocol Type',
searchGroup: 'Enter group name',
selectGroupPlaceholder: 'select group',
Members: 'Members',
partitionsFeatureComingSoon: 'Partitions feature coming soon',
},
},
};

View File

@@ -192,10 +192,8 @@ export default {
loginFailMinPlaceholder: 'After a specified number of login failures, re-login is prohibited within m minutes',
aiModelConf: 'AI Model Config',
aiModelType: 'Model Type',
aiModelTypePlaceholder: 'Please select a model type',
aiModel: 'Model',
aiModelPlaceholder: 'Please enter the model',
aiModelPlaceholder: 'protocol/model name, such as openai/gpt-3.5-turbo',
aiBaseUrl: 'Base URL',
aiBaseUrlPlaceholder: 'Please enter the model request URL',
aiApiKey: 'API Key',

View File

@@ -21,6 +21,12 @@ export default {
esDataOp: 'Es Operation',
mongoDataOp: 'Mongo Operation',
allResource: 'All Resource',
mq: {
kafka: 'Kafka',
kafkaOp: 'Kafka Operation',
},
milvus: 'Milvus',
milvusOp: 'Milvus Operation',
},
team: {
team: 'Team',

View File

@@ -4,17 +4,11 @@
* 注意国际化定义的字段,不要与原有的定义字段相同。
* /src/i18n/(zh-cn、en...)/module.ts 下的 ts 为各模块国际化内容。
*/
import { getThemeConfig } from '@/common/utils/storage';
import { createI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { useThemeConfig } from '@/store/themeConfig';
import pinia from '@/store';
import { I18nEnum } from '@/common/commonEnum';
const modules: Record<string, any> = import.meta.glob('./**/*.ts', { eager: true });
// 读取 pinia 默认语言
const { themeConfig } = storeToRefs(useThemeConfig(pinia));
function initI18n() {
// 定义变量内容
const messages: any = {};
@@ -39,6 +33,9 @@ function initI18n() {
messages[key] = Object.assign({}, ...value);
});
const themeConfig = getThemeConfig();
const globalI18n = themeConfig.globalI18n || "zh-cn";
// https://vue-i18n.intlify.dev/guide/essentials/fallback.html#explicit-fallback-with-one-locale
return createI18n({
legacy: false,
@@ -47,8 +44,8 @@ function initI18n() {
missingWarn: false,
silentFallbackWarn: true,
fallbackWarn: false,
locale: themeConfig.value.globalI18n,
fallbackLocale: I18nEnum.ZhCn.value,
locale: globalI18n,
fallbackLocale: "zh-cn",
messages,
});
}

View File

@@ -0,0 +1,131 @@
export default {
ai: {
assistant: {
newSession: '新建会话',
startNewConversation: '开始新的对话',
selectOrCreateSession: '选择一个会话或创建新会话',
},
chat: {
connectionFailed: '连接失败,请检查网络或联系管理员',
connectionDisconnected: '连接已断开,请刷新页面重试',
thinking: '思考',
toolCall: '工具调用',
toolCallResult: '工具调用结果',
type: '类型',
processed: '已处理',
interrupts: '个中断',
submitting: '(正在提交...)',
reconnecting: '正在重新连接服务器,请稍候...',
connectionLost: '连接已断开,正在尝试重新连接...',
customTriggerDialogTitle: '自定义触发符号选择弹窗',
},
interrupt: {
approval: {
title: '审批中断',
toolInfo: '工具信息',
toolName: '工具名称',
description: '描述',
executionParams: '执行参数',
interruptId: '中断ID',
operationRecord: '操作记录',
operationType: '操作类型',
operationTime: '操作时间',
additionalData: '附加数据',
approve: '确认执行',
reject: '拒绝执行',
approved: '已批准',
rejected: '已拒绝',
pendingApproval: '待审批',
pendingSubmit: '待提交',
selected: '已选择:',
rejectTitle: '拒绝原因',
rejectReasonPlaceholder: '请输入拒绝原因',
rejectReasonRequired: '拒绝原因不能为空',
},
confirmation: {
title: '确认',
pleaseSelect: '请选择',
interruptId: '中断ID',
operationRecord: '操作记录',
operationType: '操作类型',
operationTime: '操作时间',
selectionResult: '选择结果',
confirm: '确认',
cancel: '取消',
confirmed: '已确认',
cancelled: '已取消',
pendingConfirmation: '待确认',
},
generic: {
interrupt: '中断',
operationInterrupted: '操作中断',
details: '详细信息',
interruptId: '中断ID',
operationRecord: '操作记录',
operationType: '操作类型',
operationTime: '操作时间',
additionalData: '附加数据',
confirm: '确认',
reject: '拒绝',
processed: '已处理',
rejected: '已拒绝',
pending: '待处理',
rejectTitle: '拒绝原因',
rejectReasonPlaceholder: '请输入拒绝原因',
rejectReasonRequired: '拒绝原因不能为空',
},
assetSelection: {
title: '资产选择',
selectAsset: '选择资产',
selected: '已选择',
pending: '待选择',
toolInfo: '工具信息',
toolName: '工具名称',
toolDesc: '工具描述',
availableAssets: '可用资产 ({count}个)',
noAssets: '暂无可用资产',
operationRecord: '操作记录',
selectedAsset: '已选资产',
operationTime: '操作时间',
confirm: '确认选择',
cancel: '取消',
dbType: '数据库类型',
protocol: '连接协议',
sshTunnel: 'SSH隧道',
authCert: '授权凭证',
selectDbHint: '请选择数据库实例和具体数据库',
},
paramCompletion: {
title: '参数补全',
completeParam: '补全参数',
completed: '已补全',
pending: '待补全',
toolInfo: '工具信息',
toolName: '工具名称',
confirm: '确认',
cancel: '取消',
dbType: '数据库类型',
selectDbHint: '请选择数据库实例和具体数据库',
selectMachineHint: '请选择要操作的机器',
machineIp: '机器IP',
authCert: '授权凭证',
missingParams: '缺失参数',
unsupportedType: '不支持的参数类型',
enterParamsHint: '请输入以下参数',
confirmTriggered: '已选择,请提交全部',
cancelTriggered: '已取消选择',
},
status: {
interrupted: '中断中',
resumed: '已恢复',
},
action: {
approve: '确认执行',
reject: '拒绝执行',
confirm: '已确认',
cancel: '已取消',
},
batchSubmit: '提交全部 ({count}个)',
},
},
};

View File

@@ -5,6 +5,7 @@ export default {
edit: '编辑',
delete: '删除',
detail: '详情',
apply: '应用',
add: '添加',
save: '保存',
close: '关闭',
@@ -49,9 +50,11 @@ export default {
reset: '重置',
success: '成功',
fail: '失败',
requestFail: '请求失败',
previousStep: '上一步',
nextStep: '下一步',
copy: '复制',
copySuccess: '复制成功',
copyCell: '复制单元格',
search: '搜索',
pleaseInput: '请输入{label}',
@@ -70,6 +73,7 @@ export default {
selectAll: '全选',
MultiPlaceholder: '多个用逗号隔开',
appSlogan: '简洁 · 高效 · 安全',
preview: '预览',
},
layout: {
user: {

View File

@@ -165,6 +165,7 @@ export default {
transfer2Db: '迁移到数据库',
transfer2File: '迁移到文件',
fileSaveDays: '文件保留天数',
fileType: '文件类型',
transferStrategy: '迁移策略',
day: '天',
transferFull: '全量',

View File

@@ -24,6 +24,7 @@ export default {
openTerminal: '打开终端',
newTabOpenTerminal: '打开终端(新窗口)',
fileManage: '文件管理',
fileTabPrefix: '文件-',
scriptManage: '脚本管理',
machineState: '机器状态',
remoteFileDesktopManage: '远程桌面文件管理', // Remote desktop file management

View File

@@ -47,6 +47,7 @@ export default {
machineSecurityCmdSvae: '机器-命令配置-保存',
machineSecurityCmdDelete: '机器-命令配置-删除',
db: '数据库',
dbms: 'DBMS',
dbDataOp: 'DB-数据操作',
dbDataOpBase: 'DB-数据操作-基本权限',
@@ -78,6 +79,8 @@ export default {
dbTransferFileRun: '迁移文件-执行',
redis: 'Redis',
redisSave: 'Redis-保存',
redisDel: 'Redis-删除',
redisDataOp: 'Redis-数据操作',
redisDataOpBase: 'Redis-数据操作-基本权限',
redisDataOpSave: 'Redis-数据操作-数据保存',
@@ -86,6 +89,8 @@ export default {
redisManageBase: 'Redis-管理-基本权限',
mongo: 'Mongo',
mongoSave: 'Mongo-保存',
mongoDel: 'Mongo-删除',
mongoDataOp: '数据操作',
mongoDataOpBase: 'Mongo-数据操作-基本权限',
mongoDataOpSave: 'Mongo-数据操作-数据保存',
@@ -93,7 +98,9 @@ export default {
mongoManage: 'Mongo管理',
mongoManageBase: 'Mongo-管理-基本权限',
containerManageBase: '容器-管理-基本权限',
container: '容器',
containerSave: '容器-保存',
containerDel: '容器-删除',
flow: '工单流程',
myTask: '我的任务',
@@ -141,5 +148,7 @@ export default {
noPagePermission: '无页面权限',
authcertShowciphertext: '授权凭证密文查看',
aiAssistant: 'AI助手',
},
};

View File

@@ -0,0 +1,289 @@
export default {
milvus: {
createResourceGroup: '创建资源组',
createUser: '创建用户',
createRole: '创建角色',
dataOperation: '数据操作',
resourceGroupName: '资源组名',
resourceGroupNamePlaceholder: '请输入资源组名',
resourceGroupDetail: '资源组详情',
descriptionPlaceholder: '请输入描述',
collectionDetail: 'Collection 详情',
createPartition: '创建分区',
createCollection: '创建 Collection',
collectionNamePlaceholder: '请输入 Collection 名称',
description: '描述',
shardsNum: '分片数量',
consistencyLevel: '一致性',
createDatabase: '创建数据库',
databaseName: '数据库名称',
databaseNamePlaceholder: '请输入数据库名称',
partitionName: '分区名称',
partitionNamePlaceholder: '请输入分区名称',
createIndex: '创建索引',
fieldName: '字段名称',
fieldNamePlaceholder: '请输入字段名称',
indexName: '索引名称',
indexNamePlaceholder: '请输入索引名称',
indexType: '索引类型',
indexTypeMem: '内存索引',
indexTypeDisk: '磁盘索引',
indexTypeGpu: 'GPU索引',
metricType: '距离度量',
roleName: '角色名称',
roleNamePlaceholder: '请输入角色名称',
grantPrivilege: '授权',
host: '连接地址',
database: '数据库',
code: '编码',
loadStatus: '加载状态',
loaded: '已加载',
unloaded: '未加载',
detail: '详情',
load: '加载',
release: '释放',
confirmDeleteCollection: '确定要删除 Collection {name} 吗?',
confirmDeleteDatabase: '确定要删除数据库 {name} 吗?',
confirmDeletePartition: '确定要删除分区 {name} 吗?',
confirmDeleteRole: '确定要删除角色 {name} 吗?',
confirmDeleteUser: '确定要删除用户 {name} 吗?',
confirmDeleteResourceGroup: '确定要删除资源组 {name} 吗?',
partitionExists: '分区存在',
partitionNotExists: '分区不存在',
createdSuccess: '创建成功',
updatedSuccess: '修改成功',
deletedSuccess: '删除成功',
loadedSuccess: '加载成功',
releasedSuccess: '释放成功',
connSuccess: '连接成功',
savedSuccess: '保存成功',
querySuccess: '查询成功,共 {count} 条数据',
searchSuccess: '搜索成功,共 {count} 条结果',
insertSuccess: '插入成功',
insertCount: '插入条数:{count}',
versionInfo: '版本信息',
healthStatus: '健康状态',
healthy: '健康',
unhealthy: '不健康',
notFetched: '未获取',
notChecked: '未检查',
systemInfo: '系统信息',
healthDetail: '健康详情',
dbId: 'DBID',
dbName: '数据库名',
createTime: '创建时间',
apply: '应用',
checkHealth: '检查健康状态',
getVersion: '获取版本信息',
config: '配置',
databaseProperties: '数据库属性',
timezone: '时区',
timezonePlaceholder: '请选择时区',
dataQuery: '数据查询',
vectorSearch: '向量搜索',
dataInsert: '数据插入',
collectionNameLabel: 'Collection 名称',
queryExpr: '查询表达式',
queryExprPlaceholder: '如id > 0',
outputFields: '输出字段',
outputFieldsPlaceholder: '逗号分隔id,name,age',
query: '查询',
queryResults: '查询结果',
vectorField: '向量字段',
vectorFieldPlaceholder: '向量字段名',
vectorData: '向量数据',
vectorDataPlaceholder: '请输入向量数据JSON 数组格式,如:[[0.1, 0.2, 0.3, ...]]',
topK: 'TopK',
search: '搜索',
searchResults: '搜索结果',
similarityScore: '相似度分数',
otherFields: '其他字段',
vector: '向量',
inputData: '数据',
inputDataPlaceholder: '请输入 JSON 数组',
insert: '插入',
insertResult: '插入结果',
objectType: '对象类型',
objectName: '对象名称',
objectNamePlaceholder: '对象名称,如 Collection 名',
privilege: '权限',
pleaseSelect: '请选择',
pleaseInput: '请输入',
connAddress: '请输入连接地址格式host:port',
dbNamePlaceholder: '请输入数据库名,默认为 default',
checkExists: '检查是否存在',
databaseManagement: '数据库管理',
collectionManagement: 'Collection 管理',
partitionManagement: '分区管理',
userPermission: '用户权限',
roleManagement: '角色管理',
resourceGroup: '资源组',
fields: '字段',
field: '字段',
properties: '属性',
dataType: '数据类型',
dim: '维度',
maxLength: '最大长度',
elementType: '元素类型',
maxCapacity: '最大容量',
attributes: '属性',
primaryKey: '主键',
autoID: '自动生成 ID',
nullable: '可空',
partitionKey: '分区键',
clusteringKey: '聚类键',
mmap: '字段 mmap',
defaultValue: '默认值',
defaultValuePlaceholder: '请输入默认值',
index: '索引',
noIndexSupport: '该字段类型不支持索引',
noIndexCreated: '尚未创建索引',
deleteIndex: '删除索引',
currentIndex: '当前索引',
confirmDeleteIndex: '确定要删除该索引吗?',
primaryKeyOnlyInt64OrVarChar: '只有 Int64 或 VarChar 类型可以作为主键',
selectFieldToConfig: '请选择字段进行配置',
cannotDeletePrimaryKey: '无法删除自动生成的主键字段',
addFieldRequired: '请至少添加一个字段',
primaryKeyRequired: '请设置主键字段',
vectorFieldIndexRequired: '所有向量字段必须创建索引,请先为以下字段添加索引:{fields}',
editCollection: '编辑 Collection',
copyCollection: '复制 Collection',
readonly: '只读',
noIndex: '无索引',
indexes: '个索引',
dynamicField: '动态字段',
enableDynamicField: '启用动态字段',
disableDynamicField: '禁用动态字段',
dynamicFieldHint: '动态字段允许存储未在 Schema 中定义的 JSON 数据',
dynamicFieldIndexes: '动态字段索引',
addIndex: '添加索引',
jsonPath: 'JSON 路径',
jsonPathPlaceholder: '如: $meta["key_name"]',
jsonPathTip: 'JSON路径表达式指定要索引的JSON字段部分。示例: json["字段名"] 或 json["嵌套"]["字段"]',
jsonCastType: 'JSON 转换类型',
jsonCastTypeTip: '将JSON值转换为用于索引的数据类型。可选: bool、double、varchar、array_bool、array_double、array_varchar',
jsonCastFunction: 'JSON 转换函数',
jsonCastFunctionPlaceholder: '如: STRING_TO_DOUBLE',
jsonCastFunctionTip: '将JSON值转换为指定数据类型的转换函数。可用选项: STRING_TO_DOUBLE (将字符串转换为数值类型)。示例: "99.99" → 99.99',
dynamicFieldNameTip: '动态字段索引的字段名称。指定要索引的动态字段。动态字段索引必填。示例: "dynamic_field_name"',
reset: '重置',
allPartitions: '全部',
importFile: '导入文件',
insertSampleData: '插入样本数据',
importSampleDialogTitle: '将样本数据导入到 {name}',
importSampleDialogDesc: '此功能导入与 Collection Schema 匹配的随机生成的数据。对于测试和开发很有用。点击下载按钮获取数据。',
downloadOptions: '下载选项',
downloadHint: '点击按钮将生成数据并自动下载文件',
partition: '分区',
selectPartition: '选择分区',
defaultPartition: 'Default',
sampleSizeLabel: '选择或者输入样本数据大小,最大值为 10000',
sampleSizePlaceholder: '输入数量1-10000',
generate: '生成',
import: '导入',
clearData: '清空数据',
viewData: '查看数据',
export: '导出',
noData: '暂无数据',
selectCollectionFirst: '请先选择 Collection',
selectCollectionHint: '请先在 Collections 页面选择 Collection',
queryExprRequired: '请输入查询表达式',
confirmInsertSample: '确定要插入样本数据吗?',
confirmClearData: '确定要清空所有数据吗?此操作不可恢复。',
noPrimaryKey: '未找到主键字段,无法删除数据',
paginationInfo: '共 {total} 条记录,第 {current} / {pages} 页',
pageSize10: '10 条/页',
pageSize20: '20 条/页',
pageSize50: '50 条/页',
pageSize100: '100 条/页',
prevPage: '上一页',
nextPage: '下一页',
jumpTo: '跳至',
editRole: '编辑角色',
availableRoles: '可用角色',
selectedRoles: '已分配角色',
privilegeManagement: '权限管理',
privilegeGroup: '权限组',
privilegeGroupGrant: '权限组授权',
specificPrivilegeGrant: '具体权限授权',
grantPrivilegeGroup: '授权权限组',
hasAllPrivileges: '拥有所有权限',
allPrivileges: '所有权限',
databaseScope: '数据库',
collectionScope: 'Collection',
objectTypeAll: '全局',
objectTypeGlobal: '全局',
objectTypeDatabase: '数据库',
objectTypeCollection: 'Collection',
grantType: '授权方式',
grantByPrivilegeGroup: '按权限组授权',
grantByPrivilege: '按权限授权',
selectPrivilegeGroup: '选择权限组',
selectDatabase: '选择数据库',
selectCollection: '选择Collection',
privilegeGroupPlaceholder: '请选择权限组',
allDatabases: '所有数据库',
allCollections: '所有Collection',
noPrivilegeGroup: '暂无权限组',
currentPrivileges: '当前权限',
grantSuccess: '授权成功',
revokeSuccess: '撤销成功',
authorized: '已授权',
privilegeGroupType: '权限组类型',
clusterPrivilege: '集群权限',
databasePrivilege: '数据库权限',
collectionPrivilege: 'Collection权限',
customPrivilegeGroup: '自定义权限组',
privilegeGroupManagement: '权限组管理',
addPrivilegeGroup: '权限组',
editPrivilegeGroup: '编辑权限组',
deletePrivilegeGroup: '删除权限组',
privilegeGroupName: '权限组名称',
builtinPrivilegeGroup: '系统内置权限组',
customPrivilegeGroupTip: '仅可编辑自定义权限组,系统内置权限组不可修改',
selectPrivileges: '选择权限',
confirmDeletePrivilegeGroup: '确认删除权限组 [{name}]?删除后不可恢复。',
privilegeGroupSaveSuccess: '权限组保存成功',
privilegeGroupDeleteSuccess: '权限组删除成功',
selectAll: '全选',
privileges: '权限',
// System Info
databaseCount: '数据库数量',
collectionCount: 'Collection 数量',
total: '总计',
capacity: '容量',
nodeCount: '节点数',
allNormal: '全部正常',
issuesFound: '个问题',
unknown: '未知',
getVersionFailed: '获取版本失败',
checkHealthFailed: '检查健康状态失败',
systemConfig: '系统配置',
availableNodes: '可用节点',
nodeConfig: '节点配置',
requestNodeNum: '请求节点数',
limitNodeNum: '限制节点数',
nodeDetails: '节点详情',
nodeId: '节点 ID',
nodeAddress: '节点地址',
nodeHostname: '主机名',
loadedCollections: '已加载 Collection',
replica: '副本',
// 别名管理
aliases: '别名',
addAlias: '添加别名',
aliasName: '别名名称',
aliasPlaceholder: '请输入别名名称',
confirmDeleteAlias: '确定要删除别名 {name} 吗?',
addedAliasSuccess: '添加别名成功',
deletedAliasSuccess: '删除别名成功',
// 加载状态确认
loading: '加载中',
confirmLoadCollection: '确定要加载 Collection {name} 吗?',
confirmReleaseCollection: '您正在尝试释放带有数据的 {name} 。请注意,数据将不再可用于搜索。',
},
};

View File

@@ -0,0 +1,89 @@
export default {
mq: {
kafka: {
hosts: 'Hosts',
username: '用户名',
sasl_mechanism: 'SASL机制',
sasl_mechanism_placeholder: '请选择SASL机制',
key: 'Key',
keywordPlaceholder: 'ip/名称',
nodeManage: '节点管理',
topicManage: '主题管理',
produceMessage: '生产消息',
consumeMessage: '消费消息',
nodes: '节点',
config: '配置',
nodeId: '节点 ID',
addr: 'Addr',
rack: '机架',
viewConfig: '查看配置',
brokerConfig: 'Broker 配置',
topicConfig: 'Topic 配置',
selectTopic: '选择 topic',
selectTopicPlaceholder: '选择 topic',
selectTopicWarning: '请选择 topic',
selectBrokerViewConfig: '请选择 Broker 查看配置',
configName: '配置项',
configValue: '配置值',
configSource: '配置来源',
configSensitive: '敏感',
searchTopic: '输入主题名称',
createTopic: '创建主题',
createPartitions: '添加分区',
topicName: '主题名称',
topicNamePlaceholder: '请输入主题名称',
partitions: '分区',
topicPartitions: 'Topic 分区信息',
replicationFactor: '副本数',
topicStatus: '状态',
topicIsInternal: '是否内建',
viewPartitions: '查看分区',
delete: '删除',
messageKey: '消息 Key',
messageKeyPlaceholder: '可选:输入消息 Key',
partition: '分区号',
partitionPlaceholder: '可选:指定分区号,-1 表示自动分配',
messageBody: '消息内容',
messageHeaders: '消息 Headers',
addHeader: '添加 Header',
headerKey: 'Header Key',
headerValue: 'Header Value',
sendTimes: '发送次数',
compression: '压缩',
compressionPlaceholder: '请选择压缩方式',
sendMessage: '发送消息',
messageNumber: '消息数量',
consumerGroup: '消费者组',
consumerGroupPlaceholder: '请输入消费者组,为空则自动生成',
consumerOnlyTip: '注意仅在Group首次消费时生效后续改动无效',
pullTimeout: '拉取超时 (秒)',
decompression: '解压',
decompressionPlaceholder: '请选择解压方式',
decode: '解码',
decodePlaceholder: '请选择解码方式',
isolationLevel: '隔离级别',
isolationLevelPlaceholder: '请选择隔离级别',
readUncommitted: '读未提交',
readCommitted: '读已提交',
commitOffset: '提交 Offset',
loadOffsets: '读取 Offsets',
defaultConsumePosition: '默认消费位置',
earliest: '最早',
latest: '最新',
defaultConsumeStartTime: '默认消费起始时间',
selectDateTime: '选择日期时间',
offset: '偏移量',
timestamp: '时间戳',
headers: 'Headers',
messageDetail: '消息详情',
groupId: '组 ID',
coordinator: '协调器',
state: '状态',
protocolType: '协议类型',
searchGroup: '输入组名称',
selectGroupPlaceholder: '选择分组',
Members: '成员',
partitionsFeatureComingSoon: '分区详情功能即将上线',
},
},
};

View File

@@ -192,10 +192,8 @@ export default {
loginFailMinPlaceholder: '登录失败指定次数后禁止m分钟内再次登录',
aiModelConf: 'AI模型配置',
aiModelType: '模型类型',
aiModelTypePlaceholder: '选择AI模型类型',
aiModel: '模型',
aiModelPlaceholder: '请输入模型',
aiModelPlaceholder: '协议/模型名,如 openai/gpt-3.5-turbo',
aiBaseUrl: '地址',
aiBaseUrlPlaceholder: '请输入模型请求地址',
aiApiKey: 'API Key',

View File

@@ -24,6 +24,12 @@ export default {
mongoDataOp: 'Mongo操作',
containerOp: '容器操作',
allResource: '所有资源',
mq: {
kafka: 'Kafka',
kafkaOp: 'Kafka操作',
},
milvus: 'Milvus',
milvusOp: 'Milvus操作',
},
team: {
team: '团队',

View File

@@ -46,7 +46,7 @@ watch(
setTimeout(() => {
layoutScrollbarRef.value.update();
}, 500);
layoutScrollbarRef.value.setScrollTop();
layoutScrollbarRef.value.setScrollTop(0);
});
}
);

View File

@@ -4,7 +4,7 @@
<span class="logo-title">
{{ `${themeConfig.globalTitle}` }}
<sub
><span style="font-size: 10px; color: goldenrod">{{ ` ${config.version}` }}</span></sub
><span style="font-size: 10px; color: goldenrod">{{ ` ${themeConfig.version}` }}</span></sub
>
</span>
</div>
@@ -17,7 +17,6 @@
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useThemeConfig } from '@/store/themeConfig';
import config from '@/common/config';
const { themeConfig } = storeToRefs(useThemeConfig());

View File

@@ -308,17 +308,7 @@
<!-- 其它设置 -->
<el-divider content-position="left">{{ $t('layout.config.otherSetting') }}</el-divider>
<div class="layout-breadcrumb-seting-bar-flex !mt-3.5">
<div class="layout-breadcrumb-seting-bar-flex-label">{{ $t('layout.config.tagsStyle') }}</div>
<div class="layout-breadcrumb-seting-bar-flex-value">
<el-select v-model="themeConfig.tagsStyle" placeholder="请选择" size="small" style="width: 90px">
<el-option label="风格1" value="tags-style-one"></el-option>
<el-option label="风格2" value="tags-style-two"></el-option>
<el-option label="风格3" value="tags-style-three"></el-option>
</el-select>
</div>
</div>
<div class="layout-breadcrumb-seting-bar-flex !mt-3.5">
<div class="layout-breadcrumb-seting-bar-flex mt-3.5!">
<div class="layout-breadcrumb-seting-bar-flex-label">{{ $t('layout.config.animation') }}</div>
<div class="layout-breadcrumb-seting-bar-flex-value">
<el-select v-model="themeConfig.animation" size="small" style="width: 90px">
@@ -328,7 +318,7 @@
</el-select>
</div>
</div>
<div class="layout-breadcrumb-seting-bar-flex !mt-3.5 !mb-5.5">
<div class="layout-breadcrumb-seting-bar-flex mt-3.5! mb-5.5!">
<div class="layout-breadcrumb-seting-bar-flex-label">
{{ $t('layout.config.columnsAsideStyle') }}
</div>

View File

@@ -1,5 +1,10 @@
<template>
<div class="layout-navbars-breadcrumb-user" :style="{ flex: layoutUserFlexNum }">
<!-- <div class="layout-navbars-breadcrumb-user-icon" @click="onShowAiChatDialog">
<SvgIcon name="icon ai/assistant" :title="$t('layout.user.menuSearch')" />
<AiChatDialog v-model:visible="state.aiChatDialogVisible" />
</div> -->
<div class="layout-navbars-breadcrumb-user-icon">
<el-switch
@change="switchDark()"
@@ -10,20 +15,6 @@
class="dark-icon"
/>
</div>
<!-- <el-dropdown :show-timeout="70" :hide-timeout="50" trigger="click" @command="onComponentSizeChange">
<div class="layout-navbars-breadcrumb-user-icon">
<el-icon title="组件大小">
<plus />
</el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="" :disabled="state.disabledSize === ''">默认</el-dropdown-item>
<el-dropdown-item command="large" :disabled="state.disabledSize === 'large'">大型</el-dropdown-item>
<el-dropdown-item command="small" :disabled="state.disabledSize === 'small'">小型</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown> -->
<el-dropdown :show-timeout="70" :hide-timeout="50" trigger="click" @command="onLanguageChange">
<div class="layout-navbars-breadcrumb-user-icon">
@@ -81,7 +72,7 @@
</template>
<script setup lang="ts" name="layoutBreadcrumbUser">
import { ref, computed, reactive, onMounted, watch, useTemplateRef } from 'vue';
import { ref, computed, reactive, onMounted, watch, useTemplateRef, defineAsyncComponent } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessageBox, ElMessage } from 'element-plus';
import screenfull from 'screenfull';
@@ -99,6 +90,8 @@ import { useI18n } from 'vue-i18n';
import { I18nEnum } from '@/common/commonEnum';
import EnumValue from '@/common/Enum';
// const AiChatDialog = defineAsyncComponent(() => import('@/views/ai/AiChatDialog.vue'));
const router = useRouter();
const searchRef = ref();
const userNewsRef = useTemplateRef('userNewsRef');
@@ -107,6 +100,7 @@ const state = reactive({
isScreenfull: false,
disabledSize: '',
unreadMsgCount: 0,
aiChatDialogVisible: false,
});
const { userInfo } = storeToRefs(useUserInfo());
const themeConfigStore = useThemeConfig();
@@ -237,6 +231,10 @@ const initComponentSize = () => {
}
};
const onShowAiChatDialog = () => {
state.aiChatDialogVisible = true;
};
// 语言切换
const onLanguageChange = (lang: string) => {
themeConfig.value.globalI18n = lang;

View File

@@ -1,7 +1,7 @@
<template>
<div class="layout-navbars-tagsview" :class="{ 'layout-navbars-tagsview-shadow': themeConfig.layout === 'classic' }">
<el-scrollbar ref="scrollbarRef" @wheel.prevent="onHandleScroll">
<ul class="layout-navbars-tagsview-ul" :class="setTagsStyle" ref="tagsUlRef">
<ul class="layout-navbars-tagsview-ul" ref="tagsUlRef">
<li
v-for="(v, k) in tagsViews"
:key="k"
@@ -18,26 +18,16 @@
>
<SvgIcon :name="v.icon" class="layout-navbars-tagsview-ul-li-iconfont" v-if="themeConfig.isTagsviewIcon" />
<span>{{ $t(v.title) }}</span>
<template v-if="isActive(v)">
<SvgIcon
name="RefreshRight"
class="!text-[14px] ml-1 layout-navbars-tagsview-ul-li-refresh"
@click.stop="refreshCurrentTagsView($route.fullPath)"
/>
<SvgIcon
name="Close"
class="!text-[14px] layout-navbars-tagsview-ul-li-icon layout-icon-active"
v-if="!v.isAffix"
@click.stop="closeCurrentTagsView(themeConfig.isShareTagsView ? v.path : v.path)"
/>
</template>
<SvgIcon
name="Close"
class="!text-[14px] layout-navbars-tagsview-ul-li-icon layout-icon-three"
v-if="!v.isAffix"
@click.stop="closeCurrentTagsView(themeConfig.isShareTagsView ? v.path : v.path)"
v-if="isActive(v)"
name="RefreshRight"
class="text-[14px]! layout-navbars-tagsview-ul-li-icon layout-navbars-tagsview-ul-li-refresh"
@click.stop="refreshCurrentTagsView($route.fullPath)"
/>
<span v-if="!v.isAffix" class="layout-navbars-tagsview-ul-li-close-wrap" @click.stop="closeCurrentTagsView(v.path)">
<SvgIcon name="Close" class="text-[14px]! layout-navbars-tagsview-ul-li-close-icon" />
</span>
</li>
</ul>
</el-scrollbar>
@@ -46,7 +36,7 @@
</template>
<script lang="ts" setup name="layoutTagsView">
import { reactive, onMounted, computed, ref, nextTick, onBeforeUpdate, getCurrentInstance, watch } from 'vue';
import { reactive, onMounted, ref, nextTick, onBeforeUpdate, getCurrentInstance, watch } from 'vue';
import { useRoute, useRouter, onBeforeRouteUpdate } from 'vue-router';
import screenfull from 'screenfull';
import { storeToRefs } from 'pinia';
@@ -105,11 +95,6 @@ const state = reactive({
},
});
// 动态设置 tagsView 风格样式
const setTagsStyle = computed(() => {
return themeConfig.value.tagsStyle;
});
// 存储 tagsViewList 到浏览器临时缓存中,页面刷新时,保留记录
const addBrowserSetSession = (tagsViewList: Array<object>) => {
setTagViews(tagsViewList);
@@ -403,163 +388,158 @@ onBeforeRouteUpdate((to) => {
});
</script>
<style scoped lang="scss">
<style scoped lang="css">
.layout-navbars-tagsview {
background-color: var(--bg-main-color);
border-bottom: 1px solid var(--el-border-color-light, #ebeef5);
position: relative;
z-index: 4;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
:deep(.el-scrollbar__wrap) {
overflow-x: auto !important;
}
.layout-navbars-tagsview :deep(.el-scrollbar__wrap) {
overflow-x: auto !important;
}
&-ul {
list-style: none;
margin: 0;
padding: 0;
height: 34px;
display: flex;
align-items: center;
color: var(--el-text-color-regular);
font-size: 12px;
white-space: nowrap;
padding: 0 15px;
.layout-navbars-tagsview-ul {
list-style: none;
margin: 0;
height: 38px;
display: flex;
align-items: center;
color: var(--el-text-color-regular);
font-size: 13px;
white-space: nowrap;
padding: 0 15px;
}
&-li {
height: 26px;
line-height: 26px;
display: flex;
align-items: center;
border: 1px solid var(--el-border-color-lighter);
padding: 0 15px;
margin-right: 5px;
border-radius: 2px;
position: relative;
z-index: 0;
cursor: pointer;
justify-content: space-between;
.layout-navbars-tagsview-ul-li {
height: 30px;
line-height: 30px;
display: flex;
align-items: center;
border-radius: 6px;
padding: 0 12px;
margin-right: 6px;
position: relative;
z-index: 0;
cursor: pointer;
justify-content: space-between;
transition: all 0.3s ease;
border: 1px solid var(--el-border-color, #dcdfe6);
box-sizing: border-box;
background-color: var(--el-bg-color, #fafafa);
color: var(--el-text-color-regular, #606266);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
&:hover {
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
border-color: var(--el-color-primary-light-5);
}
.layout-navbars-tagsview-ul-li:not(.is-active):hover {
background-color: var(--el-fill-color-blank, #f5f7fa);
color: var(--el-text-color-primary, #303133);
border-color: var(--el-color-primary-light-7, #c6e2ff);
}
&-iconfont {
position: relative;
left: -5px;
font-size: 12px;
}
.layout-navbars-tagsview-ul-li-iconfont {
position: relative;
left: -3px;
font-size: 12px;
margin-right: 4px;
}
&-icon {
border-radius: 100%;
position: relative;
height: 14px;
width: 14px;
text-align: center;
line-height: 14px;
right: -5px;
.layout-navbars-tagsview-ul-li-icon {
border-radius: 4px;
position: relative;
height: 18px;
width: 18px;
right: -3px;
margin-left: 4px;
transition: all 0.25s ease;
color: var(--el-text-color-secondary, #909399);
display: flex;
align-items: center;
justify-content: center;
}
&:hover {
color: var(--el-color-white);
background-color: var(--el-color-primary-light-3);
}
}
.layout-navbars-tagsview-ul-li-icon:hover {
background-color: var(--el-color-info-light-7);
}
.layout-icon-active {
display: block;
}
/* 关闭按钮动画容器默认宽度0不占位hover/active 时平滑展开 */
.layout-navbars-tagsview-ul-li-close-wrap {
position: relative;
right: -3px;
height: 18px;
width: 0;
min-width: 0;
margin-left: 0;
overflow: hidden;
opacity: 0;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
cursor: pointer;
pointer-events: none;
transition:
width 0.6s cubic-bezier(0.34, 1.56, 0.64, 1),
min-width 0.6s cubic-bezier(0.34, 1.56, 0.64, 1),
margin-left 0.6s cubic-bezier(0.34, 1.56, 0.64, 1),
opacity 0.5s ease;
}
.layout-icon-three {
display: none;
}
}
/* 鼠标悬浮 li 或激活状态时展开 */
.layout-navbars-tagsview-ul-li:hover .layout-navbars-tagsview-ul-li-close-wrap,
.layout-navbars-tagsview-ul .is-active .layout-navbars-tagsview-ul-li-close-wrap {
width: 18px;
min-width: 18px;
opacity: 1;
pointer-events: auto;
}
.is-active {
color: var(--el-color-white);
background: var(--el-color-primary);
border-color: var(--el-color-primary);
transition: border-color 3s ease;
}
}
/* 关闭按钮图标:与刷新按钮尺寸和视觉完全一致 */
.layout-navbars-tagsview-ul-li-close-icon {
border-radius: 4px;
display: flex !important;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary, #909399);
transition:
background-color 0.2s ease,
color 0.2s ease;
flex-shrink: 0;
}
// 风格2
.tags-style-two {
.layout-navbars-tagsview-ul-li {
margin-right: 0 !important;
border: none !important;
position: relative;
border-radius: 3px !important;
/* 非激活 tag关闭图标 hover 变红 */
.layout-navbars-tagsview-ul-li:not(.is-active):hover .layout-navbars-tagsview-ul-li-close-icon:hover {
background-color: var(--el-color-danger);
color: var(--el-color-white);
}
.layout-icon-active {
display: none;
}
.layout-navbars-tagsview-ul .is-active {
color: var(--el-color-primary, #409eff);
background: var(--el-color-primary-light-9, #ecf5ff);
border-color: var(--el-color-primary-light-5, #409eff);
box-shadow: 0 2px 4px rgba(64, 158, 255, 0.2);
}
.layout-icon-three {
display: block;
}
.layout-navbars-tagsview-ul .is-active .layout-navbars-tagsview-ul-li-icon {
color: var(--el-color-primary, #409eff);
}
&:hover {
background: none !important;
}
}
.layout-navbars-tagsview-ul .is-active .layout-navbars-tagsview-ul-li-icon:hover {
background-color: var(--el-color-primary);
color: var(--el-color-white);
}
.is-active {
background: none !important;
color: var(--el-color-primary) !important;
}
}
/* 激活 tag 的关闭图标使用主色调 */
.layout-navbars-tagsview-ul .is-active .layout-navbars-tagsview-ul-li-close-icon {
color: var(--el-color-primary, #409eff);
}
// 风格3
.tags-style-three {
align-items: flex-end;
.tgs-style-three-svg {
-webkit-mask-image:
url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzAiIGhlaWdodD0iNzAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgZmlsbD0ibm9uZSI+CgogPGc+CiAgPHRpdGxlPkxheWVyIDE8L3RpdGxlPgogIDxwYXRoIHRyYW5zZm9ybT0icm90YXRlKC0wLjEzMzUwNiA1MC4xMTkyIDUwKSIgaWQ9InN2Z18xIiBkPSJtMTAwLjExOTE5LDEwMGMtNTUuMjI4LDAgLTEwMCwtNDQuNzcyIC0xMDAsLTEwMGwwLDEwMGwxMDAsMHoiIG9wYWNpdHk9InVuZGVmaW5lZCIgc3Ryb2tlPSJudWxsIiBmaWxsPSIjRjhFQUU3Ii8+CiAgPHBhdGggZD0ibS0wLjYzNzY2LDcuMzEyMjhjMC4xMTkxOSwwIDAuMjE3MzcsMC4wNTc5NiAwLjQ3Njc2LDAuMTE5MTljMC4yMzIsMC4wNTQ3NyAwLjI3MzI5LDAuMDM0OTEgMC4zNTc1NywwLjExOTE5YzAuMDg0MjgsMC4wODQyOCAwLjM1NzU3LDAgMC40NzY3NiwwbDAuMTE5MTksMGwwLjIzODM4LDAiIGlkPSJzdmdfMiIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHBhdGggZD0ibTI4LjkyMTM0LDY5LjA1MjQ0YzAsMC4xMTkxOSAwLDAuMjM4MzggMCwwLjM1NzU3bDAsMC4xMTkxOWwwLDAuMTE5MTkiIGlkPSJzdmdfMyIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z180IiBoZWlnaHQ9IjAiIHdpZHRoPSIxLjMxMTA4IiB5PSI2LjgzNTUyIiB4PSItMC4wNDE3MSIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z181IiBoZWlnaHQ9IjEuNzg3ODQiIHdpZHRoPSIwLjExOTE5IiB5PSI2OC40NTY1IiB4PSIyOC45MjEzNCIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z182IiBoZWlnaHQ9IjQuODg2NzciIHdpZHRoPSIxOS4wNzAzMiIgeT0iNTEuMjkzMjEiIHg9IjM2LjY2ODY2IiBzdHJva2U9Im51bGwiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+'),
url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzAiIGhlaWdodD0iNzAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgZmlsbD0ibm9uZSI+CiA8Zz4KICA8dGl0bGU+TGF5ZXIgMTwvdGl0bGU+CiAgPHBhdGggdHJhbnNmb3JtPSJyb3RhdGUoLTg5Ljc2MjQgNy4zMzAxNCA1NS4xMjUyKSIgc3Ryb2tlPSJudWxsIiBpZD0ic3ZnXzEiIGZpbGw9IiNGOEVBRTciIGQ9Im02Mi41NzQ0OSwxMTcuNTIwODZjLTU1LjIyOCwwIC0xMDAsLTQ0Ljc3MiAtMTAwLC0xMDBsMCwxMDBsMTAwLDB6IiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPgogIDxwYXRoIGQ9Im0tMC42Mzc2Niw3LjMxMjI4YzAuMTE5MTksMCAwLjIxNzM3LDAuMDU3OTYgMC40NzY3NiwwLjExOTE5YzAuMjMyLDAuMDU0NzcgMC4yNzMyOSwwLjAzNDkxIDAuMzU3NTcsMC4xMTkxOWMwLjA4NDI4LDAuMDg0MjggMC4zNTc1NywwIDAuNDc2NzYsMGwwLjExOTE5LDBsMC4yMzgzOCwwIiBpZD0ic3ZnXzIiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxwYXRoIGQ9Im0yOC45MjEzNCw2OS4wNTI0NGMwLDAuMTE5MTkgMCwwLjIzODM4IDAsMC4zNTc1N2wwLDAuMTE5MTlsMCwwLjExOTE5IiBpZD0ic3ZnXzMiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNCIgaGVpZ2h0PSIwIiB3aWR0aD0iMS4zMTEwOCIgeT0iNi44MzU1MiIgeD0iLTAuMDQxNzEiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNSIgaGVpZ2h0PSIxLjc4Nzg0IiB3aWR0aD0iMC4xMTkxOSIgeT0iNjguNDU2NSIgeD0iMjguOTIxMzQiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNiIgaGVpZ2h0PSI0Ljg4Njc3IiB3aWR0aD0iMTkuMDcwMzIiIHk9IjUxLjI5MzIxIiB4PSIzNi42Njg2NiIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiA8L2c+Cjwvc3ZnPg=='),
url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><rect rx='8' width='100%' height='100%' fill='%23F8EAE7'/></svg>");
-webkit-mask-size:
18px 30px,
20px 30px,
calc(100% - 30px) calc(100% + 17px);
-webkit-mask-position:
right bottom,
left bottom,
center top;
-webkit-mask-repeat: no-repeat;
}
.layout-navbars-tagsview-ul-li {
padding: 0 5px;
border-width: 15px 27px 15px;
border-style: solid;
border-color: transparent;
margin: 0 -15px;
.layout-icon-active {
display: none;
}
.layout-icon-three {
display: block;
}
&:hover {
@extend .tgs-style-three-svg;
background: var(--tagsview3-active-background-color);
color: unset;
}
}
.is-active {
@extend .tgs-style-three-svg;
background: var(--tagsview3-active-background-color) !important;
color: var(--el-color-primary) !important;
z-index: 1;
}
}
/* 激活 tag 的关闭图标 hover 变红 */
.layout-navbars-tagsview-ul .is-active .layout-navbars-tagsview-ul-li-close-icon:hover {
background-color: var(--el-color-danger);
color: var(--el-color-white);
}
.layout-navbars-tagsview-shadow {

View File

@@ -1,4 +1,4 @@
import { createRouter, createWebHashHistory } from 'vue-router';
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import { getToken } from '@/common/utils/storage';
@@ -11,10 +11,18 @@ import { useThemeConfig } from '@/store/themeConfig';
import { useUserInfo } from '@/store/userInfo';
import { useRoutesList } from '@/store/routesList';
import { initBackendRoutes } from './dynamicRouter';
import { getAppConfig } from '@/common/config';
// 根据环境变量获取路由模式
const getRouterMode = () => {
const mode = import.meta.env.VITE_ROUTER_MODE || 'hash';
const appConfig = getAppConfig();
return mode === 'history' ? createWebHistory(appConfig?.CTX_PATH) : createWebHashHistory(appConfig?.CTX_PATH);
};
// 添加静态路由
const router = createRouter({
history: createWebHashHistory(),
history: getRouterMode(),
routes: [...staticRoutes, ...errorRoutes],
});
@@ -58,7 +66,7 @@ export async function initRouter() {
}
// 路由加载前
router.beforeEach(async (to, from, next) => {
router.beforeEach(async (to, from) => {
NProgress.configure({ showSpinner: false });
NProgress.start();
@@ -73,22 +81,22 @@ router.beforeEach(async (to, from, next) => {
// 判断是访问登陆页有token就在当前页面没有token重置路由与用户信息到登陆页
if (toPath === URL_LOGIN) {
if (token) {
return next(from.fullPath);
return from.fullPath;
}
resetRoute();
syssocket.destory();
return next();
return true;
}
// 判断访问页面是否在路由白名单地址(静态路由)中,如果存在直接放行
if (ROUTER_WHITE_LIST.includes(toPath)) {
return next();
return true;
}
// 判断是否有token没有重定向到 login 页面
if (!token) {
return next(`${URL_LOGIN}?redirect=${toPath}`);
return `${URL_LOGIN}?redirect=${toPath}`;
}
// 终端不需要连接系统websocket消息
@@ -102,12 +110,12 @@ router.beforeEach(async (to, from, next) => {
// 可能token过期无法获取菜单权限信息等
await initRouter();
} catch (e) {
return next(`${URL_401}?redirect=${toPath}`);
return `${URL_401}?redirect=${toPath}`;
}
return next({ path: toPath, query: to.query });
return { path: toPath, query: to.query };
}
next();
return true;
});
// 路由加载后

View File

@@ -98,8 +98,6 @@ export const useThemeConfig = defineStore('themeConfig', {
/* 其它设置
------------------------------- */
// 默认 Tagsview 风格,可选 1、 tags-style-one 2、 tags-style-two 3、 tags-style-three
tagsStyle: 'tags-style-three',
// 默认主页面切换动画,可选 1、 slide-right 2、 slide-left 3、 opacitys
animation: 'slide-right',
// 默认分栏高亮风格,可选 1、 圆角 columns-round 2、 卡片 columns-card
@@ -137,6 +135,7 @@ export const useThemeConfig = defineStore('themeConfig', {
appSlogan: 'common.appSlogan',
// 网站logo icon, base64编码内容
logoIcon: logoIcon,
version: 'latest',
// 默认初始语言,可选值"<zh-cn|en|zh-tw>",默认 zh-cn
globalI18n: 'zh-cn',
// 默认全局组件大小,可选值"<|large|default|small>",默认 ''
@@ -155,12 +154,15 @@ export const useThemeConfig = defineStore('themeConfig', {
if (tc) {
this.themeConfig = tc;
document.documentElement.style.cssText = getLocal('themeConfigStyle');
} else {
getServerConf().then((res) => {
this.themeConfig.globalI18n = res.i18n;
});
}
getServerConf().then((res) => {
this.themeConfig.globalI18n = res.i18n;
this.themeConfig.version = res.version;
});
this.themeConfig.defaultListPageSize = calculatePageSizeByScreenHeight();
// 根据后台系统配置初始化
getSysStyleConfig().then((res) => {
if (res?.title) {
@@ -215,3 +217,34 @@ export const useThemeConfig = defineStore('themeConfig', {
},
},
});
// 计算每页显示数量的方法
const calculatePageSizeByScreenHeight = (): number => {
const windowHeight = window.innerHeight || document.documentElement.clientHeight;
// 计算页面其他部分的高度(这是一个大概的估算)
// 包括顶部导航、面包屑、搜索区域、分页控件等
const headerHeight = 60; // 页面顶部导航高度
const subHeaderHeight = 50; // 子页面头部或其他内容高度
const searchFormHeight = 100; // 搜索表单高度,如果显示的话
const tableHeaderHeight = 44; // 表格头部高度
const paginationHeight = 40; // 分页控件高度
const paddingMarginHeight = 30; // 额外的内外边距
// 计算可用于表格内容的高度
const availableContentHeight =
windowHeight - headerHeight - subHeaderHeight - searchFormHeight - tableHeaderHeight - paginationHeight - paddingMarginHeight;
// 根据表格尺寸确定行高
const rowHeight = 40;
// 计算理论上的行数
const calculatedRows = Math.floor(availableContentHeight / rowHeight);
// 设置限制范围
const minPageSize = 10;
const maxPageSize = 30;
// 确保返回值在合理范围内,且至少有基本的行数
return Math.max(minPageSize, Math.min(maxPageSize, calculatedRows));
};

View File

@@ -1,14 +1,24 @@
import { defineStore } from 'pinia';
import { getUser } from '@/common/utils/storage';
export interface UserInfo {
username: string;
name: string;
lastLoginIp?: string;
lastLoginTime?: string;
photo?: string; // 头像
permissions?: string[]; // 权限
}
export const useUserInfo = defineStore('userInfo', {
state: (): UserInfoState => ({
userInfo: {},
userInfo: {} as UserInfo,
}),
actions: {
// 设置用户信息
async setUserInfo(data: object) {
async setUserInfo(data: any) {
const ui = getUser();
console.log(ui);
if (ui) {
this.userInfo = ui;
} else {

View File

@@ -276,13 +276,22 @@ html.dark {
right: 0;
}
// dialog 动画
/* Bounce Animation */
.dialog-bounce-enter-active,
.dialog-bounce-leave-active,
.dialog-bounce-enter-active .el-dialog,
.dialog-bounce-leave-active .el-dialog {
transition: all 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
/* Dialog 对话框
------------------------------- */
.el-dialog {
border-radius: 6px;
/* 设置圆角 */
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
/* 添加轻微阴影效果 */
border: 1px solid var(--el-border-color-lighter);
.dialog-bounce-enter-from,
.dialog-bounce-leave-to {
opacity: 0;
}
.dialog-bounce-enter-from .el-dialog,
.dialog-bounce-leave-to .el-dialog {
transform: scale(0.3) translateY(-50px);
opacity: 0;
}

View File

@@ -40,7 +40,6 @@ declare interface ThemeConfigState {
isInvert: boolean;
isWatermark: boolean;
watermarkText: Array<string>;
tagsStyle: string;
animation: string;
columnsAsideStyle: string;
layout: string;
@@ -49,6 +48,7 @@ declare interface ThemeConfigState {
globalViceTitle: string;
appSlogan: string;
logoIcon: string;
version: string;
globalI18n: string;
globalComponentSize: string;
terminalTheme: string;
@@ -110,3 +110,10 @@ declare interface KeepAliveNamesState {
keepAliveNames: string[];
cachedViews: string[];
}
declare interface MilvusState {
dbs: any[],
selectedDb: string,
selectedCollection: string
collections: any[],
}

View File

@@ -3,3 +3,5 @@ declare module 'jsoneditor';
declare module 'asciinema-player';
declare module 'vue-grid-layout';
declare module 'uuid';
declare module 'x-markdown-vue/style';
declare module 'json-bigint';

View File

@@ -0,0 +1,161 @@
<template>
<div class="flex h-full" v-loading="sessionLoading">
<ConfigProvider :theme="isDark ? 'dark' : 'light'">
<!-- 左侧会话列表 -->
<aside class="flex flex-col border-r transition-all duration-300 ease-in-out" style="border-color: var(--el-border-color-light, #e4e7ed)">
<Conversations
:active="currentSessionId"
:items="conversations"
@change="onChangeSession"
:label-max-width="200"
:show-tooltip="true"
row-key="key"
tooltip-placement="right"
:tooltip-offset="35"
show-to-top-btn
:menuTeleported="false"
show-built-in-menu
@menu-command="onMenuCommand"
>
<template #header>
<div class="p-2 border-b" style="border-color: var(--el-border-color-lighter, #ebeef5)">
<el-button
icon="plus"
type="primary"
@click="createNewSession"
class="w-full shadow-sm hover:shadow-md transition-shadow duration-200"
>
{{ t('ai.assistant.newSession') }}
</el-button>
</div>
</template>
<!-- <template #menu="{ item }">
<div class="flex flex-col">
<el-button
v-for="menuItem in conversationMenuItems"
:key="menuItem.key"
link
size="small"
:icon="menuItem.icon"
@click.stop="onMenuCommand(menuItem.key, item)"
>
<span v-if="menuItem.label">{{ menuItem.label }}</span>
</el-button>
</div>
</template> -->
</Conversations>
</aside>
<!-- 右侧聊天区域 -->
<main class="ml-3 flex-1 flex flex-col bg-linear-to-br from-gray-50 to-white dark:from-gray-800 dark:to-gray-900">
<AiChat v-if="currentSessionId" :session-id="currentSessionId" @activate="onSessionCreated" />
<div v-else class="flex-1 flex flex-col items-center justify-center text-gray-400 dark:text-gray-500 space-y-4">
<div class="text-6xl opacity-20">💬</div>
<p class="text-lg font-medium">{{ t('ai.assistant.startNewConversation') }}</p>
<p class="text-sm">{{ t('ai.assistant.selectOrCreateSession') }}</p>
</div>
</main>
</ConfigProvider>
</div>
</template>
<script setup lang="ts" name="AiAssistant">
import { notBlankI18n } from '@/common/assert';
import { formatDate } from '@/common/utils/format';
import { useI18nOperateSuccessMsg } from '@/hooks/useI18n';
import { useThemeConfig } from '@/store/themeConfig';
import { ElMessageBox } from 'element-plus';
import { computed, defineAsyncComponent, onMounted, ref } from 'vue';
import { ConfigProvider, Conversations } from 'vue-element-plus-x';
import type { ConversationItem, ConversationMenuCommand } from 'vue-element-plus-x/types/Conversations';
import { useI18n } from 'vue-i18n';
import { aiApi } from './api';
const AiChat = defineAsyncComponent(() => import('./AiChat.vue'));
const { t } = useI18n();
const themeConfig = useThemeConfig();
const isDark = computed(() => themeConfig.themeConfig.isDark);
const conversations = ref<ConversationItem[]>([]);
// 当前会话id'-1' 表示新建会话)
const currentSessionId = ref<string>('');
// sessions 加载状态
const sessionLoading = ref<boolean>(true);
/**
* 加载会话列表
*/
const loadSessions = async () => {
try {
sessionLoading.value = true;
const sessions = await aiApi.listSessions.request();
conversations.value = sessions.map((session) => {
return {
key: session.sessionKey,
label: session.title,
createTime: formatDate(session.createTime),
updateTime: formatDate(session.updateTime),
};
});
// 默认选中第一个会话
if (!currentSessionId.value && sessions.length > 0) {
switchSession(sessions[0].sessionKey);
}
} finally {
sessionLoading.value = false;
}
};
const onChangeSession = (item: ConversationItem) => {
switchSession(item.key);
};
const createNewSession = async () => {
currentSessionId.value = '-1';
};
const switchSession = (sessionId: string) => {
currentSessionId.value = sessionId;
};
const onSessionCreated = async (sessionId: string) => {
currentSessionId.value = sessionId;
await loadSessions();
};
const deleteSession = async (sessionKey: string) => {
await aiApi.deleteSession.request({ sessionKey });
if (currentSessionId.value === sessionKey) {
currentSessionId.value = '';
}
await loadSessions();
};
// 内置菜单点击方法
const onMenuCommand = async (command: ConversationMenuCommand, item: ConversationItem) => {
if (command === 'delete') {
deleteSession(item.key);
return;
}
if (command === 'rename') {
ElMessageBox.prompt('', t('common.name'), {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
}).then(async ({ value }) => {
notBlankI18n(value, 'common.name');
await aiApi.renameSession.request({ sessionKey: item.key, title: value });
useI18nOperateSuccessMsg();
loadSessions();
});
}
};
onMounted(() => {
loadSessions();
});
</script>
<style></style>

View File

@@ -0,0 +1,302 @@
<template>
<div class="h-full flex flex-col p-5 justify-center">
<div class="w-full flex-1 overflow-hidden" v-if="messages.length > 0">
<BubbleList v-loading="msgLoading" ref="bubbleListRef" :key="bubbleListKey" :list="messages" max-height="100%" :virtual="false">
<template #avatar="{ item }">
<SvgIcon v-if="item.role == ROLE.AI" :size="24" name="icon ai/assistant" color="var(--el-color-primary)" />
<img v-else class="size-10 max-w-none rounded-full" :src="useUserInfo().userInfo.photo" alt="avatar" />
</template>
<template #header="{ item }">
<ThoughtChain :thinking-items="item.thinks" dot-size="small" class="min-w-150 max-w-300" :max-width="BUBBLE_MAX_WIDTH" row-key="id">
</ThoughtChain>
<!-- 中断组件横向 flex 排列宽度缩小 -->
<div class="flex flex-wrap gap-2">
<template v-for="internal in item.internals" :key="internal.id || internal.extra?.interruptId">
<component
class="max-w-120 shrink-0"
v-if="internal.extra?.type?.startsWith('interrupt_')"
:is="getInterruptComponent(internal.extra?.type)"
:data="internal"
:readonly="internal.extra?.resumeInfo"
@action="handleInterruptAction"
/>
</template>
</div>
<!-- 其他类型 internal 纵向排列 -->
<div v-for="internal in item.internals" :key="internal.id || internal.extra?.interruptId" class="mt-1">
<div
v-if="internal.extra?.type === 'notification'"
class="p-3 bg-blue-50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-800"
>
<div class="text-sm font-medium text-blue-700 dark:text-blue-300">
{{ internal.content?.title || internal.extra?.content?.title }}
</div>
<div class="text-xs text-blue-600 dark:text-blue-400 mt-1">
{{ internal.content?.description || internal.extra?.content?.description }}
</div>
</div>
<!-- 未知类型的降级展示 -->
<div
v-else-if="!internal.extra?.type?.startsWith('interrupt_')"
class="px-2 py-1.5 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
>
<div class="text-xs text-gray-500 dark:text-gray-400">
<span class="font-medium">{{ t('ai.chat.type') }}:</span> {{ internal.extra?.type || internal.type || 'unknown' }}
</div>
<div v-if="internal.content || internal.extra?.content" class="text-xs text-gray-600 dark:text-gray-300 mt-0.5">
{{ internal.content || internal.extra?.content?.description || JSON.stringify(internal.extra?.content || internal.content) }}
</div>
</div>
</div>
<!-- 中断处理进度提示当有未处理的中断时显示进度 -->
<div
v-if="item.unprocessedInterruptCount > 0"
class="mt-2 mb-2 px-2 py-1 bg-blue-50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-800"
>
<div class="flex items-center justify-between">
<span class="text-xs text-blue-700 dark:text-blue-300">
{{ t('ai.chat.processed') }} {{ item.pendingResumes?.length || 0 }} / {{ item.unprocessedInterruptCount }}
{{ t('ai.chat.interrupts') }}
<span
v-if="item.pendingResumes?.length === item.unprocessedInterruptCount"
class="ml-1 text-xs text-green-600 dark:text-green-400"
>
{{ t('ai.chat.submitting') }}
</span>
</span>
</div>
</div>
</template>
<template #content="{ item }">
<!-- chat 内容走 markdown -->
<MarkdownRenderer
v-if="item.role === ROLE.AI || item.role == ROLE.INTERNAL"
:markdown="item.content"
:enable-animate="true"
:is-dark="isDark"
:themes="{ light: 'github-light', dark: 'github-dark' }"
:default-theme-mode="isDark ? 'dark' : 'light'"
class="max-w-300"
/>
<!-- user 内容 纯文本 -->
<div v-if="item.role === ROLE.USER" class="whitespace-pre-wrap">
{{ item.content }}
</div>
</template>
<template #footer="{ item }">
<div class="flex justify-between items-center">
<div>
<el-button @click="copyToClipboard(item.content)" color="#626aef" icon="DocumentCopy" size="small" circle />
</div>
<div class="ml-2 mt-1 text-xs">
{{ item.time }}
</div>
</div>
</template>
</BubbleList>
</div>
<!-- 输入框区域固定在底部 -->
<div class="w-full mt-4">
<XSender
ref="senderRef"
style="border-radius: 24px"
@submit="onSubmit"
:loading="senderLoading"
:disabled="senderLoading"
:custom-dialog="true"
:custom-trigger="customSenderTrigger"
@show-tag-dialog="showTagDialog"
submit-type="enter"
:auto-focus="true"
variant="updown"
clearable
>
</XSender>
<el-dialog v-model="dialogCustomVisible" :title="t('ai.chat.customTriggerDialogTitle')" width="500">
<template v-for="option of customSenderTrigger" :key="option.prefix">
<p v-for="tag of option.tagList" :key="tag.id" @click="checkTag(tag)">
{{ tag.name }}
</p>
</template>
</el-dialog>
</div>
</div>
</template>
<script setup lang="ts" name="AiChat">
import { copyToClipboard } from '@/common/utils/string';
import { useThemeConfig } from '@/store/themeConfig';
import { useUserInfo } from '@/store/userInfo';
import { computed, reactive, ref, toRefs, useTemplateRef, watch } from 'vue';
import { useDebounceFn } from '@vueuse/core';
import { BubbleList, ThoughtChain, XSender } from 'vue-element-plus-x';
import type { BubbleListInstance } from 'vue-element-plus-x/types/BubbleList';
import { useI18n } from 'vue-i18n';
import { MarkdownRenderer } from 'x-markdown-vue';
import 'x-markdown-vue/style';
import { getInterruptComponent } from './interrupt';
import { useAiChatWebSocket } from './hooks/useAiChatWebSocket';
import { useAiChatMessages } from './hooks/useAiChatMessages';
const { t } = useI18n();
const BUBBLE_MAX_WIDTH = '80%';
// 模板中使用的常量
const ROLE = {
AI: 'assistant',
USER: 'user',
INTERNAL: 'internal',
} as const;
const props = defineProps({
sessionId: {
type: String,
default: '',
},
});
const emit = defineEmits(['activate']);
// 内部跟踪当前 sessionId支持新会话时从空值被后端赋值
const currentSessionId = ref('');
// 新会话标识(统一用 '-1' 表示)
const isNewSession = computed(() => props.sessionId === '-1');
const themeConfig = useThemeConfig();
const isDark = computed(() => themeConfig.themeConfig.isDark);
const senderRef = useTemplateRef<InstanceType<typeof XSender>>('senderRef');
const bubbleListRef = useTemplateRef<BubbleListInstance>('bubbleListRef');
/**
* 滚动到底部防抖处理500ms 内只执行一次)
*/
const scrollToBottom = useDebounceFn(() => {
bubbleListRef.value?.scrollToBottom();
}, 500);
// 使用 WebSocket hook
const messageHandler = (data: any) => {
messageHook.handleChunk(data);
};
const { initSocket, sendMessage, reconnectAttempts } = useAiChatWebSocket(messageHandler, currentSessionId, isNewSession);
// 使用消息管理 hook
const messageHook = useAiChatMessages(props, emit, currentSessionId, sendMessage);
// 直接使用 hook 的 state保持响应式连接
const { msgLoading, senderLoading } = toRefs(messageHook.state);
// 监听消息变化,自动滚动到底部
watch(
() => messageHook.messages.value.length,
() => {
scrollToBottom();
}
);
const state = reactive({
dialogCustomVisible: false,
tagPrefix: '',
});
const { dialogCustomVisible } = toRefs(state);
// 提供中断操作处理器给子组件
const handleInterruptAction = messageHook.handleInterruptAction;
let isIniting = false;
const init = async () => {
if (isIniting) {
return;
}
isIniting = true;
try {
if (!reconnectAttempts.value || reconnectAttempts.value === 0) {
initSocket();
}
if (isNewSession.value) {
messageHook.state.senderLoading = false;
}
messageHook.loadMessage();
} finally {
isIniting = false;
}
};
watch(
() => props.sessionId,
async (newVal: string) => {
if (!newVal || newVal === currentSessionId.value) {
return;
}
currentSessionId.value = newVal;
if (isNewSession.value) {
messageHook.resetMessages();
}
init();
},
{ immediate: true }
);
// 自定义触发符相关逻辑
const customSenderTrigger = ref([
{
dialogTitle: t('ai.interrupt.assetSelection.title'),
prefix: '#',
tagList: [
{ id: 'ht1', name: '话题一' },
{ id: 'ht2', name: '话题二' },
],
},
]);
const showTagDialog = (prefix: string) => {
state.tagPrefix = prefix;
state.dialogCustomVisible = true;
};
const checkTag = (tag: any) => {
senderRef.value?.customSetTag(state.tagPrefix, tag);
dialogCustomVisible.value = false;
};
/**
* 转为组件需要的数据
*/
const messages = messageHook.messages;
const bubbleListKey = messageHook.bubbleListKey;
const clearSenderEditor = () => {
senderRef.value?.clear();
};
const onSubmit = () => {
try {
const content = senderRef.value?.getModelValue().text;
// 先添加消息到 UI
messageHook.addUserMessage(content);
// 再发送 WebSocket 消息
sendMessage('text', content);
messageHook.state.senderLoading = true;
} finally {
clearSenderEditor();
}
};
</script>
<style></style>

View File

@@ -0,0 +1,57 @@
<template>
<el-drawer
:title="title"
v-model="dialogVisible"
z-index="2000"
top="32px"
size="70%"
:close-on-click-modal="false"
:show-close="true"
@close="closeDialog"
>
<template #header>
<DrawerHeader
:header="title"
:back="
() => {
dialogVisible = false;
}
"
/>
</template>
<AiAssistant></AiAssistant>
<!-- <AiChat ref="aiChatRef" :title="title" :show-close="false" /> -->
</el-drawer>
</template>
<script setup lang="ts" name="AiChatDialog">
import { ref, onMounted, useTemplateRef, watch, defineAsyncComponent } from 'vue';
const DrawerHeader = defineAsyncComponent(() => import('@/components/drawer-header/DrawerHeader.vue'));
const AiAssistant = defineAsyncComponent(() => import('./AiAssistant.vue'));
const props = defineProps({
title: {
type: String,
default: '',
},
});
const emit = defineEmits(['close', 'send', 'refresh']);
const dialogVisible = defineModel<Boolean>('visible', { required: true });
// const aiChatRef = useTemplateRef<InstanceType<typeof AiChat>>('aiChatRef');
const handleSend = (message: string) => {
emit('send', message);
};
const handleRefresh = () => {
emit('refresh');
};
const closeDialog = () => {
emit('close');
};
</script>

View File

@@ -0,0 +1,46 @@
import Api from '@/common/Api';
export interface Session {
sessionKey: string;
title: string;
createTime: string;
updateTime: string;
}
export interface ToolCall {
id: string;
function: {
name: string;
arguments: string;
};
}
export interface SessionMessage {
turnId?: string;
sessionId?: string; // 会话ID用于过滤不属于当前会话的消息
role: string;
content: string;
type?: string;
time?: any;
reasoningContent?: string;
toolCalls?: ToolCall[];
toolCallId?: string;
actionId?: string;
extra?: any;
}
export const aiApi = {
// 获取权限列表
listSessions: Api.newGet<Session[]>('/ai/chat/sessions'),
deleteSession: Api.newDelete('/ai/chat/sessions/{sessionKey}'),
renameSession: Api.newPost('/ai/chat/sessions/rename'),
listMessages: Api.newGet<SessionMessage[]>('/ai/chat/messages'),
};
export function getMachineTerminalSocketUrl(authCertName: any) {
return `/machines/terminal/${authCertName}`;
}
export function getMachineRdpSocketUrl(authCertName: any) {
return `/api/machines/rdp/${authCertName}`;
}

View File

@@ -0,0 +1,2 @@
export { useAiChatWebSocket } from './useAiChatWebSocket';
export { useAiChatMessages } from './useAiChatMessages';

View File

@@ -0,0 +1,744 @@
import { ref, reactive, computed, provide, type Ref } from 'vue';
import { useDebounceFn } from '@vueuse/core';
import { ElMessage } from 'element-plus';
import { formatDate } from '@/common/utils/format';
import { useI18n } from 'vue-i18n';
import { aiApi, SessionMessage, ToolCall } from '../api';
import { InterruptActionEvent } from '../interrupt/types';
// ==================== 常量定义 ====================
const ROLE = {
AI: 'assistant',
USER: 'user',
TOOL: 'tool',
INTERNAL: 'internal',
} as const;
const MESSAGE_TYPE = {
END: 'end',
ERROR: 'error',
} as const;
const THINK_STATUS = {
LOADING: 'loading',
SUCCESS: 'success',
ERROR: 'error',
} as const;
const THINK_TYPE = {
REASONING: 'reasoning',
TOOL: 'tool',
} as const;
const BUBBLE_MAX_WIDTH = '80%';
const TOOL_ERROR_PREFIX = '[tool error]';
const TOOL_RESULT_MAX_LENGTH = 1500;
/**
* Internal 消息类型
*/
type InternalMessageType = SessionMessage & {
id?: string; // 内部消息ID
actionId: string;
extra?: {
type?: 'interrupt' | 'resume' | string; // 内部消息类型
[key: string]: any;
};
};
type MessageType = SessionMessage & {
key?: number;
loading?: boolean; // 是否正在加载中
thinks?: Array<{
type?: string;
status: string;
id: string;
title: string;
isCanExpand: boolean;
isDefaultExpand: boolean;
thinkTitle: string;
thinkContent: string;
extra?: any;
}>; // 思考链
internals?: Array<InternalMessageType>; // 内部消息数组
pendingResumes?: InterruptActionEvent[]; // 待批量提交的中断恢复信息
};
export function useAiChatMessages(
props: { sessionId: string },
emit: (event: 'activate', sessionId: string) => void,
currentSessionId: Ref<string>,
sendMessage: (type: 'text' | 'interruptResume', content: string) => void
) {
const { t } = useI18n();
const state = reactive({
msgLoading: false,
senderLoading: false,
messages: [] as Array<MessageType>,
// 用于强制触发 messages computed 重新计算的版本号
// 当修改消息内部状态(如 pendingResumes、internals时递增
version: 0,
});
// 标记会话是否已激活(用户是否发送过消息)
const sessionActivated = ref(false);
/**
* 统一的中断操作处理器
* 所有中断操作均缓存到 pendingResumes当所有中断都处理完毕后自动批量提交
*/
const handleInterruptAction = async (action: InterruptActionEvent) => {
console.log('中断操作:', action);
// 先尝试通过 turnId 找到 AI/INTERNAL 消息(中断只属于 AI 消息容器)
let message = state.messages.find((m: MessageType) => (m.role === ROLE.AI || m.role === ROLE.INTERNAL) && m.turnId && m.turnId === action.turnId);
// 如果找不到,尝试通过 interruptId 找到包含该中断的 AI/INTERNAL 消息
if (!message) {
message = state.messages.find(
(m: MessageType) =>
(m.role === ROLE.AI || m.role === ROLE.INTERNAL) &&
m.internals?.some((internal: InternalMessageType) => (internal.actionId || internal.extra?.actionId) === action.interruptId)
);
}
// 最后回退到最后一条 AI/INTERNAL 消息
if (!message) {
message = [...state.messages].reverse().find((m: MessageType) => m.role === ROLE.AI || m.role === ROLE.INTERNAL);
}
if (!message) {
console.warn('没有消息可处理中断');
return;
}
// 初始化 pendingResumes
if (!message.pendingResumes) {
message.pendingResumes = [];
}
// 去重:同一 interruptId 只保留最新的
const existingIndex = message.pendingResumes.findIndex((r: InterruptActionEvent) => r.interruptId === action.interruptId);
if (existingIndex >= 0) {
message.pendingResumes[existingIndex] = action;
} else {
message.pendingResumes.push(action);
}
// 更新对应 interrupt 的 pending 状态,用于 UI 显示
const targetInterrupt = message.internals?.find(
(internal: InternalMessageType) =>
(internal.actionId || internal.extra?.actionId) === action.interruptId && internal.extra?.type?.startsWith('interrupt_')
);
if (targetInterrupt) {
if (!targetInterrupt.extra) {
targetInterrupt.extra = {};
}
targetInterrupt.extra.pendingResumeInfo = {
action: action.action,
payload: action.payload,
};
}
// 递增版本号,强制触发 messages computed 重新计算
state.version++;
// 强制替换 messages 数组引用,确保 BubbleList 检测到变化
state.messages = [...state.messages];
// 检查是否所有中断都已处理,如果是则自动批量提交
const unprocessedCount = (message.internals || []).filter(
(internal: InternalMessageType) => internal.extra?.type?.startsWith('interrupt_') && !internal.extra?.resumeInfo
).length;
if (message.pendingResumes && message.pendingResumes.length === unprocessedCount && unprocessedCount > 0) {
// 所有中断已处理完毕自动提交setTimeout 让当前渲染周期完成)
setTimeout(() => {
submitPendingResumes(message.turnId || '');
}, 0);
}
};
// 提供中断操作处理器给子组件,绕过 Vue 动态组件事件传递问题
provide('handleInterruptAction', handleInterruptAction);
/**
* 提交所有待处理的中断恢复信息(批量提交)
* @param turnId 轮次ID
*/
const submitPendingResumes = (turnId: string) => {
let message = state.messages.find((m: MessageType) => (m.role === ROLE.AI || m.role === ROLE.INTERNAL) && m.turnId && m.turnId === turnId);
if (!message) {
message = [...state.messages].reverse().find((m: MessageType) => m.role === ROLE.AI || m.role === ROLE.INTERNAL);
}
if (!message || !message.pendingResumes || message.pendingResumes.length === 0) {
return;
}
// 发送批量恢复请求
sendMessage('interruptResume', JSON.stringify(message.pendingResumes));
// 将 pending 状态转为正式 resumeInfo并清空 pending
for (const resume of message.pendingResumes) {
const targetInterrupt = message.internals?.find(
(internal: InternalMessageType) =>
(internal.actionId || internal.extra?.actionId) === resume.interruptId && internal.extra?.type?.startsWith('interrupt_')
);
if (targetInterrupt) {
if (!targetInterrupt.extra) {
targetInterrupt.extra = {};
}
targetInterrupt.extra.resumeInfo = {
action: resume.action,
payload: resume.payload,
};
delete targetInterrupt.extra.pendingResumeInfo;
}
}
message.pendingResumes = [];
// 递增版本号,强制触发 messages computed 重新计算
state.version++;
// 强制替换 messages 数组引用,确保 BubbleList 检测到变化
state.messages = [...state.messages];
};
/**
* 原子操作:追加工具调用到消息的思考链中
*/
const appendToolCall = (message: MessageType, toolCall: ToolCall) => {
if (!message.thinks) {
message.thinks = [];
}
// 检查是否已存在相同的 toolCall
const existingThink = message.thinks.find((t) => t.type === THINK_TYPE.TOOL && t.extra?.toolCallId === toolCall.id);
if (existingThink) {
// 如果已存在,更新内容
existingThink.thinkContent = JSON.stringify(toolCall);
} else {
// 在添加工具调用之前,将所有 loading 状态的 reasoning 思考标记为 success
for (const think of message.thinks) {
if (think.type === THINK_TYPE.REASONING && think.status === THINK_STATUS.LOADING) {
think.status = THINK_STATUS.SUCCESS;
}
}
// 否则创建新的 think
message.thinks.push({
type: THINK_TYPE.TOOL,
status: THINK_STATUS.LOADING,
id: String(toolCall.id),
title: t('ai.chat.toolCall'),
isCanExpand: true,
isDefaultExpand: false,
thinkTitle: t('ai.chat.toolCall') + ' - ' + toolCall.function?.name || '',
thinkContent: JSON.stringify(toolCall),
extra: {
toolCallId: toolCall.id,
toolName: toolCall.function?.name,
},
});
}
};
/**
* 原子操作:追加工具调用结果到消息的思考链中
*/
const appendToolResult = (message: MessageType, toolCallId: string, content?: string) => {
if (!message.thinks || !content) {
return;
}
for (let think of message.thinks) {
if (think.type !== THINK_TYPE.TOOL || !think.extra) {
continue;
}
if (think.extra.toolCallId === toolCallId) {
const displayContent = content.length > TOOL_RESULT_MAX_LENGTH ? content.substring(0, TOOL_RESULT_MAX_LENGTH) + '...' : content;
think.thinkContent = content ? `${think.thinkContent} ${t('ai.chat.toolCallResult')}: ${displayContent}` : displayContent;
if (content.startsWith(TOOL_ERROR_PREFIX)) {
think.status = THINK_STATUS.ERROR;
} else {
think.status = THINK_STATUS.SUCCESS;
}
return;
}
}
};
/**
* 原子操作:追加内部消息到消息中
*/
const appendInternal = (message: MessageType, internalMsg: SessionMessage) => {
if (internalMsg.type == MESSAGE_TYPE.ERROR) {
ElMessage.error(internalMsg.content);
// 内部错误也要取消 loading 状态
state.senderLoading = false;
return;
}
// 处理会话结束或错误
if (internalMsg.type == MESSAGE_TYPE.END) {
handleTurnEnd(message, internalMsg);
return;
}
// 处理 resume 类型的内部消息:合并到对应的 interrupt 中
if (internalMsg.extra?.type === 'resume') {
mergeResumeToInterrupt(message, internalMsg);
return;
}
if (!message.internals) {
message.internals = [];
}
message.internals.push(internalMsg as InternalMessageType);
};
/**
* 将 resume 消息合并到对应的 interrupt 消息中
*/
const mergeResumeToInterrupt = (message: MessageType, resumeMsg: SessionMessage) => {
if (!message.internals || message.internals.length === 0) {
console.warn('No internals found to merge resume message');
return;
}
const resumeData = resumeMsg.extra?.content;
const interruptId = resumeData?.interruptId;
if (!interruptId) {
console.warn('Resume message missing interruptId');
return;
}
// 查找对应的 interrupt 消息
const targetInterrupt = message.internals.find(
(internal) => (internal.actionId || internal.extra?.actionId) === interruptId && internal.extra?.type?.startsWith('interrupt_')
);
if (!targetInterrupt) {
console.warn(`Interrupt with id ${interruptId} not found`);
return;
}
// 更新 interrupt 的状态和内容
if (!targetInterrupt.extra) {
targetInterrupt.extra = {};
}
// 记录操作信息
targetInterrupt.extra.resumeInfo = {
action: resumeData.action,
timestamp: resumeMsg.time,
payload: resumeData.payload,
};
console.log('Merged resume to interrupt:', {
interruptId,
action: resumeData.action,
});
};
/**
* 处理会话结束或错误
*/
const handleTurnEnd = (message: MessageType, chunkMsg: SessionMessage) => {
message.time = chunkMsg.time;
// 会话结束,重置 loading 状态
state.senderLoading = false;
// 结束可能存在的思考链
if (message.thinks && message.thinks.length > 0) {
for (let think of message.thinks) {
if (think.status == THINK_STATUS.LOADING && think.type == THINK_TYPE.REASONING) {
think.status = THINK_STATUS.SUCCESS;
}
}
}
// 如果是新会话,触发会话激活事件
const isNewSession = props.sessionId === '-1';
if (isNewSession && !sessionActivated.value) {
sessionActivated.value = true;
setTimeout(() => {
emit('activate', currentSessionId.value);
}, 500);
}
};
/**
* 原子操作:追加推理内容到消息的思考链中
*/
const appendReasoning = (message: MessageType, reasoningContent: string) => {
if (!reasoningContent) {
return;
}
const title = t('ai.chat.thinking');
const thinks = message.thinks;
// 创建think对象的辅助函数
const createThink = (status: string, id: number | string) => ({
type: THINK_TYPE.REASONING,
status,
id: String(id),
title: title,
isCanExpand: true,
isDefaultExpand: false,
thinkTitle: title,
thinkContent: reasoningContent,
});
// 如果没有thinks数组初始化
if (!thinks || thinks.length == 0) {
message.thinks = [createThink(THINK_STATUS.LOADING, 1)];
return;
}
const thinkIndex = thinks.length - 1;
const think = thinks[thinkIndex];
// 如果title不同结束当前think并创建新的
if (think.title != title) {
// 将上一个 thinking 标记为 success
if (think.status === THINK_STATUS.LOADING) {
think.status = THINK_STATUS.SUCCESS;
}
thinks.push(createThink(THINK_STATUS.LOADING, thinkIndex + 2));
return;
}
// 如果当前think还在loading状态追加内容
if (think.status == THINK_STATUS.LOADING) {
think.thinkContent += reasoningContent;
if (!reasoningContent) {
think.status = THINK_STATUS.SUCCESS;
}
return;
}
// 否则创建新的think
thinks.push(createThink(THINK_STATUS.LOADING, thinkIndex + 2));
};
/**
* 原子操作:追加普通文本内容到消息
*/
const appendContent = (message: MessageType, content: string) => {
if (!content) {
return;
}
message.content += content;
if (message.loading) {
message.loading = false;
}
};
/**
* 统一的消息处理器
* 无论是 WebSocket chunk 还是历史消息,都通过此函数处理
*/
const processMessage = (message: SessionMessage, targetMessage: MessageType) => {
switch (message.role) {
case ROLE.USER:
// 用户消息:直接追加内容
appendContent(targetMessage, message.content);
break;
case ROLE.AI:
// AI 消息:处理内容、推理、工具调用
const isToolCall = message.toolCalls && message.toolCalls.length > 0;
if (!isToolCall) {
appendContent(targetMessage, message.content);
} else {
// 如果是工具调用,并且存在内容,则添加为推理内容
if (message.content) {
message.reasoningContent = message.content;
}
}
// 处理推理内容
if (message.reasoningContent) {
appendReasoning(targetMessage, message.reasoningContent);
}
// 处理工具调用
if (isToolCall && message.toolCalls) {
for (let toolCall of message.toolCalls) {
appendToolCall(targetMessage, toolCall);
}
}
break;
case ROLE.TOOL:
// 工具结果:更新对应的工具调用状态
if (message.toolCallId) {
appendToolResult(targetMessage, message.toolCallId, message.content || '');
}
break;
case ROLE.INTERNAL:
// 内部消息:添加到 internals 数组
appendInternal(targetMessage, message);
break;
default:
console.warn('Unknown message role:', message.role);
}
};
/**
* 处理 WebSocket 消息块
*/
const handleChunk = (chunkMsg: SessionMessage) => {
// 处理系统错误消息:使用 ElMessage.error 提示,不添加到聊天列表
if (chunkMsg.type === 'error') {
ElMessage.error(chunkMsg.content);
return;
}
const nowMsgIndex = state.messages.length - 1;
const message = state.messages[nowMsgIndex];
if (!message) {
console.warn('No message to append chunk to: ', nowMsgIndex);
return;
}
// 同步 turnId后端返回的 chunk 消息携带 turnId需要赋值给当前消息容器
// 使用 != null 以支持空字符串 turnId后端可能发送空字符串
if (chunkMsg.turnId != null && !message.turnId) {
message.turnId = chunkMsg.turnId;
}
// 使用统一的消息处理器
processMessage(chunkMsg, message);
};
/**
* 加载历史消息
*/
const loadMessage = async () => {
if (!props.sessionId || props.sessionId === '-1') {
return;
}
try {
state.msgLoading = true;
const messages = await aiApi.listMessages.request({ sessionKey: props.sessionId });
state.messages = converterMessages(messages);
// 检查最后一条 AI 消息是否还在 loading 状态,如果是则保持 senderLoading 为 true
const lastMessage = state.messages[state.messages.length - 1];
if (lastMessage && lastMessage.role === ROLE.AI && lastMessage.loading) {
state.senderLoading = true;
}
} finally {
state.msgLoading = false;
}
};
/**
* 转换消息格式
*/
const converterMessages = (messages: SessionMessage[]) => {
// 按 turnId 分组消息
const turnGroups = new Map<
string,
{
userMessage?: MessageType;
aiMessage?: MessageType;
toolCalls: SessionMessage[];
toolResults: Map<string, SessionMessage>; // toolCallId -> toolResult
internals: SessionMessage[];
}
>();
// 第一轮遍历:按 turnId 分类所有消息
for (let message of messages) {
if (!message.turnId) {
console.warn('Message without turnId skipped:', message);
continue;
}
if (!turnGroups.has(message.turnId)) {
turnGroups.set(message.turnId, {
userMessage: undefined,
aiMessage: undefined,
toolCalls: [],
toolResults: new Map(),
internals: [],
});
}
const group = turnGroups.get(message.turnId)!;
// 用户消息
if (message.role === ROLE.USER) {
group.userMessage = message;
continue;
}
// 工具调用消息
if (message.toolCalls && message.toolCalls.length > 0) {
group.toolCalls.push(message);
continue;
}
// 工具调用结果
if (message.role === ROLE.TOOL && message.toolCallId) {
group.toolResults.set(message.toolCallId, message);
continue;
}
// AI 助手消息
if (message.role === ROLE.AI) {
group.aiMessage = message;
continue;
}
// 内部消息(中断等)
if (message.role === ROLE.INTERNAL) {
group.internals.push(message);
continue;
}
}
// 第二轮遍历:构建最终消息列表,使用统一的消息处理逻辑
const finalMessages: MessageType[] = [];
for (let [turnId, group] of turnGroups) {
// 用户消息直接添加
if (group.userMessage) {
finalMessages.push({
...group.userMessage,
time: formatDate(group.userMessage.time),
});
}
// 创建主消息容器
let mainMessage: MessageType;
if (group.aiMessage) {
mainMessage = {
...group.aiMessage,
loading: false,
thinks: [],
internals: [],
};
} else {
// 如果没有 AI 消息,但有其他内容,创建一个 loading 状态的 AI 消息容器
if (group.internals.length > 0 || group.toolCalls.length > 0 || group.toolResults.size > 0) {
mainMessage = {
role: ROLE.AI,
content: '',
loading: true,
thinks: [],
internals: [],
};
} else {
// 没有任何内容,跳过
continue;
}
}
// 处理工具调用消息
for (let toolCallMsg of group.toolCalls) {
processMessage(toolCallMsg, mainMessage);
}
// 处理工具调用结果
for (let [, toolResultMsg] of group.toolResults) {
processMessage(toolResultMsg, mainMessage);
}
// 处理内部消息
for (let internalMsg of group.internals) {
processMessage(internalMsg, mainMessage);
}
finalMessages.push(mainMessage);
}
return finalMessages;
};
/**
* 转为组件需要的数据
*/
// BubbleList 强制重新渲染的 key基于 version 变化
const bubbleListKey = computed(() => `bubble-list-${state.version}`);
const messages = computed(() => {
return state.messages.map((item: MessageType) => {
const role = item.role;
const unprocessedInterruptCount = (item.internals || []).filter(
(internal: InternalMessageType) => internal.extra?.type?.startsWith('interrupt_') && !internal.extra?.resumeInfo
).length;
const pendingCount = item.pendingResumes?.length || 0;
// key 需包含 pendingCount 和 unprocessedInterruptCount确保 BubbleList 在中断状态变化时重新渲染
// param-completion 组件内部通过 watch 监听 pendingResumeInfo可在重新挂载后恢复用户输入状态
const key = item.key || `${item.turnId || 'no-turn'}-${pendingCount}-${unprocessedInterruptCount}`;
return {
key,
role: item.role,
content: item.content,
time: formatDate(item.time),
placement: role === ROLE.AI || role == ROLE.INTERNAL ? 'start' : 'end',
variant: role === ROLE.AI ? 'filled' : 'outlined', // 气泡的样式
isFog: role === ROLE.AI, // AI 消息开启雾化效果
maxWidth: BUBBLE_MAX_WIDTH,
thinks: item.thinks,
internals: JSON.parse(JSON.stringify(item.internals || [])) as InternalMessageType[], // 深拷贝一份 internals避免响应式问题
loading: item.loading,
extra: item.extra,
turnId: item.turnId,
reasoningContent: item.reasoningContent,
pendingResumes: item.pendingResumes,
unprocessedInterruptCount,
};
}) as any[];
});
/**
* 重置消息
*/
const resetMessages = () => {
state.messages = [];
state.version = 0;
sessionActivated.value = false;
};
/**
* 添加用户消息
*/
const addUserMessage = (content: string) => {
state.messages.push({
content: content,
role: ROLE.USER,
time: new Date(),
});
state.messages.push({
content: '',
role: ROLE.AI,
loading: true,
});
};
return {
state,
messages,
bubbleListKey,
handleInterruptAction,
handleChunk,
loadMessage,
resetMessages,
addUserMessage,
};
}

View File

@@ -0,0 +1,160 @@
import { createWebSocket } from '@/common/request';
import { ref, onBeforeUnmount, type Ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { ElMessage } from 'element-plus';
/**
* AI Chat WebSocket 连接管理 Hook
* 负责 WebSocket 连接、重连、消息收发等
*/
export function useAiChatWebSocket(
onMessage: (data: any) => void,
currentSessionId: Ref<string>,
isNewSession: Ref<boolean>
) {
const { t } = useI18n();
const socket = ref<WebSocket | null>(null);
const reconnectTimer = ref<any>(null);
const reconnectAttempts = ref(0);
const MAX_RECONNECT_ATTEMPTS = 5;
const RECONNECT_DELAY = 3000;
/**
* 初始化 WebSocket 连接
*/
const initSocket = async () => {
try {
console.log('init chat ws...');
const ws = await createWebSocket(`/ai/chat`);
socket.value = ws;
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
// 会话隔离:只处理属于当前激活会话的消息
if (data.sessionId && data.sessionId !== currentSessionId.value) {
// 新会话首次收到后端返回的真实 sessionId更新并通知父组件
if (isNewSession.value) {
currentSessionId.value = data.sessionId;
} else {
console.log(`忽略不属于当前会话的消息: ${data.sessionId} !== ${currentSessionId.value}`);
return;
}
}
onMessage(data);
};
ws.onclose = (event) => {
console.log('chat ws 连接关闭:', event.code, event.reason);
if (!event.wasClean) {
attemptReconnect();
}
};
ws.onerror = (error) => {
console.error('chat ws 错误:', error);
};
// 连接成功,重置重连计数
reconnectAttempts.value = 0;
} catch (e) {
console.log('连接错误', e);
// 直接显示错误提示,不传递到消息处理器
ElMessage.error(t('ai.chat.connectionFailed'));
attemptReconnect();
}
};
/**
* 尝试重连
*/
const attemptReconnect = () => {
if (reconnectAttempts.value >= MAX_RECONNECT_ATTEMPTS) {
console.warn('达到最大重连次数,停止重连');
ElMessage.error(t('ai.chat.connectionDisconnected'));
return;
}
reconnectAttempts.value++;
console.log(`尝试第 ${reconnectAttempts.value} 次重连...`);
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value);
}
reconnectTimer.value = setTimeout(() => {
initSocket();
}, RECONNECT_DELAY);
};
/**
* 清理 WebSocket 连接
*/
const cleanupSocket = () => {
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value);
reconnectTimer.value = null;
}
if (socket.value) {
socket.value.onclose = null;
socket.value.onerror = null;
socket.value.onmessage = null;
if (socket.value.readyState === WebSocket.OPEN || socket.value.readyState === WebSocket.CONNECTING) {
socket.value.close();
}
socket.value = null;
}
reconnectAttempts.value = 0;
};
/**
* 发送消息
*/
const sendMessage = (type: 'text' | 'interruptResume', content: string) => {
// 检查 WebSocket 连接状态
if (!socket.value || socket.value.readyState === WebSocket.CLOSED || socket.value.readyState === WebSocket.CLOSING) {
console.warn('WebSocket 连接已关闭,尝试重连...');
// 如果正在重连中,等待重连完成
if (reconnectAttempts.value > 0 && reconnectAttempts.value < MAX_RECONNECT_ATTEMPTS) {
ElMessage.warning(t('ai.chat.reconnecting'));
attemptReconnect();
return;
}
// 立即尝试重连
attemptReconnect();
ElMessage.error(t('ai.chat.connectionLost'));
return;
}
socket.value.send(
JSON.stringify({
type,
sessionId: currentSessionId.value,
content,
})
);
};
/**
* 获取当前连接状态
*/
const isConnected = () => {
return socket.value && socket.value.readyState === WebSocket.OPEN;
};
// 组件卸载时清理连接
onBeforeUnmount(() => {
cleanupSocket();
});
return {
initSocket,
sendMessage,
reconnectAttempts,
MAX_RECONNECT_ATTEMPTS,
};
}

View File

@@ -0,0 +1,160 @@
<template>
<div class="approval-interrupt border border-gray-200 dark:border-gray-700 rounded flex flex-col">
<!-- 紧凑头部 -->
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-800/50">
<div class="flex items-center gap-2">
<el-tag type="warning" size="small">{{ t('ai.interrupt.approval.title') }}</el-tag>
<span class="text-sm font-medium">{{ interruptData?.title }}</span>
</div>
<enum-tag v-if="isProcessed" :enums="InterruptAction" :value="currentAction" />
<el-tag v-else-if="hasPending" type="info" size="small">{{ t('ai.interrupt.approval.pendingSubmit') }}</el-tag>
<el-tag v-else type="warning" size="small">{{ t('ai.interrupt.approval.pendingApproval') }}</el-tag>
</div>
<div class="px-3 py-2 space-y-2 flex-1">
<!-- 描述信息 -->
<div v-if="interruptData?.description" class="text-xs text-gray-500 dark:text-gray-400">
{{ interruptData.description }}
</div>
<!-- 工具名一行带过 -->
<div v-if="interruptData?.toolInfo" class="text-xs text-gray-500 dark:text-gray-400">
<span>{{ t('ai.interrupt.approval.toolName') }}:</span>
<span class="font-mono text-blue-600 dark:text-blue-400 ml-1">{{ interruptData.toolInfo.name }}</span>
</div>
<!-- 执行参数固定高度常驻显示 -->
<div v-if="interruptData?.arguments" class="text-xs">
<div class="text-gray-400 text-xs mb-1">{{ t('ai.interrupt.approval.executionParams') }}</div>
<div class="h-25 overflow-y-auto">
<pre class="p-1.5 bg-white dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700 overflow-x-auto text-xs">{{
formatJson(interruptData.arguments)
}}</pre>
</div>
</div>
<!-- 待提交状态 -->
<div v-if="hasPending && !isProcessed" class="flex items-center gap-2 text-xs text-yellow-600 dark:text-yellow-400">
<span>{{ t('ai.interrupt.approval.selected') }}</span>
<enum-tag :enums="InterruptAction" :value="pendingResumeInfo.action" />
<span v-if="pendingResumeInfo.action === 'reject' && pendingResumeInfo.payload?.reason" class="truncate max-w-40" :title="pendingResumeInfo.payload.reason">
({{ pendingResumeInfo.payload.reason }})
</span>
</div>
<!-- 操作结果记录 -->
<div v-if="resumeInfo" class="flex items-center gap-2 text-xs">
<span class="text-gray-500 dark:text-gray-400">{{ t('ai.interrupt.approval.operationType') }}:</span>
<enum-tag :enums="InterruptAction" :value="resumeInfo.action" />
<span v-if="resumeInfo.action === 'reject' && resumeInfo.payload?.reason" class="text-gray-700 dark:text-gray-300 truncate max-w-40" :title="resumeInfo.payload.reason">
({{ resumeInfo.payload.reason }})
</span>
</div>
</div>
<!-- 操作按钮 -->
<div v-if="!readonly && !isProcessed && !hasPending" class="flex justify-end gap-2 px-3 py-2 border-t border-gray-100 dark:border-gray-800">
<el-button size="small" @click="handleApprove">{{ t('ai.interrupt.approval.approve') }}</el-button>
<el-button size="small" type="danger" @click="handleReject">{{ t('ai.interrupt.approval.reject') }}</el-button>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 审批类型中断组件
* 用于需要用户确认的高危操作场景
*/
import EnumValue from '@/common/Enum';
import { formatJson } from '@/common/utils/format';
import EnumTag from '@/components/enumtag/EnumTag.vue';
import { ElMessageBox } from 'element-plus';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { InternalMessage, InterruptActionEvent } from './types';
interface Props {
data: InternalMessage;
readonly?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
readonly: false,
});
const emit = defineEmits<{
action: [action: InterruptActionEvent];
}>();
const { t } = useI18n();
// 从 data 中提取常用字段
const interruptId = computed(() => props.data.actionId || props.data.extra?.actionId || '');
const turnId = computed(() => props.data.turnId || props.data.extra?.turnId || '');
const interruptData = computed(() => props.data.extra?.content);
const resumeInfo = computed(() => props.data.extra?.resumeInfo);
const pendingResumeInfo = computed(() => props.data.extra?.pendingResumeInfo);
const currentAction = computed(() => resumeInfo.value?.action || pendingResumeInfo.value?.action);
const isProcessed = computed(() => !!resumeInfo.value);
const hasPending = computed(() => !!pendingResumeInfo.value);
const interruptType = computed(() => props.data.extra?.type || '');
const InterruptAction = {
Approve: EnumValue.of('approve', 'ai.interrupt.action.approve').tagTypeSuccess(),
Reject: EnumValue.of('reject', 'ai.interrupt.action.reject').tagTypeDanger(),
};
/**
* 处理审批通过操作
*/
const handleApprove = () => {
handleAction('approve');
};
/**
* 处理审批拒绝操作,弹出输入框
*/
const handleReject = async () => {
try {
const { value: reason } = await ElMessageBox.prompt(t('ai.interrupt.approval.rejectReasonPlaceholder'), t('ai.interrupt.approval.rejectTitle'), {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
inputType: 'textarea',
inputPlaceholder: t('ai.interrupt.approval.rejectReasonPlaceholder'),
inputValidator: (value: string) => {
if (!value || !value.trim()) {
return t('ai.interrupt.approval.rejectReasonRequired');
}
return true;
},
});
// 用户输入了拒绝原因,提交操作
handleAction('reject', { reason: reason?.trim() });
} catch {
// 用户取消了操作,不做任何处理
}
};
/**
* 处理用户操作
* @param action 操作类型
* @param payload 额外数据
*/
const handleAction = (action: string, payload?: any) => {
emit('action', {
turnId: turnId.value || '',
interruptId: interruptId.value || '',
interruptType: interruptType.value || '',
action,
payload,
});
};
</script>
<style scoped>
.approval-interrupt {
@apply transition-all duration-300;
}
</style>

View File

@@ -0,0 +1,128 @@
<template>
<div class="confirmation-interrupt border border-gray-200 dark:border-gray-700 rounded flex flex-col">
<!-- 紧凑头部 -->
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-800/50">
<div class="flex items-center gap-2">
<el-tag type="primary" size="small">{{ t('ai.interrupt.confirmation.title') }}</el-tag>
<span class="text-sm font-medium">{{ interruptData?.title }}</span>
</div>
<enum-tag v-if="isProcessed" :enums="InterruptAction" :value="currentAction" />
<el-tag v-else-if="hasPending" type="info" size="small">待提交</el-tag>
<el-tag v-else type="warning" size="small">{{ t('ai.interrupt.confirmation.pendingConfirmation') }}</el-tag>
</div>
<div class="px-3 py-2 space-y-2 flex-1">
<!-- 描述信息 -->
<div v-if="interruptData?.description" class="text-xs text-gray-500 dark:text-gray-400">
{{ interruptData.description }}
</div>
<!-- 确认选项 -->
<div v-if="interruptData?.options" class="bg-blue-50 dark:bg-blue-900/20 rounded p-2 border border-blue-200 dark:border-blue-800">
<div class="text-xs font-medium text-blue-600 dark:text-blue-400 mb-1">{{ t('ai.interrupt.confirmation.pleaseSelect') }}</div>
<el-radio-group v-model="selectedOption" :disabled="readonly || isProcessed || hasPending">
<el-radio v-for="option in interruptData.options" :key="option.value" :value="option.value" class="block mb-1 text-xs">
{{ option.label }}
</el-radio>
</el-radio-group>
</div>
<!-- 操作结果记录 -->
<div v-if="resumeInfo" class="flex items-center gap-2 text-xs">
<span class="text-gray-500 dark:text-gray-400">{{ t('ai.interrupt.confirmation.operationType') }}:</span>
<enum-tag :enums="InterruptAction" :value="resumeInfo.action" />
<span v-if="resumeInfo.payload" class="text-gray-500 dark:text-gray-400 ml-1">{{ resumeInfo.payload }}</span>
</div>
</div>
<!-- 操作按钮 -->
<div v-if="!readonly && !isProcessed && !hasPending" class="flex justify-end gap-2 px-3 py-2 border-t border-gray-100 dark:border-gray-800">
<el-button size="small" type="primary" @click="handleAction('confirm', selectedOption)" :disabled="!selectedOption">
{{ t('ai.interrupt.confirmation.confirm') }}
</el-button>
<el-button size="small" @click="handleAction('cancel')">{{ t('ai.interrupt.confirmation.cancel') }}</el-button>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 确认类型中断组件
* 用于需要用户从多个选项中选择的场景
*/
import { EnumValue } from '@/common/Enum';
import EnumTag from '@/components/enumtag/EnumTag.vue';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { InternalMessage, InterruptActionEvent } from './types';
const { t } = useI18n();
interface Props {
data: InternalMessage;
readonly?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
readonly: false,
});
const emit = defineEmits<{
action: [action: InterruptActionEvent];
}>();
const selectedOption = ref<string>();
// 从 data 对象中提取常用字段
const interruptData = computed(() => props.data.extra?.content);
const interruptId = computed(() => props.data.actionId || props.data.extra?.actionId || '');
const turnId = computed(() => props.data.turnId || props.data.extra?.turnId || '');
const resumeInfo = computed(() => props.data.extra?.resumeInfo);
const pendingResumeInfo = computed(() => props.data.extra?.pendingResumeInfo);
const interruptType = computed(() => props.data.extra?.type || '');
// 根据 resumeInfo.action 计算当前状态
const currentAction = computed(() => resumeInfo.value?.action || pendingResumeInfo.value?.action);
// 判断是否已处理(有 resumeInfo 表示已处理)
const isProcessed = computed(() => !!resumeInfo.value);
const hasPending = computed(() => !!pendingResumeInfo.value);
// 从 pendingResumeInfo 恢复已选择的选项
watch(
() => pendingResumeInfo.value?.payload,
(payload: any) => {
if (payload && typeof payload === 'string') {
selectedOption.value = payload;
}
},
{ immediate: true }
);
const InterruptAction = {
Confirm: EnumValue.of('confirm', 'ai.interrupt.action.confirm').tagTypeSuccess(),
Cancel: EnumValue.of('cancel', 'ai.interrupt.action.cancel').tagTypeDanger(),
};
/**
* 处理用户操作
* @param action 操作类型
* @param payload 额外数据(如选中的选项值)
*/
const handleAction = (action: string, payload?: any) => {
emit('action', {
turnId: turnId.value || '',
interruptId: interruptId.value || '',
interruptType: interruptType.value || '',
action,
payload,
});
};
</script>
<style scoped>
.confirmation-interrupt {
@apply transition-all duration-300;
}
</style>

View File

@@ -0,0 +1,158 @@
<template>
<div class="generic-interrupt border border-gray-200 dark:border-gray-700 rounded flex flex-col">
<!-- 紧凑头部 -->
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-800/50">
<div class="flex items-center gap-2">
<el-tag type="info" size="small">{{ interruptData?.type || t('ai.interrupt.generic.interrupt') }}</el-tag>
<span class="text-sm font-medium">{{ interruptData?.title || t('ai.interrupt.generic.operationInterrupted') }}</span>
</div>
<el-tag v-if="isProcessed" :type="getActionTag(currentAction)" size="small">
{{ getActionText(currentAction) }}
</el-tag>
<el-tag v-else-if="hasPending" type="info" size="small">待提交</el-tag>
<el-tag v-else type="warning" size="small">{{ t('ai.interrupt.generic.pending') }}</el-tag>
</div>
<div class="px-3 py-2 space-y-2 flex-1">
<!-- 描述信息 -->
<div v-if="interruptData?.description" class="text-xs text-gray-500 dark:text-gray-400">
{{ interruptData.description }}
</div>
<!-- 操作结果记录 -->
<div v-if="resumeInfo" class="flex items-center gap-2 text-xs">
<span class="text-gray-500 dark:text-gray-400">{{ t('ai.interrupt.generic.operationType') }}:</span>
<el-tag :type="getActionTag(resumeInfo.action)" size="small">
{{ getActionText(resumeInfo.action) }}
</el-tag>
<span v-if="resumeInfo.action === 'reject' && resumeInfo.payload?.reason" class="text-gray-700 dark:text-gray-300 truncate max-w-40" :title="resumeInfo.payload.reason">
({{ resumeInfo.payload.reason }})
</span>
</div>
</div>
<!-- 操作按钮 -->
<div v-if="!readonly && !isProcessed && !hasPending" class="flex justify-end gap-2 px-3 py-2 border-t border-gray-100 dark:border-gray-800">
<el-button size="small" @click="handleAction('approve')">{{ t('ai.interrupt.generic.confirm') }}</el-button>
<el-button size="small" type="danger" @click="handleReject">{{ t('ai.interrupt.generic.reject') }}</el-button>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 通用中断组件
* 用于未注册特定类型的中断场景,作为降级方案
*/
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { ElMessageBox } from 'element-plus';
import type { InternalMessage, InterruptActionEvent } from './types';
const { t } = useI18n();
interface Props {
data: InternalMessage;
readonly?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
readonly: false,
});
const emit = defineEmits<{
action: [action: InterruptActionEvent];
}>();
// 从 data 对象中提取常用字段
const interruptData = computed(() => props.data.extra?.content);
const interruptId = computed(() => props.data?.actionId || props.data?.extra?.actionId || '');
const turnId = computed(() => props.data?.turnId || props.data?.extra?.turnId || '');
const resumeInfo = computed(() => props.data.extra?.resumeInfo);
const pendingResumeInfo = computed(() => props.data.extra?.pendingResumeInfo);
const interruptType = computed(() => props.data.extra?.type || '');
// 根据 resumeInfo.action 计算当前动作
const currentAction = computed(() => resumeInfo.value?.action || pendingResumeInfo.value?.action);
// 判断是否已处理(有 resumeInfo 表示已处理)
const isProcessed = computed(() => !!resumeInfo.value);
const hasPending = computed(() => !!pendingResumeInfo.value);
/**
* 处理用户操作
*/
const handleAction = (action: string, payload?: any) => {
emit('action', {
turnId: turnId.value || '',
interruptId: interruptId.value || '',
interruptType: interruptType.value || '',
action,
payload,
});
};
/**
* 获取操作类型对应的标签类型
*/
const getActionTag = (actionType: string): 'success' | 'danger' | 'info' => {
switch (actionType) {
case 'approve':
case 'confirm':
return 'success';
case 'reject':
case 'cancel':
return 'danger';
default:
return 'info';
}
};
/**
* 处理拒绝操作,弹出输入框填写原因
*/
const handleReject = async () => {
try {
const { value: reason } = await ElMessageBox.prompt(t('ai.interrupt.generic.rejectReasonPlaceholder'), t('ai.interrupt.generic.rejectTitle'), {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
inputType: 'textarea',
inputPlaceholder: t('ai.interrupt.generic.rejectReasonPlaceholder'),
inputValidator: (value: string) => {
if (!value || !value.trim()) {
return t('ai.interrupt.generic.rejectReasonRequired');
}
return true;
},
});
handleAction('reject', { reason: reason?.trim() });
} catch {
// 用户取消,不做处理
}
};
/**
* 获取操作类型的显示文本
*/
const getActionText = (action: string): string => {
switch (action) {
case 'approve':
return t('ai.interrupt.action.approve');
case 'reject':
return t('ai.interrupt.action.reject');
case 'confirm':
return t('ai.interrupt.action.confirm');
case 'cancel':
return t('ai.interrupt.action.cancel');
default:
return action;
}
};
</script>
<style scoped>
.generic-interrupt {
@apply transition-all duration-300;
}
</style>

View File

@@ -0,0 +1,188 @@
/**
* 中断组件扩展示例
*
* 本文件展示如何添加新的中断类型组件
*/
// ============================================
// 示例 1: 创建短信验证中断组件
// ============================================
/**
* 文件: SmsVerificationInterrupt.vue
*
* <template>
* <el-card class="sms-verification-interrupt">
* <template #header>
* <div class="flex items-center justify-between">
* <div class="flex items-center gap-2">
* <el-tag type="success" size="small">短信验证</el-tag>
* <span class="font-medium">{{ data.content?.title }}</span>
* </div>
* <el-tag :type="getStatusType(data.toolStatus)" size="small">
* {{ getStatusText(data.toolStatus) }}
* </el-tag>
* </div>
* </template>
*
* <div class="space-y-3">
* <div class="text-sm text-gray-600 dark:text-gray-400">
* {{ data.content?.description }}
* </div>
*
* <div v-if="data.content?.phone" class="bg-green-50 dark:bg-green-900/20 rounded p-3">
* <div class="text-xs font-medium text-green-600 dark:text-green-400 mb-2">验证码将发送至</div>
* <div class="text-lg font-mono text-green-700 dark:text-green-300">{{ data.content.phone }}</div>
* </div>
*
* <el-input
* v-model="verificationCode"
* placeholder="请输入6位验证码"
* maxlength="6"
* :disabled="readonly || data.toolStatus !== 'interrupted'"
* >
* <template #append>
* <el-button @click="sendSmsCode" :loading="sending">发送验证码</el-button>
* </template>
* </el-input>
* </div>
*
* <template #footer v-if="!readonly && data.toolStatus === 'interrupted'">
* <div class="flex justify-end gap-2">
* <el-button
* type="primary"
* size="small"
* @click="$emit('verify', data.interruptId, verificationCode)"
* :disabled="!verificationCode || verificationCode.length !== 6"
* >
* 验证
* </el-button>
* <el-button size="small" @click="$emit('cancel', data.interruptId)">取消</el-button>
* </div>
* </template>
* </el-card>
* </template>
*
* <script setup lang="ts">
* import { ref } from 'vue';
*
* interface Props {
* data: {
* interruptId: string;
* type: string;
* toolStatus: string;
* content: {
* title: string;
* description: string;
* phone?: string;
* };
* };
* readonly?: boolean;
* }
*
* const props = withDefaults(defineProps<Props>(), {
* readonly: false,
* });
*
* const emit = defineEmits<{
* verify: [interruptId: string, code: string];
* cancel: [interruptId: string];
* }>();
*
* const verificationCode = ref('');
* const sending = ref(false);
*
* const sendSmsCode = async () => {
* sending.value = true;
* // TODO: 调用发送验证码接口
* setTimeout(() => {
* sending.value = false;
* }, 1000);
* };
*
* const getStatusType = (status?: string) => {
* switch (status) {
* case 'verified': return 'success';
* case 'failed': return 'danger';
* case 'interrupted': return 'warning';
* default: return 'info';
* }
* };
*
* const getStatusText = (status?: string) => {
* switch (status) {
* case 'verified': return '已验证';
* case 'failed': return '验证失败';
* case 'interrupted': return '待验证';
* default: return status || '未知';
* }
* };
* </script>
*/
// ============================================
// 示例 2: 在 index.ts 中注册
// ============================================
/**
* 在 src/views/ai/interrupt/index.ts 中添加:
*
* import SmsVerificationInterrupt from './SmsVerificationInterrupt.vue';
*
* const interruptComponentMap = new Map<string, Component>([
* ['APPROVAL', ApprovalInterrupt],
* ['CONFIRMATION', ConfirmationInterrupt],
* ['SMS_VERIFICATION', SmsVerificationInterrupt], // 新增这一行
* ]);
*/
// ============================================
// 示例 3: 后端返回数据格式
// ============================================
/**
* {
* "extra": {
* "type": "SMS_VERIFICATION",
* "interruptId": "abc-123-def",
* "toolStatus": "interrupted",
* "content": {
* "title": "身份验证",
* "description": "为了保障账户安全,请完成短信验证",
* "phone": "138****8888"
* }
* }
* }
*/
// ============================================
// 示例 4: 处理验证事件
// ============================================
/**
* 在 AiChat.vue 或父组件中:
*
* <component
* :is="getInterruptComponent(internal.extra?.type)"
* :data="internal.extra"
* @verify="handleSmsVerification"
* @cancel="handleCancel"
* />
*
* const handleSmsVerification = async (interruptId: string, code: string) => {
* try {
* // 调用后端验证接口
* await aiApi.verifySmsCode.request({
* interruptId,
* code
* });
*
* // 更新本地状态
* updateInterruptStatus(interruptId, 'verified');
* } catch (error) {
* ElMessage.error('验证失败');
* }
* };
*/
export {};

View File

@@ -0,0 +1,74 @@
import type { Component } from 'vue';
import ApprovalInterrupt from './ApprovalInterrupt.vue';
import ConfirmationInterrupt from './ConfirmationInterrupt.vue';
import GenericInterrupt from './GenericInterrupt.vue';
import ParamCompletionInterrupt from './param-completion/index.vue';
/**
* 中断组件类型映射表
* key: interrupt type (如 'interrupt_approval', 'interrupt_confirmation' 等)
* value: 对应的 Vue 组件
*/
const interruptComponentMap = new Map<string, Component>([
['interrupt_approval', ApprovalInterrupt],
['interrupt_confirmation', ConfirmationInterrupt],
['interrupt_param_completion', ParamCompletionInterrupt],
]);
/**
* 默认中断组件(当 type 未注册时使用)
*/
const DEFAULT_INTERRUPT_COMPONENT = GenericInterrupt;
/**
* 注册新的中断组件类型
* @param type 中断类型标识(对应 internal.extra.type
* @param component Vue 组件
*
* @example
* registerInterruptComponent('CUSTOM_TYPE', CustomInterruptComponent)
*/
export function registerInterruptComponent(type: string, component: Component) {
interruptComponentMap.set(type.toLowerCase(), component);
console.log(`已注册中断组件类型: ${type}`);
}
/**
* 根据中断类型获取对应的组件
* @param type 中断类型标识
* @returns 对应的 Vue 组件,如果未找到则返回默认组件
*
* @example
* const Component = getInterruptComponent('APPROVAL')
*/
export function getInterruptComponent(type?: string): Component {
if (!type) {
return DEFAULT_INTERRUPT_COMPONENT;
}
const component = interruptComponentMap.get(type.toLowerCase());
return component || DEFAULT_INTERRUPT_COMPONENT;
}
/**
* 获取所有已注册的中断类型
* @returns 类型数组
*/
export function getRegisteredInterruptTypes(): string[] {
return Array.from(interruptComponentMap.keys());
}
/**
* 检查某个类型是否已注册
* @param type 中断类型
* @returns 是否已注册
*/
export function isInterruptTypeRegistered(type: string): boolean {
return interruptComponentMap.has(type.toLowerCase());
}
// 导出组件以便直接使用
export { ApprovalInterrupt, ParamCompletionInterrupt, ConfirmationInterrupt, GenericInterrupt };
// 导出类型定义
export type { InterruptActionEvent } from './types';

View File

@@ -0,0 +1,115 @@
<template>
<div class="db-param-input">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-2">
{{ t('ai.interrupt.paramCompletion.selectDbHint') }}
</div>
<!-- 使用 DbSelectTree 组件 -->
<DbSelectTree
v-model:db-id="dbValue.dbId"
v-model:inst-name="dbValue.instanceName"
v-model:db-name="dbValue.dbName"
v-model:tag-path="dbValue.tagPath"
v-model:db-type="dbValue.dbType"
:disabled="isConfirmed"
@select-db="onSelectDb"
/>
<!-- 已选中的数据库详细信息 -->
<div v-if="dbValue.dbId" class="mt-3 p-3 bg-primary-50 dark:bg-primary-900/20 rounded border border-primary-200 dark:border-primary-800">
<div class="flex items-center gap-2">
<SvgIcon :name="getDbDialect(dbValue.dbType)?.getInfo().icon || 'DataLine'" :size="20" />
<div class="flex-1">
<div class="font-medium text-sm">{{ dbValue.instanceName }} - {{ dbValue.dbName }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ t('ai.interrupt.paramCompletion.dbType') }}: {{ dbValue.dbType }}</div>
</div>
<el-icon v-if="isConfirmed" class="text-success">
<Check />
</el-icon>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { Check } from '@element-plus/icons-vue';
import DbSelectTree from '@/views/ops/db/component/DbSelectTree.vue';
import { getDbDialect } from '@/views/ops/db/dialect';
interface DbParamValue {
dbId: number;
dbName: string;
dbType: string;
instanceName: string;
tagPath: string;
}
interface Props {
params: any[];
readonly?: boolean;
isConfirmed?: boolean;
modelValue?: DbParamValue;
}
const props = withDefaults(defineProps<Props>(), {
readonly: false,
isConfirmed: false,
modelValue: () => ({
dbId: 0,
dbName: '',
dbType: '',
instanceName: '',
tagPath: '',
}),
});
const { t } = useI18n();
// 使用 defineModel 实现双向绑定
const dbValue = defineModel<DbParamValue>('modelValue', {
default: () => ({
dbId: 0,
dbName: '',
dbType: '',
instanceName: '',
tagPath: '',
}),
});
// 处理数据库选择
const onSelectDb = (params: any) => {
console.log('[DbParamInput] Database selected:', params);
};
// 检查是否有效
const isValid = () => {
return dbValue.value.dbId > 0;
};
// 获取参数值
const getValues = () => {
return {
id: dbValue.value.dbId,
params: { ...dbValue.value },
displayName: `${dbValue.value.instanceName} - ${dbValue.value.dbName}`,
};
};
// 获取需要缓存的参数名
const getCacheableParams = () => {
return props.params.filter((p: any) => p.cacheable === true).map((p: any) => p.param);
};
defineExpose({
isValid,
getValues,
getCacheableParams,
});
</script>
<style scoped>
.db-param-input {
padding: 0.5rem;
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<div class="generic-param-input">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-2">
{{ t('ai.interrupt.paramCompletion.enterParamsHint') }}
</div>
<el-form label-position="top" size="small">
<el-form-item v-for="param in params" :key="param.param" :label="param.name || param.param">
<el-input
v-model="paramValues[param.param]"
:placeholder="`请输入${param.name || param.param}`"
:disabled="readonly"
@input="onInput(param.param, $event)"
/>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
interface ParamInfo {
param: string;
name: string;
cacheable?: boolean;
}
interface Props {
params: ParamInfo[];
readonly?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
readonly: false,
});
const emit = defineEmits<{
change: [values: Record<string, any>];
}>();
const { t } = useI18n();
// 使用 defineModel 实现双向绑定
const paramValues = defineModel<Record<string, any>>('modelValue', {
default: {},
});
// 输入事件
const onInput = (param: string, value: any) => {
emit('change', { ...paramValues.value });
};
// 获取当前值
const getValues = () => ({ ...paramValues.value });
// 检查是否所有必填参数都有值
const isValid = () => {
return props.params.every((p) => {
const val = paramValues.value[p.param];
return val !== undefined && val !== '';
});
};
// 暴露方法给父组件
defineExpose({
getValues,
isValid,
});
</script>
<style scoped>
.generic-param-input {
padding: 0.5rem;
}
</style>

View File

@@ -0,0 +1,135 @@
<template>
<div class="machine-param-input">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-2">
{{ t('ai.interrupt.paramCompletion.selectMachineHint') }}
</div>
<!-- 使用 MachineSelectTree 组件 -->
<MachineSelectTree
v-model:auth-cert-name="machineValue.authCertName"
v-model:machine-id="machineValue.machineId"
v-model:machine-name="machineValue.machineName"
v-model:machine-ip="machineValue.machineIp"
v-model:machine-port="machineValue.machinePort"
v-model:username="machineValue.username"
v-model:tag-path="machineValue.tagPath"
:disabled="isConfirmed"
@select-machine="onSelectMachine"
/>
<!-- 已选中的机器详细信息 -->
<div
v-if="machineValue.authCertName || machineValue.machineId"
class="mt-3 p-3 bg-primary-50 dark:bg-primary-900/20 rounded border border-primary-200 dark:border-primary-800"
>
<div class="flex items-center gap-2">
<SvgIcon name="Monitor" :size="20" />
<div class="flex-1">
<div class="font-medium text-sm">{{ machineValue.machineName || '已选择机器' }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{ t('ai.interrupt.paramCompletion.machineIp') }}: {{ machineValue.machineIp || '-' }}:{{ machineValue.machinePort || '-' }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ t('ai.interrupt.paramCompletion.authCert') }}: {{ machineValue.authCertName }} ({{ machineValue.username || '-' }})
</div>
</div>
<SvgIcon v-if="isConfirmed" class="text-success" name="check" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import MachineSelectTree from '@/views/ops/machine/component/MachineSelectTree.vue';
interface MachineParamValue {
authCertName: string;
machineId: number;
machineName: string;
machineIp: string;
machinePort: number;
username: string;
tagPath: string;
}
interface Props {
params: any[];
readonly?: boolean;
isConfirmed?: boolean;
modelValue?: MachineParamValue;
}
const props = withDefaults(defineProps<Props>(), {
readonly: false,
isConfirmed: false,
modelValue: () => ({
authCertName: '',
machineId: 0,
machineName: '',
machineIp: '',
machinePort: 0,
username: '',
tagPath: '',
}),
});
const { t } = useI18n();
// 使用 defineModel 实现双向绑定
const machineValue = defineModel<MachineParamValue>('modelValue', {
default: () => ({
authCertName: '',
machineId: 0,
machineName: '',
machineIp: '',
machinePort: 0,
username: '',
tagPath: '',
}),
});
// 处理机器选择
const onSelectMachine = (params: any) => {
console.log('[MachineParamInput] Machine selected:', params);
};
// 检查是否有效
const isValid = () => {
return machineValue.value.machineId > 0;
};
// 获取参数值
const getValues = () => {
return {
id: machineValue.value.machineId,
params: {
authCertName: machineValue.value.authCertName,
machineId: machineValue.value.machineId,
machineName: machineValue.value.machineName,
machineIp: machineValue.value.machineIp,
machinePort: machineValue.value.machinePort,
username: machineValue.value.username,
tagPath: machineValue.value.tagPath,
},
displayName: `${machineValue.value.machineName} (${machineValue.value.machineIp})`,
};
};
// 获取需要缓存的参数名
const getCacheableParams = () => {
return props.params.filter((p: any) => p.cacheable === true).map((p: any) => p.param);
};
defineExpose({
isValid,
getValues,
getCacheableParams,
});
</script>
<style scoped>
.machine-param-input {
padding: 0.5rem;
}
</style>

View File

@@ -0,0 +1,224 @@
<template>
<div class="param-completion-interrupt border border-gray-200 dark:border-gray-700 rounded flex flex-col">
<!-- 紧凑头部 -->
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-800/50">
<div class="flex items-center gap-2">
<el-tag type="primary" size="small">{{ t('ai.interrupt.paramCompletion.title') }}</el-tag>
<span class="text-sm font-medium">{{ interruptData?.title || t('ai.interrupt.paramCompletion.completeParam') }}</span>
</div>
<!-- 根据操作类型显示不同状态 -->
<el-tag v-if="isProcessed && currentAction === 'complete'" type="success" size="small">{{ t('ai.interrupt.paramCompletion.completed') }}</el-tag>
<el-tag v-else-if="isProcessed && currentAction === 'cancel'" type="info" size="small">{{ t('ai.interrupt.action.cancel') }}</el-tag>
<el-tag v-else-if="hasPending" type="info" size="small">待提交</el-tag>
<el-tag v-else type="warning" size="small">{{ t('ai.interrupt.paramCompletion.pending') }}</el-tag>
</div>
<div class="px-3 py-2 space-y-2 flex-1">
<!-- 描述信息 -->
<div v-if="interruptData?.description" class="text-xs text-gray-500 dark:text-gray-400">
{{ interruptData.description }}
</div>
<!-- 工具名一行带过 -->
<div v-if="interruptData?.toolInfo" class="text-xs text-gray-500 dark:text-gray-400">
<span>{{ t('ai.interrupt.paramCompletion.toolName') }}:</span>
<span class="font-mono text-blue-600 dark:text-blue-400 ml-1">{{ interruptData.toolInfo.name }}</span>
</div>
<!-- 参数输入 -->
<component
:is="paramInputComponent"
v-if="paramInputComponent"
ref="paramInputRef"
v-model:model-value="paramInputValues"
:params="missingParams"
:readonly="isProcessed"
:is-confirmed="isProcessed"
@change="onParamChange"
/>
<div v-else class="text-center text-xs text-gray-500 dark:text-gray-400 py-2">
{{ t('ai.interrupt.paramCompletion.unsupportedType') }}: {{ paramType }}
</div>
</div>
<!-- 操作按钮 -->
<div class="flex justify-end gap-2 px-3 py-2 border-t border-gray-100 dark:border-gray-800">
<el-button size="small" @click="handleCancel" :disabled="isProcessed || hasPending">
{{ t('ai.interrupt.paramCompletion.cancel') }}
</el-button>
<el-button type="primary" size="small" @click="handleConfirm" :disabled="isProcessed || hasPending || !formValid">
{{ t('ai.interrupt.paramCompletion.confirm') }}
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, inject, markRaw, nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { InternalMessage, InterruptActionEvent } from '../types';
// 引入参数输入组件
import DbParamInput from './DbParamInput.vue';
import GenericParamInput from './GenericParamInput.vue';
import MachineParamInput from './MachineParamInput.vue';
const { t } = useI18n();
interface Props {
data: InternalMessage;
readonly?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
readonly: false,
});
const emit = defineEmits<{
action: [data: { turnId: string; interruptId: string; interruptType: string; action: string; payload: any }];
}>();
// 注入父组件提供的中断操作处理器,绕过 Vue 动态组件事件传递问题
const handleInterruptAction = inject<(action: InterruptActionEvent) => void>('handleInterruptAction');
// 从 data 中提取常用字段fallback 到 extra 中的字段,防止 actionId/turnId 顶层丢失)
const interruptData = computed(() => props.data.extra?.content);
const resumeInfo = computed(() => props.data.extra?.resumeInfo);
const pendingResumeInfo = computed(() => props.data.extra?.pendingResumeInfo);
const turnId = computed(() => props.data.turnId || props.data.extra?.turnId || '');
const interruptId = computed(() => props.data.actionId || props.data.extra?.actionId || '');
const interruptType = computed(() => props.data.extra?.type || '');
// 缺失参数列表
const missingParams = computed(() => {
return interruptData.value?.payload || [];
});
// 参数类型
const paramType = computed(() => {
return interruptData.value?.paramType || '';
});
// 参数输入组件映射
const paramInputComponents: Record<string, any> = {
db: markRaw(DbParamInput),
machine: markRaw(MachineParamInput),
// 后续可以添加更多类型
// redis: markRaw(RedisParamInput),
};
// 动态获取参数输入组件(默认为通用输入框)
const paramInputComponent = computed(() => {
const type = paramType.value.toLowerCase();
return paramInputComponents[type] || markRaw(GenericParamInput);
});
// 参数输入组件引用
const paramInputRef = ref<any>(null);
// 初始参数值(从 resumeInfo 或 pendingResumeInfo 恢复)
const paramInputValues = ref<Record<string, any>>({});
// 监听 resumeInfo / pendingResumeInfo 变化,更新 paramInputValues
watch(
() => resumeInfo.value?.payload?.params || pendingResumeInfo.value?.payload?.params,
(newParams: any) => {
if (newParams) {
paramInputValues.value = { ...newParams };
}
},
{ immediate: true, deep: true }
);
// 是否已处理(仅基于 resumeInfo
const isProcessed = computed(() => !!resumeInfo.value);
const hasPending = computed(() => !!pendingResumeInfo.value);
// 当前操作类型complete/cancel
const currentAction = computed(() => resumeInfo.value?.action || pendingResumeInfo.value?.action || '');
// 参数填写校验状态
const formValid = ref(false);
// 监听参数值变化,实时更新校验状态
watch(
paramInputValues,
() => {
formValid.value = paramInputRef.value?.isValid?.() ?? false;
},
{ deep: true }
);
// 参数变化回调
const onParamChange = (values: any) => {
console.log('[ParamCompletion] Param changed:', values);
};
// 处理确认操作
const handleConfirm = () => {
if (isProcessed.value) {
return;
}
// 实时校验
formValid.value = paramInputRef.value?.isValid?.() ?? false;
if (!formValid.value) {
return;
}
// 获取参数输入组件的值,优先使用 ref 获取,失败则回退到 paramInputValues
let inputValues = paramInputRef.value?.getValues?.();
if (!inputValues) {
inputValues = { params: { ...paramInputValues.value } };
}
// 构建 payload
const payload: any = {
...inputValues,
};
// 添加需要缓存的参数名
const cacheableParams = paramInputRef.value?.getCacheableParams?.() || [];
if (cacheableParams.length > 0) {
payload.caches = cacheableParams;
}
const actionData = {
turnId: turnId.value || '',
interruptId: interruptId.value || '',
interruptType: interruptType.value || '',
action: 'complete',
payload,
};
if (handleInterruptAction) {
handleInterruptAction(actionData);
} else {
emit('action', actionData);
}
};
// 处理取消操作
const handleCancel = () => {
if (isProcessed.value) {
return;
}
const actionData = {
turnId: turnId.value || '',
interruptId: interruptId.value || '',
interruptType: interruptType.value || '',
action: 'cancel',
payload: null,
};
if (handleInterruptAction) {
handleInterruptAction(actionData);
} else {
emit('action', actionData);
}
};
</script>
<style scoped>
.param-completion-interrupt {
transition: all 0.3s ease;
}
</style>

View File

@@ -0,0 +1,44 @@
/**
* 中断信息组件统一类型定义
*/
import type { SessionMessage } from '../api';
/**
* 内部消息类型(包含 extra 字段)
*/
export type InternalMessage = SessionMessage & {
codeId?: string;
extra?: {
type?: 'interrupt' | 'notification' | string;
content?: any;
toolStatus?: string;
interruptId?: string;
[key: string]: any;
};
};
/**
* 中断操作事件数据
*/
export interface InterruptActionEvent {
turnId: string; // 轮次Id
interruptId: string; // 中断ID
interruptType: string; // 中断类型(如 'APPROVAL', 'PARAM_COMPLETION' 等)
action: string; // 操作(如 'approve', 'reject', 'complete', 'cancel' 等)
payload?: any; // 操作携带的额外数据
}
/**
* 中断组件事件处理器类型
*/
export type InterruptActionHandler = (action: InterruptActionEvent) => void | Promise<void>;
/**
* 中断组件 Props 接口
* 所有中断组件必须遵循此接口
*/
export interface InterruptComponentProps {
data: InternalMessage; // 完整的内部消息对象
readonly?: boolean; // 是否只读模式
}

View File

@@ -0,0 +1,3 @@
export default {
AiAssistant: () => import('@/views/ai/AiAssistant.vue'),
};

View File

@@ -1,81 +1,113 @@
<template>
<div>
<el-drawer
:title="props.title"
v-model="visible"
:before-close="cancel"
size="50%"
body-class="!p-2"
header-class="!mb-2"
:destroy-on-close="true"
:close-on-click-modal="!props.instTaskId"
>
<template #header>
<DrawerHeader :header="title" :back="cancel" />
</template>
<el-drawer
:title="props.title"
v-model="visible"
:before-close="cancel"
size="50%"
body-class="!p-2"
header-class="!mb-2"
:destroy-on-close="true"
:close-on-click-modal="!props.instTaskId"
>
<template #header>
<DrawerHeader :header="title" :back="cancel" />
</template>
<div>
<el-divider content-position="left">{{ $t('flow.proc') }}</el-divider>
<el-descriptions :column="3" border>
<el-descriptions-item :span="1" :label="$t('flow.procdefName')">{{ procinst.procdefName }}</el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('flow.bizType')">
<enum-tag :enums="FlowBizType" :value="procinst.bizType"></enum-tag>
</el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('flow.initiator')">
<AccountInfo :username="procinst.creator || ''" />
</el-descriptions-item>
<el-tabs v-model="state.activeTab">
<el-tab-pane :label="$t('common.basic')" name="basic">
<div>
<el-divider content-position="left">{{ $t('flow.proc') }}</el-divider>
<el-descriptions :column="3" border>
<el-descriptions-item :span="1" :label="$t('flow.procdefName')">{{ procinst.procdefName }}</el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('flow.bizType')">
<enum-tag :enums="FlowBizType" :value="procinst.bizType"></enum-tag>
</el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('flow.initiator')">
<AccountInfo :username="procinst.creator || ''" />
</el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('flow.procinstStatus')">
<enum-tag :enums="ProcinstStatus" :value="procinst.status"></enum-tag>
</el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('flow.bizStatus')">
<enum-tag :enums="ProcinstBizStatus" :value="procinst.bizStatus"></enum-tag>
</el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('flow.startingTime')">{{ formatDate(procinst.createTime) }}</el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('flow.procinstStatus')">
<enum-tag :enums="ProcinstStatus" :value="procinst.status"></enum-tag>
</el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('flow.bizStatus')">
<enum-tag :enums="ProcinstBizStatus" :value="procinst.bizStatus"></enum-tag>
</el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('flow.startingTime')">{{ formatDate(procinst.createTime) }}</el-descriptions-item>
<div v-if="procinst.duration">
<el-descriptions-item :span="1.5" :label="$t('flow.endTime')">{{ formatDate(procinst.endTime) }}</el-descriptions-item>
<el-descriptions-item :span="1.5" :label="$t('flow.duration')">{{ formatTime(procinst.duration) }}</el-descriptions-item>
</div>
<div v-if="procinst.duration">
<el-descriptions-item :span="1.5" :label="$t('flow.endTime')">{{ formatDate(procinst.endTime) }}</el-descriptions-item>
<el-descriptions-item :span="1.5" :label="$t('flow.duration')">{{ formatTime(procinst.duration) }}</el-descriptions-item>
</div>
<el-descriptions-item :span="3" :label="$t('common.remark')">
{{ procinst.remark }}
</el-descriptions-item>
</el-descriptions>
</div>
<el-descriptions-item :span="3" :label="$t('common.remark')">
{{ procinst.remark }}
</el-descriptions-item>
</el-descriptions>
</div>
<div>
<el-divider content-position="left">{{ $t('flow.bizInfo') }}</el-divider>
<component v-if="procinst.bizType" ref="keyValueRef" :is="bizComponents[procinst.bizType]" :procinst="procinst"> </component>
</div>
<div>
<el-divider content-position="left">{{ $t('flow.bizInfo') }}</el-divider>
<component v-if="procinst.bizType" ref="keyValueRef" :is="bizComponents[procinst.bizType]" :procinst="procinst"> </component>
</div>
<div v-if="props.instTaskId">
<el-divider content-position="left">{{ $t('flow.approveForm') }}</el-divider>
<el-form :model="form" label-width="auto">
<el-form-item prop="status" :label="$t('flow.approveResult')" required>
<el-select v-model="form.status">
<el-option :label="$t(ProcinstTaskStatus.Pass.label)" :value="ProcinstTaskStatus.Pass.value"> </el-option>
<el-option :label="$t(ProcinstTaskStatus.Back.label)" :value="ProcinstTaskStatus.Back.value"> </el-option>
<el-option :label="$t(ProcinstTaskStatus.Reject.label)" :value="ProcinstTaskStatus.Reject.value"> </el-option>
</el-select>
</el-form-item>
<el-form-item prop="remark" :label="$t('common.remark')">
<el-input v-model.trim="form.remark" :placeholder="$t('common.remark')" type="textarea" clearable></el-input>
</el-form-item>
</el-form>
</div>
<div v-if="props.instTaskId">
<el-divider content-position="left">{{ $t('flow.approveForm') }}</el-divider>
<el-form :model="form" label-width="auto">
<el-form-item prop="status" :label="$t('flow.approveResult')" required>
<el-select v-model="form.status">
<el-option :label="$t(ProcinstTaskStatus.Pass.label)" :value="ProcinstTaskStatus.Pass.value"> </el-option>
<el-option :label="$t(ProcinstTaskStatus.Back.label)" :value="ProcinstTaskStatus.Back.value"> </el-option>
<el-option :label="$t(ProcinstTaskStatus.Reject.label)" :value="ProcinstTaskStatus.Reject.value"> </el-option>
</el-select>
</el-form-item>
<el-form-item prop="remark" :label="$t('common.remark')">
<el-input v-model.trim="form.remark" :placeholder="$t('common.remark')" type="textarea" clearable></el-input>
</el-form-item>
</el-form>
</div>
<div v-if="flowDef">
<el-divider content-position="left">{{ $t('flow.approveNode') }}</el-divider>
<FlowDesign height="300px" disabled center :data="flowDef" />
</div>
<div v-if="flowDef" class="h-75">
<el-divider content-position="left">{{ $t('flow.approveNode') }}</el-divider>
<FlowDesign disabled center :data="flowDef" />
</div>
</el-tab-pane>
<template #footer v-if="props.instTaskId">
<el-button @click="cancel()">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="saveBtnLoading" @click="btnOk">{{ $t('common.confirm') }}</el-button>
</template>
</el-drawer>
</div>
<el-tab-pane :label="$t('flow.approvalRecord')" name="approvalRecord">
<el-timeline>
<el-timeline-item
v-for="task in procinst.procinstTasks"
:key="task.id"
:timestamp="formatDate(task.createTime)"
:type="getTaskStatusType(task.status)"
:icon="getTaskStatusIcon(task.status)"
size="large"
placement="top"
>
<el-card shadow="hover" class="hover:shadow-md transition-shadow">
<div>
<div class="flex justify-between">
<div>
<el-text tag="b" size="large">{{ task.nodeName }}</el-text> -
<el-text tag="b" size="large" type="primary">{{ task.handler || '/' }}</el-text>
</div>
<enum-tag :enums="ProcinstTaskStatus" :value="task.status" />
</div>
<div class="mt-2">
<el-text class="ml-5" tag="b">{{ task.remark }}</el-text>
</div>
</div>
</el-card>
</el-timeline-item>
</el-timeline>
</el-tab-pane>
</el-tabs>
<template #footer v-if="props.instTaskId">
<el-button @click="cancel()">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="saveBtnLoading" @click="btnOk">{{ $t('common.confirm') }}</el-button>
</template>
</el-drawer>
</template>
<script lang="ts" setup>
@@ -118,6 +150,7 @@ const bizComponents: any = shallowReactive({
});
const state = reactive({
activeTab: 'basic',
procinst: {} as any,
flowDef: null as any,
tasks: [] as any,
@@ -207,7 +240,30 @@ const btnOk = async () => {
const cancel = () => {
visible.value = false;
state.activeTab = 'basic';
emit('cancel');
};
const getTaskStatusIcon = (status: number) => {
if (status === ProcinstTaskStatus.Pass.value) {
return 'Check';
} else if (status === ProcinstTaskStatus.Back.value) {
return 'Close';
} else if (status === ProcinstTaskStatus.Reject.value) {
return 'Close';
}
return 'SemiSelect';
};
const getTaskStatusType = (status: number) => {
if (status === ProcinstTaskStatus.Pass.value) {
return 'success';
} else if (status === ProcinstTaskStatus.Back.value) {
return 'warning';
} else if (status === ProcinstTaskStatus.Reject.value) {
return 'danger';
}
return 'primary';
};
</script>
<style lang="scss"></style>

Some files were not shown because too many files have changed in this diff Show More