feat: 系统配置 + 用户签名
- 新增 sys_config 表,存储应用版本号、标语、描述 - 关于小菜弹窗版本号改为从数据库动态获取 - 用户表新增 slogan 字段(个性签名) - 编辑资料页面支持修改签名 - 管理后台新增系统配置管理页面 - 更新 CLAUDE.md 文档
This commit is contained in:
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
|
||||
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) */
|
||||
|
||||
@@ -117,6 +117,13 @@
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "反馈管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/admin/config",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "系统配置"
|
||||
}
|
||||
}
|
||||
],
|
||||
"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>
|
||||
<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>
|
||||
|
||||
@@ -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">• 头像支持 JPG、PNG、WebP 格式,最大 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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user