appointment_system/admin/src/views/services/index.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>