feat: 新增刷新缓存功能并优化系统初始化

refactor: 重构模型加载选项和CRUD预加载逻辑

fix: 修复日期范围查询格式问题

style: 优化前端组件样式和布局

perf: 提升字典数据更新性能

docs: 更新注释和文档说明

chore: 清理无用文件和代码

test: 更新测试用例

build: 添加vue-json-pretty依赖

ci: 优化初始化脚本和事务处理
This commit is contained in:
zhangtao
2025-10-27 02:33:50 +08:00
parent ab90f86623
commit 780e02b9de
89 changed files with 1549 additions and 914 deletions
@@ -34,6 +34,8 @@ const shortcuts = [
value: () => {
const end = new Date()
const start = new Date()
// 修正:起始时间为当前时间往前 24 小时
start.setTime(start.getTime() - 3600 * 1000 * 24)
return [start, end]
}
},
@@ -0,0 +1,60 @@
<template>
<div class="json-pretty-wrapper" :style="{ maxHeight: height }">
<VueJsonPretty
v-if="isJson"
:data="parsed"
:show-line="true"
:show-double-quotes="false"
:show-length="true"
:deep="3"
/>
<pre v-else class="json-pretty-fallback">{{ displayText }}</pre>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import VueJsonPretty from 'vue-json-pretty';
import 'vue-json-pretty/lib/styles.css';
const props = defineProps({
value: { type: [String, Object, Array, Number, Boolean], default: '' },
height: { type: String, default: '240px' },
});
const parsed = computed(() => {
const v: any = props.value;
if (typeof v === 'string') {
try {
return JSON.parse(v);
} catch {
return v; // 非 JSON 字符串,原样展示
}
}
return v;
});
const isJson = computed(() => typeof parsed.value === 'object' && parsed.value !== null);
const displayText = computed(() => {
const v: any = props.value;
return typeof v === 'string' ? v : JSON.stringify(v, null, 2);
});
</script>
<style scoped>
.json-pretty-wrapper {
overflow: auto;
border: 1px solid #e5e7eb;
padding: 8px;
border-radius: 4px;
background-color: var(--el-fill-color-blank);
}
.json-pretty-fallback {
white-space: pre-wrap;
word-break: break-word;
margin: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
}
</style>
@@ -98,13 +98,12 @@ function handleViewMoreNotice() {
router.push({ name: "Notice" });
}
// 全部已读
// 全部已读:将这些公告禁用(status=false),刷新后不再出现
function handleMarkAllAsRead() {
NoticeAPI.batchAvailableNotice({
ids: noticeList.value.map((item) => item.id).filter((id): id is number => id !== undefined),
status: true
}).then(() => {
noticeList.value = [];
const ids = noticeList.value.map((item) => item.id).filter((id): id is number => id !== undefined);
NoticeAPI.batchAvailableNotice({ ids, status: false }).then(async () => {
await noticeStore.getNotice();
noticeList.value = noticeStore.noticeList;
});
}
@@ -3,7 +3,6 @@
<el-scrollbar>
<div :class="{ hidden: hidden }" class="pagination">
<el-pagination
v-show="total>0"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:background="background"
@@ -1,40 +1,54 @@
<!-- 单图上传组件 -->
<template>
<el-upload
v-model="modelValue"
class="single-upload"
list-type="picture-card"
:show-file-list="false"
:accept="props.accept"
:before-upload="handleBeforeUpload"
:http-request="handleUpload"
>
<template #default>
<el-image v-if="modelValue" :src="modelValue" />
<el-icon v-if="modelValue" class="single-upload__delete-btn" @click.stop="handleDelete">
<CircleCloseFilled />
</el-icon>
<el-icon v-else class="single-upload__add-btn">
<Plus />
</el-icon>
<el-icon v-if="modelValue" class="single-upload__preview-btn" @click.stop="handlePreview(modelValue)">
<View />
</el-icon>
</template>
</el-upload>
<el-image-viewer
v-if="showPreview"
:url-list="srcList"
show-progress
:initial-index=previewImageIndex
@close="showPreview = false"
/>
<div class="single-image-upload">
<el-upload
v-model:file-list="internalFileList"
class="single-upload"
list-type="picture-card"
:show-file-list="false"
:accept="props.accept"
:before-upload="handleBeforeUpload"
:http-request="handleUpload"
:on-success="onSuccess"
:on-error="onError"
:on-remove="handleDelete"
:disabled="props.disabled"
>
<template #default>
<template v-if="internalFileList && internalFileList.length > 0 && internalFileList[0].url">
<el-image
:key="internalFileList[0].url"
class="single-upload__image"
:src="internalFileList[0].url"
fit="cover"
:preview-src-list="props.enablePreview ? [internalFileList[0].url] : []"
:preview-teleported="true"
@click.stop="handleImageClick"
/>
<el-icon
v-if="!props.disabled"
class="single-upload__delete-btn"
@click.stop="handleDelete"
>
<CircleCloseFilled />
</el-icon>
</template>
<template v-else>
<el-icon class="single-upload__add-btn">
<Plus />
</el-icon>
</template>
</template>
</el-upload>
<div v-if="props.showTip" class="el-upload__tip">
{{ props.tipText || `支持 ${props.accept} 格式,文件大小不超过 ${props.maxFileSize}MB` }}
</div>
</div>
</template>
<script setup lang="ts">
import { UploadRawFile, UploadRequestOptions, ElMessage } from "element-plus";
import { ref, watch } from "vue";
import { UploadRawFile, UploadRequestOptions, ElMessage, type UploadUserFile } from "element-plus";
import ParamsAPI from '@/api/system/params';
const props = defineProps({
@@ -82,13 +96,77 @@ const props = defineProps({
};
},
},
/**
* 是否禁用
*/
disabled: {
type: Boolean,
default: false,
},
/**
* 是否显示提示信息
*/
showTip: {
type: Boolean,
default: false,
},
/**
* 提示文本
*/
tipText: {
type: String,
default: "",
},
/**
* 是否启用图片预览功能
*/
enablePreview: {
type: Boolean,
default: true,
},
});
const modelValue = defineModel("modelValue", {
type: String,
default: () => "",
// 接收字符串类型的modelValue,保持与现有代码的兼容性
const modelValue = defineModel<string>({
default: "",
});
// 内部使用的文件列表
const internalFileList = ref<UploadUserFile[]>([]);
// 监听modelValue变化,同步到internalFileList
watch(
() => modelValue.value,
(newVal) => {
if (newVal) {
internalFileList.value = [{
name: newVal.split('/').pop() || 'image',
url: newVal
}];
} else {
internalFileList.value = [];
}
},
{ immediate: true }
);
// 监听internalFileList变化,同步到modelValue
watch(
() => internalFileList.value,
(newVal) => {
if (newVal && newVal.length > 0 && newVal[0].url) {
modelValue.value = newVal[0].url;
} else {
modelValue.value = "";
}
},
{ deep: true }
);
/**
* 定义组件触发的事件
*/
@@ -96,8 +174,7 @@ const emit = defineEmits<{
(e: 'success', fileInfo: UploadFilePath): void;
(e: 'error', error: any): void;
(e: 'input', value: string): void;
(e: 'onSuccess', fileInfo: UploadFilePath): void;
(e: 'onError', error: any): void;
(e: 'update:modelValue', value: string): void;
}>();
/**
@@ -128,7 +205,7 @@ function handleBeforeUpload(file: UploadRawFile) {
// 限制文件大小
if (file.size > props.maxFileSize * 1024 * 1024) {
ElMessage.warning("上传图片不能大于" + props.maxFileSize + "M");
ElMessage.warning(`上传图片不能大于 ${props.maxFileSize}MB`);
return false;
}
return true;
@@ -171,20 +248,23 @@ async function handleUpload(options: UploadRequestOptions) {
* 删除图片
*/
function handleDelete() {
modelValue.value = "";
// 清空模型值并通知父组件
internalFileList.value = [];
}
/**
* 预览
* 图片点击处理
* 阻止事件冒泡到上传组件,避免触发上传
*/
const showPreview = ref(false); // 是否显示预览
const srcList = ref<string[]>([]);
const previewImageIndex = ref(0); // 预览图片的索引
function handlePreview(imagePath: string) {
srcList.value = [imagePath];
previewImageIndex.value = 0;
showPreview.value = true;
function handleImageClick(event: Event) {
// 阻止事件冒泡
event.stopPropagation();
// 如果启用了预览功能,则触发预览
if (props.enablePreview && internalFileList.value && internalFileList.value.length > 0 && internalFileList.value[0].url) {
// Element Plus的el-image组件会自动处理preview-src-list的预览功能
// 这里只需要阻止冒泡即可
}
}
/**
@@ -193,40 +273,53 @@ function handlePreview(imagePath: string) {
* @param fileInfo 上传成功后的文件信息
*/
const onSuccess = (fileInfo: UploadFilePath) => {
modelValue.value = fileInfo.file_url; // 更新绑定的值为文件URL
emit('onSuccess', fileInfo); // 触发 onSuccess 事件
emit('success', fileInfo); // 触发 success 事件
emit('input', fileInfo.file_url); // 触发 input 事件,通知父组件值变化
// 更新绑定的值为文件URL
const newFileList = [{
name: fileInfo.file_name,
url: fileInfo.file_url
}];
internalFileList.value = newFileList;
// 触发事件
emit('success', fileInfo);
emit('input', fileInfo.file_url);
emit('update:modelValue', fileInfo.file_url);
};
/**
* 上传失败回调
*/
const onError = (error: any) => {
console.log("onError", error);
emit('onError', error); // 触发 onError 事件
emit('error', error); // 触发 error 事件
console.error("图片上传失败:", error);
ElMessage.error('图片上传失败,请重试');
emit('error', error);
};
</script>
<style scoped lang="scss">
.single-image-upload {
display: flex;
flex-direction: column;
align-items: flex-start;
}
:deep(.el-upload--picture-card) {
width: v-bind("props.style.width");
height: v-bind("props.style.height");
position: relative;
width: v-bind("props.style.width ?? '150px'");
height: v-bind("props.style.height ?? '150px'");
&:hover {
.single-upload__delete-btn {
display: block;
}
}
}
.single-upload {
position: relative;
width: v-bind("props.style.width");
height: v-bind("props.style.height");
overflow: hidden;
cursor: pointer;
border: 1px var(--el-border-color) solid;
border-radius: 5px;
&:hover {
border-color: var(--el-color-primary);
&__image {
border-radius: 6px;
cursor: pointer;
}
&__delete-btn {
@@ -238,25 +331,22 @@ const onError = (error: any) => {
cursor: pointer;
background: #fff;
border-radius: 100%;
display: none;
:hover {
&:hover {
color: #ff4500;
}
}
&__preview-btn {
position: absolute;
top: 1px;
left: 1px;
font-size: 16px;
color: #409eff;
cursor: pointer;
background: #fff;
border-radius: 100%;
:hover {
color: #1d7bff;
}
&__add-btn {
font-size: 28px;
color: #8c939d;
}
}
.el-upload__tip {
font-size: 12px;
color: #606266;
margin-top: 7px;
}
</style>