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,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🎉 数据导入完成!')
|
||||
在新工单中引用
屏蔽一个用户