630 lines
18 KiB
Vue
630 lines
18 KiB
Vue
<template>
|
|
<div class="services-container">
|
|
<!-- Search and Filter Bar -->
|
|
<el-card class="filter-card">
|
|
<el-form :inline="true" :model="filters" class="filter-form">
|
|
<el-form-item label="搜索">
|
|
<el-input
|
|
v-model="filters.search"
|
|
placeholder="服务名称"
|
|
clearable
|
|
@keyup.enter="handleSearch"
|
|
style="width: 200px"
|
|
/>
|
|
</el-form-item>
|
|
<el-form-item label="分类">
|
|
<el-select v-model="filters.categoryId" placeholder="全部分类" clearable style="width: 150px">
|
|
<el-option
|
|
v-for="cat in categories"
|
|
:key="cat.id"
|
|
:label="cat.nameZh || cat.name_zh || cat.key"
|
|
:value="cat.id"
|
|
/>
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item label="状态">
|
|
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 120px">
|
|
<el-option label="已上架" value="active" />
|
|
<el-option label="已下架" value="inactive" />
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item>
|
|
<el-button type="primary" @click="handleSearch">
|
|
<el-icon><Search /></el-icon>
|
|
搜索
|
|
</el-button>
|
|
<el-button @click="handleReset">
|
|
<el-icon><Refresh /></el-icon>
|
|
重置
|
|
</el-button>
|
|
<el-button type="success" @click="handleCreate">
|
|
<el-icon><Plus /></el-icon>
|
|
新增服务
|
|
</el-button>
|
|
</el-form-item>
|
|
</el-form>
|
|
</el-card>
|
|
|
|
<!-- Service Table -->
|
|
<el-card class="table-card">
|
|
<el-table
|
|
v-loading="loading"
|
|
:data="services"
|
|
stripe
|
|
border
|
|
style="width: 100%"
|
|
>
|
|
<el-table-column prop="image" label="图片" width="100" align="center">
|
|
<template #default="{ row }">
|
|
<el-image
|
|
v-if="row.image"
|
|
:src="getImageUrl(row.image)"
|
|
:preview-src-list="[getImageUrl(row.image)]"
|
|
fit="cover"
|
|
style="width: 60px; height: 60px; border-radius: 4px;"
|
|
>
|
|
<template #error>
|
|
<div class="image-error-small">丢失</div>
|
|
</template>
|
|
</el-image>
|
|
<el-icon v-else :size="40" color="#909399"><Picture /></el-icon>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="titleZh" label="中文名称" min-width="150" show-overflow-tooltip />
|
|
<el-table-column prop="titleEn" label="英文名称" min-width="150" show-overflow-tooltip />
|
|
<el-table-column prop="titlePt" label="西语名称" min-width="150" show-overflow-tooltip />
|
|
<el-table-column prop="category" label="分类" width="120" align="center">
|
|
<template #default="{ row }">
|
|
<el-tag v-if="row.category" size="small">{{ row.category.nameZh || row.category.name_zh || '-' }}</el-tag>
|
|
<span v-else>-</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="serviceType" label="服务类型" width="180" align="center">
|
|
<template #default="{ row }">
|
|
<el-tag v-if="row.serviceType" type="warning" size="small">{{ getServiceTypeLabel(row.serviceType) }}</el-tag>
|
|
<span v-else>-</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="sortOrder" label="排序" width="80" align="center" />
|
|
<el-table-column prop="status" label="状态" width="100" align="center">
|
|
<template #default="{ row }">
|
|
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
|
|
{{ row.status === 'active' ? '已上架' : '已下架' }}
|
|
</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="createdAt" label="创建时间" width="180">
|
|
<template #default="{ row }">
|
|
{{ formatDate(row.createdAt) }}
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="操作" width="180" fixed="right" align="center">
|
|
<template #default="{ row }">
|
|
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
|
<el-icon><Edit /></el-icon>
|
|
编辑
|
|
</el-button>
|
|
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
|
<el-icon><Delete /></el-icon>
|
|
删除
|
|
</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
|
|
<!-- Pagination -->
|
|
<div class="pagination-container">
|
|
<el-pagination
|
|
v-model:current-page="pagination.page"
|
|
v-model:page-size="pagination.limit"
|
|
:page-sizes="[10, 20, 50, 100]"
|
|
:total="pagination.total"
|
|
layout="total, sizes, prev, pager, next, jumper"
|
|
@size-change="handleSizeChange"
|
|
@current-change="handlePageChange"
|
|
/>
|
|
</div>
|
|
</el-card>
|
|
|
|
<!-- Create/Edit Service Dialog -->
|
|
<el-dialog
|
|
v-model="dialogVisible"
|
|
:title="isEdit ? '编辑服务' : '新增服务'"
|
|
width="700px"
|
|
destroy-on-close
|
|
@close="resetForm"
|
|
>
|
|
<el-form
|
|
ref="formRef"
|
|
:model="form"
|
|
:rules="formRules"
|
|
label-width="100px"
|
|
label-position="right"
|
|
>
|
|
<el-form-item label="服务分类" prop="categoryId">
|
|
<el-select v-model="form.categoryId" placeholder="请选择分类" style="width: 100%">
|
|
<el-option
|
|
v-for="cat in categories"
|
|
:key="cat.id"
|
|
:label="cat.name_zh"
|
|
:value="cat.id"
|
|
/>
|
|
</el-select>
|
|
</el-form-item>
|
|
|
|
<el-form-item label="服务类型" prop="serviceType">
|
|
<el-select v-model="form.serviceType" placeholder="请选择服务具体类型" style="width: 100%" filterable>
|
|
<el-option
|
|
v-for="item in serviceTypeOptions"
|
|
:key="item.value"
|
|
:label="item.label"
|
|
:value="item.value"
|
|
/>
|
|
</el-select>
|
|
<div class="form-tip">用于跳转对应的预约表单页面</div>
|
|
</el-form-item>
|
|
|
|
<el-divider content-position="left">多语言标题</el-divider>
|
|
|
|
<el-form-item label="中文标题" prop="titleZh">
|
|
<el-input v-model="form.titleZh" placeholder="请输入中文标题" />
|
|
</el-form-item>
|
|
<el-form-item label="英文标题" prop="titleEn">
|
|
<el-input v-model="form.titleEn" placeholder="Please enter English title" />
|
|
</el-form-item>
|
|
<el-form-item label="西语标题" prop="titlePt">
|
|
<el-input v-model="form.titlePt" placeholder="Por favor, ingrese el título en español" />
|
|
</el-form-item>
|
|
|
|
<el-divider content-position="left">其他信息</el-divider>
|
|
|
|
<el-form-item label="服务图片" prop="image">
|
|
<div class="image-upload-container">
|
|
<el-upload
|
|
class="image-uploader"
|
|
:action="uploadUrl"
|
|
:headers="uploadHeaders"
|
|
:show-file-list="false"
|
|
:on-success="handleUploadSuccess"
|
|
:on-error="handleUploadError"
|
|
:before-upload="beforeUpload"
|
|
name="file"
|
|
accept="image/*"
|
|
>
|
|
<el-image
|
|
v-if="form.image"
|
|
:src="getImageUrl(form.image)"
|
|
fit="cover"
|
|
class="uploaded-image"
|
|
/>
|
|
<el-icon v-else class="image-uploader-icon"><Plus /></el-icon>
|
|
</el-upload>
|
|
<div class="upload-tip">支持 JPG、PNG 格式,大小不超过 5MB</div>
|
|
</div>
|
|
</el-form-item>
|
|
|
|
<el-form-item label="排序" prop="sortOrder">
|
|
<el-input-number
|
|
v-model="form.sortOrder"
|
|
:min="0"
|
|
:step="1"
|
|
placeholder="数字越小越靠前"
|
|
style="width: 200px"
|
|
/>
|
|
</el-form-item>
|
|
|
|
<el-form-item label="状态" prop="status">
|
|
<el-radio-group v-model="form.status">
|
|
<el-radio value="active">上架</el-radio>
|
|
<el-radio value="inactive">下架</el-radio>
|
|
</el-radio-group>
|
|
</el-form-item>
|
|
</el-form>
|
|
|
|
<template #footer>
|
|
<el-button @click="dialogVisible = false">取消</el-button>
|
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
|
{{ isEdit ? '保存' : '创建' }}
|
|
</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, reactive, computed, onMounted } from 'vue'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
import api from '@/utils/api'
|
|
|
|
// State
|
|
const loading = ref(false)
|
|
const submitting = ref(false)
|
|
const services = ref([])
|
|
const categories = ref([])
|
|
const dialogVisible = ref(false)
|
|
const isEdit = ref(false)
|
|
const editingId = ref(null)
|
|
const formRef = ref(null)
|
|
|
|
const pagination = reactive({
|
|
page: 1,
|
|
limit: 10,
|
|
total: 0,
|
|
totalPages: 0
|
|
})
|
|
|
|
const filters = reactive({
|
|
search: '',
|
|
categoryId: '',
|
|
status: ''
|
|
})
|
|
|
|
// 服务具体类型选项(用于跳转对应预约表单)
|
|
const serviceTypeOptions = [
|
|
{ value: 'flight', label: '全球机票代理' },
|
|
{ value: 'hotel', label: '全球酒店预定' },
|
|
{ value: 'lounge', label: '全球机场贵宾室服务' },
|
|
{ value: 'airport_transfer', label: '机场接/送机服务' },
|
|
{ value: 'unaccompanied_minor', label: '无成人陪伴儿童代办' },
|
|
{ value: 'train', label: '高铁票代订' },
|
|
{ value: 'telemedicine', label: '远程医疗问诊代理服务' },
|
|
{ value: 'special_passenger', label: '特殊(特护)旅客定制服务代办' },
|
|
{ value: 'pet_transport', label: '宠物托运代理' },
|
|
{ value: 'guide_translation', label: '西班牙语专业导游/翻译服务' },
|
|
{ value: 'visa', label: '签证咨询' },
|
|
{ value: 'exhibition', label: '墨西哥展会咨询与协办服务' },
|
|
{ value: 'air_logistics', label: '跨境航空物流/快递一站式服务' },
|
|
{ value: 'sea_freight', label: '海运/清关一站式服务' },
|
|
{ value: 'travel_planning', label: '旅游线路规划/咨询' },
|
|
{ value: 'insurance', label: '跨境出行意外保险/国际财产保险咨询' }
|
|
]
|
|
|
|
// Form data
|
|
const defaultForm = {
|
|
categoryId: '',
|
|
serviceType: '',
|
|
titleZh: '',
|
|
titleEn: '',
|
|
titlePt: '',
|
|
image: '',
|
|
sortOrder: 0,
|
|
status: 'active'
|
|
}
|
|
|
|
const form = reactive({ ...defaultForm })
|
|
|
|
// Form validation rules
|
|
const formRules = {
|
|
categoryId: [{ required: true, message: '请选择服务分类', trigger: 'change' }],
|
|
serviceType: [{ required: true, message: '请选择服务具体类型', trigger: 'change' }],
|
|
titleZh: [{ required: true, message: '请输入中文标题', trigger: 'blur' }],
|
|
titleEn: [{ required: true, message: '请输入英文标题', trigger: 'blur' }],
|
|
titlePt: [{ required: true, message: '请输入西语标题', trigger: 'blur' }]
|
|
}
|
|
|
|
// 获取服务类型标签
|
|
function getServiceTypeLabel(type) {
|
|
const item = serviceTypeOptions.find(t => t.value === type)
|
|
return item ? item.label : type || '-'
|
|
}
|
|
|
|
// Upload configuration
|
|
const uploadUrl = computed(() => {
|
|
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
|
|
return `${baseUrl}/api/v1/admin/upload/image`
|
|
})
|
|
|
|
const uploadHeaders = computed(() => {
|
|
const token = localStorage.getItem('admin_token')
|
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
|
})
|
|
|
|
// Fetch services
|
|
async function fetchServices() {
|
|
loading.value = true
|
|
try {
|
|
const params = {
|
|
page: pagination.page,
|
|
limit: pagination.limit,
|
|
...filters
|
|
}
|
|
// Remove empty params
|
|
Object.keys(params).forEach(key => {
|
|
if (params[key] === '' || params[key] === null || params[key] === undefined) {
|
|
delete params[key]
|
|
}
|
|
})
|
|
|
|
const response = await api.get('/api/v1/admin/services', { params })
|
|
// Support both old format (success: true) and new format (code: 0)
|
|
if (response.data.success === true || response.data.code === 0) {
|
|
services.value = response.data.data
|
|
// Handle pagination from both formats
|
|
const paginationData = response.data.pagination || {}
|
|
pagination.total = paginationData.total || 0
|
|
pagination.totalPages = paginationData.totalPages || 0
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch services:', error)
|
|
ElMessage.error('获取服务列表失败')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
// Fetch categories
|
|
async function fetchCategories() {
|
|
try {
|
|
const response = await api.get('/api/v1/admin/categories')
|
|
categories.value = response.data.data || []
|
|
} catch (error) {
|
|
console.error('Failed to fetch categories:', error)
|
|
ElMessage.error('获取分类列表失败')
|
|
}
|
|
}
|
|
|
|
// Handle search
|
|
function handleSearch() {
|
|
pagination.page = 1
|
|
fetchServices()
|
|
}
|
|
|
|
// Handle reset
|
|
function handleReset() {
|
|
filters.search = ''
|
|
filters.categoryId = ''
|
|
filters.status = ''
|
|
pagination.page = 1
|
|
fetchServices()
|
|
}
|
|
|
|
// Handle page change
|
|
function handlePageChange(page) {
|
|
pagination.page = page
|
|
fetchServices()
|
|
}
|
|
|
|
// Handle page size change
|
|
function handleSizeChange(size) {
|
|
pagination.limit = size
|
|
pagination.page = 1
|
|
fetchServices()
|
|
}
|
|
|
|
// Handle create
|
|
function handleCreate() {
|
|
isEdit.value = false
|
|
editingId.value = null
|
|
resetForm()
|
|
dialogVisible.value = true
|
|
}
|
|
|
|
// Handle edit
|
|
function handleEdit(row) {
|
|
isEdit.value = true
|
|
editingId.value = row.id
|
|
Object.assign(form, {
|
|
categoryId: row.categoryId,
|
|
serviceType: row.serviceType || '',
|
|
titleZh: row.titleZh,
|
|
titleEn: row.titleEn,
|
|
titlePt: row.titlePt,
|
|
image: row.image || '',
|
|
sortOrder: row.sortOrder || 0,
|
|
status: row.status
|
|
})
|
|
dialogVisible.value = true
|
|
}
|
|
|
|
// Handle delete
|
|
async function handleDelete(row) {
|
|
try {
|
|
await ElMessageBox.confirm(
|
|
`确定要删除服务 "${row.titleZh}" 吗?此操作不可恢复。`,
|
|
'确认删除',
|
|
{
|
|
confirmButtonText: '确定删除',
|
|
cancelButtonText: '取消',
|
|
type: 'warning'
|
|
}
|
|
)
|
|
|
|
const response = await api.delete(`/api/v1/admin/services/${row.id}`)
|
|
// Support both old format (success: true) and new format (code: 0)
|
|
if (response.data.success === true || response.data.code === 0) {
|
|
ElMessage.success('服务已删除')
|
|
fetchServices()
|
|
}
|
|
} catch (error) {
|
|
if (error !== 'cancel') {
|
|
console.error('Failed to delete service:', error)
|
|
const message = error.response?.data?.error?.message || '删除服务失败'
|
|
ElMessage.error(message)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle form submit
|
|
async function handleSubmit() {
|
|
if (!formRef.value) return
|
|
|
|
try {
|
|
await formRef.value.validate()
|
|
} catch (error) {
|
|
return
|
|
}
|
|
|
|
submitting.value = true
|
|
try {
|
|
const data = { ...form }
|
|
// Convert empty strings to null for optional fields
|
|
if (!data.image) data.image = null
|
|
if (data.price === null || data.price === '') data.price = null
|
|
|
|
let response
|
|
if (isEdit.value) {
|
|
response = await api.put(`/api/v1/admin/services/${editingId.value}`, data)
|
|
} else {
|
|
response = await api.post('/api/v1/admin/services', data)
|
|
}
|
|
|
|
// Check for both old format (success: true) and new format (code: 0)
|
|
if (response.data.success === true || response.data.code === 0) {
|
|
ElMessage.success(isEdit.value ? '服务已更新' : '服务已创建')
|
|
dialogVisible.value = false
|
|
fetchServices()
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to save service:', error)
|
|
const message = error.response?.data?.error?.message || error.response?.data?.message || '保存服务失败'
|
|
ElMessage.error(message)
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
// Reset form
|
|
function resetForm() {
|
|
Object.assign(form, defaultForm)
|
|
if (formRef.value) {
|
|
formRef.value.resetFields()
|
|
}
|
|
}
|
|
|
|
// Image upload handlers
|
|
function beforeUpload(file) {
|
|
const isImage = file.type.startsWith('image/')
|
|
const isLt5M = file.size / 1024 / 1024 < 5
|
|
|
|
if (!isImage) {
|
|
ElMessage.error('只能上传图片文件!')
|
|
return false
|
|
}
|
|
if (!isLt5M) {
|
|
ElMessage.error('图片大小不能超过 5MB!')
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function handleUploadSuccess(response) {
|
|
if (response.code === 0 || response.success) {
|
|
form.image = response.data.url
|
|
ElMessage.success('图片上传成功')
|
|
} else {
|
|
ElMessage.error(response.message || response.error?.message || '图片上传失败')
|
|
}
|
|
}
|
|
|
|
function handleUploadError() {
|
|
ElMessage.error('图片上传失败')
|
|
}
|
|
|
|
// Helper functions
|
|
function getImageUrl(path) {
|
|
if (!path) return ''
|
|
if (path.startsWith('http')) return path
|
|
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
|
|
return `${baseUrl}${path}`
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return '-'
|
|
const date = new Date(dateStr)
|
|
return date.toLocaleString('zh-CN', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit'
|
|
})
|
|
}
|
|
|
|
// Initialize
|
|
onMounted(() => {
|
|
fetchCategories()
|
|
fetchServices()
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.services-container {
|
|
.filter-card {
|
|
margin-bottom: 20px;
|
|
|
|
.filter-form {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
}
|
|
}
|
|
|
|
.table-card {
|
|
.pagination-container {
|
|
margin-top: 20px;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
}
|
|
}
|
|
|
|
.image-upload-container {
|
|
.image-uploader {
|
|
:deep(.el-upload) {
|
|
border: 1px dashed #d9d9d9;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
position: relative;
|
|
overflow: hidden;
|
|
width: 120px;
|
|
height: 120px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: border-color 0.3s;
|
|
|
|
&:hover {
|
|
border-color: #409eff;
|
|
}
|
|
}
|
|
|
|
.uploaded-image {
|
|
width: 120px;
|
|
height: 120px;
|
|
display: block;
|
|
}
|
|
|
|
.image-uploader-icon {
|
|
font-size: 28px;
|
|
color: #8c939d;
|
|
}
|
|
}
|
|
|
|
.upload-tip {
|
|
margin-top: 8px;
|
|
font-size: 12px;
|
|
color: #909399;
|
|
}
|
|
}
|
|
|
|
.form-tip {
|
|
font-size: 12px;
|
|
color: #909399;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.image-error-small {
|
|
width: 60px;
|
|
height: 60px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: #f5f7fa;
|
|
color: #909399;
|
|
font-size: 12px;
|
|
border: 1px dashed #dcdfe6;
|
|
border-radius: 4px;
|
|
}
|
|
}
|
|
</style>
|