feat: 后台管理系统完整重构 + 前后台数据联通
- 后台全面引入 Naive UI 组件库,统一 UI 规范 - 前台 usePublicApi 切换到 API 调用(GET /api/content/[key]) - 前后台数据源统一(site_content 表) - 产品线统一管理(tour/camp/course 三套编辑器) - 产品数据归一化(itinerary/faq/pricing 格式统一) - 表单提交 API 改写入 submissions 表 - 新增 submissions 标记已读 API - site-content PUT 支持 upsert - 修复前台 bug(stories 路由冲突、占位符警告、selector breadcrumb) - 消除 PageHero 类型警告(useSEO reactive 修复) - 25 个前台页面全部 HTTP 200,展示效果不变 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
这个提交包含在:
@@ -0,0 +1,52 @@
|
||||
import { desc, asc, eq, like, or, sql } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../utils/db.js'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
import { parsePagination } from '../../utils/pagination.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
|
||||
const db = useDB()
|
||||
const query = getQuery(event)
|
||||
const { page, limit, offset, search } = parsePagination(event)
|
||||
const type = query.type || 'news'
|
||||
|
||||
// 基础条件
|
||||
const conditions = [eq(schema.articles.type, type)]
|
||||
|
||||
// 搜索
|
||||
if (search) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(schema.articles.title, `%${search}%`),
|
||||
like(schema.articles.summary, `%${search}%`),
|
||||
like(schema.articles.category, `%${search}%`)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 总数
|
||||
const [{ count: total }] = db
|
||||
.select({ count: sql`count(*)` })
|
||||
.from(schema.articles)
|
||||
.where(sql`${conditions.reduce((a, b) => sql`${a} AND ${b}`)}`)
|
||||
.all()
|
||||
|
||||
// 分页数据
|
||||
const items = db
|
||||
.select()
|
||||
.from(schema.articles)
|
||||
.where(sql`${conditions.reduce((a, b) => sql`${a} AND ${b}`)}`)
|
||||
.orderBy(desc(schema.articles.id))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all()
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useDB, schema } from '../../utils/db.js'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
|
||||
const body = await readBody(event)
|
||||
if (!body?.title || !body?.slug) {
|
||||
throw createError({ statusCode: 422, message: '标题和 slug 不能为空' })
|
||||
}
|
||||
|
||||
const db = useDB()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const result = db.insert(schema.articles).values({
|
||||
slug: body.slug,
|
||||
title: body.title,
|
||||
summary: body.summary || '',
|
||||
content: body.content || '',
|
||||
category: body.category || '',
|
||||
type: body.type || 'news',
|
||||
coverImage: body.coverImage || null,
|
||||
author: body.author || '呼籁旅行',
|
||||
tags: body.tags ? JSON.stringify(body.tags) : '[]',
|
||||
seoTitle: body.seoTitle || null,
|
||||
seoDesc: body.seoDesc || null,
|
||||
seoKeywords: body.seoKeywords || null,
|
||||
published: body.published !== false ? 1 : 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).run()
|
||||
|
||||
return { success: true, id: result.lastInsertRowid }
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../../utils/db.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
const db = useDB()
|
||||
|
||||
db.delete(schema.articles)
|
||||
.where(eq(schema.articles.id, id))
|
||||
.run()
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../../utils/db.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
const db = useDB()
|
||||
|
||||
const item = db.select().from(schema.articles).where(eq(schema.articles.id, id)).get()
|
||||
|
||||
if (!item) {
|
||||
throw createError({ statusCode: 404, message: '文章不存在' })
|
||||
}
|
||||
|
||||
return item
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../../utils/db.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
const body = await readBody(event)
|
||||
|
||||
const db = useDB()
|
||||
const updateData = { updatedAt: new Date().toISOString() }
|
||||
|
||||
if (body.title !== undefined) updateData.title = body.title
|
||||
if (body.slug !== undefined) updateData.slug = body.slug
|
||||
if (body.summary !== undefined) updateData.summary = body.summary
|
||||
if (body.content !== undefined) updateData.content = body.content
|
||||
if (body.category !== undefined) updateData.category = body.category
|
||||
if (body.coverImage !== undefined) updateData.coverImage = body.coverImage
|
||||
if (body.author !== undefined) updateData.author = body.author
|
||||
if (body.tags !== undefined) updateData.tags = JSON.stringify(body.tags)
|
||||
if (body.seoTitle !== undefined) updateData.seoTitle = body.seoTitle
|
||||
if (body.seoDesc !== undefined) updateData.seoDesc = body.seoDesc
|
||||
if (body.seoKeywords !== undefined) updateData.seoKeywords = body.seoKeywords
|
||||
if (body.published !== undefined) updateData.published = body.published ? 1 : 0
|
||||
|
||||
db.update(schema.articles)
|
||||
.set(updateData)
|
||||
.where(eq(schema.articles.id, id))
|
||||
.run()
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { asc, like, or, sql } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../utils/db.js'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
import { parsePagination } from '../../utils/pagination.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
|
||||
const db = useDB()
|
||||
const { page, limit, offset, search } = parsePagination(event)
|
||||
|
||||
let whereClause = sql`1=1`
|
||||
if (search) {
|
||||
whereClause = or(
|
||||
like(schema.faqs.question, `%${search}%`),
|
||||
like(schema.faqs.answer, `%${search}%`)
|
||||
)
|
||||
}
|
||||
|
||||
const [{ count: total }] = db
|
||||
.select({ count: sql`count(*)` })
|
||||
.from(schema.faqs)
|
||||
.where(whereClause)
|
||||
.all()
|
||||
|
||||
const items = db
|
||||
.select()
|
||||
.from(schema.faqs)
|
||||
.where(whereClause)
|
||||
.orderBy(asc(schema.faqs.sortOrder))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all()
|
||||
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) }
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useDB, schema } from '../../utils/db.js'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
const body = await readBody(event)
|
||||
|
||||
if (!body?.question || !body?.answer) {
|
||||
throw createError({ statusCode: 422, message: '问题和答案不能为空' })
|
||||
}
|
||||
|
||||
const db = useDB()
|
||||
const result = db.insert(schema.faqs).values({
|
||||
question: body.question,
|
||||
answer: body.answer,
|
||||
sortOrder: body.sortOrder || 0,
|
||||
}).run()
|
||||
|
||||
return { success: true, id: result.lastInsertRowid }
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../../utils/db.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
const db = useDB()
|
||||
|
||||
db.delete(schema.faqs).where(eq(schema.faqs.id, id)).run()
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../../utils/db.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
const db = useDB()
|
||||
const item = db.select().from(schema.faqs).where(eq(schema.faqs.id, id)).get()
|
||||
if (!item) throw createError({ statusCode: 404, message: 'FAQ 不存在' })
|
||||
return item
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../../utils/db.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
const body = await readBody(event)
|
||||
const db = useDB()
|
||||
|
||||
const updateData = {}
|
||||
if (body.question !== undefined) updateData.question = body.question
|
||||
if (body.answer !== undefined) updateData.answer = body.answer
|
||||
if (body.sortOrder !== undefined) updateData.sortOrder = body.sortOrder
|
||||
if (body.published !== undefined) updateData.published = body.published ? 1 : 0
|
||||
|
||||
db.update(schema.faqs).set(updateData).where(eq(schema.faqs.id, id)).run()
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { asc, like, or, sql } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../utils/db.js'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
import { parsePagination } from '../../utils/pagination.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
const db = useDB()
|
||||
const { page, limit, offset, search } = parsePagination(event)
|
||||
|
||||
let whereClause = sql`1=1`
|
||||
if (search) {
|
||||
whereClause = or(
|
||||
like(schema.galleryItems.title, `%${search}%`),
|
||||
like(schema.galleryItems.category, `%${search}%`),
|
||||
like(schema.galleryItems.location, `%${search}%`)
|
||||
)
|
||||
}
|
||||
|
||||
const [{ count: total }] = db.select({ count: sql`count(*)` }).from(schema.galleryItems).where(whereClause).all()
|
||||
const items = db.select().from(schema.galleryItems).where(whereClause).orderBy(asc(schema.galleryItems.sortOrder)).limit(limit).offset(offset).all()
|
||||
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) }
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useDB, schema } from '../../utils/db.js'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
const body = await readBody(event)
|
||||
|
||||
if (!body?.title || !body?.image) {
|
||||
throw createError({ statusCode: 422, message: '标题和图片路径不能为空' })
|
||||
}
|
||||
|
||||
const db = useDB()
|
||||
const result = db.insert(schema.galleryItems).values({
|
||||
title: body.title,
|
||||
category: body.category || '',
|
||||
location: body.location || '',
|
||||
description: body.description || '',
|
||||
image: body.image,
|
||||
orientation: body.orientation || 'landscape',
|
||||
}).run()
|
||||
|
||||
return { success: true, id: result.lastInsertRowid }
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../../utils/db.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
const db = useDB()
|
||||
db.delete(schema.galleryItems).where(eq(schema.galleryItems.id, id)).run()
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../../utils/db.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
const db = useDB()
|
||||
const item = db.select().from(schema.galleryItems).where(eq(schema.galleryItems.id, id)).get()
|
||||
if (!item) throw createError({ statusCode: 404, message: '照片不存在' })
|
||||
return item
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../../utils/db.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
const body = await readBody(event)
|
||||
const db = useDB()
|
||||
|
||||
const updateData = {}
|
||||
if (body.title !== undefined) updateData.title = body.title
|
||||
if (body.category !== undefined) updateData.category = body.category
|
||||
if (body.location !== undefined) updateData.location = body.location
|
||||
if (body.description !== undefined) updateData.description = body.description
|
||||
if (body.image !== undefined) updateData.image = body.image
|
||||
if (body.orientation !== undefined) updateData.orientation = body.orientation
|
||||
if (body.published !== undefined) updateData.published = body.published ? 1 : 0
|
||||
if (body.sortOrder !== undefined) updateData.sortOrder = body.sortOrder
|
||||
|
||||
db.update(schema.galleryItems).set(updateData).where(eq(schema.galleryItems.id, id)).run()
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../utils/db.js'
|
||||
import { hashPassword, createSession } from '../../utils/auth.js'
|
||||
|
||||
// 简单的登录频率限制
|
||||
const loginAttempts = new Map()
|
||||
const MAX_ATTEMPTS = 5
|
||||
const LOCK_TIME = 15 * 60 * 1000 // 15 分钟
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const ip = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown'
|
||||
|
||||
// 检查频率限制
|
||||
const record = loginAttempts.get(ip)
|
||||
if (record && record.count >= MAX_ATTEMPTS && Date.now() - record.firstAt < LOCK_TIME) {
|
||||
const waitMin = Math.ceil((LOCK_TIME - (Date.now() - record.firstAt)) / 60000)
|
||||
throw createError({ statusCode: 429, message: `登录尝试过多,请 ${waitMin} 分钟后再试` })
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const { username, password } = body || {}
|
||||
|
||||
if (!username || !password) {
|
||||
throw createError({ statusCode: 422, message: '请输入用户名和密码' })
|
||||
}
|
||||
|
||||
const db = useDB()
|
||||
const user = db
|
||||
.select()
|
||||
.from(schema.adminUsers)
|
||||
.where(eq(schema.adminUsers.username, username))
|
||||
.get()
|
||||
|
||||
if (!user || user.passwordHash !== hashPassword(password)) {
|
||||
// 记录失败次数
|
||||
const current = loginAttempts.get(ip) || { count: 0, firstAt: Date.now() }
|
||||
if (Date.now() - current.firstAt > LOCK_TIME) {
|
||||
loginAttempts.set(ip, { count: 1, firstAt: Date.now() })
|
||||
} else {
|
||||
current.count++
|
||||
loginAttempts.set(ip, current)
|
||||
}
|
||||
throw createError({ statusCode: 401, message: '用户名或密码错误' })
|
||||
}
|
||||
|
||||
// 登录成功,清除计数
|
||||
loginAttempts.delete(ip)
|
||||
|
||||
const token = createSession(user.id)
|
||||
return { success: true, token, username: user.username }
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { desc, like, or, sql } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../utils/db.js'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
import { parsePagination } from '../../utils/pagination.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
|
||||
const db = useDB()
|
||||
const { page, limit, offset, search } = parsePagination(event)
|
||||
|
||||
let whereClause = sql`1=1`
|
||||
if (search) {
|
||||
whereClause = or(
|
||||
like(schema.reviews.author, `%${search}%`),
|
||||
like(schema.reviews.content, `%${search}%`),
|
||||
like(schema.reviews.title, `%${search}%`)
|
||||
)
|
||||
}
|
||||
|
||||
const [{ count: total }] = db
|
||||
.select({ count: sql`count(*)` })
|
||||
.from(schema.reviews)
|
||||
.where(whereClause)
|
||||
.all()
|
||||
|
||||
const items = db
|
||||
.select()
|
||||
.from(schema.reviews)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(schema.reviews.id))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all()
|
||||
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) }
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useDB, schema } from '../../utils/db.js'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
const body = await readBody(event)
|
||||
|
||||
if (!body?.author || !body?.content) {
|
||||
throw createError({ statusCode: 422, message: '作者和内容不能为空' })
|
||||
}
|
||||
|
||||
const db = useDB()
|
||||
const result = db.insert(schema.reviews).values({
|
||||
author: body.author,
|
||||
rating: body.rating || 5,
|
||||
title: body.title || '',
|
||||
content: body.content,
|
||||
date: body.date || '',
|
||||
verified: body.verified ? 1 : 0,
|
||||
}).run()
|
||||
|
||||
return { success: true, id: result.lastInsertRowid }
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../../utils/db.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
const db = useDB()
|
||||
|
||||
db.delete(schema.reviews).where(eq(schema.reviews.id, id)).run()
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../../utils/db.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
const db = useDB()
|
||||
const item = db.select().from(schema.reviews).where(eq(schema.reviews.id, id)).get()
|
||||
if (!item) throw createError({ statusCode: 404, message: '评价不存在' })
|
||||
return item
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../../utils/db.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
const body = await readBody(event)
|
||||
const db = useDB()
|
||||
|
||||
const updateData = {}
|
||||
if (body.author !== undefined) updateData.author = body.author
|
||||
if (body.rating !== undefined) updateData.rating = body.rating
|
||||
if (body.title !== undefined) updateData.title = body.title
|
||||
if (body.content !== undefined) updateData.content = body.content
|
||||
if (body.date !== undefined) updateData.date = body.date
|
||||
if (body.verified !== undefined) updateData.verified = body.verified ? 1 : 0
|
||||
if (body.published !== undefined) updateData.published = body.published ? 1 : 0
|
||||
if (body.sortOrder !== undefined) updateData.sortOrder = body.sortOrder
|
||||
|
||||
db.update(schema.reviews).set(updateData).where(eq(schema.reviews.id, id)).run()
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../utils/db.js'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
|
||||
const db = useDB()
|
||||
const query = getQuery(event)
|
||||
|
||||
// 如果指定了 key,返回单条
|
||||
if (query.key) {
|
||||
const item = db
|
||||
.select()
|
||||
.from(schema.siteContent)
|
||||
.where(eq(schema.siteContent.key, query.key))
|
||||
.get()
|
||||
|
||||
if (!item) {
|
||||
throw createError({ statusCode: 404, message: '未找到该配置' })
|
||||
}
|
||||
|
||||
return { ...item, data: JSON.parse(item.data) }
|
||||
}
|
||||
|
||||
// 否则返回所有配置的列表(不含 data 内容,太大了)
|
||||
const list = db
|
||||
.select({
|
||||
id: schema.siteContent.id,
|
||||
key: schema.siteContent.key,
|
||||
label: schema.siteContent.label,
|
||||
updatedAt: schema.siteContent.updatedAt,
|
||||
})
|
||||
.from(schema.siteContent)
|
||||
.all()
|
||||
|
||||
return { items: list }
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../utils/db.js'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
|
||||
const body = await readBody(event)
|
||||
if (!body?.key || body?.data === undefined) {
|
||||
throw createError({ statusCode: 422, message: 'key 和 data 不能为空' })
|
||||
}
|
||||
|
||||
const db = useDB()
|
||||
const dataStr = typeof body.data === 'string' ? body.data : JSON.stringify(body.data)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const result = db.update(schema.siteContent)
|
||||
.set({
|
||||
data: dataStr,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(schema.siteContent.key, body.key))
|
||||
.run()
|
||||
|
||||
if (result.changes === 0) {
|
||||
db.insert(schema.siteContent)
|
||||
.values({
|
||||
key: body.key,
|
||||
label: body.label || body.key,
|
||||
data: dataStr,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../../utils/db.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
/**
|
||||
* 从 site_content JSON 数组中读取单个条目
|
||||
* GET /api/admin/site-content/item?key=products&path=versions&index=0
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
const query = getQuery(event)
|
||||
const { key, path, index } = query
|
||||
|
||||
if (!key) throw createError({ statusCode: 422, message: 'key 参数必填' })
|
||||
|
||||
const db = useDB()
|
||||
const row = db.select().from(schema.siteContent).where(eq(schema.siteContent.key, key)).get()
|
||||
if (!row) throw createError({ statusCode: 404, message: '未找到配置' })
|
||||
|
||||
const data = JSON.parse(row.data)
|
||||
|
||||
// 如果指定了 path,在嵌套对象中取数组
|
||||
let arr = data
|
||||
if (path) {
|
||||
arr = path.split('.').reduce((obj, k) => obj?.[k], data)
|
||||
}
|
||||
|
||||
if (!Array.isArray(arr)) {
|
||||
throw createError({ statusCode: 422, message: `${path || 'root'} 不是数组` })
|
||||
}
|
||||
|
||||
if (index !== undefined) {
|
||||
const idx = Number(index)
|
||||
if (idx < 0 || idx >= arr.length) throw createError({ statusCode: 404, message: '索引超出范围' })
|
||||
return { item: arr[idx], index: idx, total: arr.length }
|
||||
}
|
||||
|
||||
// 不传 index 就返回列表概要(只取关键字段,不返回大块内容)
|
||||
const items = arr.map((item, i) => ({
|
||||
_index: i,
|
||||
id: item.id || '',
|
||||
name: item.name || item.title || '',
|
||||
tag: item.tag || item.line || '',
|
||||
days: item.days || '',
|
||||
nights: item.nights || '',
|
||||
subtitle: item.subtitle || '',
|
||||
audience: item.audience || '',
|
||||
}))
|
||||
|
||||
return { items, total: arr.length }
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../../utils/db.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
/**
|
||||
* 更新 site_content JSON 数组中的单个条目
|
||||
* PUT /api/admin/site-content/item
|
||||
* body: { key, path, index, item }
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
const body = await readBody(event)
|
||||
const { key, path, index, item } = body
|
||||
|
||||
if (!key || index === undefined || !item) {
|
||||
throw createError({ statusCode: 422, message: 'key, index, item 参数必填' })
|
||||
}
|
||||
|
||||
const db = useDB()
|
||||
const row = db.select().from(schema.siteContent).where(eq(schema.siteContent.key, key)).get()
|
||||
if (!row) throw createError({ statusCode: 404, message: '未找到配置' })
|
||||
|
||||
const data = JSON.parse(row.data)
|
||||
const idx = Number(index)
|
||||
|
||||
// 定位到目标数组
|
||||
if (path) {
|
||||
const keys = path.split('.')
|
||||
let parent = data
|
||||
for (let i = 0; i < keys.length - 1; i++) parent = parent[keys[i]]
|
||||
const lastKey = keys[keys.length - 1]
|
||||
if (!Array.isArray(parent[lastKey])) throw createError({ statusCode: 422, message: 'path 不是数组' })
|
||||
if (idx < 0 || idx >= parent[lastKey].length) throw createError({ statusCode: 404, message: '索引超出范围' })
|
||||
parent[lastKey][idx] = item
|
||||
} else {
|
||||
if (!Array.isArray(data)) throw createError({ statusCode: 422, message: 'root 不是数组' })
|
||||
if (idx < 0 || idx >= data.length) throw createError({ statusCode: 404, message: '索引超出范围' })
|
||||
data[idx] = item
|
||||
}
|
||||
|
||||
db.update(schema.siteContent)
|
||||
.set({ data: JSON.stringify(data), updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.siteContent.key, key))
|
||||
.run()
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { count } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../utils/db.js'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
|
||||
const db = useDB()
|
||||
|
||||
const [articles] = db.select({ count: count() }).from(schema.articles).all()
|
||||
const [reviews] = db.select({ count: count() }).from(schema.reviews).all()
|
||||
const [faqs] = db.select({ count: count() }).from(schema.faqs).all()
|
||||
const [gallery] = db.select({ count: count() }).from(schema.galleryItems).all()
|
||||
const [stories] = db.select({ count: count() }).from(schema.stories).all()
|
||||
const [submissions] = db.select({ count: count() }).from(schema.submissions).all()
|
||||
const [siteContent] = db.select({ count: count() }).from(schema.siteContent).all()
|
||||
|
||||
return {
|
||||
articles: articles.count,
|
||||
reviews: reviews.count,
|
||||
faqs: faqs.count,
|
||||
gallery: gallery.count,
|
||||
stories: stories.count,
|
||||
submissions: submissions.count,
|
||||
siteContent: siteContent.count,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { asc, like, or, sql } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../utils/db.js'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
import { parsePagination } from '../../utils/pagination.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
const db = useDB()
|
||||
const { page, limit, offset, search } = parsePagination(event)
|
||||
|
||||
let whereClause = sql`1=1`
|
||||
if (search) {
|
||||
whereClause = or(
|
||||
like(schema.stories.title, `%${search}%`),
|
||||
like(schema.stories.fromCity, `%${search}%`),
|
||||
like(schema.stories.tripProduct, `%${search}%`)
|
||||
)
|
||||
}
|
||||
|
||||
const [{ count: total }] = db.select({ count: sql`count(*)` }).from(schema.stories).where(whereClause).all()
|
||||
const items = db.select().from(schema.stories).where(whereClause).orderBy(asc(schema.stories.sortOrder)).limit(limit).offset(offset).all()
|
||||
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) }
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useDB, schema } from '../../utils/db.js'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
const body = await readBody(event)
|
||||
|
||||
if (!body?.title || !body?.slug) {
|
||||
throw createError({ statusCode: 422, message: '标题和 slug 不能为空' })
|
||||
}
|
||||
|
||||
const db = useDB()
|
||||
const result = db.insert(schema.stories).values({
|
||||
slug: body.slug,
|
||||
title: body.title,
|
||||
subtitle: body.subtitle || '',
|
||||
familyType: body.familyType || '',
|
||||
childrenAge: body.childrenAge || '',
|
||||
fromCity: body.fromCity || '',
|
||||
tripProduct: body.tripProduct || '',
|
||||
travelDate: body.travelDate || '',
|
||||
coverImage: body.coverImage || '',
|
||||
sections: body.sections ? JSON.stringify(body.sections) : '[]',
|
||||
keyMoments: body.keyMoments ? JSON.stringify(body.keyMoments) : '[]',
|
||||
seoTitle: body.seoTitle || '',
|
||||
seoDesc: body.seoDesc || '',
|
||||
published: body.published !== false ? 1 : 0,
|
||||
}).run()
|
||||
|
||||
return { success: true, id: result.lastInsertRowid }
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../../utils/db.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
const db = useDB()
|
||||
db.delete(schema.stories).where(eq(schema.stories.id, id)).run()
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../../utils/db.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
const db = useDB()
|
||||
const item = db.select().from(schema.stories).where(eq(schema.stories.id, id)).get()
|
||||
if (!item) throw createError({ statusCode: 404, message: '故事不存在' })
|
||||
return item
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../../utils/db.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
const body = await readBody(event)
|
||||
const db = useDB()
|
||||
|
||||
const updateData = {}
|
||||
if (body.title !== undefined) updateData.title = body.title
|
||||
if (body.slug !== undefined) updateData.slug = body.slug
|
||||
if (body.subtitle !== undefined) updateData.subtitle = body.subtitle
|
||||
if (body.familyType !== undefined) updateData.familyType = body.familyType
|
||||
if (body.childrenAge !== undefined) updateData.childrenAge = body.childrenAge
|
||||
if (body.fromCity !== undefined) updateData.fromCity = body.fromCity
|
||||
if (body.tripProduct !== undefined) updateData.tripProduct = body.tripProduct
|
||||
if (body.travelDate !== undefined) updateData.travelDate = body.travelDate
|
||||
if (body.coverImage !== undefined) updateData.coverImage = body.coverImage
|
||||
if (body.sections !== undefined) updateData.sections = JSON.stringify(body.sections)
|
||||
if (body.keyMoments !== undefined) updateData.keyMoments = JSON.stringify(body.keyMoments)
|
||||
if (body.seoTitle !== undefined) updateData.seoTitle = body.seoTitle
|
||||
if (body.seoDesc !== undefined) updateData.seoDesc = body.seoDesc
|
||||
if (body.published !== undefined) updateData.published = body.published ? 1 : 0
|
||||
if (body.sortOrder !== undefined) updateData.sortOrder = body.sortOrder
|
||||
|
||||
db.update(schema.stories).set(updateData).where(eq(schema.stories.id, id)).run()
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { desc, eq, like, or, sql } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../utils/db.js'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
import { parsePagination } from '../../utils/pagination.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
|
||||
const db = useDB()
|
||||
const query = getQuery(event)
|
||||
const { page, limit, offset, search } = parsePagination(event)
|
||||
const type = query.type || ''
|
||||
|
||||
const conditions = []
|
||||
if (type) conditions.push(eq(schema.submissions.type, type))
|
||||
if (search) conditions.push(like(schema.submissions.data, `%${search}%`))
|
||||
|
||||
const whereClause = conditions.length
|
||||
? conditions.reduce((a, b) => sql`${a} AND ${b}`)
|
||||
: sql`1=1`
|
||||
|
||||
const [{ count: total }] = db
|
||||
.select({ count: sql`count(*)` })
|
||||
.from(schema.submissions)
|
||||
.where(whereClause)
|
||||
.all()
|
||||
|
||||
const list = db
|
||||
.select()
|
||||
.from(schema.submissions)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(schema.submissions.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all()
|
||||
|
||||
const items = list.map(item => ({
|
||||
...item,
|
||||
data: JSON.parse(item.data),
|
||||
}))
|
||||
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) }
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../../utils/db.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
if (!id || isNaN(id)) {
|
||||
throw createError({ statusCode: 400, message: '无效的 id 参数' })
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
if (typeof body.read !== 'boolean') {
|
||||
throw createError({ statusCode: 400, message: 'read 字段必须是布尔值' })
|
||||
}
|
||||
|
||||
const db = useDB()
|
||||
|
||||
const [existing] = db
|
||||
.select()
|
||||
.from(schema.submissions)
|
||||
.where(eq(schema.submissions.id, id))
|
||||
.all()
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '记录不存在' })
|
||||
}
|
||||
|
||||
db.update(schema.submissions)
|
||||
.set({ read: body.read })
|
||||
.where(eq(schema.submissions.id, id))
|
||||
.run()
|
||||
|
||||
const [updated] = db
|
||||
.select()
|
||||
.from(schema.submissions)
|
||||
.where(eq(schema.submissions.id, id))
|
||||
.all()
|
||||
|
||||
return {
|
||||
...updated,
|
||||
data: JSON.parse(updated.data),
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { eq, count } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../../utils/db.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
const db = useDB()
|
||||
|
||||
const [result] = db
|
||||
.select({ count: count() })
|
||||
.from(schema.submissions)
|
||||
.where(eq(schema.submissions.read, false))
|
||||
.all()
|
||||
|
||||
return { count: result.count }
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { writeFileSync, existsSync, mkdirSync } from 'fs'
|
||||
import { join, extname } from 'path'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
|
||||
// 允许的图片类型
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/svg+xml']
|
||||
const MAX_SIZE = 10 * 1024 * 1024 // 10MB
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
|
||||
const formData = await readMultipartFormData(event)
|
||||
if (!formData || !formData.length) {
|
||||
throw createError({ statusCode: 422, message: '请选择要上传的文件' })
|
||||
}
|
||||
|
||||
const results = []
|
||||
|
||||
for (const file of formData) {
|
||||
if (!file.filename || !file.data) continue
|
||||
|
||||
// 校验文件类型
|
||||
const mimeType = file.type || ''
|
||||
if (!ALLOWED_TYPES.includes(mimeType)) {
|
||||
throw createError({ statusCode: 422, message: `不支持的文件类型: ${mimeType},仅支持 JPG/PNG/WebP/GIF/SVG` })
|
||||
}
|
||||
|
||||
// 校验文件大小
|
||||
if (file.data.length > MAX_SIZE) {
|
||||
throw createError({ statusCode: 422, message: `文件过大,最大 10MB` })
|
||||
}
|
||||
|
||||
// 生成文件名: 时间戳-原始文件名
|
||||
const ext = extname(file.filename).toLowerCase()
|
||||
const safeName = file.filename
|
||||
.replace(ext, '')
|
||||
.replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_')
|
||||
.slice(0, 50)
|
||||
const fileName = `${Date.now()}-${safeName}${ext}`
|
||||
|
||||
// 按年月分目录
|
||||
const now = new Date()
|
||||
const subDir = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
const uploadDir = join(process.cwd(), 'public', 'uploads', subDir)
|
||||
|
||||
if (!existsSync(uploadDir)) {
|
||||
mkdirSync(uploadDir, { recursive: true })
|
||||
}
|
||||
|
||||
const filePath = join(uploadDir, fileName)
|
||||
writeFileSync(filePath, file.data)
|
||||
|
||||
const url = `/uploads/${subDir}/${fileName}`
|
||||
results.push({ url, name: file.filename, size: file.data.length })
|
||||
}
|
||||
|
||||
return { success: true, files: results }
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* POST /api/contact
|
||||
* 联系表单提交接口(通用留言)
|
||||
*
|
||||
* 请求体:
|
||||
* {
|
||||
* name: string // 姓名(必填)
|
||||
* phone: string // 手机号(必填)
|
||||
* subject?: string // 咨询主题
|
||||
* message: string // 留言内容(必填)
|
||||
* }
|
||||
*/
|
||||
|
||||
import { checkRateLimit } from '../utils/rateLimit'
|
||||
import { notifyWecom } from '../utils/notify'
|
||||
import { useDB, schema } from '../utils/db.js'
|
||||
|
||||
const PHONE_RE = /^1[3-9]\d{9}$/
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// ── 速率限制 ──────────────────────────────────────────
|
||||
const ip = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown'
|
||||
const { allowed, remaining, resetAt } = checkRateLimit(`contact:${ip}`)
|
||||
|
||||
setResponseHeader(event, 'X-RateLimit-Remaining', String(remaining))
|
||||
|
||||
if (!allowed) {
|
||||
const waitMin = Math.ceil((resetAt - Date.now()) / 60000)
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
message: `提交过于频繁,请 ${waitMin} 分钟后再试`,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 读取请求体 ─────────────────────────────────────────
|
||||
const body = await readBody(event)
|
||||
|
||||
// ── 字段校验 ──────────────────────────────────────────
|
||||
const errors = {}
|
||||
|
||||
if (!body?.name?.trim()) {
|
||||
errors.name = '请填写您的姓名'
|
||||
}
|
||||
|
||||
if (!body?.phone || !PHONE_RE.test(body.phone.trim())) {
|
||||
errors.phone = '请填写正确的手机号码'
|
||||
}
|
||||
|
||||
if (!body?.message?.trim() || body.message.trim().length < 5) {
|
||||
errors.message = '请填写留言内容(至少5个字)'
|
||||
}
|
||||
|
||||
if (body?.message?.trim().length > 1000) {
|
||||
errors.message = '留言内容不超过1000字'
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
throw createError({
|
||||
statusCode: 422,
|
||||
data: { errors },
|
||||
message: '提交内容有误,请检查后重试',
|
||||
})
|
||||
}
|
||||
|
||||
// ── 构建表单数据 ────────────────────────────────────────
|
||||
const formData = {
|
||||
name: body.name.trim(),
|
||||
phone: body.phone.trim(),
|
||||
subject: body.subject?.trim() ?? '',
|
||||
message: body.message.trim(),
|
||||
source: 'contact-form',
|
||||
}
|
||||
|
||||
// ── 写入数据库 ────────────────────────────────────────
|
||||
const db = useDB()
|
||||
const [record] = db.insert(schema.submissions)
|
||||
.values({
|
||||
type: 'contact',
|
||||
data: JSON.stringify(formData),
|
||||
ip,
|
||||
})
|
||||
.returning()
|
||||
.all()
|
||||
|
||||
// ── 飞书通知 ──────────────────────────────────────────
|
||||
await notifyWecom('📩 新联系留言 - 呼籁旅行', {
|
||||
'编号': String(record.id),
|
||||
'姓名': formData.name,
|
||||
'手机': formData.phone,
|
||||
'主题': formData.subject || '(未填)',
|
||||
'留言': formData.message,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
id: record.id,
|
||||
message: '留言已收到,我们会尽快与您联系',
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../utils/db.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const key = getRouterParam(event, 'key')
|
||||
|
||||
const db = useDB()
|
||||
|
||||
const item = db
|
||||
.select()
|
||||
.from(schema.siteContent)
|
||||
.where(eq(schema.siteContent.key, key))
|
||||
.get()
|
||||
|
||||
if (!item) {
|
||||
throw createError({ statusCode: 404, message: `未找到配置项: ${key}` })
|
||||
}
|
||||
|
||||
setHeader(event, 'Cache-Control', 'public, max-age=60')
|
||||
|
||||
return JSON.parse(item.data)
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* POST /api/customize
|
||||
* 定制表单提交接口
|
||||
*
|
||||
* 请求体:
|
||||
* {
|
||||
* name: string // 姓名(必填)
|
||||
* phone: string // 手机号(必填)
|
||||
* wechat?: string // 微信号(选填)
|
||||
* adults: number // 成人数(必填)
|
||||
* children: number // 儿童数
|
||||
* childAges: number[] // 儿童年龄列表
|
||||
* date?: string // 出行日期 YYYY-MM-DD
|
||||
* duration?: string // 出行天数
|
||||
* activities: string[]// 特殊需求标签
|
||||
* budget?: string // 预算范围
|
||||
* remarks?: string // 补充说明
|
||||
* }
|
||||
*/
|
||||
|
||||
import { checkRateLimit } from '../utils/rateLimit'
|
||||
import { notifyWecom } from '../utils/notify'
|
||||
import { useDB, schema } from '../utils/db.js'
|
||||
|
||||
// 手机号正则
|
||||
const PHONE_RE = /^1[3-9]\d{9}$/
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// ── 速率限制 ──────────────────────────────────────────
|
||||
const ip = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown'
|
||||
const { allowed, remaining, resetAt } = checkRateLimit(`customize:${ip}`)
|
||||
|
||||
setResponseHeader(event, 'X-RateLimit-Remaining', String(remaining))
|
||||
|
||||
if (!allowed) {
|
||||
const waitMin = Math.ceil((resetAt - Date.now()) / 60000)
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
message: `提交过于频繁,请 ${waitMin} 分钟后再试`,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 读取请求体 ─────────────────────────────────────────
|
||||
const body = await readBody(event)
|
||||
|
||||
// ── 字段校验 ──────────────────────────────────────────
|
||||
const errors = {}
|
||||
|
||||
if (!body?.name?.trim()) {
|
||||
errors.name = '请填写您的姓名'
|
||||
}
|
||||
|
||||
if (!body?.phone || !PHONE_RE.test(body.phone.trim())) {
|
||||
errors.phone = '请填写正确的手机号码(11位大陆手机号)'
|
||||
}
|
||||
|
||||
if (!Number.isInteger(body?.adults) || body.adults < 1 || body.adults > 20) {
|
||||
errors.adults = '成人人数不合法'
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
throw createError({
|
||||
statusCode: 422,
|
||||
data: { errors },
|
||||
message: '提交内容有误,请检查后重试',
|
||||
})
|
||||
}
|
||||
|
||||
// ── 构建表单数据 ────────────────────────────────────────
|
||||
const formData = {
|
||||
name: body.name.trim(),
|
||||
phone: body.phone.trim(),
|
||||
wechat: body.wechat?.trim() ?? '',
|
||||
adults: body.adults,
|
||||
children: body.children ?? 0,
|
||||
childAges: Array.isArray(body.childAges) ? body.childAges : [],
|
||||
date: body.date ?? '',
|
||||
duration: body.duration ?? '',
|
||||
activities: Array.isArray(body.activities) ? body.activities : [],
|
||||
budget: body.budget ?? '',
|
||||
remarks: body.remarks?.trim() ?? '',
|
||||
source: 'customize-form',
|
||||
}
|
||||
|
||||
// ── 写入数据库 ────────────────────────────────────────
|
||||
const db = useDB()
|
||||
const [record] = db.insert(schema.submissions)
|
||||
.values({
|
||||
type: 'customize',
|
||||
data: JSON.stringify(formData),
|
||||
ip,
|
||||
})
|
||||
.returning()
|
||||
.all()
|
||||
|
||||
// ── 飞书通知(配置后生效)────────────────────────────
|
||||
await notifyWecom('🌿 新定制需求 - 呼籁旅行', {
|
||||
'编号': String(record.id),
|
||||
'姓名': formData.name,
|
||||
'手机': formData.phone,
|
||||
'微信': formData.wechat || '(同手机号)',
|
||||
'出行人数': `成人 ${formData.adults} 人${formData.children > 0 ? `、儿童 ${formData.children} 人(${formData.childAges.join('、')}岁)` : ''}`,
|
||||
'出行日期': formData.date || '未填写',
|
||||
'天数': formData.duration || '未选择',
|
||||
'特殊需求': formData.activities.length ? formData.activities.join('、') : '无',
|
||||
'预算': formData.budget || '未选择',
|
||||
'补充说明': formData.remarks || '无',
|
||||
})
|
||||
|
||||
// ── 返回 ──────────────────────────────────────────────
|
||||
return {
|
||||
success: true,
|
||||
id: record.id,
|
||||
message: '需求已收到,定制师将在2小时内联系您',
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
import { join } from 'path'
|
||||
import * as schema from './schema.js'
|
||||
|
||||
const DB_PATH = join(process.cwd(), 'server', 'database', 'hulai.db')
|
||||
|
||||
const sqlite = new Database(DB_PATH)
|
||||
// 开启 WAL 模式,性能更好
|
||||
sqlite.pragma('journal_mode = WAL')
|
||||
sqlite.pragma('foreign_keys = ON')
|
||||
|
||||
export const db = drizzle(sqlite, { schema })
|
||||
export { schema }
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 数据库初始化脚本
|
||||
* 运行: node server/database/migrate.js
|
||||
*
|
||||
* 创建所有表(如果不存在)
|
||||
*/
|
||||
import Database from 'better-sqlite3'
|
||||
import { join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname } from 'path'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const DB_PATH = join(__dirname, 'hulai.db')
|
||||
|
||||
const db = new Database(DB_PATH)
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('foreign_keys = ON')
|
||||
|
||||
// ── 建表 SQL ────────────────────────────────────────
|
||||
const tables = `
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS articles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
content TEXT,
|
||||
category TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'news',
|
||||
cover_image TEXT,
|
||||
author TEXT,
|
||||
tags TEXT,
|
||||
seo_title TEXT,
|
||||
seo_description TEXT,
|
||||
seo_keywords TEXT,
|
||||
published INTEGER DEFAULT 1,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
author TEXT NOT NULL,
|
||||
rating INTEGER DEFAULT 5,
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
date TEXT,
|
||||
verified INTEGER DEFAULT 0,
|
||||
published INTEGER DEFAULT 1,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS faqs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
question TEXT NOT NULL,
|
||||
answer TEXT NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
published INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gallery_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
category TEXT,
|
||||
location TEXT,
|
||||
description TEXT,
|
||||
image TEXT NOT NULL,
|
||||
orientation TEXT,
|
||||
published INTEGER DEFAULT 1,
|
||||
sort_order INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
subtitle TEXT,
|
||||
family_type TEXT,
|
||||
children_age TEXT,
|
||||
from_city TEXT,
|
||||
trip_product TEXT,
|
||||
travel_date TEXT,
|
||||
cover_image TEXT,
|
||||
sections TEXT,
|
||||
key_moments TEXT,
|
||||
seo_title TEXT,
|
||||
seo_description TEXT,
|
||||
published INTEGER DEFAULT 1,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS submissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
ip TEXT,
|
||||
read INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS site_content (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`
|
||||
|
||||
db.exec(tables)
|
||||
console.log('✅ 数据库表创建完成:', DB_PATH)
|
||||
|
||||
db.close()
|
||||
@@ -0,0 +1,109 @@
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
|
||||
|
||||
// ── 管理员 ──────────────────────────────────────────
|
||||
export const adminUsers = sqliteTable('admin_users', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
username: text('username').notNull().unique(),
|
||||
// 存储 hash 后的密码
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
createdAt: text('created_at').notNull().$defaultFn(() => new Date().toISOString()),
|
||||
})
|
||||
|
||||
// ── 新闻/公告 ────────────────────────────────────────
|
||||
export const articles = sqliteTable('articles', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
slug: text('slug').notNull().unique(),
|
||||
title: text('title').notNull(),
|
||||
summary: text('summary'),
|
||||
content: text('content'), // HTML 或 Markdown
|
||||
category: text('category'), // 产品 / 公告 / 活动 / 攻略 / 亲子
|
||||
type: text('type').notNull(), // news / blog
|
||||
coverImage: text('cover_image'),
|
||||
author: text('author'),
|
||||
tags: text('tags'), // JSON 数组字符串
|
||||
seoTitle: text('seo_title'),
|
||||
seoDesc: text('seo_description'),
|
||||
seoKeywords: text('seo_keywords'),
|
||||
published: integer('published', { mode: 'boolean' }).default(true),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
createdAt: text('created_at').notNull().$defaultFn(() => new Date().toISOString()),
|
||||
updatedAt: text('updated_at').notNull().$defaultFn(() => new Date().toISOString()),
|
||||
})
|
||||
|
||||
// ── 客户评价 ─────────────────────────────────────────
|
||||
export const reviews = sqliteTable('reviews', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
author: text('author').notNull(),
|
||||
rating: integer('rating').default(5),
|
||||
title: text('title'),
|
||||
content: text('content'),
|
||||
date: text('date'),
|
||||
verified: integer('verified', { mode: 'boolean' }).default(false),
|
||||
published: integer('published', { mode: 'boolean' }).default(true),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
createdAt: text('created_at').notNull().$defaultFn(() => new Date().toISOString()),
|
||||
})
|
||||
|
||||
// ── FAQ ──────────────────────────────────────────────
|
||||
export const faqs = sqliteTable('faqs', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
question: text('question').notNull(),
|
||||
answer: text('answer').notNull(),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
published: integer('published', { mode: 'boolean' }).default(true),
|
||||
})
|
||||
|
||||
// ── 相册/旅拍 ────────────────────────────────────────
|
||||
export const galleryItems = sqliteTable('gallery_items', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
title: text('title').notNull(),
|
||||
category: text('category'), // 草原 / 森林 / 湖泊 / 冬季 ...
|
||||
location: text('location'),
|
||||
description: text('description'),
|
||||
image: text('image').notNull(), // 图片路径
|
||||
orientation: text('orientation'), // portrait / landscape
|
||||
published: integer('published', { mode: 'boolean' }).default(true),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
})
|
||||
|
||||
// ── 客户故事 ─────────────────────────────────────────
|
||||
export const stories = sqliteTable('stories', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
slug: text('slug').notNull().unique(),
|
||||
title: text('title').notNull(),
|
||||
subtitle: text('subtitle'),
|
||||
familyType: text('family_type'),
|
||||
childrenAge: text('children_age'),
|
||||
fromCity: text('from_city'),
|
||||
tripProduct: text('trip_product'),
|
||||
travelDate: text('travel_date'),
|
||||
coverImage: text('cover_image'),
|
||||
// sections 存为 JSON 字符串: [{ heading, content }]
|
||||
sections: text('sections'),
|
||||
keyMoments: text('key_moments'), // JSON 数组
|
||||
seoTitle: text('seo_title'),
|
||||
seoDesc: text('seo_description'),
|
||||
published: integer('published', { mode: 'boolean' }).default(true),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
createdAt: text('created_at').notNull().$defaultFn(() => new Date().toISOString()),
|
||||
})
|
||||
|
||||
// ── 表单提交记录 ─────────────────────────────────────
|
||||
export const submissions = sqliteTable('submissions', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
type: text('type').notNull(), // contact / customize
|
||||
data: text('data').notNull(), // JSON 字符串,存完整表单数据
|
||||
ip: text('ip'),
|
||||
read: integer('read', { mode: 'boolean' }).default(false),
|
||||
createdAt: text('created_at').notNull().$defaultFn(() => new Date().toISOString()),
|
||||
})
|
||||
|
||||
// ── 站点配置(catch-all,存复杂 JSON 内容)───────────
|
||||
// 用于 products / destinations / courses / navigation 等复杂嵌套数据
|
||||
export const siteContent = sqliteTable('site_content', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
key: text('key').notNull().unique(), // 如 'products', 'navigation', 'brand'
|
||||
label: text('label').notNull(), // 显示名称,如 '产品管理', '导航配置'
|
||||
data: text('data').notNull(), // JSON 字符串
|
||||
updatedAt: text('updated_at').notNull().$defaultFn(() => new Date().toISOString()),
|
||||
})
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* 数据导入脚本
|
||||
* 运行: node server/database/seed.js
|
||||
*
|
||||
* 把 data/*.json 的内容导入 SQLite 数据库
|
||||
*/
|
||||
import Database from 'better-sqlite3'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const DB_PATH = join(__dirname, 'hulai.db')
|
||||
const DATA_DIR = join(__dirname, '..', '..', 'data')
|
||||
|
||||
const db = new Database(DB_PATH)
|
||||
|
||||
function readJSON(filename) {
|
||||
return JSON.parse(readFileSync(join(DATA_DIR, filename), 'utf-8'))
|
||||
}
|
||||
|
||||
// 简单密码 hash(生产环境建议用 bcrypt)
|
||||
function hashPassword(password) {
|
||||
return createHash('sha256').update(password).digest('hex')
|
||||
}
|
||||
|
||||
// ── 创建默认管理员 ────────────────────────────────────
|
||||
const insertAdmin = db.prepare(
|
||||
'INSERT OR IGNORE INTO admin_users (username, password_hash) VALUES (?, ?)'
|
||||
)
|
||||
insertAdmin.run('admin', hashPassword('hulai2026'))
|
||||
console.log('✅ 管理员账号: admin / hulai2026')
|
||||
|
||||
// ── 导入新闻 ──────────────────────────────────────────
|
||||
const newsData = readJSON('news.json')
|
||||
const insertArticle = db.prepare(`
|
||||
INSERT OR IGNORE INTO articles (slug, title, summary, content, category, type, author, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
for (const item of newsData.articles) {
|
||||
insertArticle.run(
|
||||
item.id, item.title, item.summary, item.content,
|
||||
item.category, 'news', '呼籁旅行',
|
||||
item.date, item.date
|
||||
)
|
||||
}
|
||||
console.log(`✅ 新闻导入: ${newsData.articles.length} 条`)
|
||||
|
||||
// ── 导入博客 ──────────────────────────────────────────
|
||||
const blogData = readJSON('blog.json')
|
||||
const insertBlog = db.prepare(`
|
||||
INSERT OR IGNORE INTO articles (slug, title, summary, content, category, type, cover_image, author, tags, seo_title, seo_description, seo_keywords, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
for (const item of blogData.articles) {
|
||||
insertBlog.run(
|
||||
item.id, item.title, item.summary, item.content,
|
||||
item.category, 'blog', item.coverImage || null,
|
||||
item.author, JSON.stringify(item.tags || []),
|
||||
item.seo?.title || null, item.seo?.description || null, item.seo?.keywords || null,
|
||||
item.date, item.date
|
||||
)
|
||||
}
|
||||
console.log(`✅ 博客导入: ${blogData.articles.length} 条`)
|
||||
|
||||
// ── 导入评价 ──────────────────────────────────────────
|
||||
const reviewsData = readJSON('reviews.json')
|
||||
const insertReview = db.prepare(`
|
||||
INSERT OR IGNORE INTO reviews (author, rating, title, content, date, verified, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
for (let i = 0; i < reviewsData.items.length; i++) {
|
||||
const item = reviewsData.items[i]
|
||||
insertReview.run(
|
||||
item.nickname, 5, item.productVersion || '',
|
||||
item.content, item.travelDate, 1, i
|
||||
)
|
||||
}
|
||||
console.log(`✅ 评价导入: ${reviewsData.items.length} 条`)
|
||||
|
||||
// ── 导入 FAQ ─────────────────────────────────────────
|
||||
const faqData = readJSON('faq.json')
|
||||
const insertFaq = db.prepare(
|
||||
'INSERT OR IGNORE INTO faqs (question, answer, sort_order) VALUES (?, ?, ?)'
|
||||
)
|
||||
// faq.json 结构: { categories: [{ questions: [{ question, answer }] }] }
|
||||
// 或者是扁平结构
|
||||
let faqCount = 0
|
||||
if (faqData.categories) {
|
||||
for (const cat of faqData.categories) {
|
||||
for (const q of (cat.questions || [])) {
|
||||
insertFaq.run(q.question || q.q, q.answer || q.a, faqCount)
|
||||
faqCount++
|
||||
}
|
||||
}
|
||||
} else if (Array.isArray(faqData)) {
|
||||
for (const q of faqData) {
|
||||
insertFaq.run(q.question || q.q, q.answer || q.a, faqCount)
|
||||
faqCount++
|
||||
}
|
||||
}
|
||||
console.log(`✅ FAQ导入: ${faqCount} 条`)
|
||||
|
||||
// ── 导入相册 ─────────────────────────────────────────
|
||||
const galleryData = readJSON('gallery.json')
|
||||
const insertGallery = db.prepare(`
|
||||
INSERT OR IGNORE INTO gallery_items (title, category, location, description, image, orientation, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
const works = galleryData.works || galleryData
|
||||
if (Array.isArray(works)) {
|
||||
for (let i = 0; i < works.length; i++) {
|
||||
const item = works[i]
|
||||
insertGallery.run(
|
||||
item.title, item.category, item.location || '',
|
||||
item.description || '', item.image, item.orientation || 'landscape', i
|
||||
)
|
||||
}
|
||||
console.log(`✅ 相册导入: ${works.length} 条`)
|
||||
}
|
||||
|
||||
// ── 导入客户故事 ──────────────────────────────────────
|
||||
const storiesData = readJSON('stories.json')
|
||||
const insertStory = db.prepare(`
|
||||
INSERT OR IGNORE INTO stories (slug, title, subtitle, family_type, children_age, from_city, trip_product, travel_date, sections, key_moments, seo_title, seo_description)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
for (const item of storiesData.stories) {
|
||||
insertStory.run(
|
||||
item.id, item.title, item.subtitle || '',
|
||||
item.familyType || '', item.childrenAge || '',
|
||||
item.fromCity || '', item.tripProduct || '', item.travelDate || '',
|
||||
JSON.stringify(item.sections || []),
|
||||
JSON.stringify(item.keyMoments || []),
|
||||
item.seo?.title || null, item.seo?.description || null
|
||||
)
|
||||
}
|
||||
console.log(`✅ 客户故事导入: ${storiesData.stories.length} 条`)
|
||||
|
||||
// ── 导入站点配置(复杂 JSON 直接存)──────────────────
|
||||
const siteConfigs = [
|
||||
{ key: 'products', label: '产品管理', file: 'products.json' },
|
||||
{ key: 'autumn-products', label: '秋季产品', file: 'autumn-products.json' },
|
||||
{ key: 'winter-products', label: '冬季产品', file: 'winter-products.json' },
|
||||
{ key: 'winter-camp', label: '冬令营', file: 'winter-camp.json' },
|
||||
{ key: 'destinations', label: '目的地对比', file: 'destinations.json' },
|
||||
{ key: 'destinations-detail', label: '目的地详情', file: 'destinations-detail.json' },
|
||||
{ key: 'courses', label: '研学课程', file: 'courses.json' },
|
||||
{ key: 'calendar', label: '出行日历', file: 'calendar.json' },
|
||||
{ key: 'guides', label: '出行指南', file: 'guides.json' },
|
||||
{ key: 'navigation', label: '导航配置', file: 'navigation.json' },
|
||||
{ key: 'brand', label: '品牌信息', file: 'brand.json' },
|
||||
{ key: 'contact', label: '联系方式', file: 'contact.json' },
|
||||
{ key: 'about', label: '关于我们', file: 'about.json' },
|
||||
{ key: 'seo', label: 'SEO配置', file: 'seo.json' },
|
||||
{ key: 'pricing', label: '价格配置', file: 'pricing.json' },
|
||||
{ key: 'customize', label: '定制配置', file: 'customize.json' },
|
||||
{ key: 'selector', label: '产品选择器', file: 'selector.json' },
|
||||
{ key: 'qualifications', label: '资质证书', file: 'qualifications.json' },
|
||||
{ key: 'partners', label: '合作伙伴', file: 'partners.json' },
|
||||
{ key: 'xiaohongshu-wall', label: '小红书墙', file: 'xiaohongshu-wall.json' },
|
||||
{ key: 'youji', label: '游记', file: 'youji.json' },
|
||||
{ key: 'images', label: '图片配置', file: 'images.json' },
|
||||
{ key: 'versions', label: '版本历史', file: 'versions.json' },
|
||||
]
|
||||
|
||||
const insertSiteContent = db.prepare(`
|
||||
INSERT OR IGNORE INTO site_content (key, label, data) VALUES (?, ?, ?)
|
||||
`)
|
||||
|
||||
for (const config of siteConfigs) {
|
||||
try {
|
||||
const data = readJSON(config.file)
|
||||
insertSiteContent.run(config.key, config.label, JSON.stringify(data))
|
||||
} catch (e) {
|
||||
console.log(`⚠️ 跳过 ${config.file}: ${e.message}`)
|
||||
}
|
||||
}
|
||||
console.log(`✅ 站点配置导入: ${siteConfigs.length} 项`)
|
||||
|
||||
// ── 导入已有表单提交记录 ──────────────────────────────
|
||||
try {
|
||||
const contactRecords = JSON.parse(
|
||||
readFileSync(join(__dirname, '..', 'storage', 'contact.json'), 'utf-8')
|
||||
)
|
||||
const insertSubmission = db.prepare(
|
||||
'INSERT OR IGNORE INTO submissions (type, data, ip, created_at) VALUES (?, ?, ?, ?)'
|
||||
)
|
||||
if (Array.isArray(contactRecords)) {
|
||||
for (const r of contactRecords) {
|
||||
insertSubmission.run('contact', JSON.stringify(r), r.ip || '', r.createdAt || new Date().toISOString())
|
||||
}
|
||||
console.log(`✅ 联系表单记录导入: ${contactRecords.length} 条`)
|
||||
}
|
||||
} catch {
|
||||
console.log('ℹ️ 无历史表单记录,跳过')
|
||||
}
|
||||
|
||||
try {
|
||||
const customizeRecords = JSON.parse(
|
||||
readFileSync(join(__dirname, '..', 'storage', 'customize.json'), 'utf-8')
|
||||
)
|
||||
const insertSubmission = db.prepare(
|
||||
'INSERT OR IGNORE INTO submissions (type, data, ip, created_at) VALUES (?, ?, ?, ?)'
|
||||
)
|
||||
if (Array.isArray(customizeRecords)) {
|
||||
for (const r of customizeRecords) {
|
||||
insertSubmission.run('customize', JSON.stringify(r), r.ip || '', r.createdAt || new Date().toISOString())
|
||||
}
|
||||
console.log(`✅ 定制表单记录导入: ${customizeRecords.length} 条`)
|
||||
}
|
||||
} catch {
|
||||
console.log('ℹ️ 无定制表单记录,跳过')
|
||||
}
|
||||
|
||||
db.close()
|
||||
console.log('\n🎉 数据导入完成!')
|
||||
@@ -0,0 +1,70 @@
|
||||
export default defineEventHandler((event) => {
|
||||
const pages = [
|
||||
{
|
||||
url: '/', priority: '1.0', changefreq: 'weekly',
|
||||
images: [{ loc: '/images/team/hero-grassland.jpg', title: '呼伦贝尔草原风光 - 呼籁旅行' }],
|
||||
},
|
||||
{
|
||||
url: '/faq', priority: '0.95', changefreq: 'weekly',
|
||||
images: [],
|
||||
},
|
||||
{
|
||||
url: '/products', priority: '0.9', changefreq: 'weekly',
|
||||
images: [{ loc: '/images/mascot/xiaomengma.png', title: '小蒙马夏令营IP形象' }],
|
||||
},
|
||||
{
|
||||
url: '/about', priority: '0.85', changefreq: 'monthly',
|
||||
images: [
|
||||
{ loc: '/images/team/hero-family.jpg', title: '呼伦贝尔家庭游合影' },
|
||||
{ loc: '/images/team/guide-group.jpg', title: '呼籁旅行领队团队合影' },
|
||||
{ loc: '/images/mascot/mascot-front.png', title: '呼籁旅行吉祥物' },
|
||||
],
|
||||
},
|
||||
{
|
||||
url: '/reviews', priority: '0.8', changefreq: 'weekly',
|
||||
images: [],
|
||||
},
|
||||
{
|
||||
url: '/contact', priority: '0.8', changefreq: 'monthly',
|
||||
images: [{ loc: '/images/contact/qrcode-wechat.jpg', title: '呼籁旅行微信客服二维码' }],
|
||||
},
|
||||
{
|
||||
url: '/guides', priority: '0.8', changefreq: 'monthly',
|
||||
images: [],
|
||||
},
|
||||
{
|
||||
url: '/summer-camp', priority: '0.85', changefreq: 'monthly',
|
||||
images: [
|
||||
{ loc: '/images/summer-camp/river-banner.jpg', title: '小蒙马夏令营河畔活动' },
|
||||
{ loc: '/images/summer-camp/grassland-group.jpg', title: '草原亲子夏令营合影' },
|
||||
{ loc: '/images/summer-camp/birch-group.jpg', title: '白桦林研学活动' },
|
||||
{ loc: '/images/summer-camp/camp-graduation.jpg', title: '夏令营结营仪式' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const base = 'https://1814.love'
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
|
||||
${pages
|
||||
.map(
|
||||
(p) => ` <url>
|
||||
<loc>${base}${p.url}</loc>
|
||||
<lastmod>${today}</lastmod>
|
||||
<changefreq>${p.changefreq}</changefreq>
|
||||
<priority>${p.priority}</priority>${p.images.map(img => `
|
||||
<image:image>
|
||||
<image:loc>${base}${img.loc}</image:loc>
|
||||
<image:title>${img.title}</image:title>
|
||||
</image:image>`).join('')}
|
||||
</url>`
|
||||
)
|
||||
.join('\n')}
|
||||
</urlset>`
|
||||
|
||||
setResponseHeader(event, 'content-type', 'application/xml')
|
||||
return xml
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"id": "CT1774007263178",
|
||||
"createdAt": "2026-03-20T11:47:43.178Z",
|
||||
"name": "李女士",
|
||||
"phone": "13711112222",
|
||||
"subject": "询问秋季团期",
|
||||
"message": "请问9月底有哪些团期可以选择?",
|
||||
"ip": "::1",
|
||||
"source": "contact-form"
|
||||
},
|
||||
{
|
||||
"id": "CT1774023293117",
|
||||
"createdAt": "2026-03-20T16:14:53.117Z",
|
||||
"name": "测试用户",
|
||||
"phone": "13800138000",
|
||||
"subject": "行程咨询",
|
||||
"message": "请问七月份有空档吗,想带家人来",
|
||||
"ip": "::1",
|
||||
"source": "contact-form"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,107 @@
|
||||
[
|
||||
{
|
||||
"id": "CZ1774007128348",
|
||||
"createdAt": "2026-03-20T11:45:28.348Z",
|
||||
"name": "测试用户",
|
||||
"phone": "13800138000",
|
||||
"wechat": "",
|
||||
"adults": 2,
|
||||
"children": 1,
|
||||
"childAges": [
|
||||
5
|
||||
],
|
||||
"date": "2026-07-01",
|
||||
"duration": "5d4n",
|
||||
"activities": [
|
||||
"骑马",
|
||||
"露营"
|
||||
],
|
||||
"budget": "1-2万",
|
||||
"remarks": "这是测试提交",
|
||||
"ip": "::1",
|
||||
"source": "customize-form"
|
||||
},
|
||||
{
|
||||
"id": "CZ1774007220100",
|
||||
"createdAt": "2026-03-20T11:47:00.100Z",
|
||||
"name": "王小明",
|
||||
"phone": "13912345678",
|
||||
"wechat": "wxid_test",
|
||||
"adults": 2,
|
||||
"children": 2,
|
||||
"childAges": [
|
||||
6,
|
||||
9
|
||||
],
|
||||
"date": "2026-08-15",
|
||||
"duration": "6d5n",
|
||||
"activities": [
|
||||
"骑马",
|
||||
"露营",
|
||||
"捕鱼"
|
||||
],
|
||||
"budget": "2-3万",
|
||||
"remarks": "希望住蒙古包",
|
||||
"ip": "::1",
|
||||
"source": "customize-form"
|
||||
},
|
||||
{
|
||||
"id": "CZ1774023293092",
|
||||
"createdAt": "2026-03-20T16:14:53.092Z",
|
||||
"name": "测试用户",
|
||||
"phone": "13800138000",
|
||||
"wechat": "",
|
||||
"adults": 2,
|
||||
"children": 1,
|
||||
"childAges": [
|
||||
5
|
||||
],
|
||||
"date": "2026-07-15",
|
||||
"duration": "6d5n",
|
||||
"activities": [],
|
||||
"budget": "8000-12000",
|
||||
"remarks": "",
|
||||
"ip": "::1",
|
||||
"source": "customize-form"
|
||||
},
|
||||
{
|
||||
"id": "CZ1774057315225",
|
||||
"createdAt": "2026-03-21T01:41:55.225Z",
|
||||
"name": "测试用户",
|
||||
"phone": "13800138000",
|
||||
"wechat": "",
|
||||
"adults": 2,
|
||||
"children": 1,
|
||||
"childAges": [],
|
||||
"date": "",
|
||||
"duration": "6天5晚",
|
||||
"activities": [
|
||||
"骑马",
|
||||
"旅拍"
|
||||
],
|
||||
"budget": "品质优先",
|
||||
"remarks": "",
|
||||
"ip": "::1",
|
||||
"source": "customize-form"
|
||||
},
|
||||
{
|
||||
"id": "CZ1774335344590",
|
||||
"createdAt": "2026-03-24T06:55:44.590Z",
|
||||
"name": "发电公司",
|
||||
"phone": "17600545225",
|
||||
"wechat": "",
|
||||
"adults": 2,
|
||||
"children": 0,
|
||||
"childAges": [],
|
||||
"date": "2026-03-26",
|
||||
"duration": "5天4晚",
|
||||
"activities": [
|
||||
"亲子活动",
|
||||
"旅拍"
|
||||
],
|
||||
"budget": "5000-8000元",
|
||||
"remarks": "第三方公司代发",
|
||||
"ip": "::1",
|
||||
"source": "customize-form"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createHash } from 'crypto'
|
||||
import Database from 'better-sqlite3'
|
||||
import { join } from 'path'
|
||||
|
||||
// ── 数据库连接(用于 session 持久化)────────────────
|
||||
let _sessionDB = null
|
||||
function getSessionDB() {
|
||||
if (!_sessionDB) {
|
||||
const dbPath = join(process.cwd(), 'server', 'database', 'hulai.db')
|
||||
_sessionDB = new Database(dbPath)
|
||||
_sessionDB.pragma('journal_mode = WAL')
|
||||
}
|
||||
return _sessionDB
|
||||
}
|
||||
|
||||
export function hashPassword(password) {
|
||||
return createHash('sha256').update(password).digest('hex')
|
||||
}
|
||||
|
||||
export function createSession(userId) {
|
||||
const token = createHash('sha256')
|
||||
.update(`${userId}-${Date.now()}-${Math.random()}`)
|
||||
.digest('hex')
|
||||
|
||||
const db = getSessionDB()
|
||||
db.prepare('INSERT OR REPLACE INTO sessions (token, user_id, created_at) VALUES (?, ?, ?)')
|
||||
.run(token, userId, new Date().toISOString())
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
export function validateSession(token) {
|
||||
const db = getSessionDB()
|
||||
const session = db.prepare('SELECT * FROM sessions WHERE token = ?').get(token)
|
||||
|
||||
if (!session) return null
|
||||
|
||||
// 7 天过期
|
||||
const createdAt = new Date(session.created_at).getTime()
|
||||
if (Date.now() - createdAt > 7 * 24 * 60 * 60 * 1000) {
|
||||
db.prepare('DELETE FROM sessions WHERE token = ?').run(token)
|
||||
return null
|
||||
}
|
||||
|
||||
return { userId: session.user_id, createdAt }
|
||||
}
|
||||
|
||||
export function destroySession(token) {
|
||||
const db = getSessionDB()
|
||||
db.prepare('DELETE FROM sessions WHERE token = ?').run(token)
|
||||
}
|
||||
|
||||
// 清理过期 session(启动时执行一次)
|
||||
try {
|
||||
const db = getSessionDB()
|
||||
db.prepare("DELETE FROM sessions WHERE datetime(created_at) < datetime('now', '-7 days')").run()
|
||||
} catch {}
|
||||
|
||||
/**
|
||||
* 从请求中提取并验证管理员身份
|
||||
* 返回 session 对象或抛出 401 错误
|
||||
*/
|
||||
export function requireAdmin(event) {
|
||||
const authHeader = getRequestHeader(event, 'authorization')
|
||||
const token = authHeader?.replace('Bearer ', '')
|
||||
|
||||
if (!token) {
|
||||
throw createError({ statusCode: 401, message: '请先登录' })
|
||||
}
|
||||
|
||||
const session = validateSession(token)
|
||||
if (!session) {
|
||||
throw createError({ statusCode: 401, message: '登录已过期,请重新登录' })
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
import { join } from 'path'
|
||||
import * as schema from '../database/schema.js'
|
||||
|
||||
let _db = null
|
||||
|
||||
export function useDB() {
|
||||
if (!_db) {
|
||||
const dbPath = join(process.cwd(), 'server', 'database', 'hulai.db')
|
||||
const sqlite = new Database(dbPath)
|
||||
sqlite.pragma('journal_mode = WAL')
|
||||
_db = drizzle(sqlite, { schema })
|
||||
}
|
||||
return _db
|
||||
}
|
||||
|
||||
export { schema }
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 企业微信群机器人 Webhook 通知
|
||||
* 在 .env 中配置 WECOM_WEBHOOK_KEY 即可启用
|
||||
* 不配置则静默跳过
|
||||
*
|
||||
* 文档:https://developer.work.weixin.qq.com/document/path/91770
|
||||
*/
|
||||
|
||||
const WECOM_API = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send'
|
||||
|
||||
/**
|
||||
* 发送企业微信群机器人 markdown 消息
|
||||
* @param {string} title - 消息标题(用作 markdown 一级标题)
|
||||
* @param {Record<string, string>} fields - { 字段名: 字段值 }
|
||||
*/
|
||||
export async function notifyWecom(title, fields) {
|
||||
const key = process.env.WECOM_WEBHOOK_KEY
|
||||
if (!key) return
|
||||
|
||||
const lines = Object.entries(fields)
|
||||
.map(([k, v]) => `> **${k}**:${v}`)
|
||||
.join('\n')
|
||||
|
||||
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
|
||||
|
||||
const content = `## ${title}\n${lines}\n> **提交时间**:${now}`
|
||||
|
||||
try {
|
||||
await $fetch(`${WECOM_API}?key=${key}`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
msgtype: 'markdown',
|
||||
markdown: { content },
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
// 通知失败不影响主流程,静默忽略
|
||||
console.error('[notify] 企业微信通知失败:', err?.message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 通用分页参数解析
|
||||
*/
|
||||
export function parsePagination(event) {
|
||||
const query = getQuery(event)
|
||||
const page = Math.max(1, Number(query.page) || 1)
|
||||
const limit = Math.min(100, Math.max(1, Number(query.limit) || 20))
|
||||
const offset = (page - 1) * limit
|
||||
const search = (query.search || '').trim()
|
||||
const sort = query.sort || ''
|
||||
const order = query.order === 'asc' ? 'asc' : 'desc'
|
||||
|
||||
return { page, limit, offset, search, sort, order }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 简单内存速率限制
|
||||
* 每个 IP 每小时最多允许 MAX_REQUESTS 次提交
|
||||
*/
|
||||
|
||||
const WINDOW_MS = 60 * 60 * 1000 // 1 小时
|
||||
const MAX_REQUESTS = 5
|
||||
|
||||
// Map<ip, { count: number, resetAt: number }>
|
||||
const store = new Map()
|
||||
|
||||
// 每 2 小时清理一次过期记录,防止内存泄漏
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [ip, record] of store.entries()) {
|
||||
if (now > record.resetAt) {
|
||||
store.delete(ip)
|
||||
}
|
||||
}
|
||||
}, 2 * 60 * 60 * 1000)
|
||||
|
||||
/**
|
||||
* 检查并记录一次请求
|
||||
* @param {string} ip
|
||||
* @returns {{ allowed: boolean, remaining: number, resetAt: number }}
|
||||
*/
|
||||
export function checkRateLimit(ip) {
|
||||
const now = Date.now()
|
||||
const record = store.get(ip)
|
||||
|
||||
if (!record || now > record.resetAt) {
|
||||
store.set(ip, { count: 1, resetAt: now + WINDOW_MS })
|
||||
return { allowed: true, remaining: MAX_REQUESTS - 1, resetAt: now + WINDOW_MS }
|
||||
}
|
||||
|
||||
if (record.count >= MAX_REQUESTS) {
|
||||
return { allowed: false, remaining: 0, resetAt: record.resetAt }
|
||||
}
|
||||
|
||||
record.count++
|
||||
return { allowed: true, remaining: MAX_REQUESTS - record.count, resetAt: record.resetAt }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 文件存储工具
|
||||
* 将表单数据追加写入 server/storage/ 目录下的 JSON 文件
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
const STORAGE_DIR = resolve(process.cwd(), 'server/storage')
|
||||
|
||||
/**
|
||||
* 确保存储目录存在
|
||||
*/
|
||||
async function ensureDir() {
|
||||
try {
|
||||
await fs.mkdir(STORAGE_DIR, { recursive: true })
|
||||
} catch {
|
||||
// 目录已存在,忽略
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将记录追加到指定文件
|
||||
* @param {string} filename - 不含路径,如 'customize.json'
|
||||
* @param {object} record
|
||||
*/
|
||||
export async function appendRecord(filename, record) {
|
||||
await ensureDir()
|
||||
const filepath = resolve(STORAGE_DIR, filename)
|
||||
|
||||
let records = []
|
||||
try {
|
||||
const raw = await fs.readFile(filepath, 'utf-8')
|
||||
records = JSON.parse(raw)
|
||||
} catch {
|
||||
// 文件不存在或解析失败,从空数组开始
|
||||
}
|
||||
|
||||
records.push(record)
|
||||
await fs.writeFile(filepath, JSON.stringify(records, null, 2), 'utf-8')
|
||||
return record
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取所有记录(管理员用)
|
||||
* @param {string} filename
|
||||
*/
|
||||
export async function readRecords(filename) {
|
||||
const filepath = resolve(STORAGE_DIR, filename)
|
||||
try {
|
||||
const raw = await fs.readFile(filepath, 'utf-8')
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户