增加任务管理模块
This commit is contained in:
parent
12a0ff8afc
commit
db16ee70de
@ -56,11 +56,46 @@ dictStore.clearCache('user_status')
|
||||
|
||||
---
|
||||
|
||||
### 2. **常量**: `src/constants/dictCodes.js`
|
||||
|
||||
集中管理所有字典编码
|
||||
|
||||
**使用示例**:
|
||||
```javascript
|
||||
import { DICT_CODES } from '@/constants/dictCodes'
|
||||
|
||||
// 好处:避免硬编码,IDE 有自动完成
|
||||
const items = await dictStore.getDictItems(DICT_CODES.USER_STATUS)
|
||||
|
||||
// 所有可用的编码:
|
||||
DICT_CODES.USER_STATUS // 用户状态
|
||||
DICT_CODES.USER_GENDER // 用户性别
|
||||
DICT_CODES.USER_ROLE // 用户角色
|
||||
DICT_CODES.DEPT_STATUS // 部门状态
|
||||
DICT_CODES.POSITION_STATUS // 职位状态
|
||||
// ... 更多编码
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. **Composable**: `src/composables/useDict.js`
|
||||
|
||||
简化在组件中使用字典的 Hook
|
||||
|
||||
**基础用法**:
|
||||
```javascript
|
||||
import { useDictionary, useUserStatusDict } from '@/composables/useDict'
|
||||
import { DICT_CODES } from '@/constants/dictCodes'
|
||||
|
||||
// 方式1:使用常量
|
||||
const { statusDict, loading } = useDictionary(DICT_CODES.USER_STATUS)
|
||||
|
||||
// 方式2:使用字符串
|
||||
const { dicts, loading } = useDictionary('user_status')
|
||||
|
||||
// 方式3:使用特化 Hook(推荐)
|
||||
const { user_statusDict, loading } = useUserStatusDict()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@ -126,9 +161,9 @@ const props = defineProps({
|
||||
|
||||
---
|
||||
|
||||
### 场景3:直接使用字典Store
|
||||
### 场景3:快速使用 Composable Hook
|
||||
|
||||
直接使用字典Store获取数据:
|
||||
最简单的方式,自动处理加载:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
@ -136,7 +171,7 @@ const props = defineProps({
|
||||
<p v-if="loading">加载中...</p>
|
||||
<el-select v-else v-model="status">
|
||||
<el-option
|
||||
v-for="item in statusDict"
|
||||
v-for="item in user_statusDict"
|
||||
:key="item.dict_value"
|
||||
:label="item.dict_label"
|
||||
:value="item.dict_value"
|
||||
@ -146,30 +181,11 @@ const props = defineProps({
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useDictStore } from '@/stores/dict'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useUserStatusDict } from '@/composables/useDict'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const dictStore = useDictStore()
|
||||
const status = ref('1')
|
||||
const statusDict = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
const fetchStatusDict = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const items = await dictStore.getDictItems('user_status')
|
||||
statusDict.value = items
|
||||
} catch (error) {
|
||||
console.error('获取用户状态字典失败:', error)
|
||||
statusDict.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStatusDict()
|
||||
})
|
||||
const status = ref('active')
|
||||
const { user_statusDict, loading } = useUserStatusDict()
|
||||
</script>
|
||||
```
|
||||
|
||||
@ -183,6 +199,7 @@ onMounted(() => {
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { useDictStore } from '@/stores/dict'
|
||||
import { DICT_CODES } from '@/constants/dictCodes'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
@ -192,9 +209,9 @@ app.use(pinia)
|
||||
// 在应用启动后预加载常用字典
|
||||
const dictStore = useDictStore()
|
||||
await dictStore.preloadDicts([
|
||||
'user_status',
|
||||
'common_status',
|
||||
'yes_no',
|
||||
DICT_CODES.USER_STATUS,
|
||||
DICT_CODES.COMMON_STATUS,
|
||||
DICT_CODES.YES_NO,
|
||||
])
|
||||
|
||||
app.mount('#app')
|
||||
@ -238,29 +255,29 @@ app.mount('#app')
|
||||
|
||||
### ✅ DO
|
||||
|
||||
1. **使用字符串而不是硬编码数字**
|
||||
1. **使用常量而不是硬编码字符串**
|
||||
```javascript
|
||||
// ✅ 好
|
||||
dictStore.getDictItems('user_status')
|
||||
dictStore.getDictItems(DICT_CODES.USER_STATUS)
|
||||
|
||||
// ❌ 差
|
||||
// 避免直接使用数字,应该使用字符串
|
||||
dictStore.getDictItems('user_status')
|
||||
```
|
||||
|
||||
2. **在父组件加载,通过 props 传给子组件**
|
||||
```javascript
|
||||
// ✅ 父组件负责数据,子组件负责展示
|
||||
// index.vue
|
||||
const statusDict = await dictStore.getDictItems('user_status')
|
||||
const statusDict = await dictStore.getDictItems(DICT_CODES.USER_STATUS)
|
||||
|
||||
// UserEdit.vue
|
||||
const props = defineProps({ statusDict: Array })
|
||||
```
|
||||
|
||||
3. **直接使用字典Store**
|
||||
3. **用 Composable 简化组件逻辑**
|
||||
```javascript
|
||||
// ✅ 直接使用Store获取数据
|
||||
const statusDict = await dictStore.getDictItems('user_status')
|
||||
// ✅ 一行代码搞定
|
||||
const { user_statusDict, loading } = useUserStatusDict()
|
||||
```
|
||||
|
||||
4. **预加载常用字典**
|
||||
@ -354,12 +371,14 @@ const item = items.find(i =>
|
||||
|
||||
## 集成检清表
|
||||
|
||||
- [x] 创建 `src/stores/dict.js` - Store
|
||||
- [x] 在 `index.vue` 中导入 `useDictStore`
|
||||
- [x] 在 `UserEdit.vue` 中使用 `useDictStore` 获取字典数据
|
||||
- [x] 测试字典加载和显示
|
||||
- [x] 验证缓存功能(打开浏览器 DevTools 检查 Network)
|
||||
- [x] 预加载常用字典(可选)
|
||||
- [ ] 创建 `src/stores/dict.js` - Store
|
||||
- [ ] 创建 `src/constants/dictCodes.js` - 常量
|
||||
- [ ] 创建 `src/composables/useDict.js` - Composable
|
||||
- [ ] 在 `index.vue` 中导入 `useDictStore`
|
||||
- [ ] 在 `UserEdit.vue` 中接收 `statusDict` props
|
||||
- [ ] 测试字典加载和显示
|
||||
- [ ] 验证缓存功能(打开浏览器 DevTools 检查 Network)
|
||||
- [ ] 预加载常用字典(可选)
|
||||
|
||||
---
|
||||
|
||||
@ -367,8 +386,8 @@ const item = items.find(i =>
|
||||
|
||||
已修改的文件:
|
||||
- ✅ `src/stores/dict.js` - 新建
|
||||
- ✅ `src/constants/dictCodes.js` - 新建
|
||||
- ✅ `src/composables/useDict.js` - 新建
|
||||
- ✅ `src/views/system/users/index.vue` - 使用 `useDictStore`
|
||||
- ✅ `src/views/system/users/components/UserEdit.vue` - 使用字典功能
|
||||
- ✅ `src/constants/dictCodes.js` - 删除(不再需要)
|
||||
- ✅ `src/composables/useDict.js` - 删除(不再需要)
|
||||
- ✅ `src/views/system/users/components/UserEdit.vue` - 导入字典库
|
||||
|
||||
|
||||
@ -1,36 +0,0 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取访问日志列表
|
||||
export function getAccessLogs(params) {
|
||||
return request({
|
||||
url: '/api/access-logs',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 根据ID获取访问日志详情
|
||||
export function getAccessLogById(id) {
|
||||
return request({
|
||||
url: `/api/access-logs/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户访问统计
|
||||
export function getUserAccessStats(params) {
|
||||
return request({
|
||||
url: '/api/access-logs/user/stats',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 清空旧访问日志
|
||||
export function clearOldAccessLogs(keepDays = 90) {
|
||||
return request({
|
||||
url: '/api/access-logs/clear',
|
||||
method: 'post',
|
||||
data: { keep_days: keepDays }
|
||||
})
|
||||
}
|
||||
@ -22,3 +22,20 @@ export function getTenantStats() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户活动日志(操作日志和登录日志)
|
||||
* @param {number} pageNum - 页码
|
||||
* @param {number} pageSize - 每页数量
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function getActivityLogs(pageNum = 1, pageSize = 10) {
|
||||
return request({
|
||||
url: "/api/dashboard/user-activity-logs",
|
||||
method: "get",
|
||||
params: {
|
||||
page_num: pageNum,
|
||||
page_size: pageSize,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -43,3 +43,38 @@ export function clearOldLogs(keepDays = 90) {
|
||||
data: { keep_days: keepDays }
|
||||
})
|
||||
}
|
||||
|
||||
// 获取访问日志列表
|
||||
export function getAccessLogs(params) {
|
||||
return request({
|
||||
url: '/api/access-logs',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 根据ID获取访问日志详情
|
||||
export function getAccessLogById(id) {
|
||||
return request({
|
||||
url: `/api/access-logs/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户访问统计
|
||||
export function getUserAccessStats(params) {
|
||||
return request({
|
||||
url: '/api/access-logs/user/stats',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 清空旧访问日志
|
||||
export function clearOldAccessLogs(keepDays = 90) {
|
||||
return request({
|
||||
url: '/api/access-logs/clear',
|
||||
method: 'post',
|
||||
data: { keep_days: keepDays }
|
||||
})
|
||||
}
|
||||
|
||||
39
pc/src/api/tasks.js
Normal file
39
pc/src/api/tasks.js
Normal file
@ -0,0 +1,39 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listTasks(params) {
|
||||
return request({
|
||||
url: '/api/oa/tasks',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getTask(id) {
|
||||
return request({
|
||||
url: `/api/oa/tasks/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createTask(data) {
|
||||
return request({
|
||||
url: '/api/oa/tasks',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateTask(id, data) {
|
||||
return request({
|
||||
url: `/api/oa/tasks/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteTask(id) {
|
||||
return request({
|
||||
url: `/api/oa/tasks/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
@ -50,8 +50,8 @@ import {
|
||||
} from "@/api/department";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useOAStore } from "@/stores/oa";
|
||||
import DepartmentList from "../components/departments/DepartmentList.vue";
|
||||
import DepartmentEdit from "../components/departments/DepartmentEdit.vue";
|
||||
import DepartmentList from "./components/DepartmentList.vue";
|
||||
import DepartmentEdit from "./components/DepartmentEdit.vue";
|
||||
|
||||
interface Department {
|
||||
id: number;
|
||||
|
||||
@ -69,9 +69,9 @@ import {
|
||||
} from "@/api/employee";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useOAStore } from "@/stores/oa";
|
||||
import EmployeeList from "../components/employees/EmployeeList.vue";
|
||||
import EmployeeEdit from "../components/employees/EmployeeEdit.vue";
|
||||
import EmployeePasswordChange from "../components/employees/EmployeePasswordChange.vue";
|
||||
import EmployeeList from "./components/EmployeeList.vue";
|
||||
import EmployeeEdit from "./components/EmployeeEdit.vue";
|
||||
import EmployeePasswordChange from "./components/EmployeePasswordChange.vue";
|
||||
|
||||
interface Employee {
|
||||
id: number;
|
||||
|
||||
@ -126,4 +126,3 @@ const handleSubmit = () => {
|
||||
emit('submit', { ...form.value });
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -163,16 +163,6 @@ const handleCollapseAll = () => {
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
|
||||
// .el-button {
|
||||
// padding: 0;
|
||||
// font-size: 13px;
|
||||
// color: var(--el-color-primary);
|
||||
|
||||
// &:hover {
|
||||
// color: var(--el-color-primary-light-3);
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@ -254,4 +244,3 @@ const handleCollapseAll = () => {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -126,4 +126,3 @@ const handleSubmit = () => {
|
||||
emit('submit', { ...form.value });
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -193,4 +193,3 @@ const handleDelete = (position: any) => {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -81,10 +81,10 @@ import {
|
||||
} from "@/api/position";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useOAStore } from "@/stores/oa";
|
||||
import DepartmentTree from "../components/organization/DepartmentTree.vue";
|
||||
import PositionList from "../components/organization/PositionList.vue";
|
||||
import OrganizationDepartmentEdit from "../components/organization/DepartmentEdit.vue";
|
||||
import OrganizationPositionEdit from "../components/organization/PositionEdit.vue";
|
||||
import DepartmentTree from "./components/DepartmentTree.vue";
|
||||
import PositionList from "./components/PositionList.vue";
|
||||
import OrganizationDepartmentEdit from "./components/DepartmentEdit.vue";
|
||||
import OrganizationPositionEdit from "./components/PositionEdit.vue";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const oaStore = useOAStore();
|
||||
|
||||
@ -52,8 +52,8 @@ import {
|
||||
} from "@/api/position";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useOAStore } from "@/stores/oa";
|
||||
import PositionList from "../components/positions/PositionList.vue";
|
||||
import PositionEdit from "../components/positions/PositionEdit.vue";
|
||||
import PositionList from "./components/PositionList.vue";
|
||||
import PositionEdit from "./components/PositionEdit.vue";
|
||||
|
||||
interface Position {
|
||||
id: number;
|
||||
|
||||
170
pc/src/views/apps/oa/tasks/components/EmployeeSelectDialog.vue
Normal file
170
pc/src/views/apps/oa/tasks/components/EmployeeSelectDialog.vue
Normal file
@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<el-dialog v-model="dialogVisible" :title="multiple ? '选择关联人' : '选择负责人'" width="800px" @close="handleClose">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="keyword" placeholder="搜索姓名/账号/手机/邮箱" clearable @keyup.enter="doFilter" />
|
||||
<el-button type="primary" @click="doFilter">搜索</el-button>
|
||||
</div>
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
:data="displayList"
|
||||
height="380"
|
||||
v-loading="loading"
|
||||
highlight-current-row
|
||||
:row-key="row => row.id"
|
||||
:reserve-selection="true"
|
||||
@current-change="onCurrentChange"
|
||||
@row-dblclick="onRowDblClick"
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<el-table-column v-if="multiple" type="selection" width="60" align="center" />
|
||||
<el-table-column type="index" label="#" width="60" align="center" />
|
||||
<el-table-column label="姓名" min-width="140" align="center">
|
||||
<template #default="{ row }">{{ getName(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="employee_no" label="工号" min-width="120" align="center" />
|
||||
<el-table-column label="手机" min-width="140" align="center">
|
||||
<template #default="{ row }">{{ row.mobile || row.phone || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="邮箱" min-width="180" align="center">
|
||||
<template #default="{ row }">{{ row.email || '-' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :disabled="multiple ? selectedRows.length===0 : !current" @click="confirm">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, defineProps, defineEmits, onMounted, nextTick } from 'vue'
|
||||
import { getTenantEmployees } from '@/api/employee'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const props = defineProps<{ visible: boolean, multiple?: boolean, selected?: Array<number|string> }>()
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [v: boolean]
|
||||
'selected': [payload: { id: number | string, name: string, raw: any }]
|
||||
'selected-multiple': [payload: Array<{ id: number | string, name: string, raw: any }>]
|
||||
}>()
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const loading = ref(false)
|
||||
const list = ref<any[]>([])
|
||||
const keyword = ref('')
|
||||
const current = ref<any>(null)
|
||||
const selectedRows = ref<any[]>([])
|
||||
const tableRef = ref()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
function getCurrentTenantId(): number | null {
|
||||
if (authStore?.user?.tenant_id) return authStore.user.tenant_id
|
||||
const userInfo = localStorage.getItem('userInfo')
|
||||
if (userInfo) {
|
||||
try {
|
||||
const u = JSON.parse(userInfo)
|
||||
return u.tenant_id || u.tenantId || null
|
||||
} catch {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
dialogVisible.value = v
|
||||
if (v && list.value.length === 0) {
|
||||
fetchEmployees()
|
||||
}
|
||||
if (v && list.value.length > 0) {
|
||||
nextTick(() => syncSelection())
|
||||
}
|
||||
})
|
||||
watch(dialogVisible, (v) => emit('update:visible', v))
|
||||
|
||||
function getName(row: any) {
|
||||
return row?.name || row?.real_name || row?.realName || row?.nickname || row?.employee_no || ''
|
||||
}
|
||||
|
||||
const displayList = computed(() => {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
if (!kw) return list.value
|
||||
return list.value.filter((it: any) => {
|
||||
const vals = [getName(it), it.employee_no, it.mobile, it.phone, it.email].map(x => (x || '').toString().toLowerCase())
|
||||
return vals.some(v => v.includes(kw))
|
||||
})
|
||||
})
|
||||
|
||||
function doFilter() {
|
||||
// 仅本地过滤,数据已加载
|
||||
}
|
||||
|
||||
async function fetchEmployees() {
|
||||
loading.value = true
|
||||
try {
|
||||
const tenantId = getCurrentTenantId()
|
||||
const res: any = tenantId ? await getTenantEmployees(tenantId) : { data: [] }
|
||||
let arr: any[] = []
|
||||
if (Array.isArray(res)) arr = res
|
||||
else if (Array.isArray(res?.data)) arr = res.data
|
||||
else if (Array.isArray(res?.data?.data)) arr = res.data.data
|
||||
list.value = arr || []
|
||||
// 同步预选
|
||||
await nextTick()
|
||||
syncSelection()
|
||||
} catch (e) {
|
||||
list.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function syncSelection() {
|
||||
if (!props.multiple) return
|
||||
const idsSet = new Set((props.selected || []).map(x => String(x)))
|
||||
// 计算应选中的行
|
||||
const rows = list.value.filter((r: any) => idsSet.has(String(r.id)))
|
||||
selectedRows.value = rows
|
||||
const table: any = tableRef.value
|
||||
if (table && table.clearSelection) {
|
||||
table.clearSelection()
|
||||
rows.forEach((r: any) => table.toggleRowSelection(r, true))
|
||||
}
|
||||
}
|
||||
|
||||
// 当已选 id 在外部变化时,若对话框打开则同步
|
||||
watch(() => props.selected, () => {
|
||||
if (dialogVisible.value) {
|
||||
nextTick(() => syncSelection())
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
function onCurrentChange(row: any) {
|
||||
current.value = row
|
||||
}
|
||||
function onRowDblClick(row: any) {
|
||||
current.value = row
|
||||
confirm()
|
||||
}
|
||||
function onSelectionChange(rows: any[]) {
|
||||
selectedRows.value = rows || []
|
||||
}
|
||||
function confirm() {
|
||||
if (props.multiple) {
|
||||
if (!selectedRows.value.length) return
|
||||
const payload = selectedRows.value.map(r => ({ id: r.id, name: getName(r), raw: r }))
|
||||
emit('selected-multiple', payload)
|
||||
} else {
|
||||
if (!current.value) return
|
||||
emit('selected', { id: current.value.id, name: getName(current.value), raw: current.value })
|
||||
}
|
||||
dialogVisible.value = false
|
||||
}
|
||||
function handleClose() {
|
||||
dialogVisible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.toolbar :deep(.el-input) { max-width: 280px; }
|
||||
</style>
|
||||
45
pc/src/views/apps/oa/tasks/components/detail.vue
Normal file
45
pc/src/views/apps/oa/tasks/components/detail.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<el-drawer v-model="visible" :title="title" size="40%" destroy-on-close>
|
||||
<el-descriptions v-if="task" :column="2" border>
|
||||
<el-descriptions-item label="编号">{{ task.task_no }}</el-descriptions-item>
|
||||
<el-descriptions-item label="名称">{{ task.task_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="负责人">{{ task.principal_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="优先级">{{ priorityText(task.priority) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ statusText(task.task_status) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="进度">{{ Number(task.progress||0) }}%</el-descriptions-item>
|
||||
<el-descriptions-item label="截止时间" :span="2">{{ formatDateTime(task.plan_end_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">{{ task.task_desc || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<el-button @click="visible=false">关闭</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useDictStore } from '@/stores/dict'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean, task: any }>()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const visible = ref(props.modelValue)
|
||||
watch(() => props.modelValue, v => visible.value = v)
|
||||
watch(visible, v => emit('update:modelValue', v))
|
||||
|
||||
const title = computed(() => props.task?.task_name ? `任务详情 - ${props.task.task_name}` : '任务详情')
|
||||
|
||||
const dictStore = useDictStore()
|
||||
const priorityText = (v: string) => dictStore.getDictLabel('task_priority', v)
|
||||
const statusText = (v: string) => dictStore.getDictLabel('task_status', v)
|
||||
const formatDateTime = (v: any) => {
|
||||
if (!v) return '-'
|
||||
const d = new Date(v)
|
||||
if (isNaN(d.getTime())) return String(v)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
278
pc/src/views/apps/oa/tasks/components/edit.vue
Normal file
278
pc/src/views/apps/oa/tasks/components/edit.vue
Normal file
@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" :title="isEdit ? '编辑任务' : '新建任务'" width="560px" destroy-on-close @closed="onClosed">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="96px">
|
||||
<el-form-item label="任务名称" prop="task_name">
|
||||
<el-input v-model="form.task_name" maxlength="255" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="关联人">
|
||||
<div class="assignees">
|
||||
<div class="tags" v-if="teamEmployeeList.length">
|
||||
<el-tag v-for="u in teamEmployeeList" :key="u.id" type="info" class="mr8" closable @close="removeTeamEmployee(u.id)">
|
||||
{{ u.name }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-button size="small" @click="employeeMultiDialogVisible = true">选择关联人</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="负责人" prop="principal_name">
|
||||
<el-input v-model="form.principal_name" placeholder="请选择负责人" readonly>
|
||||
<template #append>
|
||||
<el-button @click="employeeDialogVisible = true">选择</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="优先级" prop="priority">
|
||||
<el-select v-model="form.priority" placeholder="请选择">
|
||||
<el-option v-for="p in priorityOptions" :key="p.value" :label="p.label" :value="p.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isEdit" label="状态" prop="task_status">
|
||||
<el-select v-model="form.task_status" placeholder="请选择">
|
||||
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="计划时间">
|
||||
<div class="datetime-range">
|
||||
<el-date-picker v-model="form.plan_start_time" type="datetime" value-format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="开始时间" />
|
||||
<span class="range-sep">~</span>
|
||||
<el-date-picker v-model="form.plan_end_time" type="datetime" value-format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="结束时间" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isEdit" label="进度" prop="progress">
|
||||
<el-slider v-model="form.progress" :min="0" :max="100" show-input />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="task_desc">
|
||||
<el-input v-model="form.task_desc" type="textarea" :rows="4" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<EmployeeSelectDialog
|
||||
v-model:visible="employeeDialogVisible"
|
||||
@selected="onEmployeeSelected"
|
||||
/>
|
||||
<EmployeeSelectDialog
|
||||
v-model:visible="employeeMultiDialogVisible"
|
||||
:multiple="true"
|
||||
:selected="teamEmployeeIds"
|
||||
@selected-multiple="onEmployeesSelected"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="onSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, onMounted } from 'vue'
|
||||
import { ElMessage, FormInstance, FormRules } from 'element-plus'
|
||||
import { createTask, updateTask } from '@/api/tasks'
|
||||
import { useDictStore } from '@/stores/dict'
|
||||
import EmployeeSelectDialog from './EmployeeSelectDialog.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getTenantEmployees } from '@/api/employee'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean,
|
||||
isEdit: boolean,
|
||||
task: any
|
||||
}>()
|
||||
const emit = defineEmits(['update:modelValue', 'saved'])
|
||||
|
||||
const visible = ref(props.modelValue)
|
||||
watch(() => props.modelValue, v => visible.value = v)
|
||||
watch(visible, v => emit('update:modelValue', v))
|
||||
|
||||
const statusOptions = ref<Array<{ label: string, value: string }>>([])
|
||||
const priorityOptions = ref<Array<{ label: string, value: string }>>([])
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = reactive<any>({
|
||||
id: undefined,
|
||||
task_no: '',
|
||||
task_name: '',
|
||||
tenant_id: undefined,
|
||||
principal_id: '',
|
||||
principal_name: '',
|
||||
priority: '',
|
||||
task_status: '',
|
||||
plan_start_time: '',
|
||||
plan_end_time: '',
|
||||
progress: 0,
|
||||
task_desc: ''
|
||||
})
|
||||
// 关联人(仅提交 id 数组在 team_employee_ids)
|
||||
const teamEmployeeIds = ref<Array<number | string>>([])
|
||||
const teamEmployeeList = ref<Array<{id: number|string, name: string}>>([])
|
||||
const authStore = useAuthStore()
|
||||
const loadTeamEmployeeNames = async (ids: Array<number | string>) => {
|
||||
if (!ids || ids.length === 0) {
|
||||
teamEmployeeList.value = []
|
||||
return
|
||||
}
|
||||
let tenantId: any = authStore?.user?.tenant_id
|
||||
if (!tenantId) {
|
||||
const s = localStorage.getItem('userInfo')
|
||||
if (s) {
|
||||
try { const u = JSON.parse(s); tenantId = u.tenant_id || u.tenantId } catch {}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res: any = tenantId ? await getTenantEmployees(Number(tenantId)) : null
|
||||
const arr: any[] = Array.isArray(res) ? res : (Array.isArray(res?.data) ? res.data : (Array.isArray(res?.data?.data) ? res.data.data : []))
|
||||
const map = new Map<string, any>()
|
||||
for (const e of arr) {
|
||||
const id = String(e.id)
|
||||
const name = e.name || e.real_name || e.realName || e.nickname || e.employee_no || ''
|
||||
map.set(id, { id: e.id, name })
|
||||
}
|
||||
teamEmployeeList.value = ids.map(x => map.get(String(x)) || { id: x, name: String(x) })
|
||||
} catch {
|
||||
teamEmployeeList.value = ids.map(x => ({ id: x, name: String(x) }))
|
||||
}
|
||||
}
|
||||
|
||||
// 使用字典 store 加载
|
||||
const dictStore = useDictStore()
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const pri = await dictStore.getDictItems('task_priority')
|
||||
priorityOptions.value = (pri || []).map((it: any) => ({
|
||||
label: it.dict_label ?? it.dictLabel ?? it.label ?? it.name,
|
||||
value: it.dict_value ?? it.dictValue ?? it.value ?? it.code
|
||||
}))
|
||||
} catch {}
|
||||
try {
|
||||
const sts = await dictStore.getDictItems('task_status')
|
||||
statusOptions.value = (sts || []).map((it: any) => ({
|
||||
label: it.dict_label ?? it.dictLabel ?? it.label ?? it.name,
|
||||
value: it.dict_value ?? it.dictValue ?? it.value ?? it.code
|
||||
}))
|
||||
} catch {}
|
||||
})
|
||||
|
||||
watch(() => props.task, (t) => {
|
||||
Object.assign(form, {
|
||||
id: t?.id,
|
||||
task_no: t?.task_no || '',
|
||||
task_name: t?.task_name || '',
|
||||
tenant_id: t?.tenant_id,
|
||||
principal_id: t?.principal_id || '',
|
||||
principal_name: t?.principal_name || '',
|
||||
priority: t?.priority || '',
|
||||
task_status: t?.task_status || '',
|
||||
plan_start_time: t?.plan_start_time || '',
|
||||
plan_end_time: t?.plan_end_time || '',
|
||||
progress: Number(t?.progress || 0),
|
||||
task_desc: t?.task_desc || ''
|
||||
})
|
||||
// 初始化关联人(支持从 task.team_employee_ids 或 participant_ids 推断)
|
||||
if (Array.isArray(t?.team_employee_ids)) {
|
||||
teamEmployeeIds.value = [...t.team_employee_ids]
|
||||
} else if (typeof t?.team_employee_ids === 'string' && t.team_employee_ids) {
|
||||
teamEmployeeIds.value = t.team_employee_ids.split(',').map((x: string) => x.trim()).filter(Boolean)
|
||||
} else if (typeof t?.participant_ids === 'string' && t.participant_ids) {
|
||||
// 逗号分隔
|
||||
teamEmployeeIds.value = t.participant_ids.split(',').map((x: string) => x.trim()).filter(Boolean)
|
||||
} else {
|
||||
teamEmployeeIds.value = []
|
||||
}
|
||||
if (Array.isArray(t?.team_employee_list) && t.team_employee_list.length) {
|
||||
teamEmployeeList.value = t.team_employee_list.map((x: any) => ({ id: x.id, name: x.name }))
|
||||
} else {
|
||||
loadTeamEmployeeNames(teamEmployeeIds.value)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const rules = reactive<FormRules<any>>({
|
||||
task_name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
|
||||
principal_name: [{ required: true, message: '请输入负责人', trigger: 'blur' }],
|
||||
priority: [{ required: true, message: '请选择优先级', trigger: 'change' }],
|
||||
task_status: [{ required: props.isEdit, message: '请选择状态', trigger: 'change' }],
|
||||
plan_end_time: [{ required: true, message: '请选择截止时间', trigger: 'change' }]
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const employeeDialogVisible = ref(false)
|
||||
const employeeMultiDialogVisible = ref(false)
|
||||
const onEmployeeSelected = (payload: { id: number | string, name: string, raw: any }) => {
|
||||
form.principal_id = payload.id
|
||||
form.principal_name = payload.name
|
||||
}
|
||||
const onEmployeesSelected = (arr: Array<{ id: number | string, name: string, raw: any }>) => {
|
||||
teamEmployeeIds.value = arr.map(i => i.id)
|
||||
teamEmployeeList.value = arr.map(i => ({ id: i.id, name: i.name }))
|
||||
}
|
||||
const removeTeamEmployee = (id: number | string) => {
|
||||
teamEmployeeIds.value = teamEmployeeIds.value.filter(x => String(x) !== String(id))
|
||||
teamEmployeeList.value = teamEmployeeList.value.filter(x => String(x.id) !== String(id))
|
||||
}
|
||||
const onSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate()
|
||||
saving.value = true
|
||||
try {
|
||||
// 构造提交数据,修正时间格式为 RFC3339,确保后端可解析
|
||||
const payload: any = { ...form }
|
||||
const toRFC3339 = (val: any) => {
|
||||
if (!val) return val
|
||||
// 兼容 'YYYY-MM-DD HH:mm:ss' 与已含 'T' 的情况
|
||||
const s = typeof val === 'string' ? val.replace(' ', 'T') : val
|
||||
const d = new Date(s)
|
||||
return isNaN(d.getTime()) ? val : d.toISOString()
|
||||
}
|
||||
payload.plan_start_time = toRFC3339(payload.plan_start_time)
|
||||
payload.plan_end_time = toRFC3339(payload.plan_end_time)
|
||||
if (payload.principal_id !== undefined && payload.principal_id !== null && payload.principal_id !== '') {
|
||||
const n = Number(payload.principal_id)
|
||||
payload.principal_id = isNaN(n) ? payload.principal_id : n
|
||||
}
|
||||
if (payload.tenant_id !== undefined && payload.tenant_id !== null && payload.tenant_id !== '') {
|
||||
const tn = Number(payload.tenant_id)
|
||||
payload.tenant_id = isNaN(tn) ? payload.tenant_id : tn
|
||||
}
|
||||
|
||||
let res: any
|
||||
// 追加 team_employee_ids 参数(仅 ID 数组)
|
||||
payload.team_employee_ids = (teamEmployeeIds.value || []).map((x: any) => {
|
||||
const n = Number(x)
|
||||
return isNaN(n) ? x : n
|
||||
})
|
||||
|
||||
if (props.isEdit && form.id) {
|
||||
res = await updateTask(form.id, payload)
|
||||
} else {
|
||||
// 新建时不传状态,由后端或业务默认
|
||||
delete payload.task_status
|
||||
res = await createTask(payload)
|
||||
}
|
||||
if (res?.code === 0 || res?.success) {
|
||||
ElMessage.success('保存成功')
|
||||
visible.value = false
|
||||
emit('saved')
|
||||
} else {
|
||||
ElMessage.error(res?.message || '保存失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onClosed = () => {
|
||||
// reset if needed
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.datetime-range {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
:deep(.el-date-editor) {
|
||||
width: 45%;
|
||||
}
|
||||
</style>
|
||||
245
pc/src/views/apps/oa/tasks/index.vue
Normal file
245
pc/src/views/apps/oa/tasks/index.vue
Normal file
@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<div class="task-page">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="query.keyword" placeholder="搜索任务名称/编号/负责人" clearable @keyup.enter="fetchList" />
|
||||
<el-select v-model="query.status" placeholder="状态" clearable @change="handleFilterChange">
|
||||
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||
</el-select>
|
||||
<el-select v-model="query.priority" placeholder="优先级" clearable @change="handleFilterChange">
|
||||
<el-option v-for="p in priorityOptions" :key="p.value" :label="p.label" :value="p.value" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="openCreate">新建任务</el-button>
|
||||
<el-button @click="fetchList">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="table-card">
|
||||
<el-table :data="list" border stripe v-loading="loading">
|
||||
<el-table-column type="index" label="#" width="60" align="center" />
|
||||
<el-table-column prop="task_no" label="编号" width="180" show-overflow-tooltip align="center" />
|
||||
<el-table-column prop="task_name" label="任务名称" min-width="220" show-overflow-tooltip align="center">
|
||||
<!-- <template #default="{ row }">
|
||||
<el-link type="primary" @click="openDetail(row)">{{ row.task_name }}</el-link>
|
||||
</template> -->
|
||||
</el-table-column>
|
||||
<el-table-column prop="principal_name" label="负责人" width="120" align="center" />
|
||||
<el-table-column prop="priority" label="优先级" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="priorityTagType(row.priority)" effect="plain">{{ priorityText(row.priority) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="task_status" label="状态" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.task_status)">{{ statusText(row.task_status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="progress" label="进度" width="200" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="Number(row.progress || 0)" :stroke-width="14" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="任务周期" min-width="300" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.plan_start_time) }} ~ {{ formatDateTime(row.plan_end_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link size="small" @click="openDetail(row)">查看</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="openDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.pageSize"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
@current-change="fetchList"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<Edit
|
||||
v-model="formDialogVisible"
|
||||
:is-edit="isEdit"
|
||||
:task="currentTask"
|
||||
@saved="fetchList"
|
||||
/>
|
||||
|
||||
<Detail
|
||||
v-model="detailVisible"
|
||||
:task="currentTask"
|
||||
/>
|
||||
|
||||
<!-- 删除改为内联确认,不使用独立组件 -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { listTasks, getTask, deleteTask } from '@/api/tasks'
|
||||
import Edit from './components/edit.vue'
|
||||
import Detail from './components/detail.vue'
|
||||
import { useDictStore } from '@/stores/dict'
|
||||
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const list = ref<any[]>([])
|
||||
|
||||
const dictStore = useDictStore()
|
||||
const statusOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
const priorityOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
|
||||
const query = reactive({
|
||||
keyword: '',
|
||||
status: '',
|
||||
priority: '',
|
||||
page: 1,
|
||||
pageSize: 10
|
||||
})
|
||||
|
||||
const formDialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const currentTask = ref<any>(null)
|
||||
const detailVisible = ref(false)
|
||||
const deleteDialogVisible = ref(false) // 保留变量但不渲染独立组件
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await listTasks({
|
||||
keyword: query.keyword,
|
||||
status: query.status,
|
||||
priority: query.priority,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize
|
||||
})
|
||||
if (res?.code === 0) {
|
||||
list.value = res.data?.list || res.data?.items || res.data || []
|
||||
total.value = res.data?.total || list.value.length
|
||||
} else {
|
||||
ElMessage.error(res?.message || '获取任务列表失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('获取任务列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
query.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleSizeChange = () => {
|
||||
query.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
isEdit.value = false
|
||||
currentTask.value = null
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
const openEdit = async (row: any) => {
|
||||
isEdit.value = true
|
||||
try {
|
||||
const res: any = await getTask(row.id)
|
||||
currentTask.value = res?.data || row
|
||||
} catch {
|
||||
currentTask.value = row
|
||||
}
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
const openDetail = (row: any) => {
|
||||
currentTask.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const openDelete = (row: any) => {
|
||||
ElMessageBox.confirm(`确认删除任务「${row.task_name || row.task_no}」吗?`, '提示', { type: 'warning' })
|
||||
.then(async () => {
|
||||
const res: any = await deleteTask(row.id)
|
||||
if (res?.code === 0 || res?.success) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res?.message || '删除失败')
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const priorityTagType = (v: string) => {
|
||||
if (v === 'urgent') return 'danger'
|
||||
if (v === 'high') return 'warning'
|
||||
if (v === 'low') return 'success'
|
||||
return 'info'
|
||||
}
|
||||
const statusTagType = (v: string) => {
|
||||
if (v === 'in_progress') return 'warning'
|
||||
if (v === 'completed') return 'success'
|
||||
if (v === 'closed') return 'info'
|
||||
if (v === 'paused') return 'danger'
|
||||
return ''
|
||||
}
|
||||
const priorityText = (v: string) => dictStore.getDictLabel('task_priority', v)
|
||||
const statusText = (v: string) => dictStore.getDictLabel('task_status', v)
|
||||
const formatDateTime = (v: any) => {
|
||||
if (!v) return '-'
|
||||
const d = new Date(v)
|
||||
if (isNaN(d.getTime())) return String(v)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [pri, sts] = await Promise.all([
|
||||
dictStore.getDictItems('task_priority'),
|
||||
dictStore.getDictItems('task_status')
|
||||
])
|
||||
priorityOptions.value = (pri || []).map((it: any) => ({
|
||||
label: it.dict_label ?? it.dictLabel ?? it.label ?? it.name,
|
||||
value: it.dict_value ?? it.dictValue ?? it.value ?? it.code
|
||||
}))
|
||||
statusOptions.value = (sts || []).map((it: any) => ({
|
||||
label: it.dict_label ?? it.dictLabel ?? it.label ?? it.name,
|
||||
value: it.dict_value ?? it.dictValue ?? it.value ?? it.code
|
||||
}))
|
||||
} catch {}
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.task-page {
|
||||
padding: 16px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: nowrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.table-card {
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
:deep(.toolbar .el-button){
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
</style>
|
||||
@ -10,12 +10,7 @@
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-grid">
|
||||
<div
|
||||
v-for="(stat, index) in stats"
|
||||
:key="index"
|
||||
class="stat-card"
|
||||
:class="stat.type"
|
||||
>
|
||||
<div v-for="(stat, index) in stats" :key="index" class="stat-card" :class="stat.type">
|
||||
<div class="stat-icon-wrapper">
|
||||
<el-icon :size="28">
|
||||
<component :is="stat.icon" />
|
||||
@ -25,25 +20,28 @@
|
||||
<div class="stat-value">{{ stat.value }}</div>
|
||||
<div class="stat-label">{{ stat.label }}</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat-trend"
|
||||
:class="stat.change > 0 ? 'up' : stat.change < 0 ? 'down' : 'flat'"
|
||||
>
|
||||
<el-icon v-if="stat.change > 0" :size="14"><ArrowUp /></el-icon>
|
||||
<el-icon v-else-if="stat.change < 0" :size="14"><ArrowDown /></el-icon>
|
||||
<div class="stat-trend" :class="stat.change > 0 ? 'up' : stat.change < 0 ? 'down' : 'flat'">
|
||||
<el-icon v-if="stat.change > 0" :size="14">
|
||||
<ArrowUp />
|
||||
</el-icon>
|
||||
<el-icon v-else-if="stat.change < 0" :size="14">
|
||||
<ArrowDown />
|
||||
</el-icon>
|
||||
<span>{{ stat.change > 0 ? '+' : '' }}{{ stat.change }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<div class="charts-section">
|
||||
<!-- <div class="charts-section">
|
||||
<div class="chart-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">月收入走势</h3>
|
||||
<el-dropdown trigger="click">
|
||||
<el-button type="primary" link>
|
||||
<el-icon><MoreFilled /></el-icon>
|
||||
<el-icon>
|
||||
<MoreFilled />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
@ -63,7 +61,9 @@
|
||||
<h3 class="card-title">用户活跃分布</h3>
|
||||
<el-dropdown trigger="click">
|
||||
<el-button type="primary" link>
|
||||
<el-icon><MoreFilled /></el-icon>
|
||||
<el-icon>
|
||||
<MoreFilled />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
@ -77,7 +77,7 @@
|
||||
<canvas id="barChart" height="160"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<div class="lists-section">
|
||||
@ -85,68 +85,62 @@
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">待办任务</h3>
|
||||
<el-button type="primary" link size="small">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加任务
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="list-content">
|
||||
<div
|
||||
v-for="(task, idx) in tasks"
|
||||
:key="idx"
|
||||
class="task-item"
|
||||
:class="{ done: task.completed }"
|
||||
>
|
||||
<el-checkbox
|
||||
v-model="task.completed"
|
||||
@change="handleTaskChange(task)"
|
||||
/>
|
||||
<div v-for="(task, idx) in paginatedTasks" :key="idx" class="task-item" :class="{ done: task.completed }">
|
||||
<el-checkbox v-model="task.completed" @change="handleTaskChange(task)" />
|
||||
<div class="task-info">
|
||||
<div class="task-title">{{ task.title }}</div>
|
||||
<div class="task-meta">
|
||||
<span class="task-date">{{ task.date }}</span>
|
||||
<el-tag
|
||||
:type="getPriorityType(task.priority)"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
<div class="task-title">
|
||||
{{ task.title }}
|
||||
<el-tag :type="getPriorityType(task.priority)" size="small" effect="plain">
|
||||
{{ task.priority }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="task-meta">
|
||||
<span class="task-date">{{ task.date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty
|
||||
v-if="tasks.filter(t => !t.completed).length === 0"
|
||||
description="暂无待办任务"
|
||||
:image-size="80"
|
||||
/>
|
||||
</div>
|
||||
<el-empty v-if="tasks.length === 0" description="暂无待办任务" :image-size="80" />
|
||||
<div v-if="tasks.length > taskPageSize" class="pagination-wrapper">
|
||||
<el-pagination v-model:current-page="taskCurrentPage" :page-size="taskPageSize" :total="tasks.length"
|
||||
layout="prev, pager, next" small @current-change="handleTaskPageChange" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">最新动态</h3>
|
||||
<el-button type="primary" link size="small">查看全部</el-button>
|
||||
<!-- <el-button type="primary" link size="small" @click="goToActivityLogs">
|
||||
查看全部
|
||||
</el-button> -->
|
||||
</div>
|
||||
<div class="list-content">
|
||||
<div
|
||||
v-for="(activity, idx) in activities"
|
||||
:key="idx"
|
||||
class="activity-item"
|
||||
>
|
||||
<el-avatar :src="activity.avatar" :size="40" />
|
||||
<div v-for="(activity, idx) in paginatedActivityLogs" :key="idx" class="activity-item">
|
||||
<div class="activity-icon" :class="activity.type">
|
||||
<el-icon>
|
||||
<component :is="getActivityIcon(activity.type)" />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="activity-info">
|
||||
<div class="activity-text">
|
||||
<span class="activity-user">{{ activity.user }}</span>
|
||||
<span class="activity-action">{{ activity.action }}</span>
|
||||
<span class="activity-module">{{ activity.operation }}</span>
|
||||
<span class="activity-action">{{ activity.description }}</span>
|
||||
</div>
|
||||
<div class="activity-time">{{ activity.time }}</div>
|
||||
<div class="activity-time">{{ formatTime(activity.timestamp) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty
|
||||
v-if="activities.length === 0"
|
||||
description="暂无动态"
|
||||
:image-size="80"
|
||||
/>
|
||||
<el-empty v-if="activityLogs.length === 0" description="暂无动态" :image-size="80" />
|
||||
<div v-if="totalActivityLogs > pageSize" class="pagination-wrapper">
|
||||
<el-pagination v-model:current-page="currentPage" :page-size="pageSize" :total="totalActivityLogs"
|
||||
layout="prev, pager, next" small @current-change="handlePageChange" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -166,9 +160,11 @@ import {
|
||||
MoreFilled,
|
||||
Plus,
|
||||
Document,
|
||||
Edit,
|
||||
View,
|
||||
} from "@element-plus/icons-vue";
|
||||
import { getKnowledgeCount } from "@/api/knowledge";
|
||||
import { getPlatformStats, getTenantStats } from "@/api/dashboard";
|
||||
import { getPlatformStats, getTenantStats, getActivityLogs } from "@/api/dashboard";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
Chart.register(...registerables);
|
||||
@ -289,6 +285,9 @@ const fetchTenantStats = async () => {
|
||||
};
|
||||
|
||||
// 任务列表
|
||||
const taskCurrentPage = ref(1);
|
||||
const taskPageSize = ref(5);
|
||||
|
||||
const tasks = ref([
|
||||
{
|
||||
title: "完成Q2预算审核",
|
||||
@ -308,29 +307,116 @@ const tasks = ref([
|
||||
priority: "Low",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
title: "准备季度报告",
|
||||
date: "2024-06-12",
|
||||
priority: "High",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
title: "更新项目文档",
|
||||
date: "2024-06-13",
|
||||
priority: "Medium",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
title: "代码审查",
|
||||
date: "2024-06-14",
|
||||
priority: "Low",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
title: "客户需求沟通",
|
||||
date: "2024-06-15",
|
||||
priority: "High",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
title: "测试环境部署",
|
||||
date: "2024-06-16",
|
||||
priority: "Medium",
|
||||
completed: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// 分页后的任务列表
|
||||
const paginatedTasks = computed(() => {
|
||||
const start = (taskCurrentPage.value - 1) * taskPageSize.value;
|
||||
const end = start + taskPageSize.value;
|
||||
return tasks.value.slice(start, end);
|
||||
});
|
||||
|
||||
// 处理任务页码变化
|
||||
const handleTaskPageChange = (page: number) => {
|
||||
taskCurrentPage.value = page;
|
||||
};
|
||||
|
||||
// 动态记录
|
||||
const activities = ref([
|
||||
{
|
||||
user: "Emma",
|
||||
action: "添加了新用户",
|
||||
time: "1 小时前",
|
||||
avatar: "https://picsum.photos/id/1027/40/40",
|
||||
},
|
||||
{
|
||||
user: "John",
|
||||
action: "修改了高级权限",
|
||||
time: "3 小时前",
|
||||
avatar: "https://picsum.photos/id/1012/40/40",
|
||||
},
|
||||
{
|
||||
user: "Jessica",
|
||||
action: "完成订单分析报表",
|
||||
time: "昨天",
|
||||
avatar: "https://picsum.photos/id/1000/40/40",
|
||||
},
|
||||
]);
|
||||
const activityLogs = ref<any[]>([]);
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(5);
|
||||
const totalActivityLogs = ref(0);
|
||||
|
||||
// 分页后的活动日志
|
||||
const paginatedActivityLogs = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value;
|
||||
const end = start + pageSize.value;
|
||||
return activityLogs.value.slice(start, end);
|
||||
});
|
||||
|
||||
// 加载活动日志
|
||||
const fetchActivityLogs = async () => {
|
||||
try {
|
||||
const data = await getActivityLogs(1, 100); // 获取更多数据用于分页
|
||||
if (data?.code === 0 && data?.data?.logs) {
|
||||
activityLogs.value = data.data.logs;
|
||||
totalActivityLogs.value = data.data.logs.length;
|
||||
} else {
|
||||
console.warn('Unexpected response format:', data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch activity logs:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理页码变化
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page;
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timestamp: string | Date) => {
|
||||
if (!timestamp) return '-';
|
||||
const date = new Date(timestamp);
|
||||
if (isNaN(date.getTime())) return String(timestamp);
|
||||
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days < 1) {
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
if (hours < 1) {
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
return `${minutes}分钟前`;
|
||||
}
|
||||
return `${hours}小时前`;
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
// 获取活动图标
|
||||
const getActivityIcon = (type: string) => {
|
||||
if (type === 'operation') return 'Edit';
|
||||
if (type === 'access') return 'View';
|
||||
return 'Document';
|
||||
};
|
||||
|
||||
// 获取优先级类型
|
||||
const getPriorityType = (priority: string) => {
|
||||
@ -356,6 +442,9 @@ onMounted(() => {
|
||||
fetchPlatformStats();
|
||||
}
|
||||
|
||||
// 加载活动日志
|
||||
fetchActivityLogs();
|
||||
|
||||
// 折线图
|
||||
const lineChartEl = document.getElementById("lineChart") as HTMLCanvasElement | null;
|
||||
if (!lineChartEl) {
|
||||
@ -700,6 +789,18 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.list-content {
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@ -736,8 +837,14 @@ onMounted(() => {
|
||||
.task-title {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.el-tag{
|
||||
margin-left: 8px !important;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
@ -753,6 +860,14 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@ -772,6 +887,27 @@ onMounted(() => {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
|
||||
&.operation {
|
||||
background-color: rgba(79, 132, 255, 0.2);
|
||||
color: #4f84ff;
|
||||
}
|
||||
|
||||
&.access {
|
||||
background-color: rgba(85, 190, 130, 0.2);
|
||||
color: #55be82;
|
||||
}
|
||||
}
|
||||
|
||||
.activity-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@ -781,7 +917,7 @@ onMounted(() => {
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 4px;
|
||||
|
||||
.activity-user {
|
||||
.activity-module {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
@ -803,6 +939,7 @@ onMounted(() => {
|
||||
|
||||
// 响应式
|
||||
@media (max-width: 1200px) {
|
||||
|
||||
.charts-section,
|
||||
.lists-section {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@ -1,367 +0,0 @@
|
||||
<template>
|
||||
<div class="access-log-container">
|
||||
<!-- 统计面板 -->
|
||||
<!-- <StatisticsPanel /> -->
|
||||
|
||||
<!-- 搜索和操作栏 -->
|
||||
<el-card shadow="hover" style="margin-bottom: 20px">
|
||||
<el-form :model="filters" label-width="100px" :inline="true">
|
||||
<el-form-item label="用户">
|
||||
<el-input v-model="filters.username" placeholder="搜索用户名" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="模块">
|
||||
<el-select v-model="filters.module" placeholder="选择模块" clearable>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option
|
||||
v-for="opt in moduleOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="资源类型">
|
||||
<el-input v-model="filters.resource_type" placeholder="搜索资源类型" clearable />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-form :model="dateRange" label-width="100px" :inline="true">
|
||||
<el-form-item label="访问时间">
|
||||
<el-date-picker
|
||||
v-model="dateRange.range"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div style="margin-top: 15px">
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
<el-button @click="showClearDialog">清空日志</el-button>
|
||||
<el-button @click="handleExport">导出</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 日志列表 -->
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>访问日志列表</span>
|
||||
<span class="log-count">共 {{ total }} 条</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
:data="tableData"
|
||||
stripe
|
||||
style="width: 100%; margin-bottom: 20px"
|
||||
v-loading="loading"
|
||||
@row-click="handleRowClick"
|
||||
>
|
||||
<el-table-column prop="username" label="用户" align="center" width="120" />
|
||||
<el-table-column prop="module_name" label="模块" align="center" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag>{{ row.module_name }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="resource_type" label="资源类型" align="center" width="120" />
|
||||
<el-table-column prop="request_url" label="访问路径" align="center" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="row.request_url" placement="top">
|
||||
<span>{{ row.request_url }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="request_method" label="方法" align="center" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getMethodTag(row.request_method)">
|
||||
{{ row.request_method }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip_address" label="IP地址" align="center" width="140" />
|
||||
<el-table-column prop="duration" label="耗时(ms)" align="center" width="100" />
|
||||
<el-table-column prop="create_time" label="访问时间" align="center" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.create_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="showDetail(row)">
|
||||
详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<el-dialog v-model="detailDialogVisible" title="访问日志详情" width="70%">
|
||||
<el-descriptions v-if="currentRecord" :column="2" border>
|
||||
<el-descriptions-item label="日志ID">
|
||||
{{ currentRecord.id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户">
|
||||
{{ currentRecord.username }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="租户ID">
|
||||
{{ currentRecord.tenant_id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户ID">
|
||||
{{ currentRecord.user_id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="模块">
|
||||
{{ currentRecord.module_name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="资源类型">
|
||||
{{ currentRecord.resource_type }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="资源ID">
|
||||
{{ currentRecord.resource_id || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="访问路径">
|
||||
{{ currentRecord.request_url }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="请求方法">
|
||||
{{ currentRecord.request_method }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="查询字符串">
|
||||
{{ currentRecord.query_string || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="IP地址">
|
||||
{{ currentRecord.ip_address }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="User Agent">
|
||||
<div style="word-break: break-all; font-size: 12px">
|
||||
{{ currentRecord.user_agent }}
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="耗时(ms)">
|
||||
{{ currentRecord.duration }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="访问时间">
|
||||
{{ formatTime(currentRecord.create_time) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 清空对话框 -->
|
||||
<el-dialog v-model="clearDialogVisible" title="清空日志" width="400px">
|
||||
<el-form :model="clearForm" label-width="100px">
|
||||
<el-form-item label="保留天数">
|
||||
<el-input-number v-model="clearForm.keepDays" :min="0" :max="365" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="clearDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" @click="handleClear">确定清空</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getAccessLogs, clearOldAccessLogs } from '@/api/accessLog'
|
||||
import { getAllMenus } from '@/api/menu'
|
||||
|
||||
const filters = reactive({
|
||||
username: '',
|
||||
module: '',
|
||||
resource_type: ''
|
||||
})
|
||||
|
||||
const dateRange = reactive({
|
||||
range: null
|
||||
})
|
||||
|
||||
const tableData = ref([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const detailDialogVisible = ref(false)
|
||||
const clearDialogVisible = ref(false)
|
||||
const currentRecord = ref(null)
|
||||
|
||||
const clearForm = reactive({
|
||||
keepDays: 90
|
||||
})
|
||||
|
||||
const moduleOptions = ref([])
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time) => {
|
||||
if (!time) return '-'
|
||||
const date = new Date(time)
|
||||
return date.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取HTTP方法的标签类型
|
||||
const getMethodTag = (method) => {
|
||||
const tagMap = {
|
||||
GET: 'success',
|
||||
POST: 'warning',
|
||||
PUT: 'info',
|
||||
DELETE: 'danger'
|
||||
}
|
||||
return tagMap[method] || 'info'
|
||||
}
|
||||
|
||||
// 加载菜单数据(用于模块选项)
|
||||
const loadMenus = async () => {
|
||||
try {
|
||||
const res = await getAllMenus()
|
||||
if (res.data) {
|
||||
const menus = res.data.list || res.data || []
|
||||
const options = menus.map((m) => ({
|
||||
value: m.path ? m.path.split('/').pop() : m.permission,
|
||||
label: m.name
|
||||
}))
|
||||
moduleOptions.value = options
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load menus:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载日志列表
|
||||
const loadLogs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page_num: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
username: filters.username || undefined,
|
||||
module: filters.module || undefined,
|
||||
resource_type: filters.resource_type || undefined
|
||||
}
|
||||
|
||||
if (dateRange.range && dateRange.range.length === 2) {
|
||||
params.start_time = dateRange.range[0].toLocaleString('zh-CN')
|
||||
params.end_time = dateRange.range[1].toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const res = await getAccessLogs(params)
|
||||
if (res.data) {
|
||||
tableData.value = res.data
|
||||
// 补充模块名称(从 moduleOptions 中查找)
|
||||
tableData.value.forEach((row) => {
|
||||
const module = moduleOptions.value.find((m) => m.value === row.module)
|
||||
row.module_name = module ? module.label : row.module
|
||||
})
|
||||
total.value = res.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('加载访问日志失败')
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
currentPage.value = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
// 处理重置
|
||||
const handleReset = () => {
|
||||
filters.username = ''
|
||||
filters.module = ''
|
||||
filters.resource_type = ''
|
||||
dateRange.range = null
|
||||
currentPage.value = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
// 处理日期改变
|
||||
const handleDateChange = () => {
|
||||
currentPage.value = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
// 处理分页改变
|
||||
const handlePageChange = () => {
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
// 显示详情
|
||||
const showDetail = (row) => {
|
||||
currentRecord.value = row
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 显示清空对话框
|
||||
const showClearDialog = () => {
|
||||
clearDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 处理清空日志
|
||||
const handleClear = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要清空 ${clearForm.keepDays} 天前的日志吗?该操作不可撤销。`,
|
||||
'警告',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
await clearOldAccessLogs(clearForm.keepDays)
|
||||
ElMessage.success('日志清空成功')
|
||||
clearDialogVisible.value = false
|
||||
loadLogs()
|
||||
} catch (e) {
|
||||
// 取消操作
|
||||
}
|
||||
}
|
||||
|
||||
// 处理导出
|
||||
const handleExport = () => {
|
||||
// 可以调用后端导出接口或使用前端库导出
|
||||
ElMessage.info('导出功能暂未实现')
|
||||
}
|
||||
|
||||
// 页面初始化
|
||||
onMounted(async () => {
|
||||
await loadMenus()
|
||||
await loadLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.access-log-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.log-count {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@ -1,8 +1,9 @@
|
||||
<template>
|
||||
<div class="operation-log-container">
|
||||
<!-- 统计面板 -->
|
||||
<!-- <StatisticsPanel /> -->
|
||||
|
||||
<!-- 日志类型标签页 -->
|
||||
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
|
||||
<!-- 操作日志标签页 -->
|
||||
<el-tab-pane label="操作日志" name="operation">
|
||||
<!-- 搜索和操作栏 -->
|
||||
<el-card shadow="hover" style="margin-bottom: 20px">
|
||||
<el-form :model="filters" label-width="100px" :inline="true">
|
||||
@ -123,6 +124,171 @@
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<OperationLogDetail ref="detailRef" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 访问日志标签页 -->
|
||||
<el-tab-pane label="访问日志" name="access">
|
||||
<!-- 搜索和操作栏 -->
|
||||
<el-card shadow="hover" style="margin-bottom: 20px">
|
||||
<el-form :model="accessFilters" label-width="100px" :inline="true">
|
||||
<el-form-item label="用户">
|
||||
<el-input v-model="accessFilters.username" placeholder="搜索用户名" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="模块">
|
||||
<el-select v-model="accessFilters.module" placeholder="选择模块" clearable>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option
|
||||
v-for="opt in moduleOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="资源类型">
|
||||
<el-input v-model="accessFilters.resource_type" placeholder="搜索资源类型" clearable />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-form :model="accessDateRange" label-width="100px" :inline="true">
|
||||
<el-form-item label="访问时间">
|
||||
<el-date-picker
|
||||
v-model="accessDateRange.range"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
@change="handleAccessDateChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div style="margin-top: 15px">
|
||||
<el-button type="primary" @click="handleAccessSearch">查询</el-button>
|
||||
<el-button @click="handleAccessReset">重置</el-button>
|
||||
<el-button @click="showAccessClearDialog">清空日志</el-button>
|
||||
<el-button @click="handleAccessExport">导出</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 访问日志列表 -->
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>访问日志列表</span>
|
||||
<span class="log-count">共 {{ accessTotal }} 条</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
:data="accessTableData"
|
||||
stripe
|
||||
style="width: 100%; margin-bottom: 20px"
|
||||
v-loading="accessLoading"
|
||||
@row-click="handleAccessRowClick"
|
||||
>
|
||||
<el-table-column prop="username" label="用户" align="center" width="120" />
|
||||
<el-table-column prop="ip_address" label="IP地址" align="center" width="140" />
|
||||
<el-table-column prop="request_method" label="方法" align="center" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getMethodTag(row.request_method)">
|
||||
{{ row.request_method }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="request_url" label="访问路径" align="center" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="row.request_url" placement="top">
|
||||
<span>{{ row.request_url }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="duration" label="耗时(ms)" align="center" width="100" />
|
||||
<el-table-column prop="status" label="结果" align="center" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
|
||||
{{ row.status === 1 ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="访问时间" align="center" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.create_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click.stop="showAccessDetail(row)">
|
||||
详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-model:current-page="accessPagination.page_num"
|
||||
v-model:page-size="accessPagination.page_size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="accessTotal"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@change="loadAccessLogs"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 访问日志详情对话框 -->
|
||||
<el-dialog v-model="accessDetailVisible" title="访问日志详情" width="60%" top="5vh">
|
||||
<el-scrollbar max-height="60vh">
|
||||
<el-descriptions v-if="currentAccessRecord" :column="2" border>
|
||||
<el-descriptions-item label="日志ID">
|
||||
{{ currentAccessRecord.id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户">
|
||||
{{ currentAccessRecord.username }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户ID">
|
||||
{{ currentAccessRecord.user_id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="租户ID">
|
||||
{{ currentAccessRecord.tenant_id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="访问路径">
|
||||
{{ currentAccessRecord.request_url }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="请求方法">
|
||||
<el-tag :type="getMethodTag(currentAccessRecord.request_method)">
|
||||
{{ currentAccessRecord.request_method }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="IP地址">
|
||||
{{ currentAccessRecord.ip_address }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="currentAccessRecord.status === 1 ? 'success' : 'danger'">
|
||||
{{ currentAccessRecord.status === 1 ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="耗时(ms)">
|
||||
{{ currentAccessRecord.duration }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="访问时间">
|
||||
{{ formatTime(currentAccessRecord.create_time) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="User Agent" :span="2">
|
||||
<div style="word-break: break-all; font-size: 12px">
|
||||
{{ currentAccessRecord.user_agent || '-' }}
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="查询参数" :span="2">
|
||||
<div style="word-break: break-all; font-size: 12px">
|
||||
{{ currentAccessRecord.query_string || '-' }}
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-scrollbar>
|
||||
</el-dialog>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 清空日志对话框 -->
|
||||
<el-dialog v-model="clearDialogVisible" title="清空旧日志" width="400px">
|
||||
@ -149,12 +315,13 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { getOperationLogs, clearOldLogs } from '@/api/operationLog'
|
||||
import { getOperationLogs, clearOldLogs, getAccessLogs, clearOldAccessLogs } from '@/api/operationLog'
|
||||
import { getAllMenus } from '@/api/menu'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
// import StatisticsPanel from './components/StatisticsPanel.vue'
|
||||
import OperationLogDetail from './components/OperationLogDetail.vue'
|
||||
|
||||
const activeTab = ref('operation')
|
||||
|
||||
const tableData = ref([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
@ -182,6 +349,28 @@ const clearForm = reactive({
|
||||
keep_days: 90
|
||||
})
|
||||
|
||||
// 访问日志相关状态
|
||||
const accessTableData = ref([])
|
||||
const accessLoading = ref(false)
|
||||
const accessTotal = ref(0)
|
||||
const accessDetailVisible = ref(false)
|
||||
const currentAccessRecord = ref(null)
|
||||
|
||||
const accessPagination = reactive({
|
||||
page_num: 1,
|
||||
page_size: 20
|
||||
})
|
||||
|
||||
const accessFilters = reactive({
|
||||
username: '',
|
||||
module: '',
|
||||
resource_type: ''
|
||||
})
|
||||
|
||||
const accessDateRange = reactive({
|
||||
range: null
|
||||
})
|
||||
|
||||
const getOperationTag = (operation) => {
|
||||
const map = {
|
||||
'CREATE': 'success',
|
||||
@ -289,8 +478,12 @@ const showClearDialog = () => {
|
||||
}
|
||||
|
||||
const handleClearLogs = async () => {
|
||||
const logType = activeTab.value === 'operation' ? '操作日志' : '访问日志'
|
||||
const clearFunc = activeTab.value === 'operation' ? clearOldLogs : clearOldAccessLogs
|
||||
const reloadFunc = activeTab.value === 'operation' ? loadLogs : loadAccessLogs
|
||||
|
||||
ElMessageBox.confirm(
|
||||
`将删除超过 ${clearForm.keep_days} 天的所有操作日志,此操作无法撤销!`,
|
||||
`将删除超过 ${clearForm.keep_days} 天的所有${logType},此操作无法撤销!`,
|
||||
'警告',
|
||||
{
|
||||
confirmButtonText: '确认',
|
||||
@ -299,11 +492,11 @@ const handleClearLogs = async () => {
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
const res = await clearOldLogs(clearForm.keep_days)
|
||||
const res = await clearFunc(clearForm.keep_days)
|
||||
if (res.success) {
|
||||
ElMessage.success('日志清空成功')
|
||||
clearDialogVisible.value = false
|
||||
loadLogs()
|
||||
reloadFunc()
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('清空日志失败')
|
||||
@ -317,6 +510,87 @@ const handleExport = () => {
|
||||
ElMessage.info('导出功能开发中...')
|
||||
}
|
||||
|
||||
// 标签页切换
|
||||
const handleTabChange = (tabName) => {
|
||||
if (tabName === 'access') {
|
||||
loadAccessLogs()
|
||||
} else {
|
||||
loadLogs()
|
||||
}
|
||||
}
|
||||
|
||||
// 访问日志相关方法
|
||||
const loadAccessLogs = async () => {
|
||||
accessLoading.value = true
|
||||
try {
|
||||
const params = {
|
||||
...accessPagination,
|
||||
...accessFilters
|
||||
}
|
||||
|
||||
if (accessDateRange.range && accessDateRange.range.length === 2) {
|
||||
params.start_time = new Date(accessDateRange.range[0]).toISOString().split('T')[0]
|
||||
params.end_time = new Date(accessDateRange.range[1]).toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
const res = await getAccessLogs(params)
|
||||
if (res.success || res.data) {
|
||||
// Backend returns data in res.data.logs format
|
||||
accessTableData.value = res.data?.logs || res.data || []
|
||||
accessTotal.value = res.data?.total || res.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('加载访问日志失败')
|
||||
} finally {
|
||||
accessLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleAccessSearch = () => {
|
||||
accessPagination.page_num = 1
|
||||
loadAccessLogs()
|
||||
}
|
||||
|
||||
const handleAccessReset = () => {
|
||||
accessFilters.username = ''
|
||||
accessFilters.module = ''
|
||||
accessFilters.resource_type = ''
|
||||
accessDateRange.range = null
|
||||
accessPagination.page_num = 1
|
||||
loadAccessLogs()
|
||||
}
|
||||
|
||||
const handleAccessDateChange = () => {
|
||||
handleAccessSearch()
|
||||
}
|
||||
|
||||
const handleAccessRowClick = (row) => {
|
||||
showAccessDetail(row)
|
||||
}
|
||||
|
||||
const showAccessDetail = (row) => {
|
||||
currentAccessRecord.value = row
|
||||
accessDetailVisible.value = true
|
||||
}
|
||||
|
||||
const showAccessClearDialog = () => {
|
||||
clearDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleAccessExport = () => {
|
||||
ElMessage.info('导出功能开发中...')
|
||||
}
|
||||
|
||||
const getMethodTag = (method) => {
|
||||
const tagMap = {
|
||||
GET: 'success',
|
||||
POST: 'warning',
|
||||
PUT: 'info',
|
||||
DELETE: 'danger'
|
||||
}
|
||||
return tagMap[method] || 'info'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMenus()
|
||||
loadLogs()
|
||||
|
||||
@ -25,6 +25,43 @@
|
||||
<el-input v-model="form.email" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 部门 -->
|
||||
<el-form-item label="部门">
|
||||
<el-select
|
||||
v-model="form.department_id"
|
||||
placeholder="请选择部门"
|
||||
style="width: 100%"
|
||||
:loading="loadingDepartments"
|
||||
clearable
|
||||
@change="handleDepartmentChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="dept in departmentList"
|
||||
:key="dept.id"
|
||||
:label="dept.name"
|
||||
:value="dept.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 职位 -->
|
||||
<el-form-item label="职位">
|
||||
<el-select
|
||||
v-model="form.position_id"
|
||||
placeholder="请选择职位"
|
||||
style="width: 100%"
|
||||
:loading="loadingPositions"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="pos in positionList"
|
||||
:key="pos.id"
|
||||
:label="pos.name"
|
||||
:value="pos.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 角色 -->
|
||||
<el-form-item label="角色">
|
||||
<el-select
|
||||
@ -73,16 +110,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import {
|
||||
addUser,
|
||||
editUser,
|
||||
getUserInfo,
|
||||
} from "@/api/user";
|
||||
|
||||
import { getTenantPositions, getPositionsByDepartment } from "@/api/position";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useDictStore } from "@/stores/dict";
|
||||
import { DICT_CODES } from "@/constants/dictCodes";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const dictStore = useDictStore();
|
||||
@ -100,23 +138,44 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
departmentList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
positionList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
loadingRoles: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loadingDepartments: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loadingPositions: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
statusDict: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
tenantId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'submit', 'close']);
|
||||
const emit = defineEmits(['update:modelValue', 'submit', 'close', 'fetch-positions']);
|
||||
|
||||
const visible = ref(false);
|
||||
const formRef = ref(null);
|
||||
const loadingRoles = ref(false);
|
||||
const loadingDepartments = ref(false);
|
||||
const loadingPositions = ref(false);
|
||||
const isAdd = ref(false);
|
||||
const statusDict = ref([]);
|
||||
|
||||
const form = ref<any>({
|
||||
id: null,
|
||||
@ -127,6 +186,8 @@ const form = ref<any>({
|
||||
role: null,
|
||||
status: "1", // ✅ 改为字典中的值"1"(启用)而不是"active"
|
||||
tenant_id: null,
|
||||
department_id: null,
|
||||
position_id: null,
|
||||
});
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
@ -154,22 +215,6 @@ const getCurrentTenantId = () => {
|
||||
return props.tenantId || 0;
|
||||
};
|
||||
|
||||
// 获取状态字典数据
|
||||
const fetchStatusDict = async () => {
|
||||
try {
|
||||
const dictItems = await dictStore.getDictItems('user_status');
|
||||
statusDict.value = dictItems || [];
|
||||
} catch (error) {
|
||||
console.error("获取用户状态字典失败:", error);
|
||||
statusDict.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时获取字典数据
|
||||
onMounted(() => {
|
||||
fetchStatusDict();
|
||||
});
|
||||
|
||||
const loadUserData = async (user: any) => {
|
||||
try {
|
||||
// 处理两种调用方式:传递用户对象或用户 ID
|
||||
@ -200,11 +245,16 @@ const loadUserData = async (user: any) => {
|
||||
password: "",
|
||||
email: data.email,
|
||||
role: roleValue,
|
||||
department_id: data.department_id || null,
|
||||
position_id: data.position_id || null,
|
||||
status: statusValue, // ✅ 使用字典中的原始值
|
||||
tenant_id: data.tenant_id || tenantId,
|
||||
};
|
||||
|
||||
|
||||
// 如果用户有部门,根据部门加载职位
|
||||
if (form.value.department_id) {
|
||||
await loadPositions(form.value.department_id);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Failed to load user data:', e);
|
||||
const errorMsg = e?.response?.data?.message || e?.message || "加载用户失败";
|
||||
@ -213,7 +263,31 @@ const loadUserData = async (user: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadPositions = async (departmentId?: number) => {
|
||||
loadingPositions.value = true;
|
||||
try {
|
||||
const tenantId = getCurrentTenantId();
|
||||
let res;
|
||||
if (departmentId && departmentId > 0) {
|
||||
res = await getPositionsByDepartment(departmentId);
|
||||
} else {
|
||||
res = await getTenantPositions(tenantId);
|
||||
}
|
||||
// 职位列表由父组件通过 prop 传入,这里仅用于加载示例
|
||||
} catch (error: any) {
|
||||
console.error('获取职位列表失败:', error);
|
||||
} finally {
|
||||
loadingPositions.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDepartmentChange = (departmentId: number | null) => {
|
||||
form.value.position_id = null;
|
||||
if (departmentId && departmentId > 0) {
|
||||
// 让父组件加载对应部门的职位
|
||||
emit('fetch-positions', departmentId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
visible.value = false;
|
||||
@ -229,6 +303,8 @@ const handleClose = () => {
|
||||
role: null,
|
||||
status: "1",
|
||||
tenant_id: null,
|
||||
department_id: null,
|
||||
position_id: null,
|
||||
};
|
||||
isAdd.value = false;
|
||||
emit('close');
|
||||
@ -250,6 +326,13 @@ const handleSubmit = async () => {
|
||||
submitData.role = form.value.role;
|
||||
}
|
||||
|
||||
if (form.value.department_id) {
|
||||
submitData.department_id = form.value.department_id;
|
||||
}
|
||||
if (form.value.position_id) {
|
||||
submitData.position_id = form.value.position_id;
|
||||
}
|
||||
|
||||
if (form.value.tenant_id) {
|
||||
submitData.tenant_id = form.value.tenant_id;
|
||||
}
|
||||
@ -275,6 +358,13 @@ const handleSubmit = async () => {
|
||||
submitData.role = form.value.role;
|
||||
}
|
||||
|
||||
if (form.value.department_id) {
|
||||
submitData.department_id = form.value.department_id;
|
||||
}
|
||||
if (form.value.position_id) {
|
||||
submitData.position_id = form.value.position_id;
|
||||
}
|
||||
|
||||
if (form.value.tenant_id) {
|
||||
submitData.tenant_id = form.value.tenant_id;
|
||||
}
|
||||
@ -305,6 +395,8 @@ defineExpose({
|
||||
role: null,
|
||||
status: "1",
|
||||
tenant_id: tenantId || getCurrentTenantId(),
|
||||
department_id: null,
|
||||
position_id: null,
|
||||
};
|
||||
visible.value = true;
|
||||
},
|
||||
|
||||
@ -32,7 +32,16 @@
|
||||
align="center"
|
||||
min-width="200"
|
||||
/>
|
||||
|
||||
<el-table-column prop="department" label="部门" width="150" align="center">
|
||||
<template #default="scope">
|
||||
<span>{{ scope.row.departmentName || '未分配' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="position" label="职位" width="150" align="center">
|
||||
<template #default="scope">
|
||||
<span>{{ scope.row.positionName || '未分配' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role" label="角色" width="150" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getRoleTagType(scope.row.roleName)">
|
||||
@ -107,6 +116,8 @@ import {
|
||||
getUserInfo,
|
||||
} from "@/api/user";
|
||||
import { getRoleByTenantId, getAllRoles } from "@/api/role";
|
||||
import { getTenantDepartments } from "@/api/department";
|
||||
import { getTenantPositions } from "@/api/position";
|
||||
import { getDictItemsByCode } from '@/api/dict'
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
@ -129,6 +140,14 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
departmentList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
positionList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
statusDict: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
@ -216,6 +235,22 @@ const fetchUsers = async () => {
|
||||
roleName = roleInfo ? roleInfo.roleName : '';
|
||||
}
|
||||
|
||||
// 查找部门名称
|
||||
let departmentName = '';
|
||||
const departmentId = item.department_id || null;
|
||||
if (departmentId) {
|
||||
const deptInfo = (props.departmentList as any[]).find(d => d.id === departmentId);
|
||||
departmentName = deptInfo ? deptInfo.name : '';
|
||||
}
|
||||
|
||||
// 查找职位名称
|
||||
let positionName = '';
|
||||
const positionId = item.position_id || null;
|
||||
if (positionId) {
|
||||
const posInfo = (props.positionList as any[]).find(p => p.id === positionId);
|
||||
positionName = posInfo ? posInfo.name : '';
|
||||
}
|
||||
|
||||
// 处理时间字段
|
||||
const lastLoginTime = item.last_login_time || item.lastLoginTime || null;
|
||||
const lastLoginIp = item.last_login_ip || item.lastLoginIp || null;
|
||||
@ -227,6 +262,10 @@ const fetchUsers = async () => {
|
||||
email: item.email,
|
||||
role: roleValue,
|
||||
roleName: roleName,
|
||||
department_id: departmentId,
|
||||
departmentName: departmentName,
|
||||
position_id: positionId,
|
||||
positionName: positionName,
|
||||
status: item.status,
|
||||
lastLoginTime: lastLoginTime
|
||||
? new Date(lastLoginTime).toLocaleString("zh-CN", {
|
||||
|
||||
@ -36,7 +36,16 @@
|
||||
align="center"
|
||||
min-width="200"
|
||||
/>
|
||||
|
||||
<el-table-column prop="department" label="部门" width="150" align="center">
|
||||
<template #default="scope">
|
||||
<span>{{ scope.row.departmentName || '未分配' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="position" label="职位" width="150" align="center">
|
||||
<template #default="scope">
|
||||
<span>{{ scope.row.positionName || '未分配' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role" label="角色" width="150" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getRoleTagType(scope.row.roleName)">
|
||||
@ -105,7 +114,11 @@
|
||||
@update:modelValue="editDialogVisible = $event"
|
||||
:is-edit="isEdit"
|
||||
:role-list="roleList"
|
||||
:department-list="departmentList"
|
||||
:position-list="positionList"
|
||||
:loading-roles="loadingRoles"
|
||||
:loading-departments="loadingDepartments"
|
||||
:loading-positions="loadingPositions"
|
||||
:status-dict="statusDict"
|
||||
:tenant-id="getCurrentTenantId()"
|
||||
@submit="handleEditSuccess"
|
||||
@ -134,6 +147,8 @@ import {
|
||||
getUserInfo,
|
||||
} from "@/api/user";
|
||||
import { getRoleByTenantId, getAllRoles } from "@/api/role";
|
||||
import { getTenantDepartments } from "@/api/department";
|
||||
import { getTenantPositions, getPositionsByDepartment } from "@/api/position";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useDictStore } from "@/stores/dict";
|
||||
import UserEditDialog from './components/UserEdit.vue'
|
||||
@ -160,6 +175,10 @@ const total = ref(0);
|
||||
const users = ref<any[]>([]);
|
||||
const roleList = ref<any[]>([]);
|
||||
const loadingRoles = ref(false);
|
||||
const departmentList = ref<any[]>([]);
|
||||
const loadingDepartments = ref(false);
|
||||
const positionList = ref<any[]>([]);
|
||||
const loadingPositions = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
// 状态字典
|
||||
@ -178,8 +197,11 @@ const changePasswordRef = ref()
|
||||
|
||||
const fetchStatusDict = async () => {
|
||||
try {
|
||||
console.log('Starting to fetch status dict...');
|
||||
const items = await dictStore.getDictItems('user_status');
|
||||
console.log('Fetched statusDict items:', items);
|
||||
statusDict.value = items;
|
||||
console.log('statusDict.value updated:', statusDict.value);
|
||||
} catch (err) {
|
||||
console.error('Error fetching status dict:', err);
|
||||
statusDict.value = [];
|
||||
@ -245,7 +267,48 @@ const fetchRoles = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 获取部门列表
|
||||
const fetchDepartments = async () => {
|
||||
loadingDepartments.value = true;
|
||||
try {
|
||||
const tenantId = getCurrentTenantId();
|
||||
const res = await getTenantDepartments(tenantId);
|
||||
if (res.code === 0 && res.data) {
|
||||
departmentList.value = Array.isArray(res.data) ? res.data : [];
|
||||
} else {
|
||||
departmentList.value = [];
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取部门列表失败:', error);
|
||||
departmentList.value = [];
|
||||
} finally {
|
||||
loadingDepartments.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取职位列表
|
||||
const fetchPositions = async (departmentId?: number) => {
|
||||
loadingPositions.value = true;
|
||||
try {
|
||||
const tenantId = getCurrentTenantId();
|
||||
let res;
|
||||
if (departmentId && departmentId > 0) {
|
||||
res = await getPositionsByDepartment(departmentId);
|
||||
} else {
|
||||
res = await getTenantPositions(tenantId);
|
||||
}
|
||||
if (res.code === 0 && res.data) {
|
||||
positionList.value = Array.isArray(res.data) ? res.data : [];
|
||||
} else {
|
||||
positionList.value = [];
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取职位列表失败:', error);
|
||||
positionList.value = [];
|
||||
} finally {
|
||||
loadingPositions.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取角色标签类型
|
||||
const getRoleTagType = (roleName: string) => {
|
||||
@ -302,6 +365,20 @@ const fetchUsers = async () => {
|
||||
roleName = roleInfo ? roleInfo.roleName : '';
|
||||
}
|
||||
|
||||
let departmentName = '';
|
||||
const departmentId = item.department_id || null;
|
||||
if (departmentId) {
|
||||
const deptInfo = departmentList.value.find(d => d.id === departmentId);
|
||||
departmentName = deptInfo ? deptInfo.name : '';
|
||||
}
|
||||
|
||||
let positionName = '';
|
||||
const positionId = item.position_id || null;
|
||||
if (positionId) {
|
||||
const posInfo = positionList.value.find(p => p.id === positionId);
|
||||
positionName = posInfo ? posInfo.name : '';
|
||||
}
|
||||
|
||||
// 确保状态值正确处理
|
||||
const statusValue = item.status !== undefined && item.status !== null ? item.status : '1';
|
||||
|
||||
@ -315,6 +392,10 @@ const fetchUsers = async () => {
|
||||
email: item.email,
|
||||
role: roleValue,
|
||||
roleName: roleName,
|
||||
department_id: departmentId,
|
||||
departmentName: departmentName,
|
||||
position_id: positionId,
|
||||
positionName: positionName,
|
||||
status: statusValue, // 使用处理后的状态值
|
||||
lastLoginTime: lastLoginTime
|
||||
? new Date(lastLoginTime).toLocaleString("zh-CN", {
|
||||
@ -342,6 +423,8 @@ const fetchUsers = async () => {
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
fetchRoles(),
|
||||
fetchDepartments(),
|
||||
fetchPositions(),
|
||||
fetchStatusDict(),
|
||||
]);
|
||||
fetchUsers();
|
||||
@ -356,6 +439,8 @@ const refresh = async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchRoles(),
|
||||
fetchDepartments(),
|
||||
fetchPositions(),
|
||||
fetchStatusDict(),
|
||||
]);
|
||||
await fetchUsers();
|
||||
@ -402,7 +487,14 @@ const handleEditSuccess = () => {
|
||||
fetchUsers();
|
||||
};
|
||||
|
||||
|
||||
// 职位加载回调
|
||||
const handleFetchPositions = (departmentId: number | null) => {
|
||||
if (departmentId && departmentId > 0) {
|
||||
fetchPositions(departmentId);
|
||||
} else {
|
||||
fetchPositions();
|
||||
}
|
||||
};
|
||||
|
||||
// 删除用户
|
||||
const handleDelete = async (user: User) => {
|
||||
|
||||
@ -194,9 +194,9 @@ func (c *AuthController) Login() {
|
||||
TenantId: tenantId,
|
||||
UserId: userId,
|
||||
Username: usernameForToken,
|
||||
Module: "auth",
|
||||
Module: "登录模块",
|
||||
ResourceType: "user",
|
||||
Operation: "LOGIN",
|
||||
Operation: "登录",
|
||||
IpAddress: clientIP,
|
||||
UserAgent: c.Ctx.Input.Header("User-Agent"),
|
||||
RequestMethod: "POST",
|
||||
|
||||
@ -2,6 +2,8 @@ package controllers
|
||||
|
||||
import (
|
||||
"server/models"
|
||||
"server/services"
|
||||
"time"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
@ -106,3 +108,104 @@ func (c *DashboardController) GetTenantStats() {
|
||||
}
|
||||
c.ServeJSON()
|
||||
}
|
||||
|
||||
// GetUserActivityLogs 获取当前用户的最新活动日志(包含操作日志和登录日志)
|
||||
// @router /api/dashboard/user-activity-logs [get]
|
||||
func (c *DashboardController) GetUserActivityLogs() {
|
||||
// 获取当前用户ID和租户ID
|
||||
userId := 0
|
||||
if userIdVal, ok := c.Ctx.Input.GetData("userId").(int); ok {
|
||||
userId = userIdVal
|
||||
}
|
||||
|
||||
tenantId := 0
|
||||
if tenantIdVal, ok := c.Ctx.Input.GetData("tenantId").(int); ok {
|
||||
tenantId = tenantIdVal
|
||||
}
|
||||
|
||||
if userId <= 0 {
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 1,
|
||||
"message": "无法获取用户信息",
|
||||
"data": nil,
|
||||
}
|
||||
c.ServeJSON()
|
||||
return
|
||||
}
|
||||
|
||||
// 获取查询参数
|
||||
pageNum, _ := c.GetInt("page_num", 1)
|
||||
pageSize, _ := c.GetInt("page_size", 10)
|
||||
logType := c.GetString("type") // "operation" 或 "access" 或 "all"
|
||||
operation := c.GetString("operation") // 操作类型过滤
|
||||
|
||||
if logType == "" {
|
||||
logType = "all"
|
||||
}
|
||||
|
||||
type ActivityLog struct {
|
||||
Id int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Username string `json:"username"`
|
||||
Module string `json:"module"`
|
||||
Operation string `json:"operation"`
|
||||
Action string `json:"action"`
|
||||
Description string `json:"description"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
var logs []ActivityLog
|
||||
|
||||
// 获取操作日志
|
||||
if logType == "operation" || logType == "all" {
|
||||
operationLogs, _, err := services.GetOperationLogs(tenantId, userId, "", operation, nil, nil, pageNum, pageSize)
|
||||
if err == nil && operationLogs != nil {
|
||||
for _, log := range operationLogs {
|
||||
logs = append(logs, ActivityLog{
|
||||
Id: log.Id,
|
||||
Type: "operation",
|
||||
Username: log.Username,
|
||||
Module: log.Module,
|
||||
Operation: log.Operation,
|
||||
Action: log.Operation,
|
||||
Description: log.Description,
|
||||
Timestamp: log.CreateTime,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取访问/登录日志
|
||||
if logType == "access" || logType == "all" {
|
||||
accessLogs, _, err := services.GetAccessLogs(tenantId, userId, "", "", nil, nil, pageNum, pageSize)
|
||||
if err == nil && accessLogs != nil {
|
||||
for _, log := range accessLogs {
|
||||
logs = append(logs, ActivityLog{
|
||||
Id: log.Id,
|
||||
Type: "access",
|
||||
Username: log.Username,
|
||||
Module: log.Module,
|
||||
Operation: log.RequestMethod,
|
||||
Action: "访问",
|
||||
Description: "访问 " + log.Module,
|
||||
Timestamp: log.CreateTime,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按时间戳排序(最新的在前)
|
||||
if len(logs) > pageSize {
|
||||
logs = logs[:pageSize]
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": map[string]interface{}{
|
||||
"logs": logs,
|
||||
"total": len(logs),
|
||||
},
|
||||
}
|
||||
c.ServeJSON()
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"server/models"
|
||||
"server/services"
|
||||
"strconv"
|
||||
@ -245,3 +246,193 @@ func (c *OperationLogController) ClearOldLogs() {
|
||||
}
|
||||
c.ServeJSON()
|
||||
}
|
||||
|
||||
// GetAccessLogs 获取访问日志列表
|
||||
func (c *OperationLogController) GetAccessLogs() {
|
||||
// 获取租户ID和用户ID
|
||||
tenantIdData := c.Ctx.Input.GetData("tenantId")
|
||||
tenantId := 0
|
||||
if tenantIdData != nil {
|
||||
if tid, ok := tenantIdData.(int); ok {
|
||||
tenantId = tid
|
||||
}
|
||||
}
|
||||
|
||||
userIdData := c.Ctx.Input.GetData("userId")
|
||||
userId := 0
|
||||
if userIdData != nil {
|
||||
if uid, ok := userIdData.(int); ok {
|
||||
userId = uid
|
||||
}
|
||||
}
|
||||
|
||||
// 获取查询参数
|
||||
pageNum, _ := c.GetInt("page_num", 1)
|
||||
pageSize, _ := c.GetInt("page_size", 20)
|
||||
module := c.GetString("module")
|
||||
resourceType := c.GetString("resource_type")
|
||||
startTimeStr := c.GetString("start_time")
|
||||
endTimeStr := c.GetString("end_time")
|
||||
|
||||
var startTime, endTime *time.Time
|
||||
|
||||
if startTimeStr != "" {
|
||||
if t, err := time.Parse("2006-01-02", startTimeStr); err == nil {
|
||||
startTime = &t
|
||||
}
|
||||
}
|
||||
|
||||
if endTimeStr != "" {
|
||||
if t, err := time.Parse("2006-01-02", endTimeStr); err == nil {
|
||||
endTime = &t
|
||||
}
|
||||
}
|
||||
|
||||
// 查询访问日志
|
||||
logs, total, err := services.GetAccessLogs(tenantId, userId, module, resourceType, startTime, endTime, pageNum, pageSize)
|
||||
if err != nil {
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "查询访问日志失败: " + err.Error(),
|
||||
}
|
||||
c.ServeJSON()
|
||||
return
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"logs": logs,
|
||||
"total": total,
|
||||
},
|
||||
}
|
||||
c.ServeJSON()
|
||||
}
|
||||
|
||||
// GetAccessLogById 根据ID获取访问日志详情
|
||||
func (c *OperationLogController) GetAccessLogById() {
|
||||
idStr := c.Ctx.Input.Param(":id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "无效的日志ID",
|
||||
}
|
||||
c.ServeJSON()
|
||||
return
|
||||
}
|
||||
|
||||
log, err := services.GetAccessLogById(id)
|
||||
if err != nil {
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "查询日志详情失败: " + err.Error(),
|
||||
}
|
||||
c.ServeJSON()
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
var user *models.User
|
||||
if log.UserId > 0 {
|
||||
user, _ = models.GetUserById(log.UserId)
|
||||
}
|
||||
|
||||
// 构造返回数据
|
||||
result := map[string]interface{}{
|
||||
"id": log.Id,
|
||||
"tenant_id": log.TenantId,
|
||||
"user_id": log.UserId,
|
||||
"username": log.Username,
|
||||
"module": log.Module,
|
||||
"resource_type": log.ResourceType,
|
||||
"resource_id": log.ResourceId,
|
||||
"request_url": log.RequestUrl,
|
||||
"query_string": log.QueryString,
|
||||
"ip_address": log.IpAddress,
|
||||
"user_agent": log.UserAgent,
|
||||
"request_method": log.RequestMethod,
|
||||
"duration": log.Duration,
|
||||
"create_time": log.CreateTime,
|
||||
}
|
||||
|
||||
// 如果有用户信息,添加到结果中
|
||||
if user != nil {
|
||||
result["user_nickname"] = user.Nickname
|
||||
result["user_email"] = user.Email
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"success": true,
|
||||
"data": result,
|
||||
}
|
||||
c.ServeJSON()
|
||||
}
|
||||
|
||||
// GetUserAccessStats 获取用户访问统计
|
||||
func (c *OperationLogController) GetUserAccessStats() {
|
||||
userIdData := c.Ctx.Input.GetData("userId")
|
||||
userId := 0
|
||||
if userIdData != nil {
|
||||
if uid, ok := userIdData.(int); ok {
|
||||
userId = uid
|
||||
}
|
||||
}
|
||||
|
||||
tenantIdData := c.Ctx.Input.GetData("tenantId")
|
||||
tenantId := 0
|
||||
if tenantIdData != nil {
|
||||
if tid, ok := tenantIdData.(int); ok {
|
||||
tenantId = tid
|
||||
}
|
||||
}
|
||||
|
||||
days, _ := c.GetInt("days", 7)
|
||||
|
||||
stats, err := services.GetUserAccessStats(tenantId, userId, days)
|
||||
if err != nil {
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "查询统计失败: " + err.Error(),
|
||||
}
|
||||
c.ServeJSON()
|
||||
return
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"success": true,
|
||||
"data": stats,
|
||||
}
|
||||
c.ServeJSON()
|
||||
}
|
||||
|
||||
// ClearOldAccessLogs 清空旧访问日志
|
||||
func (c *OperationLogController) ClearOldAccessLogs() {
|
||||
// 获取参数
|
||||
var params map[string]interface{}
|
||||
if err := c.ParseForm(¶ms); err != nil {
|
||||
// 如果ParseForm失败,尝试解析JSON
|
||||
c.Ctx.Input.Bind(¶ms, "json")
|
||||
}
|
||||
|
||||
keepDays := 90 // 默认保留90天
|
||||
if kd, ok := params["keep_days"].(float64); ok {
|
||||
keepDays = int(kd)
|
||||
}
|
||||
|
||||
rowsAffected, err := services.ClearOldAccessLogs(keepDays)
|
||||
if err != nil {
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "清空旧日志失败: " + err.Error(),
|
||||
}
|
||||
c.ServeJSON()
|
||||
return
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("清空旧日志成功,共删除 %d 条记录", rowsAffected),
|
||||
}
|
||||
c.ServeJSON()
|
||||
}
|
||||
|
||||
306
server/controllers/task.go
Normal file
306
server/controllers/task.go
Normal file
@ -0,0 +1,306 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"server/models"
|
||||
"server/services"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// TaskController OA任务管理控制器
|
||||
type TaskController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
// GetTasks 列表查询
|
||||
// @router /api/oa/tasks [get]
|
||||
func (c *TaskController) GetTasks() {
|
||||
// 获取租户ID(JWT中间件写入)
|
||||
tenantId := 0
|
||||
if v := c.Ctx.Input.GetData("tenantId"); v != nil {
|
||||
if tid, ok := v.(int); ok {
|
||||
tenantId = tid
|
||||
}
|
||||
}
|
||||
// 允许请求参数覆盖(如有)
|
||||
if reqTenantId, err := c.GetInt("tenant_id"); err == nil && reqTenantId > 0 {
|
||||
tenantId = reqTenantId
|
||||
}
|
||||
if tenantId <= 0 {
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 1,
|
||||
"message": "租户ID无效",
|
||||
"data": nil,
|
||||
}
|
||||
c.ServeJSON()
|
||||
return
|
||||
}
|
||||
|
||||
keyword := c.GetString("keyword")
|
||||
status := c.GetString("status")
|
||||
priority := c.GetString("priority")
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 10)
|
||||
|
||||
items, total, err := services.ListOATasks(tenantId, keyword, status, priority, page, pageSize)
|
||||
if err != nil {
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 1,
|
||||
"message": "获取任务列表失败: " + err.Error(),
|
||||
"data": nil,
|
||||
}
|
||||
c.ServeJSON()
|
||||
return
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 0,
|
||||
"message": "获取成功",
|
||||
"data": map[string]interface{}{
|
||||
"list": items,
|
||||
"total": total,
|
||||
},
|
||||
}
|
||||
c.ServeJSON()
|
||||
}
|
||||
|
||||
// GetTaskById 详情
|
||||
// @router /api/oa/tasks/:id [get]
|
||||
func (c *TaskController) GetTaskById() {
|
||||
id, err := c.GetInt(":id")
|
||||
if err != nil || id <= 0 {
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 1,
|
||||
"message": "任务ID无效",
|
||||
"data": nil,
|
||||
}
|
||||
c.ServeJSON()
|
||||
return
|
||||
}
|
||||
t, err := services.GetOATaskById(id)
|
||||
if err != nil {
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 1,
|
||||
"message": "获取任务详情失败: " + err.Error(),
|
||||
"data": nil,
|
||||
}
|
||||
c.ServeJSON()
|
||||
return
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 0,
|
||||
"message": "获取成功",
|
||||
"data": t,
|
||||
}
|
||||
c.ServeJSON()
|
||||
}
|
||||
|
||||
// CreateTask 新增
|
||||
// @router /api/oa/tasks [post]
|
||||
func (c *TaskController) CreateTask() {
|
||||
var t models.Task
|
||||
body := c.Ctx.Input.RequestBody
|
||||
|
||||
// 先解析 team_employee_ids
|
||||
var extra struct {
|
||||
TeamEmployeeIds []int64 `json:"team_employee_ids"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &extra)
|
||||
|
||||
// 去除 team_employee_ids 字段后再解析为 Task,避免 array->string 报错
|
||||
clean := map[string]interface{}{}
|
||||
if err := json.Unmarshal(body, &clean); err != nil {
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 1,
|
||||
"message": "参数解析失败: " + err.Error(),
|
||||
"data": nil,
|
||||
}
|
||||
c.ServeJSON()
|
||||
return
|
||||
}
|
||||
delete(clean, "team_employee_ids")
|
||||
buf, _ := json.Marshal(clean)
|
||||
if err := json.Unmarshal(buf, &t); err != nil {
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 1,
|
||||
"message": "参数解析失败: " + err.Error(),
|
||||
"data": nil,
|
||||
}
|
||||
c.ServeJSON()
|
||||
return
|
||||
}
|
||||
|
||||
if len(extra.TeamEmployeeIds) > 0 {
|
||||
ids := make([]string, 0, len(extra.TeamEmployeeIds))
|
||||
for _, id := range extra.TeamEmployeeIds {
|
||||
ids = append(ids, strconv.FormatInt(id, 10))
|
||||
}
|
||||
t.TeamEmployeeIds = strings.Join(ids, ",")
|
||||
}
|
||||
|
||||
// 从上下文取租户和用户名交给服务层处理默认值
|
||||
tenantId := 0
|
||||
if v := c.Ctx.Input.GetData("tenantId"); v != nil {
|
||||
if tid, ok := v.(int); ok {
|
||||
tenantId = tid
|
||||
}
|
||||
}
|
||||
username := ""
|
||||
if u, ok := c.Ctx.Input.GetData("username").(string); ok {
|
||||
username = u
|
||||
}
|
||||
|
||||
if err := services.CreateOATask(&t, tenantId, username); err != nil {
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 1,
|
||||
"message": "创建任务失败: " + err.Error(),
|
||||
"data": nil,
|
||||
}
|
||||
c.ServeJSON()
|
||||
return
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 0,
|
||||
"message": "创建成功",
|
||||
"data": map[string]interface{}{
|
||||
"id": t.Id,
|
||||
},
|
||||
}
|
||||
c.ServeJSON()
|
||||
}
|
||||
|
||||
// UpdateTask 更新
|
||||
// @router /api/oa/tasks/:id [put]
|
||||
func (c *TaskController) UpdateTask() {
|
||||
id, err := c.GetInt(":id")
|
||||
if err != nil || id <= 0 {
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 1,
|
||||
"message": "任务ID无效",
|
||||
"data": nil,
|
||||
}
|
||||
c.ServeJSON()
|
||||
return
|
||||
}
|
||||
|
||||
// 保证存在
|
||||
if _, err := services.GetOATaskById(id); err != nil {
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 1,
|
||||
"message": "任务不存在: " + err.Error(),
|
||||
"data": nil,
|
||||
}
|
||||
c.ServeJSON()
|
||||
return
|
||||
}
|
||||
|
||||
var t models.Task
|
||||
body := c.Ctx.Input.RequestBody
|
||||
|
||||
// 先解析 team_employee_ids
|
||||
var extra struct {
|
||||
TeamEmployeeIds []int64 `json:"team_employee_ids"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &extra)
|
||||
|
||||
// 去除 team_employee_ids 字段后再解析为 Task,避免 array->string 报错
|
||||
clean := map[string]interface{}{}
|
||||
if err := json.Unmarshal(body, &clean); err != nil {
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 1,
|
||||
"message": "参数解析失败: " + err.Error(),
|
||||
"data": nil,
|
||||
}
|
||||
c.ServeJSON()
|
||||
return
|
||||
}
|
||||
delete(clean, "team_employee_ids")
|
||||
buf, _ := json.Marshal(clean)
|
||||
if err := json.Unmarshal(buf, &t); err != nil {
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 1,
|
||||
"message": "参数解析失败: " + err.Error(),
|
||||
"data": nil,
|
||||
}
|
||||
c.ServeJSON()
|
||||
return
|
||||
}
|
||||
t.Id = id
|
||||
|
||||
ids := make([]string, 0, len(extra.TeamEmployeeIds))
|
||||
for _, eid := range extra.TeamEmployeeIds {
|
||||
ids = append(ids, strconv.FormatInt(eid, 10))
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
t.TeamEmployeeIds = strings.Join(ids, ",")
|
||||
} else {
|
||||
// 明确传空时,清空关联人
|
||||
t.TeamEmployeeIds = ""
|
||||
}
|
||||
|
||||
// 操作人交由服务层设置
|
||||
username := ""
|
||||
if u, ok := c.Ctx.Input.GetData("username").(string); ok {
|
||||
username = u
|
||||
}
|
||||
|
||||
if err := services.UpdateOATask(&t, username); err != nil {
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 1,
|
||||
"message": "更新任务失败: " + err.Error(),
|
||||
"data": nil,
|
||||
}
|
||||
c.ServeJSON()
|
||||
return
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 0,
|
||||
"message": "更新成功",
|
||||
"data": nil,
|
||||
}
|
||||
c.ServeJSON()
|
||||
}
|
||||
|
||||
// DeleteTask 删除(软删除)
|
||||
// @router /api/oa/tasks/:id [delete]
|
||||
func (c *TaskController) DeleteTask() {
|
||||
id, err := c.GetInt(":id")
|
||||
if err != nil || id <= 0 {
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 1,
|
||||
"message": "任务ID无效",
|
||||
"data": nil,
|
||||
}
|
||||
c.ServeJSON()
|
||||
return
|
||||
}
|
||||
|
||||
operatorName := "system"
|
||||
operatorId := 0
|
||||
if username, ok := c.Ctx.Input.GetData("username").(string); ok && username != "" {
|
||||
operatorName = username
|
||||
}
|
||||
if uid, ok := c.Ctx.Input.GetData("userId").(int); ok && uid > 0 {
|
||||
operatorId = uid
|
||||
}
|
||||
|
||||
if err := services.DeleteOATask(id, operatorName, operatorId); err != nil {
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 1,
|
||||
"message": "删除任务失败: " + err.Error(),
|
||||
"data": nil,
|
||||
}
|
||||
c.ServeJSON()
|
||||
return
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 0,
|
||||
"message": "删除成功",
|
||||
"data": nil,
|
||||
}
|
||||
c.ServeJSON()
|
||||
}
|
||||
75
server/database/yz_tenant_tasks.sql
Normal file
75
server/database/yz_tenant_tasks.sql
Normal file
@ -0,0 +1,75 @@
|
||||
CREATE TABLE `yz_tenant_tasks` (
|
||||
-- 主键与基础标识
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '任务ID(主键)',
|
||||
`tenant_id` bigint NOT NULL COMMENT '租户ID(多租户隔离,如无多租户需求可设为默认1)',
|
||||
`task_no` varchar(64) NOT NULL COMMENT '任务编号(唯一标识,如TASK20251112001)',
|
||||
|
||||
-- 任务核心信息
|
||||
`task_name` varchar(255) NOT NULL COMMENT '任务名称',
|
||||
`task_desc` text COMMENT '任务描述(富文本内容,支持图片/表格)',
|
||||
`task_type` varchar(32) DEFAULT 'common' COMMENT '任务类型(common=普通任务,project=项目任务,repeat=重复任务,可自定义)',
|
||||
`business_tag` varchar(64) COMMENT '业务标签(多个标签用逗号分隔,如"紧急,日常协作")',
|
||||
|
||||
-- 关联信息
|
||||
`parent_task_id` bigint DEFAULT NULL COMMENT '父任务ID(子任务关联用,无父任务则为NULL)',
|
||||
`project_id` bigint DEFAULT NULL COMMENT '关联项目ID(关联OA项目模块)',
|
||||
`related_id` bigint DEFAULT NULL COMMENT '关联其他模块ID(如审批单ID、客户ID)',
|
||||
`related_type` varchar(32) COMMENT '关联模块类型(approval=审批单,customer=客户,为空则无关联)',
|
||||
|
||||
-- 人员配置
|
||||
`creator_id` bigint NOT NULL COMMENT '创建人ID',
|
||||
`creator_name` varchar(64) NOT NULL COMMENT '创建人姓名',
|
||||
`principal_id` bigint NOT NULL COMMENT '负责人ID',
|
||||
`principal_name` varchar(64) NOT NULL COMMENT '负责人姓名',
|
||||
`participant_ids` varchar(512) COMMENT '参与人ID(多个用逗号分隔)',
|
||||
`participant_names` varchar(512) COMMENT '参与人姓名(多个用逗号分隔)',
|
||||
`cc_ids` varchar(512) COMMENT '抄送人ID(多个用逗号分隔)',
|
||||
`cc_names` varchar(512) COMMENT '抄送人姓名(多个用逗号分隔)',
|
||||
|
||||
-- 时间配置
|
||||
`plan_start_time` datetime DEFAULT NULL COMMENT '计划开始时间',
|
||||
`plan_end_time` datetime NOT NULL COMMENT '计划截止时间',
|
||||
`actual_start_time` datetime DEFAULT NULL COMMENT '实际开始时间',
|
||||
`actual_end_time` datetime DEFAULT NULL COMMENT '实际结束时间',
|
||||
`estimated_hours` decimal(10,2) DEFAULT NULL COMMENT '预估工时(小时)',
|
||||
`actual_hours` decimal(10,2) DEFAULT NULL COMMENT '实际工时(小时)',
|
||||
|
||||
-- 状态与优先级
|
||||
`task_status` varchar(32) NOT NULL DEFAULT 'not_started' COMMENT '任务状态(not_started=未开始,in_progress=进行中,paused=暂停,completed=已完成,closed=已关闭,可自定义)',
|
||||
`priority` varchar(16) NOT NULL DEFAULT 'medium' COMMENT '优先级(high=高,medium=中,low=低,urgent=紧急)',
|
||||
`progress` tinyint NOT NULL DEFAULT 0 COMMENT '任务进度(0-100,子任务存在时自动计算)',
|
||||
|
||||
-- 规则与审批配置
|
||||
`need_approval` tinyint NOT NULL DEFAULT 0 COMMENT '是否需要完成审批(0=否,1=是)',
|
||||
`approval_id` bigint DEFAULT NULL COMMENT '关联审批单ID(完成审批时填写)',
|
||||
`delay_approved` tinyint NOT NULL DEFAULT 0 COMMENT '是否已延期审批(0=否,1=是)',
|
||||
`old_plan_end_time` datetime COMMENT '原计划截止时间(延期时记录)',
|
||||
|
||||
-- 重复任务配置
|
||||
`repeat_type` varchar(16) DEFAULT NULL COMMENT '重复类型(daily=按日,weekly=按周,monthly=按月,为空则非重复任务)',
|
||||
`repeat_cycle` int DEFAULT NULL COMMENT '重复周期(如每周重复则为7,每月重复则为30)',
|
||||
`repeat_end_time` datetime DEFAULT NULL COMMENT '重复截止时间(重复任务终止时间)',
|
||||
|
||||
-- 辅助字段
|
||||
`attachment_ids` varchar(1024) COMMENT '附件ID(关联文件表,多个用逗号分隔)',
|
||||
`remark` varchar(512) COMMENT '备注(额外说明)',
|
||||
`is_archived` tinyint NOT NULL DEFAULT 0 COMMENT '是否归档(0=未归档,1=已归档)',
|
||||
`archive_time` datetime COMMENT '归档时间',
|
||||
|
||||
-- 审计字段
|
||||
`created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` tinyint NOT NULL DEFAULT 0 COMMENT '逻辑删除(0=正常,1=删除)',
|
||||
`deleted_time` datetime COMMENT '删除时间',
|
||||
`operator_id` bigint COMMENT '最后操作人ID',
|
||||
`operator_name` varchar(64) COMMENT '最后操作人姓名',
|
||||
|
||||
-- 索引
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_tenant_task_no` (`tenant_id`,`task_no`) COMMENT '租户+任务编号唯一索引',
|
||||
KEY `idx_tenant_principal` (`tenant_id`,`principal_id`) COMMENT '租户+负责人索引(查询个人任务)',
|
||||
KEY `idx_tenant_status` (`tenant_id`,`task_status`) COMMENT '租户+状态索引(筛选任务状态)',
|
||||
KEY `idx_tenant_project` (`tenant_id`,`project_id`) COMMENT '租户+项目索引(查询项目下任务)',
|
||||
KEY `idx_tenant_plan_end_time` (`tenant_id`,`plan_end_time`) COMMENT '租户+截止时间索引(逾期提醒、日历视图)',
|
||||
KEY `idx_parent_task_id` (`parent_task_id`) COMMENT '父任务ID索引(查询子任务)'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA系统任务表(多租户适配)';
|
||||
166
server/mcp-server/client.py
Normal file
166
server/mcp-server/client.py
Normal file
@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MySQL MCP Server 交互式客户端示例
|
||||
可用于测试和与 MCP 服务器交互
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import subprocess
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
def pretty_print_json(obj):
|
||||
"""美化打印 JSON 对象"""
|
||||
print(json.dumps(obj, indent=2, ensure_ascii=False))
|
||||
|
||||
def run_interactive_client():
|
||||
"""运行交互式客户端"""
|
||||
|
||||
script_dir = Path(__file__).parent
|
||||
binary_path = script_dir / "mcp-server.exe"
|
||||
|
||||
if not binary_path.exists():
|
||||
print(f"❌ Error: Binary not found at {binary_path}")
|
||||
print("Please build the project first:")
|
||||
print(" cd e:\\Demos\\DemoOwns\\Go\\yunzer_go\\server\\mcp-server")
|
||||
print(" go build -o mcp-server.exe main.go")
|
||||
return
|
||||
|
||||
print("🚀 Starting MySQL MCP Server...")
|
||||
|
||||
# 启动进程
|
||||
process = subprocess.Popen(
|
||||
[str(binary_path)],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
print("✅ Server started. Type 'help' for commands.\n")
|
||||
|
||||
request_id = 0
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
user_input = input(">>> ").strip()
|
||||
|
||||
if not user_input:
|
||||
continue
|
||||
|
||||
if user_input.lower() == "help":
|
||||
print("""
|
||||
Available commands:
|
||||
init - Initialize server
|
||||
tables - List all tables
|
||||
schema <table> - Get table schema
|
||||
query <sql> - Execute SELECT query
|
||||
exec <sql> - Execute INSERT/UPDATE/DELETE
|
||||
json <json_string> - Send raw JSON-RPC request
|
||||
help - Show this help
|
||||
exit / quit - Exit the client
|
||||
|
||||
Examples:
|
||||
> tables
|
||||
> schema users
|
||||
> query SELECT * FROM users LIMIT 5
|
||||
> exec INSERT INTO users (name, email) VALUES ('John', 'john@example.com')
|
||||
> json {"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"SELECT COUNT(*) as count FROM users"}}
|
||||
""")
|
||||
continue
|
||||
|
||||
if user_input.lower() in ["exit", "quit"]:
|
||||
break
|
||||
|
||||
request_id += 1
|
||||
|
||||
# 解析命令
|
||||
if user_input.lower() == "init":
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"method": "initialize",
|
||||
"params": {}
|
||||
}
|
||||
|
||||
elif user_input.lower() == "tables":
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"method": "get_tables",
|
||||
"params": {}
|
||||
}
|
||||
|
||||
elif user_input.lower().startswith("schema "):
|
||||
table = user_input[7:].strip()
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"method": "get_table_schema",
|
||||
"params": {"table": table}
|
||||
}
|
||||
|
||||
elif user_input.lower().startswith("query "):
|
||||
sql = user_input[6:].strip()
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"method": "query",
|
||||
"params": {"sql": sql, "args": []}
|
||||
}
|
||||
|
||||
elif user_input.lower().startswith("exec "):
|
||||
sql = user_input[5:].strip()
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"method": "execute",
|
||||
"params": {"sql": sql, "args": []}
|
||||
}
|
||||
|
||||
elif user_input.lower().startswith("json "):
|
||||
json_str = user_input[5:].strip()
|
||||
try:
|
||||
request = json.loads(json_str)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"❌ Invalid JSON: {e}")
|
||||
continue
|
||||
|
||||
else:
|
||||
print("❌ Unknown command. Type 'help' for available commands.")
|
||||
continue
|
||||
|
||||
# 发送请求
|
||||
request_json = json.dumps(request)
|
||||
process.stdin.write(request_json + "\n")
|
||||
process.stdin.flush()
|
||||
|
||||
# 读取响应
|
||||
response_str = process.stdout.readline()
|
||||
if response_str:
|
||||
try:
|
||||
response = json.loads(response_str)
|
||||
print("\n✅ Response:")
|
||||
pretty_print_json(response)
|
||||
print()
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"❌ Failed to parse response: {e}")
|
||||
print(f"Raw response: {response_str}")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n^C Exiting...")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
|
||||
finally:
|
||||
print("\n🛑 Stopping server...")
|
||||
process.terminate()
|
||||
process.wait()
|
||||
print("✓ Server stopped")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_interactive_client()
|
||||
20
server/mcp-server/config.json
Normal file
20
server/mcp-server/config.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"mysql": {
|
||||
"user": "gotest",
|
||||
"password": "2nZhRdMPCNZrdzsd",
|
||||
"host": "212.64.112.158",
|
||||
"port": 3388,
|
||||
"database": "gotest",
|
||||
"charset": "utf8mb4",
|
||||
"timeout": "10s",
|
||||
"readTimeout": "30s",
|
||||
"writeTimeout": "30s",
|
||||
"maxIdleConns": 10,
|
||||
"maxOpenConns": 100,
|
||||
"connMaxLifetime": "30m"
|
||||
},
|
||||
"server": {
|
||||
"logLevel": "info",
|
||||
"enableQueryLogging": false
|
||||
}
|
||||
}
|
||||
5
server/mcp-server/go.mod
Normal file
5
server/mcp-server/go.mod
Normal file
@ -0,0 +1,5 @@
|
||||
module mcp-server
|
||||
|
||||
go 1.17
|
||||
|
||||
require github.com/go-sql-driver/mysql v1.7.0
|
||||
2
server/mcp-server/go.sum
Normal file
2
server/mcp-server/go.sum
Normal file
@ -0,0 +1,2 @@
|
||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
387
server/mcp-server/main.go
Normal file
387
server/mcp-server/main.go
Normal file
@ -0,0 +1,387 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// MCPRequest 表示 MCP 请求
|
||||
type MCPRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID interface{} `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
|
||||
// MCPResponse 表示 MCP 响应
|
||||
type MCPResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID interface{} `json:"id"`
|
||||
Result interface{} `json:"result,omitempty"`
|
||||
Error *MCPError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// MCPError 表示 MCP 错误
|
||||
type MCPError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data string `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// QueryParams 表示查询参数
|
||||
type QueryParams struct {
|
||||
SQL string `json:"sql"`
|
||||
Args []interface{} `json:"args"`
|
||||
}
|
||||
|
||||
// ExecuteParams 表示执行参数
|
||||
type ExecuteParams struct {
|
||||
SQL string `json:"sql"`
|
||||
Args []interface{} `json:"args"`
|
||||
}
|
||||
|
||||
var db *sql.DB
|
||||
|
||||
func main() {
|
||||
// 初始化数据库
|
||||
if err := initDatabase(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to initialize database: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// 启动 MCP 服务器
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析请求
|
||||
var req MCPRequest
|
||||
if err := json.Unmarshal([]byte(line), &req); err != nil {
|
||||
sendError(nil, -32700, "Parse error", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理请求
|
||||
handleRequest(&req)
|
||||
}
|
||||
}
|
||||
|
||||
func initDatabase() error {
|
||||
// 从环境变量或默认值读取配置
|
||||
user := getEnv("MYSQL_USER", "gotest")
|
||||
pass := getEnv("MYSQL_PASS", "2nZhRdMPCNZrdzsd")
|
||||
urls := getEnv("MYSQL_URLS", "212.64.112.158:3388")
|
||||
dbName := getEnv("MYSQL_DB", "gotest")
|
||||
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=10s&readTimeout=30s&writeTimeout=30s",
|
||||
user, pass, urls, dbName)
|
||||
|
||||
var err error
|
||||
db, err = sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
if err := db.Ping(); err != nil {
|
||||
return fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
// 配置连接池
|
||||
db.SetMaxIdleConns(10)
|
||||
db.SetMaxOpenConns(100)
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Database connected successfully\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleRequest(req *MCPRequest) {
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
handleInitialize(req)
|
||||
case "query":
|
||||
handleQuery(req)
|
||||
case "execute":
|
||||
handleExecute(req)
|
||||
case "get_tables":
|
||||
handleGetTables(req)
|
||||
case "get_table_schema":
|
||||
handleGetTableSchema(req)
|
||||
default:
|
||||
sendError(req.ID, -32601, "Method not found", fmt.Sprintf("Unknown method: %s", req.Method))
|
||||
}
|
||||
}
|
||||
|
||||
func handleInitialize(req *MCPRequest) {
|
||||
result := map[string]interface{}{
|
||||
"protocolVersion": "1.0",
|
||||
"capabilities": map[string]interface{}{
|
||||
"tools": []map[string]interface{}{
|
||||
{
|
||||
"name": "query",
|
||||
"description": "Execute a SELECT query and return results",
|
||||
"inputSchema": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"sql": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "SQL SELECT query",
|
||||
},
|
||||
"args": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "Query parameters (optional)",
|
||||
},
|
||||
},
|
||||
"required": []string{"sql"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "execute",
|
||||
"description": "Execute an INSERT, UPDATE, or DELETE query",
|
||||
"inputSchema": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"sql": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "SQL INSERT/UPDATE/DELETE query",
|
||||
},
|
||||
"args": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "Query parameters (optional)",
|
||||
},
|
||||
},
|
||||
"required": []string{"sql"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_tables",
|
||||
"description": "Get all table names in the database",
|
||||
"inputSchema": map[string]interface{}{
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_table_schema",
|
||||
"description": "Get the schema of a specific table",
|
||||
"inputSchema": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"table": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Table name",
|
||||
},
|
||||
},
|
||||
"required": []string{"table"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"serverInfo": map[string]interface{}{
|
||||
"name": "MySQL MCP Server",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
}
|
||||
sendResponse(req.ID, result)
|
||||
}
|
||||
|
||||
func handleQuery(req *MCPRequest) {
|
||||
var params QueryParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
sendError(req.ID, -32602, "Invalid params", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if params.SQL == "" {
|
||||
sendError(req.ID, -32602, "Invalid params", "SQL query is required")
|
||||
return
|
||||
}
|
||||
|
||||
// 确保是 SELECT 查询
|
||||
if !strings.HasPrefix(strings.ToUpper(strings.TrimSpace(params.SQL)), "SELECT") {
|
||||
sendError(req.ID, -32602, "Invalid query", "Only SELECT queries are allowed")
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.Query(params.SQL, params.Args...)
|
||||
if err != nil {
|
||||
sendError(req.ID, -32603, "Database error", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// 获取列名
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
sendError(req.ID, -32603, "Database error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 读取数据
|
||||
var results []map[string]interface{}
|
||||
for rows.Next() {
|
||||
values := make([]interface{}, len(columns))
|
||||
valuePtrs := make([]interface{}, len(columns))
|
||||
for i := range columns {
|
||||
valuePtrs[i] = &values[i]
|
||||
}
|
||||
|
||||
if err := rows.Scan(valuePtrs...); err != nil {
|
||||
sendError(req.ID, -32603, "Database error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
entry := make(map[string]interface{})
|
||||
for i, col := range columns {
|
||||
val := values[i]
|
||||
b, ok := val.([]byte)
|
||||
if ok {
|
||||
entry[col] = string(b)
|
||||
} else {
|
||||
entry[col] = val
|
||||
}
|
||||
}
|
||||
results = append(results, entry)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
sendError(req.ID, -32603, "Database error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
sendResponse(req.ID, map[string]interface{}{
|
||||
"rows": results,
|
||||
"count": len(results),
|
||||
})
|
||||
}
|
||||
|
||||
func handleExecute(req *MCPRequest) {
|
||||
var params ExecuteParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
sendError(req.ID, -32602, "Invalid params", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if params.SQL == "" {
|
||||
sendError(req.ID, -32602, "Invalid params", "SQL query is required")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := db.Exec(params.SQL, params.Args...)
|
||||
if err != nil {
|
||||
sendError(req.ID, -32603, "Database error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
lastID, _ := result.LastInsertId()
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
|
||||
sendResponse(req.ID, map[string]interface{}{
|
||||
"lastInsertId": lastID,
|
||||
"rowsAffected": rowsAffected,
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetTables(req *MCPRequest) {
|
||||
rows, err := db.Query("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE()")
|
||||
if err != nil {
|
||||
sendError(req.ID, -32603, "Database error", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tables []string
|
||||
for rows.Next() {
|
||||
var tableName string
|
||||
if err := rows.Scan(&tableName); err != nil {
|
||||
sendError(req.ID, -32603, "Database error", err.Error())
|
||||
return
|
||||
}
|
||||
tables = append(tables, tableName)
|
||||
}
|
||||
|
||||
sendResponse(req.ID, map[string]interface{}{
|
||||
"tables": tables,
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetTableSchema(req *MCPRequest) {
|
||||
var params struct {
|
||||
Table string `json:"table"`
|
||||
}
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
sendError(req.ID, -32602, "Invalid params", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.Query(fmt.Sprintf("DESCRIBE %s", params.Table))
|
||||
if err != nil {
|
||||
sendError(req.ID, -32603, "Database error", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var schema []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var field, typeStr, null, key, defaultVal, extra string
|
||||
if err := rows.Scan(&field, &typeStr, &null, &key, &defaultVal, &extra); err != nil {
|
||||
sendError(req.ID, -32603, "Database error", err.Error())
|
||||
return
|
||||
}
|
||||
schema = append(schema, map[string]interface{}{
|
||||
"field": field,
|
||||
"type": typeStr,
|
||||
"null": null,
|
||||
"key": key,
|
||||
"default": defaultVal,
|
||||
"extra": extra,
|
||||
})
|
||||
}
|
||||
|
||||
sendResponse(req.ID, schema)
|
||||
}
|
||||
|
||||
func sendResponse(id interface{}, result interface{}) {
|
||||
response := MCPResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Result: result,
|
||||
}
|
||||
data, _ := json.Marshal(response)
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
|
||||
func sendError(id interface{}, code int, message string, data string) {
|
||||
response := MCPResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Error: &MCPError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Data: data,
|
||||
},
|
||||
}
|
||||
jsonData, _ := json.Marshal(response)
|
||||
fmt.Println(string(jsonData))
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
BIN
server/mcp-server/mcp-server.exe
Normal file
BIN
server/mcp-server/mcp-server.exe
Normal file
Binary file not shown.
23
server/mcp-server/start.bat
Normal file
23
server/mcp-server/start.bat
Normal file
@ -0,0 +1,23 @@
|
||||
@echo off
|
||||
REM MySQL MCP Server 启动脚本
|
||||
|
||||
REM 设置环境变量(可选,从 app.conf 读取)
|
||||
set MYSQL_USER=gotest
|
||||
set MYSQL_PASS=2nZhRdMPCNZrdzsd
|
||||
set MYSQL_URLS=212.64.112.158:3388
|
||||
set MYSQL_DB=gotest
|
||||
|
||||
REM 编译
|
||||
echo Building MCP Server...
|
||||
go build -o mcp-server.exe main.go
|
||||
|
||||
if errorlevel 1 (
|
||||
echo Build failed!
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Build successful! Starting MCP Server...
|
||||
echo.
|
||||
|
||||
REM 启动服务器
|
||||
mcp-server.exe
|
||||
24
server/mcp-server/start.sh
Normal file
24
server/mcp-server/start.sh
Normal file
@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
|
||||
# MySQL MCP Server 启动脚本
|
||||
|
||||
# 设置环境变量(可选)
|
||||
export MYSQL_USER=gotest
|
||||
export MYSQL_PASS=2nZhRdMPCNZrdzsd
|
||||
export MYSQL_URLS=212.64.112.158:3388
|
||||
export MYSQL_DB=gotest
|
||||
|
||||
# 编译
|
||||
echo "Building MCP Server..."
|
||||
go build -o mcp-server main.go
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Build failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Build successful! Starting MCP Server..."
|
||||
echo ""
|
||||
|
||||
# 启动服务器
|
||||
./mcp-server
|
||||
174
server/mcp-server/test.py
Normal file
174
server/mcp-server/test.py
Normal file
@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MySQL MCP Server 测试脚本
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import json
|
||||
import time
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
class MCPClient:
|
||||
"""MCP 客户端"""
|
||||
|
||||
def __init__(self, binary_path):
|
||||
"""初始化客户端"""
|
||||
self.binary_path = binary_path
|
||||
self.process = None
|
||||
self.request_id = 0
|
||||
|
||||
def start(self):
|
||||
"""启动 MCP 服务器"""
|
||||
print("Starting MCP Server...")
|
||||
self.process = subprocess.Popen(
|
||||
[self.binary_path],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
time.sleep(1) # 等待服务器启动
|
||||
print("✓ MCP Server started")
|
||||
|
||||
def stop(self):
|
||||
"""停止服务器"""
|
||||
if self.process:
|
||||
self.process.terminate()
|
||||
self.process.wait()
|
||||
print("✓ MCP Server stopped")
|
||||
|
||||
def send_request(self, method, params=None):
|
||||
"""发送请求"""
|
||||
self.request_id += 1
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": self.request_id,
|
||||
"method": method,
|
||||
"params": params or {}
|
||||
}
|
||||
|
||||
json_str = json.dumps(request)
|
||||
print(f"\n→ Sending: {method}")
|
||||
print(f" Request: {json_str}")
|
||||
|
||||
self.process.stdin.write(json_str + "\n")
|
||||
self.process.stdin.flush()
|
||||
|
||||
# 读取响应
|
||||
response_str = self.process.stdout.readline()
|
||||
print(f" Response: {response_str.strip()}")
|
||||
|
||||
try:
|
||||
response = json.loads(response_str)
|
||||
return response
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" Error parsing response: {e}")
|
||||
return None
|
||||
|
||||
def test_mcp_server():
|
||||
"""测试 MCP 服务器"""
|
||||
|
||||
# 获取二进制文件路径
|
||||
script_dir = Path(__file__).parent
|
||||
binary_path = script_dir / "mcp-server.exe"
|
||||
|
||||
if not binary_path.exists():
|
||||
print(f"Error: Binary not found at {binary_path}")
|
||||
print("Please build the project first: go build -o mcp-server.exe main.go")
|
||||
return False
|
||||
|
||||
client = MCPClient(str(binary_path))
|
||||
|
||||
try:
|
||||
# 启动服务器
|
||||
client.start()
|
||||
|
||||
# 测试 1: 初始化
|
||||
print("\n" + "="*50)
|
||||
print("Test 1: Initialize")
|
||||
print("="*50)
|
||||
response = client.send_request("initialize")
|
||||
if response and "result" in response:
|
||||
print("✓ Initialize successful")
|
||||
else:
|
||||
print("✗ Initialize failed")
|
||||
return False
|
||||
|
||||
# 测试 2: 获取表列表
|
||||
print("\n" + "="*50)
|
||||
print("Test 2: Get Tables")
|
||||
print("="*50)
|
||||
response = client.send_request("get_tables")
|
||||
if response and "result" in response:
|
||||
tables = response["result"].get("tables", [])
|
||||
print(f"✓ Get tables successful, found {len(tables)} tables")
|
||||
if tables:
|
||||
print(f" Tables: {', '.join(tables[:5])}")
|
||||
else:
|
||||
print("✗ Get tables failed")
|
||||
return False
|
||||
|
||||
# 测试 3: 查询数据
|
||||
print("\n" + "="*50)
|
||||
print("Test 3: Query Data")
|
||||
print("="*50)
|
||||
response = client.send_request("query", {
|
||||
"sql": "SELECT * FROM users LIMIT 5",
|
||||
"args": []
|
||||
})
|
||||
if response and "result" in response:
|
||||
count = response["result"].get("count", 0)
|
||||
print(f"✓ Query successful, returned {count} rows")
|
||||
if count > 0:
|
||||
print(f" Sample row: {response['result']['rows'][0]}")
|
||||
else:
|
||||
print("✗ Query failed")
|
||||
if "error" in response:
|
||||
print(f" Error: {response['error']['message']}")
|
||||
|
||||
# 测试 4: 获取表结构
|
||||
print("\n" + "="*50)
|
||||
print("Test 4: Get Table Schema")
|
||||
print("="*50)
|
||||
response = client.send_request("get_table_schema", {
|
||||
"table": "users"
|
||||
})
|
||||
if response and "result" in response:
|
||||
schema = response["result"]
|
||||
print(f"✓ Get schema successful, found {len(schema)} columns")
|
||||
for col in schema[:3]:
|
||||
print(f" - {col['field']}: {col['type']}")
|
||||
else:
|
||||
print("✗ Get schema failed")
|
||||
|
||||
# 测试 5: 参数化查询
|
||||
print("\n" + "="*50)
|
||||
print("Test 5: Parameterized Query")
|
||||
print("="*50)
|
||||
response = client.send_request("query", {
|
||||
"sql": "SELECT * FROM users WHERE id = ?",
|
||||
"args": [1]
|
||||
})
|
||||
if response and "result" in response:
|
||||
count = response["result"].get("count", 0)
|
||||
print(f"✓ Parameterized query successful, returned {count} rows")
|
||||
else:
|
||||
print("✗ Parameterized query failed")
|
||||
|
||||
print("\n" + "="*50)
|
||||
print("All tests completed!")
|
||||
print("="*50)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return False
|
||||
finally:
|
||||
client.stop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = test_mcp_server()
|
||||
sys.exit(0 if success else 1)
|
||||
16
server/mcp-server/vscode-config-example.json
Normal file
16
server/mcp-server/vscode-config-example.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"comment": "VS Code MCP 配置示例 - 将此配置添加到你的 VS Code settings.json",
|
||||
"modelContextProtocol": {
|
||||
"servers": {
|
||||
"mysql": {
|
||||
"command": "e:\\Demos\\DemoOwns\\Go\\yunzer_go\\server\\mcp-server\\mcp-server.exe",
|
||||
"env": {
|
||||
"MYSQL_USER": "gotest",
|
||||
"MYSQL_PASS": "2nZhRdMPCNZrdzsd",
|
||||
"MYSQL_URLS": "212.64.112.158:3388",
|
||||
"MYSQL_DB": "gotest"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"server/models"
|
||||
@ -12,15 +13,22 @@ import (
|
||||
"github.com/beego/beego/v2/server/web/context"
|
||||
)
|
||||
|
||||
// OperationLogMiddleware 操作日志中间件 - 记录所有的CREATE、UPDATE、DELETE操作
|
||||
// OperationLogMiddleware 操作日志中间件 - 记录所有接口的调用记录
|
||||
func OperationLogMiddleware(ctx *context.Context) {
|
||||
// 记录所有重要操作,包括修改类(POST/PUT/PATCH/DELETE)和读取类(GET)用于统计账户访问功能
|
||||
// 跳过静态资源和内部路由
|
||||
url := ctx.Input.URL()
|
||||
if shouldSkipLogging(url) {
|
||||
return
|
||||
}
|
||||
|
||||
method := ctx.Input.Method()
|
||||
|
||||
// 获取用户信息和租户信息(由 JWT 中间件设置在 Input.Data 中)
|
||||
userId := 0
|
||||
tenantId := 0
|
||||
username := ""
|
||||
userType := "" // 用户类型:user(平台用户) 或 employee(租户员工)
|
||||
|
||||
if v := ctx.Input.GetData("userId"); v != nil {
|
||||
if id, ok := v.(int); ok {
|
||||
userId = id
|
||||
@ -36,112 +44,90 @@ func OperationLogMiddleware(ctx *context.Context) {
|
||||
username = s
|
||||
}
|
||||
}
|
||||
if v := ctx.Input.GetData("userType"); v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
userType = s
|
||||
}
|
||||
}
|
||||
|
||||
// 如果无法获取用户ID,继续记录为匿名访问(userId=0),以便统计未登录或授权失败的访问
|
||||
if userId == 0 {
|
||||
// debug: 输出一些上下文信息,帮助定位为何未能获取 userId
|
||||
fmt.Printf("OperationLogMiddleware: anonymous request %s %s, Authorization header length=%d\n", method, ctx.Input.URL(), len(ctx.Input.Header("Authorization")))
|
||||
// 确保 username 有值,便于区分
|
||||
// 用户信息补全
|
||||
if username == "" {
|
||||
username = "anonymous"
|
||||
}
|
||||
}
|
||||
|
||||
// 读取请求体(对于有请求体的方法)
|
||||
var requestBody string
|
||||
if method == "POST" || method == "PUT" || method == "PATCH" {
|
||||
body, err := io.ReadAll(ctx.Request.Body)
|
||||
if err == nil {
|
||||
if err == nil && len(body) > 0 {
|
||||
requestBody = string(body)
|
||||
// 重置请求体,使其可以被后续处理
|
||||
ctx.Request.Body = io.NopCloser(strings.NewReader(requestBody))
|
||||
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
}
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
ipAddress := ctx.Input.IP()
|
||||
userAgent := ctx.Input.Header("User-Agent")
|
||||
queryString := ctx.Request.URL.RawQuery
|
||||
|
||||
// 使用延迟函数来记录操作
|
||||
defer func() {
|
||||
duration := time.Since(startTime)
|
||||
|
||||
// 解析操作类型
|
||||
operation := parseOperationType(method, ctx.Input.URL())
|
||||
resourceType := parseResourceType(ctx.Input.URL())
|
||||
resourceId := parseResourceId(ctx.Input.URL())
|
||||
module := parseModule(ctx.Input.URL())
|
||||
// 解析操作相关信息
|
||||
operation := parseOperationType(method, url)
|
||||
module := parseModule(url)
|
||||
resourceType := parseResourceType(url)
|
||||
resourceId := parseResourceId(url)
|
||||
|
||||
// 如果是读取/访问行为,写入访问日志(sys_access_log),否则写入操作日志(sys_operation_log)
|
||||
if operation == "READ" {
|
||||
access := &models.AccessLog{
|
||||
TenantId: tenantId,
|
||||
UserId: userId,
|
||||
Username: username,
|
||||
Module: module,
|
||||
ResourceType: resourceType,
|
||||
ResourceId: &resourceId,
|
||||
RequestUrl: ctx.Input.URL(),
|
||||
IpAddress: ctx.Input.IP(),
|
||||
UserAgent: ctx.Input.Header("User-Agent"),
|
||||
RequestMethod: method,
|
||||
Duration: int(duration.Milliseconds()),
|
||||
}
|
||||
|
||||
// 在 QueryString 中记录查询参数和匿名标识
|
||||
qs := ctx.Request.URL.RawQuery
|
||||
if qs != "" {
|
||||
access.QueryString = qs
|
||||
}
|
||||
if userId == 0 {
|
||||
// 将匿名标识拼入 QueryString 以便查询(也可改为独立字段)
|
||||
if access.QueryString != "" {
|
||||
access.QueryString = "anonymous=true; " + access.QueryString
|
||||
} else {
|
||||
access.QueryString = "anonymous=true"
|
||||
}
|
||||
}
|
||||
|
||||
if err := services.AddAccessLog(access); err != nil {
|
||||
fmt.Printf("Failed to save access log: %v\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 创建操作日志
|
||||
// 为所有接口都记录日志
|
||||
log := &models.OperationLog{
|
||||
TenantId: tenantId,
|
||||
UserId: userId,
|
||||
Username: username,
|
||||
Module: module,
|
||||
ResourceType: resourceType,
|
||||
ResourceId: &resourceId,
|
||||
Operation: operation,
|
||||
IpAddress: ctx.Input.IP(),
|
||||
UserAgent: ctx.Input.Header("User-Agent"),
|
||||
IpAddress: ipAddress,
|
||||
UserAgent: userAgent,
|
||||
RequestMethod: method,
|
||||
RequestUrl: ctx.Input.URL(),
|
||||
Status: 1, // 默认成功,实际应该根据响应状态码更新
|
||||
RequestUrl: url,
|
||||
Status: 1, // 默认成功
|
||||
Duration: int(duration.Milliseconds()),
|
||||
CreateTime: time.Now(),
|
||||
}
|
||||
|
||||
// 如果是写操作,保存请求体作为新值;对于读取操作可以在Description里记录query
|
||||
if requestBody != "" {
|
||||
log.NewValue = requestBody
|
||||
} else if method == "GET" {
|
||||
// 把查询字符串放到描述里,便于分析访问参数
|
||||
qs := ctx.Request.URL.RawQuery
|
||||
if qs != "" {
|
||||
log.Description = "query=" + qs
|
||||
}
|
||||
// 设置资源ID
|
||||
if resourceId > 0 {
|
||||
log.ResourceId = &resourceId
|
||||
}
|
||||
|
||||
// 标记匿名访问信息(当 userId==0)
|
||||
if userId == 0 {
|
||||
if log.Description != "" {
|
||||
log.Description = "anonymous=true; " + log.Description
|
||||
} else {
|
||||
log.Description = "anonymous=true"
|
||||
// 记录请求信息到Description
|
||||
var description strings.Builder
|
||||
if requestBody != "" {
|
||||
description.WriteString("Request: " + truncateString(requestBody, 500))
|
||||
}
|
||||
if queryString != "" {
|
||||
if description.Len() > 0 {
|
||||
description.WriteString(" | ")
|
||||
}
|
||||
description.WriteString("Query: " + queryString)
|
||||
}
|
||||
|
||||
log.Description = description.String()
|
||||
|
||||
// 如果有请求体,作为NewValue保存
|
||||
if requestBody != "" {
|
||||
log.NewValue = requestBody
|
||||
}
|
||||
|
||||
// 添加用户类型信息到Description
|
||||
if userType != "" {
|
||||
if log.Description != "" {
|
||||
log.Description += " | "
|
||||
}
|
||||
log.Description += "UserType: " + userType
|
||||
}
|
||||
|
||||
// 调用服务层保存日志
|
||||
@ -207,3 +193,30 @@ func parseModule(url string) string {
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// shouldSkipLogging 判断是否需要跳过日志记录
|
||||
func shouldSkipLogging(url string) bool {
|
||||
// 跳过静态资源、健康检查等
|
||||
skipPatterns := []string{
|
||||
"/static/",
|
||||
"/uploads/",
|
||||
"/favicon.ico",
|
||||
"/health",
|
||||
"/ping",
|
||||
}
|
||||
|
||||
for _, pattern := range skipPatterns {
|
||||
if strings.HasPrefix(url, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// truncateString 截断字符串到指定长度
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
|
||||
123
server/models/task.go
Normal file
123
server/models/task.go
Normal file
@ -0,0 +1,123 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
|
||||
// Task 任务模型(对应表 yz_tenant_tasks)
|
||||
type Task struct {
|
||||
Id int `orm:"auto" json:"id"`
|
||||
TenantId int `orm:"column(tenant_id)" json:"tenant_id"`
|
||||
TaskNo string `orm:"column(task_no);size(64)" json:"task_no"`
|
||||
TaskName string `orm:"column(task_name);size(255)" json:"task_name"`
|
||||
TaskDesc string `orm:"column(task_desc);type(text);null" json:"task_desc"`
|
||||
TaskType string `orm:"column(task_type);size(32);null" json:"task_type"`
|
||||
BusinessTag string `orm:"column(business_tag);size(64);null" json:"business_tag"`
|
||||
ParentTaskId int `orm:"column(parent_task_id);null" json:"parent_task_id"`
|
||||
ProjectId int `orm:"column(project_id);null" json:"project_id"`
|
||||
RelatedId int `orm:"column(related_id);null" json:"related_id"`
|
||||
RelatedType string `orm:"column(related_type);size(32);null" json:"related_type"`
|
||||
TeamEmployeeIds string `orm:"column(team_employee_ids);size(512);null" json:"team_employee_ids"`
|
||||
CreatorId int `orm:"column(creator_id)" json:"creator_id"`
|
||||
CreatorName string `orm:"column(creator_name);size(64)" json:"creator_name"`
|
||||
PrincipalId int `orm:"column(principal_id)" json:"principal_id"`
|
||||
PrincipalName string `orm:"column(principal_name);size(64)" json:"principal_name"`
|
||||
ParticipantIds string `orm:"column(participant_ids);size(512);null" json:"participant_ids"`
|
||||
ParticipantNames string `orm:"column(participant_names);size(512);null" json:"participant_names"`
|
||||
CcIds string `orm:"column(cc_ids);size(512);null" json:"cc_ids"`
|
||||
CcNames string `orm:"column(cc_names);size(512);null" json:"cc_names"`
|
||||
PlanStartTime *time.Time `orm:"column(plan_start_time);null;type(datetime)" json:"plan_start_time"`
|
||||
PlanEndTime time.Time `orm:"column(plan_end_time);type(datetime)" json:"plan_end_time"`
|
||||
ActualStartTime *time.Time `orm:"column(actual_start_time);null;type(datetime)" json:"actual_start_time"`
|
||||
ActualEndTime *time.Time `orm:"column(actual_end_time);null;type(datetime)" json:"actual_end_time"`
|
||||
EstimatedHours float64 `orm:"column(estimated_hours);null;digits(10);decimals(2)" json:"estimated_hours"`
|
||||
ActualHours float64 `orm:"column(actual_hours);null;digits(10);decimals(2)" json:"actual_hours"`
|
||||
TaskStatus string `orm:"column(task_status);size(32)" json:"task_status"`
|
||||
Priority string `orm:"column(priority);size(16)" json:"priority"`
|
||||
Progress int8 `orm:"column(progress)" json:"progress"`
|
||||
NeedApproval int8 `orm:"column(need_approval)" json:"need_approval"`
|
||||
ApprovalId int `orm:"column(approval_id);null" json:"approval_id"`
|
||||
DelayApproved int8 `orm:"column(delay_approved)" json:"delay_approved"`
|
||||
OldPlanEndTime *time.Time `orm:"column(old_plan_end_time);null;type(datetime)" json:"old_plan_end_time"`
|
||||
RepeatType string `orm:"column(repeat_type);size(16);null" json:"repeat_type"`
|
||||
RepeatCycle int `orm:"column(repeat_cycle);null" json:"repeat_cycle"`
|
||||
RepeatEndTime *time.Time `orm:"column(repeat_end_time);null;type(datetime)" json:"repeat_end_time"`
|
||||
AttachmentIds string `orm:"column(attachment_ids);size(1024);null" json:"attachment_ids"`
|
||||
Remark string `orm:"column(remark);size(512);null" json:"remark"`
|
||||
IsArchived int8 `orm:"column(is_archived)" json:"is_archived"`
|
||||
ArchiveTime *time.Time `orm:"column(archive_time);null;type(datetime)" json:"archive_time"`
|
||||
CreatedTime time.Time `orm:"column(created_time);type(datetime);auto_now_add" json:"created_time"`
|
||||
UpdatedTime time.Time `orm:"column(updated_time);type(datetime);auto_now" json:"updated_time"`
|
||||
DeletedTime *time.Time `orm:"column(deleted_time);null;type(datetime)" json:"deleted_time"`
|
||||
OperatorId int `orm:"column(operator_id);null" json:"operator_id"`
|
||||
OperatorName string `orm:"column(operator_name);size(64);null" json:"operator_name"`
|
||||
}
|
||||
|
||||
func (t *Task) TableName() string {
|
||||
return "yz_tenant_tasks"
|
||||
}
|
||||
|
||||
func init() {
|
||||
orm.RegisterModel(new(Task))
|
||||
}
|
||||
|
||||
// -------- 数据访问函数 ---------
|
||||
|
||||
// ListTasks 分页查询任务
|
||||
func ListTasks(tenantId int, keyword, status, priority string, page, pageSize int) (tasks []*Task, total int64, err error) {
|
||||
o := orm.NewOrm()
|
||||
qs := o.QueryTable(new(Task)).Filter("tenant_id", tenantId).Filter("deleted_time__isnull", true)
|
||||
|
||||
if keyword != "" {
|
||||
cond := orm.NewCondition()
|
||||
cond1 := cond.Or("task_name__icontains", keyword).Or("task_no__icontains", keyword).Or("principal_name__icontains", keyword)
|
||||
qs = qs.SetCond(cond1)
|
||||
}
|
||||
if status != "" {
|
||||
qs = qs.Filter("task_status", status)
|
||||
}
|
||||
if priority != "" {
|
||||
qs = qs.Filter("priority", priority)
|
||||
}
|
||||
|
||||
total, err = qs.Count()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
_, err = qs.OrderBy("-id").Limit(pageSize, offset).All(&tasks)
|
||||
return
|
||||
}
|
||||
|
||||
// GetTaskById 获取单个任务
|
||||
func GetTaskById(id int) (*Task, error) {
|
||||
o := orm.NewOrm()
|
||||
t := Task{Id: id}
|
||||
err := o.Read(&t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// CreateTask 新建任务
|
||||
func CreateTask(t *Task) error {
|
||||
o := orm.NewOrm()
|
||||
_, err := o.Insert(t)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateTask 更新任务
|
||||
func UpdateTask(t *Task, cols ...string) error {
|
||||
o := orm.NewOrm()
|
||||
_, err := o.Update(t, cols...)
|
||||
return err
|
||||
}
|
||||
@ -29,6 +29,17 @@ type User struct {
|
||||
LastLoginIp string `orm:"column(last_login_ip);null;size(50)" json:"last_login_ip"`
|
||||
}
|
||||
|
||||
// GetUserById 根据ID获取用户信息
|
||||
func GetUserById(id int) (*User, error) {
|
||||
o := orm.NewOrm()
|
||||
user := &User{Id: id}
|
||||
err := o.Read(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// TableName 设置表名,默认为yz_users
|
||||
func (u *User) TableName() string {
|
||||
return "yz_users"
|
||||
@ -47,6 +58,7 @@ func Init(version string) {
|
||||
orm.RegisterModel(new(DictType))
|
||||
orm.RegisterModel(new(DictItem))
|
||||
orm.RegisterModel(new(OperationLog))
|
||||
orm.RegisterModel(new(AccessLog))
|
||||
|
||||
ormConfig, err := beego.AppConfig.String("orm")
|
||||
if err != nil {
|
||||
@ -90,5 +102,20 @@ func Init(version string) {
|
||||
fmt.Println("数据库连接成功!")
|
||||
fmt.Printf("当前项目版本: %s\n", version)
|
||||
fmt.Println("数据库连接池配置: MaxIdleConns=10, MaxOpenConns=100, ConnMaxLifetime=1h")
|
||||
|
||||
// 自动创建或更新表结构
|
||||
o := orm.NewOrm()
|
||||
_, err = o.Raw("SET FOREIGN_KEY_CHECKS=0").Exec()
|
||||
if err != nil {
|
||||
fmt.Println("关闭外键检查失败:", err)
|
||||
}
|
||||
if err := orm.RunSyncdb("default", false, true); err != nil {
|
||||
fmt.Println("数据库表同步失败:", err)
|
||||
}
|
||||
_, err = o.Raw("SET FOREIGN_KEY_CHECKS=1").Exec()
|
||||
if err != nil {
|
||||
fmt.Println("启用外键检查失败:", err)
|
||||
}
|
||||
fmt.Println("数据库表自动同步完成")
|
||||
}
|
||||
}
|
||||
|
||||
@ -312,6 +312,10 @@ func init() {
|
||||
// OA基础数据合并接口(一次性获取部门、职位、角色)
|
||||
beego.Router("/api/oa/base-data/:tenantId", &controllers.OAController{}, "get:GetOABaseData")
|
||||
|
||||
// OA任务管理路由
|
||||
beego.Router("/api/oa/tasks", &controllers.TaskController{}, "get:GetTasks;post:CreateTask")
|
||||
beego.Router("/api/oa/tasks/:id", &controllers.TaskController{}, "get:GetTaskById;put:UpdateTask;delete:DeleteTask")
|
||||
|
||||
// 权限管理路由
|
||||
beego.Router("/api/permissions/menus", &controllers.PermissionController{}, "get:GetAllMenuPermissions")
|
||||
beego.Router("/api/permissions/role/:roleId", &controllers.PermissionController{}, "get:GetRolePermissions")
|
||||
@ -323,6 +327,7 @@ func init() {
|
||||
// 仪表盘路由
|
||||
beego.Router("/api/dashboard/platform-stats", &controllers.DashboardController{}, "get:GetPlatformStats")
|
||||
beego.Router("/api/dashboard/tenant-stats", &controllers.DashboardController{}, "get:GetTenantStats")
|
||||
beego.Router("/api/dashboard/user-activity-logs", &controllers.DashboardController{}, "get:GetUserActivityLogs")
|
||||
|
||||
// 字典管理路由
|
||||
beego.Router("/api/dict/types", &controllers.DictController{}, "get:GetDictTypes;post:AddDictType")
|
||||
@ -345,4 +350,10 @@ func init() {
|
||||
beego.Router("/api/operation-logs/tenant/stats", &controllers.OperationLogController{}, "get:GetTenantStats")
|
||||
beego.Router("/api/operation-logs/clear", &controllers.OperationLogController{}, "post:ClearOldLogs")
|
||||
|
||||
// 访问日志路由 - 统一到操作日志控制器
|
||||
beego.Router("/api/access-logs", &controllers.OperationLogController{}, "get:GetAccessLogs")
|
||||
beego.Router("/api/access-logs/:id", &controllers.OperationLogController{}, "get:GetAccessLogById")
|
||||
beego.Router("/api/access-logs/user/stats", &controllers.OperationLogController{}, "get:GetUserAccessStats")
|
||||
beego.Router("/api/access-logs/clear", &controllers.OperationLogController{}, "post:ClearOldAccessLogs")
|
||||
|
||||
}
|
||||
|
||||
Binary file not shown.
@ -41,7 +41,7 @@ func GetModuleName(module string) string {
|
||||
|
||||
// 如果菜单表中找不到,使用默认映射
|
||||
defaultMap := map[string]string{
|
||||
"auth": "认证",
|
||||
"auth": "登录模块",
|
||||
"dict": "字典管理",
|
||||
"user": "用户管理",
|
||||
"role": "角色管理",
|
||||
@ -142,7 +142,7 @@ func GetModuleNames(modules []string) (map[string]string, error) {
|
||||
|
||||
// 3. 对于仍然没有匹配的,使用默认映射
|
||||
defaultMap := map[string]string{
|
||||
"auth": "认证",
|
||||
"auth": "登录模块",
|
||||
"dict": "字典管理",
|
||||
"user": "用户管理",
|
||||
"role": "角色管理",
|
||||
@ -367,3 +367,139 @@ func DeleteOldLogs(keepDays int) (int64, error) {
|
||||
|
||||
return rowsAffected, nil
|
||||
}
|
||||
|
||||
// GetAccessLogs 获取访问日志列表
|
||||
func GetAccessLogs(tenantId int, userId int, module string, resourceType string, startTime *time.Time, endTime *time.Time, pageNum int, pageSize int) ([]*models.AccessLog, int64, error) {
|
||||
o := orm.NewOrm()
|
||||
qs := o.QueryTable("sys_access_log")
|
||||
|
||||
// 租户过滤
|
||||
if tenantId > 0 {
|
||||
qs = qs.Filter("tenant_id", tenantId)
|
||||
}
|
||||
|
||||
// 用户过滤
|
||||
if userId > 0 {
|
||||
qs = qs.Filter("user_id", userId)
|
||||
}
|
||||
|
||||
// 模块过滤
|
||||
if module != "" {
|
||||
qs = qs.Filter("module", module)
|
||||
}
|
||||
|
||||
// 资源类型过滤
|
||||
if resourceType != "" {
|
||||
qs = qs.Filter("resource_type", resourceType)
|
||||
}
|
||||
|
||||
// 时间范围过滤
|
||||
if startTime != nil {
|
||||
qs = qs.Filter("create_time__gte", startTime)
|
||||
}
|
||||
if endTime != nil {
|
||||
qs = qs.Filter("create_time__lte", endTime)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
total, err := qs.Count()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("查询日志总数失败: %v", err)
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
var logs []*models.AccessLog
|
||||
offset := (pageNum - 1) * pageSize
|
||||
_, err = qs.OrderBy("-create_time").Offset(offset).Limit(pageSize).All(&logs)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("查询访问日志失败: %v", err)
|
||||
}
|
||||
|
||||
return logs, total, nil
|
||||
}
|
||||
|
||||
// GetAccessLogById 根据ID获取访问日志
|
||||
func GetAccessLogById(id int64) (*models.AccessLog, error) {
|
||||
o := orm.NewOrm()
|
||||
log := &models.AccessLog{Id: id}
|
||||
err := o.Read(log)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("日志不存在: %v", err)
|
||||
}
|
||||
return log, nil
|
||||
}
|
||||
|
||||
// GetUserAccessStats 获取用户访问统计
|
||||
func GetUserAccessStats(tenantId int, userId int, days int) (map[string]interface{}, error) {
|
||||
o := orm.NewOrm()
|
||||
|
||||
startTime := time.Now().AddDate(0, 0, -days)
|
||||
|
||||
// 获取总访问数
|
||||
var totalCount int64
|
||||
err := o.Raw(
|
||||
"SELECT COUNT(*) FROM sys_access_log WHERE tenant_id = ? AND user_id = ? AND create_time >= ?",
|
||||
tenantId, userId, startTime,
|
||||
).QueryRow(&totalCount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 获取按模块分组的访问数
|
||||
type ModuleCount struct {
|
||||
Module string
|
||||
Count int
|
||||
}
|
||||
var moduleCounts []ModuleCount
|
||||
_, err = o.Raw(
|
||||
"SELECT module, COUNT(*) as count FROM sys_access_log WHERE tenant_id = ? AND user_id = ? AND create_time >= ? GROUP BY module",
|
||||
tenantId, userId, startTime,
|
||||
).QueryRows(&moduleCounts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 获取按状态分组的访问数
|
||||
type StatusCount struct {
|
||||
Status int
|
||||
Count int
|
||||
}
|
||||
var statusCounts []StatusCount
|
||||
_, err = o.Raw(
|
||||
"SELECT status, COUNT(*) as count FROM sys_access_log WHERE tenant_id = ? AND user_id = ? AND create_time >= ? GROUP BY status",
|
||||
tenantId, userId, startTime,
|
||||
).QueryRows(&statusCounts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 构建结果
|
||||
stats := map[string]interface{}{
|
||||
"total_access": totalCount,
|
||||
"module_stats": moduleCounts,
|
||||
"status_stats": statusCounts,
|
||||
"period_days": days,
|
||||
"from_time": startTime,
|
||||
"to_time": time.Now(),
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// ClearOldAccessLogs 清除旧访问日志(保留指定天数)
|
||||
func ClearOldAccessLogs(keepDays int) (int64, error) {
|
||||
o := orm.NewOrm()
|
||||
cutoffTime := time.Now().AddDate(0, 0, -keepDays)
|
||||
|
||||
result, err := o.Raw("DELETE FROM sys_access_log WHERE create_time < ?", cutoffTime).Exec()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("删除旧访问日志失败: %v", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("获取受影响行数失败: %v", err)
|
||||
}
|
||||
|
||||
return rowsAffected, nil
|
||||
}
|
||||
|
||||
71
server/services/task.go
Normal file
71
server/services/task.go
Normal file
@ -0,0 +1,71 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"server/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ListOATasks 服务层:分页查询任务
|
||||
func ListOATasks(tenantId int, keyword, status, priority string, page, pageSize int) (tasks []*models.Task, total int64, err error) {
|
||||
return models.ListTasks(tenantId, keyword, status, priority, page, pageSize)
|
||||
}
|
||||
|
||||
// GetOATaskById 服务层:获取任务详情
|
||||
func GetOATaskById(id int) (*models.Task, error) {
|
||||
return models.GetTaskById(id)
|
||||
}
|
||||
|
||||
// CreateOATask 服务层:创建任务
|
||||
func CreateOATask(t *models.Task, tenantId int, username string) error {
|
||||
// 填充租户
|
||||
if t.TenantId == 0 && tenantId > 0 {
|
||||
t.TenantId = tenantId
|
||||
}
|
||||
// 默认状态与优先级
|
||||
if t.TaskStatus == "" {
|
||||
t.TaskStatus = ""
|
||||
}
|
||||
if t.Priority == "" {
|
||||
t.Priority = ""
|
||||
}
|
||||
// 任务编号
|
||||
if t.TaskNo == "" {
|
||||
t.TaskNo = genTaskNo(t.TenantId)
|
||||
}
|
||||
// 创建人与操作人
|
||||
if t.CreatorName == "" && username != "" {
|
||||
t.CreatorName = username
|
||||
}
|
||||
if t.OperatorName == "" && username != "" {
|
||||
t.OperatorName = username
|
||||
}
|
||||
return models.CreateTask(t)
|
||||
}
|
||||
|
||||
// UpdateOATask 服务层:更新任务
|
||||
func UpdateOATask(t *models.Task, username string, cols ...string) error {
|
||||
if username != "" {
|
||||
t.OperatorName = username
|
||||
}
|
||||
return models.UpdateTask(t, cols...)
|
||||
}
|
||||
|
||||
// DeleteOATask 服务层:软删除任务
|
||||
func DeleteOATask(id int, operatorName string, operatorId int) error {
|
||||
// 读取当前任务
|
||||
t, err := models.GetTaskById(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
t.DeletedTime = &now
|
||||
t.OperatorName = operatorName
|
||||
t.OperatorId = operatorId
|
||||
return models.UpdateTask(t, "deleted_time", "operator_name", "operator_id")
|
||||
}
|
||||
|
||||
// 生成任务编号:TASK{tenantId}{YYYYMMDDHHMMSS}
|
||||
func genTaskNo(tenantId int) string {
|
||||
return fmt.Sprintf("TASK%d%s", tenantId, time.Now().Format("20060102150405"))
|
||||
}
|
||||
28
server/sql/create_access_log_table.sql
Normal file
28
server/sql/create_access_log_table.sql
Normal file
@ -0,0 +1,28 @@
|
||||
-- 创建访问日志表
|
||||
CREATE TABLE IF NOT EXISTS `sys_access_log` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '日志ID',
|
||||
`tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户ID',
|
||||
`user_id` bigint NOT NULL DEFAULT 0 COMMENT '用户ID',
|
||||
`username` varchar(64) DEFAULT '' COMMENT '用户名',
|
||||
`ip_address` varchar(50) DEFAULT '' COMMENT 'IP地址',
|
||||
`module` varchar(64) DEFAULT '' COMMENT '模块',
|
||||
`action` varchar(128) DEFAULT '' COMMENT '操作',
|
||||
`resource_type` varchar(64) DEFAULT '' COMMENT '资源类型',
|
||||
`resource_id` varchar(255) DEFAULT '' COMMENT '资源ID',
|
||||
`request_method` varchar(10) DEFAULT '' COMMENT '请求方法',
|
||||
`request_url` varchar(255) DEFAULT '' COMMENT '请求URL',
|
||||
`user_agent` varchar(255) DEFAULT '' COMMENT 'User Agent',
|
||||
`status_code` int DEFAULT 200 COMMENT '状态码',
|
||||
`response_time` bigint DEFAULT 0 COMMENT '响应时间(毫秒)',
|
||||
`description` longtext COMMENT '日志描述',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`create_by` varchar(64) DEFAULT '' COMMENT '创建人',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`update_by` varchar(64) DEFAULT '' COMMENT '更新人',
|
||||
`delete_flag` tinyint DEFAULT 0 COMMENT '删除标记(0-正常,1-删除)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tenant_id` (`tenant_id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_create_time` (`create_time`),
|
||||
KEY `idx_username` (`username`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='系统访问日志表';
|
||||
Binary file not shown.
Loading…
Reference in New Issue
Block a user