初始化

This commit is contained in:
2026-03-03 09:53:54 +08:00
commit 3f349a35a4
437 changed files with 65639 additions and 0 deletions

View File

@@ -0,0 +1,321 @@
<template>
<div class="art-full-height">
<div class="box-border flex gap-4 h-full max-md:block max-md:gap-0 max-md:h-auto">
<div class="flex-shrink-0 w-64 h-full max-md:w-full max-md:h-auto max-md:mb-5">
<ElCard class="tree-card art-card-xs flex flex-col h-full mt-0" shadow="never">
<template #header>
<div class="flex justify-between items-center">
<b>附件分类</b>
<SaButton
v-permission="'core:attachment:edit'"
type="primary"
@click="categoryShowDialog('add')"
/>
</div>
</template>
<ElScrollbar>
<ElTree
:data="treeData"
:props="{ children: 'children', label: 'label' }"
node-key="id"
default-expand-all
highlight-current
:expand-on-click-node="false"
@node-click="handleNodeClick"
>
<template #default="{ node, data }">
<div class="flex items-center justify-between w-full" v-if="data.id > 1">
<span>{{ node.label }}</span>
<div class="tree-node-actions">
<SaButton
v-permission="'core:attachment:edit'"
type="secondary"
@click="categoryShowDialog('edit', data)"
/>
<SaButton
v-permission="'core:attachment:edit'"
type="error"
@click="categoryDeleteRow(data, categoryApi.delete, getCategoryList)"
/>
</div>
</div>
</template>
</ElTree>
</ElScrollbar>
</ElCard>
</div>
<div class="flex flex-col flex-grow min-w-0">
<ElCard class="art-table-card !mt-0" shadow="never">
<!-- 表格头部 -->
<div class="flex justify-between items-center mb-4">
<ElSpace wrap>
<ElUpload
v-permission="'core:system:uploadImage'"
class="upload-btn"
:show-file-list="false"
:http-request="handleUpload"
:before-upload="beforeUpload"
accept="image/*"
>
<ElButton :icon="UploadFilled">上传图片</ElButton>
</ElUpload>
<ElButton
v-permission="'core:attachment:edit'"
:disabled="selectedRows.length === 0"
@click="deleteSelectedRows(api.delete, refreshData)"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-5-line" />
</template>
删除
</ElButton>
<ElButton
v-permission="'core:attachment:edit'"
:disabled="selectedRows.length === 0"
@click="moveDialogVisible = true"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:swap-box-line" />
</template>
移动
</ElButton>
</ElSpace>
<ElSpace wrap>
<SaSelect
v-model="searchForm.storage_mode"
placeholder="请选择存储模式"
dict="upload_mode"
@change="handleSearch"
clearable
style="width: 160px"
/>
<ElInput
v-model="searchForm.origin_name"
placeholder="请输入文件名称"
:suffix-icon="Search"
@keyup.enter="handleSearch"
@clear="handleSearch"
clearable
style="width: 240px"
/>
</ElSpace>
</div>
<!-- 表格 -->
<ArtTable
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex gap-2">
<SaButton
v-permission="'core:attachment:edit'"
type="secondary"
@click="showDialog('edit', row)"
/>
<SaButton
v-permission="'core:attachment:edit'"
type="error"
@click="deleteRow(row, api.delete, refreshData)"
/>
</div>
</template>
</ArtTable>
</ElCard>
</div>
</div>
<!-- 分类弹窗 -->
<CategoryDialog
v-model="categoryDialogVisible"
:dialog-type="categoryDialogType"
:data="categoryDialogData"
@success="getCategoryList"
/>
<!-- 移动弹窗 -->
<MoveDialog v-model="moveDialogVisible" :selected-rows="selectedRows" @success="refreshData" />
<!-- 表单弹窗 -->
<EditDialog
v-model="dialogVisible"
:dialog-type="dialogType"
:data="dialogData"
@success="refreshData"
/>
</div>
</template>
<script setup lang="ts">
import api from '@/api/safeguard/attachment'
import categoryApi from '@/api/safeguard/category'
import { uploadImage } from '@/api/auth'
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import { Search, UploadFilled } from '@element-plus/icons-vue'
import type { UploadRequestOptions, UploadProps } from 'element-plus'
import EditDialog from './modules/edit-dialog.vue'
import CategoryDialog from './modules/category-dialog.vue'
import MoveDialog from './modules/move-dialog.vue'
/** 附件分类数据 */
const treeData = ref([])
/** 获取附件分类数据 */
const getCategoryList = () => {
categoryApi.list({ tree: true }).then((data: any) => {
treeData.value = data
})
}
/**
* 切换附件分类
* @param data
*/
const handleNodeClick = (data: any) => {
if (data.id === 1) {
searchParams.category_id = undefined
} else {
searchParams.category_id = data.id
}
getData()
}
/** 附件分类弹窗相关 */
const {
dialogType: categoryDialogType,
dialogVisible: categoryDialogVisible,
dialogData: categoryDialogData,
showDialog: categoryShowDialog,
deleteRow: categoryDeleteRow
} = useSaiAdmin()
/** 移动弹窗相关 */
const moveDialogVisible = ref(false)
/** 附件弹窗相关 */
const {
dialogType,
dialogVisible,
dialogData,
showDialog,
selectedRows,
handleSelectionChange,
deleteRow,
deleteSelectedRows
} = useSaiAdmin()
/** 附件搜索表单 */
const searchForm = ref({
origin_name: undefined,
storage_mode: undefined,
category_id: undefined,
orderField: 'create_time',
orderType: 'desc'
})
/** 附件表格相关 */
const {
columns,
data,
loading,
pagination,
getData,
searchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
apiParams: {
...searchForm.value
},
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'url', label: '预览', saiType: 'image', width: 80 },
{ prop: 'origin_name', label: '文件名称', minWidth: 160, showOverflowTooltip: true },
{
prop: 'storage_mode',
label: '存储模式',
width: 100,
saiType: 'dict',
saiDict: 'upload_mode'
},
{ prop: 'mime_type', label: '文件类型', width: 160, showOverflowTooltip: true },
{ prop: 'size_info', label: '文件大小', width: 100 },
{ prop: 'create_time', label: '上传时间', width: 180, sortable: true },
{ prop: 'operation', label: '操作', width: 100, fixed: 'right', useSlot: true }
]
}
})
/** 附件搜索 */
const handleSearch = () => {
Object.assign(searchParams, searchForm.value)
getData()
}
/** 附件上传前验证 */
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const isImage = file.type.startsWith('image/')
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return false
}
const isLt5M = file.size / 1024 / 1024 < 5
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB!')
return false
}
return true
}
/** 附件处理上传 */
const handleUpload = async (options: UploadRequestOptions) => {
const { file } = options
try {
const formData = new FormData()
formData.append('file', file)
await uploadImage(formData)
ElMessage.success('上传成功')
refreshData()
} catch (error: any) {
console.error('上传失败:', error)
ElMessage.error(error.message || '上传失败')
}
}
/** 初始化附件分类数据 */
onMounted(() => {
getCategoryList()
})
</script>
<style lang="scss" scoped>
.tree-node-actions {
opacity: 0;
transition: opacity 0.2s;
display: flex;
gap: 4px;
}
.el-tree-node__content:hover .tree-node-actions {
opacity: 1;
}
:deep(.el-tree-node__content) {
height: 32px;
}
</style>

View File

@@ -0,0 +1,166 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? '新增分类' : '编辑分类'"
width="600px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
<el-form-item label="上级分类" prop="parent_id">
<el-tree-select
v-model="formData.parent_id"
:data="optionData.treeData"
:render-after-expand="false"
check-strictly
clearable
/>
</el-form-item>
<el-form-item label="分类名称" prop="category_name">
<el-input v-model="formData.category_name" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" placeholder="请输入排序" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '@/api/safeguard/category'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
dialogType: string
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
data: undefined
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
const optionData = reactive({
treeData: <any[]>[]
})
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
parent_id: [{ required: true, message: '请选择上级分类', trigger: 'change' }],
category_name: [{ required: true, message: '请输入分类名称', trigger: 'blur' }]
})
/**
* 初始数据
*/
const initialFormData = {
id: null,
parent_id: null,
level: '',
category_name: '',
sort: 100
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
const data = await api.list({ tree: true })
optionData.treeData = data
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
initForm()
}
}
/**
* 初始化表单数据
*/
const initForm = () => {
if (props.data) {
for (const key in formData) {
if (props.data[key] != null && props.data[key] != undefined) {
;(formData as any)[key] = props.data[key]
}
}
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (props.dialogType === 'add') {
await api.save(formData)
ElMessage.success('新增成功')
} else {
await api.update(formData)
ElMessage.success('修改成功')
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,134 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? '新增文件' : '编辑文件'"
width="800px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
<el-form-item label="文件名称" prop="origin_name">
<el-input v-model="formData.origin_name" placeholder="请输入文件名称" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import api from '@/api/safeguard/attachment'
interface Props {
modelValue: boolean
dialogType: string
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
data: undefined
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 表单验证规则
const rules: FormRules = {
origin_name: [{ required: true, message: '请输入文件名称', trigger: 'blur' }]
}
// 初始表单数据
const initialFormData = {
id: '',
origin_name: ''
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
// 初始化页面数据
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
initForm(props.data)
}
}
/**
* 初始化表单数据
*/
const initForm = (data: any) => {
if (data) {
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
;(formData as any)[key] = data[key]
}
}
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (props.dialogType === 'edit') {
await api.update(formData)
ElMessage.success('修改成功')
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,147 @@
<template>
<el-dialog
v-model="visible"
title="移动到分类"
width="500px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
<el-form-item>
<div class="text-gray-600 mb-2">
已选择 <span class="text-primary font-medium">{{ selectedCount }}</span> 个文件
</div>
</el-form-item>
<el-form-item label="目标分类" prop="category_id">
<el-tree-select
v-model="formData.category_id"
:data="optionData.treeData"
:render-after-expand="false"
check-strictly
clearable
placeholder="请选择目标分类"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定移动</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '@/api/safeguard/attachment'
import categoryApi from '@/api/safeguard/category'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
selectedRows: any[]
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
selectedRows: () => []
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
const optionData = reactive({
treeData: <any[]>[]
})
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 选中数量
*/
const selectedCount = computed(() => props.selectedRows.length)
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
category_id: [{ required: true, message: '请选择目标分类', trigger: 'change' }]
})
/**
* 初始数据
*/
const initialFormData = {
category_id: null
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
// 重置为初始值
Object.assign(formData, initialFormData)
const data = await categoryApi.list({ tree: true })
optionData.treeData = data
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
const ids = props.selectedRows.map((row) => row.id)
await api.move({
ids: ids,
category_id: formData.category_id
})
ElMessage.success(`成功移动 ${ids.length} 个文件`)
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,76 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
:label-width="'70px'"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="用户名" prop="username">
<el-input v-model="formData.username" placeholder="请输入用户名" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="手机号" prop="phone">
<el-input v-model="formData.phone" placeholder="请输入手机号" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="状态" prop="status">
<sa-select v-model="formData.status" dict="data_status" clearable />
</el-form-item>
</el-col>
</sa-search-bar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const isExpanded = ref<boolean>(false)
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 重置
function handleReset() {
searchBarRef.value?.ref.resetFields()
emit('reset')
}
// 搜索
async function handleSearch() {
emit('search', formData.value)
}
// 展开/收起
function handleExpand(expanded: boolean) {
isExpanded.value = expanded
}
// 栅格占据的列数
const setSpan = (span: number) => {
return {
span: span,
xs: 24, // 手机:满宽显示
sm: span >= 12 ? span : 12, // 平板大于等于12保持否则用半宽
md: span >= 8 ? span : 8, // 中等屏幕大于等于8保持否则用三分之一宽
lg: span,
xl: span
}
}
</script>

View File

@@ -0,0 +1,249 @@
<template>
<div class="page-content mb-5">
<el-row :gutter="20">
<el-col :span="24" class="mb-4">
<!-- 字典缓存 信息 -->
<el-card class="art-table-card" shadow="never">
<template #header>
<span class="text-lg font-medium">数据字典-缓存信息</span>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="缓存TAG">
<div class="flex-c">
<span>{{ cacheInfo.dict_cache?.tag }}</span>
<ElButton
v-permission="'core:server:clear'"
class="ml-2"
v-ripple
@click="handleClearCache(cacheInfo.dict_cache?.tag)"
>
<template #icon>
<ArtSvgIcon icon="ri:eraser-line" />
</template>
清理缓存
</ElButton>
</div>
</el-descriptions-item>
<el-descriptions-item label="有效期">
{{ cacheInfo.dict_cache?.expire }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="24" class="mb-4">
<!-- 配置缓存 信息 -->
<el-card class="art-table-card" shadow="never">
<template #header>
<span class="text-lg font-medium">系统配置-缓存信息</span>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="缓存TAG">
<div class="flex-c">
<span>{{ cacheInfo.config_cache?.tag }}</span>
<ElButton
v-permission="'core:server:clear'"
class="ml-2"
v-ripple
@click="handleClearCache(cacheInfo.config_cache?.tag)"
>
<template #icon>
<ArtSvgIcon icon="ri:eraser-line" />
</template>
清理缓存
</ElButton>
</div>
</el-descriptions-item>
<el-descriptions-item label="有效期">
{{ cacheInfo.config_cache?.expire }}
</el-descriptions-item>
<el-descriptions-item label="缓存前缀">
{{ cacheInfo.config_cache?.prefix }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="24" class="mb-4">
<!-- 菜单缓存 信息 -->
<el-card class="art-table-card" shadow="never">
<template #header>
<span class="text-lg font-medium">菜单数据-缓存信息</span>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="缓存TAG">
<div class="flex-c">
<span>{{ cacheInfo.menu_cache?.tag }}</span>
<ElButton
v-permission="'core:server:clear'"
class="ml-2"
v-ripple
@click="handleClearCache(cacheInfo.menu_cache?.tag)"
>
<template #icon>
<ArtSvgIcon icon="ri:eraser-line" />
</template>
清理
</ElButton>
</div>
</el-descriptions-item>
<el-descriptions-item label="有效期">
{{ cacheInfo.menu_cache?.expire }}
</el-descriptions-item>
<el-descriptions-item label="缓存前缀">
{{ cacheInfo.menu_cache?.prefix }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="24" class="mb-4">
<!-- 权限缓存 信息 -->
<el-card class="art-table-card" shadow="never">
<template #header>
<span class="text-lg font-medium">权限按钮-缓存信息</span>
<span class="text-sm text-gray-500"> 缓存权限按钮的数据 </span>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="缓存TAG">
<div class="flex-c">
<span>{{ cacheInfo.button_cache?.tag }}</span>
<ElButton
v-permission="'core:server:clear'"
class="ml-2"
v-ripple
@click="handleClearCache(cacheInfo.button_cache?.tag)"
>
<template #icon>
<ArtSvgIcon icon="ri:eraser-line" />
</template>
清理
</ElButton>
</div>
</el-descriptions-item>
<el-descriptions-item label="有效期">
{{ cacheInfo.button_cache?.expire }}
</el-descriptions-item>
<el-descriptions-item label="缓存前缀">
{{ cacheInfo.button_cache?.prefix }}
</el-descriptions-item>
<el-descriptions-item label="角色前缀">
{{ cacheInfo.button_cache?.role }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="24" class="mb-4">
<!-- 反射文件缓存 信息 -->
<el-card class="art-table-card" shadow="never">
<template #header>
<span class="text-lg font-medium">反射文件-缓存信息</span>
<span class="text-sm text-gray-500"> 缓存反射文件的反射属性的方法名称和权限参数 </span>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="缓存TAG">
<div class="flex-c">
<span>{{ cacheInfo.reflection_cache?.tag }}</span>
<ElButton
v-permission="'core:server:clear'"
class="ml-2"
v-ripple
@click="handleClearCache(cacheInfo.reflection_cache?.tag)"
>
<template #icon>
<ArtSvgIcon icon="ri:eraser-line" />
</template>
清理缓存
</ElButton>
</div>
</el-descriptions-item>
<el-descriptions-item label="有效期">
{{ cacheInfo.reflection_cache?.expire }}
</el-descriptions-item>
<el-descriptions-item label="非验证方法缓存前缀">
{{ cacheInfo.reflection_cache?.no_need }}
</el-descriptions-item>
<el-descriptions-item label="方法名称和权限缓存参数">
{{ cacheInfo.reflection_cache?.attr }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import api from '@/api/safeguard/server'
import { onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const loading = ref(false)
const cacheInfo = reactive({
menu_cache: {} as any,
button_cache: {} as any,
config_cache: {} as any,
dict_cache: {} as any,
reflection_cache: {} as any
})
/**
* 更新缓存信息
*/
const updateCacheInfo = async () => {
loading.value = true
try {
const data = await api.cache({})
cacheInfo.menu_cache = data.menu_cache
cacheInfo.button_cache = data.button_cache
cacheInfo.config_cache = data.config_cache
cacheInfo.dict_cache = data.dict_cache
cacheInfo.reflection_cache = data.reflection_cache
} finally {
loading.value = false
}
}
/**
* 清理缓存
*/
const handleClearCache = (tag: string): void => {
if (!tag) {
ElMessage.warning('请选择要清理的缓存')
return
}
ElMessageBox.confirm(`确定要清理标签:【${tag}】的缓存吗?`, '清理选中缓存', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(() => {
api.clear({ tag }).then(() => {
ElMessage.success('操作成功')
updateCacheInfo()
})
})
}
onMounted(() => {
updateCacheInfo()
})
</script>
<style lang="scss" scoped>
:deep(.el-descriptions__label) {
width: 200px;
}
:deep(.el-descriptions__content) {
width: 400px;
}
</style>

View File

@@ -0,0 +1,214 @@
<template>
<div class="art-full-height">
<!-- 搜索面板 -->
<TableSearch
v-model="searchForm"
@search="handleSearch"
@reset="resetSearchParams"
></TableSearch>
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<ElSpace wrap>
<ElButton
v-permission="'core:database:edit'"
:disabled="selectedRows.length === 0"
@click="handleOptimizeRows()"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:tools-fill" />
</template>
优化表
</ElButton>
<ElButton
v-permission="'core:database:edit'"
:disabled="selectedRows.length === 0"
@click="handleFragmentRows()"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:wrench-line" />
</template>
清理碎片
</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
rowKey="name"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex gap-2">
<SaButton
v-permission="'core:database:index'"
type="primary"
icon="ri:node-tree"
tool-tip="表结构"
@click="handleTableDialog(row)"
/>
<SaButton
v-permission="'core:recycle:index'"
type="success"
icon="ri:recycle-line"
tool-tip="回收站"
@click="handleRecycleDialog(row)"
/>
</div>
</template>
</ArtTable>
</ElCard>
<!-- 表结构信息 -->
<TableDialog v-model="dialogVisible" :data="dialogData" />
<!-- 回收站 -->
<RecycleList v-model="recycleVisible" :data="recycleData" />
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import { ElMessageBox } from 'element-plus'
import api from '@/api/safeguard/database'
import TableSearch from './modules/table-search.vue'
import TableDialog from './modules/table-dialog.vue'
import RecycleList from './modules/recycle-list.vue'
// 搜索表单
const searchForm = ref({
name: undefined,
orderField: 'create_time',
orderType: 'desc'
})
// 搜索处理
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
getData()
}
// 表格配置
const {
columns,
columnChecks,
data,
loading,
getData,
searchParams,
pagination,
resetSearchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
apiParams: {
...searchForm.value
},
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'name', label: '表名称', minWidth: 200 },
{ prop: 'comment', label: '表注释', minWidth: 150, showOverflowTooltip: true },
{ prop: 'engine', label: '表引擎', width: 120 },
{ prop: 'update_time', label: '更新时间', width: 180, sortable: true },
{ prop: 'rows', label: '总行数', width: 120 },
{ prop: 'data_free', label: '碎片大小', width: 120 },
{ prop: 'data_length', label: '数据大小', width: 120 },
{ prop: 'collation', label: '字符集', width: 180 },
{ prop: 'create_time', label: '创建时间', width: 180, sortable: true },
{ prop: 'operation', label: '操作', width: 100, fixed: 'right', useSlot: true }
]
}
})
// 编辑配置
const { dialogVisible, dialogData, selectedRows, handleSelectionChange } = useSaiAdmin()
const recycleVisible = ref(false)
const recycleData = ref({})
/**
* 表结构
* @param row
*/
const handleTableDialog = (row: Record<string, any>): void => {
dialogVisible.value = true
dialogData.value = row
}
/**
* 回收站
* @param row
*/
const handleRecycleDialog = (row: Record<string, any>): void => {
recycleVisible.value = true
recycleData.value = row
}
/**
* 优化表
*/
const handleOptimizeRows = (): void => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请选择要优化的行')
return
}
ElMessageBox.confirm(
`确定要优化选中的 ${selectedRows.value.length} 条数据吗?`,
'优化选中数据',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}
).then(() => {
api.optimize({ tables: selectedRows.value.map((row) => row.name) }).then(() => {
ElMessage.success('操作成功')
refreshData()
selectedRows.value = []
})
})
}
/**
* 清理表碎片
*/
const handleFragmentRows = (): void => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请选择要清理碎片的行')
return
}
ElMessageBox.confirm(
`确定要清理选中的 ${selectedRows.value.length} 条数据吗?`,
'清理碎片操作',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}
).then(() => {
api.fragment({ tables: selectedRows.value.map((row) => row.name) }).then(() => {
ElMessage.success('操作成功')
refreshData()
selectedRows.value = []
})
})
}
</script>

View File

@@ -0,0 +1,212 @@
<template>
<el-drawer
v-model="visible"
:title="`回收站 - ${props.data?.name}`"
size="70%"
destroy-on-close
:close-on-click-modal="false"
@close="handleClose"
>
<div class="art-full-height">
<!-- 表格头部 -->
<div>
<ElSpace wrap>
<ElButton
v-permission="'core:recycle:edit'"
:disabled="selectedRows.length === 0"
@click="handleDestroyRows()"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-5-line" />
</template>
销毁
</ElButton>
<ElButton
v-permission="'core:recycle:edit'"
:disabled="selectedRows.length === 0"
@click="handleRestoreRows()"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:restart-line" />
</template>
恢复
</ElButton>
</ElSpace>
</div>
<!-- 表格 -->
<ArtTable
ref="tableRef"
rowKey="id"
:loading="loading"
:data="tableData"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 数据详情插槽 -->
<template #json_data="{ row }">
{{ JSON.stringify(row) }}
</template>
</ArtTable>
</div>
</el-drawer>
</template>
<script setup lang="ts">
import api from '@/api/safeguard/database'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
interface Props {
modelValue: boolean
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
data: undefined
})
const emit = defineEmits<Emits>()
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
refreshData()
}
}
const refreshData = () => {
searchForm.value.table = props.data?.name
Object.assign(searchParams, searchForm.value)
getData()
}
const searchForm = ref({
table: null
})
const {
loading,
data: tableData,
columns,
getData,
pagination,
searchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange
} = useTable({
core: {
apiFn: api.getRecycle,
immediate: false,
apiParams: {
...searchForm.value
},
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'delete_time', label: '删除时间', width: 180 },
{ prop: 'json_data', label: '数据详情', useSlot: true, showOverflowTooltip: true }
]
}
})
// 编辑配置
const { handleSelectionChange, selectedRows } = useSaiAdmin()
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
}
/**
* 销毁选中数据
*/
const handleDestroyRows = (): void => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请选择要销毁的行')
return
}
ElMessageBox.confirm(
`确定要销毁选中的 ${selectedRows.value.length} 条数据吗?`,
'销毁选中数据',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}
).then(() => {
api
.delete({ table: searchForm.value.table, ids: selectedRows.value.map((row) => row.id) })
.then(() => {
ElMessage.success('操作成功')
refreshData()
selectedRows.value = []
})
})
}
/**
* 恢复选中数据
*/
const handleRestoreRows = (): void => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请选择要恢复的行')
return
}
ElMessageBox.confirm(
`确定要恢复选中的 ${selectedRows.value.length} 条数据吗?`,
'恢复选中数据',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}
).then(() => {
api
.recovery({ table: searchForm.value.table, ids: selectedRows.value.map((row) => row.id) })
.then(() => {
ElMessage.success('操作成功')
refreshData()
selectedRows.value = []
})
})
}
</script>

View File

@@ -0,0 +1,78 @@
<template>
<el-dialog v-model="visible" title="表结构信息" width="800px" align-center @close="handleClose">
<div>
<el-table :data="tableData" style="width: 100%">
<el-table-column prop="column_name" label="字段名称" width="180"> </el-table-column>
<el-table-column prop="column_type" label="字段类型" width="120"> </el-table-column>
<el-table-column prop="column_key" label="字段索引" width="100"> </el-table-column>
<el-table-column prop="column_default" label="默认值" width="100"> </el-table-column>
<el-table-column prop="column_comment" label="字段注释" min-width="200" showOverflowTooltip>
</el-table-column>
</el-table>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import api from '@/api/safeguard/database'
interface Props {
modelValue: boolean
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
data: undefined
})
const emit = defineEmits<Emits>()
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
const tableData = ref<Api.Common.ApiData[]>([])
/**
* 初始化页面数据
*/
const initPage = async () => {
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
if (props.data.name) {
const data = await api.getDetailed({ table: props.data.name })
tableData.value = data
}
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
}
</script>

View File

@@ -0,0 +1,67 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="100px"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="表名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入表名称" clearable />
</el-form-item>
</el-col>
</sa-search-bar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 展开/收起
const isExpanded = ref<boolean>(false)
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 重置
function handleReset() {
searchBarRef.value?.ref.resetFields()
emit('reset')
}
// 搜索
async function handleSearch() {
emit('search', formData.value)
}
// 展开/收起
function handleExpand(expanded: boolean) {
isExpanded.value = expanded
}
// 栅格占据的列数
const setSpan = (span: number) => {
return {
span: span,
xs: 24, // 手机:满宽显示
sm: span >= 12 ? span : 12, // 平板大于等于12保持否则用半宽
md: span >= 8 ? span : 8, // 中等屏幕大于等于8保持否则用三分之一宽
lg: span,
xl: span
}
}
</script>

View File

@@ -0,0 +1,402 @@
<!-- 左右页面 -->
<template>
<div class="art-full-height">
<div class="box-border flex gap-4 h-full max-md:block max-md:gap-0 max-md:h-auto">
<div class="flex-shrink-0 h-full max-md:w-full max-md:h-auto max-md:mb-5">
<ElCard class="left-card art-card-xs flex flex-col h-full mt-0" shadow="never">
<template #header>
<b>数据字典</b>
</template>
<ElSpace wrap>
<SaButton type="primary" icon="ri:refresh-line" @click="refreshTypeData" />
<SaButton
v-permission="'core:dict:edit'"
type="primary"
@click="typeShowDialog('add')"
/>
<SaButton v-permission="'core:dict:edit'" type="secondary" @click="updateTypeDialog" />
<SaButton v-permission="'core:dict:edit'" type="error" @click="deleteTypeDialog" />
</ElSpace>
<ArtTable
rowKey="id"
:loading="loading"
:data="typeData"
:columns="typeColumns"
:pagination="typePagination"
highlight-current-row
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 基础列 -->
<template #name-header="{ column }">
<ElPopover placement="bottom" :width="200" trigger="hover">
<template #reference>
<div class="flex items-center gap-2 text-theme c-p custom-header">
<span>{{ column.label }}</span>
<ElIcon>
<Search />
</ElIcon>
</div>
</template>
<ElInput
v-model="typeSearch.name"
placeholder="搜索字典名称"
size="small"
clearable
@input="handleTypeSearch"
>
<template #prefix>
<ElIcon>
<Search />
</ElIcon>
</template>
</ElInput>
</ElPopover>
</template>
<template #code-header="{ column }">
<ElPopover placement="bottom" :width="200" trigger="hover">
<template #reference>
<div class="flex items-center gap-2 text-theme c-p custom-header">
<span>{{ column.label }}</span>
<ElIcon>
<Search />
</ElIcon>
</div>
</template>
<ElInput
v-model="typeSearch.code"
placeholder="搜索字典标识"
size="small"
clearable
@input="handleTypeSearch"
>
<template #prefix>
<ElIcon>
<Search />
</ElIcon>
</template>
</ElInput>
</ElPopover>
</template>
<template #id="{ row }">
<ElRadio
v-model="selectedId"
:value="row.id"
@update:modelValue="handleTypeChange(row.id, row)"
/>
</template>
</ArtTable>
</ElCard>
</div>
<div class="flex flex-col flex-1 min-w-0" v-if="selectedId === 0">
<ElCard class="flex flex-col flex-5 min-h-0 !mt-0" shadow="never">
<el-empty description="请先选择左侧字典类型配置" />
</ElCard>
</div>
<div class="flex flex-col flex-1 min-w-0" v-if="selectedId > 0">
<DictSearch v-model="searchForm" @search="handleSearch" @reset="handleReset" />
<ElCard class="flex flex-col flex-5 min-h-0 art-table-card" shadow="never">
<ElSpace wrap>
<ElButton
v-permission="'core:dict:edit'"
@click="showDataDialog('add', { type_id: selectedId })"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:add-fill" />
</template>
新增
</ElButton>
<ElButton
v-permission="'core:dict:edit'"
@click="deleteSelectedRows(api.dataDelete, getDictData)"
:disabled="selectedRows.length === 0"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-5-line" />
</template>
删除
</ElButton>
</ElSpace>
<ArtTable
rowKey="id"
:loading="loading"
:data="dictData"
:columns="dictColumns"
:pagination="dictPagination"
highlight-current-row
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 基础列 -->
<template #label="{ row }">
<ElTag
:style="{
backgroundColor: getColor(row.color, 'bg'),
borderColor: getColor(row.color, 'border'),
color: getColor(row.color, 'text')
}"
>
{{ row.label }}
</ElTag>
</template>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex gap-2">
<SaButton
v-permission="'core:dict:edit'"
type="secondary"
@click="showDataDialog('edit', row)"
/>
<SaButton
v-permission="'core:dict:edit'"
type="error"
@click="deleteRow(row, api.dataDelete, getDictData)"
/>
</div>
</template>
</ArtTable>
</ElCard>
</div>
</div>
<!-- 字典编辑弹窗 -->
<TypeEditDialog
v-model="typeVisible"
:dialog-type="typeDialogType"
:data="currentTypeData"
@success="getTypeData()"
/>
<!-- 字典项编辑弹窗 -->
<DictEditDialog
v-model="dictVisible"
:dialog-type="dictDialogType"
:data="currentDictData"
@success="getDictData()"
/>
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import api from '@/api/safeguard/dict'
import DictSearch from '@/views/safeguard/dict/modules/dict-search.vue'
import DictEditDialog from './modules/dict-edit-dialog.vue'
import TypeEditDialog from './modules/type-edit-dialog.vue'
// 字典类型数据
const {
dialogType: typeDialogType,
dialogVisible: typeVisible,
dialogData: currentTypeData,
showDialog: typeShowDialog,
deleteRow: typeDeleteRow
} = useSaiAdmin()
// 字典类型
const selectedId = ref(0)
const selectedRow = ref({})
const typeSearch = ref({
name: '',
code: ''
})
/** 修改字典类型 */
const updateTypeDialog = () => {
if (selectedId.value === 0) {
ElMessage.error('请选择要修改的数据')
return
}
typeShowDialog('edit', { ...selectedRow.value })
}
/** 删除字典类型 */
const deleteTypeDialog = () => {
if (selectedId.value === 0) {
ElMessage.error('请选择要删除的数据')
return
}
typeDeleteRow({ ...selectedRow.value }, api.delete, refreshTypeData)
}
/** 字典类型搜索 */
const handleTypeSearch = () => {
Object.assign(searchTypeParams, typeSearch.value)
getTypeData()
}
/** 字典类型切换 */
const handleTypeChange = (val: any, row?: any) => {
selectedId.value = val
selectedRow.value = row
searchForm.value.type_id = val
Object.assign(searchParams, searchForm.value)
getDictData()
}
/** 刷新数据 */
const refreshTypeData = () => {
selectedId.value = 0
selectedRow.value = {}
getTypeData()
getDictData()
}
// 字典类型数据
const {
data: typeData,
columns: typeColumns,
getData: getTypeData,
searchParams: searchTypeParams,
loading,
pagination: typePagination,
handleSizeChange,
handleCurrentChange
} = useTable({
core: {
apiFn: api.typeList,
apiParams: {
...typeSearch.value
},
columnsFactory: () => [
{ prop: 'id', label: '选中', width: 80, align: 'center', useSlot: true },
{ prop: 'name', label: '字典名称', useHeaderSlot: true, width: 150 },
{ prop: 'code', label: '字典标识', useHeaderSlot: true, width: 150 },
{ prop: 'status', label: '状态', saiType: 'dict', saiDict: 'data_status', width: 100 }
]
}
})
// 字典项数据
const {
dialogType: dictDialogType,
dialogVisible: dictVisible,
dialogData: currentDictData,
showDialog: showDataDialog,
deleteRow,
handleSelectionChange,
selectedRows,
deleteSelectedRows
} = useSaiAdmin()
/** 字典项搜索 */
const searchForm = ref({
label: '',
value: '',
status: '',
type_id: null
})
// 字典项数据
const {
data: dictData,
columns: dictColumns,
getData: getDictData,
pagination: dictPagination,
searchParams
} = useTable({
core: {
apiFn: api.dataList,
immediate: false,
apiParams: {
...searchForm.value
},
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'label', label: '字典标签', useSlot: true },
{ prop: 'value', label: '字典键值' },
{ prop: 'color', label: '颜色' },
{ prop: 'sort', label: '排序' },
{ prop: 'status', label: '状态', saiType: 'dict', saiDict: 'data_status' },
{ prop: 'operation', label: '操作', useSlot: true, width: 120 }
]
}
})
// 字典项搜索
const handleSearch = (params: Record<string, any>) => {
if (selectedId.value) {
Object.assign(searchParams, params)
getDictData()
}
}
// 字典项重置搜索
const handleReset = () => {
if (!selectedId.value) {
ElMessage.warning('请选择字典类型')
return
}
Object.assign(searchParams, {
label: '',
value: '',
status: '',
type_id: selectedId.value
})
getDictData()
}
const getColor = (color: string | undefined, type: 'bg' | 'border' | 'text') => {
// 如果没有指定颜色,使用默认主色调
if (!color) {
const colors = {
bg: 'var(--el-color-primary-light-9)',
border: 'var(--el-color-primary-light-8)',
text: 'var(--el-color-primary)'
}
return colors[type]
}
// 如果是 hex 颜色,转换为 RGB
let r, g, b
if (color.startsWith('#')) {
const hex = color.slice(1)
r = parseInt(hex.slice(0, 2), 16)
g = parseInt(hex.slice(2, 4), 16)
b = parseInt(hex.slice(4, 6), 16)
} else if (color.startsWith('rgb')) {
const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/)
if (match) {
r = parseInt(match[1])
g = parseInt(match[2])
b = parseInt(match[3])
} else {
return color
}
} else {
return color
}
// 根据类型返回不同的颜色变体
switch (type) {
case 'bg':
// 背景色 - 更浅的版本
return `rgba(${r}, ${g}, ${b}, 0.1)`
case 'border':
// 边框色 - 中等亮度
return `rgba(${r}, ${g}, ${b}, 0.3)`
case 'text':
// 文字色 - 原始颜色
return `rgb(${r}, ${g}, ${b})`
default:
return color
}
}
</script>
<style scoped>
.left-card :deep(.el-card__body) {
flex: 1;
min-height: 0;
padding: 10px 2px 10px 10px;
}
</style>

View File

@@ -0,0 +1,184 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? '新增字典项数据' : '编辑字典项数据'"
width="600px"
align-center
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
<el-form-item label="字典标签" prop="label">
<el-input v-model="formData.label" placeholder="请输入字典标签" />
</el-form-item>
<el-form-item label="字典键值" prop="value">
<el-input v-model="formData.value" placeholder="请输入字典键值" />
</el-form-item>
<el-form-item label="颜色选择" prop="color">
<el-color-picker v-model="formData.color" color-format="hex" :predefine="predefineColors" />
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" placeholder="请输入排序" />
</el-form-item>
<el-form-item label="状态" prop="status">
<SaiRadio v-model="formData.status" dict="data_status" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '@/api/safeguard/dict'
import { ElMessage } from 'element-plus'
import { useDictStore } from '@/store/modules/dict'
import SaiRadio from '@/components/sai/sa-radio/index.vue'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
dialogType: string
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
data: undefined
})
const emit = defineEmits<Emits>()
const dictStore = useDictStore()
const formRef = ref<FormInstance>()
const predefineColors = ref([
'#ff4500',
'#ff8c00',
'#ffd700',
'#90ee90',
'#00ced1',
'#1e90ff',
'#c71585',
'#5d87ff',
'#b48df3',
'#1d84ff',
'#60c041',
'#38c0fc',
'#f9901f',
'#ff80c8',
'#909399'
])
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
label: [{ required: true, message: '请输入字典标签', trigger: 'blur' }],
value: [{ required: true, message: '请输入字典键值', trigger: 'blur' }]
})
/**
* 初始数据
*/
const initialFormData = {
id: null,
type_id: '',
code: '',
label: '',
color: '#5d87ff',
value: '',
remark: '',
sort: 100,
status: 1
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
// 初始化页面数据
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
initForm(props.data)
}
}
/**
* 初始化表单数据
*/
const initForm = (data: any) => {
if (data) {
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
;(formData as any)[key] = data[key]
}
}
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (props.dialogType === 'add') {
await api.dataSave(formData)
ElMessage.success('新增成功')
} else {
await api.dataUpdate(formData)
ElMessage.success('修改成功')
}
dictStore.refresh()
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,76 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="80px"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="字典标签" prop="label">
<el-input v-model="formData.label" placeholder="请输入字典标签" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="字典键值" prop="value">
<el-input v-model="formData.value" placeholder="请输入字典键值" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="状态" prop="status">
<sa-select v-model="formData.status" dict="data_status" clearable />
</el-form-item>
</el-col>
</sa-search-bar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const isExpanded = ref<boolean>(false)
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 重置
function handleReset() {
searchBarRef.value?.ref.resetFields()
emit('reset')
}
// 搜索
async function handleSearch() {
emit('search', formData.value)
}
// 展开/收起
function handleExpand(expanded: boolean) {
isExpanded.value = expanded
}
// 栅格占据的列数
const setSpan = (span: number) => {
return {
span: span,
xs: 24, // 手机:满宽显示
sm: span >= 12 ? span : 12, // 平板大于等于12保持否则用半宽
md: span >= 8 ? span : 8, // 中等屏幕大于等于8保持否则用三分之一宽
lg: span,
xl: span
}
}
</script>

View File

@@ -0,0 +1,153 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? '新增字典数据' : '编辑字典数据'"
width="600px"
align-center
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
<el-form-item label="字典名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入字典名称" />
</el-form-item>
<el-form-item label="字典标识" prop="code">
<el-input v-model="formData.code" placeholder="请输入字典标识" />
</el-form-item>
<el-form-item label="状态" prop="status">
<SaiRadio v-model="formData.status" dict="data_status" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '@/api/safeguard/dict'
import { ElMessage } from 'element-plus'
import SaiRadio from '@/components/sai/sa-radio/index.vue'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
dialogType: string
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
data: undefined
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入字典名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入字典标识', trigger: 'blur' }]
})
/**
* 初始数据
*/
const initialFormData = {
id: null,
name: '',
code: '',
remark: '',
status: 1
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
// 初始化页面数据
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
initForm(props.data)
}
}
/**
* 初始化表单数据
*/
const initForm = (data: any) => {
if (data) {
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
;(formData as any)[key] = data[key]
}
}
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (props.dialogType === 'add') {
await api.save(formData)
ElMessage.success('新增成功')
} else {
await api.update(formData)
ElMessage.success('修改成功')
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,121 @@
<template>
<div class="art-full-height">
<!-- 搜索面板 -->
<TableSearch
v-model="searchForm"
@search="handleSearch"
@reset="resetSearchParams"
></TableSearch>
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<ElSpace wrap>
<ElButton
v-permission="'core:email:destroy'"
:disabled="selectedRows.length === 0"
@click="deleteSelectedRows(api.delete, refreshData)"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-5-line" />
</template>
删除
</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
rowKey="id"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 操作列 -->
<template #status="{ row }">
<ElTag v-if="row.status == 'success'" type="success">成功</ElTag>
<ElTag v-else type="danger">失败</ElTag>
</template>
<template #operation="{ row }">
<div class="flex">
<SaButton
v-permission="'core:email:destroy'"
type="error"
@click="deleteRow(row, api.delete, refreshData)"
/>
</div>
</template>
</ArtTable>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import api from '@/api/safeguard/emailLog'
import TableSearch from './modules/table-search.vue'
// 搜索表单
const searchForm = ref({
from: undefined,
email: undefined,
status: undefined,
create_time: undefined,
orderField: 'create_time',
orderType: 'desc'
})
// 搜索处理
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
getData()
}
// 表格配置
const {
columns,
columnChecks,
data,
loading,
getData,
searchParams,
pagination,
resetSearchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
apiParams: {
...searchForm.value
},
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'id', label: '编号', width: 100, align: 'center' },
{ prop: 'gateway', label: '服务Host' },
{ prop: 'from', label: '发件人', minWidth: 150, showOverflowTooltip: true },
{ prop: 'email', label: '收件人', minWidth: 150, showOverflowTooltip: true },
{ prop: 'code', label: '验证码' },
{ prop: 'status', label: '发送状态', useSlot: true },
{ prop: 'response', label: '发送结果', minWidth: 150, showOverflowTooltip: true },
{ prop: 'create_time', label: '发送时间', width: 180, sortable: true },
{ prop: 'operation', label: '操作', width: 80, fixed: 'right', useSlot: true }
]
}
})
// 编辑配置
const { deleteRow, deleteSelectedRows, selectedRows, handleSelectionChange } = useSaiAdmin()
</script>

View File

@@ -0,0 +1,93 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="100px"
:showExpand="true"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="发件人" prop="from">
<el-input v-model="formData.from" placeholder="请输入发件人" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="收件人" prop="email">
<el-input v-model="formData.email" placeholder="请输入收件人" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="发送状态" prop="status">
<el-select v-model="formData.status" placeholder="请选择发送状态" clearable>
<el-option label="成功" value="success" />
<el-option label="失败" value="failure" />
</el-select>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(12)" v-show="isExpanded">
<el-form-item label="发送时间" prop="create_time">
<el-date-picker
v-model="formData.create_time"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
clearable
/>
</el-form-item>
</el-col>
</sa-search-bar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 展开/收起
const isExpanded = ref<boolean>(false)
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 重置
function handleReset() {
searchBarRef.value?.ref.resetFields()
emit('reset')
}
// 搜索
async function handleSearch() {
emit('search', formData.value)
}
// 展开/收起
function handleExpand(expanded: boolean) {
isExpanded.value = expanded
}
// 栅格占据的列数
const setSpan = (span: number) => {
return {
span: span,
xs: 24, // 手机:满宽显示
sm: span >= 12 ? span : 12, // 平板大于等于12保持否则用半宽
md: span >= 8 ? span : 8, // 中等屏幕大于等于8保持否则用三分之一宽
lg: span,
xl: span
}
}
</script>

View File

@@ -0,0 +1,122 @@
<template>
<div class="art-full-height">
<!-- 搜索面板 -->
<TableSearch
v-model="searchForm"
@search="handleSearch"
@reset="resetSearchParams"
></TableSearch>
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<ElSpace wrap>
<ElButton
v-permission="'core:logs:deleteLogin'"
:disabled="selectedRows.length === 0"
@click="deleteSelectedRows(api.delete, refreshData)"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-5-line" />
</template>
删除
</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
rowKey="id"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 操作列 -->
<template #status="{ row }">
<ElTag v-if="row.status == 1" type="success">成功</ElTag>
<ElTag v-else type="danger">失败</ElTag>
</template>
<template #operation="{ row }">
<div class="flex">
<SaButton
v-permission="'core:logs:deleteLogin'"
type="error"
@click="deleteRow(row, api.delete, refreshData)"
/>
</div>
</template>
</ArtTable>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import api from '@/api/safeguard/loginLog'
import TableSearch from './modules/table-search.vue'
// 搜索表单
const searchForm = ref({
username: undefined,
ip: undefined,
status: undefined,
login_time: undefined,
orderField: 'login_time',
orderType: 'desc'
})
// 搜索处理
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
getData()
}
// 表格配置
const {
columns,
columnChecks,
data,
loading,
getData,
searchParams,
pagination,
resetSearchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
apiParams: {
...searchForm.value
},
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'id', label: '编号', width: 100, align: 'center' },
{ prop: 'username', label: '登录用户' },
{ prop: 'status', label: '登录状态', useSlot: true },
{ prop: 'ip', label: '登录IP' },
{ prop: 'ip_location', label: '登录地点' },
{ prop: 'os', label: '操作系统' },
{ prop: 'browser', label: '浏览器' },
{ prop: 'message', label: '登录信息', showOverflowTooltip: true },
{ prop: 'login_time', label: '登录时间', width: 180, sortable: true },
{ prop: 'operation', label: '操作', width: 80, fixed: 'right', useSlot: true }
]
}
})
// 编辑配置
const { deleteRow, deleteSelectedRows, selectedRows, handleSelectionChange } = useSaiAdmin()
</script>

View File

@@ -0,0 +1,93 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="100px"
:showExpand="true"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="登录用户" prop="username">
<el-input v-model="formData.username" placeholder="请输入登录用户" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="登录IP" prop="ip">
<el-input v-model="formData.ip" placeholder="请输入登录IP" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="登录状态" prop="status">
<el-select v-model="formData.status" placeholder="请选择登录状态" clearable>
<el-option label="成功" value="1" />
<el-option label="失败" value="0" />
</el-select>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(12)" v-show="isExpanded">
<el-form-item label="登录时间" prop="login_time">
<el-date-picker
v-model="formData.login_time"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
clearable
/>
</el-form-item>
</el-col>
</sa-search-bar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 展开/收起
const isExpanded = ref<boolean>(false)
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 重置
function handleReset() {
searchBarRef.value?.ref.resetFields()
emit('reset')
}
// 搜索
async function handleSearch() {
emit('search', formData.value)
}
// 展开/收起
function handleExpand(expanded: boolean) {
isExpanded.value = expanded
}
// 栅格占据的列数
const setSpan = (span: number) => {
return {
span: span,
xs: 24, // 手机:满宽显示
sm: span >= 12 ? span : 12, // 平板大于等于12保持否则用半宽
md: span >= 8 ? span : 8, // 中等屏幕大于等于8保持否则用三分之一宽
lg: span,
xl: span
}
}
</script>

View File

@@ -0,0 +1,170 @@
<template>
<div class="art-full-height">
<!-- 搜索面板 -->
<TableSearch
v-model="searchForm"
@search="handleSearch"
@reset="resetSearchParams"
></TableSearch>
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<ElSpace wrap>
<ElButton
v-permission="'core:logs:deleteOper'"
:disabled="selectedRows.length === 0"
@click="deleteSelectedRows(api.delete, refreshData)"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-5-line" />
</template>
删除
</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
rowKey="id"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex gap-2">
<SaButton type="success" @click="handleParams(row)" />
<SaButton
v-permission="'core:logs:deleteOper'"
type="error"
@click="deleteRow(row, api.delete, refreshData)"
/>
</div>
</template>
</ArtTable>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import { ElMessageBox } from 'element-plus'
import api from '@/api/safeguard/operLog'
import TableSearch from './modules/table-search.vue'
// 搜索表单
const searchForm = ref({
username: undefined,
ip: undefined,
service_name: undefined,
router: undefined,
create_time: undefined,
orderField: 'create_time',
orderType: 'desc'
})
// 搜索处理
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
getData()
}
// 表格配置
const {
columns,
columnChecks,
data,
loading,
getData,
searchParams,
pagination,
resetSearchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
apiParams: {
...searchForm.value
},
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'id', label: '编号', width: 100, align: 'center' },
{ prop: 'username', label: '操作用户' },
{ prop: 'service_name', label: '业务名称' },
{ prop: 'router', label: '路由', minWidth: 180, showOverflowTooltip: true },
{ prop: 'ip', label: '操作IP' },
{ prop: 'ip_location', label: '操作地点' },
{ prop: 'create_time', label: '操作时间', width: 180, sortable: true },
{ prop: 'operation', label: '操作', width: 100, fixed: 'right', useSlot: true }
]
}
})
// 编辑配置
const { deleteRow, deleteSelectedRows, selectedRows, handleSelectionChange } = useSaiAdmin()
// 预览参数
const handleParams = (row: any) => {
let formattedData = row.request_data
// 尝试格式化JSON数据
if (row.request_data) {
try {
// 如果已经是对象,直接格式化;如果是字符串,先解析再格式化
const parsedData =
typeof row.request_data === 'string' ? JSON.parse(row.request_data) : row.request_data
formattedData = JSON.stringify(parsedData, null, 2)
} catch (error) {
// 如果解析失败,保持原样显示
formattedData = row.request_data
console.log('Error parsing JSON:', error)
}
}
ElMessageBox({
title: '请求参数',
message: h(
'div',
{
style: {
maxHeight: '400px',
minWidth: '380px',
overflow: 'auto',
backgroundColor: '#f5f5f5',
padding: '16px',
borderRadius: '4px'
}
},
[
h(
'pre',
{
style: {
margin: 0,
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
fontSize: '14px',
lineHeight: '1.5',
color: '#333'
}
},
formattedData
)
]
),
callback: () => {}
})
}
</script>

View File

@@ -0,0 +1,90 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="100px"
:showExpand="true"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="操作用户" prop="username">
<el-input v-model="formData.username" placeholder="请输入操作用户" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="操作路由" prop="router">
<el-input v-model="formData.router" placeholder="请输入操作路由" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="操作IP" prop="ip">
<el-input v-model="formData.ip" placeholder="请输入操作IP" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(12)" v-show="isExpanded">
<el-form-item label="操作时间" prop="create_time">
<el-date-picker
v-model="formData.create_time"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
clearable
/>
</el-form-item>
</el-col>
</sa-search-bar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 展开/收起
const isExpanded = ref<boolean>(false)
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 重置
function handleReset() {
searchBarRef.value?.ref.resetFields()
emit('reset')
}
// 搜索
async function handleSearch() {
emit('search', formData.value)
}
// 展开/收起
function handleExpand(expanded: boolean) {
isExpanded.value = expanded
}
// 栅格占据的列数
const setSpan = (span: number) => {
return {
span: span,
xs: 24, // 手机:满宽显示
sm: span >= 12 ? span : 12, // 平板大于等于12保持否则用半宽
md: span >= 8 ? span : 8, // 中等屏幕大于等于8保持否则用三分之一宽
lg: span,
xl: span
}
}
</script>

View File

@@ -0,0 +1,213 @@
<template>
<div class="page-content mb-5">
<el-row :gutter="20">
<!-- 内存 信息 -->
<el-col :span="24" class="mb-4">
<el-card class="art-table-card" shadow="never">
<template #header>
<span class="text-lg font-medium">内存信息</span>
</template>
<div class="flex justify-between">
<div class="flex-1">
<el-descriptions :column="1" border>
<el-descriptions-item label="总内存">
{{ serverInfo.memory.total }}
</el-descriptions-item>
<el-descriptions-item label="已使用内存">
{{ serverInfo.memory.used }}
</el-descriptions-item>
<el-descriptions-item label="PHP使用内存">
{{ serverInfo.memory.php }}
</el-descriptions-item>
<el-descriptions-item label="空闲内存">
{{ serverInfo.memory.free }}
</el-descriptions-item>
<el-descriptions-item label="使用率">
{{ serverInfo.memory.rate }}
</el-descriptions-item>
</el-descriptions>
</div>
<div class="w-80 p-4 text-center">
<div class="pb-3.5">
<span class="text-base font-medium">内存使用率</span>
</div>
<el-progress
type="dashboard"
:percentage="Number.parseFloat(serverInfo.memory.rate)"
/>
</div>
</div>
</el-card>
</el-col>
<!-- PHP 信息 -->
<el-col :span="24" class="mb-4">
<el-card class="art-table-card" shadow="never">
<template #header>
<span class="text-lg font-medium">PHP及环境信息</span>
</template>
<div class="py-2">
<el-descriptions :column="2" border class="php-config" v-if="serverInfo.phpEnv">
<el-descriptions-item
label="PHP版本"
label-class-name="php-label"
content-class-name="php-content"
>
{{ serverInfo.phpEnv?.php_version }}
</el-descriptions-item>
<el-descriptions-item
label="操作系统"
label-class-name="php-label"
content-class-name="php-content"
>
{{ serverInfo.phpEnv?.os }}
</el-descriptions-item>
<el-descriptions-item
label="项目路径"
label-class-name="php-label"
content-class-name="php-content"
>
<div class="project-path">{{ serverInfo.phpEnv?.project_path }}</div>
</el-descriptions-item>
<el-descriptions-item
label="内存限制"
label-class-name="php-label"
content-class-name="php-content"
>
{{ serverInfo.phpEnv?.memory_limit }}
</el-descriptions-item>
<el-descriptions-item
label="最大执行时间"
label-class-name="php-label"
content-class-name="php-content"
>
{{
serverInfo.phpEnv?.max_execution_time === '0'
? '无限制'
: `${serverInfo.phpEnv?.max_execution_time}`
}}
</el-descriptions-item>
<el-descriptions-item
label="错误报告"
label-class-name="php-label"
content-class-name="php-content"
>
{{ serverInfo.phpEnv?.error_reporting }}
</el-descriptions-item>
<el-descriptions-item
label="显示错误"
label-class-name="php-label"
content-class-name="php-content"
>
{{ serverInfo.phpEnv?.display_errors }}
</el-descriptions-item>
<el-descriptions-item
label="上传限制"
label-class-name="php-label"
content-class-name="php-content"
>
{{ serverInfo.phpEnv?.upload_max_filesize }}
</el-descriptions-item>
<el-descriptions-item
label="POST大小"
label-class-name="php-label"
content-class-name="php-content"
>
{{ serverInfo.phpEnv?.post_max_size }}
</el-descriptions-item>
<el-descriptions-item
label="扩展目录"
label-class-name="php-label"
content-class-name="php-content"
>
{{ serverInfo.phpEnv?.extension_dir }}
</el-descriptions-item>
<el-descriptions-item
label="扩展目录"
label-class-name="php-label"
content-class-name="php-content"
>
{{ serverInfo.phpEnv?.loaded_extensions }}
</el-descriptions-item>
</el-descriptions>
</div>
</el-card>
</el-col>
<!-- 磁盘 信息 -->
<el-col :span="24" class="mb-4">
<el-card class="art-table-card" shadow="never">
<template #header>
<div class="card-header">
<span><i class="el-icon-disk"></i> 磁盘监控</span>
</div>
</template>
<el-table :data="serverInfo.disk" style="width: 100%">
<el-table-column prop="filesystem" label="文件系统" />
<el-table-column prop="size" label="总大小" />
<el-table-column prop="used" label="已用空间" />
<el-table-column prop="available" label="可用空间" />
<el-table-column prop="use_percentage" label="使用率">
<template #default="{ row }">
<el-progress
:percentage="parseInt(row.use_percentage.replace('%', ''))"
:stroke-width="12"
:show-text="true"
/>
</template>
</el-table-column>
<el-table-column prop="mounted_on" label="挂载点" />
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import api from '@/api/safeguard/server'
import { onMounted } from 'vue'
const loading = ref(false)
const serverInfo = reactive({
memory: {
total: '',
used: '',
rate: '',
php: '',
free: ''
},
disk: [] as any[],
phpEnv: {} as any
})
/**
* 更新服务器信息
*/
const updateServer = async () => {
loading.value = true
try {
const data = await api.monitor({})
serverInfo.memory = data.memory
serverInfo.phpEnv = data.phpEnv
serverInfo.disk = data.disk
} finally {
loading.value = false
}
}
onMounted(() => {
updateServer()
})
</script>
<style lang="scss" scoped>
:deep(.el-descriptions__label) {
width: 200px;
}
:deep(.el-descriptions__content) {
width: 400px;
}
</style>