Initial commit: hulai admin web frontend
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
当前提交
7183882e5d
11
.claude/launch.json
普通文件
11
.claude/launch.json
普通文件
@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "admin-web",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
11
.claude/settings.local.json
普通文件
11
.claude/settings.local.json
普通文件
@ -0,0 +1,11 @@
|
||||
{
|
||||
"permissions": {
|
||||
"defaultMode": "bypassPermissions",
|
||||
"allow": [
|
||||
"mcp__Claude_Preview__preview_start",
|
||||
"Bash(find . -type f -name package.json -o -name vite.config.* -o -name tsconfig.json -o -name .vue)",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(npm install:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
27
.gitignore
vendored
普通文件
27
.gitignore
vendored
普通文件
@ -0,0 +1,27 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
12
index.html
普通文件
12
index.html
普通文件
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>呼籁旅行 · 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
2510
package-lock.json
自动生成的
普通文件
2510
package-lock.json
自动生成的
普通文件
文件差异内容过多而无法显示
加载差异
26
package.json
普通文件
26
package.json
普通文件
@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "hulai-admin-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.7.9",
|
||||
"element-plus": "^2.9.1",
|
||||
"pinia": "^2.3.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"less": "^4.2.1",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^31.0.0",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
9
src/App.vue
普通文件
9
src/App.vue
普通文件
@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<el-config-provider :locale="zhCn">
|
||||
<router-view />
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
</script>
|
||||
20
src/api/about.js
普通文件
20
src/api/about.js
普通文件
@ -0,0 +1,20 @@
|
||||
import api from './index'
|
||||
|
||||
export const getStory = () => api.get('/about/story')
|
||||
export const updateStory = (data) => api.put('/about/story', data)
|
||||
export const getSubsidiaries = () => api.get('/about/subsidiaries')
|
||||
export const updateSubsidiaries = (data) => api.put('/about/subsidiaries', data)
|
||||
export const getCertifications = () => api.get('/about/certifications')
|
||||
export const updateCertifications = (data) => api.put('/about/certifications', data)
|
||||
export const getTrademark = () => api.get('/about/trademark')
|
||||
export const updateTrademark = (data) => api.put('/about/trademark', data)
|
||||
export const getCopyrights = () => api.get('/about/copyrights')
|
||||
export const updateCopyrights = (data) => api.put('/about/copyrights', data)
|
||||
export const getGuarantees = () => api.get('/about/guarantees')
|
||||
export const updateGuarantees = (data) => api.put('/about/guarantees', data)
|
||||
export const getXiaohongshu = () => api.get('/about/xiaohongshu')
|
||||
export const updateXiaohongshu = (data) => api.put('/about/xiaohongshu', data)
|
||||
export const getTeam = () => api.get('/about/team')
|
||||
export const updateTeam = (data) => api.put('/about/team', data)
|
||||
export const getCulture = () => api.get('/about/culture')
|
||||
export const updateCulture = (data) => api.put('/about/culture', data)
|
||||
5
src/api/auth.js
普通文件
5
src/api/auth.js
普通文件
@ -0,0 +1,5 @@
|
||||
import api from './index'
|
||||
|
||||
export const login = (data) => api.post('/auth/login', data)
|
||||
export const getMe = () => api.get('/auth/me')
|
||||
export const changePassword = (data) => api.put('/auth/password', data)
|
||||
14
src/api/autumnProducts.js
普通文件
14
src/api/autumnProducts.js
普通文件
@ -0,0 +1,14 @@
|
||||
import api from './index'
|
||||
|
||||
export const getConfig = () => api.get('/autumn-products/config')
|
||||
export const updateConfig = (data) => api.put('/autumn-products/config', data)
|
||||
export const getVersions = () => api.get('/autumn-products/versions')
|
||||
export const createVersion = (data) => api.post('/autumn-products/versions', data)
|
||||
export const updateVersion = (id, data) => api.put(`/autumn-products/versions/${id}`, data)
|
||||
export const deleteVersion = (id) => api.delete(`/autumn-products/versions/${id}`)
|
||||
export const getHighlights = (category) => api.get('/autumn-products/highlights', { params: category ? { category } : {} })
|
||||
export const updateHighlights = (data) => api.put('/autumn-products/highlights', data)
|
||||
export const getTimeline = () => api.get('/autumn-products/timeline')
|
||||
export const createTimeline = (data) => api.post('/autumn-products/timeline', data)
|
||||
export const updateTimeline = (id, data) => api.put(`/autumn-products/timeline/${id}`, data)
|
||||
export const deleteTimeline = (id) => api.delete(`/autumn-products/timeline/${id}`)
|
||||
7
src/api/blog.js
普通文件
7
src/api/blog.js
普通文件
@ -0,0 +1,7 @@
|
||||
import api from './index'
|
||||
|
||||
export const getBlogs = (params) => api.get('/blog/', { params })
|
||||
export const createBlog = (data) => api.post('/blog/', data)
|
||||
export const updateBlog = (id, data) => api.put(`/blog/${id}`, data)
|
||||
export const deleteBlog = (id) => api.delete(`/blog/${id}`)
|
||||
export const reorderBlogs = (ids) => api.put('/blog/reorder/batch', ids)
|
||||
4
src/api/brand.js
普通文件
4
src/api/brand.js
普通文件
@ -0,0 +1,4 @@
|
||||
import api from './index'
|
||||
|
||||
export const getBrand = () => api.get('/brand/')
|
||||
export const updateBrand = (data) => api.put('/brand/', data)
|
||||
5
src/api/calendar.js
普通文件
5
src/api/calendar.js
普通文件
@ -0,0 +1,5 @@
|
||||
import api from './index'
|
||||
|
||||
export const getCalendar = (year) => api.get('/calendar/', { params: { year } })
|
||||
export const updateCalendarMonth = (id, data) => api.put(`/calendar/${id}`, data)
|
||||
export const initCalendarYear = (year) => api.post('/calendar/init', null, { params: { year } })
|
||||
8
src/api/contact.js
普通文件
8
src/api/contact.js
普通文件
@ -0,0 +1,8 @@
|
||||
import api from './index'
|
||||
|
||||
export const getChannels = () => api.get('/contact/channels')
|
||||
export const createChannel = (data) => api.post('/contact/channels', data)
|
||||
export const updateChannel = (id, data) => api.put(`/contact/channels/${id}`, data)
|
||||
export const deleteChannel = (id) => api.delete(`/contact/channels/${id}`)
|
||||
export const getContactConfig = () => api.get('/contact/config')
|
||||
export const updateContactConfig = (data) => api.put('/contact/config', data)
|
||||
4
src/api/courses.js
普通文件
4
src/api/courses.js
普通文件
@ -0,0 +1,4 @@
|
||||
import api from './index'
|
||||
|
||||
export const getCoursesConfig = () => api.get('/courses/')
|
||||
export const updateCoursesConfig = (data) => api.put('/courses/', data)
|
||||
6
src/api/customize.js
普通文件
6
src/api/customize.js
普通文件
@ -0,0 +1,6 @@
|
||||
import api from './index'
|
||||
|
||||
export const getSubmissions = (params) => api.get('/customize/submissions', { params })
|
||||
export const getSubmission = (id) => api.get(`/customize/submissions/${id}`)
|
||||
export const updateSubmissionStatus = (id, status) =>
|
||||
api.put(`/customize/submissions/${id}/status`, { status })
|
||||
12
src/api/destinations.js
普通文件
12
src/api/destinations.js
普通文件
@ -0,0 +1,12 @@
|
||||
import api from './index'
|
||||
|
||||
export const getConfig = () => api.get('/destinations/config')
|
||||
export const updateConfig = (data) => api.put('/destinations/config', data)
|
||||
export const getItems = () => api.get('/destinations/items')
|
||||
export const updateItems = (data) => api.put('/destinations/items', data)
|
||||
export const getDimensions = () => api.get('/destinations/dimensions')
|
||||
export const createDimension = (data) => api.post('/destinations/dimensions', data)
|
||||
export const updateDimension = (id, data) => api.put(`/destinations/dimensions/${id}`, data)
|
||||
export const deleteDimension = (id) => api.delete(`/destinations/dimensions/${id}`)
|
||||
export const getHonestItems = () => api.get('/destinations/honest-items')
|
||||
export const updateHonestItems = (data) => api.put('/destinations/honest-items', data)
|
||||
@ -0,0 +1,7 @@
|
||||
import api from './index'
|
||||
|
||||
export const getDestinationsDetail = (params) => api.get('/destinations-detail/', { params })
|
||||
export const createDestinationDetail = (data) => api.post('/destinations-detail/', data)
|
||||
export const updateDestinationDetail = (id, data) => api.put(`/destinations-detail/${id}`, data)
|
||||
export const deleteDestinationDetail = (id) => api.delete(`/destinations-detail/${id}`)
|
||||
export const reorderDestinationsDetail = (ids) => api.put('/destinations-detail/reorder/batch', ids)
|
||||
4
src/api/export.js
普通文件
4
src/api/export.js
普通文件
@ -0,0 +1,4 @@
|
||||
import api from './index'
|
||||
|
||||
export const doExport = (data) => api.post('/export/', data)
|
||||
export const getExportLog = () => api.get('/export/log')
|
||||
9
src/api/faq.js
普通文件
9
src/api/faq.js
普通文件
@ -0,0 +1,9 @@
|
||||
import api from './index'
|
||||
|
||||
export const getCategories = () => api.get('/faq/categories')
|
||||
export const createCategory = (data) => api.post('/faq/categories', data)
|
||||
export const updateCategory = (id, data) => api.put(`/faq/categories/${id}`, data)
|
||||
export const deleteCategory = (id) => api.delete(`/faq/categories/${id}`)
|
||||
export const createQuestion = (data, categoryId) => api.post(`/faq/questions?category_id=${categoryId}`, data)
|
||||
export const updateQuestion = (id, data) => api.put(`/faq/questions/${id}`, data)
|
||||
export const deleteQuestion = (id) => api.delete(`/faq/questions/${id}`)
|
||||
7
src/api/gallery.js
普通文件
7
src/api/gallery.js
普通文件
@ -0,0 +1,7 @@
|
||||
import api from './index'
|
||||
|
||||
export const getGallery = (params) => api.get('/gallery/', { params })
|
||||
export const createGalleryItem = (data) => api.post('/gallery/', data)
|
||||
export const updateGalleryItem = (id, data) => api.put(`/gallery/${id}`, data)
|
||||
export const deleteGalleryItem = (id) => api.delete(`/gallery/${id}`)
|
||||
export const reorderGallery = (ids) => api.put('/gallery/reorder/batch', ids)
|
||||
6
src/api/guides.js
普通文件
6
src/api/guides.js
普通文件
@ -0,0 +1,6 @@
|
||||
import api from './index'
|
||||
|
||||
export const getGuideConfig = () => api.get('/guides/config')
|
||||
export const updateGuideConfig = (data) => api.put('/guides/config', data)
|
||||
export const getGuideSections = () => api.get('/guides/sections')
|
||||
export const updateGuideSection = (sectionId, data) => api.put(`/guides/sections/${sectionId}`, data)
|
||||
38
src/api/index.js
普通文件
38
src/api/index.js
普通文件
@ -0,0 +1,38 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '../router'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 60000,
|
||||
})
|
||||
|
||||
// Request interceptor: attach JWT token
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Response interceptor: unified error handling
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
router.push('/login')
|
||||
ElMessage.warning('登录已过期,请重新登录')
|
||||
} else if (error.response?.status === 403) {
|
||||
ElMessage.error('没有操作权限')
|
||||
} else if (error.response?.status >= 500) {
|
||||
ElMessage.error('服务器错误,请稍后重试')
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
ElMessage.error('请求超时,请检查网络连接')
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
4
src/api/navigation.js
普通文件
4
src/api/navigation.js
普通文件
@ -0,0 +1,4 @@
|
||||
import api from './index'
|
||||
|
||||
export const getNavigation = () => api.get('/navigation/')
|
||||
export const updateNavigation = (data) => api.put('/navigation/', data)
|
||||
7
src/api/news.js
普通文件
7
src/api/news.js
普通文件
@ -0,0 +1,7 @@
|
||||
import api from './index'
|
||||
|
||||
export const getNews = (params) => api.get('/news/', { params })
|
||||
export const createNews = (data) => api.post('/news/', data)
|
||||
export const updateNews = (id, data) => api.put(`/news/${id}`, data)
|
||||
export const deleteNews = (id) => api.delete(`/news/${id}`)
|
||||
export const reorderNews = (ids) => api.put('/news/reorder/batch', ids)
|
||||
7
src/api/partners.js
普通文件
7
src/api/partners.js
普通文件
@ -0,0 +1,7 @@
|
||||
import api from './index'
|
||||
|
||||
export const getPartners = () => api.get('/partners/')
|
||||
export const createPartner = (data) => api.post('/partners/', data)
|
||||
export const updatePartner = (id, data) => api.put(`/partners/${id}`, data)
|
||||
export const deletePartner = (id) => api.delete(`/partners/${id}`)
|
||||
export const reorderPartners = (ids) => api.put('/partners/reorder/batch', ids)
|
||||
7
src/api/pricing.js
普通文件
7
src/api/pricing.js
普通文件
@ -0,0 +1,7 @@
|
||||
import api from './index'
|
||||
|
||||
export const getPricing = () => api.get('/pricing/')
|
||||
export const createPricing = (data) => api.post('/pricing/', data)
|
||||
export const updatePricing = (id, data) => api.put(`/pricing/${id}`, data)
|
||||
export const deletePricing = (id) => api.delete(`/pricing/${id}`)
|
||||
export const reorderPricing = (ids) => api.put('/pricing/reorder/batch', ids)
|
||||
12
src/api/products.js
普通文件
12
src/api/products.js
普通文件
@ -0,0 +1,12 @@
|
||||
import api from './index'
|
||||
|
||||
export const getProductConfig = () => api.get('/products/config')
|
||||
export const updateProductConfig = (data) => api.put('/products/config', data)
|
||||
export const getVersions = () => api.get('/products/versions')
|
||||
export const createVersion = (data) => api.post('/products/versions', data)
|
||||
export const updateVersion = (id, data) => api.put(`/products/versions/${id}`, data)
|
||||
export const deleteVersion = (id) => api.delete(`/products/versions/${id}`)
|
||||
export const getSummerCamp = () => api.get('/products/summer-camp')
|
||||
export const updateSummerCamp = (data) => api.put('/products/summer-camp', data)
|
||||
export const getSelectionGuide = () => api.get('/products/selection-guide')
|
||||
export const updateSelectionGuide = (data) => api.put('/products/selection-guide', data)
|
||||
7
src/api/qualifications.js
普通文件
7
src/api/qualifications.js
普通文件
@ -0,0 +1,7 @@
|
||||
import api from './index'
|
||||
|
||||
export const getQualifications = () => api.get('/qualifications/')
|
||||
export const createQualification = (data) => api.post('/qualifications/', data)
|
||||
export const updateQualification = (id, data) => api.put(`/qualifications/${id}`, data)
|
||||
export const deleteQualification = (id) => api.delete(`/qualifications/${id}`)
|
||||
export const reorderQualifications = (ids) => api.put('/qualifications/reorder/batch', ids)
|
||||
9
src/api/reviews.js
普通文件
9
src/api/reviews.js
普通文件
@ -0,0 +1,9 @@
|
||||
import api from './index'
|
||||
|
||||
export const getReviewSummary = () => api.get('/reviews/summary')
|
||||
export const updateReviewSummary = (data) => api.put('/reviews/summary', data)
|
||||
export const getReviews = (params) => api.get('/reviews/', { params })
|
||||
export const createReview = (data) => api.post('/reviews/', data)
|
||||
export const updateReview = (id, data) => api.put(`/reviews/${id}`, data)
|
||||
export const deleteReview = (id) => api.delete(`/reviews/${id}`)
|
||||
export const reorderReviews = (ids) => api.put('/reviews/reorder/batch', ids)
|
||||
4
src/api/selector.js
普通文件
4
src/api/selector.js
普通文件
@ -0,0 +1,4 @@
|
||||
import api from './index'
|
||||
|
||||
export const getSelectorConfig = () => api.get('/selector/')
|
||||
export const updateSelectorConfig = (data) => api.put('/selector/', data)
|
||||
4
src/api/seo.js
普通文件
4
src/api/seo.js
普通文件
@ -0,0 +1,4 @@
|
||||
import api from './index'
|
||||
|
||||
export const getSeoPages = () => api.get('/seo/pages')
|
||||
export const updateSeoPage = (pageKey, data) => api.put(`/seo/pages/${pageKey}`, data)
|
||||
6
src/api/site-images.js
普通文件
6
src/api/site-images.js
普通文件
@ -0,0 +1,6 @@
|
||||
import api from './index'
|
||||
|
||||
export const getSiteImages = () => api.get('/site-images/')
|
||||
export const updateSiteImage = (id, data) => api.put(`/site-images/${id}`, data)
|
||||
export const createSiteImage = (data) => api.post('/site-images/', data)
|
||||
export const deleteSiteImage = (id) => api.delete(`/site-images/${id}`)
|
||||
7
src/api/stories.js
普通文件
7
src/api/stories.js
普通文件
@ -0,0 +1,7 @@
|
||||
import api from './index'
|
||||
|
||||
export const getStories = (params) => api.get('/stories/', { params })
|
||||
export const createStory = (data) => api.post('/stories/', data)
|
||||
export const updateStory = (id, data) => api.put(`/stories/${id}`, data)
|
||||
export const deleteStory = (id) => api.delete(`/stories/${id}`)
|
||||
export const reorderStories = (ids) => api.put('/stories/reorder/batch', ids)
|
||||
9
src/api/upload.js
普通文件
9
src/api/upload.js
普通文件
@ -0,0 +1,9 @@
|
||||
import api from './index'
|
||||
|
||||
export const uploadImage = (file, subdir = 'uploads') => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return api.post(`/upload/image?subdir=${subdir}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
}
|
||||
8
src/api/users.js
普通文件
8
src/api/users.js
普通文件
@ -0,0 +1,8 @@
|
||||
import api from './index'
|
||||
|
||||
export const getUsers = () => api.get('/auth/users')
|
||||
export const createUser = (data) => api.post('/auth/users', data)
|
||||
export const updateUser = (id, data) => api.put(`/auth/users/${id}`, data)
|
||||
export const resetPassword = (id, data) => api.put(`/auth/users/${id}/reset-password`, data)
|
||||
export const deleteUser = (id) => api.delete(`/auth/users/${id}`)
|
||||
export const changeMyPassword = (data) => api.put('/auth/password', data)
|
||||
16
src/api/versions.js
普通文件
16
src/api/versions.js
普通文件
@ -0,0 +1,16 @@
|
||||
import api from './index'
|
||||
|
||||
export const getVersionConfig = () => api.get('/versions/config')
|
||||
export const updateVersionConfig = (data) => api.put('/versions/config', data)
|
||||
export const getUpgrades = () => api.get('/versions/upgrades')
|
||||
export const updateUpgrades = (data) => api.put('/versions/upgrades', data)
|
||||
export const getHighlights = () => api.get('/versions/highlights')
|
||||
export const updateHighlights = (data) => api.put('/versions/highlights', data)
|
||||
export const getCompare = () => api.get('/versions/compare')
|
||||
export const updateCompare = (data) => api.put('/versions/compare', data)
|
||||
export const getTimeline = () => api.get('/versions/timeline')
|
||||
export const createTimeline = (data) => api.post('/versions/timeline', data)
|
||||
export const updateTimeline = (id, data) => api.put(`/versions/timeline/${id}`, data)
|
||||
export const deleteTimeline = (id) => api.delete(`/versions/timeline/${id}`)
|
||||
export const getPhilosophy = () => api.get('/versions/philosophy')
|
||||
export const updatePhilosophy = (data) => api.put('/versions/philosophy', data)
|
||||
12
src/api/winterCamp.js
普通文件
12
src/api/winterCamp.js
普通文件
@ -0,0 +1,12 @@
|
||||
import api from './index'
|
||||
|
||||
export const getConfig = () => api.get('/winter-camp/config')
|
||||
export const updateConfig = (data) => api.put('/winter-camp/config', data)
|
||||
export const getHotels = () => api.get('/winter-camp/hotels')
|
||||
export const updateHotels = (data) => api.put('/winter-camp/hotels', data)
|
||||
export const getItinerary = () => api.get('/winter-camp/itinerary')
|
||||
export const updateItinerary = (data) => api.put('/winter-camp/itinerary', data)
|
||||
export const getFaq = () => api.get('/winter-camp/faq')
|
||||
export const createFaq = (data) => api.post('/winter-camp/faq', data)
|
||||
export const updateFaq = (id, data) => api.put(`/winter-camp/faq/${id}`, data)
|
||||
export const deleteFaq = (id) => api.delete(`/winter-camp/faq/${id}`)
|
||||
14
src/api/winterProducts.js
普通文件
14
src/api/winterProducts.js
普通文件
@ -0,0 +1,14 @@
|
||||
import api from './index'
|
||||
|
||||
export const getConfig = () => api.get('/winter-products/config')
|
||||
export const updateConfig = (data) => api.put('/winter-products/config', data)
|
||||
export const getVersions = () => api.get('/winter-products/versions')
|
||||
export const createVersion = (data) => api.post('/winter-products/versions', data)
|
||||
export const updateVersion = (id, data) => api.put(`/winter-products/versions/${id}`, data)
|
||||
export const deleteVersion = (id) => api.delete(`/winter-products/versions/${id}`)
|
||||
export const getHighlights = (category) => api.get('/winter-products/highlights', { params: category ? { category } : {} })
|
||||
export const updateHighlights = (data) => api.put('/winter-products/highlights', data)
|
||||
export const getTimeline = () => api.get('/winter-products/timeline')
|
||||
export const createTimeline = (data) => api.post('/winter-products/timeline', data)
|
||||
export const updateTimeline = (id, data) => api.put(`/winter-products/timeline/${id}`, data)
|
||||
export const deleteTimeline = (id) => api.delete(`/winter-products/timeline/${id}`)
|
||||
@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="image-upload-wrap">
|
||||
<div v-if="modelValue" class="image-preview">
|
||||
<el-image :src="previewUrl" fit="cover" :preview-src-list="[previewUrl]" />
|
||||
<div class="image-actions">
|
||||
<el-button type="danger" circle size="small" @click="emit('update:modelValue', '')">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-upload
|
||||
v-else
|
||||
:show-file-list="false"
|
||||
:before-upload="beforeUpload"
|
||||
:http-request="handleUpload"
|
||||
accept=".jpg,.jpeg,.png,.webp"
|
||||
class="upload-trigger"
|
||||
>
|
||||
<div class="upload-placeholder">
|
||||
<el-icon :size="28"><Plus /></el-icon>
|
||||
<span>点击上传</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
<el-input
|
||||
v-if="showInput"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
placeholder="或填写图片路径"
|
||||
style="margin-top: 6px;"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { uploadImage } from '../../api/upload'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
subdir: { type: String, default: 'uploads' },
|
||||
showInput: { type: Boolean, default: true },
|
||||
width: { type: String, default: '120px' },
|
||||
height: { type: String, default: '120px' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const previewUrl = computed(() => {
|
||||
if (!props.modelValue) return ''
|
||||
if (props.modelValue.startsWith('http')) return props.modelValue
|
||||
return props.modelValue
|
||||
})
|
||||
|
||||
const beforeUpload = (file) => {
|
||||
const isImage = ['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
|
||||
const isLt5M = file.size / 1024 / 1024 < 5
|
||||
if (!isImage) { ElMessage.error('只能上传 JPG/PNG/WebP 格式'); return false }
|
||||
if (!isLt5M) { ElMessage.error('图片不能超过 5MB'); return false }
|
||||
return true
|
||||
}
|
||||
|
||||
const handleUpload = async ({ file }) => {
|
||||
try {
|
||||
const res = await uploadImage(file, props.subdir)
|
||||
emit('update:modelValue', res.data.url)
|
||||
ElMessage.success('上传成功')
|
||||
} catch {
|
||||
ElMessage.error('上传失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-upload-wrap {
|
||||
display: inline-block;
|
||||
}
|
||||
.image-preview {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.image-preview .el-image {
|
||||
width: v-bind(width);
|
||||
height: v-bind(height);
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e4e7ed;
|
||||
object-fit: cover;
|
||||
}
|
||||
.image-actions {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
}
|
||||
.upload-trigger .upload-placeholder {
|
||||
width: v-bind(width);
|
||||
height: v-bind(height);
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
color: #909399;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
font-size: 13px;
|
||||
}
|
||||
.upload-trigger .upload-placeholder:hover {
|
||||
border-color: #409EFF;
|
||||
color: #409EFF;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<el-container style="height: 100vh">
|
||||
<el-aside width="220px" style="background: linear-gradient(180deg, #0a1a12 0%, #0f2419 50%, #142e1e 100%);">
|
||||
<div class="logo">
|
||||
<h1>呼籁管理后台</h1>
|
||||
<span class="logo-sub">HULAI CMS</span>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="$route.path"
|
||||
router
|
||||
background-color="transparent"
|
||||
text-color="rgba(255,255,255,0.7)"
|
||||
active-text-color="#ffffff"
|
||||
>
|
||||
<el-menu-item index="/">
|
||||
<el-icon><DataBoard /></el-icon>
|
||||
<span>仪表盘</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-sub-menu index="content">
|
||||
<template #title>
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>内容管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/reviews">客户评价</el-menu-item>
|
||||
<el-menu-item index="/stories">客户故事</el-menu-item>
|
||||
<el-menu-item index="/blog">博客文章</el-menu-item>
|
||||
<el-menu-item index="/news">新闻动态</el-menu-item>
|
||||
<el-menu-item index="/faq">常见问答</el-menu-item>
|
||||
<el-menu-item index="/contact">联系方式</el-menu-item>
|
||||
<el-menu-item index="/seo">SEO配置</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-sub-menu index="product">
|
||||
<template #title>
|
||||
<el-icon><Goods /></el-icon>
|
||||
<span>产品管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/products">额吉的故乡(夏)</el-menu-item>
|
||||
<el-menu-item index="/products/autumn">游牧的森林(秋)</el-menu-item>
|
||||
<el-menu-item index="/products/winter">嗨冰雪(冬)</el-menu-item>
|
||||
<el-menu-item index="/products/summer-camp">夏令营</el-menu-item>
|
||||
<el-menu-item index="/products/winter-camp">冬季营</el-menu-item>
|
||||
<el-menu-item index="/products/selection-guide">选品指南</el-menu-item>
|
||||
<el-menu-item index="/versions">版本历史</el-menu-item>
|
||||
<el-menu-item index="/destinations">目的地对比</el-menu-item>
|
||||
<el-menu-item index="/destinations-detail">景点详情</el-menu-item>
|
||||
<el-menu-item index="/courses">研学课程</el-menu-item>
|
||||
<el-menu-item index="/selector">选版工具</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-sub-menu index="brand-about">
|
||||
<template #title>
|
||||
<el-icon><OfficeBuilding /></el-icon>
|
||||
<span>品牌与公司</span>
|
||||
</template>
|
||||
<el-menu-item index="/brand">品牌信息</el-menu-item>
|
||||
<el-menu-item index="/about">关于我们</el-menu-item>
|
||||
<el-menu-item index="/qualifications">资质荣誉</el-menu-item>
|
||||
<el-menu-item index="/partners">合作伙伴</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item index="/guides">
|
||||
<el-icon><Guide /></el-icon>
|
||||
<span>出行指南</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/calendar">
|
||||
<el-icon><Calendar /></el-icon>
|
||||
<span>行程日历</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/pricing">
|
||||
<el-icon><Tickets /></el-icon>
|
||||
<span>价格信息</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/navigation">
|
||||
<el-icon><Menu /></el-icon>
|
||||
<span>导航菜单</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/gallery">
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>旅拍作品</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/site-images">
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>网站图片</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/customize/submissions">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>定制表单记录</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/users">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>用户管理</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/export">
|
||||
<el-icon><Upload /></el-icon>
|
||||
<span>导出与构建</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<el-header class="app-header">
|
||||
<div class="header-left">
|
||||
<span class="header-breadcrumb">{{ $route.name }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-tag v-if="authStore.user" type="info" effect="plain" round>
|
||||
{{ authStore.user.display_name || authStore.user.username }}
|
||||
</el-tag>
|
||||
<el-button text @click="handleLogout" style="color: var(--text-tertiary);">退出</el-button>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main style="background: var(--color-bg); padding: 24px;">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
onMounted(() => {
|
||||
authStore.fetchUser()
|
||||
})
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.clearAuth()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logo {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.logo h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: rgba(255,255,255,0.95);
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.logo-sub {
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.4);
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--color-bg-card);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
padding: 0 24px;
|
||||
height: 56px;
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header-breadcrumb {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
19
src/main.js
普通文件
19
src/main.js
普通文件
@ -0,0 +1,19 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/index.less'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
// Register all icons
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
66
src/router/index.js
普通文件
66
src/router/index.js
普通文件
@ -0,0 +1,66 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/Login.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../components/layout/AdminLayout.vue'),
|
||||
children: [
|
||||
{ path: '', name: 'Dashboard', component: () => import('../views/Dashboard.vue') },
|
||||
{ path: 'brand', name: 'Brand', component: () => import('../views/brand/BrandEdit.vue') },
|
||||
{ path: 'products', name: 'Products', component: () => import('../views/products/ProductList.vue') },
|
||||
{ path: 'products/summer-camp', name: 'SummerCamp', component: () => import('../views/products/SummerCampEdit.vue') },
|
||||
{ path: 'products/selection-guide', name: 'SelectionGuide', component: () => import('../views/products/SelectionGuideEdit.vue') },
|
||||
{ path: 'products/autumn', name: 'AutumnProducts', component: () => import('../views/products/AutumnProductList.vue') },
|
||||
{ path: 'products/winter', name: 'WinterProducts', component: () => import('../views/products/WinterProductList.vue') },
|
||||
{ path: 'products/winter-camp', name: 'WinterCamp', component: () => import('../views/products/WinterCampEdit.vue') },
|
||||
{ path: 'destinations', name: 'Destinations', component: () => import('../views/destinations/DestinationEdit.vue') },
|
||||
{ path: 'faq', name: 'FAQ', component: () => import('../views/faq/FaqList.vue') },
|
||||
{ path: 'reviews', name: 'Reviews', component: () => import('../views/reviews/ReviewList.vue') },
|
||||
{ path: 'about', name: 'About', component: () => import('../views/about/AboutEdit.vue') },
|
||||
{ path: 'contact', name: 'Contact', component: () => import('../views/contact/ContactList.vue') },
|
||||
{ path: 'seo', name: 'SEO', component: () => import('../views/seo/SeoList.vue') },
|
||||
{ path: 'navigation', name: 'Navigation', component: () => import('../views/navigation/NavigationEdit.vue') },
|
||||
{ path: 'versions', name: 'Versions', component: () => import('../views/versions/VersionTimeline.vue') },
|
||||
{ path: 'guides', name: 'Guides', component: () => import('../views/guides/GuideList.vue') },
|
||||
{ path: 'site-images', name: 'SiteImages', component: () => import('../views/site-images/SiteImageList.vue') },
|
||||
{ path: 'users', name: 'Users', component: () => import('../views/users/UserList.vue') },
|
||||
{ path: 'export', name: 'Export', component: () => import('../views/export/ExportPanel.vue') },
|
||||
{ path: 'blog', name: 'Blog', component: () => import('../views/blog/BlogList.vue') },
|
||||
{ path: 'stories', name: 'Stories', component: () => import('../views/stories/StoryList.vue') },
|
||||
{ path: 'news', name: 'News', component: () => import('../views/news/NewsList.vue') },
|
||||
{ path: 'pricing', name: 'Pricing', component: () => import('../views/pricing/PricingList.vue') },
|
||||
{ path: 'qualifications', name: 'Qualifications', component: () => import('../views/qualifications/QualificationList.vue') },
|
||||
{ path: 'partners', name: 'Partners', component: () => import('../views/partners/PartnerList.vue') },
|
||||
{ path: 'calendar', name: 'Calendar', component: () => import('../views/calendar/CalendarEdit.vue') },
|
||||
{ path: 'destinations-detail', name: 'DestinationsDetail', component: () => import('../views/destinations-detail/DestinationsDetailList.vue') },
|
||||
{ path: 'gallery', name: 'Gallery', component: () => import('../views/gallery/GalleryList.vue') },
|
||||
{ path: 'courses', name: 'Courses', component: () => import('../views/courses/CoursesEdit.vue') },
|
||||
{ path: 'selector', name: 'Selector', component: () => import('../views/selector/SelectorEdit.vue') },
|
||||
{ path: 'customize/submissions', name: 'CustomizeSubmissions', component: () => import('../views/customize/CustomizeSubmissions.vue') },
|
||||
],
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
// Auth guard
|
||||
router.beforeEach((to, from, next) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!to.meta.public && !token) {
|
||||
next('/login')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
30
src/stores/auth.js
普通文件
30
src/stores/auth.js
普通文件
@ -0,0 +1,30 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getMe } from '../api/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref(null)
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
|
||||
const setToken = (t) => {
|
||||
token.value = t
|
||||
localStorage.setItem('token', t)
|
||||
}
|
||||
|
||||
const clearAuth = () => {
|
||||
token.value = ''
|
||||
user.value = null
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const res = await getMe()
|
||||
user.value = res.data
|
||||
} catch {
|
||||
clearAuth()
|
||||
}
|
||||
}
|
||||
|
||||
return { user, token, setToken, clearAuth, fetchUser }
|
||||
})
|
||||
188
src/styles/index.less
普通文件
188
src/styles/index.less
普通文件
@ -0,0 +1,188 @@
|
||||
/* ===== 呼籁管理后台设计系统 ===== */
|
||||
|
||||
/* 设计变量 - 墨绿质感方案 */
|
||||
:root {
|
||||
/* 品牌色 - 低饱和墨绿 */
|
||||
--color-primary: #1a5632;
|
||||
--color-primary-light: #f0f5f1;
|
||||
--color-primary-dark: #0f3d22;
|
||||
/* 辅助色 */
|
||||
--color-accent: #b8860b;
|
||||
--color-accent-light: #faf5eb;
|
||||
/* 中性色 */
|
||||
--color-bg: #f7f8f7;
|
||||
--color-bg-card: #ffffff;
|
||||
--color-border: #e2e5e3;
|
||||
--color-border-light: #eef0ef;
|
||||
/* 文字色 - 更沉稳 */
|
||||
--text-primary: #1a1f1c;
|
||||
--text-secondary: #3d4a40;
|
||||
--text-tertiary: #7a857c;
|
||||
--text-disabled: #b5bdb7;
|
||||
/* 间距 */
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 24px;
|
||||
--space-xl: 32px;
|
||||
/* 圆角 */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
/* 阴影 */
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
color: var(--text-primary);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
/* ===== 页面头部 ===== */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-lg);
|
||||
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 过滤栏 ===== */
|
||||
.filter-bar {
|
||||
margin-bottom: var(--space-md);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ===== Element Plus 覆盖 ===== */
|
||||
|
||||
/* 卡片 */
|
||||
.el-card {
|
||||
border-radius: var(--radius-md) !important;
|
||||
border-color: var(--color-border-light) !important;
|
||||
box-shadow: var(--shadow-sm) !important;
|
||||
transition: box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-md) !important;
|
||||
}
|
||||
}
|
||||
.el-card__header {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
border-bottom-color: var(--color-border-light) !important;
|
||||
padding: 16px 20px !important;
|
||||
}
|
||||
.el-card__body {
|
||||
padding: 20px !important;
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
.el-table {
|
||||
border-radius: var(--radius-md) !important;
|
||||
overflow: hidden;
|
||||
|
||||
th.el-table__cell {
|
||||
background: var(--color-primary-light) !important;
|
||||
color: var(--text-primary) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 按钮 - 只覆盖实心按钮,不影响 text 类型 */
|
||||
.el-button--primary:not(.is-text):not(.is-link) {
|
||||
background-color: var(--color-primary) !important;
|
||||
border-color: var(--color-primary) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary-dark) !important;
|
||||
border-color: var(--color-primary-dark) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 表格内操作按钮 - 加粗加深确保可读 */
|
||||
.el-table .el-button.is-text {
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
.el-table .el-button--primary.is-text {
|
||||
color: #2563eb !important;
|
||||
}
|
||||
.el-table .el-button--danger.is-text {
|
||||
color: #dc2626 !important;
|
||||
}
|
||||
|
||||
/* 标签 */
|
||||
.el-tag {
|
||||
border-radius: var(--radius-sm) !important;
|
||||
}
|
||||
|
||||
/* 对话框 */
|
||||
.el-dialog {
|
||||
border-radius: var(--radius-lg) !important;
|
||||
}
|
||||
.el-dialog__header {
|
||||
padding: 20px 24px 12px !important;
|
||||
}
|
||||
.el-dialog__title {
|
||||
font-weight: 700 !important;
|
||||
font-size: 18px !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
.el-dialog__body {
|
||||
padding: 12px 24px 20px !important;
|
||||
}
|
||||
|
||||
/* Tab 标签页 */
|
||||
.el-tabs__item.is-active {
|
||||
color: var(--color-primary) !important;
|
||||
}
|
||||
.el-tabs__active-bar {
|
||||
background-color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
/* 分割线 */
|
||||
.el-divider__text {
|
||||
color: var(--text-secondary) !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
/* 菜单 */
|
||||
.el-menu-item.is-active {
|
||||
color: var(--color-primary) !important;
|
||||
background: var(--color-primary-light) !important;
|
||||
border-right: 3px solid var(--color-primary);
|
||||
}
|
||||
|
||||
/* 统计数字 */
|
||||
.el-statistic {
|
||||
.el-statistic__number {
|
||||
color: var(--color-primary) !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 响应式 ===== */
|
||||
@media (max-width: 1200px) {
|
||||
.page-header h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
197
src/views/Dashboard.vue
普通文件
197
src/views/Dashboard.vue
普通文件
@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>仪表盘</h2>
|
||||
<el-button type="primary" @click="$router.push('/export')">导出 JSON</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="16" style="margin-bottom: 24px;">
|
||||
<el-col :span="6" v-for="item in statCards" :key="item.key">
|
||||
<div class="stat-card" :style="{borderLeft: `4px solid ${item.color}`}">
|
||||
<div class="stat-icon" :style="{background: item.bg}">
|
||||
<el-icon :size="22" :style="{color: item.color}"><component :is="item.icon" /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats[item.key] }}</div>
|
||||
<div class="stat-label">{{ item.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<!-- 快捷操作 -->
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header><span style="font-size:15px;">快捷操作</span></template>
|
||||
<div class="quick-actions">
|
||||
<div class="action-item" @click="$router.push('/reviews')">
|
||||
<el-icon :size="24" color="#3a7d44"><ChatLineRound /></el-icon>
|
||||
<span>管理评价</span>
|
||||
</div>
|
||||
<div class="action-item" @click="$router.push('/faq')">
|
||||
<el-icon :size="24" color="#d97706"><QuestionFilled /></el-icon>
|
||||
<span>管理FAQ</span>
|
||||
</div>
|
||||
<div class="action-item" @click="$router.push('/products')">
|
||||
<el-icon :size="24" color="#2563eb"><Goods /></el-icon>
|
||||
<span>管理产品</span>
|
||||
</div>
|
||||
<div class="action-item" @click="$router.push('/site-images')">
|
||||
<el-icon :size="24" color="#e11d48"><Picture /></el-icon>
|
||||
<span>网站图片</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 最近导出 -->
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header><span style="font-size:15px;">最近导出</span></template>
|
||||
<div v-if="recentExports.length === 0" class="empty-state">
|
||||
<el-icon :size="40" color="#c0c4cc"><Document /></el-icon>
|
||||
<p>暂无导出记录</p>
|
||||
</div>
|
||||
<div v-for="log in recentExports" :key="log.id" class="export-log-item">
|
||||
<el-tag :type="log.status === 'success' ? 'success' : 'danger'" size="small" round>
|
||||
{{ log.status === 'success' ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
<span class="log-user">{{ log.exported_by }}</span>
|
||||
<span class="log-time">{{ log.created_at }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getReviews } from '../api/reviews'
|
||||
import { getCategories } from '../api/faq'
|
||||
import { getChannels } from '../api/contact'
|
||||
import { getVersions } from '../api/products'
|
||||
import { getExportLog } from '../api/export'
|
||||
|
||||
const statCards = [
|
||||
{ key: 'reviewCount', label: '客户评价', icon: 'ChatLineRound', color: '#1a5632', bg: '#f0f5f1' },
|
||||
{ key: 'faqCount', label: 'FAQ 问题', icon: 'QuestionFilled', color: '#92400e', bg: '#faf5eb' },
|
||||
{ key: 'versionCount', label: '产品版本', icon: 'Goods', color: '#1e3a5f', bg: '#eef2f7' },
|
||||
{ key: 'contactCount', label: '联系渠道', icon: 'Phone', color: '#6b2142', bg: '#f7eef2' },
|
||||
]
|
||||
|
||||
const stats = ref({ reviewCount: 0, faqCount: 0, versionCount: 0, contactCount: 0 })
|
||||
const recentExports = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [reviewRes, faqRes, contactRes, versionRes, exportRes] = await Promise.allSettled([
|
||||
getReviews({ page: 1, page_size: 1 }),
|
||||
getCategories(),
|
||||
getChannels(),
|
||||
getVersions(),
|
||||
getExportLog(),
|
||||
])
|
||||
if (reviewRes.status === 'fulfilled') stats.value.reviewCount = reviewRes.value.data.total
|
||||
if (faqRes.status === 'fulfilled') {
|
||||
stats.value.faqCount = faqRes.value.data.reduce((sum, cat) => sum + (cat.questions?.length || 0), 0)
|
||||
}
|
||||
if (contactRes.status === 'fulfilled') stats.value.contactCount = contactRes.value.data.length
|
||||
if (versionRes.status === 'fulfilled') stats.value.versionCount = versionRes.value.data.length
|
||||
if (exportRes.status === 'fulfilled') recentExports.value = exportRes.value.data.slice(0, 5)
|
||||
} catch {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all 0.2s ease;
|
||||
cursor: default;
|
||||
}
|
||||
.stat-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
.action-item:hover {
|
||||
background: var(--color-primary-light);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.action-item span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px 0;
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
.empty-state p {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.export-log-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
font-size: 13px;
|
||||
}
|
||||
.export-log-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.log-user {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.log-time {
|
||||
color: var(--text-tertiary);
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
67
src/views/Login.vue
普通文件
67
src/views/Login.vue
普通文件
@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<el-card class="login-card" shadow="hover">
|
||||
<h2 style="text-align: center; margin-bottom: 30px; color: #303133;">呼籁旅行 · 管理后台</h2>
|
||||
<el-form :model="form" @submit.prevent="handleLogin">
|
||||
<el-form-item>
|
||||
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" size="large" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" size="large" style="width: 100%" :loading="loading" native-type="submit">
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { login } from '../api/auth'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const form = ref({ username: '', password: '' })
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!form.value.username || !form.value.password) {
|
||||
ElMessage.warning('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await login(form.value)
|
||||
authStore.setToken(res.data.access_token)
|
||||
await authStore.fetchUser()
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
ElMessage.error(err.response?.data?.detail || '登录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, #0a1a12 0%, #0f2419 60%, #1a5632 100%);
|
||||
}
|
||||
.login-card {
|
||||
width: 420px;
|
||||
padding: 24px;
|
||||
border-radius: 16px !important;
|
||||
}
|
||||
</style>
|
||||
211
src/views/about/AboutEdit.vue
普通文件
211
src/views/about/AboutEdit.vue
普通文件
@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header"><h2>关于我们</h2></div>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<!-- 品牌故事 -->
|
||||
<el-tab-pane label="品牌故事" name="story">
|
||||
<el-card>
|
||||
<el-form :model="story" label-width="90px">
|
||||
<el-form-item label="标题"><el-input v-model="story.title" /></el-form-item>
|
||||
<el-form-item label="内容"><el-input v-model="story.content" type="textarea" :autosize="{minRows:4,maxRows:15}" /></el-form-item>
|
||||
<el-form-item label="创立时刻"><el-input v-model="story.founding_moment" /></el-form-item>
|
||||
</el-form>
|
||||
<el-button type="primary" @click="save('story')" :loading="saving" style="margin-top:12px">保存</el-button>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 旗下企业 -->
|
||||
<el-tab-pane label="旗下企业" name="subsidiaries">
|
||||
<el-card>
|
||||
<div v-for="(s, i) in subsidiaries" :key="i" style="display:flex;gap:8px;margin-bottom:10px;align-items:center;">
|
||||
<el-input v-model="s.name" placeholder="公司名称" style="width:280px" />
|
||||
<el-input v-model="s.role" placeholder="角色定位" />
|
||||
<el-input v-model="s.established" placeholder="成立日期" style="width:110px" />
|
||||
<el-button type="danger" text @click="subsidiaries.splice(i,1)">删除</el-button>
|
||||
</div>
|
||||
<el-button @click="subsidiaries.push({name:'',role:'',established:''})">+ 添加企业</el-button>
|
||||
<el-button type="primary" @click="save('subsidiaries')" :loading="saving" style="margin-left:12px">保存</el-button>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 资质荣誉 -->
|
||||
<el-tab-pane label="资质荣誉" name="certifications">
|
||||
<el-card>
|
||||
<div v-for="(c, i) in certifications" :key="i" style="display:flex;gap:8px;margin-bottom:10px;align-items:center;">
|
||||
<el-input v-model="c.title" placeholder="荣誉名称" />
|
||||
<el-input v-model="c.detail" placeholder="补充说明" />
|
||||
<el-button type="danger" text @click="certifications.splice(i,1)">删除</el-button>
|
||||
</div>
|
||||
<el-button @click="certifications.push({title:'',detail:''})">+ 添加</el-button>
|
||||
<el-button type="primary" @click="save('certifications')" :loading="saving" style="margin-left:12px">保存</el-button>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 商标 -->
|
||||
<el-tab-pane label="商标" name="trademark">
|
||||
<el-card>
|
||||
<el-form :model="trademark" label-width="90px">
|
||||
<el-form-item label="商标名称"><el-input v-model="trademark.name" /></el-form-item>
|
||||
<el-form-item label="注册范围"><el-input v-model="trademark.scope" /></el-form-item>
|
||||
<el-form-item label="持有人"><el-input v-model="trademark.holder" /></el-form-item>
|
||||
<el-form-item label="说明"><el-input v-model="trademark.description" type="textarea" :autosize="{minRows:4,maxRows:12}" /></el-form-item>
|
||||
</el-form>
|
||||
<el-button type="primary" @click="save('trademark')" :loading="saving" style="margin-top:12px">保存</el-button>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 著作权 -->
|
||||
<el-tab-pane label="著作权" name="copyrights">
|
||||
<el-card>
|
||||
<div v-for="(c, i) in copyrights" :key="i" style="margin-bottom:16px;padding:12px;background:#fafafa;border-radius:8px;">
|
||||
<div style="display:flex;gap:8px;margin-bottom:8px;align-items:center;">
|
||||
<el-input v-model="c.name" placeholder="作品名称" style="width:200px" />
|
||||
<el-input v-model="c.reg_no" placeholder="登记号" style="width:260px" />
|
||||
<el-input v-model="c.category" placeholder="类别" style="width:120px" />
|
||||
<el-button type="danger" text @click="copyrights.splice(i,1)">删除</el-button>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<el-input v-model="c.holder" placeholder="著作权人" />
|
||||
<el-input v-model="c.date" placeholder="登记日期" style="width:120px" />
|
||||
</div>
|
||||
<el-input v-model="c.description" placeholder="说明" style="margin-top:8px" />
|
||||
</div>
|
||||
<el-button @click="copyrights.push({name:'',reg_no:'',category:'',holder:'',date:'',description:''})">+ 添加著作权</el-button>
|
||||
<el-button type="primary" @click="save('copyrights')" :loading="saving" style="margin-left:12px">保存</el-button>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 服务保障 -->
|
||||
<el-tab-pane label="服务保障" name="guarantees">
|
||||
<el-card>
|
||||
<div v-for="(g, i) in guarantees" :key="i" style="display:flex;gap:8px;margin-bottom:10px;align-items:center;">
|
||||
<el-input v-model="g.title" placeholder="保障项目" style="width:200px" />
|
||||
<el-input v-model="g.detail" placeholder="详细说明" />
|
||||
<el-button type="danger" text @click="guarantees.splice(i,1)">删除</el-button>
|
||||
</div>
|
||||
<el-button @click="guarantees.push({title:'',detail:''})">+ 添加</el-button>
|
||||
<el-button type="primary" @click="save('guarantees')" :loading="saving" style="margin-left:12px">保存</el-button>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 小红书 -->
|
||||
<el-tab-pane label="小红书" name="xiaohongshu">
|
||||
<el-card>
|
||||
<el-form :model="xiaohongshu" label-width="100px">
|
||||
<el-form-item label="账号名称"><el-input v-model="xiaohongshu.account" /></el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8"><el-form-item label="已认证"><el-switch v-model="xiaohongshu.verified" /></el-form-item></el-col>
|
||||
<el-col :span="16"><el-form-item label="认证类型"><el-input v-model="xiaohongshu.verified_type" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="粉丝数"><el-input v-model="xiaohongshu.followers" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="获赞数"><el-input v-model="xiaohongshu.likes" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-form-item label="Tagline"><el-input v-model="xiaohongshu.tagline" /></el-form-item>
|
||||
<el-form-item label="简介"><el-input v-model="xiaohongshu.description" type="textarea" :autosize="{minRows:4,maxRows:12}" /></el-form-item>
|
||||
<el-form-item label="荣誉奖项">
|
||||
<div v-for="(a, i) in xiaohongshu.awards" :key="i" style="display:flex;gap:8px;margin-bottom:6px;width:100%;">
|
||||
<el-input v-model="xiaohongshu.awards[i]" />
|
||||
<el-button type="danger" text @click="xiaohongshu.awards.splice(i,1)">删</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="xiaohongshu.awards.push('')">+ 添加奖项</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item label="标签">
|
||||
<el-select v-model="xiaohongshu.tags" multiple filterable allow-create placeholder="输入标签" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-button type="primary" @click="save('xiaohongshu')" :loading="saving" style="margin-top:12px">保存</el-button>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 团队 -->
|
||||
<el-tab-pane label="团队介绍" name="team">
|
||||
<el-card>
|
||||
<el-form :model="team" label-width="90px">
|
||||
<el-form-item label="团队简介"><el-input v-model="team.summary" type="textarea" :autosize="{minRows:4,maxRows:15}" /></el-form-item>
|
||||
</el-form>
|
||||
<el-button type="primary" @click="save('team')" :loading="saving" style="margin-top:12px">保存</el-button>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 企业文化 -->
|
||||
<el-tab-pane label="企业文化" name="culture">
|
||||
<el-card>
|
||||
<el-form :model="culture" label-width="90px">
|
||||
<el-form-item label="透明度"><el-input v-model="culture.transparency" type="textarea" :autosize="{minRows:4,maxRows:12}" /></el-form-item>
|
||||
</el-form>
|
||||
<el-divider>核心价值观</el-divider>
|
||||
<div v-for="(v, i) in culture.values" :key="i" style="display:flex;gap:8px;margin-bottom:10px;align-items:flex-start;">
|
||||
<el-input v-model="v.name" placeholder="价值观名称" style="width:120px" />
|
||||
<el-input v-model="v.expression" placeholder="具体表达" type="textarea" :autosize="{minRows:3,maxRows:10}" />
|
||||
<el-button type="danger" text @click="culture.values.splice(i,1)">删除</el-button>
|
||||
</div>
|
||||
<el-button @click="culture.values.push({name:'',expression:''})">+ 添加价值观</el-button>
|
||||
<el-button type="primary" @click="save('culture')" :loading="saving" style="margin-left:12px">保存</el-button>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as aboutApi from '../../api/about'
|
||||
|
||||
const activeTab = ref('story')
|
||||
const saving = ref(false)
|
||||
|
||||
const story = ref({ title: '', content: '', founding_moment: '' })
|
||||
const subsidiaries = ref([])
|
||||
const certifications = ref([])
|
||||
const trademark = ref({ name: '', scope: '', holder: '', description: '' })
|
||||
const copyrights = ref([])
|
||||
const guarantees = ref([])
|
||||
const xiaohongshu = ref({ account: '', verified: false, verified_type: '', followers: '', likes: '', awards: [], tagline: '', tags: [], description: '' })
|
||||
const team = ref({ summary: '' })
|
||||
const culture = ref({ transparency: '', values: [] })
|
||||
|
||||
onMounted(async () => {
|
||||
const results = await Promise.allSettled([
|
||||
aboutApi.getStory(), aboutApi.getSubsidiaries(), aboutApi.getCertifications(),
|
||||
aboutApi.getTrademark(), aboutApi.getCopyrights(), aboutApi.getGuarantees(),
|
||||
aboutApi.getXiaohongshu(), aboutApi.getTeam(), aboutApi.getCulture(),
|
||||
])
|
||||
const [storyR, subsR, certsR, tmR, copyR, guarR, xhsR, teamR, cultureR] = results
|
||||
if (storyR.status === 'fulfilled') story.value = storyR.value.data
|
||||
if (subsR.status === 'fulfilled') subsidiaries.value = subsR.value.data
|
||||
if (certsR.status === 'fulfilled') certifications.value = certsR.value.data
|
||||
if (tmR.status === 'fulfilled') trademark.value = tmR.value.data
|
||||
if (copyR.status === 'fulfilled') copyrights.value = copyR.value.data
|
||||
if (guarR.status === 'fulfilled') guarantees.value = guarR.value.data
|
||||
if (xhsR.status === 'fulfilled') xiaohongshu.value = { ...xiaohongshu.value, ...xhsR.value.data }
|
||||
if (teamR.status === 'fulfilled') team.value = teamR.value.data
|
||||
if (cultureR.status === 'fulfilled') culture.value = { ...culture.value, ...cultureR.value.data }
|
||||
})
|
||||
|
||||
const saveMap = {
|
||||
story: () => aboutApi.updateStory(story.value),
|
||||
subsidiaries: () => aboutApi.updateSubsidiaries(subsidiaries.value),
|
||||
certifications: () => aboutApi.updateCertifications(certifications.value),
|
||||
trademark: () => aboutApi.updateTrademark(trademark.value),
|
||||
copyrights: () => aboutApi.updateCopyrights(copyrights.value),
|
||||
guarantees: () => aboutApi.updateGuarantees(guarantees.value),
|
||||
xiaohongshu: () => aboutApi.updateXiaohongshu(xiaohongshu.value),
|
||||
team: () => aboutApi.updateTeam(team.value),
|
||||
culture: () => aboutApi.updateCulture(culture.value),
|
||||
}
|
||||
|
||||
const save = async (key) => {
|
||||
saving.value = true
|
||||
try {
|
||||
await saveMap[key]()
|
||||
ElMessage.success('保存成功')
|
||||
} catch {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
179
src/views/blog/BlogList.vue
普通文件
179
src/views/blog/BlogList.vue
普通文件
@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>博客文章管理</h2>
|
||||
<el-button type="primary" @click="showDialog()">新增文章</el-button>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar" style="margin-bottom:16px;display:flex;gap:8px;">
|
||||
<el-input v-model="keyword" placeholder="搜索标题/摘要" clearable style="width:260px" @clear="loadData" @keyup.enter="loadData" />
|
||||
<el-button @click="loadData">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" border stripe v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="title" label="标题" show-overflow-tooltip />
|
||||
<el-table-column prop="slug" label="Slug" width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="author" label="作者" width="100" />
|
||||
<el-table-column prop="category" label="分类" width="100" />
|
||||
<el-table-column prop="published_at" label="发布时间" width="120" />
|
||||
<el-table-column label="显示" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.is_visible" @change="handleToggleVisible(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text size="small" @click="showDialog(row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除该文章?" @confirm="handleDelete(row.id)">
|
||||
<template #reference>
|
||||
<el-button type="danger" text size="small">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
style="margin-top:16px;justify-content:flex-end;"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑文章' : '新增文章'" width="760px">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="form.title" placeholder="文章标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Slug">
|
||||
<el-input v-model="form.slug" placeholder="URL 路径,如 my-first-post" />
|
||||
</el-form-item>
|
||||
<el-form-item label="封面图">
|
||||
<el-input v-model="form.cover_image" placeholder="图片URL" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="作者">
|
||||
<el-input v-model="form.author" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="分类">
|
||||
<el-input v-model="form.category" placeholder="如:旅行攻略" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="发布时间">
|
||||
<el-input v-model="form.published_at" placeholder="如:2025年3月" />
|
||||
</el-form-item>
|
||||
<el-form-item label="标签">
|
||||
<el-select v-model="form.tags" multiple filterable allow-create placeholder="输入后回车添加" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="摘要">
|
||||
<el-input v-model="form.summary" type="textarea" :autosize="{ minRows: 2, maxRows: 5 }" />
|
||||
</el-form-item>
|
||||
<el-form-item label="正文">
|
||||
<el-input v-model="form.content" type="textarea" :autosize="{ minRows: 8, maxRows: 20 }" />
|
||||
</el-form-item>
|
||||
<el-form-item label="显示">
|
||||
<el-switch v-model="form.is_visible" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getBlogs, createBlog, updateBlog, deleteBlog } from '../../api/blog'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const keyword = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref(null)
|
||||
|
||||
const defaultForm = () => ({
|
||||
title: '', slug: '', cover_image: '', summary: '', content: '',
|
||||
author: '', category: '', tags: [], published_at: '', is_visible: true,
|
||||
})
|
||||
const form = ref(defaultForm())
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getBlogs({ page: page.value, page_size: pageSize, keyword: keyword.value })
|
||||
list.value = res.data.items
|
||||
total.value = res.data.total
|
||||
} catch {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showDialog = (row) => {
|
||||
if (row) {
|
||||
editingId.value = row.id
|
||||
form.value = { ...row, tags: row.tags || [] }
|
||||
} else {
|
||||
editingId.value = null
|
||||
form.value = defaultForm()
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.value.title) { ElMessage.warning('请填写标题'); return }
|
||||
if (!form.value.slug) { ElMessage.warning('请填写 Slug'); return }
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateBlog(editingId.value, form.value)
|
||||
} else {
|
||||
await createBlog(form.value)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch (err) {
|
||||
ElMessage.error(err.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleVisible = async (row) => {
|
||||
try {
|
||||
await updateBlog(row.id, row)
|
||||
ElMessage.success('更新成功')
|
||||
} catch {
|
||||
ElMessage.error('更新失败')
|
||||
row.is_visible = !row.is_visible
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await deleteBlog(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
83
src/views/brand/BrandEdit.vue
普通文件
83
src/views/brand/BrandEdit.vue
普通文件
@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>品牌信息</h2>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loading">
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="品牌名称"><el-input v-model="form.brand.name" /></el-form-item>
|
||||
<el-form-item label="全称"><el-input v-model="form.brand.full_name" /></el-form-item>
|
||||
<el-form-item label="域名"><el-input v-model="form.brand.domain" /></el-form-item>
|
||||
<el-form-item label="网址"><el-input v-model="form.brand.url" /></el-form-item>
|
||||
<el-form-item label="情感slogan"><el-input v-model="form.brand.slogan_emotional" /></el-form-item>
|
||||
<el-form-item label="功能slogan"><el-input v-model="form.brand.slogan_functional" /></el-form-item>
|
||||
<el-form-item label="ICP主体"><el-input v-model="form.brand.icp_entity" /></el-form-item>
|
||||
<el-form-item label="ICP备案号"><el-input v-model="form.brand.icp" /></el-form-item>
|
||||
<el-form-item label="电子合同"><el-input v-model="form.brand.e_contract" /></el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-divider>信任数据</el-divider>
|
||||
<div v-for="(stat, i) in form.trust_stats" :key="i" style="display: flex; gap: 8px; margin-bottom: 8px;">
|
||||
<el-input v-model="stat.value" placeholder="值" style="width: 100px" />
|
||||
<el-input v-model="stat.unit" placeholder="单位" style="width: 80px" />
|
||||
<el-input v-model="stat.label" placeholder="标签" />
|
||||
<el-button type="danger" text @click="form.trust_stats.splice(i, 1)">删除</el-button>
|
||||
</div>
|
||||
<el-button @click="form.trust_stats.push({ value: '', unit: '', label: '' })">+ 添加信任数据</el-button>
|
||||
|
||||
<el-divider>差异化优势</el-divider>
|
||||
<div v-for="(diff, i) in form.differentiators" :key="i" style="margin-bottom: 12px;">
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<el-input v-model="diff.title" placeholder="标题" style="width: 150px" />
|
||||
<el-input v-model="diff.description" placeholder="描述" />
|
||||
<el-button type="danger" text @click="form.differentiators.splice(i, 1)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-button @click="form.differentiators.push({ title: '', description: '' })">+ 添加差异化优势</el-button>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getBrand, updateBrand } from '../../api/brand'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = ref({
|
||||
brand: { name: '', full_name: '', domain: '', url: '', slogan_emotional: '', slogan_functional: '', icp_entity: '', icp: '', icp_url: '', e_contract: '' },
|
||||
trust_stats: [],
|
||||
differentiators: [],
|
||||
})
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getBrand()
|
||||
const d = res.data
|
||||
form.value = {
|
||||
brand: { name: d.name, full_name: d.full_name, domain: d.domain, url: d.url, slogan_emotional: d.slogan_emotional, slogan_functional: d.slogan_functional, icp_entity: d.icp_entity, icp: d.icp, icp_url: d.icp_url, e_contract: d.e_contract },
|
||||
trust_stats: d.trust_stats || [],
|
||||
differentiators: d.differentiators || [],
|
||||
}
|
||||
} catch {}
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
await updateBrand(form.value)
|
||||
ElMessage.success('保存成功')
|
||||
} catch {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
144
src/views/calendar/CalendarEdit.vue
普通文件
144
src/views/calendar/CalendarEdit.vue
普通文件
@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>行程日历管理</h2>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<el-input-number v-model="year" :min="2024" :max="2030" controls-position="right" style="width:120px" @change="loadData" />
|
||||
<span style="color:#999;font-size:13px;">年</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="16" v-loading="loading">
|
||||
<el-col :span="8" v-for="item in months" :key="item.id" style="margin-bottom:16px;">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<span style="font-weight:600;">{{ item.month }}月 {{ item.title }}</span>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<el-tag :type="item.is_available ? 'success' : 'info'" size="small">
|
||||
{{ item.is_available ? '可预订' : '暂未开放' }}
|
||||
</el-tag>
|
||||
<el-button type="primary" text size="small" @click="showDialog(item)">编辑</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div style="font-size:13px;color:#606266;">
|
||||
<div v-if="item.weather" style="margin-bottom:4px;">
|
||||
<span style="color:#999;">天气:</span>{{ item.weather }}
|
||||
</div>
|
||||
<div v-if="item.description" style="margin-bottom:4px;color:#606266;" class="ellipsis-2">
|
||||
{{ item.description }}
|
||||
</div>
|
||||
<div v-if="item.highlights?.length" style="margin-top:6px;">
|
||||
<el-tag v-for="h in item.highlights.slice(0,3)" :key="h" size="small" style="margin-right:4px;margin-bottom:4px;">{{ h }}</el-tag>
|
||||
<span v-if="item.highlights.length > 3" style="font-size:12px;color:#999;">+{{ item.highlights.length - 3 }}</span>
|
||||
</div>
|
||||
<div v-if="item.events?.length" style="margin-top:4px;color:#999;font-size:12px;">
|
||||
{{ item.events.length }} 个活动
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="`编辑 ${editingItem?.month}月 行程日历`" width="700px">
|
||||
<el-form :model="form" label-width="90px" v-if="form">
|
||||
<el-form-item label="月份标题">
|
||||
<el-input v-model="form.title" placeholder="如:盛夏七月" />
|
||||
</el-form-item>
|
||||
<el-form-item label="天气描述">
|
||||
<el-input v-model="form.weather" placeholder="如:气温15-25°C,草原最美季节" />
|
||||
</el-form-item>
|
||||
<el-form-item label="月份简介">
|
||||
<el-input v-model="form.description" type="textarea" :autosize="{ minRows: 2, maxRows: 5 }" placeholder="这个月份的特点和亮点描述" />
|
||||
</el-form-item>
|
||||
<el-form-item label="亮点标签">
|
||||
<el-select v-model="form.highlights" multiple filterable allow-create placeholder="输入后回车添加" style="width:100%">
|
||||
<el-option v-for="h in highlightOptions" :key="h" :label="h" :value="h" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="可预订">
|
||||
<el-switch v-model="form.is_available" />
|
||||
</el-form-item>
|
||||
<el-form-item label="活动安排">
|
||||
<div style="width:100%;">
|
||||
<div v-for="(ev, i) in form.events" :key="i" style="display:flex;gap:8px;margin-bottom:8px;align-items:flex-start;">
|
||||
<el-input v-model="form.events[i].date" placeholder="日期" style="width:110px;flex-shrink:0;" />
|
||||
<el-input v-model="form.events[i].title" placeholder="活动名称" style="width:160px;flex-shrink:0;" />
|
||||
<el-input v-model="form.events[i].description" placeholder="活动描述(可选)" />
|
||||
<el-button type="danger" text @click="form.events.splice(i, 1)" style="flex-shrink:0;">删除</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="form.events.push({ date: '', title: '', description: '' })">+ 添加活动</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getCalendar, updateCalendarMonth } from '../../api/calendar'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const year = ref(new Date().getFullYear())
|
||||
const months = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingItem = ref(null)
|
||||
const form = ref(null)
|
||||
|
||||
const highlightOptions = ['亲子游', '摄影天堂', '草原骑马', '篝火晚会', '那达慕', '观星', '雪地活动', '冰雪运动', '采摘', '民俗体验']
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getCalendar(year.value)
|
||||
months.value = res.data
|
||||
} catch {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showDialog = (item) => {
|
||||
editingItem.value = item
|
||||
form.value = {
|
||||
...item,
|
||||
highlights: [...(item.highlights || [])],
|
||||
events: (item.events || []).map(e => ({ ...e })),
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
await updateCalendarMonth(editingItem.value.id, form.value)
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch (err) {
|
||||
ElMessage.error(err.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ellipsis-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
140
src/views/contact/ContactList.vue
普通文件
140
src/views/contact/ContactList.vue
普通文件
@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>联系方式管理</h2>
|
||||
<el-button type="primary" @click="showDialog()">新增渠道</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 安全提示编辑 -->
|
||||
<el-card style="margin-bottom:16px;">
|
||||
<div style="display:flex;gap:12px;align-items:flex-end;">
|
||||
<div style="flex:1;">
|
||||
<div style="font-size:13px;color:var(--text-tertiary);margin-bottom:6px;">页面底部安全提示语</div>
|
||||
<el-input v-model="securityNotice" type="textarea" :autosize="{minRows:2,maxRows:4}" placeholder="如:请认准官方渠道,谨防假冒..." />
|
||||
</div>
|
||||
<el-button type="primary" @click="saveNotice" :loading="savingNotice">保存</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-table :data="list" border stripe v-loading="loading">
|
||||
<el-table-column prop="type" label="类型" width="100" />
|
||||
<el-table-column prop="label" label="名称" width="120" />
|
||||
<el-table-column prop="value" label="值" />
|
||||
<el-table-column prop="description" label="描述" show-overflow-tooltip />
|
||||
<el-table-column label="主要" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_primary" type="success" size="small">是</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text size="small" @click="showDialog(row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除?" @confirm="handleDelete(row.id)">
|
||||
<template #reference>
|
||||
<el-button type="danger" text size="small">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑渠道' : '新增渠道'" width="500px">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="form.type" style="width: 100%">
|
||||
<el-option v-for="t in typeOptions" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="名称"><el-input v-model="form.label" /></el-form-item>
|
||||
<el-form-item label="值"><el-input v-model="form.value" /></el-form-item>
|
||||
<el-form-item label="二维码">
|
||||
<ImageUpload v-model="form.qr_image" subdir="contact" width="100px" height="100px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="form.description" type="textarea" /></el-form-item>
|
||||
<el-form-item label="主要"><el-switch v-model="form.is_primary" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getChannels, createChannel, updateChannel, deleteChannel, getContactConfig, updateContactConfig } from '../../api/contact'
|
||||
import ImageUpload from '../../components/common/ImageUpload.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const savingNotice = ref(false)
|
||||
const securityNotice = ref('')
|
||||
const list = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref(null)
|
||||
const typeOptions = ['wechat', 'miniprogram', 'phone', 'address', 'email', 'complaint', 'hr']
|
||||
const form = ref({ type: 'wechat', label: '', value: '', qr_image: '', description: '', is_primary: false })
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getChannels()
|
||||
list.value = res.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showDialog = (row) => {
|
||||
if (row) {
|
||||
editingId.value = row.id
|
||||
form.value = { ...row }
|
||||
} else {
|
||||
editingId.value = null
|
||||
form.value = { type: 'wechat', label: '', value: '', qr_image: '', description: '', is_primary: false }
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateChannel(editingId.value, form.value)
|
||||
} else {
|
||||
await createChannel(form.value)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch (err) {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await deleteChannel(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const saveNotice = async () => {
|
||||
savingNotice.value = true
|
||||
try { await updateContactConfig({ security_notice: securityNotice.value }); ElMessage.success('保存成功') }
|
||||
catch { ElMessage.error('保存失败') }
|
||||
finally { savingNotice.value = false }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadData()
|
||||
try { const res = await getContactConfig(); securityNotice.value = res.data.security_notice || '' } catch {}
|
||||
})
|
||||
</script>
|
||||
110
src/views/courses/CoursesEdit.vue
普通文件
110
src/views/courses/CoursesEdit.vue
普通文件
@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>研学课程配置</h2>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loading">
|
||||
<!-- 课程模块 -->
|
||||
<el-divider content-position="left">课程模块</el-divider>
|
||||
<div v-for="(mod, i) in form.modules" :key="i" style="border:1px solid var(--color-border);border-radius:8px;padding:16px;margin-bottom:12px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||
<strong>模块 {{ i + 1 }}</strong>
|
||||
<el-button type="danger" text size="small" @click="form.modules.splice(i,1)">删除模块</el-button>
|
||||
</div>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="模块名称"><el-input v-model="mod.name" /></el-form-item>
|
||||
<el-form-item label="图标">
|
||||
<el-input v-model="mod.icon" placeholder="emoji 或 图片URL" style="width:200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="mod.description" type="textarea" :autosize="{minRows:2}" /></el-form-item>
|
||||
<el-form-item label="活动列表">
|
||||
<div v-for="(act, j) in (mod.activities || [])" :key="j" style="display:flex;gap:8px;margin-bottom:6px">
|
||||
<el-input v-model="mod.activities[j]" placeholder="活动名称" />
|
||||
<el-button type="danger" text @click="mod.activities.splice(j,1)">删</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="mod.activities = [...(mod.activities||[]), '']">+ 添加活动</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-button @click="form.modules.push({name:'',icon:'',description:'',activities:[]})">+ 添加课程模块</el-button>
|
||||
|
||||
<!-- 年龄段 -->
|
||||
<el-divider content-position="left" style="margin-top:24px">年龄段配置</el-divider>
|
||||
<div v-for="(ag, i) in form.age_groups" :key="i" style="border:1px solid var(--color-border);border-radius:8px;padding:16px;margin-bottom:12px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||
<strong>年龄段 {{ i + 1 }}</strong>
|
||||
<el-button type="danger" text size="small" @click="form.age_groups.splice(i,1)">删除</el-button>
|
||||
</div>
|
||||
<el-form label-width="80px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="标签"><el-input v-model="ag.label" placeholder="如:6-8岁" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="范围"><el-input v-model="ag.range" placeholder="如:6-8" /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="适合内容"><el-input v-model="ag.description" type="textarea" :autosize="{minRows:2}" /></el-form-item>
|
||||
<el-form-item label="推荐模块">
|
||||
<el-input v-model="ag.recommended_modules" placeholder="推荐的课程模块名,逗号分隔" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-button @click="form.age_groups.push({label:'',range:'',description:'',recommended_modules:''})">+ 添加年龄段</el-button>
|
||||
|
||||
<!-- FAQ -->
|
||||
<el-divider content-position="left" style="margin-top:24px">常见问答</el-divider>
|
||||
<div v-for="(faq, i) in form.faqs" :key="i" style="border:1px solid var(--color-border);border-radius:8px;padding:16px;margin-bottom:12px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<strong>FAQ {{ i + 1 }}</strong>
|
||||
<el-button type="danger" text size="small" @click="form.faqs.splice(i,1)">删除</el-button>
|
||||
</div>
|
||||
<el-form label-width="50px">
|
||||
<el-form-item label="问题"><el-input v-model="faq.question" /></el-form-item>
|
||||
<el-form-item label="回答"><el-input v-model="faq.answer" type="textarea" :autosize="{minRows:2}" /></el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-button @click="form.faqs.push({question:'',answer:''})">+ 添加问答</el-button>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getCoursesConfig, updateCoursesConfig } from '../../api/courses'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = ref({ modules: [], age_groups: [], faqs: [] })
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getCoursesConfig()
|
||||
form.value = {
|
||||
modules: res.data.modules || [],
|
||||
age_groups: res.data.age_groups || [],
|
||||
faqs: res.data.faqs || [],
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
await updateCoursesConfig(form.value)
|
||||
ElMessage.success('保存成功')
|
||||
} catch {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>定制表单提交记录</h2>
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-tag type="warning">待处理: {{ pendingCount }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="keyword" placeholder="搜索姓名/电话/微信" style="width:220px" @keyup.enter="loadData" clearable @clear="loadData" />
|
||||
<el-select v-model="statusFilter" placeholder="全部状态" style="width:120px" @change="loadData">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="待处理" value="pending" />
|
||||
<el-option label="已处理" value="processed" />
|
||||
</el-select>
|
||||
<el-button @click="loadData">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" border stripe v-loading="loading" @row-click="showDetail">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="created_at" label="提交时间" width="160" />
|
||||
<el-table-column prop="name" label="姓名" width="100" />
|
||||
<el-table-column prop="phone" label="电话" width="130" />
|
||||
<el-table-column prop="wechat" label="微信" width="130" />
|
||||
<el-table-column label="人数" width="80">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.adults }}大{{ row.children }}小</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="travel_dates" label="出行时间" width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="budget" label="预算" width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="source" label="来源" width="100" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'pending' ? 'warning' : 'success'" size="small">
|
||||
{{ row.status === 'pending' ? '待处理' : '已处理' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.status === 'pending'"
|
||||
type="success"
|
||||
text
|
||||
size="small"
|
||||
@click.stop="markProcessed(row)"
|
||||
>标记已处理</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
type="info"
|
||||
text
|
||||
size="small"
|
||||
@click.stop="markPending(row)"
|
||||
>重置待处理</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
layout="total, prev, pager, next"
|
||||
style="margin-top:16px;justify-content:flex-end;display:flex"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
|
||||
<!-- Detail Dialog -->
|
||||
<el-dialog v-model="detailVisible" title="提交详情" width="600px">
|
||||
<el-descriptions v-if="currentItem" :column="2" border>
|
||||
<el-descriptions-item label="提交时间" :span="2">{{ currentItem.created_at }}</el-descriptions-item>
|
||||
<el-descriptions-item label="姓名">{{ currentItem.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="电话">{{ currentItem.phone }}</el-descriptions-item>
|
||||
<el-descriptions-item label="微信">{{ currentItem.wechat }}</el-descriptions-item>
|
||||
<el-descriptions-item label="来源">{{ currentItem.source }}</el-descriptions-item>
|
||||
<el-descriptions-item label="成人数">{{ currentItem.adults }}</el-descriptions-item>
|
||||
<el-descriptions-item label="儿童数">{{ currentItem.children }}</el-descriptions-item>
|
||||
<el-descriptions-item label="出行时间" :span="2">{{ currentItem.travel_dates }}</el-descriptions-item>
|
||||
<el-descriptions-item label="预算">{{ currentItem.budget }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="currentItem.status === 'pending' ? 'warning' : 'success'" size="small">
|
||||
{{ currentItem.status === 'pending' ? '待处理' : '已处理' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="感兴趣标签" :span="2">
|
||||
<el-tag v-for="t in (currentItem.interests || [])" :key="t" size="small" style="margin-right:4px">{{ t }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ currentItem.notes }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
<el-button
|
||||
v-if="currentItem?.status === 'pending'"
|
||||
type="success"
|
||||
@click="markProcessed(currentItem); detailVisible = false"
|
||||
>标记已处理</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getSubmissions, updateSubmissionStatus } from '../../api/customize'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const keyword = ref('')
|
||||
const statusFilter = ref('')
|
||||
const detailVisible = ref(false)
|
||||
const currentItem = ref(null)
|
||||
|
||||
const pendingCount = computed(() => list.value.filter(i => i.status === 'pending').length)
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getSubmissions({
|
||||
page: page.value,
|
||||
page_size: pageSize,
|
||||
keyword: keyword.value,
|
||||
status: statusFilter.value,
|
||||
})
|
||||
list.value = res.data.items
|
||||
total.value = res.data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showDetail = (row) => {
|
||||
currentItem.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const markProcessed = async (row) => {
|
||||
try {
|
||||
await updateSubmissionStatus(row.id, 'processed')
|
||||
row.status = 'processed'
|
||||
if (currentItem.value?.id === row.id) currentItem.value.status = 'processed'
|
||||
ElMessage.success('已标记为已处理')
|
||||
} catch {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const markPending = async (row) => {
|
||||
try {
|
||||
await updateSubmissionStatus(row.id, 'pending')
|
||||
row.status = 'pending'
|
||||
ElMessage.success('已重置为待处理')
|
||||
} catch {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>景点详情管理</h2>
|
||||
<el-button type="primary" @click="showDialog()">+ 新增景点</el-button>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="keyword" placeholder="搜索景点名称/位置" style="width:240px" @keyup.enter="loadData" clearable @clear="loadData" />
|
||||
<el-button @click="loadData">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" border stripe v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column label="封面" width="80">
|
||||
<template #default="{ row }">
|
||||
<img v-if="row.cover_image" :src="row.cover_image" style="width:60px;height:40px;object-fit:cover;border-radius:4px;" />
|
||||
<span v-else style="color:#ccc">无</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="景点名称" width="160" />
|
||||
<el-table-column prop="slug" label="Slug" width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="location" label="位置" width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="best_season" label="最佳季节" width="120" show-overflow-tooltip />
|
||||
<el-table-column label="可见" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_visible ? 'success' : 'info'" size="small">{{ row.is_visible ? '显示' : '隐藏' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text size="small" @click="showDialog(row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除此景点?" @confirm="handleDelete(row.id)">
|
||||
<template #reference>
|
||||
<el-button type="danger" text size="small">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
layout="total, prev, pager, next"
|
||||
style="margin-top:16px;justify-content:flex-end;display:flex"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑景点' : '新增景点'" width="780px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="14">
|
||||
<el-form-item label="景点名称"><el-input v-model="form.name" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="10">
|
||||
<el-form-item label="Slug"><el-input v-model="form.slug" placeholder="用于URL,仅英文/数字/连字符" /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="副标题"><el-input v-model="form.subtitle" /></el-form-item>
|
||||
<el-form-item label="封面图片">
|
||||
<ImageUpload v-model="form.cover_image" subdir="destinations" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="位置"><el-input v-model="form.location" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最佳季节"><el-input v-model="form.best_season" placeholder="如:夏季(6-8月)" /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="建议时长"><el-input v-model="form.duration" placeholder="如:半天 / 1天" /></el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :autosize="{minRows:3,maxRows:8}" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider>亮点(highlights)</el-divider>
|
||||
<div v-for="(h, i) in form.highlights" :key="i" style="display:flex;gap:8px;margin-bottom:8px;align-items:flex-start">
|
||||
<el-input v-model="h.icon" placeholder="图标(emoji)" style="width:80px;flex-shrink:0" />
|
||||
<el-input v-model="h.title" placeholder="标题" style="width:120px;flex-shrink:0" />
|
||||
<el-input v-model="h.description" placeholder="描述" />
|
||||
<el-button type="danger" text @click="form.highlights.splice(i,1)">删</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="form.highlights.push({icon:'',title:'',description:''})">+ 添加亮点</el-button>
|
||||
|
||||
<el-divider>相册图片(gallery)</el-divider>
|
||||
<div v-for="(img, i) in form.gallery" :key="i" style="display:flex;gap:8px;margin-bottom:8px;align-items:center">
|
||||
<el-input v-model="form.gallery[i]" placeholder="图片URL" />
|
||||
<el-button type="danger" text @click="form.gallery.splice(i,1)">删</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="form.gallery.push('')">+ 添加图片</el-button>
|
||||
|
||||
<el-form-item label="标签" style="margin-top:16px">
|
||||
<el-input v-model="tagsInput" placeholder="用英文逗号分隔,如:草原,骑马,亲子" />
|
||||
<div style="font-size:12px;color:#909399;margin-top:4px">保存时自动拆分为标签数组</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否显示">
|
||||
<el-switch v-model="form.is_visible" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
getDestinationsDetail,
|
||||
createDestinationDetail,
|
||||
updateDestinationDetail,
|
||||
deleteDestinationDetail,
|
||||
} from '../../api/destinationsDetail'
|
||||
import ImageUpload from '../../components/common/ImageUpload.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const keyword = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref(null)
|
||||
const tagsInput = ref('')
|
||||
|
||||
const defaultForm = () => ({
|
||||
name: '',
|
||||
slug: '',
|
||||
subtitle: '',
|
||||
cover_image: '',
|
||||
description: '',
|
||||
location: '',
|
||||
best_season: '',
|
||||
duration: '',
|
||||
highlights: [],
|
||||
gallery: [],
|
||||
tags: [],
|
||||
is_visible: true,
|
||||
})
|
||||
|
||||
const form = ref(defaultForm())
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getDestinationsDetail({ page: page.value, page_size: pageSize, keyword: keyword.value })
|
||||
list.value = res.data.items
|
||||
total.value = res.data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showDialog = (row) => {
|
||||
if (row) {
|
||||
editingId.value = row.id
|
||||
form.value = {
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
subtitle: row.subtitle || '',
|
||||
cover_image: row.cover_image || '',
|
||||
description: row.description || '',
|
||||
location: row.location || '',
|
||||
best_season: row.best_season || '',
|
||||
duration: row.duration || '',
|
||||
highlights: row.highlights ? JSON.parse(JSON.stringify(row.highlights)) : [],
|
||||
gallery: row.gallery ? [...row.gallery] : [],
|
||||
tags: row.tags ? [...row.tags] : [],
|
||||
is_visible: row.is_visible,
|
||||
}
|
||||
tagsInput.value = (row.tags || []).join(', ')
|
||||
} else {
|
||||
editingId.value = null
|
||||
form.value = defaultForm()
|
||||
tagsInput.value = ''
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
form.value.tags = tagsInput.value.split(',').map(t => t.trim()).filter(Boolean)
|
||||
form.value.gallery = form.value.gallery.filter(Boolean)
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateDestinationDetail(editingId.value, form.value)
|
||||
} else {
|
||||
await createDestinationDetail(form.value)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch (err) {
|
||||
ElMessage.error(err.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await deleteDestinationDetail(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header"><h2>目的地对比</h2></div>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<!-- 页面配置 -->
|
||||
<el-tab-pane label="页面配置" name="config">
|
||||
<el-card>
|
||||
<el-form :model="config" label-width="100px">
|
||||
<el-form-item label="标题"><el-input v-model="config.title" /></el-form-item>
|
||||
<el-form-item label="副标题"><el-input v-model="config.subtitle" /></el-form-item>
|
||||
<el-form-item label="引言"><el-input v-model="config.intro" type="textarea" :autosize="{minRows:3,maxRows:8}" /></el-form-item>
|
||||
<el-form-item label="结语标题"><el-input v-model="config.closing_title" /></el-form-item>
|
||||
<el-form-item label="结语内容"><el-input v-model="config.closing_text" type="textarea" :autosize="{minRows:3,maxRows:8}" /></el-form-item>
|
||||
<el-form-item label="坦诚标题"><el-input v-model="config.honest_title" /></el-form-item>
|
||||
<el-form-item label="坦诚副标题"><el-input v-model="config.honest_subtitle" /></el-form-item>
|
||||
<el-form-item label="数据来源"><el-input v-model="config.data_sources" type="textarea" :autosize="{minRows:2,maxRows:4}" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="saveConfig" :loading="savingConfig">保存配置</el-button></el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 目的地 -->
|
||||
<el-tab-pane label="对比目的地" name="items">
|
||||
<div v-for="(d, i) in items" :key="i" style="margin-bottom:12px;">
|
||||
<el-card>
|
||||
<el-form :model="d" label-width="80px" inline>
|
||||
<el-form-item label="ID"><el-input v-model="d.dest_id" style="width:120px;" /></el-form-item>
|
||||
<el-form-item label="名称"><el-input v-model="d.name" style="width:140px;" /></el-form-item>
|
||||
<el-form-item label="标签"><el-input v-model="d.tag" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="推荐"><el-switch v-model="d.highlight" /></el-form-item>
|
||||
<el-button type="danger" text @click="items.splice(i, 1)">删除</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
<el-button @click="items.push({dest_id:'',name:'',tag:'',highlight:false})">+ 添加目的地</el-button>
|
||||
<div style="margin-top:16px;"><el-button type="primary" @click="saveItems" :loading="savingItems">保存目的地</el-button></div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 对比维度 -->
|
||||
<el-tab-pane label="对比维度" name="dimensions">
|
||||
<el-button type="primary" @click="showDimDialog()" style="margin-bottom:16px;">新增维度</el-button>
|
||||
<el-table :data="dimensions" border stripe>
|
||||
<el-table-column prop="icon" label="图标" width="60" />
|
||||
<el-table-column prop="label" label="维度" width="100" />
|
||||
<el-table-column v-for="(d, i) in items" :key="d.dest_id" :label="d.name || `目的地${i+1}`">
|
||||
<template #default="{ row }">{{ (row.values || [])[i] || '' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text size="small" @click="showDimDialog(row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除?" @confirm="handleDeleteDim(row.id)">
|
||||
<template #reference><el-button type="danger" text size="small">删除</el-button></template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 坦诚说明 -->
|
||||
<el-tab-pane label="坦诚说明" name="honest">
|
||||
<div v-for="(h, i) in honestItems" :key="i" style="display:flex;gap:8px;margin-bottom:8px;">
|
||||
<el-input v-model="honestItems[i].text" />
|
||||
<el-button type="danger" text @click="honestItems.splice(i, 1)">删除</el-button>
|
||||
</div>
|
||||
<el-button @click="honestItems.push({text:''})">+ 添加</el-button>
|
||||
<div style="margin-top:16px;"><el-button type="primary" @click="saveHonestItems" :loading="savingHonest">保存坦诚说明</el-button></div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 维度编辑对话框 -->
|
||||
<el-dialog v-model="dimDialogVisible" :title="editingDimId ? '编辑对比维度' : '新增对比维度'" width="700px">
|
||||
<el-form :model="dimForm" label-width="80px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6"><el-form-item label="图标"><el-input v-model="dimForm.icon" /></el-form-item></el-col>
|
||||
<el-col :span="18"><el-form-item label="维度名"><el-input v-model="dimForm.label" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-form-item v-for="(d, i) in items" :key="i" :label="d.name || `目的地${i+1}`">
|
||||
<el-input v-model="dimForm.values[i]" type="textarea" :autosize="{minRows:1,maxRows:4}" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dimDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSaveDim" :loading="savingDim">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as destApi from '../../api/destinations'
|
||||
|
||||
const activeTab = ref('dimensions')
|
||||
const savingConfig = ref(false)
|
||||
const savingItems = ref(false)
|
||||
const savingDim = ref(false)
|
||||
const savingHonest = ref(false)
|
||||
|
||||
const config = ref({})
|
||||
const items = ref([])
|
||||
const dimensions = ref([])
|
||||
const honestItems = ref([])
|
||||
|
||||
// Dimension dialog
|
||||
const dimDialogVisible = ref(false)
|
||||
const editingDimId = ref(null)
|
||||
const dimForm = ref({ label: '', icon: '', values: ['', '', ''] })
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [configRes, itemsRes, dimsRes, honestRes] = await Promise.all([
|
||||
destApi.getConfig(), destApi.getItems(), destApi.getDimensions(), destApi.getHonestItems(),
|
||||
])
|
||||
config.value = configRes.data
|
||||
items.value = itemsRes.data
|
||||
dimensions.value = dimsRes.data
|
||||
honestItems.value = honestRes.data
|
||||
} catch (e) {
|
||||
ElMessage.error('加载数据失败')
|
||||
}
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
savingConfig.value = true
|
||||
try { await destApi.updateConfig(config.value); ElMessage.success('保存成功') } finally { savingConfig.value = false }
|
||||
}
|
||||
|
||||
const saveItems = async () => {
|
||||
savingItems.value = true
|
||||
try { await destApi.updateItems(items.value); ElMessage.success('保存成功') } finally { savingItems.value = false }
|
||||
}
|
||||
|
||||
const showDimDialog = (row) => {
|
||||
if (row) {
|
||||
editingDimId.value = row.id
|
||||
dimForm.value = { label: row.label, icon: row.icon, values: [...(row.values || [])] }
|
||||
} else {
|
||||
editingDimId.value = null
|
||||
dimForm.value = { label: '', icon: '', values: items.value.map(() => '') }
|
||||
}
|
||||
dimDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSaveDim = async () => {
|
||||
savingDim.value = true
|
||||
try {
|
||||
if (editingDimId.value) await destApi.updateDimension(editingDimId.value, dimForm.value)
|
||||
else await destApi.createDimension(dimForm.value)
|
||||
ElMessage.success('保存成功')
|
||||
dimDialogVisible.value = false
|
||||
loadData()
|
||||
} finally { savingDim.value = false }
|
||||
}
|
||||
|
||||
const handleDeleteDim = async (id) => {
|
||||
await destApi.deleteDimension(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
}
|
||||
|
||||
const saveHonestItems = async () => {
|
||||
savingHonest.value = true
|
||||
try { await destApi.updateHonestItems(honestItems.value); ElMessage.success('保存成功') } finally { savingHonest.value = false }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header { margin-bottom: 20px; }
|
||||
</style>
|
||||
121
src/views/export/ExportPanel.vue
普通文件
121
src/views/export/ExportPanel.vue
普通文件
@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header"><h2>导出与构建</h2></div>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="14">
|
||||
<el-card header="导出 JSON 到 Nuxt 项目">
|
||||
<el-checkbox-group v-model="selectedModules" style="display:flex;flex-wrap:wrap;gap:8px 20px;">
|
||||
<el-checkbox v-for="m in allModules" :key="m.value" :label="m.value">{{ m.label }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
|
||||
<div style="margin-top:20px;display:flex;gap:12px;align-items:center;">
|
||||
<el-button type="primary" @click="handleExport(false)" :loading="exporting">导出选中</el-button>
|
||||
<el-button type="success" @click="handleExportAll(false)" :loading="exporting">导出全部</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button type="warning" @click="handleExportAll(true)" :loading="exporting">导出全部 + 重新构建</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="exportResult" :title="exportResult.message" :type="exportResult.type" show-icon closable style="margin-top:16px;" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="10">
|
||||
<el-card header="最近导出记录" v-loading="logLoading">
|
||||
<el-table :data="logs" size="small" max-height="400">
|
||||
<el-table-column prop="exported_by" label="操作人" width="80" />
|
||||
<el-table-column label="状态" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" size="small">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="时间" width="160" />
|
||||
<el-table-column prop="message" label="详情" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { doExport, getExportLog } from '../../api/export'
|
||||
|
||||
const allModules = [
|
||||
{ value: 'brand', label: '品牌信息' },
|
||||
{ value: 'products', label: '产品' },
|
||||
{ value: 'faq', label: '常见问答' },
|
||||
{ value: 'reviews', label: '客户评价' },
|
||||
{ value: 'about', label: '关于我们' },
|
||||
{ value: 'contact', label: '联系方式' },
|
||||
{ value: 'seo', label: 'SEO配置' },
|
||||
{ value: 'navigation', label: '导航菜单' },
|
||||
{ value: 'versions', label: '版本历史' },
|
||||
{ value: 'guides', label: '出行指南' },
|
||||
{ value: 'images', label: '网站图片' },
|
||||
{ value: 'autumn-products', label: '秋季产品' },
|
||||
{ value: 'winter-products', label: '冬季产品' },
|
||||
{ value: 'winter-camp', label: '冬季营' },
|
||||
{ value: 'destinations', label: '目的地对比' },
|
||||
]
|
||||
|
||||
const selectedModules = ref([])
|
||||
const exporting = ref(false)
|
||||
const exportResult = ref(null)
|
||||
const logs = ref([])
|
||||
const logLoading = ref(false)
|
||||
|
||||
const loadLogs = async () => {
|
||||
logLoading.value = true
|
||||
try {
|
||||
const res = await getExportLog()
|
||||
logs.value = res.data
|
||||
} finally { logLoading.value = false }
|
||||
}
|
||||
|
||||
const handleExport = async (triggerRebuild) => {
|
||||
if (selectedModules.value.length === 0) {
|
||||
ElMessage.warning('请选择要导出的模块')
|
||||
return
|
||||
}
|
||||
exporting.value = true
|
||||
exportResult.value = null
|
||||
try {
|
||||
const res = await doExport({ modules: selectedModules.value, trigger_rebuild: triggerRebuild })
|
||||
const allSuccess = Object.values(res.data.results).every(v => v === 'success')
|
||||
exportResult.value = {
|
||||
type: allSuccess ? 'success' : 'warning',
|
||||
message: allSuccess ? '导出成功!' : `部分失败: ${JSON.stringify(res.data.results)}`,
|
||||
}
|
||||
loadLogs()
|
||||
} catch (err) {
|
||||
exportResult.value = { type: 'error', message: '导出失败: ' + (err.response?.data?.detail || err.message) }
|
||||
} finally { exporting.value = false }
|
||||
}
|
||||
|
||||
const handleExportAll = async (triggerRebuild) => {
|
||||
if (triggerRebuild) {
|
||||
try {
|
||||
await ElMessageBox.confirm('导出全部模块并重新构建静态站,构建可能需要几分钟,确认执行?', '确认构建', { type: 'warning' })
|
||||
} catch { return }
|
||||
}
|
||||
selectedModules.value = allModules.map(m => m.value)
|
||||
exporting.value = true
|
||||
exportResult.value = null
|
||||
try {
|
||||
const res = await doExport({ modules: ['all'], trigger_rebuild: triggerRebuild })
|
||||
const allSuccess = Object.values(res.data.results).every(v => v === 'success')
|
||||
exportResult.value = {
|
||||
type: allSuccess ? 'success' : 'warning',
|
||||
message: (allSuccess ? '全部导出成功!' : `部分失败`) + (res.data.rebuild || ''),
|
||||
}
|
||||
loadLogs()
|
||||
} catch (err) {
|
||||
exportResult.value = { type: 'error', message: '导出失败' }
|
||||
} finally { exporting.value = false }
|
||||
}
|
||||
|
||||
onMounted(loadLogs)
|
||||
</script>
|
||||
125
src/views/faq/FaqList.vue
普通文件
125
src/views/faq/FaqList.vue
普通文件
@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>常见问答管理</h2>
|
||||
<el-button type="primary" @click="showAddQuestion">新增问题</el-button>
|
||||
</div>
|
||||
|
||||
<el-collapse v-model="activeCategories" v-loading="loading">
|
||||
<el-collapse-item v-for="cat in categories" :key="cat.id" :name="cat.id">
|
||||
<template #title>
|
||||
<span style="font-size: 15px; font-weight: 600;">{{ cat.name }}</span>
|
||||
<el-tag size="small" style="margin-left: 8px;">{{ cat.questions?.length || 0 }}题</el-tag>
|
||||
</template>
|
||||
<el-table :data="cat.questions || []" border size="small">
|
||||
<el-table-column prop="question" label="问题" show-overflow-tooltip />
|
||||
<el-table-column prop="answer" label="回答" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text size="small" @click="showEditQuestion(cat, row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除?" @confirm="handleDeleteQuestion(row.id)">
|
||||
<template #reference>
|
||||
<el-button type="danger" text size="small">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
|
||||
<!-- Question Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="editingQuestionId ? '编辑问题' : '新增问题'" width="700px">
|
||||
<el-form :model="qForm" label-width="80px">
|
||||
<el-form-item label="所属分类">
|
||||
<el-select v-model="qForm.category_id" style="width: 100%">
|
||||
<el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="问题ID">
|
||||
<el-input v-model="qForm.question_id" placeholder="如: what-is-eqi" />
|
||||
</el-form-item>
|
||||
<el-form-item label="问题">
|
||||
<el-input v-model="qForm.question" />
|
||||
</el-form-item>
|
||||
<el-form-item label="回答">
|
||||
<el-input v-model="qForm.answer" type="textarea" :autosize="{minRows:4,maxRows:15}" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSaveQuestion" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getCategories, createQuestion, updateQuestion, deleteQuestion } from '../../api/faq'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const categories = ref([])
|
||||
const activeCategories = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingQuestionId = ref(null)
|
||||
const qForm = ref({ category_id: null, question_id: '', question: '', answer: '', related_links: [] })
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getCategories()
|
||||
categories.value = res.data
|
||||
activeCategories.value = res.data.map(c => c.id)
|
||||
} catch {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showAddQuestion = () => {
|
||||
editingQuestionId.value = null
|
||||
qForm.value = { category_id: categories.value[0]?.id, question_id: '', question: '', answer: '', related_links: [] }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const showEditQuestion = (cat, row) => {
|
||||
editingQuestionId.value = row.id
|
||||
qForm.value = { category_id: cat.id, question_id: row.question_id, question: row.question, answer: row.answer, related_links: row.related_links || [] }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSaveQuestion = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
const data = { question_id: qForm.value.question_id, question: qForm.value.question, answer: qForm.value.answer, related_links: qForm.value.related_links }
|
||||
if (editingQuestionId.value) {
|
||||
await updateQuestion(editingQuestionId.value, data)
|
||||
} else {
|
||||
await createQuestion(data, qForm.value.category_id)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch (err) {
|
||||
ElMessage.error(err.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteQuestion = async (id) => {
|
||||
try {
|
||||
await deleteQuestion(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
177
src/views/gallery/GalleryList.vue
普通文件
177
src/views/gallery/GalleryList.vue
普通文件
@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>旅拍作品管理</h2>
|
||||
<el-button type="primary" @click="showDialog()">+ 新增作品</el-button>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="keyword" placeholder="搜索标题/摄影师/位置" style="width:240px" @keyup.enter="loadData" clearable @clear="loadData" />
|
||||
<el-button @click="loadData">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" border stripe v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column label="图片" width="90">
|
||||
<template #default="{ row }">
|
||||
<img :src="row.image" style="width:70px;height:50px;object-fit:cover;border-radius:4px;" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="标题" width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="photographer" label="摄影师" width="120" />
|
||||
<el-table-column prop="location" label="拍摄地点" width="140" show-overflow-tooltip />
|
||||
<el-table-column label="标签" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="t in (row.tags || []).slice(0,3)" :key="t" size="small" style="margin-right:4px">{{ t }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="可见" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_visible ? 'success' : 'info'" size="small">{{ row.is_visible ? '显示' : '隐藏' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text size="small" @click="showDialog(row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除此作品?" @confirm="handleDelete(row.id)">
|
||||
<template #reference>
|
||||
<el-button type="danger" text size="small">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
layout="total, prev, pager, next"
|
||||
style="margin-top:16px;justify-content:flex-end;display:flex"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑作品' : '新增作品'" width="620px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-form-item label="图片">
|
||||
<ImageUpload v-model="form.image" subdir="gallery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="标题"><el-input v-model="form.title" /></el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="摄影师"><el-input v-model="form.photographer" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="拍摄地点"><el-input v-model="form.location" /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :autosize="{minRows:2,maxRows:6}" />
|
||||
</el-form-item>
|
||||
<el-form-item label="标签">
|
||||
<el-input v-model="tagsInput" placeholder="用英文逗号分隔,如:草原,骑马,日落" />
|
||||
</el-form-item>
|
||||
<el-form-item label="是否显示">
|
||||
<el-switch v-model="form.is_visible" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getGallery, createGalleryItem, updateGalleryItem, deleteGalleryItem } from '../../api/gallery'
|
||||
import ImageUpload from '../../components/common/ImageUpload.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const keyword = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref(null)
|
||||
const tagsInput = ref('')
|
||||
|
||||
const defaultForm = () => ({
|
||||
title: '',
|
||||
image: '',
|
||||
photographer: '',
|
||||
location: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
is_visible: true,
|
||||
})
|
||||
|
||||
const form = ref(defaultForm())
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getGallery({ page: page.value, page_size: pageSize, keyword: keyword.value })
|
||||
list.value = res.data.items
|
||||
total.value = res.data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showDialog = (row) => {
|
||||
if (row) {
|
||||
editingId.value = row.id
|
||||
form.value = {
|
||||
title: row.title || '',
|
||||
image: row.image || '',
|
||||
photographer: row.photographer || '',
|
||||
location: row.location || '',
|
||||
description: row.description || '',
|
||||
tags: row.tags ? [...row.tags] : [],
|
||||
is_visible: row.is_visible,
|
||||
}
|
||||
tagsInput.value = (row.tags || []).join(', ')
|
||||
} else {
|
||||
editingId.value = null
|
||||
form.value = defaultForm()
|
||||
tagsInput.value = ''
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
form.value.tags = tagsInput.value.split(',').map(t => t.trim()).filter(Boolean)
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateGalleryItem(editingId.value, form.value)
|
||||
} else {
|
||||
await createGalleryItem(form.value)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch (err) {
|
||||
ElMessage.error(err.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await deleteGalleryItem(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
234
src/views/guides/GuideList.vue
普通文件
234
src/views/guides/GuideList.vue
普通文件
@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header"><h2>出行指南管理</h2></div>
|
||||
|
||||
<el-card header="页面介绍" style="margin-bottom:20px;">
|
||||
<el-input v-model="pageIntro" type="textarea" :autosize="{minRows:3,maxRows:10}" />
|
||||
<el-button type="primary" @click="saveIntro" style="margin-top:10px" :loading="saving">保存</el-button>
|
||||
</el-card>
|
||||
|
||||
<el-tabs v-model="activeSection" v-loading="loading">
|
||||
<el-tab-pane v-for="section in sections" :key="section.section_id" :label="(section.icon||'') + ' ' + (section.title||'')" :name="section.section_id">
|
||||
<!-- 通用字段 -->
|
||||
<el-card style="margin-bottom:16px;">
|
||||
<el-form label-width="80px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="14"><el-form-item label="标题"><el-input v-model="section.title" /></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="图标"><el-input v-model="section.icon" style="font-size:20px;" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-form-item label="副标题"><el-input v-model="section.subtitle" /></el-form-item>
|
||||
<el-form-item label="介绍"><el-input v-model="section.content" type="textarea" :autosize="{minRows:3,maxRows:10}" /></el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 证件准备 -->
|
||||
<el-card v-if="section.section_id === 'documents'" header="证件清单">
|
||||
<div v-for="(item, i) in getDataArr(section, 'items')" :key="i" style="display:flex;gap:8px;margin-bottom:8px;align-items:center;">
|
||||
<el-tag :type="item.important ? 'danger' : 'info'" size="small">{{ item.important ? '必带' : '可选' }}</el-tag>
|
||||
<el-input v-model="item.text" />
|
||||
<el-switch v-model="item.important" active-text="必带" />
|
||||
<el-button type="danger" text @click="getDataArr(section,'items').splice(i,1)">删</el-button>
|
||||
</div>
|
||||
<el-button @click="getDataArr(section,'items').push({text:'',important:false})">+ 添加证件</el-button>
|
||||
<el-divider>小贴士</el-divider>
|
||||
<div v-for="(tip, i) in getDataArr(section, 'tips')" :key="i" style="display:flex;gap:8px;margin-bottom:6px;">
|
||||
<el-input v-model="getDataArr(section,'tips')[i]" />
|
||||
<el-button type="danger" text @click="getDataArr(section,'tips').splice(i,1)">删</el-button>
|
||||
</div>
|
||||
<el-button @click="getDataArr(section,'tips').push('')">+ 添加贴士</el-button>
|
||||
</el-card>
|
||||
|
||||
<!-- 穿搭建议 -->
|
||||
<el-card v-if="section.section_id === 'clothing'" header="按月穿搭">
|
||||
<div v-for="(month, i) in getDataArr(section, 'months')" :key="i" style="margin-bottom:16px;padding:12px;background:#fafafa;border-radius:8px;">
|
||||
<div style="display:flex;gap:8px;margin-bottom:8px;align-items:center;">
|
||||
<el-input v-model="month.name" placeholder="月份" style="width:80px" />
|
||||
<el-input v-model="month.desc" placeholder="月份特点" />
|
||||
<el-switch v-model="month.highlight" active-text="旺季" />
|
||||
<el-button type="danger" text @click="getDataArr(section,'months').splice(i,1)">删除</el-button>
|
||||
</div>
|
||||
<el-row :gutter="8" style="margin-bottom:4px;">
|
||||
<el-col :span="6"><el-input v-model="month.tempDay" placeholder="白天温度" size="small"><template #prepend>日</template></el-input></el-col>
|
||||
<el-col :span="6"><el-input v-model="month.tempNight" placeholder="夜间温度" size="small"><template #prepend>夜</template></el-input></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="8">
|
||||
<el-col :span="12"><el-input v-model="month.adultWear" placeholder="成人穿搭建议" size="small" type="textarea" :autosize="{minRows:3,maxRows:10}" /></el-col>
|
||||
<el-col :span="12"><el-input v-model="month.kidsWear" placeholder="儿童穿搭建议" size="small" type="textarea" :autosize="{minRows:3,maxRows:10}" /></el-col>
|
||||
</el-row>
|
||||
<el-input v-if="month.note !== undefined" v-model="month.note" placeholder="特别备注" size="small" style="margin-top:6px" />
|
||||
</div>
|
||||
<el-button @click="getDataArr(section,'months').push({name:'',desc:'',tempDay:'',tempNight:'',adultWear:'',kidsWear:'',highlight:false})">+ 添加月份</el-button>
|
||||
<el-divider>额外提示</el-divider>
|
||||
<div v-for="(tip, i) in getDataArr(section, 'extraTips')" :key="i" style="display:flex;gap:8px;margin-bottom:6px;">
|
||||
<el-input v-model="getDataArr(section,'extraTips')[i]" />
|
||||
<el-button type="danger" text @click="getDataArr(section,'extraTips').splice(i,1)">删</el-button>
|
||||
</div>
|
||||
<el-button @click="getDataArr(section,'extraTips').push('')">+ 添加提示</el-button>
|
||||
</el-card>
|
||||
|
||||
<!-- 行李清单 -->
|
||||
<el-card v-if="section.section_id === 'packing'" header="行李清单">
|
||||
<div v-for="(cat, ci) in getDataArr(section, 'categories')" :key="ci" style="margin-bottom:16px;padding:12px;background:#fafafa;border-radius:8px;">
|
||||
<div style="display:flex;gap:8px;margin-bottom:8px;align-items:center;">
|
||||
<el-input v-model="cat.name" placeholder="分类名" style="width:120px;font-weight:600" />
|
||||
<el-button type="danger" text @click="getDataArr(section,'categories').splice(ci,1)">删除分类</el-button>
|
||||
</div>
|
||||
<div v-for="(item, ii) in (cat.items||[])" :key="ii" style="display:flex;gap:8px;margin-bottom:4px;margin-left:16px;">
|
||||
<el-input v-model="cat.items[ii]" size="small" />
|
||||
<el-button type="danger" text size="small" @click="cat.items.splice(ii,1)">删</el-button>
|
||||
</div>
|
||||
<el-button size="small" style="margin-left:16px" @click="(cat.items=cat.items||[]).push('')">+ 添加物品</el-button>
|
||||
</div>
|
||||
<el-button @click="getDataArr(section,'categories').push({name:'',items:['']})">+ 添加分类</el-button>
|
||||
<el-divider>呼籁提供</el-divider>
|
||||
<div v-for="(p, i) in getDataArr(section, 'provided')" :key="i" style="display:flex;gap:8px;margin-bottom:4px;">
|
||||
<el-input v-model="getDataArr(section,'provided')[i]" size="small" />
|
||||
<el-button type="danger" text size="small" @click="getDataArr(section,'provided').splice(i,1)">删</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="getDataArr(section,'provided').push('')">+ 添加</el-button>
|
||||
<el-divider>不建议携带</el-divider>
|
||||
<div v-for="(d, i) in getDataArr(section, 'dontBring')" :key="i" style="display:flex;gap:8px;margin-bottom:4px;">
|
||||
<el-input v-model="getDataArr(section,'dontBring')[i]" size="small" />
|
||||
<el-button type="danger" text size="small" @click="getDataArr(section,'dontBring').splice(i,1)">删</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="getDataArr(section,'dontBring').push('')">+ 添加</el-button>
|
||||
</el-card>
|
||||
|
||||
<!-- 活动安全须知 -->
|
||||
<el-card v-if="section.section_id === 'activities'" header="活动列表">
|
||||
<div v-for="(act, i) in getDataArr(section, 'activities')" :key="i" style="margin-bottom:16px;padding:12px;background:#fafafa;border-radius:8px;">
|
||||
<div style="display:flex;gap:8px;margin-bottom:8px;align-items:center;">
|
||||
<el-input v-model="act.icon" placeholder="图标" style="width:60px" />
|
||||
<el-input v-model="act.name" placeholder="活动名称" style="width:150px;font-weight:600" />
|
||||
<el-button type="danger" text @click="getDataArr(section,'activities').splice(i,1)">删除</el-button>
|
||||
</div>
|
||||
<el-input v-model="act.intro" placeholder="活动介绍" style="margin-bottom:6px" />
|
||||
<div style="font-size:13px;color:#606266;margin:6px 0 4px;">安全提醒:</div>
|
||||
<div v-for="(tip, ti) in (act.safety || [])" :key="ti" style="display:flex;gap:8px;margin-bottom:4px;margin-left:16px;">
|
||||
<el-input v-model="act.safety[ti]" size="small" />
|
||||
<el-button type="danger" text size="small" @click="act.safety.splice(ti,1)">删</el-button>
|
||||
</div>
|
||||
<el-button size="small" style="margin-left:16px" @click="(act.safety = act.safety || []).push('')">+ 添加安全提醒</el-button>
|
||||
</div>
|
||||
<el-button @click="getDataArr(section,'activities').push({name:'',icon:'',intro:'',safety:['']})">+ 添加活动</el-button>
|
||||
</el-card>
|
||||
|
||||
<!-- 实用信息 -->
|
||||
<el-card v-if="section.section_id === 'practical'" header="实用信息">
|
||||
<div v-for="(topic, i) in getDataArr(section, 'topics')" :key="i" style="margin-bottom:12px;padding:10px;background:#fafafa;border-radius:8px;">
|
||||
<div style="display:flex;gap:8px;margin-bottom:6px;align-items:center;">
|
||||
<el-input v-model="topic.title" placeholder="主题" style="width:200px;font-weight:600" />
|
||||
<el-button type="danger" text @click="getDataArr(section,'topics').splice(i,1)">删除</el-button>
|
||||
</div>
|
||||
<div v-for="(tip, ti) in (topic.tips || [])" :key="ti" style="display:flex;gap:8px;margin-bottom:4px;margin-left:16px;">
|
||||
<el-input v-model="topic.tips[ti]" size="small" />
|
||||
<el-button type="danger" text size="small" @click="topic.tips.splice(ti,1)">删</el-button>
|
||||
</div>
|
||||
<el-button size="small" style="margin-left:16px" @click="(topic.tips = topic.tips || []).push('')">+ 添加</el-button>
|
||||
</div>
|
||||
<el-button @click="getDataArr(section,'topics').push({title:'',tips:['']})">+ 添加主题</el-button>
|
||||
<el-divider>推荐 APP</el-divider>
|
||||
<div v-for="(app, i) in getDataArr(section, 'apps')" :key="i" style="display:flex;gap:8px;margin-bottom:6px;">
|
||||
<el-input v-model="app.name" placeholder="APP名称" style="width:120px" />
|
||||
<el-input v-model="app.use" placeholder="用途" />
|
||||
<el-button type="danger" text @click="getDataArr(section,'apps').splice(i,1)">删</el-button>
|
||||
</div>
|
||||
<el-button @click="getDataArr(section,'apps').push({name:'',use:''})">+ 添加 APP</el-button>
|
||||
</el-card>
|
||||
|
||||
<!-- 幼童指南 -->
|
||||
<el-card v-if="section.section_id === 'toddler'" header="幼童活动推荐">
|
||||
<h4 style="color:#67C23A;margin-bottom:8px;">推荐参加</h4>
|
||||
<div v-for="(r, i) in getDataArr(section, 'recommended')" :key="'r'+i" style="display:flex;gap:8px;margin-bottom:6px;">
|
||||
<el-input v-model="r.name" placeholder="活动名" style="width:150px" />
|
||||
<el-input v-model="r.note" placeholder="说明" />
|
||||
<el-button type="danger" text @click="getDataArr(section,'recommended').splice(i,1)">删</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="getDataArr(section,'recommended').push({name:'',note:''})">+ 添加</el-button>
|
||||
|
||||
<h4 style="color:#E6A23C;margin:16px 0 8px;">视情况参加</h4>
|
||||
<div v-for="(c, i) in getDataArr(section, 'conditional')" :key="'c'+i" style="display:flex;gap:8px;margin-bottom:6px;">
|
||||
<el-input v-model="c.name" placeholder="活动名" style="width:150px" />
|
||||
<el-input v-model="c.note" placeholder="说明" />
|
||||
<el-button type="danger" text @click="getDataArr(section,'conditional').splice(i,1)">删</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="getDataArr(section,'conditional').push({name:'',note:''})">+ 添加</el-button>
|
||||
|
||||
<h4 style="color:#F56C6C;margin:16px 0 8px;">不建议参加</h4>
|
||||
<div v-for="(n, i) in getDataArr(section, 'notRecommended')" :key="'n'+i" style="display:flex;gap:8px;margin-bottom:6px;">
|
||||
<el-input v-model="n.name" placeholder="活动名" style="width:150px" />
|
||||
<el-input v-model="n.note" placeholder="说明" />
|
||||
<el-button type="danger" text @click="getDataArr(section,'notRecommended').splice(i,1)">删</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="getDataArr(section,'notRecommended').push({name:'',note:''})">+ 添加</el-button>
|
||||
|
||||
<el-divider>装备建议</el-divider>
|
||||
<div v-for="(e, i) in getDataArr(section, 'equipment')" :key="'e'+i" style="display:flex;gap:8px;margin-bottom:4px;">
|
||||
<el-input v-model="getDataArr(section,'equipment')[i]" size="small" />
|
||||
<el-button type="danger" text size="small" @click="getDataArr(section,'equipment').splice(i,1)">删</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="getDataArr(section,'equipment').push('')">+ 添加</el-button>
|
||||
|
||||
<el-divider>用餐建议</el-divider>
|
||||
<div v-for="(d, i) in getDataArr(section, 'dining')" :key="'d'+i" style="display:flex;gap:8px;margin-bottom:4px;">
|
||||
<el-input v-model="getDataArr(section,'dining')[i]" size="small" />
|
||||
<el-button type="danger" text size="small" @click="getDataArr(section,'dining').splice(i,1)">删</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="getDataArr(section,'dining').push('')">+ 添加</el-button>
|
||||
</el-card>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<div style="margin-top:16px;">
|
||||
<el-button type="primary" size="large" @click="saveSection(section)" :loading="saving">保存「{{ section.title }}」</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getGuideConfig, updateGuideConfig, getGuideSections, updateGuideSection } from '../../api/guides'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const pageIntro = ref('')
|
||||
const sections = ref([])
|
||||
const activeSection = ref('documents')
|
||||
|
||||
const getDataArr = (section, key) => {
|
||||
if (!section.data) section.data = {}
|
||||
if (!section.data[key]) section.data[key] = []
|
||||
return section.data[key]
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [configRes, sectionsRes] = await Promise.all([getGuideConfig(), getGuideSections()])
|
||||
pageIntro.value = configRes.data.page_intro || ''
|
||||
sections.value = sectionsRes.data
|
||||
} finally { loading.value = false }
|
||||
})
|
||||
|
||||
const saveIntro = async () => {
|
||||
saving.value = true
|
||||
try { await updateGuideConfig({ page_intro: pageIntro.value }); ElMessage.success('保存成功') }
|
||||
catch { ElMessage.error('保存失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
const saveSection = async (section) => {
|
||||
saving.value = true
|
||||
try {
|
||||
await updateGuideSection(section.section_id, {
|
||||
section_id: section.section_id, title: section.title,
|
||||
subtitle: section.subtitle, icon: section.icon, content: section.content,
|
||||
data: section.data,
|
||||
})
|
||||
ElMessage.success('保存成功')
|
||||
} catch { ElMessage.error('保存失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>导航菜单管理</h2>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</div>
|
||||
|
||||
<el-card header="顶部导航">
|
||||
<div v-for="(h, i) in nav.header" :key="i" style="display:flex;gap:8px;margin-bottom:8px;">
|
||||
<el-input v-model="h.text" placeholder="显示文字" style="width:150px" />
|
||||
<el-input v-model="h.to" placeholder="路径 如 /about" />
|
||||
<el-button type="danger" text @click="nav.header.splice(i,1)">删除</el-button>
|
||||
</div>
|
||||
<el-button @click="nav.header.push({text:'',to:''})">+ 添加导航项</el-button>
|
||||
</el-card>
|
||||
|
||||
<el-card header="底部导航" style="margin-top:20px;">
|
||||
<div v-for="(group, gi) in nav.footer" :key="gi" style="margin-bottom:20px;padding:16px;background:#f9f9f9;border-radius:8px;">
|
||||
<div style="display:flex;gap:8px;margin-bottom:10px;">
|
||||
<el-input v-model="group.title" placeholder="分组标题" style="width:200px;font-weight:600;" />
|
||||
<el-button type="danger" text @click="nav.footer.splice(gi,1)">删除分组</el-button>
|
||||
</div>
|
||||
<div v-for="(link, li) in group.links" :key="li" style="display:flex;gap:8px;margin-bottom:6px;margin-left:20px;">
|
||||
<el-input v-model="link.text" placeholder="文字" style="width:150px" />
|
||||
<el-input v-model="link.to" placeholder="路径" />
|
||||
<el-button type="danger" text @click="group.links.splice(li,1)">删</el-button>
|
||||
</div>
|
||||
<el-button size="small" style="margin-left:20px;" @click="group.links.push({text:'',to:''})">+ 添加链接</el-button>
|
||||
</div>
|
||||
<el-button @click="nav.footer.push({title:'',links:[]})">+ 添加分组</el-button>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getNavigation, updateNavigation } from '../../api/navigation'
|
||||
|
||||
const saving = ref(false)
|
||||
const nav = ref({ header: [], footer: [] })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await getNavigation()
|
||||
nav.value = res.data
|
||||
} catch {}
|
||||
})
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
await updateNavigation(nav.value)
|
||||
ElMessage.success('保存成功')
|
||||
} catch { ElMessage.error('保存失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
170
src/views/news/NewsList.vue
普通文件
170
src/views/news/NewsList.vue
普通文件
@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>新闻动态管理</h2>
|
||||
<el-button type="primary" @click="showDialog()">新增新闻</el-button>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar" style="margin-bottom:16px;display:flex;gap:8px;">
|
||||
<el-input v-model="keyword" placeholder="搜索标题/摘要" clearable style="width:260px" @clear="loadData" @keyup.enter="loadData" />
|
||||
<el-button @click="loadData">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" border stripe v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="title" label="标题" show-overflow-tooltip />
|
||||
<el-table-column prop="source" label="来源" width="120" />
|
||||
<el-table-column prop="published_at" label="发布时间" width="130" />
|
||||
<el-table-column label="显示" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.is_visible" @change="handleToggleVisible(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text size="small" @click="showDialog(row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除该新闻?" @confirm="handleDelete(row.id)">
|
||||
<template #reference>
|
||||
<el-button type="danger" text size="small">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
style="margin-top:16px;justify-content:flex-end;"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑新闻' : '新增新闻'" width="760px">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="form.title" placeholder="新闻标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="封面图">
|
||||
<el-input v-model="form.cover_image" placeholder="图片URL" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="来源">
|
||||
<el-input v-model="form.source" placeholder="如:新华社、呼籁官方" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="发布时间">
|
||||
<el-input v-model="form.published_at" placeholder="如:2025年3月15日" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="来源链接">
|
||||
<el-input v-model="form.source_url" placeholder="原文链接(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="摘要">
|
||||
<el-input v-model="form.summary" type="textarea" :autosize="{ minRows: 2, maxRows: 5 }" />
|
||||
</el-form-item>
|
||||
<el-form-item label="正文">
|
||||
<el-input v-model="form.content" type="textarea" :autosize="{ minRows: 8, maxRows: 20 }" />
|
||||
</el-form-item>
|
||||
<el-form-item label="显示">
|
||||
<el-switch v-model="form.is_visible" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getNews, createNews, updateNews, deleteNews } from '../../api/news'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const keyword = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref(null)
|
||||
|
||||
const defaultForm = () => ({
|
||||
title: '', cover_image: '', summary: '', content: '',
|
||||
source: '', source_url: '', published_at: '', is_visible: true,
|
||||
})
|
||||
const form = ref(defaultForm())
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getNews({ page: page.value, page_size: pageSize, keyword: keyword.value })
|
||||
list.value = res.data.items
|
||||
total.value = res.data.total
|
||||
} catch {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showDialog = (row) => {
|
||||
if (row) {
|
||||
editingId.value = row.id
|
||||
form.value = { ...row }
|
||||
} else {
|
||||
editingId.value = null
|
||||
form.value = defaultForm()
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.value.title) { ElMessage.warning('请填写标题'); return }
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateNews(editingId.value, form.value)
|
||||
} else {
|
||||
await createNews(form.value)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch (err) {
|
||||
ElMessage.error(err.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleVisible = async (row) => {
|
||||
try {
|
||||
await updateNews(row.id, row)
|
||||
ElMessage.success('更新成功')
|
||||
} catch {
|
||||
ElMessage.error('更新失败')
|
||||
row.is_visible = !row.is_visible
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await deleteNews(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
132
src/views/partners/PartnerList.vue
普通文件
132
src/views/partners/PartnerList.vue
普通文件
@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>合作伙伴管理</h2>
|
||||
<el-button type="primary" @click="showDialog()">新增伙伴</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" border stripe v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="name" label="名称" width="180" />
|
||||
<el-table-column label="Logo" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-image v-if="row.logo" :src="row.logo" style="width:60px;height:40px;object-fit:contain;" fit="contain" />
|
||||
<span v-else style="color:#999">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="category" label="分类" width="120" />
|
||||
<el-table-column prop="website" label="官网" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text size="small" @click="showDialog(row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除?" @confirm="handleDelete(row.id)">
|
||||
<template #reference>
|
||||
<el-button type="danger" text size="small">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑伙伴' : '新增伙伴'" width="560px">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="form.name" placeholder="合作伙伴名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Logo">
|
||||
<el-input v-model="form.logo" placeholder="Logo图片URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="官网">
|
||||
<el-input v-model="form.website" placeholder="官网链接(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="form.category" placeholder="选择分类" allow-create filterable style="width:100%">
|
||||
<el-option label="酒店住宿" value="酒店住宿" />
|
||||
<el-option label="景区合作" value="景区合作" />
|
||||
<el-option label="交通出行" value="交通出行" />
|
||||
<el-option label="保险金融" value="保险金融" />
|
||||
<el-option label="媒体合作" value="媒体合作" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :autosize="{ minRows: 2, maxRows: 5 }" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getPartners, createPartner, updatePartner, deletePartner } from '../../api/partners'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const list = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref(null)
|
||||
|
||||
const defaultForm = () => ({
|
||||
name: '', logo: '', website: '', description: '', category: '',
|
||||
})
|
||||
const form = ref(defaultForm())
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getPartners()
|
||||
list.value = res.data
|
||||
} catch {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showDialog = (row) => {
|
||||
if (row) {
|
||||
editingId.value = row.id
|
||||
form.value = { ...row }
|
||||
} else {
|
||||
editingId.value = null
|
||||
form.value = defaultForm()
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.value.name) { ElMessage.warning('请填写名称'); return }
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updatePartner(editingId.value, form.value)
|
||||
} else {
|
||||
await createPartner(form.value)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch (err) {
|
||||
ElMessage.error(err.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await deletePartner(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
170
src/views/pricing/PricingList.vue
普通文件
170
src/views/pricing/PricingList.vue
普通文件
@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>价格信息管理</h2>
|
||||
<el-button type="primary" @click="showDialog()">新增价格</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" border stripe v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="product_name" label="产品名称" show-overflow-tooltip />
|
||||
<el-table-column prop="product_slug" label="Slug" width="140" />
|
||||
<el-table-column label="价格" width="160">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.price_from">{{ row.price_from }}{{ row.price_unit }}</span>
|
||||
<span v-else style="color:#999">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="price_label" label="价格标签" width="120" />
|
||||
<el-table-column label="显示" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.is_visible" @change="handleToggleVisible(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text size="small" @click="showDialog(row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除?" @confirm="handleDelete(row.id)">
|
||||
<template #reference>
|
||||
<el-button type="danger" text size="small">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑价格' : '新增价格'" width="680px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-form-item label="产品名称">
|
||||
<el-input v-model="form.product_name" placeholder="如:额吉的故乡" />
|
||||
</el-form-item>
|
||||
<el-form-item label="产品Slug">
|
||||
<el-input v-model="form.product_slug" placeholder="如:eqi" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="10">
|
||||
<el-form-item label="起步价">
|
||||
<el-input v-model="form.price_from" placeholder="如:4980" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="7">
|
||||
<el-form-item label="单位">
|
||||
<el-input v-model="form.price_unit" placeholder="如:元/人" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="7">
|
||||
<el-form-item label="价格标签">
|
||||
<el-input v-model="form.price_label" placeholder="如:起" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="价格说明">
|
||||
<el-input v-model="form.description" type="textarea" :autosize="{ minRows: 2, maxRows: 6 }" placeholder="详细说明" />
|
||||
</el-form-item>
|
||||
<el-form-item label="包含内容">
|
||||
<div v-for="(f, i) in form.features" :key="i" style="display:flex;gap:8px;margin-bottom:8px;">
|
||||
<el-input v-model="form.features[i]" placeholder="如:全程专业领队" />
|
||||
<el-button type="danger" text @click="form.features.splice(i, 1)">删除</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="form.features.push('')">+ 添加</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item label="不含项目">
|
||||
<div v-for="(n, i) in form.notes" :key="i" style="display:flex;gap:8px;margin-bottom:8px;">
|
||||
<el-input v-model="form.notes[i]" placeholder="如:个人消费" />
|
||||
<el-button type="danger" text @click="form.notes.splice(i, 1)">删除</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="form.notes.push('')">+ 添加</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item label="显示">
|
||||
<el-switch v-model="form.is_visible" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getPricing, createPricing, updatePricing, deletePricing } from '../../api/pricing'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const list = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref(null)
|
||||
|
||||
const defaultForm = () => ({
|
||||
product_name: '', product_slug: '', price_from: '', price_unit: '元/人',
|
||||
price_label: '起', description: '', features: [], notes: [], is_visible: true,
|
||||
})
|
||||
const form = ref(defaultForm())
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getPricing()
|
||||
list.value = res.data
|
||||
} catch {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showDialog = (row) => {
|
||||
if (row) {
|
||||
editingId.value = row.id
|
||||
form.value = { ...row, features: [...(row.features || [])], notes: [...(row.notes || [])] }
|
||||
} else {
|
||||
editingId.value = null
|
||||
form.value = defaultForm()
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.value.product_name) { ElMessage.warning('请填写产品名称'); return }
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updatePricing(editingId.value, form.value)
|
||||
} else {
|
||||
await createPricing(form.value)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch (err) {
|
||||
ElMessage.error(err.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleVisible = async (row) => {
|
||||
try {
|
||||
await updatePricing(row.id, row)
|
||||
ElMessage.success('更新成功')
|
||||
} catch {
|
||||
ElMessage.error('更新失败')
|
||||
row.is_visible = !row.is_visible
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await deletePricing(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<SeasonalProductList title="秋季产品 · 游牧的森林" :api="api" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SeasonalProductList from './SeasonalProductList.vue'
|
||||
import * as api from '../../api/autumnProducts'
|
||||
</script>
|
||||
132
src/views/products/ProductList.vue
普通文件
132
src/views/products/ProductList.vue
普通文件
@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>产品版本管理</h2>
|
||||
<el-button type="primary" @click="showDialog()">新增版本</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 产品总述和定价理念 -->
|
||||
<el-card style="margin-bottom:20px;">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="产品总述"><el-input v-model="productConfig.narrative" type="textarea" :autosize="{minRows:3,maxRows:10}" /></el-form-item>
|
||||
<el-form-item label="定价理念"><el-input v-model="productConfig.pricing_philosophy" type="textarea" :autosize="{minRows:3,maxRows:10}" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="saveConfig" :loading="savingConfig">保存产品配置</el-button></el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-table :data="versions" border stripe v-loading="loading">
|
||||
<el-table-column prop="version_id" label="版本ID" width="130" />
|
||||
<el-table-column prop="name" label="产品名称" show-overflow-tooltip />
|
||||
<el-table-column prop="tag" label="标签" width="100" />
|
||||
<el-table-column label="天数" width="80">
|
||||
<template #default="{ row }">{{ row.days }}天{{ row.nights }}晚</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="audience" label="适合人群" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text size="small" @click="showDialog(row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除?" @confirm="handleDelete(row.id)">
|
||||
<template #reference>
|
||||
<el-button type="danger" text size="small">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑版本' : '新增版本'" width="700px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-form-item label="版本ID"><el-input v-model="form.version_id" placeholder="如: v9-6d5n-family" /></el-form-item>
|
||||
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8"><el-form-item label="天数"><el-input-number v-model="form.days" :min="1" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="晚数"><el-input-number v-model="form.nights" :min="0" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="标签"><el-input v-model="form.tag" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-form-item label="适合人群"><el-input v-model="form.audience" /></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="form.description" type="textarea" :autosize="{minRows:4,maxRows:12}" /></el-form-item>
|
||||
<el-form-item label="亮点">
|
||||
<div v-for="(h, i) in form.highlights" :key="i" style="display: flex; gap: 8px; margin-bottom: 8px; width: 100%;">
|
||||
<el-input v-model="form.highlights[i]" />
|
||||
<el-button type="danger" text @click="form.highlights.splice(i, 1)">删除</el-button>
|
||||
</div>
|
||||
<el-button @click="form.highlights.push('')">+ 添加亮点</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getVersions, createVersion, updateVersion, deleteVersion, getProductConfig, updateProductConfig } from '../../api/products'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const savingConfig = ref(false)
|
||||
const versions = ref([])
|
||||
const productConfig = ref({ narrative: '', pricing_philosophy: '' })
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref(null)
|
||||
const form = ref({ version_id: '', name: '', days: 4, nights: 3, audience: '', description: '', tag: '', highlights: [] })
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getVersions()
|
||||
versions.value = res.data
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
const showDialog = (row) => {
|
||||
if (row) {
|
||||
editingId.value = row.id
|
||||
form.value = { ...row, highlights: [...(row.highlights || [])] }
|
||||
} else {
|
||||
editingId.value = null
|
||||
form.value = { version_id: '', name: '', days: 4, nights: 3, audience: '', description: '', tag: '', highlights: [] }
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateVersion(editingId.value, form.value)
|
||||
} else {
|
||||
await createVersion(form.value)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch (err) {
|
||||
ElMessage.error(err.response?.data?.detail || '保存失败')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await deleteVersion(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch { ElMessage.error('删除失败') }
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
savingConfig.value = true
|
||||
try { await updateProductConfig(productConfig.value); ElMessage.success('保存成功') }
|
||||
catch { ElMessage.error('保存失败') }
|
||||
finally { savingConfig.value = false }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadData()
|
||||
try { const res = await getProductConfig(); productConfig.value = res.data } catch {}
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ title }}</h2>
|
||||
<el-button type="primary" @click="showVersionDialog()">新增版本</el-button>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<!-- 产品配置 -->
|
||||
<el-tab-pane label="产品配置" name="config">
|
||||
<el-card>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="品牌理念"><el-input v-model="config.narrative" type="textarea" :autosize="{minRows:3,maxRows:10}" /></el-form-item>
|
||||
<el-form-item label="品牌调性"><el-input v-model="config.style" type="textarea" :autosize="{minRows:2,maxRows:6}" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="saveConfig" :loading="savingConfig">保存配置</el-button></el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 产品版本 -->
|
||||
<el-tab-pane label="产品版本" name="versions">
|
||||
<el-table :data="versions" border stripe v-loading="loading">
|
||||
<el-table-column prop="version_id" label="版本ID" width="160" />
|
||||
<el-table-column prop="name" label="名称" show-overflow-tooltip />
|
||||
<el-table-column prop="tag" label="标签" width="100" />
|
||||
<el-table-column label="天数" width="100">
|
||||
<template #default="{ row }">{{ row.days }}天{{ row.nights }}晚</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="line" label="线路" width="80" />
|
||||
<el-table-column prop="audience" label="适合人群" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text size="small" @click="showVersionDialog(row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除?" @confirm="handleDeleteVersion(row.id)">
|
||||
<template #reference><el-button type="danger" text size="small">删除</el-button></template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 行程亮点 -->
|
||||
<el-tab-pane label="行程亮点" name="highlights">
|
||||
<div v-for="cat in highlightCategories" :key="cat.key" style="margin-bottom:24px;">
|
||||
<h3>{{ cat.label }}</h3>
|
||||
<div v-for="(h, i) in highlightsByCategory[cat.key]" :key="i" style="display:flex;gap:8px;margin-bottom:8px;">
|
||||
<el-input v-model="h.title" placeholder="标题" style="width:200px;" />
|
||||
<el-input v-model="h.description" placeholder="描述" />
|
||||
<el-button type="danger" text @click="highlightsByCategory[cat.key].splice(i, 1)">删除</el-button>
|
||||
</div>
|
||||
<el-button @click="highlightsByCategory[cat.key].push({title:'',description:''})">+ 添加</el-button>
|
||||
</div>
|
||||
<el-button type="primary" @click="saveHighlights" :loading="savingHighlights" style="margin-top:16px;">保存全部亮点</el-button>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 版本时间线 -->
|
||||
<el-tab-pane label="版本时间线" name="timeline">
|
||||
<el-button type="primary" @click="showTimelineDialog()" style="margin-bottom:16px;">新增时间线</el-button>
|
||||
<el-table :data="timeline" border stripe>
|
||||
<el-table-column prop="version" label="版本" width="100" />
|
||||
<el-table-column prop="date" label="日期" width="120" />
|
||||
<el-table-column prop="title" label="标题" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text size="small" @click="showTimelineDialog(row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除?" @confirm="handleDeleteTimeline(row.id)">
|
||||
<template #reference><el-button type="danger" text size="small">删除</el-button></template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 版本编辑对话框 -->
|
||||
<el-dialog v-model="versionDialogVisible" :title="editingVersionId ? '编辑版本' : '新增版本'" width="700px">
|
||||
<el-form :model="versionForm" label-width="90px">
|
||||
<el-form-item label="版本ID"><el-input v-model="versionForm.version_id" placeholder="如: autumn-south-7d6n" /></el-form-item>
|
||||
<el-form-item label="名称"><el-input v-model="versionForm.name" /></el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6"><el-form-item label="天数"><el-input-number v-model="versionForm.days" :min="1" /></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="晚数"><el-input-number v-model="versionForm.nights" :min="0" /></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="标签"><el-input v-model="versionForm.tag" /></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="线路"><el-input v-model="versionForm.line" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-form-item label="路线"><el-input v-model="versionForm.route" /></el-form-item>
|
||||
<el-form-item label="适合人群"><el-input v-model="versionForm.audience" /></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="versionForm.description" type="textarea" :autosize="{minRows:3,maxRows:10}" /></el-form-item>
|
||||
<el-form-item label="亮点">
|
||||
<div v-for="(h, i) in versionForm.highlights" :key="i" style="display:flex;gap:8px;margin-bottom:8px;width:100%;">
|
||||
<el-input v-model="versionForm.highlights[i]" />
|
||||
<el-button type="danger" text @click="versionForm.highlights.splice(i, 1)">删除</el-button>
|
||||
</div>
|
||||
<el-button @click="versionForm.highlights.push('')">+ 添加亮点</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item label="每日行程">
|
||||
<div v-for="(it, i) in versionForm.itinerary" :key="i" style="display:flex;gap:8px;margin-bottom:8px;width:100%;">
|
||||
<el-input v-model="versionForm.itinerary[i]" />
|
||||
<el-button type="danger" text @click="versionForm.itinerary.splice(i, 1)">删除</el-button>
|
||||
</div>
|
||||
<el-button @click="versionForm.itinerary.push('')">+ 添加行程</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="versionDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSaveVersion" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 时间线编辑对话框 -->
|
||||
<el-dialog v-model="timelineDialogVisible" :title="editingTimelineId ? '编辑时间线' : '新增时间线'" width="600px">
|
||||
<el-form :model="timelineForm" label-width="80px">
|
||||
<el-form-item label="版本"><el-input v-model="timelineForm.version" placeholder="如: V3" /></el-form-item>
|
||||
<el-form-item label="日期"><el-input v-model="timelineForm.date" placeholder="如: 2025.09" /></el-form-item>
|
||||
<el-form-item label="标题"><el-input v-model="timelineForm.title" /></el-form-item>
|
||||
<el-form-item label="原因"><el-input v-model="timelineForm.reason" type="textarea" :autosize="{minRows:2,maxRows:6}" /></el-form-item>
|
||||
<el-form-item label="变更">
|
||||
<div v-for="(ch, i) in timelineForm.changes" :key="i" style="display:flex;gap:8px;margin-bottom:8px;width:100%;">
|
||||
<el-select v-model="ch.type" style="width:100px;">
|
||||
<el-option label="新增" value="add" /><el-option label="优化" value="optimize" /><el-option label="删除" value="remove" />
|
||||
</el-select>
|
||||
<el-input v-model="ch.text" />
|
||||
<el-button type="danger" text @click="timelineForm.changes.splice(i, 1)">删除</el-button>
|
||||
</div>
|
||||
<el-button @click="timelineForm.changes.push({type:'add',text:''})">+ 添加变更</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="timelineDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSaveTimeline" :loading="savingTimeline">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
api: Object, // { getConfig, updateConfig, getVersions, createVersion, updateVersion, deleteVersion, getHighlights, updateHighlights, getTimeline, createTimeline, updateTimeline, deleteTimeline }
|
||||
})
|
||||
|
||||
const activeTab = ref('versions')
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const savingConfig = ref(false)
|
||||
const savingHighlights = ref(false)
|
||||
const savingTimeline = ref(false)
|
||||
|
||||
// Config
|
||||
const config = ref({ narrative: '', style: '' })
|
||||
|
||||
// Versions
|
||||
const versions = ref([])
|
||||
const versionDialogVisible = ref(false)
|
||||
const editingVersionId = ref(null)
|
||||
const versionForm = ref({ version_id: '', name: '', days: 4, nights: 3, tag: '', line: '', route: '', audience: '', description: '', highlights: [], itinerary: [] })
|
||||
|
||||
// Highlights
|
||||
const highlightCategories = [
|
||||
{ key: 'shared', label: '共同亮点' },
|
||||
{ key: 'south', label: '南线特色' },
|
||||
{ key: 'north', label: '北线特色' },
|
||||
]
|
||||
const highlightsByCategory = reactive({ shared: [], south: [], north: [] })
|
||||
|
||||
// Timeline
|
||||
const timeline = ref([])
|
||||
const timelineDialogVisible = ref(false)
|
||||
const editingTimelineId = ref(null)
|
||||
const timelineForm = ref({ version: '', date: '', title: '', reason: '', changes: [] })
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [configRes, versionsRes, highlightsRes, timelineRes] = await Promise.all([
|
||||
props.api.getConfig(), props.api.getVersions(), props.api.getHighlights(), props.api.getTimeline(),
|
||||
])
|
||||
config.value = configRes.data
|
||||
versions.value = versionsRes.data
|
||||
timeline.value = timelineRes.data
|
||||
|
||||
// Group highlights
|
||||
const all = highlightsRes.data
|
||||
highlightsByCategory.shared = all.filter(h => h.category === 'shared').map(h => ({ title: h.title, description: h.description }))
|
||||
highlightsByCategory.south = all.filter(h => h.category === 'south').map(h => ({ title: h.title, description: h.description }))
|
||||
highlightsByCategory.north = all.filter(h => h.category === 'north').map(h => ({ title: h.title, description: h.description }))
|
||||
} catch (e) {
|
||||
ElMessage.error('加载数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
savingConfig.value = true
|
||||
try {
|
||||
await props.api.updateConfig(config.value)
|
||||
ElMessage.success('配置保存成功')
|
||||
} finally { savingConfig.value = false }
|
||||
}
|
||||
|
||||
const showVersionDialog = (row) => {
|
||||
if (row) {
|
||||
editingVersionId.value = row.id
|
||||
versionForm.value = { ...row, highlights: [...(row.highlights || [])], itinerary: [...(row.itinerary || [])] }
|
||||
} else {
|
||||
editingVersionId.value = null
|
||||
versionForm.value = { version_id: '', name: '', days: 4, nights: 3, tag: '', line: '', route: '', audience: '', description: '', highlights: [], itinerary: [] }
|
||||
}
|
||||
versionDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSaveVersion = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingVersionId.value) {
|
||||
await props.api.updateVersion(editingVersionId.value, versionForm.value)
|
||||
} else {
|
||||
await props.api.createVersion(versionForm.value)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
versionDialogVisible.value = false
|
||||
loadData()
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
const handleDeleteVersion = async (id) => {
|
||||
await props.api.deleteVersion(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
}
|
||||
|
||||
const saveHighlights = async () => {
|
||||
savingHighlights.value = true
|
||||
try {
|
||||
const all = [
|
||||
...highlightsByCategory.shared.map(h => ({ ...h, category: 'shared' })),
|
||||
...highlightsByCategory.south.map(h => ({ ...h, category: 'south' })),
|
||||
...highlightsByCategory.north.map(h => ({ ...h, category: 'north' })),
|
||||
]
|
||||
await props.api.updateHighlights(all)
|
||||
ElMessage.success('亮点保存成功')
|
||||
} finally { savingHighlights.value = false }
|
||||
}
|
||||
|
||||
const showTimelineDialog = (row) => {
|
||||
if (row) {
|
||||
editingTimelineId.value = row.id
|
||||
timelineForm.value = { ...row, changes: [...(row.changes || [])] }
|
||||
} else {
|
||||
editingTimelineId.value = null
|
||||
timelineForm.value = { version: '', date: '', title: '', reason: '', changes: [] }
|
||||
}
|
||||
timelineDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSaveTimeline = async () => {
|
||||
savingTimeline.value = true
|
||||
try {
|
||||
if (editingTimelineId.value) {
|
||||
await props.api.updateTimeline(editingTimelineId.value, timelineForm.value)
|
||||
} else {
|
||||
await props.api.createTimeline(timelineForm.value)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
timelineDialogVisible.value = false
|
||||
loadData()
|
||||
} finally { savingTimeline.value = false }
|
||||
}
|
||||
|
||||
const handleDeleteTimeline = async (id) => {
|
||||
await props.api.deleteTimeline(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
h3 { margin: 0 0 12px; color: #333; }
|
||||
</style>
|
||||
@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>选品指南</h2>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</div>
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:16px;">
|
||||
选品指南帮助客户根据假期长度、孩子年龄、旅行偏好选择合适的产品版本,显示在官网产品页底部。
|
||||
</el-alert>
|
||||
|
||||
<el-card v-loading="loading">
|
||||
<el-tabs v-model="activeTab">
|
||||
<!-- 按假期长度 -->
|
||||
<el-tab-pane label="按假期长度" name="vacation">
|
||||
<div v-for="(item, i) in form.by_vacation_length" :key="'v'+i" style="margin-bottom:16px;padding:14px;background:#fafafa;border-radius:8px;">
|
||||
<div style="display:flex;gap:8px;margin-bottom:8px;align-items:center;">
|
||||
<el-input v-model="item.condition" placeholder="条件(如:只有4天假期)" style="flex:1" />
|
||||
<el-button type="danger" text @click="form.by_vacation_length.splice(i,1)">删除</el-button>
|
||||
</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8"><el-input v-model="item.recommendation" placeholder="推荐版本" /></el-col>
|
||||
<el-col :span="16"><el-input v-model="item.reason" placeholder="推荐原因" /></el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<el-button @click="form.by_vacation_length.push({condition:'',recommendation:'',reason:''})">+ 添加推荐</el-button>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 按孩子年龄 -->
|
||||
<el-tab-pane label="按孩子年龄" name="age">
|
||||
<div v-for="(item, i) in form.by_child_age" :key="'a'+i" style="margin-bottom:16px;padding:14px;background:#fafafa;border-radius:8px;">
|
||||
<div style="display:flex;gap:8px;margin-bottom:8px;align-items:center;">
|
||||
<el-input v-model="item.condition" placeholder="年龄条件(如:3-5岁)" style="flex:1" />
|
||||
<el-button type="danger" text @click="form.by_child_age.splice(i,1)">删除</el-button>
|
||||
</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8"><el-input v-model="item.recommendation" placeholder="推荐版本" /></el-col>
|
||||
<el-col :span="16"><el-input v-model="item.reason" placeholder="推荐原因" /></el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<el-button @click="form.by_child_age.push({condition:'',recommendation:'',reason:''})">+ 添加推荐</el-button>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 按旅行偏好 -->
|
||||
<el-tab-pane label="按旅行偏好" name="preference">
|
||||
<div v-for="(item, i) in form.by_preference" :key="'p'+i" style="margin-bottom:16px;padding:14px;background:#fafafa;border-radius:8px;">
|
||||
<div style="display:flex;gap:8px;margin-bottom:8px;align-items:center;">
|
||||
<el-input v-model="item.condition" placeholder="偏好条件(如:注重住宿品质)" style="flex:1" />
|
||||
<el-button type="danger" text @click="form.by_preference.splice(i,1)">删除</el-button>
|
||||
</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8"><el-input v-model="item.recommendation" placeholder="推荐版本" /></el-col>
|
||||
<el-col :span="16"><el-input v-model="item.reason" placeholder="推荐原因" /></el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<el-button @click="form.by_preference.push({condition:'',recommendation:'',reason:''})">+ 添加推荐</el-button>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getSelectionGuide, updateSelectionGuide } from '../../api/products'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const activeTab = ref('vacation')
|
||||
const form = ref({ by_vacation_length: [], by_child_age: [], by_preference: [] })
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getSelectionGuide()
|
||||
form.value = res.data
|
||||
} finally { loading.value = false }
|
||||
})
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
await updateSelectionGuide(form.value)
|
||||
ElMessage.success('保存成功')
|
||||
} catch { ElMessage.error('保存失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>小蒙马夏令营</h2>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</div>
|
||||
<el-card v-loading="loading">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="定位"><el-input v-model="form.positioning" type="textarea" :autosize="{minRows:4,maxRows:15}" /></el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8"><el-form-item label="天数"><el-input-number v-model="form.days" :min="1" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="晚数"><el-input-number v-model="form.nights" :min="0" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-form-item label="与V9区别"><el-input v-model="form.difference_from_v9" type="textarea" :autosize="{minRows:3,maxRows:8}" /></el-form-item>
|
||||
|
||||
<el-divider>设计原则</el-divider>
|
||||
<div v-for="(p, i) in form.principles" :key="'p'+i" style="display:flex;gap:8px;margin-bottom:8px">
|
||||
<el-input v-model="form.principles[i]" />
|
||||
<el-button type="danger" text @click="form.principles.splice(i,1)">删除</el-button>
|
||||
</div>
|
||||
<el-button @click="form.principles.push('')">+ 添加原则</el-button>
|
||||
|
||||
<el-divider>核心活动</el-divider>
|
||||
<div v-for="(a, i) in form.activities" :key="'a'+i" style="display:flex;gap:8px;margin-bottom:8px">
|
||||
<el-input v-model="form.activities[i]" />
|
||||
<el-button type="danger" text @click="form.activities.splice(i,1)">删除</el-button>
|
||||
</div>
|
||||
<el-button @click="form.activities.push('')">+ 添加活动</el-button>
|
||||
|
||||
<el-divider>每日行程</el-divider>
|
||||
<div v-for="(it, i) in form.itinerary" :key="'it'+i" style="display:flex;gap:8px;margin-bottom:8px;align-items:flex-start;">
|
||||
<el-tag type="info" style="min-width:50px;text-align:center;margin-top:6px;">Day{{ i+1 }}</el-tag>
|
||||
<el-input v-model="form.itinerary[i]" type="textarea" :autosize="{minRows:2,maxRows:6}" />
|
||||
<el-button type="danger" text @click="form.itinerary.splice(i,1)" style="margin-top:6px;">删除</el-button>
|
||||
</div>
|
||||
<el-button @click="form.itinerary.push('')">+ 添加一天</el-button>
|
||||
|
||||
<el-divider>常见问答</el-divider>
|
||||
<div v-for="(f, i) in form.faq" :key="'f'+i" style="margin-bottom:12px;padding:12px;background:#fafafa;border-radius:8px;">
|
||||
<div style="display:flex;gap:8px;margin-bottom:6px;align-items:center;">
|
||||
<span style="font-weight:600;min-width:20px;">Q:</span>
|
||||
<el-input v-model="f.question" placeholder="问题" />
|
||||
<el-button type="danger" text @click="form.faq.splice(i,1)">删除</el-button>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:flex-start;">
|
||||
<span style="font-weight:600;min-width:20px;margin-top:6px;">A:</span>
|
||||
<el-input v-model="f.answer" type="textarea" :autosize="{minRows:2,maxRows:8}" placeholder="回答" />
|
||||
</div>
|
||||
</div>
|
||||
<el-button @click="form.faq.push({question:'',answer:''})">+ 添加问答</el-button>
|
||||
|
||||
<el-divider>期数信息</el-divider>
|
||||
<div v-for="(val, key) in form.sessions" :key="key" style="display:flex;gap:8px;margin-bottom:8px;align-items:center;">
|
||||
<el-input :model-value="key" disabled style="width:120px" />
|
||||
<el-input-number :model-value="val" @update:model-value="form.sessions[key] = $event" />
|
||||
<span style="color:#909399;font-size:13px;">期</span>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getSummerCamp, updateSummerCamp } from '../../api/products'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = ref({ name: '', positioning: '', days: 6, nights: 5, sessions: {}, difference_from_v9: '', principles: [], activities: [], itinerary: [], faq: [] })
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getSummerCamp()
|
||||
const data = res.data
|
||||
// faq 字段可能用 q/a 格式,统一为 question/answer
|
||||
if (data.faq) {
|
||||
data.faq = data.faq.map(f => ({
|
||||
question: f.question || f.q || '',
|
||||
answer: f.answer || f.a || '',
|
||||
}))
|
||||
}
|
||||
form.value = { ...form.value, ...data }
|
||||
} finally { loading.value = false }
|
||||
})
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
await updateSummerCamp(form.value)
|
||||
ElMessage.success('保存成功')
|
||||
} catch { ElMessage.error('保存失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header"><h2>冬季营 · 小蒙马冬季亲子营</h2></div>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<!-- 基本配置 -->
|
||||
<el-tab-pane label="基本配置" name="config">
|
||||
<el-card>
|
||||
<el-form :model="config" label-width="100px">
|
||||
<el-form-item label="营地名称"><el-input v-model="config.name" /></el-form-item>
|
||||
<el-form-item label="品牌定位"><el-input v-model="config.positioning" type="textarea" :autosize="{minRows:3,maxRows:10}" /></el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6"><el-form-item label="天数"><el-input-number v-model="config.days" :min="1" /></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="晚数"><el-input-number v-model="config.nights" :min="0" /></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="家庭数"><el-input-number v-model="config.max_families" :min="1" /></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="总期数"><el-input-number v-model="config.total_sessions" :min="1" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8"><el-form-item label="年龄范围"><el-input v-model="config.age_range" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="定金"><el-input-number v-model="config.deposit" :min="0" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="季节"><el-input v-model="config.season" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-form-item label="路线"><el-input v-model="config.route" /></el-form-item>
|
||||
<el-form-item label="为什么呼伦贝尔"><el-input v-model="config.why_hulunbuir" type="textarea" :autosize="{minRows:3,maxRows:10}" /></el-form-item>
|
||||
<el-form-item label="结语"><el-input v-model="config.closing_note" type="textarea" :autosize="{minRows:3,maxRows:10}" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="saveConfig" :loading="savingConfig">保存配置</el-button></el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 酒店 -->
|
||||
<el-tab-pane label="住宿安排" name="hotels">
|
||||
<div v-for="(h, i) in hotels" :key="i" style="margin-bottom:16px;">
|
||||
<el-card>
|
||||
<el-form :model="h" label-width="80px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8"><el-form-item label="酒店名"><el-input v-model="h.name" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="星级"><el-input v-model="h.star" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="住宿天"><el-input v-model="h.nights" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-form-item label="描述"><el-input v-model="h.description" type="textarea" :autosize="{minRows:2,maxRows:6}" /></el-form-item>
|
||||
</el-form>
|
||||
<el-button type="danger" text @click="hotels.splice(i, 1)">删除此酒店</el-button>
|
||||
</el-card>
|
||||
</div>
|
||||
<el-button @click="hotels.push({name:'',star:'',nights:'',description:''})">+ 添加酒店</el-button>
|
||||
<div style="margin-top:16px;"><el-button type="primary" @click="saveHotels" :loading="savingHotels">保存酒店</el-button></div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 行程 -->
|
||||
<el-tab-pane label="7天行程" name="itinerary">
|
||||
<div v-for="(day, i) in itinerary" :key="i" style="margin-bottom:16px;">
|
||||
<el-card>
|
||||
<template #header><strong>D{{ day.day }} · {{ day.title || '(未填写)' }}</strong></template>
|
||||
<el-form :model="day" label-width="80px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="4"><el-form-item label="天"><el-input-number v-model="day.day" :min="1" /></el-form-item></el-col>
|
||||
<el-col :span="10"><el-form-item label="标题"><el-input v-model="day.title" /></el-form-item></el-col>
|
||||
<el-col :span="10"><el-form-item label="住宿"><el-input v-model="day.hotel" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-form-item label="概要"><el-input v-model="day.summary" type="textarea" :autosize="{minRows:1,maxRows:4}" /></el-form-item>
|
||||
<el-form-item label="亮点">
|
||||
<div v-for="(h, j) in day.highlights" :key="j" style="display:flex;gap:8px;margin-bottom:8px;width:100%;">
|
||||
<el-input v-model="day.highlights[j]" />
|
||||
<el-button type="danger" text @click="day.highlights.splice(j, 1)">删除</el-button>
|
||||
</div>
|
||||
<el-button @click="day.highlights.push('')">+ 添加亮点</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
<el-button @click="itinerary.push({day:itinerary.length+1,title:'',summary:'',highlights:[],hotel:''})">+ 添加天</el-button>
|
||||
<div style="margin-top:16px;"><el-button type="primary" @click="saveItinerary" :loading="savingItinerary">保存行程</el-button></div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- FAQ -->
|
||||
<el-tab-pane label="常见问题" name="faq">
|
||||
<el-button type="primary" @click="showFaqDialog()" style="margin-bottom:16px;">新增FAQ</el-button>
|
||||
<el-table :data="faqList" border stripe>
|
||||
<el-table-column prop="question" label="问题" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text size="small" @click="showFaqDialog(row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除?" @confirm="handleDeleteFaq(row.id)">
|
||||
<template #reference><el-button type="danger" text size="small">删除</el-button></template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 优势和服务 -->
|
||||
<el-tab-pane label="优势与服务" name="advantages">
|
||||
<el-card style="margin-bottom:20px;">
|
||||
<template #header><strong>亲子营优势</strong></template>
|
||||
<div v-for="(a, i) in config.camp_advantages" :key="i" style="display:flex;gap:8px;margin-bottom:8px;">
|
||||
<el-input v-model="config.camp_advantages[i]" />
|
||||
<el-button type="danger" text @click="config.camp_advantages.splice(i, 1)">删除</el-button>
|
||||
</div>
|
||||
<el-button @click="config.camp_advantages.push('')">+ 添加优势</el-button>
|
||||
</el-card>
|
||||
<el-card style="margin-bottom:20px;">
|
||||
<template #header><strong>随行服务配置</strong></template>
|
||||
<div v-for="(s, i) in config.service_config" :key="i" style="display:flex;gap:8px;margin-bottom:8px;">
|
||||
<el-input v-model="config.service_config[i]" />
|
||||
<el-button type="danger" text @click="config.service_config.splice(i, 1)">删除</el-button>
|
||||
</div>
|
||||
<el-button @click="config.service_config.push('')">+ 添加</el-button>
|
||||
</el-card>
|
||||
<el-button type="primary" @click="saveConfig" :loading="savingConfig">保存</el-button>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- FAQ对话框 -->
|
||||
<el-dialog v-model="faqDialogVisible" :title="editingFaqId ? '编辑FAQ' : '新增FAQ'" width="600px">
|
||||
<el-form :model="faqForm" label-width="60px">
|
||||
<el-form-item label="问题"><el-input v-model="faqForm.question" /></el-form-item>
|
||||
<el-form-item label="回答"><el-input v-model="faqForm.answer" type="textarea" :autosize="{minRows:4,maxRows:12}" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="faqDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSaveFaq" :loading="savingFaq">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as campApi from '../../api/winterCamp'
|
||||
|
||||
const activeTab = ref('config')
|
||||
const savingConfig = ref(false)
|
||||
const savingHotels = ref(false)
|
||||
const savingItinerary = ref(false)
|
||||
const savingFaq = ref(false)
|
||||
|
||||
const config = ref({ name: '', positioning: '', days: 7, nights: 6, max_families: 8, total_sessions: 3, age_range: '', deposit: 2000, season: '', route: '', why_hulunbuir: '', closing_note: '', photographer: {}, winter_clothing: {}, camp_advantages: [], service_config: [], camp_essentials: [] })
|
||||
const hotels = ref([])
|
||||
const itinerary = ref([])
|
||||
const faqList = ref([])
|
||||
|
||||
// FAQ dialog
|
||||
const faqDialogVisible = ref(false)
|
||||
const editingFaqId = ref(null)
|
||||
const faqForm = ref({ question: '', answer: '' })
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [configRes, hotelsRes, itineraryRes, faqRes] = await Promise.all([
|
||||
campApi.getConfig(), campApi.getHotels(), campApi.getItinerary(), campApi.getFaq(),
|
||||
])
|
||||
config.value = { ...config.value, ...configRes.data, camp_advantages: configRes.data.camp_advantages || [], service_config: configRes.data.service_config || [], camp_essentials: configRes.data.camp_essentials || [] }
|
||||
hotels.value = hotelsRes.data
|
||||
itinerary.value = itineraryRes.data.map(d => ({ ...d, highlights: d.highlights || [] }))
|
||||
faqList.value = faqRes.data
|
||||
} catch (e) {
|
||||
ElMessage.error('加载数据失败')
|
||||
}
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
savingConfig.value = true
|
||||
try { await campApi.updateConfig(config.value); ElMessage.success('配置保存成功') } finally { savingConfig.value = false }
|
||||
}
|
||||
|
||||
const saveHotels = async () => {
|
||||
savingHotels.value = true
|
||||
try { await campApi.updateHotels(hotels.value); ElMessage.success('酒店保存成功') } finally { savingHotels.value = false }
|
||||
}
|
||||
|
||||
const saveItinerary = async () => {
|
||||
savingItinerary.value = true
|
||||
try { await campApi.updateItinerary(itinerary.value); ElMessage.success('行程保存成功') } finally { savingItinerary.value = false }
|
||||
}
|
||||
|
||||
const showFaqDialog = (row) => {
|
||||
if (row) { editingFaqId.value = row.id; faqForm.value = { question: row.question, answer: row.answer } }
|
||||
else { editingFaqId.value = null; faqForm.value = { question: '', answer: '' } }
|
||||
faqDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSaveFaq = async () => {
|
||||
savingFaq.value = true
|
||||
try {
|
||||
if (editingFaqId.value) await campApi.updateFaq(editingFaqId.value, faqForm.value)
|
||||
else await campApi.createFaq(faqForm.value)
|
||||
ElMessage.success('保存成功')
|
||||
faqDialogVisible.value = false
|
||||
loadData()
|
||||
} finally { savingFaq.value = false }
|
||||
}
|
||||
|
||||
const handleDeleteFaq = async (id) => {
|
||||
await campApi.deleteFaq(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header { margin-bottom: 20px; }
|
||||
</style>
|
||||
@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<SeasonalProductList title="冬季产品 · 嗨冰雪" :api="api" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SeasonalProductList from './SeasonalProductList.vue'
|
||||
import * as api from '../../api/winterProducts'
|
||||
</script>
|
||||
@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>资质荣誉管理</h2>
|
||||
<el-button type="primary" @click="showDialog()">新增</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" border stripe v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="title" label="名称" show-overflow-tooltip />
|
||||
<el-table-column prop="issuer" label="颁发机构" width="160" />
|
||||
<el-table-column prop="year" label="年份" width="100" />
|
||||
<el-table-column prop="category" label="分类" width="100" />
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text size="small" @click="showDialog(row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除?" @confirm="handleDelete(row.id)">
|
||||
<template #reference>
|
||||
<el-button type="danger" text size="small">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑资质' : '新增资质'" width="580px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="form.title" placeholder="资质/荣誉名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="颁发机构">
|
||||
<el-input v-model="form.issuer" placeholder="颁发机构" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="年份">
|
||||
<el-input v-model="form.year" placeholder="如:2024" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="form.category" placeholder="选择分类" style="width:100%">
|
||||
<el-option label="资质认证" value="资质认证" />
|
||||
<el-option label="荣誉奖项" value="荣誉奖项" />
|
||||
<el-option label="行业认可" value="行业认可" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="证书图片">
|
||||
<el-input v-model="form.image" placeholder="图片URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :autosize="{ minRows: 2, maxRows: 6 }" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getQualifications, createQualification, updateQualification, deleteQualification } from '../../api/qualifications'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const list = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref(null)
|
||||
|
||||
const defaultForm = () => ({
|
||||
title: '', issuer: '', year: '', image: '', description: '', category: '',
|
||||
})
|
||||
const form = ref(defaultForm())
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getQualifications()
|
||||
list.value = res.data
|
||||
} catch {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showDialog = (row) => {
|
||||
if (row) {
|
||||
editingId.value = row.id
|
||||
form.value = { ...row }
|
||||
} else {
|
||||
editingId.value = null
|
||||
form.value = defaultForm()
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.value.title) { ElMessage.warning('请填写名称'); return }
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateQualification(editingId.value, form.value)
|
||||
} else {
|
||||
await createQualification(form.value)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch (err) {
|
||||
ElMessage.error(err.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await deleteQualification(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
209
src/views/reviews/ReviewList.vue
普通文件
209
src/views/reviews/ReviewList.vue
普通文件
@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>客户评价管理</h2>
|
||||
<el-button type="primary" @click="showDialog()">新增评价</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 评价概要 -->
|
||||
<el-card style="margin-bottom:16px;">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<span>评价概要(显示在官网评价页顶部)</span>
|
||||
<el-button type="primary" @click="saveSummary" :loading="savingSummary">保存概要</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6">
|
||||
<div style="font-size:13px;color:var(--text-tertiary);margin-bottom:6px;">已服务家庭数</div>
|
||||
<el-input-number v-model="summary.total_count" :min="0" :step="100" style="width:100%;" />
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<div style="font-size:13px;color:var(--text-tertiary);margin-bottom:6px;">好评率</div>
|
||||
<el-input v-model="summary.approval_rate" placeholder="98%" />
|
||||
</el-col>
|
||||
<el-col :span="14">
|
||||
<div style="font-size:13px;color:var(--text-tertiary);margin-bottom:6px;">评价关键词</div>
|
||||
<el-select v-model="summary.keywords" multiple filterable allow-create placeholder="输入关键词后回车添加" style="width:100%;" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="keyword" placeholder="搜索昵称/内容" clearable style="width: 250px" @clear="loadData" @keyup.enter="loadData" />
|
||||
<el-button @click="loadData">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" border stripe v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="nickname" label="昵称" width="120" />
|
||||
<el-table-column prop="travel_date" label="出行时间" width="110" />
|
||||
<el-table-column prop="product_version" label="产品版本" width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="content" label="评价内容" show-overflow-tooltip />
|
||||
<el-table-column label="显示" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.is_visible" @change="handleToggleVisible(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text size="small" @click="showDialog(row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除?" @confirm="handleDelete(row.id)">
|
||||
<template #reference>
|
||||
<el-button type="danger" text size="small">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
style="margin-top: 16px; justify-content: flex-end;"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
|
||||
<!-- Edit Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑评价' : '新增评价'" width="700px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-form-item label="昵称">
|
||||
<el-input v-model="form.nickname" />
|
||||
</el-form-item>
|
||||
<el-form-item label="出行时间">
|
||||
<el-input v-model="form.travel_date" placeholder="如: 2024年6月" />
|
||||
</el-form-item>
|
||||
<el-form-item label="产品版本">
|
||||
<el-input v-model="form.product_version" placeholder="如: 额吉的故乡V9·6天5晚" />
|
||||
</el-form-item>
|
||||
<el-form-item label="评价截图">
|
||||
<ImageUpload v-model="form.screenshot" subdir="reviews" width="120px" height="160px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="评价内容">
|
||||
<el-input v-model="form.content" type="textarea" :autosize="{minRows:5,maxRows:15}" />
|
||||
</el-form-item>
|
||||
<el-form-item label="出行场景">
|
||||
<el-select v-model="form.scenes" multiple filterable allow-create placeholder="选择或输入标签" style="width: 100%">
|
||||
<el-option v-for="s in sceneOptions" :key="s" :label="s" :value="s" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关注点">
|
||||
<el-select v-model="form.concerns" multiple filterable allow-create placeholder="选择或输入标签" style="width: 100%">
|
||||
<el-option v-for="c in concernOptions" :key="c" :label="c" :value="c" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="显示">
|
||||
<el-switch v-model="form.is_visible" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getReviews, createReview, updateReview, deleteReview, getReviewSummary, updateReviewSummary } from '../../api/reviews'
|
||||
import ImageUpload from '../../components/common/ImageUpload.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const savingSummary = ref(false)
|
||||
const summary = ref({ total_count: 0, approval_rate: '', keywords: [] })
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const keyword = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref(null)
|
||||
|
||||
const sceneOptions = ['带小孩', '家庭游', '带老人', '情侣游', '闺蜜游']
|
||||
const concernOptions = ['领队服务', '行程安排', '拍照超美', '司机服务', '亲子活动', '住宿品质', '餐饮体验']
|
||||
|
||||
const defaultForm = () => ({
|
||||
nickname: '', travel_date: '', product_version: '', screenshot: '',
|
||||
content: '', scenes: [], concerns: [], is_visible: true,
|
||||
})
|
||||
const form = ref(defaultForm())
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getReviews({ page: page.value, page_size: pageSize, keyword: keyword.value })
|
||||
list.value = res.data.items
|
||||
total.value = res.data.total
|
||||
} catch (err) {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showDialog = (row) => {
|
||||
if (row) {
|
||||
editingId.value = row.id
|
||||
form.value = { ...row }
|
||||
} else {
|
||||
editingId.value = null
|
||||
form.value = defaultForm()
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateReview(editingId.value, form.value)
|
||||
} else {
|
||||
await createReview(form.value)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch (err) {
|
||||
ElMessage.error(err.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleVisible = async (row) => {
|
||||
try {
|
||||
await updateReview(row.id, row)
|
||||
ElMessage.success('更新成功')
|
||||
} catch {
|
||||
ElMessage.error('更新失败')
|
||||
row.is_visible = !row.is_visible
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await deleteReview(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const saveSummary = async () => {
|
||||
savingSummary.value = true
|
||||
try { await updateReviewSummary(summary.value); ElMessage.success('概要已保存') }
|
||||
catch { ElMessage.error('保存失败') }
|
||||
finally { savingSummary.value = false }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadData()
|
||||
try { const res = await getReviewSummary(); summary.value = res.data } catch {}
|
||||
})
|
||||
</script>
|
||||
131
src/views/selector/SelectorEdit.vue
普通文件
131
src/views/selector/SelectorEdit.vue
普通文件
@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>选版工具配置</h2>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loading">
|
||||
<!-- 问题列表 -->
|
||||
<el-divider content-position="left">问题列表</el-divider>
|
||||
<div
|
||||
v-for="(q, qi) in form.questions"
|
||||
:key="qi"
|
||||
style="border:1px solid var(--color-border);border-radius:8px;padding:16px;margin-bottom:12px"
|
||||
>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||
<strong>问题 {{ qi + 1 }}</strong>
|
||||
<el-button type="danger" text size="small" @click="form.questions.splice(qi,1)">删除</el-button>
|
||||
</div>
|
||||
<el-form label-width="70px">
|
||||
<el-form-item label="问题ID"><el-input v-model="q.id" placeholder="英文唯一标识,如 travel_style" style="width:240px" /></el-form-item>
|
||||
<el-form-item label="问题文本"><el-input v-model="q.text" /></el-form-item>
|
||||
<el-form-item label="选项">
|
||||
<div v-for="(opt, oi) in (q.options || [])" :key="oi" style="display:flex;gap:8px;margin-bottom:6px">
|
||||
<el-input v-model="opt.value" placeholder="选项值(英文)" style="width:160px;flex-shrink:0" />
|
||||
<el-input v-model="opt.label" placeholder="选项文案" />
|
||||
<el-button type="danger" text @click="q.options.splice(oi,1)">删</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="q.options = [...(q.options||[]), {value:'',label:''}]">+ 添加选项</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-button @click="form.questions.push({id:'',text:'',options:[]})">+ 添加问题</el-button>
|
||||
|
||||
<!-- 推荐规则 -->
|
||||
<el-divider content-position="left" style="margin-top:24px">推荐规则</el-divider>
|
||||
<div
|
||||
v-for="(rule, ri) in form.rules"
|
||||
:key="ri"
|
||||
style="border:1px solid var(--color-border);border-radius:8px;padding:16px;margin-bottom:12px"
|
||||
>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||
<strong>规则 {{ ri + 1 }}</strong>
|
||||
<el-button type="danger" text size="small" @click="form.rules.splice(ri,1)">删除</el-button>
|
||||
</div>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="推荐版本"><el-input v-model="rule.recommendation" placeholder="推荐的产品版本名称" /></el-form-item>
|
||||
<el-form-item label="版本标题"><el-input v-model="rule.title" /></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="rule.description" type="textarea" :autosize="{minRows:2}" /></el-form-item>
|
||||
<el-form-item label="触发条件">
|
||||
<div style="font-size:12px;color:#909399;margin-bottom:8px">每行一条条件,格式:问题ID=选项值</div>
|
||||
<el-input
|
||||
v-model="ruleConditionsText[ri]"
|
||||
type="textarea"
|
||||
:autosize="{minRows:3}"
|
||||
placeholder="travel_style=family duration=7days"
|
||||
@blur="parseRuleConditions(ri)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-button @click="addRule">+ 添加规则</el-button>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getSelectorConfig, updateSelectorConfig } from '../../api/selector'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = ref({ questions: [], rules: [] })
|
||||
const ruleConditionsText = ref([])
|
||||
|
||||
const conditionsToText = (conditions) => {
|
||||
if (!conditions || typeof conditions !== 'object') return ''
|
||||
return Object.entries(conditions).map(([k, v]) => `${k}=${v}`).join('\n')
|
||||
}
|
||||
|
||||
const textToConditions = (text) => {
|
||||
const result = {}
|
||||
text.split('\n').forEach(line => {
|
||||
const [k, v] = line.split('=')
|
||||
if (k && v) result[k.trim()] = v.trim()
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
const parseRuleConditions = (ri) => {
|
||||
form.value.rules[ri].conditions = textToConditions(ruleConditionsText.value[ri] || '')
|
||||
}
|
||||
|
||||
const addRule = () => {
|
||||
form.value.rules.push({ recommendation: '', title: '', description: '', conditions: {} })
|
||||
ruleConditionsText.value.push('')
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getSelectorConfig()
|
||||
form.value = {
|
||||
questions: res.data.questions || [],
|
||||
rules: res.data.rules || [],
|
||||
}
|
||||
ruleConditionsText.value = form.value.rules.map(r => conditionsToText(r.conditions || {}))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
// Parse all rule conditions before saving
|
||||
form.value.rules.forEach((rule, i) => {
|
||||
rule.conditions = textToConditions(ruleConditionsText.value[i] || '')
|
||||
})
|
||||
saving.value = true
|
||||
try {
|
||||
await updateSelectorConfig(form.value)
|
||||
ElMessage.success('保存成功')
|
||||
} catch {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
91
src/views/seo/SeoList.vue
普通文件
91
src/views/seo/SeoList.vue
普通文件
@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header"><h2>SEO 配置</h2></div>
|
||||
|
||||
<el-table :data="pages" border stripe v-loading="loading">
|
||||
<el-table-column prop="page_key" label="页面" width="120" />
|
||||
<el-table-column prop="title" label="标题" show-overflow-tooltip />
|
||||
<el-table-column prop="h1" label="H1" width="200" show-overflow-tooltip />
|
||||
<el-table-column label="TL;DR" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.tldr" type="success" size="small">已配置</el-tag>
|
||||
<el-tag v-else type="info" size="small">未配置</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text size="small" @click="showEdit(row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="`编辑 SEO: ${editingKey}`" width="700px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-form-item label="Title"><el-input v-model="form.title" /></el-form-item>
|
||||
<el-form-item label="Description"><el-input v-model="form.description" type="textarea" :autosize="{minRows:4,maxRows:12}" /></el-form-item>
|
||||
<el-form-item label="H1"><el-input v-model="form.h1" /></el-form-item>
|
||||
<el-form-item label="Keywords">
|
||||
<el-input v-model="form.keywords" type="textarea" :autosize="{minRows:3,maxRows:10}" placeholder="用英文逗号分隔,如:呼伦贝尔亲子游,家庭定制游,草原旅行" />
|
||||
<div style="font-size:12px;color:#909399;margin-top:4px;">关键词帮助 AI 搜索引擎理解页面内容,建议 5-8 个核心词</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="OG Image">
|
||||
<ImageUpload v-model="form.og_image" subdir="og" width="200px" height="105px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="TL;DR摘要">
|
||||
<el-input v-model="form.tldr" type="textarea" :autosize="{minRows:3,maxRows:8}" placeholder="页面核心信息的极简摘要,供AI搜索引擎和TL;DR摘要块使用" />
|
||||
<div style="font-size:12px;color:#909399;margin-top:4px">一句话说清楚这个页面是关于什么的,建议100字以内</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getSeoPages, updateSeoPage } from '../../api/seo'
|
||||
import ImageUpload from '../../components/common/ImageUpload.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const pages = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingKey = ref('')
|
||||
const form = ref({ title: '', description: '', h1: '', keywords: '', og_image: '', tldr: '' })
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getSeoPages()
|
||||
pages.value = res.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showEdit = (row) => {
|
||||
editingKey.value = row.page_key
|
||||
form.value = { title: row.title, description: row.description, h1: row.h1, keywords: row.keywords || '', og_image: row.og_image, tldr: row.tldr || '' }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
await updateSeoPage(editingKey.value, form.value)
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>网站图片管理</h2>
|
||||
</div>
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:20px;">
|
||||
修改图片后,请到「导出与构建」页面导出 images 模块,前端网站才会更新。建议上传前按「建议尺寸」裁剪图片。
|
||||
</el-alert>
|
||||
|
||||
<div v-loading="loading">
|
||||
<el-card v-for="group in groupedImages" :key="group.name" :header="groupLabels[group.name] || group.name" style="margin-bottom:20px;">
|
||||
<div class="image-grid">
|
||||
<div v-for="img in group.items" :key="img.id" class="image-item">
|
||||
<div class="image-card">
|
||||
<div class="image-preview-box">
|
||||
<el-image v-if="img.image_path" :src="img.image_path" fit="contain" :preview-src-list="[img.image_path]" class="preview-img" />
|
||||
<div v-else class="no-image">暂无图片</div>
|
||||
</div>
|
||||
<div class="image-info">
|
||||
<div class="image-label">{{ img.label || img.image_key }}</div>
|
||||
<div class="image-hint" v-if="img.size_hint">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
建议尺寸:{{ img.size_hint }}
|
||||
</div>
|
||||
<div class="image-desc" v-if="img.description">{{ img.description }}</div>
|
||||
<div class="image-path">{{ img.image_path }}</div>
|
||||
</div>
|
||||
<div class="image-actions">
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:before-upload="beforeUpload"
|
||||
:http-request="(opt) => handleUpload(opt, img)"
|
||||
accept=".jpg,.jpeg,.png,.webp"
|
||||
>
|
||||
<el-button type="primary" size="small">替换图片</el-button>
|
||||
</el-upload>
|
||||
<el-button size="small" @click="showEditDialog(img)">编辑路径</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- Edit Dialog -->
|
||||
<el-dialog v-model="dialogVisible" title="编辑图片信息" width="500px">
|
||||
<el-form :model="editForm" label-width="90px">
|
||||
<el-form-item label="分组">
|
||||
<el-input :model-value="editForm.group_name" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="键名">
|
||||
<el-input :model-value="editForm.image_key" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="图片路径">
|
||||
<el-input v-model="editForm.image_path" />
|
||||
</el-form-item>
|
||||
<el-form-item label="显示名称">
|
||||
<el-input v-model="editForm.label" />
|
||||
</el-form-item>
|
||||
<el-form-item label="建议尺寸">
|
||||
<el-input v-model="editForm.size_hint" />
|
||||
</el-form-item>
|
||||
<el-form-item label="说明">
|
||||
<el-input v-model="editForm.description" type="textarea" :autosize="{minRows:3,maxRows:10}" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getSiteImages, updateSiteImage } from '../../api/site-images'
|
||||
import { uploadImage } from '../../api/upload'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const images = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const editForm = ref({})
|
||||
|
||||
const groupLabels = {
|
||||
logo: 'Logo 品牌标识',
|
||||
hero: '首页大图',
|
||||
brandStory: '品牌故事',
|
||||
mascot: 'IP 吉祥物',
|
||||
team: '团队照片',
|
||||
xiaohongshu: '小红书',
|
||||
summerCamp: '夏令营照片',
|
||||
}
|
||||
|
||||
const groupOrder = ['logo', 'hero', 'brandStory', 'team', 'summerCamp', 'mascot', 'xiaohongshu']
|
||||
|
||||
const groupedImages = computed(() => {
|
||||
const map = {}
|
||||
for (const img of images.value) {
|
||||
if (!map[img.group_name]) map[img.group_name] = { name: img.group_name, items: [] }
|
||||
map[img.group_name].items.push(img)
|
||||
}
|
||||
// Sort by predefined order
|
||||
return groupOrder.filter(g => map[g]).map(g => map[g]).concat(
|
||||
Object.keys(map).filter(g => !groupOrder.includes(g)).map(g => map[g])
|
||||
)
|
||||
})
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getSiteImages()
|
||||
images.value = res.data
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
const beforeUpload = (file) => {
|
||||
const isImage = ['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
|
||||
const isLt5M = file.size / 1024 / 1024 < 5
|
||||
if (!isImage) { ElMessage.error('只能上传 JPG/PNG/WebP 格式'); return false }
|
||||
if (!isLt5M) { ElMessage.error('图片不能超过 5MB'); return false }
|
||||
return true
|
||||
}
|
||||
|
||||
const handleUpload = async ({ file }, img) => {
|
||||
try {
|
||||
const subdir = img.group_name
|
||||
const res = await uploadImage(file, subdir)
|
||||
img.image_path = res.data.url
|
||||
await updateSiteImage(img.id, img)
|
||||
ElMessage.success('替换成功')
|
||||
loadData()
|
||||
} catch {
|
||||
ElMessage.error('上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
const showEditDialog = (img) => {
|
||||
editForm.value = { ...img }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
await updateSiteImage(editForm.value.id, editForm.value)
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch { ElMessage.error('保存失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.image-card {
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.image-card:hover {
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
.image-preview-box {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.preview-img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
.no-image {
|
||||
color: #c0c4cc;
|
||||
font-size: 14px;
|
||||
}
|
||||
.image-info {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.image-label {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.image-hint {
|
||||
font-size: 12px;
|
||||
color: #409EFF;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.image-desc {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.image-path {
|
||||
font-size: 11px;
|
||||
color: #c0c4cc;
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
.image-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
182
src/views/stories/StoryList.vue
普通文件
182
src/views/stories/StoryList.vue
普通文件
@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>客户故事管理</h2>
|
||||
<el-button type="primary" @click="showDialog()">新增故事</el-button>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar" style="margin-bottom:16px;display:flex;gap:8px;">
|
||||
<el-input v-model="keyword" placeholder="搜索标题/客户姓名" clearable style="width:260px" @clear="loadData" @keyup.enter="loadData" />
|
||||
<el-button @click="loadData">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" border stripe v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="title" label="标题" show-overflow-tooltip />
|
||||
<el-table-column prop="customer_name" label="客户" width="100" />
|
||||
<el-table-column prop="travel_date" label="出行时间" width="120" />
|
||||
<el-table-column prop="product_name" label="产品" width="160" show-overflow-tooltip />
|
||||
<el-table-column label="显示" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.is_visible" @change="handleToggleVisible(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text size="small" @click="showDialog(row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除该故事?" @confirm="handleDelete(row.id)">
|
||||
<template #reference>
|
||||
<el-button type="danger" text size="small">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
style="margin-top:16px;justify-content:flex-end;"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑故事' : '新增故事'" width="760px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="form.title" placeholder="故事标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="封面图">
|
||||
<el-input v-model="form.cover_image" placeholder="图片URL" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="客户姓名">
|
||||
<el-input v-model="form.customer_name" placeholder="如:王女士" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="出行时间">
|
||||
<el-input v-model="form.travel_date" placeholder="如:2024年7月" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="头像">
|
||||
<el-input v-model="form.avatar" placeholder="客户头像URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="关联产品">
|
||||
<el-input v-model="form.product_name" placeholder="如:额吉的故乡V9·6天5晚" />
|
||||
</el-form-item>
|
||||
<el-form-item label="出行场景">
|
||||
<el-select v-model="form.scenes" multiple filterable allow-create placeholder="选择或输入标签" style="width:100%">
|
||||
<el-option v-for="s in sceneOptions" :key="s" :label="s" :value="s" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="摘要">
|
||||
<el-input v-model="form.summary" type="textarea" :autosize="{ minRows: 2, maxRows: 5 }" />
|
||||
</el-form-item>
|
||||
<el-form-item label="正文">
|
||||
<el-input v-model="form.content" type="textarea" :autosize="{ minRows: 8, maxRows: 20 }" />
|
||||
</el-form-item>
|
||||
<el-form-item label="显示">
|
||||
<el-switch v-model="form.is_visible" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getStories, createStory, updateStory, deleteStory } from '../../api/stories'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const keyword = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref(null)
|
||||
|
||||
const sceneOptions = ['带小孩', '家庭游', '带老人', '情侣游', '闺蜜游', '亲子研学']
|
||||
|
||||
const defaultForm = () => ({
|
||||
title: '', cover_image: '', summary: '', content: '',
|
||||
customer_name: '', avatar: '', travel_date: '', product_name: '',
|
||||
scenes: [], is_visible: true,
|
||||
})
|
||||
const form = ref(defaultForm())
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getStories({ page: page.value, page_size: pageSize, keyword: keyword.value })
|
||||
list.value = res.data.items
|
||||
total.value = res.data.total
|
||||
} catch {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showDialog = (row) => {
|
||||
if (row) {
|
||||
editingId.value = row.id
|
||||
form.value = { ...row, scenes: row.scenes || [] }
|
||||
} else {
|
||||
editingId.value = null
|
||||
form.value = defaultForm()
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.value.title) { ElMessage.warning('请填写标题'); return }
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateStory(editingId.value, form.value)
|
||||
} else {
|
||||
await createStory(form.value)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch (err) {
|
||||
ElMessage.error(err.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleVisible = async (row) => {
|
||||
try {
|
||||
await updateStory(row.id, row)
|
||||
ElMessage.success('更新成功')
|
||||
} catch {
|
||||
ElMessage.error('更新失败')
|
||||
row.is_visible = !row.is_visible
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await deleteStory(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
188
src/views/users/UserList.vue
普通文件
188
src/views/users/UserList.vue
普通文件
@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>用户管理</h2>
|
||||
<el-button type="primary" @click="showCreateDialog">新增用户</el-button>
|
||||
</div>
|
||||
|
||||
<el-card style="margin-bottom:20px;">
|
||||
<h4 style="margin-bottom:12px;">修改我的密码</h4>
|
||||
<div style="display:flex;gap:12px;align-items:flex-end;">
|
||||
<el-input v-model="myPwd.old_password" type="password" placeholder="当前密码" show-password style="width:200px" />
|
||||
<el-input v-model="myPwd.new_password" type="password" placeholder="新密码" show-password style="width:200px" />
|
||||
<el-button type="warning" @click="handleChangeMyPwd" :loading="saving">修改密码</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-table :data="users" border stripe v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="username" label="用户名" width="120" />
|
||||
<el-table-column prop="display_name" label="显示名称" width="150" />
|
||||
<el-table-column label="角色" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.role === 'admin' ? 'danger' : 'info'">{{ row.role === 'admin' ? '管理员' : '编辑' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">{{ row.is_active ? '启用' : '禁用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text size="small" @click="showEditDialog(row)">编辑</el-button>
|
||||
<el-button type="warning" text size="small" @click="showResetPwdDialog(row)">重置密码</el-button>
|
||||
<el-button :type="row.is_active ? 'info' : 'success'" text size="small" @click="toggleActive(row)">
|
||||
{{ row.is_active ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
<el-popconfirm title="确认删除该用户?" @confirm="handleDelete(row.id)">
|
||||
<template #reference>
|
||||
<el-button type="danger" text size="small">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 新增/编辑用户 -->
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑用户' : '新增用户'" width="450px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" :disabled="!!editingId" placeholder="登录用的用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!editingId" label="密码">
|
||||
<el-input v-model="form.password" type="password" show-password placeholder="初始密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="显示名称">
|
||||
<el-input v-model="form.display_name" placeholder="运营同学看到的名字" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-radio-group v-model="form.role">
|
||||
<el-radio value="admin">管理员(可管理用户和导出)</el-radio>
|
||||
<el-radio value="editor">编辑(只能编辑内容)</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 重置密码 -->
|
||||
<el-dialog v-model="resetPwdVisible" title="重置密码" width="400px">
|
||||
<p style="margin-bottom:12px;color:#606266;">为 <strong>{{ resetTarget }}</strong> 设置新密码:</p>
|
||||
<el-input v-model="newPassword" type="password" show-password placeholder="输入新密码" />
|
||||
<template #footer>
|
||||
<el-button @click="resetPwdVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleResetPwd" :loading="saving">确认重置</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getUsers, createUser, updateUser, resetPassword, deleteUser, changeMyPassword } from '../../api/users'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const users = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref(null)
|
||||
const form = ref({ username: '', password: '', display_name: '', role: 'editor' })
|
||||
|
||||
const resetPwdVisible = ref(false)
|
||||
const resetTargetId = ref(null)
|
||||
const resetTarget = ref('')
|
||||
const newPassword = ref('')
|
||||
|
||||
const myPwd = ref({ old_password: '', new_password: '' })
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getUsers()
|
||||
users.value = res.data
|
||||
} catch (err) {
|
||||
if (err.response?.status === 403) ElMessage.warning('仅管理员可查看用户列表')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
const showCreateDialog = () => {
|
||||
editingId.value = null
|
||||
form.value = { username: '', password: '', display_name: '', role: 'editor' }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const showEditDialog = (row) => {
|
||||
editingId.value = row.id
|
||||
form.value = { username: row.username, display_name: row.display_name || '', role: row.role }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateUser(editingId.value, { display_name: form.value.display_name, role: form.value.role })
|
||||
} else {
|
||||
if (!form.value.username || !form.value.password) { ElMessage.warning('请填写用户名和密码'); saving.value = false; return }
|
||||
await createUser(form.value)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch (err) {
|
||||
ElMessage.error(err.response?.data?.detail || '保存失败')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
const toggleActive = async (row) => {
|
||||
try {
|
||||
await updateUser(row.id, { is_active: !row.is_active })
|
||||
ElMessage.success(row.is_active ? '已禁用' : '已启用')
|
||||
loadData()
|
||||
} catch { ElMessage.error('操作失败') }
|
||||
}
|
||||
|
||||
const showResetPwdDialog = (row) => {
|
||||
resetTargetId.value = row.id
|
||||
resetTarget.value = row.display_name || row.username
|
||||
newPassword.value = ''
|
||||
resetPwdVisible.value = true
|
||||
}
|
||||
|
||||
const handleResetPwd = async () => {
|
||||
if (!newPassword.value) { ElMessage.warning('请输入新密码'); return }
|
||||
saving.value = true
|
||||
try {
|
||||
await resetPassword(resetTargetId.value, { new_password: newPassword.value })
|
||||
ElMessage.success('密码已重置')
|
||||
resetPwdVisible.value = false
|
||||
} catch { ElMessage.error('重置失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await deleteUser(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch (err) { ElMessage.error(err.response?.data?.detail || '删除失败') }
|
||||
}
|
||||
|
||||
const handleChangeMyPwd = async () => {
|
||||
if (!myPwd.value.old_password || !myPwd.value.new_password) { ElMessage.warning('请填写当前密码和新密码'); return }
|
||||
saving.value = true
|
||||
try {
|
||||
await changeMyPassword(myPwd.value)
|
||||
ElMessage.success('密码修改成功')
|
||||
myPwd.value = { old_password: '', new_password: '' }
|
||||
} catch (err) { ElMessage.error(err.response?.data?.detail || '修改失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header"><h2>版本历史管理</h2></div>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<!-- 基本统计 -->
|
||||
<el-tab-pane label="版本统计" name="config">
|
||||
<el-card>
|
||||
<el-form :model="config" label-width="100px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8"><el-form-item label="迭代次数"><el-input-number v-model="config.stats_iterations" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="年数"><el-input-number v-model="config.stats_years" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="服务人次"><el-input v-model="config.stats_guests" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-form-item label="引用文字"><el-input v-model="config.quote_text" type="textarea" :autosize="{minRows:3,maxRows:10}" /></el-form-item>
|
||||
<el-form-item label="引用作者"><el-input v-model="config.quote_author" /></el-form-item>
|
||||
</el-form>
|
||||
<el-button type="primary" @click="save('config')" :loading="saving">保存</el-button>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- V9升级亮点 -->
|
||||
<el-tab-pane label="V9升级亮点" name="upgrades">
|
||||
<el-card>
|
||||
<div v-for="(u, i) in upgrades" :key="i" style="margin-bottom:16px;padding:12px;background:#fafafa;border-radius:8px;">
|
||||
<div style="display:flex;gap:8px;margin-bottom:8px;align-items:center;">
|
||||
<el-input v-model="u.tag" placeholder="标签(如:营地)" style="width:100px" />
|
||||
<el-input v-model="u.name" placeholder="升级项名称" />
|
||||
<el-button type="danger" text @click="upgrades.splice(i,1)">删除</el-button>
|
||||
</div>
|
||||
<el-input v-model="u.description" placeholder="升级说明" type="textarea" :autosize="{minRows:3,maxRows:10}" style="margin-bottom:6px" />
|
||||
<el-input v-model="u.reason" placeholder="升级原因(客户反馈→改进)" />
|
||||
</div>
|
||||
<el-button @click="upgrades.push({tag:'',name:'',description:'',reason:''})">+ 添加升级项</el-button>
|
||||
<el-button type="primary" @click="save('upgrades')" :loading="saving" style="margin-left:12px">保存</el-button>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 核心亮点 -->
|
||||
<el-tab-pane label="核心亮点" name="highlights">
|
||||
<el-card>
|
||||
<div v-for="(h, i) in highlights" :key="i" style="display:flex;gap:8px;margin-bottom:8px;align-items:center;">
|
||||
<el-select v-model="h.label" placeholder="标签" style="width:100px">
|
||||
<el-option v-for="l in ['自营','升级','新增','服务']" :key="l" :label="l" :value="l" />
|
||||
</el-select>
|
||||
<el-input v-model="h.text" placeholder="亮点说明" />
|
||||
<el-button type="danger" text @click="highlights.splice(i,1)">删除</el-button>
|
||||
</div>
|
||||
<el-button @click="highlights.push({label:'',text:''})">+ 添加亮点</el-button>
|
||||
<el-button type="primary" @click="save('highlights')" :loading="saving" style="margin-left:12px">保存</el-button>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- V8 vs V9 对比 -->
|
||||
<el-tab-pane label="V8 vs V9 对比" name="compare">
|
||||
<el-card>
|
||||
<div style="display:flex;gap:16px;margin-bottom:16px;">
|
||||
<el-input v-model="compare.headers[0]" placeholder="左列标题(如 V8)" style="width:300px" />
|
||||
<el-input v-model="compare.headers[1]" placeholder="右列标题(如 V9)" style="width:300px" />
|
||||
</div>
|
||||
<el-divider>对比项</el-divider>
|
||||
<div v-for="(row, i) in compare.rows" :key="i" style="display:flex;gap:8px;margin-bottom:8px;align-items:center;">
|
||||
<el-input v-model="compare.rows[i][0]" placeholder="V8 旧内容" />
|
||||
<el-icon><Right /></el-icon>
|
||||
<el-input v-model="compare.rows[i][1]" placeholder="V9 新内容" />
|
||||
<el-button type="danger" text @click="compare.rows.splice(i,1)">删除</el-button>
|
||||
</div>
|
||||
<el-button @click="compare.rows.push(['',''])">+ 添加对比项</el-button>
|
||||
<el-button type="primary" @click="save('compare')" :loading="saving" style="margin-left:12px">保存</el-button>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 版本时间线 -->
|
||||
<el-tab-pane label="版本时间线" name="timeline">
|
||||
<el-card>
|
||||
<el-button type="primary" @click="showTimelineDialog()" style="margin-bottom:16px">+ 新增版本记录</el-button>
|
||||
<el-timeline>
|
||||
<el-timeline-item v-for="item in timeline" :key="item.id" :timestamp="item.date" placement="top">
|
||||
<el-card shadow="hover">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h4 style="margin:0;">{{ item.version }} - {{ item.title }}</h4>
|
||||
<div>
|
||||
<el-button type="primary" text size="small" @click="showTimelineDialog(item)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除?" @confirm="deleteTimelineItem(item.id)">
|
||||
<template #reference>
|
||||
<el-button type="danger" text size="small">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="ch in item.changes" :key="ch.text" style="margin:4px 0;">
|
||||
<el-tag :type="ch.type==='add'?'success':ch.type==='remove'?'danger':'warning'" size="small">{{ {add:'新增',update:'优化',remove:'移除'}[ch.type] || ch.type }}</el-tag>
|
||||
<span style="margin-left:8px;">{{ ch.text }}</span>
|
||||
</div>
|
||||
<p v-if="item.reason" style="margin-top:8px;color:#909399;font-size:13px;">{{ item.reason }}</p>
|
||||
</el-card>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 迭代理念 -->
|
||||
<el-tab-pane label="迭代理念" name="philosophy">
|
||||
<el-card>
|
||||
<div v-for="(p, i) in philosophy" :key="i" style="display:flex;gap:8px;margin-bottom:10px;align-items:flex-start;">
|
||||
<el-input v-model="p.label" placeholder="标签" style="width:80px" />
|
||||
<el-input v-model="p.text" placeholder="理念说明" type="textarea" :autosize="{minRows:3,maxRows:10}" />
|
||||
<el-button type="danger" text @click="philosophy.splice(i,1)">删除</el-button>
|
||||
</div>
|
||||
<el-button @click="philosophy.push({label:'',text:''})">+ 添加理念</el-button>
|
||||
<el-button type="primary" @click="save('philosophy')" :loading="saving" style="margin-left:12px">保存</el-button>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- Timeline Edit Dialog -->
|
||||
<el-dialog v-model="tlDialogVisible" :title="editingTlId ? '编辑版本记录' : '新增版本记录'" width="700px">
|
||||
<el-form :model="tlForm" label-width="80px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8"><el-form-item label="版本号"><el-input v-model="tlForm.version" placeholder="如 V9" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="日期"><el-input v-model="tlForm.date" placeholder="2026.03.01" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-form-item label="标题"><el-input v-model="tlForm.title" /></el-form-item>
|
||||
<el-form-item label="升级原因"><el-input v-model="tlForm.reason" type="textarea" :autosize="{minRows:4,maxRows:12}" /></el-form-item>
|
||||
<el-form-item label="变更项">
|
||||
<div v-for="(ch, i) in tlForm.changes" :key="i" style="display:flex;gap:8px;margin-bottom:8px;width:100%;">
|
||||
<el-select v-model="ch.type" style="width:100px">
|
||||
<el-option label="新增" value="add" />
|
||||
<el-option label="更新" value="update" />
|
||||
<el-option label="移除" value="remove" />
|
||||
</el-select>
|
||||
<el-input v-model="ch.text" placeholder="变更说明" />
|
||||
<el-button type="danger" text @click="tlForm.changes.splice(i,1)">删</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="tlForm.changes.push({type:'add',text:''})">+ 添加变更</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="tlDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveTimeline" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as versionsApi from '../../api/versions'
|
||||
|
||||
const activeTab = ref('config')
|
||||
const saving = ref(false)
|
||||
|
||||
const config = ref({ stats_iterations: 0, stats_years: 0, stats_guests: '', quote_text: '', quote_author: '' })
|
||||
const upgrades = ref([])
|
||||
const highlights = ref([])
|
||||
const compare = ref({ headers: ['', ''], rows: [] })
|
||||
const timeline = ref([])
|
||||
const philosophy = ref([])
|
||||
|
||||
// Timeline dialog
|
||||
const tlDialogVisible = ref(false)
|
||||
const editingTlId = ref(null)
|
||||
const tlForm = ref({ version: '', date: '', title: '', reason: '', changes: [] })
|
||||
|
||||
onMounted(async () => {
|
||||
const results = await Promise.allSettled([
|
||||
versionsApi.getVersionConfig(), versionsApi.getUpgrades(), versionsApi.getHighlights(),
|
||||
versionsApi.getCompare(), versionsApi.getTimeline(), versionsApi.getPhilosophy(),
|
||||
])
|
||||
if (results[0].status === 'fulfilled') config.value = results[0].value.data
|
||||
if (results[1].status === 'fulfilled') upgrades.value = results[1].value.data
|
||||
if (results[2].status === 'fulfilled') highlights.value = results[2].value.data
|
||||
if (results[3].status === 'fulfilled') {
|
||||
const d = results[3].value.data
|
||||
compare.value = { headers: d.headers?.length ? d.headers : ['', ''], rows: d.rows || [] }
|
||||
}
|
||||
if (results[4].status === 'fulfilled') timeline.value = results[4].value.data
|
||||
if (results[5].status === 'fulfilled') philosophy.value = results[5].value.data
|
||||
})
|
||||
|
||||
const saveMap = {
|
||||
config: () => versionsApi.updateVersionConfig(config.value),
|
||||
upgrades: () => versionsApi.updateUpgrades(upgrades.value),
|
||||
highlights: () => versionsApi.updateHighlights(highlights.value),
|
||||
compare: () => versionsApi.updateCompare(compare.value),
|
||||
philosophy: () => versionsApi.updatePhilosophy(philosophy.value),
|
||||
}
|
||||
|
||||
const save = async (key) => {
|
||||
saving.value = true
|
||||
try { await saveMap[key](); ElMessage.success('保存成功') }
|
||||
catch { ElMessage.error('保存失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
const showTimelineDialog = (item) => {
|
||||
if (item) {
|
||||
editingTlId.value = item.id
|
||||
tlForm.value = { version: item.version, date: item.date, title: item.title, reason: item.reason, changes: [...(item.changes || [])] }
|
||||
} else {
|
||||
editingTlId.value = null
|
||||
tlForm.value = { version: '', date: '', title: '', reason: '', changes: [{ type: 'add', text: '' }] }
|
||||
}
|
||||
tlDialogVisible.value = true
|
||||
}
|
||||
|
||||
const saveTimeline = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingTlId.value) {
|
||||
await versionsApi.updateTimeline(editingTlId.value, tlForm.value)
|
||||
} else {
|
||||
await versionsApi.createTimeline(tlForm.value)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
tlDialogVisible.value = false
|
||||
const res = await versionsApi.getTimeline()
|
||||
timeline.value = res.data
|
||||
} catch { ElMessage.error('保存失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
const deleteTimelineItem = async (id) => {
|
||||
try {
|
||||
await versionsApi.deleteTimeline(id)
|
||||
ElMessage.success('删除成功')
|
||||
const res = await versionsApi.getTimeline()
|
||||
timeline.value = res.data
|
||||
} catch { ElMessage.error('删除失败') }
|
||||
}
|
||||
</script>
|
||||
52
vite.config.js
普通文件
52
vite.config.js
普通文件
@ -0,0 +1,52 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 800,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules/element-plus')) {
|
||||
return 'element-plus'
|
||||
}
|
||||
if (id.includes('node_modules/@element-plus/icons-vue')) {
|
||||
return 'element-icons'
|
||||
}
|
||||
if (
|
||||
id.includes('node_modules/vue/') ||
|
||||
id.includes('node_modules/vue-router') ||
|
||||
id.includes('node_modules/@vue/') ||
|
||||
id.includes('node_modules/pinia')
|
||||
) {
|
||||
return 'vue-vendor'
|
||||
}
|
||||
if (id.includes('node_modules')) {
|
||||
return 'vendor'
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
正在加载...
x
在新工单中引用
屏蔽一个用户