feat: 系统配置 + 用户签名

- 新增 sys_config 表,存储应用版本号、标语、描述
- 关于小菜弹窗版本号改为从数据库动态获取
- 用户表新增 slogan 字段(个性签名)
- 编辑资料页面支持修改签名
- 管理后台新增系统配置管理页面
- 更新 CLAUDE.md 文档
This commit is contained in:
2026-06-08 10:37:04 +08:00
parent 29614fdb70
commit 2705335002
15 changed files with 469 additions and 21 deletions

View File

@@ -68,10 +68,14 @@ client/src/
│ ├── stats/ # 统计分析 │ ├── stats/ # 统计分析
│ ├── bills/ # 全部账单 (下拉刷新/上拉加载) │ ├── bills/ # 全部账单 (下拉刷新/上拉加载)
│ ├── profile/ # 我的(身份切换入口) │ ├── profile/ # 我的(身份切换入口)
│ ├── profile-edit/ # 编辑资料(昵称+头像) │ ├── profile-edit/ # 编辑资料(昵称+头像+签名
│ ├── group-manage/ # 群组管理(创建/加入/退出/解散) │ ├── group-manage/ # 群组管理(创建/加入/退出/解散)
│ ├── category-manage/ # 分类管理 │ ├── category-manage/ # 分类管理
── budget/ # 预算设置 ── budget/ # 预算设置
│ ├── notifications/ # 通知中心
│ ├── feedback/ # 意见反馈
│ ├── privacy/ # 隐私政策
│ └── admin/ # 管理后台 (index/users/notifications/feedback/config)
├── components/ # 公共组件 ├── components/ # 公共组件
│ ├── BudgetBar/ # 预算进度条 │ ├── BudgetBar/ # 预算进度条
│ ├── CategoryIcon/ # 分类图标 (首字+颜色) │ ├── CategoryIcon/ # 分类图标 (首字+颜色)
@@ -96,6 +100,11 @@ client/src/
│ ├── stats.ts # 统计接口 │ ├── stats.ts # 统计接口
│ ├── user.ts # 用户信息接口 (含头像上传) │ ├── user.ts # 用户信息接口 (含头像上传)
│ ├── group.ts # 群组接口 │ ├── group.ts # 群组接口
│ ├── notification.ts # 通知接口
│ ├── filter.ts # 筛选方案接口
│ ├── feedback.ts # 反馈接口
│ ├── config.ts # 系统配置接口
│ ├── admin.ts # 管理后台接口
│ └── index.ts # barrel 导出 │ └── index.ts # barrel 导出
├── utils/ ├── utils/
│ ├── format.ts # 金额/日期格式化 │ ├── format.ts # 金额/日期格式化
@@ -116,6 +125,11 @@ server/src/
│ ├── stats.ts # 统计查询 (支持 group_id) │ ├── stats.ts # 统计查询 (支持 group_id)
│ ├── user.ts # 用户信息 + 头像上传 │ ├── user.ts # 用户信息 + 头像上传
│ ├── group.ts # 群组 CRUD (创建/加入/退出/解散) │ ├── group.ts # 群组 CRUD (创建/加入/退出/解散)
│ ├── notification.ts # 通知/公告 CRUD
│ ├── filter.ts # 筛选方案 CRUD
│ ├── feedback.ts # 用户反馈 (提交/查询/处理)
│ ├── config.ts # 系统配置 (公开读/管理员写)
│ ├── admin.ts # 管理后台 (仪表盘/用户管理)
│ └── backup.ts # 备份 API │ └── backup.ts # 备份 API
├── utils/ ├── utils/
│ ├── backup.ts # Node.js 数据库备份 (gzip) │ ├── backup.ts # Node.js 数据库备份 (gzip)
@@ -124,16 +138,21 @@ server/src/
├── connection.ts # MySQL 连接池 ├── connection.ts # MySQL 连接池
├── init.ts # 建表 + seed + 迁移 (幂等) ├── init.ts # 建表 + seed + 迁移 (幂等)
├── schema.sql # 表结构 ├── schema.sql # 表结构
└── seed.sql # 默认分类数据 └── seed.sql # 默认分类数据 + 系统配置
``` ```
### 数据库表 ### 数据库表
- `users` — 用户 (openid, nickname, avatar_url) - `users` — 用户 (openid, nickname, avatar_url, slogan, role)
- `categories` — 分类 (user_id=0 默认, 其他=自定义) - `categories` — 分类 (user_id=0 默认, 其他=自定义)
- `transactions` — 交易记录 (user_id, group_id, amount 分) - `transactions` — 交易记录 (user_id, group_id, amount 分)
- `budgets` — 预算 (user_id, month, amount) - `budgets` — 预算 (user_id, month, amount)
- `groups` — 群组 (name, invite_code, created_by) - `groups` — 群组 (name, invite_code, created_by)
- `group_members` — 群组成员 (group_id, user_id, role) - `group_members` — 群组成员 (group_id, user_id, role)
- `notifications` — 通知/公告 (type, title, content, is_pinned, is_urgent)
- `notification_reads` — 公告已读记录 (notification_id, user_id)
- `saved_filters` — 保存的筛选方案 (user_id, name, filters)
- `feedbacks` — 用户反馈 (user_id, type, content, status)
- `sys_config` — 系统配置 (config_key, config_value)
### 金额处理 ### 金额处理
- 存储单位: 分 (整数,避免浮点精度问题) - 存储单位: 分 (整数,避免浮点精度问题)

11
client/src/api/config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { request } from '@/utils/request'
/** 获取系统配置 */
export function getConfig(): Promise<Record<string, string>> {
return request('/config')
}
/** 更新系统配置(管理员) */
export function updateConfig(data: Record<string, string>) {
return request('/config', { method: 'PUT', data })
}

View File

@@ -6,6 +6,7 @@ export interface UserInfo {
id: number id: number
nickname: string nickname: string
avatar_url: string avatar_url: string
slogan: string
role: 'user' | 'admin' role: 'user' | 'admin'
created_at: string created_at: string
} }
@@ -15,9 +16,11 @@ export function getUserInfo() {
return request<UserInfo>({ url: '/user/me' }) return request<UserInfo>({ url: '/user/me' })
} }
/** 更新昵称 */ /** 更新昵称/签名 */
export function updateNickname(nickname: string) { export function updateNickname(nickname: string, slogan?: string) {
return request({ url: '/user/me', method: 'PUT', data: { nickname } }) const data: any = { nickname }
if (slogan !== undefined) data.slogan = slogan
return request({ url: '/user/me', method: 'PUT', data })
} }
/** 上传头像(返回新 URL */ /** 上传头像(返回新 URL */

View File

@@ -117,6 +117,13 @@
"navigationStyle": "custom", "navigationStyle": "custom",
"navigationBarTitleText": "反馈管理" "navigationBarTitleText": "反馈管理"
} }
},
{
"path": "pages/admin/config",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "系统配置"
}
} }
], ],
"globalStyle": { "globalStyle": {

View File

@@ -0,0 +1,239 @@
<template>
<view class="page">
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="nav-bar">
<view class="nav-back" @tap="goBack">
<Icon name="arrowLeft" :size="40" color="#2D1B1B" />
</view>
<text class="nav-title">系统配置</text>
<view class="nav-save" @tap="saveConfig">
<text class="save-text">保存</text>
</view>
</view>
</view>
<view class="content" v-if="!loading">
<view class="config-card">
<text class="card-title">应用信息</text>
<view class="config-item">
<text class="config-label">版本号</text>
<input class="config-input" v-model="form.app_version" placeholder="如 1.0.0" />
</view>
<view class="config-divider"></view>
<view class="config-item">
<text class="config-label">标语</text>
<input class="config-input" v-model="form.app_slogan" placeholder="温暖 x 轻松的个人记账小程序" />
</view>
<view class="config-divider"></view>
<view class="config-item">
<text class="config-label">描述</text>
<input class="config-input" v-model="form.app_description" placeholder="让记账从负担变成享受" />
</view>
</view>
<view class="tips-card">
<text class="tips-title">说明</text>
<text class="tips-text"> 版本号会显示在关于小菜弹窗中</text>
<text class="tips-text"> 标语和描述会显示在关于小菜弹窗中</text>
</view>
</view>
<view v-else class="loading-box">
<text class="loading-text">加载中...</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system'
import { getConfig, updateConfig } from '@/api/config'
import Icon from '@/components/Icon/Icon.vue'
const loading = ref(true)
const saving = ref(false)
const form = reactive({
app_version: '',
app_slogan: '',
app_description: ''
})
onMounted(async () => {
await waitForReady()
try {
const config = await getConfig()
form.app_version = config.app_version || '1.0.0'
form.app_slogan = config.app_slogan || ''
form.app_description = config.app_description || ''
} catch (e: any) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
})
async function saveConfig() {
if (saving.value) return
if (!form.app_version.trim()) {
uni.showToast({ title: '请输入版本号', icon: 'none' })
return
}
saving.value = true
try {
await updateConfig({
app_version: form.app_version.trim(),
app_slogan: form.app_slogan.trim(),
app_description: form.app_description.trim()
})
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 1500)
} catch (e: any) {
uni.showToast({ title: e.message || '保存失败', icon: 'none' })
} finally {
saving.value = false
}
}
function goBack() {
uni.navigateBack()
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: $bg;
}
.header-fixed {
@include sticky-header;
}
.status-bar { @include status-bar; }
.nav-bar {
display: flex;
align-items: center;
padding: $space-sm $space-xl $space-md;
}
.nav-back { @include nav-back; }
.nav-title {
flex: 1;
text-align: center;
font-size: $font-2xl;
font-weight: 600;
color: $text;
}
.nav-save {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.6; }
}
.save-text {
font-size: $font-lg;
font-weight: 600;
color: $primary;
}
.content {
padding: $space-lg $space-xl;
}
.config-card {
background: $surface;
border-radius: $radius-2xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
overflow: hidden;
margin-bottom: $space-lg;
}
.card-title {
font-size: $font-lg;
font-weight: 600;
color: $text;
display: block;
padding: $space-lg $space-xl $space-sm;
}
.config-item {
display: flex;
align-items: center;
padding: $space-lg $space-xl;
}
.config-label {
width: 120rpx;
font-size: $font-lg;
font-weight: 500;
color: $text;
flex-shrink: 0;
}
.config-input {
flex: 1;
font-size: $font-lg;
color: $text;
}
.config-divider {
height: 2rpx;
background: $border;
margin: 0 $space-xl;
}
.tips-card {
padding: $space-lg $space-xl;
background: $surface;
border-radius: $radius-2xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
}
.tips-title {
font-size: $font-lg;
font-weight: 600;
color: $text;
display: block;
margin-bottom: $space-sm;
}
.tips-text {
font-size: $font-md;
color: $text-sec;
display: block;
margin-bottom: $space-xs;
}
.loading-box {
display: flex;
align-items: center;
justify-content: center;
padding: 120rpx 0;
}
.loading-text {
font-size: $font-lg;
color: $text-muted;
}
</style>

View File

@@ -77,6 +77,11 @@
<text class="entry-text">反馈管理</text> <text class="entry-text">反馈管理</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" /> <Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view> </view>
<view class="entry-item" @tap="goConfig">
<Icon name="settings" :size="32" color="#FF8C69" />
<text class="entry-text">系统配置</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view> </view>
</view> </view>
</view> </view>
@@ -120,6 +125,7 @@ function goBack() { uni.navigateBack() }
function goUsers() { uni.navigateTo({ url: '/pages/admin/users' }) } function goUsers() { uni.navigateTo({ url: '/pages/admin/users' }) }
function goNotificationManage() { uni.navigateTo({ url: '/pages/admin/notifications' }) } function goNotificationManage() { uni.navigateTo({ url: '/pages/admin/notifications' }) }
function goFeedbackManage() { uni.navigateTo({ url: '/pages/admin/feedback' }) } function goFeedbackManage() { uni.navigateTo({ url: '/pages/admin/feedback' }) }
function goConfig() { uni.navigateTo({ url: '/pages/admin/config' }) }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -29,11 +29,17 @@
<text class="form-label">昵称</text> <text class="form-label">昵称</text>
<input class="form-input" v-model="formNickname" placeholder="请输入昵称" maxlength="50" /> <input class="form-input" v-model="formNickname" placeholder="请输入昵称" maxlength="50" />
</view> </view>
<view class="form-divider"></view>
<view class="form-item">
<text class="form-label">签名</text>
<input class="form-input" v-model="formSlogan" placeholder="写一句个性签名吧" maxlength="100" />
</view>
</view> </view>
<view class="tips-card"> <view class="tips-card">
<text class="tips-title">提示</text> <text class="tips-title">提示</text>
<text class="tips-text"> 昵称最长 50 个字符</text> <text class="tips-text"> 昵称最长 50 个字符</text>
<text class="tips-text"> 签名最长 100 个字符</text>
<text class="tips-text"> 头像支持 JPGPNGWebP 格式最大 2MB</text> <text class="tips-text"> 头像支持 JPGPNGWebP 格式最大 2MB</text>
</view> </view>
</view> </view>
@@ -48,6 +54,7 @@ import Icon from '@/components/Icon/Icon.vue'
const userStore = useUserStore() const userStore = useUserStore()
const formNickname = ref('') const formNickname = ref('')
const formSlogan = ref('')
const previewUrl = ref('') const previewUrl = ref('')
const uploading = ref(false) const uploading = ref(false)
const saving = ref(false) const saving = ref(false)
@@ -55,6 +62,7 @@ const saving = ref(false)
onMounted(async () => { onMounted(async () => {
await waitForReady() await waitForReady()
formNickname.value = userStore.nickname formNickname.value = userStore.nickname
formSlogan.value = userStore.slogan
previewUrl.value = userStore.avatarUrl previewUrl.value = userStore.avatarUrl
}) })
@@ -93,7 +101,10 @@ async function saveProfile() {
} }
saving.value = true saving.value = true
try { try {
await userStore.updateProfile(nickname) await userStore.updateProfile({
nickname,
slogan: formSlogan.value.trim()
})
uni.showToast({ title: '保存成功', icon: 'success' }) uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 1500) setTimeout(() => uni.navigateBack(), 1500)
} catch { } catch {
@@ -207,6 +218,12 @@ async function saveProfile() {
padding: $space-lg $space-xl; padding: $space-lg $space-xl;
} }
.form-divider {
height: 2rpx;
background: $border;
margin: 0 $space-xl;
}
.form-label { .form-label {
width: 120rpx; width: 120rpx;
font-size: $font-lg; font-size: $font-lg;

View File

@@ -12,7 +12,7 @@
</view> </view>
<view class="p-info"> <view class="p-info">
<text class="p-name">{{ userStore.nickname }}</text> <text class="p-name">{{ userStore.nickname }}</text>
<text class="p-tagline">记账小能手</text> <text class="p-tagline">{{ userStore.slogan }}</text>
<text class="p-streak">累计 {{ txStore.total }} 笔记录</text> <text class="p-streak">累计 {{ txStore.total }} 笔记录</text>
</view> </view>
<Icon name="chevronRight" :size="28" color="rgba(255,255,255,0.6)" /> <Icon name="chevronRight" :size="28" color="rgba(255,255,255,0.6)" />
@@ -85,9 +85,9 @@
<view class="modal-mask" v-if="showAbout" @tap="showAbout = false"> <view class="modal-mask" v-if="showAbout" @tap="showAbout = false">
<view class="modal" @tap.stop> <view class="modal" @tap.stop>
<text class="about-title">小菜记账</text> <text class="about-title">小菜记账</text>
<text class="about-version">v1.0.0</text> <text class="about-version">v{{ appConfig.app_version || '1.0.0' }}</text>
<text class="about-desc">温暖 x 轻松的个人记账小程序</text> <text class="about-desc">{{ appConfig.app_slogan || '温暖 x 轻松的个人记账小程序' }}</text>
<text class="about-desc">让记账从负担变成享受</text> <text class="about-desc">{{ appConfig.app_description || '让记账从负担变成享受' }}</text>
<view class="modal-btns modal-btns-top"> <view class="modal-btns modal-btns-top">
<view class="modal-btn confirm" @tap="showAbout = false">知道了</view> <view class="modal-btn confirm" @tap="showAbout = false">知道了</view>
</view> </view>
@@ -143,6 +143,7 @@ import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group' import { useGroupStore } from '@/stores/group'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { getTransactions } from '@/api/transaction' import { getTransactions } from '@/api/transaction'
import { getConfig } from '@/api/config'
import { formatAmount } from '@/utils/format' import { formatAmount } from '@/utils/format'
import { statusBarHeight } from '@/utils/system' import { statusBarHeight } from '@/utils/system'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
@@ -159,6 +160,7 @@ const loading = ref(true)
const showAbout = ref(false) const showAbout = ref(false)
const showIdentity = ref(false) const showIdentity = ref(false)
const initialLoaded = ref(false) const initialLoaded = ref(false)
const appConfig = ref<Record<string, string>>({})
onMounted(async () => { onMounted(async () => {
await waitForReady() await waitForReady()
@@ -169,7 +171,8 @@ onMounted(async () => {
budgetStore.fetchBudget(), budgetStore.fetchBudget(),
txStore.fetchTransactions({ page: 1, pageSize: 1 }), txStore.fetchTransactions({ page: 1, pageSize: 1 }),
userStore.fetchUserInfo(), userStore.fetchUserInfo(),
groupStore.fetchGroups() groupStore.fetchGroups(),
getConfig().then(config => { appConfig.value = config }).catch(() => {})
]) ])
} catch (e) { } catch (e) {
console.error('Profile load error:', e) console.error('Profile load error:', e)

View File

@@ -10,6 +10,10 @@ export const useUserStore = defineStore('user', () => {
return userInfo.value?.nickname || uni.getStorageSync('xc:nickname') || '小菜' return userInfo.value?.nickname || uni.getStorageSync('xc:nickname') || '小菜'
}) })
const slogan = computed(() => {
return userInfo.value?.slogan || '记账小能手'
})
const avatarUrl = computed(() => { const avatarUrl = computed(() => {
const filename = userInfo.value?.avatar_url || uni.getStorageSync('xc:avatar_url') || '' const filename = userInfo.value?.avatar_url || uni.getStorageSync('xc:avatar_url') || ''
if (!filename) return '' if (!filename) return ''
@@ -28,10 +32,17 @@ export const useUserStore = defineStore('user', () => {
} }
} }
async function updateProfile(nickname: string) { async function updateProfile(data: { nickname?: string; slogan?: string }) {
await api.updateNickname(nickname) const nickname = data.nickname ?? userInfo.value?.nickname ?? ''
if (userInfo.value) userInfo.value.nickname = nickname const slogan = data.slogan ?? userInfo.value?.slogan ?? ''
uni.setStorageSync('xc:nickname', nickname) await api.updateNickname(nickname, slogan)
if (userInfo.value) {
if (data.nickname !== undefined) userInfo.value.nickname = data.nickname
if (data.slogan !== undefined) userInfo.value.slogan = data.slogan
}
if (data.nickname !== undefined) {
uni.setStorageSync('xc:nickname', data.nickname)
}
} }
async function uploadAvatar(filePath: string) { async function uploadAvatar(filePath: string) {
@@ -41,5 +52,5 @@ export const useUserStore = defineStore('user', () => {
return avatarUrl.value return avatarUrl.value
} }
return { userInfo, nickname, avatarUrl, fetchUserInfo, updateProfile, uploadAvatar } return { userInfo, nickname, slogan, avatarUrl, fetchUserInfo, updateProfile, uploadAvatar }
}) })

View File

@@ -165,6 +165,56 @@ async function runMigrations(conn: mysql.Connection) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) 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() { export async function initDatabase() {

View File

@@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS users (
session_key VARCHAR(100), session_key VARCHAR(100),
nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称', nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称',
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL', avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
slogan VARCHAR(100) DEFAULT '记账小能手' COMMENT '个性签名',
role ENUM('user', 'admin') DEFAULT 'user' COMMENT '角色', role ENUM('user', 'admin') DEFAULT 'user' COMMENT '角色',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE 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), INDEX idx_created (created_at),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) 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;

View File

@@ -15,3 +15,9 @@ INSERT IGNORE INTO categories (user_id, name, icon, color, type, sort_order) VAL
(0, '副业', 'TrendingUp', '#7BC67E', 'income', 2), (0, '副业', 'TrendingUp', '#7BC67E', 'income', 2),
(0, '理财', 'PieChart', '#7BC67E', 'income', 3), (0, '理财', 'PieChart', '#7BC67E', 'income', 3),
(0, '其他收入', 'Plus', '#BFB3B3', 'income', 4); (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', '让记账从负担变成享受');

View File

@@ -19,6 +19,7 @@ import notificationRoutes from './routes/notification'
import filterRoutes from './routes/filter' import filterRoutes from './routes/filter'
import adminRoutes from './routes/admin' import adminRoutes from './routes/admin'
import feedbackRoutes from './routes/feedback' import feedbackRoutes from './routes/feedback'
import configRoutes from './routes/config'
import { backupDatabase } from './utils/backup' import { backupDatabase } from './utils/backup'
// Warn if token secret is using default fallback // 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/filters', apiLimiter, filterRoutes)
app.use('/api/admin', apiLimiter, adminRoutes) app.use('/api/admin', apiLimiter, adminRoutes)
app.use('/api/feedback', apiLimiter, feedbackRoutes) app.use('/api/feedback', apiLimiter, feedbackRoutes)
app.use('/api/config', apiLimiter, configRoutes)
app.get('/api/health', async (_req, res) => { app.get('/api/health', async (_req, res) => {
try { try {

View 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

View File

@@ -48,7 +48,7 @@ const upload = multer({
router.get('/me', async (req: AuthRequest, res: Response) => { router.get('/me', async (req: AuthRequest, res: Response) => {
try { try {
const [rows] = await pool.query( 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] [req.userId]
) )
const user = (rows as any[])[0] 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) => { router.put('/me', async (req: AuthRequest, res: Response) => {
try { try {
const { nickname } = req.body const { nickname, slogan } = req.body
if (nickname !== undefined) { if (nickname !== undefined) {
if (typeof nickname !== 'string' || nickname.trim().length === 0) { 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 updates: string[] = []
const params: any[] = [] const params: any[] = []
@@ -84,6 +93,11 @@ router.put('/me', async (req: AuthRequest, res: Response) => {
params.push(nickname.trim()) params.push(nickname.trim())
} }
if (slogan !== undefined) {
updates.push('slogan = ?')
params.push(slogan.trim())
}
if (updates.length === 0) { if (updates.length === 0) {
return res.status(400).json({ code: 40001, message: '没有需要更新的字段' }) return res.status(400).json({ code: 40001, message: '没有需要更新的字段' })
} }