同步6/16

This commit is contained in:
Zy
2026-06-01 18:12:58 +08:00
parent 38274ab612
commit 50e7b14fd6
183 changed files with 8956 additions and 4803 deletions

View File

@@ -84,7 +84,7 @@ const handleClick = (item) => {
};
// 暴露方法给父组件调用
defineExpose({ open });
defineExpose({ open, close });
</script>
<style scoped>

View File

@@ -3,7 +3,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
import { ref, onMounted, onBeforeUnmount, watch, nextTick, shallowRef } from 'vue';
import * as echarts from 'echarts';
import type { EChartsOption, ECharts } from 'echarts';
@@ -19,7 +19,6 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
width: '100%',
height: '100%',
options: () => ({}) as EChartsOption,
theme: '',
autoResize: true,
immediate: true
@@ -27,36 +26,38 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{
ready: [chart: ECharts];
click: [params: any];
click: [params: echarts.ECElementEvent];
rendered: [];
}>();
const chartRef = ref<HTMLDivElement>();
let chartInstance: ECharts | null = null;
// 3. 使用 shallowRef 优化性能,因为 ECharts 实例不需要深层响应式
const chartInstance = shallowRef<ECharts | null>(null);
let resizeObserver: ResizeObserver | null = null;
const initChart = () => {
if (!chartRef.value) return;
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
if (chartInstance.value) {
chartInstance.value.dispose();
chartInstance.value = null;
}
chartInstance = echarts.init(chartRef.value, props.theme);
chartInstance.setOption(props.options);
const instance = echarts.init(chartRef.value, props.theme);
chartInstance.value = instance;
chartInstance.value.setOption(props.options);
emit('ready', chartInstance);
emit('ready', instance);
emit('rendered');
chartInstance.on('click', (params: any) => {
instance.on('click', (params: any) => {
emit('click', params);
});
};
const updateChart = () => {
if (chartInstance) {
chartInstance.setOption(props.options, {
if (chartInstance.value) {
chartInstance.value.setOption(props.options, {
notMerge: false,
lazyUpdate: false
});
@@ -66,15 +67,15 @@ const updateChart = () => {
// ✅ 核心修复:支持 scale 缩放后重绘
const resizeChart = () => {
if (!chartInstance) return;
chartInstance.resize();
if (!chartInstance.value) return;
chartInstance.value.resize();
};
const getInstance = () => chartInstance;
const getInstance = () => chartInstance.value;
const exportAsImage = (type: 'png' | 'svg' | 'jpeg' = 'png') => {
return (
chartInstance?.getDataURL({
chartInstance.value?.getDataURL({
type,
pixelRatio: 2,
backgroundColor: '#fff'
@@ -86,23 +87,18 @@ watch(() => props.options, updateChart, { deep: true });
onMounted(() => {
nextTick(() => initChart());
if (props.autoResize && chartRef.value) {
resizeObserver = new ResizeObserver(resizeChart);
resizeObserver.observe(chartRef.value);
}
});
// ✅ 关键:监听 window.resizescale 缩放会触发 window.resize
onMounted(() => {
window.addEventListener('resize', resizeChart);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeChart);
resizeObserver?.disconnect();
chartInstance?.dispose();
chartInstance = null;
chartInstance.value?.dispose();
chartInstance.value = null;
});
defineExpose({

View File

@@ -0,0 +1,304 @@
<template>
<slot name="default" />
<el-dialog append-to-body v-model="dialog.dialogVisible" :title="dialog.dialogTitle" width="1050">
<el-table ref="multipleTableRef" :data="tableData" border @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="序号" align="center" width="55" type="index" />
<el-table-column label="业主" align="center">
<template #default="scope">
<span>{{ scope.row.residentName ? scope.row.residentName : '---' }}</span>
</template>
</el-table-column>
<el-table-column label="性别" align="center">
<template #default="scope">
<dict-tag :options="sys_user_sex" :value="scope.row.gender"></dict-tag>
</template>
</el-table-column>
<el-table-column label="手机号" align="center" prop="phone" />
<el-table-column label="车位" align="center" prop="parkingName" />
</el-table>
<template #footer>
<div class="dialog-footer flex flex-items-center flex-justify-between">
<pagination
style="margin-top: 0"
layout="total, prev, pager, next, jumper"
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getlist"
/>
<div>
<el-button @click="conback">退出</el-button>
<el-button type="primary" @click="confirm"> 确定 </el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { getBuildinglistAPI, getHouselistAPI } from '@/api/system/house';
import { BillUserlist, queryResidentList_Cars, queryResidentList_holder } from '@/api/system/SdbBill';
import { ref, nextTick, getCurrentInstance, ComponentInternalInstance, toRefs } from 'vue';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const { sys_user_sex } = toRefs<any>(proxy?.useDict('sys_user_sex'));
const props = defineProps({
returnvalue: {
type: String,
default: 'parkingId'
},
titleName: {
type: String,
default: '选择业主'
},
/** 是否多选 false 单选 */
isMultiple: {
type: Boolean,
default: false
}
});
const { titleName, returnvalue } = toRefs(props);
const userid = ref([]);
// ✅ 手动设置默认值(替代 withDefaults
const emit = defineEmits<{
change: [value: string | number | (string | number)[] | BillUserlist | BillUserlist[]]; // 支持返回数组
}>();
const dialog = ref({
dialogVisible: false,
dialogTitle: titleName.value
});
const queryParams = ref({
pageNum: 1,
pageSize: 10
});
const multipleTableRef = ref();
const tableData = ref<BillUserlist[]>([]);
const total = ref(0);
const loading = ref(false);
const isFetching = ref(false);
// 存储选中项
const multipleSelection = ref<BillUserlist[]>([]);
const isMultiple = computed(() => props.isMultiple ?? false);
// 优化 handleUserSelection避免重复查找
const handleUserSelection = () => {
isFetching.value = true;
if (!multipleTableRef.value || !tableData.value.length) {
isFetching.value = false;
return;
}
const rowsToSelect = tableData.value.filter((row) => {
return multipleSelection.value.some((selected) => selected.parkingId === row.parkingId);
});
multipleTableRef.value.clearSelection();
if (rowsToSelect.length > 0) {
nextTick(() => {
rowsToSelect.forEach((row) => {
multipleTableRef.value?.toggleRowSelection(row, true);
});
isFetching.value = false;
});
} else {
isFetching.value = false;
}
};
// ====================== 方法 ======================
// 获取列表
function getlist() {
isFetching.value = true; // 加锁
loading.value = true;
queryResidentList_Cars(queryParams.value)
.then((res) => {
tableData.value = res.rows;
total.value = res.total;
handleUserSelection();
})
.catch((error) => {
console.error('获取用户列表失败:', error);
})
.finally(() => {
loading.value = false;
isFetching.value = false; // 加锁
});
}
/** 楼栋列表 */
const buildinglist = ref([]);
/** 房屋列表 */
const unitlist = ref([]);
/** 筛选房屋类型 */
function handleChangeHouse(val: string) {
getBuildinglistAPI().then((res) => {
buildinglist.value = res.data;
});
}
/** 单元列表 */
function handleChangebuilding(val: string) {
getHouselistAPI(val).then((res) => {
unitlist.value = res.data.units;
});
}
// ==============================================
// ✅ 新增:处理 selection-change多选专用
// ==============================================
function handleSelectionChange(selection: BillUserlist[]) {
if (isFetching.value) return;
if (isMultiple.value) {
const currentSelectedIds = new Set(selection.map((item) => item.parkingId));
const otherPageSelections = multipleSelection.value.filter((item) => {
const isCurrentPageItem = tableData.value.some((row) => row.parkingId === item.parkingId);
if (isCurrentPageItem) {
return currentSelectedIds.has(item.parkingId);
} else {
return true;
}
});
// 3. 合并:其他页保留的 + 当前页新选中的
const finalSelection = [...otherPageSelections, ...selection];
// 4. 去重(以防万一)
const uniqueMap = new Map();
finalSelection.forEach((item) => uniqueMap.set(item.parkingId, item));
multipleSelection.value = Array.from(uniqueMap.values());
console.log(multipleSelection.value.map((item) => item.parkingId));
} else {
// --- 单选逻辑 ---
if (selection.length > 0) {
const lastSelected = selection[selection.length - 1];
multipleSelection.value = [lastSelected];
nextTick(() => {
if (selection.length > 1) {
multipleTableRef.value?.clearSelection();
multipleTableRef.value?.toggleRowSelection(lastSelected, true);
}
});
} else {
multipleSelection.value = [];
}
}
}
// ==============================================
// 最佳单选方案:点击行选中,无报错、无循环、最稳定
// ==============================================
// 优化 handleRowClick避免重复
function handleRowClick(row: BillUserlist) {
if (!multipleTableRef.value) return;
if (isMultiple.value) {
const index = multipleSelection.value.findIndex((item) => item.parkingId === row.parkingId);
if (index > -1) {
// 取消选中
multipleSelection.value.splice(index, 1);
multipleTableRef.value?.toggleRowSelection(row, false);
} else {
// 选中
multipleSelection.value.push(row);
multipleTableRef.value?.toggleRowSelection(row, true);
}
} else {
// 【单选模式】先清空,再选中当前行
multipleSelection.value = [];
multipleTableRef.value.clearSelection();
multipleSelection.value = [row];
multipleTableRef.value.toggleRowSelection(row, true);
}
}
// 重置查询
function resetQuery() {
queryParams.value = {
pageNum: 1,
pageSize: 10
};
getlist();
}
// 退出
function conback() {
dialog.value.dialogVisible = false;
resetQuery();
}
// ==============================================
// ✅ 核心修改:根据 returnvalue 返回对应的值
// ==============================================
function confirm() {
dialog.value.dialogVisible = false;
if (isMultiple.value) {
// 多选
if (returnvalue.value === '*') {
// ✅ 返回整个对象数组
emit('change', multipleSelection.value);
} else {
// 返回指定字段数组
emit(
'change',
multipleSelection.value.map((item) => item[returnvalue.value as keyof BillUserlist])
);
}
} else {
// 单选
if (returnvalue.value === '*') {
// ✅ 返回整个对象
emit('change', multipleSelection.value[0]);
} else {
// 返回指定字段
emit('change', multipleSelection.value[0][returnvalue.value as keyof BillUserlist]);
}
}
multipleSelection.value = [];
multipleTableRef.value.clearSelection();
}
defineExpose({
open: (arr: string[] = []) => {
userid.value = arr;
if (arr && arr.length > 0) {
multipleSelection.value = arr.map(
(id) =>
({
parkingId: id
}) as BillUserlist
);
console.log(multipleSelection.value);
} else {
multipleSelection.value = [];
}
getlist();
dialog.value.dialogVisible = true;
}
});
</script>
<style scoped lang="scss">
.residentBox {
width: 385px;
height: 35px;
line-height: 35px;
border: 1px solid #ccc;
border-radius: 5px;
padding: 0 10px;
.defaultbox {
display: flex;
align-items: center;
justify-content: space-between;
}
cursor: pointer;
&:hover {
background-color: #f4f8ff;
}
}
</style>

View File

@@ -0,0 +1,251 @@
<template>
<slot name="default" />
<el-dialog append-to-body v-model="dialog.dialogVisible" :title="dialog.dialogTitle" width="1050">
<el-table v-loading="loading" ref="multipleTableRef" :data="tableData" border @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="业主" align="center">
<template #default="scope">
<span>{{ scope.row.user_name ? scope.row.user_name : '---' }}</span>
</template>
</el-table-column>
<el-table-column label="手机号" align="center" prop="user_phone" />
</el-table>
<template #footer>
<div class="dialog-footer flex flex-items-center flex-justify-between">
<pagination
style="margin-top: 0"
layout="total, prev, pager, next, jumper"
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getlist"
/>
<div>
<el-button @click="conback">退出</el-button>
<el-button type="primary" @click="confirm"> 确定 </el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { BillUserlist } from '@/api/system/SdbBill';
import { ref, nextTick, getCurrentInstance, ComponentInternalInstance, toRefs } from 'vue';
import { getAppUser } from '@/api/system/cars';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const props = defineProps({
returnvalue: {
type: String,
default: '*'
},
titleName: {
type: String,
default: '选择业主'
},
/** 是否多选 false 单选 */
isMultiple: {
type: Boolean,
default: false
}
});
const { titleName, returnvalue } = toRefs(props);
const userid = ref([]);
// ✅ 手动设置默认值(替代 withDefaults
const emit = defineEmits<{
change: [value: string | number | (string | number)[] | BillUserlist | BillUserlist[]]; // 支持返回数组
}>();
const dialog = ref({
dialogVisible: false,
dialogTitle: titleName.value
});
const queryParams = ref({
pageNum: 1,
pageSize: 10
});
const multipleTableRef = ref();
const tableData = ref<BillUserlist[]>([]);
const total = ref(0);
const loading = ref(false);
const isFetching = ref(false);
// 存储选中项
const multipleSelection = ref<BillUserlist[]>([]);
const isMultiple = computed(() => props.isMultiple ?? false);
// 优化 handleUserSelection避免重复查找
const handleUserSelection = () => {
isFetching.value = true;
if (!multipleTableRef.value || !tableData.value.length) {
isFetching.value = false;
return;
}
const rowsToSelect = tableData.value.filter((row) => {
return multipleSelection.value.some((selected) => selected.parkingId === row.parkingId);
});
multipleTableRef.value.clearSelection();
if (rowsToSelect.length > 0) {
nextTick(() => {
rowsToSelect.forEach((row) => {
multipleTableRef.value?.toggleRowSelection(row, true);
});
isFetching.value = false;
});
} else {
isFetching.value = false;
}
};
// ====================== 方法 ======================
// 获取列表
function getlist() {
isFetching.value = true; // 加锁
loading.value = true;
getAppUser(queryParams.value)
.then((res) => {
tableData.value = res.rows;
total.value = res.total;
handleUserSelection();
})
.catch((error) => {
console.error('获取用户列表失败:', error);
})
.finally(() => {
loading.value = false;
isFetching.value = false; // 加锁
});
}
// ==============================================
// ✅ 新增:处理 selection-change多选专用
// ==============================================
function handleSelectionChange(selection: BillUserlist[]) {
if (isFetching.value) return;
if (isMultiple.value) {
const currentSelectedIds = new Set(selection.map((item) => item.parkingId));
const otherPageSelections = multipleSelection.value.filter((item) => {
const isCurrentPageItem = tableData.value.some((row) => row.parkingId === item.parkingId);
if (isCurrentPageItem) {
return currentSelectedIds.has(item.parkingId);
} else {
return true;
}
});
// 3. 合并:其他页保留的 + 当前页新选中的
const finalSelection = [...otherPageSelections, ...selection];
// 4. 去重(以防万一)
const uniqueMap = new Map();
finalSelection.forEach((item) => uniqueMap.set(item.parkingId, item));
multipleSelection.value = Array.from(uniqueMap.values());
console.log(multipleSelection.value.map((item) => item.parkingId));
} else {
// --- 单选逻辑 ---
if (selection.length > 0) {
const lastSelected = selection[selection.length - 1];
multipleSelection.value = [lastSelected];
nextTick(() => {
if (selection.length > 1) {
multipleTableRef.value?.clearSelection();
multipleTableRef.value?.toggleRowSelection(lastSelected, true);
}
});
} else {
multipleSelection.value = [];
}
}
}
// 重置查询
function resetQuery() {
queryParams.value = {
pageNum: 1,
pageSize: 10
};
getlist();
}
// 退出
function conback() {
dialog.value.dialogVisible = false;
resetQuery();
}
// ==============================================
// ✅ 核心修改:根据 returnvalue 返回对应的值
// ==============================================
function confirm() {
dialog.value.dialogVisible = false;
if (isMultiple.value) {
// 多选
if (returnvalue.value === '*') {
// ✅ 返回整个对象数组
emit('change', multipleSelection.value);
} else {
// 返回指定字段数组
emit(
'change',
multipleSelection.value.map((item) => item[returnvalue.value as keyof BillUserlist])
);
}
} else {
// 单选
if (returnvalue.value === '*') {
// ✅ 返回整个对象
emit('change', multipleSelection.value[0]);
} else {
// 返回指定字段
emit('change', multipleSelection.value[0][returnvalue.value as keyof BillUserlist]);
}
}
multipleSelection.value = [];
multipleTableRef.value.clearSelection();
}
defineExpose({
open: (arr: string[] = []) => {
userid.value = arr;
if (arr && arr.length > 0) {
multipleSelection.value = arr.map(
(id) =>
({
parkingId: id
}) as BillUserlist
);
console.log(multipleSelection.value);
} else {
multipleSelection.value = [];
}
getlist();
dialog.value.dialogVisible = true;
}
});
</script>
<style scoped lang="scss">
.residentBox {
width: 385px;
height: 35px;
line-height: 35px;
border: 1px solid #ccc;
border-radius: 5px;
padding: 0 10px;
.defaultbox {
display: flex;
align-items: center;
justify-content: space-between;
}
cursor: pointer;
&:hover {
background-color: #f4f8ff;
}
}
</style>

View File

@@ -1,8 +1,8 @@
<template>
<slot name="default" />
<el-dialog append-to-body v-model="dialog.dialogVisible" :title="dialog.dialogTitle" width="1250">
<nav class="flex flex-items-center mb-8">
<el-dialog append-to-body v-model="dialog.dialogVisible" :title="dialog.dialogTitle" width="1400">
<nav class="flex flex-items-center mb-8 justify-between">
<!-- <div class="flex flex-items-center">
<div style="width: 70px; text-align: right; padding-right: 10px">房屋类型</div>
<div class="flex-1">
@@ -12,32 +12,40 @@
</div>
</div> -->
<div class="flex flex-items-center">
<div style="width: 50px; text-align: right; padding-right: 10px">楼栋</div>
<div class="flex-1"></div>
<el-select @change="handleChangebuilding" clearable style="width: 140px" v-model="queryParams.buildingId">
<el-option v-for="(item, index) in buildinglist" :key="index" :label="item.buildingName" :value="item.id"></el-option>
</el-select>
</div>
<div class="flex flex-items-center mr-20px">
<div style="width: 50px; text-align: right; padding-right: 10px">单元</div>
<div class="flex-1">
<el-select @change="handleChangeUnit" clearable style="width: 140px" v-model="queryParams.unitNoId">
<el-option v-for="(item, index) in unitlist" :key="index" :label="item.unitNoName" :value="item.unitNoId"></el-option>
<div class="flex flex-items-center">
<div style="width: 50px; text-align: right; padding-right: 10px">楼栋</div>
<div class="flex-1"></div>
<el-select @change="handleChangebuilding" clearable style="width: 120px" v-model="queryParams.buildingId">
<el-option v-for="(item, index) in buildinglist" :key="index" :label="item.buildingName" :value="item.id"></el-option>
</el-select>
</div>
</div>
<div class="flex flex-items-center mr-20px">
<div style="width: 80px; text-align: right; padding-right: 10px">房屋类型</div>
<div class="flex-1">
<el-select clearable @change="handleHouseUseChange" style="width: 140px" v-model="queryParams.houseUseTypeId">
<el-option v-for="(item, index) in houseUseTypeList" :key="index" :label="item.name" :value="item.id"></el-option>
</el-select>
<div class="flex flex-items-center mr-20px">
<div style="width: 50px; text-align: right; padding-right: 10px">单元</div>
<div class="flex-1">
<el-select @change="handleChangeUnit" clearable style="width: 120px" v-model="queryParams.unitNoId">
<el-option v-for="(item, index) in unitlist" :key="index" :label="item.unitNoName" :value="item.unitNoId"></el-option>
</el-select>
</div>
</div>
</div>
<div class="flex flex-items-center mr-20px">
<div style="width: 80px; text-align: right; padding-right: 10px">业主名称</div>
<div class="flex-1">
<el-input clearable @clear="handleClear" v-model="queryParams.residentName" placeholder="查找业主"></el-input>
<div class="flex flex-items-center mr-20px">
<div style="width: 80px; text-align: right; padding-right: 10px">房屋类型</div>
<div class="flex-1">
<el-select clearable @change="handleHouseUseChange" style="width: 120px" v-model="queryParams.houseUseTypeId">
<el-option v-for="(item, index) in houseUseTypeList" :key="index" :label="item.name" :value="item.id"></el-option>
</el-select>
</div>
</div>
<div class="flex flex-items-center mr-20px">
<div style="width: 60px; text-align: right; padding-right: 10px">房屋号</div>
<div class="flex-1">
<el-input clearable @clear="handleClear" style="width: 120px" v-model="queryParams.houseNo" placeholder="请输入房屋号"></el-input>
</div>
</div>
<div class="flex flex-items-center mr-20px">
<div style="width: 80px; text-align: right; padding-right: 10px">业主名称</div>
<div class="flex-1">
<el-input clearable @clear="handleClear" style="width: 120px" v-model="queryParams.residentName" placeholder="请输入业主名称"></el-input>
</div>
</div>
</div>
<div>
@@ -45,14 +53,7 @@
<el-button @click="resetQuery">重置</el-button>
</div>
</nav>
<el-table
v-loading="loading"
@row-click="handleRowClick"
ref="multipleTableRef"
:data="tableData"
border
@selection-change="handleSelectionChange"
>
<el-table row-key="houseId" v-loading="loading" ref="multipleTableRef" :data="tableData" border @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="序号" align="center" width="55" type="index" />
<el-table-column label="小区" align="center" prop="villageName" />
@@ -87,7 +88,7 @@
<script setup lang="ts">
import { getBuildinglistAPI, getBuildingOnly, getHouselistAPI } from '@/api/system/house';
import { listHouseUseType } from '@/api/system/houseUse';
import { listHouseUseType, listHouseUseType_add_house } from '@/api/system/houseUse';
import { BillHouse, listBillHouse } from '@/api/system/SdbBill';
import { ref, watch, nextTick, getCurrentInstance, ComponentInternalInstance, toRefs } from 'vue';
@@ -128,7 +129,7 @@ const dialog = ref({
const queryParams = ref({
pageNum: 1,
pageSize: 10,
// houseUseTypeId: null,
houseNo: '',
buildingId: null,
unitNoId: null,
houseUseTypeId: null,
@@ -147,29 +148,35 @@ const isMultiple = computed(() => props.isMultiple ?? false);
// 优化 handleUserSelection避免重复查找
const handleUserSelection = () => {
if (!tableData.value.length) return;
multipleSelection.value = [];
if (showids.value.length > 0) {
const arr = showids.value;
arr.forEach((item) => {
const target = tableData.value.find((row) => row.houseId == item);
target && multipleSelection.value.push(target);
});
isFetching.value = true;
if (!multipleTableRef.value || !tableData.value.length) {
isFetching.value = false;
return;
}
// 1. 获取当前页所有行的 houseId 集合,用于快速判断
// const currentPageIds = new Set(tableData.value.map((item) => item.houseId));
const rowsToSelect = tableData.value.filter((row) => {
return multipleSelection.value.some((selected) => selected.houseId === row.houseId);
});
multipleTableRef.value.clearSelection();
if (rowsToSelect.length > 0) {
nextTick(() => {
if (multipleTableRef.value) {
multipleTableRef.value.clearSelection();
multipleSelection.value.forEach((item) => {
multipleTableRef.value.toggleRowSelection(item, true);
});
}
rowsToSelect.forEach((row) => {
multipleTableRef.value?.toggleRowSelection(row, true);
});
isFetching.value = false;
});
} else {
isFetching.value = false;
}
};
// ====================== 方法 ======================
const houseUseTypeList = ref([]);
const isFetching = ref(false);
// 获取列表
function getlist() {
isFetching.value = true; // 🔒 加锁:禁止 selection-change 更新数据
loading.value = true;
listBillHouse(queryParams.value)
.then((res) => {
@@ -182,6 +189,7 @@ function getlist() {
})
.finally(() => {
loading.value = false;
isFetching.value = false;
});
}
@@ -225,34 +233,44 @@ function handleChangeUnit() {
// ✅ 新增:处理 selection-change多选专用
// ==============================================
function handleSelectionChange(selection: BillHouse[]) {
// 🛑 如果正在获取数据/恢复状态,直接返回,不要更新 multipleSelection
if (isFetching.value) return;
if (isMultiple.value) {
multipleSelection.value = selection;
}
}
// ==============================================
// 最佳单选方案:点击行选中,无报错、无循环、最稳定
// ==============================================
// 优化 handleRowClick避免重复
function handleRowClick(row: BillHouse) {
if (!multipleTableRef.value) return;
if (isMultiple.value) {
const index = multipleSelection.value.findIndex((item) => item.houseId === row.houseId);
if (index > -1) {
// 取消选中
multipleSelection.value.splice(index, 1);
multipleTableRef.value?.toggleRowSelection(row, false);
} else {
// 选中
multipleSelection.value.push(row);
multipleTableRef.value?.toggleRowSelection(row, true);
}
// 1. 获取当前表格中所有选中行的 ID
const currentSelectedIds = new Set(selection.map((item) => item.houseId));
const otherPageSelections = multipleSelection.value.filter((item) => {
// 判断 item 是否属于当前页
const isCurrentPageItem = tableData.value.some((row) => row.houseId === item.houseId);
if (isCurrentPageItem) {
// 如果是当前页的数据,检查是否在当前 selection 中
return currentSelectedIds.has(item.houseId);
} else {
// 如果不是当前页的数据,保留(假设是之前页选的)
return true;
}
});
// 去重合并
const finalSelection = [...otherPageSelections, ...selection];
// 再次去重以防万一
const uniqueMap = new Map();
finalSelection.forEach((item) => uniqueMap.set(item.houseId, item));
multipleSelection.value = Array.from(uniqueMap.values());
console.log(multipleSelection.value.map((item) => item.houseId));
} else {
// 【单选模式】先清空,再选中当前行
multipleSelection.value = [];
multipleTableRef.value.clearSelection();
multipleSelection.value = [row];
multipleTableRef.value.toggleRowSelection(row, true);
// ✅ 补充单选模式下selection-change 触发时,直接取最新的一项
// 这样配合 handleRowClick 的 clearSelection + toggle 就能实现完美单选
if (selection.length > 0) {
const lastSelected = selection[selection.length - 1];
multipleSelection.value = [lastSelected];
nextTick(() => {
if (selection.length > 1) {
multipleTableRef.value?.clearSelection();
multipleTableRef.value?.toggleRowSelection(lastSelected, true);
}
});
} else {
multipleSelection.value = [];
}
}
}
// 重置查询
@@ -263,6 +281,7 @@ function resetQuery() {
houseUseTypeId: null,
buildingId: null,
unitNoId: null,
houseNo: '',
residentName: ''
};
getlist();
@@ -308,15 +327,20 @@ function confirm() {
defineExpose({
open: (showidArray: string[] = []) => {
multipleSelection.value = [];
showids.value = showidArray;
if (showidArray && showidArray.length > 0) {
multipleSelection.value = showidArray.map(
(id) =>
({
houseId: id
}) as BillHouse
);
}
getlist();
dialog.value.dialogVisible = true;
listHouseUseType({
pageNum: 1,
pageSize: 99999
}).then((res) => {
houseUseTypeList.value = res.rows;
listHouseUseType_add_house().then((res) => {
houseUseTypeList.value = res.data;
});
handleChangeHouse();
}

View File

@@ -28,8 +28,7 @@
</div>
</nav>
<!-- 关键去掉 @selection-change改用 row-click 实现单选永不报错 -->
<el-table ref="multipleTableRef" :data="tableData" border @row-click="handleRowClick" @selection-change="handleSelectionChange">
<el-table ref="multipleTableRef" row-key="id" :data="tableData" border @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="序号" align="center" width="55" type="index" />
<el-table-column label="姓名" align="center" prop="name" />
@@ -38,6 +37,11 @@
<dict-tag :options="com_resident_type" :value="row.type" />
</template>
</el-table-column>
<el-table-column label="与业主关系" align="center" width="95" prop="ownerRelationship">
<template #default="{ row }">
<dict-tag :options="com_resident_relationship" :value="row.ownerRelationship" />
</template>
</el-table-column>
<el-table-column label="状态" align="center" width="85" prop="status">
<template #default="{ row }">
<dict-tag :options="com_certification_status" :value="row.status" />
@@ -45,7 +49,7 @@
</el-table-column>
<el-table-column label="证件号码" align="center" prop="idCard" />
<el-table-column label="手机号码" align="center" prop="phone" />
<el-table-column label="房间" align="center">
<el-table-column label="房间" show-overflow-tooltip align="center">
<template #default="{ row }">
{{ row.housePath }}
</template>
@@ -75,7 +79,7 @@
import { listResident } from '@/api/system/resident';
import { ResidentVO } from '@/api/system/resident/type';
import { getHousePathById } from '@/utils/house';
import { ref, watch, nextTick, getCurrentInstance, ComponentInternalInstance, toRefs } from 'vue';
import { ref, nextTick, getCurrentInstance, ComponentInternalInstance, toRefs } from 'vue';
import { useHouseStore } from '@/store/modules/house';
const houseStore = useHouseStore();
@@ -111,7 +115,7 @@ const props = defineProps<{
// ✅ 手动设置默认值(替代 withDefaults
const isMultiple = computed(() => props.isMultiple ?? false);
const returnvalue = computed(() => props.returnvalue ?? 'id'); // 👈 默认 userId
const returnvalue = computed(() => props.returnvalue ?? '*'); // 👈 默认 userId
const { userid } = toRefs(props);
@@ -140,50 +144,66 @@ const total = ref(0);
const loading = ref(false);
// ==============================================
// ✅ 新增:处理 selection-change(多选专用)
// ✅ 优化:处理 selection-change
// ==============================================
function handleSelectionChange(selection: tableType[]) {
if (isMultiple.value) {
multipleSelection.value = selection;
}
}
// 多选逻辑:合并当前页选中项和之前页选中项
const currentSelectedIds = new Set(selection.map((item) => item.id));
// ==============================================
// 最佳单选方案:点击行选中,无报错、无循环、最稳定
// ==============================================
function handleRowClick(row) {
if (isMultiple.value) {
// 多选:点击行切换选中状态
const isSelected = multipleSelection.value.some((item) => item.id === row.id);
if (isSelected) {
multipleSelection.value = multipleSelection.value.filter((item) => item.id !== row.id);
multipleTableRef.value?.toggleRowSelection(row, false);
} else {
multipleSelection.value.push(row);
multipleTableRef.value?.toggleRowSelection(row, true);
}
// 过滤掉当前页未选中的旧数据
const otherPageSelections = multipleSelection.value.filter((item) => {
const isCurrentPageItem = tableData.value.some((row) => row.id === item.id);
if (isCurrentPageItem) {
return currentSelectedIds.has(item.id);
}
return true; // 保留其他页的数据
});
// 合并当前页新选中的数据
const finalSelection = [...otherPageSelections, ...selection];
// 去重
const uniqueMap = new Map();
finalSelection.forEach((item) => uniqueMap.set(item.id, item));
multipleSelection.value = Array.from(uniqueMap.values());
} else {
// 单选:原有逻辑保持不变
multipleTableRef.value?.clearSelection();
multipleTableRef.value?.toggleRowSelection(row, true);
multipleSelection.value = [row];
// 单选逻辑:始终只保留最后一个选中的项
if (selection.length > 0) {
// 获取最后选中的那一项
const lastSelected = selection[selection.length - 1];
multipleSelection.value = [lastSelected];
// 关键:如果 selection 长度大于1比如之前选了一个现在又选一个el-table可能暂时上报两个
// 我们需要手动清除表格中其他的勾选状态,只保留最后一个
nextTick(() => {
if (selection.length > 1) {
multipleTableRef.value?.clearSelection();
multipleTableRef.value?.toggleRowSelection(lastSelected, true);
}
});
} else {
multipleSelection.value = [];
}
}
}
// ==============================================
// ✅ 修改:支持多选回显
// ==============================================
// ✅ 优化:回显逻辑
const handleUserSelection = () => {
if (!userid.value || !tableData.value.length) return;
if (!multipleTableRef.value) return;
// 先清空所有选择
multipleTableRef.value.clearSelection();
multipleSelection.value = [];
if (!props.userid && props.userid !== 0) return;
nextTick(() => {
multipleTableRef.value?.clearSelection();
if (isMultiple.value && Array.isArray(userid.value)) {
if (isMultiple.value && Array.isArray(props.userid)) {
// 多选回显
const selectedItems: tableType[] = [];
userid.value.forEach((id) => {
const findItem = tableData.value.find((item) => item.userId == id);
props.userid.forEach((id) => {
const findItem = tableData.value.find((item) => item.id == id || item.userId == id);
if (findItem) {
selectedItems.push(findItem);
multipleTableRef.value?.toggleRowSelection(findItem, true);
@@ -191,38 +211,43 @@ const handleUserSelection = () => {
});
multipleSelection.value = selectedItems;
} else {
// 单选回显(原有逻辑)
const findItem = tableData.value.find((item) => item.id == userid.value);
// 单选回显
// 注意props.userid 可能是单个 ID
const targetId = Array.isArray(props.userid) ? props.userid[0] : props.userid;
const findItem = tableData.value.find((item) => item.id == targetId || item.userId == targetId);
if (findItem) {
multipleSelection.value = [findItem];
multipleTableRef.value?.toggleRowSelection(findItem, true);
multipleSelection.value = [findItem];
}
}
});
};
watch(userid, handleUserSelection, { immediate: true });
watch(tableData, handleUserSelection);
// 打开选择框
function choiceResidentFun() {
// 初始化
getlist();
dialog.value.dialogVisible = true;
}
// 获取列表
function getlist() {
loading.value = true;
listResident(queryParams.value).then((res) => {
tableData.value = res.rows;
tableData.value.forEach((item) => {
if (item.houseId !== null) {
const houselist = getHousePathById(treedata.value, item.houseId, 'label');
item.housePath = houselist?.join('-') || '';
}
listResident(queryParams.value)
.then((res) => {
tableData.value = res.rows;
tableData.value.forEach((item) => {
if (item.houseId !== null) {
const houselist = getHousePathById(treedata.value, item.houseId, 'label');
item.housePath = houselist?.join('-') || '';
}
});
handleUserSelection();
total.value = res.total;
})
.finally(() => {
loading.value = false;
});
total.value = res.total;
loading.value = false;
});
}
// 重置查询
@@ -268,6 +293,7 @@ function confirm() {
}
} else {
// 单选
console.log(multipleSelection.value[0], multipleSelection.value);
if (returnvalue.value === '*') {
// ✅ 返回整个对象
emit('change', multipleSelection.value[0]);
@@ -276,13 +302,21 @@ function confirm() {
emit('change', multipleSelection.value[0][returnvalue.value as keyof tableType]);
}
}
} else {
emit('change', null);
}
}
// 初始化
getlist();
defineExpose({
open: () => {
open: (arr) => {
if (arr && arr.length > 0) {
multipleSelection.value = arr.map((id) => ({
id: id
}));
} else {
multipleSelection.value = [];
}
getlist();
dialog.value.dialogVisible = true;
}
});

View File

@@ -2,12 +2,12 @@
<slot v-if="$slots.default" />
<div v-else class="residentBox" @click="choiceResidentFun">
<div class="defaultbox" v-if="multipleSelection.length === 0">
<div class="defaultbox" v-if="multipleSelection.length === 0 && props.username === null">
<span>请选择</span>
<el-icon><Search /></el-icon>
</div>
<div v-else class="overflow-auto overflow-hidden w-full text-ellipsis text-nowrap">
<span>姓名{{ multipleSelection[0]?.name }}</span>
<span>姓名{{ multipleSelection[0]?.nickName ?? props.username }}</span>
</div>
</div>
@@ -30,7 +30,7 @@
<el-button @click="resetQuery">重置</el-button>
</div>
</nav>
<el-table @row-click="handleRowClick" ref="multipleTableRef" :data="tableData" border @selection-change="handleSelectionChange">
<el-table ref="multipleTableRef" :data="tableData" border @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="序号" align="center" width="55" type="index" />
<el-table-column label="姓名" align="center" prop="nickName" />
@@ -80,9 +80,13 @@ const props = defineProps({
type: String,
default: ''
},
username: {
type: String,
default: null
},
returnvalue: {
type: String,
default: 'id'
default: 'userId'
},
titleName: {
type: String,
@@ -121,6 +125,7 @@ const multipleTableRef = ref();
const tableData = ref<tableType[]>([]);
const total = ref(0);
const loading = ref(false);
const isFetching = ref(false); // 加锁,防止重复触发 selection-change
// 存储选中项
const multipleSelection = ref<tableType[]>([]);
@@ -129,29 +134,31 @@ const isMultiple = computed(() => props.isMultiple ?? false);
// 优化 handleUserSelection避免重复查找
const handleUserSelection = () => {
if (!userid.value || !tableData.value.length) return;
multipleSelection.value = [];
if (userid.value.trim() !== '') {
const arr = userid.value.split(',');
arr.forEach((item) => {
const target = tableData.value.find((row) => row.userId == item);
target && multipleSelection.value.push(target);
});
isFetching.value = true;
console.log('获取列表');
if (tableData.value.length <= 0 || !multipleTableRef.value) {
isFetching.value = false;
return;
}
const rowsToSelect = tableData.value.filter((row) => {
return multipleSelection.value.some((selected) => selected.userId === row.userId);
});
multipleTableRef.value.clearSelection();
if (rowsToSelect.length > 0) {
nextTick(() => {
if (multipleTableRef.value) {
multipleTableRef.value.clearSelection();
multipleSelection.value.forEach((item) => {
multipleTableRef.value.toggleRowSelection(item, true);
});
}
rowsToSelect.forEach((row) => {
multipleTableRef.value?.toggleRowSelection(row, true);
});
isFetching.value = false;
});
} else {
isFetching.value = false;
}
};
// 监听
// 监听优化:只在 userid 变化时触发
// watch(userid, handleUserSelection, { immediate: true });
watch(userid, handleUserSelection, { immediate: true });
// watch(tableData, () => {
// if (userid.value) handleUserSelection(); // 仅在有 userid 时重新处理
// });
@@ -160,6 +167,17 @@ const handleUserSelection = () => {
// 打开选择框
function choiceResidentFun() {
dialog.value.dialogVisible = true;
multipleSelection.value = []; // 打开时清空之前的选择
if (typeof props.userid === 'string' && props.userid.trim() !== '') {
multipleSelection.value = [
{
userId: props.userid
} as ResidentVO
];
} else if (Array.isArray(props.userid)) {
multipleSelection.value = props.userid.map((id) => ({ userId: id })) as tableType[];
}
getlist();
}
// 获取列表
@@ -167,8 +185,9 @@ function getlist() {
loading.value = true;
getUserByRoleKey(userType.value)
.then((res) => {
tableData.value = res.data;
tableData.value = res.rows;
total.value = res.total;
handleUserSelection();
})
.catch((error) => {
console.error('获取用户列表失败:', error);
@@ -177,40 +196,45 @@ function getlist() {
loading.value = false;
});
}
// ==============================================
// ✅ 新增:处理 selection-change多选专用
// ==============================================
function handleSelectionChange(selection: tableType[]) {
if (isFetching.value) return;
if (isMultiple.value) {
multipleSelection.value = selection;
}
}
// ==============================================
// 最佳单选方案:点击行选中,无报错、无循环、最稳定
// ==============================================
// 优化 handleRowClick避免重复
function handleRowClick(row: tableType) {
if (!multipleTableRef.value) return;
if (isMultiple.value) {
const index = multipleSelection.value.findIndex((item) => item.id === row.id);
if (index > -1) {
// 取消选中
multipleSelection.value.splice(index, 1);
multipleTableRef.value?.toggleRowSelection(row, false);
} else {
// 选中
multipleSelection.value.push(row);
multipleTableRef.value?.toggleRowSelection(row, true);
}
const currentSelectedIds = new Set(selection.map((item) => item.id));
const otherPageSelections = multipleSelection.value.filter((item) => {
const isCurrentPageItem = tableData.value.some((row) => row.id === item.id);
if (isCurrentPageItem) {
return currentSelectedIds.has(item.id);
} else {
return true;
}
});
// 3. 合并:其他页保留的 + 当前页新选中的
const finalSelection = [...otherPageSelections, ...selection];
// 4. 去重(以防万一)
const uniqueMap = new Map();
finalSelection.forEach((item) => uniqueMap.set(item.id, item));
multipleSelection.value = Array.from(uniqueMap.values());
console.log(multipleSelection.value.map((item) => item.id));
} else {
// 【单选模式】先清空,再选中当前行
multipleSelection.value = [];
multipleTableRef.value.clearSelection();
multipleSelection.value = [row];
multipleTableRef.value.toggleRowSelection(row, true);
// --- 单选逻辑 ---
if (selection.length > 0) {
const lastSelected = selection[selection.length - 1];
multipleSelection.value = [lastSelected];
nextTick(() => {
if (selection.length > 1) {
multipleTableRef.value?.clearSelection();
multipleTableRef.value?.toggleRowSelection(lastSelected, true);
}
});
} else {
multipleSelection.value = [];
}
}
console.log('handleSelectionChange', selection);
}
// 重置查询
function resetQuery() {
@@ -250,23 +274,21 @@ function confirm() {
// 单选
if (returnvalue.value === '*') {
// ✅ 返回整个对象
emit('change', multipleSelection.value[0]);
emit('change', multipleSelection.value[multipleSelection.value.length - 1]);
} else {
// 返回指定字段
emit('change', multipleSelection.value[0][returnvalue.value as keyof tableType]);
emit('change', multipleSelection.value[multipleSelection.value.length - 1][returnvalue.value as keyof tableType]);
}
}
multipleSelection.value = [];
multipleTableRef.value.clearSelection();
}
// 初始化
getlist();
onMounted(() => {
// 初始化
});
defineExpose({
open: () => {
dialog.value.dialogVisible = true;
handleUserSelection();
getlist();
}
});
</script>

View File

@@ -1,7 +1,7 @@
<template>
<slot name="default" />
<el-dialog append-to-body v-model="dialog.dialogVisible" :title="dialog.dialogTitle" width="1050">
<el-table @row-click="handleRowClick" ref="multipleTableRef" :data="tableData" border @selection-change="handleSelectionChange">
<el-table ref="multipleTableRef" :data="tableData" border @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="序号" align="center" width="55" type="index" />
<el-table-column label="业主" align="center">
@@ -49,7 +49,7 @@ const { sys_user_sex } = toRefs<any>(proxy?.useDict('sys_user_sex'));
const props = defineProps({
returnvalue: {
type: String,
default: 'houseId'
default: 'residentId'
},
titleName: {
type: String,
@@ -84,7 +84,7 @@ const multipleTableRef = ref();
const tableData = ref<BillUserlist[]>([]);
const total = ref(0);
const loading = ref(false);
const isFetching = ref(false);
// 存储选中项
const multipleSelection = ref<BillUserlist[]>([]);
@@ -92,29 +92,32 @@ const isMultiple = computed(() => props.isMultiple ?? false);
// 优化 handleUserSelection避免重复查找
const handleUserSelection = () => {
if (!tableData.value.length) return;
multipleSelection.value = [];
const arr = userid.value;
if (!arr.length) return;
arr.forEach((item) => {
const target = tableData.value.find((row) => row.residentId == item);
target && multipleSelection.value.push(target);
isFetching.value = true;
if (!multipleTableRef.value || !tableData.value.length) {
isFetching.value = false;
return;
}
const rowsToSelect = tableData.value.filter((row) => {
return multipleSelection.value.some((selected) => selected.residentId === row.residentId);
});
nextTick(() => {
if (multipleTableRef.value) {
multipleTableRef.value.clearSelection();
multipleSelection.value.forEach((item) => {
multipleTableRef.value.toggleRowSelection(item, true);
multipleTableRef.value.clearSelection();
if (rowsToSelect.length > 0) {
nextTick(() => {
rowsToSelect.forEach((row) => {
multipleTableRef.value?.toggleRowSelection(row, true);
});
}
});
isFetching.value = false;
});
} else {
isFetching.value = false;
}
};
// ====================== 方法 ======================
// 获取列表
function getlist() {
isFetching.value = true; // 加锁
loading.value = true;
queryResidentList_holder(queryParams.value)
.then((res) => {
@@ -127,6 +130,7 @@ function getlist() {
})
.finally(() => {
loading.value = false;
isFetching.value = false; // 加锁
});
}
@@ -152,8 +156,31 @@ function handleChangebuilding(val: string) {
// ✅ 新增:处理 selection-change多选专用
// ==============================================
function handleSelectionChange(selection: BillUserlist[]) {
if (isFetching.value) return;
if (isMultiple.value) {
multipleSelection.value = selection;
const currentSelectedIds = new Set(selection.map((item) => item.residentId));
const otherPageSelections = multipleSelection.value.filter((item) => {
const isCurrentPageItem = tableData.value.some((row) => row.residentId === item.residentId);
if (isCurrentPageItem) {
return currentSelectedIds.has(item.residentId);
} else {
return true;
}
});
// 3. 合并:其他页保留的 + 当前页新选中的
const finalSelection = [...otherPageSelections, ...selection];
// 4. 去重(以防万一)
const uniqueMap = new Map();
finalSelection.forEach((item) => uniqueMap.set(item.residentId, item));
multipleSelection.value = Array.from(uniqueMap.values());
console.log(multipleSelection.value.map((item) => item.residentId));
} else {
// --- 单选逻辑 ---
if (selection.length > 0) {
multipleSelection.value = [selection[selection.length - 1]];
} else {
multipleSelection.value = [];
}
}
}
// ==============================================
@@ -232,6 +259,17 @@ function confirm() {
defineExpose({
open: (arr: string[] = []) => {
userid.value = arr;
if (arr && arr.length > 0) {
multipleSelection.value = arr.map(
(id) =>
({
residentId: id
}) as BillUserlist
);
} else {
multipleSelection.value = [];
}
getlist();
dialog.value.dialogVisible = true;
}

View File

@@ -22,17 +22,24 @@
@paste="onPaste"
@focus="activeIndex = index"
@blur="onBlur"
@compositionend="onCompositionEnd(index)"
/>
</div>
</div>
<!-- 错误提示 Element Plus 原生完全一致 -->
<!-- 错误提示 -->
<transition name="el-fade-in-linear">
<div v-if="hasError && errorMessage" class="plate-error">
{{ errorMessage }}
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { LicensePlateValidator } from '@/utils/Reg';
import { ref, watch, nextTick } from 'vue';
import { LicensePlateValidator } from '@/utils/Reg';
// 省份简称
const PROVINCES = [
'京',
'津',
@@ -66,7 +73,12 @@ const PROVINCES = [
'宁',
'琼'
];
const VALID_CHARS = /^[A-HJ-NP-Z0-9]$/i;
// 合法基础字符:字母 (排除I,O) 和 数字
const VALID_CHAR_REGEX = /^[A-HJ-NP-Z0-9]$/i;
// 车牌特殊结尾字符
const SPECIAL_CHARS = ['警', '学', '挂', '临', '超', '港', '澳'];
const props = defineProps<{
modelValue: string;
@@ -85,22 +97,23 @@ const chars = ref<string[]>(Array(8).fill(''));
const hasError = ref(false);
const errorMessage = ref('');
// 外部值同步
// 外部值同步到内部
watch(
() => props.modelValue,
(val) => {
const currentValue = chars.value.join('').trim();
if (val !== currentValue) {
const cleanVal = (val || '').toUpperCase().trim();
const currentJoined = chars.value.join('').trim();
if (cleanVal !== currentJoined) {
const newChars = Array(8).fill('');
if (val) {
val
.toUpperCase()
.split('')
.forEach((c, i) => {
if (i < 8) newChars[i] = c;
});
if (cleanVal) {
const arr = cleanVal.split('');
for (let i = 0; i < Math.min(arr.length, 8); i++) {
newChars[i] = arr[i];
}
}
chars.value = newChars;
if (cleanVal) clearError();
}
},
{ immediate: true }
@@ -109,43 +122,76 @@ watch(
// 内部值同步到外部
watch(
chars,
() => {
const value = chars.value.join('').trim();
(newChars) => {
const value = newChars.join('').trim();
emit('update:modelValue', value);
emit('change', value);
// 输入时清除错误
clearError();
if (hasError.value) {
clearError();
}
},
{ deep: true }
);
function onInput(index: number) {
const char = chars.value[index].toUpperCase();
let char = chars.value[index];
if (index === 0 && !PROVINCES.includes(char)) {
chars.value[index] = '';
return;
if (!char) return;
// 统一转大写 (汉字不受影响)
char = char.toUpperCase();
let isValid = false;
// 1. 第一位:必须是省份
if (index === 0) {
if (PROVINCES.includes(char)) {
isValid = true;
}
}
// 2. 特殊字符处理 (警/学/挂/临/超/港/澳)
else if (SPECIAL_CHARS.includes(char)) {
// 特殊字符通常出现在最后一位,或者倒数第二位(如临时车牌的"临"可能在最后,也可能在倒数第二?不,通常是最后)
// 为了用户体验,允许在任何非第一位的位置输入,但最终校验会判断位置是否正确
isValid = true;
}
// 3. 常规字符:字母或数字
else {
if (VALID_CHAR_REGEX.test(char)) {
isValid = true;
}
}
if (index > 0 && !VALID_CHARS.test(char)) {
if (!isValid) {
chars.value[index] = '';
return;
}
} else {
chars.value[index] = char;
chars.value[index] = char;
if (char && index < 7) {
nextTick(() => inputRefs.value[index + 1]?.focus());
// 如果输入的是特殊字符,通常意味着车牌结束,不再自动跳转
// 如果输入的是常规字符,且不是最后一格,则跳转
if (!SPECIAL_CHARS.includes(char) && char && index < 7) {
nextTick(() => {
inputRefs.value[index + 1]?.focus();
});
}
}
}
function onCompositionEnd(index: number) {
onInput(index);
}
function onKeydown(e: KeyboardEvent, index: number) {
if (e.key === 'Backspace' && !chars.value[index] && index > 0) {
e.preventDefault();
nextTick(() => {
inputRefs.value[index - 1]?.focus();
inputRefs.value[index - 1]?.select();
});
if (e.key === 'Backspace') {
if (!chars.value[index] && index > 0) {
e.preventDefault();
nextTick(() => {
inputRefs.value[index - 1]?.focus();
});
}
return;
}
if (e.key === 'ArrowLeft' && index > 0) {
@@ -160,20 +206,37 @@ function onKeydown(e: KeyboardEvent, index: number) {
function onPaste(e: ClipboardEvent) {
e.preventDefault();
const text = e.clipboardData?.getData('text')?.trim().toUpperCase() || '';
const text = e.clipboardData?.getData('text')?.toUpperCase().trim() || '';
if (!text) return;
const newChars = Array(8).fill('');
text.split('').forEach((c, i) => {
if (i >= 8) return;
if (i === 0 && PROVINCES.includes(c)) newChars[i] = c;
if (i > 0 && VALID_CHARS.test(c)) newChars[i] = c;
const filledChars = [...chars.value];
let insertPos = activeIndex.value >= 0 ? activeIndex.value : 0;
for (let i = 0; i < text.length; i++) {
if (insertPos >= 8) break;
const char = text[i];
let canInsert = false;
if (insertPos === 0) {
if (PROVINCES.includes(char)) canInsert = true;
} else if (SPECIAL_CHARS.includes(char)) {
canInsert = true;
} else {
if (VALID_CHAR_REGEX.test(char)) canInsert = true;
}
if (canInsert) {
filledChars[insertPos] = char;
insertPos++;
}
}
chars.value = filledChars;
nextTick(() => {
const focusIdx = Math.min(insertPos, 7);
inputRefs.value[focusIdx]?.focus();
});
chars.value = newChars;
const lastIndex = Math.min(text.length - 1, 6);
nextTick(() => inputRefs.value[lastIndex + 1]?.focus());
}
function onBlur() {
@@ -181,49 +244,50 @@ function onBlur() {
const hasFocus = inputRefs.value.some((input) => document.activeElement === input);
if (!hasFocus) {
activeIndex.value = -1;
validate();
}
}, 10);
}, 200);
}
// ✅ 手动验证方法(核心)
function validate(): { valid: boolean; message?: string } {
const value = chars.value.join('').trim();
// 必填验证
if (props.required && !value) {
hasError.value = true;
errorMessage.value = '请输入车牌号';
return { valid: false, message: errorMessage.value };
setError('请输入车牌号');
return { valid: false, message: '请输入车牌号' };
}
// 格式验证
if (value && !isValidPlate(value)) {
hasError.value = true;
errorMessage.value = '请输入正确的车牌号格式';
return { valid: false, message: errorMessage.value };
if (!value) {
clearError();
return { valid: true };
}
if (!LicensePlateValidator.isValidPlate(value)) {
setError('车牌号格式不正确');
return { valid: false, message: '车牌号格式不正确' };
}
// 验证通过
clearError();
return { valid: true };
}
// 车牌格式验证(内置,不依赖外部工具)
function isValidPlate(plate: string): boolean {
return LicensePlateValidator.isValidPlate(plate);
function setError(msg: string) {
hasError.value = true;
errorMessage.value = msg;
}
// 清除错误
function clearError() {
hasError.value = false;
errorMessage.value = '';
}
// 暴露方法
defineExpose({
focus: () => nextTick(() => inputRefs.value[0]?.focus()),
clear: () => {
chars.value = Array(8).fill('');
clearError();
emit('update:modelValue', '');
emit('change', '');
},
validate,
clearError
@@ -235,6 +299,7 @@ defineExpose({
display: flex;
flex-direction: column;
gap: 4px;
width: fit-content;
}
.plate_line {
@@ -242,15 +307,17 @@ defineExpose({
align-items: center;
gap: 6px;
}
.plate-item {
width: 24px;
height: 24px;
line-height: 24px;
width: 32px;
height: 32px;
border: 1px solid #dcdfe6;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
background-color: #fff;
}
.plate-item.active {
@@ -265,6 +332,7 @@ defineExpose({
.plate-item.disabled {
background-color: #f5f7fa;
cursor: not-allowed;
color: #a8abb2;
}
.plate-item input {
@@ -272,10 +340,13 @@ defineExpose({
height: 100%;
border: none;
text-align: center;
font-size: 15px;
font-size: 16px;
font-weight: 500;
background: transparent;
outline: none;
padding: 0;
margin: 0;
color: inherit;
}
.plate-error {
@@ -283,5 +354,6 @@ defineExpose({
font-size: 12px;
line-height: 1.5;
margin-top: 2px;
min-height: 18px;
}
</style>