ut-picker-area.vue 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679
  1. <template>
  2. <u-popup :show="show" mode="bottom" round="30rpx" closeable @close="close">
  3. <view class="up-picker-area d-flex flex-cln">
  4. <!-- 标题 -->
  5. <view class="area-title">{{ title }}</view>
  6. <!-- 顶部链路 -->
  7. <view class="area-breadcrumb">
  8. <!-- 全国根:selectCodeMax 为空或 000000 时显示 -->
  9. <template v-if="isNationRoot">
  10. <view class="crumb disabled" @click="onClickNationBreadcrumb">全国</view>
  11. <view class="sep" v-if="selectedCodes.length">/</view>
  12. </template>
  13. <!-- 基础链路(selectCodeMax 的完整链路),仅最后一个可点击以切换到第一页(市级) -->
  14. <template v-for="(code, idx) in baseChain" :key="'base-' + code">
  15. <view class="crumb" :class="idx === baseChain.length - 1 ? '' : 'disabled'" @click="idx === baseChain.length - 1 && onClickBaseBreadcrumb()">
  16. {{ getNameByCode(code) }}
  17. </view>
  18. <view class="sep" v-if="idx < baseChain.length - 1 || selectedCodes.length">/</view>
  19. </template>
  20. <!-- 选中链路 -->
  21. <template v-for="(code, idx) in selectedCodes" :key="'sel-' + code">
  22. <view class="crumb" :class="{ active: idx === currentSwiper }" @click="jumpToLevel(idx)">
  23. {{ getNameByCode(code) }}
  24. </view>
  25. <view class="sep" v-if="idx < selectedCodes.length - 1">/</view>
  26. </template>
  27. </view>
  28. <!-- 滑动列区域 -->
  29. <view class="area-body">
  30. <swiper :current="currentSwiper" @change="onSwiperChange" class="area-swiper">
  31. <template v-for="(parent, colIdx) in columnParents" :key="'col-' + parent">
  32. <swiper-item>
  33. <scroll-view
  34. :id="`sv-${colIdx}`"
  35. scroll-y
  36. class="area-scroll"
  37. :scroll-into-view="scrollIntoViewArr[colIdx]"
  38. >
  39. <!-- 顶部特殊项:全国(仅全国范围且首列显示) -->
  40. <view v-if="isNationRoot && colIdx === 0" class="picker-item" :id="`area-item-0-000000`" @click="onPickNation">
  41. <view class="name" :class="{ selected: currentValue === '000000' }">全国</view>
  42. <up-icon v-if="currentValue === '000000'" name="checkbox-mark" color="#000" size="36rpx" />
  43. </view>
  44. <!-- 顶部特殊项:选择范围自身(非全国,首列显示),如 云南省 -->
  45. <view v-else-if="!isNationRoot && colIdx === 0" class="picker-item" :id="`area-item-0-${canonical6(baseRoot)}`" @click="onPickBaseRoot">
  46. <view class="name" :class="{ selected: currentValue === baseRoot }">{{ getNameByCode(baseRoot) }}</view>
  47. <up-icon v-if="currentValue === baseRoot" name="checkbox-mark" color="#000" size="36rpx" />
  48. </view>
  49. <!-- 正常子级项 -->
  50. <template v-for="item in childrenMap[parent] || []" :key="item.adcdCode">
  51. <view class="picker-item" :id="`area-item-${colIdx}-${canonical6(item.adcdCode)}`" @click="onPick(item, colIdx)">
  52. <view class="name" :class="{ selected: canonical6(selectedCodes[colIdx]) === canonical6(item.adcdCode) }">
  53. {{ item.adcdName }}
  54. </view>
  55. <up-icon v-if="canonical6(selectedCodes[colIdx]) === canonical6(item.adcdCode)" name="checkbox-mark" color="#000" size="36rpx" />
  56. </view>
  57. </template>
  58. </scroll-view>
  59. </swiper-item>
  60. </template>
  61. </swiper>
  62. </view>
  63. <!-- 底部操作 -->
  64. <view class="area-footer">
  65. <up-button class="btn" @click="close">取消</up-button>
  66. <up-button class="btn" color="#2A6D52" @click="confirmPick">确定</up-button>
  67. </view>
  68. </view>
  69. </u-popup>
  70. <up-toast ref="uToastRef" />
  71. </template>
  72. <script setup lang="ts">
  73. import { ref, computed, watch, nextTick, getCurrentInstance } from 'vue';
  74. import { useClientRequest } from '@/utils/request';
  75. type MaybeStringNumber = string | number;
  76. interface AreaItem {
  77. adcdCode: string;
  78. adcdName: string;
  79. }
  80. interface ConfirmPayload {
  81. value: string;
  82. name: string;
  83. fullNames: string;
  84. fullName: string;
  85. }
  86. interface UtPickerAreaProps {
  87. modelValue?: MaybeStringNumber;
  88. show?: boolean;
  89. title?: string;
  90. maxLevel?: number;
  91. selectCodeMax?: string;
  92. selectedCodes?: string[];
  93. }
  94. const props = withDefaults(defineProps<UtPickerAreaProps>(), {
  95. modelValue: '',
  96. show: false,
  97. title: '选择区域',
  98. maxLevel: 3,
  99. selectCodeMax: '',
  100. selectedCodes: () => [] as string[],
  101. });
  102. const emits = defineEmits<{
  103. (e: 'update:modelValue', value: string): void;
  104. (e: 'update:show', value: boolean): void;
  105. (e: 'confirm', payload: ConfirmPayload): void;
  106. (e: 'update:selectedCodes', value: string[]): void;
  107. }>();
  108. const uToastRef = ref<{ show: (opts: any) => void } | null>(null);
  109. const close = () => emits('update:show', false);
  110. // ---------------- 工具与状态 ----------------
  111. // 统一为字符串,不再强制 6 位;全国仍使用 '000000'
  112. const normalize6 = (code: MaybeStringNumber): string => String(code ?? '');
  113. // 修复:全国返回空串用于层级与前缀判断,子级都视为全国的后代
  114. const trim00Pairs = (code: MaybeStringNumber): string => {
  115. const s = normalize6(code);
  116. if (s === '000000') return '';
  117. if (s.length <= 6) {
  118. let t = s;
  119. while (t.endsWith('00')) t = t.slice(0, -2);
  120. return t;
  121. }
  122. // 6 位之后不做去 00 处理,直接保留扩展位(如 9/12 位)
  123. let first6 = s.slice(0, 6);
  124. while (first6.endsWith('00')) first6 = first6.slice(0, -2);
  125. return first6 + s.slice(6);
  126. };
  127. // 层级:0 全国;<=6 位时按 2 位一层;>6 位时 9/12 位按 3 位一层
  128. const codeLevel = (code: MaybeStringNumber): number => {
  129. const t = trim00Pairs(code);
  130. if (!t) return 0;
  131. if (t.length <= 6) return Math.floor(t.length / 2);
  132. return 3 + Math.ceil((t.length - 6) / 3);
  133. };
  134. const isDescendant = (code: MaybeStringNumber, root: MaybeStringNumber): boolean => {
  135. const p = trim00Pairs(root);
  136. const c = trim00Pairs(code);
  137. return c.startsWith(p); // p=='' 时,任何 c 都成立(全国范围)
  138. };
  139. // 生成用于 nameMap 的最小 6 位标准键:
  140. // - 全国固定 '000000'
  141. // - <=6 位:去掉末尾 00 对后右补齐到 6 位
  142. // - >6 位:取前 6 位,再按上述规则去 00 后补齐到 6 位
  143. const nameKey = (code: MaybeStringNumber): string => {
  144. const s = String(code ?? '');
  145. if (!s || s === '000000') return '000000';
  146. const base6 = s.length <= 6 ? s : s.slice(0, 6);
  147. let t = base6;
  148. while (t.endsWith('00') && t.length >= 2) t = t.slice(0, -2);
  149. return t.padEnd(6, '0');
  150. };
  151. // 规范化用于比较和锚点:
  152. // - 全国返回 '000000'
  153. // - <=6 位:去尾部 00 对后右补齐至 6 位
  154. // - >6 位:保持原始完整码
  155. const canonical6 = (code: MaybeStringNumber): string => {
  156. const s = String(code ?? '');
  157. if (!s) return '';
  158. if (s === '000000') return '000000';
  159. if (s.length <= 6) {
  160. let t = s;
  161. while (t.endsWith('00')) t = t.slice(0, -2);
  162. return t.padEnd(6, '0');
  163. }
  164. return s;
  165. };
  166. const chainFromRoot = (code: MaybeStringNumber): string[] => {
  167. const s = normalize6(code);
  168. if (!s || s === '000000') return [];
  169. const list: string[] = [];
  170. // 省/市/区:统一输出为“最少6位”,少于补0,多余去掉末尾00(canonical6 内处理)
  171. for (let i = 2; i <= Math.min(6, s.length); i += 2) {
  172. list.push(canonical6(s.slice(0, i)));
  173. }
  174. // 扩展层级:9/12 位,保持真实长度;其 6 位基底已按 canonical6 规范化
  175. if (s.length > 6) {
  176. const base6 = canonical6(s).slice(0, 6);
  177. for (let j = 9; j <= s.length; j += 3) {
  178. list.push(base6 + s.slice(6, j));
  179. }
  180. }
  181. return list;
  182. };
  183. // 根(选择范围)与基础链路
  184. const baseRoot = computed(() => {
  185. const c = normalize6(props.selectCodeMax || '');
  186. // 空值或显式全国,统一为 '000000'
  187. if (!c || c === '000000') return '000000';
  188. return trim00Pairs(c);
  189. });
  190. const baseChain = computed(() => {
  191. // 完整显示 selectCodeMax 的链路(不包含全国 '000000')
  192. return chainFromRoot(baseRoot.value);
  193. });
  194. const isNationRoot = computed(() => baseRoot.value === '000000');
  195. // 缓存:父code -> 子数组
  196. const childrenMap = ref<Record<string, AreaItem[] | undefined>>({});
  197. // 新增:进行中请求去重缓存(父code -> Promise)
  198. const pendingMap = ref<Record<string, Promise<AreaItem[]> | undefined>>({});
  199. // 名称缓存:code -> name
  200. const nameMap = ref<Record<string, string>>({ '000000': '全国' });
  201. // 选中链路(不包含 baseRoot),例如 baseRoot=省,选中市/区... 这里存 [市, 区, ...]
  202. const selectedCodes = ref<string[]>(props?.selectedCodes || []);
  203. const currentValue = ref<string>(''); // 当前选中最终 code
  204. // 计算列:每列的父节点仅为“下一列的父节点”,避免最后一级再多出一列
  205. const columnParents = computed<string[]>(() => {
  206. const parents: string[] = [];
  207. const baseL = codeLevel(baseRoot.value); // baseRoot 的绝对层级(全国=0,省=1,市=2...)
  208. const maxCols = Math.max(0, props.maxLevel - baseL); // 允许渲染的最大列数(例如全国根且 maxLevel=3 -> 3 列:省/市/区)
  209. if (maxCols <= 0) return parents;
  210. // 第 0 列父节点固定为 baseRoot(展示 baseRoot 的子级)
  211. parents.push(baseRoot.value);
  212. // 后续列的父节点是“上一列选中的 code”,最多渲染到 maxCols 数量;没有选中则不再渲染,避免空列
  213. for (let k = 1; k < maxCols; k++) {
  214. const parentCode = selectedCodes.value[k - 1];
  215. if (!parentCode) break; // 未选择上一列时,不渲染后续空列
  216. parents.push(parentCode);
  217. }
  218. return parents;
  219. });
  220. // 当前 swiper 索引(指向当前操作的列)
  221. const currentSwiper = ref<number>(0);
  222. // ---------------- 异步数据获取(带缓存 + 去重) ----------------
  223. const getChildren = async (parentCode: string): Promise<AreaItem[]> => {
  224. const key = normalize6(parentCode);
  225. // 命中结果缓存
  226. if (childrenMap.value[key]) return childrenMap.value[key] as AreaItem[];
  227. // 命中请求去重缓存
  228. if (pendingMap.value[key]) return pendingMap.value[key] as Promise<AreaItem[]>;
  229. const p = (async () => {
  230. const res: any = await useClientRequest.get('/app/adcd/listChildrenByCode', {
  231. adcdCode: key,
  232. leval: 1,
  233. pageSize: 1000,
  234. });
  235. if (res?.code === 200) {
  236. const rows: AreaItem[] = (res.rows || []).map((x: any) => ({
  237. // 不再截断为 6 位,保留完整 code
  238. adcdCode: nameKey(x.adcdCode),
  239. adcdName: x.adcdName,
  240. }));
  241. childrenMap.value[key] = rows; // 写入结果缓存
  242. rows.forEach((r) => {
  243. // 名称缓存:精确键必存;仅当长度<=6时,才缓存到最少6位标准键,避免覆盖区县名称
  244. nameMap.value[r.adcdCode] = r.adcdName;
  245. nameMap.value[nameKey(r.adcdCode)] = r.adcdName;
  246. });
  247. delete pendingMap.value[key]; // 清除进行中缓存
  248. return rows;
  249. }
  250. childrenMap.value[key] = [];
  251. delete pendingMap.value[key];
  252. return [];
  253. })();
  254. pendingMap.value[key] = p;
  255. return p;
  256. };
  257. // 确保能显示 baseChain 的名称:逐级拉取上级的子级来命名
  258. const ensureBaseNames = async () => {
  259. // 若 baseChain 为空无需处理
  260. if (!baseChain.value.length) return;
  261. // 第一层名称来自全国的子级
  262. await getChildren('000000');
  263. // 逐级往下,确保每一层的 name 显示
  264. for (let i = 0; i < baseChain.value.length; i++) {
  265. const parent = i === 0 ? '000000' : baseChain.value[i - 1];
  266. await getChildren(parent);
  267. }
  268. };
  269. // 获取名称
  270. const getNameByCode = (code: string | number): string => {
  271. const c = normalize6(code);
  272. if (c === '000000') return '全国';
  273. // 先查精确键
  274. let val = nameMap.value[c];
  275. if (val) return val;
  276. // 对长度<=6的code再查最少6位标准键,避免用>6位的名称覆盖6位层级
  277. if (c.length <= 6) {
  278. const k6 = nameKey(c);
  279. val = nameMap.value[k6];
  280. if (val) return val;
  281. }
  282. // 即时回退:在已加载的 childrenMap 中查找匹配项并缓存
  283. for (const rows of Object.values(childrenMap.value)) {
  284. if (!rows) continue;
  285. const found = rows.find((r) => r.adcdCode === c);
  286. if (found) {
  287. nameMap.value[c] = found.adcdName;
  288. if (String(found.adcdCode).length <= 6) {
  289. nameMap.value[nameKey(found.adcdCode)] = found.adcdName;
  290. }
  291. return found.adcdName;
  292. }
  293. }
  294. return '';
  295. };
  296. // ---------------- 回显与列构造 ----------------
  297. const toast = (msg: string) => {
  298. uToastRef.value?.show({ message: msg, type: 'error' });
  299. };
  300. // 将传入值回显为 selectedCodes(不含 baseRoot)
  301. const applyModelValue = async (val: MaybeStringNumber): Promise<void> => {
  302. const v = normalize6(val);
  303. // 确保基础链路名称已就绪,避免回显时中文缺失
  304. await ensureBaseNames();
  305. if (!v) {
  306. selectedCodes.value = [];
  307. currentValue.value = '';
  308. currentSwiper.value = Math.min(columnParents.value.length - 1, 0);
  309. return;
  310. }
  311. if (v === '000000') {
  312. selectedCodes.value = [];
  313. currentValue.value = '000000';
  314. await ensureColumnsData();
  315. currentSwiper.value = 0;
  316. scrollToAllSelected();
  317. return;
  318. }
  319. // 新增:等于 baseRoot 自身时
  320. if (v === baseRoot.value) {
  321. selectedCodes.value = [];
  322. currentValue.value = baseRoot.value;
  323. await ensureColumnsData();
  324. currentSwiper.value = 0;
  325. scrollToAllSelected();
  326. return;
  327. }
  328. // 必须在选择范围内
  329. if (!isDescendant(v, baseRoot.value)) {
  330. toast('不在选择范围内');
  331. selectedCodes.value = [];
  332. currentValue.value = '';
  333. currentSwiper.value = 0;
  334. return;
  335. }
  336. const full = chainFromRoot(v);
  337. const base = baseChain.value;
  338. const baseL = codeLevel(baseRoot.value);
  339. const maxCols = Math.max(0, props.maxLevel - baseL);
  340. const picked = full.slice(base.length, base.length + maxCols);
  341. selectedCodes.value = picked;
  342. currentValue.value = picked.length ? picked[picked.length - 1] : baseRoot.value;
  343. await ensureColumnsData();
  344. currentSwiper.value = Math.max(0, Math.min(selectedCodes.value.length - 1, columnParents.value.length - 1));
  345. scrollToAllSelected();
  346. };
  347. // 确保每一列的 children 数据已拉取
  348. const ensureColumnsData = async (): Promise<void> => {
  349. for (const parent of columnParents.value) {
  350. await getChildren(parent);
  351. }
  352. };
  353. // ---------------- 交互 ----------------
  354. const onSwiperChange = (e: any) => {
  355. const idx = e?.detail?.current ?? 0;
  356. const maxIdx = Math.max(0, columnParents.value.length - 1);
  357. currentSwiper.value = Math.min(idx, maxIdx);
  358. };
  359. // 跳到链路的某一级进行重新选择
  360. const jumpToLevel = (idx: number) => {
  361. // 只能跳选中链路,且 idx 为选中链路下标(不包含 baseChain)
  362. currentSwiper.value = idx;
  363. };
  364. // 选择某列的一项(到达最后一级时自动确认)
  365. const onPick = async (item: AreaItem, colIdx: number) => {
  366. const code = canonical6(item.adcdCode);
  367. if (!isDescendant(code, baseRoot.value)) {
  368. return toast('不在选择范围内');
  369. }
  370. // 特判:误点到全国(正常不会走到这,因为全国走 onPickNation)
  371. if (code === '000000' && isNationRoot.value && colIdx === 0) {
  372. return onPickNation();
  373. }
  374. selectedCodes.value = [...selectedCodes.value.slice(0, colIdx), code];
  375. currentValue.value = code;
  376. // 是否达到允许的最后一列(相对 baseRoot 的列数)
  377. const baseL = codeLevel(baseRoot.value);
  378. const maxCols = Math.max(0, props.maxLevel - baseL);
  379. const reachedFinalColumn = (colIdx + 1) >= maxCols;
  380. if (reachedFinalColumn) {
  381. scrollToAllSelected();
  382. confirmPick();
  383. return;
  384. }
  385. await getChildren(code);
  386. const nextChildren = childrenMap.value[code] || [];
  387. currentSwiper.value = nextChildren.length ? colIdx + 1 : colIdx;
  388. scrollToAllSelected();
  389. };
  390. // 选全国
  391. const onPickNation = (): void => {
  392. if (!isNationRoot.value) return;
  393. selectedCodes.value = [];
  394. currentValue.value = '000000';
  395. currentSwiper.value = 0;
  396. scrollToAllSelected();
  397. };
  398. // 选“选择范围自身”(非全国时的顶部项,如:云南省)
  399. const onPickBaseRoot = async (): Promise<void> => {
  400. if (isNationRoot.value) return; // 全国时不走这里
  401. // 将当前值选为 baseRoot,自身级别
  402. selectedCodes.value = [];
  403. currentValue.value = baseRoot.value;
  404. currentSwiper.value = 0;
  405. // 若允许的列数仅 1(选择范围本身即最终列),直接确认
  406. const baseL = codeLevel(baseRoot.value);
  407. const maxCols = Math.max(0, props.maxLevel - baseL);
  408. if (maxCols <= 1) {
  409. scrollToAllSelected();
  410. confirmPick();
  411. return;
  412. }
  413. // 否则保持在第1列,滚动到该项
  414. await ensureColumnsData();
  415. scrollToAllSelected();
  416. };
  417. // 组装完整名称:包含基础链路与已选链路(全国仅返回“全国”)
  418. const buildFullName = (): string => {
  419. if (currentValue.value === '000000') return '全国';
  420. const baseNames = baseChain.value.map((code) => getNameByCode(code));
  421. // 若当前值是 baseRoot 自身(选择范围自身),则不重复追加 selectedCodes
  422. const selNames = selectedCodes.value.map((code) => getNameByCode(code));
  423. return [...baseNames, ...selNames].filter(Boolean).join('');
  424. };
  425. const confirmPick = (): void => {
  426. if (!currentValue.value && isNationRoot.value) currentValue.value = '000000';
  427. const codeList = selectedCodes.value.slice();
  428. const nameList = codeList.map((i) => getNameByCode(i));
  429. const fullName = buildFullName();
  430. emits('update:modelValue', currentValue.value);
  431. emits('confirm', {
  432. value: currentValue.value,
  433. name: getNameByCode(currentValue.value),
  434. fullNames: nameList.join(''),
  435. fullName // 新增:完整地址名称
  436. });
  437. emits('update:selectedCodes', codeList);
  438. close();
  439. };
  440. // ---------------- 滚动(H5 兼容)----------------
  441. // 选中项锚点
  442. const scrollIntoViewArr = ref<string[]>([]);
  443. const instance = getCurrentInstance();
  444. const proxy = instance && instance.proxy;
  445. const refreshScrollArrays = (): void => {
  446. const len = columnParents.value.length;
  447. // 初始化长度匹配列数
  448. if (scrollIntoViewArr.value.length !== len) {
  449. scrollIntoViewArr.value = Array(len).fill('');
  450. }
  451. };
  452. // 仅用锚点滚动
  453. const updateScrollIntoView = (): void => {
  454. refreshScrollArrays();
  455. scrollIntoViewArr.value = scrollIntoViewArr.value.map(() => '');
  456. nextTick(() => {
  457. scrollIntoViewArr.value = columnParents.value.map((_, i) => {
  458. if (i === 0 && isNationRoot.value && currentValue.value === '000000') return 'area-item-0-000000';
  459. if (i === 0 && !isNationRoot.value && currentValue.value === baseRoot.value) return `area-item-0-${canonical6(baseRoot.value)}`;
  460. const code = selectedCodes.value[i];
  461. return code ? `area-item-${i}-${canonical6(code)}` : '';
  462. });
  463. });
  464. };
  465. const scrollToAllSelected = (): void => {
  466. updateScrollIntoView();
  467. };
  468. // ---------------- 监听与初始化 ----------------
  469. const rebuildByInputs = async (): Promise<void> => {
  470. await ensureBaseNames();
  471. await ensureColumnsData();
  472. await applyModelValue(String(props.modelValue || ''));
  473. };
  474. watch(
  475. () => [props.selectCodeMax, props.maxLevel],
  476. async () => {
  477. // 变更范围或层级,重建链路与列
  478. selectedCodes.value = [];
  479. currentValue.value = '';
  480. currentSwiper.value = 0;
  481. await rebuildByInputs();
  482. },
  483. { immediate: true }
  484. );
  485. watch(
  486. () => props.modelValue,
  487. async (nv) => {
  488. await applyModelValue(String(nv || ''));
  489. }
  490. );
  491. // 每次列父集合变化时,刷新列数据并滚动
  492. watch(
  493. () => columnParents.value.slice(),
  494. async () => {
  495. await ensureColumnsData();
  496. // 定位到当前列并滚动选中项
  497. currentSwiper.value = Math.max(0, Math.min(currentSwiper.value, columnParents.value.length - 1));
  498. scrollToAllSelected();
  499. }
  500. );
  501. // 点击顶部“全国”:切到省级并滚动到省级区域
  502. const onClickNationBreadcrumb = async (): Promise<void> => {
  503. if (!isNationRoot.value) return;
  504. // 确保省级数据已加载
  505. await getChildren('000000');
  506. // 切到第 0 列(省级)
  507. currentSwiper.value = 0;
  508. // 保证滚动数组长度正确
  509. refreshScrollArrays();
  510. // 有已选省则对齐该省;否则滚到顶部
  511. nextTick(() => {
  512. const first = selectedCodes.value[0];
  513. scrollIntoViewArr.value.splice(0, 1, first ? `area-item-0-${canonical6(first)}` : '');
  514. });
  515. };
  516. // 点击顶部“选择范围自身”(如云南省):切到第0列(市级)并滚动到已选市;未选则回到顶部
  517. const onClickBaseBreadcrumb = async (): Promise<void> => {
  518. if (isNationRoot.value) return; // 全国时不走这里
  519. await getChildren(baseRoot.value); // 确保市级数据已加载
  520. currentSwiper.value = 0; // 切到第0列(展示 baseRoot 的子级:市)
  521. refreshScrollArrays();
  522. nextTick(() => {
  523. const selCity = selectedCodes.value[0]; // 若已有选中的市
  524. scrollIntoViewArr.value.splice(0, 1, selCity ? `area-item-0-${canonical6(selCity)}` : '');
  525. });
  526. };
  527. </script>
  528. <style scoped lang="scss">
  529. .up-picker-area {
  530. height: 70vh; /* 高度 70vh */
  531. display: flex;
  532. flex-direction: column;
  533. background: #fff;
  534. }
  535. .area-title {
  536. padding: 20rpx 24rpx;
  537. font-size: 32rpx;
  538. font-weight: 600;
  539. text-align: center;
  540. color: #000;
  541. border-bottom: 1px solid #f2f2f2;
  542. }
  543. .area-breadcrumb {
  544. display: flex;
  545. align-items: center;
  546. flex-wrap: wrap;
  547. padding: 16rpx 24rpx;
  548. gap: 8rpx;
  549. border-bottom: 1px solid #f2f2f2;
  550. .crumb {
  551. font-size: 28rpx;
  552. color: #333;
  553. padding: 8rpx 12rpx;
  554. border-radius: 8rpx;
  555. &.active {
  556. font-weight: 700;
  557. color: #000;
  558. }
  559. &.disabled {
  560. color: #999;
  561. }
  562. }
  563. .sep {
  564. color: #bbb;
  565. padding: 0 8rpx;
  566. }
  567. }
  568. .area-body {
  569. flex: 1;
  570. overflow: hidden;
  571. }
  572. .area-swiper {
  573. height: 100%;
  574. }
  575. .area-scroll {
  576. height: 100%;
  577. /* 移除 will-change 与像素滚动相关样式,仅保留稳定布局 */
  578. }
  579. .picker-item {
  580. display: flex;
  581. align-items: center;
  582. padding: 24rpx 32rpx;
  583. border-bottom: 1px solid #f5f5f5;
  584. .name {
  585. flex: 1;
  586. font-size: 30rpx;
  587. color: #333;
  588. &.selected {
  589. font-weight: 700;
  590. color: #000; /* 选中项黑色加粗 */
  591. }
  592. }
  593. }
  594. .area-footer {
  595. display: flex;
  596. gap: 20rpx;
  597. padding: 20rpx 24rpx;
  598. border-top: 1px solid #f2f2f2;
  599. background: #fff;
  600. .btn {
  601. flex: 1;
  602. }
  603. }
  604. </style>