dman-web-admin/src/views/merchant/List.vue
2025-01-09 18:43:16 +08:00

733 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>