feat: 移动端完善与操作日志审计功能

1. 移动端fund-mobile完善:
   - 新增项目列表页面 (project/List.vue)
   - 新增客户列表页面 (customer/List.vue)
   - 新增统一API文件 (api/index.ts)
   - 更新路由配置,新增项目和客户路由
   - 首页增加项目和客户快捷入口

2. 操作日志审计功能:
   - OperationLog实体类: 操作日志数据模型
   - OperationLogMapper: MyBatis-Plus Mapper
   - OperationLogService: 日志服务接口和实现
   - OperationLogController: 日志查询API
   - OperationLogAspect: AOP切面自动记录操作日志
   - 支持异步保存,只记录写操作(增删改)

3. 操作日志功能特性:
   - 自动拦截Controller层方法
   - 记录用户ID、用户名、操作描述、请求参数
   - 记录IP、UserAgent、操作时间、耗时
   - 支持成功/失败状态记录
   - 支持分页查询和历史日志清理
This commit is contained in:
zhangjf 2026-02-20 09:16:00 +08:00
parent eafb783e2b
commit 47703e40c4
55 changed files with 789 additions and 0 deletions

View File

@ -0,0 +1,68 @@
import request from './request'
// 用户认证
export function login(data: { username: string; password: string }) {
return request.post('/sys/api/v1/auth/login', data)
}
export function getUserInfo() {
return request.get('/sys/api/v1/auth/info')
}
export function logout() {
return request.post('/sys/api/v1/auth/logout')
}
// 项目管理
export function getProjectList(params?: { pageNum: number; pageSize: number; projectName?: string }) {
return request.get('/proj/api/v1/project/page', { params })
}
export function getProjectById(id: number) {
return request.get(`/proj/api/v1/project/${id}`)
}
// 客户管理
export function getCustomerList(params?: { pageNum: number; pageSize: number; customerName?: string }) {
return request.get('/cust/api/v1/customer/page', { params })
}
// 支出管理
export function createExpense(data: any) {
return request.post('/exp/api/v1/exp/expense', data)
}
export function getExpenseList(params: { pageNum: number; pageSize: number }) {
return request.get('/exp/api/v1/exp/expense/page', { params })
}
// 应收款管理
export function getReceivableList(params: { pageNum: number; pageSize: number; status?: string }) {
return request.get('/receipt/api/v1/receipt/receivable/page', { params })
}
export function getUpcomingDueList(daysWithin: number = 7) {
return request.get(`/receipt/api/v1/receipt/receivable/upcoming-due?daysWithin=${daysWithin}`)
}
// 统计数据
export function getTodayIncome() {
return request.get('/receipt/api/v1/receipt/receivable/stats/today-income')
}
export function getTodayExpense() {
return request.get('/exp/api/v1/exp/expense/stats/today-expense')
}
export function getUnpaidAmount() {
return request.get('/receipt/api/v1/receipt/receivable/stats/unpaid-amount')
}
export function getOverdueCount() {
return request.get('/receipt/api/v1/receipt/receivable/stats/overdue-count')
}
// 支出类型
export function getExpenseTypeTree() {
return request.get('/exp/api/v1/exp/expense-type/tree')
}

View File

@ -21,6 +21,18 @@ const router = createRouter({
component: () => import('@/views/receivable/List.vue'),
meta: { title: '应收款列表', requiresAuth: true }
},
{
path: '/project',
name: 'ProjectList',
component: () => import('@/views/project/List.vue'),
meta: { title: '项目列表', requiresAuth: true }
},
{
path: '/customer',
name: 'CustomerList',
component: () => import('@/views/customer/List.vue'),
meta: { title: '客户列表', requiresAuth: true }
},
{
path: '/my',
name: 'My',

View File

@ -61,6 +61,18 @@
</div>
<div class="quick-text">应收款</div>
</div>
<div class="quick-item" @click="$router.push('/project')">
<div class="quick-icon">
<van-icon name="todo-list-o" />
</div>
<div class="quick-text">项目</div>
</div>
<div class="quick-item" @click="$router.push('/customer')">
<div class="quick-icon">
<van-icon name="friends-o" />
</div>
<div class="quick-text">客户</div>
</div>
<div class="quick-item" @click="$router.push('/my')">
<div class="quick-icon">
<van-icon name="user-o" />

View File

@ -0,0 +1,186 @@
<template>
<div class="page customer-list">
<van-nav-bar title="客户列表" left-arrow @click-left="$router.back()" />
<!-- 搜索栏 -->
<div class="search-bar">
<van-search v-model="searchText" placeholder="搜索客户名称" @search="handleSearch" />
</div>
<!-- 客户列表 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<div class="customer-card" v-for="item in list" :key="item.customerId">
<div class="customer-header">
<div class="customer-avatar">{{ item.customerName?.charAt(0) || 'C' }}</div>
<div class="customer-info">
<div class="customer-name">{{ item.customerName }}</div>
<div class="customer-short">{{ item.customerShort || '-' }}</div>
</div>
<van-tag :type="getLevelType(item.level)">{{ getLevelText(item.level) }}</van-tag>
</div>
<div class="customer-detail">
<div class="detail-item" v-if="item.phone">
<van-icon name="phone-o" />
<span>{{ item.phone }}</span>
</div>
<div class="detail-item" v-if="item.industry">
<van-icon name="cluster-o" />
<span>{{ item.industry }}</span>
</div>
</div>
</div>
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { getCustomerList } from '@/api'
const searchText = ref('')
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const list = ref<any[]>([])
const pageNum = ref(1)
const pageSize = 10
const getLevelType = (level: string): 'primary' | 'success' | 'warning' | 'danger' | 'default' => {
const map: Record<string, 'primary' | 'success' | 'warning' | 'danger' | 'default'> = {
'A': 'primary',
'B': 'success',
'C': 'warning',
'D': 'danger'
}
return map[level] || 'default'
}
const getLevelText = (level: string) => {
const map: Record<string, string> = {
'A': 'A类',
'B': 'B类',
'C': 'C类',
'D': 'D类'
}
return map[level] || '普通'
}
const loadData = async () => {
try {
const res: any = await getCustomerList({
pageNum: pageNum.value,
pageSize,
customerName: searchText.value || undefined
})
const records = res.data?.records || []
if (pageNum.value === 1) {
list.value = records
} else {
list.value.push(...records)
}
finished.value = records.length < pageSize
} catch (e) {
console.error(e)
finished.value = true
} finally {
loading.value = false
refreshing.value = false
}
}
const onLoad = () => {
pageNum.value++
loadData()
}
const onRefresh = () => {
pageNum.value = 1
finished.value = false
loadData()
}
const handleSearch = () => {
pageNum.value = 1
finished.value = false
list.value = []
loadData()
}
</script>
<style scoped>
.customer-list {
background: #f5f5f5;
min-height: 100vh;
}
.search-bar {
background: #fff;
padding: 8px 0;
}
.customer-card {
background: #fff;
margin: 12px;
padding: 16px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.customer-header {
display: flex;
align-items: center;
gap: 12px;
}
.customer-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, #007AFF, #5AC8FA);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 600;
color: #fff;
}
.customer-info {
flex: 1;
}
.customer-name {
font-size: 16px;
font-weight: 600;
color: #333;
}
.customer-short {
font-size: 13px;
color: #999;
margin-top: 2px;
}
.customer-detail {
display: flex;
gap: 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
.detail-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #666;
}
</style>

View File

@ -0,0 +1,205 @@
<template>
<div class="page project-list">
<van-nav-bar title="项目列表" left-arrow @click-left="$router.back()" />
<!-- 搜索栏 -->
<div class="search-bar">
<van-search v-model="searchText" placeholder="搜索项目名称" @search="handleSearch" />
</div>
<!-- 项目列表 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<div class="project-card" v-for="item in list" :key="item.projectId" @click="goDetail(item)">
<div class="project-header">
<span class="project-name">{{ item.projectName }}</span>
<van-tag :type="getStatusType(item.status)">{{ getStatusText(item.status) }}</van-tag>
</div>
<div class="project-info">
<div class="info-item">
<van-icon name="user-o" />
<span>{{ item.customerName || '-' }}</span>
</div>
<div class="info-item">
<van-icon name="clock-o" />
<span>{{ item.startDate }}</span>
</div>
</div>
<div class="project-amount">
<div class="amount-item">
<span class="label">合同金额</span>
<span class="value">{{ formatMoney(item.contractAmount) }}</span>
</div>
<div class="amount-item">
<span class="label">预算金额</span>
<span class="value">{{ formatMoney(item.budgetAmount) }}</span>
</div>
</div>
</div>
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { getProjectList } from '@/api'
const router = useRouter()
const searchText = ref('')
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const list = ref<any[]>([])
const pageNum = ref(1)
const pageSize = 10
const getStatusType = (status: string): 'primary' | 'success' | 'warning' | 'danger' | 'default' => {
const map: Record<string, 'primary' | 'success' | 'warning' | 'danger' | 'default'> = {
'ongoing': 'primary',
'completed': 'success',
'preparing': 'warning',
'archived': 'default',
'cancelled': 'danger'
}
return map[status] || 'default'
}
const getStatusText = (status: string) => {
const map: Record<string, string> = {
'ongoing': '进行中',
'completed': '已完成',
'preparing': '筹备中',
'archived': '已归档',
'cancelled': '已取消'
}
return map[status] || status
}
const formatMoney = (value: number) => {
if (!value) return '¥0'
return '¥' + value.toLocaleString()
}
const loadData = async () => {
try {
const res: any = await getProjectList({
pageNum: pageNum.value,
pageSize,
projectName: searchText.value || undefined
})
const records = res.data?.records || []
if (pageNum.value === 1) {
list.value = records
} else {
list.value.push(...records)
}
finished.value = records.length < pageSize
} catch (e) {
console.error(e)
finished.value = true
} finally {
loading.value = false
refreshing.value = false
}
}
const onLoad = () => {
pageNum.value++
loadData()
}
const onRefresh = () => {
pageNum.value = 1
finished.value = false
loadData()
}
const handleSearch = () => {
pageNum.value = 1
finished.value = false
list.value = []
loadData()
}
const goDetail = (item: any) => {
router.push(`/project/${item.projectId}`)
}
</script>
<style scoped>
.project-list {
background: #f5f5f5;
min-height: 100vh;
}
.search-bar {
background: #fff;
padding: 8px 0;
}
.project-card {
background: #fff;
margin: 12px;
padding: 16px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.project-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.project-name {
font-size: 16px;
font-weight: 600;
color: #333;
}
.project-info {
display: flex;
gap: 16px;
margin-bottom: 12px;
}
.info-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #666;
}
.project-amount {
display: flex;
gap: 24px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
.amount-item {
display: flex;
flex-direction: column;
}
.amount-item .label {
font-size: 12px;
color: #999;
}
.amount-item .value {
font-size: 15px;
font-weight: 600;
color: #333;
margin-top: 4px;
}
</style>

View File

@ -0,0 +1,51 @@
package com.fundplatform.sys.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fundplatform.common.core.Result;
import com.fundplatform.sys.data.entity.OperationLog;
import com.fundplatform.sys.service.OperationLogService;
import org.springframework.web.bind.annotation.*;
/**
* 操作日志Controller
*/
@RestController
@RequestMapping("/api/v1/log")
public class OperationLogController {
private final OperationLogService operationLogService;
public OperationLogController(OperationLogService operationLogService) {
this.operationLogService = operationLogService;
}
/**
* 分页查询操作日志
*/
@GetMapping("/page")
public Result<Page<OperationLog>> page(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) Long userId,
@RequestParam(required = false) String operation,
@RequestParam(required = false) String startTime,
@RequestParam(required = false) String endTime) {
return Result.success(operationLogService.pageLogs(pageNum, pageSize, userId, operation, startTime, endTime));
}
/**
* 获取日志详情
*/
@GetMapping("/{id}")
public Result<OperationLog> getById(@PathVariable Long id) {
return Result.success(operationLogService.getById(id));
}
/**
* 清理历史日志
*/
@DeleteMapping("/clean")
public Result<Integer> cleanLogs(@RequestParam(defaultValue = "90") int days) {
return Result.success(operationLogService.cleanLogs(days));
}
}

View File

@ -0,0 +1,135 @@
package com.fundplatform.sys.data.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
/**
* 操作日志实体
*/
@TableName("sys_operation_log")
public class OperationLog {
@TableId(type = IdType.AUTO)
private Long logId;
private Long userId;
private String username;
private String operation;
private String method;
private String params;
private String ip;
private String userAgent;
private LocalDateTime operationTime;
private Long costTime;
private String result;
private String errorMsg;
public Long getLogId() {
return logId;
}
public void setLogId(Long logId) {
this.logId = logId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getOperation() {
return operation;
}
public void setOperation(String operation) {
this.operation = operation;
}
public String getMethod() {
return method;
}
public void setMethod(String method) {
this.method = method;
}
public String getParams() {
return params;
}
public void setParams(String params) {
this.params = params;
}
public String getIp() {
return ip;
}
public void setIp(String ip) {
this.ip = ip;
}
public String getUserAgent() {
return userAgent;
}
public void setUserAgent(String userAgent) {
this.userAgent = userAgent;
}
public LocalDateTime getOperationTime() {
return operationTime;
}
public void setOperationTime(LocalDateTime operationTime) {
this.operationTime = operationTime;
}
public Long getCostTime() {
return costTime;
}
public void setCostTime(Long costTime) {
this.costTime = costTime;
}
public String getResult() {
return result;
}
public void setResult(String result) {
this.result = result;
}
public String getErrorMsg() {
return errorMsg;
}
public void setErrorMsg(String errorMsg) {
this.errorMsg = errorMsg;
}
}

View File

@ -0,0 +1,12 @@
package com.fundplatform.sys.data.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fundplatform.sys.data.entity.OperationLog;
import org.apache.ibatis.annotations.Mapper;
/**
* 操作日志Mapper
*/
@Mapper
public interface OperationLogMapper extends BaseMapper<OperationLog> {
}

View File

@ -0,0 +1,30 @@
package com.fundplatform.sys.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fundplatform.sys.data.entity.OperationLog;
/**
* 操作日志服务接口
*/
public interface OperationLogService {
/**
* 记录操作日志
*/
void saveLog(OperationLog log);
/**
* 分页查询操作日志
*/
Page<OperationLog> pageLogs(int pageNum, int pageSize, Long userId, String operation, String startTime, String endTime);
/**
* 根据ID查询日志详情
*/
OperationLog getById(Long logId);
/**
* 清理指定天数前的日志
*/
int cleanLogs(int days);
}

View File

@ -0,0 +1,72 @@
package com.fundplatform.sys.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fundplatform.sys.data.entity.OperationLog;
import com.fundplatform.sys.data.mapper.OperationLogMapper;
import com.fundplatform.sys.service.OperationLogService;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
/**
* 操作日志服务实现
*/
@Service
public class OperationLogServiceImpl implements OperationLogService {
private final OperationLogMapper operationLogMapper;
public OperationLogServiceImpl(OperationLogMapper operationLogMapper) {
this.operationLogMapper = operationLogMapper;
}
@Override
@Async
public void saveLog(OperationLog log) {
if (log.getOperationTime() == null) {
log.setOperationTime(LocalDateTime.now());
}
operationLogMapper.insert(log);
}
@Override
public Page<OperationLog> pageLogs(int pageNum, int pageSize, Long userId, String operation, String startTime, String endTime) {
Page<OperationLog> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<OperationLog> wrapper = new LambdaQueryWrapper<>();
if (userId != null) {
wrapper.eq(OperationLog::getUserId, userId);
}
if (operation != null && !operation.isEmpty()) {
wrapper.like(OperationLog::getOperation, operation);
}
if (startTime != null && !startTime.isEmpty()) {
LocalDate startDate = LocalDate.parse(startTime);
wrapper.ge(OperationLog::getOperationTime, LocalDateTime.of(startDate, LocalTime.MIN));
}
if (endTime != null && !endTime.isEmpty()) {
LocalDate endDate = LocalDate.parse(endTime);
wrapper.le(OperationLog::getOperationTime, LocalDateTime.of(endDate, LocalTime.MAX));
}
wrapper.orderByDesc(OperationLog::getOperationTime);
return operationLogMapper.selectPage(page, wrapper);
}
@Override
public OperationLog getById(Long logId) {
return operationLogMapper.selectById(logId);
}
@Override
public int cleanLogs(int days) {
LocalDateTime threshold = LocalDateTime.now().minusDays(days);
LambdaQueryWrapper<OperationLog> wrapper = new LambdaQueryWrapper<>();
wrapper.lt(OperationLog::getOperationTime, threshold);
return operationLogMapper.delete(wrapper);
}
}

View File

@ -30,6 +30,7 @@
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/vo/UserVO.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/data/service/SysTenantDataService.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/service/DeptService.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/data/entity/OperationLog.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/dto/MenuDTO.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/data/service/SysConfigDataService.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/controller/HealthController.java
@ -39,9 +40,12 @@
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/controller/RoleController.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/controller/TenantController.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/service/impl/ConfigServiceImpl.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/controller/OperationLogController.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/data/mapper/OperationLogMapper.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/data/entity/SysMenu.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/service/impl/DeptServiceImpl.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/utils/JwtUtil.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/service/OperationLogService.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/aop/ApiLogAspect.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/service/AuthService.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/dto/RoleDTO.java
@ -59,7 +63,9 @@
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/controller/TestController.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/vo/DeptVO.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/data/mapper/SysRoleMapper.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/aop/OperationLogAspect.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/service/impl/MenuServiceImpl.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/SysApplication.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/dto/LoginRequestDTO.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/service/impl/OperationLogServiceImpl.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-sys/src/main/java/com/fundplatform/sys/service/TenantService.java