ykt-wxapp/pages/case/group-manage.vue

695 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="manage-container">
<view class="group-list">
<scroll-view scroll-y class="sort-scroll">
<movable-area :key="areaKey" class="drag-area" :style="{ height: dragAreaHeight + 'px' }">
<movable-view
v-for="(item, index) in groups"
:key="item._id"
class="drag-item"
direction="vertical"
:disabled="dragEnabledId !== String(item._id)"
:y="Number(item._y || 0)"
:animation="draggingId !== String(item._id)"
@change="(e) => onDragChange(e, item)"
@touchend="() => onTouchEnd(item)"
@touchcancel="() => onTouchEnd(item)"
>
<view class="group-item" :class="{ 'is-dragging': draggingId === String(item._id) }">
<view
class="left-action"
:class="{ disabled: Boolean(item.parentGroupId) || Boolean(dragEnabledId) }"
@click.stop="handleDelete(item, index)"
@longpress.stop
>
<uni-icons
type="minus-filled"
size="24"
:color="Boolean(item.parentGroupId) || Boolean(dragEnabledId) ? '#ddd' : '#ff4d4f'"
></uni-icons>
</view>
<view class="group-name">
<view class="name-row">
<text class="name-text">{{ item.groupName }}</text>
<text v-if="item.parentGroupId" class="corp-tag">机构</text>
</view>
<text v-if="item.description" class="desc">{{ item.description }}</text>
</view>
<view class="right-actions">
<uni-icons
type="compose"
size="24"
:color="Boolean(dragEnabledId) ? '#ddd' : '#5d8aff'"
class="icon-edit"
@click.stop="handleEdit(item, index)"
@longpress.stop
></uni-icons>
<view
class="drag-handle"
@touchstart="() => startHoldToDrag(item)"
@touchend="() => cancelHoldToDrag()"
@touchcancel="() => cancelHoldToDrag()"
>
<uni-icons type="bars" size="24" color="#5d8aff" class="icon-drag"></uni-icons>
</view>
</view>
</view>
</movable-view>
</movable-area>
</scroll-view>
</view>
<!-- Bottom Button -->
<view class="footer">
<button class="add-btn" :disabled="Boolean(dragEnabledId)" @click="handleAdd">添加新分组</button>
</view>
<!-- Dialog -->
<view v-if="showDialog" class="dialog-mask">
<view class="dialog-content">
<view class="dialog-header">{{ dialogTitle }}</view>
<view class="dialog-body">
<input class="dialog-input" type="text" v-model="inputValue" placeholder="请输入分组名称" />
</view>
<view class="dialog-footer">
<button class="dialog-btn cancel" @click="closeDialog">取消</button>
<button class="dialog-btn confirm" @click="handleSave">保存</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { storeToRefs } from 'pinia';
import api from '@/utils/api';
import useAccountStore from '@/store/account';
import { hideLoading, loading, toast } from '@/utils/widget';
// State
const groups = ref([]);
const originalGroups = ref([]);
const areaKey = ref(0);
const loadSeq = ref(0);
const ITEM_HEIGHT = 74; // px需与样式保持一致
const draggingId = ref('');
const dragEnabledId = ref('');
const savingSort = ref(false);
const lastSavedOrderKey = ref('');
const holdTimer = ref(null);
const holdCandidateId = ref('');
const dragAreaHeight = computed(() => (groups.value.length || 0) * ITEM_HEIGHT);
const GROUPS_RELOAD_KEY = 'ykt_case_groups_need_reload';
const CURRENT_TEAM_STORAGE_KEY = 'ykt_case_current_team';
const accountStore = useAccountStore();
const { account, doctorInfo } = storeToRefs(accountStore);
const { getDoctorInfo } = accountStore;
function getUserId() {
const d = doctorInfo.value || {};
const a = account.value || {};
return String(d.userid || d.userId || d.corpUserId || a.userid || a.userId || '') || '';
}
function getCorpId() {
const d = doctorInfo.value || {};
const a = account.value || {};
const team = uni.getStorageSync(CURRENT_TEAM_STORAGE_KEY) || {};
return String(d.corpId || a.corpId || team.corpId || '') || '';
}
function getTeamId() {
const team = uni.getStorageSync(CURRENT_TEAM_STORAGE_KEY) || {};
return String(team.teamId || '') || '';
}
function sortGroupList(list) {
const { orderList, corpList, restList } = (Array.isArray(list) ? list : []).reduce(
(p, c) => {
if (typeof c?.sortOrder === 'number') p.orderList.push(c);
else if (c?.parentGroupId) p.corpList.push(c);
else p.restList.push(c);
return p;
},
{ orderList: [], corpList: [], restList: [] }
);
orderList.sort((a, b) => a.sortOrder - b.sortOrder);
return [...orderList, ...corpList, ...restList];
}
async function ensureDoctor() {
if (doctorInfo.value) return;
if (!account.value?.openid) return;
try {
await getDoctorInfo();
} catch (e) {
// ignore
}
}
async function loadGroups() {
const seq = (loadSeq.value += 1);
await ensureDoctor();
const corpId = getCorpId();
const teamId = getTeamId();
if (!corpId || !teamId) return;
loading('加载中...');
try {
const projection = { _id: 1, groupName: 1, parentGroupId: 1, description: 1, sortOrder: 1 };
const res = await api('getGroups', { corpId, teamId, page: 1, pageSize: 1000, projection, countGroupMember: false });
if (!res?.success) {
toast(res?.message || '获取分组失败');
return;
}
const list = Array.isArray(res.data) ? res.data : Array.isArray(res.data?.data) ? res.data.data : [];
const sorted = sortGroupList(list);
if (seq !== loadSeq.value) return;
const next = sorted.map((i, idx) => ({ ...i, _y: idx * ITEM_HEIGHT }));
// movable-view 在频繁新增后偶发错位:清空再赋值,并通过 key 强制重建 movable-area
groups.value = [];
await nextTick();
if (seq !== loadSeq.value) return;
groups.value = next;
originalGroups.value = next.map((i) => ({ ...i }));
lastSavedOrderKey.value = getOrderKey(groups.value);
areaKey.value += 1;
} catch (e) {
toast('获取分组失败');
} finally {
hideLoading();
}
}
function normalizeListPayload(res) {
const payload =
res && typeof res === 'object'
? res.data && typeof res.data === 'object' && !Array.isArray(res.data)
? res.data
: res
: {};
const list = Array.isArray(payload.list) ? payload.list : Array.isArray(payload.data) ? payload.data : [];
const total = Number(payload.total ?? res?.total ?? list.length) || 0;
return { list, total };
}
async function fetchGroupMemberIds(groupId) {
const corpId = getCorpId();
const teamId = getTeamId();
const userId = getUserId();
if (!corpId || !teamId || !userId || !groupId) {
return { success: false, message: '缺少用户/团队信息', ids: [] };
}
const ids = [];
const seen = new Set();
const pageSize = 200;
let page = 1;
let total = 0;
while (true) {
const res = await api('searchCorpCustomerForCaseList', {
corpId,
userId,
teamId,
page,
pageSize,
groupIds: [String(groupId)],
});
if (!res?.success) {
return { success: false, message: res?.message || '获取分组成员失败', ids: [] };
}
const { list, total: nextTotal } = normalizeListPayload(res);
total = nextTotal;
list.forEach((member) => {
const memberId = String(member?._id || member?.id || '');
if (!memberId || seen.has(memberId)) return;
seen.add(memberId);
ids.push(memberId);
});
if (list.length < pageSize || (total > 0 && ids.length >= total)) break;
page += 1;
}
return { success: true, ids };
}
async function removeMembersFromGroup(memberIds, groupId) {
for (const memberId of memberIds) {
const res = await api('addGroupIdForMember', { memberId, fromGroupId: String(groupId) });
if (!res?.success) {
return { success: false, message: res?.message || '分组成员移出失败' };
}
}
return { success: true };
}
const showDialog = ref(false);
const dialogMode = ref('add'); // 'add' or 'edit'
const inputValue = ref('');
const editingIndex = ref(-1);
const dialogTitle = ref('添加新分组');
// Methods
const handleAdd = () => {
if (dragEnabledId.value) return;
dialogMode.value = 'add';
dialogTitle.value = '添加新分组';
inputValue.value = '';
showDialog.value = true;
};
const handleEdit = (item, index) => {
if (dragEnabledId.value) return;
dialogMode.value = 'edit';
dialogTitle.value = '编辑分组名称';
inputValue.value = item.groupName || '';
editingIndex.value = index;
showDialog.value = true;
};
const handleDelete = (item, index) => {
if (dragEnabledId.value) return;
if (item?.parentGroupId) return;
(async () => {
await ensureDoctor();
const corpId = getCorpId();
const teamId = getTeamId();
if (!corpId || !teamId) {
toast('缺少团队信息');
return;
}
const groupId = String(item?._id || '');
const memberRes = await fetchGroupMemberIds(groupId);
if (!memberRes.success) {
toast(memberRes.message || '获取分组成员失败');
return;
}
const memberIds = memberRes.ids;
uni.showModal({
title: '提示',
content: memberIds.length
? '分组删除后,所有分组内成员自动出组。确定要删除该分组吗?'
: '确定要删除该分组吗?',
cancelText: '取消',
confirmText: '确定删除',
success: async (res) => {
if (!res.confirm) return;
loading('');
try {
if (memberIds.length) {
const removeRes = await removeMembersFromGroup(memberIds, groupId);
if (!removeRes.success) {
toast(removeRes.message || '删除失败');
return;
}
}
const delRes = await api('removeGroup', { corpId, id: groupId, teamId, groupType: 'team' });
if (!delRes?.success) {
toast(delRes?.message || '删除失败');
return;
}
toast('删除成功');
uni.setStorageSync(GROUPS_RELOAD_KEY, 1);
await loadGroups();
} finally {
hideLoading();
}
},
});
})();
};
const closeDialog = () => {
showDialog.value = false;
};
const handleSave = async () => {
if (!inputValue.value.trim()) {
uni.showToast({ title: '请输入分组名称', icon: 'none' });
return;
}
await ensureDoctor();
const corpId = getCorpId();
const teamId = getTeamId();
const userId = getUserId();
if (!corpId || !teamId || !userId) {
toast('缺少用户/团队信息');
return;
}
loading('保存中...');
try {
if (dialogMode.value === 'add') {
const params = {
groupName: inputValue.value.trim(),
teamId,
groupType: 'team',
creator: userId,
};
const res = await api('createGroup', { corpId, teamId, params });
if (!res?.success) {
toast(res?.message || '新增失败');
return;
}
toast('新增成功');
} else {
const item = groups.value[editingIndex.value];
if (!item?._id) return;
const res = await api('updateGroup', { corpId, id: item._id, params: { groupName: inputValue.value.trim() } });
if (!res?.success) {
toast(res?.message || '修改失败');
return;
}
toast('修改成功');
}
uni.setStorageSync(GROUPS_RELOAD_KEY, 1);
closeDialog();
await loadGroups();
} finally {
hideLoading();
}
};
function initDragPositions() {
groups.value = groups.value.map((i, idx) => ({ ...i, _y: idx * ITEM_HEIGHT }));
}
function getOrderKey(list) {
return (Array.isArray(list) ? list : []).map((i) => String(i?._id || '')).join('|');
}
function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
function findIndexById(id) {
const sid = String(id || '');
return groups.value.findIndex((g) => String(g?._id || '') === sid);
}
function setYByIndex(activeId, activeY) {
const sid = String(activeId || '');
groups.value.forEach((g, idx) => {
if (sid && String(g?._id || '') === sid && typeof activeY === 'number') g._y = activeY;
else g._y = idx * ITEM_HEIGHT;
});
}
function clearHoldTimer() {
if (holdTimer.value) {
clearTimeout(holdTimer.value);
holdTimer.value = null;
}
holdCandidateId.value = '';
}
async function onTouchEnd(item) {
const id = String(item?._id || '');
clearHoldTimer();
if (!id) return;
// 只有当本次已进入拖动时,才执行拖动结束逻辑
if (dragEnabledId.value === id) {
draggingId.value = '';
setYByIndex('', null);
dragEnabledId.value = '';
await maybeAutoSaveSort();
}
}
function startHoldToDrag(item) {
const id = String(item?._id || '');
if (!id) return;
if (savingSort.value) return;
if (dragEnabledId.value) return;
clearHoldTimer();
holdCandidateId.value = id;
holdTimer.value = setTimeout(() => {
if (holdCandidateId.value !== id) return;
initDragPositions();
dragEnabledId.value = id;
draggingId.value = id;
}, 350);
}
function cancelHoldToDrag() {
clearHoldTimer();
}
async function maybeAutoSaveSort() {
if (savingSort.value) return;
const key = getOrderKey(groups.value);
if (!key || key === lastSavedOrderKey.value) return;
savingSort.value = true;
try {
const teamId = getTeamId();
if (!teamId) return;
const data = groups.value.map((i, idx) => ({ _id: i._id, sortOrder: idx }));
const res = await api('sortGroups', { teamId, data });
if (!res?.success) {
toast(res?.message || '保存失败');
return;
}
lastSavedOrderKey.value = key;
originalGroups.value = groups.value.map((i, idx) => ({ ...i, _y: idx * ITEM_HEIGHT }));
uni.setStorageSync(GROUPS_RELOAD_KEY, 1);
toast('已保存');
} finally {
savingSort.value = false;
}
}
function onDragChange(e, item) {
const detail = e?.detail || {};
if (detail.source && detail.source !== 'touch') return;
const id = String(item?._id || '');
if (!id) return;
if (dragEnabledId.value !== id) return;
const from = findIndexById(id);
if (from < 0) return;
const y = Number(detail.y || 0);
const len = groups.value.length;
const to = clamp(Math.round(y / ITEM_HEIGHT), 0, Math.max(0, len - 1));
if (to !== from) {
const moved = groups.value.splice(from, 1)[0];
groups.value.splice(to, 0, moved);
}
setYByIndex(id, y);
}
onLoad(() => {
loadGroups();
});
</script>
<style lang="scss" scoped>
.manage-container {
min-height: 100vh;
background-color: #fff;
padding-bottom: 80px; // Space for footer
}
.group-list {
padding: 0 15px;
}
.sort-scroll {
max-height: calc(100vh - 120px);
}
.drag-area {
width: 100%;
}
.drag-item {
width: 100%;
height: 74px;
}
.group-item {
display: flex;
align-items: center;
padding: 15px 0;
border-bottom: 1px solid #eee;
&.is-dragging {
background: #f7fbff;
}
.left-action {
margin-right: 15px;
&.disabled {
opacity: 0.6;
}
}
.group-name {
flex: 1;
font-size: 16px;
color: #333;
.name-row {
display: flex;
align-items: center;
gap: 8px;
}
.corp-tag {
font-size: 12px;
padding: 2px 6px;
border-radius: 10px;
background: #f0f0f0;
color: #666;
}
.desc {
display: block;
margin-top: 4px;
font-size: 12px;
color: #999;
}
}
.right-actions {
display: flex;
align-items: center;
gap: 15px;
.icon-edit, .icon-drag {
padding: 5px; // Increase tap area
}
.icon-sort {
padding: 5px;
}
}
}
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #fff;
padding: 15px 20px 30px; // Safe area padding
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
justify-content: center;
.add-btn {
background-color: #0877F1;
color: #fff;
border-radius: 8px;
font-size: 16px;
height: 44px;
line-height: 44px;
border: none;
width: 100%;
max-width: 520px;
&::after {
border: none;
}
&:disabled {
opacity: 0.6;
}
&.plain {
background-color: #fff;
color: #666;
border: 1px solid #ddd;
}
}
}
.drag-handle {
padding: 5px;
}
// Custom Dialog Styles
.dialog-mask {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 999;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
}
.dialog-content {
width: 280px;
background-color: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
.dialog-header {
padding: 20px 0 10px;
text-align: center;
font-size: 18px;
font-weight: 500;
color: #333;
}
.dialog-body {
padding: 10px 20px 20px;
.dialog-input {
border: 1px solid #ddd;
height: 40px;
padding: 0 10px;
border-radius: 4px;
font-size: 14px;
}
}
.dialog-footer {
display: flex;
padding: 0 20px 20px;
justify-content: space-between;
gap: 15px;
.dialog-btn {
flex: 1;
height: 36px;
line-height: 36px;
font-size: 14px;
margin: 0;
&.cancel {
background-color: #fff;
color: #666;
border: 1px solid #ddd;
}
&.confirm {
background-color: #0877F1;
color: #fff;
border: none; // Remove border for confirm button usually
}
&::after {
border: none;
}
}
}
}
</style>