feat: 标签支持拖拽移动与机器支持执行命令查看

This commit is contained in:
meilin.huang
2024-04-21 19:35:58 +08:00
parent 44805ce580
commit ebe73e2f19
21 changed files with 595 additions and 118 deletions

View File

@@ -17,7 +17,7 @@
"countup.js": "^2.8.0",
"cropperjs": "^1.6.1",
"echarts": "^5.5.0",
"element-plus": "^2.7.0",
"element-plus": "^2.7.1",
"js-base64": "^3.7.7",
"jsencrypt": "^3.3.2",
"lodash": "^4.17.21",
@@ -35,7 +35,7 @@
"trzsz": "^1.1.5",
"uuid": "^9.0.1",
"vue": "^3.4.23",
"vue-router": "^4.3.1",
"vue-router": "^4.3.2",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-search": "^0.13.0",
@@ -57,7 +57,7 @@
"prettier": "^3.2.5",
"sass": "^1.75.0",
"typescript": "^5.4.5",
"vite": "^5.2.9",
"vite": "^5.2.10",
"vue-eslint-parser": "^9.4.2"
},
"browserslist": [

View File

@@ -34,6 +34,7 @@
<script lang="ts" setup>
import { toRefs, reactive, onMounted } from 'vue';
import { tagApi } from '../tag/api';
import { TagResourceTypeEnum } from '@/common/commonEnum';
//定义事件
const emit = defineEmits(['update:modelValue', 'changeTag', 'input']);
@@ -42,6 +43,10 @@ const props = defineProps({
selectTags: {
type: [Array<any>],
},
tagType: {
type: Number,
default: TagResourceTypeEnum.Tag.value,
},
});
const state = reactive({
@@ -54,7 +59,7 @@ const { tags } = toRefs(state);
onMounted(async () => {
state.selectTags = props.selectTags;
state.tags = await tagApi.getTagTrees.request({ type: -1 });
state.tags = await tagApi.getTagTrees.request({ type: props.tagType });
});
const changeTag = () => {

View File

@@ -6,12 +6,13 @@
:before-close="handleClose"
:close-on-click-modal="false"
:destroy-on-close="true"
width="800"
width="1000"
@open="getTermOps()"
>
<page-table ref="pageTableRef" :page-api="machineApi.termOpRecs" :lazy="true" height="100%" v-model:query-form="query" :columns="columns">
<template #action="{ data }">
<el-button @click="playRec(data)" loading-icon="loading" :loading="data.playRecLoding" type="primary" link>回放</el-button>
<el-button @click="showExecCmds(data)" type="primary" link>命令</el-button>
</template>
</page-table>
</el-dialog>
@@ -26,6 +27,17 @@
>
<div ref="playerRef" id="rc-player"></div>
</el-dialog>
<el-dialog :title="title" v-model="execCmdsDialogVisible" :close-on-click-modal="false" :destroy-on-close="true" width="500">
<el-table :data="state.execCmds" max-height="480" stripe size="small">
<el-table-column prop="cmd" label="命令" show-overflow-tooltip min-width="150px"> </el-table-column>
<el-table-column prop="time" label="执行时间" min-width="80" show-overflow-tooltip>
<template #default="scope">
{{ dateFormat(new Date(scope.row.time * 1000).toString()) }}
</template>
</el-table-column>
</el-table>
</el-dialog>
</div>
</template>
@@ -36,6 +48,7 @@ import * as AsciinemaPlayer from 'asciinema-player';
import 'asciinema-player/dist/bundle/asciinema-player.css';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { dateFormat } from '@/common/utils/date';
const props = defineProps({
visible: { type: Boolean },
@@ -50,7 +63,7 @@ const columns = [
TableColumn.new('createTime', '开始时间').isTime().setMinWidth(150),
TableColumn.new('endTime', '结束时间').isTime().setMinWidth(150),
TableColumn.new('recordFilePath', '文件路径').setMinWidth(200),
TableColumn.new('action', '操作').isSlot().setMinWidth(60).fixedRight().alignCenter(),
TableColumn.new('action', '操作').isSlot().setMinWidth(120).fixedRight().alignCenter(),
];
const playerRef = ref(null);
@@ -63,11 +76,12 @@ const state = reactive({
pageSize: 10,
machineId: 0,
},
playerDialogVisible: false,
execCmdsDialogVisible: false,
execCmds: [],
});
const { dialogVisible, query, playerDialogVisible } = toRefs(state);
const { dialogVisible, query, playerDialogVisible, execCmdsDialogVisible } = toRefs(state);
watch(props, async (newValue: any) => {
const visible = newValue.visible;
@@ -82,6 +96,11 @@ const getTermOps = async () => {
pageTableRef.value.search();
};
const showExecCmds = (data: any) => {
state.execCmds = JSON.parse(data.execCmds);
state.execCmdsDialogVisible = true;
};
let player: any = null;
const playRec = async (rec: any) => {

View File

@@ -37,6 +37,10 @@
@node-contextmenu="nodeContextmenu"
@node-click="treeNodeClick"
:default-expanded-keys="defaultExpandedKeys"
draggable
:allow-drop="allowDrop"
:allow-drag="allowDrag"
@node-drop="handleDrop"
:expand-on-click-node="false"
:filter-node-method="filterNode"
>
@@ -265,6 +269,82 @@ watch(
}
);
const allowDrop = (draggingNode: any, dropNode: any, type: any) => {
// 不允许同层级移动
if (type != 'inner') {
return false;
}
const dropNodeData = dropNode.data;
const draggingNodeData = draggingNode.data;
const dropTagType = dropNodeData.type;
const draggingTagType = draggingNodeData.type;
// 目标节点只允许为标签类型
if (dropTagType != TagResourceTypeEnum.Tag.value) {
return false;
}
// 目标节点下没有子节点
if (!dropNodeData.children) {
// 都为标签类型允许移动
if (dropTagType == draggingTagType && dropTagType == TagResourceTypeEnum.Tag.value) {
return true;
}
// 目标节点为标签,允许移动
if (dropTagType == TagResourceTypeEnum.Tag.value) {
return true;
}
return false;
}
for (let child of dropNodeData.children) {
// 当前移动节点若在目标节点下有相同code则不允许移动
if (draggingNodeData.code == child.code) {
return false;
}
const childType = child.type;
// 移动节点非标签类型时(资源标签),并且子节点存在标签类型,则不允许移动,因为资源只允许放在叶子标签类型下
if (draggingTagType != TagResourceTypeEnum.Tag.value && childType == TagResourceTypeEnum.Tag.value) {
return false;
}
// 移动节点为标签类型时(资源标签),并且子节点存在资源类型,则不允许移动
if (draggingTagType == TagResourceTypeEnum.Tag.value && childType != TagResourceTypeEnum.Tag.value) {
return false;
}
}
return true;
};
const allowDrag = (node: any) => {
const tagType = node.data.type;
return (
tagType == TagResourceTypeEnum.Tag.value ||
tagType == TagResourceTypeEnum.Db.value ||
tagType == TagResourceTypeEnum.Redis.value ||
tagType == TagResourceTypeEnum.Machine.value ||
tagType == TagResourceTypeEnum.Mongo.value
);
};
const handleDrop = async (draggingNode: any, dropNode: any) => {
const draggingData = draggingNode.data;
const dropData = dropNode.data;
try {
await tagApi.movingTag.request({
fromPath: draggingData.codePath,
toPath: dropData.codePath,
});
} finally {
search();
}
};
const parseTagPath = (tagPath: string) => {
if (!tagPath) {
return [];

View File

@@ -5,6 +5,7 @@ export const tagApi = {
getTagTrees: Api.newGet('/tag-trees'),
saveTagTree: Api.newPost('/tag-trees'),
delTagTree: Api.newDelete('/tag-trees/{id}'),
movingTag: Api.newPost('/tag-trees/moving'),
getResourceTagPaths: Api.newGet('/tag-trees/resources/{resourceType}/tag-paths'),
countTagResource: Api.newGet('/tag-trees/resources/count'),

View File

@@ -30,6 +30,7 @@ require (
github.com/robfig/cron/v3 v3.0.1 //
github.com/sijms/go-ora/v2 v2.8.10
github.com/stretchr/testify v1.8.4
github.com/veops/go-ansiterm v0.0.5
go.mongodb.org/mongo-driver v1.14.0 // mongo
golang.org/x/crypto v0.22.0 // ssh
golang.org/x/oauth2 v0.18.0
@@ -70,6 +71,7 @@ require (
github.com/kr/fs v0.1.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
@@ -77,6 +79,7 @@ require (
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.3 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect

View File

@@ -13,6 +13,7 @@ import (
"mayfly-go/pkg/logx"
"mayfly-go/pkg/model"
"mayfly-go/pkg/scheduler"
"mayfly-go/pkg/utils/jsonx"
"mayfly-go/pkg/utils/stringx"
"os"
"path"
@@ -76,7 +77,24 @@ func (m *machineTermOpAppImpl) TermConn(ctx context.Context, cli *mcm.Cli, wsCon
recorder = mcm.NewRecorder(f)
}
mts, err := mcm.NewTerminalSession(stringx.Rand(16), wsConn, cli, rows, cols, recorder)
createTsParam := &mcm.CreateTerminalSessionParam{
SessionId: stringx.Rand(16),
Cli: cli,
WsConn: wsConn,
Rows: rows,
Cols: cols,
Recorder: recorder,
LogCmd: cli.Info.EnableRecorder == 1,
}
// createTsParam.CmdFilterFuncs = []mcm.CmdFilterFunc{func(cmd string) error {
// if strings.HasPrefix(cmd, "rm") {
// return errorx.NewBiz("该命令已被禁用...")
// }
// return nil
// }}
mts, err := mcm.NewTerminalSession(createTsParam)
if err != nil {
return err
}
@@ -87,6 +105,7 @@ func (m *machineTermOpAppImpl) TermConn(ctx context.Context, cli *mcm.Cli, wsCon
if termOpRecord != nil {
now := time.Now()
termOpRecord.EndTime = &now
termOpRecord.ExecCmds = jsonx.ToStr(mts.GetExecCmds())
return m.Insert(ctx, termOpRecord)
}
return nil

View File

@@ -11,6 +11,7 @@ type MachineTermOp struct {
MachineId uint64 `json:"machineId"`
Username string `json:"username"`
RecordFilePath string `json:"recordFilePath"` // 回放文件路径
ExecCmds string `json:"execCmds"` // 执行的命令
CreateTime *time.Time `json:"createTime"`
CreatorId uint64 `json:"creatorId"`

View File

@@ -9,6 +9,7 @@ import (
type Terminal struct {
SshSession *ssh.Session
StdinPipe io.WriteCloser
StdoutReader *bufio.Reader
}
@@ -44,7 +45,7 @@ func (t *Terminal) Write(p []byte) (int, error) {
return t.StdinPipe.Write(p)
}
func (t *Terminal) ReadRune() (r rune, size int, err error) {
func (t *Terminal) ReadRune() (rune, int, error) {
return t.StdoutReader.ReadRune()
}

View File

@@ -0,0 +1,214 @@
package mcm
import (
"bytes"
"fmt"
"mayfly-go/pkg/errorx"
"strings"
"time"
"github.com/veops/go-ansiterm"
)
// 命令过滤函数若返回error则不执行该命令
type CmdFilterFunc func(cmd string) error
const (
CR = 0x0d // 单个字节 13 通常表示发送一个 CRCarriage Return回车字符 \r
EOT = 0x03 // 通过向标准输入发送单个字节 3 通常表示发送一个 EOTEnd of Transmission信号。EOT 是一种控制字符,在通信中用于指示数据传输的结束。发送 EOT 信号可以被用来终止当前的交互或数据传输
)
type ExecutedCmd struct {
Cmd string `json:"cmd"` // 执行的命令
Time int64 `json:"time"` // 执行时间戳
}
// TerminalHandler 终端处理器
type TerminalHandler struct {
Filters []CmdFilterFunc
ExecutedCmds []*ExecutedCmd // 已执行的命令
Parser *Parser
}
// PreWriteHandle 写入数据至终端前的处理,可进行过滤等操作
func (tf *TerminalHandler) PreWriteHandle(p []byte) error {
tf.Parser.AppendInputData(p)
// 不是回车命令,则表示命令未结束
if bytes.LastIndex(p, []byte{CR}) != 0 {
return nil
}
// time.Sleep(time.Millisecond * 30)
command := tf.Parser.GetCmd()
// 重置终端输入输出
tf.Parser.Reset()
if command == "" {
return nil
}
// 执行命令过滤器
for _, filter := range tf.Filters {
if err := filter(command); err != nil {
msg := fmt.Sprintf("\r\n%s%s", tf.Parser.Ps1, GetErrorContent(err.Error()))
return errorx.NewBiz(msg)
}
}
// 记录执行命令
tf.ExecutedCmds = append(tf.ExecutedCmds, &ExecutedCmd{
Cmd: command,
Time: time.Now().Unix(),
})
return nil
}
// HandleRead 处理从终端读取的数据进行操作
func (tf *TerminalHandler) HandleRead(data []byte) error {
tf.Parser.AppendOutData(data)
return nil
}
type Parser struct {
Output *ansiterm.ByteStream
InputData []byte
OutputData []byte
Ps1 string
vimState bool
commandState bool
}
func NewParser(width, height int) *Parser {
return &Parser{
Output: NewParserByteStream(width, height),
vimState: false,
commandState: true,
}
}
func NewParserByteStream(width, height int) *ansiterm.ByteStream {
screen := ansiterm.NewScreen(width, height)
stream := ansiterm.InitByteStream(screen, false)
stream.Attach(screen)
return stream
}
var (
enterMarks = [][]byte{
[]byte("\x1b[?1049h"),
[]byte("\x1b[?1048h"),
[]byte("\x1b[?1047h"),
[]byte("\x1b[?47h"),
[]byte("\x1b[?25l"),
}
exitMarks = [][]byte{
[]byte("\x1b[?1049l"),
[]byte("\x1b[?1048l"),
[]byte("\x1b[?1047l"),
[]byte("\x1b[?47l"),
}
screenMarks = [][]byte{
{0x1b, 0x5b, 0x4b, 0x0d, 0x0a},
{0x1b, 0x5b, 0x34, 0x6c},
}
)
func (p *Parser) AppendInputData(data []byte) {
if len(p.InputData) == 0 {
// 如 "root@cloud-s0ervh-hh87:~# " 获取前一段用户名等提示内容
p.Ps1 = p.GetOutput()
}
p.InputData = append(p.InputData, data...)
}
func (p *Parser) AppendOutData(data []byte) {
// 非编辑等状态,则追加输出内容
if !p.State(data) {
p.OutputData = append(p.OutputData, data...)
}
}
// GetCmd 获取执行的命令
func (p *Parser) GetCmd() string {
// "root@cloud-s0ervh-hh87:~# ls"
s := p.GetOutput()
// Ps1 = "root@cloud-s0ervh-hh87:~# "
return strings.TrimPrefix(s, p.Ps1)
}
func (p *Parser) Reset() {
p.Output.Listener.Reset()
p.OutputData = nil
p.InputData = nil
}
func (p *Parser) GetOutput() string {
p.Output.Feed(p.OutputData)
res := parseOutput(p.Output.Listener.Display())
if len(res) == 0 {
return ""
}
return res[len(res)-1]
}
func parseOutput(data []string) (output []string) {
for _, line := range data {
if strings.TrimSpace(line) != "" {
output = append(output, line)
}
}
return output
}
func (p *Parser) State(b []byte) bool {
if !p.vimState && IsEditEnterMode(b) {
if !isNewScreen(b) {
p.vimState = true
p.commandState = false
}
}
if p.vimState && IsEditExitMode(b) {
// 重置终端输入输出
p.Reset()
p.vimState = false
p.commandState = true
}
return p.vimState
}
func isNewScreen(p []byte) bool {
return matchMark(p, screenMarks)
}
func IsEditEnterMode(p []byte) bool {
return matchMark(p, enterMarks)
}
func IsEditExitMode(p []byte) bool {
return matchMark(p, exitMarks)
}
func matchMark(p []byte, marks [][]byte) bool {
for _, item := range marks {
if bytes.Contains(p, item) {
return true
}
}
return false
}
// GetErrorContent 包装返回终端错误消息
func GetErrorContent(msg string) string {
return fmt.Sprintf("\033[1;31m%s\033[0m", msg)
}
// GetErrorContentRn 包装返回终端错误消息, 并自动回车换行
func GetErrorContentRn(msg string) string {
return fmt.Sprintf("\r\n%s", GetErrorContent(msg))
}

View File

@@ -2,6 +2,7 @@ package mcm
import (
"context"
"fmt"
"io"
"mayfly-go/pkg/errorx"
"mayfly-go/pkg/logx"
@@ -27,6 +28,7 @@ type TerminalSession struct {
ID string
wsConn *websocket.Conn
terminal *Terminal
handler *TerminalHandler
recorder *Recorder
ctx context.Context
cancel context.CancelFunc
@@ -34,7 +36,21 @@ type TerminalSession struct {
tick *time.Ticker
}
func NewTerminalSession(sessionId string, ws *websocket.Conn, cli *Cli, rows, cols int, recorder *Recorder) (*TerminalSession, error) {
type CreateTerminalSessionParam struct {
SessionId string
Cli *Cli
WsConn *websocket.Conn
Rows int
Cols int
Recorder *Recorder
LogCmd bool // 是否记录命令
CmdFilterFuncs []CmdFilterFunc // 命令过滤器
}
func NewTerminalSession(param *CreateTerminalSessionParam) (*TerminalSession, error) {
sessionId, rows, cols := param.SessionId, param.Rows, param.Cols
cli, ws, recorder := param.Cli, param.WsConn, param.Recorder
terminal, err := NewTerminal(cli)
if err != nil {
return nil, err
@@ -52,12 +68,19 @@ func NewTerminalSession(sessionId string, ws *websocket.Conn, cli *Cli, rows, co
recorder.WriteHeader(rows-3, cols)
}
var handler *TerminalHandler
// 记录命令或者存在命令过滤器时,则创建对应的终端处理器
if param.LogCmd || param.CmdFilterFuncs != nil {
handler = &TerminalHandler{Parser: NewParser(120, 40), Filters: param.CmdFilterFuncs}
}
ctx, cancel := context.WithCancel(context.Background())
tick := time.NewTicker(time.Millisecond * time.Duration(60))
ts := &TerminalSession{
ID: sessionId,
wsConn: ws,
terminal: terminal,
handler: handler,
recorder: recorder,
ctx: ctx,
cancel: cancel,
@@ -66,7 +89,7 @@ func NewTerminalSession(sessionId string, ws *websocket.Conn, cli *Cli, rows, co
}
// 清除终端内容
WriteMessage(ws, "\033[2J\033[3J\033[1;1H")
ts.WriteToWs("\033[2J\033[3J\033[1;1H")
return ts, nil
}
@@ -89,6 +112,14 @@ func (r TerminalSession) Stop() {
}
}
// 获取终端会话执行的所有命令
func (r TerminalSession) GetExecCmds() []*ExecutedCmd {
if r.handler != nil {
return r.handler.ExecutedCmds
}
return []*ExecutedCmd{}
}
func (ts TerminalSession) readFromTerminal() {
for {
select {
@@ -116,20 +147,27 @@ func (ts TerminalSession) writeToWebsocket() {
case <-ts.ctx.Done():
return
case <-ts.tick.C:
if len(buf) > 0 {
if len(buf) == 0 {
continue
}
if ts.handler != nil {
ts.handler.HandleRead(buf)
}
s := string(buf)
if err := WriteMessage(ts.wsConn, s); err != nil {
if err := ts.WriteToWs(s); err != nil {
logx.Error("机器ssh终端发送消息至websocket失败: ", err)
return
}
// 如果记录器存在,则记录操作回放信息
if ts.recorder != nil {
ts.recorder.Lock()
ts.recorder.WriteData(OutPutType, s)
ts.recorder.Unlock()
}
buf = []byte{}
}
case data := <-ts.dataChan:
if data != utf8.RuneError {
p := make([]byte, utf8.RuneLen(data))
@@ -149,16 +187,15 @@ type WsMsg struct {
Rows int `json:"rows"`
}
// 接收客户端ws发送过来的消息并写入终端会话中。
// receiveWsMsg 接收客户端ws发送过来的消息并写入终端会话中。
func (ts *TerminalSession) receiveWsMsg() {
wsConn := ts.wsConn
for {
select {
case <-ts.ctx.Done():
return
default:
// read websocket msg
_, wsData, err := wsConn.ReadMessage()
_, wsData, err := ts.wsConn.ReadMessage()
if err != nil {
logx.Debugf("机器ssh终端读取websocket消息失败: %s", err.Error())
return
@@ -166,7 +203,7 @@ func (ts *TerminalSession) receiveWsMsg() {
// 解析消息
msgObj, err := parseMsg(wsData)
if err != nil {
WriteMessage(wsConn, "\r\n\033[1;31m提示: 消息内容解析失败...\033[0m")
ts.WriteToWs(GetErrorContentRn("消息内容解析失败..."))
logx.Error("机器ssh终端消息解析失败: ", err)
return
}
@@ -179,14 +216,25 @@ func (ts *TerminalSession) receiveWsMsg() {
}
}
case Data:
data := []byte(msgObj.Msg)
if ts.handler != nil {
if err := ts.handler.PreWriteHandle(data); err != nil {
ts.WriteToWs(err.Error())
// 发送命令终止指令
ts.terminal.Write([]byte{EOT})
continue
}
}
_, err := ts.terminal.Write([]byte(msgObj.Msg))
if err != nil {
logx.Debugf("机器ssh终端写入消息失败: %s", err)
logx.Errorf("写入数据至ssh终端失败: %s", err)
ts.WriteToWs(GetErrorContentRn(fmt.Sprintf("写入数据至ssh终端失败: %s", err.Error())))
}
case Ping:
_, err := ts.terminal.SshSession.SendRequest("ping", true, nil)
if err != nil {
WriteMessage(wsConn, "\r\n\033[1;31m提示: 终端连接已断开...\033[0m")
ts.WriteToWs(GetErrorContentRn("终端连接已断开..."))
return
}
}
@@ -194,8 +242,9 @@ func (ts *TerminalSession) receiveWsMsg() {
}
}
func WriteMessage(ws *websocket.Conn, msg string) error {
return ws.WriteMessage(websocket.TextMessage, []byte(msg))
// WriteToWs 将消息写入websocket连接
func (ts *TerminalSession) WriteToWs(msg string) error {
return ts.wsConn.WriteMessage(websocket.TextMessage, []byte(msg))
}
// 解析消息

View File

@@ -7,3 +7,8 @@ type TagTree struct {
Pid uint64 `json:"pid"`
}
type MovingTag struct {
FromPath string `json:"fromPath" binding:"required"`
ToPath string `json:"toPath" binding:"required"`
}

View File

@@ -20,44 +20,48 @@ type TagTree struct {
func (p *TagTree) GetTagTree(rc *req.Ctx) {
tagType := entity.TagType(rc.QueryInt("type"))
// 超管返回所有标签树
if rc.GetLoginAccount().Id == consts.AdminId {
var tagTrees vo.TagTreeVOS
p.TagTreeApp.ListByQuery(&entity.TagTreeQuery{Type: tagType}, &tagTrees)
rc.ResData = tagTrees.ToTrees(0)
accountTags := p.TagTreeApp.GetAccountTags(rc.GetLoginAccount().Id, &entity.TagTreeQuery{Type: tagType})
if len(accountTags) == 0 {
rc.ResData = []any{}
return
}
// 获取用户可以操作访问的标签路径
tagPaths := p.TagTreeApp.ListTagByAccountId(rc.GetLoginAccount().Id)
rootTag := make(map[string][]string, 0)
for _, accountTagPath := range tagPaths {
root := strings.Split(accountTagPath, "/")[0] + entity.CodePathSeparator
tags := rootTag[root]
tags = append(tags, accountTagPath)
rootTag[root] = tags
}
// 获取所有以root标签开头的子标签
var tags []*entity.TagTree
p.TagTreeApp.ListByQuery(&entity.TagTreeQuery{CodePathLikes: collx.MapKeys(rootTag), Type: tagType}, &tags)
allTags := p.complteTags(accountTags)
tagTrees := make(vo.TagTreeVOS, 0)
for _, tag := range tags {
tagPath := tag.CodePath
root := strings.Split(tagPath, "/")[0] + entity.CodePathSeparator
// 获取用户可操作的标签路径列表
accountTagPaths := rootTag[root]
for _, accountTagPath := range accountTagPaths {
if strings.HasPrefix(tagPath, accountTagPath) || strings.HasPrefix(accountTagPath, tagPath) {
for _, tag := range allTags {
tagTrees = append(tagTrees, tag)
break
}
}
rc.ResData = tagTrees.ToTrees(0)
}
rc.ResData = tagTrees.ToTrees(0)
// complteTags 补全标签信息,使其能构造为树结构
func (p *TagTree) complteTags(resourceTags []*entity.TagTree) []*entity.TagTree {
codePath2Tag := collx.ArrayToMap(resourceTags, func(tag *entity.TagTree) string {
return tag.CodePath
})
// 如tagPath = tag1/tag2/tag3/ 需要转为该路径所关联的所有标签路径即 tag1/ tag1/tag2/ tag1/tag2/tag3/三个相关联标签,才可以构造成一棵树
allTagPaths := make([]string, 0)
for _, tagPath := range collx.MapKeys(codePath2Tag) {
allTagPaths = append(allTagPaths, entity.GetAllCodePath(tagPath)...)
}
allTagPaths = collx.ArrayDeduplicate(allTagPaths)
notExistCodePaths := make([]string, 0)
for _, tagPath := range allTagPaths {
if _, ok := codePath2Tag[tagPath]; !ok {
notExistCodePaths = append(notExistCodePaths, tagPath)
}
}
// 未存在需要补全的标签信息,则返回
if len(notExistCodePaths) == 0 {
return resourceTags
}
var tags []*entity.TagTree
p.TagTreeApp.ListByQuery(&entity.TagTreeQuery{CodePaths: notExistCodePaths}, &tags)
// 完善需要补充的标签信息
return append(resourceTags, tags...)
}
func (p *TagTree) ListByQuery(rc *req.Ctx) {
@@ -82,6 +86,13 @@ func (p *TagTree) DelTagTree(rc *req.Ctx) {
biz.ErrIsNil(p.TagTreeApp.Delete(rc.MetaCtx, uint64(rc.PathParamInt("id"))))
}
func (p *TagTree) MovingTag(rc *req.Ctx) {
movingForm := &form.MovingTag{}
req.BindJsonAndValid(rc, movingForm)
rc.ReqParam = movingForm
biz.ErrIsNil(p.TagTreeApp.MovingTag(rc.MetaCtx, movingForm.FromPath, movingForm.ToPath))
}
// 获取用户可操作的标签路径
func (p *TagTree) TagResources(rc *req.Ctx) {
resourceType := int8(rc.PathParamInt("rtype"))

View File

@@ -77,6 +77,9 @@ type TagTree interface {
// ChangeParentTag 变更指定类型标签的父标签
ChangeParentTag(ctx context.Context, tagType entity.TagType, tagCode string, parentTagType entity.TagType, newParentCode string) error
// MovingTag 移动标签
MovingTag(ctx context.Context, fromTagPath string, toTagPath string) error
// DeleteTagByParam 删除标签,会删除该标签下所有子标签信息以及团队关联的标签信息
DeleteTagByParam(ctx context.Context, param *DelResourceTagParam) error
@@ -300,13 +303,7 @@ func (p *tagTreeAppImpl) SaveResourceTag(ctx context.Context, param *SaveResourc
}
}
// 删除team关联的标签
if err := p.tagTreeTeamRepo.DeleteByWheres(ctx, collx.M{"tag_id in ?": delTagIds}); err != nil {
return err
}
if err := p.DeleteByWheres(ctx, collx.M{"id in ?": delTagIds}); err != nil {
return err
}
return p.deleteByIds(ctx, delTagIds)
}
return nil
@@ -382,6 +379,34 @@ func (p *tagTreeAppImpl) ChangeParentTag(ctx context.Context, tagType entity.Tag
return nil
}
func (p *tagTreeAppImpl) MovingTag(ctx context.Context, fromTagPath string, toTagPath string) error {
fromTag := &entity.TagTree{CodePath: fromTagPath}
if err := p.GetBy(fromTag); err != nil {
return errorx.NewBiz("移动标签不存在")
}
toTag := &entity.TagTree{CodePath: toTagPath}
if err := p.GetBy(toTag); err != nil {
return errorx.NewBiz("目标标签不存在")
}
// 获取要移动标签的所有子标签
var childrenTags []*entity.TagTree
p.ListByQuery(&entity.TagTreeQuery{CodePathLike: fromTagPath}, &childrenTags)
// 获取父路径, 若fromTagPath=tag1/tag2/1|xxx则返回 tag1/tag2/
fromParentPath := entity.GetParentPath(fromTagPath, 0)
for _, childTag := range childrenTags {
// 替换path若childPath = tag1/tag2/1|xxx/11|yyy, toTagPath=tag3/tag4则替换为tag3/tag4/1|xxx/11|yyy/
childTag.CodePath = strings.Replace(childTag.CodePath, fromParentPath, toTagPath, 1)
if err := p.UpdateById(ctx, childTag); err != nil {
return err
}
}
return nil
}
func (p *tagTreeAppImpl) DeleteTagByParam(ctx context.Context, param *DelResourceTagParam) error {
// 获取资源编号对应的资源标签信息
var resourceTags []*entity.TagTree
@@ -410,16 +435,7 @@ func (p *tagTreeAppImpl) DeleteTagByParam(ctx context.Context, param *DelResourc
return item.Id
})
// 删除code_path下的所有子标签
if err := p.DeleteByWheres(ctx, collx.M{
"id in ?": childrenTagIds,
}); err != nil {
return err
}
// 删除team关联的标签
if err := p.tagTreeTeamRepo.DeleteByWheres(ctx, collx.M{"tag_id in ?": childrenTagIds}); err != nil {
return err
}
return p.deleteByIds(ctx, childrenTagIds)
}
return nil
@@ -484,14 +500,9 @@ func (p *tagTreeAppImpl) Delete(ctx context.Context, id uint64) error {
return errorx.NewBiz("您无权删除该标签")
}
return p.Tx(ctx, func(ctx context.Context) error {
return p.DeleteTagByParam(ctx, &DelResourceTagParam{
Id: id,
})
}, func(ctx context.Context) error {
// 删除该标签关联的团队信息
return p.tagTreeTeamRepo.DeleteByCond(ctx, &entity.TagTreeTeam{TagId: id})
})
}
func (p *tagTreeAppImpl) toTags(parentTags []*entity.TagTree, param *ResourceTag) []*entity.TagTree {
@@ -533,3 +544,14 @@ func (p *tagTreeAppImpl) toTags(parentTags []*entity.TagTree, param *ResourceTag
return tags
}
func (p *tagTreeAppImpl) deleteByIds(ctx context.Context, tagIds []uint64) error {
if err := p.DeleteByWheres(ctx, collx.M{
"id in ?": tagIds,
}); err != nil {
return err
}
// 删除team关联的标签
return p.tagTreeTeamRepo.DeleteByWheres(ctx, collx.M{"tag_id in ?": tagIds})
}

View File

@@ -6,10 +6,8 @@ type TagTreeQuery struct {
model.Model
Type TagType `json:"type"`
Code string `json:"code"` // 标识
Codes []string
CodePath string `json:"codePath"` // 标识路径
CodePaths []string
CodePaths []string // 标识路径
Name string `json:"name"` // 名称
CodePathLike string // 标识符路径模糊查询
CodePathLikes []string

View File

@@ -51,23 +51,7 @@ func (pt *TagTree) IsRoot() bool {
// GetParentPath 获取父标签路径, 如CodePath = test/test1/test2/ -> index = 0 => test/test1/ index = 1 => test/
func (pt *TagTree) GetParentPath(index int) string {
// 去除末尾的斜杠
codePath := strings.TrimSuffix(pt.CodePath, "/")
// 使用 Split 方法将路径按斜杠分割成切片
paths := strings.Split(codePath, "/")
// 确保索引在有效范围内
if index < 0 {
index = 0
} else if index > len(paths)-2 {
index = len(paths) - 2
}
// 按索引拼接父标签路径
parentPath := strings.Join(paths[:len(paths)-index-1], "/")
return parentPath + "/"
return GetParentPath(pt.CodePath, index)
}
// GetTagPath 获取标签段路径,不获取对应资源相关路径
@@ -141,6 +125,45 @@ func GetCodeByPath(tagType TagType, codePaths ...string) []string {
return collx.ArrayDeduplicate[string](codes)
}
// GetParentPath 获取父标签路径, 如CodePath = test/test1/test2/ -> index = 0 => test/test1/ index = 1 => test/
func GetParentPath(codePath string, index int) string {
// 去除末尾的斜杠
codePath = strings.TrimSuffix(codePath, CodePathSeparator)
// 使用 Split 方法将路径按斜杠分割成切片
paths := strings.Split(codePath, CodePathSeparator)
// 确保索引在有效范围内
if index < 0 {
index = 0
} else if index > len(paths)-2 {
index = len(paths) - 2
}
// 按索引拼接父标签路径
parentPath := strings.Join(paths[:len(paths)-index-1], CodePathSeparator)
return parentPath + CodePathSeparator
}
// GetAllCodePath 根据表情路径获取所有相关的标签codePath
func GetAllCodePath(codePath string) []string {
// 去除末尾的斜杠
codePath = strings.TrimSuffix(codePath, CodePathSeparator)
// 使用 Split 方法将路径按斜杠分割成切片
paths := strings.Split(codePath, CodePathSeparator)
var result []string
var partialPath string
for _, path := range paths {
partialPath += path + CodePathSeparator
result = append(result, partialPath)
}
return result
}
// TagPathSection 标签路径段
type TagPathSection struct {
Type TagType `json:"type"` // 类型: -1.普通标签; 其他值则为对应的资源类型

View File

@@ -0,0 +1,23 @@
package entity
import (
"fmt"
"strings"
"testing"
)
func TestGetPathSection(t *testing.T) {
fromPath := "tag1/tag2/1|xx/"
childPath := "tag1/tag2/1|xx/11|yy/"
toPath := "tag3/"
parentSection := GetTagPathSections(GetParentPath(fromPath, 0))
childSection := GetTagPathSections(childPath)
res := toPath + childSection[len(GetTagPathSections(fromPath)):].ToCodePath()
res1 := toPath + childSection[len(parentSection):].ToCodePath()
pPath := GetParentPath(fromPath, 0)
r := strings.Replace(childPath, pPath, toPath, 1)
r1 := strings.Replace(fromPath, pPath, toPath, 1)
fmt.Println(res, res1, r, r1)
}

View File

@@ -16,17 +16,13 @@ func newTagTreeRepo() repository.TagTree {
}
func (p *tagTreeRepoImpl) SelectByCondition(condition *entity.TagTreeQuery, toEntity any, orderBy ...string) {
sql := "SELECT DISTINCT(p.id), p.type, p.code, p.code_path, p.name, p.remark, p.create_time, p.creator, p.update_time, p.modifier FROM t_tag_tree p WHERE p.is_deleted = 0 "
sql := "SELECT p.id, p.type, p.code, p.code_path, p.name, p.remark, p.create_time, p.creator, p.update_time, p.modifier FROM t_tag_tree p WHERE p.is_deleted = 0 "
params := make([]any, 0)
if condition.Name != "" {
sql = sql + " AND p.name LIKE ?"
params = append(params, "%"+condition.Name+"%")
}
if condition.CodePath != "" {
sql = sql + " AND p.code_path = ?"
params = append(params, condition.CodePath)
}
if len(condition.Codes) > 0 {
sql = sql + " AND p.code IN (?)"
params = append(params, condition.Codes)

View File

@@ -26,6 +26,8 @@ func InitTagTreeRouter(router *gin.RouterGroup) {
req.NewDelete(":id", m.DelTagTree).Log(req.NewLogSave("标签树-删除信息")).RequiredPermissionCode("tag:del"),
req.NewPost("/moving", m.MovingTag).Log(req.NewLogSave("标签树-移动标签")).RequiredPermissionCode("tag:save"),
req.NewGet("/resources/:rtype/tag-paths", m.TagResources),
req.NewGet("/resources/count", m.CountTagResource),

View File

@@ -493,6 +493,7 @@ CREATE TABLE `t_machine_term_op` (
`machine_id` bigint NOT NULL COMMENT '机器id',
`username` varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT '登录用户名',
`record_file_path` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT '终端回放文件路径',
`exec_cmds` TEXT NULL COMMENT '执行的命令记录'
`creator_id` bigint unsigned DEFAULT NULL,
`creator` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
`create_time` datetime NOT NULL,

View File

@@ -144,3 +144,7 @@ UPDATE t_tag_tree SET `type` = 2 WHERE `type` = 100;
ALTER TABLE t_tag_tree DROP COLUMN pid;
ALTER TABLE t_tag_tree_team DROP COLUMN tag_path;
-- 新增记录执行命令字段
ALTER TABLE t_machine_term_op ADD exec_cmds TEXT NULL COMMENT '执行的命令记录';
ALTER TABLE t_machine_term_op CHANGE exec_cmds exec_cmds TEXT NULL COMMENT '执行的命令记录' AFTER record_file_path;