zhangwencui
10 天以前 dab35bff798cfce2ddf7e8a67e4e1cdb144f5b4c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
<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>