733 lines
20 KiB
Vue
733 lines
20 KiB
Vue
<template>
|
||
<page-container>
|
||
<div class="merchant-list">
|
||
<div class="table-header">
|
||
<h1>商家列表</h1>
|
||
<a-button type="primary" @click="showAddModal">添加商家</a-button>
|
||
</div>
|
||
|
||
<a-table
|
||
:columns="columns"
|
||
:data-source="tableData"
|
||
:pagination="pagination"
|
||
:loading="loading"
|
||
@change="handleTableChange"
|
||
row-key="id"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'location'">
|
||
<a @click="showMap(record)">查看位置</a>
|
||
</template>
|
||
<template v-if="column.key === 'create_time'">
|
||
{{ formatDateTime(record.create_time) }}
|
||
</template>
|
||
<template v-if="column.key === 'action'">
|
||
<a-button type="link" @click="handleManageImages(record)">管理图片</a-button>
|
||
</template>
|
||
</template>
|
||
</a-table>
|
||
|
||
<!-- 地图弹窗 -->
|
||
<a-modal
|
||
v-model:visible="mapVisible"
|
||
title="商家位置"
|
||
:footer="null"
|
||
width="800px"
|
||
@cancel="closeMap"
|
||
>
|
||
<div id="map-container" style="height: 500px;"></div>
|
||
</a-modal>
|
||
|
||
<!-- 添加商家模态框 -->
|
||
<a-modal
|
||
v-model:visible="addModalVisible"
|
||
title="添加商家"
|
||
:confirmLoading="confirmLoading"
|
||
width="800px"
|
||
@cancel="handleCancel"
|
||
>
|
||
<template #footer>
|
||
<a-space>
|
||
<a-button @click="handleCancel">取消</a-button>
|
||
<a-button type="primary" :loading="confirmLoading" @click="handleAdd">
|
||
保存
|
||
</a-button>
|
||
</a-space>
|
||
</template>
|
||
|
||
<a-form
|
||
ref="formRef"
|
||
:model="formState"
|
||
:rules="rules"
|
||
:label-col="{ span: 6 }"
|
||
:wrapper-col="{ span: 16 }"
|
||
>
|
||
<a-form-item label="商家名称" name="name" required>
|
||
<a-input v-model:value="formState.name" placeholder="请输入商家名称" />
|
||
</a-form-item>
|
||
|
||
<a-form-item label="营业时间" name="business_hours" required>
|
||
<a-input v-model:value="formState.business_hours" placeholder="例如:10:30~20:30" />
|
||
</a-form-item>
|
||
|
||
<a-form-item label="联系电话" name="phone" required>
|
||
<a-input v-model:value="formState.phone" placeholder="请输入联系电话" />
|
||
</a-form-item>
|
||
|
||
<a-form-item label="地址搜索">
|
||
<a-auto-complete
|
||
v-model:value="searchAddress"
|
||
:options="searchOptions"
|
||
placeholder="输入地址搜索"
|
||
@change="handleSearch"
|
||
@select="handleSelect"
|
||
:loading="searchLoading"
|
||
allow-clear
|
||
/>
|
||
</a-form-item>
|
||
|
||
<a-form-item label="地图选点" required>
|
||
<div class="map-container">
|
||
<div id="add-map-container" style="height: 300px;"></div>
|
||
</div>
|
||
</a-form-item>
|
||
|
||
<a-form-item label="详细地址" name="address" required>
|
||
<a-input v-model:value="formState.address" placeholder="请输入详细地址" />
|
||
</a-form-item>
|
||
|
||
<a-form-item label="经纬度" required>
|
||
<a-input-group compact>
|
||
<a-input-number
|
||
v-model:value="formState.longitude"
|
||
:min="-180"
|
||
:max="180"
|
||
style="width: 50%"
|
||
placeholder="经度"
|
||
disabled
|
||
/>
|
||
<a-input-number
|
||
v-model:value="formState.latitude"
|
||
:min="-90"
|
||
:max="90"
|
||
style="width: 50%"
|
||
placeholder="纬度"
|
||
disabled
|
||
/>
|
||
</a-input-group>
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
|
||
<!-- 添加图片管理模态框 -->
|
||
<a-modal
|
||
v-model:visible="imageModalVisible"
|
||
title="商家图片管理"
|
||
width="800px"
|
||
@ok="handleSaveImages"
|
||
@cancel="handleCancelImages"
|
||
:confirmLoading="imagesSaving"
|
||
okText="保存"
|
||
>
|
||
<div class="image-wall">
|
||
<a-upload
|
||
v-model:fileList="fileList"
|
||
:customRequest="handleUpload"
|
||
list-type="picture-card"
|
||
:multiple="true"
|
||
@preview="handlePreview"
|
||
@remove="handleRemove"
|
||
accept="image/*"
|
||
>
|
||
<div v-if="fileList.length < 8">
|
||
<plus-outlined />
|
||
<div style="margin-top: 8px">上传图片</div>
|
||
</div>
|
||
</a-upload>
|
||
</div>
|
||
</a-modal>
|
||
|
||
<!-- 图片预览模态框 -->
|
||
<a-modal
|
||
:visible="previewVisible"
|
||
:title="previewTitle"
|
||
:footer="null"
|
||
@cancel="handlePreviewCancel"
|
||
>
|
||
<img :alt="previewTitle" style="width: 100%" :src="previewImage" />
|
||
</a-modal>
|
||
</div>
|
||
</page-container>
|
||
</template>
|
||
|
||
<script>
|
||
import { defineComponent, ref, onMounted, nextTick } from 'vue'
|
||
import { message, Upload, Modal } from 'ant-design-vue'
|
||
import dayjs from 'dayjs'
|
||
import PageContainer from '@/components/PageContainer.vue'
|
||
import { loadAMap, createMap, createAutoComplete, createGeocoder } from '@/utils/amap.js'
|
||
import request from '@/utils/request'
|
||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||
|
||
export default defineComponent({
|
||
components: {
|
||
PageContainer,
|
||
PlusOutlined,
|
||
AUpload: Upload,
|
||
AModal: Modal
|
||
},
|
||
setup() {
|
||
const loading = ref(false)
|
||
const tableData = ref([])
|
||
const mapVisible = ref(false)
|
||
const currentMap = ref(null)
|
||
const currentMarker = ref(null)
|
||
|
||
const pagination = ref({
|
||
current: 1,
|
||
pageSize: 10,
|
||
total: 0,
|
||
showSizeChanger: true,
|
||
showTotal: (total) => `共 ${total} 条记录`
|
||
})
|
||
|
||
const columns = [
|
||
{
|
||
title: 'ID',
|
||
dataIndex: 'id',
|
||
key: 'id',
|
||
width: 80,
|
||
align: 'center',
|
||
},
|
||
{
|
||
title: '商家名称',
|
||
dataIndex: 'name',
|
||
key: 'name',
|
||
width: 150,
|
||
},
|
||
{
|
||
title: '营业时间',
|
||
dataIndex: 'business_hours',
|
||
key: 'business_hours',
|
||
width: 150,
|
||
},
|
||
{
|
||
title: '地址',
|
||
dataIndex: 'address',
|
||
key: 'address',
|
||
width: 200,
|
||
},
|
||
{
|
||
title: '位置',
|
||
key: 'location',
|
||
width: 100,
|
||
align: 'center',
|
||
},
|
||
{
|
||
title: '联系电话',
|
||
dataIndex: 'phone',
|
||
key: 'phone',
|
||
width: 120,
|
||
},
|
||
{
|
||
title: '创建时间',
|
||
dataIndex: 'create_time',
|
||
key: 'create_time',
|
||
width: 180,
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
width: 120,
|
||
align: 'center',
|
||
fixed: 'right'
|
||
}
|
||
]
|
||
|
||
// 格式化日期时间
|
||
const formatDateTime = (value) => {
|
||
if (!value) return ''
|
||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||
}
|
||
|
||
// 获取商家列表数据
|
||
const fetchData = async () => {
|
||
try {
|
||
loading.value = true
|
||
const params = {
|
||
skip: (pagination.value.current - 1) * pagination.value.pageSize,
|
||
limit: pagination.value.pageSize
|
||
}
|
||
|
||
const res = await request.get('/api/merchant', { params })
|
||
if (res.data) {
|
||
tableData.value = res.data.items || []
|
||
pagination.value.total = res.data.total || 0
|
||
}
|
||
} catch (error) {
|
||
console.error('获取商家列表失败:', error)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 表格变化处理
|
||
const handleTableChange = (pag) => {
|
||
pagination.value.current = pag.current
|
||
pagination.value.pageSize = pag.pageSize
|
||
fetchData()
|
||
}
|
||
|
||
// 显示地图
|
||
const showMap = async (record) => {
|
||
mapVisible.value = true
|
||
|
||
await nextTick()
|
||
|
||
try {
|
||
await loadAMap()
|
||
|
||
if (!currentMap.value) {
|
||
currentMap.value = createMap('map-container', {
|
||
zoom: 15,
|
||
viewMode: '3D'
|
||
})
|
||
}
|
||
|
||
const position = [record.longitude, record.latitude]
|
||
currentMap.value.setCenter(position)
|
||
|
||
if (currentMarker.value) {
|
||
currentMarker.value.remove()
|
||
}
|
||
|
||
currentMarker.value = new window.AMap.Marker({
|
||
position,
|
||
map: currentMap.value,
|
||
title: record.name
|
||
})
|
||
} catch (error) {
|
||
console.error('加载地图失败:', error)
|
||
message.error('加载地图失败')
|
||
}
|
||
}
|
||
|
||
// 关闭地图
|
||
const closeMap = () => {
|
||
mapVisible.value = false
|
||
}
|
||
|
||
// 添加商家相关
|
||
const addModalVisible = ref(false)
|
||
const confirmLoading = ref(false)
|
||
const formRef = ref(null)
|
||
const searchAddress = ref('')
|
||
const searchOptions = ref([])
|
||
const searchLoading = ref(false)
|
||
const addMap = ref(null)
|
||
const addMarker = ref(null)
|
||
|
||
const formState = ref({
|
||
name: '',
|
||
business_hours: '',
|
||
address: '',
|
||
longitude: null,
|
||
latitude: null,
|
||
phone: ''
|
||
})
|
||
|
||
const rules = {
|
||
name: [{ required: true, message: '请输入商家名称' }],
|
||
business_hours: [{ required: true, message: '请输入营业时间' }],
|
||
address: [{ required: true, message: '请输入详细地址' }],
|
||
phone: [{ required: true, message: '请输入联系电话' }],
|
||
}
|
||
|
||
// 显示添加模态框
|
||
const showAddModal = async () => {
|
||
addModalVisible.value = true
|
||
await nextTick()
|
||
initAddMap()
|
||
}
|
||
|
||
// 初始化地图
|
||
const initAddMap = async () => {
|
||
try {
|
||
await loadAMap()
|
||
|
||
if (!addMap.value) {
|
||
addMap.value = createMap('add-map-container', {
|
||
zoom: 13,
|
||
viewMode: '2D'
|
||
})
|
||
|
||
// 添加点击事件
|
||
addMap.value.on('click', async (e) => {
|
||
const { lng, lat } = e.lnglat
|
||
updateMarkerPosition(lng, lat)
|
||
|
||
// 逆地理编码获取地址
|
||
const geocoder = createGeocoder()
|
||
geocoder.getAddress([lng, lat], (status, result) => {
|
||
if (status === 'complete' && result.info === 'OK') {
|
||
const address = result.regeocode
|
||
// 更新表单数据
|
||
formState.value.address = address.formattedAddress
|
||
formState.value.longitude = lng
|
||
formState.value.latitude = lat
|
||
} else {
|
||
message.error('获取地址信息失败')
|
||
}
|
||
})
|
||
})
|
||
}
|
||
} catch (error) {
|
||
console.error('初始化地图失败:', error)
|
||
message.error('初始化地图失败')
|
||
}
|
||
}
|
||
|
||
// 更新标记位置
|
||
const updateMarkerPosition = (lng, lat) => {
|
||
if (addMarker.value) {
|
||
addMarker.value.setPosition([lng, lat])
|
||
} else {
|
||
const AMap = window.AMap
|
||
addMarker.value = new AMap.Marker({
|
||
position: [lng, lat],
|
||
map: addMap.value
|
||
})
|
||
}
|
||
}
|
||
|
||
// 地址搜索
|
||
const handleSearch = async (value) => {
|
||
if (!value || value.length < 2) {
|
||
searchOptions.value = []
|
||
return
|
||
}
|
||
|
||
try {
|
||
searchLoading.value = true
|
||
await loadAMap()
|
||
|
||
const autoComplete = createAutoComplete()
|
||
await new Promise((resolve, reject) => {
|
||
autoComplete.search(value, (status, result) => {
|
||
if (status === 'complete') {
|
||
searchOptions.value = result.tips.map(tip => ({
|
||
value: tip.name,
|
||
label: `${tip.name} (${tip.district})`,
|
||
location: tip.location
|
||
}))
|
||
resolve(result)
|
||
} else {
|
||
reject(new Error(status))
|
||
}
|
||
searchLoading.value = false
|
||
})
|
||
})
|
||
} catch (error) {
|
||
console.error('搜索地址失败:', error)
|
||
searchLoading.value = false
|
||
message.error('搜索地址失败')
|
||
}
|
||
}
|
||
|
||
// 选择地址
|
||
const handleSelect = (value, option) => {
|
||
const selected = searchOptions.value.find(opt => opt.value === value)
|
||
if (selected && selected.location) {
|
||
const { lng, lat } = selected.location
|
||
updateMarkerPosition(lng, lat)
|
||
addMap.value.setCenter([lng, lat])
|
||
|
||
// 使用逆地理编码获取完整地址
|
||
const geocoder = createGeocoder()
|
||
geocoder.getAddress([lng, lat], (status, result) => {
|
||
if (status === 'complete' && result.info === 'OK') {
|
||
const address = result.regeocode
|
||
// 更新表单数据
|
||
formState.value.address = address.formattedAddress
|
||
formState.value.longitude = lng
|
||
formState.value.latitude = lat
|
||
} else {
|
||
// 如果获取详细地址失败,至少使用搜索选中的地址
|
||
formState.value.address = value
|
||
formState.value.longitude = lng
|
||
formState.value.latitude = lat
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 提交表单
|
||
const handleAdd = () => {
|
||
formRef.value.validate().then(async () => {
|
||
try {
|
||
confirmLoading.value = true
|
||
await request.post('/api/merchant', formState.value)
|
||
message.success('添加成功')
|
||
addModalVisible.value = false
|
||
fetchData()
|
||
} catch (error) {
|
||
console.error('添加商家失败:', error)
|
||
} finally {
|
||
confirmLoading.value = false
|
||
}
|
||
})
|
||
}
|
||
|
||
// 取消添加
|
||
const handleCancel = () => {
|
||
formRef.value?.resetFields()
|
||
addModalVisible.value = false
|
||
if (addMarker.value) {
|
||
addMarker.value.setMap(null)
|
||
addMarker.value = null
|
||
}
|
||
}
|
||
|
||
// 图片管理相关
|
||
const imageModalVisible = ref(false)
|
||
const imagesSaving = ref(false)
|
||
const currentMerchant = ref(null)
|
||
const fileList = ref([])
|
||
const previewVisible = ref(false)
|
||
const previewImage = ref('')
|
||
const previewTitle = ref('')
|
||
|
||
// 打开图片管理
|
||
const handleManageImages = (record) => {
|
||
currentMerchant.value = record
|
||
imageModalVisible.value = true
|
||
|
||
// 加载商家现有图片
|
||
if (record.images && record.images.length > 0) {
|
||
fileList.value = record.images.map((item, index) => ({
|
||
uid: `-${index}`,
|
||
name: item.image_url.substring(item.image_url.lastIndexOf('/') + 1),
|
||
status: 'done',
|
||
url: item.image_url,
|
||
image_url: item.image_url,
|
||
sort: item.sort
|
||
}))
|
||
} else {
|
||
fileList.value = []
|
||
}
|
||
}
|
||
|
||
// 处理图片上传
|
||
const handleUpload = async ({ file, onSuccess, onError, onProgress }) => {
|
||
const formData = new FormData()
|
||
formData.append('files', file)
|
||
|
||
try {
|
||
onProgress({ percent: 50 })
|
||
const res = await request.post('/api/upload/images', formData, {
|
||
headers: {
|
||
'Content-Type': 'multipart/form-data'
|
||
}
|
||
})
|
||
|
||
if (res.code === 200 && res.data && res.data.urls && res.data.urls.length > 0) {
|
||
const url = res.data.urls[0]
|
||
const fileInfo = {
|
||
uid: `new-${Date.now()}`,
|
||
name: url.substring(url.lastIndexOf('/') + 1),
|
||
status: 'done',
|
||
response: res.data, // 保存完整的响应数据
|
||
url: url, // 用于预览显示
|
||
sort: fileList.value.length
|
||
}
|
||
|
||
// 只更新本地文件列表,不保存到服务器
|
||
fileList.value = [...fileList.value, fileInfo]
|
||
onSuccess(res.data) // 传入完整的响应数据
|
||
message.success('上传成功')
|
||
} else {
|
||
throw new Error('上传失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('上传图片失败:', error)
|
||
onError()
|
||
message.error('上传失败')
|
||
}
|
||
}
|
||
|
||
// 图片预览
|
||
const handlePreview = async (file) => {
|
||
previewImage.value = file.url || file.preview
|
||
previewVisible.value = true
|
||
previewTitle.value = file.name || file.url.substring(file.url.lastIndexOf('/') + 1)
|
||
}
|
||
|
||
// 关闭预览
|
||
const handlePreviewCancel = () => {
|
||
previewVisible.value = false
|
||
previewTitle.value = ''
|
||
}
|
||
|
||
// 移除图片
|
||
const handleRemove = async (file) => {
|
||
// 只从本地文件列表中移除,不更新服务器
|
||
fileList.value = fileList.value.filter(f => f.uid !== file.uid)
|
||
return true
|
||
}
|
||
|
||
// 保存图片列表
|
||
const handleSaveImages = async () => {
|
||
try {
|
||
imagesSaving.value = true
|
||
|
||
// 构造符合接口要求的数据格式
|
||
const data = {
|
||
images: fileList.value.map((file, index) => {
|
||
// 获取图片 URL 的优先级:
|
||
// 1. 如果是新上传的图片,从 response.urls[0] 获取
|
||
// 2. 如果是已有的图片,使用 url 字段
|
||
const imageUrl = file.response?.urls?.[0] || file.url
|
||
return {
|
||
image_url: imageUrl,
|
||
sort: index
|
||
}
|
||
})
|
||
}
|
||
|
||
// 打印检查数据
|
||
console.log('Saving data:', data)
|
||
|
||
const res = await request.put(`/api/merchant/${currentMerchant.value.id}`, data)
|
||
if (res.code === 200) {
|
||
message.success('保存成功')
|
||
imageModalVisible.value = false
|
||
fetchData()
|
||
} else {
|
||
throw new Error(res.message || '保存失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('保存失败:', error)
|
||
message.error(error.message || '保存失败')
|
||
} finally {
|
||
imagesSaving.value = false
|
||
}
|
||
}
|
||
|
||
// 取消图片管理
|
||
const handleCancelImages = () => {
|
||
fileList.value = []
|
||
imageModalVisible.value = false
|
||
}
|
||
|
||
onMounted(() => {
|
||
fetchData()
|
||
})
|
||
|
||
return {
|
||
loading,
|
||
columns,
|
||
tableData,
|
||
pagination,
|
||
mapVisible,
|
||
handleTableChange,
|
||
showMap,
|
||
closeMap,
|
||
formatDateTime,
|
||
addModalVisible,
|
||
confirmLoading,
|
||
formRef,
|
||
formState,
|
||
rules,
|
||
searchAddress,
|
||
searchOptions,
|
||
searchLoading,
|
||
showAddModal,
|
||
handleSearch,
|
||
handleSelect,
|
||
handleAdd,
|
||
handleCancel,
|
||
imageModalVisible,
|
||
imagesSaving,
|
||
fileList,
|
||
previewVisible,
|
||
previewImage,
|
||
previewTitle,
|
||
handleManageImages,
|
||
handleUpload,
|
||
handlePreview,
|
||
handlePreviewCancel,
|
||
handleRemove,
|
||
handleSaveImages,
|
||
handleCancelImages
|
||
}
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.merchant-list {
|
||
padding: 24px;
|
||
}
|
||
|
||
.table-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.table-header h1 {
|
||
margin: 0;
|
||
}
|
||
|
||
:deep(.ant-table-content) {
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.map-container {
|
||
border: 1px solid #d9d9d9;
|
||
border-radius: 2px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.image-wall {
|
||
padding: 24px;
|
||
background: #fafafa;
|
||
border-radius: 2px;
|
||
min-height: 200px;
|
||
}
|
||
|
||
:deep(.ant-upload-select-picture-card) {
|
||
width: 104px !important;
|
||
height: 104px !important;
|
||
margin: 0 8px 8px 0;
|
||
border: 1px dashed #d9d9d9;
|
||
border-radius: 2px;
|
||
cursor: pointer;
|
||
transition: border-color 0.3s;
|
||
|
||
&:hover {
|
||
border-color: #1890ff;
|
||
}
|
||
}
|
||
|
||
:deep(.ant-upload-list-picture-card-container) {
|
||
width: 104px;
|
||
height: 104px;
|
||
margin: 0 8px 8px 0;
|
||
}
|
||
|
||
:deep(.ant-upload-list-picture-card .ant-upload-list-item) {
|
||
padding: 4px;
|
||
}
|
||
|
||
:deep(.ant-upload-select-picture-card i) {
|
||
font-size: 32px;
|
||
color: #999;
|
||
}
|
||
|
||
:deep(.ant-upload-select-picture-card .ant-upload-text) {
|
||
margin-top: 8px;
|
||
color: #666;
|
||
}
|
||
</style> |