feat: 官网代码全量更新 (2026-04-18)
这个提交包含在:
@@ -0,0 +1,39 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
|
||||
/**
|
||||
* 对 public/uploads 下的图片跑 tesseract chi_sim OCR。
|
||||
* Body: { image: '/uploads/reviews/xxx.jpg' }
|
||||
* 仅允许 /uploads/ 前缀,防止任意文件读取。
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
const { image } = await readBody(event)
|
||||
|
||||
if (!image || typeof image !== 'string' || !image.startsWith('/uploads/')) {
|
||||
throw createError({ statusCode: 422, message: '仅支持 /uploads/ 下的图片' })
|
||||
}
|
||||
|
||||
const rel = image.replace(/^\/+/, '')
|
||||
const abs = resolve(join(process.cwd(), 'public', rel))
|
||||
const allowedRoot = resolve(join(process.cwd(), 'public/uploads'))
|
||||
if (!abs.startsWith(allowedRoot + '/') && abs !== allowedRoot) {
|
||||
throw createError({ statusCode: 400, message: '非法路径' })
|
||||
}
|
||||
if (!existsSync(abs)) {
|
||||
throw createError({ statusCode: 404, message: '图片不存在' })
|
||||
}
|
||||
|
||||
try {
|
||||
const text = execFileSync('tesseract', [abs, 'stdout', '-l', 'chi_sim'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
})
|
||||
return { text: text || '' }
|
||||
} catch (e) {
|
||||
throw createError({ statusCode: 500, message: 'OCR 执行失败(请确认已安装 tesseract + chi_sim 语言包)' })
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { desc, like, or, sql } from 'drizzle-orm'
|
||||
import { desc, asc, eq, like, or, and, sql, isNotNull, ne, isNull } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../utils/db.js'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
import { parsePagination } from '../../utils/pagination.js'
|
||||
@@ -8,15 +8,28 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const db = useDB()
|
||||
const { page, limit, offset, search } = parsePagination(event)
|
||||
const q = getQuery(event)
|
||||
|
||||
let whereClause = sql`1=1`
|
||||
const conditions = []
|
||||
if (search) {
|
||||
whereClause = or(
|
||||
conditions.push(or(
|
||||
like(schema.reviews.author, `%${search}%`),
|
||||
like(schema.reviews.content, `%${search}%`),
|
||||
like(schema.reviews.title, `%${search}%`)
|
||||
)
|
||||
like(schema.reviews.title, `%${search}%`),
|
||||
))
|
||||
}
|
||||
if (q.published === 'true' || q.published === '1') conditions.push(eq(schema.reviews.published, true))
|
||||
if (q.published === 'false' || q.published === '0') conditions.push(eq(schema.reviews.published, false))
|
||||
if (q.hasScreenshot === 'true' || q.hasScreenshot === '1') {
|
||||
conditions.push(and(isNotNull(schema.reviews.screenshot), ne(schema.reviews.screenshot, '')))
|
||||
}
|
||||
if (q.hasScreenshot === 'false' || q.hasScreenshot === '0') {
|
||||
conditions.push(or(isNull(schema.reviews.screenshot), eq(schema.reviews.screenshot, '')))
|
||||
}
|
||||
if (q.scene) conditions.push(like(schema.reviews.scenes, `%"${q.scene}"%`))
|
||||
if (q.concern) conditions.push(like(schema.reviews.concerns, `%"${q.concern}"%`))
|
||||
|
||||
const whereClause = conditions.length ? and(...conditions) : sql`1=1`
|
||||
|
||||
const [{ count: total }] = db
|
||||
.select({ count: sql`count(*)` })
|
||||
@@ -24,14 +37,21 @@ export default defineEventHandler(async (event) => {
|
||||
.where(whereClause)
|
||||
.all()
|
||||
|
||||
const items = db
|
||||
const rows = db
|
||||
.select()
|
||||
.from(schema.reviews)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(schema.reviews.id))
|
||||
.orderBy(asc(schema.reviews.sortOrder), desc(schema.reviews.id))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all()
|
||||
|
||||
// 解析 JSON 字段方便前端展示
|
||||
const items = rows.map(r => ({
|
||||
...r,
|
||||
scenes: (() => { try { return JSON.parse(r.scenes || '[]') } catch { return [] } })(),
|
||||
concerns: (() => { try { return JSON.parse(r.concerns || '[]') } catch { return [] } })(),
|
||||
}))
|
||||
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) }
|
||||
})
|
||||
|
||||
@@ -16,7 +16,12 @@ export default defineEventHandler(async (event) => {
|
||||
title: body.title || '',
|
||||
content: body.content,
|
||||
date: body.date || '',
|
||||
screenshot: body.screenshot || '',
|
||||
scenes: JSON.stringify(Array.isArray(body.scenes) ? body.scenes : []),
|
||||
concerns: JSON.stringify(Array.isArray(body.concerns) ? body.concerns : []),
|
||||
sortOrder: body.sortOrder || 0,
|
||||
verified: body.verified ? 1 : 0,
|
||||
published: body.published === false ? 0 : 1,
|
||||
}).run()
|
||||
|
||||
return { success: true, id: result.lastInsertRowid }
|
||||
|
||||
@@ -8,5 +8,9 @@ export default defineEventHandler(async (event) => {
|
||||
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
|
||||
return {
|
||||
...item,
|
||||
scenes: (() => { try { return JSON.parse(item.scenes || '[]') } catch { return [] } })(),
|
||||
concerns: (() => { try { return JSON.parse(item.concerns || '[]') } catch { return [] } })(),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,6 +15,9 @@ export default defineEventHandler(async (event) => {
|
||||
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.screenshot !== undefined) updateData.screenshot = body.screenshot
|
||||
if (body.scenes !== undefined) updateData.scenes = Array.isArray(body.scenes) ? JSON.stringify(body.scenes) : body.scenes
|
||||
if (body.concerns !== undefined) updateData.concerns = Array.isArray(body.concerns) ? JSON.stringify(body.concerns) : body.concerns
|
||||
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
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { inArray } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../../utils/db.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
/**
|
||||
* 批量操作:{ action: 'publish'|'unpublish'|'delete', ids: number[] }
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
const { action, ids } = await readBody(event)
|
||||
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
throw createError({ statusCode: 422, message: 'ids 不能为空' })
|
||||
}
|
||||
const db = useDB()
|
||||
|
||||
switch (action) {
|
||||
case 'publish':
|
||||
db.update(schema.reviews).set({ published: true }).where(inArray(schema.reviews.id, ids)).run()
|
||||
break
|
||||
case 'unpublish':
|
||||
db.update(schema.reviews).set({ published: false }).where(inArray(schema.reviews.id, ids)).run()
|
||||
break
|
||||
case 'delete':
|
||||
db.delete(schema.reviews).where(inArray(schema.reviews.id, ids)).run()
|
||||
break
|
||||
default:
|
||||
throw createError({ statusCode: 422, message: '未知 action' })
|
||||
}
|
||||
return { success: true, affected: ids.length }
|
||||
})
|
||||
@@ -17,7 +17,8 @@ export default defineEventHandler(async (event) => {
|
||||
.get()
|
||||
|
||||
if (!item) {
|
||||
throw createError({ statusCode: 404, message: '未找到该配置' })
|
||||
// 返回空配置壳,前端按默认值渲染;保存时会自动创建
|
||||
return { key: query.key, label: '', data: {} }
|
||||
}
|
||||
|
||||
return { ...item, data: JSON.parse(item.data) }
|
||||
|
||||
+59
-15
@@ -1,4 +1,4 @@
|
||||
import { count } from 'drizzle-orm'
|
||||
import { count, eq, desc, sql, gte } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../utils/db.js'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
|
||||
@@ -7,21 +7,65 @@ export default defineEventHandler(async (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()
|
||||
const [newsRow] = db.select({ c: count() }).from(schema.articles).where(eq(schema.articles.type, 'news')).all()
|
||||
const [blogRow] = db.select({ c: count() }).from(schema.articles).where(eq(schema.articles.type, 'blog')).all()
|
||||
const [reviewsRow] = db.select({ c: count() }).from(schema.reviews).all()
|
||||
const [faqsRow] = db.select({ c: count() }).from(schema.faqs).all()
|
||||
const [galleryRow] = db.select({ c: count() }).from(schema.galleryItems).all()
|
||||
const [storiesRow] = db.select({ c: count() }).from(schema.stories).all()
|
||||
const [subTotalRow] = db.select({ c: count() }).from(schema.submissions).all()
|
||||
const [subUnreadRow] = db.select({ c: count() }).from(schema.submissions).where(eq(schema.submissions.read, false)).all()
|
||||
const [siteContentRow] = db.select({ c: count() }).from(schema.siteContent).all()
|
||||
|
||||
// 产品线数量(从 site_content 的 product-lines 键解析)
|
||||
let productLines = 0
|
||||
try {
|
||||
const pl = db.select().from(schema.siteContent).where(eq(schema.siteContent.key, 'product-lines')).all()
|
||||
if (pl[0]?.data) {
|
||||
const arr = JSON.parse(pl[0].data)
|
||||
if (Array.isArray(arr)) productLines = arr.length
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 近 7 天真实提交(仅统计用户表单,文章/评价为后台手动录入不计入)
|
||||
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()
|
||||
const [recentSubRow] = db.select({ c: count() }).from(schema.submissions).where(gte(schema.submissions.createdAt, weekAgo)).all()
|
||||
const [recentContactRow] = db.select({ c: count() }).from(schema.submissions).where(sql`${schema.submissions.type} = 'contact' AND ${schema.submissions.createdAt} >= ${weekAgo}`).all()
|
||||
const [recentCustomizeRow] = db.select({ c: count() }).from(schema.submissions).where(sql`${schema.submissions.type} = 'customize' AND ${schema.submissions.createdAt} >= ${weekAgo}`).all()
|
||||
|
||||
// 最近 5 条提交 / 最近 5 条评价
|
||||
const recentSubmissions = db.select({
|
||||
id: schema.submissions.id, type: schema.submissions.type,
|
||||
read: schema.submissions.read, createdAt: schema.submissions.createdAt,
|
||||
}).from(schema.submissions).orderBy(desc(schema.submissions.id)).limit(5).all()
|
||||
|
||||
const recentReviews = db.select({
|
||||
id: schema.reviews.id, author: schema.reviews.author, rating: schema.reviews.rating,
|
||||
title: schema.reviews.title, createdAt: schema.reviews.createdAt,
|
||||
}).from(schema.reviews).orderBy(desc(schema.reviews.id)).limit(5).all()
|
||||
|
||||
return {
|
||||
articles: articles.count,
|
||||
reviews: reviews.count,
|
||||
faqs: faqs.count,
|
||||
gallery: gallery.count,
|
||||
stories: stories.count,
|
||||
submissions: submissions.count,
|
||||
siteContent: siteContent.count,
|
||||
articles: {
|
||||
total: newsRow.c + blogRow.c,
|
||||
news: newsRow.c,
|
||||
blog: blogRow.c,
|
||||
},
|
||||
reviews: reviewsRow.c,
|
||||
faqs: faqsRow.c,
|
||||
gallery: galleryRow.c,
|
||||
stories: storiesRow.c,
|
||||
submissions: {
|
||||
total: subTotalRow.c,
|
||||
unread: subUnreadRow.c,
|
||||
},
|
||||
siteContent: siteContentRow.c,
|
||||
productLines,
|
||||
recent: {
|
||||
submissions7d: recentSubRow.c,
|
||||
contact7d: recentContactRow.c,
|
||||
customize7d: recentCustomizeRow.c,
|
||||
},
|
||||
recentSubmissions,
|
||||
recentReviews,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -20,6 +20,10 @@ export default defineEventHandler(async (event) => {
|
||||
tripProduct: body.tripProduct || '',
|
||||
travelDate: body.travelDate || '',
|
||||
coverImage: body.coverImage || '',
|
||||
summary: body.summary || '',
|
||||
author: body.author || '',
|
||||
sourceUrl: body.sourceUrl || '',
|
||||
tags: JSON.stringify(Array.isArray(body.tags) ? body.tags : []),
|
||||
sections: body.sections ? JSON.stringify(body.sections) : '[]',
|
||||
keyMoments: body.keyMoments ? JSON.stringify(body.keyMoments) : '[]',
|
||||
seoTitle: body.seoTitle || '',
|
||||
|
||||
@@ -8,5 +8,10 @@ export default defineEventHandler(async (event) => {
|
||||
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
|
||||
return {
|
||||
...item,
|
||||
tags: (() => { try { return JSON.parse(item.tags || '[]') } catch { return [] } })(),
|
||||
sections: (() => { try { return JSON.parse(item.sections || '[]') } catch { return [] } })(),
|
||||
keyMoments: (() => { try { return JSON.parse(item.keyMoments || '[]') } catch { return [] } })(),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -19,6 +19,10 @@ export default defineEventHandler(async (event) => {
|
||||
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.summary !== undefined) updateData.summary = body.summary
|
||||
if (body.author !== undefined) updateData.author = body.author
|
||||
if (body.sourceUrl !== undefined) updateData.sourceUrl = body.sourceUrl
|
||||
if (body.tags !== undefined) updateData.tags = Array.isArray(body.tags) ? JSON.stringify(body.tags) : body.tags
|
||||
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
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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 db = useDB()
|
||||
|
||||
const [existing] = db
|
||||
.select()
|
||||
.from(schema.submissions)
|
||||
.where(eq(schema.submissions.id, id))
|
||||
.all()
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '记录不存在' })
|
||||
}
|
||||
|
||||
db.delete(schema.submissions)
|
||||
.where(eq(schema.submissions.id, id))
|
||||
.run()
|
||||
|
||||
return { success: true, id }
|
||||
})
|
||||
@@ -10,9 +10,26 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 400, message: '无效的 id 参数' })
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
if (typeof body.read !== 'boolean') {
|
||||
throw createError({ statusCode: 400, message: 'read 字段必须是布尔值' })
|
||||
const body = await readBody(event) || {}
|
||||
const patch = {}
|
||||
|
||||
if (typeof body.read === 'boolean') {
|
||||
patch.read = body.read
|
||||
}
|
||||
|
||||
if (typeof body.replied === 'boolean') {
|
||||
patch.replied = body.replied
|
||||
patch.repliedAt = body.replied ? new Date().toISOString() : null
|
||||
// 标记为已回复时自动视为已读
|
||||
if (body.replied) patch.read = true
|
||||
}
|
||||
|
||||
if (body.data && typeof body.data === 'object' && !Array.isArray(body.data)) {
|
||||
patch.data = JSON.stringify(body.data)
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
throw createError({ statusCode: 400, message: '请求体必须包含 read/replied/data 中的至少一个字段' })
|
||||
}
|
||||
|
||||
const db = useDB()
|
||||
@@ -28,7 +45,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
db.update(schema.submissions)
|
||||
.set({ read: body.read })
|
||||
.set(patch)
|
||||
.where(eq(schema.submissions.id, id))
|
||||
.run()
|
||||
|
||||
|
||||
@@ -24,12 +24,30 @@ function buildFromEntityTable(db, key, siteData) {
|
||||
scenes: r.scenes ? JSON.parse(r.scenes) : [],
|
||||
concerns: r.concerns ? JSON.parse(r.concerns) : [],
|
||||
}))
|
||||
// 合并 site_content 的 summary(如果有/��
|
||||
const summary = siteData?.summary || {
|
||||
totalCount: items.length,
|
||||
approvalRate: '98%',
|
||||
// 高频关键词:从评价正文中按预设词表统计出现频次
|
||||
const KEYWORD_DICT = [
|
||||
'领队', '服务', '贴心', '耐心', '细心', '专业',
|
||||
'摄影', '拍照', '旅拍', '骑马', '越野车', '滑草',
|
||||
'营地', '烧烤', '住宿', '行程', '错峰', '不排队',
|
||||
'亲子', '小朋友', '孩子', '家人', '老人',
|
||||
'草原', '风景', '美', '惊艳', '难忘', '舒适',
|
||||
'安排', '推荐', '值得', '良心', '安心', '用心',
|
||||
]
|
||||
const freq = {}
|
||||
for (const it of items) {
|
||||
const c = it.content || ''
|
||||
for (const kw of KEYWORD_DICT) if (c.includes(kw)) freq[kw] = (freq[kw] || 0) + 1
|
||||
}
|
||||
const keywords = Object.entries(freq)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 12)
|
||||
.map(([w]) => w)
|
||||
|
||||
const summary = {
|
||||
totalCount: siteData?.summary?.totalCount || items.length,
|
||||
approvalRate: siteData?.summary?.approvalRate || '98%',
|
||||
keywords: siteData?.summary?.keywords?.length ? siteData.summary.keywords : keywords,
|
||||
}
|
||||
// 用实体表 items 覆盖 site_content 的 items
|
||||
return { summary, items }
|
||||
}
|
||||
|
||||
@@ -82,6 +100,7 @@ function buildFromEntityTable(db, key, siteData) {
|
||||
.all()
|
||||
.map(s => ({
|
||||
...s,
|
||||
tags: s.tags ? (() => { try { return JSON.parse(s.tags) } catch { return [] } })() : [],
|
||||
sections: s.sections ? JSON.parse(s.sections) : [],
|
||||
keyMoments: s.keyMoments ? JSON.parse(s.keyMoments) : [],
|
||||
}))
|
||||
@@ -91,7 +110,7 @@ function buildFromEntityTable(db, key, siteData) {
|
||||
case 'news': {
|
||||
const items = db.select().from(schema.articles)
|
||||
.where(eq(schema.articles.published, true))
|
||||
.orderBy(desc(schema.articles.id))
|
||||
.orderBy(desc(schema.articles.createdAt), desc(schema.articles.id))
|
||||
.all()
|
||||
.filter(a => a.type === 'news')
|
||||
.map(a => ({
|
||||
@@ -153,7 +172,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (ENTITY_KEYS.includes(key)) {
|
||||
const result = buildFromEntityTable(db, key, siteData)
|
||||
if (result) {
|
||||
setHeader(event, 'Cache-Control', 'public, max-age=60')
|
||||
setHeader(event, 'Cache-Control', 'no-store, must-revalidate')
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -165,7 +184,7 @@ export default defineEventHandler(async (event) => {
|
||||
.get()
|
||||
const productsData = productsItem ? JSON.parse(productsItem.data) : null
|
||||
if (productsData?.summerCamp) {
|
||||
setHeader(event, 'Cache-Control', 'public, max-age=60')
|
||||
setHeader(event, 'Cache-Control', 'no-store, must-revalidate')
|
||||
return productsData.summerCamp
|
||||
}
|
||||
}
|
||||
@@ -175,6 +194,6 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 404, message: `未找到配置项: ${key}` })
|
||||
}
|
||||
|
||||
setHeader(event, 'Cache-Control', 'public, max-age=60')
|
||||
setHeader(event, 'Cache-Control', 'no-store, must-revalidate')
|
||||
return siteData
|
||||
})
|
||||
|
||||
@@ -52,6 +52,9 @@ CREATE TABLE IF NOT EXISTS reviews (
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
date TEXT,
|
||||
screenshot TEXT,
|
||||
scenes TEXT,
|
||||
concerns TEXT,
|
||||
verified INTEGER DEFAULT 0,
|
||||
published INTEGER DEFAULT 1,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
@@ -104,6 +107,8 @@ CREATE TABLE IF NOT EXISTS submissions (
|
||||
data TEXT NOT NULL,
|
||||
ip TEXT,
|
||||
read INTEGER DEFAULT 0,
|
||||
replied INTEGER DEFAULT 0,
|
||||
replied_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
@@ -125,4 +130,15 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
db.exec(tables)
|
||||
console.log('✅ 数据库表创建完成:', DB_PATH)
|
||||
|
||||
// ── 增量迁移:为已有表补齐新列(幂等)───────────────
|
||||
function addColumnIfMissing(table, column, ddl) {
|
||||
const info = db.prepare(`PRAGMA table_info(${table})`).all()
|
||||
if (!info.some(c => c.name === column)) {
|
||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddl}`)
|
||||
console.log(` + ${table}.${column} 列已添加`)
|
||||
}
|
||||
}
|
||||
addColumnIfMissing('submissions', 'replied', 'INTEGER DEFAULT 0')
|
||||
addColumnIfMissing('submissions', 'replied_at', 'TEXT')
|
||||
|
||||
db.close()
|
||||
|
||||
@@ -81,6 +81,10 @@ export const stories = sqliteTable('stories', {
|
||||
tripProduct: text('trip_product'),
|
||||
travelDate: text('travel_date'),
|
||||
coverImage: text('cover_image'),
|
||||
summary: text('summary'), // 卡片展示的 2 行摘要
|
||||
author: text('author'), // 客户昵称/作者
|
||||
sourceUrl: text('source_url'), // 小红书等外链(有则前台卡片外跳)
|
||||
tags: text('tags'), // JSON 数组(标签)
|
||||
// sections 存为 JSON 字符串: [{ heading, content }]
|
||||
sections: text('sections'),
|
||||
keyMoments: text('key_moments'), // JSON 数组
|
||||
@@ -98,6 +102,8 @@ export const submissions = sqliteTable('submissions', {
|
||||
data: text('data').notNull(), // JSON 字符串,存完整表单数据
|
||||
ip: text('ip'),
|
||||
read: integer('read', { mode: 'boolean' }).default(false),
|
||||
replied: integer('replied', { mode: 'boolean' }).default(false),
|
||||
repliedAt: text('replied_at'),
|
||||
createdAt: text('created_at').notNull().$defaultFn(() => new Date().toISOString()),
|
||||
})
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ console.log(`✅ 客户故事导入: ${storiesData.stories.length} 条`)
|
||||
|
||||
// ── 导入站点配置(复杂 JSON 直接存)──────────────────
|
||||
const siteConfigs = [
|
||||
{ key: 'product-lines', label: '产品线列表', file: 'product-lines.json' },
|
||||
{ key: 'products', label: '产品管理', file: 'products.json' },
|
||||
{ key: 'autumn-products', label: '秋季产品', file: 'autumn-products.json' },
|
||||
{ key: 'winter-products', label: '冬季产品', file: 'winter-products.json' },
|
||||
@@ -149,6 +150,8 @@ const siteConfigs = [
|
||||
{ key: 'courses', label: '研学课程', file: 'courses.json' },
|
||||
{ key: 'calendar', label: '出行日历', file: 'calendar.json' },
|
||||
{ key: 'guides', label: '出行指南', file: 'guides.json' },
|
||||
{ key: 'guide-custom', label: '定制游怎么选 (文章)', file: 'guide-custom.json' },
|
||||
{ key: 'section-titles', label: '栏目标题', file: 'section-titles.json' },
|
||||
{ key: 'navigation', label: '导航配置', file: 'navigation.json' },
|
||||
{ key: 'brand', label: '品牌信息', file: 'brand.json' },
|
||||
{ key: 'contact', label: '联系方式', file: 'contact.json' },
|
||||
|
||||
+1
-22
@@ -1,22 +1 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
[]
|
||||
|
||||
+1
-107
@@ -1,107 +1 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
[]
|
||||
|
||||
在新工单中引用
屏蔽一个用户