增加任务管理模块
This commit is contained in:
+64
-45
@@ -56,11 +56,46 @@ dictStore.clearCache('user_status')
|
||||
|
||||
---
|
||||
|
||||
### 2. **常量**: `src/constants/dictCodes.js`
|
||||
|
||||
集中管理所有字典编码
|
||||
|
||||
**使用示例**:
|
||||
```javascript
|
||||
import { DICT_CODES } from '@/constants/dictCodes'
|
||||
|
||||
// 好处:避免硬编码,IDE 有自动完成
|
||||
const items = await dictStore.getDictItems(DICT_CODES.USER_STATUS)
|
||||
|
||||
// 所有可用的编码:
|
||||
DICT_CODES.USER_STATUS // 用户状态
|
||||
DICT_CODES.USER_GENDER // 用户性别
|
||||
DICT_CODES.USER_ROLE // 用户角色
|
||||
DICT_CODES.DEPT_STATUS // 部门状态
|
||||
DICT_CODES.POSITION_STATUS // 职位状态
|
||||
// ... 更多编码
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. **Composable**: `src/composables/useDict.js`
|
||||
|
||||
简化在组件中使用字典的 Hook
|
||||
|
||||
**基础用法**:
|
||||
```javascript
|
||||
import { useDictionary, useUserStatusDict } from '@/composables/useDict'
|
||||
import { DICT_CODES } from '@/constants/dictCodes'
|
||||
|
||||
// 方式1:使用常量
|
||||
const { statusDict, loading } = useDictionary(DICT_CODES.USER_STATUS)
|
||||
|
||||
// 方式2:使用字符串
|
||||
const { dicts, loading } = useDictionary('user_status')
|
||||
|
||||
// 方式3:使用特化 Hook(推荐)
|
||||
const { user_statusDict, loading } = useUserStatusDict()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -126,9 +161,9 @@ const props = defineProps({
|
||||
|
||||
---
|
||||
|
||||
### 场景3:直接使用字典Store
|
||||
### 场景3:快速使用 Composable Hook
|
||||
|
||||
直接使用字典Store获取数据:
|
||||
最简单的方式,自动处理加载:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
@@ -136,7 +171,7 @@ const props = defineProps({
|
||||
<p v-if="loading">加载中...</p>
|
||||
<el-select v-else v-model="status">
|
||||
<el-option
|
||||
v-for="item in statusDict"
|
||||
v-for="item in user_statusDict"
|
||||
:key="item.dict_value"
|
||||
:label="item.dict_label"
|
||||
:value="item.dict_value"
|
||||
@@ -146,30 +181,11 @@ const props = defineProps({
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useDictStore } from '@/stores/dict'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useUserStatusDict } from '@/composables/useDict'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const dictStore = useDictStore()
|
||||
const status = ref('1')
|
||||
const statusDict = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
const fetchStatusDict = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const items = await dictStore.getDictItems('user_status')
|
||||
statusDict.value = items
|
||||
} catch (error) {
|
||||
console.error('获取用户状态字典失败:', error)
|
||||
statusDict.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStatusDict()
|
||||
})
|
||||
const status = ref('active')
|
||||
const { user_statusDict, loading } = useUserStatusDict()
|
||||
</script>
|
||||
```
|
||||
|
||||
@@ -183,6 +199,7 @@ onMounted(() => {
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { useDictStore } from '@/stores/dict'
|
||||
import { DICT_CODES } from '@/constants/dictCodes'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
@@ -192,9 +209,9 @@ app.use(pinia)
|
||||
// 在应用启动后预加载常用字典
|
||||
const dictStore = useDictStore()
|
||||
await dictStore.preloadDicts([
|
||||
'user_status',
|
||||
'common_status',
|
||||
'yes_no',
|
||||
DICT_CODES.USER_STATUS,
|
||||
DICT_CODES.COMMON_STATUS,
|
||||
DICT_CODES.YES_NO,
|
||||
])
|
||||
|
||||
app.mount('#app')
|
||||
@@ -238,29 +255,29 @@ app.mount('#app')
|
||||
|
||||
### ✅ DO
|
||||
|
||||
1. **使用字符串而不是硬编码数字**
|
||||
1. **使用常量而不是硬编码字符串**
|
||||
```javascript
|
||||
// ✅ 好
|
||||
dictStore.getDictItems('user_status')
|
||||
dictStore.getDictItems(DICT_CODES.USER_STATUS)
|
||||
|
||||
// ❌ 差
|
||||
// 避免直接使用数字,应该使用字符串
|
||||
dictStore.getDictItems('user_status')
|
||||
```
|
||||
|
||||
2. **在父组件加载,通过 props 传给子组件**
|
||||
```javascript
|
||||
// ✅ 父组件负责数据,子组件负责展示
|
||||
// index.vue
|
||||
const statusDict = await dictStore.getDictItems('user_status')
|
||||
const statusDict = await dictStore.getDictItems(DICT_CODES.USER_STATUS)
|
||||
|
||||
// UserEdit.vue
|
||||
const props = defineProps({ statusDict: Array })
|
||||
```
|
||||
|
||||
3. **直接使用字典Store**
|
||||
3. **用 Composable 简化组件逻辑**
|
||||
```javascript
|
||||
// ✅ 直接使用Store获取数据
|
||||
const statusDict = await dictStore.getDictItems('user_status')
|
||||
// ✅ 一行代码搞定
|
||||
const { user_statusDict, loading } = useUserStatusDict()
|
||||
```
|
||||
|
||||
4. **预加载常用字典**
|
||||
@@ -354,12 +371,14 @@ const item = items.find(i =>
|
||||
|
||||
## 集成检清表
|
||||
|
||||
- [x] 创建 `src/stores/dict.js` - Store
|
||||
- [x] 在 `index.vue` 中导入 `useDictStore`
|
||||
- [x] 在 `UserEdit.vue` 中使用 `useDictStore` 获取字典数据
|
||||
- [x] 测试字典加载和显示
|
||||
- [x] 验证缓存功能(打开浏览器 DevTools 检查 Network)
|
||||
- [x] 预加载常用字典(可选)
|
||||
- [ ] 创建 `src/stores/dict.js` - Store
|
||||
- [ ] 创建 `src/constants/dictCodes.js` - 常量
|
||||
- [ ] 创建 `src/composables/useDict.js` - Composable
|
||||
- [ ] 在 `index.vue` 中导入 `useDictStore`
|
||||
- [ ] 在 `UserEdit.vue` 中接收 `statusDict` props
|
||||
- [ ] 测试字典加载和显示
|
||||
- [ ] 验证缓存功能(打开浏览器 DevTools 检查 Network)
|
||||
- [ ] 预加载常用字典(可选)
|
||||
|
||||
---
|
||||
|
||||
@@ -367,8 +386,8 @@ const item = items.find(i =>
|
||||
|
||||
已修改的文件:
|
||||
- ✅ `src/stores/dict.js` - 新建
|
||||
- ✅ `src/constants/dictCodes.js` - 新建
|
||||
- ✅ `src/composables/useDict.js` - 新建
|
||||
- ✅ `src/views/system/users/index.vue` - 使用 `useDictStore`
|
||||
- ✅ `src/views/system/users/components/UserEdit.vue` - 使用字典功能
|
||||
- ✅ `src/constants/dictCodes.js` - 删除(不再需要)
|
||||
- ✅ `src/composables/useDict.js` - 删除(不再需要)
|
||||
- ✅ `src/views/system/users/components/UserEdit.vue` - 导入字典库
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取访问日志列表
|
||||
export function getAccessLogs(params) {
|
||||
return request({
|
||||
url: '/api/access-logs',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 根据ID获取访问日志详情
|
||||
export function getAccessLogById(id) {
|
||||
return request({
|
||||
url: `/api/access-logs/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户访问统计
|
||||
export function getUserAccessStats(params) {
|
||||
return request({
|
||||
url: '/api/access-logs/user/stats',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 清空旧访问日志
|
||||
export function clearOldAccessLogs(keepDays = 90) {
|
||||
return request({
|
||||
url: '/api/access-logs/clear',
|
||||
method: 'post',
|
||||
data: { keep_days: keepDays }
|
||||
})
|
||||
}
|
||||
@@ -22,3 +22,20 @@ export function getTenantStats() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户活动日志(操作日志和登录日志)
|
||||
* @param {number} pageNum - 页码
|
||||
* @param {number} pageSize - 每页数量
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function getActivityLogs(pageNum = 1, pageSize = 10) {
|
||||
return request({
|
||||
url: "/api/dashboard/user-activity-logs",
|
||||
method: "get",
|
||||
params: {
|
||||
page_num: pageNum,
|
||||
page_size: pageSize,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -43,3 +43,38 @@ export function clearOldLogs(keepDays = 90) {
|
||||
data: { keep_days: keepDays }
|
||||
})
|
||||
}
|
||||
|
||||
// 获取访问日志列表
|
||||
export function getAccessLogs(params) {
|
||||
return request({
|
||||
url: '/api/access-logs',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 根据ID获取访问日志详情
|
||||
export function getAccessLogById(id) {
|
||||
return request({
|
||||
url: `/api/access-logs/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户访问统计
|
||||
export function getUserAccessStats(params) {
|
||||
return request({
|
||||
url: '/api/access-logs/user/stats',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 清空旧访问日志
|
||||
export function clearOldAccessLogs(keepDays = 90) {
|
||||
return request({
|
||||
url: '/api/access-logs/clear',
|
||||
method: 'post',
|
||||
data: { keep_days: keepDays }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listTasks(params) {
|
||||
return request({
|
||||
url: '/api/oa/tasks',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getTask(id) {
|
||||
return request({
|
||||
url: `/api/oa/tasks/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createTask(data) {
|
||||
return request({
|
||||
url: '/api/oa/tasks',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateTask(id, data) {
|
||||
return request({
|
||||
url: `/api/oa/tasks/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteTask(id) {
|
||||
return request({
|
||||
url: `/api/oa/tasks/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
@@ -50,8 +50,8 @@ import {
|
||||
} from "@/api/department";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useOAStore } from "@/stores/oa";
|
||||
import DepartmentList from "../components/departments/DepartmentList.vue";
|
||||
import DepartmentEdit from "../components/departments/DepartmentEdit.vue";
|
||||
import DepartmentList from "./components/DepartmentList.vue";
|
||||
import DepartmentEdit from "./components/DepartmentEdit.vue";
|
||||
|
||||
interface Department {
|
||||
id: number;
|
||||
|
||||
@@ -69,9 +69,9 @@ import {
|
||||
} from "@/api/employee";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useOAStore } from "@/stores/oa";
|
||||
import EmployeeList from "../components/employees/EmployeeList.vue";
|
||||
import EmployeeEdit from "../components/employees/EmployeeEdit.vue";
|
||||
import EmployeePasswordChange from "../components/employees/EmployeePasswordChange.vue";
|
||||
import EmployeeList from "./components/EmployeeList.vue";
|
||||
import EmployeeEdit from "./components/EmployeeEdit.vue";
|
||||
import EmployeePasswordChange from "./components/EmployeePasswordChange.vue";
|
||||
|
||||
interface Employee {
|
||||
id: number;
|
||||
|
||||
-1
@@ -126,4 +126,3 @@ const handleSubmit = () => {
|
||||
emit('submit', { ...form.value });
|
||||
};
|
||||
</script>
|
||||
|
||||
-11
@@ -163,16 +163,6 @@ const handleCollapseAll = () => {
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
|
||||
// .el-button {
|
||||
// padding: 0;
|
||||
// font-size: 13px;
|
||||
// color: var(--el-color-primary);
|
||||
|
||||
// &:hover {
|
||||
// color: var(--el-color-primary-light-3);
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,4 +244,3 @@ const handleCollapseAll = () => {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
-1
@@ -126,4 +126,3 @@ const handleSubmit = () => {
|
||||
emit('submit', { ...form.value });
|
||||
};
|
||||
</script>
|
||||
|
||||
-1
@@ -193,4 +193,3 @@ const handleDelete = (position: any) => {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -81,10 +81,10 @@ import {
|
||||
} from "@/api/position";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useOAStore } from "@/stores/oa";
|
||||
import DepartmentTree from "../components/organization/DepartmentTree.vue";
|
||||
import PositionList from "../components/organization/PositionList.vue";
|
||||
import OrganizationDepartmentEdit from "../components/organization/DepartmentEdit.vue";
|
||||
import OrganizationPositionEdit from "../components/organization/PositionEdit.vue";
|
||||
import DepartmentTree from "./components/DepartmentTree.vue";
|
||||
import PositionList from "./components/PositionList.vue";
|
||||
import OrganizationDepartmentEdit from "./components/DepartmentEdit.vue";
|
||||
import OrganizationPositionEdit from "./components/PositionEdit.vue";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const oaStore = useOAStore();
|
||||
|
||||
@@ -52,8 +52,8 @@ import {
|
||||
} from "@/api/position";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useOAStore } from "@/stores/oa";
|
||||
import PositionList from "../components/positions/PositionList.vue";
|
||||
import PositionEdit from "../components/positions/PositionEdit.vue";
|
||||
import PositionList from "./components/PositionList.vue";
|
||||
import PositionEdit from "./components/PositionEdit.vue";
|
||||
|
||||
interface Position {
|
||||
id: number;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<div class="task-page">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="query.keyword" placeholder="搜索任务名称/编号/负责人" clearable @keyup.enter="fetchList" />
|
||||
<el-select v-model="query.status" placeholder="状态" clearable @change="handleFilterChange">
|
||||
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||
</el-select>
|
||||
<el-select v-model="query.priority" placeholder="优先级" clearable @change="handleFilterChange">
|
||||
<el-option v-for="p in priorityOptions" :key="p.value" :label="p.label" :value="p.value" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="openCreate">新建任务</el-button>
|
||||
<el-button @click="fetchList">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="table-card">
|
||||
<el-table :data="list" border stripe v-loading="loading">
|
||||
<el-table-column type="index" label="#" width="60" align="center" />
|
||||
<el-table-column prop="task_no" label="编号" width="180" show-overflow-tooltip align="center" />
|
||||
<el-table-column prop="task_name" label="任务名称" min-width="220" show-overflow-tooltip align="center">
|
||||
<!-- <template #default="{ row }">
|
||||
<el-link type="primary" @click="openDetail(row)">{{ row.task_name }}</el-link>
|
||||
</template> -->
|
||||
</el-table-column>
|
||||
<el-table-column prop="principal_name" label="负责人" width="120" align="center" />
|
||||
<el-table-column prop="priority" label="优先级" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="priorityTagType(row.priority)" effect="plain">{{ priorityText(row.priority) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="task_status" label="状态" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.task_status)">{{ statusText(row.task_status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="progress" label="进度" width="200" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="Number(row.progress || 0)" :stroke-width="14" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="任务周期" min-width="300" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.plan_start_time) }} ~ {{ formatDateTime(row.plan_end_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link size="small" @click="openDetail(row)">查看</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="openDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.pageSize"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
@current-change="fetchList"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<Edit
|
||||
v-model="formDialogVisible"
|
||||
:is-edit="isEdit"
|
||||
:task="currentTask"
|
||||
@saved="fetchList"
|
||||
/>
|
||||
|
||||
<Detail
|
||||
v-model="detailVisible"
|
||||
:task="currentTask"
|
||||
/>
|
||||
|
||||
<!-- 删除改为内联确认,不使用独立组件 -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { listTasks, getTask, deleteTask } from '@/api/tasks'
|
||||
import Edit from './components/edit.vue'
|
||||
import Detail from './components/detail.vue'
|
||||
import { useDictStore } from '@/stores/dict'
|
||||
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const list = ref<any[]>([])
|
||||
|
||||
const dictStore = useDictStore()
|
||||
const statusOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
const priorityOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
|
||||
const query = reactive({
|
||||
keyword: '',
|
||||
status: '',
|
||||
priority: '',
|
||||
page: 1,
|
||||
pageSize: 10
|
||||
})
|
||||
|
||||
const formDialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const currentTask = ref<any>(null)
|
||||
const detailVisible = ref(false)
|
||||
const deleteDialogVisible = ref(false) // 保留变量但不渲染独立组件
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await listTasks({
|
||||
keyword: query.keyword,
|
||||
status: query.status,
|
||||
priority: query.priority,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize
|
||||
})
|
||||
if (res?.code === 0) {
|
||||
list.value = res.data?.list || res.data?.items || res.data || []
|
||||
total.value = res.data?.total || list.value.length
|
||||
} else {
|
||||
ElMessage.error(res?.message || '获取任务列表失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('获取任务列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
query.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleSizeChange = () => {
|
||||
query.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
isEdit.value = false
|
||||
currentTask.value = null
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
const openEdit = async (row: any) => {
|
||||
isEdit.value = true
|
||||
try {
|
||||
const res: any = await getTask(row.id)
|
||||
currentTask.value = res?.data || row
|
||||
} catch {
|
||||
currentTask.value = row
|
||||
}
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
const openDetail = (row: any) => {
|
||||
currentTask.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const openDelete = (row: any) => {
|
||||
ElMessageBox.confirm(`确认删除任务「${row.task_name || row.task_no}」吗?`, '提示', { type: 'warning' })
|
||||
.then(async () => {
|
||||
const res: any = await deleteTask(row.id)
|
||||
if (res?.code === 0 || res?.success) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res?.message || '删除失败')
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const priorityTagType = (v: string) => {
|
||||
if (v === 'urgent') return 'danger'
|
||||
if (v === 'high') return 'warning'
|
||||
if (v === 'low') return 'success'
|
||||
return 'info'
|
||||
}
|
||||
const statusTagType = (v: string) => {
|
||||
if (v === 'in_progress') return 'warning'
|
||||
if (v === 'completed') return 'success'
|
||||
if (v === 'closed') return 'info'
|
||||
if (v === 'paused') return 'danger'
|
||||
return ''
|
||||
}
|
||||
const priorityText = (v: string) => dictStore.getDictLabel('task_priority', v)
|
||||
const statusText = (v: string) => dictStore.getDictLabel('task_status', v)
|
||||
const formatDateTime = (v: any) => {
|
||||
if (!v) return '-'
|
||||
const d = new Date(v)
|
||||
if (isNaN(d.getTime())) return String(v)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [pri, sts] = await Promise.all([
|
||||
dictStore.getDictItems('task_priority'),
|
||||
dictStore.getDictItems('task_status')
|
||||
])
|
||||
priorityOptions.value = (pri || []).map((it: any) => ({
|
||||
label: it.dict_label ?? it.dictLabel ?? it.label ?? it.name,
|
||||
value: it.dict_value ?? it.dictValue ?? it.value ?? it.code
|
||||
}))
|
||||
statusOptions.value = (sts || []).map((it: any) => ({
|
||||
label: it.dict_label ?? it.dictLabel ?? it.label ?? it.name,
|
||||
value: it.dict_value ?? it.dictValue ?? it.value ?? it.code
|
||||
}))
|
||||
} catch {}
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.task-page {
|
||||
padding: 16px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: nowrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.table-card {
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
:deep(.toolbar .el-button){
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -10,12 +10,7 @@
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-grid">
|
||||
<div
|
||||
v-for="(stat, index) in stats"
|
||||
:key="index"
|
||||
class="stat-card"
|
||||
:class="stat.type"
|
||||
>
|
||||
<div v-for="(stat, index) in stats" :key="index" class="stat-card" :class="stat.type">
|
||||
<div class="stat-icon-wrapper">
|
||||
<el-icon :size="28">
|
||||
<component :is="stat.icon" />
|
||||
@@ -25,25 +20,28 @@
|
||||
<div class="stat-value">{{ stat.value }}</div>
|
||||
<div class="stat-label">{{ stat.label }}</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat-trend"
|
||||
:class="stat.change > 0 ? 'up' : stat.change < 0 ? 'down' : 'flat'"
|
||||
>
|
||||
<el-icon v-if="stat.change > 0" :size="14"><ArrowUp /></el-icon>
|
||||
<el-icon v-else-if="stat.change < 0" :size="14"><ArrowDown /></el-icon>
|
||||
<div class="stat-trend" :class="stat.change > 0 ? 'up' : stat.change < 0 ? 'down' : 'flat'">
|
||||
<el-icon v-if="stat.change > 0" :size="14">
|
||||
<ArrowUp />
|
||||
</el-icon>
|
||||
<el-icon v-else-if="stat.change < 0" :size="14">
|
||||
<ArrowDown />
|
||||
</el-icon>
|
||||
<span>{{ stat.change > 0 ? '+' : '' }}{{ stat.change }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<div class="charts-section">
|
||||
<!-- <div class="charts-section">
|
||||
<div class="chart-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">月收入走势</h3>
|
||||
<el-dropdown trigger="click">
|
||||
<el-button type="primary" link>
|
||||
<el-icon><MoreFilled /></el-icon>
|
||||
<el-icon>
|
||||
<MoreFilled />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
@@ -63,7 +61,9 @@
|
||||
<h3 class="card-title">用户活跃分布</h3>
|
||||
<el-dropdown trigger="click">
|
||||
<el-button type="primary" link>
|
||||
<el-icon><MoreFilled /></el-icon>
|
||||
<el-icon>
|
||||
<MoreFilled />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
@@ -77,7 +77,7 @@
|
||||
<canvas id="barChart" height="160"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<div class="lists-section">
|
||||
@@ -85,68 +85,62 @@
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">待办任务</h3>
|
||||
<el-button type="primary" link size="small">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加任务
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="list-content">
|
||||
<div
|
||||
v-for="(task, idx) in tasks"
|
||||
:key="idx"
|
||||
class="task-item"
|
||||
:class="{ done: task.completed }"
|
||||
>
|
||||
<el-checkbox
|
||||
v-model="task.completed"
|
||||
@change="handleTaskChange(task)"
|
||||
/>
|
||||
<div v-for="(task, idx) in paginatedTasks" :key="idx" class="task-item" :class="{ done: task.completed }">
|
||||
<el-checkbox v-model="task.completed" @change="handleTaskChange(task)" />
|
||||
<div class="task-info">
|
||||
<div class="task-title">{{ task.title }}</div>
|
||||
<div class="task-meta">
|
||||
<span class="task-date">{{ task.date }}</span>
|
||||
<el-tag
|
||||
:type="getPriorityType(task.priority)"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
<div class="task-title">
|
||||
{{ task.title }}
|
||||
<el-tag :type="getPriorityType(task.priority)" size="small" effect="plain">
|
||||
{{ task.priority }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="task-meta">
|
||||
<span class="task-date">{{ task.date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty
|
||||
v-if="tasks.filter(t => !t.completed).length === 0"
|
||||
description="暂无待办任务"
|
||||
:image-size="80"
|
||||
/>
|
||||
<el-empty v-if="tasks.length === 0" description="暂无待办任务" :image-size="80" />
|
||||
<div v-if="tasks.length > taskPageSize" class="pagination-wrapper">
|
||||
<el-pagination v-model:current-page="taskCurrentPage" :page-size="taskPageSize" :total="tasks.length"
|
||||
layout="prev, pager, next" small @current-change="handleTaskPageChange" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">最新动态</h3>
|
||||
<el-button type="primary" link size="small">查看全部</el-button>
|
||||
<!-- <el-button type="primary" link size="small" @click="goToActivityLogs">
|
||||
查看全部
|
||||
</el-button> -->
|
||||
</div>
|
||||
<div class="list-content">
|
||||
<div
|
||||
v-for="(activity, idx) in activities"
|
||||
:key="idx"
|
||||
class="activity-item"
|
||||
>
|
||||
<el-avatar :src="activity.avatar" :size="40" />
|
||||
<div v-for="(activity, idx) in paginatedActivityLogs" :key="idx" class="activity-item">
|
||||
<div class="activity-icon" :class="activity.type">
|
||||
<el-icon>
|
||||
<component :is="getActivityIcon(activity.type)" />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="activity-info">
|
||||
<div class="activity-text">
|
||||
<span class="activity-user">{{ activity.user }}</span>
|
||||
<span class="activity-action">{{ activity.action }}</span>
|
||||
<span class="activity-module">{{ activity.operation }}</span>
|
||||
<span class="activity-action">{{ activity.description }}</span>
|
||||
</div>
|
||||
<div class="activity-time">{{ activity.time }}</div>
|
||||
<div class="activity-time">{{ formatTime(activity.timestamp) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty
|
||||
v-if="activities.length === 0"
|
||||
description="暂无动态"
|
||||
:image-size="80"
|
||||
/>
|
||||
<el-empty v-if="activityLogs.length === 0" description="暂无动态" :image-size="80" />
|
||||
<div v-if="totalActivityLogs > pageSize" class="pagination-wrapper">
|
||||
<el-pagination v-model:current-page="currentPage" :page-size="pageSize" :total="totalActivityLogs"
|
||||
layout="prev, pager, next" small @current-change="handlePageChange" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,9 +160,11 @@ import {
|
||||
MoreFilled,
|
||||
Plus,
|
||||
Document,
|
||||
Edit,
|
||||
View,
|
||||
} from "@element-plus/icons-vue";
|
||||
import { getKnowledgeCount } from "@/api/knowledge";
|
||||
import { getPlatformStats, getTenantStats } from "@/api/dashboard";
|
||||
import { getPlatformStats, getTenantStats, getActivityLogs } from "@/api/dashboard";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
Chart.register(...registerables);
|
||||
@@ -228,23 +224,23 @@ const fetchPlatformStats = async () => {
|
||||
const res = await getPlatformStats();
|
||||
if (res?.code === 0 || res?.success) {
|
||||
const data = res.data || {};
|
||||
|
||||
|
||||
// 知识库
|
||||
if (data.knowledgeCount) {
|
||||
stats.value[0].value = data.knowledgeCount.total?.toString() || "0";
|
||||
stats.value[0].change = parseFloat((data.knowledgeCount.growthRate || 0).toFixed(1));
|
||||
}
|
||||
|
||||
|
||||
// 用户数
|
||||
stats.value[1].label = "用户数";
|
||||
stats.value[1].value = (data.userCount || 0).toString();
|
||||
stats.value[1].change = 0;
|
||||
|
||||
|
||||
// 员工数
|
||||
stats.value[2].label = "员工数";
|
||||
stats.value[2].value = (data.employeeCount || 0).toString();
|
||||
stats.value[2].change = 0;
|
||||
|
||||
|
||||
// 租户数
|
||||
stats.value[3].label = "租户数";
|
||||
stats.value[3].value = (data.tenantCount || 0).toString();
|
||||
@@ -261,23 +257,23 @@ const fetchTenantStats = async () => {
|
||||
const res = await getTenantStats();
|
||||
if (res?.code === 0 || res?.success) {
|
||||
const data = res.data || {};
|
||||
|
||||
|
||||
// 知识库
|
||||
if (data.knowledgeCount) {
|
||||
stats.value[0].value = data.knowledgeCount.total?.toString() || "0";
|
||||
stats.value[0].change = parseFloat((data.knowledgeCount.growthRate || 0).toFixed(1));
|
||||
}
|
||||
|
||||
|
||||
// 员工数
|
||||
stats.value[1].label = "员工数";
|
||||
stats.value[1].value = (data.employeeCount || 0).toString();
|
||||
stats.value[1].change = 0;
|
||||
|
||||
|
||||
// 部门数
|
||||
stats.value[2].label = "部门数";
|
||||
stats.value[2].value = (data.departmentCount || 0).toString();
|
||||
stats.value[2].change = 0;
|
||||
|
||||
|
||||
// 职位数
|
||||
stats.value[3].label = "职位数";
|
||||
stats.value[3].value = (data.positionCount || 0).toString();
|
||||
@@ -289,6 +285,9 @@ const fetchTenantStats = async () => {
|
||||
};
|
||||
|
||||
// 任务列表
|
||||
const taskCurrentPage = ref(1);
|
||||
const taskPageSize = ref(5);
|
||||
|
||||
const tasks = ref([
|
||||
{
|
||||
title: "完成Q2预算审核",
|
||||
@@ -308,29 +307,116 @@ const tasks = ref([
|
||||
priority: "Low",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
title: "准备季度报告",
|
||||
date: "2024-06-12",
|
||||
priority: "High",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
title: "更新项目文档",
|
||||
date: "2024-06-13",
|
||||
priority: "Medium",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
title: "代码审查",
|
||||
date: "2024-06-14",
|
||||
priority: "Low",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
title: "客户需求沟通",
|
||||
date: "2024-06-15",
|
||||
priority: "High",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
title: "测试环境部署",
|
||||
date: "2024-06-16",
|
||||
priority: "Medium",
|
||||
completed: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// 分页后的任务列表
|
||||
const paginatedTasks = computed(() => {
|
||||
const start = (taskCurrentPage.value - 1) * taskPageSize.value;
|
||||
const end = start + taskPageSize.value;
|
||||
return tasks.value.slice(start, end);
|
||||
});
|
||||
|
||||
// 处理任务页码变化
|
||||
const handleTaskPageChange = (page: number) => {
|
||||
taskCurrentPage.value = page;
|
||||
};
|
||||
|
||||
// 动态记录
|
||||
const activities = ref([
|
||||
{
|
||||
user: "Emma",
|
||||
action: "添加了新用户",
|
||||
time: "1 小时前",
|
||||
avatar: "https://picsum.photos/id/1027/40/40",
|
||||
},
|
||||
{
|
||||
user: "John",
|
||||
action: "修改了高级权限",
|
||||
time: "3 小时前",
|
||||
avatar: "https://picsum.photos/id/1012/40/40",
|
||||
},
|
||||
{
|
||||
user: "Jessica",
|
||||
action: "完成订单分析报表",
|
||||
time: "昨天",
|
||||
avatar: "https://picsum.photos/id/1000/40/40",
|
||||
},
|
||||
]);
|
||||
const activityLogs = ref<any[]>([]);
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(5);
|
||||
const totalActivityLogs = ref(0);
|
||||
|
||||
// 分页后的活动日志
|
||||
const paginatedActivityLogs = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value;
|
||||
const end = start + pageSize.value;
|
||||
return activityLogs.value.slice(start, end);
|
||||
});
|
||||
|
||||
// 加载活动日志
|
||||
const fetchActivityLogs = async () => {
|
||||
try {
|
||||
const data = await getActivityLogs(1, 100); // 获取更多数据用于分页
|
||||
if (data?.code === 0 && data?.data?.logs) {
|
||||
activityLogs.value = data.data.logs;
|
||||
totalActivityLogs.value = data.data.logs.length;
|
||||
} else {
|
||||
console.warn('Unexpected response format:', data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch activity logs:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理页码变化
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page;
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timestamp: string | Date) => {
|
||||
if (!timestamp) return '-';
|
||||
const date = new Date(timestamp);
|
||||
if (isNaN(date.getTime())) return String(timestamp);
|
||||
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days < 1) {
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
if (hours < 1) {
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
return `${minutes}分钟前`;
|
||||
}
|
||||
return `${hours}小时前`;
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
// 获取活动图标
|
||||
const getActivityIcon = (type: string) => {
|
||||
if (type === 'operation') return 'Edit';
|
||||
if (type === 'access') return 'View';
|
||||
return 'Document';
|
||||
};
|
||||
|
||||
// 获取优先级类型
|
||||
const getPriorityType = (priority: string) => {
|
||||
@@ -355,14 +441,17 @@ onMounted(() => {
|
||||
} else {
|
||||
fetchPlatformStats();
|
||||
}
|
||||
|
||||
|
||||
// 加载活动日志
|
||||
fetchActivityLogs();
|
||||
|
||||
// 折线图
|
||||
const lineChartEl = document.getElementById("lineChart") as HTMLCanvasElement | null;
|
||||
if (!lineChartEl) {
|
||||
console.error("Line chart element not found");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
new Chart(lineChartEl, {
|
||||
type: "line",
|
||||
data: {
|
||||
@@ -427,7 +516,7 @@ onMounted(() => {
|
||||
console.error("Bar chart element not found");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
new Chart(barChartEl, {
|
||||
type: "bar",
|
||||
data: {
|
||||
@@ -700,6 +789,18 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.list-content {
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -736,8 +837,14 @@ onMounted(() => {
|
||||
.task-title {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.el-tag{
|
||||
margin-left: 8px !important;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
@@ -753,6 +860,14 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -772,6 +887,27 @@ onMounted(() => {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
|
||||
&.operation {
|
||||
background-color: rgba(79, 132, 255, 0.2);
|
||||
color: #4f84ff;
|
||||
}
|
||||
|
||||
&.access {
|
||||
background-color: rgba(85, 190, 130, 0.2);
|
||||
color: #55be82;
|
||||
}
|
||||
}
|
||||
|
||||
.activity-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -781,7 +917,7 @@ onMounted(() => {
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 4px;
|
||||
|
||||
.activity-user {
|
||||
.activity-module {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
@@ -803,6 +939,7 @@ onMounted(() => {
|
||||
|
||||
// 响应式
|
||||
@media (max-width: 1200px) {
|
||||
|
||||
.charts-section,
|
||||
.lists-section {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -1,367 +0,0 @@
|
||||
<template>
|
||||
<div class="access-log-container">
|
||||
<!-- 统计面板 -->
|
||||
<!-- <StatisticsPanel /> -->
|
||||
|
||||
<!-- 搜索和操作栏 -->
|
||||
<el-card shadow="hover" style="margin-bottom: 20px">
|
||||
<el-form :model="filters" label-width="100px" :inline="true">
|
||||
<el-form-item label="用户">
|
||||
<el-input v-model="filters.username" placeholder="搜索用户名" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="模块">
|
||||
<el-select v-model="filters.module" placeholder="选择模块" clearable>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option
|
||||
v-for="opt in moduleOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="资源类型">
|
||||
<el-input v-model="filters.resource_type" placeholder="搜索资源类型" clearable />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-form :model="dateRange" label-width="100px" :inline="true">
|
||||
<el-form-item label="访问时间">
|
||||
<el-date-picker
|
||||
v-model="dateRange.range"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div style="margin-top: 15px">
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
<el-button @click="showClearDialog">清空日志</el-button>
|
||||
<el-button @click="handleExport">导出</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 日志列表 -->
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>访问日志列表</span>
|
||||
<span class="log-count">共 {{ total }} 条</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
:data="tableData"
|
||||
stripe
|
||||
style="width: 100%; margin-bottom: 20px"
|
||||
v-loading="loading"
|
||||
@row-click="handleRowClick"
|
||||
>
|
||||
<el-table-column prop="username" label="用户" align="center" width="120" />
|
||||
<el-table-column prop="module_name" label="模块" align="center" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag>{{ row.module_name }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="resource_type" label="资源类型" align="center" width="120" />
|
||||
<el-table-column prop="request_url" label="访问路径" align="center" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="row.request_url" placement="top">
|
||||
<span>{{ row.request_url }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="request_method" label="方法" align="center" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getMethodTag(row.request_method)">
|
||||
{{ row.request_method }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip_address" label="IP地址" align="center" width="140" />
|
||||
<el-table-column prop="duration" label="耗时(ms)" align="center" width="100" />
|
||||
<el-table-column prop="create_time" label="访问时间" align="center" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.create_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="showDetail(row)">
|
||||
详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<el-dialog v-model="detailDialogVisible" title="访问日志详情" width="70%">
|
||||
<el-descriptions v-if="currentRecord" :column="2" border>
|
||||
<el-descriptions-item label="日志ID">
|
||||
{{ currentRecord.id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户">
|
||||
{{ currentRecord.username }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="租户ID">
|
||||
{{ currentRecord.tenant_id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户ID">
|
||||
{{ currentRecord.user_id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="模块">
|
||||
{{ currentRecord.module_name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="资源类型">
|
||||
{{ currentRecord.resource_type }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="资源ID">
|
||||
{{ currentRecord.resource_id || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="访问路径">
|
||||
{{ currentRecord.request_url }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="请求方法">
|
||||
{{ currentRecord.request_method }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="查询字符串">
|
||||
{{ currentRecord.query_string || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="IP地址">
|
||||
{{ currentRecord.ip_address }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="User Agent">
|
||||
<div style="word-break: break-all; font-size: 12px">
|
||||
{{ currentRecord.user_agent }}
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="耗时(ms)">
|
||||
{{ currentRecord.duration }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="访问时间">
|
||||
{{ formatTime(currentRecord.create_time) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 清空对话框 -->
|
||||
<el-dialog v-model="clearDialogVisible" title="清空日志" width="400px">
|
||||
<el-form :model="clearForm" label-width="100px">
|
||||
<el-form-item label="保留天数">
|
||||
<el-input-number v-model="clearForm.keepDays" :min="0" :max="365" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="clearDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" @click="handleClear">确定清空</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getAccessLogs, clearOldAccessLogs } from '@/api/accessLog'
|
||||
import { getAllMenus } from '@/api/menu'
|
||||
|
||||
const filters = reactive({
|
||||
username: '',
|
||||
module: '',
|
||||
resource_type: ''
|
||||
})
|
||||
|
||||
const dateRange = reactive({
|
||||
range: null
|
||||
})
|
||||
|
||||
const tableData = ref([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const detailDialogVisible = ref(false)
|
||||
const clearDialogVisible = ref(false)
|
||||
const currentRecord = ref(null)
|
||||
|
||||
const clearForm = reactive({
|
||||
keepDays: 90
|
||||
})
|
||||
|
||||
const moduleOptions = ref([])
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time) => {
|
||||
if (!time) return '-'
|
||||
const date = new Date(time)
|
||||
return date.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取HTTP方法的标签类型
|
||||
const getMethodTag = (method) => {
|
||||
const tagMap = {
|
||||
GET: 'success',
|
||||
POST: 'warning',
|
||||
PUT: 'info',
|
||||
DELETE: 'danger'
|
||||
}
|
||||
return tagMap[method] || 'info'
|
||||
}
|
||||
|
||||
// 加载菜单数据(用于模块选项)
|
||||
const loadMenus = async () => {
|
||||
try {
|
||||
const res = await getAllMenus()
|
||||
if (res.data) {
|
||||
const menus = res.data.list || res.data || []
|
||||
const options = menus.map((m) => ({
|
||||
value: m.path ? m.path.split('/').pop() : m.permission,
|
||||
label: m.name
|
||||
}))
|
||||
moduleOptions.value = options
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load menus:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载日志列表
|
||||
const loadLogs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page_num: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
username: filters.username || undefined,
|
||||
module: filters.module || undefined,
|
||||
resource_type: filters.resource_type || undefined
|
||||
}
|
||||
|
||||
if (dateRange.range && dateRange.range.length === 2) {
|
||||
params.start_time = dateRange.range[0].toLocaleString('zh-CN')
|
||||
params.end_time = dateRange.range[1].toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const res = await getAccessLogs(params)
|
||||
if (res.data) {
|
||||
tableData.value = res.data
|
||||
// 补充模块名称(从 moduleOptions 中查找)
|
||||
tableData.value.forEach((row) => {
|
||||
const module = moduleOptions.value.find((m) => m.value === row.module)
|
||||
row.module_name = module ? module.label : row.module
|
||||
})
|
||||
total.value = res.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('加载访问日志失败')
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
currentPage.value = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
// 处理重置
|
||||
const handleReset = () => {
|
||||
filters.username = ''
|
||||
filters.module = ''
|
||||
filters.resource_type = ''
|
||||
dateRange.range = null
|
||||
currentPage.value = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
// 处理日期改变
|
||||
const handleDateChange = () => {
|
||||
currentPage.value = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
// 处理分页改变
|
||||
const handlePageChange = () => {
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
// 显示详情
|
||||
const showDetail = (row) => {
|
||||
currentRecord.value = row
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 显示清空对话框
|
||||
const showClearDialog = () => {
|
||||
clearDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 处理清空日志
|
||||
const handleClear = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要清空 ${clearForm.keepDays} 天前的日志吗?该操作不可撤销。`,
|
||||
'警告',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
await clearOldAccessLogs(clearForm.keepDays)
|
||||
ElMessage.success('日志清空成功')
|
||||
clearDialogVisible.value = false
|
||||
loadLogs()
|
||||
} catch (e) {
|
||||
// 取消操作
|
||||
}
|
||||
}
|
||||
|
||||
// 处理导出
|
||||
const handleExport = () => {
|
||||
// 可以调用后端导出接口或使用前端库导出
|
||||
ElMessage.info('导出功能暂未实现')
|
||||
}
|
||||
|
||||
// 页面初始化
|
||||
onMounted(async () => {
|
||||
await loadMenus()
|
||||
await loadLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.access-log-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.log-count {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,9 @@
|
||||
<template>
|
||||
<div class="operation-log-container">
|
||||
<!-- 统计面板 -->
|
||||
<!-- <StatisticsPanel /> -->
|
||||
|
||||
<!-- 日志类型标签页 -->
|
||||
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
|
||||
<!-- 操作日志标签页 -->
|
||||
<el-tab-pane label="操作日志" name="operation">
|
||||
<!-- 搜索和操作栏 -->
|
||||
<el-card shadow="hover" style="margin-bottom: 20px">
|
||||
<el-form :model="filters" label-width="100px" :inline="true">
|
||||
@@ -123,6 +124,171 @@
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<OperationLogDetail ref="detailRef" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 访问日志标签页 -->
|
||||
<el-tab-pane label="访问日志" name="access">
|
||||
<!-- 搜索和操作栏 -->
|
||||
<el-card shadow="hover" style="margin-bottom: 20px">
|
||||
<el-form :model="accessFilters" label-width="100px" :inline="true">
|
||||
<el-form-item label="用户">
|
||||
<el-input v-model="accessFilters.username" placeholder="搜索用户名" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="模块">
|
||||
<el-select v-model="accessFilters.module" placeholder="选择模块" clearable>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option
|
||||
v-for="opt in moduleOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="资源类型">
|
||||
<el-input v-model="accessFilters.resource_type" placeholder="搜索资源类型" clearable />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-form :model="accessDateRange" label-width="100px" :inline="true">
|
||||
<el-form-item label="访问时间">
|
||||
<el-date-picker
|
||||
v-model="accessDateRange.range"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
@change="handleAccessDateChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div style="margin-top: 15px">
|
||||
<el-button type="primary" @click="handleAccessSearch">查询</el-button>
|
||||
<el-button @click="handleAccessReset">重置</el-button>
|
||||
<el-button @click="showAccessClearDialog">清空日志</el-button>
|
||||
<el-button @click="handleAccessExport">导出</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 访问日志列表 -->
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>访问日志列表</span>
|
||||
<span class="log-count">共 {{ accessTotal }} 条</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
:data="accessTableData"
|
||||
stripe
|
||||
style="width: 100%; margin-bottom: 20px"
|
||||
v-loading="accessLoading"
|
||||
@row-click="handleAccessRowClick"
|
||||
>
|
||||
<el-table-column prop="username" label="用户" align="center" width="120" />
|
||||
<el-table-column prop="ip_address" label="IP地址" align="center" width="140" />
|
||||
<el-table-column prop="request_method" label="方法" align="center" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getMethodTag(row.request_method)">
|
||||
{{ row.request_method }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="request_url" label="访问路径" align="center" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="row.request_url" placement="top">
|
||||
<span>{{ row.request_url }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="duration" label="耗时(ms)" align="center" width="100" />
|
||||
<el-table-column prop="status" label="结果" align="center" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
|
||||
{{ row.status === 1 ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="访问时间" align="center" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.create_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click.stop="showAccessDetail(row)">
|
||||
详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-model:current-page="accessPagination.page_num"
|
||||
v-model:page-size="accessPagination.page_size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="accessTotal"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@change="loadAccessLogs"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 访问日志详情对话框 -->
|
||||
<el-dialog v-model="accessDetailVisible" title="访问日志详情" width="60%" top="5vh">
|
||||
<el-scrollbar max-height="60vh">
|
||||
<el-descriptions v-if="currentAccessRecord" :column="2" border>
|
||||
<el-descriptions-item label="日志ID">
|
||||
{{ currentAccessRecord.id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户">
|
||||
{{ currentAccessRecord.username }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户ID">
|
||||
{{ currentAccessRecord.user_id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="租户ID">
|
||||
{{ currentAccessRecord.tenant_id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="访问路径">
|
||||
{{ currentAccessRecord.request_url }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="请求方法">
|
||||
<el-tag :type="getMethodTag(currentAccessRecord.request_method)">
|
||||
{{ currentAccessRecord.request_method }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="IP地址">
|
||||
{{ currentAccessRecord.ip_address }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="currentAccessRecord.status === 1 ? 'success' : 'danger'">
|
||||
{{ currentAccessRecord.status === 1 ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="耗时(ms)">
|
||||
{{ currentAccessRecord.duration }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="访问时间">
|
||||
{{ formatTime(currentAccessRecord.create_time) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="User Agent" :span="2">
|
||||
<div style="word-break: break-all; font-size: 12px">
|
||||
{{ currentAccessRecord.user_agent || '-' }}
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="查询参数" :span="2">
|
||||
<div style="word-break: break-all; font-size: 12px">
|
||||
{{ currentAccessRecord.query_string || '-' }}
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-scrollbar>
|
||||
</el-dialog>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 清空日志对话框 -->
|
||||
<el-dialog v-model="clearDialogVisible" title="清空旧日志" width="400px">
|
||||
@@ -149,12 +315,13 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { getOperationLogs, clearOldLogs } from '@/api/operationLog'
|
||||
import { getOperationLogs, clearOldLogs, getAccessLogs, clearOldAccessLogs } from '@/api/operationLog'
|
||||
import { getAllMenus } from '@/api/menu'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
// import StatisticsPanel from './components/StatisticsPanel.vue'
|
||||
import OperationLogDetail from './components/OperationLogDetail.vue'
|
||||
|
||||
const activeTab = ref('operation')
|
||||
|
||||
const tableData = ref([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
@@ -182,6 +349,28 @@ const clearForm = reactive({
|
||||
keep_days: 90
|
||||
})
|
||||
|
||||
// 访问日志相关状态
|
||||
const accessTableData = ref([])
|
||||
const accessLoading = ref(false)
|
||||
const accessTotal = ref(0)
|
||||
const accessDetailVisible = ref(false)
|
||||
const currentAccessRecord = ref(null)
|
||||
|
||||
const accessPagination = reactive({
|
||||
page_num: 1,
|
||||
page_size: 20
|
||||
})
|
||||
|
||||
const accessFilters = reactive({
|
||||
username: '',
|
||||
module: '',
|
||||
resource_type: ''
|
||||
})
|
||||
|
||||
const accessDateRange = reactive({
|
||||
range: null
|
||||
})
|
||||
|
||||
const getOperationTag = (operation) => {
|
||||
const map = {
|
||||
'CREATE': 'success',
|
||||
@@ -289,8 +478,12 @@ const showClearDialog = () => {
|
||||
}
|
||||
|
||||
const handleClearLogs = async () => {
|
||||
const logType = activeTab.value === 'operation' ? '操作日志' : '访问日志'
|
||||
const clearFunc = activeTab.value === 'operation' ? clearOldLogs : clearOldAccessLogs
|
||||
const reloadFunc = activeTab.value === 'operation' ? loadLogs : loadAccessLogs
|
||||
|
||||
ElMessageBox.confirm(
|
||||
`将删除超过 ${clearForm.keep_days} 天的所有操作日志,此操作无法撤销!`,
|
||||
`将删除超过 ${clearForm.keep_days} 天的所有${logType},此操作无法撤销!`,
|
||||
'警告',
|
||||
{
|
||||
confirmButtonText: '确认',
|
||||
@@ -299,11 +492,11 @@ const handleClearLogs = async () => {
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
const res = await clearOldLogs(clearForm.keep_days)
|
||||
const res = await clearFunc(clearForm.keep_days)
|
||||
if (res.success) {
|
||||
ElMessage.success('日志清空成功')
|
||||
clearDialogVisible.value = false
|
||||
loadLogs()
|
||||
reloadFunc()
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('清空日志失败')
|
||||
@@ -317,6 +510,87 @@ const handleExport = () => {
|
||||
ElMessage.info('导出功能开发中...')
|
||||
}
|
||||
|
||||
// 标签页切换
|
||||
const handleTabChange = (tabName) => {
|
||||
if (tabName === 'access') {
|
||||
loadAccessLogs()
|
||||
} else {
|
||||
loadLogs()
|
||||
}
|
||||
}
|
||||
|
||||
// 访问日志相关方法
|
||||
const loadAccessLogs = async () => {
|
||||
accessLoading.value = true
|
||||
try {
|
||||
const params = {
|
||||
...accessPagination,
|
||||
...accessFilters
|
||||
}
|
||||
|
||||
if (accessDateRange.range && accessDateRange.range.length === 2) {
|
||||
params.start_time = new Date(accessDateRange.range[0]).toISOString().split('T')[0]
|
||||
params.end_time = new Date(accessDateRange.range[1]).toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
const res = await getAccessLogs(params)
|
||||
if (res.success || res.data) {
|
||||
// Backend returns data in res.data.logs format
|
||||
accessTableData.value = res.data?.logs || res.data || []
|
||||
accessTotal.value = res.data?.total || res.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('加载访问日志失败')
|
||||
} finally {
|
||||
accessLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleAccessSearch = () => {
|
||||
accessPagination.page_num = 1
|
||||
loadAccessLogs()
|
||||
}
|
||||
|
||||
const handleAccessReset = () => {
|
||||
accessFilters.username = ''
|
||||
accessFilters.module = ''
|
||||
accessFilters.resource_type = ''
|
||||
accessDateRange.range = null
|
||||
accessPagination.page_num = 1
|
||||
loadAccessLogs()
|
||||
}
|
||||
|
||||
const handleAccessDateChange = () => {
|
||||
handleAccessSearch()
|
||||
}
|
||||
|
||||
const handleAccessRowClick = (row) => {
|
||||
showAccessDetail(row)
|
||||
}
|
||||
|
||||
const showAccessDetail = (row) => {
|
||||
currentAccessRecord.value = row
|
||||
accessDetailVisible.value = true
|
||||
}
|
||||
|
||||
const showAccessClearDialog = () => {
|
||||
clearDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleAccessExport = () => {
|
||||
ElMessage.info('导出功能开发中...')
|
||||
}
|
||||
|
||||
const getMethodTag = (method) => {
|
||||
const tagMap = {
|
||||
GET: 'success',
|
||||
POST: 'warning',
|
||||
PUT: 'info',
|
||||
DELETE: 'danger'
|
||||
}
|
||||
return tagMap[method] || 'info'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMenus()
|
||||
loadLogs()
|
||||
|
||||
@@ -25,6 +25,43 @@
|
||||
<el-input v-model="form.email" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 部门 -->
|
||||
<el-form-item label="部门">
|
||||
<el-select
|
||||
v-model="form.department_id"
|
||||
placeholder="请选择部门"
|
||||
style="width: 100%"
|
||||
:loading="loadingDepartments"
|
||||
clearable
|
||||
@change="handleDepartmentChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="dept in departmentList"
|
||||
:key="dept.id"
|
||||
:label="dept.name"
|
||||
:value="dept.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 职位 -->
|
||||
<el-form-item label="职位">
|
||||
<el-select
|
||||
v-model="form.position_id"
|
||||
placeholder="请选择职位"
|
||||
style="width: 100%"
|
||||
:loading="loadingPositions"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="pos in positionList"
|
||||
:key="pos.id"
|
||||
:label="pos.name"
|
||||
:value="pos.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 角色 -->
|
||||
<el-form-item label="角色">
|
||||
<el-select
|
||||
@@ -73,16 +110,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import {
|
||||
addUser,
|
||||
editUser,
|
||||
getUserInfo,
|
||||
} from "@/api/user";
|
||||
|
||||
import { getTenantPositions, getPositionsByDepartment } from "@/api/position";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useDictStore } from "@/stores/dict";
|
||||
import { DICT_CODES } from "@/constants/dictCodes";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const dictStore = useDictStore();
|
||||
@@ -100,23 +138,44 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
departmentList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
positionList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
loadingRoles: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loadingDepartments: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loadingPositions: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
statusDict: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
tenantId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'submit', 'close']);
|
||||
const emit = defineEmits(['update:modelValue', 'submit', 'close', 'fetch-positions']);
|
||||
|
||||
const visible = ref(false);
|
||||
const formRef = ref(null);
|
||||
const loadingRoles = ref(false);
|
||||
const loadingDepartments = ref(false);
|
||||
const loadingPositions = ref(false);
|
||||
const isAdd = ref(false);
|
||||
const statusDict = ref([]);
|
||||
|
||||
const form = ref<any>({
|
||||
id: null,
|
||||
@@ -127,6 +186,8 @@ const form = ref<any>({
|
||||
role: null,
|
||||
status: "1", // ✅ 改为字典中的值"1"(启用)而不是"active"
|
||||
tenant_id: null,
|
||||
department_id: null,
|
||||
position_id: null,
|
||||
});
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
@@ -154,22 +215,6 @@ const getCurrentTenantId = () => {
|
||||
return props.tenantId || 0;
|
||||
};
|
||||
|
||||
// 获取状态字典数据
|
||||
const fetchStatusDict = async () => {
|
||||
try {
|
||||
const dictItems = await dictStore.getDictItems('user_status');
|
||||
statusDict.value = dictItems || [];
|
||||
} catch (error) {
|
||||
console.error("获取用户状态字典失败:", error);
|
||||
statusDict.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时获取字典数据
|
||||
onMounted(() => {
|
||||
fetchStatusDict();
|
||||
});
|
||||
|
||||
const loadUserData = async (user: any) => {
|
||||
try {
|
||||
// 处理两种调用方式:传递用户对象或用户 ID
|
||||
@@ -200,11 +245,16 @@ const loadUserData = async (user: any) => {
|
||||
password: "",
|
||||
email: data.email,
|
||||
role: roleValue,
|
||||
department_id: data.department_id || null,
|
||||
position_id: data.position_id || null,
|
||||
status: statusValue, // ✅ 使用字典中的原始值
|
||||
tenant_id: data.tenant_id || tenantId,
|
||||
};
|
||||
|
||||
|
||||
// 如果用户有部门,根据部门加载职位
|
||||
if (form.value.department_id) {
|
||||
await loadPositions(form.value.department_id);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Failed to load user data:', e);
|
||||
const errorMsg = e?.response?.data?.message || e?.message || "加载用户失败";
|
||||
@@ -213,7 +263,31 @@ const loadUserData = async (user: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadPositions = async (departmentId?: number) => {
|
||||
loadingPositions.value = true;
|
||||
try {
|
||||
const tenantId = getCurrentTenantId();
|
||||
let res;
|
||||
if (departmentId && departmentId > 0) {
|
||||
res = await getPositionsByDepartment(departmentId);
|
||||
} else {
|
||||
res = await getTenantPositions(tenantId);
|
||||
}
|
||||
// 职位列表由父组件通过 prop 传入,这里仅用于加载示例
|
||||
} catch (error: any) {
|
||||
console.error('获取职位列表失败:', error);
|
||||
} finally {
|
||||
loadingPositions.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDepartmentChange = (departmentId: number | null) => {
|
||||
form.value.position_id = null;
|
||||
if (departmentId && departmentId > 0) {
|
||||
// 让父组件加载对应部门的职位
|
||||
emit('fetch-positions', departmentId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
visible.value = false;
|
||||
@@ -229,6 +303,8 @@ const handleClose = () => {
|
||||
role: null,
|
||||
status: "1",
|
||||
tenant_id: null,
|
||||
department_id: null,
|
||||
position_id: null,
|
||||
};
|
||||
isAdd.value = false;
|
||||
emit('close');
|
||||
@@ -250,6 +326,13 @@ const handleSubmit = async () => {
|
||||
submitData.role = form.value.role;
|
||||
}
|
||||
|
||||
if (form.value.department_id) {
|
||||
submitData.department_id = form.value.department_id;
|
||||
}
|
||||
if (form.value.position_id) {
|
||||
submitData.position_id = form.value.position_id;
|
||||
}
|
||||
|
||||
if (form.value.tenant_id) {
|
||||
submitData.tenant_id = form.value.tenant_id;
|
||||
}
|
||||
@@ -275,6 +358,13 @@ const handleSubmit = async () => {
|
||||
submitData.role = form.value.role;
|
||||
}
|
||||
|
||||
if (form.value.department_id) {
|
||||
submitData.department_id = form.value.department_id;
|
||||
}
|
||||
if (form.value.position_id) {
|
||||
submitData.position_id = form.value.position_id;
|
||||
}
|
||||
|
||||
if (form.value.tenant_id) {
|
||||
submitData.tenant_id = form.value.tenant_id;
|
||||
}
|
||||
@@ -305,6 +395,8 @@ defineExpose({
|
||||
role: null,
|
||||
status: "1",
|
||||
tenant_id: tenantId || getCurrentTenantId(),
|
||||
department_id: null,
|
||||
position_id: null,
|
||||
};
|
||||
visible.value = true;
|
||||
},
|
||||
|
||||
@@ -32,7 +32,16 @@
|
||||
align="center"
|
||||
min-width="200"
|
||||
/>
|
||||
|
||||
<el-table-column prop="department" label="部门" width="150" align="center">
|
||||
<template #default="scope">
|
||||
<span>{{ scope.row.departmentName || '未分配' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="position" label="职位" width="150" align="center">
|
||||
<template #default="scope">
|
||||
<span>{{ scope.row.positionName || '未分配' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role" label="角色" width="150" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getRoleTagType(scope.row.roleName)">
|
||||
@@ -107,6 +116,8 @@ import {
|
||||
getUserInfo,
|
||||
} from "@/api/user";
|
||||
import { getRoleByTenantId, getAllRoles } from "@/api/role";
|
||||
import { getTenantDepartments } from "@/api/department";
|
||||
import { getTenantPositions } from "@/api/position";
|
||||
import { getDictItemsByCode } from '@/api/dict'
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
@@ -129,6 +140,14 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
departmentList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
positionList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
statusDict: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
@@ -216,6 +235,22 @@ const fetchUsers = async () => {
|
||||
roleName = roleInfo ? roleInfo.roleName : '';
|
||||
}
|
||||
|
||||
// 查找部门名称
|
||||
let departmentName = '';
|
||||
const departmentId = item.department_id || null;
|
||||
if (departmentId) {
|
||||
const deptInfo = (props.departmentList as any[]).find(d => d.id === departmentId);
|
||||
departmentName = deptInfo ? deptInfo.name : '';
|
||||
}
|
||||
|
||||
// 查找职位名称
|
||||
let positionName = '';
|
||||
const positionId = item.position_id || null;
|
||||
if (positionId) {
|
||||
const posInfo = (props.positionList as any[]).find(p => p.id === positionId);
|
||||
positionName = posInfo ? posInfo.name : '';
|
||||
}
|
||||
|
||||
// 处理时间字段
|
||||
const lastLoginTime = item.last_login_time || item.lastLoginTime || null;
|
||||
const lastLoginIp = item.last_login_ip || item.lastLoginIp || null;
|
||||
@@ -227,6 +262,10 @@ const fetchUsers = async () => {
|
||||
email: item.email,
|
||||
role: roleValue,
|
||||
roleName: roleName,
|
||||
department_id: departmentId,
|
||||
departmentName: departmentName,
|
||||
position_id: positionId,
|
||||
positionName: positionName,
|
||||
status: item.status,
|
||||
lastLoginTime: lastLoginTime
|
||||
? new Date(lastLoginTime).toLocaleString("zh-CN", {
|
||||
|
||||
@@ -36,7 +36,16 @@
|
||||
align="center"
|
||||
min-width="200"
|
||||
/>
|
||||
|
||||
<el-table-column prop="department" label="部门" width="150" align="center">
|
||||
<template #default="scope">
|
||||
<span>{{ scope.row.departmentName || '未分配' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="position" label="职位" width="150" align="center">
|
||||
<template #default="scope">
|
||||
<span>{{ scope.row.positionName || '未分配' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role" label="角色" width="150" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getRoleTagType(scope.row.roleName)">
|
||||
@@ -105,7 +114,11 @@
|
||||
@update:modelValue="editDialogVisible = $event"
|
||||
:is-edit="isEdit"
|
||||
:role-list="roleList"
|
||||
:department-list="departmentList"
|
||||
:position-list="positionList"
|
||||
:loading-roles="loadingRoles"
|
||||
:loading-departments="loadingDepartments"
|
||||
:loading-positions="loadingPositions"
|
||||
:status-dict="statusDict"
|
||||
:tenant-id="getCurrentTenantId()"
|
||||
@submit="handleEditSuccess"
|
||||
@@ -134,6 +147,8 @@ import {
|
||||
getUserInfo,
|
||||
} from "@/api/user";
|
||||
import { getRoleByTenantId, getAllRoles } from "@/api/role";
|
||||
import { getTenantDepartments } from "@/api/department";
|
||||
import { getTenantPositions, getPositionsByDepartment } from "@/api/position";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useDictStore } from "@/stores/dict";
|
||||
import UserEditDialog from './components/UserEdit.vue'
|
||||
@@ -160,6 +175,10 @@ const total = ref(0);
|
||||
const users = ref<any[]>([]);
|
||||
const roleList = ref<any[]>([]);
|
||||
const loadingRoles = ref(false);
|
||||
const departmentList = ref<any[]>([]);
|
||||
const loadingDepartments = ref(false);
|
||||
const positionList = ref<any[]>([]);
|
||||
const loadingPositions = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
// 状态字典
|
||||
@@ -178,8 +197,11 @@ const changePasswordRef = ref()
|
||||
|
||||
const fetchStatusDict = async () => {
|
||||
try {
|
||||
console.log('Starting to fetch status dict...');
|
||||
const items = await dictStore.getDictItems('user_status');
|
||||
console.log('Fetched statusDict items:', items);
|
||||
statusDict.value = items;
|
||||
console.log('statusDict.value updated:', statusDict.value);
|
||||
} catch (err) {
|
||||
console.error('Error fetching status dict:', err);
|
||||
statusDict.value = [];
|
||||
@@ -245,7 +267,48 @@ const fetchRoles = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 获取部门列表
|
||||
const fetchDepartments = async () => {
|
||||
loadingDepartments.value = true;
|
||||
try {
|
||||
const tenantId = getCurrentTenantId();
|
||||
const res = await getTenantDepartments(tenantId);
|
||||
if (res.code === 0 && res.data) {
|
||||
departmentList.value = Array.isArray(res.data) ? res.data : [];
|
||||
} else {
|
||||
departmentList.value = [];
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取部门列表失败:', error);
|
||||
departmentList.value = [];
|
||||
} finally {
|
||||
loadingDepartments.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取职位列表
|
||||
const fetchPositions = async (departmentId?: number) => {
|
||||
loadingPositions.value = true;
|
||||
try {
|
||||
const tenantId = getCurrentTenantId();
|
||||
let res;
|
||||
if (departmentId && departmentId > 0) {
|
||||
res = await getPositionsByDepartment(departmentId);
|
||||
} else {
|
||||
res = await getTenantPositions(tenantId);
|
||||
}
|
||||
if (res.code === 0 && res.data) {
|
||||
positionList.value = Array.isArray(res.data) ? res.data : [];
|
||||
} else {
|
||||
positionList.value = [];
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取职位列表失败:', error);
|
||||
positionList.value = [];
|
||||
} finally {
|
||||
loadingPositions.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取角色标签类型
|
||||
const getRoleTagType = (roleName: string) => {
|
||||
@@ -302,6 +365,20 @@ const fetchUsers = async () => {
|
||||
roleName = roleInfo ? roleInfo.roleName : '';
|
||||
}
|
||||
|
||||
let departmentName = '';
|
||||
const departmentId = item.department_id || null;
|
||||
if (departmentId) {
|
||||
const deptInfo = departmentList.value.find(d => d.id === departmentId);
|
||||
departmentName = deptInfo ? deptInfo.name : '';
|
||||
}
|
||||
|
||||
let positionName = '';
|
||||
const positionId = item.position_id || null;
|
||||
if (positionId) {
|
||||
const posInfo = positionList.value.find(p => p.id === positionId);
|
||||
positionName = posInfo ? posInfo.name : '';
|
||||
}
|
||||
|
||||
// 确保状态值正确处理
|
||||
const statusValue = item.status !== undefined && item.status !== null ? item.status : '1';
|
||||
|
||||
@@ -315,6 +392,10 @@ const fetchUsers = async () => {
|
||||
email: item.email,
|
||||
role: roleValue,
|
||||
roleName: roleName,
|
||||
department_id: departmentId,
|
||||
departmentName: departmentName,
|
||||
position_id: positionId,
|
||||
positionName: positionName,
|
||||
status: statusValue, // 使用处理后的状态值
|
||||
lastLoginTime: lastLoginTime
|
||||
? new Date(lastLoginTime).toLocaleString("zh-CN", {
|
||||
@@ -342,6 +423,8 @@ const fetchUsers = async () => {
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
fetchRoles(),
|
||||
fetchDepartments(),
|
||||
fetchPositions(),
|
||||
fetchStatusDict(),
|
||||
]);
|
||||
fetchUsers();
|
||||
@@ -356,6 +439,8 @@ const refresh = async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchRoles(),
|
||||
fetchDepartments(),
|
||||
fetchPositions(),
|
||||
fetchStatusDict(),
|
||||
]);
|
||||
await fetchUsers();
|
||||
@@ -402,7 +487,14 @@ const handleEditSuccess = () => {
|
||||
fetchUsers();
|
||||
};
|
||||
|
||||
|
||||
// 职位加载回调
|
||||
const handleFetchPositions = (departmentId: number | null) => {
|
||||
if (departmentId && departmentId > 0) {
|
||||
fetchPositions(departmentId);
|
||||
} else {
|
||||
fetchPositions();
|
||||
}
|
||||
};
|
||||
|
||||
// 删除用户
|
||||
const handleDelete = async (user: User) => {
|
||||
|
||||
Reference in New Issue
Block a user