czh-20260501-增加密级相关功能

This commit is contained in:
zhihao
2026-05-01 03:10:47 +08:00
parent d9d5ab70cc
commit c15be6a79b
14 changed files with 1197 additions and 162 deletions
+3
View File
@@ -45,6 +45,7 @@ enum Api {
listAllByMainId = '/tasklist/taskListDetial/listAllByMainId',
myResponsibleTasks = '/tasklist/taskList/myResponsibleTasks',
myFollowedTasks = '/tasklist/taskList/myFollowedTasks',
getCurrentUserSecurityLevel = '/tasklist/taskList/getCurrentUserSecurityLevel',
}
export const getExportUrl = Api.exportXls;
@@ -150,3 +151,5 @@ export const listAllByMainId = (params) => defHttp.get({ url: Api.listAllByMainI
export const myResponsibleTasks = () => defHttp.get({ url: Api.myResponsibleTasks });
export const myFollowedTasks = () => defHttp.get({ url: Api.myFollowedTasks });
export const getCurrentUserSecurityLevel = () => defHttp.get({ url: Api.getCurrentUserSecurityLevel });
+27 -8
View File
@@ -2,6 +2,7 @@ import { BasicColumn } from '/@/components/Table';
import { FormSchema } from '/@/components/Table';
import { JVxeTypes, JVxeColumn } from '/@/components/jeecg/JVxeTable/types';
import type { TaskListGroup } from './types';
import { getAvailableSecretLevels } from './types';
export const columns: BasicColumn[] = [
{
@@ -300,14 +301,32 @@ export const createTaskFormSchema: FormSchema[] = [
},
];
export const createListFormSchema: FormSchema[] = [
{
label: '清单名称',
field: 'tasklistName',
component: 'Input',
required: true,
},
];
export function getCreateListFormSchema(userSecurityLevel: number): FormSchema[] {
if (!userSecurityLevel) userSecurityLevel = 3;
return [
{
label: '清单名称',
field: 'tasklistName',
component: 'Input',
required: true,
componentProps: {
placeholder: '请输入清单名称',
},
},
{
label: '密级',
field: 'secretLevel',
component: 'Select',
required: true,
defaultValue: 1,
componentProps: {
getPopupContainer: () => document.body,
options: getAvailableSecretLevels(userSecurityLevel),
placeholder: '请选择密级',
},
},
];
}
export const mockTaskListGroups: TaskListGroup[] = [
{
@@ -12,7 +12,7 @@
<div class="collab-modal">
<div class="collab-body">
<div class="member-list">
<div v-for="item in collaborators" :key="item.permissionId" class="member-item" @mouseenter="hoverId = item.permissionId" @mouseleave="hoverId = ''">
<div v-for="item in allMembers" :key="item.permissionId || item.userId" class="member-item" @mouseenter="hoverId = item.permissionId || item.userId" @mouseleave="hoverId = ''">
<div class="member-avatar" :style="{ background: getColor(item.username) }">
{{ (item.username || '?').charAt(0) }}
</div>
@@ -26,7 +26,7 @@
<template v-if="item.permission === '1'">
<span class="role-label">所有者</span>
</template>
<template v-else-if="isOwner">
<template v-else-if="isOwner && item.permissionId">
<a-dropdown :trigger="['click']" :overlayStyle="{ minWidth: '120px' }">
<span class="role-label clickable" @click.stop>
{{ item.permission === '2' ? '可编辑' : '可阅读' }}
@@ -54,7 +54,7 @@
</template>
</div>
</div>
<div v-if="collaborators.length === 0" class="empty-state">
<div v-if="allMembers.length === 0" class="empty-state">
<Icon icon="ant-design:team-outlined" style="font-size: 36px; color: #d9d9d9; margin-bottom: 8px" />
<span>暂无协作人</span>
</div>
@@ -68,10 +68,10 @@
</span>
</div>
<div class="add-panel-body">
<JSelectUser
<SzUserSelect
v-model:value="addForm.userIds"
:label-key="'realname'"
:row-key="'id'"
:labelKey="'realname'"
:rowKey="'id'"
placeholder="搜索并选择用户(支持多选)"
/>
<div class="add-panel-row">
@@ -84,7 +84,7 @@
</div>
<div class="add-panel-footer">
<a-button size="small" @click="showAddPanel = false">取消</a-button>
<a-button size="small" type="primary" :disabled="!addForm.userIds" @click="onBatchAdd">
<a-button size="small" type="primary" :disabled="!addForm.userIds" @click="onConfirmAdd">
确认添加
</a-button>
</div>
@@ -96,18 +96,20 @@
<Icon icon="ant-design:user-add-outlined" style="margin-right: 4px" />
添加协作人
</a-button>
<a-button type="primary" size="small" @click="onClose">完成</a-button>
<a-button type="primary" size="small" @click="onDone">完成</a-button>
</div>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive } from 'vue';
import { ref, reactive, computed } from 'vue';
import { message } from 'ant-design-vue';
import Icon from '/@/components/Icon/index';
import JSelectUser from '/@/components/Form/src/jeecg/components/JSelectUser.vue';
import SzUserSelect from '/@/components/semri/userComponent/SzUserSelect.vue';
import { getCollaborators, addCollaborator, removeCollaborator, updateCollaboratorPermission } from '../TaskList.api';
import { getAvatarColor } from '../types';
import { checkUserSecLevel } from '../utils/taskUtils';
import { useMessage } from '/@/hooks/web/useMessage';
const { createMessage } = useMessage();
@@ -119,9 +121,17 @@
permission: string;
}
interface PendingAddItem {
userId: string;
username: string;
permission: string;
}
const props = defineProps<{
taskListId: string;
isOwner: boolean;
secretLevel?: number;
secretText?: string;
}>();
const emit = defineEmits<{
@@ -136,6 +146,17 @@
userIds: '',
permission: '2',
});
const pendingAddList = ref<PendingAddItem[]>([]);
const allMembers = computed(() => {
const pendingAsCollab: CollaboratorItem[] = pendingAddList.value.map((p) => ({
permissionId: '',
userId: p.userId,
username: p.username,
permission: p.permission,
}));
return [...collaborators.value, ...pendingAsCollab];
});
function getColor(name: string): string {
return getAvatarColor(name || '');
@@ -144,6 +165,9 @@
async function open() {
visible.value = true;
showAddPanel.value = false;
pendingAddList.value = [];
addForm.userIds = '';
addForm.permission = '2';
await loadCollaborators();
}
@@ -152,6 +176,7 @@
showAddPanel.value = false;
addForm.userIds = '';
addForm.permission = '2';
pendingAddList.value = [];
}
async function loadCollaborators() {
@@ -163,28 +188,48 @@
}
}
async function onBatchAdd() {
async function onConfirmAdd() {
if (!addForm.userIds) return;
const ids = addForm.userIds.split(',').filter(Boolean);
if (ids.length === 0) return;
let successCount = 0;
let failCount = 0;
const secretLevel = props.secretLevel || 1;
const { ok, invalidNames } = await checkUserSecLevel(addForm.userIds, secretLevel);
if (!ok) {
message.warning(`以下人员的密级不满足当前清单(${props.secretText || '非密'})要求:${invalidNames.join('、')}`);
return;
}
const nameMap: Record<string, string> = {};
try {
const { getUserList } = await import('/@/api/common/api');
const res = await getUserList({ id: ids.join(','), pageNo: 1, pageSize: ids.length * 2 });
if (res.records && res.records.length > 0) {
res.records.forEach((u: any) => {
nameMap[u.id] = u.realname || u.username;
});
}
} catch (e) {
console.warn('获取用户名失败', e);
}
const existingIds = new Set(collaborators.value.map((c) => c.userId));
const pendingIds = new Set(pendingAddList.value.map((p) => p.userId));
let addedCount = 0;
for (const uid of ids) {
try {
await addCollaborator({
taskListId: props.taskListId,
if (!existingIds.has(uid) && !pendingIds.has(uid)) {
pendingAddList.value.push({
userId: uid,
username: nameMap[uid] || uid,
permission: addForm.permission,
});
successCount++;
} catch {
failCount++;
addedCount++;
}
}
if (successCount > 0) {
createMessage.success(`成功添加 ${successCount} 位协作人${failCount > 0 ? `${failCount} 位已存在` : ''}`);
if (addedCount > 0) {
createMessage.success(`添加 ${addedCount} 位协作人`);
} else {
createMessage.warning('所选用户均已是协作人');
}
@@ -192,8 +237,39 @@
addForm.userIds = '';
addForm.permission = '2';
showAddPanel.value = false;
await loadCollaborators();
emit('changed');
}
async function onDone() {
if (pendingAddList.value.length > 0) {
let successCount = 0;
let failCount = 0;
for (const item of pendingAddList.value) {
try {
await addCollaborator({
taskListId: props.taskListId,
userId: item.userId,
permission: item.permission,
});
successCount++;
} catch {
failCount++;
}
}
if (successCount > 0) {
createMessage.success(`成功添加 ${successCount} 位协作人${failCount > 0 ? `${failCount} 位失败` : ''}`);
} else {
createMessage.warning('添加协作人失败');
}
pendingAddList.value = [];
emit('changed');
}
visible.value = false;
showAddPanel.value = false;
addForm.userIds = '';
addForm.permission = '2';
}
async function onRemove(permissionId: string) {
@@ -7,35 +7,41 @@
<script lang="ts" setup>
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicForm, useForm } from '/@/components/Form/index';
import { createListFormSchema } from '../TaskList.data';
import { getCreateListFormSchema } from '../TaskList.data';
import { addTaskList } from '../TaskList.api';
import { getSecretText } from '../types';
const emit = defineEmits(['register', 'success']);
const [registerForm, { resetFields, validate }] = useForm({
let pendingGroupId = '';
let pendingUserSecurityLevel = 3;
const [registerForm, { resetFields, validate, updateSchema }] = useForm({
labelWidth: 100,
schemas: createListFormSchema,
schemas: getCreateListFormSchema(3),
showActionButtonGroup: false,
baseColProps: { span: 24 },
});
let pendingGroupId = '';
const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data) => {
await resetFields();
pendingGroupId = data?.groupId || '';
pendingUserSecurityLevel = data?.userSecurityLevel || 3;
updateSchema(getCreateListFormSchema(pendingUserSecurityLevel));
});
async function handleSubmit() {
try {
setModalProps({ confirmLoading: true });
const values = await validate();
const listId = await addTaskList({
tasklistName: values.tasklistName || values.name,
const result = await addTaskList({
tasklistName: values.tasklistName,
pid: pendingGroupId || undefined,
secretLevel: values.secretLevel,
secretText: getSecretText(values.secretLevel),
});
closeModal();
emit('success', listId);
emit('success', result);
} finally {
setModalProps({ confirmLoading: false });
}
@@ -1,19 +1,36 @@
<template>
<BasicModal v-bind="$attrs" @register="registerModal" destroy-on-close title="创建任务" :width="600" @ok="handleSubmit">
<BasicForm @register="registerForm" />
<div class="attachment-row">
<span class="attachment-label">附件</span>
<span class="attachment-trigger" @click="openFileUploadModal">添加附件</span>
<TaskAttachmentUpload
ref="fileUploadRef"
:bussiness-id="''"
:bussiness-secret-level="currentSecretLevel"
:disabled="false"
style="display: none"
/>
</div>
</BasicModal>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicForm, useForm } from '/@/components/Form/index';
import TaskAttachmentUpload from './TaskAttachmentUpload.vue';
import { createTaskFormSchema } from '../TaskList.data';
import { addTask } from '../TaskList.api';
import { checkUserSecLevel } from '../utils/taskUtils';
const emit = defineEmits(['register', 'success']);
const groupId = ref('');
const mainId = ref('');
const currentSecretLevel = ref(1);
const currentSecretText = ref('非密');
const fileUploadRef = ref<InstanceType<typeof TaskAttachmentUpload> | null>(null);
const [registerForm, { setFieldsValue, resetFields, validate }] = useForm({
labelWidth: 100,
@@ -26,6 +43,8 @@
await resetFields();
groupId.value = data?.groupId || '';
mainId.value = data?.mainId || '';
currentSecretLevel.value = data?.secretLevel || 1;
currentSecretText.value = data?.secretText || '非密';
if (groupId.value) {
await setFieldsValue({ groupId: groupId.value });
}
@@ -35,7 +54,16 @@
try {
setModalProps({ confirmLoading: true });
const values = await validate();
await addTask({
if (values.assigneeId) {
const { ok, invalidNames } = await checkUserSecLevel(values.assigneeId, currentSecretLevel.value);
if (!ok) {
message.warning(`以下人员的密级不满足当前清单(${currentSecretText.value})要求:${invalidNames.join('、')}`);
return;
}
}
const result = await addTask({
mainId: mainId.value,
taskName: values.taskName,
taskDesc: values.taskDesc || '',
@@ -46,10 +74,42 @@
startTime: values.startTime || null,
endTime: values.endTime || null,
});
if (fileUploadRef.value?.hasPendingFiles()) {
await fileUploadRef.value.bindBussinessId(result);
}
closeModal();
emit('success');
} finally {
setModalProps({ confirmLoading: false });
}
}
function openFileUploadModal() {
fileUploadRef.value?.handleOpenUploadModal();
}
</script>
<style lang="less" scoped>
.attachment-row {
display: flex;
align-items: center;
padding: 0 0 16px;
gap: 8px;
}
.attachment-label {
width: 100px;
flex-shrink: 0;
text-align: right;
font-size: 14px;
color: rgba(0, 0, 0, 0.85);
}
.attachment-trigger {
color: #8c8c8c;
cursor: pointer;
font-size: 14px;
&:hover {
color: #3370ff;
}
}
</style>
@@ -0,0 +1,462 @@
<!-- 文件上传下载删除组件任务附件专用副本 -->
<template>
<div class="s-upload-file-container">
<div class="upload-btn-wrapper" v-if="!disabled">
<a-button type="primary" @click="handleOpenUploadModal">
<Icon icon="ant-design:upload-outlined" />
选择文件上传
</a-button>
</div>
<div class="file-list-wrapper">
<div v-if="displayFileList.length > 0" class="file-list-header">
<span class="file-count"> {{ displayFileList.length }} 个文件</span>
<a-button
type="link"
size="small"
:loading="downloadingAll"
@click="handleDownloadAll"
>
<Icon icon="ant-design:download-outlined" />
下载全部
</a-button>
</div>
<a-spin :spinning="loading">
<a-empty v-if="!loading && displayFileList.length === 0" description="暂无文件" />
<div v-else class="file-list">
<div
v-for="file in displayFileList"
:key="file.fileId || file.id"
class="file-item"
>
<div class="file-info">
<Icon icon="ant-design:file-outlined" class="file-icon" />
<span class="file-name">{{ file.fileName }}</span>
<a-tag :color="getSecretLevelColor(file.secretLevel)" class="secret-tag">
{{ getSecretLevelLabel(file.secretLevel) }}
</a-tag>
<a-tag v-if="file.pending" color="orange" class="secret-tag">
待保存
</a-tag>
<span v-if="file.fileSize" class="file-size">{{ formatFileSize(file.fileSize) }}</span>
</div>
<div class="file-actions">
<a-button
type="link"
size="small"
@click="handleDownload(file)"
>
<Icon icon="ant-design:download-outlined" />
下载
</a-button>
<a-button
v-if="!disabled"
type="link"
size="small"
danger
@click="handleDelete(file)"
>
<Icon icon="ant-design:delete-outlined" />
删除
</a-button>
</div>
</div>
</div>
</a-spin>
</div>
<TaskAttachmentUploadModal
v-model:visible="uploadModalVisible"
:secret-level-options="secretLevelOptions"
:business-id="bussinessId"
:multiple="multiple"
@upload-success="handleUploadSuccess"
@upload-error="handleUploadError"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from 'vue';
import { Icon } from '/src/components/Icon';
import { defHttp } from '/src/utils/http/axios';
import { useMessage } from '/src/hooks/web/useMessage';
import TaskAttachmentUploadModal from './TaskAttachmentUploadModal.vue';
const { createMessage, createConfirm } = useMessage();
const props = defineProps({
bussinessId: {
type: String,
default: ''
},
bussinessSecretLevel: {
type: Number,
default: 1,
validator: (value: number) => [1, 2, 3, 4].includes(value)
},
disabled: {
type: Boolean,
default: false
},
multiple: {
type: Boolean,
default: true
}
});
const emit = defineEmits(['upload-success', 'delete-success', 'upload-error']);
const serverFileList = ref<any[]>([]);
const pendingFileList = ref<any[]>([]);
const loading = ref<boolean>(false);
const uploadModalVisible = ref<boolean>(false);
const binding = ref<boolean>(false);
const downloadingAll = ref<boolean>(false);
const displayFileList = computed(() => {
return [
...serverFileList.value.map(f => ({ ...f, pending: false })),
...pendingFileList.value.map(f => ({ ...f, pending: true }))
];
});
const SECRET_LEVEL_CONFIG = {
1: { label: '非密', color: 'success' },
2: { label: '内部', color: 'processing' },
3: { label: '秘密', color: 'warning' },
4: { label: '机密', color: 'error' }
};
function getSecretLevelInfo(level: number) {
return SECRET_LEVEL_CONFIG[level] || SECRET_LEVEL_CONFIG[1];
}
function getSecretLevelLabel(level: number) {
return getSecretLevelInfo(level).label;
}
function getSecretLevelColor(level: number) {
return getSecretLevelInfo(level).color;
}
function formatFileSize(bytes: number): string {
if (!bytes || bytes === 0) return '';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
const secretLevelOptions = computed(() => {
const level = props.bussinessSecretLevel;
const options = [];
options.push({ label: '非密', value: 1 });
if (level >= 2) options.push({ label: '内部', value: 2 });
if (level >= 3) options.push({ label: '秘密', value: 3 });
if (level >= 4) options.push({ label: '机密', value: 4 });
return options;
});
function handleOpenUploadModal() {
uploadModalVisible.value = true;
}
async function handleUploadSuccess(result: any) {
if (result.results && Array.isArray(result.results)) {
result.results.forEach((fileInfo: any) => {
if (!fileInfo.bound) {
pendingFileList.value.push({
fileId: fileInfo.fileId,
fileName: fileInfo.fileName,
filePath: fileInfo.filePath,
fileSize: fileInfo.fileSize,
secretLevel: fileInfo.secretLevel,
fileUploadType: fileInfo.fileUploadType
});
}
});
}
if (props.bussinessId) {
await loadFileList();
}
emit('upload-success', result);
}
function handleUploadError(error: any) {
emit('upload-error', error);
}
async function handleDownload(file: any) {
try {
const fileUrl = file.filePath;
const fileName = file.fileName || fileUrl.substring(fileUrl.lastIndexOf('/') + 1);
const domainUrl = import.meta.env.VITE_GLOB_DOMAIN_URL;
const data = await defHttp.get(
{ url: `${domainUrl}/sys/file/download`, params: { fileUrl }, responseType: 'blob' },
{ isTransformResponse: false }
);
if (!data || data.size === 0) {
createMessage.warning('文件下载失败');
return;
}
if (typeof window.navigator.msSaveBlob !== 'undefined') {
window.navigator.msSaveBlob(new Blob([data]), fileName);
} else {
const blobUrl = window.URL.createObjectURL(new Blob([data]));
const link = document.createElement('a');
link.style.display = 'none';
link.href = blobUrl;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
}
} catch (error) {
console.error('下载失败:', error);
createMessage.error('文件下载失败');
}
}
async function handleDownloadAll() {
const files = displayFileList.value;
if (files.length === 0) return;
downloadingAll.value = true;
let successCount = 0;
let failCount = 0;
for (const file of files) {
try {
await handleDownload(file);
successCount++;
} catch (error) {
failCount++;
console.error(`下载文件"${file.fileName}"失败:`, error);
}
}
downloadingAll.value = false;
if (successCount > 0) {
createMessage.success(`成功下载 ${successCount} 个文件${failCount > 0 ? `${failCount} 个失败` : ''}`);
} else {
createMessage.error('所有文件下载失败');
}
}
function handleDelete(file: any) {
createConfirm({
iconType: 'warning',
title: '确认删除',
content: `确定要删除文件"${file.fileName}"吗?`,
okText: '确认',
cancelText: '取消',
onOk: async () => {
try {
loading.value = true;
await defHttp.get({
url: '/sys/file/delete',
params: { id: file.fileId || file.id }
});
createMessage.success('文件删除成功');
if (file.pending) {
pendingFileList.value = pendingFileList.value.filter(f => f.fileId !== file.fileId);
} else {
serverFileList.value = serverFileList.value.filter(f => (f.id || f.fileId) !== (file.id || file.fileId));
}
emit('delete-success', file.fileId || file.id);
} catch (error) {
console.error('删除失败:', error);
createMessage.error('文件删除失败');
} finally {
loading.value = false;
}
}
});
}
async function loadFileList() {
if (!props.bussinessId) {
return;
}
try {
loading.value = true;
const result = await defHttp.get({
url: '/sys/file/getFileInfoByBussinessId',
params: { bussinessId: props.bussinessId }
});
if (Array.isArray(result)) {
serverFileList.value = result;
} else {
serverFileList.value = [];
}
} catch (error) {
console.error('加载文件列表失败:', error);
serverFileList.value = [];
createMessage.error('加载文件列表失败');
} finally {
loading.value = false;
}
}
async function bindBussinessId(newBussinessId: string) {
if (!newBussinessId) {
createMessage.warning('bussinessId 不能为空');
return false;
}
if (pendingFileList.value.length === 0) {
return true;
}
const fileIds = pendingFileList.value.map(f => f.fileId).join(',');
try {
binding.value = true;
await defHttp.get({
url: '/sys/file/updateFilesInfo',
params: {
bussinessId: newBussinessId,
fileIds: fileIds
}
});
createMessage.success(`成功关联 ${pendingFileList.value.length} 个文件`);
pendingFileList.value = [];
await loadFileList();
return true;
} catch (error) {
console.error('关联文件失败:', error);
createMessage.error('关联文件失败,请重试');
return false;
} finally {
binding.value = false;
}
}
function getPendingFileIds(): string[] {
return pendingFileList.value.map(f => f.fileId);
}
function hasPendingFiles(): boolean {
return pendingFileList.value.length > 0;
}
defineExpose({
bindBussinessId,
getPendingFileIds,
hasPendingFiles,
loadFileList,
handleOpenUploadModal
});
watch(() => props.bussinessId, (newVal) => {
if (newVal && newVal.length > 0) {
loadFileList();
} else {
serverFileList.value = [];
}
}, { immediate: true });
</script>
<style lang="less" scoped>
.s-upload-file-container {
width: 100%;
max-width: 100%;
.upload-btn-wrapper {
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 12px;
}
.file-list-wrapper {
width: 100%;
min-height: 100px;
padding: 12px;
background: #fafafa;
border-radius: 4px;
.file-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
.file-count {
font-size: 13px;
color: #8c8c8c;
}
}
}
.file-list {
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 4px;
margin-bottom: 8px;
transition: all 0.3s;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&:last-child {
margin-bottom: 0;
}
.file-info {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
overflow: hidden;
.file-icon {
font-size: 16px;
color: #1890ff;
flex-shrink: 0;
}
.file-name {
font-size: 14px;
color: #262626;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.secret-tag {
margin-left: 4px;
font-weight: 500;
flex-shrink: 0;
}
.file-size {
font-size: 12px;
color: #8c8c8c;
flex-shrink: 0;
}
}
.file-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
}
}
}
</style>
@@ -0,0 +1,253 @@
<!-- 文件上传弹窗组件任务附件专用副本 -->
<template>
<a-modal
:visible="visible"
title="选择文件密级并上传"
:width="600"
:confirm-loading="uploading"
@ok="handleUpload"
@cancel="handleCancel"
:destroyOnClose="true"
>
<a-form layout="vertical" style="padding: 0 20px">
<a-form-item label="文件密级" required>
<a-radio-group v-model:value="selectedSecretLevel">
<a-radio
v-for="option in secretLevelOptions"
:key="option.value"
:value="option.value"
>
<a-tag :color="getSecretLevelColor(option.value)">
{{ option.label }}
</a-tag>
</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="选择文件" required>
<a-upload
:before-upload="beforeUpload"
:file-list="tempFileList"
@remove="handleRemoveFile"
:multiple="multiple"
:max-count="multiple ? undefined : 1"
>
<a-button>
<Icon icon="ant-design:file-add-outlined" />
{{ multiple ? '选择文件(可多选)' : '选择文件' }}
</a-button>
</a-upload>
</a-form-item>
<a-alert
v-if="selectedFiles.length > 0"
:message="`已选择 ${selectedFiles.length} 个文件`"
type="info"
show-icon
>
<template #description>
<div class="selected-files-summary">
<div v-for="(f, idx) in selectedFiles" :key="f.uid" class="summary-item">
{{ idx + 1}}. {{ f.name }}{{ formatFileSize(f.size) }}
</div>
</div>
</template>
</a-alert>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { Icon } from '/src/components/Icon';
import { useMessage } from '/src/hooks/web/useMessage';
import { uploadMyFile } from '/src/api/common/api';
const { createMessage } = useMessage();
const props = defineProps({
visible: {
type: Boolean,
required: true
},
secretLevelOptions: {
type: Array,
default: () => []
},
businessId: {
type: String,
default: ''
},
multiple: {
type: Boolean,
default: true
}
});
const emit = defineEmits(['update:visible', 'upload-success', 'upload-error']);
const selectedSecretLevel = ref<number>(1);
const selectedFiles = ref<any[]>([]);
const tempFileList = ref<any[]>([]);
const uploading = ref<boolean>(false);
function getSecretLevelColor(level: number) {
const colors = {
1: 'success',
2: 'processing',
3: 'warning',
4: 'error'
};
return colors[level] || 'default';
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
function beforeUpload(file) {
const isValidSize = file.size / 1024 / 1024 < 500;
if (!isValidSize) {
createMessage.error(`文件"${file.name}"大小超过500MB,已跳过`);
return false;
}
if (!props.multiple) {
selectedFiles.value = [file];
tempFileList.value = [{
uid: file.uid,
name: file.name,
status: 'done',
size: file.size
}];
} else {
selectedFiles.value = [...selectedFiles.value, file];
tempFileList.value = [...tempFileList.value, {
uid: file.uid,
name: file.name,
status: 'done',
size: file.size
}];
}
return false;
}
function handleRemoveFile(file) {
selectedFiles.value = selectedFiles.value.filter(f => f.uid !== file.uid);
tempFileList.value = tempFileList.value.filter(f => f.uid !== file.uid);
}
async function handleUpload() {
if (selectedFiles.value.length === 0) {
createMessage.warning('请先选择文件');
return;
}
if (!selectedSecretLevel.value) {
createMessage.warning('请选择文件密级');
return;
}
uploading.value = true;
try {
const uploadPromises = selectedFiles.value.map(file => {
const formData = new FormData();
formData.append('file', file);
formData.append('secretLevel', selectedSecretLevel.value.toString());
if (props.businessId) {
formData.append('business_id', props.businessId);
}
return uploadMyFile('/sys/file/upload', formData).then(res => ({
rawFile: file,
response: res
}));
});
const results = await Promise.allSettled(uploadPromises);
let successCount = 0;
let failCount = 0;
const successResults: any[] = [];
results.forEach((r) => {
if (r.status === 'fulfilled') {
const result = r.value?.response?.data;
if (result && result.success) {
successCount++;
successResults.push({
fileId: result.result.fileId,
fileName: r.value.rawFile.name,
filePath: result.result.savePath,
fileSize: r.value.rawFile.size,
secretLevel: selectedSecretLevel.value,
fileUploadType: result.result.fileUploadType,
bound: !!props.businessId
});
} else {
failCount++;
}
} else {
failCount++;
}
});
if (successCount > 0) {
createMessage.success(`成功上传 ${successCount} 个文件${failCount > 0 ? `${failCount} 个失败` : ''}`);
emit('upload-success', { success: true, results: successResults });
handleCancel();
} else {
createMessage.error('所有文件上传失败');
emit('upload-error', new Error('所有文件上传失败'));
}
} catch (error) {
console.error('上传失败:', error);
createMessage.error('文件上传失败');
emit('upload-error', error);
} finally {
uploading.value = false;
}
}
function handleCancel() {
emit('update:visible', false);
setTimeout(() => {
selectedSecretLevel.value = 1;
selectedFiles.value = [];
tempFileList.value = [];
}, 300);
}
watch(() => props.visible, (newVal) => {
if (newVal && props.secretLevelOptions.length > 0) {
selectedSecretLevel.value = props.secretLevelOptions[0].value;
}
});
</script>
<style lang="less" scoped>
.secret-tip {
margin-top: 8px;
color: #faad14;
font-size: 12px;
display: flex;
align-items: center;
gap: 4px;
}
.selected-files-summary {
max-height: 150px;
overflow-y: auto;
.summary-item {
padding: 2px 0;
font-size: 12px;
color: #595959;
}
}
</style>
+43 -1
View File
@@ -36,6 +36,9 @@
</a-dropdown>
</div>
<div v-if="currentList" class="content-header-actions">
<span v-if="currentList.secretText" class="header-secret-badge" :style="{ color: SECRET_LEVEL_COLOR[currentList.secretLevel || 1] || '#8c8c8c', borderColor: SECRET_LEVEL_COLOR[currentList.secretLevel || 1] || '#8c8c8c' }">
{{ currentList.secretText }}
</span>
<div class="collaborator-bar" @click="emit('open-collaborator')">
<AvatarDisplay
v-if="collaboratorDisplayNames"
@@ -246,6 +249,7 @@
<div class="list-group-col list-group-col-name">名称</div>
<div class="list-group-col list-group-col-owner">所有者</div>
<div class="list-group-col list-group-col-collaborators">协作者</div>
<div class="list-group-col list-group-col-secret">密级</div>
<div class="list-group-col list-group-col-time">创建时间</div>
</div>
<div v-for="list in allTaskLists" :key="list.id" class="list-group-row" @click="emit('list-select', list.id)">
@@ -273,6 +277,16 @@
</template>
<span v-else class="field-placeholder">-</span>
</div>
<div class="list-group-col list-group-col-secret">
<span
v-if="list.secretText"
class="secret-badge-inline"
:style="{ color: SECRET_LEVEL_COLOR[(list as any).secretLevel || 1] || '#8c8c8c', borderColor: SECRET_LEVEL_COLOR[(list as any).secretLevel || 1] || '#8c8c8c' }"
>
{{ list.secretText }}
</span>
<span v-else class="field-placeholder">-</span>
</div>
<div class="list-group-col list-group-col-time">
<span class="field-value">{{ getFormattedCreateTime(list) }}</span>
</div>
@@ -790,6 +804,7 @@
:user-ids="userSelectModalCurrentIds"
:user-names="userSelectModalCurrentNames"
:multiple="true"
:secret-level="currentList?.secretLevel || 1"
@confirm="onUserSelectConfirm"
/>
</div>
@@ -804,7 +819,7 @@
import UserSelectModal from './UserSelectModal.vue';
import { useUserStore } from '/@/store/modules/user';
import { useMessage } from '/@/hooks/web/useMessage';
import { FIELD_CONFIG, GROUP_OPTIONS, SORT_OPTIONS, MAX_TASK_DEPTH, FILTER_FIELD_CONFIGS, FILTER_OPERATOR_LABELS } from '../types';
import { FIELD_CONFIG, GROUP_OPTIONS, SORT_OPTIONS, MAX_TASK_DEPTH, FILTER_FIELD_CONFIGS, FILTER_OPERATOR_LABELS, SECRET_LEVEL_COLOR } from '../types';
import { PRIORITY_OPTIONS, getPriorityBg, getPriorityLabel, isOverdue } from '../utils/taskUtils';
import type {
TaskList,
@@ -1851,6 +1866,15 @@
gap: 8px;
}
.header-secret-badge {
font-size: 11px;
padding: 0 6px;
border-radius: 3px;
border: 1px solid;
line-height: 18px;
flex-shrink: 0;
}
.collaborator-bar {
display: flex;
align-items: center;
@@ -2150,6 +2174,9 @@
.content-body {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.task-group-header {
@@ -2651,6 +2678,21 @@
padding: 0 12px;
}
.list-group-col-secret {
width: 80px;
padding: 0 12px;
text-align: center;
}
.secret-badge-inline {
font-size: 11px;
padding: 1px 6px;
border-radius: 3px;
border: 1px solid;
line-height: 16px;
white-space: nowrap;
}
.list-group-col-time {
width: 160px;
padding: 0 12px;
@@ -76,12 +76,19 @@
<div class="detail-row-content">
<div class="detail-field-row">
<span class="detail-field-label">负责人</span>
<div v-if="taskData.assigneeName" class="assignee-info" :class="{ 'readonly-field': isReadOnly }" @click="!isReadOnly && openDrawerUserSelect('assignee')">
<div v-if="!isFullReadOnly" class="detail-field-clickable" @click="openDrawerUserSelect('assignee')">
<template v-if="taskData.assigneeName">
<a-tooltip :title="taskData.assigneeName" placement="top">
<AvatarDisplay :names="taskData.assigneeName" :size="28" :show-name="true" />
</a-tooltip>
</template>
<span v-else class="field-placeholder">未指定</span>
</div>
<div v-else-if="taskData.assigneeName" class="assignee-info readonly-field">
<a-tooltip :title="taskData.assigneeName" placement="top">
<AvatarDisplay :names="taskData.assigneeName" :size="28" :show-name="true" />
</a-tooltip>
</div>
<a-button v-else-if="!isReadOnly" type="text" class="add-field-btn" @click="openDrawerUserSelect('assignee')">添加</a-button>
<span v-else class="detail-field-value">-</span>
</div>
</div>
@@ -94,12 +101,19 @@
<div class="detail-row-content">
<div class="detail-field-row">
<span class="detail-field-label">参与人</span>
<div v-if="taskData.participantName" class="assignee-info" :class="{ 'readonly-field': isFullReadOnly }" @click="!isFullReadOnly && openDrawerUserSelect('participant')">
<div v-if="!isFullReadOnly" class="detail-field-clickable" @click="openDrawerUserSelect('participant')">
<template v-if="taskData.participantName">
<a-tooltip :title="taskData.participantName" placement="top">
<AvatarDisplay :names="taskData.participantName" :size="28" :show-name="true" />
</a-tooltip>
</template>
<span v-else class="field-placeholder">未指定</span>
</div>
<div v-else-if="taskData.participantName" class="assignee-info readonly-field">
<a-tooltip :title="taskData.participantName" placement="top">
<AvatarDisplay :names="taskData.participantName" :size="28" :show-name="true" />
</a-tooltip>
</div>
<a-button v-else-if="!isFullReadOnly" type="text" class="add-field-btn" @click="openDrawerUserSelect('participant')">添加</a-button>
<span v-else class="detail-field-value">-</span>
</div>
</div>
@@ -222,7 +236,14 @@
<Icon icon="tasklist-attachment|svg" />
</span>
<div class="detail-row-content">
<span class="desc-trigger"> 添加附件 </span>
<span class="desc-trigger" :class="{ 'readonly-field': isFullReadOnly }" @click="!isFullReadOnly && openFileUploadModal()"> 添加附件 </span>
<TaskAttachmentUpload
ref="fileUploadRef"
:bussiness-id="taskData.id || ''"
:bussiness-secret-level="currentListSecretLevel"
:disabled="isFullReadOnly"
style="display: none"
/>
</div>
</div>
@@ -323,32 +344,29 @@
<span class="detail-row-icon">
<Icon icon="tasklist-followers|svg" />
</span>
<a-tooltip v-if="followers.length > 0" :title="taskData.followersName" placement="top">
<div v-if="!isFullReadOnly" class="follower-clickable" @click="openDrawerUserSelect('followers')">
<template v-if="followers.length > 0">
<AvatarDisplay :names="taskData.followersName" :size="24" :show-name="false" />
</template>
<span v-else class="follower-empty">将通知 0 人</span>
</div>
<a-tooltip v-else-if="followers.length > 0" :title="taskData.followersName" placement="top">
<AvatarDisplay :names="taskData.followersName" :size="24" :show-name="false" />
</a-tooltip>
<span v-else class="follower-empty">将通知 0 人</span>
<a-button v-if="!isFullReadOnly" type="text" size="small" @click="openDrawerUserSelect('followers')">
<Icon icon="ant-design:plus-outlined" />
</a-button>
</div>
</template>
<UserSelectModal
ref="drawerUserSelectRef"
:title="
subTaskUserSelectId
? '选择子任务负责人'
: drawerUserSelectType === 'assignee'
? '选择负责人'
: drawerUserSelectType === 'participant'
? '选择参与人'
: '选择关注人'
"
ref="userSelectModalRef"
:title="drawerUserSelectType === 'assignee' ? '选择负责人' : drawerUserSelectType === 'participant' ? '选择参与人' : '选择关注人'"
:user-ids="drawerUserSelectCurrentIds"
:user-names="drawerUserSelectCurrentNames"
:multiple="true"
@confirm="onDrawerUserSelectConfirm"
:secret-level="currentListSecretLevel || 1"
@confirm="onUserSelectModalConfirm"
/>
</BasicDrawer>
</template>
@@ -357,7 +375,8 @@
import { BasicDrawer, useDrawerInner } from '/@/components/Drawer';
import Icon from '/@/components/Icon/index';
import AvatarDisplay from './AvatarDisplay.vue';
import UserSelectModal from './UserSelectModal.vue';
import SzUserSelectBySecLevel from '/@/components/semri/userComponent/SzUserSelectBySecLevel.vue';
import TaskAttachmentUpload from './TaskAttachmentUpload.vue';
import TaskDateRangePicker from './TaskDateRangePicker.vue';
import draggable from 'vuedraggable';
import dayjs from 'dayjs';
@@ -412,16 +431,20 @@
const editingSubTaskValue = ref<any>(null);
const editingSubTaskStartValue = ref<string | null>(null);
const editingSubTaskEndValue = ref<string | null>(null);
const subTaskDatePickerOpen = ref(false);
const subTaskUserSelectId = ref<string>('');
const subTaskDatePickerOpen = ref(false);
const drawerDatePickerVisible = ref(false);
const drawerDateEditMode = ref<'start' | 'end' | null>(null);
const drawerPickerEpoch = ref(0);
const drawerUserSelectRef = ref();
const currentListSecretLevel = ref<number>(1);
const drawerUserSelectType = ref<'assignee' | 'followers' | 'participant'>('assignee');
const drawerUserSelectCurrentIds = ref('');
const drawerUserSelectCurrentNames = ref('');
const drawerUserSelectCurrentIds = ref<string>('');
const drawerUserSelectCurrentNames = ref<string>('');
const userSelectModalRef = ref<InstanceType<typeof UserSelectModal> | null>(null);
const fileUploadRef = ref<InstanceType<typeof TaskAttachmentUpload> | null>(null);
const parentTask = computed(() => {
if (!taskData.value || !taskData.value.pid) return null;
@@ -508,6 +531,7 @@
taskData.value = data?.task ? { ...data.task } : null;
allTasks.value = data?.allTasks ? [...data.allTasks] : [];
isReadOnly.value = data?.readOnly === true;
currentListSecretLevel.value = data?.secretLevel || 1;
showNewSubTaskInput.value = false;
newSubTaskName.value = '';
editingTitle.value = false;
@@ -681,62 +705,71 @@
}
function openDrawerUserSelect(type: 'assignee' | 'followers' | 'participant') {
if (!taskData.value) return;
drawerUserSelectType.value = type;
if (type === 'assignee') {
drawerUserSelectCurrentIds.value = taskData.value?.assigneeId || '';
drawerUserSelectCurrentNames.value = taskData.value?.assigneeName || '';
drawerUserSelectCurrentIds.value = taskData.value.assigneeId || '';
drawerUserSelectCurrentNames.value = taskData.value.assigneeName || '';
} else if (type === 'participant') {
drawerUserSelectCurrentIds.value = taskData.value?.participantId || '';
drawerUserSelectCurrentNames.value = taskData.value?.participantName || '';
drawerUserSelectCurrentIds.value = taskData.value.participantId || '';
drawerUserSelectCurrentNames.value = taskData.value.participantName || '';
} else {
drawerUserSelectCurrentIds.value = taskData.value?.followersId || '';
drawerUserSelectCurrentNames.value = taskData.value?.followersName || '';
drawerUserSelectCurrentIds.value = taskData.value.followersId || '';
drawerUserSelectCurrentNames.value = taskData.value.followersName || '';
}
nextTick(() => {
drawerUserSelectRef.value?.open();
userSelectModalRef.value?.open();
});
}
function onDrawerUserSelectConfirm(userIds: string, userNames: string) {
function onUserSelectModalConfirm(userIds: string, userNames: string) {
if (subTaskUserSelectId.value) {
emit('update-sub-task-fields', subTaskUserSelectId.value, {
assigneeId: userIds,
assigneeName: userNames,
});
const child = allTasks.value.find((t) => t.id === subTaskUserSelectId.value);
if (child) {
child.assigneeId = userIds;
child.assigneeName = userNames;
}
subTaskUserSelectId.value = '';
return;
onSubTaskUserSelectConfirm(userIds, userNames);
} else {
onDrawerUserSelectConfirm(userIds, userNames);
}
}
function onDrawerUserSelectConfirm(userIds: string, userNames: string) {
if (!taskData.value) return;
const type = drawerUserSelectType.value;
if (type === 'assignee') {
taskData.value.assigneeId = userIds;
taskData.value.assigneeName = userNames;
autoSave();
} else if (type === 'participant') {
taskData.value.participantId = userIds;
taskData.value.participantName = userNames;
autoSave();
} else {
taskData.value.followersId = userIds;
taskData.value.followersName = userNames;
autoSave();
}
autoSave();
}
function openSubTaskUserSelect(child: TaskItem) {
subTaskUserSelectId.value = child.id;
drawerUserSelectCurrentIds.value = child.assigneeId || '';
drawerUserSelectCurrentNames.value = child.assigneeName || '';
drawerUserSelectType.value = 'assignee';
nextTick(() => {
drawerUserSelectRef.value?.open();
userSelectModalRef.value?.open();
});
}
function onSubTaskUserSelectConfirm(userIds: string, userNames: string) {
if (!subTaskUserSelectId.value) return;
emit('update-sub-task-fields', subTaskUserSelectId.value, {
assigneeId: userIds,
assigneeName: userNames,
});
const child = allTasks.value.find((t) => t.id === subTaskUserSelectId.value);
if (child) {
child.assigneeId = userIds;
child.assigneeName = userNames;
}
subTaskUserSelectId.value = '';
}
function openSubTaskDateEditor(childId: string) {
editingSubTask.value = childId;
editingSubTaskField.value = 'dateRange';
@@ -780,6 +813,10 @@
showNewSubTaskInput.value = false;
}
}
function openFileUploadModal() {
fileUploadRef.value?.handleOpenUploadModal();
}
</script>
<style lang="less" scoped>
+3 -43
View File
@@ -103,18 +103,6 @@
</div>
</template>
</draggable>
<div v-if="showNewListInput && newListGroupId === ''" class="sidebar-new-item-row">
<Icon icon="ant-design:file-text-outlined" class="sidebar-item-icon" style="opacity: 0.4" />
<a-input
v-model:value="newListName"
size="small"
placeholder="输入清单名称..."
autofocus
style="flex: 1"
@press-enter="submitNewList"
@blur="showNewListInput = false"
/>
</div>
</div>
<draggable :list="localUserGroups" group="tasklistgroups" item-key="id" :animation="200" drag-class="group-drag-ghost" @start="onGroupDragStart" @end="onGroupDragEnd">
@@ -152,18 +140,6 @@
</div>
<template v-if="group.delFlag !== 1 && !collapsedGroups[group.id]">
<div class="sidebar-group-items" :data-group-id="group.id">
<div v-if="showNewListInput && newListGroupId === group.id" class="sidebar-new-item-row">
<Icon icon="ant-design:file-text-outlined" class="sidebar-item-icon" style="opacity: 0.4" />
<a-input
v-model:value="newListName"
size="small"
placeholder="输入清单名称..."
autofocus
style="flex: 1"
@press-enter="submitNewList"
@blur="showNewListInput = false"
/>
</div>
<draggable
:list="getLocalGroupTaskLists(group)"
group="tasklists"
@@ -211,7 +187,7 @@
</template>
</draggable>
<div
v-if="getLocalGroupTaskLists(group).length === 0 && !(showNewListInput && newListGroupId === group.id)"
v-if="getLocalGroupTaskLists(group).length === 0"
class="sidebar-empty-hint"
>
可拖拽清单加入该分组
@@ -263,7 +239,7 @@
const emit = defineEmits<{
'view-select': [view: ViewType];
'list-select': [listId: string];
'create-list': [name: string, groupId: string];
'create-list-modal': [groupId: string];
'create-group': [name: string];
'rename-list': [listId: string, newName: string];
'delete-list': [listId: string];
@@ -336,9 +312,6 @@
const collapsedGroups = ref<Record<string, boolean>>({});
const showNewGroupInput = ref(false);
const newGroupName = ref('');
const showNewListInput = ref(false);
const newListGroupId = ref('');
const newListName = ref('');
const renamingId = ref('');
const renamingType = ref<'list' | 'group'>('list');
const renameValue = ref('');
@@ -439,20 +412,7 @@
}
function startNewList(groupId: string) {
showNewListInput.value = true;
newListGroupId.value = groupId;
newListName.value = '';
}
function submitNewList() {
const name = newListName.value.trim();
if (!name) {
showNewListInput.value = false;
return;
}
emit('create-list', name, newListGroupId.value);
showNewListInput.value = false;
newListName.value = '';
emit('create-list-modal', groupId);
}
function onListMenuClick({ key }: { key: string }, listId: string, currentName: string) {
@@ -47,11 +47,10 @@
</span>
</div>
<div class="add-panel-body">
<JSelectUser
<SzUserSelect
v-model:value="addForm.userIds"
:label-key="'realname'"
:row-key="'id'"
multiple="multiple"
:labelKey="'realname'"
:rowKey="'id'"
placeholder="搜索并选择用户(支持多选)"
/>
</div>
@@ -77,10 +76,12 @@
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue';
import { message } from 'ant-design-vue';
import Icon from '/@/components/Icon/index';
import JSelectUser from '/@/components/Form/src/jeecg/components/JSelectUser.vue';
import SzUserSelect from '/@/components/semri/userComponent/SzUserSelect.vue';
import { getAvatarColor } from '../types';
import { getUserList } from '/@/api/common/api';
import { checkUserSecLevel } from '../utils/taskUtils';
interface MemberItem {
userId: string;
@@ -93,10 +94,12 @@
userIds: string;
userNames: string;
multiple?: boolean;
secretLevel?: number;
}>(),
{
title: '选择人员',
multiple: true,
secretLevel: 1,
}
);
@@ -170,6 +173,15 @@
async function onConfirmAdd() {
if (!addForm.userIds) return;
const secretLevel = props.secretLevel || 1;
const { ok, invalidNames } = await checkUserSecLevel(addForm.userIds, secretLevel);
if (!ok) {
const map: Record<number, string> = { 1: '非密', 2: '内部', 3: '秘密', 4: '机密' };
message.warning(`以下人员的密级不满足当前清单(${map[secretLevel] || '非密'})要求:${invalidNames.join('、')}`);
return;
}
const newIds = addForm.userIds.split(',').filter(Boolean);
const existingIds = new Set(memberList.value.map((m) => m.userId));
if (!props.multiple) {
@@ -186,14 +198,19 @@
} catch (e) {
console.warn('获取用户名失败,使用ID作为名称', e);
}
let addedCount = 0;
for (const nid of newIds) {
if (!existingIds.has(nid) || !props.multiple) {
memberList.value.push({
userId: nid,
username: nameMap[nid] || nid,
});
addedCount++;
}
}
if (addedCount > 0) {
message.success(`已添加 ${addedCount} 位人员`);
}
addForm.userIds = '';
showAddPanel.value = false;
}
+67 -25
View File
@@ -9,7 +9,7 @@
:permission-map="permissionMap"
@view-select="onViewSelect"
@list-select="onListSelect"
@create-list="onCreateList"
@create-list-modal="onCreateListModal"
@create-group="onCreateGroup"
@rename-list="onRenameList"
@delete-list="onDeleteList"
@@ -90,12 +90,13 @@
@unfollow-task="onUnfollowTask"
@move-task="onMoveTask"
/>
<CollaboratorModal ref="collaboratorModalRef" :task-list-id="currentListId" :is-owner="isCurrentOwner" @changed="onCollaboratorsChanged" />
<CollaboratorModal ref="collaboratorModalRef" :task-list-id="currentListId" :is-owner="isCurrentOwner" :secret-level="currentList?.secretLevel || 1" :secret-text="currentList?.secretText || '非密'" @changed="onCollaboratorsChanged" />
</div>
</template>
<script lang="ts" name="tasklist-index-page" setup>
import { ref, computed, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { useModal } from '/@/components/Modal';
import { useDrawer } from '/@/components/Drawer';
import { useUserStore } from '/@/store/modules/user';
@@ -134,8 +135,10 @@
myFollowedTasks,
moveTask,
moveTaskGroup,
getCurrentUserSecurityLevel,
} from './TaskList.api';
import { DEFAULT_VISIBLE_FIELDS } from './types';
import { isOverdue, checkUserSecLevel } from './utils/taskUtils';
import type {
TaskListGroup,
StatusFilter,
@@ -162,7 +165,7 @@
const currentDrawerTaskId = ref<string>('');
const [registerTaskModal, { openModal: openTaskModal }] = useModal();
const [registerListModal, { openModal: _openListModal }] = useModal();
const [registerListModal, { openModal: openListModal }] = useModal();
const [registerDetailDrawer, { openDrawer: openDetailDrawer }] = useDrawer();
const collaboratorModalRef = ref<InstanceType<typeof CollaboratorModal> | null>(null);
@@ -170,6 +173,8 @@
const userStore = useUserStore();
const currentUserSecurityLevel = ref<number>(3);
const flatTasks = ref<TaskItem[]>([]);
const flatViewTitle = ref('');
const flatViewEditable = ref(false);
@@ -227,6 +232,8 @@
sortOrder: fav.sortOrder || 0,
delFlag: 0,
groups: [],
secretLevel: fav.secretLevel,
secretText: fav.secretText,
};
if (fav.pid) {
@@ -261,7 +268,14 @@
return groups;
}
onMounted(() => {
onMounted(async () => {
try {
const level = await getCurrentUserSecurityLevel();
currentUserSecurityLevel.value = level || 3;
console.log('[TaskList] 当前用户密级:', currentUserSecurityLevel.value);
} catch (e) {
console.error('[TaskList] 获取用户密级失败', e);
}
loadFavorites();
onViewSelect('my-tasks');
});
@@ -565,6 +579,8 @@
ownerName: item.ownerName || '',
collaboratorNames: item.collaboratorNames || '',
createTimeStr: item.createTimeStr || '',
secretLevel: (item as any).secretLevel,
secretText: (item as any).secretText,
},
],
}));
@@ -587,32 +603,22 @@
console.error('[TaskList] 创建任务失败', e);
}
} else {
openTaskModal(true, { groupId, mainId: currentListId.value });
openTaskModal(true, { groupId, mainId: currentListId.value, secretLevel: currentList.value?.secretLevel || 1, secretText: currentList.value?.secretText || '非密' });
}
}
async function onCreateList(name: string, groupId: string) {
if (!name || !name.trim()) return;
try {
const listId = await addTaskList({
tasklistName: name.trim(),
pid: groupId || undefined,
sortOrder: 1,
});
if (listId) {
await addToFavorites({ taskListId: listId, pid: groupId || undefined });
}
await loadFavorites();
} catch (e) {
console.error('[TaskList] 创建清单失败', e);
}
function onCreateListModal(groupId: string) {
openListModal(true, {
groupId,
userSecurityLevel: currentUserSecurityLevel.value,
});
}
function onListCreated(listId?: string) {
async function onListCreated(listId?: string) {
if (listId) {
addToFavorites({ taskListId: listId });
await addToFavorites({ taskListId: listId });
}
loadFavorites();
await loadFavorites();
}
async function onTaskCreated() {
@@ -775,7 +781,7 @@
const task = allTasks.find((t) => t.id === taskId);
if (task) {
const readOnly = isFlatView.value ? !canEditFlatTask(task) : currentList.value?.myPermission === '3';
openDetailDrawer(true, { task, allTasks, readOnly });
openDetailDrawer(true, { task, allTasks, readOnly, secretLevel: currentList.value?.secretLevel || 1 });
}
}
@@ -834,11 +840,20 @@
const task = allTasks.find((t) => t.id === currentDrawerTaskId.value);
if (task) {
const readOnly = isFlatView.value ? !canEditFlatTask(task) : currentList.value?.myPermission === '3';
openDetailDrawer(true, { task, allTasks, readOnly });
openDetailDrawer(true, { task, allTasks, readOnly, secretLevel: currentList.value?.secretLevel || 1 });
}
}
async function onUpdateTaskField(taskId: string, field: string, value: any) {
const userFields = ['assigneeId', 'participantId', 'followersId'];
if (userFields.includes(field) && value) {
const secretLevel = currentList.value?.secretLevel || 1;
const { ok, invalidNames } = await checkUserSecLevel(value, secretLevel);
if (!ok) {
message.warning(`以下人员的密级不满足当前清单(${currentList.value?.secretText || '非密'})要求:${invalidNames.join('、')}`);
return;
}
}
try {
await editTask({ id: taskId, [field]: value });
if (isFlatView.value) {
@@ -855,6 +870,17 @@
}
async function onUpdateTaskFields(taskId: string, fields: Record<string, any>) {
const userFields = ['assigneeId', 'participantId', 'followersId'];
for (const f of userFields) {
if (fields[f]) {
const secretLevel = currentList.value?.secretLevel || 1;
const { ok, invalidNames } = await checkUserSecLevel(fields[f], secretLevel);
if (!ok) {
message.warning(`以下人员的密级不满足当前清单(${currentList.value?.secretText || '非密'})要求:${invalidNames.join('、')}`);
return;
}
}
}
try {
await editTask({ id: taskId, ...fields });
if (isFlatView.value) {
@@ -871,6 +897,22 @@
}
async function onSaveTask(updatedTask: TaskItem) {
const secretLevel = currentList.value?.secretLevel || 1;
const secretText = currentList.value?.secretText || '非密';
const userIdFields = [
{ value: updatedTask.assigneeId, label: '负责人' },
{ value: updatedTask.participantId, label: '参与人' },
{ value: updatedTask.followersId, label: '关注人' },
];
for (const { value } of userIdFields) {
if (value) {
const { ok, invalidNames } = await checkUserSecLevel(value, secretLevel);
if (!ok) {
message.warning(`以下人员的密级不满足当前清单(${secretText})要求:${invalidNames.join('、')}`);
return;
}
}
}
try {
await editTask({
id: updatedTask.id,
+27
View File
@@ -96,6 +96,8 @@ export interface TaskList {
collaboratorNames?: string;
createTimeStr?: string;
myPermission?: string;
secretLevel?: number;
secretText?: string;
}
export interface TaskListGroup {
@@ -246,3 +248,28 @@ export interface FlatTaskItem {
task: TaskItem;
level: number;
}
export const SECRET_LEVEL_MAP = [
{ value: 1, label: '非密' },
{ value: 2, label: '内部' },
{ value: 3, label: '秘密' },
{ value: 4, label: '机密' },
] as const;
export const SECRET_LEVEL_COLOR: Record<number, string> = {
1: '#8c8c8c',
2: '#1677ff',
3: '#fa8c16',
4: '#f5222d',
};
export function getAvailableSecretLevels(userSecurityLevel: number) {
if (!userSecurityLevel) userSecurityLevel = 3;
return SECRET_LEVEL_MAP.filter((item) => userSecurityLevel > item.value);
}
export function getSecretText(level?: number | null): string {
if (level == null) return '非密';
const found = SECRET_LEVEL_MAP.find((item) => item.value === level);
return found ? found.label : '非密';
}
+31
View File
@@ -27,3 +27,34 @@ export function isOverdue(task: { endTime?: string; completed?: boolean }): bool
end.setHours(0, 0, 0, 0);
return now > end;
}
export async function checkUserSecLevel(
userIds: string,
secretLevel: number
): Promise<{ ok: boolean; invalidNames: string[] }> {
if (!userIds || !userIds.trim()) return { ok: true, invalidNames: [] };
const { getUserList } = await import('/@/api/common/api');
const ids = userIds.split(',').map((id) => id.trim()).filter(Boolean);
if (ids.length === 0) return { ok: true, invalidNames: [] };
const level = secretLevel || 1;
try {
const res = await getUserList({ id: ids.join(','), pageNo: 1, pageSize: ids.length });
const records: any[] = res?.records || [];
const nameMap: Record<string, string> = {};
const levelMap: Record<string, number> = {};
for (const r of records) {
nameMap[r.id] = r.realname || r.id;
levelMap[r.id] = r.userSecurityLevel ?? 3;
}
const invalidNames: string[] = [];
for (const id of ids) {
const userSecLevel = levelMap[id] ?? 3;
if (userSecLevel <= level) {
invalidNames.push(nameMap[id] || id);
}
}
return { ok: invalidNames.length === 0, invalidNames };
} catch {
return { ok: true, invalidNames: [] };
}
}