hldy_app_mini/component/public/superpicker.vue

531 lines
14 KiB
Vue
Raw Normal View History

2025-11-18 13:08:28 +08:00
<template>
<view v-if="visible" class="overlay" @touchmove.prevent.self>
<view class="box" :style="boxStyle" @touchstart.stop.prevent="onDragStartTouch"
@mousedown.stop.prevent="onDragStartMouse">
<view class="header" ref="headerRef">
<view class="title">{{ title }}</view>
<view class="actions">
<button class="btn" @click="cancel">取消</button>
<button class="btn" @click="confirm">确定</button>
</view>
</view>
<picker-view class="picker-view" :style="{
height: pickerHeight + 'px',
'--item-h': ITEM_H + 'px',
'--cols': displayColumns.length || 1
}" :value="normalizedSelectedIndexes" @change="onPickerChange">
<picker-view-column class="picker-view-column" v-for="(col, ci) in displayColumns" :key="ci">
<view v-for="(item, i) in col" :key="i" class="picker-item">{{ item }}</view>
<!-- :style="{ height: ITEM_H + 'px', lineHeight: ITEM_H + 'px' }" -->
</picker-view-column>
</picker-view>
<view class="resize-handle" @touchstart.stop.prevent="onResizeStartTouch"
@mousedown.stop.prevent="onResizeStartMouse">
<view class="grip"></view>
</view>
</view>
</view>
</template>
<script setup>
import {
ref,
computed,
watch,
onMounted,
onBeforeUnmount,
nextTick
} from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
title: {
type: String,
default: '请选择'
},
initLeft: {
type: Number,
default: 50
},
initTop: {
type: Number,
default: 100
},
initWidth: {
type: Number,
default: 320
},
initHeight: {
type: Number,
default: 320
},
minWidth: {
type: Number,
default: 180
},
minHeight: {
type: Number,
default: 200
},
maxWidth: {
type: Number,
default: 1000
},
maxHeight: {
type: Number,
default: 1200
},
columns: {
type: Array,
default: () => [
[]
]
},
nameKey: {
type: [String, Array],
default: 'name'
},
value: {
type: Array,
default: () => []
}
});
2025-11-18 13:15:34 +08:00
const emit = defineEmits(['update:modelValue', 'confirm', 'change', 'update:position', 'update:size','close']);
2025-11-18 13:08:28 +08:00
/* ========== 基本可见性 / 同步 ========== */
const visible = ref(props.modelValue);
watch(() => props.modelValue, v => visible.value = v);
watch(visible, v => emit('update:modelValue', v));
/* ========== 位置 / 尺寸 ========== */
const left = ref(props.initLeft);
const top = ref(props.initTop);
const width = ref(props.initWidth);
const height = ref(props.initHeight);
/* ========== 固定行高(关键:整数像素) ========== */
const ITEM_H = 44; // 行高px保持整数像素
const HEADER_H = 44; // header 高度px与你样式一致
/* ========== selectedIndexes 初始值 & 正规化 ========== */
const selectedIndexes = ref(
(props.value && props.value.length) ? props.value.map(v => Number(v || 0)) : (Array.isArray(props.columns) ?
props.columns.map(() => 0) : [])
);
watch(() => props.value, v => {
if (v && v.length) {
selectedIndexes.value = v.map(x => Number(x || 0));
clampSelectedIndexes();
}
});
const normalizedSelectedIndexes = computed(() => selectedIndexes.value.map(v => Number(v || 0)));
/* ========== columns -> displayColumns显示文本 ========== */
function getByPath(obj, path) {
if (obj == null) return undefined;
if (!path) return obj;
const parts = path.split('.');
let cur = obj;
for (let p of parts) {
if (cur == null) return undefined;
cur = cur[p];
}
return cur;
}
const displayColumns = computed(() => {
const raw = props.columns || [];
const nk = props.nameKey;
return raw.map((col, ci) => {
if (!Array.isArray(col)) return [];
return col.map(item => {
if (item == null) return '';
if (typeof item === 'object') {
let keyToUse = Array.isArray(nk) ? (nk[ci] !== undefined ? nk[ci] : nk[0]) :
nk;
if (!keyToUse) {
return item.name ?? item.label ?? item.title ?? String(item);
}
const val = getByPath(item, keyToUse);
return (val === undefined || val === null) ? (item.name ?? item.label ?? item
.title ?? String(item)) : String(val);
} else {
return String(item);
}
});
});
});
const rawColumns = computed(() => (props.columns || []).map(col => Array.isArray(col) ? col : []));
/* ========== 屏幕信息(只用 uni.getSystemInfoSyncApp/H5/小程序 均可用) ========== */
let screenW = 800,
screenH = 600; // 安全默认值(不会访问 window
onMounted(() => {
try {
const info = uni.getSystemInfoSync();
// uni.getSystemInfoSync 在 App/H5/小程序 都可用,优先使用
screenW = info.windowWidth || info.screenWidth || screenW;
screenH = info.windowHeight || info.screenHeight || screenH;
} catch (e) {
// 若异常,不尝试访问 window保留默认值
screenW = screenW;
screenH = screenH;
}
});
/* ========== 拖拽 / 缩放(保持原逻辑) ========== */
let dragging = false;
let dragStart = {
x: 0,
y: 0,
left: 0,
top: 0
};
let resizing = false;
let resizeStart = {
x: 0,
y: 0,
w: 0,
h: 0
};
function onDragStartTouch(e) {
const t = e.touches && e.touches[0];
if (t) startDrag(t.clientX, t.clientY);
}
function onDragStartMouse(e) {
startDrag(e.clientX, e.clientY);
}
function startDrag(cx, cy) {
dragging = true;
dragStart.x = cx;
dragStart.y = cy;
dragStart.left = left.value;
dragStart.top = top.value;
}
function onResizeStartTouch(e) {
const t = e.touches && e.touches[0];
if (t) startResize(t.clientX, t.clientY);
}
function onResizeStartMouse(e) {
startResize(e.clientX, e.clientY);
}
function startResize(cx, cy) {
resizing = true;
resizeStart.x = cx;
resizeStart.y = cy;
resizeStart.w = width.value;
resizeStart.h = height.value;
}
function onTouchMove(e) {
if (!dragging && !resizing) return;
const t = e.touches && e.touches[0];
if (t) handleMove(t.clientX, t.clientY, e);
}
function onMouseMove(e) {
if (!dragging && !resizing) return;
handleMove(e.clientX, e.clientY, e);
}
function handleMove(cx, cy, e) {
if (dragging) {
const dx = cx - dragStart.x;
const dy = cy - dragStart.y;
left.value = Math.min(Math.max(0, dragStart.left + dx), Math.max(0, screenW - width.value));
top.value = Math.min(Math.max(0, dragStart.top + dy), Math.max(0, screenH - height.value));
emit('update:position', {
left: left.value,
top: top.value
});
} else if (resizing) {
const dx = cx - resizeStart.x;
const dy = cy - resizeStart.y;
let nw = Math.min(props.maxWidth, Math.max(props.minWidth, Math.round(resizeStart.w + dx)));
let nh = Math.min(props.maxHeight, Math.max(props.minHeight, Math.round(resizeStart.h + dy)));
if (left.value + nw > screenW) nw = screenW - left.value;
if (top.value + nh > screenH) nh = screenH - top.value;
width.value = nw;
height.value = nh;
emit('update:size', {
width: width.value,
height: height.value
});
}
if (e && e.preventDefault) e.preventDefault();
}
function onMouseUp() {
if (dragging) dragging = false;
if (resizing) resizing = false;
}
function onTouchEnd() {
if (dragging) dragging = false;
if (resizing) resizing = false;
}
/* ========== picker 行数 / 高度 计算(关键) ========== */
const pickerHeight = computed(() => {
const avail = Math.max(0, Math.round(height.value) - HEADER_H);
let rows = Math.floor(avail / ITEM_H);
if (rows < 1) rows = 1;
if (rows % 2 === 0) rows = rows - 1 > 0 ? rows - 1 : 1;
return rows * ITEM_H;
});
/* ========== clamp helper ========== */
function clampSelectedIndexes() {
const colsArr = rawColumns.value || [];
if (selectedIndexes.value.length !== colsArr.length) {
selectedIndexes.value = Array.from({
length: colsArr.length
}, (_, i) => selectedIndexes.value[i] ?? 0);
}
selectedIndexes.value = selectedIndexes.value.map((idx, ci) => {
const col = Array.isArray(colsArr[ci]) ? colsArr[ci] : [];
const maxIdx = Math.max(0, col.length - 1);
return col.length ? Math.min(Math.max(0, Number(idx) || 0), maxIdx) : 0;
});
}
/* ========== 对齐重置逻辑(核心) ========== */
let resetTimer = null;
function resetPickerAlign(delay = 40) {
if (resetTimer) clearTimeout(resetTimer);
resetTimer = setTimeout(() => {
nextTick(() => {
clampSelectedIndexes();
selectedIndexes.value = selectedIndexes.value.map(v => Number(v || 0));
});
resetTimer = null;
}, delay);
}
/* 在关键变化上调用重置columns / visible / pickerHeight */
watch(() => props.columns, () => {
clampSelectedIndexes();
resetPickerAlign(30);
}, {
deep: true,
immediate: true
});
watch(() => visible.value, (v) => {
if (v) {
resetPickerAlign(60);
setTimeout(() => resetPickerAlign(40), 120);
}
});
watch(() => pickerHeight.value, () => {
resetPickerAlign(30);
});
/* ========== picker change / confirm / cancel ========== */
function onPickerChange(e) {
const val = (e && e.detail && e.detail.value) ? e.detail.value : e;
if (Array.isArray(val)) {
selectedIndexes.value = val.map((v, ci) => {
const col = rawColumns.value[ci] || [];
const max = Math.max(0, col.length - 1);
const num = Number(v) || 0;
return col.length ? Math.min(Math.max(0, num), max) : 0;
});
}
emit('change', selectedIndexes.value.slice());
resetPickerAlign(80);
}
function confirm() {
const result = selectedIndexes.value.map((idx, ci) => {
const col = rawColumns.value[ci] || [];
const display = (displayColumns.value[ci] && displayColumns.value[ci][idx] !== undefined) ?
displayColumns.value[ci][idx] : (col[idx] !== undefined ? String(col[idx]) : '');
const val = col[idx] !== undefined ? col[idx] : (display || null);
return {
index: idx,
value: val,
display
};
});
emit('confirm', result);
visible.value = false;
}
function cancel() {
visible.value = false;
2025-11-18 13:15:34 +08:00
emit('close');
2025-11-18 13:08:28 +08:00
}
/* ========== boxStyle ========== */
const boxStyle = computed(() => ({
position: 'fixed',
left: `${Math.round(left.value)}px`,
top: `${Math.round(top.value)}px`,
width: `${Math.round(width.value)}px`,
height: `${Math.round(height.value)}px`,
zIndex: 1000,
background: '#fff',
borderRadius: '8px',
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
overflow: 'hidden',
transform: 'translateZ(0)'
}));
/* ========== 全局事件监听(更稳健的平台检测) ========== */
/* 使 window/documentH5 App
因为原生 App 的触摸事件通常在组件内部就能处理touchstart/touchmove/touchend */
let globalEventTarget = null;
if (typeof window !== 'undefined' && window && typeof window.addEventListener === 'function') {
globalEventTarget = window;
} else if (typeof document !== 'undefined' && document && typeof document.addEventListener === 'function') {
globalEventTarget = document;
} else {
globalEventTarget = null; // 在原生 App非 H5中通常为 null
}
onMounted(() => {
if (globalEventTarget) {
try {
globalEventTarget.addEventListener('mousemove', onMouseMove);
globalEventTarget.addEventListener('mouseup', onMouseUp);
// touchmove 需要 passive:false 以阻止默认行为
globalEventTarget.addEventListener('touchmove', onTouchMove, {
passive: false
});
globalEventTarget.addEventListener('touchend', onTouchEnd);
} catch (e) {
// 如果某些环境不支持 options 参数,用 fallback
try {
globalEventTarget.addEventListener('touchmove', onTouchMove);
globalEventTarget.addEventListener('touchend', onTouchEnd);
} catch (err) {
// 忽略
}
}
}
});
onBeforeUnmount(() => {
if (globalEventTarget) {
try {
globalEventTarget.removeEventListener('mousemove', onMouseMove);
globalEventTarget.removeEventListener('mouseup', onMouseUp);
globalEventTarget.removeEventListener('touchmove', onTouchMove);
globalEventTarget.removeEventListener('touchend', onTouchEnd);
} catch (e) {
// 忽略
}
}
if (resetTimer) {
clearTimeout(resetTimer);
resetTimer = null;
}
});
</script>
<style scoped>
.overlay {
position: fixed;
inset: 0;
z-index: 900;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: flex-start;
justify-content: flex-start;
}
.box {
background: #fff;
display: flex;
flex-direction: column;
user-select: none;
-webkit-user-select: none;
transform: translateZ(0);
}
.header {
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
border-bottom: 1px solid #eee;
background: linear-gradient(90deg, #fff, #fafafa);
cursor: move;
}
.title {
font-size: 16px;
font-weight: 600;
}
.actions {
display: flex;
gap: 8px;
}
.btn {
padding: 6px 8px;
border-radius: 6px;
background: #f5f5f5;
}
.picker-view {
width: 100%;
box-sizing: border-box;
-webkit-overflow-scrolling: touch;
overflow: hidden;
}
.picker-view-column {
display: inline-block;
vertical-align: top;
width: calc(100% / var(--cols, 1));
box-sizing: border-box;
text-align: center;
}
.picker-item {
display: block;
/* height: var(--item-h);
line-height: var(--item-h); */
text-align: center;
font-size: 14px;
box-sizing: border-box;
user-select: none;
-webkit-user-select: none;
display: flex;
justify-content: center;
align-items: center;
}
.resize-handle {
position: absolute;
right: 6px;
bottom: 6px;
width: 28px;
height: 28px;
touch-action: none;
}
.grip {
width: 100%;
height: 100%;
border-radius: 4px;
border: 1px dashed #bbb;
box-sizing: border-box;
}
</style>