feat: 系统配置 + 用户签名
- 新增 sys_config 表,存储应用版本号、标语、描述 - 关于小菜弹窗版本号改为从数据库动态获取 - 用户表新增 slogan 字段(个性签名) - 编辑资料页面支持修改签名 - 管理后台新增系统配置管理页面 - 更新 CLAUDE.md 文档
This commit is contained in:
27
CLAUDE.md
27
CLAUDE.md
@@ -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
11
client/src/api/config.ts
Normal 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 })
|
||||||
|
}
|
||||||
@@ -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) */
|
||||||
|
|||||||
@@ -117,6 +117,13 @@
|
|||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom",
|
||||||
"navigationBarTitleText": "反馈管理"
|
"navigationBarTitleText": "反馈管理"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin/config",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "系统配置"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"globalStyle": {
|
"globalStyle": {
|
||||||
|
|||||||
239
client/src/pages/admin/config.vue
Normal file
239
client/src/pages/admin/config.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">• 头像支持 JPG、PNG、WebP 格式,最大 2MB</text>
|
<text class="tips-text">• 头像支持 JPG、PNG、WebP 格式,最大 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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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', '让记账从负担变成享受');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
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) => {
|
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: '没有需要更新的字段' })
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user