feat: 系统配置 + 用户签名
- 新增 sys_config 表,存储应用版本号、标语、描述 - 关于小菜弹窗版本号改为从数据库动态获取 - 用户表新增 slogan 字段(个性签名) - 编辑资料页面支持修改签名 - 管理后台新增系统配置管理页面 - 更新 CLAUDE.md 文档
This commit is contained in:
@@ -165,6 +165,56 @@ async function runMigrations(conn: mysql.Connection) {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
}
|
||||
|
||||
// users 表添加 slogan(个性签名)
|
||||
const hasSlogan = await columnExists(conn, 'users', 'slogan')
|
||||
if (!hasSlogan) {
|
||||
console.log('[DB] Migrating: adding slogan to users')
|
||||
await conn.query("ALTER TABLE users ADD COLUMN slogan VARCHAR(100) DEFAULT '记账小能手' COMMENT '个性签名' AFTER avatar_url")
|
||||
}
|
||||
|
||||
// 系统配置表
|
||||
const hasSysConfig = await tableExists(conn, 'sys_config')
|
||||
if (!hasSysConfig) {
|
||||
console.log('[DB] Migrating: creating sys_config table')
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS sys_config (
|
||||
config_key VARCHAR(50) PRIMARY KEY COMMENT '配置键',
|
||||
config_value TEXT NOT NULL COMMENT '配置值',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
// 插入默认配置
|
||||
await conn.query(`
|
||||
INSERT IGNORE INTO sys_config (config_key, config_value) VALUES
|
||||
('app_version', '1.0.0'),
|
||||
('app_slogan', '温暖 x 轻松的个人记账小程序'),
|
||||
('app_description', '让记账从负担变成享受')
|
||||
`)
|
||||
}
|
||||
|
||||
// 反馈表
|
||||
const hasFeedbacks = await tableExists(conn, 'feedbacks')
|
||||
if (!hasFeedbacks) {
|
||||
console.log('[DB] Migrating: creating feedbacks table')
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS feedbacks (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
type ENUM('bug', 'feature', 'ux', 'other') NOT NULL DEFAULT 'other' COMMENT '反馈类型',
|
||||
content TEXT NOT NULL COMMENT '反馈内容',
|
||||
contact VARCHAR(100) DEFAULT '' COMMENT '联系方式',
|
||||
status ENUM('pending', 'processed', 'ignored') DEFAULT 'pending' COMMENT '处理状态',
|
||||
admin_reply TEXT DEFAULT '' COMMENT '管理员回复',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created (created_at),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function initDatabase() {
|
||||
|
||||
@@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
session_key VARCHAR(100),
|
||||
nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称',
|
||||
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
|
||||
slogan VARCHAR(100) DEFAULT '记账小能手' COMMENT '个性签名',
|
||||
role ENUM('user', 'admin') DEFAULT 'user' COMMENT '角色',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
@@ -130,3 +131,9 @@ CREATE TABLE IF NOT EXISTS feedbacks (
|
||||
INDEX idx_created (created_at),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sys_config (
|
||||
config_key VARCHAR(50) PRIMARY KEY COMMENT '配置键',
|
||||
config_value TEXT NOT NULL COMMENT '配置值',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
@@ -15,3 +15,9 @@ INSERT IGNORE INTO categories (user_id, name, icon, color, type, sort_order) VAL
|
||||
(0, '副业', 'TrendingUp', '#7BC67E', 'income', 2),
|
||||
(0, '理财', 'PieChart', '#7BC67E', 'income', 3),
|
||||
(0, '其他收入', 'Plus', '#BFB3B3', 'income', 4);
|
||||
|
||||
-- 系统配置
|
||||
INSERT IGNORE INTO sys_config (config_key, config_value) VALUES
|
||||
('app_version', '1.0.0'),
|
||||
('app_slogan', '温暖 x 轻松的个人记账小程序'),
|
||||
('app_description', '让记账从负担变成享受');
|
||||
|
||||
@@ -19,6 +19,7 @@ import notificationRoutes from './routes/notification'
|
||||
import filterRoutes from './routes/filter'
|
||||
import adminRoutes from './routes/admin'
|
||||
import feedbackRoutes from './routes/feedback'
|
||||
import configRoutes from './routes/config'
|
||||
import { backupDatabase } from './utils/backup'
|
||||
|
||||
// Warn if token secret is using default fallback
|
||||
@@ -151,6 +152,7 @@ app.use('/api/notifications', apiLimiter, notificationRoutes)
|
||||
app.use('/api/filters', apiLimiter, filterRoutes)
|
||||
app.use('/api/admin', apiLimiter, adminRoutes)
|
||||
app.use('/api/feedback', apiLimiter, feedbackRoutes)
|
||||
app.use('/api/config', apiLimiter, configRoutes)
|
||||
|
||||
app.get('/api/health', async (_req, res) => {
|
||||
try {
|
||||
|
||||
53
server/src/routes/config.ts
Normal file
53
server/src/routes/config.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Router } from 'express'
|
||||
import { pool } from '../db/connection'
|
||||
import { authMiddleware } from '../middleware/auth'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/** 获取公开配置(无需管理员权限) */
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.execute('SELECT config_key, config_value FROM sys_config')
|
||||
const config: Record<string, string> = {}
|
||||
for (const row of rows as any[]) {
|
||||
config[row.config_key] = row.config_value
|
||||
}
|
||||
res.json(config)
|
||||
} catch (error) {
|
||||
console.error('Get config error:', error)
|
||||
res.status(500).json({ error: '获取配置失败' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 更新配置(管理员权限) */
|
||||
router.put('/', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).userId
|
||||
const [users] = await pool.execute('SELECT role FROM users WHERE id = ?', [userId])
|
||||
const user = (users as any[])[0]
|
||||
|
||||
if (!user || user.role !== 'admin') {
|
||||
return res.status(403).json({ error: '无权限' })
|
||||
}
|
||||
|
||||
const data = req.body
|
||||
if (!data || typeof data !== 'object') {
|
||||
return res.status(400).json({ error: '无效的配置数据' })
|
||||
}
|
||||
|
||||
// 批量更新配置
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
await pool.execute(
|
||||
'INSERT INTO sys_config (config_key, config_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE config_value = ?',
|
||||
[key, value, value]
|
||||
)
|
||||
}
|
||||
|
||||
res.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Update config error:', error)
|
||||
res.status(500).json({ error: '更新配置失败' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -48,7 +48,7 @@ const upload = multer({
|
||||
router.get('/me', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, nickname, avatar_url, role, created_at FROM users WHERE id = ?',
|
||||
'SELECT id, nickname, avatar_url, slogan, role, created_at FROM users WHERE id = ?',
|
||||
[req.userId]
|
||||
)
|
||||
const user = (rows as any[])[0]
|
||||
@@ -62,10 +62,10 @@ router.get('/me', async (req: AuthRequest, res: Response) => {
|
||||
}
|
||||
})
|
||||
|
||||
/** 更新用户昵称 */
|
||||
/** 更新用户昵称/签名 */
|
||||
router.put('/me', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { nickname } = req.body
|
||||
const { nickname, slogan } = req.body
|
||||
|
||||
if (nickname !== undefined) {
|
||||
if (typeof nickname !== 'string' || nickname.trim().length === 0) {
|
||||
@@ -76,6 +76,15 @@ router.put('/me', async (req: AuthRequest, res: Response) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (slogan !== undefined) {
|
||||
if (typeof slogan !== 'string') {
|
||||
return res.status(400).json({ code: 40001, message: '签名格式错误' })
|
||||
}
|
||||
if (slogan.length > 100) {
|
||||
return res.status(400).json({ code: 40001, message: '签名不能超过100个字符' })
|
||||
}
|
||||
}
|
||||
|
||||
const updates: string[] = []
|
||||
const params: any[] = []
|
||||
|
||||
@@ -84,6 +93,11 @@ router.put('/me', async (req: AuthRequest, res: Response) => {
|
||||
params.push(nickname.trim())
|
||||
}
|
||||
|
||||
if (slogan !== undefined) {
|
||||
updates.push('slogan = ?')
|
||||
params.push(slogan.trim())
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).json({ code: 40001, message: '没有需要更新的字段' })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user