增加任务管理模块

This commit is contained in:
李志强 2025-11-12 17:32:03 +08:00
parent 12a0ff8afc
commit db16ee70de
54 changed files with 3638 additions and 672 deletions

View File

@ -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 ```vue
<template> <template>
@ -136,7 +171,7 @@ const props = defineProps({
<p v-if="loading">加载中...</p> <p v-if="loading">加载中...</p>
<el-select v-else v-model="status"> <el-select v-else v-model="status">
<el-option <el-option
v-for="item in statusDict" v-for="item in user_statusDict"
:key="item.dict_value" :key="item.dict_value"
:label="item.dict_label" :label="item.dict_label"
:value="item.dict_value" :value="item.dict_value"
@ -146,30 +181,11 @@ const props = defineProps({
</template> </template>
<script setup> <script setup>
import { useDictStore } from '@/stores/dict' import { useUserStatusDict } from '@/composables/useDict'
import { ref, onMounted } from 'vue' import { ref } from 'vue'
const dictStore = useDictStore() const status = ref('active')
const status = ref('1') const { user_statusDict, loading } = useUserStatusDict()
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()
})
</script> </script>
``` ```
@ -183,6 +199,7 @@ onMounted(() => {
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import { useDictStore } from '@/stores/dict' import { useDictStore } from '@/stores/dict'
import { DICT_CODES } from '@/constants/dictCodes'
const app = createApp(App) const app = createApp(App)
const pinia = createPinia() const pinia = createPinia()
@ -192,9 +209,9 @@ app.use(pinia)
// 在应用启动后预加载常用字典 // 在应用启动后预加载常用字典
const dictStore = useDictStore() const dictStore = useDictStore()
await dictStore.preloadDicts([ await dictStore.preloadDicts([
'user_status', DICT_CODES.USER_STATUS,
'common_status', DICT_CODES.COMMON_STATUS,
'yes_no', DICT_CODES.YES_NO,
]) ])
app.mount('#app') app.mount('#app')
@ -238,29 +255,29 @@ app.mount('#app')
### ✅ DO ### ✅ DO
1. **使用字符串而不是硬编码数字** 1. **使用常量而不是硬编码字符串**
```javascript ```javascript
// ✅ 好 // ✅ 好
dictStore.getDictItems('user_status') dictStore.getDictItems(DICT_CODES.USER_STATUS)
// ❌ 差 // ❌ 差
// 避免直接使用数字,应该使用字符串 dictStore.getDictItems('user_status')
``` ```
2. **在父组件加载,通过 props 传给子组件** 2. **在父组件加载,通过 props 传给子组件**
```javascript ```javascript
// ✅ 父组件负责数据,子组件负责展示 // ✅ 父组件负责数据,子组件负责展示
// index.vue // index.vue
const statusDict = await dictStore.getDictItems('user_status') const statusDict = await dictStore.getDictItems(DICT_CODES.USER_STATUS)
// UserEdit.vue // UserEdit.vue
const props = defineProps({ statusDict: Array }) const props = defineProps({ statusDict: Array })
``` ```
3. **直接使用字典Store** 3. **用 Composable 简化组件逻辑**
```javascript ```javascript
// ✅ 直接使用Store获取数据 // ✅ 一行代码搞定
const statusDict = await dictStore.getDictItems('user_status') const { user_statusDict, loading } = useUserStatusDict()
``` ```
4. **预加载常用字典** 4. **预加载常用字典**
@ -354,12 +371,14 @@ const item = items.find(i =>
## 集成检清表 ## 集成检清表
- [x] 创建 `src/stores/dict.js` - Store - [ ] 创建 `src/stores/dict.js` - Store
- [x] 在 `index.vue` 中导入 `useDictStore` - [ ] 创建 `src/constants/dictCodes.js` - 常量
- [x] 在 `UserEdit.vue` 中使用 `useDictStore` 获取字典数据 - [ ] 创建 `src/composables/useDict.js` - Composable
- [x] 测试字典加载和显示 - [ ] 在 `index.vue` 中导入 `useDictStore`
- [x] 验证缓存功能(打开浏览器 DevTools 检查 Network - [ ] 在 `UserEdit.vue` 中接收 `statusDict` props
- [x] 预加载常用字典(可选) - [ ] 测试字典加载和显示
- [ ] 验证缓存功能(打开浏览器 DevTools 检查 Network
- [ ] 预加载常用字典(可选)
--- ---
@ -367,8 +386,8 @@ const item = items.find(i =>
已修改的文件: 已修改的文件:
- ✅ `src/stores/dict.js` - 新建 - ✅ `src/stores/dict.js` - 新建
- ✅ `src/constants/dictCodes.js` - 新建
- ✅ `src/composables/useDict.js` - 新建
- ✅ `src/views/system/users/index.vue` - 使用 `useDictStore` - ✅ `src/views/system/users/index.vue` - 使用 `useDictStore`
- ✅ `src/views/system/users/components/UserEdit.vue` - 使用字典功能 - ✅ `src/views/system/users/components/UserEdit.vue` - 导入字典库
- ✅ `src/constants/dictCodes.js` - 删除(不再需要)
- ✅ `src/composables/useDict.js` - 删除(不再需要)

View File

@ -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 }
})
}

View File

@ -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,
},
});
}

View File

@ -43,3 +43,38 @@ export function clearOldLogs(keepDays = 90) {
data: { keep_days: keepDays } 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
View 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'
})
}

View File

@ -50,8 +50,8 @@ import {
} from "@/api/department"; } from "@/api/department";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useOAStore } from "@/stores/oa"; import { useOAStore } from "@/stores/oa";
import DepartmentList from "../components/departments/DepartmentList.vue"; import DepartmentList from "./components/DepartmentList.vue";
import DepartmentEdit from "../components/departments/DepartmentEdit.vue"; import DepartmentEdit from "./components/DepartmentEdit.vue";
interface Department { interface Department {
id: number; id: number;

View File

@ -69,9 +69,9 @@ import {
} from "@/api/employee"; } from "@/api/employee";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useOAStore } from "@/stores/oa"; import { useOAStore } from "@/stores/oa";
import EmployeeList from "../components/employees/EmployeeList.vue"; import EmployeeList from "./components/EmployeeList.vue";
import EmployeeEdit from "../components/employees/EmployeeEdit.vue"; import EmployeeEdit from "./components/EmployeeEdit.vue";
import EmployeePasswordChange from "../components/employees/EmployeePasswordChange.vue"; import EmployeePasswordChange from "./components/EmployeePasswordChange.vue";
interface Employee { interface Employee {
id: number; id: number;

View File

@ -126,4 +126,3 @@ const handleSubmit = () => {
emit('submit', { ...form.value }); emit('submit', { ...form.value });
}; };
</script> </script>

View File

@ -163,16 +163,6 @@ const handleCollapseAll = () => {
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: 8px;
margin-top: 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> </style>

View File

@ -126,4 +126,3 @@ const handleSubmit = () => {
emit('submit', { ...form.value }); emit('submit', { ...form.value });
}; };
</script> </script>

View File

@ -193,4 +193,3 @@ const handleDelete = (position: any) => {
} }
} }
</style> </style>

View File

@ -81,10 +81,10 @@ import {
} from "@/api/position"; } from "@/api/position";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useOAStore } from "@/stores/oa"; import { useOAStore } from "@/stores/oa";
import DepartmentTree from "../components/organization/DepartmentTree.vue"; import DepartmentTree from "./components/DepartmentTree.vue";
import PositionList from "../components/organization/PositionList.vue"; import PositionList from "./components/PositionList.vue";
import OrganizationDepartmentEdit from "../components/organization/DepartmentEdit.vue"; import OrganizationDepartmentEdit from "./components/DepartmentEdit.vue";
import OrganizationPositionEdit from "../components/organization/PositionEdit.vue"; import OrganizationPositionEdit from "./components/PositionEdit.vue";
const authStore = useAuthStore(); const authStore = useAuthStore();
const oaStore = useOAStore(); const oaStore = useOAStore();

View File

@ -52,8 +52,8 @@ import {
} from "@/api/position"; } from "@/api/position";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useOAStore } from "@/stores/oa"; import { useOAStore } from "@/stores/oa";
import PositionList from "../components/positions/PositionList.vue"; import PositionList from "./components/PositionList.vue";
import PositionEdit from "../components/positions/PositionEdit.vue"; import PositionEdit from "./components/PositionEdit.vue";
interface Position { interface Position {
id: number; id: number;

View 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>

View 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>

View 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>

View 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>

View File

@ -10,12 +10,7 @@
<!-- 统计卡片 --> <!-- 统计卡片 -->
<div class="stats-grid"> <div class="stats-grid">
<div <div v-for="(stat, index) in stats" :key="index" class="stat-card" :class="stat.type">
v-for="(stat, index) in stats"
:key="index"
class="stat-card"
:class="stat.type"
>
<div class="stat-icon-wrapper"> <div class="stat-icon-wrapper">
<el-icon :size="28"> <el-icon :size="28">
<component :is="stat.icon" /> <component :is="stat.icon" />
@ -25,25 +20,28 @@
<div class="stat-value">{{ stat.value }}</div> <div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div> <div class="stat-label">{{ stat.label }}</div>
</div> </div>
<div <div class="stat-trend" :class="stat.change > 0 ? 'up' : stat.change < 0 ? 'down' : 'flat'">
class="stat-trend" <el-icon v-if="stat.change > 0" :size="14">
:class="stat.change > 0 ? 'up' : stat.change < 0 ? 'down' : 'flat'" <ArrowUp />
> </el-icon>
<el-icon v-if="stat.change > 0" :size="14"><ArrowUp /></el-icon> <el-icon v-else-if="stat.change < 0" :size="14">
<el-icon v-else-if="stat.change < 0" :size="14"><ArrowDown /></el-icon> <ArrowDown />
</el-icon>
<span>{{ stat.change > 0 ? '+' : '' }}{{ stat.change }}%</span> <span>{{ stat.change > 0 ? '+' : '' }}{{ stat.change }}%</span>
</div> </div>
</div> </div>
</div> </div>
<!-- 图表区域 --> <!-- 图表区域 -->
<div class="charts-section"> <!-- <div class="charts-section">
<div class="chart-card"> <div class="chart-card">
<div class="card-header"> <div class="card-header">
<h3 class="card-title">月收入走势</h3> <h3 class="card-title">月收入走势</h3>
<el-dropdown trigger="click"> <el-dropdown trigger="click">
<el-button type="primary" link> <el-button type="primary" link>
<el-icon><MoreFilled /></el-icon> <el-icon>
<MoreFilled />
</el-icon>
</el-button> </el-button>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
@ -63,7 +61,9 @@
<h3 class="card-title">用户活跃分布</h3> <h3 class="card-title">用户活跃分布</h3>
<el-dropdown trigger="click"> <el-dropdown trigger="click">
<el-button type="primary" link> <el-button type="primary" link>
<el-icon><MoreFilled /></el-icon> <el-icon>
<MoreFilled />
</el-icon>
</el-button> </el-button>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
@ -77,7 +77,7 @@
<canvas id="barChart" height="160"></canvas> <canvas id="barChart" height="160"></canvas>
</div> </div>
</div> </div>
</div> </div> -->
<!-- 列表区域 --> <!-- 列表区域 -->
<div class="lists-section"> <div class="lists-section">
@ -85,68 +85,62 @@
<div class="card-header"> <div class="card-header">
<h3 class="card-title">待办任务</h3> <h3 class="card-title">待办任务</h3>
<el-button type="primary" link size="small"> <el-button type="primary" link size="small">
<el-icon><Plus /></el-icon> <el-icon>
<Plus />
</el-icon>
添加任务 添加任务
</el-button> </el-button>
</div> </div>
<div class="list-content"> <div class="list-content">
<div <div v-for="(task, idx) in paginatedTasks" :key="idx" class="task-item" :class="{ done: task.completed }">
v-for="(task, idx) in tasks" <el-checkbox v-model="task.completed" @change="handleTaskChange(task)" />
: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-info">
<div class="task-title">{{ task.title }}</div> <div class="task-title">
<div class="task-meta"> {{ task.title }}
<span class="task-date">{{ task.date }}</span> <el-tag :type="getPriorityType(task.priority)" size="small" effect="plain">
<el-tag
:type="getPriorityType(task.priority)"
size="small"
effect="plain"
>
{{ task.priority }} {{ task.priority }}
</el-tag> </el-tag>
</div> </div>
<div class="task-meta">
<span class="task-date">{{ task.date }}</span>
</div> </div>
</div> </div>
<el-empty </div>
v-if="tasks.filter(t => !t.completed).length === 0" <el-empty v-if="tasks.length === 0" description="暂无待办任务" :image-size="80" />
description="暂无待办任务" <div v-if="tasks.length > taskPageSize" class="pagination-wrapper">
:image-size="80" <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> </div>
<div class="list-card"> <div class="list-card">
<div class="card-header"> <div class="card-header">
<h3 class="card-title">最新动态</h3> <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>
<div class="list-content"> <div class="list-content">
<div <div v-for="(activity, idx) in paginatedActivityLogs" :key="idx" class="activity-item">
v-for="(activity, idx) in activities" <div class="activity-icon" :class="activity.type">
:key="idx" <el-icon>
class="activity-item" <component :is="getActivityIcon(activity.type)" />
> </el-icon>
<el-avatar :src="activity.avatar" :size="40" /> </div>
<div class="activity-info"> <div class="activity-info">
<div class="activity-text"> <div class="activity-text">
<span class="activity-user">{{ activity.user }}</span> <span class="activity-module">{{ activity.operation }}</span>
<span class="activity-action">{{ activity.action }}</span> <span class="activity-action">{{ activity.description }}</span>
</div> </div>
<div class="activity-time">{{ activity.time }}</div> <div class="activity-time">{{ formatTime(activity.timestamp) }}</div>
</div> </div>
</div> </div>
<el-empty <el-empty v-if="activityLogs.length === 0" description="暂无动态" :image-size="80" />
v-if="activities.length === 0" <div v-if="totalActivityLogs > pageSize" class="pagination-wrapper">
description="暂无动态" <el-pagination v-model:current-page="currentPage" :page-size="pageSize" :total="totalActivityLogs"
:image-size="80" layout="prev, pager, next" small @current-change="handlePageChange" />
/> </div>
</div> </div>
</div> </div>
</div> </div>
@ -166,9 +160,11 @@ import {
MoreFilled, MoreFilled,
Plus, Plus,
Document, Document,
Edit,
View,
} from "@element-plus/icons-vue"; } from "@element-plus/icons-vue";
import { getKnowledgeCount } from "@/api/knowledge"; import { getKnowledgeCount } from "@/api/knowledge";
import { getPlatformStats, getTenantStats } from "@/api/dashboard"; import { getPlatformStats, getTenantStats, getActivityLogs } from "@/api/dashboard";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
Chart.register(...registerables); Chart.register(...registerables);
@ -289,6 +285,9 @@ const fetchTenantStats = async () => {
}; };
// //
const taskCurrentPage = ref(1);
const taskPageSize = ref(5);
const tasks = ref([ const tasks = ref([
{ {
title: "完成Q2预算审核", title: "完成Q2预算审核",
@ -308,29 +307,116 @@ const tasks = ref([
priority: "Low", priority: "Low",
completed: false, 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([ const activityLogs = ref<any[]>([]);
{ const currentPage = ref(1);
user: "Emma", const pageSize = ref(5);
action: "添加了新用户", const totalActivityLogs = ref(0);
time: "1 小时前",
avatar: "https://picsum.photos/id/1027/40/40", //
}, const paginatedActivityLogs = computed(() => {
{ const start = (currentPage.value - 1) * pageSize.value;
user: "John", const end = start + pageSize.value;
action: "修改了高级权限", return activityLogs.value.slice(start, end);
time: "3 小时前", });
avatar: "https://picsum.photos/id/1012/40/40",
}, //
{ const fetchActivityLogs = async () => {
user: "Jessica", try {
action: "完成订单分析报表", const data = await getActivityLogs(1, 100); //
time: "昨天", if (data?.code === 0 && data?.data?.logs) {
avatar: "https://picsum.photos/id/1000/40/40", 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) => { const getPriorityType = (priority: string) => {
@ -356,6 +442,9 @@ onMounted(() => {
fetchPlatformStats(); fetchPlatformStats();
} }
//
fetchActivityLogs();
// 线 // 线
const lineChartEl = document.getElementById("lineChart") as HTMLCanvasElement | null; const lineChartEl = document.getElementById("lineChart") as HTMLCanvasElement | null;
if (!lineChartEl) { if (!lineChartEl) {
@ -700,6 +789,18 @@ onMounted(() => {
} }
.list-content { .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 { .task-item {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@ -736,8 +837,14 @@ onMounted(() => {
.task-title { .task-title {
font-size: 14px; font-size: 14px;
color: var(--el-text-color-primary); color: var(--el-text-color-primary);
margin-bottom: 6px; margin-bottom: 4px;
font-weight: 500; font-weight: 500;
display: flex;
align-items: center;
}
.el-tag{
margin-left: 8px !important;
} }
.task-meta { .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 { .activity-item {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@ -772,6 +887,27 @@ onMounted(() => {
border-radius: 8px; 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 { .activity-info {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@ -781,7 +917,7 @@ onMounted(() => {
color: var(--el-text-color-primary); color: var(--el-text-color-primary);
margin-bottom: 4px; margin-bottom: 4px;
.activity-user { .activity-module {
font-weight: 600; font-weight: 600;
color: var(--el-text-color-primary); color: var(--el-text-color-primary);
} }
@ -803,6 +939,7 @@ onMounted(() => {
// //
@media (max-width: 1200px) { @media (max-width: 1200px) {
.charts-section, .charts-section,
.lists-section { .lists-section {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@ -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>

View File

@ -1,8 +1,9 @@
<template> <template>
<div class="operation-log-container"> <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-card shadow="hover" style="margin-bottom: 20px">
<el-form :model="filters" label-width="100px" :inline="true"> <el-form :model="filters" label-width="100px" :inline="true">
@ -123,6 +124,171 @@
<!-- 详情对话框 --> <!-- 详情对话框 -->
<OperationLogDetail ref="detailRef" /> <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"> <el-dialog v-model="clearDialogVisible" title="清空旧日志" width="400px">
@ -149,12 +315,13 @@
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' 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 { getAllMenus } from '@/api/menu'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
// import StatisticsPanel from './components/StatisticsPanel.vue'
import OperationLogDetail from './components/OperationLogDetail.vue' import OperationLogDetail from './components/OperationLogDetail.vue'
const activeTab = ref('operation')
const tableData = ref([]) const tableData = ref([])
const loading = ref(false) const loading = ref(false)
const total = ref(0) const total = ref(0)
@ -182,6 +349,28 @@ const clearForm = reactive({
keep_days: 90 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 getOperationTag = (operation) => {
const map = { const map = {
'CREATE': 'success', 'CREATE': 'success',
@ -289,8 +478,12 @@ const showClearDialog = () => {
} }
const handleClearLogs = async () => { const handleClearLogs = async () => {
const logType = activeTab.value === 'operation' ? '操作日志' : '访问日志'
const clearFunc = activeTab.value === 'operation' ? clearOldLogs : clearOldAccessLogs
const reloadFunc = activeTab.value === 'operation' ? loadLogs : loadAccessLogs
ElMessageBox.confirm( ElMessageBox.confirm(
`将删除超过 ${clearForm.keep_days} 天的所有操作日志,此操作无法撤销!`, `将删除超过 ${clearForm.keep_days} 天的所有${logType},此操作无法撤销!`,
'警告', '警告',
{ {
confirmButtonText: '确认', confirmButtonText: '确认',
@ -299,11 +492,11 @@ const handleClearLogs = async () => {
} }
).then(async () => { ).then(async () => {
try { try {
const res = await clearOldLogs(clearForm.keep_days) const res = await clearFunc(clearForm.keep_days)
if (res.success) { if (res.success) {
ElMessage.success('日志清空成功') ElMessage.success('日志清空成功')
clearDialogVisible.value = false clearDialogVisible.value = false
loadLogs() reloadFunc()
} }
} catch (error) { } catch (error) {
ElMessage.error('清空日志失败') ElMessage.error('清空日志失败')
@ -317,6 +510,87 @@ const handleExport = () => {
ElMessage.info('导出功能开发中...') 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 () => { onMounted(async () => {
await loadMenus() await loadMenus()
loadLogs() loadLogs()

View File

@ -25,6 +25,43 @@
<el-input v-model="form.email" /> <el-input v-model="form.email" />
</el-form-item> </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-form-item label="角色">
<el-select <el-select
@ -73,16 +110,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue"; import { ref, computed, watch } from "vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { import {
addUser, addUser,
editUser, editUser,
getUserInfo, getUserInfo,
} from "@/api/user"; } from "@/api/user";
import { getTenantPositions, getPositionsByDepartment } from "@/api/position";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useDictStore } from "@/stores/dict"; import { useDictStore } from "@/stores/dict";
import { DICT_CODES } from "@/constants/dictCodes";
const authStore = useAuthStore(); const authStore = useAuthStore();
const dictStore = useDictStore(); const dictStore = useDictStore();
@ -100,23 +138,44 @@ const props = defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
departmentList: {
type: Array,
default: () => [],
},
positionList: {
type: Array,
default: () => [],
},
loadingRoles: { loadingRoles: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
loadingDepartments: {
type: Boolean,
default: false,
},
loadingPositions: {
type: Boolean,
default: false,
},
statusDict: {
type: Array,
default: () => [],
},
tenantId: { tenantId: {
type: Number, type: Number,
default: 0, default: 0,
}, },
}); });
const emit = defineEmits(['update:modelValue', 'submit', 'close']); const emit = defineEmits(['update:modelValue', 'submit', 'close', 'fetch-positions']);
const visible = ref(false); const visible = ref(false);
const formRef = ref(null); const formRef = ref(null);
const loadingRoles = ref(false); const loadingRoles = ref(false);
const loadingDepartments = ref(false);
const loadingPositions = ref(false);
const isAdd = ref(false); const isAdd = ref(false);
const statusDict = ref([]);
const form = ref<any>({ const form = ref<any>({
id: null, id: null,
@ -127,6 +186,8 @@ const form = ref<any>({
role: null, role: null,
status: "1", // "1""active" status: "1", // "1""active"
tenant_id: null, tenant_id: null,
department_id: null,
position_id: null,
}); });
const dialogTitle = computed(() => { const dialogTitle = computed(() => {
@ -154,22 +215,6 @@ const getCurrentTenantId = () => {
return props.tenantId || 0; 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) => { const loadUserData = async (user: any) => {
try { try {
// ID // ID
@ -200,11 +245,16 @@ const loadUserData = async (user: any) => {
password: "", password: "",
email: data.email, email: data.email,
role: roleValue, role: roleValue,
department_id: data.department_id || null,
position_id: data.position_id || null,
status: statusValue, // 使 status: statusValue, // 使
tenant_id: data.tenant_id || tenantId, tenant_id: data.tenant_id || tenantId,
}; };
//
if (form.value.department_id) {
await loadPositions(form.value.department_id);
}
} catch (e: any) { } catch (e: any) {
console.error('Failed to load user data:', e); console.error('Failed to load user data:', e);
const errorMsg = e?.response?.data?.message || e?.message || "加载用户失败"; 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 = () => { const handleCancel = () => {
visible.value = false; visible.value = false;
@ -229,6 +303,8 @@ const handleClose = () => {
role: null, role: null,
status: "1", status: "1",
tenant_id: null, tenant_id: null,
department_id: null,
position_id: null,
}; };
isAdd.value = false; isAdd.value = false;
emit('close'); emit('close');
@ -250,6 +326,13 @@ const handleSubmit = async () => {
submitData.role = form.value.role; 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) { if (form.value.tenant_id) {
submitData.tenant_id = form.value.tenant_id; submitData.tenant_id = form.value.tenant_id;
} }
@ -275,6 +358,13 @@ const handleSubmit = async () => {
submitData.role = form.value.role; 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) { if (form.value.tenant_id) {
submitData.tenant_id = form.value.tenant_id; submitData.tenant_id = form.value.tenant_id;
} }
@ -305,6 +395,8 @@ defineExpose({
role: null, role: null,
status: "1", status: "1",
tenant_id: tenantId || getCurrentTenantId(), tenant_id: tenantId || getCurrentTenantId(),
department_id: null,
position_id: null,
}; };
visible.value = true; visible.value = true;
}, },

View File

@ -32,7 +32,16 @@
align="center" align="center"
min-width="200" 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"> <el-table-column prop="role" label="角色" width="150" align="center">
<template #default="scope"> <template #default="scope">
<el-tag :type="getRoleTagType(scope.row.roleName)"> <el-tag :type="getRoleTagType(scope.row.roleName)">
@ -107,6 +116,8 @@ import {
getUserInfo, getUserInfo,
} from "@/api/user"; } from "@/api/user";
import { getRoleByTenantId, getAllRoles } from "@/api/role"; import { getRoleByTenantId, getAllRoles } from "@/api/role";
import { getTenantDepartments } from "@/api/department";
import { getTenantPositions } from "@/api/position";
import { getDictItemsByCode } from '@/api/dict' import { getDictItemsByCode } from '@/api/dict'
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
@ -129,6 +140,14 @@ const props = defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
departmentList: {
type: Array,
default: () => [],
},
positionList: {
type: Array,
default: () => [],
},
statusDict: { statusDict: {
type: Array, type: Array,
default: () => [], default: () => [],
@ -216,6 +235,22 @@ const fetchUsers = async () => {
roleName = roleInfo ? roleInfo.roleName : ''; 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 lastLoginTime = item.last_login_time || item.lastLoginTime || null;
const lastLoginIp = item.last_login_ip || item.lastLoginIp || null; const lastLoginIp = item.last_login_ip || item.lastLoginIp || null;
@ -227,6 +262,10 @@ const fetchUsers = async () => {
email: item.email, email: item.email,
role: roleValue, role: roleValue,
roleName: roleName, roleName: roleName,
department_id: departmentId,
departmentName: departmentName,
position_id: positionId,
positionName: positionName,
status: item.status, status: item.status,
lastLoginTime: lastLoginTime lastLoginTime: lastLoginTime
? new Date(lastLoginTime).toLocaleString("zh-CN", { ? new Date(lastLoginTime).toLocaleString("zh-CN", {

View File

@ -36,7 +36,16 @@
align="center" align="center"
min-width="200" 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"> <el-table-column prop="role" label="角色" width="150" align="center">
<template #default="scope"> <template #default="scope">
<el-tag :type="getRoleTagType(scope.row.roleName)"> <el-tag :type="getRoleTagType(scope.row.roleName)">
@ -105,7 +114,11 @@
@update:modelValue="editDialogVisible = $event" @update:modelValue="editDialogVisible = $event"
:is-edit="isEdit" :is-edit="isEdit"
:role-list="roleList" :role-list="roleList"
:department-list="departmentList"
:position-list="positionList"
:loading-roles="loadingRoles" :loading-roles="loadingRoles"
:loading-departments="loadingDepartments"
:loading-positions="loadingPositions"
:status-dict="statusDict" :status-dict="statusDict"
:tenant-id="getCurrentTenantId()" :tenant-id="getCurrentTenantId()"
@submit="handleEditSuccess" @submit="handleEditSuccess"
@ -134,6 +147,8 @@ import {
getUserInfo, getUserInfo,
} from "@/api/user"; } from "@/api/user";
import { getRoleByTenantId, getAllRoles } from "@/api/role"; import { getRoleByTenantId, getAllRoles } from "@/api/role";
import { getTenantDepartments } from "@/api/department";
import { getTenantPositions, getPositionsByDepartment } from "@/api/position";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useDictStore } from "@/stores/dict"; import { useDictStore } from "@/stores/dict";
import UserEditDialog from './components/UserEdit.vue' import UserEditDialog from './components/UserEdit.vue'
@ -160,6 +175,10 @@ const total = ref(0);
const users = ref<any[]>([]); const users = ref<any[]>([]);
const roleList = ref<any[]>([]); const roleList = ref<any[]>([]);
const loadingRoles = ref(false); const loadingRoles = ref(false);
const departmentList = ref<any[]>([]);
const loadingDepartments = ref(false);
const positionList = ref<any[]>([]);
const loadingPositions = ref(false);
const loading = ref(false); const loading = ref(false);
// //
@ -178,8 +197,11 @@ const changePasswordRef = ref()
const fetchStatusDict = async () => { const fetchStatusDict = async () => {
try { try {
console.log('Starting to fetch status dict...');
const items = await dictStore.getDictItems('user_status'); const items = await dictStore.getDictItems('user_status');
console.log('Fetched statusDict items:', items);
statusDict.value = items; statusDict.value = items;
console.log('statusDict.value updated:', statusDict.value);
} catch (err) { } catch (err) {
console.error('Error fetching status dict:', err); console.error('Error fetching status dict:', err);
statusDict.value = []; 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) => { const getRoleTagType = (roleName: string) => {
@ -302,6 +365,20 @@ const fetchUsers = async () => {
roleName = roleInfo ? roleInfo.roleName : ''; 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'; const statusValue = item.status !== undefined && item.status !== null ? item.status : '1';
@ -315,6 +392,10 @@ const fetchUsers = async () => {
email: item.email, email: item.email,
role: roleValue, role: roleValue,
roleName: roleName, roleName: roleName,
department_id: departmentId,
departmentName: departmentName,
position_id: positionId,
positionName: positionName,
status: statusValue, // 使 status: statusValue, // 使
lastLoginTime: lastLoginTime lastLoginTime: lastLoginTime
? new Date(lastLoginTime).toLocaleString("zh-CN", { ? new Date(lastLoginTime).toLocaleString("zh-CN", {
@ -342,6 +423,8 @@ const fetchUsers = async () => {
onMounted(async () => { onMounted(async () => {
await Promise.all([ await Promise.all([
fetchRoles(), fetchRoles(),
fetchDepartments(),
fetchPositions(),
fetchStatusDict(), fetchStatusDict(),
]); ]);
fetchUsers(); fetchUsers();
@ -356,6 +439,8 @@ const refresh = async () => {
try { try {
await Promise.all([ await Promise.all([
fetchRoles(), fetchRoles(),
fetchDepartments(),
fetchPositions(),
fetchStatusDict(), fetchStatusDict(),
]); ]);
await fetchUsers(); await fetchUsers();
@ -402,7 +487,14 @@ const handleEditSuccess = () => {
fetchUsers(); fetchUsers();
}; };
//
const handleFetchPositions = (departmentId: number | null) => {
if (departmentId && departmentId > 0) {
fetchPositions(departmentId);
} else {
fetchPositions();
}
};
// //
const handleDelete = async (user: User) => { const handleDelete = async (user: User) => {

View File

@ -194,9 +194,9 @@ func (c *AuthController) Login() {
TenantId: tenantId, TenantId: tenantId,
UserId: userId, UserId: userId,
Username: usernameForToken, Username: usernameForToken,
Module: "auth", Module: "登录模块",
ResourceType: "user", ResourceType: "user",
Operation: "LOGIN", Operation: "登录",
IpAddress: clientIP, IpAddress: clientIP,
UserAgent: c.Ctx.Input.Header("User-Agent"), UserAgent: c.Ctx.Input.Header("User-Agent"),
RequestMethod: "POST", RequestMethod: "POST",

View File

@ -2,6 +2,8 @@ package controllers
import ( import (
"server/models" "server/models"
"server/services"
"time"
"github.com/beego/beego/v2/client/orm" "github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web" beego "github.com/beego/beego/v2/server/web"
@ -106,3 +108,104 @@ func (c *DashboardController) GetTenantStats() {
} }
c.ServeJSON() 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()
}

View File

@ -1,6 +1,7 @@
package controllers package controllers
import ( import (
"fmt"
"server/models" "server/models"
"server/services" "server/services"
"strconv" "strconv"
@ -245,3 +246,193 @@ func (c *OperationLogController) ClearOldLogs() {
} }
c.ServeJSON() 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(&params); err != nil {
// 如果ParseForm失败尝试解析JSON
c.Ctx.Input.Bind(&params, "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
View 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() {
// 获取租户IDJWT中间件写入
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()
}

View 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
View 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()

View 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
View 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
View 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
View 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, &params); 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, &params); 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, &params); 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
}

Binary file not shown.

View 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

View 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
View 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)

View 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"
}
}
}
}
}

View File

@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"bytes"
"fmt" "fmt"
"io" "io"
"server/models" "server/models"
@ -12,15 +13,22 @@ import (
"github.com/beego/beego/v2/server/web/context" "github.com/beego/beego/v2/server/web/context"
) )
// OperationLogMiddleware 操作日志中间件 - 记录所有的CREATE、UPDATE、DELETE操作 // OperationLogMiddleware 操作日志中间件 - 记录所有接口的调用记录
func OperationLogMiddleware(ctx *context.Context) { func OperationLogMiddleware(ctx *context.Context) {
// 记录所有重要操作包括修改类POST/PUT/PATCH/DELETE和读取类GET用于统计账户访问功能 // 跳过静态资源和内部路由
url := ctx.Input.URL()
if shouldSkipLogging(url) {
return
}
method := ctx.Input.Method() method := ctx.Input.Method()
// 获取用户信息和租户信息(由 JWT 中间件设置在 Input.Data 中) // 获取用户信息和租户信息(由 JWT 中间件设置在 Input.Data 中)
userId := 0 userId := 0
tenantId := 0 tenantId := 0
username := "" username := ""
userType := "" // 用户类型user(平台用户) 或 employee(租户员工)
if v := ctx.Input.GetData("userId"); v != nil { if v := ctx.Input.GetData("userId"); v != nil {
if id, ok := v.(int); ok { if id, ok := v.(int); ok {
userId = id userId = id
@ -36,112 +44,90 @@ func OperationLogMiddleware(ctx *context.Context) {
username = s 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 == "" { if username == "" {
username = "anonymous" username = "anonymous"
} }
}
// 读取请求体(对于有请求体的方法) // 读取请求体(对于有请求体的方法)
var requestBody string var requestBody string
if method == "POST" || method == "PUT" || method == "PATCH" { if method == "POST" || method == "PUT" || method == "PATCH" {
body, err := io.ReadAll(ctx.Request.Body) body, err := io.ReadAll(ctx.Request.Body)
if err == nil { if err == nil && len(body) > 0 {
requestBody = string(body) requestBody = string(body)
// 重置请求体,使其可以被后续处理 // 重置请求体,使其可以被后续处理
ctx.Request.Body = io.NopCloser(strings.NewReader(requestBody)) ctx.Request.Body = io.NopCloser(bytes.NewBuffer(body))
} }
} }
startTime := time.Now() startTime := time.Now()
ipAddress := ctx.Input.IP()
userAgent := ctx.Input.Header("User-Agent")
queryString := ctx.Request.URL.RawQuery
// 使用延迟函数来记录操作 // 使用延迟函数来记录操作
defer func() { defer func() {
duration := time.Since(startTime) duration := time.Since(startTime)
// 解析操作类型 // 解析操作相关信息
operation := parseOperationType(method, ctx.Input.URL()) operation := parseOperationType(method, url)
resourceType := parseResourceType(ctx.Input.URL()) module := parseModule(url)
resourceId := parseResourceId(ctx.Input.URL()) resourceType := parseResourceType(url)
module := parseModule(ctx.Input.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{ log := &models.OperationLog{
TenantId: tenantId, TenantId: tenantId,
UserId: userId, UserId: userId,
Username: username, Username: username,
Module: module, Module: module,
ResourceType: resourceType, ResourceType: resourceType,
ResourceId: &resourceId,
Operation: operation, Operation: operation,
IpAddress: ctx.Input.IP(), IpAddress: ipAddress,
UserAgent: ctx.Input.Header("User-Agent"), UserAgent: userAgent,
RequestMethod: method, RequestMethod: method,
RequestUrl: ctx.Input.URL(), RequestUrl: url,
Status: 1, // 默认成功,实际应该根据响应状态码更新 Status: 1, // 默认成功
Duration: int(duration.Milliseconds()), Duration: int(duration.Milliseconds()),
CreateTime: time.Now(), CreateTime: time.Now(),
} }
// 如果是写操作保存请求体作为新值对于读取操作可以在Description里记录query // 设置资源ID
if requestBody != "" { if resourceId > 0 {
log.NewValue = requestBody log.ResourceId = &resourceId
} else if method == "GET" {
// 把查询字符串放到描述里,便于分析访问参数
qs := ctx.Request.URL.RawQuery
if qs != "" {
log.Description = "query=" + qs
}
} }
// 标记匿名访问信息(当 userId==0 // 记录请求信息到Description
if userId == 0 { var description strings.Builder
if log.Description != "" { if requestBody != "" {
log.Description = "anonymous=true; " + log.Description description.WriteString("Request: " + truncateString(requestBody, 500))
} else {
log.Description = "anonymous=true"
} }
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" 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
View 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
}

View File

@ -29,6 +29,17 @@ type User struct {
LastLoginIp string `orm:"column(last_login_ip);null;size(50)" json:"last_login_ip"` 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 // TableName 设置表名默认为yz_users
func (u *User) TableName() string { func (u *User) TableName() string {
return "yz_users" return "yz_users"
@ -47,6 +58,7 @@ func Init(version string) {
orm.RegisterModel(new(DictType)) orm.RegisterModel(new(DictType))
orm.RegisterModel(new(DictItem)) orm.RegisterModel(new(DictItem))
orm.RegisterModel(new(OperationLog)) orm.RegisterModel(new(OperationLog))
orm.RegisterModel(new(AccessLog))
ormConfig, err := beego.AppConfig.String("orm") ormConfig, err := beego.AppConfig.String("orm")
if err != nil { if err != nil {
@ -90,5 +102,20 @@ func Init(version string) {
fmt.Println("数据库连接成功!") fmt.Println("数据库连接成功!")
fmt.Printf("当前项目版本: %s\n", version) fmt.Printf("当前项目版本: %s\n", version)
fmt.Println("数据库连接池配置: MaxIdleConns=10, MaxOpenConns=100, ConnMaxLifetime=1h") 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("数据库表自动同步完成")
} }
} }

View File

@ -312,6 +312,10 @@ func init() {
// OA基础数据合并接口一次性获取部门、职位、角色 // OA基础数据合并接口一次性获取部门、职位、角色
beego.Router("/api/oa/base-data/:tenantId", &controllers.OAController{}, "get:GetOABaseData") 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/menus", &controllers.PermissionController{}, "get:GetAllMenuPermissions")
beego.Router("/api/permissions/role/:roleId", &controllers.PermissionController{}, "get:GetRolePermissions") 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/platform-stats", &controllers.DashboardController{}, "get:GetPlatformStats")
beego.Router("/api/dashboard/tenant-stats", &controllers.DashboardController{}, "get:GetTenantStats") 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") 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/tenant/stats", &controllers.OperationLogController{}, "get:GetTenantStats")
beego.Router("/api/operation-logs/clear", &controllers.OperationLogController{}, "post:ClearOldLogs") 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.

View File

@ -41,7 +41,7 @@ func GetModuleName(module string) string {
// 如果菜单表中找不到,使用默认映射 // 如果菜单表中找不到,使用默认映射
defaultMap := map[string]string{ defaultMap := map[string]string{
"auth": "认证", "auth": "登录模块",
"dict": "字典管理", "dict": "字典管理",
"user": "用户管理", "user": "用户管理",
"role": "角色管理", "role": "角色管理",
@ -142,7 +142,7 @@ func GetModuleNames(modules []string) (map[string]string, error) {
// 3. 对于仍然没有匹配的,使用默认映射 // 3. 对于仍然没有匹配的,使用默认映射
defaultMap := map[string]string{ defaultMap := map[string]string{
"auth": "认证", "auth": "登录模块",
"dict": "字典管理", "dict": "字典管理",
"user": "用户管理", "user": "用户管理",
"role": "角色管理", "role": "角色管理",
@ -367,3 +367,139 @@ func DeleteOldLogs(keepDays int) (int64, error) {
return rowsAffected, nil 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
View 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"))
}

View 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.