mirror of
https://github.com/lzh-1625/go_process_manager.git
synced 2025-10-04 23:52:53 +08:00
update
This commit is contained in:
@@ -4,34 +4,33 @@ var CF = new(configuration)
|
||||
|
||||
// 只支持 float64、int、int64、bool、string类型
|
||||
type configuration struct {
|
||||
LogLevel string `default:"debug" describe:"日志等级[debug,info]"`
|
||||
Listen string `default:":8797" describe:"监听端口"`
|
||||
StorgeType string `default:"sqlite" describe:"存储引擎[sqlite、es、bleve]"`
|
||||
EsUrl string `default:"" describe:"Elasticsearch url"`
|
||||
EsIndex string `default:"server_log_v1" describe:"Elasticsearch index"`
|
||||
EsUsername string `default:"" describe:"Elasticsearch用户名"`
|
||||
EsPassword string `default:"" describe:"Elasticsearch密码"`
|
||||
EsWindowLimit bool `default:"true" describe:"Es分页10000条限制"`
|
||||
FileSizeLimit float64 `default:"10.0" describe:"文件大小限制(MB)"`
|
||||
ProcessInputPrefix string `default:">" describe:"进程输入前缀"`
|
||||
ProcessRestartsLimit int `default:"2" describe:"进程重启次数限制"`
|
||||
ProcessMsgCacheLinesLimit int `default:"50" describe:"std进程缓存消息行数"`
|
||||
ProcessMsgCacheBufLimit int `default:"4096" describe:"pty进程缓存消息字节长度"`
|
||||
ProcessExpireTime int64 `default:"60" describe:"进程控制权过期时间(秒)"`
|
||||
PerformanceInfoListLength int `default:"30" describe:"性能信息存储长度"`
|
||||
PerformanceInfoInterval int `default:"60" describe:"监控获取间隔时间(秒)"`
|
||||
TerminalConnectTimeout int `default:"10" describe:"终端连接超时时间(分钟)"`
|
||||
UserPassWordMinLength int `default:"4" describe:"用户密码最小长度"`
|
||||
LogMinLenth int `default:"0" describe:"过滤日志最小长度"`
|
||||
LogHandlerPoolSize int `default:"10" describe:"日志处理并行数"`
|
||||
PprofEnable bool `default:"true" describe:"启用pprof分析工具"`
|
||||
KillWaitTime int `default:"5" describe:"kill信号等待时间(秒)"`
|
||||
TaskTimeout int `default:"60" describe:"任务执行超时时间(秒)"`
|
||||
TokenExpirationTime int64 `default:"720" describe:"token过期时间(小时)"`
|
||||
WsHealthCheckInterval int `default:"3" describe:"ws主动健康检查间隔(秒)"`
|
||||
CgroupPeriod int64 `default:"100000" describe:"CgroupPeriod"`
|
||||
CgroupSwapLimit bool `default:"false" describe:"cgroup swap限制"`
|
||||
CondWaitTime int `default:"30" describe:"长轮询等待时间(秒)"`
|
||||
PerformanceCapacityDisplay bool `default:"false" describe:"性能资源容量显示"`
|
||||
Tui bool `default:"-"`
|
||||
LogLevel string `default:"debug" describe:"日志等级[debug,info]"`
|
||||
Listen string `default:":8797" describe:"监听端口"`
|
||||
StorgeType string `default:"sqlite" describe:"存储引擎[sqlite、es、bleve]"`
|
||||
EsUrl string `default:"" describe:"Elasticsearch url"`
|
||||
EsIndex string `default:"server_log_v1" describe:"Elasticsearch index"`
|
||||
EsUsername string `default:"" describe:"Elasticsearch用户名"`
|
||||
EsPassword string `default:"" describe:"Elasticsearch密码"`
|
||||
EsWindowLimit bool `default:"true" describe:"Es分页10000条限制"`
|
||||
FileSizeLimit float64 `default:"10.0" describe:"文件大小限制(MB)"`
|
||||
ProcessInputPrefix string `default:">" describe:"进程输入前缀"`
|
||||
ProcessRestartsLimit int `default:"2" describe:"进程重启次数限制"`
|
||||
ProcessMsgCacheLinesLimit int `default:"50" describe:"std进程缓存消息行数"`
|
||||
ProcessMsgCacheBufLimit int `default:"4096" describe:"pty进程缓存消息字节长度"`
|
||||
ProcessExpireTime int64 `default:"60" describe:"进程控制权过期时间(秒)"`
|
||||
PerformanceInfoListLength int `default:"30" describe:"性能信息存储长度"`
|
||||
PerformanceInfoInterval int `default:"60" describe:"监控获取间隔时间(秒)"`
|
||||
TerminalConnectTimeout int `default:"10" describe:"终端连接超时时间(分钟)"`
|
||||
UserPassWordMinLength int `default:"4" describe:"用户密码最小长度"`
|
||||
LogMinLenth int `default:"0" describe:"过滤日志最小长度"`
|
||||
LogHandlerPoolSize int `default:"10" describe:"日志处理并行数"`
|
||||
PprofEnable bool `default:"true" describe:"启用pprof分析工具"`
|
||||
KillWaitTime int `default:"5" describe:"kill信号等待时间(秒)"`
|
||||
TaskTimeout int `default:"60" describe:"任务执行超时时间(秒)"`
|
||||
TokenExpirationTime int64 `default:"720" describe:"token过期时间(小时)"`
|
||||
WsHealthCheckInterval int `default:"3" describe:"ws主动健康检查间隔(秒)"`
|
||||
CgroupPeriod int64 `default:"100000" describe:"CgroupPeriod"`
|
||||
CgroupSwapLimit bool `default:"false" describe:"cgroup swap限制"`
|
||||
CondWaitTime int `default:"30" describe:"长轮询等待时间(秒)"`
|
||||
Tui bool `default:"-"`
|
||||
}
|
||||
|
@@ -45,12 +45,18 @@ func (p *procApi) DeleteNewProcess(ctx *gin.Context, req struct {
|
||||
func (p *procApi) KillProcess(ctx *gin.Context, req struct {
|
||||
Uuid int `form:"uuid" binding:"required"`
|
||||
}) (err error) {
|
||||
if !hasOprPermission(ctx, req.Uuid, eum.OperationStop) {
|
||||
return errors.New("not permission")
|
||||
}
|
||||
return logic.ProcessCtlLogic.KillProcess(req.Uuid)
|
||||
}
|
||||
|
||||
func (p *procApi) StartProcess(ctx *gin.Context, req struct {
|
||||
Uuid int `form:"uuid" binding:"required"`
|
||||
Uuid int `json:"uuid" binding:"required"`
|
||||
}) (err error) {
|
||||
if !hasOprPermission(ctx, req.Uuid, eum.OperationStart) {
|
||||
return errors.New("not permission")
|
||||
}
|
||||
prod, err := logic.ProcessCtlLogic.GetProcess(req.Uuid)
|
||||
if err != nil { // 进程不存在则创建
|
||||
proConfig, err := repository.ProcessRepository.GetProcessConfigById(req.Uuid)
|
||||
|
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -46,9 +47,15 @@ func (w *WsConnetInstance) Cancel() {
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // 允许所有跨域请求
|
||||
},
|
||||
}
|
||||
|
||||
func (w *wsApi) WebsocketHandle(ctx *gin.Context, req model.WebsocketHandleReq) (err error) {
|
||||
if !hasOprPermission(ctx, req.Uuid, eum.OperationTerminal) {
|
||||
return errors.New("not permission")
|
||||
}
|
||||
reqUser := getUserName(ctx)
|
||||
proc, err := logic.ProcessCtlLogic.GetProcess(req.Uuid)
|
||||
if err != nil {
|
||||
|
@@ -7,7 +7,6 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/google/shlex"
|
||||
"github.com/lzh-1625/go_process_manager/config"
|
||||
"github.com/lzh-1625/go_process_manager/internal/app/eum"
|
||||
"github.com/lzh-1625/go_process_manager/internal/app/model"
|
||||
"github.com/lzh-1625/go_process_manager/internal/app/repository"
|
||||
@@ -116,10 +115,8 @@ func (p *processCtlLogic) getProcessInfoList(processConfiglist []model.Process)
|
||||
pi.User = process.GetUserString()
|
||||
pi.Usage.Cpu = process.performanceStatus.cpu
|
||||
pi.Usage.Mem = process.performanceStatus.mem
|
||||
if config.CF.PerformanceCapacityDisplay {
|
||||
pi.Usage.CpuCapacity = float64(runtime.NumCPU()) * 100.0
|
||||
pi.Usage.MemCapacity = float64(utils.UnwarpIgnore(mem.VirtualMemory()).Total >> 10)
|
||||
}
|
||||
pi.Usage.CpuCapacity = float64(runtime.NumCPU()) * 100.0
|
||||
pi.Usage.MemCapacity = float64(utils.UnwarpIgnore(mem.VirtualMemory()).Total >> 10)
|
||||
pi.Usage.Time = process.performanceStatus.time
|
||||
pi.TermType = process.Type()
|
||||
pi.CgroupEnable = process.Config.cgroupEnable
|
||||
|
@@ -1,11 +1,7 @@
|
||||
package middle
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
|
||||
"github.com/lzh-1625/go_process_manager/internal/app/eum"
|
||||
"github.com/lzh-1625/go_process_manager/internal/app/repository"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -20,24 +16,3 @@ func RolePermission(needPermission eum.Role) func(ctx *gin.Context) {
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func OprPermission(op eum.OprPermission) func(ctx *gin.Context) {
|
||||
return func(ctx *gin.Context) {
|
||||
uuid, err := strconv.Atoi(ctx.Query("uuid"))
|
||||
if err != nil {
|
||||
rErr(ctx, -1, "Invalid parameters!", nil)
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
if v, ok := ctx.Get(eum.CtxRole); !ok || v.(eum.Role) <= eum.RoleAdmin {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
if !reflect.ValueOf(repository.PermissionRepository.GetPermission(ctx.GetString(eum.CtxUserName), uuid)).FieldByName(string(op)).Bool() {
|
||||
rErr(ctx, -1, "Insufficient permissions; please check your access rights!", nil)
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
@@ -43,7 +43,7 @@ func (p *waitCond) Trigger() {
|
||||
}
|
||||
|
||||
func (p *waitCond) WaitGetMiddel(c *gin.Context) {
|
||||
reqUser := c.GetHeader("token")
|
||||
reqUser := c.GetHeader("Uuid")
|
||||
defer p.timeMap.Store(reqUser, p.ts)
|
||||
if ts, ok := p.timeMap.Load(reqUser); !ok || ts.(int64) > p.ts {
|
||||
c.Next()
|
||||
|
@@ -9,7 +9,7 @@ type Process struct {
|
||||
Cwd string `gorm:"column:cwd" json:"cwd"`
|
||||
AutoRestart bool `gorm:"column:auto_restart" json:"autoRestart"`
|
||||
CompulsoryRestart bool `gorm:"column:compulsory_restart" json:"compulsoryRestart"`
|
||||
PushIds string `gorm:"column:push_ids" json:"pushIds"`
|
||||
PushIds string `gorm:"column:push_ids;type:json" json:"pushIds"`
|
||||
LogReport bool `gorm:"column:log_report" json:"logReport"`
|
||||
TermType eum.TerminalType `gorm:"column:term_type" json:"termType"`
|
||||
CgroupEnable bool `gorm:"column:cgroup_enable" json:"cgroupEnable"`
|
||||
|
@@ -58,16 +58,16 @@ func routePathInit(r *gin.Engine) {
|
||||
{
|
||||
wsGroup := apiGroup.Group("/ws")
|
||||
{
|
||||
wsGroup.GET("", middle.OprPermission(eum.OperationTerminal), bind(api.WsApi.WebsocketHandle, Query))
|
||||
wsGroup.GET("", bind(api.WsApi.WebsocketHandle, Query))
|
||||
wsGroup.GET("/share", bind(api.WsApi.WebsocketShareHandle, Query))
|
||||
}
|
||||
|
||||
processGroup := apiGroup.Group("/process")
|
||||
{
|
||||
processGroup.DELETE("", middle.OprPermission(eum.OperationStop), bind(api.ProcApi.KillProcess, Query))
|
||||
processGroup.DELETE("", bind(api.ProcApi.KillProcess, Query))
|
||||
processGroup.GET("", bind(api.ProcApi.GetProcessList, None))
|
||||
processGroup.GET("/wait", middle.ProcessWaitCond.WaitGetMiddel, bind(api.ProcApi.GetProcessList, None))
|
||||
processGroup.PUT("", middle.OprPermission(eum.OperationStart), bind(api.ProcApi.StartProcess, Query))
|
||||
processGroup.PUT("", bind(api.ProcApi.StartProcess, Body))
|
||||
processGroup.PUT("/all", bind(api.ProcApi.StartAllProcess, None))
|
||||
processGroup.DELETE("/all", bind(api.ProcApi.KillAllProcess, None))
|
||||
processGroup.POST("/share", middle.RolePermission(eum.RoleAdmin), bind(api.ProcApi.ProcessCreateShare, Body))
|
||||
|
49
resources/package-lock.json
generated
49
resources/package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"@vueup/vue-quill": "^1.2.0",
|
||||
"@vueuse/core": "^10.11.0",
|
||||
"@vueuse/integrations": "^10.11.0",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@yeger/vue-masonry-wall": "^5.0.14",
|
||||
"apexcharts": "^3.52.0",
|
||||
"axios": "^1.7.5",
|
||||
@@ -44,7 +45,10 @@
|
||||
"vue3-perfect-scrollbar": "^2.0.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuetify": "^3.6.14",
|
||||
"webfontloader": "^1.6.28"
|
||||
"webfontloader": "^1.6.28",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-attach": "^0.9.0",
|
||||
"xterm-addon-fit": "^0.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
@@ -2810,6 +2814,22 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-canvas": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/@xterm/addon-canvas/-/addon-canvas-0.7.0.tgz",
|
||||
"integrity": "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@yeger/debounce": {
|
||||
"version": "2.0.13",
|
||||
"resolved": "https://registry.npmmirror.com/@yeger/debounce/-/debounce-2.0.13.tgz",
|
||||
@@ -7371,6 +7391,33 @@
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xterm": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/xterm/-/xterm-5.3.0.tgz",
|
||||
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xterm-addon-attach": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmmirror.com/xterm-addon-attach/-/xterm-addon-attach-0.9.0.tgz",
|
||||
"integrity": "sha512-NykWWOsobVZPPK3P9eFkItrnBK9Lw0f94uey5zhqIVB1bhswdVBfl+uziEzSOhe2h0rT9wD0wOeAYsdSXeavPw==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/addon-attach instead.",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm-addon-fit": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmmirror.com/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz",
|
||||
"integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.1.tgz",
|
||||
|
@@ -18,6 +18,7 @@
|
||||
"@vueup/vue-quill": "^1.2.0",
|
||||
"@vueuse/core": "^10.11.0",
|
||||
"@vueuse/integrations": "^10.11.0",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@yeger/vue-masonry-wall": "^5.0.14",
|
||||
"apexcharts": "^3.52.0",
|
||||
"axios": "^1.7.5",
|
||||
@@ -46,7 +47,10 @@
|
||||
"vue3-perfect-scrollbar": "^2.0.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuetify": "^3.6.14",
|
||||
"webfontloader": "^1.6.28"
|
||||
"webfontloader": "^1.6.28",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-attach": "^0.9.0",
|
||||
"xterm-addon-fit": "^0.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
|
@@ -45,7 +45,6 @@ onMounted(() => {
|
||||
<component :is="currentLayout" v-if="isRouterLoaded">
|
||||
<router-view> </router-view>
|
||||
</component>
|
||||
<BackToTop />
|
||||
<Snackbar />
|
||||
</v-app>
|
||||
</template>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { ProcessItem } from "../types/process/process";
|
||||
import { ProcessConfig, ProcessItem } from "../types/process/process";
|
||||
import api from "./api";
|
||||
|
||||
export function getProcessList() {
|
||||
@@ -30,7 +30,7 @@ export function getContorl(uuid) {
|
||||
}
|
||||
|
||||
export function getProcessConfig(uuid) {
|
||||
return api.get("/process/config", { uuid }).then((res) => res);
|
||||
return api.get<ProcessConfig>("/process/config", { uuid }).then((res) => res);
|
||||
}
|
||||
|
||||
export function deleteProcessConfig(uuid) {
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { PushItem } from "../types/push/push";
|
||||
import api from "./api";
|
||||
|
||||
export function createPush(data) {
|
||||
@@ -5,7 +6,7 @@ export function createPush(data) {
|
||||
}
|
||||
|
||||
export function getPushList() {
|
||||
return api.get("/push/list", undefined).then((res) => res);
|
||||
return api.get<PushItem[]>("/push/list", undefined).then((res) => res);
|
||||
}
|
||||
|
||||
export function deletePush(id) {
|
||||
|
@@ -1,6 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ProcessItem } from "~/src/types/process/process";
|
||||
import { init } from "echarts";
|
||||
import TerminalPty from "./TerminalPty.vue";
|
||||
import { killProcess, startProcess } from "~/src/api/process";
|
||||
import { useSnackbarStore } from "~/src/stores/snackbarStore";
|
||||
import ProcessConfig from "./ProcessConfig.vue";
|
||||
let chartInstance;
|
||||
|
||||
const snackbarStore = useSnackbarStore();
|
||||
const initEChart = () => {
|
||||
props.data.usage.cpu = (props.data.usage.cpu ?? [0, 0]).map((num) =>
|
||||
parseFloat(num.toFixed(2))
|
||||
@@ -20,8 +27,8 @@ const initEChart = () => {
|
||||
},
|
||||
animationDuration: 2000,
|
||||
grid: {
|
||||
left: "3%",
|
||||
right: "4%",
|
||||
left: "0%",
|
||||
right: "0%", // 原来 4%,改成更大
|
||||
bottom: "3%",
|
||||
containLabel: true,
|
||||
},
|
||||
@@ -34,7 +41,7 @@ const initEChart = () => {
|
||||
yAxis: [
|
||||
{
|
||||
type: "value",
|
||||
name: " CPU(" + cpu + "%)",
|
||||
name: "CPU(" + cpu + "%)",
|
||||
min: 0, // 设置CPU的y轴最小值为10
|
||||
max: props.data.usage.cpuCapacity,
|
||||
minInterval: 0.1,
|
||||
@@ -46,7 +53,7 @@ const initEChart = () => {
|
||||
},
|
||||
{
|
||||
type: "value",
|
||||
name: " 内存(" + mem + "MB)",
|
||||
name: "内存(" + mem + "MB)",
|
||||
max: parseFloat((props.data.usage.memCapacity / 1024).toFixed(2)),
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
@@ -114,18 +121,63 @@ const initEChart = () => {
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(option);
|
||||
myChart.setOption(option);
|
||||
chartInstance = myChart;
|
||||
};
|
||||
|
||||
type WsHandle = {
|
||||
wsConnect: () => void;
|
||||
};
|
||||
const terminalComponent = ref<WsHandle | null>(null);
|
||||
|
||||
type ConfigHandle = {
|
||||
openConfigDialog: () => void;
|
||||
test: () => void;
|
||||
};
|
||||
const processConfigComponent = ref<ConfigHandle | null>(null);
|
||||
|
||||
const buttons = [
|
||||
{ label: "按钮1", action: () => console.log("按钮1点击") },
|
||||
{ label: "按钮2", action: () => console.log("按钮2点击") },
|
||||
{ label: "按钮3", action: () => console.log("按钮3点击") },
|
||||
{
|
||||
icon: "mdi-console",
|
||||
action: () => {
|
||||
terminalComponent.value?.wsConnect();
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "mdi-play",
|
||||
action: () => {
|
||||
startProcess(props.data.uuid).then((e) => {
|
||||
if (e.code === 0) {
|
||||
snackbarStore.showSuccessMessage("success");
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "mdi-stop",
|
||||
action: () => {
|
||||
killProcess(props.data.uuid).then((e) => {
|
||||
if (e.code === 0) {
|
||||
snackbarStore.showSuccessMessage("success");
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "mdi-pencil",
|
||||
action: () => {
|
||||
processConfigComponent.value?.openConfigDialog();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const handleResize = () => {
|
||||
chartInstance.resize();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initEChart();
|
||||
window.addEventListener("resize", handleResize);
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -137,9 +189,53 @@ const props = defineProps<{
|
||||
<div class="chart-container">
|
||||
<!-- 顶部:进程名字 + 菜单 -->
|
||||
<div class="header">
|
||||
<div class="top-left">{{ props.data.name }}</div>
|
||||
<div class="top-left">
|
||||
<v-icon
|
||||
color="green"
|
||||
v-if="props.data.state.state == 3 || props.data.state.state == 1"
|
||||
x-large
|
||||
style="float: left"
|
||||
>
|
||||
mdi-checkbox-marked-circle</v-icon
|
||||
>
|
||||
<v-icon
|
||||
color="red"
|
||||
v-if="props.data.state.state == 0"
|
||||
x-large
|
||||
style="float: left"
|
||||
>
|
||||
mdi-stop-circle</v-icon
|
||||
>
|
||||
<div v-if="props.data.state.state == 2" style="float: left">
|
||||
<v-tooltip top color="warning">
|
||||
<template>
|
||||
<v-icon color="yellow accent-4" x-large> mdi-alert-circle</v-icon>
|
||||
</template>
|
||||
<span>{{ props.data.state.info }}</span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
{{ props.data.name }}
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<button @click="">菜单</button>
|
||||
<v-menu bottom left>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click=""
|
||||
density="compact"
|
||||
class="px-1 min-w-0"
|
||||
v-bind="props"
|
||||
>
|
||||
<v-icon>mdi-dots-vertical</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list nav dense>
|
||||
<v-list-item @click=""> 获取控制权 </v-list-item>
|
||||
<v-list-item @click=""> 删除进程 </v-list-item>
|
||||
<v-list-item @click=""> 创建分享链接 </v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,12 +245,36 @@ const props = defineProps<{
|
||||
<!-- 底部:按钮组 + 时间 -->
|
||||
<div class="footer">
|
||||
<div class="bottom-left">
|
||||
<button v-for="(btn, idx) in buttons" :key="idx" @click="">
|
||||
{{ btn.label }}
|
||||
</button>
|
||||
<v-chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
style="border-color: grey; color: black"
|
||||
class="d-flex align-center"
|
||||
>
|
||||
<v-btn
|
||||
v-for="(btn, idx) in buttons"
|
||||
:key="idx"
|
||||
@click="btn.action"
|
||||
size="small"
|
||||
:icon="btn.icon"
|
||||
variant="text"
|
||||
density="comfortable"
|
||||
/>
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div class="bottom-right text-caption">
|
||||
<span>{{ props.data.startTime }}</span>
|
||||
</div>
|
||||
<div class="bottom-right">{{ props.data.startTime }}</div>
|
||||
</div>
|
||||
|
||||
<TerminalPty
|
||||
v-if="props.data.termType == 'pty'"
|
||||
:data="props.data"
|
||||
ref="terminalComponent"
|
||||
></TerminalPty>
|
||||
<TerminalPty v-else :data="props.data"></TerminalPty>
|
||||
<ProcessConfig :data="props.data" ref="processConfigComponent"></ProcessConfig>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -163,10 +283,8 @@ const props = defineProps<{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 260px; /* 可根据实际容器调整 */
|
||||
height: 250px; /* 可根据实际容器调整 */
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -175,7 +293,7 @@ const props = defineProps<{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
padding: 5px 5px;
|
||||
font-weight: bold;
|
||||
height: 30px; /* 顶部固定高度 */
|
||||
}
|
||||
@@ -183,7 +301,9 @@ const props = defineProps<{
|
||||
/* 中间图表自适应 */
|
||||
.chart {
|
||||
flex: 1; /* 占满剩余空间 */
|
||||
width: 100%;
|
||||
width: 90%;
|
||||
margin-left: 5%;
|
||||
margin-right: 5%;
|
||||
}
|
||||
|
||||
/* 底部 footer */
|
||||
|
224
resources/src/components/process/ProcessConfig.vue
Normal file
224
resources/src/components/process/ProcessConfig.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { getProcessConfig, putProcessConfig } from "~/src/api/process";
|
||||
import { getPushList } from "~/src/api/push";
|
||||
import { useSnackbarStore } from "~/src/stores/snackbarStore";
|
||||
import { ProcessConfig, ProcessItem } from "~/src/types/process/process";
|
||||
|
||||
const snackbarStore = useSnackbarStore();
|
||||
const dialog = ref(false);
|
||||
const configForm = ref<Partial<ProcessConfig>>({});
|
||||
const pushItems = ref<{ value: any; label: string }[]>([]);
|
||||
const pushSelectedValues = ref([]);
|
||||
|
||||
watch(
|
||||
pushSelectedValues,
|
||||
(newValues) => {
|
||||
configForm.value.pushIds = JSON.stringify(newValues);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const props = defineProps<{
|
||||
data: ProcessItem;
|
||||
}>();
|
||||
|
||||
defineExpose({
|
||||
openConfigDialog: () => {
|
||||
getConfig();
|
||||
initPushItem();
|
||||
dialog.value = true;
|
||||
},
|
||||
});
|
||||
|
||||
const getConfig = () => {
|
||||
getProcessConfig(props.data.uuid).then((e) => {
|
||||
// 使用 Object.assign 来更新响应式对象,而不是替换它
|
||||
if (e.data) {
|
||||
Object.assign(configForm.value, e.data);
|
||||
pushSelectedValues.value = JSON.parse(
|
||||
(e.data!.pushIds as string) == "" ? "[]" : (e.data!.pushIds as string)
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateJsonString = () => {
|
||||
configForm.value.pushIds = JSON.stringify(pushSelectedValues);
|
||||
};
|
||||
|
||||
const editConfig = () => {
|
||||
putProcessConfig(configForm.value).then((e) => {
|
||||
if (e.code === 0) {
|
||||
snackbarStore.showSuccessMessage("sucess");
|
||||
dialog.value = false; // 成功后通常会关闭对话框
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const initPushItem = () => {
|
||||
getPushList().then((resp) => {
|
||||
// 3. 更新 ref 的 .value
|
||||
if (resp.data) {
|
||||
pushItems.value = resp.data.map((e) => ({
|
||||
value: e.id,
|
||||
label: `${e.remark} [${e.id}]`,
|
||||
}));
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog v-model="dialog" width="700">
|
||||
<v-card>
|
||||
<v-card-title class="text-h5 grey lighten-2">
|
||||
<v-icon left>mdi-cog</v-icon>
|
||||
设置
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-container>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
label="进程名称"
|
||||
v-model="configForm.name"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
label="工作目录"
|
||||
v-model="configForm.cwd"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
label="启动命令"
|
||||
v-model="configForm.cmd"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
label="终端类型"
|
||||
disabled
|
||||
v-model="configForm.termType"
|
||||
:items="['pty', 'std']"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
></v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="pushSelectedValues"
|
||||
@change="updateJsonString"
|
||||
:items="pushItems"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
chips
|
||||
label="状态推送"
|
||||
multiple
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
></v-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-divider class="my-4"></v-divider>
|
||||
|
||||
<v-row align="center">
|
||||
<v-col cols="12" sm="3">
|
||||
<v-switch
|
||||
:disabled="props.data.state?.state === 1"
|
||||
v-model="configForm.cgroupEnable"
|
||||
label="资源限制"
|
||||
color="primary"
|
||||
hide-details
|
||||
></v-switch>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-text-field
|
||||
:disabled="
|
||||
!configForm.cgroupEnable || props.data.state?.state === 3
|
||||
"
|
||||
label="CPU 限制 (%)"
|
||||
type="number"
|
||||
v-model.number="configForm.cpuLimit"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-text-field
|
||||
:disabled="
|
||||
!configForm.cgroupEnable || props.data.state?.state === 3
|
||||
"
|
||||
label="内存限制 (MB)"
|
||||
type="number"
|
||||
v-model.number="configForm.memoryLimit"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-divider class="my-4"></v-divider>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-switch
|
||||
v-model="configForm.autoRestart"
|
||||
label="自动重启"
|
||||
color="primary"
|
||||
hide-details
|
||||
></v-switch>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-switch
|
||||
:disabled="!configForm.autoRestart"
|
||||
v-model="configForm.compulsoryRestart"
|
||||
label="强制重启"
|
||||
color="primary"
|
||||
hide-details
|
||||
></v-switch>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-switch
|
||||
v-model="configForm.logReport"
|
||||
label="日志上报"
|
||||
color="primary"
|
||||
hide-details
|
||||
></v-switch>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" color="grey-darken-1" @click="dialog = false">
|
||||
<v-icon left>mdi-close</v-icon>
|
||||
取消
|
||||
</v-btn>
|
||||
<v-btn variant="flat" color="primary" @click="editConfig">
|
||||
<v-icon left>mdi-check</v-icon>
|
||||
确认
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
167
resources/src/components/process/TerminalPty.vue
Normal file
167
resources/src/components/process/TerminalPty.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, onUnmounted } from "vue"; // 引入 watch, nextTick 和 onUnmounted
|
||||
import { useSnackbarStore } from "~/src/stores/snackbarStore";
|
||||
import { ProcessItem } from "~/src/types/process/process";
|
||||
import { Terminal } from "xterm";
|
||||
import { FitAddon } from "xterm-addon-fit";
|
||||
import { AttachAddon } from "xterm-addon-attach";
|
||||
import { CanvasAddon } from '@xterm/addon-canvas';
|
||||
import 'xterm/css/xterm.css';
|
||||
|
||||
const snackbarStore = useSnackbarStore();
|
||||
const dialog = ref(false);
|
||||
const props = defineProps<{
|
||||
data: ProcessItem;
|
||||
}>();
|
||||
|
||||
const xtermEl = ref<HTMLElement | null>(null);
|
||||
|
||||
let socket: WebSocket | null = null;
|
||||
let term: Terminal | null = null;
|
||||
const fitAddon = new FitAddon();
|
||||
|
||||
defineExpose({
|
||||
wsConnect: () => {
|
||||
dialog.value = true;
|
||||
},
|
||||
});
|
||||
|
||||
// 使用 watch 监听 dialog 的状态变化
|
||||
watch(dialog, (newValue) => {
|
||||
if (newValue) {
|
||||
nextTick(() => {
|
||||
initWebSocketPty();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const initWebSocketPty = () => {
|
||||
if (!xtermEl.value) {
|
||||
snackbarStore.showErrorMessage("终端容器初始化失败");
|
||||
return;
|
||||
}
|
||||
// 在这里计算初始尺寸更准确
|
||||
const initialCols = Math.floor(xtermEl.value.clientWidth / 9);
|
||||
const initialRows = Math.floor(xtermEl.value.clientHeight / 19);
|
||||
|
||||
const baseUrl = `ws://${window.location.hostname}:8797/api/ws`;
|
||||
const url = `${baseUrl}?uuid=${props.data.uuid}&token=${localStorage.getItem("token")}&cols=${initialCols}&rows=${initialRows}`;
|
||||
|
||||
initSocket(url);
|
||||
};
|
||||
|
||||
const initSocket = (url: string) => {
|
||||
socket = new WebSocket(url);
|
||||
|
||||
socket.onopen = () => {
|
||||
// WebSocket 连接成功后,初始化 Terminal
|
||||
initTerm();
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
snackbarStore.showErrorMessage("终端连接断开");
|
||||
};
|
||||
|
||||
socket.onerror = (err) => {
|
||||
snackbarStore.showErrorMessage("终端连接发生错误");
|
||||
console.error("WebSocket Error:", err);
|
||||
};
|
||||
};
|
||||
|
||||
const initTerm = () => {
|
||||
if (!socket || !xtermEl.value) return;
|
||||
|
||||
term = new Terminal({
|
||||
// rendererType: "canvas", // 已通过插件方式加载,此处无需设置
|
||||
convertEol: true,
|
||||
disableStdin: false,
|
||||
cursorBlink: true,
|
||||
theme: {
|
||||
foreground: "#ECECEC",
|
||||
cursor: "help",
|
||||
},
|
||||
});
|
||||
|
||||
const attachAddon = new AttachAddon(socket);
|
||||
|
||||
term.loadAddon(new CanvasAddon()); // 推荐先加载渲染器
|
||||
term.loadAddon(attachAddon);
|
||||
term.loadAddon(fitAddon);
|
||||
|
||||
term.open(xtermEl.value);
|
||||
|
||||
// 在打开后执行 fit() 来适配尺寸
|
||||
fitAddon.fit();
|
||||
term.focus();
|
||||
window.addEventListener("resize", handleResize);
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
fitAddon.fit();
|
||||
};
|
||||
|
||||
const wsClose = () => {
|
||||
dialog.value = false;
|
||||
cleanup();
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
if (term) {
|
||||
term.dispose();
|
||||
term = null;
|
||||
}
|
||||
if (socket) {
|
||||
socket.close();
|
||||
socket = null;
|
||||
}
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog
|
||||
fullscreen
|
||||
hide-overlay
|
||||
transition="dialog-bottom-transition"
|
||||
v-model="dialog"
|
||||
@update:modelValue="val => !val && cleanup()"
|
||||
>
|
||||
<v-card
|
||||
style="
|
||||
height: 100%;
|
||||
background-color: black;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
"
|
||||
>
|
||||
<v-toolbar
|
||||
dense
|
||||
dark
|
||||
color="blue-grey darken-4"
|
||||
style="height: 35px; flex-grow: 0"
|
||||
>
|
||||
<v-toolbar-title style="height: 100%"
|
||||
>{{ props.data.name }} ({{ props.data.termType }})</v-toolbar-title
|
||||
>
|
||||
<v-spacer></v-spacer>
|
||||
<v-toolbar-items style="height: 35px">
|
||||
<v-btn icon dense dark @click="wsClose">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar-items>
|
||||
</v-toolbar>
|
||||
<div id="xterm" ref="xtermEl" style="flex-grow: 1; height: 100%; width: 100%;"></div>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#xterm .terminal {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
@@ -1,34 +1,49 @@
|
||||
export interface ProcessItem {
|
||||
name: string;
|
||||
uuid: number;
|
||||
startTime: Date;
|
||||
user: string;
|
||||
usage: Usage;
|
||||
state: State;
|
||||
termType: TermType;
|
||||
cgroupEnable: boolean;
|
||||
memoryLimit: number | null;
|
||||
cpuLimit: number | null;
|
||||
name: string;
|
||||
uuid: number;
|
||||
startTime: Date;
|
||||
user: string;
|
||||
usage: Usage;
|
||||
state: State;
|
||||
termType: TermType;
|
||||
cgroupEnable: boolean;
|
||||
memoryLimit: number | null;
|
||||
cpuLimit: number | null;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
state: number;
|
||||
info: Info;
|
||||
state: number;
|
||||
info: Info;
|
||||
}
|
||||
|
||||
export enum Info {
|
||||
Empty = "",
|
||||
重启次数异常 = "重启次数异常",
|
||||
Empty = "",
|
||||
重启次数异常 = "重启次数异常",
|
||||
}
|
||||
|
||||
export enum TermType {
|
||||
Pty = "pty",
|
||||
Pty = "pty",
|
||||
}
|
||||
|
||||
export interface Usage {
|
||||
cpuCapacity: number;
|
||||
memCapacity: number;
|
||||
cpu: number[] | null;
|
||||
mem: number[] | null;
|
||||
time: string[] | null;
|
||||
cpuCapacity: number;
|
||||
memCapacity: number;
|
||||
cpu: number[] | null;
|
||||
mem: number[] | null;
|
||||
time: string[] | null;
|
||||
}
|
||||
|
||||
export interface ProcessConfig {
|
||||
uuid: number;
|
||||
name: string;
|
||||
cmd: string;
|
||||
cwd: string;
|
||||
autoRestart: boolean;
|
||||
compulsoryRestart: boolean;
|
||||
pushIds: number[] | string;
|
||||
logReport: boolean;
|
||||
termType: string;
|
||||
cgroupEnable: boolean;
|
||||
memoryLimit: null;
|
||||
cpuLimit: null;
|
||||
}
|
||||
|
8
resources/src/types/push/push.ts
Normal file
8
resources/src/types/push/push.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface PushItem {
|
||||
id: number;
|
||||
method: string;
|
||||
url: string;
|
||||
body: string;
|
||||
remark: string;
|
||||
enable: boolean;
|
||||
}
|
@@ -10,18 +10,41 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import ProcessCard from "@/components/process/ProcessCard.vue";
|
||||
import axios from "axios";
|
||||
import { getProcessList } from "~/src/api/process";
|
||||
import { ProcessItem } from "~/src/types/process/process";
|
||||
|
||||
const processData = ref<ProcessItem[]>();
|
||||
const uuid: string = crypto.randomUUID();
|
||||
|
||||
const initProcessData = () => {
|
||||
getProcessList().then((e) => {
|
||||
processData.value = e.data!;
|
||||
console.log(e.data);
|
||||
processData.value = e.data!.sort((a, b) => a.name.localeCompare(b.name));
|
||||
getProcessListWait();
|
||||
});
|
||||
};
|
||||
|
||||
var cancelTokenSource;
|
||||
const getProcessListWait = () => {
|
||||
cancelTokenSource = axios.CancelToken.source();
|
||||
axios
|
||||
.get("api/process/wait", {
|
||||
cancelToken: cancelTokenSource.token,
|
||||
headers: {
|
||||
Authorization: "bearer " + localStorage.getItem("token"),
|
||||
Uuid: uuid,
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
processData.value = response.data.data.sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
getProcessListWait();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("请求错误:", error);
|
||||
});
|
||||
};
|
||||
onMounted(() => {
|
||||
initProcessData();
|
||||
});
|
||||
@@ -32,17 +55,24 @@ onMounted(() => {
|
||||
display: flex;
|
||||
flex-wrap: wrap; /* 自动换行 */
|
||||
justify-content: space-between; /* 两边与中间间距均匀 */
|
||||
gap: 50px; /* 每个 div 之间的间距 */
|
||||
gap: 80px; /* 每个 div 之间的间距 */
|
||||
}
|
||||
|
||||
.responsive-box {
|
||||
flex: 1 1 300px; /* 最小宽度 500px */
|
||||
min-width: 300px; /* 强制最小宽度 */
|
||||
max-width: 100%; /* 不超过容器宽度 */
|
||||
background: #f5f5f5;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
flex: 1 1 300px; /* 最小宽度 300px */
|
||||
min-width: 300px;
|
||||
max-width: 100%;
|
||||
background: #ffffff; /* 改为白色背景 */
|
||||
border-radius: 16px; /* 圆角 */
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); /* 柔和阴影 */
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
padding: 10px; /* 内边距,让内容不贴边 */
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease; /* 交互动画 */
|
||||
}
|
||||
|
||||
/* 悬停效果 */
|
||||
.responsive-box:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
</style>
|
||||
|
Reference in New Issue
Block a user