Merge remote-tracking branch 'origin/master' into feature/bfi-api-rate

# Conflicts:
#	src/views/vehicle/customer-archive.vue
This commit is contained in:
刘泉佳
2026-09-24 03:15:23 +08:00
114 changed files with 10215 additions and 2861 deletions
+65 -21
View File
@@ -10,12 +10,8 @@
<el-button type="primary" :loading="searching" @click="searchKeyword">搜索</el-button>
</div>
<div class="address-map-picker__content">
<map-search-results
v-if="searchResults.length"
:results="searchResults"
@select="selectSearchResult"
/>
<div ref="map" class="address-map-picker__map"></div>
<map-search-results :results="searchResults" @select="selectSearchResult" />
</div>
<div class="address-map-picker__info">{{ status }}</div>
<template #footer>
@@ -28,6 +24,8 @@
</template>
<script>
import { normalizeMapSearchResults } from '@/utils/map-search';
const AMAP_KEY = '653b7cf105ad7fb8ec9b2f5198ade315';
const AMAP_SECURITY_CODE = '5aab65c632e48ebae0d0e28f5b28e21a';
let amapLoader;
@@ -42,6 +40,14 @@ export default {
type: String,
default: '',
},
longitude: {
type: [String, Number],
default: '',
},
latitude: {
type: [String, Number],
default: '',
},
},
emits: ['update:modelValue', 'confirm'],
data() {
@@ -66,9 +72,11 @@ export default {
this.visible = value;
if (value) {
this.keyword = this.address || '';
this.selected = {};
this.status = '可搜索地址或点击地图选点';
this.searchResults = [];
this.selected = this.buildInitialSelection();
this.status = this.selected.longitude
? this.selected.address || '已选点,可确认回填'
: '可搜索地址或点击地图选点';
}
},
},
@@ -82,6 +90,18 @@ export default {
}
},
methods: {
buildInitialSelection() {
const longitude = Number(this.longitude);
const latitude = Number(this.latitude);
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) {
return {};
}
return {
longitude,
latitude,
address: this.address || '',
};
},
loadAmap() {
if (window.AMap && window.AMap.Map) {
return Promise.resolve();
@@ -110,15 +130,27 @@ export default {
return new Promise(resolve => {
this.$nextTick(() => {
if (!this.$refs.map) return resolve(null);
const initial = this.buildInitialSelection();
const center =
Number.isFinite(initial.longitude) && Number.isFinite(initial.latitude)
? [initial.longitude, initial.latitude]
: [116.40769, 39.89945];
if (!this.amap) {
this.amap = new window.AMap.Map(this.$refs.map, {
center: [116.40769, 39.89945],
zoom: 11,
center,
zoom: initial.longitude ? 14 : 11,
});
this.amap.on('click', event => this.pickPoint(event.lnglat));
} else {
this.amap.resize();
this.clearMarker();
this.amap.setZoomAndCenter(initial.longitude ? 14 : 11, center);
}
if (initial.longitude) {
const point = new window.AMap.LngLat(initial.longitude, initial.latitude);
this.renderMarker(point);
this.selected = { ...initial };
this.status = initial.address || '已选点,可确认回填';
}
resolve(this.amap);
});
@@ -180,19 +212,14 @@ export default {
.then(result => {
if (!result) return;
const geocodes = result.geocodes || (result.location ? [result] : []);
this.searchResults = geocodes.map((item, index) => ({
id: item.id || index,
name: item.formattedAddress || item.address || keyword,
address: item.formattedAddress || item.address || keyword,
location: item.location,
}));
const point = geocodes[0]?.location || result.location;
this.searchResults = normalizeMapSearchResults(geocodes, keyword);
const point = this.searchResults[0]?.location || result.location;
if (!point) {
throw new Error('未找到匹配地址');
}
return this.$nextTick().then(() => {
this.amap?.resize();
this.pickPoint(point, geocodes[0]?.formattedAddress || keyword);
this.pickPoint(point, this.searchResults[0]?.address || keyword);
});
})
.catch(() => {
@@ -254,11 +281,19 @@ export default {
}
},
confirm() {
if (!this.selected.longitude || !this.selected.latitude) {
this.$message.warning('请先搜索或点击地图完成选点');
return;
}
if (!this.selected.address) {
this.$message.warning('请等待地址解析完成后再确认');
return;
}
this.$emit('confirm', this.selected.address);
this.$emit('confirm', {
address: this.selected.address,
longitude: this.selected.longitude,
latitude: this.selected.latitude,
});
this.visible = false;
},
},
@@ -278,7 +313,6 @@ export default {
}
&__map {
flex: 1 1 auto;
width: 100%;
min-width: 0;
height: 420px;
@@ -286,9 +320,19 @@ export default {
}
&__content {
display: flex;
gap: 12px;
position: relative;
display: block;
min-width: 0;
overflow: visible;
.address-map-picker__map {
position: relative;
z-index: 1;
}
:deep(.map-search-results) {
z-index: 2000;
}
}
&__info {
@@ -0,0 +1,108 @@
<template>
<el-dialog
:model-value="modelValue"
title="变更记录详情"
append-to-body
destroy-on-close
align-center
width="1100px"
class="change-record-detail-dialog"
@update:model-value="$emit('update:modelValue', $event)"
@open="resetPage"
>
<el-table
:data="pageRows"
border
max-height="60vh"
:show-overflow-tooltip="false"
>
<el-table-column prop="field" label="变更字段" min-width="180" />
<el-table-column
prop="before"
label="变更前"
min-width="360"
class-name="change-record-detail-value"
/>
<el-table-column
prop="after"
label="变更后"
min-width="500"
class-name="change-record-detail-value"
/>
</el-table>
<el-empty v-if="!total" description="暂无变更内容" :image-size="60" />
<div class="change-record-detail-dialog__pager">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
background
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
/>
</div>
<template #footer>
<el-button type="primary" @click="$emit('update:modelValue', false)">关闭</el-button>
</template>
</el-dialog>
</template>
<script>
export default {
name: 'ChangeRecordDetailDialog',
props: {
modelValue: {
type: Boolean,
default: false,
},
rows: {
type: Array,
default: () => [],
},
},
emits: ['update:modelValue'],
data() {
return {
currentPage: 1,
pageSize: 10,
};
},
computed: {
total() {
return this.rows.length;
},
pageRows() {
const start = (this.currentPage - 1) * this.pageSize;
return this.rows.slice(start, start + this.pageSize);
},
},
watch: {
rows() {
const maxPage = Math.max(1, Math.ceil(this.total / this.pageSize) || 1);
if (this.currentPage > maxPage) this.currentPage = 1;
},
},
methods: {
resetPage() {
this.currentPage = 1;
this.pageSize = 10;
},
},
};
</script>
<style lang="scss" scoped>
.change-record-detail-dialog__pager {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
:deep(.change-record-detail-value .cell) {
white-space: pre-wrap;
word-break: break-word;
overflow: visible;
text-overflow: clip;
line-height: 1.6;
}
</style>
+181 -22
View File
@@ -1,20 +1,55 @@
<template>
<div v-if="results.length" class="map-search-results">
<div class="map-search-results__title">搜索结果</div>
<el-empty v-if="!results.length" description="暂无搜索结果" :image-size="60" />
<div
v-for="(item, index) in results"
:key="item.id || index"
class="map-search-results__item"
@click="$emit('select', item)"
>
<div class="map-search-results__name">{{ item.name || item.address || '未命名地址' }}</div>
<div class="map-search-results__address">{{ item.address || item.name || '-' }}</div>
<div class="map-search-results__list">
<div
v-for="(item, index) in pageResults"
:key="item.id || `${currentPage}-${index}`"
class="map-search-results__item"
:class="{ 'is-active': isActive(item, index) }"
@click="handleSelect(item, index)"
>
<img
v-if="item.photo || item.image"
class="map-search-results__thumb"
:src="item.photo || item.image"
alt=""
/>
<div class="map-search-results__body">
<div class="map-search-results__name">
{{ item.name || item.address || '未命名地址' }}
</div>
<div class="map-search-results__address">
地址:{{ item.address || item.name || '-' }}
</div>
</div>
</div>
</div>
<div v-if="totalPages > 1" class="map-search-results__pager">
<button
v-for="page in visiblePages"
:key="page"
type="button"
class="map-search-results__page"
:class="{ 'is-active': page === currentPage }"
@click="currentPage = page"
>
{{ page }}
</button>
<button
type="button"
class="map-search-results__page map-search-results__page--next"
:disabled="currentPage >= totalPages"
@click="goNext"
>
下一页
</button>
</div>
</div>
</template>
<script>
const PAGE_SIZE = 5;
export default {
props: {
results: {
@@ -23,43 +58,167 @@ export default {
},
},
emits: ['select'],
data() {
return {
currentPage: 1,
activeKey: '',
};
},
computed: {
totalPages() {
return Math.max(1, Math.ceil((this.results || []).length / PAGE_SIZE));
},
pageResults() {
const start = (this.currentPage - 1) * PAGE_SIZE;
return (this.results || []).slice(start, start + PAGE_SIZE);
},
visiblePages() {
const maxButtons = 3;
let start = Math.max(1, this.currentPage - 1);
let end = Math.min(this.totalPages, start + maxButtons - 1);
start = Math.max(1, end - maxButtons + 1);
const pages = [];
for (let page = start; page <= end; page += 1) {
pages.push(page);
}
return pages;
},
},
watch: {
results() {
this.currentPage = 1;
this.activeKey = '';
},
},
methods: {
resultKey(item, index) {
return item?.id ?? `${this.currentPage}-${index}`;
},
isActive(item, index) {
return this.activeKey === this.resultKey(item, index);
},
handleSelect(item, index) {
this.activeKey = this.resultKey(item, index);
this.$emit('select', item);
},
goNext() {
if (this.currentPage < this.totalPages) {
this.currentPage += 1;
}
},
},
};
</script>
<style lang="scss" scoped>
.map-search-results {
width: 260px;
height: 420px;
overflow-y: auto;
border: 1px solid #eff1f7;
position: absolute;
top: 0;
left: 0;
z-index: 2000;
display: flex;
flex-direction: column;
width: 360px;
max-height: 360px;
overflow: hidden;
background: #fff;
flex: 0 0 260px;
border: 1px solid #dcdfe6;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
pointer-events: auto;
&__title {
padding: 10px 12px;
border-bottom: 1px solid #eff1f7;
font-weight: 600;
&__list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
&__item {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 12px;
cursor: pointer;
border-bottom: 1px solid #f5f5f5;
border-bottom: 1px solid #ebeef5;
}
&__item:hover {
background: #f5f9ff;
&__item:hover,
&__item.is-active {
background: #f5f5f5;
}
&__thumb {
flex: 0 0 48px;
width: 48px;
height: 48px;
object-fit: cover;
border: 1px solid #ebeef5;
background: #fafafa;
}
&__body {
flex: 1 1 auto;
min-width: 0;
}
&__name {
overflow: hidden;
color: #303133;
font-size: 14px;
font-weight: 600;
line-height: 1.4;
white-space: nowrap;
text-overflow: ellipsis;
}
&__address {
margin-top: 4px;
color: #909399;
font-size: 12px;
line-height: 1.5;
word-break: break-all;
}
&__pager {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 6px;
align-items: center;
padding: 8px 10px;
border-top: 1px solid #ebeef5;
background: #fff;
}
&__page {
min-width: 28px;
height: 28px;
padding: 0 8px;
color: #606266;
font-size: 13px;
line-height: 26px;
text-align: center;
background: #fff;
border: 1px solid #dcdfe6;
cursor: pointer;
&:hover:not(:disabled) {
color: #409eff;
border-color: #409eff;
}
&.is-active {
color: #409eff;
border-color: #409eff;
}
&:disabled {
color: #c0c4cc;
cursor: not-allowed;
}
&--next {
min-width: 56px;
}
}
}
</style>
@@ -212,7 +212,14 @@ export default {
.filter(Boolean);
},
acceptText() {
return this.acceptedList.join(',');
return this.acceptedList
.map(item => {
const value = String(item).trim();
if (!value || value.includes('/') || value.startsWith('.')) return value;
return `.${value}`;
})
.filter(Boolean)
.join(',');
},
tipText() {
return this.tip || `${UNIFIED_ATTACHMENT_TIP_PREFIX}${this.maxSize}M`;
@@ -304,12 +311,22 @@ export default {
});
},
handleSuccess(response, file, files) {
if (!response || response.success === false || (response.code && response.code !== 200)) {
const code = response?.code;
const codeInvalid = code !== undefined && code !== null && code !== '' && Number(code) !== 200;
if (!response || response.success === false || codeInvalid) {
this.$message.error((response && response.msg) || '上传失败');
return;
}
this.emitUploadFiles(this.multiple ? files : [file]);
this.$emit('success', this.normalizeUploadFile(file));
if (file && !file.response) {
file.response = response;
}
const normalized = this.normalizeUploadFile(file, response);
if (!normalized.url) {
this.$message.error('上传成功但未返回文件地址');
return;
}
this.$emit('success', normalized);
this.emitUploadFiles(this.multiple ? files : [file], normalized);
this.$message.success('上传成功');
},
handleChange(file, files) {
@@ -323,35 +340,89 @@ export default {
this.emitUploadFiles(files);
return true;
},
emitUploadFiles(files) {
const list = files
.filter(file => file.status === 'success' || this.getFileUrl(file))
.map(this.normalizeUploadFile)
emitUploadFiles(files, preferredFile) {
const list = (files || [])
.filter(file => {
if (file?.status === 'fail') return false;
return (
file?.status === 'success' ||
this.getFileUrl(file) ||
file?.response?.data ||
file?.response?.link ||
file?.response
);
})
.map(file => this.normalizeUploadFile(file))
.filter(item => item.url);
this.$emit('update:modelValue', list);
this.$emit('change', list);
if (preferredFile?.url) {
const exists = list.some(
item =>
(preferredFile.uid && item.uid && String(item.uid) === String(preferredFile.uid)) ||
item.url === preferredFile.url
);
if (!exists) list.push(preferredFile);
}
const current = this.parseValue(this.modelValue).map(item => ({
...item,
url: this.getFileUrl(item),
}));
const merged = [...current];
list.forEach(file => {
const index = merged.findIndex(
item =>
(file.uid && item.uid && String(item.uid) === String(file.uid)) ||
(file.url && this.getFileUrl(item) === file.url)
);
if (index >= 0) merged.splice(index, 1, { ...merged[index], ...file });
else merged.push(file);
});
const result = merged.filter(item => this.getFileUrl(item));
this.$emit('update:modelValue', result);
this.$emit('change', result);
},
normalizeUploadFile(file) {
const data = (file.response && file.response.data) || file.data || file;
const url = data.link || data.url || data.domain || file.url || '';
normalizeUploadFile(file, responseOverride) {
const response = responseOverride || file?.response;
let data = {};
if (response && typeof response === 'object') {
if (response.data && typeof response.data === 'object') {
data = response.data;
} else if (response.link || response.url || response.domain) {
data = response;
}
}
if (!data.link && !data.url && !data.domain) {
data = file?.data && typeof file.data === 'object' ? file.data : file || {};
}
const url =
data.link ||
data.url ||
data.domain ||
data.fileLink ||
data.fileUrl ||
file?.url ||
file?.link ||
'';
const originalName =
data.originalName ||
file.originalName ||
file.name ||
file?.originalName ||
file?.name ||
data.name ||
this.getFileName(url) ||
'附件';
const extension = this.getExtension({ name: originalName, url });
return {
...data,
uid: file.uid || data.uid,
uid: file?.uid || data.uid,
originalName,
name: originalName,
url,
link: data.link || url,
size: data.size || data.attachSize || file.size || '',
size: data.size || data.attachSize || file?.size || '',
extension,
mimeType: data.mimeType || data.contentType || file.raw?.type || MIME_MAP[extension] || '',
mimeType: data.mimeType || data.contentType || file?.raw?.type || MIME_MAP[extension] || '',
};
},
handlePreview(file) {