更新oa的数据统计
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export function getReimbursementDashboard() {
|
||||||
|
return request({
|
||||||
|
url: '/backend/reimbursements/dashboard',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function getReimbursementList(params) {
|
export function getReimbursementList(params) {
|
||||||
return request({
|
return request({
|
||||||
url: '/backend/reimbursements',
|
url: '/backend/reimbursements',
|
||||||
|
|||||||
@@ -1,255 +1,256 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="erp-dashboard">
|
<div class="oa-dashboard">
|
||||||
<!-- 第一行统计卡片 -->
|
<div class="page-header">
|
||||||
<el-row :gutter="16" class="stat-row">
|
<div>
|
||||||
<el-col :span="4" :xs="12" v-for="(item, index) in firstRowStats" :key="'first-' + index">
|
<h2>OA 工作台</h2>
|
||||||
<div class="stat-card">
|
<p>个人办公数据概览</p>
|
||||||
<div class="stat-header">
|
</div>
|
||||||
<span class="stat-label">{{ item.label }}</span>
|
<el-button :icon="Refresh" @click="loadDashboard">刷新</el-button>
|
||||||
<el-icon class="stat-icon"><Info-Filled /></el-icon>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="stat-value">
|
|
||||||
<span class="currency">¥</span>
|
|
||||||
<span class="number">{{ formatMoney(item.value) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
|
|
||||||
<!-- 第二行统计卡片 -->
|
<!-- 报销统计卡片 -->
|
||||||
<el-row :gutter="16" class="stat-row" style="margin-top: 16px;">
|
<el-row :gutter="16" class="stat-row">
|
||||||
<el-col :span="4" :xs="12" v-for="(item, index) in secondRowStats" :key="'second-' + index">
|
<el-col :span="8" :xs="24" v-for="item in statCards" :key="item.key">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-header">
|
<div class="stat-title">{{ item.label }}</div>
|
||||||
<span class="stat-label">{{ item.label }}</span>
|
<div class="stat-amount">
|
||||||
<el-icon class="stat-icon"><Info-Filled /></el-icon>
|
|
||||||
</div>
|
|
||||||
<div class="stat-value">
|
|
||||||
<span class="currency">¥</span>
|
<span class="currency">¥</span>
|
||||||
<span class="number">{{ formatMoney(item.value) }}</span>
|
<span class="number">{{ money(item.data.total) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-meta">
|
||||||
|
<span>{{ item.data.count }} 单</span>
|
||||||
|
<span>已报销 ¥ {{ money(item.data.paid) }}</span>
|
||||||
|
<span>未报销 ¥ {{ money(item.data.unpaid) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<!-- 图表区域 -->
|
<!-- 图表区域 -->
|
||||||
<el-row :gutter="16" class="chart-row" style="margin-top: 16px;">
|
<el-row :gutter="16" class="chart-row">
|
||||||
<el-col :span="8" :xs="24">
|
<el-col :span="14" :xs="24">
|
||||||
<div class="chart-card">
|
<div class="chart-card">
|
||||||
<div class="chart-title">销售统计</div>
|
<div class="chart-title">近6个月报销趋势</div>
|
||||||
<div ref="salesChartRef" class="chart-container"></div>
|
<div ref="trendChartRef" class="chart-container"></div>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="8" :xs="24">
|
<el-col :span="10" :xs="24">
|
||||||
<div class="chart-card">
|
<div class="chart-card">
|
||||||
<div class="chart-title">零售统计</div>
|
<div class="chart-title">状态分布</div>
|
||||||
<div ref="retailChartRef" class="chart-container"></div>
|
<div class="status-dist">
|
||||||
</div>
|
<div
|
||||||
</el-col>
|
v-for="(item, key) in dashboard.status_dist"
|
||||||
<el-col :span="8" :xs="24">
|
:key="key"
|
||||||
<div class="chart-card">
|
class="dist-row"
|
||||||
<div class="chart-title">采购统计</div>
|
>
|
||||||
<div ref="purchaseChartRef" class="chart-container"></div>
|
<el-tag :type="statusType(Number(key))" size="small">{{
|
||||||
|
item.name
|
||||||
|
}}</el-tag>
|
||||||
|
<div class="dist-bar-wrap">
|
||||||
|
<div
|
||||||
|
class="dist-bar"
|
||||||
|
:style="{ width: distPercent(item.count) }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="dist-count"
|
||||||
|
>{{ item.count }} 单 / ¥ {{ money(item.amount) }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<el-empty
|
||||||
|
v-if="!Object.keys(dashboard.status_dist).length"
|
||||||
|
description="暂无数据"
|
||||||
|
:image-size="60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 报销类型分析 -->
|
||||||
|
<div class="chart-card type-card">
|
||||||
|
<div class="chart-title">报销类型分析</div>
|
||||||
|
<div class="type-dist">
|
||||||
|
<div
|
||||||
|
v-for="item in dashboard.expense_types"
|
||||||
|
:key="item.name"
|
||||||
|
class="type-row"
|
||||||
|
>
|
||||||
|
<span class="type-name">{{ item.name }}</span>
|
||||||
|
<div class="type-bar-wrap">
|
||||||
|
<div class="type-bar" :style="{ width: typePercent(item.amount) }" />
|
||||||
|
</div>
|
||||||
|
<span class="type-count">{{ item.count }} 笔</span>
|
||||||
|
<span class="type-amount">¥ {{ money(item.amount) }}</span>
|
||||||
|
</div>
|
||||||
|
<el-empty
|
||||||
|
v-if="!dashboard.expense_types.length"
|
||||||
|
description="暂无费用明细"
|
||||||
|
:image-size="60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted, shallowRef, nextTick } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||||
import * as echarts from 'echarts';
|
import * as echarts from "echarts";
|
||||||
import { InfoFilled } from '@element-plus/icons-vue';
|
import { Refresh } from "@element-plus/icons-vue";
|
||||||
|
import { getReimbursementDashboard } from "@/api/reimburse";
|
||||||
|
|
||||||
// 统计卡片数据 - 第一行
|
const emptyStat = () => ({ total: 0, paid: 0, unpaid: 0, count: 0 });
|
||||||
const firstRowStats = ref([
|
const dashboard = reactive({
|
||||||
{ label: '今日销售', value: 0 },
|
week: emptyStat(),
|
||||||
{ label: '今日零售', value: 0 },
|
month: emptyStat(),
|
||||||
{ label: '今日采购', value: 0 },
|
year: emptyStat(),
|
||||||
{ label: '本月累计销售', value: 0 },
|
trend: [],
|
||||||
{ label: '本月累计零售', value: 0 },
|
status_dist: {},
|
||||||
{ label: '本月累计采购', value: 0 },
|
expense_types: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const statCards = computed(() => [
|
||||||
|
{ key: "week", label: "本周报销", data: dashboard.week },
|
||||||
|
{ key: "month", label: "本月报销", data: dashboard.month },
|
||||||
|
{ key: "year", label: "本年报销", data: dashboard.year },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 统计卡片数据 - 第二行
|
// 趋势图
|
||||||
const secondRowStats = ref([
|
const trendChartRef = ref(null);
|
||||||
{ label: '昨日销售', value: 0 },
|
let trendChart = null;
|
||||||
{ label: '昨日零售', value: 0 },
|
|
||||||
{ label: '昨日采购', value: 0 },
|
|
||||||
{ label: '今年累计销售', value: 0 },
|
|
||||||
{ label: '今年累计零售', value: 0 },
|
|
||||||
{ label: '今年累计采购', value: 0 },
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 图表实例
|
const renderTrendChart = () => {
|
||||||
const salesChartRef = ref<HTMLElement | null>(null);
|
if (!trendChartRef.value) return;
|
||||||
const retailChartRef = ref<HTMLElement | null>(null);
|
if (!trendChart) trendChart = echarts.init(trendChartRef.value);
|
||||||
const purchaseChartRef = ref<HTMLElement | null>(null);
|
const months = dashboard.trend.map((item) => item.month.slice(5) + "月");
|
||||||
const salesChartInstance = shallowRef<echarts.ECharts | null>(null);
|
trendChart.setOption({
|
||||||
const retailChartInstance = shallowRef<echarts.ECharts | null>(null);
|
tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
|
||||||
const purchaseChartInstance = shallowRef<echarts.ECharts | null>(null);
|
legend: { data: ["已报销", "未报销"], top: 0 },
|
||||||
|
|
||||||
// 格式化金额
|
|
||||||
const formatMoney = (value: number): string => {
|
|
||||||
return value.toLocaleString();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 生成最近6个月的月份标签
|
|
||||||
const getMonthLabels = (): string[] => {
|
|
||||||
const labels: string[] = [];
|
|
||||||
const now = new Date();
|
|
||||||
for (let i = 5; i >= 0; i--) {
|
|
||||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
|
||||||
const year = d.getFullYear();
|
|
||||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
|
||||||
labels.push(`${year}-${month}`);
|
|
||||||
}
|
|
||||||
return labels;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 初始化图表通用配置
|
|
||||||
const initChart = (refEl: HTMLElement, title: string, color: string) => {
|
|
||||||
const chart = echarts.init(refEl);
|
|
||||||
const months = getMonthLabels();
|
|
||||||
|
|
||||||
chart.setOption({
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
axisPointer: { type: 'cross' }
|
|
||||||
},
|
|
||||||
grid: {
|
grid: {
|
||||||
left: '3%',
|
left: "3%",
|
||||||
right: '4%',
|
right: "4%",
|
||||||
bottom: '3%',
|
bottom: "3%",
|
||||||
top: '15%',
|
top: "18%",
|
||||||
containLabel: true
|
containLabel: true,
|
||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: "category",
|
||||||
boundaryGap: false,
|
|
||||||
data: months,
|
data: months,
|
||||||
axisLine: { lineStyle: { color: '#E0E6ED' } },
|
axisLine: { lineStyle: { color: "#E0E6ED" } },
|
||||||
axisLabel: { color: '#606266', fontSize: 11 }
|
axisLabel: { color: "#606266", fontSize: 11 },
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: "value",
|
||||||
axisLine: { show: false },
|
axisLine: { show: false },
|
||||||
axisTick: { show: false },
|
axisTick: { show: false },
|
||||||
splitLine: { lineStyle: { color: '#F2F6FC' } },
|
splitLine: { lineStyle: { color: "#F2F6FC" } },
|
||||||
axisLabel: { color: '#606266', fontSize: 11 }
|
axisLabel: { color: "#606266", fontSize: 11 },
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: title,
|
name: "已报销",
|
||||||
type: 'line',
|
type: "bar",
|
||||||
smooth: true,
|
stack: "total",
|
||||||
symbol: 'circle',
|
barMaxWidth: 32,
|
||||||
symbolSize: 6,
|
itemStyle: { color: "#67C23A" },
|
||||||
lineStyle: { color: color, width: 2 },
|
data: dashboard.trend.map((item) => Number(item.paid || 0)),
|
||||||
itemStyle: { color: color },
|
},
|
||||||
areaStyle: {
|
{
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
name: "未报销",
|
||||||
{ offset: 0, color: color + '40' },
|
type: "bar",
|
||||||
{ offset: 1, color: color + '10' }
|
stack: "total",
|
||||||
])
|
barMaxWidth: 32,
|
||||||
},
|
itemStyle: { color: "#E6A23C" },
|
||||||
data: [0, 0, 0, 0, 0, 0]
|
data: dashboard.trend.map((item) => Number(item.unpaid || 0)),
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
return chart;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 初始化所有图表
|
// 状态分布占比
|
||||||
const initCharts = () => {
|
const distTotal = computed(() =>
|
||||||
if (salesChartRef.value) {
|
Object.values(dashboard.status_dist).reduce(
|
||||||
salesChartInstance.value = initChart(salesChartRef.value, '销售', '#409EFF');
|
(sum, item) => sum + Number(item.count || 0),
|
||||||
}
|
0,
|
||||||
if (retailChartRef.value) {
|
),
|
||||||
retailChartInstance.value = initChart(retailChartRef.value, '零售', '#67C23A');
|
);
|
||||||
}
|
const distPercent = (count) =>
|
||||||
if (purchaseChartRef.value) {
|
distTotal.value
|
||||||
purchaseChartInstance.value = initChart(purchaseChartRef.value, '采购', '#E6A23C');
|
? `${Math.round((Number(count || 0) / distTotal.value) * 100)}%`
|
||||||
|
: "0%";
|
||||||
|
|
||||||
|
// 类型分析占比
|
||||||
|
const maxTypeAmount = computed(() =>
|
||||||
|
Math.max(1, ...dashboard.expense_types.map((item) => Number(item.amount || 0))),
|
||||||
|
);
|
||||||
|
const typePercent = (amount) =>
|
||||||
|
`${Math.round((Number(amount || 0) / maxTypeAmount.value) * 100)}%`;
|
||||||
|
|
||||||
|
const responseData = (res) => res?.data?.data ?? res?.data ?? res ?? {};
|
||||||
|
|
||||||
|
const loadDashboard = async () => {
|
||||||
|
try {
|
||||||
|
const data = responseData(await getReimbursementDashboard());
|
||||||
|
dashboard.week = data.week || emptyStat();
|
||||||
|
dashboard.month = data.month || emptyStat();
|
||||||
|
dashboard.year = data.year || emptyStat();
|
||||||
|
dashboard.trend = data.trend || [];
|
||||||
|
dashboard.status_dist = data.status_dist || {};
|
||||||
|
dashboard.expense_types = data.expense_types || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("加载报销统计失败:", error?.message);
|
||||||
}
|
}
|
||||||
|
renderTrendChart();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 加载数据(模拟接口调用)
|
const handleResize = () => trendChart?.resize();
|
||||||
const loadData = async () => {
|
|
||||||
// TODO: 调用接口获取真实数据
|
|
||||||
// const res = await getErpDashboard();
|
|
||||||
|
|
||||||
// 模拟数据更新
|
|
||||||
firstRowStats.value = [
|
|
||||||
{ label: '今日销售', value: 12580 },
|
|
||||||
{ label: '今日零售', value: 8560 },
|
|
||||||
{ label: '今日采购', value: 23400 },
|
|
||||||
{ label: '本月累计销售', value: 358600 },
|
|
||||||
{ label: '本月累计零售', value: 245800 },
|
|
||||||
{ label: '本月累计采购', value: 568900 },
|
|
||||||
];
|
|
||||||
|
|
||||||
secondRowStats.value = [
|
|
||||||
{ label: '昨日销售', value: 15230 },
|
|
||||||
{ label: '昨日零售', value: 9870 },
|
|
||||||
{ label: '昨日采购', value: 18900 },
|
|
||||||
{ label: '今年累计销售', value: 2856000 },
|
|
||||||
{ label: '今年累计零售', value: 1985000 },
|
|
||||||
{ label: '今年累计采购', value: 4568000 },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 更新图表数据
|
|
||||||
updateCharts();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 更新图表数据
|
const money = (value) => Number(value || 0).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
const updateCharts = () => {
|
const statusType = (value) =>
|
||||||
const salesData = [28000, 32000, 35000, 38000, 42000, 358600];
|
({ 0: "info", 1: "warning", 2: "success", 3: "danger", 4: "info", 5: "success" })[value] ||
|
||||||
const retailData = [18000, 21000, 22000, 25000, 28000, 245800];
|
"info";
|
||||||
const purchaseData = [45000, 48000, 52000, 55000, 58000, 568900];
|
|
||||||
|
|
||||||
if (salesChartInstance.value) {
|
|
||||||
salesChartInstance.value.setOption({ series: [{ data: salesData }] });
|
|
||||||
}
|
|
||||||
if (retailChartInstance.value) {
|
|
||||||
retailChartInstance.value.setOption({ series: [{ data: retailData }] });
|
|
||||||
}
|
|
||||||
if (purchaseChartInstance.value) {
|
|
||||||
purchaseChartInstance.value.setOption({ series: [{ data: purchaseData }] });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 自适应窗口大小
|
|
||||||
const handleResize = () => {
|
|
||||||
salesChartInstance.value?.resize();
|
|
||||||
retailChartInstance.value?.resize();
|
|
||||||
purchaseChartInstance.value?.resize();
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
initCharts();
|
await loadDashboard();
|
||||||
await nextTick();
|
window.addEventListener("resize", handleResize);
|
||||||
await loadData();
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('resize', handleResize);
|
window.removeEventListener("resize", handleResize);
|
||||||
salesChartInstance.value?.dispose();
|
trendChart?.dispose();
|
||||||
retailChartInstance.value?.dispose();
|
trendChart = null;
|
||||||
purchaseChartInstance.value?.dispose();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.erp-dashboard {
|
.oa-dashboard {
|
||||||
background-color: #f5f7fa;
|
background: #f5f7fa;
|
||||||
// min-height: calc(100vh - 84px);
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: #303133;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-row {
|
.stat-row {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
.el-col {
|
.el-col {
|
||||||
margin-bottom: 0;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,48 +259,46 @@ onUnmounted(() => {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
.stat-header {
|
.stat-title {
|
||||||
display: flex;
|
color: #909399;
|
||||||
justify-content: space-between;
|
font-size: 13px;
|
||||||
align-items: center;
|
margin-bottom: 6px;
|
||||||
margin-bottom: 12px;
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #606266;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-icon {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #c0c4cc;
|
|
||||||
cursor: help;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-amount {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
.currency {
|
.currency {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #303133;
|
color: #303133;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.number {
|
.number {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #303133;
|
color: #303133;
|
||||||
font-family: 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-row {
|
.chart-row {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
.el-col {
|
.el-col {
|
||||||
margin-bottom: 0;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,10 +307,7 @@ onUnmounted(() => {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||||
height: 320px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.chart-title {
|
.chart-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -320,70 +316,103 @@ onUnmounted(() => {
|
|||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
border-bottom: 1px solid #ebeef5;
|
border-bottom: 1px solid #ebeef5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-container {
|
.chart-container {
|
||||||
flex: 1;
|
height: 280px;
|
||||||
min-height: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 手机端适配:统计卡片每行 2 个,图表卡片每行 1 个
|
.type-card {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dist,
|
||||||
|
.type-dist {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-row,
|
||||||
|
.type-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-bar-wrap,
|
||||||
|
.type-bar-wrap {
|
||||||
|
flex: 1;
|
||||||
|
height: 10px;
|
||||||
|
background: #f0f2f5;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: #409eff;
|
||||||
|
border-radius: 5px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #409eff, #67c23a);
|
||||||
|
border-radius: 5px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-count {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-name {
|
||||||
|
width: 110px;
|
||||||
|
color: #606266;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: right;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-count {
|
||||||
|
width: 50px;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-amount {
|
||||||
|
width: 110px;
|
||||||
|
color: #303133;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.erp-dashboard {
|
.oa-dashboard {
|
||||||
overflow-x: hidden;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// el-row gutter 通过负 margin + col padding 实现,手机端缩小间距并防止横向溢出
|
.page-header {
|
||||||
.stat-row {
|
flex-direction: column;
|
||||||
margin-left: -4px !important;
|
align-items: flex-start;
|
||||||
margin-right: -4px !important;
|
gap: 12px;
|
||||||
|
|
||||||
:deep(.el-col) {
|
|
||||||
padding-left: 4px !important;
|
|
||||||
padding-right: 4px !important;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
|
|
||||||
&:nth-last-child(-n + 2) {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-row {
|
.type-name {
|
||||||
margin-left: 0 !important;
|
width: 80px;
|
||||||
margin-right: 0 !important;
|
|
||||||
|
|
||||||
:deep(.el-col) {
|
|
||||||
padding-left: 0 !important;
|
|
||||||
padding-right: 0 !important;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 一行两个统计卡片,缩小内边距与字号防止挤压
|
.type-amount {
|
||||||
.stat-card {
|
width: auto;
|
||||||
padding: 10px 12px;
|
|
||||||
|
|
||||||
.stat-header {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value .number {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-card {
|
|
||||||
height: 280px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -140,25 +140,25 @@
|
|||||||
>
|
>
|
||||||
详情
|
详情
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<!-- <el-button
|
||||||
size="small"
|
size="small"
|
||||||
type="danger"
|
type="danger"
|
||||||
link
|
link
|
||||||
@click="handleDelete(scope.row)"
|
@click="handleDelete(scope.row)"
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</el-button>
|
</el-button> -->
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<!-- 批量删除 -->
|
<!-- 批量删除 -->
|
||||||
<div class="batch-bar" v-if="selectedLogs.length > 0">
|
<!-- <div class="batch-bar" v-if="selectedLogs.length > 0">
|
||||||
<el-button type="danger" plain @click="handleBatchDelete">
|
<el-button type="danger" plain @click="handleBatchDelete">
|
||||||
<el-icon><Delete /></el-icon>
|
<el-icon><Delete /></el-icon>
|
||||||
批量删除 ({{ selectedLogs.length }})
|
批量删除 ({{ selectedLogs.length }})
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div> -->
|
||||||
|
|
||||||
<!-- 分页 -->
|
<!-- 分页 -->
|
||||||
<div class="pagination-bar">
|
<div class="pagination-bar">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"path"
|
"path"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -1149,3 +1150,150 @@ func nullableReimburseString(value string) *string {
|
|||||||
func uint64Ptr(value uint64) *uint64 {
|
func uint64Ptr(value uint64) *uint64 {
|
||||||
return &value
|
return &value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dashboard GET /backend/reimbursements/dashboard — 报销统计(周/月/年 + 近6个月趋势 + 状态分布)
|
||||||
|
func (c *BackendReimburseController) Dashboard() {
|
||||||
|
claims, ok := c.claims()
|
||||||
|
if !ok {
|
||||||
|
c.reply(401, "未登录或无权限", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
y, m, _ := now.Date()
|
||||||
|
today := time.Date(y, m, now.Day(), 0, 0, 0, 0, now.Location())
|
||||||
|
tomorrow := today.AddDate(0, 0, 1)
|
||||||
|
weekday := int(today.Weekday())
|
||||||
|
if weekday == 0 {
|
||||||
|
weekday = 7
|
||||||
|
}
|
||||||
|
weekStart := today.AddDate(0, 0, -(weekday - 1))
|
||||||
|
monthStart := time.Date(y, m, 1, 0, 0, 0, 0, now.Location())
|
||||||
|
yearStart := time.Date(y, 1, 1, 0, 0, 0, 0, now.Location())
|
||||||
|
|
||||||
|
var rows []models.BackendReimbursement
|
||||||
|
_, err := models.Orm.QueryTable(new(models.BackendReimbursement)).
|
||||||
|
Filter("tid", claims.TenantId).
|
||||||
|
Filter("user_id", claims.UserID).
|
||||||
|
Filter("is_deleted", 0).
|
||||||
|
All(&rows)
|
||||||
|
if err != nil {
|
||||||
|
c.reply(500, "查询失败", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: 个人报销单量小,一次全量查询内存聚合;量大时改 SQL GROUP BY
|
||||||
|
newStat := func() map[string]interface{} {
|
||||||
|
return map[string]interface{}{"total": 0.0, "paid": 0.0, "unpaid": 0.0, "count": 0}
|
||||||
|
}
|
||||||
|
addTo := func(stat map[string]interface{}, row *models.BackendReimbursement) {
|
||||||
|
stat["total"] = stat["total"].(float64) + row.TotalAmount
|
||||||
|
stat["count"] = stat["count"].(int) + 1
|
||||||
|
if row.Status == 5 { // 已打款视为已报销
|
||||||
|
stat["paid"] = stat["paid"].(float64) + row.TotalAmount
|
||||||
|
} else {
|
||||||
|
stat["unpaid"] = stat["unpaid"].(float64) + row.TotalAmount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inRange := func(t, start, end time.Time) bool {
|
||||||
|
return !t.Before(start) && t.Before(end)
|
||||||
|
}
|
||||||
|
|
||||||
|
weekStat, monthStat, yearStat := newStat(), newStat(), newStat()
|
||||||
|
monthKeys := make([]string, 0, 6)
|
||||||
|
monthStats := map[string]map[string]interface{}{}
|
||||||
|
for i := 5; i >= 0; i-- {
|
||||||
|
key := time.Date(y, m-time.Month(i), 1, 0, 0, 0, 0, now.Location()).Format("2006-01")
|
||||||
|
monthKeys = append(monthKeys, key)
|
||||||
|
monthStats[key] = newStat()
|
||||||
|
}
|
||||||
|
statusNames := map[int8]string{0: "草稿", 1: "审批中", 2: "已通过", 3: "已驳回", 4: "已撤回", 5: "已打款"}
|
||||||
|
statusDist := map[string]interface{}{}
|
||||||
|
|
||||||
|
for i := range rows {
|
||||||
|
row := &rows[i]
|
||||||
|
if inRange(row.ApplyDate, weekStart, tomorrow) {
|
||||||
|
addTo(weekStat, row)
|
||||||
|
}
|
||||||
|
if inRange(row.ApplyDate, monthStart, tomorrow) {
|
||||||
|
addTo(monthStat, row)
|
||||||
|
}
|
||||||
|
if !row.ApplyDate.Before(yearStart) {
|
||||||
|
addTo(yearStat, row)
|
||||||
|
}
|
||||||
|
if st, ok := monthStats[row.ApplyDate.Format("2006-01")]; ok {
|
||||||
|
addTo(st, row)
|
||||||
|
}
|
||||||
|
sk := strconv.Itoa(int(row.Status))
|
||||||
|
dist, ok := statusDist[sk].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
dist = map[string]interface{}{"name": statusNames[row.Status], "count": 0, "amount": 0.0}
|
||||||
|
statusDist[sk] = dist
|
||||||
|
}
|
||||||
|
dist["count"] = dist["count"].(int) + 1
|
||||||
|
dist["amount"] = dist["amount"].(float64) + row.TotalAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
trend := make([]map[string]interface{}, 0, len(monthKeys))
|
||||||
|
for _, key := range monthKeys {
|
||||||
|
st := monthStats[key]
|
||||||
|
trend = append(trend, map[string]interface{}{
|
||||||
|
"month": key, "total": st["total"], "paid": st["paid"],
|
||||||
|
"unpaid": st["unpaid"], "count": st["count"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 报销类型分析:仅统计已打款(status=5)的报销单费用明细
|
||||||
|
paidIDs := map[uint64]bool{}
|
||||||
|
for i := range rows {
|
||||||
|
if rows[i].Status == 5 {
|
||||||
|
paidIDs[rows[i].ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var items []models.BackendReimbursementItem
|
||||||
|
_, err = models.Orm.QueryTable(new(models.BackendReimbursementItem)).
|
||||||
|
Filter("tid", claims.TenantId).
|
||||||
|
Filter("uid", claims.UserID).
|
||||||
|
Filter("is_deleted", 0).
|
||||||
|
All(&items)
|
||||||
|
if err != nil {
|
||||||
|
c.reply(500, "查询失败", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
typeAgg := map[string]*struct {
|
||||||
|
count int
|
||||||
|
amount float64
|
||||||
|
}{}
|
||||||
|
for i := range items {
|
||||||
|
if !paidIDs[items[i].ReimbursementID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(items[i].ExpenseType)
|
||||||
|
if name == "" {
|
||||||
|
name = "未分类"
|
||||||
|
}
|
||||||
|
agg, ok := typeAgg[name]
|
||||||
|
if !ok {
|
||||||
|
agg = &struct {
|
||||||
|
count int
|
||||||
|
amount float64
|
||||||
|
}{}
|
||||||
|
typeAgg[name] = agg
|
||||||
|
}
|
||||||
|
agg.count++
|
||||||
|
agg.amount += items[i].Amount
|
||||||
|
}
|
||||||
|
expenseTypes := make([]map[string]interface{}, 0, len(typeAgg))
|
||||||
|
for name, agg := range typeAgg {
|
||||||
|
expenseTypes = append(expenseTypes, map[string]interface{}{
|
||||||
|
"name": name, "count": agg.count, "amount": agg.amount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(expenseTypes, func(i, j int) bool {
|
||||||
|
return expenseTypes[i]["amount"].(float64) > expenseTypes[j]["amount"].(float64)
|
||||||
|
})
|
||||||
|
|
||||||
|
c.reply(200, "success", map[string]interface{}{
|
||||||
|
"week": weekStat, "month": monthStat, "year": yearStat,
|
||||||
|
"trend": trend, "status_dist": statusDist, "expense_types": expenseTypes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ func (c *PlatformAuthController) LoginPlatform() {
|
|||||||
// 控制器只做 HTTP 解析与响应编排,业务逻辑放 services 层
|
// 控制器只做 HTTP 解析与响应编排,业务逻辑放 services 层
|
||||||
token, loginUser, err := services.PlatformAdminLogin(req.Account, req.Password)
|
token, loginUser, err := services.PlatformAdminLogin(req.Account, req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
RecordLoginLog(nil, 0, req.Account, "", "", "password", 0, err.Error(), c.Ctx.Input.IP(), c.Ctx.Request.UserAgent())
|
||||||
c.Data["json"] = map[string]interface{}{
|
c.Data["json"] = map[string]interface{}{
|
||||||
"code": 401,
|
"code": 401,
|
||||||
"msg": err.Error(),
|
"msg": err.Error(),
|
||||||
@@ -137,6 +138,8 @@ func (c *PlatformAuthController) LoginPlatform() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RecordLoginLog(nil, loginUser.ID, loginUser.Account, loginUser.Name, "", "password", 1, "登录成功", c.Ctx.Input.IP(), c.Ctx.Request.UserAgent())
|
||||||
|
|
||||||
c.Data["json"] = map[string]interface{}{
|
c.Data["json"] = map[string]interface{}{
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"msg": "登录成功",
|
"msg": "登录成功",
|
||||||
@@ -422,4 +425,3 @@ func (c *PlatformAuthController) SendResetCode() {
|
|||||||
}
|
}
|
||||||
_ = c.ServeJSON()
|
_ = c.ServeJSON()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
"server/pkg/jwtutil"
|
||||||
|
|
||||||
|
"github.com/beego/beego/v2/client/orm"
|
||||||
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlatformLoginLogController 登录日志(yz_system_login_log,平台端)
|
||||||
|
type PlatformLoginLogController struct {
|
||||||
|
beego.Controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PlatformLoginLogController) jsonErr(httpStatus, bizCode int, msg string) {
|
||||||
|
c.Ctx.Output.SetStatus(httpStatus)
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PlatformLoginLogController) platformClaims() (*jwtutil.Claims, error) {
|
||||||
|
auth := c.Ctx.Request.Header.Get("Authorization")
|
||||||
|
if auth == "" {
|
||||||
|
return nil, fmt.Errorf("未登录")
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(auth, " ", 2)
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
return nil, fmt.Errorf("认证信息格式错误")
|
||||||
|
}
|
||||||
|
claims, err := jwtutil.ParseToken(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("无效的token")
|
||||||
|
}
|
||||||
|
if claims.UserType != "platform" {
|
||||||
|
return nil, fmt.Errorf("无权访问")
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List GET /platform/loginLogs?page=1&pageSize=20&keyword=&status=&loginType=&startTime=&endTime=
|
||||||
|
func (c *PlatformLoginLogController) List() {
|
||||||
|
if _, err := c.platformClaims(); err != nil {
|
||||||
|
c.jsonErr(401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page, _ := c.GetInt("page", 1)
|
||||||
|
pageSize, _ := c.GetInt("pageSize", 20)
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
if pageSize > 200 {
|
||||||
|
pageSize = 200
|
||||||
|
}
|
||||||
|
|
||||||
|
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||||
|
statusStr := strings.TrimSpace(c.GetString("status"))
|
||||||
|
loginType := strings.TrimSpace(c.GetString("loginType"))
|
||||||
|
startTimeStr := strings.TrimSpace(c.GetString("startTime"))
|
||||||
|
endTimeStr := strings.TrimSpace(c.GetString("endTime"))
|
||||||
|
|
||||||
|
qs := models.Orm.QueryTable(new(models.SystemLoginLog)).Filter("delete_time__isnull", true)
|
||||||
|
|
||||||
|
cond := orm.NewCondition()
|
||||||
|
needCond := false
|
||||||
|
|
||||||
|
if statusStr != "" {
|
||||||
|
if st, err := strconv.Atoi(statusStr); err == nil {
|
||||||
|
cond = cond.And("status", st)
|
||||||
|
needCond = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if loginType != "" {
|
||||||
|
cond = cond.And("login_type", loginType)
|
||||||
|
needCond = true
|
||||||
|
}
|
||||||
|
if keyword != "" {
|
||||||
|
kw := orm.NewCondition().
|
||||||
|
Or("account__icontains", keyword).
|
||||||
|
Or("user_name__icontains", keyword).
|
||||||
|
Or("tenant_name__icontains", keyword).
|
||||||
|
Or("ip__icontains", keyword)
|
||||||
|
if uid, err := strconv.ParseUint(keyword, 10, 64); err == nil && uid > 0 {
|
||||||
|
kw = kw.Or("user_id", uid)
|
||||||
|
}
|
||||||
|
cond = cond.AndCond(kw)
|
||||||
|
needCond = true
|
||||||
|
}
|
||||||
|
if t, err := parseTimeFlexible(startTimeStr); err == nil && !t.IsZero() {
|
||||||
|
cond = cond.And("create_time__gte", t)
|
||||||
|
needCond = true
|
||||||
|
}
|
||||||
|
if t, err := parseTimeFlexible(endTimeStr); err == nil && !t.IsZero() {
|
||||||
|
cond = cond.And("create_time__lte", t)
|
||||||
|
needCond = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if needCond {
|
||||||
|
qs = qs.SetCond(cond)
|
||||||
|
}
|
||||||
|
|
||||||
|
total, err := qs.Count()
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(500, 500, "获取登录日志失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []models.SystemLoginLog
|
||||||
|
_, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(500, 500, "获取登录日志失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]map[string]interface{}, 0, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
item := map[string]interface{}{
|
||||||
|
"id": rows[i].ID,
|
||||||
|
"tid": rows[i].Tid,
|
||||||
|
"user_id": rows[i].UserID,
|
||||||
|
"account": rows[i].Account,
|
||||||
|
"user_name": rows[i].UserName,
|
||||||
|
"tenant_name": rows[i].TenantName,
|
||||||
|
"login_type": rows[i].LoginType,
|
||||||
|
"status": rows[i].Status,
|
||||||
|
"message": rows[i].Message,
|
||||||
|
"ip": rows[i].IP,
|
||||||
|
"user_agent": rows[i].UserAgent,
|
||||||
|
"create_time": rows[i].CreateTime.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
list = append(list, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"list": list,
|
||||||
|
"total": total,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail GET /platform/loginLogs/:id
|
||||||
|
func (c *PlatformLoginLogController) Detail() {
|
||||||
|
if _, err := c.platformClaims(); err != nil {
|
||||||
|
c.jsonErr(401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idStr := c.Ctx.Input.Param(":id")
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.jsonErr(400, 400, "无效ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var row models.SystemLoginLog
|
||||||
|
err = models.Orm.QueryTable(new(models.SystemLoginLog)).
|
||||||
|
Filter("id", id).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
One(&row)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(404, 404, "记录不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"id": row.ID,
|
||||||
|
"tid": row.Tid,
|
||||||
|
"user_id": row.UserID,
|
||||||
|
"account": row.Account,
|
||||||
|
"user_name": row.UserName,
|
||||||
|
"tenant_name": row.TenantName,
|
||||||
|
"login_type": row.LoginType,
|
||||||
|
"status": row.Status,
|
||||||
|
"message": row.Message,
|
||||||
|
"ip": row.IP,
|
||||||
|
"user_agent": row.UserAgent,
|
||||||
|
"create_time": row.CreateTime.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": out}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete DELETE /platform/loginLogs/:id
|
||||||
|
func (c *PlatformLoginLogController) Delete() {
|
||||||
|
if _, err := c.platformClaims(); err != nil {
|
||||||
|
c.jsonErr(401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idStr := c.Ctx.Input.Param(":id")
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.jsonErr(400, 400, "无效ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
n, err := models.Orm.QueryTable(new(models.SystemLoginLog)).
|
||||||
|
Filter("id", id).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Update(map[string]interface{}{"delete_time": now})
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(500, 500, "删除失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
c.jsonErr(404, 404, "记录不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
type platformLoginLogBatchDeletePayload struct {
|
||||||
|
IDs []uint64 `json:"ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchDelete POST /platform/loginLogs/batchDelete
|
||||||
|
func (c *PlatformLoginLogController) BatchDelete() {
|
||||||
|
if _, err := c.platformClaims(); err != nil {
|
||||||
|
c.jsonErr(401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p platformLoginLogBatchDeletePayload
|
||||||
|
if err := json.Unmarshal(raw, &p); err != nil {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(p.IDs) == 0 {
|
||||||
|
c.jsonErr(400, 400, "请选择要删除的日志")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
_, err = models.Orm.QueryTable(new(models.SystemLoginLog)).
|
||||||
|
Filter("id__in", p.IDs).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Update(map[string]interface{}{"delete_time": now})
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(500, 500, "批量删除失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "批量删除成功"}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
@@ -204,6 +204,7 @@ func RegisterAuthRoutes() {
|
|||||||
|
|
||||||
// 报销与审批管理
|
// 报销与审批管理
|
||||||
beego.Router("/backend/reimbursements", &controllers.BackendReimburseController{}, "get:List;post:Create")
|
beego.Router("/backend/reimbursements", &controllers.BackendReimburseController{}, "get:List;post:Create")
|
||||||
|
beego.Router("/backend/reimbursements/dashboard", &controllers.BackendReimburseController{}, "get:Dashboard")
|
||||||
beego.Router("/backend/reimbursements/types/manage", &controllers.BackendReimburseController{}, "get:TypesManage")
|
beego.Router("/backend/reimbursements/types/manage", &controllers.BackendReimburseController{}, "get:TypesManage")
|
||||||
beego.Router("/backend/reimbursements/types/:type_id", &controllers.BackendReimburseController{}, "post:UpdateType;delete:DeleteType")
|
beego.Router("/backend/reimbursements/types/:type_id", &controllers.BackendReimburseController{}, "post:UpdateType;delete:DeleteType")
|
||||||
beego.Router("/backend/reimbursements/types", &controllers.BackendReimburseController{}, "get:Types;post:CreateType")
|
beego.Router("/backend/reimbursements/types", &controllers.BackendReimburseController{}, "get:Types;post:CreateType")
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ func Register() {
|
|||||||
beego.Router("/platform/operationLogs/:id", &controllers.PlatformOperationLogController{}, "get:Detail;delete:Delete")
|
beego.Router("/platform/operationLogs/:id", &controllers.PlatformOperationLogController{}, "get:Detail;delete:Delete")
|
||||||
beego.Router("/platform/operationLogs/batchDelete", &controllers.PlatformOperationLogController{}, "post:BatchDelete")
|
beego.Router("/platform/operationLogs/batchDelete", &controllers.PlatformOperationLogController{}, "post:BatchDelete")
|
||||||
|
|
||||||
|
// 登录日志(yz_system_login_log)
|
||||||
|
beego.Router("/platform/loginLogs", &controllers.PlatformLoginLogController{}, "get:List")
|
||||||
|
beego.Router("/platform/loginLogs/batchDelete", &controllers.PlatformLoginLogController{}, "post:BatchDelete")
|
||||||
|
beego.Router("/platform/loginLogs/:id", &controllers.PlatformLoginLogController{}, "get:Detail;delete:Delete")
|
||||||
|
|
||||||
// 域名管理(主域名池 / 租户域名)
|
// 域名管理(主域名池 / 租户域名)
|
||||||
beego.Router("/platform/domain/pool/index", &controllers.PlatformDomainPoolController{}, "get:Index")
|
beego.Router("/platform/domain/pool/index", &controllers.PlatformDomainPoolController{}, "get:Index")
|
||||||
beego.Router("/platform/domain/pool/getEnabledDomains", &controllers.PlatformDomainPoolController{}, "get:GetEnabledDomains")
|
beego.Router("/platform/domain/pool/getEnabledDomains", &controllers.PlatformDomainPoolController{}, "get:GetEnabledDomains")
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取登录日志列表
|
||||||
|
* @param {Object} params 查询参数
|
||||||
|
* @param {number} params.page 页码
|
||||||
|
* @param {number} params.pageSize 每页数量
|
||||||
|
* @param {string} params.keyword 关键词搜索(账号/姓名/租户/IP)
|
||||||
|
* @param {string} params.status 状态筛选
|
||||||
|
* @param {string} params.loginType 登录方式筛选
|
||||||
|
* @param {string} params.startTime 开始时间
|
||||||
|
* @param {string} params.endTime 结束时间
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export function getLoginLogs(params) {
|
||||||
|
return request({
|
||||||
|
url: "/platform/loginLogs",
|
||||||
|
method: "get",
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取登录日志详情
|
||||||
|
* @param {number|string} id 日志ID
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export function getLoginLogDetail(id) {
|
||||||
|
return request({
|
||||||
|
url: `/platform/loginLogs/${id}`,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除登录日志
|
||||||
|
* @param {number|string} id 日志ID
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export function deleteLoginLog(id) {
|
||||||
|
return request({
|
||||||
|
url: `/platform/loginLogs/${id}`,
|
||||||
|
method: "delete",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除登录日志
|
||||||
|
* @param {Array} ids 日志ID数组
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export function batchDeleteLoginLogs(ids) {
|
||||||
|
return request({
|
||||||
|
url: "/platform/loginLogs/batchDelete",
|
||||||
|
method: "post",
|
||||||
|
data: { ids },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<template>
|
||||||
|
<el-drawer v-model="visible" title="登录日志详情" size="50%">
|
||||||
|
<div class="log-detail" v-if="log">
|
||||||
|
<el-descriptions :column="2" border>
|
||||||
|
<el-descriptions-item label="日志ID" label-width="150px">
|
||||||
|
{{ log.id }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="登录状态" label-width="150px">
|
||||||
|
<el-tag :type="log.status === 1 ? 'success' : 'danger'" size="small">
|
||||||
|
{{ log.status === 1 ? "成功" : "失败" }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="用户ID" label-width="150px">
|
||||||
|
{{ log.user_id }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="用户姓名" label-width="150px">
|
||||||
|
{{ log.user_name || "-" }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="登录账号" label-width="150px">
|
||||||
|
{{ log.account }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="租户名称" label-width="150px">
|
||||||
|
{{ log.tenant_name || "-" }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="登录方式" label-width="150px">
|
||||||
|
<el-tag type="primary" size="small">
|
||||||
|
{{ getLoginTypeLabel(log.login_type) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="登录时间" label-width="150px">
|
||||||
|
{{ log.create_time }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="IP地址" label-width="150px">
|
||||||
|
{{ log.ip || "-" }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="结果信息" label-width="150px">
|
||||||
|
{{ log.message || "-" }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="用户代理" :span="2" label-width="150px">
|
||||||
|
<div class="user-agent-text">{{ log.user_agent || "无" }}</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</div>
|
||||||
|
<div v-else class="loading-container">
|
||||||
|
<el-empty description="加载中..." />
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
import { getLoginLogDetail } from "@/api/loginLog";
|
||||||
|
|
||||||
|
interface LoginLog {
|
||||||
|
id: number;
|
||||||
|
tid: number | null;
|
||||||
|
user_id: number;
|
||||||
|
account: string;
|
||||||
|
user_name: string;
|
||||||
|
tenant_name: string;
|
||||||
|
login_type: string;
|
||||||
|
status: number;
|
||||||
|
message: string;
|
||||||
|
ip: string;
|
||||||
|
user_agent: string;
|
||||||
|
create_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
logId: {
|
||||||
|
type: Number,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const log = ref<LoginLog | null>(null);
|
||||||
|
|
||||||
|
// 登录方式标签
|
||||||
|
const getLoginTypeLabel = (type: string): string => {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
password: "密码登录",
|
||||||
|
sms: "短信验证码",
|
||||||
|
email: "邮箱验证码",
|
||||||
|
};
|
||||||
|
return typeMap[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取日志详情
|
||||||
|
const fetchLogDetail = async (logId?: number) => {
|
||||||
|
const targetId = logId || props.logId;
|
||||||
|
if (!targetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.value = null; // 清空之前的数据
|
||||||
|
try {
|
||||||
|
const res = await getLoginLogDetail(targetId);
|
||||||
|
if (res.code === 200) {
|
||||||
|
log.value = res.data;
|
||||||
|
} else {
|
||||||
|
console.error("获取登录日志详情失败:", res?.msg || "未知错误");
|
||||||
|
log.value = null;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("获取登录日志详情失败", error);
|
||||||
|
log.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听 modelValue 变化
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newVal) => {
|
||||||
|
visible.value = newVal;
|
||||||
|
if (newVal && props.logId) {
|
||||||
|
fetchLogDetail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 监听 visible 变化
|
||||||
|
watch(visible, (newVal) => {
|
||||||
|
if (!newVal) {
|
||||||
|
emit("update:modelValue", false);
|
||||||
|
log.value = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 暴露方法给父组件
|
||||||
|
defineExpose({
|
||||||
|
open: (logId: number) => {
|
||||||
|
if (logId) {
|
||||||
|
fetchLogDetail(logId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.log-detail {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-agent-text {
|
||||||
|
word-break: break-all;
|
||||||
|
color: #606266;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container-box">
|
||||||
|
<div class="header-bar">
|
||||||
|
<h2>登录日志</h2>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button @click="refresh">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-divider></el-divider>
|
||||||
|
|
||||||
|
<!-- 搜索筛选 -->
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-form :inline="true" :model="filterForm" class="filter-form">
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input
|
||||||
|
v-model="filterForm.keyword"
|
||||||
|
placeholder="搜索账号、姓名、租户、IP"
|
||||||
|
clearable
|
||||||
|
style="width: 200px"
|
||||||
|
@clear="handleSearch"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select
|
||||||
|
v-model="filterForm.status"
|
||||||
|
placeholder="请选择状态"
|
||||||
|
clearable
|
||||||
|
style="width: 120px"
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<el-option label="成功" value="1" />
|
||||||
|
<el-option label="失败" value="0" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="登录方式">
|
||||||
|
<el-select
|
||||||
|
v-model="filterForm.loginType"
|
||||||
|
placeholder="请选择登录方式"
|
||||||
|
clearable
|
||||||
|
style="width: 140px"
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<el-option label="密码登录" value="password" />
|
||||||
|
<el-option label="短信验证码" value="sms" />
|
||||||
|
<el-option label="邮箱验证码" value="email" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="时间范围">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="datetimerange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始时间"
|
||||||
|
end-placeholder="结束时间"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
style="width: 350px"
|
||||||
|
@change="handleDateRangeChange"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch">
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
搜索
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 登录日志列表 -->
|
||||||
|
<el-table
|
||||||
|
:data="logs"
|
||||||
|
style="width: 100%"
|
||||||
|
v-loading="loading"
|
||||||
|
@selection-change="handleSelectionChange"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="55" align="center" />
|
||||||
|
<el-table-column prop="id" label="ID" width="60" align="center" />
|
||||||
|
<el-table-column
|
||||||
|
prop="user_name"
|
||||||
|
label="用户姓名"
|
||||||
|
width="110"
|
||||||
|
align="center"
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
prop="account"
|
||||||
|
label="登录账号"
|
||||||
|
width="130"
|
||||||
|
align="center"
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
prop="tenant_name"
|
||||||
|
label="租户名称"
|
||||||
|
width="130"
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
|
<template #default="scope">
|
||||||
|
{{ scope.row.tenant_name || "-" }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="login_type" label="登录方式" width="100" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag type="primary" size="small">
|
||||||
|
{{ getLoginTypeLabel(scope.row.login_type) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="status" label="状态" width="80" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag
|
||||||
|
:type="scope.row.status === 1 ? 'success' : 'danger'"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ scope.row.status === 1 ? "成功" : "失败" }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
prop="message"
|
||||||
|
label="结果信息"
|
||||||
|
min-width="140"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column prop="ip" label="IP地址" width="130" align="center" />
|
||||||
|
<el-table-column
|
||||||
|
prop="create_time"
|
||||||
|
label="登录时间"
|
||||||
|
width="180"
|
||||||
|
align="center"
|
||||||
|
/>
|
||||||
|
<el-table-column label="操作" width="120" align="center" fixed="right">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="handleViewDetail(scope.row)"
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="pagination-bar">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 详情对话框 -->
|
||||||
|
<DetailDialog
|
||||||
|
ref="detailDialogRef"
|
||||||
|
v-model="detailDialogVisible"
|
||||||
|
:log-id="currentLogId"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, nextTick } from "vue";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import { Refresh, Search } from "@element-plus/icons-vue";
|
||||||
|
import { getLoginLogs } from "@/api/loginLog";
|
||||||
|
import DetailDialog from "./components/detail.vue";
|
||||||
|
|
||||||
|
interface LoginLog {
|
||||||
|
id: number;
|
||||||
|
tid: number | null;
|
||||||
|
user_id: number;
|
||||||
|
account: string;
|
||||||
|
user_name: string;
|
||||||
|
tenant_name: string;
|
||||||
|
login_type: string;
|
||||||
|
status: number;
|
||||||
|
message: string;
|
||||||
|
ip: string;
|
||||||
|
user_agent: string;
|
||||||
|
create_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = ref(20);
|
||||||
|
const total = ref(0);
|
||||||
|
const loading = ref(false);
|
||||||
|
const logs = ref<LoginLog[]>([]);
|
||||||
|
const selectedLogs = ref<LoginLog[]>([]);
|
||||||
|
|
||||||
|
// 筛选表单
|
||||||
|
const filterForm = ref({
|
||||||
|
keyword: "",
|
||||||
|
status: "",
|
||||||
|
loginType: "",
|
||||||
|
startTime: "",
|
||||||
|
endTime: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const dateRange = ref<[string, string] | null>(null);
|
||||||
|
|
||||||
|
// 详情对话框
|
||||||
|
const detailDialogVisible = ref(false);
|
||||||
|
const detailDialogRef = ref<any>(null);
|
||||||
|
const currentLogId = ref<number | undefined>(undefined);
|
||||||
|
|
||||||
|
// 登录方式标签
|
||||||
|
const getLoginTypeLabel = (type: string): string => {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
password: "密码登录",
|
||||||
|
sms: "短信验证码",
|
||||||
|
email: "邮箱验证码",
|
||||||
|
};
|
||||||
|
return typeMap[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取登录日志列表
|
||||||
|
const fetchLogs = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filterForm.value.keyword) {
|
||||||
|
params.keyword = filterForm.value.keyword;
|
||||||
|
}
|
||||||
|
if (filterForm.value.status !== "") {
|
||||||
|
params.status = filterForm.value.status;
|
||||||
|
}
|
||||||
|
if (filterForm.value.loginType) {
|
||||||
|
params.loginType = filterForm.value.loginType;
|
||||||
|
}
|
||||||
|
if (filterForm.value.startTime) {
|
||||||
|
params.startTime = filterForm.value.startTime;
|
||||||
|
}
|
||||||
|
if (filterForm.value.endTime) {
|
||||||
|
params.endTime = filterForm.value.endTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await getLoginLogs(params);
|
||||||
|
if (res.code === 200) {
|
||||||
|
logs.value = res.data.list || [];
|
||||||
|
total.value = res.data.total || 0;
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || "获取登录日志失败");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.message || "获取登录日志失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 刷新
|
||||||
|
const refresh = () => {
|
||||||
|
fetchLogs();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
page.value = 1;
|
||||||
|
fetchLogs();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
const handleReset = () => {
|
||||||
|
filterForm.value = {
|
||||||
|
keyword: "",
|
||||||
|
status: "",
|
||||||
|
loginType: "",
|
||||||
|
startTime: "",
|
||||||
|
endTime: "",
|
||||||
|
};
|
||||||
|
dateRange.value = null;
|
||||||
|
page.value = 1;
|
||||||
|
fetchLogs();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 时间范围变化
|
||||||
|
const handleDateRangeChange = (val: [string, string] | null) => {
|
||||||
|
if (val && val.length === 2) {
|
||||||
|
filterForm.value.startTime = val[0];
|
||||||
|
filterForm.value.endTime = val[1];
|
||||||
|
} else {
|
||||||
|
filterForm.value.startTime = "";
|
||||||
|
filterForm.value.endTime = "";
|
||||||
|
}
|
||||||
|
handleSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 分页改变
|
||||||
|
const handlePageChange = (val: number) => {
|
||||||
|
page.value = val;
|
||||||
|
fetchLogs();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 每页数量改变
|
||||||
|
const handleSizeChange = (val: number) => {
|
||||||
|
pageSize.value = val;
|
||||||
|
page.value = 1;
|
||||||
|
fetchLogs();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 选择改变
|
||||||
|
const handleSelectionChange = (selection: LoginLog[]) => {
|
||||||
|
selectedLogs.value = selection;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 查看详情
|
||||||
|
const handleViewDetail = (log: LoginLog) => {
|
||||||
|
currentLogId.value = log.id;
|
||||||
|
detailDialogVisible.value = true;
|
||||||
|
nextTick(() => {
|
||||||
|
if (detailDialogRef.value && detailDialogRef.value.open) {
|
||||||
|
detailDialogRef.value.open(log.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchLogs();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.container-box {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
.filter-form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-bar {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user