<script setup lang="ts">
|
/**
|
* 部门树选择面板(对齐 web 端 form-create/components/dept-select.vue)
|
*
|
* web 端用的是 antd `TreeSelect`:
|
* - `:tree-data` 由 `/system/dept/simple-list` 的**扁平列表**经 handleTree 建树而来
|
* - `show-search` + 按 name 模糊过滤
|
* - `:tree-checkable="multiple"`,且**没有**设置 treeCheckStrictly(默认 false)
|
* - **没有**设置 showCheckedStrategy(默认 SHOW_CHILD)
|
*
|
* 具体语义见 ../dept-tree.ts(纯逻辑单独抽出,便于校验):
|
* 1. 单选:所有节点可选,**父级同样可选**;
|
* 2. 多选:父子联动,勾父级 = 连带勾选全部后代;
|
* 3. 多选提交值按 SHOW_CHILD 过滤,**最终只保留叶子节点**。
|
*/
|
import { computed, ref, watch } from "vue";
|
import { toast } from "../shared";
|
import {
|
deptExpandableKeys,
|
deptKey,
|
deptSubmitKeys,
|
deptVisibleKeys,
|
filterDeptTree,
|
flattenDeptRows,
|
indexDeptTree,
|
toggleDeptCheck,
|
type DeptNode,
|
} from "../dept-tree";
|
|
const props = withDefaults(
|
defineProps<{
|
show: boolean;
|
title?: string;
|
multiple?: boolean;
|
/** 已建树的部门数据(父页面用 buildTree 生成) */
|
nodes: DeptNode[];
|
selectedKeys?: Array<number | string>;
|
readonly?: boolean;
|
emptyText?: string;
|
}>(),
|
{
|
title: "选择部门",
|
multiple: false,
|
selectedKeys: () => [],
|
readonly: false,
|
emptyText: "暂无可选部门",
|
},
|
);
|
|
const emit = defineEmits<{
|
"update:show": [value: boolean];
|
confirm: [keys: Array<number | string>];
|
}>();
|
|
const keyword = ref("");
|
/** 手动展开的 key(搜索时会被命中路径覆盖) */
|
const expanded = ref<string[]>([]);
|
/**
|
* 内部选中集(含被联动的祖先节点),存的都是字符串 key。
|
* 与 web 端 antd 的 rawCheckedValues 一致,提交前再按 SHOW_CHILD 过滤。
|
*/
|
const picked = ref<string[]>([]);
|
|
const index = computed(() => indexDeptTree(props.nodes || []));
|
const pickedSet = computed(() => new Set(picked.value));
|
|
const displayNodes = computed(() => filterDeptTree(props.nodes || [], keyword.value));
|
|
/** 展开集合:搜索时强制展开全部命中路径 */
|
const expandedSet = computed(() =>
|
keyword.value.trim() ? deptVisibleKeys(displayNodes.value) : new Set(expanded.value),
|
);
|
|
const rows = computed(() =>
|
flattenDeptRows(index.value, displayNodes.value, expandedSet.value, pickedSet.value),
|
);
|
|
const expandableKeys = computed(() => deptExpandableKeys(props.nodes || []));
|
const allExpanded = computed(
|
() =>
|
expandableKeys.value.length > 0 &&
|
expandableKeys.value.every(key => expandedSet.value.has(key)),
|
);
|
|
/** 已选数量(叶子口径,与提交值一致) */
|
const pickedCount = computed(() =>
|
props.multiple ? deptSubmitKeys(index.value, picked.value).length : picked.value.length,
|
);
|
|
function toggleExpandAll() {
|
expanded.value = allExpanded.value ? [] : [...expandableKeys.value];
|
}
|
|
function toggleExpand(node: DeptNode) {
|
const key = deptKey(node);
|
const next = new Set(expanded.value);
|
if (next.has(key)) next.delete(key);
|
else next.add(key);
|
expanded.value = [...next];
|
}
|
|
function toggleCheck(node: DeptNode) {
|
if (props.readonly) return;
|
const key = deptKey(node);
|
|
// 单选:父级同样可选,选中即回填并关闭
|
if (!props.multiple) {
|
picked.value = [key];
|
emit("confirm", [node.id as number | string]);
|
close();
|
return;
|
}
|
|
picked.value = toggleDeptCheck(index.value, picked.value, key);
|
}
|
|
function clearAll() {
|
picked.value = [];
|
}
|
|
function confirmPick() {
|
if (!picked.value.length) {
|
uni.showToast({ title: "请至少选择一条数据", icon: "none" });
|
return;
|
}
|
emit("confirm", deptSubmitKeys(index.value, picked.value));
|
close();
|
}
|
|
function close() {
|
emit("update:show", false);
|
}
|
|
watch(
|
() => props.show,
|
value => {
|
if (!value) return;
|
keyword.value = "";
|
// 默认展开全部:移动端没有 hover,折叠态很难感知层级
|
expanded.value = [...expandableKeys.value];
|
picked.value = props.selectedKeys.map(String);
|
},
|
{ immediate: true },
|
);
|
</script>
|
|
<template>
|
<view v-if="show" class="sheet-mask" @click="close">
|
<view class="sheet" @click.stop>
|
<view class="sheet-head">
|
<text class="sheet-head-title">{{ title }}</text>
|
<text class="sheet-close" @click="close">关闭</text>
|
</view>
|
|
<view class="sheet-search">
|
<input v-model="keyword" placeholder="搜索部门名称" confirm-type="search" />
|
<text v-if="keyword" @click="keyword = ''">清除</text>
|
<text v-else class="expand-toggle" @click="toggleExpandAll">
|
{{ allExpanded ? '收起全部' : '展开全部' }}
|
</text>
|
</view>
|
|
<scroll-view class="sheet-body" scroll-y>
|
<template v-if="rows.length">
|
<view
|
v-for="row in rows"
|
:key="row.key"
|
class="tree-row"
|
:style="{ paddingLeft: `${row.depth * 34}rpx` }"
|
@click="toggleCheck(row.node)"
|
>
|
<text
|
class="tree-arrow"
|
:class="{ open: row.expanded, empty: !row.hasChildren }"
|
@click.stop="row.hasChildren && toggleExpand(row.node)"
|
>
|
{{ row.hasChildren ? (row.expanded ? '▾' : '▸') : '' }}
|
</text>
|
<text class="tree-label">{{ row.node.name || row.node.id }}</text>
|
<view class="tree-check" :class="row.state">
|
<text v-if="row.state === 'all'">✓</text>
|
<text v-else-if="row.state === 'half'" class="half-bar" />
|
</view>
|
</view>
|
</template>
|
<view v-else class="sheet-state">{{ emptyText }}</view>
|
</scroll-view>
|
|
<view v-if="multiple && !readonly" class="sheet-foot">
|
<text>已选 {{ pickedCount }} 项</text>
|
<view>
|
<button class="ghost" @click="clearAll">清空</button>
|
<button class="primary" @click="confirmPick">确定</button>
|
</view>
|
</view>
|
</view>
|
</view>
|
</template>
|
|
<style lang="scss" scoped>
|
.sheet-mask{position:fixed;top:0;right:0;bottom:0;left:0;z-index:900;display:flex;align-items:flex-end;background:rgba(16,35,63,.45)}
|
.sheet{display:flex;flex-direction:column;width:100%;max-height:80vh;background:#fff;border-radius:24rpx 24rpx 0 0}
|
.sheet-head{display:flex;align-items:center;justify-content:space-between;height:96rpx;padding:0 28rpx;border-bottom:1rpx solid #edf1f6}
|
.sheet-head-title{color:#10233f;font-size:30rpx;font-weight:600}
|
.sheet-close{color:var(--color-primary, #1769d8);font-size:25rpx}
|
.sheet-search{display:flex;align-items:center;height:76rpx;gap:16rpx;margin:18rpx 28rpx 6rpx;padding:0 20rpx;background:#f4f7fb;border-radius:14rpx}
|
.sheet-search input{min-width:0;flex:1;font-size:26rpx}
|
.sheet-search text{flex:none;color:var(--color-primary, #1769d8);font-size:24rpx}
|
.sheet-body{height:660rpx;flex:none;padding:8rpx 28rpx 0;box-sizing:border-box}
|
.tree-row{display:flex;align-items:center;gap:10rpx;padding:20rpx 0;border-bottom:1rpx solid #f2f5f9}
|
.tree-arrow{width:32rpx;flex:none;color:#8494a8;font-size:24rpx;text-align:center}
|
.tree-arrow.empty{opacity:0}
|
.tree-label{min-width:0;flex:1;overflow:hidden;color:#1e3049;font-size:27rpx;text-overflow:ellipsis;white-space:nowrap}
|
.tree-check{display:flex;width:40rpx;height:40rpx;flex:none;align-items:center;justify-content:center;color:#fff;font-size:24rpx;background:#fff;border:2rpx solid #cfd9e6;border-radius:8rpx}
|
.tree-check.all,
|
.tree-check.half{background:var(--color-primary, #1769d8);border-color:var(--color-primary, #1769d8)}
|
.half-bar{width:20rpx;height:4rpx;background:#fff;border-radius:2rpx}
|
.sheet-state{padding:60rpx 0;color:#8795a8;font-size:25rpx;text-align:center}
|
.sheet-foot{display:flex;align-items:center;justify-content:space-between;padding:18rpx 28rpx 34rpx;border-top:1rpx solid #edf1f6}
|
.sheet-foot>text{color:#617287;font-size:24rpx}
|
.sheet-foot>view{display:flex;gap:16rpx}
|
.sheet-foot button{height:72rpx;min-height:72rpx;padding:0 34rpx;margin:0;font-size:26rpx;line-height:70rpx;border-radius:36rpx}
|
.sheet-foot button::after{border:0}
|
.ghost{color:var(--color-primary, #1769d8);background:#f2f6fc;border:1rpx solid #cfe0f7}
|
.primary{color:#fff;background:var(--color-primary, #1769d8)}
|
</style>
|