增加任务管理模块

This commit is contained in:
2025-11-12 17:32:03 +08:00
parent 12a0ff8afc
commit db16ee70de
54 changed files with 3638 additions and 672 deletions
+64 -45
View File
@@ -56,11 +56,46 @@ dictStore.clearCache('user_status')
---
### 2. **常量**: `src/constants/dictCodes.js`
集中管理所有字典编码
**使用示例**
```javascript
import { DICT_CODES } from '@/constants/dictCodes'
// 好处:避免硬编码,IDE 有自动完成
const items = await dictStore.getDictItems(DICT_CODES.USER_STATUS)
// 所有可用的编码:
DICT_CODES.USER_STATUS // 用户状态
DICT_CODES.USER_GENDER // 用户性别
DICT_CODES.USER_ROLE // 用户角色
DICT_CODES.DEPT_STATUS // 部门状态
DICT_CODES.POSITION_STATUS // 职位状态
// ... 更多编码
```
---
### 3. **Composable**: `src/composables/useDict.js`
简化在组件中使用字典的 Hook
**基础用法**
```javascript
import { useDictionary, useUserStatusDict } from '@/composables/useDict'
import { DICT_CODES } from '@/constants/dictCodes'
// 方式1:使用常量
const { statusDict, loading } = useDictionary(DICT_CODES.USER_STATUS)
// 方式2:使用字符串
const { dicts, loading } = useDictionary('user_status')
// 方式3:使用特化 Hook(推荐)
const { user_statusDict, loading } = useUserStatusDict()
```
---
@@ -126,9 +161,9 @@ const props = defineProps({
---
### 场景3直接使用字典Store
### 场景3快速使用 Composable Hook
直接使用字典Store获取数据
最简单的方式,自动处理加载
```vue
<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` - 导入字典库
-36
View File
@@ -1,36 +0,0 @@
import request from '@/utils/request'
// 获取访问日志列表
export function getAccessLogs(params) {
return request({
url: '/api/access-logs',
method: 'get',
params
})
}
// 根据ID获取访问日志详情
export function getAccessLogById(id) {
return request({
url: `/api/access-logs/${id}`,
method: 'get'
})
}
// 获取用户访问统计
export function getUserAccessStats(params) {
return request({
url: '/api/access-logs/user/stats',
method: 'get',
params
})
}
// 清空旧访问日志
export function clearOldAccessLogs(keepDays = 90) {
return request({
url: '/api/access-logs/clear',
method: 'post',
data: { keep_days: keepDays }
})
}
+17
View File
@@ -22,3 +22,20 @@ export function getTenantStats() {
});
}
/**
* 获取用户活动日志(操作日志和登录日志)
* @param {number} pageNum - 页码
* @param {number} pageSize - 每页数量
* @returns {Promise}
*/
export function getActivityLogs(pageNum = 1, pageSize = 10) {
return request({
url: "/api/dashboard/user-activity-logs",
method: "get",
params: {
page_num: pageNum,
page_size: pageSize,
},
});
}
+35
View File
@@ -43,3 +43,38 @@ export function clearOldLogs(keepDays = 90) {
data: { keep_days: keepDays }
})
}
// 获取访问日志列表
export function getAccessLogs(params) {
return request({
url: '/api/access-logs',
method: 'get',
params
})
}
// 根据ID获取访问日志详情
export function getAccessLogById(id) {
return request({
url: `/api/access-logs/${id}`,
method: 'get'
})
}
// 获取用户访问统计
export function getUserAccessStats(params) {
return request({
url: '/api/access-logs/user/stats',
method: 'get',
params
})
}
// 清空旧访问日志
export function clearOldAccessLogs(keepDays = 90) {
return request({
url: '/api/access-logs/clear',
method: 'post',
data: { keep_days: keepDays }
})
}
+39
View File
@@ -0,0 +1,39 @@
import request from '@/utils/request'
export function listTasks(params) {
return request({
url: '/api/oa/tasks',
method: 'get',
params
})
}
export function getTask(id) {
return request({
url: `/api/oa/tasks/${id}`,
method: 'get'
})
}
export function createTask(data) {
return request({
url: '/api/oa/tasks',
method: 'post',
data
})
}
export function updateTask(id, data) {
return request({
url: `/api/oa/tasks/${id}`,
method: 'put',
data
})
}
export function deleteTask(id) {
return request({
url: `/api/oa/tasks/${id}`,
method: 'delete'
})
}
+2 -2
View File
@@ -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;
+3 -3
View File
@@ -69,9 +69,9 @@ import {
} from "@/api/employee";
import { useAuthStore } from "@/stores/auth";
import { useOAStore } from "@/stores/oa";
import EmployeeList from "../components/employees/EmployeeList.vue";
import EmployeeEdit from "../components/employees/EmployeeEdit.vue";
import EmployeePasswordChange from "../components/employees/EmployeePasswordChange.vue";
import EmployeeList from "./components/EmployeeList.vue";
import EmployeeEdit from "./components/EmployeeEdit.vue";
import EmployeePasswordChange from "./components/EmployeePasswordChange.vue";
interface Employee {
id: number;
@@ -126,4 +126,3 @@ const handleSubmit = () => {
emit('submit', { ...form.value });
};
</script>
@@ -163,16 +163,6 @@ const handleCollapseAll = () => {
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
// .el-button {
// padding: 0;
// font-size: 13px;
// color: var(--el-color-primary);
// &:hover {
// color: var(--el-color-primary-light-3);
// }
// }
}
}
@@ -254,4 +244,3 @@ const handleCollapseAll = () => {
}
}
</style>
@@ -126,4 +126,3 @@ const handleSubmit = () => {
emit('submit', { ...form.value });
};
</script>
@@ -193,4 +193,3 @@ const handleDelete = (position: any) => {
}
}
</style>
+4 -4
View File
@@ -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();
+2 -2
View File
@@ -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>
+245
View File
@@ -0,0 +1,245 @@
<template>
<div class="task-page">
<div class="toolbar">
<el-input v-model="query.keyword" placeholder="搜索任务名称/编号/负责人" clearable @keyup.enter="fetchList" />
<el-select v-model="query.status" placeholder="状态" clearable @change="handleFilterChange">
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
<el-select v-model="query.priority" placeholder="优先级" clearable @change="handleFilterChange">
<el-option v-for="p in priorityOptions" :key="p.value" :label="p.label" :value="p.value" />
</el-select>
<el-button type="primary" @click="openCreate">新建任务</el-button>
<el-button @click="fetchList">刷新</el-button>
</div>
<el-card shadow="never" class="table-card">
<el-table :data="list" border stripe v-loading="loading">
<el-table-column type="index" label="#" width="60" align="center" />
<el-table-column prop="task_no" label="编号" width="180" show-overflow-tooltip align="center" />
<el-table-column prop="task_name" label="任务名称" min-width="220" show-overflow-tooltip align="center">
<!-- <template #default="{ row }">
<el-link type="primary" @click="openDetail(row)">{{ row.task_name }}</el-link>
</template> -->
</el-table-column>
<el-table-column prop="principal_name" label="负责人" width="120" align="center" />
<el-table-column prop="priority" label="优先级" width="110" align="center">
<template #default="{ row }">
<el-tag :type="priorityTagType(row.priority)" effect="plain">{{ priorityText(row.priority) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="task_status" label="状态" width="120" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.task_status)">{{ statusText(row.task_status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="progress" label="进度" width="200" align="center">
<template #default="{ row }">
<el-progress :percentage="Number(row.progress || 0)" :stroke-width="14" />
</template>
</el-table-column>
<el-table-column label="任务周期" min-width="300" align="center">
<template #default="{ row }">
{{ formatDateTime(row.plan_start_time) }} ~ {{ formatDateTime(row.plan_end_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row }">
<el-button link size="small" @click="openDetail(row)">查看</el-button>
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="openDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
:page-sizes="[10, 20, 50]"
@current-change="fetchList"
@size-change="handleSizeChange"
/>
</div>
</el-card>
<Edit
v-model="formDialogVisible"
:is-edit="isEdit"
:task="currentTask"
@saved="fetchList"
/>
<Detail
v-model="detailVisible"
:task="currentTask"
/>
<!-- 删除改为内联确认不使用独立组件 -->
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listTasks, getTask, deleteTask } from '@/api/tasks'
import Edit from './components/edit.vue'
import Detail from './components/detail.vue'
import { useDictStore } from '@/stores/dict'
const loading = ref(false)
const total = ref(0)
const list = ref<any[]>([])
const dictStore = useDictStore()
const statusOptions = ref<Array<{ label: string; value: string }>>([])
const priorityOptions = ref<Array<{ label: string; value: string }>>([])
const query = reactive({
keyword: '',
status: '',
priority: '',
page: 1,
pageSize: 10
})
const formDialogVisible = ref(false)
const isEdit = ref(false)
const currentTask = ref<any>(null)
const detailVisible = ref(false)
const deleteDialogVisible = ref(false) // 保留变量但不渲染独立组件
const fetchList = async () => {
loading.value = true
try {
const res: any = await listTasks({
keyword: query.keyword,
status: query.status,
priority: query.priority,
page: query.page,
pageSize: query.pageSize
})
if (res?.code === 0) {
list.value = res.data?.list || res.data?.items || res.data || []
total.value = res.data?.total || list.value.length
} else {
ElMessage.error(res?.message || '获取任务列表失败')
}
} catch (e) {
ElMessage.error('获取任务列表失败')
} finally {
loading.value = false
}
}
const handleFilterChange = () => {
query.page = 1
fetchList()
}
const handleSizeChange = () => {
query.page = 1
fetchList()
}
const openCreate = () => {
isEdit.value = false
currentTask.value = null
formDialogVisible.value = true
}
const openEdit = async (row: any) => {
isEdit.value = true
try {
const res: any = await getTask(row.id)
currentTask.value = res?.data || row
} catch {
currentTask.value = row
}
formDialogVisible.value = true
}
const openDetail = (row: any) => {
currentTask.value = row
detailVisible.value = true
}
const openDelete = (row: any) => {
ElMessageBox.confirm(`确认删除任务「${row.task_name || row.task_no}」吗?`, '提示', { type: 'warning' })
.then(async () => {
const res: any = await deleteTask(row.id)
if (res?.code === 0 || res?.success) {
ElMessage.success('删除成功')
fetchList()
} else {
ElMessage.error(res?.message || '删除失败')
}
})
.catch(() => {})
}
const priorityTagType = (v: string) => {
if (v === 'urgent') return 'danger'
if (v === 'high') return 'warning'
if (v === 'low') return 'success'
return 'info'
}
const statusTagType = (v: string) => {
if (v === 'in_progress') return 'warning'
if (v === 'completed') return 'success'
if (v === 'closed') return 'info'
if (v === 'paused') return 'danger'
return ''
}
const priorityText = (v: string) => dictStore.getDictLabel('task_priority', v)
const statusText = (v: string) => dictStore.getDictLabel('task_status', v)
const formatDateTime = (v: any) => {
if (!v) return '-'
const d = new Date(v)
if (isNaN(d.getTime())) return String(v)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
onMounted(async () => {
try {
const [pri, sts] = await Promise.all([
dictStore.getDictItems('task_priority'),
dictStore.getDictItems('task_status')
])
priorityOptions.value = (pri || []).map((it: any) => ({
label: it.dict_label ?? it.dictLabel ?? it.label ?? it.name,
value: it.dict_value ?? it.dictValue ?? it.value ?? it.code
}))
statusOptions.value = (sts || []).map((it: any) => ({
label: it.dict_label ?? it.dictLabel ?? it.label ?? it.name,
value: it.dict_value ?? it.dictValue ?? it.value ?? it.code
}))
} catch {}
fetchList()
})
</script>
<style scoped lang="less">
.task-page {
padding: 16px;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: nowrap;
margin-bottom: 12px;
}
.table-card {
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
}
:deep(.toolbar .el-button){
margin-left: 0 !important;
}
</style>
+226 -89
View File
@@ -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;
-367
View File
@@ -1,367 +0,0 @@
<template>
<div class="access-log-container">
<!-- 统计面板 -->
<!-- <StatisticsPanel /> -->
<!-- 搜索和操作栏 -->
<el-card shadow="hover" style="margin-bottom: 20px">
<el-form :model="filters" label-width="100px" :inline="true">
<el-form-item label="用户">
<el-input v-model="filters.username" placeholder="搜索用户名" clearable />
</el-form-item>
<el-form-item label="模块">
<el-select v-model="filters.module" placeholder="选择模块" clearable>
<el-option label="全部" value="" />
<el-option
v-for="opt in moduleOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="资源类型">
<el-input v-model="filters.resource_type" placeholder="搜索资源类型" clearable />
</el-form-item>
</el-form>
<el-form :model="dateRange" label-width="100px" :inline="true">
<el-form-item label="访问时间">
<el-date-picker
v-model="dateRange.range"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
@change="handleDateChange"
/>
</el-form-item>
</el-form>
<div style="margin-top: 15px">
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button @click="showClearDialog">清空日志</el-button>
<el-button @click="handleExport">导出</el-button>
</div>
</el-card>
<!-- 日志列表 -->
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>访问日志列表</span>
<span class="log-count"> {{ total }} </span>
</div>
</template>
<el-table
:data="tableData"
stripe
style="width: 100%; margin-bottom: 20px"
v-loading="loading"
@row-click="handleRowClick"
>
<el-table-column prop="username" label="用户" align="center" width="120" />
<el-table-column prop="module_name" label="模块" align="center" width="120">
<template #default="{ row }">
<el-tag>{{ row.module_name }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="resource_type" label="资源类型" align="center" width="120" />
<el-table-column prop="request_url" label="访问路径" align="center" min-width="200">
<template #default="{ row }">
<el-tooltip :content="row.request_url" placement="top">
<span>{{ row.request_url }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="request_method" label="方法" align="center" width="80">
<template #default="{ row }">
<el-tag :type="getMethodTag(row.request_method)">
{{ row.request_method }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="ip_address" label="IP地址" align="center" width="140" />
<el-table-column prop="duration" label="耗时(ms)" align="center" width="100" />
<el-table-column prop="create_time" label="访问时间" align="center" width="180">
<template #default="{ row }">
{{ formatTime(row.create_time) }}
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="100">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="showDetail(row)">
详情
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@change="handlePageChange"
/>
</el-card>
<!-- 详情对话框 -->
<el-dialog v-model="detailDialogVisible" title="访问日志详情" width="70%">
<el-descriptions v-if="currentRecord" :column="2" border>
<el-descriptions-item label="日志ID">
{{ currentRecord.id }}
</el-descriptions-item>
<el-descriptions-item label="用户">
{{ currentRecord.username }}
</el-descriptions-item>
<el-descriptions-item label="租户ID">
{{ currentRecord.tenant_id }}
</el-descriptions-item>
<el-descriptions-item label="用户ID">
{{ currentRecord.user_id }}
</el-descriptions-item>
<el-descriptions-item label="模块">
{{ currentRecord.module_name }}
</el-descriptions-item>
<el-descriptions-item label="资源类型">
{{ currentRecord.resource_type }}
</el-descriptions-item>
<el-descriptions-item label="资源ID">
{{ currentRecord.resource_id || '-' }}
</el-descriptions-item>
<el-descriptions-item label="访问路径">
{{ currentRecord.request_url }}
</el-descriptions-item>
<el-descriptions-item label="请求方法">
{{ currentRecord.request_method }}
</el-descriptions-item>
<el-descriptions-item label="查询字符串">
{{ currentRecord.query_string || '-' }}
</el-descriptions-item>
<el-descriptions-item label="IP地址">
{{ currentRecord.ip_address }}
</el-descriptions-item>
<el-descriptions-item label="User Agent">
<div style="word-break: break-all; font-size: 12px">
{{ currentRecord.user_agent }}
</div>
</el-descriptions-item>
<el-descriptions-item label="耗时(ms)">
{{ currentRecord.duration }}
</el-descriptions-item>
<el-descriptions-item label="访问时间">
{{ formatTime(currentRecord.create_time) }}
</el-descriptions-item>
</el-descriptions>
</el-dialog>
<!-- 清空对话框 -->
<el-dialog v-model="clearDialogVisible" title="清空日志" width="400px">
<el-form :model="clearForm" label-width="100px">
<el-form-item label="保留天数">
<el-input-number v-model="clearForm.keepDays" :min="0" :max="365" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="clearDialogVisible = false">取消</el-button>
<el-button type="danger" @click="handleClear">确定清空</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getAccessLogs, clearOldAccessLogs } from '@/api/accessLog'
import { getAllMenus } from '@/api/menu'
const filters = reactive({
username: '',
module: '',
resource_type: ''
})
const dateRange = reactive({
range: null
})
const tableData = ref([])
const loading = ref(false)
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
const detailDialogVisible = ref(false)
const clearDialogVisible = ref(false)
const currentRecord = ref(null)
const clearForm = reactive({
keepDays: 90
})
const moduleOptions = ref([])
// 格式化时间
const formatTime = (time) => {
if (!time) return '-'
const date = new Date(time)
return date.toLocaleString('zh-CN')
}
// 获取HTTP方法的标签类型
const getMethodTag = (method) => {
const tagMap = {
GET: 'success',
POST: 'warning',
PUT: 'info',
DELETE: 'danger'
}
return tagMap[method] || 'info'
}
// 加载菜单数据(用于模块选项)
const loadMenus = async () => {
try {
const res = await getAllMenus()
if (res.data) {
const menus = res.data.list || res.data || []
const options = menus.map((m) => ({
value: m.path ? m.path.split('/').pop() : m.permission,
label: m.name
}))
moduleOptions.value = options
}
} catch (e) {
console.error('Failed to load menus:', e)
}
}
// 加载日志列表
const loadLogs = async () => {
loading.value = true
try {
const params = {
page_num: currentPage.value,
page_size: pageSize.value,
username: filters.username || undefined,
module: filters.module || undefined,
resource_type: filters.resource_type || undefined
}
if (dateRange.range && dateRange.range.length === 2) {
params.start_time = dateRange.range[0].toLocaleString('zh-CN')
params.end_time = dateRange.range[1].toLocaleString('zh-CN')
}
const res = await getAccessLogs(params)
if (res.data) {
tableData.value = res.data
// 补充模块名称(从 moduleOptions 中查找)
tableData.value.forEach((row) => {
const module = moduleOptions.value.find((m) => m.value === row.module)
row.module_name = module ? module.label : row.module
})
total.value = res.total || 0
}
} catch (error) {
ElMessage.error('加载访问日志失败')
console.error(error)
} finally {
loading.value = false
}
}
// 处理搜索
const handleSearch = () => {
currentPage.value = 1
loadLogs()
}
// 处理重置
const handleReset = () => {
filters.username = ''
filters.module = ''
filters.resource_type = ''
dateRange.range = null
currentPage.value = 1
loadLogs()
}
// 处理日期改变
const handleDateChange = () => {
currentPage.value = 1
loadLogs()
}
// 处理分页改变
const handlePageChange = () => {
loadLogs()
}
// 显示详情
const showDetail = (row) => {
currentRecord.value = row
detailDialogVisible.value = true
}
// 显示清空对话框
const showClearDialog = () => {
clearDialogVisible.value = true
}
// 处理清空日志
const handleClear = async () => {
try {
await ElMessageBox.confirm(
`确定要清空 ${clearForm.keepDays} 天前的日志吗?该操作不可撤销。`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await clearOldAccessLogs(clearForm.keepDays)
ElMessage.success('日志清空成功')
clearDialogVisible.value = false
loadLogs()
} catch (e) {
// 取消操作
}
}
// 处理导出
const handleExport = () => {
// 可以调用后端导出接口或使用前端库导出
ElMessage.info('导出功能暂未实现')
}
// 页面初始化
onMounted(async () => {
await loadMenus()
await loadLogs()
})
</script>
<style scoped>
.access-log-container {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.log-count {
color: #909399;
font-size: 14px;
}
</style>
+282 -8
View File
@@ -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()
+113 -21
View File
@@ -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", {
+94 -2
View File
@@ -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) => {