Initial commit: hulai admin web frontend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
刘涛 2026-03-22 22:01:36 +08:00
当前提交 7183882e5d
共有 80 个文件被更改,包括 8646 次插入0 次删除

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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 自动生成的 普通文件

文件差异内容过多而无法显示 加载差异

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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

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

查看文件

@ -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 普通文件
查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

@ -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 普通文件
查看文件

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

查看文件

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

查看文件

@ -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 普通文件
查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

@ -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&#10;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 普通文件
查看文件

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

查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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'
}
},
},
},
},
})