fix 选择器

This commit is contained in:
2025-10-30 17:59:04 +08:00
parent 2b252e1b3c
commit 0935ec5ec5
22 changed files with 2313 additions and 125 deletions

View File

@@ -276,10 +276,10 @@
</div>
</div>
<div class="detail-section" v-if="currentLog.responseResult">
<div class="detail-section" v-if="currentLog.responseData">
<h4 class="detail-title">响应结果</h4>
<div class="detail-content">
<pre class="code-block">{{ currentLog.responseResult }}</pre>
<pre class="code-block">{{ currentLog.responseData }}</pre>
</div>
</div>

View File

@@ -0,0 +1,354 @@
<template>
<div class="task-card">
<!-- 卡片头部 -->
<div class="card-header">
<div class="header-content">
<h3 class="task-title">{{ task.learningTask.name }}</h3>
<div class="task-meta">
<!-- <div class="meta-item">
<img class="icon" src="@/assets/imgs/usermange.svg" alt="部门" />
<span class="meta-text">{{ getDeptName }}</span>
</div> -->
<div class="meta-item">
<img class="icon" src="@/assets/imgs/calendar-icon.svg" alt="日期" />
<span class="meta-text">截止: {{ formatDate(task.learningTask.endTime) }}</span>
</div>
</div>
</div>
<div class="status-badge" :class="getStatusClass(task.learningTask.status)">
{{ getStatusText(task.learningTask.status, task.learningTask.startTime, task.learningTask.endTime) }}
</div>
</div>
<!-- 统计信息 -->
<div class="card-stats">
<div class="stat-item">
<div class="stat-label">分配人数</div>
<div class="stat-value">{{ task.totalTaskNum || 0 }}</div>
</div>
<div class="stat-item stat-completed">
<div class="stat-label">已完成</div>
<div class="stat-value stat-value-completed">{{ task.completedTaskNum || 0 }}</div>
</div>
<div class="stat-item stat-rate">
<div class="stat-label">完成率</div>
<div class="stat-value stat-value-rate">{{ completionRate }}</div>
</div>
</div>
<!-- 进度条 -->
<div class="card-progress">
<div class="progress-header">
<span class="progress-label">完成进度</span>
<span class="progress-value">{{ completionRate }}</span>
</div>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: completionRate }"></div>
</div>
</div>
<!-- 操作按钮 -->
<div class="card-footer">
<div class="card-actions">
<button class="btn-link btn-primary" @click="$emit('view', task)">查看</button>
<button class="btn-link btn-warning" @click="$emit('edit', task)" v-if="task.learningTask.status !== 1">编辑</button>
<button class="btn-link btn-success" @click="$emit('publish', task)" v-if="task.learningTask.status !== 1">发布</button>
<button class="btn-link btn-warning" @click="$emit('unpublish', task)" v-if="task.learningTask.status === 1">下架</button>
<button class="btn-link btn-primary" @click="$emit('statistics', task)" v-if="task.learningTask.status !== 0">统计</button>
<button class="btn-link btn-warning" @click="$emit('update-user', task)">修改人员</button>
<button class="btn-link btn-danger" @click="$emit('delete', task)" v-if="task.learningTask.status === 0">删除</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { TaskVO } from '@/types/study';
interface Props {
task: TaskVO;
}
const props = defineProps<Props>();
defineEmits<{
view: [task: TaskVO];
edit: [task: TaskVO];
publish: [task: TaskVO];
unpublish: [task: TaskVO];
statistics: [task: TaskVO];
'update-user': [task: TaskVO];
delete: [task: TaskVO];
}>();
/**
* 获取部门名称
*/
// const getDeptName = computed(() => {
// if (props.task.taskUsers && props.task.taskUsers.length > 0) {
// const deptNames = new Set(props.task.taskUsers.map(u => u.deptName).filter(Boolean));
// if (deptNames.size === 0) return '全校';
// if (deptNames.size === 1) return Array.from(deptNames)[0] || '全校';
// return '多个部门';
// }
// return '全校';
// });
/**
* 格式化日期
*/
function formatDate(dateStr?: string): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).replace(/\//g, '-');
}
/**
* 获取状态样式类
*/
function getStatusClass(status?: number): string {
switch (status) {
case 0:
return 'status-draft';
case 1:
return 'status-ongoing';
case 2:
return 'status-ended';
default:
return '';
}
}
/**
* 获取状态文本
*/
function getStatusText(status?: number, startTime?: string, endTime?: string): string {
if (status === 0) return '草稿';
if (status === 1) {
const now = new Date();
const start = new Date(startTime!);
const end = new Date(endTime!);
if (now >= start && now <= end) {
return '进行中';
} else if (now < start) {
return '未开始';
} else {
return '已结束';
}
}
if (status === 2) return '下架';
return '未知';
}
/**
* 计算完成率
*/
const completionRate = computed(() => {
const total = props.task.totalTaskNum || 0;
const completed = props.task.completedTaskNum || 0;
if (total === 0) return '0%';
const rate = Math.round((completed / total) * 100);
return `${rate}%`;
});
</script>
<style lang="scss" scoped>
.task-card {
background: #F9FAFB;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 14px;
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
transition: all 0.3s;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
}
// 卡片头部
.card-header {
display: flex;
justify-content: space-between;
gap: 16px;
}
.header-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.task-title {
font-size: 16px;
font-weight: 400;
line-height: 1.5em;
letter-spacing: -1.953125%;
color: #101828;
margin: 0;
}
.task-meta {
display: flex;
align-items: center;
gap: 16px;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
.icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.meta-text {
font-size: 14px;
font-weight: 400;
line-height: 1.428571em;
letter-spacing: -1.07421875%;
color: #4A5565;
}
}
.status-badge {
display: flex;
align-items: center;
justify-content: center;
padding: 2px 8px;
height: 22px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
line-height: 1.333333em;
white-space: nowrap;
&.status-draft {
background: #F4F4F5;
color: #909399;
border: 1px solid transparent;
}
&.status-ongoing {
background: #DBEAFE;
color: #1447E6;
border: 1px solid transparent;
}
&.status-ended {
background: #F0F9FF;
color: #409EFF;
border: 1px solid transparent;
}
}
// 统计信息
.card-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.stat-item {
background: #FFFFFF;
border-radius: 10px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-label {
font-size: 14px;
font-weight: 400;
line-height: 1.428571em;
letter-spacing: -1.07421875%;
color: #4A5565;
}
.stat-value {
font-size: 20px;
font-weight: 400;
line-height: 1.4em;
letter-spacing: -2.24609375%;
color: #101828;
}
.stat-value-completed {
color: #00A63E;
}
.stat-value-rate {
color: #155DFC;
}
// 进度条
.card-progress {
display: flex;
flex-direction: column;
gap: 8px;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.progress-label {
font-size: 14px;
font-weight: 400;
line-height: 1.428571em;
letter-spacing: -1.07421875%;
color: #4A5565;
}
.progress-value {
font-size: 14px;
font-weight: 400;
line-height: 1.428571em;
letter-spacing: -1.07421875%;
color: #101828;
}
.progress-bar {
width: 100%;
height: 8px;
background: rgba(3, 2, 19, 0.2);
border-radius: 16777200px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #030213;
border-radius: 16777200px;
transition: width 0.3s ease;
}
// 卡片底部
.card-footer {
padding-top: 16px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
// 操作按钮
.card-actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 6px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export { default as TaskCard } from './TaskCard.vue';

View File

@@ -409,7 +409,16 @@ function resetForm() {
// 获取所有可选角色的接口
async function fetchAllRoles() {
return await roleApi.getAllRoles();
const result = await roleApi.getAllRoles();
if (result.success && result.dataList) {
result.dataList = result.dataList.map(item => ({
roleID: item.roleID,
name: item.roleName, // roleName -> name
description: item.roleDescription // roleDescription -> description
}));
}
return result;
}
// 获取部门已绑定角色的接口

View File

@@ -638,7 +638,15 @@ async function fetchPermissionMenus() {
// 获取所有角色的接口
async function fetchAllRoles() {
return await roleApi.getAllRoles();
const result = await roleApi.getAllRoles();
if (result.success && result.dataList) {
result.dataList = result.dataList.map(item => ({
roleID: item.roleID,
name: item.roleName,
description: item.roleDescription
}));
}
return result;
}
// 获取权限已绑定角色的接口

View File

@@ -19,9 +19,9 @@
border
stripe
>
<el-table-column prop="name" label="角色名称" min-width="200" />
<el-table-column prop="roleName" label="角色名称" min-width="200" />
<el-table-column prop="roleID" label="角色ID" min-width="150" />
<el-table-column prop="description" label="角色描述" min-width="200" show-overflow-tooltip />
<el-table-column prop="roleDescription" label="角色描述" min-width="200" show-overflow-tooltip />
<el-table-column prop="creatorName" label="创建人" width="120" />
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column label="操作" width="280" fixed="right">
@@ -99,7 +99,7 @@
<!-- 绑定权限对话框 -->
<GenericSelector
v-model:visible="bindPermissionDialogVisible"
:title="`绑定权限 - ${currentRole?.name || ''}`"
:title="`绑定权限 - ${currentRole?.roleName || ''}`"
left-title="可选权限"
right-title="已选权限"
:fetch-available-api="fetchAllPermissions"
@@ -122,7 +122,7 @@
<script setup lang="ts">
import { roleApi } from '@/apis/system/role';
import { permissionApi } from '@/apis/system/permission';
import { SysRole, SysPermission } from '@/types';
import { SysRole, SysPermission, UserDeptRoleVO } from '@/types';
import { AdminLayout } from '@/views/admin';
import { GenericSelector } from '@/components/base';
@@ -134,7 +134,7 @@ import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus';
import { Plus } from '@element-plus/icons-vue';
// 数据状态
const roleList = ref<SysRole[]>([]);
const roleList = ref<UserDeptRoleVO[]>([]);
const loading = ref(false);
const submitting = ref(false);
@@ -142,7 +142,7 @@ const submitting = ref(false);
// 移除:改为由 GenericSelector 通过 API 加载
// const permissionList = ref<SysPermission[]>([]);
// const initialBoundPermissions = ref<SysPermission[]>([]);
const currentRole = ref<SysRole | null>(null);
const currentRole = ref<UserDeptRoleVO | null>(null);
// 对话框状态
const dialogVisible = ref(false);

View File

@@ -5,7 +5,7 @@
<button class="btn-back" @click="handleCancel">
<span class="arrow-left"></span> 返回
</button>
<h2 class="page-title">{{ taskID ? '编辑学习任务' : '创建学习任务' }}</h2>
<h2 class="page-title">{{ taskId ? '编辑学习任务' : '创建学习任务' }}</h2>
</div>
<div class="task-form">
@@ -152,7 +152,7 @@
<!-- 操作按钮 -->
<div class="form-actions">
<button class="btn-primary" @click="handleSubmit" :disabled="submitting">
{{ submitting ? '提交中...' : (taskID ? '更新任务' : '创建任务') }}
{{ submitting ? '提交中...' : (taskId ? '更新任务' : '创建任务') }}
</button>
<button class="btn-default" @click="handleCancel">取消</button>
</div>
@@ -252,52 +252,27 @@
</div>
</div>
<!-- 用户选择器弹窗 -->
<div v-if="showUserSelector" class="modal-overlay" @click.self="showUserSelector = false">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">选择学习用户</h3>
<button class="modal-close" @click="showUserSelector = false"></button>
</div>
<div class="modal-body">
<div class="search-box">
<input
v-model="userSearchKeyword"
type="text"
class="search-input"
placeholder="搜索用户名或手机号"
@input="searchUsers"
/>
</div>
<div v-if="userLoading" class="loading-tip">加载中...</div>
<div v-else-if="availableUsers.length === 0" class="empty-tip">暂无可选用户</div>
<div v-else class="selector-list">
<label
v-for="user in availableUsers"
:key="user.id"
class="selector-item"
>
<input
type="checkbox"
:value="user.id"
:checked="isUserSelected(user.id)"
@change="toggleUser(user)"
/>
<div class="selector-item-content">
<h4 class="selector-item-title">{{ user.username }}</h4>
<p class="selector-item-meta">邮箱: {{ user.email || '未知' }} | {{ user.phone || '-' }}</p>
</div>
</label>
</div>
</div>
<div class="modal-footer">
<button class="btn-primary" @click="showUserSelector = false">确定</button>
</div>
</div>
</div>
<!-- 用户选择器组件 -->
<GenericSelector
v-model:visible="showUserSelector"
mode="add"
title="选择学习用户"
left-title="可选用户"
right-title="已选用户"
:fetch-available-api="fetchAllUsers"
:fetch-selected-api="fetchSelectedUsers"
:filter-selected="filterUsers"
:loading="userLoading"
:item-config="{ id: 'id', label: 'username', sublabel: 'deptName' }"
:use-tree="true"
:tree-transform="transformUsersToTree"
:tree-props="{ children: 'children', label: 'displayName', id: 'id' }"
:only-leaf-selectable="true"
unit-name=""
search-placeholder="搜索用户..."
@confirm="handleUserSelectConfirm"
@cancel="showUserSelector = false"
/>
</div>
</template>
@@ -308,6 +283,7 @@ import { courseApi } from '@/apis/study';
import { resourceApi } from '@/apis/resource';
import { userApi } from '@/apis/system';
import { learningTaskApi } from '@/apis/study';
import { GenericSelector } from '@/components/base';
import type { TaskVO, Course, TaskItemVO } from '@/types/study';
import type { Resource } from '@/types/resource';
import type { SysUser } from '@/types/user';
@@ -321,7 +297,7 @@ defineExpose({
});
interface Props {
taskID?: string;
taskId?: string;
}
const props = defineProps<Props>();
@@ -369,7 +345,6 @@ const showUserSelector = ref(false);
// 搜索关键词
const courseSearchKeyword = ref('');
const resourceSearchKeyword = ref('');
const userSearchKeyword = ref('');
// 加载状态
const courseLoading = ref(false);
@@ -377,22 +352,52 @@ const resourceLoading = ref(false);
const userLoading = ref(false);
const submitting = ref(false);
onMounted(() => {
if (props.taskID) {
loadTask();
onMounted(async () => {
// 先加载所有可选项
await Promise.all([
loadCourses(),
loadResources(),
loadUsers()
]);
// 如果是编辑模式,加载任务数据并恢复选择
if (props.taskId) {
await loadTask();
}
loadCourses();
loadResources();
loadUsers();
});
// 加载任务数据
async function loadTask() {
try {
const res = await learningTaskApi.getTaskById(props.taskID!);
const res = await learningTaskApi.getTaskById(props.taskId!);
if (res.success && res.data) {
taskData.value = res.data as TaskVO;
// TODO: 根据 taskCourses、taskResources、taskUsers 恢复选中状态
// 转换时间格式为 datetime-local 可接受的格式
if (taskData.value.learningTask.startTime) {
taskData.value.learningTask.startTime = formatDateTimeLocal(taskData.value.learningTask.startTime);
}
if (taskData.value.learningTask.endTime) {
taskData.value.learningTask.endTime = formatDateTimeLocal(taskData.value.learningTask.endTime);
}
// 恢复课程选择
if (taskData.value.taskCourses && taskData.value.taskCourses.length > 0) {
const courseIds = taskData.value.taskCourses.map(tc => tc.courseID);
selectedCourses.value = availableCourses.value.filter(c => courseIds.includes(c.courseID));
}
// 恢复资源选择
if (taskData.value.taskResources && taskData.value.taskResources.length > 0) {
const resourceIds = taskData.value.taskResources.map(tr => tr.resourceID);
selectedResources.value = availableResources.value.filter(r => resourceIds.includes(r.resourceID));
}
// 恢复用户选择
if (taskData.value.taskUsers && taskData.value.taskUsers.length > 0) {
const userIds = taskData.value.taskUsers.map(tu => tu.userID);
selectedUsers.value = availableUsers.value.filter(u => userIds.includes(u.id));
}
}
} catch (error) {
console.error('加载任务失败:', error);
@@ -400,6 +405,29 @@ async function loadTask() {
}
}
/**
* 将 ISO 8601 时间格式转换为 datetime-local 输入框需要的格式
* @param dateTimeString ISO 8601 格式的时间字符串
* @returns YYYY-MM-DDTHH:mm 格式的字符串
*/
function formatDateTimeLocal(dateTimeString: string): string {
if (!dateTimeString) return '';
const date = new Date(dateTimeString);
// 检查日期是否有效
if (isNaN(date.getTime())) return '';
// 获取本地时间的各个部分
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}T${hours}:${minutes}`;
}
// 加载课程列表
async function loadCourses() {
courseLoading.value = true;
@@ -474,19 +502,132 @@ async function loadUsers() {
}
}
// 搜索用户
function searchUsers() {
if (!userSearchKeyword.value) {
loadUsers();
return;
/**
* 获取所有用户的API函数
*/
async function fetchAllUsers() {
const filter: any = { username: undefined };
return await userApi.getUserList(filter);
}
/**
* 获取已选用户的API函数
*/
async function fetchSelectedUsers() {
return {
success: true,
dataList: selectedUsers.value,
code: 200,
message: '',
login: true,
auth: true
};
}
/**
* 过滤已选用户
*/
function filterUsers(available: any[], selected: any[]) {
const selectedIds = new Set(selected.map(item => item.id));
return available.filter(item => !selectedIds.has(item.id));
}
/**
* 将用户扁平数据转换为树形结构(按部门分组)
*/
function transformUsersToTree(flatData: any[]): any[] {
if (!flatData || flatData.length === 0) {
return [];
}
const keyword = userSearchKeyword.value.toLowerCase();
availableUsers.value = availableUsers.value.filter(user =>
user.username?.toLowerCase().includes(keyword) ||
user.phone?.includes(keyword) ||
user.email?.toLowerCase().includes(keyword)
);
// 按部门分组
const deptMap = new Map<string, any>();
const tree: any[] = [];
flatData.forEach(item => {
const deptID = item.deptID || 'unknown';
const deptName = item.deptName || '未分配部门';
// 优先使用 parentID如果不存在则使用 parentDeptID
const parentID = item.parentID || item.parentDeptID;
if (!deptMap.has(deptID)) {
// 创建部门节点
deptMap.set(deptID, {
id: `dept_${deptID}`,
displayName: deptName,
deptID: deptID,
deptName: deptName,
parentDeptID: parentID,
children: [],
isDept: true
});
}
// 添加用户到部门的children中
const deptNode = deptMap.get(deptID);
if (deptNode) {
deptNode.children.push({
...item,
displayName: item.username || item.id,
isDept: false
});
}
});
// 构建部门层级关系
const deptNodes = Array.from(deptMap.values());
const deptTreeMap = new Map<string, any>();
// 初始化所有部门节点
deptNodes.forEach(dept => {
deptTreeMap.set(dept.deptID, { ...dept });
});
// 构建部门树
deptNodes.forEach(dept => {
const node = deptTreeMap.get(dept.deptID);
if (!node) return;
if (!dept.parentDeptID || dept.parentDeptID === '0' || dept.parentDeptID === '') {
// 根部门
tree.push(node);
} else {
// 子部门
const parent = deptTreeMap.get(dept.parentDeptID);
if (parent) {
// 将用户节点暂存
const users = node.children || [];
// 添加部门到父部门(先添加部门)
if (!parent.children) {
parent.children = [];
}
// 找到部门子节点的插入位置(部门应该在用户之前)
const firstUserIndex = parent.children.findIndex((child: any) => !child.isDept);
const insertIndex = firstUserIndex === -1 ? parent.children.length : firstUserIndex;
// 插入部门节点
parent.children.splice(insertIndex, 0, {
...node,
children: users
});
} else {
// 找不到父节点,作为根节点
tree.push(node);
}
}
});
return tree;
}
/**
* 处理用户选择器确认事件
*/
function handleUserSelectConfirm(users: any[]) {
selectedUsers.value = users as SysUser[];
showUserSelector.value = false;
}
// 课程选择相关
@@ -526,19 +667,6 @@ function removeResource(index: number) {
}
// 用户选择相关
function isUserSelected(userID?: string) {
return selectedUsers.value.some(u => u.id === userID);
}
function toggleUser(user: SysUser) {
const index = selectedUsers.value.findIndex(u => u.id === user.id);
if (index > -1) {
selectedUsers.value.splice(index, 1);
} else {
selectedUsers.value.push(user);
}
}
function removeUser(index: number) {
selectedUsers.value.splice(index, 1);
}
@@ -623,14 +751,14 @@ async function handleSubmit() {
} as TaskItemVO));
let res;
if (props.taskID) {
if (props.taskId) {
res = await learningTaskApi.updateTask(taskData.value);
} else {
res = await learningTaskApi.createTask(taskData.value);
}
if (res.success) {
ElMessage.success(props.taskID ? '任务更新成功' : '任务创建成功');
ElMessage.success(props.taskId ? '任务更新成功' : '任务创建成功');
emit('success');
} else {
ElMessage.error(res.message || '操作失败');