Merge remote-tracking branch 'origin/master'

This commit is contained in:
ye1023
2026-06-17 09:37:20 +08:00
8 changed files with 951 additions and 531 deletions
@@ -1,539 +1,135 @@
<template>
<a-modal
v-model:open="visible"
title="协作人管理"
:footer="null"
width="520px"
:bodyStyle="{ padding: '0' }"
:centered="true"
:destroyOnClose="true"
@cancel="onClose"
>
<div class="collab-modal">
<div class="collab-body">
<div class="member-list">
<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>
<div class="member-main">
<div class="member-name-row">
<span class="member-name">{{ item.username }}</span>
<span v-if="item.permission === '1'" class="role-badge is-owner">所有者</span>
</div>
</div>
<div class="member-right">
<template v-if="item.permission === '1'">
<span class="role-label">所有者</span>
</template>
<template v-else-if="isOwner && item.permissionId">
<a-dropdown :trigger="['click']" :overlayStyle="{ minWidth: '120px' }">
<span class="role-label clickable" @click.stop>
{{ item.permission === '2' ? '可编辑' : '可阅读' }}
<Icon icon="ant-design:down-outlined" style="font-size: 10px; margin-left: 2px" />
</span>
<template #overlay>
<a-menu @click="({ key }) => onPermissionChange(item.permissionId, key)">
<a-menu-item key="2" :class="{ 'active-key': item.permission === '2' }">
<span>可编辑</span>
<Icon v-if="item.permission === '2'" icon="ant-design:check-outlined" class="check-icon" />
</a-menu-item>
<a-menu-item key="3" :class="{ 'active-key': item.permission === '3' }">
<span>可阅读</span>
<Icon v-if="item.permission === '3'" icon="ant-design:check-outlined" class="check-icon" />
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<span v-show="hoverId === item.permissionId" class="remove-icon" @click="onRemove(item.permissionId)">
<Icon icon="ant-design:close-outlined" />
</span>
</template>
<template v-else>
<span class="role-label">{{ item.permission === '2' ? '可编辑' : '可阅读' }}</span>
</template>
</div>
</div>
<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>
</div>
<div v-if="showAddPanel && isOwner" class="add-panel">
<div class="add-panel-header">
<span>添加协作人</span>
<span class="add-panel-close" @click="showAddPanel = false">
<Icon icon="ant-design:close-outlined" />
</span>
</div>
<div class="add-panel-body">
<SzDeptUserModal
v-model:value="addForm.userIds"
valueKey="id"
:role="1"
:secLevel="secretLevel"
/>
<div class="add-panel-row">
<span class="add-panel-label">权限</span>
<a-radio-group v-model:value="addForm.permission" size="small">
<a-radio-button value="2">可编辑</a-radio-button>
<a-radio-button value="3">可阅读</a-radio-button>
</a-radio-group>
</div>
</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="onConfirmAdd">
确认添加
</a-button>
</div>
</div>
</div>
<div v-if="isOwner && !showAddPanel" class="collab-footer">
<a-button type="text" size="small" @click="showAddPanel = true">
<Icon icon="ant-design:user-add-outlined" style="margin-right: 4px" />
添加协作人
</a-button>
<a-button type="primary" size="small" @click="onDone">完成</a-button>
</div>
</div>
</a-modal>
<SzCollabUserModal
ref="modalRef"
hide-trigger
title="选择协作人"
:role="1"
:sec-level="secretLevel"
:excluded-user-ids="ownerUserId"
@confirm="onConfirm"
/>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, nextTick } from 'vue';
import { message } from 'ant-design-vue';
import Icon from '/@/components/Icon/index';
import SzDeptUserModal from '/@/components/semri/deptUserComponent/SzDeptUserModal.vue';
import { getCollaborators, addCollaborator, removeCollaborator, updateCollaboratorPermission } from '../TaskList.api';
import { getAvatarColor } from '../types';
import { checkUserSecLevel } from '../utils/taskUtils';
import { useMessage } from '/@/hooks/web/useMessage';
import { ref } from 'vue';
import SzCollabUserModal from '/@/components/semri/collabUserComponent/SzCollabUserModal.vue';
import { getCollaborators, addCollaborator, removeCollaborator, updateCollaboratorPermission } from '../TaskList.api';
import { useMessage } from '/@/hooks/web/useMessage';
import type { CollabConfirmResult } from '/@/components/semri/collabUserComponent/types';
const { createMessage } = useMessage();
const { createMessage } = useMessage();
interface CollaboratorItem {
permissionId: string;
userId: string;
username: string;
permission: string;
interface CollaboratorItem {
permissionId: string;
userId: string;
username: string;
permission: string;
}
const props = defineProps<{
taskListId: string;
secretLevel?: number;
}>();
const emit = defineEmits<{
changed: [collaboratorNames: string];
}>();
const modalRef = ref<InstanceType<typeof SzCollabUserModal> | null>(null);
let cachedCollaborators: CollaboratorItem[] = [];
const ownerUserId = ref('');
async function open(taskListId?: string) {
const id = taskListId || props.taskListId;
// Show modal immediately with loading state
modalRef.value?.open();
cachedCollaborators = [];
ownerUserId.value = '';
try {
const options = { successMessageMode: 'none' as const };
const data: any[] = await getCollaborators({ taskListId: id }, options);
cachedCollaborators = data || [];
} catch (e) {
console.error('[CollaboratorModal] load failed', e);
}
interface PendingAddItem {
userId: string;
username: string;
permission: string;
}
const owner = cachedCollaborators.find((c) => c.permission === '1');
if (owner) ownerUserId.value = owner.userId;
const props = defineProps<{
taskListId: string;
isOwner: boolean;
secretLevel?: number;
secretText?: string;
}>();
const editIds = cachedCollaborators.filter((c) => c.permission === '2').map((c) => c.userId).join(',');
const readIds = cachedCollaborators.filter((c) => c.permission === '3').map((c) => c.userId).join(',');
modalRef.value?.open(editIds, readIds);
}
const emit = defineEmits<{
changed: [collaboratorNames: string];
}>();
function buildNamesFromResult(result: CollabConfirmResult): string {
const names: string[] = [];
if (result.editableNames) names.push(...result.editableNames.split(',').filter(Boolean));
if (result.readonlyNames) names.push(...result.readonlyNames.split(',').filter(Boolean));
return names.join(',');
}
const visible = ref(false);
const collaborators = ref<CollaboratorItem[]>([]);
const hoverId = ref('');
const showAddPanel = ref(false);
const addForm = reactive({
userIds: '',
permission: '2',
});
const pendingAddList = ref<PendingAddItem[]>([]);
const pendingRemoveIds = ref<Set<string>>(new Set());
const pendingPermissionMap = ref<Map<string, string>>(new Map());
async function onConfirm(result: CollabConfirmResult) {
const suppressOptions = { successMessageMode: 'none' as const };
const oldMap = new Map<string, { permissionId: string; permission: string }>();
const allMembers = computed(() => {
const pendingAsCollab: CollaboratorItem[] = pendingAddList.value.map((p) => ({
permissionId: '',
userId: p.userId,
username: p.username,
permission: p.permission,
}));
return [...collaborators.value, ...pendingAsCollab]
.filter((m) => (m.permission !== '1' ? !pendingRemoveIds.value.has(m.permissionId) : true))
.map((m) => {
if (m.permissionId && pendingPermissionMap.value.has(m.permissionId)) {
return { ...m, permission: pendingPermissionMap.value.get(m.permissionId)! };
}
return m;
});
});
const collaboratorNamesSnapshot = computed(() => {
return allMembers.value
.filter((m) => m.permission !== '1')
.map((m) => m.username)
.filter(Boolean)
.join(',');
});
function getColor(name: string): string {
return getAvatarColor(name || '');
}
async function open() {
collaborators.value = [];
showAddPanel.value = false;
pendingAddList.value = [];
pendingRemoveIds.value.clear();
pendingPermissionMap.value.clear();
addForm.userIds = '';
addForm.permission = '2';
visible.value = true;
await nextTick();
await loadCollaborators();
}
function onClose() {
visible.value = false;
showAddPanel.value = false;
addForm.userIds = '';
addForm.permission = '2';
pendingAddList.value = [];
pendingRemoveIds.value.clear();
pendingPermissionMap.value.clear();
}
async function loadCollaborators() {
try {
const options = { successMessageMode: 'none' as const };
const data: any[] = await getCollaborators({ taskListId: props.taskListId }, options);
collaborators.value = data;
} catch (e) {
console.error('[CollaboratorModal] load failed', e);
for (const c of cachedCollaborators) {
if (c.permission !== '1') {
oldMap.set(c.userId, { permissionId: c.permissionId, permission: c.permission });
}
}
async function onConfirmAdd() {
if (!addForm.userIds) return;
const ids = addForm.userIds.split(',').filter(Boolean);
if (ids.length === 0) return;
const newEditIds = new Set(result.editableIds ? result.editableIds.split(',').filter(Boolean) : []);
const newReadIds = new Set(result.readonlyIds ? result.readonlyIds.split(',').filter(Boolean) : []);
const allNewIds = new Set([...newEditIds, ...newReadIds]);
const secretLevel = props.secretLevel || 1;
const { ok, invalidNames } = await checkUserSecLevel(addForm.userIds, secretLevel);
if (!ok) {
message.warning(`以下人员的密级不满足当前计划(${props.secretText || '非密'})要求:${invalidNames.join('、')}`);
return;
}
let errorMsg = '';
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));
for (const uid of ids) {
if (!existingIds.has(uid) && !pendingIds.has(uid)) {
pendingAddList.value.push({
userId: uid,
username: nameMap[uid] || uid,
permission: addForm.permission,
});
// 新增
for (const uid of allNewIds) {
if (!oldMap.has(uid)) {
const perm = newEditIds.has(uid) ? '2' : '3';
try {
await addCollaborator({ taskListId: props.taskListId, userId: uid, permission: perm }, suppressOptions);
} catch {
errorMsg = '部分协作人添加失败';
}
}
addForm.userIds = '';
addForm.permission = '2';
showAddPanel.value = false;
}
function onDone() {
const hasChanges =
pendingAddList.value.length > 0 || pendingRemoveIds.value.size > 0 || pendingPermissionMap.value.size > 0;
const itemsToAdd = [...pendingAddList.value];
const idsToRemove = [...pendingRemoveIds.value];
const permsToChange = [...pendingPermissionMap.value];
const listId = props.taskListId;
pendingAddList.value = [];
pendingRemoveIds.value.clear();
pendingPermissionMap.value.clear();
visible.value = false;
showAddPanel.value = false;
addForm.userIds = '';
addForm.permission = '2';
if (!hasChanges) return;
const suppressOptions = { successMessageMode: 'none' as const };
let errorMsg = '';
(async () => {
for (const item of itemsToAdd) {
try {
await addCollaborator(
{ taskListId: listId, userId: item.userId, permission: item.permission },
suppressOptions,
);
} catch {
errorMsg = '部分协作人添加失败';
}
// 移除
for (const [uid, old] of oldMap) {
if (!allNewIds.has(uid)) {
try {
await removeCollaborator({ permissionId: old.permissionId }, suppressOptions);
} catch {
errorMsg = '部分协作人移除失败';
}
for (const permissionId of idsToRemove) {
try {
await removeCollaborator({ permissionId }, suppressOptions);
} catch {
errorMsg = '部分协作人移除失败';
}
}
for (const [permissionId, newPermission] of permsToChange) {
try {
await updateCollaboratorPermission({ permissionId, permission: newPermission }, suppressOptions);
} catch {
errorMsg = '部分权限修改失败';
}
}
await loadCollaborators();
emit('changed', collaboratorNamesSnapshot.value);
if (errorMsg) {
createMessage.warning(errorMsg);
} else {
createMessage.success('编辑成功');
}
})();
}
}
function onRemove(permissionId: string) {
pendingRemoveIds.value.add(permissionId);
// 权限变更
for (const [uid, old] of oldMap) {
const newPerm = newEditIds.has(uid) ? '2' : newReadIds.has(uid) ? '3' : null;
if (newPerm && newPerm !== old.permission && old.permissionId) {
try {
await updateCollaboratorPermission({ permissionId: old.permissionId, permission: newPerm }, suppressOptions);
} catch {
errorMsg = '部分权限修改失败';
}
}
}
function onPermissionChange(permissionId: string, newPermission: string) {
pendingPermissionMap.value.set(permissionId, newPermission);
}
const names = buildNamesFromResult(result);
emit('changed', names);
defineExpose({ open });
if (errorMsg) {
createMessage.warning(errorMsg);
} else {
createMessage.success('更新成功');
}
}
defineExpose({ open });
</script>
<style lang="less" scoped>
.collab-modal {
display: flex;
flex-direction: column;
}
.collab-body {
position: relative;
min-height: 200px;
}
.member-list {
padding: 8px 0;
max-height: 400px;
overflow-y: auto;
}
.member-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 20px;
transition: background 0.12s;
&:hover {
background: #f5f6f7;
}
}
.member-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
font-size: 13px;
font-weight: 500;
}
.member-main {
flex: 1;
min-width: 0;
}
.member-name-row {
display: flex;
align-items: center;
gap: 6px;
}
.member-name {
font-size: 14px;
color: #1f2329;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.role-badge {
display: inline-flex;
align-items: center;
padding: 0 6px;
font-size: 11px;
line-height: 18px;
border-radius: 3px;
flex-shrink: 0;
&.is-owner {
background: #e8f3ff;
color: #3370ff;
}
}
.member-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
min-width: 70px;
justify-content: flex-end;
}
.role-label {
font-size: 13px;
color: #8f959e;
&.clickable {
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: background 0.12s;
&:hover {
background: #e8e8e8;
}
}
}
.remove-icon {
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
cursor: pointer;
color: #8f959e;
font-size: 12px;
transition: all 0.12s;
&:hover {
background: #f0f0f0;
color: #f5222d;
}
}
.check-icon {
margin-left: auto;
color: #3370ff;
font-size: 12px;
}
.active-key {
color: #3370ff;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 0;
color: #bfbfbf;
font-size: 13px;
}
.add-panel {
position: absolute;
inset: 0;
background: #fff;
display: flex;
flex-direction: column;
z-index: 10;
}
.add-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 12px;
font-size: 14px;
font-weight: 500;
color: #1f2329;
}
.add-panel-close {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
cursor: pointer;
color: #8f959e;
&:hover {
background: #f0f0f0;
}
}
.add-panel-body {
padding: 0 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.add-panel-row {
display: flex;
align-items: center;
gap: 12px;
}
.add-panel-label {
font-size: 13px;
color: #595959;
white-space: nowrap;
}
.add-panel-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
margin-top: auto;
}
.collab-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 20px;
border-top: 1px solid #f0f0f0;
}
</style>
+15 -4
View File
@@ -1362,19 +1362,20 @@
userSelectModalTaskId.value = taskId;
const task = findTaskById(taskId);
let ids = '';
if (type === 'assignee') {
userSelectModalCurrentIds.value = task?.assigneeId || '';
ids = task?.assigneeId || '';
userSelectModalCurrentNames.value = task?.assigneeName || '';
} else if (type === 'participant') {
userSelectModalCurrentIds.value = task?.participantId || '';
ids = task?.participantId || '';
userSelectModalCurrentNames.value = task?.participantName || '';
} else {
userSelectModalCurrentIds.value = task?.followersId || '';
ids = task?.followersId || '';
userSelectModalCurrentNames.value = task?.followersName || '';
}
nextTick(() => {
userSelectModalRef.value?.open();
userSelectModalRef.value?.open(ids);
});
}
@@ -1911,6 +1912,7 @@
display: flex;
align-items: center;
justify-content: space-between;
overflow: hidden;
}
.content-header-row {
@@ -1924,6 +1926,8 @@
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
overflow: hidden;
}
.add-list-btn {
@@ -1952,6 +1956,7 @@
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.header-secret-badge {
@@ -1972,6 +1977,7 @@
border-radius: 6px;
border: 1px solid #e8e8e8;
transition: all 0.15s;
white-space: nowrap;
&:hover {
border-color: #d0d0d0;
@@ -2001,6 +2007,9 @@
font-size: 18px;
font-weight: 600;
color: #1f1f1f;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-more-btn {
@@ -2017,6 +2026,8 @@
display: flex;
align-items: center;
gap: 0;
min-width: 0;
overflow: hidden;
}
.content-header-rename-row {
@@ -668,19 +668,20 @@
userSelectModalTaskId.value = taskId;
const task = props.tasks.find((t) => t.id === taskId);
let ids = '';
if (type === 'assignee') {
userSelectModalCurrentIds.value = task?.assigneeId || '';
ids = task?.assigneeId || '';
userSelectModalCurrentNames.value = task?.assigneeName || '';
} else if (type === 'participant') {
userSelectModalCurrentIds.value = task?.participantId || '';
ids = task?.participantId || '';
userSelectModalCurrentNames.value = task?.participantName || '';
} else {
userSelectModalCurrentIds.value = task?.followersId || '';
ids = task?.followersId || '';
userSelectModalCurrentNames.value = task?.followersName || '';
}
nextTick(() => {
userSelectModalRef.value?.open();
userSelectModalRef.value?.open(ids);
});
}
+3 -9
View File
@@ -94,7 +94,7 @@
@unfollow-task="onUnfollowTask"
@move-task="onMoveTask"
/>
<CollaboratorModal ref="collaboratorModalRef" :task-list-id="collaboratorTargetListId || currentListId" :is-owner="collaboratorTargetListId ? isOwnerOfTargetList : isCurrentOwner" :secret-level="collaboratorTargetList ? collaboratorTargetList.secretLevel || 1 : currentList?.secretLevel || 1" :secret-text="collaboratorTargetList ? collaboratorTargetList.secretText || '非密' : currentList?.secretText || '非密'" @changed="onCollaboratorsChanged" />
<CollaboratorModal ref="collaboratorModalRef" :task-list-id="collaboratorTargetListId || currentListId" :secret-level="collaboratorTargetList ? collaboratorTargetList.secretLevel || 1 : currentList?.secretLevel || 1" @changed="onCollaboratorsChanged" />
</div>
</template>
@@ -300,8 +300,6 @@
return findListInGroups(taskListGroups.value, currentListId.value) || findListInGroups(quickAccessGroups.value, currentListId.value);
});
const isCurrentOwner = computed(() => currentList.value?.myPermission === '1');
async function loadPermissionForList(listId: string) {
const silentOpts = { successMessageMode: 'none' as const };
try {
@@ -497,7 +495,7 @@
function onOpenCollaborator() {
collaboratorTargetListId.value = '';
collaboratorModalRef.value?.open();
collaboratorModalRef.value?.open(currentListId.value);
}
const collaboratorTargetList = computed(() => {
@@ -510,10 +508,6 @@
return null;
});
const isOwnerOfTargetList = computed(() => {
return collaboratorTargetList.value?.myPermission === '1';
});
function onOpenCollaboratorForList(listId: string) {
const list = findListInQuickAccess(listId);
if (list && list.myPermission !== '1') {
@@ -521,7 +515,7 @@
return;
}
collaboratorTargetListId.value = listId;
collaboratorModalRef.value?.open();
collaboratorModalRef.value?.open(listId);
}
function findListInQuickAccess(listId: string): TaskList | null {