调整数据库表的单复数
This commit is contained in:
239
resources/admin/src/pages/system/city/components/SaveDialog.vue
Normal file
239
resources/admin/src/pages/system/city/components/SaveDialog.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
:title="title"
|
||||
:width="500"
|
||||
:confirm-loading="loading"
|
||||
@ok="handleOk"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 18 }"
|
||||
>
|
||||
<a-form-item label="城市名称" name="title">
|
||||
<a-input
|
||||
v-model:value="formData.title"
|
||||
placeholder="请输入城市名称"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="城市编码" name="code">
|
||||
<a-input
|
||||
v-model:value="formData.code"
|
||||
placeholder="请输入城市编码"
|
||||
allow-clear
|
||||
:disabled="mode === 'edit'"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
v-if="mode === 'add'"
|
||||
label="父级城市"
|
||||
name="parent_code"
|
||||
>
|
||||
<a-select
|
||||
v-model:value="formData.parent_code"
|
||||
placeholder="请选择父级城市"
|
||||
allow-clear
|
||||
show-search
|
||||
:filter-option="filterOption"
|
||||
>
|
||||
<a-select-option :value="null"
|
||||
>无(顶级城市)</a-select-option
|
||||
>
|
||||
<a-select-option
|
||||
v-for="city in flatCities"
|
||||
:key="city.code"
|
||||
:value="city.code"
|
||||
>
|
||||
{{ city.title }} ({{ city.code }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
v-if="mode === 'addChild'"
|
||||
label="父级城市"
|
||||
name="parent_code"
|
||||
>
|
||||
<a-input v-model:value="parentCityTitle" disabled />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch } from "vue";
|
||||
import { message } from "ant-design-vue";
|
||||
import api from "@/api/system";
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
cityData: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: "add",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:visible", "success"]);
|
||||
|
||||
const formRef = ref(null);
|
||||
const loading = ref(false);
|
||||
const flatCities = ref([]);
|
||||
const parentCityTitle = ref("");
|
||||
|
||||
const formData = reactive({
|
||||
title: "",
|
||||
code: "",
|
||||
parent_code: null,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
title: [
|
||||
{ required: true, message: "请输入城市名称", trigger: "blur" },
|
||||
{ max: 100, message: "城市名称不能超过100个字符", trigger: "blur" },
|
||||
],
|
||||
code: [
|
||||
{ required: true, message: "请输入城市编码", trigger: "blur" },
|
||||
{ max: 50, message: "城市编码不能超过50个字符", trigger: "blur" },
|
||||
{
|
||||
pattern: /^[A-Za-z0-9_-]+$/,
|
||||
message: "城市编码只能包含字母、数字、下划线和横线",
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const title = computed(() => {
|
||||
const titles = {
|
||||
add: "新增城市",
|
||||
addChild: "新增子城市",
|
||||
edit: "编辑城市",
|
||||
};
|
||||
return titles[props.mode] || "城市";
|
||||
});
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit("update:visible", val),
|
||||
});
|
||||
|
||||
// 加载所有城市(用于父级选择)
|
||||
const loadAllCities = async () => {
|
||||
try {
|
||||
const res = await api.cities.list.get({ page_size: 1000 });
|
||||
if (res.code === 200) {
|
||||
flatCities.value = res.data.list || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载城市列表失败", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索过滤
|
||||
const filterOption = (input, option) => {
|
||||
return option.children[0].children
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase());
|
||||
};
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
formData.title = "";
|
||||
formData.code = "";
|
||||
formData.parent_code = null;
|
||||
parentCityTitle.value = "";
|
||||
formRef.value?.clearValidate();
|
||||
};
|
||||
|
||||
// 监听 cityData 变化
|
||||
watch(
|
||||
() => props.cityData,
|
||||
(val) => {
|
||||
if (val && props.visible) {
|
||||
formData.title = val.title || "";
|
||||
formData.code = val.code || "";
|
||||
formData.parent_code = val.parent_code || null;
|
||||
|
||||
if (props.mode === "addChild" && val.parent_code) {
|
||||
// 查找父级城市名称
|
||||
const parentCity = flatCities.value.find(
|
||||
(city) => city.code === val.parent_code,
|
||||
);
|
||||
parentCityTitle.value = parentCity
|
||||
? `${parentCity.title} (${parentCity.code})`
|
||||
: val.parent_code;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
// 监听弹窗打开
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
loadAllCities();
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 确定
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
loading.value = true;
|
||||
|
||||
const data = {
|
||||
title: formData.title,
|
||||
code: formData.code,
|
||||
};
|
||||
|
||||
if (props.mode === "add" && formData.parent_code) {
|
||||
data.parent_code = formData.parent_code;
|
||||
}
|
||||
|
||||
let res;
|
||||
if (props.mode === "add" || props.mode === "addChild") {
|
||||
res = await api.cities.add.post(data);
|
||||
} else if (props.mode === "edit") {
|
||||
res = await api.cities.edit.put(props.cityData.id, data);
|
||||
}
|
||||
|
||||
if (res.code === 200) {
|
||||
message.success(props.mode === "edit" ? "更新成功" : "创建成功");
|
||||
emit("success");
|
||||
handleCancel();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.errorFields) {
|
||||
// 表单验证错误,不提示
|
||||
} else {
|
||||
message.error(error.message || "操作失败");
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 取消
|
||||
const handleCancel = () => {
|
||||
resetForm();
|
||||
emit("update:visible", false);
|
||||
};
|
||||
</script>
|
||||
384
resources/admin/src/pages/system/city/index.vue
Normal file
384
resources/admin/src/pages/system/city/index.vue
Normal file
@@ -0,0 +1,384 @@
|
||||
<template>
|
||||
<div class="pages-base-layout city-page">
|
||||
<div class="tool-bar">
|
||||
<div class="left-panel">
|
||||
<a-space>
|
||||
<a-input
|
||||
v-model:value="searchForm.keyword"
|
||||
placeholder="城市名称/编码"
|
||||
allow-clear
|
||||
style="width: 180px"
|
||||
/>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<template #icon><search-outlined /></template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
<template #icon><redo-outlined /></template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
<div class="right-panel">
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<template #icon><plus-outlined /></template>
|
||||
新增
|
||||
</a-button>
|
||||
<a-dropdown>
|
||||
<a-button>
|
||||
更多
|
||||
<down-outlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item @click="handleRefresh">
|
||||
<reload-outlined />
|
||||
刷新
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="handleExpandAll">
|
||||
<expand-outlined />
|
||||
展开全部
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="handleCollapseAll">
|
||||
<compress-outlined />
|
||||
折叠全部
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-content">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tableData"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
:row-key="rowKey"
|
||||
:expand-row-by-click="true"
|
||||
:default-expand-all-rows="false"
|
||||
:expand-icon-column-index="1"
|
||||
:indent-size="20"
|
||||
@expand="handleExpand"
|
||||
>
|
||||
<template #expandedRowRender="{ record }">
|
||||
<a-table
|
||||
:columns="childColumns"
|
||||
:data-source="record.children"
|
||||
:pagination="false"
|
||||
:row-key="rowKey"
|
||||
size="small"
|
||||
:show-header="false"
|
||||
>
|
||||
<template #bodyCell="{ column, record: child }">
|
||||
<template v-if="column.key === 'title'">
|
||||
<a-tag color="blue">{{ child.title }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'code'">
|
||||
{{ child.code }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleAddChild(child)"
|
||||
>
|
||||
新增子级
|
||||
</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleEdit(child)"
|
||||
>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除该城市吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDelete(child.id)"
|
||||
>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
>删除</a-button
|
||||
>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'title'">
|
||||
<a-tag color="green">{{ record.title }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'code'">
|
||||
{{ record.code }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'parent_code'">
|
||||
<a-tag v-if="record.parent_code" color="purple">{{
|
||||
record.parent_code
|
||||
}}</a-tag>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'has_children'">
|
||||
<a-tag v-if="hasChildren(record)" color="orange"
|
||||
>有</a-tag
|
||||
>
|
||||
<span v-else class="text-gray-400">无</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleAddChild(record)"
|
||||
>
|
||||
新增子级
|
||||
</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleEdit(record)"
|
||||
>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除该城市吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDelete(record.id)"
|
||||
>
|
||||
<a-button type="link" size="small" danger
|
||||
>删除</a-button
|
||||
>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<SaveDialog
|
||||
v-model:visible="saveDialogVisible"
|
||||
:city-data="currentCity"
|
||||
:mode="saveMode"
|
||||
@success="handleSaveSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { message } from "ant-design-vue";
|
||||
import api from "@/api/system";
|
||||
import SaveDialog from "./components/SaveDialog.vue";
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
keyword: "",
|
||||
});
|
||||
|
||||
// 表格数据
|
||||
const tableData = ref([]);
|
||||
const loading = ref(false);
|
||||
const expandedKeys = ref([]);
|
||||
|
||||
// 新增/编辑弹窗
|
||||
const saveDialogVisible = ref(false);
|
||||
const saveMode = ref("add");
|
||||
const currentCity = ref(null);
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: "城市名称",
|
||||
key: "title",
|
||||
width: 250,
|
||||
},
|
||||
{
|
||||
title: "城市编码",
|
||||
key: "code",
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: "父级编码",
|
||||
key: "parent_code",
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: "子级数量",
|
||||
key: "has_children",
|
||||
width: 120,
|
||||
align: "center",
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 200,
|
||||
fixed: "right",
|
||||
},
|
||||
];
|
||||
|
||||
// 子表格列配置
|
||||
const childColumns = [
|
||||
{
|
||||
title: "城市名称",
|
||||
key: "title",
|
||||
width: 250,
|
||||
},
|
||||
{
|
||||
title: "城市编码",
|
||||
key: "code",
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 250,
|
||||
},
|
||||
];
|
||||
|
||||
const rowKey = (record) => record.id;
|
||||
|
||||
// 判断是否有子级
|
||||
const hasChildren = (record) => {
|
||||
return record.children && record.children.length > 0;
|
||||
};
|
||||
|
||||
// 加载城市树形数据
|
||||
const loadCityTree = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await api.cities.tree.get();
|
||||
if (res.code === 200) {
|
||||
tableData.value = res.data || [];
|
||||
}
|
||||
} catch (error) {
|
||||
message.error("加载城市数据失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
loadCityTree();
|
||||
};
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
searchForm.keyword = "";
|
||||
loadCityTree();
|
||||
};
|
||||
|
||||
// 刷新
|
||||
const handleRefresh = () => {
|
||||
loadCityTree();
|
||||
};
|
||||
|
||||
// 展开全部
|
||||
const handleExpandAll = () => {
|
||||
const allKeys = [];
|
||||
const collectKeys = (list) => {
|
||||
list.forEach((item) => {
|
||||
if (item.children && item.children.length > 0) {
|
||||
allKeys.push(item.id);
|
||||
collectKeys(item.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
collectKeys(tableData.value);
|
||||
expandedKeys.value = allKeys;
|
||||
};
|
||||
|
||||
// 折叠全部
|
||||
const handleCollapseAll = () => {
|
||||
expandedKeys.value = [];
|
||||
};
|
||||
|
||||
// 展开/折叠事件
|
||||
const handleExpand = (expanded, record) => {
|
||||
if (expanded) {
|
||||
if (!expandedKeys.value.includes(record.id)) {
|
||||
expandedKeys.value.push(record.id);
|
||||
}
|
||||
} else {
|
||||
expandedKeys.value = expandedKeys.value.filter(
|
||||
(key) => key !== record.id,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 新增
|
||||
const handleAdd = () => {
|
||||
saveMode.value = "add";
|
||||
currentCity.value = {
|
||||
title: "",
|
||||
code: "",
|
||||
parent_code: null,
|
||||
};
|
||||
saveDialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 新增子级
|
||||
const handleAddChild = (record) => {
|
||||
saveMode.value = "addChild";
|
||||
currentCity.value = {
|
||||
title: "",
|
||||
code: "",
|
||||
parent_code: record.code,
|
||||
};
|
||||
saveDialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (record) => {
|
||||
saveMode.value = "edit";
|
||||
currentCity.value = {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
code: record.code,
|
||||
parent_code: record.parent_code,
|
||||
};
|
||||
saveDialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
const res = await api.cities.delete.delete(id);
|
||||
if (res.code === 200) {
|
||||
message.success("删除成功");
|
||||
loadCityTree();
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 保存成功
|
||||
const handleSaveSuccess = () => {
|
||||
loadCityTree();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadCityTree();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.city-page {
|
||||
@extend .pages-base-layout;
|
||||
|
||||
.text-gray-400 {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
201
resources/admin/src/pages/system/log/components/DetailDialog.vue
Normal file
201
resources/admin/src/pages/system/log/components/DetailDialog.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
title="日志详情"
|
||||
width="800px"
|
||||
:footer="null"
|
||||
>
|
||||
<a-descriptions v-if="logData" bordered :column="2" size="small">
|
||||
<a-descriptions-item label="日志ID" :span="1">
|
||||
{{ logData.id }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="用户名" :span="1">
|
||||
{{ logData.username || "-" }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="模块" :span="1">
|
||||
{{ logData.module || "-" }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="操作" :span="1">
|
||||
{{ logData.action || "-" }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="请求方式" :span="1">
|
||||
<a-tag :color="getMethodColor(logData.method)">
|
||||
{{ logData.method }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="URL" :span="1">
|
||||
{{ logData.url || "-" }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="IP地址" :span="1">
|
||||
{{ logData.ip || "-" }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态码" :span="1">
|
||||
{{ logData.status_code || "-" }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态" :span="1">
|
||||
<a-tag
|
||||
:color="logData.status === 'success' ? 'success' : 'error'"
|
||||
>
|
||||
{{ logData.status === "success" ? "成功" : "失败" }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="执行时间" :span="1">
|
||||
<span
|
||||
:style="{
|
||||
color: getExecutionTimeColor(logData.execution_time),
|
||||
}"
|
||||
>
|
||||
{{ logData.execution_time }}ms
|
||||
</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间" :span="2">
|
||||
{{ logData.created_at || "-" }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="User Agent" :span="2">
|
||||
<div style="max-height: 60px; overflow-y: auto">
|
||||
{{ logData.user_agent || "-" }}
|
||||
</div>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item
|
||||
v-if="logData.error_message"
|
||||
label="错误信息"
|
||||
:span="2"
|
||||
>
|
||||
<a-alert
|
||||
:message="logData.error_message"
|
||||
type="error"
|
||||
show-icon
|
||||
/>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item
|
||||
v-if="logData.params && Object.keys(logData.params).length > 0"
|
||||
label="请求参数"
|
||||
:span="2"
|
||||
>
|
||||
<a-collapse>
|
||||
<a-collapse-panel key="params" header="查看请求参数">
|
||||
<pre
|
||||
style="
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
"
|
||||
>{{ formatJson(logData.params) }}</pre
|
||||
>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item
|
||||
v-if="logData.result"
|
||||
label="返回结果"
|
||||
:span="2"
|
||||
>
|
||||
<a-collapse>
|
||||
<a-collapse-panel key="result" header="查看返回结果">
|
||||
<pre
|
||||
style="
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
"
|
||||
>{{ formatJson(logData.result) }}</pre
|
||||
>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<template #footer>
|
||||
<a-button @click="handleClose">关闭</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import systemApi from "@/api/system";
|
||||
|
||||
defineOptions({
|
||||
name: "LogDetailDialog",
|
||||
});
|
||||
|
||||
const visible = ref(false);
|
||||
const logData = ref(null);
|
||||
|
||||
// 获取请求方式颜色
|
||||
const getMethodColor = (method) => {
|
||||
const colors = {
|
||||
GET: "blue",
|
||||
POST: "green",
|
||||
PUT: "orange",
|
||||
DELETE: "red",
|
||||
PATCH: "purple",
|
||||
};
|
||||
return colors[method] || "default";
|
||||
};
|
||||
|
||||
// 获取执行时间颜色
|
||||
const getExecutionTimeColor = (time) => {
|
||||
if (time > 1000) return "#ff4d4f";
|
||||
if (time > 500) return "#faad14";
|
||||
return "#52c41a";
|
||||
};
|
||||
|
||||
// 格式化JSON
|
||||
const formatJson = (data) => {
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(data), null, 2);
|
||||
} catch (e) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(data, null, 2);
|
||||
};
|
||||
|
||||
// 打开弹窗
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
return {
|
||||
setData,
|
||||
};
|
||||
};
|
||||
|
||||
// 设置数据
|
||||
const setData = async (data) => {
|
||||
if (data && data.id) {
|
||||
// 如果传入了完整数据,直接使用
|
||||
if (data.params || data.result) {
|
||||
logData.value = data;
|
||||
} else {
|
||||
// 否则从服务器获取完整数据
|
||||
try {
|
||||
const res = await systemApi.logs.detail.get(data.id);
|
||||
if (res.code === 200) {
|
||||
logData.value = res.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取日志详情失败:", error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logData.value = data;
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭弹窗
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
logData.value = null;
|
||||
};
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
open,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.ant-descriptions-item-label) {
|
||||
width: 120px;
|
||||
}
|
||||
</style>
|
||||
604
resources/admin/src/pages/system/log/index.vue
Normal file
604
resources/admin/src/pages/system/log/index.vue
Normal file
@@ -0,0 +1,604 @@
|
||||
<template>
|
||||
<div class="pages-sidebar-layout log-page">
|
||||
<div class="left-box">
|
||||
<div class="header">日志筛选</div>
|
||||
<div class="body">
|
||||
<a-tree
|
||||
v-model:selectedKeys="selectedTreeKeys"
|
||||
:tree-data="filterTreeData"
|
||||
show-line
|
||||
:field-names="{
|
||||
title: 'title',
|
||||
key: 'key',
|
||||
children: 'children',
|
||||
}"
|
||||
@select="onTreeSelect"
|
||||
>
|
||||
<template #icon="{ dataRef }">
|
||||
<ApiOutlined v-if="dataRef.type === 'method'" />
|
||||
<CheckCircleOutlined
|
||||
v-else-if="
|
||||
dataRef.type === 'status' &&
|
||||
dataRef.key === 'success'
|
||||
"
|
||||
/>
|
||||
<CloseCircleOutlined
|
||||
v-else-if="
|
||||
dataRef.type === 'status' &&
|
||||
dataRef.key === 'error'
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</a-tree>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-box">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="statistics-cards">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<a-card size="small" class="stat-card">
|
||||
<a-statistic
|
||||
title="总日志数"
|
||||
:value="statistics.total || 0"
|
||||
>
|
||||
<template #prefix>
|
||||
<FileTextOutlined style="color: #1890ff" />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card size="small" class="stat-card">
|
||||
<a-statistic
|
||||
title="成功数"
|
||||
:value="statistics.success || 0"
|
||||
>
|
||||
<template #prefix>
|
||||
<CheckCircleOutlined
|
||||
style="color: #52c41a"
|
||||
/>
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card size="small" class="stat-card">
|
||||
<a-statistic
|
||||
title="失败数"
|
||||
:value="statistics.error || 0"
|
||||
>
|
||||
<template #prefix>
|
||||
<CloseCircleOutlined
|
||||
style="color: #ff4d4f"
|
||||
/>
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card size="small" class="stat-card">
|
||||
<a-statistic
|
||||
title="平均响应时间"
|
||||
:value="statistics.avg_time || 0"
|
||||
suffix="ms"
|
||||
>
|
||||
<template #prefix>
|
||||
<ClockCircleOutlined
|
||||
style="color: #faad14"
|
||||
/>
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<div class="tool-bar">
|
||||
<div class="left-panel">
|
||||
<a-space>
|
||||
<a-range-picker
|
||||
v-model:value="dateRange"
|
||||
:placeholder="['开始时间', '结束时间']"
|
||||
style="width: 260px"
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button @click="handleLogReset">
|
||||
<template #icon><RedoOutlined /></template>
|
||||
清除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
<div class="right-panel">
|
||||
<a-space>
|
||||
<a-button
|
||||
@click="handleExport"
|
||||
:loading="exportLoading"
|
||||
>
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
导出
|
||||
</a-button>
|
||||
<a-dropdown>
|
||||
<a-button>
|
||||
更多
|
||||
<DownOutlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item @click="handleClearLogs">
|
||||
<DeleteOutlined />清理日志
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="handleRefreshStats">
|
||||
<ReloadOutlined />刷新统计
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-content">
|
||||
<sc-table
|
||||
ref="tableRef"
|
||||
:columns="columns"
|
||||
:data-source="tableData"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:row-key="rowKey"
|
||||
@refresh="refreshTable"
|
||||
@paginationChange="handlePaginationChange"
|
||||
>
|
||||
<template #status="{ record }">
|
||||
<a-tag
|
||||
:color="
|
||||
record.status === 'success'
|
||||
? 'success'
|
||||
: 'error'
|
||||
"
|
||||
>
|
||||
{{ record.status === "success" ? "成功" : "失败" }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template #method="{ record }">
|
||||
<a-tag :color="getMethodColor(record.method)">
|
||||
{{ record.method }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template #execution_time="{ record }">
|
||||
<span
|
||||
:style="{
|
||||
color:
|
||||
record.execution_time > 1000
|
||||
? '#ff4d4f'
|
||||
: record.execution_time > 500
|
||||
? '#faad14'
|
||||
: '#52c41a',
|
||||
}"
|
||||
>
|
||||
{{ record.execution_time }}ms
|
||||
</span>
|
||||
</template>
|
||||
<template #action="{ record }">
|
||||
<a-space>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleView(record)"
|
||||
>查看详情</a-button
|
||||
>
|
||||
<a-popconfirm
|
||||
title="确定删除该日志吗?"
|
||||
@confirm="handleDelete(record)"
|
||||
>
|
||||
<a-button type="link" size="small" danger
|
||||
>删除</a-button
|
||||
>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</sc-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志详情弹窗 -->
|
||||
<detail-dialog ref="detailDialogRef" />
|
||||
|
||||
<!-- 清理日志确认弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="dialog.clear"
|
||||
title="清理日志"
|
||||
:confirm-loading="clearLoading"
|
||||
@ok="handleConfirmClear"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="清理天数">
|
||||
<a-input-number
|
||||
v-model:value="clearDays"
|
||||
:min="1"
|
||||
:max="365"
|
||||
style="width: 100%"
|
||||
placeholder="请输入天数"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-alert
|
||||
message="删除指定天数之前的日志记录,此操作不可恢复"
|
||||
type="warning"
|
||||
show-icon
|
||||
/>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from "vue";
|
||||
import { message } from "ant-design-vue";
|
||||
import {
|
||||
SearchOutlined,
|
||||
RedoOutlined,
|
||||
DeleteOutlined,
|
||||
DownOutlined,
|
||||
ApiOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
FileTextOutlined,
|
||||
ClockCircleOutlined,
|
||||
DownloadOutlined,
|
||||
ReloadOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
import scTable from "@/components/scTable/index.vue";
|
||||
import detailDialog from "./components/DetailDialog.vue";
|
||||
import systemApi from "@/api/system";
|
||||
import { useTable } from "@/hooks/useTable";
|
||||
|
||||
defineOptions({
|
||||
name: "systemLog",
|
||||
});
|
||||
|
||||
// 统计数据
|
||||
const statistics = ref({
|
||||
total: 0,
|
||||
success: 0,
|
||||
error: 0,
|
||||
avg_time: 0,
|
||||
});
|
||||
|
||||
// 使用useTable hooks
|
||||
const {
|
||||
tableRef,
|
||||
searchForm,
|
||||
tableData,
|
||||
loading,
|
||||
pagination,
|
||||
handleSearch,
|
||||
handlePaginationChange,
|
||||
refreshTable,
|
||||
} = useTable({
|
||||
api: systemApi.logs.list.get,
|
||||
searchForm: {
|
||||
method: "",
|
||||
status: "",
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
},
|
||||
columns: [],
|
||||
needPagination: true,
|
||||
});
|
||||
|
||||
// 对话框状态
|
||||
const dialog = reactive({
|
||||
detail: false,
|
||||
clear: false,
|
||||
});
|
||||
|
||||
// 弹窗引用
|
||||
const detailDialogRef = ref(null);
|
||||
|
||||
// 清理日志相关
|
||||
const clearDays = ref(30);
|
||||
const clearLoading = ref(false);
|
||||
|
||||
// 导出相关
|
||||
const exportLoading = ref(false);
|
||||
|
||||
// 日期范围
|
||||
const dateRange = ref([]);
|
||||
|
||||
// 树形筛选数据
|
||||
const treeData = ref([
|
||||
{
|
||||
title: "请求方式",
|
||||
key: "method-root",
|
||||
type: "root",
|
||||
children: [
|
||||
{ title: "GET", key: "GET", type: "method" },
|
||||
{ title: "POST", key: "POST", type: "method" },
|
||||
{ title: "PUT", key: "PUT", type: "method" },
|
||||
{ title: "DELETE", key: "DELETE", type: "method" },
|
||||
{ title: "PATCH", key: "PATCH", type: "method" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "返回状态",
|
||||
key: "status-root",
|
||||
type: "root",
|
||||
children: [
|
||||
{ title: "成功", key: "success", type: "status" },
|
||||
{ title: "失败", key: "error", type: "status" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// 选中的树节点
|
||||
const selectedTreeKeys = ref([]);
|
||||
|
||||
// 行key
|
||||
const rowKey = "id";
|
||||
|
||||
// 过滤后的树数据
|
||||
const filterTreeData = computed(() => {
|
||||
return treeData.value;
|
||||
});
|
||||
|
||||
// 获取请求方式颜色
|
||||
const getMethodColor = (method) => {
|
||||
const colors = {
|
||||
GET: "blue",
|
||||
POST: "green",
|
||||
PUT: "orange",
|
||||
DELETE: "red",
|
||||
PATCH: "purple",
|
||||
};
|
||||
return colors[method] || "default";
|
||||
};
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{ title: "ID", dataIndex: "id", key: "id", width: 80 },
|
||||
{ title: "用户名", dataIndex: "username", key: "username", width: 120 },
|
||||
{ title: "模块", dataIndex: "module", key: "module", width: 120 },
|
||||
{ title: "操作", dataIndex: "action", key: "action", width: 150 },
|
||||
{
|
||||
title: "请求方式",
|
||||
dataIndex: "method",
|
||||
key: "method",
|
||||
width: 100,
|
||||
align: "center",
|
||||
slot: "method",
|
||||
},
|
||||
{ title: "URL", dataIndex: "url", key: "url", ellipsis: true },
|
||||
{ title: "IP地址", dataIndex: "ip", key: "ip", width: 140 },
|
||||
{
|
||||
title: "状态码",
|
||||
dataIndex: "status_code",
|
||||
key: "status_code",
|
||||
width: 100,
|
||||
align: "center",
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 100,
|
||||
align: "center",
|
||||
slot: "status",
|
||||
},
|
||||
{
|
||||
title: "执行时间",
|
||||
dataIndex: "execution_time",
|
||||
key: "execution_time",
|
||||
width: 120,
|
||||
align: "center",
|
||||
slot: "execution_time",
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
dataIndex: "table_action",
|
||||
key: "table_action",
|
||||
width: 150,
|
||||
align: "center",
|
||||
slot: "action",
|
||||
fixed: "right",
|
||||
},
|
||||
];
|
||||
|
||||
// 加载统计数据
|
||||
const loadStatistics = async () => {
|
||||
try {
|
||||
const res = await systemApi.logs.statistics.get(searchForm);
|
||||
if (res.code === 200) {
|
||||
statistics.value = res.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载统计数据失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 树形选择事件
|
||||
const onTreeSelect = (selectedKeys, { selectedNodes }) => {
|
||||
if (selectedKeys.length > 0) {
|
||||
const key = selectedKeys[0];
|
||||
const node = selectedNodes[0];
|
||||
|
||||
// 重置搜索表单
|
||||
searchForm.method = "";
|
||||
searchForm.status = "";
|
||||
|
||||
// 根据节点类型设置对应的筛选条件
|
||||
if (node.type === "method") {
|
||||
searchForm.method = key;
|
||||
} else if (node.type === "status") {
|
||||
searchForm.status = key;
|
||||
}
|
||||
|
||||
// 触发搜索
|
||||
handleSearch();
|
||||
// 同时刷新统计数据
|
||||
loadStatistics();
|
||||
} else {
|
||||
// 取消选择时清空筛选条件
|
||||
searchForm.method = "";
|
||||
searchForm.status = "";
|
||||
handleSearch();
|
||||
// 同时刷新统计数据
|
||||
loadStatistics();
|
||||
}
|
||||
};
|
||||
|
||||
// 日期变化
|
||||
const handleDateChange = (dates) => {
|
||||
if (dates && dates.length === 2) {
|
||||
searchForm.start_date = dates[0].format("YYYY-MM-DD HH:mm:ss");
|
||||
searchForm.end_date = dates[1].format("YYYY-MM-DD HH:mm:ss");
|
||||
} else {
|
||||
searchForm.start_date = "";
|
||||
searchForm.end_date = "";
|
||||
}
|
||||
// 自动触发搜索
|
||||
handleSearch();
|
||||
// 同时刷新统计数据
|
||||
loadStatistics();
|
||||
};
|
||||
|
||||
// 重置
|
||||
const handleLogReset = () => {
|
||||
// 重置日期范围
|
||||
dateRange.value = [];
|
||||
searchForm.start_date = "";
|
||||
searchForm.end_date = "";
|
||||
|
||||
// 重置树形选择
|
||||
selectedTreeKeys.value = [];
|
||||
searchForm.method = "";
|
||||
searchForm.status = "";
|
||||
|
||||
// 重置分页到第一页
|
||||
pagination.current = 1;
|
||||
|
||||
// 触发搜索
|
||||
handleSearch();
|
||||
// 同时刷新统计数据
|
||||
loadStatistics();
|
||||
};
|
||||
|
||||
// 查看详情
|
||||
const handleView = async (record) => {
|
||||
const dialog = await detailDialogRef.value?.open();
|
||||
dialog?.setData(record);
|
||||
};
|
||||
|
||||
// 删除日志
|
||||
const handleDelete = async (record) => {
|
||||
try {
|
||||
const res = await systemApi.logs.delete.delete(record.id);
|
||||
if (res.code === 200) {
|
||||
message.success("删除成功");
|
||||
refreshTable();
|
||||
// 同时刷新统计数据
|
||||
loadStatistics();
|
||||
} else {
|
||||
message.error(res.message || "删除失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("删除日志失败:", error);
|
||||
message.error("删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 清理日志
|
||||
const handleClearLogs = () => {
|
||||
dialog.clear = true;
|
||||
};
|
||||
|
||||
// 确认清理日志
|
||||
const handleConfirmClear = async () => {
|
||||
if (!clearDays.value || clearDays.value < 1) {
|
||||
message.warning("请输入有效的天数");
|
||||
return;
|
||||
}
|
||||
|
||||
clearLoading.value = true;
|
||||
try {
|
||||
const res = await systemApi.logs.clear.post({ days: clearDays.value });
|
||||
if (res.code === 200) {
|
||||
message.success("清理成功");
|
||||
dialog.clear = false;
|
||||
clearDays.value = 30;
|
||||
refreshTable();
|
||||
// 同时刷新统计数据
|
||||
loadStatistics();
|
||||
} else {
|
||||
message.error(res.message || "清理失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("清理日志失败:", error);
|
||||
message.error("清理失败");
|
||||
} finally {
|
||||
clearLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新统计
|
||||
const handleRefreshStats = () => {
|
||||
loadStatistics();
|
||||
};
|
||||
|
||||
// 导出日志
|
||||
const handleExport = async () => {
|
||||
exportLoading.value = true;
|
||||
try {
|
||||
const blob = await systemApi.logs.export.get(searchForm);
|
||||
|
||||
// 创建下载链接
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `系统日志_${new Date().getTime()}.xlsx`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
message.success("导出成功");
|
||||
} catch (error) {
|
||||
console.error("导出失败:", error);
|
||||
message.error("导出失败");
|
||||
} finally {
|
||||
exportLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
// 页面加载时自动搜索一次
|
||||
handleSearch();
|
||||
// 加载统计数据
|
||||
loadStatistics();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.log-page {
|
||||
.statistics-cards {
|
||||
margin-bottom: 16px;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user