hookehuyr

feat(person-picker): 新增人员筛选组件并完善开发配置

新增完整的PersonPickerField人员筛选组件,支持搜索、多选、类型筛选及黑名单展示
封装搜索人员的后端API接口
调整开发环境默认反向代理目标地址至开发服务器
在首页添加临时测试字段方便联调测试
1 -###
2 - # @Date: 2023-02-13 14:56:34
3 - # @LastEditors: hookehuyr hookehuyr@gmail.com
4 - # @LastEditTime: 2025-11-27 16:16:59
5 - # @FilePath: /data-table/.env.development
6 - # @Description: 文件描述
7 -###
8 # 资源公共路径 1 # 资源公共路径
9 VITE_BASE = / 2 VITE_BASE = /
10 3
...@@ -23,11 +16,11 @@ VITE_PIN = ...@@ -23,11 +16,11 @@ VITE_PIN =
23 16
24 # 反向代理服务器地址 17 # 反向代理服务器地址
25 # VITE_PROXY_TARGET = https://oa.anxinchashi.com/ 18 # VITE_PROXY_TARGET = https://oa.anxinchashi.com/
26 -# VITE_PROXY_TARGET = http://oa-dev.onwall.cn 19 +VITE_PROXY_TARGET = http://oa-dev.onwall.cn
27 # VITE_PROXY_TARGET = http://oa.onwall.cn 20 # VITE_PROXY_TARGET = http://oa.onwall.cn
28 # VITE_PROXY_TARGET = https://www.wxgzjs.cn/ 21 # VITE_PROXY_TARGET = https://www.wxgzjs.cn/
29 # VITE_PROXY_TARGET = https://oa.baorongsi.com/ 22 # VITE_PROXY_TARGET = https://oa.baorongsi.com/
30 -VITE_PROXY_TARGET = https://oa.jcedu.org/ 23 +# VITE_PROXY_TARGET = https://oa.jcedu.org/
31 24
32 # PC端地址 25 # PC端地址
33 VITE_MOBILE_URL = http://localhost:5173/ 26 VITE_MOBILE_URL = http://localhost:5173/
......
1 /* 1 /*
2 * @Date: 2022-06-17 14:54:29 2 * @Date: 2022-06-17 14:54:29
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2024-06-05 10:12:06 4 + * @LastEditTime: 2026-05-25 13:48:13
5 * @FilePath: /data-table/src/api/component.js 5 * @FilePath: /data-table/src/api/component.js
6 * @Description: 组件接口 6 * @Description: 组件接口
7 */ 7 */
...@@ -12,6 +12,7 @@ const Api = { ...@@ -12,6 +12,7 @@ const Api = {
12 FLOW_DEPT_LIST: '/srv/?a=flow_setting&t=flow_dept_list', 12 FLOW_DEPT_LIST: '/srv/?a=flow_setting&t=flow_dept_list',
13 FLOW_ROLE_LIST: '/srv/?a=flow_setting&t=flow_role_list', 13 FLOW_ROLE_LIST: '/srv/?a=flow_setting&t=flow_role_list',
14 SEARCH_USER_DEPT_ROLE: '/srv/?a=flow_setting&t=search_user_dept_role', 14 SEARCH_USER_DEPT_ROLE: '/srv/?a=flow_setting&t=search_user_dept_role',
15 + SEARCH_PERSON_PICKER: '/srv/?a=person_picker_search',
15 } 16 }
16 17
17 /** 18 /**
...@@ -42,3 +43,14 @@ export const getFlowRoleListAPI = (params) => fn(fetch.get(Api.FLOW_ROLE_LIST, p ...@@ -42,3 +43,14 @@ export const getFlowRoleListAPI = (params) => fn(fetch.get(Api.FLOW_ROLE_LIST, p
42 * @param: word 搜索内容 43 * @param: word 搜索内容
43 */ 44 */
44 export const searchUserDeptRoleAPI = (params) => fn(fetch.get(Api.SEARCH_USER_DEPT_ROLE, params)); 45 export const searchUserDeptRoleAPI = (params) => fn(fetch.get(Api.SEARCH_USER_DEPT_ROLE, params));
46 +
47 +/**
48 + * @description: 搜索人员
49 + * @param: form_code 表单code
50 + * @param: field_name 字段名
51 + * @param: keyword 搜索关键字
52 + * @param: type volunteer=义工、contact=联系人
53 + * @param: page 页码,从0开始
54 + * @param: limit 每页数量
55 + */
56 +export const searchPersonPickerAPI = (params) => fn(fetch.get(Api.SEARCH_PERSON_PICKER, params));
......
1 +<!--
2 + * @Date: 2026-05-25 17:10:00
3 + * @LastEditors: hookehuyr hookehuyr@gmail.com
4 + * @LastEditTime: 2026-05-25 15:27:25
5 + * @FilePath: /data-table/src/components/PersonPickerField/MyComponent.vue
6 + * @Description: 人员筛选控件内部面板
7 +-->
8 +<template>
9 + <div class="person-picker-field-inner">
10 + <div class="select-person-box" @click="openPopup">
11 + <template v-if="confirmedPersons.length">
12 + <div
13 + v-for="person in confirmedPersons"
14 + :key="person.id"
15 + :class="['select-person-item', { 'select-person-item--blacklist': isPersonBlacklisted(person) }]"
16 + >
17 + <van-icon name="contact-o" />&nbsp;
18 + <div class="person-display-text">
19 + <span>{{ formatPersonTitle(person) }}</span>
20 + <span v-if="isPersonBlacklisted(person)" class="blacklist-badge">黑名单</span>
21 + </div>
22 + </div>
23 + </template>
24 + <div v-else class="select-person-placeholder">
25 + {{ props.component_props.readonly ? '暂无已选人员' : '点击搜索人员' }}
26 + </div>
27 + </div>
28 +
29 + <van-popup
30 + v-model:show="showPopup"
31 + position="bottom"
32 + :close-on-click-overlay="false"
33 + :style="{ height: '90vh' }"
34 + >
35 + <div class="search-bar">
36 + <div class="search-input-row">
37 + <van-field
38 + ref="searchInputRef"
39 + v-model="searchKeyword"
40 + class="search-input-field"
41 + placeholder="请输入姓名、法名、手机号或证件号"
42 + :border="false"
43 + @keyup.enter="onSearch"
44 + >
45 + <template #button>
46 + <div class="search-bar-actions">
47 + <van-button size="small" type="primary" :loading="searching" @click="onSearch">搜索</van-button>
48 + <van-button size="small" plain type="default" @click="onClearSearch">清空</van-button>
49 + </div>
50 + </template>
51 + </van-field>
52 + </div>
53 + <div v-if="typeFilterOptions.length > 1" class="type-filter-row">
54 + <div
55 + v-for="option in typeFilterOptions"
56 + :key="option.value || 'all'"
57 + :class="['type-filter-chip', { 'type-filter-chip--active': selectedTypeFilter === option.value }]"
58 + @click="onSelectSearchType(option)"
59 + >
60 + {{ option.text }}
61 + </div>
62 + </div>
63 + </div>
64 +
65 + <div class="selected-box" style="margin: 1rem;">
66 + <div v-for="person in draftPersons" :key="person.id" class="selected-item">
67 + <div class="selected-item__text">
68 + <span>{{ formatPersonTitle(person) }}</span>
69 + <span v-if="isPersonBlacklisted(person)" class="blacklist-badge blacklist-badge--light">黑名单</span>
70 + </div>
71 + <van-icon name="close" @click="removeDraftPerson(person)" />
72 + </div>
73 + </div>
74 +
75 + <div v-if="hasSearched" class="search-result-container">
76 + <van-list
77 + v-model:loading="loading"
78 + :finished="finished"
79 + :finished-text="finishedTextStatus ? '没有更多了' : ''"
80 + @load="onLoad"
81 + >
82 + <van-checkbox-group v-model="searchResultChecked" @change="onSearchResultChange">
83 + <div v-for="person in searchResults" :key="person.id" class="search-result-item">
84 + <van-checkbox
85 + :name="person.id"
86 + shape="square"
87 + icon-size="14px"
88 + :checked-color="styleColor.baseColor"
89 + >
90 + <div class="search-result-item__content">
91 + <div class="search-result-item__title">
92 + <span>{{ person.name }}</span>
93 + <span v-if="person.nickname"> / {{ person.nickname }}</span>
94 + <span v-if="isPersonBlacklisted(person)" class="blacklist-badge">黑名单</span>
95 + </div>
96 + <div class="search-result-item__meta">
97 + <span v-if="person.phone">{{ person.phone }}</span>
98 + <span v-if="person.idcard">{{ person.phone ? ' / ' : '' }}{{ person.idcard }}</span>
99 + </div>
100 + <div class="search-result-item__type">{{ formatPersonType(person.type) }}</div>
101 + </div>
102 + </van-checkbox>
103 + </div>
104 + </van-checkbox-group>
105 + </van-list>
106 +
107 + <div v-if="emptyStatus" class="search-result-empty">暂无搜索结果</div>
108 + </div>
109 +
110 + <div class="popup-footer">
111 + <van-row gutter="10" style="padding: 0 10px 1rem;">
112 + <van-col span="12">
113 + <van-button block round type="default" @click="onCancelClick">取消</van-button>
114 + </van-col>
115 + <van-col span="12">
116 + <van-button block round type="primary" @click="onConfirmClick">确定</van-button>
117 + </van-col>
118 + </van-row>
119 + </div>
120 + </van-popup>
121 + </div>
122 +</template>
123 +
124 +<script setup>
125 +import { inject, ref } from 'vue';
126 +import { useRoute } from 'vue-router';
127 +import { useCustomFieldValue } from '@vant/use';
128 +import Cookies from 'js-cookie';
129 +import _ from 'lodash';
130 +import { showToast } from 'vant';
131 +import { styleColor } from '@/constant.js';
132 +import { searchPersonPickerAPI } from '@/api/component.js';
133 +
134 +const props = inject('props');
135 +const $route = useRoute();
136 +
137 +const showPopup = ref(false);
138 +const searching = ref(false);
139 +const hasSearched = ref(false);
140 +const loading = ref(false);
141 +const finished = ref(false);
142 +const finishedTextStatus = ref(true);
143 +const emptyStatus = ref(false);
144 +const searchInputRef = ref(null);
145 +const searchKeyword = ref('');
146 +const searchResults = ref([]);
147 +const searchResultChecked = ref([]);
148 +const confirmedPersons = ref([]);
149 +const draftPersons = ref([]);
150 +const fieldValue = ref([]);
151 +const searchPage = ref(0);
152 +const searchLimit = ref(Number(props.component_props.limit) || 20);
153 +const searchTotal = ref(0);
154 +const selectedTypeFilter = ref('');
155 +
156 +/**
157 + * 将人员接口记录整理成组件内部统一结构。
158 + * 这里直接按后端约定字段读取,只补齐展示时会用到的空字符串。
159 + *
160 + * @param {Object} person 人员记录
161 + * @returns {{ id: number, name: string, nickname: string, phone: string, idcard: string, type: string, is_blacklist: boolean }}
162 + */
163 +const mapPersonRecord = (person) => {
164 + return {
165 + id: Number(person.id),
166 + name: person.name || '',
167 + nickname: person.nickname || '',
168 + phone: person.phone || '',
169 + idcard: person.idcard || '',
170 + type: person.type || '',
171 + is_blacklist: normalizeBlacklistFlag(person.is_blacklist),
172 + };
173 +};
174 +
175 +/**
176 + * 兼容接口和历史缓存里可能出现的布尔、数字、字符串黑名单标识。
177 + *
178 + * @param {boolean|number|string} value 黑名单标识
179 + * @returns {boolean}
180 + */
181 +const normalizeBlacklistFlag = (value) => {
182 + if (typeof value === 'string') {
183 + const normalizedValue = value.trim().toLowerCase();
184 + return normalizedValue === 'true' || normalizedValue === '1';
185 + }
186 +
187 + return value === true || value === 1;
188 +};
189 +
190 +/**
191 + * 组件默认值可能来自历史数据或 cookie,这里保留最小限度的字符串反序列化。
192 + *
193 + * @param {Array|String} value 已选人员列表
194 + * @returns {Array}
195 + */
196 +const normalizeStoredPersonList = (value) => {
197 + const source = typeof value === 'string' ? JSON.parse(value || '[]') : (value || []);
198 +
199 + return source.map(mapPersonRecord);
200 +};
201 +
202 +/**
203 + * 后端现在明确返回 person_types 数组,这里只做去重和空值过滤。
204 + *
205 + * @param {string[]} personTypes 人员类型数组
206 + * @returns {string[]}
207 + */
208 +const normalizePersonTypes = (personTypes = []) => {
209 + return _.uniq(personTypes
210 + .map((item) => String(item || '').trim())
211 + .filter(Boolean));
212 +};
213 +
214 +/**
215 + * 将已确认人员同步到自定义字段值,保证表单提交拿到的是最终确认结果。
216 + */
217 +const syncFieldValue = () => {
218 + fieldValue.value = _.cloneDeep(confirmedPersons.value);
219 + props.value = fieldValue.value;
220 +};
221 +
222 +const formatPersonType = (type) => {
223 + return type || '人员';
224 +};
225 +
226 +const formatPersonTitle = (person) => {
227 + if (!person.nickname) {
228 + return `${person.name} / ${formatPersonType(person.type)}`;
229 + }
230 + return `${person.name} / ${person.nickname} / ${formatPersonType(person.type)}`;
231 +};
232 +
233 +const isPersonBlacklisted = (person) => {
234 + return Boolean(person?.is_blacklist);
235 +};
236 +
237 +/**
238 + * 当前字段允许搜索的人员类型列表,直接取后端配置。
239 + */
240 +const allowedPersonTypes = computed(() => {
241 + return normalizePersonTypes(props.component_props.person_types);
242 +});
243 +
244 +/**
245 + * 当只允许一种人员类型时,默认锁定这个筛选值。
246 + */
247 +const defaultSearchType = computed(() => {
248 + return allowedPersonTypes.value.length === 1 ? allowedPersonTypes.value[0] : '';
249 +});
250 +
251 +/**
252 + * 根据后端返回的 person_types 生成顶部筛选项。
253 + */
254 +const typeFilterOptions = computed(() => {
255 + if (!allowedPersonTypes.value.length) {
256 + return [];
257 + }
258 +
259 + const allowedOptions = allowedPersonTypes.value.map((type) => ({
260 + text: type,
261 + value: type,
262 + }));
263 +
264 + if (allowedOptions.length === 1) {
265 + return allowedOptions;
266 + }
267 +
268 + return [{ text: '全部类型', value: '' }, ...allowedOptions];
269 +});
270 +
271 +const getSearchType = () => {
272 + return defaultSearchType.value || selectedTypeFilter.value || '';
273 +};
274 +
275 +/**
276 + * 将结果列表限制在字段允许的类型范围内,同时叠加当前顶部筛选值。
277 + *
278 + * @param {Array} personList 人员结果列表
279 + * @returns {Array}
280 + */
281 +const filterPersonListByType = (personList) => {
282 + const searchType = getSearchType();
283 +
284 + return personList.filter((person) => {
285 + if (allowedPersonTypes.value.length && !allowedPersonTypes.value.includes(person.type)) {
286 + return false;
287 + }
288 +
289 + if (searchType && person.type !== searchType) {
290 + return false;
291 + }
292 +
293 + return true;
294 + });
295 +};
296 +
297 +/**
298 + * 根据当前草稿已选项回填搜索结果勾选状态,保证翻页后勾选表现一致。
299 + */
300 +const syncSearchChecked = () => {
301 + const selectedIdSet = new Set(draftPersons.value.map((person) => person.id));
302 + searchResultChecked.value = searchResults.value
303 + .filter((person) => selectedIdSet.has(person.id))
304 + .map((person) => person.id);
305 +};
306 +
307 +/**
308 + * 重置搜索面板状态;打开弹层、取消和清空搜索时都会复用这套逻辑。
309 + */
310 +const resetSearchState = () => {
311 + hasSearched.value = false;
312 + searching.value = false;
313 + loading.value = false;
314 + finished.value = false;
315 + finishedTextStatus.value = true;
316 + emptyStatus.value = false;
317 + searchKeyword.value = '';
318 + searchResults.value = [];
319 + searchResultChecked.value = [];
320 + searchPage.value = 0;
321 + searchTotal.value = 0;
322 +};
323 +
324 +const openPopup = async () => {
325 + if (props.component_props.readonly) {
326 + return false;
327 + }
328 +
329 + draftPersons.value = _.cloneDeep(confirmedPersons.value);
330 + resetSearchState();
331 + showPopup.value = true;
332 +};
333 +
334 +const onCancelClick = () => {
335 + showPopup.value = false;
336 + draftPersons.value = _.cloneDeep(confirmedPersons.value);
337 + resetSearchState();
338 +};
339 +
340 +const writeDraftCookie = () => {
341 + const currentValue = _.cloneDeep(fieldValue.value);
342 + const existingCookie = Cookies.get($route.query.code);
343 +
344 + if (existingCookie) {
345 + const obj = JSON.parse(existingCookie);
346 + obj[props.key] = currentValue;
347 + Cookies.set($route.query.code, JSON.stringify(obj), { expires: 1 });
348 + } else {
349 + Cookies.set($route.query.code, JSON.stringify({ [props.key]: currentValue }), { expires: 1 });
350 + }
351 +};
352 +
353 +const onConfirmClick = () => {
354 + confirmedPersons.value = _.cloneDeep(draftPersons.value);
355 + syncFieldValue();
356 + writeDraftCookie();
357 + showPopup.value = false;
358 + resetSearchState();
359 +};
360 +
361 +const removeDraftPerson = (person) => {
362 + draftPersons.value = draftPersons.value.filter((item) => item.id !== person.id);
363 + syncSearchChecked();
364 +};
365 +
366 +/**
367 + * 勾选变化时只处理当前搜索结果页,避免跨页已选项被误删。
368 + *
369 + * @param {number[]} checkedIds 当前结果页勾选的人员 id
370 + */
371 +const onSearchResultChange = (checkedIds) => {
372 + const checkedIdSet = new Set(checkedIds);
373 + const currentSearchIdSet = new Set(searchResults.value.map((person) => person.id));
374 +
375 + // 先移除当前搜索页中被取消勾选的人员,避免跨页已选项被误删。
376 + draftPersons.value = draftPersons.value.filter((person) => {
377 + if (!currentSearchIdSet.has(person.id)) {
378 + return true;
379 + }
380 + return checkedIdSet.has(person.id);
381 + });
382 +
383 + const selectedFromCurrentSearch = searchResults.value.filter((person) => checkedIdSet.has(person.id));
384 + draftPersons.value = _.uniqBy([...draftPersons.value, ...selectedFromCurrentSearch], 'id');
385 +};
386 +
387 +const onClearSearch = () => {
388 + resetSearchState();
389 +};
390 +
391 +const onSelectSearchType = async (action) => {
392 + selectedTypeFilter.value = action.value;
393 +
394 + if (searchKeyword.value.trim() && hasSearched.value) {
395 + await onSearch();
396 + }
397 +};
398 +
399 +/**
400 + * 组装人员搜索接口参数。
401 + *
402 + * @param {number} page 页码,从 0 开始
403 + * @returns {{ form_code: string, field_name: string, keyword: string, page: number, limit: number, type?: string, force_back?: string }}
404 + */
405 +const buildSearchParams = (page) => {
406 + const params = {
407 + form_code: $route.query.code,
408 + field_name: props.key,
409 + keyword: searchKeyword.value.trim(),
410 + page,
411 + limit: searchLimit.value,
412 + };
413 +
414 + const searchType = getSearchType();
415 + if (searchType) {
416 + params.type = searchType;
417 + }
418 + if ($route.query.force_back) {
419 + params.force_back = $route.query.force_back;
420 + }
421 +
422 + return params;
423 +};
424 +
425 +/**
426 + * 拉取单页搜索结果,并转换成组件内部统一结构。
427 + * 组件接口封装在报错时会返回 false,这里需要兜底,避免搜索面板直接抛运行时异常。
428 + *
429 + * @param {number} page 页码,从 0 开始
430 + * @returns {Promise<{ count: number, data: Array }>}
431 + */
432 +const fetchSearchPage = async (page) => {
433 + const result = await searchPersonPickerAPI(buildSearchParams(page));
434 + if (!result || !Array.isArray(result.data)) {
435 + return {
436 + count: 0,
437 + data: [],
438 + };
439 + }
440 +
441 + const remoteResults = filterPersonListByType(result.data.map(mapPersonRecord));
442 +
443 + return {
444 + count: Number(result.count) || remoteResults.length,
445 + data: remoteResults,
446 + };
447 +};
448 +
449 +/**
450 + * 合并分页结果并去重,避免上拉加载时重复插入同一人员。
451 + *
452 + * @param {Array} data 当前页结果
453 + */
454 +const mergeSearchResults = (data) => {
455 + searchResults.value = _.uniqBy([...searchResults.value, ...data], 'id');
456 + emptyStatus.value = Object.is(searchResults.value.length, 0);
457 + finishedTextStatus.value = !emptyStatus.value;
458 + syncSearchChecked();
459 +};
460 +
461 +/**
462 + * 根据接口总数和当前页结果更新无限滚动结束状态。
463 + *
464 + * @param {Array} pageData 当前页结果
465 + * @param {number} total 接口返回总数
466 + */
467 +const updateFinishedStatus = (pageData, total) => {
468 + searchTotal.value = total;
469 +
470 + if (!pageData.length) {
471 + finished.value = true;
472 + return;
473 + }
474 +
475 + if (searchResults.value.length >= total) {
476 + finished.value = true;
477 + return;
478 + }
479 +
480 + if (pageData.length < searchLimit.value) {
481 + finished.value = true;
482 + }
483 +};
484 +
485 +/**
486 + * 执行一次新的搜索,并重置分页状态。
487 + */
488 +const onSearch = async () => {
489 + const keyword = searchKeyword.value.trim();
490 + if (!keyword) {
491 + showToast('请输入搜索关键词');
492 + return;
493 + }
494 +
495 + searching.value = true;
496 + hasSearched.value = true;
497 + loading.value = true;
498 + finished.value = false;
499 + finishedTextStatus.value = true;
500 + emptyStatus.value = false;
501 + searchResults.value = [];
502 + searchResultChecked.value = [];
503 + searchPage.value = 0;
504 + searchTotal.value = 0;
505 +
506 + try {
507 + const firstPageResult = await fetchSearchPage(0);
508 + mergeSearchResults(firstPageResult.data);
509 + updateFinishedStatus(firstPageResult.data, firstPageResult.count);
510 + } finally {
511 + loading.value = false;
512 + searching.value = false;
513 + }
514 +};
515 +
516 +/**
517 + * 加载下一页搜索结果。
518 + */
519 +const onLoad = async () => {
520 + if (!hasSearched.value || finished.value) {
521 + loading.value = false;
522 + return;
523 + }
524 +
525 + const nextPage = searchPage.value + 1;
526 +
527 + try {
528 + const nextPageResult = await fetchSearchPage(nextPage);
529 + mergeSearchResults(nextPageResult.data);
530 + updateFinishedStatus(nextPageResult.data, nextPageResult.count);
531 + searchPage.value = nextPage;
532 + } finally {
533 + loading.value = false;
534 + }
535 +};
536 +
537 +useCustomFieldValue(() => fieldValue.value);
538 +
539 +onMounted(() => {
540 + confirmedPersons.value = normalizeStoredPersonList(props.component_props.default);
541 + draftPersons.value = _.cloneDeep(confirmedPersons.value);
542 + selectedTypeFilter.value = defaultSearchType.value || '';
543 + syncFieldValue();
544 +});
545 +</script>
546 +
547 +<style lang="less" scoped>
548 +.person-picker-field-inner {
549 + width: 100%;
550 +}
551 +
552 +.select-person-box,
553 +.selected-box {
554 + min-height: 4rem;
555 + border: 1px dashed #dfdfdf;
556 + margin: 1rem 0;
557 + overflow: auto;
558 + border-radius: 5px;
559 + padding: 0.5rem;
560 + display: flex;
561 + flex-wrap: wrap;
562 + align-items: flex-start;
563 +}
564 +
565 +.select-person-item,
566 +.selected-item {
567 + margin-right: 5px;
568 + margin-bottom: 5px;
569 + font-size: 0.85rem;
570 + padding: 5px 8px;
571 + background-color: #c2915f;
572 + color: #fff;
573 + min-height: 1.2rem;
574 + display: flex;
575 + align-items: center;
576 + border-radius: 4px;
577 +}
578 +
579 +.selected-item {
580 + max-width: 100%;
581 +}
582 +
583 +.selected-item__text {
584 + display: flex;
585 + align-items: center;
586 + flex-wrap: wrap;
587 + gap: 0.35rem;
588 + margin-right: 0.25rem;
589 +}
590 +
591 +.select-person-item--blacklist {
592 + background-color: #a46943;
593 +}
594 +
595 +.person-display-text {
596 + display: flex;
597 + align-items: center;
598 + flex-wrap: wrap;
599 + gap: 0.35rem;
600 +}
601 +
602 +.select-person-placeholder {
603 + color: #999;
604 + font-size: 0.9rem;
605 + line-height: 1.8rem;
606 +}
607 +
608 +.search-bar {
609 + margin: 1rem 0;
610 + padding: 0;
611 +}
612 +
613 +.search-input-row {
614 + display: flex;
615 + align-items: center;
616 +}
617 +
618 +.search-input-field {
619 + width: 100%;
620 +}
621 +
622 +.search-bar-actions {
623 + display: flex;
624 + gap: 0.5rem;
625 +}
626 +
627 +.type-filter-row {
628 + display: flex;
629 + flex-wrap: wrap;
630 + gap: 0.5rem;
631 + margin-top: 0.35rem;
632 + padding-left: 0.75rem;
633 +}
634 +
635 +.type-filter-chip {
636 + padding: 0.15rem 0.1rem;
637 + color: #888;
638 + font-size: 0.82rem;
639 + line-height: 1.2rem;
640 +}
641 +
642 +.type-filter-chip--active {
643 + color: #c2915f;
644 + font-weight: 600;
645 +}
646 +
647 +.search-result-container {
648 + height: calc(90vh - 17rem);
649 + overflow: auto;
650 + padding: 0 1rem 6rem;
651 +}
652 +
653 +.search-result-item {
654 + padding: 0.75rem 0;
655 + border-bottom: 1px solid #f0f0f0;
656 +}
657 +
658 +.search-result-item__content {
659 + padding-left: 0.5rem;
660 + white-space: normal;
661 +}
662 +
663 +.search-result-item__title {
664 + font-size: 0.9rem;
665 + color: #333;
666 + line-height: 1.4;
667 + display: flex;
668 + align-items: center;
669 + flex-wrap: wrap;
670 + gap: 0.35rem;
671 +}
672 +
673 +.search-result-item__meta {
674 + margin-top: 0.25rem;
675 + font-size: 0.78rem;
676 + color: #888;
677 + word-break: break-all;
678 +}
679 +
680 +.search-result-item__type {
681 + margin-top: 0.25rem;
682 + font-size: 0.78rem;
683 + color: #c2915f;
684 +}
685 +
686 +.search-result-empty {
687 + padding: 1rem 0;
688 + text-align: center;
689 + color: #999;
690 + font-size: 0.9rem;
691 +}
692 +
693 +.blacklist-badge {
694 + display: inline-flex;
695 + align-items: center;
696 + justify-content: center;
697 + padding: 0 0.35rem;
698 + border-radius: 999px;
699 + font-size: 0.7rem;
700 + line-height: 1.35rem;
701 + color: #fff;
702 + background-color: #d84d4d;
703 +}
704 +
705 +.blacklist-badge--light {
706 + background-color: rgba(216, 77, 77, 0.16);
707 + color: #d84d4d;
708 +}
709 +
710 +.popup-footer {
711 + position: fixed;
712 + bottom: 0;
713 + left: 0;
714 + width: 100%;
715 + background: #fff;
716 +}
717 +
718 +:deep(.van-field__body) {
719 + border: var(--border-style);
720 + border-radius: 0.25rem;
721 + padding: 0.25rem 0.5rem;
722 +}
723 +</style>
1 +<!--
2 + * @Date: 2026-05-25 17:10:00
3 + * @LastEditors: Codex
4 + * @LastEditTime: 2026-05-25 17:10:00
5 + * @FilePath: /data-table/src/components/PersonPickerField/index.vue
6 + * @Description: 人员筛选控件
7 +-->
8 +<template>
9 + <div v-if="HideShow" class="person-picker-field-page">
10 + <div :class="[isGroup ? 'group-label' : 'label']">
11 + <span v-if="item.component_props.disabled_show"><van-icon name="https://cdn.ipadbiz.cn/custom_form/icon/closed-eye1.png" /></span>
12 + <span v-if="item.component_props.required" style="color: red">&nbsp;*</span>
13 + <span :class="[ReadonlyShow ? 'readonly-show' : '']">{{ item.component_props.label }}</span>
14 + </div>
15 +
16 + <van-field :name="item.key" :rules="rules" style="padding: 0 1rem;">
17 + <template #input>
18 + <my-component />
19 + </template>
20 + </van-field>
21 + </div>
22 +</template>
23 +
24 +<script setup>
25 +import { useRoute } from 'vue-router';
26 +import MyComponent from './MyComponent.vue';
27 +
28 +const $route = useRoute();
29 +const props = defineProps({
30 + item: Object,
31 +});
32 +
33 +// 注入子组件属性,复用现有字段组件的表单接入方式。
34 +provide('props', props.item);
35 +
36 +// 隐藏显示
37 +const HideShow = computed(() => {
38 + return !props.item.component_props.disabled;
39 +});
40 +
41 +// 只读显示-流程模式
42 +const ReadonlyShow = computed(() => {
43 + return ($route.query.page_type === 'flow' || $route.query.page_type === 'edit') && !props.item.component_props.readonly;
44 +});
45 +
46 +// 集合组标识
47 +const isGroup = computed(() => {
48 + return props.item.component_props.is_field_group;
49 +});
50 +
51 +const required = props.item.component_props.required;
52 +const validator = (val) => {
53 + if (!required) {
54 + return true;
55 + }
56 + return Array.isArray(val) && val.length > 0;
57 +};
58 +const validatorMessage = (val, rule) => {
59 + if (required && (!Array.isArray(val) || !val.length)) {
60 + return '选择不能为空';
61 + }
62 +};
63 +const rules = [{ validator, message: validatorMessage }];
64 +</script>
65 +
66 +<style lang="less" scoped>
67 +.person-picker-field-page {
68 + .label {
69 + padding: 1rem 1rem 0 1rem;
70 + font-size: 0.9rem;
71 + font-weight: bold;
72 + }
73 +
74 + .group-label {
75 + padding: 0.75rem 0 0.75rem 1rem;
76 + font-size: 0.9rem;
77 + font-weight: bold;
78 + background-color: #f9f9f9;
79 + color: #666;
80 + border-top: 1px solid #eee;
81 + border-bottom: 1px solid #eee;
82 +
83 + span {
84 + color: red;
85 + }
86 + }
87 +}
88 +</style>
...@@ -33,6 +33,7 @@ import AppointmentField from '@/components/AppointmentField/index.vue'; ...@@ -33,6 +33,7 @@ import AppointmentField from '@/components/AppointmentField/index.vue';
33 import CustomField from '@/components/CustomField/index.vue'; 33 import CustomField from '@/components/CustomField/index.vue';
34 import GroupField from '@/components/GroupField/index.vue'; 34 import GroupField from '@/components/GroupField/index.vue';
35 import OrgPickerField from '@/components/OrgPickerField/index.vue'; 35 import OrgPickerField from '@/components/OrgPickerField/index.vue';
36 +import PersonPickerField from '@/components/PersonPickerField/index.vue';
36 import VolunteerGroupField from '@/components/VolunteerGroupField/index.vue'; 37 import VolunteerGroupField from '@/components/VolunteerGroupField/index.vue';
37 38
38 /** 39 /**
...@@ -66,6 +67,7 @@ import VolunteerGroupField from '@/components/VolunteerGroupField/index.vue'; ...@@ -66,6 +67,7 @@ import VolunteerGroupField from '@/components/VolunteerGroupField/index.vue';
66 * @type appointment 预约控件 AppointmentField 67 * @type appointment 预约控件 AppointmentField
67 * @type group 组集合输入控件 GroupField 68 * @type group 组集合输入控件 GroupField
68 * @type org_picker 树形选择控件 OrgPickerField 69 * @type org_picker 树形选择控件 OrgPickerField
70 + * @type person_picker 人员筛选控件 PersonPickerField
69 * @type volunteer_group 义工组别选择控件 VolunteerGroupField 71 * @type volunteer_group 义工组别选择控件 VolunteerGroupField
70 * @type table 表格控件 TableField 72 * @type table 表格控件 TableField
71 */ 73 */
...@@ -183,6 +185,9 @@ export function createComponentType(data) { ...@@ -183,6 +185,9 @@ export function createComponentType(data) {
183 if (item.component_props.tag === 'org_picker') { 185 if (item.component_props.tag === 'org_picker') {
184 item.component = OrgPickerField; 186 item.component = OrgPickerField;
185 } 187 }
188 + if (item.component_props.tag === 'person_picker') {
189 + item.component = PersonPickerField;
190 + }
186 if (item.component_props.tag === 'volunteer_group') { 191 if (item.component_props.tag === 'volunteer_group') {
187 item.component = VolunteerGroupField; 192 item.component = VolunteerGroupField;
188 } 193 }
......
1 <!-- 1 <!--
2 * @Date: 2022-07-18 10:22:22 2 * @Date: 2022-07-18 10:22:22
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-11-27 16:25:10 4 + * @LastEditTime: 2026-05-25 13:58:52
5 * @FilePath: /data-table/src/views/index.vue 5 * @FilePath: /data-table/src/views/index.vue
6 * @Description: 首页 6 * @Description: 首页
7 --> 7 -->
...@@ -509,6 +509,25 @@ onMounted(async () => { ...@@ -509,6 +509,25 @@ onMounted(async () => {
509 // "interaction_type": "h5edit" 509 // "interaction_type": "h5edit"
510 // }) 510 // })
511 511
512 + // 临时测试字段:直接挂到页面渲染链里,方便联调人员搜索多选组件。
513 + page_form.push({
514 + tag: 'person_picker',
515 + name: 'person_picker_temp',
516 + index: 999999,
517 + label: '人员筛选(临时测试)',
518 + unique: false,
519 + default: [],
520 + disabled: false,
521 + field_id: 999999,
522 + readonly: false,
523 + required: false,
524 + data_type: 'text',
525 + field_name: 'field_person_picker_temp',
526 + placeholder: '请输入关键词后搜索人员',
527 + interaction_type: 'h5edit',
528 + person_type: '',
529 + });
530 +
512 formData.value = formatData(page_form); 531 formData.value = formatData(page_form);
513 // TAG: 构建分页组件 532 // TAG: 构建分页组件
514 if (page_type === 'add' || page_type === undefined || model === 'preview') { 533 if (page_type === 'add' || page_type === undefined || model === 'preview') {
......