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

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

View File

@@ -117,6 +117,13 @@
"navigationStyle": "custom",
"navigationBarTitleText": "反馈管理"
}
},
{
"path": "pages/admin/config",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "系统配置"
}
}
],
"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>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</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>
@@ -120,6 +125,7 @@ function goBack() { uni.navigateBack() }
function goUsers() { uni.navigateTo({ url: '/pages/admin/users' }) }
function goNotificationManage() { uni.navigateTo({ url: '/pages/admin/notifications' }) }
function goFeedbackManage() { uni.navigateTo({ url: '/pages/admin/feedback' }) }
function goConfig() { uni.navigateTo({ url: '/pages/admin/config' }) }
</script>
<style lang="scss" scoped>

View File

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

View File

@@ -12,7 +12,7 @@
</view>
<view class="p-info">
<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>
</view>
<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" @tap.stop>
<text class="about-title">小菜记账</text>
<text class="about-version">v1.0.0</text>
<text class="about-desc">温暖 x 轻松的个人记账小程序</text>
<text class="about-desc">让记账从负担变成享受</text>
<text class="about-version">v{{ appConfig.app_version || '1.0.0' }}</text>
<text class="about-desc">{{ appConfig.app_slogan || '温暖 x 轻松的个人记账小程序' }}</text>
<text class="about-desc">{{ appConfig.app_description || '让记账从负担变成享受' }}</text>
<view class="modal-btns modal-btns-top">
<view class="modal-btn confirm" @tap="showAbout = false">知道了</view>
</view>
@@ -143,6 +143,7 @@ import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
import { waitForReady } from '@/utils/app-ready'
import { getTransactions } from '@/api/transaction'
import { getConfig } from '@/api/config'
import { formatAmount } from '@/utils/format'
import { statusBarHeight } from '@/utils/system'
import Icon from '@/components/Icon/Icon.vue'
@@ -159,6 +160,7 @@ const loading = ref(true)
const showAbout = ref(false)
const showIdentity = ref(false)
const initialLoaded = ref(false)
const appConfig = ref<Record<string, string>>({})
onMounted(async () => {
await waitForReady()
@@ -169,7 +171,8 @@ onMounted(async () => {
budgetStore.fetchBudget(),
txStore.fetchTransactions({ page: 1, pageSize: 1 }),
userStore.fetchUserInfo(),
groupStore.fetchGroups()
groupStore.fetchGroups(),
getConfig().then(config => { appConfig.value = config }).catch(() => {})
])
} catch (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') || '小菜'
})
const slogan = computed(() => {
return userInfo.value?.slogan || '记账小能手'
})
const avatarUrl = computed(() => {
const filename = userInfo.value?.avatar_url || uni.getStorageSync('xc:avatar_url') || ''
if (!filename) return ''
@@ -28,10 +32,17 @@ export const useUserStore = defineStore('user', () => {
}
}
async function updateProfile(nickname: string) {
await api.updateNickname(nickname)
if (userInfo.value) userInfo.value.nickname = nickname
uni.setStorageSync('xc:nickname', nickname)
async function updateProfile(data: { nickname?: string; slogan?: string }) {
const nickname = data.nickname ?? userInfo.value?.nickname ?? ''
const slogan = data.slogan ?? userInfo.value?.slogan ?? ''
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) {
@@ -41,5 +52,5 @@ export const useUserStore = defineStore('user', () => {
return avatarUrl.value
}
return { userInfo, nickname, avatarUrl, fetchUserInfo, updateProfile, uploadAvatar }
return { userInfo, nickname, slogan, avatarUrl, fetchUserInfo, updateProfile, uploadAvatar }
})