add event page

This commit is contained in:
akrike
2025-12-04 23:31:19 +08:00
parent 5c3ddd5f9c
commit c06c010b19
21 changed files with 1060 additions and 20 deletions

View File

@@ -41,16 +41,26 @@ func initDb() {
func initConfiguration() {
defer func() {
if err := recover(); err != nil {
panic("config init fail")
log.Panic("config init fail", err)
}
}()
configKvMap := map[string]string{}
data, err := repository.ConfigRepository.GetAllConfig()
if err != nil {
panic(err)
}
for _, v := range data {
configKvMap[v.Key] = *v.Value
}
typeElem := reflect.TypeOf(config.CF).Elem()
valueElem := reflect.ValueOf(config.CF).Elem()
for i := 0; i < typeElem.NumField(); i++ {
typeField := typeElem.Field(i)
valueField := valueElem.Field(i)
value, err := repository.ConfigRepository.GetConfigValue(typeField.Name)
if err != nil {
value, ok := configKvMap[typeField.Name]
if !ok {
value = typeField.Tag.Get("default")
}
if value == "-" {

22
internal/app/api/event.go Normal file
View File

@@ -0,0 +1,22 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/lzh-1625/go_process_manager/internal/app/logic"
"github.com/lzh-1625/go_process_manager/internal/app/model"
)
type eventApi struct{}
var EventApi = new(eventApi)
func (e *eventApi) GetEventList(ctx *gin.Context, req model.EventListReq) any {
data, total, err := logic.EventLogic.Get(req)
if err != nil {
return err
}
return model.EventListResp{
Total: total,
Data: data,
}
}

View File

@@ -33,3 +33,7 @@ func (e *eventLogic) Create(name string, eventType eum.EventType, additionalKv .
log.Logger.Errorw("事件创建失败", "err", err)
}
}
func (e *eventLogic) Get(req model.EventListReq) ([]*model.Event, int64, error) {
return repository.EventRepository.GetList(req)
}

View File

@@ -2,6 +2,8 @@ package logic
import (
"context"
"errors"
"strconv"
"time"
"github.com/google/uuid"
@@ -35,12 +37,14 @@ func NewTaskJob(data model.Task) (*TaskJob, error) {
return tj, nil
}
func (t *TaskJob) Run(ctx context.Context) {
func (t *TaskJob) Run(ctx context.Context) (err error) {
if ctx.Value(eum.CtxTaskTraceId{}) == nil {
ctx = context.WithValue(ctx, eum.CtxTaskTraceId{}, uuid.NewString())
}
EventLogic.Create(t.TaskConfig.Name, eum.EventTaskStart, "traceId", ctx.Value(eum.CtxTaskTraceId{}).(string))
defer EventLogic.Create(t.TaskConfig.Name, eum.EventTaskStop, "traceId", ctx.Value(eum.CtxTaskTraceId{}).(string))
defer func() {
EventLogic.Create(t.TaskConfig.Name, eum.EventTaskStop, "traceId", ctx.Value(eum.CtxTaskTraceId{}).(string), "success", strconv.FormatBool(err == nil))
}()
t.Running = true
middle.TaskWaitCond.Trigger()
defer func() {
@@ -48,11 +52,12 @@ func (t *TaskJob) Run(ctx context.Context) {
middle.TaskWaitCond.Trigger()
}()
var ok bool
var proc *ProcessBase
// 判断条件是否满足
if t.TaskConfig.Condition == eum.TaskCondPass {
if t.TaskConfig.Condition == eum.TaskCondPass || t.TaskConfig.ProcessId == 0 {
ok = true
} else {
proc, err := ProcessCtlLogic.GetProcess(t.TaskConfig.OperationTarget)
proc, err = ProcessCtlLogic.GetProcess(t.TaskConfig.OperationTarget)
if err != nil {
return
}
@@ -63,7 +68,7 @@ func (t *TaskJob) Run(ctx context.Context) {
return
}
proc, err := ProcessCtlLogic.GetProcess(t.TaskConfig.OperationTarget)
proc, err = ProcessCtlLogic.GetProcess(t.TaskConfig.OperationTarget)
if err != nil {
log.Logger.Debugw("不存在该进程,结束任务")
return
@@ -73,12 +78,14 @@ func (t *TaskJob) Run(ctx context.Context) {
log.Logger.Infow("任务开始执行")
if !OperationHandle[t.TaskConfig.Operation](t.TaskConfig, proc) {
log.Logger.Warnw("任务执行失败")
err = errors.New("task execute failed")
return
}
log.Logger.Infow("任务执行成功", "target", t.TaskConfig.OperationTarget)
if t.TaskConfig.NextId != nil {
nextTask, err := TaskLogic.getTaskJob(*t.TaskConfig.NextId)
var nextTask *TaskJob
nextTask, err = TaskLogic.getTaskJob(*t.TaskConfig.NextId)
if err != nil {
log.Logger.Errorw("无法获取到下一个节点,结束任务", "nextId", t.TaskConfig.NextId)
return
@@ -92,11 +99,12 @@ func (t *TaskJob) Run(ctx context.Context) {
log.Logger.Errorw("下一个节点已在运行,结束任务", "nextId", t.TaskConfig.NextId)
return
}
nextTask.Run(ctx)
err = nextTask.Run(ctx)
}
} else {
log.Logger.Infow("任务流结束")
}
return
}
func (t *TaskJob) InitCronHandle() error {

View File

@@ -17,3 +17,17 @@ type Event struct {
func (*Event) TableName() string {
return "event"
}
type EventListReq struct {
Page int `form:"page"`
Size int `form:"size"`
StartTime int64 `form:"startTime"`
EndTime int64 `form:"endTime"`
Type eum.EventType `form:"type"`
Name string `form:"name"`
}
type EventListResp struct {
Total int64 `json:"total"`
Data []*Event `json:"data"`
}

View File

@@ -19,6 +19,11 @@ func (c *configRepository) GetConfigValue(key string) (string, error) {
return *data.Value, err
}
func (c *configRepository) GetAllConfig() ([]*model.Config, error) {
data, err := query.Config.Select(query.Config.Value).Find()
return data, err
}
func (c *configRepository) SetConfigValue(key, value string) error {
config := model.Config{Key: key}
updateData := model.Config{Value: &value}

View File

@@ -1,6 +1,9 @@
package repository
import (
"context"
"time"
"github.com/lzh-1625/go_process_manager/internal/app/model"
"github.com/lzh-1625/go_process_manager/internal/app/repository/query"
)
@@ -12,3 +15,21 @@ var EventRepository = new(eventRepository)
func (e *eventRepository) Create(event model.Event) error {
return query.Event.Create(&event)
}
func (e *eventRepository) GetList(req model.EventListReq) ([]*model.Event, int64, error) {
tx := query.Event.WithContext(context.TODO())
if req.StartTime != 0 {
tx = tx.Where(query.Event.CreatedTime.Gte(time.Unix(req.StartTime, 0)))
}
if req.EndTime != 0 {
tx = tx.Where(query.Event.CreatedTime.Lte(time.Unix(req.EndTime, 0)))
}
if req.Type != "" {
tx = tx.Where(query.Event.Type.Eq(string(req.Type)))
}
if req.Name != "" {
tx = tx.Where(query.Event.Name.Like("%" + req.Name + "%"))
}
return tx.Order(query.Event.CreatedTime.Desc()).FindByPage((req.Page-1)*req.Size, req.Size)
}

View File

@@ -122,6 +122,11 @@ func routePathInit(r *gin.Engine) {
fileGroup.GET("", bind(api.FileApi.FileReadHandler, Query))
}
eventGroup := apiGroup.Group("/event").Use(middle.RolePermission(eum.RoleAdmin))
{
eventGroup.GET("", bind(api.EventApi.GetEventList, Query))
}
permissionGroup := apiGroup.Group("/permission").Use(middle.RolePermission(eum.RoleRoot))
{
permissionGroup.GET("/list", bind(api.PermissionApi.GetPermissionList, Query))
@@ -138,7 +143,7 @@ func routePathInit(r *gin.Engine) {
{
configGroup.GET("", bind(api.ConfigApi.GetSystemConfiguration, None))
configGroup.PUT("", bind(api.ConfigApi.SetSystemConfiguration, None))
configGroup.GET("/reload", bind(api.ConfigApi.LogConfigReload, None))
configGroup.PUT("/reload", bind(api.ConfigApi.LogConfigReload, None))
}
}
}

View File

@@ -0,0 +1,7 @@
import api from "./api";
import type { EventListReq, EventListResp } from "../types/event/event";
export function getEventList(params: EventListReq) {
return api.get<EventListResp>("/event", params).then((res) => res);
}

View File

@@ -12,7 +12,7 @@ const props = defineProps({
<v-list nav dense>
<template v-for="menuArea in props.menu" :key="menuArea.key">
<div v-if="menuArea.key || menuArea.text" class="pa-1 mt-2 text-overline">
{{ menuArea.text }}
{{ menuArea.key ? $t(menuArea.key) : menuArea.text }}
</div>
<template v-if="menuArea.items">
<template v-for="menuItem in menuArea.items" :key="menuItem.key">
@@ -28,7 +28,7 @@ const props = defineProps({
<Icon class="mx-2 mr-5" width="20" :icon="menuItem.icon" />
</template>
<v-list-item-title
v-text="$t(menuItem.key)"
v-text="menuItem.key ? $t(menuItem.key) : menuItem.text"
class="font-weight-bold"
></v-list-item-title>
</v-list-item>
@@ -40,7 +40,7 @@ const props = defineProps({
<Icon class="mx-2 mr-5" width="20" :icon="menuItem.icon" />
</template>
<v-list-item-title
v-text="menuItem.text"
v-text="menuItem.key ? $t(menuItem.key) : menuItem.text"
class="font-weight-bold"
></v-list-item-title>
</v-list-item>
@@ -57,7 +57,7 @@ const props = defineProps({
<Icon class="mx-2 mr-5" width="20" :icon="subMenuItem.icon" />
</template>
<v-list-item-title
v-text="subMenuItem.text"
v-text="subMenuItem.key ? $t(subMenuItem.key) : subMenuItem.text"
class="font-weight-bold"
></v-list-item-title>
</v-list-item>

View File

@@ -5,7 +5,14 @@ export default [
key: "menu.log",
link: "/log",
},
{
icon: "mdi-bell-ring",
name: "event-page",
key: "menu.event",
link: "/event",
},
];

View File

@@ -5,5 +5,11 @@ export default [
text: "系统设置",
link: "/settings",
},
{
icon: "mdi-bell-ring",
key: "menu.push",
text: "推送管理",
link: "/settings/push",
},
];

View File

@@ -27,34 +27,42 @@ export default {
},
{
text: "process",
key: "menu.group.process",
items: menuProcess,
},
{
text: "task",
key: "menu.group.task",
items: menuTask,
},
{
text: "log",
key: "menu.group.log",
items: menuLog,
},
{
text: "user",
key: "menu.group.user",
items: menuUser,
},
{
text: "settings",
key: "menu.group.settings",
items: menuSettings,
},
{
text: "Apps",
key: "menu.group.apps",
items: menuApps,
},
{
text: "Data",
key: "menu.group.data",
items: menuData,
},
{
text: "Landing",
key: "menu.group.landing",
items: [
...menuLanding,
// {
@@ -68,21 +76,22 @@ export default {
{
text: "UI - Theme Preview",
key: "menu.group.ui",
items: menuUI,
},
{
text: "Pages",
key: "menu.pages",
key: "menu.group.pages",
items: menuPages,
},
{
text: "Charts",
key: "menu.charts",
key: "menu.group.charts",
items: menuCharts,
},
{
text: "UML",
// key: "menu.uml",
key: "menu.group.uml",
items: menuUML,
},
],

View File

@@ -28,7 +28,29 @@ export default {
signin: "Sign In",
},
menu: {
process: "ProcessManager",
// Core feature menus
process: "Process Manager",
task: "Scheduled Tasks",
log: "Log Viewer",
event: "System Events",
user: "User Management",
settings: "Settings",
push: "Push Management",
// Group titles
group: {
process: "Process",
task: "Task",
log: "Log",
user: "User",
settings: "Settings",
apps: "Apps",
data: "Data",
landing: "Landing",
ui: "UI - Theme Preview",
pages: "Pages",
charts: "Charts",
uml: "UML",
},
search: 'Search (press "ctrl + /" to focus)',
dashboard: "Dashboard",
logout: "Logout",

View File

@@ -34,6 +34,29 @@ export default {
signin: "サインイン",
},
menu: {
// コア機能メニュー
process: "プロセス管理",
task: "スケジュールタスク",
log: "ログビューア",
event: "システムイベント",
user: "ユーザー管理",
settings: "システム設定",
push: "プッシュ管理",
// グループタイトル
group: {
process: "プロセス",
task: "タスク",
log: "ログ",
user: "ユーザー",
settings: "設定",
apps: "アプリ",
data: "データ",
landing: "ランディング",
ui: "UI - テーマプレビュー",
pages: "ページ",
charts: "チャート",
uml: "UML",
},
search: "検索フォーカスするには「ctrl + /」を押します)",
dashboard: "ダッシュボード",
logout: "ログアウト",

View File

@@ -28,8 +28,29 @@ export default {
signin: "登录",
},
menu: {
// 核心功能菜单
process: "进程管理",
search: "搜索(按“ Ctrl + /”进行聚焦)",
task: "定时任务",
log: "日志查看",
event: "系统事件",
user: "用户管理",
settings: "系统设置",
push: "推送管理",
// 分组标题
group: {
process: "进程",
task: "任务",
log: "日志",
user: "用户",
settings: "设置",
apps: "应用",
data: "数据",
landing: "着陆页",
ui: "UI - 主题预览",
pages: "页面",
charts: "图表",
uml: "UML",
},
dashboard: "仪表板",
logout: "登出",
profile: "个人资料",

View File

@@ -9,5 +9,14 @@ export default [
category: "Data",
},
},
{
path: "/event",
component: () => import("@/views/log/Event.vue"),
meta: {
requiresAuth: true,
layout: "landing",
category: "Data",
},
},
];

View File

@@ -9,5 +9,14 @@ export default [
category: "Settings",
},
},
{
path: "/settings/push",
component: () => import("@/views/settings/Push.vue"),
meta: {
requiresAuth: true,
layout: "landing",
category: "Settings",
},
},
];

View File

@@ -0,0 +1,33 @@
// 事件类型枚举
export type EventType =
| "ProcessStart"
| "ProcessStop"
| "ProcessWarning"
| "TaskStart"
| "TaskStop";
// 事件模型
export interface Event {
id: number;
name: string;
type: EventType;
additional: string;
createdTime: string;
}
// 事件列表请求参数
export interface EventListReq {
page?: number;
size?: number;
startTime?: number;
endTime?: number;
type?: EventType;
name?: string;
}
// 事件列表响应
export interface EventListResp {
total: number;
data: Event[];
}

View File

@@ -0,0 +1,375 @@
<template>
<v-container fluid class="py-6 px-8">
<!-- 事件查看工具栏 -->
<v-card class="mb-6 rounded-2xl elevation-3">
<!-- 顶部标题和操作按钮 -->
<div class="pa-4 d-flex align-center justify-space-between flex-wrap">
<div class="d-flex align-center mb-2 mb-sm-0">
<v-icon size="40" color="primary" class="mr-3">mdi-bell-ring</v-icon>
<span class="text-h5 font-weight-bold text-primary">系统事件</span>
</div>
<div class="d-flex align-center ga-3 flex-wrap">
<v-btn
color="primary"
variant="flat"
class="rounded-lg px-4"
@click="refreshEvents"
:loading="loading"
>
<v-icon start>mdi-refresh</v-icon>
刷新
</v-btn>
</div>
</div>
<v-divider></v-divider>
<!-- 筛选条件 -->
<v-expansion-panels flat>
<v-expansion-panel>
<v-expansion-panel-title>
<v-icon start>mdi-filter</v-icon>
筛选条件
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-container fluid>
<v-row dense>
<!-- 进程/任务名筛选 -->
<v-col cols="12" sm="6" md="3">
<v-text-field
label="名称"
variant="outlined"
density="comfortable"
v-model="searchForm.name"
clearable
prepend-inner-icon="mdi-tag"
/>
</v-col>
<!-- 事件类型筛选 -->
<v-col cols="12" sm="6" md="3">
<v-select
label="事件类型"
variant="outlined"
density="comfortable"
v-model="searchForm.type"
:items="eventTypes"
item-title="label"
item-value="value"
clearable
prepend-inner-icon="mdi-shape"
/>
</v-col>
<!-- 开始时间 -->
<v-col cols="12" sm="6" md="3">
<v-text-field
label="开始时间"
variant="outlined"
density="comfortable"
type="datetime-local"
v-model="searchForm.startTime"
clearable
prepend-inner-icon="mdi-calendar-start"
/>
</v-col>
<!-- 结束时间 -->
<v-col cols="12" sm="6" md="3">
<v-text-field
label="结束时间"
variant="outlined"
density="comfortable"
type="datetime-local"
v-model="searchForm.endTime"
clearable
prepend-inner-icon="mdi-calendar-end"
/>
</v-col>
<!-- 操作按钮 -->
<v-col cols="12" class="d-flex align-center ga-2">
<v-btn color="primary" @click="searchEvents" :loading="loading">
<v-icon start>mdi-magnify</v-icon>
搜索
</v-btn>
<v-btn color="grey" variant="tonal" @click="resetSearch">
<v-icon start>mdi-refresh</v-icon>
重置
</v-btn>
</v-col>
</v-row>
</v-container>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card>
<!-- 事件列表 -->
<v-card class="rounded-2xl elevation-2">
<v-data-table
:headers="headers"
:items="eventData"
:loading="loading"
:items-per-page="pageSize"
item-key="id"
class="text-body-2"
density="comfortable"
>
<!-- 事件类型 -->
<template #item.type="{ item }">
<v-chip
:color="getEventTypeColor(item.type)"
size="small"
variant="tonal"
>
<v-icon start size="small">{{ getEventTypeIcon(item.type) }}</v-icon>
{{ getEventTypeLabel(item.type) }}
</v-chip>
</template>
<!-- 名称 -->
<template #item.name="{ item }">
<v-chip color="primary" size="small" variant="tonal">
{{ item.name }}
</v-chip>
</template>
<!-- 附加信息 -->
<template #item.additional="{ item }">
<div v-if="item.additional" class="additional-info">
<template v-for="(value, key) in parseAdditional(item.additional)" :key="key">
<v-chip size="x-small" variant="outlined" class="mr-1 mb-1">
{{ key }}: {{ value }}
</v-chip>
</template>
</div>
<span v-else class="text-grey">-</span>
</template>
<!-- 时间 -->
<template #item.createdTime="{ item }">
<span class="text-caption">{{ formatTime(item.createdTime) }}</span>
</template>
<!-- 底部分页 -->
<template #bottom>
<div class="text-center pa-4">
<v-pagination
v-model="currentPage"
:length="totalPages"
:total-visible="7"
@update:model-value="handlePageChange"
></v-pagination>
<div class="mt-2 text-caption text-grey">
{{ totalEvents }} 条事件每页 {{ pageSize }}
</div>
</div>
</template>
</v-data-table>
</v-card>
</v-container>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { getEventList } from "~/src/api/event";
import type { Event, EventListReq, EventType } from "~/src/types/event/event";
import { useSnackbarStore } from "~/src/stores/snackbarStore";
const snackbarStore = useSnackbarStore();
// 事件类型选项
const eventTypes = [
{ label: "进程启动", value: "ProcessStart" },
{ label: "进程停止", value: "ProcessStop" },
{ label: "进程警告", value: "ProcessWarning" },
{ label: "任务启动", value: "TaskStart" },
{ label: "任务停止", value: "TaskStop" },
];
// 表头定义
const headers = [
{ title: "事件类型", key: "type", width: "150px" },
{ title: "名称", key: "name", width: "150px" },
{ title: "附加信息", key: "additional", sortable: false },
{ title: "时间", key: "createdTime", width: "180px" },
];
// 数据
const eventData = ref<Event[]>([]);
const totalEvents = ref(0);
const currentPage = ref(1);
const pageSize = ref(20);
const loading = ref(false);
// 搜索表单
const searchForm = ref({
name: "",
type: "" as EventType | "",
startTime: "",
endTime: "",
});
// 计算总页数
const totalPages = computed(() => {
return Math.ceil(totalEvents.value / pageSize.value);
});
// 获取事件类型颜色
const getEventTypeColor = (type: EventType) => {
const colorMap: Record<EventType, string> = {
ProcessStart: "success",
ProcessStop: "error",
ProcessWarning: "warning",
TaskStart: "info",
TaskStop: "secondary",
};
return colorMap[type] || "grey";
};
// 获取事件类型图标
const getEventTypeIcon = (type: EventType) => {
const iconMap: Record<EventType, string> = {
ProcessStart: "mdi-play-circle",
ProcessStop: "mdi-stop-circle",
ProcessWarning: "mdi-alert-circle",
TaskStart: "mdi-clock-start",
TaskStop: "mdi-clock-end",
};
return iconMap[type] || "mdi-information";
};
// 获取事件类型标签
const getEventTypeLabel = (type: EventType) => {
const labelMap: Record<EventType, string> = {
ProcessStart: "进程启动",
ProcessStop: "进程停止",
ProcessWarning: "进程警告",
TaskStart: "任务启动",
TaskStop: "任务停止",
};
return labelMap[type] || type;
};
// 解析附加信息
const parseAdditional = (additional: string): Record<string, string> => {
try {
return JSON.parse(additional);
} catch {
return {};
}
};
// 格式化时间
const formatTime = (timestamp: string) => {
if (!timestamp) return "-";
const date = new Date(timestamp);
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
// 构建查询参数
const buildQuery = (): EventListReq => {
const query: EventListReq = {
page: currentPage.value,
size: pageSize.value,
};
if (searchForm.value.name) {
query.name = searchForm.value.name;
}
if (searchForm.value.type) {
query.type = searchForm.value.type as EventType;
}
if (searchForm.value.startTime) {
query.startTime = new Date(searchForm.value.startTime).getTime();
}
if (searchForm.value.endTime) {
query.endTime = new Date(searchForm.value.endTime).getTime();
}
return query;
};
// 加载事件
const loadEvents = async () => {
loading.value = true;
try {
const query = buildQuery();
const response = await getEventList(query);
if (response.code === 0 && response.data) {
eventData.value = response.data.data || [];
totalEvents.value = response.data.total || 0;
} else {
snackbarStore.showErrorMessage("加载事件失败");
}
} catch (error) {
console.error("加载事件错误:", error);
snackbarStore.showErrorMessage("加载事件出错");
} finally {
loading.value = false;
}
};
// 搜索事件
const searchEvents = () => {
currentPage.value = 1;
loadEvents();
};
// 重置搜索
const resetSearch = () => {
searchForm.value = {
name: "",
type: "",
startTime: "",
endTime: "",
};
currentPage.value = 1;
loadEvents();
};
// 刷新事件
const refreshEvents = () => {
loadEvents();
};
// 处理页码变化
const handlePageChange = (page: number) => {
currentPage.value = page;
loadEvents();
};
// 初始化
onMounted(() => {
loadEvents();
});
</script>
<style scoped>
.additional-info {
max-width: 400px;
}
:deep(.v-data-table__th) {
font-weight: 600 !important;
font-size: 0.875rem !important;
}
:deep(.v-data-table__td) {
padding: 12px 16px !important;
}
</style>

View File

@@ -0,0 +1,430 @@
<template>
<v-container fluid class="py-6 px-8">
<!-- 页面标题和操作按钮 -->
<v-card class="mb-6 rounded-2xl elevation-3">
<div class="pa-4 d-flex align-center justify-space-between flex-wrap">
<div class="d-flex align-center mb-2 mb-sm-0">
<v-icon size="40" color="primary" class="mr-3">mdi-bell-ring</v-icon>
<span class="text-h5 font-weight-bold text-primary">推送管理</span>
</div>
<v-btn
color="primary"
variant="flat"
class="rounded-lg px-4"
@click="openAddDialog"
>
<v-icon start>mdi-plus</v-icon>
新增推送
</v-btn>
</div>
</v-card>
<!-- 推送列表 -->
<v-card class="rounded-2xl elevation-2" :loading="loading">
<v-data-table
:headers="headers"
:items="pushList"
:loading="loading"
item-key="id"
class="text-body-2"
density="comfortable"
>
<!-- HTTP方法列 -->
<template #item.method="{ item }">
<v-chip
:color="getMethodColor(item.method)"
size="small"
variant="flat"
class="font-weight-bold"
>
{{ item.method }}
</v-chip>
</template>
<!-- URL列 -->
<template #item.url="{ item }">
<div class="text-truncate" style="max-width: 300px;" :title="item.url">
{{ item.url }}
</div>
</template>
<!-- Body列 -->
<template #item.body="{ item }">
<div class="text-truncate" style="max-width: 200px;" :title="item.body">
{{ item.body || '-' }}
</div>
</template>
<!-- 备注列 -->
<template #item.remark="{ item }">
<div class="text-truncate" style="max-width: 150px;" :title="item.remark">
{{ item.remark || '-' }}
</div>
</template>
<!-- 启用状态列 -->
<template #item.enable="{ item }">
<v-switch
:model-value="item.enable"
color="success"
density="compact"
hide-details
@update:model-value="toggleEnable(item)"
></v-switch>
</template>
<!-- 操作列 -->
<template #item.actions="{ item }">
<div class="d-flex ga-1">
<v-btn
color="primary"
size="small"
variant="tonal"
icon
@click="openEditDialog(item)"
>
<v-icon size="small">mdi-pencil</v-icon>
<v-tooltip activator="parent" location="top">编辑</v-tooltip>
</v-btn>
<v-btn
color="error"
size="small"
variant="tonal"
icon
@click="confirmDelete(item)"
>
<v-icon size="small">mdi-delete</v-icon>
<v-tooltip activator="parent" location="top">删除</v-tooltip>
</v-btn>
</div>
</template>
<!-- 无数据提示 -->
<template #no-data>
<div class="text-center py-8">
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-bell-off</v-icon>
<div class="text-h6 text-grey">暂无推送配置</div>
<div class="text-body-2 text-grey-lighten-1 mb-4">点击上方按钮添加新的推送配置</div>
</div>
</template>
</v-data-table>
</v-card>
<!-- 新增/编辑对话框 -->
<v-dialog v-model="dialog" max-width="600" persistent>
<v-card class="rounded-xl">
<v-card-title class="d-flex align-center pa-4">
<v-icon color="primary" class="mr-2">
{{ isEdit ? 'mdi-pencil' : 'mdi-plus' }}
</v-icon>
{{ isEdit ? '编辑推送' : '新增推送' }}
</v-card-title>
<v-divider></v-divider>
<v-card-text class="pa-4">
<v-form ref="formRef" v-model="formValid">
<v-row dense>
<v-col cols="12" sm="4">
<v-select
v-model="form.method"
label="HTTP方法"
:items="methodOptions"
variant="outlined"
density="comfortable"
:rules="[rules.required]"
prepend-inner-icon="mdi-web"
></v-select>
</v-col>
<v-col cols="12" sm="8">
<v-text-field
v-model="form.url"
label="推送URL"
variant="outlined"
density="comfortable"
:rules="[rules.required, rules.url]"
prepend-inner-icon="mdi-link"
placeholder="https://example.com/webhook"
></v-text-field>
</v-col>
<v-col cols="12">
<v-textarea
v-model="form.body"
label="请求体 (Body)"
variant="outlined"
density="comfortable"
rows="4"
prepend-inner-icon="mdi-code-json"
placeholder='{"message": "{{.Message}}", "time": "{{.Time}}"}'
hint="支持模板变量: {{.Message}}, {{.Time}}, {{.Level}} 等"
persistent-hint
></v-textarea>
</v-col>
<v-col cols="12">
<v-text-field
v-model="form.remark"
label="备注"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-note-text"
placeholder="推送配置描述"
></v-text-field>
</v-col>
<v-col cols="12">
<v-switch
v-model="form.enable"
label="启用推送"
color="success"
hide-details
></v-switch>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="tonal" @click="closeDialog">取消</v-btn>
<v-btn
color="primary"
variant="flat"
@click="submitForm"
:loading="submitLoading"
:disabled="!formValid"
>
{{ isEdit ? '保存' : '创建' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 删除确认对话框 -->
<v-dialog v-model="deleteDialog" max-width="400">
<v-card class="rounded-xl">
<v-card-title class="d-flex align-center pa-4">
<v-icon color="error" class="mr-2">mdi-alert-circle</v-icon>
确认删除
</v-card-title>
<v-divider></v-divider>
<v-card-text class="pa-4">
<p>确定要删除这个推送配置吗</p>
<p class="text-caption text-grey mt-2">
备注: {{ deleteItem?.remark || '无备注' }}
</p>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="tonal" @click="deleteDialog = false">取消</v-btn>
<v-btn
color="error"
variant="flat"
@click="handleDelete"
:loading="deleteLoading"
>
删除
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { getPushList, createPush, editPush, deletePush } from "~/src/api/push";
import type { PushItem } from "~/src/types/push/push";
import { useSnackbarStore } from "~/src/stores/snackbarStore";
const snackbarStore = useSnackbarStore();
// 数据状态
const loading = ref(false);
const pushList = ref<PushItem[]>([]);
// 对话框状态
const dialog = ref(false);
const isEdit = ref(false);
const formRef = ref();
const formValid = ref(false);
const submitLoading = ref(false);
// 删除对话框状态
const deleteDialog = ref(false);
const deleteItem = ref<PushItem | null>(null);
const deleteLoading = ref(false);
// HTTP方法选项
const methodOptions = ["GET", "POST", "PUT", "DELETE"];
// 表单数据
const defaultForm = {
id: 0,
method: "POST",
url: "",
body: "",
remark: "",
enable: true,
};
const form = ref({ ...defaultForm });
// 表格列定义
const headers = [
{ title: "HTTP方法", key: "method", width: "100px" },
{ title: "推送URL", key: "url" },
{ title: "请求体", key: "body" },
{ title: "备注", key: "remark" },
{ title: "启用", key: "enable", width: "80px" },
{ title: "操作", key: "actions", width: "120px", sortable: false },
];
// 验证规则
const rules = {
required: (v: string) => !!v || "必填项",
url: (v: string) => {
if (!v) return true;
try {
new URL(v);
return true;
} catch {
return "请输入有效的URL";
}
},
};
// 获取HTTP方法颜色
const getMethodColor = (method: string) => {
const colors: Record<string, string> = {
GET: "success",
POST: "primary",
PUT: "warning",
DELETE: "error",
};
return colors[method] || "grey";
};
// 加载推送列表
const loadPushList = async () => {
loading.value = true;
try {
const res = await getPushList();
if (res.code === 0 && res.data) {
pushList.value = res.data;
}
} catch (error) {
console.error("加载推送列表失败:", error);
snackbarStore.showErrorMessage("加载推送列表失败");
} finally {
loading.value = false;
}
};
// 打开新增对话框
const openAddDialog = () => {
isEdit.value = false;
form.value = { ...defaultForm };
dialog.value = true;
};
// 打开编辑对话框
const openEditDialog = (item: PushItem) => {
isEdit.value = true;
form.value = { ...item };
dialog.value = true;
};
// 关闭对话框
const closeDialog = () => {
dialog.value = false;
form.value = { ...defaultForm };
};
// 提交表单
const submitForm = async () => {
if (!formValid.value) return;
submitLoading.value = true;
try {
const data = {
id: form.value.id,
method: form.value.method,
url: form.value.url,
body: form.value.body,
remark: form.value.remark,
enable: form.value.enable,
};
let res;
if (isEdit.value) {
res = await editPush(data);
} else {
res = await createPush(data);
}
if (res.code === 0) {
snackbarStore.showSuccessMessage(isEdit.value ? "保存成功" : "创建成功");
closeDialog();
loadPushList();
}
} catch (error) {
console.error("提交失败:", error);
snackbarStore.showErrorMessage(isEdit.value ? "保存失败" : "创建失败");
} finally {
submitLoading.value = false;
}
};
// 切换启用状态
const toggleEnable = async (item: PushItem) => {
try {
const res = await editPush({
...item,
enable: !item.enable,
});
if (res.code === 0) {
snackbarStore.showSuccessMessage(item.enable ? "已禁用" : "已启用");
loadPushList();
}
} catch (error) {
console.error("切换状态失败:", error);
snackbarStore.showErrorMessage("操作失败");
}
};
// 确认删除
const confirmDelete = (item: PushItem) => {
deleteItem.value = item;
deleteDialog.value = true;
};
// 执行删除
const handleDelete = async () => {
if (!deleteItem.value) return;
deleteLoading.value = true;
try {
const res = await deletePush(deleteItem.value.id);
if (res.code === 0) {
snackbarStore.showSuccessMessage("删除成功");
deleteDialog.value = false;
deleteItem.value = null;
loadPushList();
}
} catch (error) {
console.error("删除失败:", error);
snackbarStore.showErrorMessage("删除失败");
} finally {
deleteLoading.value = false;
}
};
// 初始化
onMounted(() => {
loadPushList();
});
</script>
<style scoped>
.v-data-table :deep(th) {
font-weight: 600 !important;
}
</style>