Files
FastapiAdmin/frontend/web/src/views/module_system/log/index.vue
T
zhangtao 5ff72b086f refactor: 重构项目模块结构与代码细节优化
1.  调整后端模块路由与插件文件结构,迁移部分模块代码至api/v1目录
2.  优化代码注释格式与文档字符串,简化冗余代码
3.  添加监控仪表盘、工单评论等新模块功能
4.  修复前端部分组件交互逻辑与类型定义
5.  清理冗余的初始化数据与废弃配置文件
6.  新增租户注册表单字段与国际化支持
7.  优化HTTP请求拦截器与快捷键功能
2026-07-10 00:18:11 +08:00

738 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 日志管理:登录日志 + 操作日志 -->
<template>
<div class="fa-full-height">
<ElTabs v-model="activeTab" type="card">
<ElTabPane label="操作日志" name="operation">
<FaSearchBar
v-show="opShowSearchBar"
ref="opSearchBarRef"
v-model="opSearchForm"
:items="opSearchItems"
:rules="opSearchBarRules"
:is-expand="false"
:show-expand="true"
:show-reset="true"
:show-search="true"
:disabled-search="false"
:default-expanded="false"
include-audit
:audit-item-options="{ showTenantId: true }"
@search="handleOpSearch"
@reset="onOpResetSearch"
/>
<ElCard class="fa-table-card" :style="{ 'margin-top': opShowSearchBar ? '12px' : '0' }">
<FaTableHeader
v-model:columns="opColumnChecks"
v-model:showSearchBar="opShowSearchBar"
:loading="opLoading"
@refresh="opRefreshData"
>
<template #left>
<FaTableHeaderLeft
:remove-ids="opSelectedIds"
:perm-export="['module_system:log:export']"
:perm-delete="['module_system:log:delete']"
:delete-loading="opBatchDeleting"
@export="openOpExport"
@delete="handleOpBatchDelete"
/>
</template>
</FaTableHeader>
<FaTable
ref="opTableRef"
:loading="opLoading"
:data="opData"
:columns="opColumns"
:pagination="opPagination"
@selection-change="onOpTableSelectionChange"
@pagination:size-change="opHandleSizeChange"
@pagination:current-change="opHandleCurrentChange"
/>
</ElCard>
<FaDialog
v-model="opDialogVisible.visible"
:title="opDialogVisible.title"
width="960px"
dialog-class="crud-embed-dialog"
modal-class="crud-embed-dialog"
form-mode="detail"
@confirm="handleOpCloseDialog"
>
<FaDescriptions
:column="8"
:data="opFormData"
:items="opDetailItems"
label-width="200px"
max-height="75vh"
>
<template #request_method="{ row }">
<ElTag :type="getMethodType(row?.request_method as string)">{{
row?.request_method
}}</ElTag>
</template>
<template #response_code="{ row }">
<ElTag :type="getStatusCodeType(row?.response_code as number)">{{
row?.response_code
}}</ElTag>
</template>
<template #request_payload="{ row }">
<FaJsonPretty
:value="(row as unknown as OperationLogTable)?.request_payload"
height="80px"
/>
</template>
<template #response_json="{ row }">
<FaJsonPretty
:value="(row as unknown as OperationLogTable)?.response_json"
height="140px"
/>
</template>
</FaDescriptions>
</FaDialog>
<FaExportDialog
v-model="opExportVisible"
:content-config="opExportContentConfig"
:query-params="opExportQueryParams"
:page-data="opData"
:selection-data="opSelectedRows"
/>
</ElTabPane>
<ElTabPane label="登录日志" name="login">
<FaSearchBar
v-show="loginShowSearchBar"
ref="loginSearchBarRef"
v-model="loginSearchForm"
:items="loginSearchItems"
:rules="loginSearchBarRules"
:is-expand="false"
:show-expand="true"
:show-reset="true"
:show-search="true"
:disabled-search="false"
:default-expanded="false"
include-audit
:audit-item-options="{ showTenantId: true }"
@search="handleLoginSearch"
@reset="onLoginResetSearch"
/>
<ElCard class="fa-table-card" :style="{ 'margin-top': loginShowSearchBar ? '12px' : '0' }">
<FaTableHeader
v-model:columns="loginColumnChecks"
v-model:showSearchBar="loginShowSearchBar"
:loading="loginLoading"
@refresh="loginRefreshData"
>
<template #left>
<FaTableHeaderLeft
:remove-ids="loginSelectedIds"
:perm-delete="['module_system:login_log:delete']"
:delete-loading="loginBatchDeleting"
@delete="handleLoginBatchDelete"
/>
</template>
</FaTableHeader>
<FaTable
ref="loginTableRef"
:loading="loginLoading"
:data="loginData"
:columns="loginColumns"
:pagination="loginPagination"
@selection-change="onLoginTableSelectionChange"
@pagination:size-change="loginHandleSizeChange"
@pagination:current-change="loginHandleCurrentChange"
/>
</ElCard>
<FaDialog
v-model="loginDialogVisible.visible"
:title="loginDialogVisible.title"
width="640px"
dialog-class="crud-embed-dialog"
modal-class="crud-embed-dialog"
form-mode="detail"
@confirm="handleLoginCloseDialog"
>
<FaDescriptions
:column="2"
:data="loginFormData"
:items="loginDetailItems"
label-width="120px"
max-height="75vh"
>
<template #status="{ row }">
<ElTag :type="row?.status === 1 ? 'success' : 'danger'">{{
row?.status === 1 ? "成功" : "失败"
}}</ElTag>
</template>
</FaDescriptions>
</FaDialog>
</ElTabPane>
</ElTabs>
</div>
</template>
<script setup lang="ts">
import { h } from "vue";
import { useTable } from "@/hooks/core/useTable";
import { useImportExport } from "@/hooks/core/useImportExport";
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
import { useTableSelection } from "@/hooks/core/useTableSelection";
import { confirmDelete, confirmBatchDelete } from "@/hooks/core/useConfirm";
import { cleanEmptyArrayParams, stripPaginationParams } from "@/utils/query";
import type { ColumnOption } from "@/types/component";
import OperationLogAPI, {
type OperationLogPageQuery,
type OperationLogTable,
type LoginLogTable,
LoginLogAPI,
} from "@/api/module_system/log";
import { useAuth } from "@/hooks/core/useAuth";
import {
renderTableOperationCell,
type TableOperationAction,
resolveStatusColumns,
type StatusType,
} from "@utils";
import type { IObject } from "@/components/modal/types";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaStatusTag from "@/components/others/fa-status-tag/index.vue";
import FaCopyButton from "@/components/others/fa-copy-button/index.vue";
defineOptions({
name: "Log",
inheritAttrs: false,
});
const { hasAuth } = useAuth();
const activeTab = ref<"operation" | "login">("operation");
// ==================== 操作日志 ====================
type OpSearchForm = {
request_path?: string;
request_ip?: string;
created_id?: number;
created_time?: string[];
};
const opSearchForm = ref<OpSearchForm>({
request_path: undefined,
request_ip: undefined,
created_id: undefined,
created_time: undefined,
});
const opShowSearchBar = ref(true);
const opSearchBarRef = ref<InstanceType<typeof FaSearchBar> | null>(null);
const opSearchBarRules: Record<string, unknown> = {};
const opSearchItems = computed<SearchFormItem[]>(() => [
{
label: "请求路径",
key: "request_path",
type: "input",
placeholder: "请输入请求路径",
clearable: true,
span: 6,
},
{
label: "请求IP",
key: "request_ip",
type: "input",
placeholder: "请输入请求IP",
clearable: true,
span: 6,
},
]);
function buildOpReplaceParams(p: OpSearchForm): Record<string, unknown> {
return {
request_path: p.request_path,
request_ip: p.request_ip,
created_id: p.created_id,
created_time:
Array.isArray(p.created_time) && p.created_time.length === 2 ? p.created_time : undefined,
};
}
const opTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(null);
const {
selectedRows: opSelectedRows,
selectedIds: opSelectedIds,
batchDeleting: opBatchDeleting,
onTableSelectionChange: onOpTableSelectionChange,
} = useTableSelection<OperationLogTable>();
const {
columns: opColumns,
columnChecks: opColumnChecks,
data: opData,
loading: opLoading,
pagination: opPagination,
searchParams: opSearchParams,
getData: opGetData,
replaceSearchParams: opReplaceSearchParams,
resetSearchParams: opResetSearchParams,
handleSizeChange: opHandleSizeChange,
handleCurrentChange: opHandleCurrentChange,
refreshData: opRefreshData,
refreshRemove: opRefreshRemove,
} = useTable({
core: {
apiFn: OperationLogAPI.list,
apiParams: { page_no: 1, page_size: 10 },
columnsFactory: (): ColumnOption<OperationLogTable>[] => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "request_path", label: "请求路径", minWidth: 200, showOverflowTooltip: true },
{
prop: "request_method",
label: "请求方法",
minWidth: 100,
formatter: (row: OperationLogTable) =>
h(FaStatusTag, {
type: getMethodType(row.request_method),
label: row.request_method ?? "",
}),
},
{
prop: "response_code",
label: "状态码",
minWidth: 100,
formatter: (row: OperationLogTable) =>
h(FaStatusTag, {
type: getStatusCodeType(row.response_code),
label: String(row.response_code ?? ""),
}),
},
{ prop: "request_ip", label: "请求IP", minWidth: 140, showOverflowTooltip: true },
{ prop: "process_time", label: "处理时间", minWidth: 120 },
{ prop: "description", label: "描述", minWidth: 120, showOverflowTooltip: true },
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
{
prop: "operation",
label: "操作",
width: 160,
fixed: "right",
align: "center",
formatter: (row: OperationLogTable) => formatOpActionCell(row),
},
],
},
});
const opCrudCols = computed(() =>
opColumns.value.map((c: ColumnOption<OperationLogTable>) => ({
prop: c.prop,
label: c.label,
type:
(c as { type?: string }).type === "selection" ? ("selection" as const) : ("default" as const),
show: true,
}))
);
const opExportQueryParams = computed(() => {
const sp = stripPaginationParams(opSearchParams as Record<string, unknown>);
return cleanEmptyArrayParams({ ...sp }) as unknown as OperationLogPageQuery;
});
const opExportContentConfig = computed(() => ({
permPrefix: "module_system:log",
cols: opCrudCols.value,
exportsBlobAction: async (params: IObject) => {
const res = await OperationLogAPI.export(
cleanEmptyArrayParams({
...(opExportQueryParams.value as unknown as Record<string, unknown>),
...params,
}) as OperationLogPageQuery
);
return res.data as Blob;
},
}));
const opFormData = ref<OperationLogTable>({} as OperationLogTable);
const opDetailItems: import("@/components/others/fa-descriptions/index.vue").DescriptionsItem[] = [
{ label: "描述", prop: "description", span: 8 },
{ label: "请求路径", prop: "request_path" },
{ label: "请求方法", prop: "request_method", slot: "request_method" },
{ label: "响应状态码", prop: "response_code", slot: "response_code" },
{ label: "请求IP", prop: "request_ip" },
{ label: "处理时间", prop: "process_time" },
{ label: "请求参数", prop: "request_payload", slot: "request_payload", span: 8 },
{ label: "响应数据", prop: "response_json", slot: "response_json", span: 8 },
{ label: "创建时间", prop: "created_time" },
];
const { dialogVisible: opDialogVisible, closeDialog: opCloseDialog } = useCrudDialog();
const { exportVisible: opExportVisible, openExport: openOpExport } = useImportExport();
async function handleOpSearch(params: OpSearchForm) {
await opSearchBarRef.value?.validate?.();
opReplaceSearchParams(buildOpReplaceParams(params));
opGetData();
}
function onOpResetSearch() {
opSearchForm.value = {
request_path: undefined,
request_ip: undefined,
created_id: undefined,
created_time: undefined,
};
void opResetSearchParams();
}
async function handleOpCloseDialog() {
opCloseDialog();
Object.assign(opFormData.value, {});
}
async function handleOpOpenDetail(id: number) {
opDialogVisible.title = "操作日志详情";
const response = await OperationLogAPI.detail(id);
Object.assign(opFormData.value, response.data.data ?? {});
opDialogVisible.visible = true;
}
async function deleteOpRow(id: number) {
try {
await confirmDelete();
await OperationLogAPI.delete([id]);
opTableRef.value?.elTableRef?.clearSelection();
await opRefreshRemove();
} catch {
/* 用户取消 */
}
}
function buildOpRowActions(row: OperationLogTable): TableOperationAction[] {
const all: TableOperationAction[] = [
{
key: "detail",
label: "详情",
artType: "view",
perm: "module_system:log:detail",
run: () => {
if (row.id != null) void handleOpOpenDetail(row.id);
},
},
{
key: "delete",
label: "删除",
artType: "delete",
icon: "ri:delete-bin-4-line",
perm: "module_system:log:delete",
run: () => {
if (row.id != null) deleteOpRow(row.id);
},
},
];
return all.filter((a) => a.perm != null && hasAuth(a.perm));
}
function formatOpActionCell(row: OperationLogTable) {
return renderTableOperationCell(buildOpRowActions(row), {
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 log-table-actions",
});
}
async function handleOpBatchDelete() {
const ids = opSelectedIds.value;
if (ids.length === 0) return;
try {
await confirmBatchDelete(ids.length);
opBatchDeleting.value = true;
await OperationLogAPI.delete(ids);
opTableRef.value?.elTableRef?.clearSelection();
await opRefreshRemove();
} catch {
/* 用户取消 */
} finally {
opBatchDeleting.value = false;
}
}
// ==================== 登录日志 ====================
type LoginSearchForm = { username?: string; status?: number; created_time?: string[] };
const loginSearchForm = ref<LoginSearchForm>({
username: undefined,
status: undefined,
created_time: undefined,
});
const loginShowSearchBar = ref(true);
const loginSearchBarRef = ref<InstanceType<typeof FaSearchBar> | null>(null);
const loginSearchBarRules: Record<string, unknown> = {};
const loginStatusOptions = ref([
{ label: "成功", value: 1 },
{ label: "失败", value: 2 },
]);
const loginSearchItems = computed<SearchFormItem[]>(() => [
{
label: "用户名",
key: "username",
type: "input",
placeholder: "请输入用户名",
clearable: true,
span: 6,
},
{
label: "登录状态",
key: "status",
type: "select",
props: { placeholder: "请选择状态", options: loginStatusOptions.value, clearable: true },
span: 6,
},
{
label: "登录时间",
key: "created_time",
type: "datetimerange",
span: 6,
props: {
type: "datetimerange",
rangeSeparator: "至",
startPlaceholder: "开始日期",
endPlaceholder: "结束日期",
format: "YYYY-MM-DD HH:mm:ss",
valueFormat: "YYYY-MM-DD HH:mm:ss",
style: { width: "100%" },
},
},
]);
function buildLoginReplaceParams(p: LoginSearchForm): Record<string, unknown> {
return {
username: p.username || undefined,
status:
p.status !== undefined && p.status !== null && p.status !== ("" as unknown as number)
? Number(p.status)
: undefined,
created_time:
Array.isArray(p.created_time) && p.created_time.length === 2 ? p.created_time : undefined,
};
}
const loginTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(null);
const {
selectedIds: loginSelectedIds,
batchDeleting: loginBatchDeleting,
onTableSelectionChange: onLoginTableSelectionChange,
} = useTableSelection<LoginLogTable>();
const {
columns: loginColumns,
columnChecks: loginColumnChecks,
data: loginData,
loading: loginLoading,
pagination: loginPagination,
getData: loginGetData,
replaceSearchParams: loginReplaceSearchParams,
resetSearchParams: loginResetSearchParams,
handleSizeChange: loginHandleSizeChange,
handleCurrentChange: loginHandleCurrentChange,
refreshData: loginRefreshData,
refreshRemove: loginRefreshRemove,
} = useTable({
core: {
apiFn: LoginLogAPI.list,
apiParams: { page_no: 1, page_size: 10 },
columnsFactory: resolveStatusColumns<LoginLogTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{
prop: "status",
label: "登录状态",
width: 88,
status: {
1: { type: "success", text: "成功" },
0: { type: "danger", text: "失败" },
},
},
{ prop: "username", label: "用户名", minWidth: 120, showOverflowTooltip: true },
{
prop: "login_ip",
label: "登录IP",
minWidth: 140,
formatter: (row: LoginLogTable) =>
h("span", { class: "inline-flex items-center gap-0.5" }, [
row.login_ip ?? "",
row.login_ip
? h(FaCopyButton, { text: row.login_ip, style: { marginLeft: "2px" } })
: null,
]),
},
{ prop: "login_location", label: "登录地点", minWidth: 160, showOverflowTooltip: true },
{ prop: "request_os", label: "操作系统", minWidth: 120 },
{ prop: "request_browser", label: "浏览器", minWidth: 180, showOverflowTooltip: true },
{ prop: "msg", label: "提示消息", minWidth: 200, showOverflowTooltip: true },
{ prop: "created_time", label: "登录时间", width: 168, showOverflowTooltip: true },
{
prop: "operation",
label: "操作",
width: 120,
fixed: "right",
align: "center",
formatter: (row: LoginLogTable) => formatLoginActionCell(row),
},
]),
},
});
const loginFormData = ref<LoginLogTable>({} as LoginLogTable);
const loginDetailItems = [
{ label: "登录状态", prop: "status", slot: "status" },
{ label: "用户名", prop: "username" },
{ label: "登录IP", prop: "login_ip" },
{ label: "登录地点", prop: "login_location" },
{ label: "操作系统", prop: "request_os" },
{ label: "浏览器", prop: "request_browser" },
{ label: "提示消息", prop: "msg", span: 2 },
{ label: "登录时间", prop: "created_time" },
];
const { dialogVisible: loginDialogVisible, closeDialog: loginCloseDialog } = useCrudDialog();
async function handleLoginSearch(params: LoginSearchForm) {
await loginSearchBarRef.value?.validate?.();
loginReplaceSearchParams(buildLoginReplaceParams(params));
loginGetData();
}
function onLoginResetSearch() {
loginSearchForm.value = { username: undefined, status: undefined, created_time: undefined };
void loginResetSearchParams();
}
async function handleLoginOpenDetail(id: number) {
loginDialogVisible.title = "登录日志详情";
const response = await LoginLogAPI.detail(id);
Object.assign(loginFormData.value, response.data.data ?? {});
loginDialogVisible.visible = true;
}
function handleLoginCloseDialog() {
loginCloseDialog();
}
async function deleteLoginRow(id: number) {
try {
await confirmDelete();
await LoginLogAPI.delete([id]);
loginTableRef.value?.elTableRef?.clearSelection();
await loginRefreshRemove();
} catch {
/* 用户取消 */
}
}
function buildLoginRowActions(row: LoginLogTable): TableOperationAction[] {
const all: TableOperationAction[] = [
{
key: "detail",
label: "详情",
artType: "view",
perm: "module_system:login_log:query",
run: () => {
if (row.id != null) void handleLoginOpenDetail(row.id);
},
},
{
key: "delete",
label: "删除",
artType: "delete",
icon: "ri:delete-bin-4-line",
perm: "module_system:login_log:delete",
run: () => {
if (row.id != null) deleteLoginRow(row.id);
},
},
];
return all.filter((a) => a.perm != null && hasAuth(a.perm));
}
function formatLoginActionCell(row: LoginLogTable) {
return renderTableOperationCell(buildLoginRowActions(row), {
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 loginlog-table-actions",
});
}
async function handleLoginBatchDelete() {
const ids = loginSelectedIds.value;
if (ids.length === 0) return;
try {
await confirmBatchDelete(ids.length);
loginBatchDeleting.value = true;
await LoginLogAPI.delete(ids);
loginTableRef.value?.elTableRef?.clearSelection();
await loginRefreshRemove();
} catch {
/* 用户取消 */
} finally {
loginBatchDeleting.value = false;
}
}
// 修复:登录日志 tab 在组件挂载时数据已请求但 DOM 未就绪,首次切换到时重新请求
const loginTabLoaded = ref(false);
watch(activeTab, (tab) => {
if (tab === "login" && !loginTabLoaded.value) {
loginTabLoaded.value = true;
nextTick(() => {
loginGetData();
});
}
});
// ==================== 通用 ====================
function getStatusCodeType(code?: number): StatusType {
if (code === undefined) return "info";
if (code >= 200 && code < 300) return "success";
if (code >= 300 && code < 400) return "warning";
return "danger";
}
function getMethodType(method?: string): StatusType {
if (method === undefined) return "info";
if (method === "GET") return "info";
if (method === "POST") return "success";
if (method === "PUT" || method === "PATCH") return "warning";
if (method === "DELETE") return "danger";
return "info";
}
</script>
<style scoped lang="scss">
:deep(.el-tabs) {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
:deep(.el-tabs__content) {
flex: 1;
min-height: 0;
overflow: visible;
}
:deep(.el-tab-pane) {
display: flex;
flex-direction: column;
height: 100%;
}
</style>