feat: 我的页整合扫一扫/设置与提现、all-chapters去重、内容上传API、文档与后台登录

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
卡若
2026-02-20 18:50:16 +08:00
parent 09fb67d2af
commit 0e4baa4b7f
27 changed files with 1526 additions and 347 deletions

41
app/admin/error.tsx Normal file
View File

@@ -0,0 +1,41 @@
'use client'
import { useEffect } from 'react'
export default function AdminError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error('[Admin] 页面错误:', error)
}, [error])
return (
<div className="min-h-screen flex items-center justify-center bg-[#0a1628]">
<div className="bg-[#0f2137] border border-gray-700/50 rounded-2xl p-8 max-w-md w-full mx-4 shadow-xl">
<div className="text-center">
<div className="text-5xl mb-4">😞</div>
<h2 className="text-xl font-bold text-white mb-2"></h2>
<p className="text-gray-400 text-sm mb-6"></p>
<div className="flex gap-3">
<button
onClick={reset}
className="flex-1 py-2.5 rounded-lg bg-[#38bdac] hover:bg-[#2da396] text-white text-sm font-medium"
>
</button>
<a
href="/admin"
className="flex-1 py-2.5 rounded-lg bg-gray-700 hover:bg-gray-600 text-gray-200 text-sm font-medium text-center"
>
</a>
</div>
</div>
</div>
</div>
)
}

View File

@@ -11,24 +11,25 @@ export default function AdminDashboard() {
const [users, setUsers] = useState<any[]>([])
const [purchases, setPurchases] = useState<any[]>([])
// 从API获取数据
// 从API获取数据(任意接口失败时仍保持页面可展示,不抛错)
async function loadData() {
try {
// 获取用户数据
const usersRes = await fetch('/api/db/users')
const usersData = await usersRes.json()
if (usersData.success && usersData.users) {
const usersData = await usersRes.ok ? usersRes.json().catch(() => ({})) : { success: false }
if (usersData.success && Array.isArray(usersData.users)) {
setUsers(usersData.users)
}
// 获取订单数据
} catch (e) {
console.warn('加载用户数据失败', e)
}
try {
const ordersRes = await fetch('/api/orders')
const ordersData = await ordersRes.json()
if (ordersData.success && ordersData.orders) {
const ordersData = await ordersRes.ok ? ordersRes.json().catch(() => ({})) : { success: false }
if (ordersData.success && Array.isArray(ordersData.orders)) {
setPurchases(ordersData.orders)
}
} catch (e) {
console.log('加载数据失败', e)
console.warn('加载订单数据失败', e)
}
}
@@ -63,7 +64,7 @@ export default function AdminDashboard() {
)
}
const totalRevenue = purchases.reduce((sum, p) => sum + (p.amount || 0), 0)
const totalRevenue = purchases.reduce((sum, p) => sum + (Number(p?.amount) || 0), 0)
const totalUsers = users.length
const totalPurchases = purchases.length
@@ -71,7 +72,7 @@ export default function AdminDashboard() {
{ title: "总用户数", value: totalUsers, icon: Users, color: "text-blue-400", bg: "bg-blue-500/20", link: "/admin/users" },
{
title: "总收入",
value: `¥${totalRevenue.toFixed(2)}`,
value: `¥${Number(totalRevenue).toFixed(2)}`,
icon: TrendingUp,
color: "text-[#38bdac]",
bg: "bg-[#38bdac]/20",
@@ -80,7 +81,7 @@ export default function AdminDashboard() {
{ title: "订单数", value: totalPurchases, icon: ShoppingBag, color: "text-purple-400", bg: "bg-purple-500/20", link: "/admin/orders" },
{
title: "转化率",
value: `${totalUsers > 0 ? ((totalPurchases / totalUsers) * 100).toFixed(1) : 0}%`,
value: `${totalUsers > 0 ? (Number(totalPurchases) / Number(totalUsers) * 100).toFixed(1) : 0}%`,
icon: BookOpen,
color: "text-orange-400",
bg: "bg-orange-500/20",
@@ -132,11 +133,11 @@ export default function AdminDashboard() {
>
<div>
<p className="text-sm font-medium text-white">{p.sectionTitle || "整本购买"}</p>
<p className="text-xs text-gray-500">{new Date(p.createdAt).toLocaleString()}</p>
<p className="text-xs text-gray-500">{p?.createdAt ? new Date(p.createdAt).toLocaleString() : "-"}</p>
</div>
<div className="text-right">
<p className="text-sm font-bold text-[#38bdac]">+¥{p.amount}</p>
<p className="text-xs text-gray-400">{p.paymentMethod || "微信支付"}</p>
<p className="text-sm font-bold text-[#38bdac]">+¥{Number(p?.amount) || 0}</p>
<p className="text-xs text-gray-400">{p?.paymentMethod || "微信支付"}</p>
</div>
</div>
))}
@@ -169,7 +170,7 @@ export default function AdminDashboard() {
</div>
</div>
<p className="text-xs text-gray-400">
{u.createdAt ? new Date(u.createdAt).toLocaleDateString() : "-"}
{u?.createdAt ? new Date(u.createdAt).toLocaleDateString() : "-"}
</p>
</div>
))}

View File

@@ -0,0 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
import { getAdminCookieName, getAdminCookieOptions } from '@/lib/admin-auth'
export async function POST(_req: NextRequest) {
const res = NextResponse.json({ success: true })
const opts = getAdminCookieOptions()
res.cookies.set(getAdminCookieName(), '', { ...opts, maxAge: 0 })
return res
}

View File

@@ -0,0 +1,72 @@
/**
* Web 端登录:手机号 + 密码
* POST { phone, password } -> 校验后返回用户信息(不含密码)
*/
import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
import { verifyPassword } from '@/lib/password'
function mapRowToUser(r: any) {
return {
id: r.id,
phone: r.phone || '',
nickname: r.nickname || '',
isAdmin: !!r.is_admin,
purchasedSections: Array.isArray(r.purchased_sections)
? r.purchased_sections
: (r.purchased_sections ? JSON.parse(String(r.purchased_sections)) : []) || [],
hasFullBook: !!r.has_full_book,
referralCode: r.referral_code || '',
earnings: parseFloat(String(r.earnings || 0)),
pendingEarnings: parseFloat(String(r.pending_earnings || 0)),
withdrawnEarnings: parseFloat(String(r.withdrawn_earnings || 0)),
referralCount: Number(r.referral_count) || 0,
createdAt: r.created_at || '',
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { phone, password } = body
if (!phone || !password) {
return NextResponse.json(
{ success: false, error: '请输入手机号和密码' },
{ status: 400 }
)
}
const rows = await query(
'SELECT id, phone, nickname, password, is_admin, has_full_book, referral_code, earnings, pending_earnings, withdrawn_earnings, referral_count, purchased_sections, created_at FROM users WHERE phone = ?',
[String(phone).trim()]
) as any[]
if (!rows || rows.length === 0) {
return NextResponse.json(
{ success: false, error: '用户不存在或密码错误' },
{ status: 401 }
)
}
const row = rows[0]
const storedPassword = row.password == null ? '' : String(row.password)
if (!verifyPassword(String(password), storedPassword)) {
return NextResponse.json(
{ success: false, error: '密码错误' },
{ status: 401 }
)
}
const user = mapRowToUser(row)
return NextResponse.json({ success: true, user })
} catch (e) {
console.error('[Auth Login] error:', e)
return NextResponse.json(
{ success: false, error: '登录失败' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,54 @@
/**
* 忘记密码 / 重置密码Web 端)
* POST { phone, newPassword } -> 按手机号更新密码(无验证码版本,适合内测/内部使用)
*/
import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
import { hashPassword } from '@/lib/password'
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { phone, newPassword } = body
if (!phone || !newPassword) {
return NextResponse.json(
{ success: false, error: '请输入手机号和新密码' },
{ status: 400 }
)
}
const trimmedPhone = String(phone).trim()
const trimmedPassword = String(newPassword).trim()
if (trimmedPassword.length < 6) {
return NextResponse.json(
{ success: false, error: '密码至少 6 位' },
{ status: 400 }
)
}
const rows = await query('SELECT id FROM users WHERE phone = ?', [trimmedPhone]) as any[]
if (!rows || rows.length === 0) {
return NextResponse.json(
{ success: false, error: '该手机号未注册' },
{ status: 404 }
)
}
const hashed = hashPassword(trimmedPassword)
await query('UPDATE users SET password = ?, updated_at = NOW() WHERE phone = ?', [
hashed,
trimmedPhone,
])
return NextResponse.json({ success: true, message: '密码已重置,请使用新密码登录' })
} catch (e) {
console.error('[Auth ResetPassword] error:', e)
return NextResponse.json(
{ success: false, error: '重置失败' },
{ status: 500 }
)
}
}

View File

@@ -17,23 +17,30 @@ export async function GET() {
`) as any[]
if (dbChapters && dbChapters.length > 0) {
console.log('[All Chapters API] 从数据库读取成功,共', dbChapters.length, '')
console.log('[All Chapters API] 从数据库读取成功,共', dbChapters.length, '')
// 格式化数据
const allChapters = dbChapters.map((chapter: any) => ({
id: chapter.id,
sectionId: chapter.section_id,
title: chapter.title,
sectionTitle: chapter.section_title,
content: chapter.content,
isFree: !!chapter.is_free,
price: chapter.price || 0,
words: chapter.words || Math.floor(Math.random() * 3000) + 2000,
sectionOrder: chapter.section_order,
chapterOrder: chapter.chapter_order,
createdAt: chapter.created_at,
updatedAt: chapter.updated_at
}))
// 格式化并按 id 去重(保留首次出现)
const seen = new Set<string>()
const allChapters = dbChapters
.map((chapter: any) => ({
id: chapter.id,
sectionId: chapter.section_id ?? chapter.id,
title: chapter.title ?? chapter.section_title,
sectionTitle: chapter.section_title ?? chapter.title,
content: chapter.content,
isFree: !!chapter.is_free,
price: chapter.price || 0,
words: chapter.words || Math.floor(Math.random() * 3000) + 2000,
sectionOrder: chapter.section_order,
chapterOrder: chapter.chapter_order,
createdAt: chapter.created_at,
updatedAt: chapter.updated_at
}))
.filter((row: { id: string }) => {
if (seen.has(row.id)) return false
seen.add(row.id)
return true
})
return NextResponse.json({
success: true,
@@ -44,7 +51,51 @@ export async function GET() {
})
}
} catch (dbError) {
console.log('[All Chapters API] 数据库读取失败,尝试文件读取:', (dbError as Error).message)
console.log('[All Chapters API] sections 表读取失败,尝试 chapters 表:', (dbError as Error).message)
}
// 方案1b: 从 chapters 表读取(与 lib/db 表结构一致)
try {
const dbChapters = await query(`
SELECT id, part_id, part_title, chapter_id, chapter_title, section_title, content,
is_free, price, word_count, sort_order, created_at, updated_at
FROM chapters
ORDER BY sort_order ASC, id ASC
`) as any[]
if (dbChapters && dbChapters.length > 0) {
console.log('[All Chapters API] 从 chapters 表读取成功,共', dbChapters.length, '条')
const seen = new Set<string>()
const allChapters = dbChapters
.map((row: any) => ({
id: row.id,
sectionId: row.id,
title: row.section_title,
sectionTitle: row.section_title,
content: row.content,
isFree: !!row.is_free,
price: row.price || 0,
words: row.word_count || 0,
sectionOrder: row.sort_order ?? 0,
chapterOrder: 0,
createdAt: row.created_at,
updatedAt: row.updated_at
}))
.filter((row: { id: string }) => {
if (seen.has(row.id)) return false
seen.add(row.id)
return true
})
return NextResponse.json({
success: true,
data: allChapters,
chapters: allChapters,
total: allChapters.length,
source: 'database'
})
}
} catch (e2) {
console.log('[All Chapters API] chapters 表读取失败,尝试文件:', (e2 as Error).message)
}
// 方案2: 从JSON文件读取
@@ -72,11 +123,20 @@ export async function GET() {
}
if (chaptersData.length > 0) {
// 添加字数估算
const allChapters = chaptersData.map((chapter: any) => ({
...chapter,
words: chapter.words || Math.floor(Math.random() * 3000) + 2000
}))
// 添加字数估算并按 id 去重
const seen = new Set<string>()
const allChapters = chaptersData
.map((chapter: any) => ({
...chapter,
id: chapter.id ?? chapter.sectionId,
words: chapter.words || Math.floor(Math.random() * 3000) + 2000
}))
.filter((row: any) => {
const id = row.id || row.sectionId
if (!id || seen.has(String(id))) return false
seen.add(String(id))
return true
})
return NextResponse.json({
success: true,

View File

@@ -0,0 +1,97 @@
/**
* 内容上传 API
* 供科室/Skill 直接上传单篇文章到书籍内容,写入 chapters 表
* 字段标题、定价、内容、格式、插入内容中的图片URL 列表)
*/
import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
function slug(id: string): string {
return id.replace(/\s+/g, '-').replace(/[^\w\u4e00-\u9fa5-]/g, '').slice(0, 30) || 'section'
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const {
title,
price = 1,
content = '',
format = 'markdown',
images = [],
partId = 'part-1',
partTitle = '真实的人',
chapterId = 'chapter-1',
chapterTitle = '未分类',
isFree = false,
sectionId
} = body
if (!title || typeof title !== 'string') {
return NextResponse.json(
{ success: false, error: '标题 title 不能为空' },
{ status: 400 }
)
}
// 若内容中含占位符 {{image_0}} {{image_1}},用 images 数组替换
let finalContent = typeof content === 'string' ? content : ''
if (Array.isArray(images) && images.length > 0) {
images.forEach((url: string, i: number) => {
finalContent = finalContent.replace(
new RegExp(`\\{\\{image_${i}\\}\\}`, 'g'),
url.startsWith('http') ? `![图${i + 1}](${url})` : url
)
})
}
// 未替换的占位符去掉
finalContent = finalContent.replace(/\{\{image_\d+\}\}/g, '')
const wordCount = (finalContent || '').length
const id = sectionId || `upload.${slug(title)}.${Date.now()}`
await query(
`INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title, section_title, content, word_count, is_free, price, sort_order, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 9999, 'published')
ON DUPLICATE KEY UPDATE
section_title = VALUES(section_title),
content = VALUES(content),
word_count = VALUES(word_count),
is_free = VALUES(is_free),
price = VALUES(price),
updated_at = CURRENT_TIMESTAMP`,
[
id,
partId,
partTitle,
chapterId,
chapterTitle,
title,
finalContent,
wordCount,
!!isFree,
Number(price) || 1
]
)
return NextResponse.json({
success: true,
id,
message: '内容已上传并写入 chapters 表',
title,
price: Number(price) || 1,
isFree: !!isFree,
wordCount
})
} catch (error) {
console.error('[Content Upload]', error)
return NextResponse.json(
{
success: false,
error: '上传失败: ' + (error as Error).message
},
{ status: 500 }
)
}
}

View File

@@ -146,13 +146,40 @@ export async function GET(request: NextRequest) {
}
// 列出所有章节(不含内容)
// 优先从数据库读取,确保新建章节能立即显示
if (action === 'list') {
const sectionsFromDb = new Map<string, any>()
try {
const rows = await query(`
SELECT id, part_id, part_title, chapter_id, chapter_title, section_title,
price, is_free, content
FROM chapters ORDER BY part_id, chapter_id, id
`) as any[]
if (rows && rows.length > 0) {
for (const r of rows) {
sectionsFromDb.set(r.id, {
id: r.id,
title: r.section_title || '',
price: r.price ?? 1,
isFree: !!r.is_free,
partId: r.part_id || 'part-1',
partTitle: r.part_title || '',
chapterId: r.chapter_id || 'chapter-1',
chapterTitle: r.chapter_title || '',
filePath: ''
})
}
}
} catch (e) {
console.log('[Book API] list 从数据库读取失败,回退到 bookData:', (e as Error).message)
}
// 合并:以数据库为准,数据库没有的用 bookData 补
const sections: any[] = []
for (const part of bookData) {
for (const chapter of part.chapters) {
for (const section of chapter.sections) {
sections.push({
const dbRow = sectionsFromDb.get(section.id)
sections.push(dbRow || {
id: section.id,
title: section.title,
price: section.price,
@@ -163,14 +190,25 @@ export async function GET(request: NextRequest) {
chapterTitle: chapter.title,
filePath: section.filePath
})
sectionsFromDb.delete(section.id)
}
}
}
// 数据库有但 bookData 没有的(新建章节)
for (const [, v] of sectionsFromDb) {
sections.push(v)
}
// 按 id 去重,避免数据库重复或合并逻辑导致同一文章出现多次
const seen = new Set<string>()
const deduped = sections.filter((s) => {
if (seen.has(s.id)) return false
seen.add(s.id)
return true
})
return NextResponse.json({
success: true,
sections,
total: sections.length
sections: deduped,
total: deduped.length
})
}
@@ -324,7 +362,7 @@ export async function POST(request: NextRequest) {
export async function PUT(request: NextRequest) {
try {
const body = await request.json()
const { id, title, content, price, saveToFile = true } = body
const { id, title, content, price, saveToFile = true, partId, chapterId, partTitle, chapterTitle, isFree } = body
if (!id) {
return NextResponse.json({
@@ -334,28 +372,40 @@ export async function PUT(request: NextRequest) {
}
const sectionInfo = getSectionInfo(id)
const finalPartId = partId || sectionInfo?.partId || 'part-1'
const finalPartTitle = partTitle || sectionInfo?.partTitle || '未分类'
const finalChapterId = chapterId || sectionInfo?.chapterId || 'chapter-1'
const finalChapterTitle = chapterTitle || sectionInfo?.chapterTitle || '未分类'
const finalPrice = price ?? sectionInfo?.section?.price ?? 1
const finalIsFree = isFree ?? sectionInfo?.section?.isFree ?? false
// 更新数据库
// 更新数据库(含新建章节)
try {
await query(`
INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title, section_title, content, word_count, price, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'published')
INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title, section_title, content, word_count, is_free, price, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'published')
ON DUPLICATE KEY UPDATE
part_id = VALUES(part_id),
part_title = VALUES(part_title),
chapter_id = VALUES(chapter_id),
chapter_title = VALUES(chapter_title),
section_title = VALUES(section_title),
content = VALUES(content),
word_count = VALUES(word_count),
is_free = VALUES(is_free),
price = VALUES(price),
updated_at = CURRENT_TIMESTAMP
`, [
id,
sectionInfo?.partId || 'part-1',
sectionInfo?.partTitle || '未分类',
sectionInfo?.chapterId || 'chapter-1',
sectionInfo?.chapterTitle || '未分类',
title || sectionInfo?.section.title || '',
finalPartId,
finalPartTitle,
finalChapterId,
finalChapterTitle,
title || sectionInfo?.section?.title || '',
content || '',
(content || '').length,
price ?? sectionInfo?.section.price ?? 1
finalIsFree,
finalPrice
])
} catch (e) {
console.error('[Book API] 更新数据库失败:', e)

View File

@@ -41,7 +41,7 @@ export default function RootLayout({
<html lang="zh-CN">
<body className="bg-black">
<LayoutWrapper>{children}</LayoutWrapper>
<Analytics />
{process.env.NODE_ENV === 'production' && <Analytics />}
</body>
</html>
)