czh-20260429-实现任务管理的前端功能

This commit is contained in:
zhihao
2026-04-30 09:24:45 +08:00
parent dd88adab73
commit 2641522dcd
35 changed files with 8355 additions and 0 deletions
@@ -0,0 +1,155 @@
<template>
<div class="avatar-display" :class="{ 'avatar-display-completed': completed }">
<template v-if="names.length === 0">
<span class="avatar-empty">-</span>
</template>
<template v-else-if="names.length === 1">
<div class="avatar-single-wrap">
<div class="avatar-circle" :style="{ background: getColor(names[0]), width: size + 'px', height: size + 'px' }">
<span class="avatar-text" :style="{ fontSize: fontSize + 'px' }">{{ names[0].charAt(0) }}</span>
</div>
<span v-if="showName" class="avatar-name">{{ names[0] }}</span>
</div>
</template>
<template v-else-if="names.length <= maxShow">
<div
v-for="(name, idx) in names"
:key="idx"
class="avatar-circle"
:style="{ background: getColor(name), width: size + 'px', height: size + 'px', marginLeft: idx > 0 ? overlap + 'px' : '0', zIndex: idx + 1 }"
>
<span class="avatar-text" :style="{ fontSize: fontSize + 'px' }">{{ name.charAt(0) }}</span>
</div>
</template>
<template v-else>
<div
v-for="(name, idx) in names.slice(0, maxShow - 1)"
:key="idx"
class="avatar-circle"
:style="{ background: getColor(name), width: size + 'px', height: size + 'px', marginLeft: idx > 0 ? overlap + 'px' : '0', zIndex: idx + 1 }"
>
<span class="avatar-text" :style="{ fontSize: fontSize + 'px' }">{{ name.charAt(0) }}</span>
</div>
<div
class="avatar-overflow"
:style="{ width: size + 'px', height: size + 'px', marginLeft: overlap + 'px', fontSize: overflowFontSize + 'px', zIndex: names.length }"
>
+{{ names.length - (maxShow - 1) }}
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { getAvatarColor } from '../types';
const props = withDefaults(
defineProps<{
names: string;
size?: number;
maxShow?: number;
overlap?: number;
showName?: boolean;
completed?: boolean;
}>(),
{
size: 24,
maxShow: 5,
overlap: -8,
showName: true,
completed: false,
},
);
const fontSize = computed(() => Math.max(10, Math.round(props.size * 0.54)));
const overflowFontSize = computed(() => Math.max(8, Math.round(props.size * 0.42)));
const parsedNames = computed(() => {
if (!props.names) return [];
return props.names
.split(/[,,、]/)
.map((s) => s.trim())
.filter(Boolean);
});
const names = computed(() => parsedNames.value);
function getColor(name: string): string {
return getAvatarColor(name);
}
</script>
<style lang="less" scoped>
.avatar-display {
display: flex;
align-items: center;
gap: 0;
overflow: hidden;
}
.avatar-display-completed {
opacity: 0.5;
}
.avatar-single-wrap {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px 2px 2px;
background: #f5f6f7;
border-radius: 14px;
max-width: 100%;
overflow: hidden;
}
.avatar-circle {
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
box-shadow: 0 0 0 2px #fff;
}
.avatar-text {
color: #fff;
font-weight: 500;
line-height: 1;
font-size: 14px !important;
}
.avatar-name {
color: #646a73;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
}
.avatar-count {
font-size: 14px;
color: #646a73;
margin-left: 4px;
white-space: nowrap;
}
.avatar-overflow {
border-radius: 50%;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
color: #8c8c8c;
font-weight: 500;
position: relative;
box-shadow: 0 0 0 2px #fff;
flex-shrink: 0;
}
.avatar-empty {
color: #c0c4cc;
font-size: 14px;
}
</style>
@@ -0,0 +1,430 @@
<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 collaborators" :key="item.permissionId" class="member-item" @mouseenter="hoverId = item.permissionId" @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">
<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="collaborators.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">
<JSelectUser
v-model:value="addForm.userIds"
:label-key="'realname'"
:row-key="'id'"
placeholder="搜索并选择用户(支持多选)"
/>
<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="onBatchAdd">
确认添加
</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="onClose">完成</a-button>
</div>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive } from 'vue';
import Icon from '/@/components/Icon/index';
import JSelectUser from '/@/components/Form/src/jeecg/components/JSelectUser.vue';
import { getCollaborators, addCollaborator, removeCollaborator, updateCollaboratorPermission } from '../TaskList.api';
import { getAvatarColor } from '../types';
import { useMessage } from '/@/hooks/web/useMessage';
const { createMessage } = useMessage();
interface CollaboratorItem {
permissionId: string;
userId: string;
username: string;
permission: string;
}
const props = defineProps<{
taskListId: string;
isOwner: boolean;
}>();
const emit = defineEmits<{
changed: [];
}>();
const visible = ref(false);
const collaborators = ref<CollaboratorItem[]>([]);
const hoverId = ref('');
const showAddPanel = ref(false);
const addForm = reactive({
userIds: '',
permission: '2',
});
function getColor(name: string): string {
return getAvatarColor(name || '');
}
async function open() {
visible.value = true;
showAddPanel.value = false;
await loadCollaborators();
}
function onClose() {
visible.value = false;
showAddPanel.value = false;
addForm.userIds = '';
addForm.permission = '2';
}
async function loadCollaborators() {
try {
const data: any[] = await getCollaborators({ taskListId: props.taskListId });
collaborators.value = data;
} catch (e) {
console.error('[CollaboratorModal] load failed', e);
}
}
async function onBatchAdd() {
if (!addForm.userIds) return;
const ids = addForm.userIds.split(',').filter(Boolean);
if (ids.length === 0) return;
let successCount = 0;
let failCount = 0;
for (const uid of ids) {
try {
await addCollaborator({
taskListId: props.taskListId,
userId: uid,
permission: addForm.permission,
});
successCount++;
} catch {
failCount++;
}
}
if (successCount > 0) {
createMessage.success(`成功添加 ${successCount} 位协作人${failCount > 0 ? `${failCount} 位已存在` : ''}`);
} else {
createMessage.warning('所选用户均已是协作人');
}
addForm.userIds = '';
addForm.permission = '2';
showAddPanel.value = false;
await loadCollaborators();
emit('changed');
}
async function onRemove(permissionId: string) {
try {
await removeCollaborator({ permissionId });
await loadCollaborators();
emit('changed');
} catch (e: any) {
createMessage.error(e?.message || '移除失败');
}
}
async function onPermissionChange(permissionId: string, newPermission: string) {
try {
await updateCollaboratorPermission({ permissionId, permission: newPermission });
await loadCollaborators();
emit('changed');
} catch (e: any) {
createMessage.error(e?.message || '修改失败');
}
}
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>
@@ -0,0 +1,43 @@
<template>
<BasicModal v-bind="$attrs" @register="registerModal" destroy-on-close title="创建清单" :width="500" @ok="handleSubmit">
<BasicForm @register="registerForm" />
</BasicModal>
</template>
<script lang="ts" setup>
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicForm, useForm } from '/@/components/Form/index';
import { createListFormSchema } from '../TaskList.data';
import { addTaskList } from '../TaskList.api';
const emit = defineEmits(['register', 'success']);
const [registerForm, { resetFields, validate }] = useForm({
labelWidth: 100,
schemas: createListFormSchema,
showActionButtonGroup: false,
baseColProps: { span: 24 },
});
let pendingGroupId = '';
const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data) => {
await resetFields();
pendingGroupId = data?.groupId || '';
});
async function handleSubmit() {
try {
setModalProps({ confirmLoading: true });
const values = await validate();
await addTaskList({
tasklistName: values.tasklistName || values.name,
pid: pendingGroupId || undefined,
});
closeModal();
emit('success');
} finally {
setModalProps({ confirmLoading: false });
}
}
</script>
@@ -0,0 +1,55 @@
<template>
<BasicModal v-bind="$attrs" @register="registerModal" destroy-on-close title="创建任务" :width="600" @ok="handleSubmit">
<BasicForm @register="registerForm" />
</BasicModal>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicForm, useForm } from '/@/components/Form/index';
import { createTaskFormSchema } from '../TaskList.data';
import { addTask } from '../TaskList.api';
const emit = defineEmits(['register', 'success']);
const groupId = ref('');
const mainId = ref('');
const [registerForm, { setFieldsValue, resetFields, validate }] = useForm({
labelWidth: 100,
schemas: createTaskFormSchema,
showActionButtonGroup: false,
baseColProps: { span: 24 },
});
const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data) => {
await resetFields();
groupId.value = data?.groupId || '';
mainId.value = data?.mainId || '';
if (groupId.value) {
await setFieldsValue({ groupId: groupId.value });
}
});
async function handleSubmit() {
try {
setModalProps({ confirmLoading: true });
const values = await validate();
await addTask({
mainId: mainId.value,
taskName: values.taskName,
taskDesc: values.taskDesc || '',
priority: values.priority || '',
type: '1',
pid: groupId.value || '',
assigneeId: values.assigneeId || '',
startTime: values.startTime || null,
endTime: values.endTime || null,
});
closeModal();
emit('success');
} finally {
setModalProps({ confirmLoading: false });
}
}
</script>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,123 @@
<template>
<a-date-picker
v-if="mode === 'start' || mode === 'range'"
v-model:value="startDayjs"
placeholder="选择开始时间"
format="YYYY-MM-DD"
:allow-clear="true"
:open="startOpen"
@change="onStartChange"
@open-change="onStartOpenChange"
/>
<a-date-picker
v-if="mode === 'end' || mode === 'range'"
v-model:value="endDayjs"
placeholder="选择结束时间"
format="YYYY-MM-DD"
:allow-clear="true"
:open="endOpen"
@change="onEndChange"
@open-change="onEndOpenChange"
/>
</template>
<script lang="ts" setup>
import { ref, watch, nextTick } from 'vue';
import dayjs from 'dayjs';
type DateRangeValue = [string | null, string | null];
type PickerMode = 'start' | 'end' | 'range';
const props = withDefaults(
defineProps<{
startValue?: string | null;
endValue?: string | null;
visible?: boolean;
triggerRef?: HTMLElement | null;
mode?: PickerMode;
}>(),
{
startValue: null,
endValue: null,
visible: false,
triggerRef: null,
mode: 'range',
}
);
const emit = defineEmits<{
change: [value: DateRangeValue];
'update:visible': [value: boolean];
}>();
const startDayjs = ref<any>(null);
const endDayjs = ref<any>(null);
const startOpen = ref(false);
const endOpen = ref(false);
watch(
() => props.startValue,
(val) => {
startDayjs.value = val ? dayjs(val) : null;
},
{ immediate: true }
);
watch(
() => props.endValue,
(val) => {
endDayjs.value = val ? dayjs(val) : null;
},
{ immediate: true }
);
watch(
() => props.visible,
async (visible) => {
if (visible) {
await nextTick();
if (props.mode === 'start' || props.mode === 'range') {
startOpen.value = true;
}
if (props.mode === 'end') {
endOpen.value = true;
}
} else {
startOpen.value = false;
endOpen.value = false;
}
}
);
function onStartOpenChange(open: boolean) {
startOpen.value = open;
if (!open) {
emit('update:visible', false);
}
}
function onEndOpenChange(open: boolean) {
endOpen.value = open;
if (!open) {
emit('update:visible', false);
}
}
function onStartChange(date: any) {
const value = date ? date.format('YYYY-MM-DD') : null;
emit('change', [value, props.endValue]);
if (props.mode === 'start') {
startOpen.value = false;
emit('update:visible', false);
}
}
function onEndChange(date: any) {
const value = date ? date.format('YYYY-MM-DD') : null;
emit('change', [props.startValue, value]);
if (props.mode === 'end') {
endOpen.value = false;
emit('update:visible', false);
}
}
</script>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,123 @@
<template>
<div>
<BasicForm @register="registerForm" ref="formRef" />
<!-- 子表单区域 -->
<a-tabs v-model:activeKey="activeKey" animated @change="handleChangeTabs">
<a-tab-pane tab="工作任务清单详情" key="taskListDetial" :forceRender="true">
<JVxeTable
keep-source
resizable
ref="taskListDetial"
v-if="taskListDetialTable.show"
:loading="taskListDetialTable.loading"
:columns="taskListDetialTable.columns"
:dataSource="taskListDetialTable.dataSource"
:height="340"
:rowNumber="true"
:rowSelection="true"
:disabled="formDisabled"
:toolbar="true"
/>
</a-tab-pane>
</a-tabs>
<div style="width: 100%; text-align: center" v-if="!formDisabled">
<a-button @click="handleSubmit" pre-icon="ant-design:check" type="primary"> </a-button>
</div>
</div>
</template>
<script lang="ts">
import { BasicForm, useForm } from '/@/components/Form/index';
import { computed, defineComponent, reactive, ref } from 'vue';
import { defHttp } from '/@/utils/http/axios';
import { propTypes } from '/@/utils/propTypes';
import { useJvxeMethod } from '/@/hooks/system/useJvxeMethods';
import { getBpmFormSchema, taskListDetialColumns } from '../TaskList.data';
import { saveOrUpdate, taskListDetialList } from '../TaskList.api';
export default defineComponent({
name: 'TaskListForm',
components: {
BasicForm,
},
props: {
formData: propTypes.object.def({}),
formBpm: propTypes.bool.def(true),
},
setup(props) {
const [registerForm, { setFieldsValue, setProps }] = useForm({
labelWidth: 150,
schemas: getBpmFormSchema(props.formData),
showActionButtonGroup: false,
baseColProps: { span: 24 },
});
const formDisabled = computed(() => {
if (props.formData.disabled === false) {
return false;
}
return true;
});
const refKeys = ref(['taskListDetial']);
const activeKey = ref('taskListDetial');
const taskListDetial = ref();
const tableRefs = { taskListDetial };
const taskListDetialTable = reactive({
loading: false,
dataSource: [],
columns: taskListDetialColumns,
show: false,
});
const [handleChangeTabs, handleSubmit, requestSubTableData, formRef] = useJvxeMethod(
requestAddOrEdit,
classifyIntoFormData,
tableRefs,
activeKey,
refKeys,
validateSubForm
);
function classifyIntoFormData(allValues) {
let main = Object.assign({}, allValues.formValue);
return {
...main, // 展开
taskListDetialList: allValues.tablesValue[0].tableData,
};
}
//表单提交事件
async function requestAddOrEdit(values) {
await saveOrUpdate(values, true);
}
const queryByIdUrl = '/tasklist/taskList/queryById';
async function initFormData() {
let params = { id: props.formData.dataId };
const data = await defHttp.get({ url: queryByIdUrl, params });
//设置表单的值
await setFieldsValue({ ...data });
requestSubTableData(taskListDetialList, { id: data.id }, taskListDetialTable, () => {
taskListDetialTable.show = true;
});
//默认是禁用
await setProps({ disabled: formDisabled.value });
}
initFormData();
return {
registerForm,
formDisabled,
formRef,
handleSubmit,
activeKey,
handleChangeTabs,
taskListDetial,
taskListDetialTable,
};
},
});
</script>
@@ -0,0 +1,119 @@
<template>
<BasicModal v-bind="$attrs" @register="registerModal" destroyOnClose :title="title" :width="800" @ok="handleSubmit">
<BasicForm @register="registerForm" ref="formRef" name="TaskListForm" />
<!-- 子表单区域 -->
<a-tabs v-model:activeKey="activeKey" animated @change="handleChangeTabs">
<a-tab-pane tab="工作任务清单详情" key="taskListDetial" :forceRender="true">
<JVxeTable
keep-source
resizable
ref="taskListDetial"
:loading="taskListDetialTable.loading"
:columns="taskListDetialTable.columns"
:dataSource="taskListDetialTable.dataSource"
:height="340"
:rowNumber="true"
:rowSelection="true"
:disabled="formDisabled"
:toolbar="true"
/>
</a-tab-pane>
</a-tabs>
</BasicModal>
</template>
<script lang="ts" setup>
import { ref, computed, unref, reactive } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicForm, useForm } from '/@/components/Form/index';
import { JVxeTable } from '/@/components/jeecg/JVxeTable';
import { useJvxeMethod } from '/@/hooks/system/useJvxeMethods.ts';
import { formSchema, taskListDetialColumns } from '../TaskList.data';
import { saveOrUpdate, taskListDetialList } from '../TaskList.api';
// Emits声明
const emit = defineEmits(['register', 'success']);
const isUpdate = ref(true);
const formDisabled = ref(false);
const refKeys = ref(['taskListDetial']);
const activeKey = ref('taskListDetial');
const taskListDetial = ref();
const tableRefs = { taskListDetial };
const taskListDetialTable = reactive({
loading: false,
dataSource: [],
columns: taskListDetialColumns,
});
//表单配置
const [registerForm, { setProps, resetFields, setFieldsValue }] = useForm({
labelWidth: 150,
schemas: formSchema,
showActionButtonGroup: false,
baseColProps: { span: 24 },
});
//表单赋值
const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data) => {
//重置表单
await reset();
setModalProps({ confirmLoading: false, showCancelBtn: data?.showFooter, showOkBtn: data?.showFooter });
isUpdate.value = !!data?.isUpdate;
formDisabled.value = !data?.showFooter;
if (unref(isUpdate)) {
//表单赋值
await setFieldsValue({
...data.record,
});
requestSubTableData(taskListDetialList, { id: data?.record?.id }, taskListDetialTable);
}
// 隐藏底部时禁用整个表单
setProps({ disabled: !data?.showFooter });
});
//方法配置
const [handleChangeTabs, handleSubmit, requestSubTableData, formRef] = useJvxeMethod(
requestAddOrEdit,
classifyIntoFormData,
tableRefs,
activeKey,
refKeys
);
//设置标题
const title = computed(() => (!unref(isUpdate) ? '新增' : !unref(formDisabled) ? '编辑' : '详情'));
async function reset() {
await resetFields();
activeKey.value = 'taskListDetial';
taskListDetialTable.dataSource = [];
}
function classifyIntoFormData(allValues) {
let main = Object.assign({}, allValues.formValue);
return {
...main, // 展开
taskListDetialList: allValues.tablesValue[0].tableData,
};
}
//表单提交事件
async function requestAddOrEdit(values) {
try {
setModalProps({ confirmLoading: true });
//提交表单
await saveOrUpdate(values, isUpdate.value);
//关闭弹窗
closeModal();
//刷新列表
emit('success');
} finally {
setModalProps({ confirmLoading: false });
}
}
</script>
<style lang="less" scoped>
/** 时间和数字输入框样式 */
:deep(.ant-input-number) {
width: 100%;
}
:deep(.ant-calendar-picker) {
width: 100%;
}
</style>
@@ -0,0 +1,794 @@
<template>
<div class="task-sidebar">
<div class="sidebar-header">
<span class="sidebar-title">
<Icon icon="ant-design:menu-outlined" class="mr-1" />
任务
</span>
</div>
<div class="sidebar-nav">
<div
v-for="item in NAV_ITEMS"
:key="item.id"
class="sidebar-item"
:class="{ active: currentView === item.id }"
@click="emit('view-select', item.id)"
>
<Icon :icon="item.icon" class="sidebar-item-icon" />
<span>{{ item.label }}</span>
</div>
</div>
<div class="sidebar-divider"></div>
<div class="sidebar-section">
<div class="sidebar-quick-header" @click="quickAccessExpanded = !quickAccessExpanded">
<Icon icon="tasklist-arrow-down|svg" class="quick-access-arrow" :class="{ expanded: quickAccessExpanded }" />
<span class="sidebar-quick-title">快速访问</span>
</div>
<template v-if="quickAccessExpanded">
<div
v-for="item in QUICK_ACCESS_ITEMS"
:key="item.id"
class="sidebar-item sidebar-quick-access-sub"
:class="{ active: currentView === item.id }"
@click="onQuickAccessClick(item)"
>
<Icon :icon="item.icon" class="sidebar-item-icon" />
<span>{{ item.label }}</span>
</div>
</template>
</div>
<div class="sidebar-divider"></div>
<div class="sidebar-section sidebar-lists-section">
<div class="sidebar-favorites-header">
<Icon icon="tasklist-task-list|svg" class="sidebar-favorites-icon" />
<span class="sidebar-favorites-name">收藏清单</span>
<Icon icon="ant-design:plus-outlined" class="sidebar-group-add" @click.stop="startNewList('')" />
</div>
<div class="sidebar-favorites-items">
<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>
<draggable :model-value="ungroupedTaskLists" group="tasklists" item-key="id" handle=".list-drag-handle" :animation="200" @end="onDragEnd">
<template #item="{ element: taskList }">
<div
v-if="renamingId !== taskList.id"
class="sidebar-item"
:class="{ active: currentListId === taskList.id }"
:data-list-id="taskList.id"
@click="emit('list-select', taskList.id)"
@mouseenter="hoveredListId = taskList.id"
@mouseleave="hoveredListId = ''"
>
<span class="list-drag-handle">
<Icon icon="tasklist-list-drag-handle|svg" />
</span>
<Icon icon="ant-design:file-text-outlined" class="sidebar-item-icon" />
<span class="sidebar-item-name">{{ taskList.name }}</span>
<a-dropdown :trigger="['click']">
<Icon v-show="hoveredListId === taskList.id" icon="ant-design:ellipsis-outlined" class="sidebar-item-more" @click.stop />
<template #overlay>
<a-menu @click="onListMenuClick($event, taskList.id, taskList.name)">
<a-menu-item v-if="canEditList(taskList.id)" key="rename">
<Icon icon="ant-design:edit-outlined" class="mr-1" />
重命名
</a-menu-item>
<a-menu-item key="remove">
<Icon icon="ant-design:minus-circle-outlined" class="mr-1" />
移除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div v-else class="sidebar-item sidebar-item-renaming">
<span class="list-drag-handle-placeholder"></span>
<Icon icon="ant-design:file-text-outlined" class="sidebar-item-icon" style="opacity: 0.4" />
<a-input v-model:value="renameValue" size="small" style="flex: 1" @press-enter="confirmInlineRename" @blur="confirmInlineRename" />
</div>
</template>
</draggable>
</div>
<draggable :model-value="userGroups" group="tasklistgroups" item-key="id" :animation="200" drag-class="group-drag-ghost" @start="onGroupDragStart" @end="onGroupDragEnd">
<template #item="{ element: group }">
<div :data-group-id="group.id" :class="{ 'group-being-dragged': draggingGroupId === group.id }">
<div
v-if="group.delFlag !== 1 && renamingId !== group.id"
class="sidebar-group-header"
@mousedown="onGroupMouseDown($event, group.id)"
@mousemove="onGroupMouseMove($event)"
@mouseup="onGroupMouseUp($event, group.id)"
>
<Icon icon="tasklist-arrow-down|svg" class="group-collapse-arrow" :class="{ collapsed: collapsedGroups[group.id] || draggingGroupId === group.id }" />
<span class="sidebar-group-name">{{ group.name }}</span>
<a-dropdown :trigger="['click']" @click.stop>
<Icon icon="ant-design:ellipsis-outlined" class="sidebar-group-more" @click.stop />
<template #overlay>
<a-menu @click="onGroupMenuClick($event, group.id, group.name)">
<a-menu-item key="rename">
<Icon icon="ant-design:edit-outlined" class="mr-1" />
重命名
</a-menu-item>
<a-menu-item key="delete" danger>
<Icon icon="ant-design:delete-outlined" class="mr-1" />
删除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<Icon icon="ant-design:plus-outlined" class="sidebar-group-add" @click.stop="startNewList(group.id)" />
</div>
<div v-if="group.delFlag !== 1 && renamingId === group.id" class="sidebar-group-header sidebar-group-renaming" @click.stop>
<Icon icon="tasklist-arrow-down|svg" class="group-collapse-arrow" :class="{ collapsed: collapsedGroups[group.id] || draggingGroupId === group.id }" />
<a-input v-model:value="renameValue" size="small" style="flex: 1" @press-enter="confirmInlineRename" @blur="confirmInlineRename" />
</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
:model-value="getGroupTaskLists(group)"
group="tasklists"
item-key="id"
handle=".list-drag-handle"
:animation="200"
@end="onDragEnd"
>
<template #item="{ element: taskList }">
<div
v-if="renamingId !== taskList.id"
class="sidebar-item"
:class="{ active: currentListId === taskList.id }"
:data-list-id="taskList.id"
@click="emit('list-select', taskList.id)"
@mouseenter="hoveredListId = taskList.id"
@mouseleave="hoveredListId = ''"
>
<span class="list-drag-handle">
<Icon icon="tasklist-list-drag-handle|svg" />
</span>
<Icon icon="ant-design:file-text-outlined" class="sidebar-item-icon" />
<span class="sidebar-item-name">{{ taskList.name }}</span>
<a-dropdown :trigger="['click']">
<Icon v-show="hoveredListId === taskList.id" icon="ant-design:ellipsis-outlined" class="sidebar-item-more" @click.stop />
<template #overlay>
<a-menu @click="onListMenuClick($event, taskList.id, taskList.name)">
<a-menu-item v-if="canEditList(taskList.id)" key="rename">
<Icon icon="ant-design:edit-outlined" class="mr-1" />
重命名
</a-menu-item>
<a-menu-item key="remove">
<Icon icon="ant-design:minus-circle-outlined" class="mr-1" />
移除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div v-else class="sidebar-item sidebar-item-renaming">
<span class="list-drag-handle-placeholder"></span>
<Icon icon="ant-design:file-text-outlined" class="sidebar-item-icon" style="opacity: 0.4" />
<a-input v-model:value="renameValue" size="small" style="flex: 1" @press-enter="confirmInlineRename" @blur="confirmInlineRename" />
</div>
</template>
</draggable>
<div
v-if="group.taskLists.filter((l) => l.delFlag !== 1).length === 0 && !(showNewListInput && newListGroupId === group.id)"
class="sidebar-empty-hint"
>
可拖拽清单加入该分组
</div>
</div>
</template>
</div>
</template>
</draggable>
<div v-if="showNewGroupInput" class="sidebar-new-group-row">
<Icon icon="ant-design:plus-outlined" class="sidebar-new-icon" />
<a-input
v-model:value="newGroupName"
size="small"
placeholder="输入分组名称..."
autofocus
style="flex: 1"
@press-enter="submitNewGroup"
@blur="showNewGroupInput = false"
/>
</div>
<div v-else class="sidebar-new-group-btn" @click="showNewGroupInput = true">
<Icon icon="ant-design:plus-outlined" class="sidebar-new-icon" />
<span>新建分组</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import Icon from '/@/components/Icon/index';
import draggable from 'vuedraggable';
import { useMessage } from '/@/hooks/web/useMessage';
import { NAV_ITEMS, QUICK_ACCESS_ITEMS, QUICK_ACCESS_TAB_MAP } from '../types';
import type { TaskListGroup, ViewType } from '../types';
const { createConfirm } = useMessage();
const props = defineProps<{
taskListGroups: TaskListGroup[];
currentView: ViewType;
currentListId: string;
favoriteIdMap: Map<string, string>;
permissionMap: Map<string, string>;
}>();
const emit = defineEmits<{
'view-select': [view: ViewType];
'list-select': [listId: string];
'create-list': [name: string, groupId: string];
'create-group': [name: string];
'rename-list': [listId: string, newName: string];
'delete-list': [listId: string];
'rename-group': [groupId: string, newName: string];
'delete-group': [groupId: string];
'quick-access-tab-click': [tabKey: string];
'move-list': [favoriteId: string, targetGroupId: string, sortOrder: number];
'move-group': [groupId: string, targetSortOrder: number];
}>();
function onQuickAccessClick(item: { id: string }) {
const tabKey = QUICK_ACCESS_TAB_MAP[item.id];
if (tabKey) {
emit('quick-access-tab-click', tabKey);
}
}
const quickAccessExpanded = ref(true);
const ungroupedTaskLists = computed(() => {
const group = props.taskListGroups.find((g) => g.id === '__ungrouped__');
if (group) {
return [...group.taskLists].filter((l) => l.delFlag !== 1).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
}
return [];
});
function getGroupTaskLists(group: TaskListGroup) {
return [...group.taskLists].filter((l) => l.delFlag !== 1).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
}
const userGroups = computed(() => {
return props.taskListGroups.filter((g) => g.id !== '__ungrouped__' && g.delFlag !== 1).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
});
const hoveredListId = ref('');
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('');
const draggingGroupId = ref('');
const dragStartX = ref(0);
const dragStartY = ref(0);
const isDragging = ref(false);
const DRAG_THRESHOLD = 5;
function toggleGroup(groupId: string) {
collapsedGroups.value = {
...collapsedGroups.value,
[groupId]: !collapsedGroups.value[groupId],
};
}
function onDragEnd(evt: any) {
const itemEl = evt.item;
const listId = itemEl?.dataset?.listId;
if (!listId) return;
const favoriteId = props.favoriteIdMap.get(listId);
if (!favoriteId) return;
const toContainer = evt.to;
const targetGroupId = toContainer?.closest('[data-group-id]')?.dataset?.groupId || '';
const newIndex = evt.newIndex ?? 0;
emit('move-list', favoriteId, targetGroupId, newIndex + 1);
}
function onGroupMouseDown(evt: MouseEvent, groupId: string) {
dragStartX.value = evt.clientX;
dragStartY.value = evt.clientY;
isDragging.value = false;
}
function onGroupMouseMove(evt: MouseEvent) {
const dx = Math.abs(evt.clientX - dragStartX.value);
const dy = Math.abs(evt.clientY - dragStartY.value);
if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) {
isDragging.value = true;
}
}
function onGroupMouseUp(evt: MouseEvent, groupId: string) {
if (!isDragging.value) {
toggleGroup(groupId);
}
isDragging.value = false;
dragStartX.value = 0;
dragStartY.value = 0;
}
function onGroupDragStart(evt: any) {
const itemEl = evt.item;
const groupId = itemEl?.dataset?.groupId;
if (groupId) {
draggingGroupId.value = groupId;
}
}
function onGroupDragEnd(evt: any) {
draggingGroupId.value = '';
isDragging.value = false;
const itemEl = evt.item;
const groupId = itemEl?.dataset?.groupId || itemEl?.closest('[data-group-id]')?.dataset?.groupId;
if (!groupId) return;
const newIndex = evt.newIndex ?? 0;
emit('move-group', groupId, newIndex + 1);
}
function canEditList(listId: string): boolean {
const perm = props.permissionMap.get(listId);
return perm === '1' || perm === '2';
}
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 = '';
}
function onListMenuClick({ key }: { key: string }, listId: string, currentName: string) {
if (key === 'rename') {
renamingId.value = listId;
renamingType.value = 'list';
renameValue.value = currentName;
} else if (key === 'remove') {
createConfirm({
iconType: 'warning',
title: '确认删除',
content: '删除任务清单后,其下所有任务也将被删除,确定要删除吗?',
okText: '确认',
cancelText: '取消',
onOk: () => {
emit('delete-list', listId);
},
});
}
}
function onGroupMenuClick({ key }: { key: string }, groupId: string, currentName: string) {
if (key === 'rename') {
renamingId.value = groupId;
renamingType.value = 'group';
renameValue.value = currentName;
} else if (key === 'delete') {
emit('delete-group', groupId);
}
}
function confirmInlineRename() {
const name = renameValue.value.trim();
if (name && renamingId.value) {
if (renamingType.value === 'list') {
emit('rename-list', renamingId.value, name);
} else {
emit('rename-group', renamingId.value, name);
}
}
renamingId.value = '';
renameValue.value = '';
}
function submitNewGroup() {
if (newGroupName.value.trim()) {
emit('create-group', newGroupName.value.trim());
newGroupName.value = '';
showNewGroupInput.value = false;
}
}
</script>
<style lang="less" scoped>
.task-sidebar {
width: 240px;
min-width: 240px;
height: 100%;
background: #fff;
border-right: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
overflow: hidden;
user-select: none;
}
.sidebar-header {
padding: 14px 16px 10px;
display: flex;
align-items: center;
justify-content: space-between;
}
.sidebar-title {
font-size: 16px;
font-weight: 600;
color: #1f1f1f;
display: flex;
align-items: center;
}
.sidebar-nav {
padding: 4px 8px;
}
.sidebar-divider {
height: 1px;
background: #f0f0f0;
margin: 4px 12px;
}
.sidebar-section {
padding: 4px 8px;
}
.sidebar-quick-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
cursor: pointer;
color: #8c8c8c;
font-size: 14px;
user-select: none;
&:hover {
color: #595959;
}
}
.sidebar-quick-title {
font-size: 14px;
font-weight: 600;
color: #595959;
}
.quick-access-arrow {
font-size: 12px;
color: #8c8c8c;
transition: transform 0.2s ease;
transform: rotate(-90deg);
&.expanded {
transform: rotate(0deg);
}
}
.sidebar-item.sidebar-quick-access-sub {
padding-left: 30px;
font-size: 14px;
}
.sidebar-section-header {
padding: 6px 8px 2px;
}
.sidebar-section-title {
font-size: 14px;
font-weight: 600;
color: #8c8c8c;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sidebar-lists-section {
flex: 1;
overflow-y: auto;
padding-bottom: 0;
}
.sidebar-favorites-header {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px 2px;
position: relative;
}
.sidebar-favorites-icon {
color: #8f959e;
flex-shrink: 0;
font-size: 14px;
}
.sidebar-favorites-name {
font-size: 14px;
font-weight: 600;
color: #595959;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-favorites-items {
.sidebar-item {
padding-left: 8px;
}
}
.sidebar-group-items {
.sidebar-item {
padding-left: 8px;
}
}
.sidebar-group-header {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px 2px;
cursor: pointer;
position: relative;
&:hover .sidebar-group-name {
color: #333;
}
.sidebar-group-more,
.sidebar-group-add {
opacity: 0;
transition: opacity 0.15s;
}
&:hover .sidebar-group-more,
&:hover .sidebar-group-add {
opacity: 1;
}
}
.group-collapse-arrow {
font-size: 12px;
color: #8f959e;
transition: transform 0.2s ease;
flex-shrink: 0;
width: 16px;
text-align: center;
&.collapsed {
transform: rotate(-90deg);
}
}
.sidebar-group-name {
font-size: 14px;
font-weight: 600;
color: #595959;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-group-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 10px;
color: #999;
}
.sidebar-group-add {
margin-left: auto;
font-size: 14px;
color: #999;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
&:hover {
color: #1677ff;
background: #e6f4ff;
}
}
.sidebar-group-more {
font-size: 16px;
color: #999;
padding: 2px;
border-radius: 4px;
flex-shrink: 0;
&:hover {
color: #1677ff;
background: #e6f4ff;
}
}
.sidebar-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
cursor: pointer;
border-radius: 6px;
margin: 1px 0;
font-size: 14px;
color: #595959;
transition: all 0.15s ease;
position: relative;
&:hover {
background: #f5f5f5;
color: #333;
}
&.active {
background: #e6f4ff;
color: #1677ff;
font-weight: 500;
}
}
.sidebar-item-icon {
font-size: 14px;
flex-shrink: 0;
}
.sidebar-item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.sidebar-item-more {
font-size: 14px;
color: #999;
padding: 2px;
border-radius: 4px;
flex-shrink: 0;
&:hover {
color: #1677ff;
background: #e6f4ff;
}
}
.list-drag-handle {
cursor: grab;
font-size: 12px;
color: transparent;
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
flex-shrink: 0;
transition: color 0.15s;
&:active {
cursor: grabbing;
}
}
.sidebar-item:hover .list-drag-handle {
color: #c0c4cc;
&:hover {
color: #8c8c8c;
}
}
.list-drag-handle-placeholder {
width: 14px;
flex-shrink: 0;
}
.sidebar-empty-hint {
padding: 8px 10px 4px 38px;
font-size: 12px;
color: #bfbfbf;
font-style: italic;
}
.sidebar-new-group-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
margin: 4px 0;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
color: #8c8c8c;
&:hover {
background: #f5f5f5;
color: #333;
}
}
.sidebar-new-group-row {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
margin: 4px 0;
}
.sidebar-new-item-row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px 4px 8px;
margin: 2px 0;
}
.group-being-dragged > .sidebar-group-items {
display: none !important;
}
.group-drag-ghost {
opacity: 0.6;
}
.sidebar-item-renaming {
background: #f5f6f7;
}
.sidebar-group-renaming {
background: #f5f6f7;
}
.sidebar-new-icon {
font-size: 12px;
color: #8c8c8c;
}
</style>
@@ -0,0 +1,366 @@
<template>
<a-modal
v-model:open="visible"
:title="title"
:footer="null"
width="520px"
:bodyStyle="{ padding: '0' }"
:centered="true"
:destroyOnClose="true"
@cancel="onClose"
>
<div class="user-modal">
<div class="user-body">
<div class="member-list">
<div
v-for="item in memberList"
:key="item.userId"
class="member-item"
@mouseenter="hoverId = 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>
</div>
</div>
<div class="member-right">
<span v-show="hoverId === item.userId" class="remove-icon" @click="onRemove(item.userId)">
<Icon icon="ant-design:close-outlined" />
</span>
</div>
</div>
<div v-if="memberList.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" class="add-panel">
<div class="add-panel-header">
<span>{{ addPanelTitle }}</span>
<span class="add-panel-close" @click="showAddPanel = false">
<Icon icon="ant-design:close-outlined" />
</span>
</div>
<div class="add-panel-body">
<JSelectUser
v-model:value="addForm.userIds"
:label-key="'realname'"
:row-key="'id'"
multiple="multiple"
placeholder="搜索并选择用户(支持多选)"
/>
</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="!showAddPanel" class="user-footer">
<a-button type="text" size="small" @click="showAddPanel = true">
<Icon icon="ant-design:user-add-outlined" style="margin-right: 4px" />
{{ addPanelTitle }}
</a-button>
<a-button type="primary" size="small" @click="onDone">完成</a-button>
</div>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue';
import Icon from '/@/components/Icon/index';
import JSelectUser from '/@/components/Form/src/jeecg/components/JSelectUser.vue';
import { getAvatarColor } from '../types';
import { getUserList } from '/@/api/common/api';
interface MemberItem {
userId: string;
username: string;
}
const props = withDefaults(
defineProps<{
title?: string;
userIds: string;
userNames: string;
multiple?: boolean;
}>(),
{
title: '选择人员',
multiple: true,
}
);
const emit = defineEmits<{
confirm: [userIds: string, userNames: string];
}>();
const visible = ref(false);
const hoverId = ref('');
const showAddPanel = ref(false);
const addForm = reactive({
userIds: '',
});
const memberList = ref<MemberItem[]>([]);
const addPanelTitle = computed(() => {
return props.title === '选择负责人' ? '添加负责人' : props.title === '选择参与人' ? '添加参与人' : props.title === '选择关注人' ? '添加关注人' : '添加人员';
});
function getColor(name: string): string {
return getAvatarColor(name || '');
}
function parseMembers(userIds: string, userNames: string): MemberItem[] {
if (!userIds) return [];
const ids = userIds.split(',').filter(Boolean);
const names = userNames ? userNames.split(',').filter(Boolean) : [];
return ids.map((id, i) => ({
userId: id.trim(),
username: (names[i] || id).trim(),
}));
}
async function open() {
memberList.value = parseMembers(props.userIds, props.userNames);
const needFetch = memberList.value.filter((m) => m.username === m.userId);
if (needFetch.length > 0) {
try {
const ids = needFetch.map((m) => m.userId);
const res = await getUserList({ id: ids.join(','), pageNo: 1, pageSize: ids.length * 2 });
if (res.records && res.records.length > 0) {
const nameMap: Record<string, string> = {};
res.records.forEach((u: any) => {
nameMap[u.id] = u.realname || u.username;
});
memberList.value.forEach((m) => {
if (nameMap[m.userId]) {
m.username = nameMap[m.userId];
}
});
}
} catch (e) {
console.warn('获取用户名失败', e);
}
}
showAddPanel.value = false;
addForm.userIds = '';
visible.value = true;
}
function onClose() {
visible.value = false;
showAddPanel.value = false;
addForm.userIds = '';
}
function onRemove(userId: string) {
memberList.value = memberList.value.filter((m) => m.userId !== userId);
}
async function onConfirmAdd() {
if (!addForm.userIds) return;
const newIds = addForm.userIds.split(',').filter(Boolean);
const existingIds = new Set(memberList.value.map((m) => m.userId));
if (!props.multiple) {
memberList.value = [];
}
const nameMap: Record<string, string> = {};
try {
const res = await getUserList({ id: newIds.join(','), pageNo: 1, pageSize: newIds.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('获取用户名失败,使用ID作为名称', e);
}
for (const nid of newIds) {
if (!existingIds.has(nid) || !props.multiple) {
memberList.value.push({
userId: nid,
username: nameMap[nid] || nid,
});
}
}
addForm.userIds = '';
showAddPanel.value = false;
}
function onDone() {
const ids = memberList.value.map((m) => m.userId).join(',');
const names = memberList.value.map((m) => m.username).join(',');
emit('confirm', ids, names);
visible.value = false;
}
defineExpose({ open });
</script>
<style lang="less" scoped>
.user-modal {
display: flex;
flex-direction: column;
}
.user-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;
}
.member-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
min-width: 30px;
justify-content: flex-end;
}
.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;
}
}
.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-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
margin-top: auto;
}
.user-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 20px;
border-top: 1px solid #f0f0f0;
}
</style>