修改架构,移动端优先。
save
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
// 从 content/articles/*.md 读取文章列表和详情
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import matter from 'gray-matter'
|
||||
import { marked } from 'marked'
|
||||
|
||||
const DIR = path.join(process.cwd(), 'content', 'articles')
|
||||
|
||||
function readAll() {
|
||||
if (!fs.existsSync(DIR)) return []
|
||||
return fs.readdirSync(DIR)
|
||||
.filter((f) => f.endsWith('.md'))
|
||||
.map((fileName) => {
|
||||
const fullPath = path.join(DIR, fileName)
|
||||
const raw = fs.readFileSync(fullPath, 'utf8')
|
||||
const { data, content } = matter(raw)
|
||||
const slug = fileName.replace(/\.md$/, '')
|
||||
const dateMatch = fileName.match(/^(\d{4}-\d{2}-\d{2})-/)
|
||||
const date = dateMatch ? dateMatch[1] : ''
|
||||
return {
|
||||
slug,
|
||||
date,
|
||||
title: data.title || slug.replace(/^\d{4}-\d{2}-\d{2}-/, ''),
|
||||
excerpt: content.replace(/\s+/g, ' ').trim().slice(0, 150) + (content.length > 150 ? '...' : ''),
|
||||
}
|
||||
})
|
||||
.sort((a, b) => (a.date < b.date ? 1 : -1))
|
||||
}
|
||||
|
||||
export function listArticles() {
|
||||
return readAll().map(({ slug, title, date, excerpt }) => ({ slug, title, date, excerpt }))
|
||||
}
|
||||
|
||||
export function getArticle(slug) {
|
||||
// 防止路径穿越
|
||||
if (slug.includes('/') || slug.includes('\\') || slug.includes('..')) return null
|
||||
const fullPath = path.join(DIR, slug + '.md')
|
||||
if (!fs.existsSync(fullPath)) return null
|
||||
const raw = fs.readFileSync(fullPath, 'utf8')
|
||||
const { data, content } = matter(raw)
|
||||
const dateMatch = slug.match(/^(\d{4}-\d{2}-\d{2})-/)
|
||||
const date = dateMatch ? dateMatch[1] : ''
|
||||
return {
|
||||
slug,
|
||||
date,
|
||||
title: data.title || slug.replace(/^\d{4}-\d{2}-\d{2}-/, ''),
|
||||
contentHtml: marked.parse(content),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// 服务端 Prisma 单例, 使用 mariadb 适配器
|
||||
import 'dotenv/config'
|
||||
import { PrismaMariaDb } from '@prisma/adapter-mariadb'
|
||||
import { PrismaClient } from '../generated/prisma/client.ts'
|
||||
|
||||
function parseDatabaseUrl(url) {
|
||||
const u = new URL(url)
|
||||
return {
|
||||
host: u.hostname,
|
||||
port: Number(u.port) || 3306,
|
||||
user: decodeURIComponent(u.username),
|
||||
password: decodeURIComponent(u.password),
|
||||
database: u.pathname.replace(/^\//, ''),
|
||||
}
|
||||
}
|
||||
|
||||
const adapter = new PrismaMariaDb(parseDatabaseUrl(process.env.DATABASE_URL))
|
||||
export const prisma = new PrismaClient({ adapter })
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'dotenv/config'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import authRouter from './routes/auth.js'
|
||||
import dataRouter from './routes/data.js'
|
||||
import articlesRouter from './routes/articles.js'
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3001
|
||||
const DIST_DIR = path.join(process.cwd(), 'dist')
|
||||
const HAS_DIST = fs.existsSync(DIST_DIR)
|
||||
|
||||
const app = express()
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
|
||||
// 健康检查
|
||||
app.get('/api/health', (_req, res) => res.json({ ok: true, ts: Date.now() }))
|
||||
|
||||
// 业务路由
|
||||
app.use('/api/auth', authRouter)
|
||||
app.use('/api', dataRouter)
|
||||
app.use('/api', articlesRouter)
|
||||
|
||||
// 静态资源 (Vite build 产物)
|
||||
if (HAS_DIST) {
|
||||
app.use(express.static(DIST_DIR, { index: false, maxAge: '1h', etag: true }))
|
||||
|
||||
// SPA fallback: 非 /api/* 的请求一律返回 index.html, 由前端路由处理
|
||||
app.get(/^\/(?!api\/).*/, (_req, res, next) => {
|
||||
const indexHtml = path.join(DIST_DIR, 'index.html')
|
||||
if (!fs.existsSync(indexHtml)) return next()
|
||||
res.sendFile(indexHtml)
|
||||
})
|
||||
}
|
||||
|
||||
// 统一错误处理
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error('[api error]', err)
|
||||
res.status(500).json({ error: err.message || '服务器内部错误' })
|
||||
})
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[iboard-api] listening on http://127.0.0.1:${PORT}`)
|
||||
console.log(`[iboard-api] static dist: ${HAS_DIST ? DIST_DIR : '(not built, API only)'}`)
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
// 简易 JWT 鉴权: 解析 Authorization: Bearer xxx, 注入 req.user
|
||||
// 失败时统一返回 401
|
||||
export const JWT_SECRET = process.env.JWT_SECRET || 'iboard-dev-secret-change-me'
|
||||
const TOKEN_TTL = '7d' // 7 天, 与"记住登录"语义一致
|
||||
|
||||
export function signToken(payload) {
|
||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_TTL })
|
||||
}
|
||||
|
||||
export function authRequired(req, res, next) {
|
||||
const h = req.headers.authorization || ''
|
||||
const m = h.match(/^Bearer\s+(.+)$/)
|
||||
if (!m) return res.status(401).json({ error: '未登录' })
|
||||
try {
|
||||
req.user = jwt.verify(m[1], JWT_SECRET)
|
||||
next()
|
||||
} catch (e) {
|
||||
return res.status(401).json({ error: '登录已过期' })
|
||||
}
|
||||
}
|
||||
|
||||
export function authOptional(req, _res, next) {
|
||||
const h = req.headers.authorization || ''
|
||||
const m = h.match(/^Bearer\s+(.+)$/)
|
||||
if (m) {
|
||||
try { req.user = jwt.verify(m[1], JWT_SECRET) } catch { /* ignore */ }
|
||||
}
|
||||
next()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Router } from 'express'
|
||||
import { authRequired } from '../middleware.js'
|
||||
import { listArticles, getArticle } from '../articles.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(authRequired)
|
||||
|
||||
router.get('/articles', (_req, res) => {
|
||||
res.json({ articles: listArticles() })
|
||||
})
|
||||
|
||||
router.get('/articles/:slug', (req, res) => {
|
||||
const article = getArticle(req.params.slug)
|
||||
if (!article) return res.status(404).json({ error: '文章不存在' })
|
||||
res.json({ article })
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Router } from 'express'
|
||||
import crypto from 'node:crypto'
|
||||
import { prisma } from '../db.js'
|
||||
import { authRequired, signToken } from '../middleware.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 校验密码: 接受 scrypt$salt$hash 格式
|
||||
function verifyPassword(plain, stored) {
|
||||
if (!stored || !stored.startsWith('scrypt$')) return false
|
||||
const parts = stored.split('$')
|
||||
if (parts.length !== 3) return false
|
||||
const [, salt, hash] = parts
|
||||
const computed = crypto.scryptSync(plain, salt, 64).toString('hex')
|
||||
// 常数时间比较
|
||||
const a = Buffer.from(computed, 'hex')
|
||||
const b = Buffer.from(hash, 'hex')
|
||||
if (a.length !== b.length) return false
|
||||
return crypto.timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
const { email, password } = req.body || {}
|
||||
if (!email || !password) return res.status(400).json({ error: '邮箱或密码不能为空' })
|
||||
const user = await prisma.user.findUnique({ where: { email: String(email).toLowerCase() } })
|
||||
if (!user) return res.status(401).json({ error: '邮箱或密码错误' })
|
||||
if (!verifyPassword(String(password), user.password)) {
|
||||
return res.status(401).json({ error: '邮箱或密码错误' })
|
||||
}
|
||||
const payload = { sub: user.id, email: user.email, name: user.name }
|
||||
const token = signToken(payload)
|
||||
res.json({ token, user: { id: user.id, name: user.name, email: user.email, createdAt: user.createdAt } })
|
||||
})
|
||||
|
||||
router.get('/me', authRequired, async (req, res) => {
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.sub } })
|
||||
if (!user) return res.status(401).json({ error: '用户不存在' })
|
||||
res.json({ user: { id: user.id, name: user.name, email: user.email, createdAt: user.createdAt } })
|
||||
})
|
||||
|
||||
router.post('/logout', (_req, res) => {
|
||||
// JWT 无状态, 前端清 token 即可; 这里返回 200 让前端不用特判
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { authRequired } from '../middleware.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(authRequired)
|
||||
|
||||
router.get('/indicators', async (_req, res, next) => {
|
||||
try {
|
||||
const rows = await prisma.indicator.findMany({ orderBy: [{ groupName: 'asc' }, { name: 'asc' }] })
|
||||
res.json({ indicators: rows })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
router.get('/strategies', async (_req, res, next) => {
|
||||
try {
|
||||
const rows = await prisma.strategy.findMany({ orderBy: { name: 'asc' } })
|
||||
res.json({ strategies: rows })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
router.get('/sow', async (_req, res, next) => {
|
||||
try {
|
||||
const rows = await prisma.econ_SowInventory.findMany({ orderBy: { month: 'asc' } })
|
||||
res.json({ rows })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user