fix: 调整前端组件样式和修复后端调度器问题

修复操作列按钮宽度计算问题,调整日志详情页标签宽度
优化任务状态选项标签,从"暂停"改为"停用"
移除后端调度器无用导入,添加job_id到日志记录
添加job_id查询参数到任务日志接口
优化前端应用列表页搜索表单和分页样式
修复Pydantic错误消息处理,去除冗余前缀
添加应用模型的前置处理和验证逻辑
This commit is contained in:
zhangtao
2025-10-28 01:35:06 +08:00
parent 780e02b9de
commit 96335b385f
9 changed files with 85 additions and 86 deletions
@@ -35,11 +35,14 @@ class JobLogQueryParam:
def __init__( def __init__(
self, self,
job_id: Optional[int] = Query(None, description="定时任务ID"),
job_name: Optional[str] = Query(None, description="任务名称"), job_name: Optional[str] = Query(None, description="任务名称"),
status: Optional[bool] = Query(None, description="状态: 正常,失败"), status: Optional[bool] = Query(None, description="状态: 正常,失败"),
start_time: Optional[DateTimeStr] = Query(None, description="开始时间", example="2025-01-01 00:00:00"), start_time: Optional[DateTimeStr] = Query(None, description="开始时间", example="2025-01-01 00:00:00"),
end_time: Optional[DateTimeStr] = Query(None, description="结束时间", example="2025-12-31 23:59:59"), end_time: Optional[DateTimeStr] = Query(None, description="结束时间", example="2025-12-31 23:59:59"),
) -> None: ) -> None:
# 定时任务ID查询
self.job_id = job_id
# 模糊查询字段 # 模糊查询字段
self.job_name = ("like", job_name) self.job_name = ("like", job_name)
# 精确查询字段 # 精确查询字段
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Optional from typing import Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.core.base_schema import BaseSchema from app.core.base_schema import BaseSchema
@@ -14,6 +14,28 @@ class ApplicationCreateSchema(BaseModel):
status: bool = Field(True, description="是否启用(True:启用 False:禁用)") status: bool = Field(True, description="是否启用(True:启用 False:禁用)")
description: Optional[str] = Field(default=None, max_length=255, description="描述") description: Optional[str] = Field(default=None, max_length=255, description="描述")
@model_validator(mode="before")
@classmethod
def _normalize(cls, data):
"""模型级前置处理:去除首尾空格,空字符串转为 None(可选字段)。"""
if isinstance(data, dict):
for key in ("name", "access_url", "icon_url", "description"):
val = data.get(key)
if isinstance(val, str):
val = val.strip()
# 将可选字段的空字符串转换为 None
if key in ("icon_url", "description") and val == "":
val = None
data[key] = val
return data
@field_validator('name')
@classmethod
def _validate_name_length(cls, v: str) -> str:
if len(v) > 64:
raise ValueError('应用名称长度不能超过64字符')
return v
class ApplicationUpdateSchema(ApplicationCreateSchema): class ApplicationUpdateSchema(ApplicationCreateSchema):
"""应用更新模型""" """应用更新模型"""
+1 -2
View File
@@ -15,8 +15,6 @@ from apscheduler.jobstores.redis import RedisJobStore
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger from apscheduler.triggers.date import DateTrigger
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
import asyncio
import threading
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from app.config.setting import settings from app.config.setting import settings
@@ -117,6 +115,7 @@ class SchedulerUtil:
status=status, status=status,
exception_info=exception_info, exception_info=exception_info,
create_time=datetime.now(), create_time=datetime.now(),
job_id=job_id,
) )
# 使用线程池执行异步操作以避免阻塞调度器和数据库锁定问题 # 使用线程池执行异步操作以避免阻塞调度器和数据库锁定问题
+8 -1
View File
@@ -109,7 +109,14 @@ def handle_exception(app: FastAPI):
"value could not be parsed to a boolean": "类型错误,提交参数应该为布尔值!", "value could not be parsed to a boolean": "类型错误,提交参数应该为布尔值!",
"Input should be a valid list": "类型错误,输入应该是一个有效的列表!" "Input should be a valid list": "类型错误,输入应该是一个有效的列表!"
} }
msg = error_mapping.get(exc.errors()[0].get('msg'), exc.errors()[0].get('msg')) raw_msg = exc.errors()[0].get('msg')
msg = error_mapping.get(raw_msg, raw_msg)
# 去掉Pydantic默认的前缀“Value error”, 仅保留具体提示内容
if isinstance(msg, str) and msg.startswith("Value error"):
if "," in msg:
msg = msg.split(",", 1)[1].strip()
else:
msg = msg.replace("Value error", "").strip()
logger.error(f"请求地址: {request.url}, 错误信息: {msg}, 错误详情: {exc}") logger.error(f"请求地址: {request.url}, 错误信息: {msg}, 错误详情: {exc}")
return ErrorResponse(msg=str(msg), status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, data=exc.body) return ErrorResponse(msg=str(msg), status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, data=exc.body)
@@ -82,7 +82,7 @@ const getOperationMaxWidth = () => {
const buttons = item.querySelectorAll(".el-button"); const buttons = item.querySelectorAll(".el-button");
// 获取每行按钮的总宽度 // 获取每行按钮的总宽度
totalWidth = Array.from(buttons).reduce((acc, button: any) => { totalWidth = Array.from(buttons).reduce((acc, button: any) => {
return acc + button.scrollWidth + 22; // 每个按钮的宽度加上预留宽度 return acc + button.scrollWidth + 14; // 每个按钮的宽度加上预留宽度
}, 0); }, 0);
// 获取最大的宽度 // 获取最大的宽度
@@ -127,7 +127,7 @@
<el-dialog v-model="dialogVisible.visible" :title="dialogVisible.title" @close="handleCloseDialog"> <el-dialog v-model="dialogVisible.visible" :title="dialogVisible.title" @close="handleCloseDialog">
<!-- 详情 --> <!-- 详情 -->
<template v-if="dialogVisible.type === 'detail'"> <template v-if="dialogVisible.type === 'detail'">
<el-descriptions :column="2" border> <el-descriptions :column="2" border label-width="120px">
<el-descriptions-item label="日志ID" :span="2">{{ detailFormData.id }}</el-descriptions-item> <el-descriptions-item label="日志ID" :span="2">{{ detailFormData.id }}</el-descriptions-item>
<el-descriptions-item label="任务名称" :span="2">{{ detailFormData.job_name }}</el-descriptions-item> <el-descriptions-item label="任务名称" :span="2">{{ detailFormData.job_name }}</el-descriptions-item>
<el-descriptions-item label="任务组名" :span="2">{{ detailFormData.job_group }}</el-descriptions-item> <el-descriptions-item label="任务组名" :span="2">{{ detailFormData.job_group }}</el-descriptions-item>
+2 -2
View File
@@ -24,7 +24,7 @@
style="width: 167.5px" style="width: 167.5px"
> >
<el-option value="true" label="启用" /> <el-option value="true" label="启用" />
<el-option value="false" label="暂停" /> <el-option value="false" label="停用" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<!-- 时间范围,收起状态下隐藏 --> <!-- 时间范围,收起状态下隐藏 -->
@@ -521,7 +521,7 @@
max-height="500px" max-height="500px"
@change="handlechangeCron" @change="handlechangeCron"
@close="openCron = false" @close="openCron = false"
></vue3CronPlus> />
</el-popover> </el-popover>
</el-form-item> </el-form-item>
<!-- 开始日期和结束日期 --> <!-- 开始日期和结束日期 -->
+45 -77
View File
@@ -2,29 +2,47 @@
<template> <template>
<div class="app-container"> <div class="app-container">
<!-- 顶部搜索和操作区域 --> <!-- 顶部搜索和操作区域 -->
<div class="search-container mb-4"> <div class="search-container">
<el-form ref="queryFormRef" :model="queryFormData" :inline="true" label-suffix=":"> <el-form ref="queryFormRef" :model="queryFormData" :inline="true" label-suffix=":">
<el-form-item prop="name" label="应用名称"> <el-form-item prop="name" label="应用名称">
<el-input v-model="queryFormData.name" placeholder="请输入应用名称" clearable style="width: 200px" /> <el-input v-model="queryFormData.name" placeholder="请输入应用名称" clearable/>
</el-form-item> </el-form-item>
<el-form-item prop="status" label="状态"> <el-form-item prop="status" label="状态">
<el-select v-model="queryFormData.status" placeholder="请选择状态" clearable style="width: 120px"> <el-select v-model="queryFormData.status" placeholder="请选择状态" clearable style="width: 170px;">
<el-option label="启用" :value="true" /> <el-option label="启用" :value="true" />
<el-option label="停用" :value="false" /> <el-option label="停用" :value="false" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item prop="creator" label="创建人"> <el-form-item v-if="isExpand" prop="creator" label="创建人">
<el-input v-model="queryFormData.creator" placeholder="请输入创建人" clearable style="width: 150px" /> <UserTableSelect
v-model="queryFormData.creator"
@confirm-click="handleConfirm"
@clear-click="handleQuery"
/>
</el-form-item> </el-form-item>
<el-form-item class="search-buttons"> <el-form-item class="search-buttons">
<el-button v-hasPerm="['application:myapp:query']" type="primary" icon="search" @click="handleQuery">查询</el-button> <el-button v-hasPerm="['application:myapp:query']" type="primary" icon="search" @click="handleQuery">查询</el-button>
<el-button v-hasPerm="['application:myapp:query']" icon="refresh" @click="handleResetQuery">重置</el-button> <el-button v-hasPerm="['application:myapp:query']" icon="refresh" @click="handleResetQuery">重置</el-button>
<!-- 展开/收起 -->
<template v-if="isExpandable">
<el-link class="ml-3" type="primary" underline="never" @click="isExpand = !isExpand">
{{ isExpand ? "收起" : "展开" }}
<el-icon>
<template v-if="isExpand">
<ArrowUp />
</template>
<template v-else>
<ArrowDown />
</template>
</el-icon>
</el-link>
</template>
</el-form-item> </el-form-item>
</el-form> </el-form>
</div> </div>
<!-- 应用卡片展示区域 --> <!-- 应用卡片展示区域 -->
<el-card shadow="hover" class="app-grid-card"> <el-card shadow="hover" class="app-grid-card" >
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>应用市场</span> <span>应用市场</span>
@@ -35,7 +53,7 @@
</template> </template>
<!-- 应用网格 --> <!-- 应用网格 -->
<div v-loading="loading" class="app-grid"> <div v-loading="loading" class="app-grid" >
<el-card <el-card
v-for="app in applicationList" v-for="app in applicationList"
:key="app.id" :key="app.id"
@@ -136,17 +154,14 @@
<!-- 分页区域 --> <!-- 分页区域 -->
<template #footer> <template #footer>
<div class="pagination-container"> <!-- 使用卡片 footer 样式右对齐,无需额外容器 -->
<el-pagination <pagination
v-model:current-page="queryFormData.page_no" v-model:total="total"
v-model:page-size="queryFormData.page_size" v-model:page="queryFormData.page_no"
:total="total" v-model:limit="queryFormData.page_size"
:page-sizes="[12, 24, 48]" :page-sizes="[12, 24, 48]"
layout="total, sizes, prev, pager, next, jumper" @pagination="loadApplicationList"
@size-change="handleSizeChange" />
@current-change="handleCurrentChange"
/>
</div>
</template> </template>
</el-card> </el-card>
@@ -232,6 +247,8 @@ const total = ref(0);
const dialogVisible = ref(false); const dialogVisible = ref(false);
const dialogType = ref<'create' | 'edit'>('create'); const dialogType = ref<'create' | 'edit'>('create');
const currentApp = ref<ApplicationInfo | null>(null); const currentApp = ref<ApplicationInfo | null>(null);
const isExpand = ref(false);
const isExpandable = ref(true);
// 分页查询参数 // 分页查询参数
const queryFormData = reactive<ApplicationPageQuery>({ const queryFormData = reactive<ApplicationPageQuery>({
@@ -256,7 +273,9 @@ const formData = reactive<ApplicationForm>({
// 表单验证规则 // 表单验证规则
const formRules = reactive({ const formRules = reactive({
name: [{ required: true, message: "请输入应用名称", trigger: "blur" }], name: [
{ required: true, message: "请输入应用名称", trigger: "blur" },
{ min: 2, max: 30, message: "长度在 2 到 30 个字符", trigger: "blur" }],
access_url: [ access_url: [
{ required: true, message: "请输入访问地址", trigger: "blur" }, { required: true, message: "请输入访问地址", trigger: "blur" },
{ type: 'url' as const, message: "请输入正确的URL格式", trigger: "blur" } { type: 'url' as const, message: "请输入正确的URL格式", trigger: "blur" }
@@ -298,6 +317,11 @@ async function handleQuery() {
await loadApplicationList(); await loadApplicationList();
} }
// 选择创建人后触发查询
function handleConfirm() {
handleQuery();
}
// 重置查询 // 重置查询
async function handleResetQuery() { async function handleResetQuery() {
queryFormRef.value?.resetFields(); queryFormRef.value?.resetFields();
@@ -305,19 +329,6 @@ async function handleResetQuery() {
await loadApplicationList(); await loadApplicationList();
} }
// 分页大小改变
async function handleSizeChange(size: number) {
queryFormData.page_size = size;
queryFormData.page_no = 1;
await loadApplicationList();
}
// 当前页改变
async function handleCurrentChange(page: number) {
queryFormData.page_no = page;
await loadApplicationList();
}
// 创建应用 // 创建应用
function handleCreateApp() { function handleCreateApp() {
console.log('handleCreateApp'); console.log('handleCreateApp');
@@ -465,19 +476,6 @@ onMounted(() => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.app-container {
padding: 20px;
background-color: var(--el-bg-color-page);
min-height: calc(100vh - 84px);
}
.search-container {
background: var(--el-bg-color);
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.app-grid-card { .app-grid-card {
height: calc(100vh - 200px); height: calc(100vh - 200px);
position: relative; position: relative;
@@ -486,6 +484,8 @@ onMounted(() => {
:deep(.el-card__footer) { :deep(.el-card__footer) {
margin-top: auto; margin-top: auto;
display: flex;
justify-content: flex-end;
} }
} }
@@ -493,14 +493,11 @@ onMounted(() => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
font-weight: 600;
} }
.app-grid { .app-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
padding: 20px 0;
flex: 1; flex: 1;
} }
@@ -508,13 +505,6 @@ onMounted(() => {
border: 1px solid var(--el-border-color-lighter); border: 1px solid var(--el-border-color-lighter);
border-radius: 6px; border-radius: 6px;
transition: all 0.3s ease; transition: all 0.3s ease;
min-height: 180px;
width: 100%;
&:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
} }
.app-card-header-el { .app-card-header-el {
@@ -531,14 +521,6 @@ onMounted(() => {
min-width: 0; min-width: 0;
} }
.app-avatar-el {
flex-shrink: 0;
}
.app-title-section {
flex: 1;
min-width: 0;
}
.app-name-el { .app-name-el {
font-size: 16px; font-size: 16px;
@@ -551,18 +533,7 @@ onMounted(() => {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.app-status-tag {
margin-top: 2px;
}
.app-menu-btn {
padding: 4px;
color: var(--el-text-color-secondary);
&:hover {
color: var(--el-color-primary);
}
}
.app-card-content { .app-card-content {
margin: 12px 0; margin: 12px 0;
@@ -571,8 +542,6 @@ onMounted(() => {
.app-description { .app-description {
font-size: 14px; font-size: 14px;
color: var(--el-text-color-regular); color: var(--el-text-color-regular);
line-height: 1.5;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
line-clamp: 2; line-clamp: 2;
@@ -625,7 +594,6 @@ onMounted(() => {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
max-width: 100%;
} }
.app-card-actions { .app-card-actions {
+1 -1
View File
@@ -154,7 +154,7 @@
<el-dialog v-model="dialogVisible.visible" :title="dialogVisible.title" @close="handleCloseDialog"> <el-dialog v-model="dialogVisible.visible" :title="dialogVisible.title" @close="handleCloseDialog">
<!-- 详情 --> <!-- 详情 -->
<template v-if="dialogVisible.type === 'detail'"> <template v-if="dialogVisible.type === 'detail'">
<el-descriptions :column="8" border label-width="140px"> <el-descriptions :column="8" border label-width="200px">
<el-descriptions-item label="日志类型" :span="2"> <el-descriptions-item label="日志类型" :span="2">
<el-tag :type="formData.type === 1 ? 'success' : 'primary'"> <el-tag :type="formData.type === 1 ? 'success' : 'primary'">
{{ formData.type === 1 ? '登录日志' : '操作日志' }} {{ formData.type === 1 ? '登录日志' : '操作日志' }}