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>
这个提交包含在:
Mimingguang
2026-03-24 17:48:02 +08:00
共同撰写人 Claude Opus 4.6
当前提交 ee078b61bc
共修改 444 个文件,包含 59890 行新增和 0 行删除
+52
查看文件
@@ -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),
}
})
+34
查看文件
@@ -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 }
})
+16
查看文件
@@ -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 }
})
+18
查看文件
@@ -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
})
+33
查看文件
@@ -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 }
})
+36
查看文件
@@ -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) }
})
+20
查看文件
@@ -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 }
})
+14
查看文件
@@ -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 }
})
+12
查看文件
@@ -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
})
+21
查看文件
@@ -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 }
})
+24
查看文件
@@ -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) }
})
+23
查看文件
@@ -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 }
})
+11
查看文件
@@ -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 }
})
+12
查看文件
@@ -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
})
+25
查看文件
@@ -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 }
})
+51
查看文件
@@ -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 }
})
+37
查看文件
@@ -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) }
})
+23
查看文件
@@ -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 }
})
+14
查看文件
@@ -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 }
})
+12
查看文件
@@ -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
})
+25
查看文件
@@ -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 }
})
+38
查看文件
@@ -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 }
})
+37
查看文件
@@ -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 }
})
+51
查看文件
@@ -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 }
})
+47
查看文件
@@ -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 }
})
+27
查看文件
@@ -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,
}
})
+24
查看文件
@@ -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) }
})
+31
查看文件
@@ -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 }
})
+11
查看文件
@@ -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 }
})
+12
查看文件
@@ -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
})
+32
查看文件
@@ -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 }
})
+43
查看文件
@@ -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) }
})
+45
查看文件
@@ -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 }
})
+58
查看文件
@@ -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 }
})
+99
查看文件
@@ -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: '留言已收到,我们会尽快与您联系',
}
})
+22
查看文件
@@ -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)
})
+116
查看文件
@@ -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小时内联系您',
}
})