修改架构,移动端优先。

save
This commit is contained in:
cheney
2026-08-03 18:05:50 +08:00
committed by Cheney
parent 1c07b417ce
commit 08b3655a38
71 changed files with 2524 additions and 290 deletions
+49
View File
@@ -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),
}
}
+18
View File
@@ -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 })
+47
View File
@@ -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)'}`)
})
+31
View File
@@ -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()
}
+19
View File
@@ -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
+46
View File
@@ -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
+30
View File
@@ -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