index.vue 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. <template>
  2. <view>
  3. <uv-popup ref="popupRef" mode="bottom" round="24rpx" @change="onPopupChange">
  4. <view class="dept-select-container">
  5. <!-- Header -->
  6. <view class="header">
  7. <view class="title">选择组织部门</view>
  8. <uv-icon name="close" color="#94a3b8" size="20" @click="close"></uv-icon>
  9. </view>
  10. <!-- Breadcrumbs (Hierarchy Path) -->
  11. <view class="breadcrumbs" v-if="path.length > 0">
  12. <scroll-view scroll-x class="breadcrumb-scroll" :show-scrollbar="false">
  13. <view class="breadcrumb-inner">
  14. <view class="breadcrumb-item" @click="goHome">
  15. <text class="breadcrumb-text">全部</text>
  16. <uv-icon name="arrow-right" size="12" color="#cbd5e1" customStyle="margin: 0 8rpx;"></uv-icon>
  17. </view>
  18. <view v-for="(node, index) in path" :key="node.id" class="breadcrumb-item" @click="goToPath(index)">
  19. <text class="breadcrumb-text" :class="{'active': index === path.length - 1}">{{ node.deptName }}</text>
  20. <uv-icon v-if="index < path.length - 1" name="arrow-right" size="12" color="#cbd5e1" customStyle="margin: 0 8rpx;"></uv-icon>
  21. </view>
  22. </view>
  23. </scroll-view>
  24. </view>
  25. <!-- Search Bar -->
  26. <view class="search-box">
  27. <uv-search v-model="keyword" placeholder="搜索部门名称" :showAction="false" @change="onSearch" @clear="onSearch"></uv-search>
  28. </view>
  29. <!-- Content Area -->
  30. <scroll-view scroll-y class="list-container">
  31. <view v-if="isSearching" class="search-results">
  32. <view v-for="item in searchResults" :key="item.id" class="node-item" @click="handleSelect(item)">
  33. <view class="node-content">
  34. <view class="node-name-group">
  35. <text class="node-name">{{ item.deptName }}</text>
  36. <text class="node-path" v-if="item.fullPath">{{ item.fullPath }}</text>
  37. </view>
  38. <view class="node-action">
  39. <uv-icon v-if="!item.hasChildren" name="checkbox-mark" :color="modelValue === item.id ? '#3b82f6' : '#e2e8f0'" size="20"></uv-icon>
  40. <text v-else class="dir-tip">进入</text>
  41. </view>
  42. </view>
  43. </view>
  44. <view v-if="searchResults.length === 0" class="empty-tip">未找到匹配的部门</view>
  45. </view>
  46. <view v-else class="level-list">
  47. <view v-for="node in currentList" :key="node.id" class="node-item" @click="handleClick(node)">
  48. <view class="node-content">
  49. <view class="node-info">
  50. <uv-icon :name="node.children && node.children.length > 0 ? 'folder' : 'order'"
  51. :color="node.children && node.children.length > 0 ? '#60a5fa' : '#94a3b8'" size="20" style="margin-right: 20rpx;"></uv-icon>
  52. <text class="node-name" :class="{'selected': modelValue === node.id}">{{ node.deptName }}</text>
  53. </view>
  54. <view class="node-action">
  55. <template v-if="node.children && node.children.length > 0">
  56. <text class="child-count">{{ node.children.length }}</text>
  57. <uv-icon name="arrow-right" color="#cbd5e1" size="16"></uv-icon>
  58. </template>
  59. <template v-else>
  60. <uv-icon name="checkbox-mark" :color="modelValue === node.id ? '#3b82f6' : '#e2e8f0'" size="20"></uv-icon>
  61. </template>
  62. </view>
  63. </view>
  64. </view>
  65. </view>
  66. </scroll-view>
  67. <!-- Footer / Tip -->
  68. <view class="footer-tip" v-if="!isSearching">
  69. <uv-icon name="info-circle" color="#3b82f6" size="14" customStyle="margin-right: 8rpx;"></uv-icon>
  70. <text>请选择最末级科室</text>
  71. </view>
  72. </view>
  73. </uv-popup>
  74. </view>
  75. </template>
  76. <script setup lang="ts">
  77. import { ref, watch, computed } from 'vue';
  78. const props = defineProps({
  79. modelValue: [Number, String],
  80. treeData: {
  81. type: Array as any,
  82. default: () => []
  83. }
  84. });
  85. const emit = defineEmits(['update:modelValue', 'select']);
  86. const popupRef = ref();
  87. const keyword = ref('');
  88. const isSearching = computed(() => keyword.value.trim().length > 0);
  89. const path = ref<any[]>([]);
  90. const currentList = ref<any[]>([]);
  91. // Initialize currentList when treeData arrives
  92. watch(() => props.treeData, (newVal) => {
  93. if (newVal && newVal.length > 0 && path.value.length === 0) {
  94. currentList.value = newVal;
  95. }
  96. }, { immediate: true });
  97. const searchResults = ref<any[]>([]);
  98. const onSearch = () => {
  99. if (!keyword.value.trim()) {
  100. searchResults.value = [];
  101. return;
  102. }
  103. const results: any[] = [];
  104. const walk = (nodes: any[], parentPath: string = '') => {
  105. for (const node of nodes) {
  106. const currentPath = parentPath ? `${parentPath} / ${node.deptName}` : node.deptName;
  107. if (node.deptName.includes(keyword.value)) {
  108. results.push({
  109. ...node,
  110. fullPath: parentPath,
  111. hasChildren: node.children && node.children.length > 0
  112. });
  113. }
  114. if (node.children) walk(node.children, currentPath);
  115. }
  116. };
  117. walk(props.treeData);
  118. searchResults.value = results;
  119. };
  120. const open = () => {
  121. popupRef.value.open();
  122. };
  123. const close = () => {
  124. popupRef.value.close();
  125. };
  126. const onPopupChange = (e: any) => {
  127. if (!e.show) {
  128. // Reset path when closing if desired, or keep state
  129. }
  130. };
  131. const handleClick = (node: any) => {
  132. if (node.children && node.children.length > 0) {
  133. path.value.push(node);
  134. currentList.value = node.children;
  135. } else {
  136. handleSelect(node);
  137. }
  138. };
  139. const handleSelect = (node: any) => {
  140. if (node.children && node.children.length > 0) {
  141. // Drill down instead of select
  142. path.value = findPathToNode(props.treeData, node.id) || [];
  143. currentList.value = node.children;
  144. keyword.value = '';
  145. return;
  146. }
  147. emit('update:modelValue', node.id);
  148. emit('select', node);
  149. close();
  150. };
  151. const findPathToNode = (nodes: any[], id: number, currentPath: any[] = []): any[] | null => {
  152. for (const node of nodes) {
  153. if (node.id === id) return [...currentPath, node];
  154. if (node.children) {
  155. const res = findPathToNode(node.children, id, [...currentPath, node]);
  156. if (res) return res;
  157. }
  158. }
  159. return null;
  160. };
  161. const goToPath = (index: number) => {
  162. const targetNode = path.value[index];
  163. path.value = path.value.slice(0, index + 1);
  164. currentList.value = targetNode.children;
  165. };
  166. const goHome = () => {
  167. path.value = [];
  168. currentList.value = props.treeData;
  169. };
  170. defineExpose({ open, close });
  171. </script>
  172. <style lang="scss" scoped>
  173. .dept-select-container {
  174. background-color: #ffffff;
  175. height: 80vh;
  176. display: flex;
  177. flex-direction: column;
  178. border-radius: 24rpx 24rpx 0 0;
  179. }
  180. .header {
  181. padding: 30rpx 40rpx;
  182. display: flex;
  183. justify-content: space-between;
  184. align-items: center;
  185. border-bottom: 2rpx solid #f1f5f9;
  186. }
  187. .title {
  188. font-size: 34rpx;
  189. font-weight: 600;
  190. color: #1e293b;
  191. }
  192. .breadcrumbs {
  193. padding: 20rpx 40rpx;
  194. background-color: #f8fafc;
  195. border-bottom: 2rpx solid #f1f5f9;
  196. }
  197. .breadcrumb-scroll {
  198. width: 100%;
  199. }
  200. .breadcrumb-inner {
  201. display: flex;
  202. align-items: center;
  203. white-space: nowrap;
  204. }
  205. .breadcrumb-item {
  206. display: flex;
  207. align-items: center;
  208. }
  209. .breadcrumb-text {
  210. font-size: 26rpx;
  211. color: #64748b;
  212. &.active {
  213. color: #3b82f6;
  214. font-weight: 500;
  215. }
  216. }
  217. .search-box {
  218. padding: 20rpx 40rpx;
  219. }
  220. .list-container {
  221. flex: 1;
  222. height: 0;
  223. }
  224. .node-item {
  225. padding: 0 40rpx;
  226. &:active {
  227. background-color: #f8fafc;
  228. }
  229. }
  230. .node-content {
  231. padding: 30rpx 0;
  232. display: flex;
  233. justify-content: space-between;
  234. align-items: center;
  235. border-bottom: 2rpx solid #f1f5f9;
  236. }
  237. .node-info {
  238. display: flex;
  239. align-items: center;
  240. flex: 1;
  241. }
  242. .node-name {
  243. font-size: 30rpx;
  244. color: #334155;
  245. &.selected {
  246. color: #3b82f6;
  247. font-weight: 600;
  248. }
  249. }
  250. .node-action {
  251. display: flex;
  252. align-items: center;
  253. }
  254. .child-count {
  255. font-size: 24rpx;
  256. color: #94a3b8;
  257. margin-right: 12rpx;
  258. }
  259. .dir-tip {
  260. font-size: 24rpx;
  261. color: #3b82f6;
  262. background: #eff6ff;
  263. padding: 4rpx 16rpx;
  264. border-radius: 20rpx;
  265. }
  266. .search-results {
  267. .node-name-group {
  268. display: flex;
  269. flex-direction: column;
  270. }
  271. .node-path {
  272. font-size: 22rpx;
  273. color: #94a3b8;
  274. margin-top: 4rpx;
  275. }
  276. }
  277. .empty-tip {
  278. padding: 100rpx 0;
  279. text-align: center;
  280. color: #94a3b8;
  281. font-size: 28rpx;
  282. }
  283. .footer-tip {
  284. padding: 20rpx 40rpx calc(20rpx + env(safe-area-inset-bottom));
  285. background-color: #f8fafc;
  286. display: flex;
  287. align-items: center;
  288. font-size: 24rpx;
  289. color: #64748b;
  290. border-top: 2rpx solid #f1f5f9;
  291. }
  292. </style>