Compare commits

..

6 Commits

Author SHA1 Message Date
8b5589192d fix: 修复编辑公告保存失败 — 图片上传逻辑
- uploadImageIfNeeded 增加 notif_ 前缀判断
- 编辑保留原图时不再尝试上传本地文件
2026-06-08 11:04:33 +08:00
5124b390a7 fix: 移除 configStore 默认值,等待后端返回
- appName/appSlogan/appDescription 改为 computed,从 config 读取
- 后端配置加载完成前为空字符串
2026-06-08 11:02:16 +08:00
6af876a8f4 feat: 品牌文案配置化 + 全页面下拉刷新
- 新增 configStore 管理品牌文案(appName, appSlogan, appDescription)
- 关于弹窗、分享标题使用 configStore 读取品牌名
- 所有数据页面添加下拉刷新功能
- 所有数据页面 onShow 时自动刷新数据
2026-06-08 10:59:09 +08:00
62ca400b64 feat: 反馈回复通知 + 修复导入
- 管理员回复反馈时自动发送个人通知给用户
- 修复 config.ts 和 feedback.ts 的 pool 导入方式
2026-06-08 10:40:42 +08:00
2705335002 feat: 系统配置 + 用户签名
- 新增 sys_config 表,存储应用版本号、标语、描述
- 关于小菜弹窗版本号改为从数据库动态获取
- 用户表新增 slogan 字段(个性签名)
- 编辑资料页面支持修改签名
- 管理后台新增系统配置管理页面
- 更新 CLAUDE.md 文档
2026-06-08 10:37:04 +08:00
29614fdb70 feat: 隐私政策 + 意见反馈系统
- 新增隐私政策页面,满足小程序审核要求
- 新增意见反馈功能(用户提交 + 管理员查看)
- 后端新增 feedbacks 表和反馈 API
- 管理后台新增反馈管理入口
- 我的页面添加意见反馈、隐私政策入口
2026-06-08 10:16:28 +08:00
28 changed files with 1673 additions and 36 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)
### 金额处理 ### 金额处理
- 存储单位: 分 (整数,避免浮点精度问题) - 存储单位: 分 (整数,避免浮点精度问题)

View File

@@ -2,6 +2,7 @@
import { onLaunch } from '@dcloudio/uni-app' import { onLaunch } from '@dcloudio/uni-app'
import { wxLogin, demoLogin } from '@/api/auth' import { wxLogin, demoLogin } from '@/api/auth'
import { useGroupStore } from '@/stores/group' import { useGroupStore } from '@/stores/group'
import { useConfigStore } from '@/stores/config'
import { saveLoginResult } from '@/utils/request' import { saveLoginResult } from '@/utils/request'
import { markReady } from '@/utils/app-ready' import { markReady } from '@/utils/app-ready'
@@ -25,7 +26,9 @@ async function loginWithRetry(loginFn: () => Promise<any>, retries = 3): Promise
onLaunch(() => { onLaunch(() => {
const groupStore = useGroupStore() const groupStore = useGroupStore()
const configStore = useConfigStore()
groupStore.init() groupStore.init()
configStore.fetchConfig()
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
uni.login({ uni.login({

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

@@ -0,0 +1,20 @@
import { request } from '@/utils/request'
/** 提交反馈 */
export function submitFeedback(data: {
type: string
content: string
contact?: string
}) {
return request('/feedback', { method: 'POST', data })
}
/** 获取反馈列表(管理员) */
export function getFeedbackList(params?: { page?: number; pageSize?: number; status?: string }) {
return request('/feedback', { params })
}
/** 更新反馈状态(管理员) */
export function updateFeedbackStatus(id: number, status: string) {
return request(`/feedback/${id}/status`, { method: 'PUT', data: { status } })
}

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

@@ -35,21 +35,24 @@
"path": "pages/profile/index", "path": "pages/profile/index",
"style": { "style": {
"navigationStyle": "custom", "navigationStyle": "custom",
"navigationBarTitleText": "我的" "navigationBarTitleText": "我的",
"enablePullDownRefresh": true
} }
}, },
{ {
"path": "pages/category-manage/index", "path": "pages/category-manage/index",
"style": { "style": {
"navigationStyle": "custom", "navigationStyle": "custom",
"navigationBarTitleText": "分类管理" "navigationBarTitleText": "分类管理",
"enablePullDownRefresh": true
} }
}, },
{ {
"path": "pages/budget/index", "path": "pages/budget/index",
"style": { "style": {
"navigationStyle": "custom", "navigationStyle": "custom",
"navigationBarTitleText": "预算设置" "navigationBarTitleText": "预算设置",
"enablePullDownRefresh": true
} }
}, },
{ {
@@ -63,7 +66,8 @@
"path": "pages/group-manage/index", "path": "pages/group-manage/index",
"style": { "style": {
"navigationStyle": "custom", "navigationStyle": "custom",
"navigationBarTitleText": "一起记" "navigationBarTitleText": "一起记",
"enablePullDownRefresh": true
} }
}, },
{ {
@@ -78,7 +82,8 @@
"path": "pages/admin/index", "path": "pages/admin/index",
"style": { "style": {
"navigationStyle": "custom", "navigationStyle": "custom",
"navigationBarTitleText": "管理后台" "navigationBarTitleText": "管理后台",
"enablePullDownRefresh": true
} }
}, },
{ {
@@ -86,7 +91,7 @@
"style": { "style": {
"navigationStyle": "custom", "navigationStyle": "custom",
"navigationBarTitleText": "用户管理", "navigationBarTitleText": "用户管理",
"enablePullDownRefresh": false "enablePullDownRefresh": true
} }
}, },
{ {
@@ -96,6 +101,36 @@
"navigationBarTitleText": "公告管理", "navigationBarTitleText": "公告管理",
"enablePullDownRefresh": true "enablePullDownRefresh": true
} }
},
{
"path": "pages/privacy/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "隐私政策"
}
},
{
"path": "pages/feedback/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "意见反馈"
}
},
{
"path": "pages/admin/feedback",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "反馈管理",
"enablePullDownRefresh": true
}
},
{
"path": "pages/admin/config",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "系统配置",
"enablePullDownRefresh": true
}
} }
], ],
"globalStyle": { "globalStyle": {

View File

@@ -0,0 +1,260 @@
<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 { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
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 loadConfig() {
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 {}
}
// 返回页面时静默刷新
onShow(() => {
loadConfig()
})
onPullDownRefresh(async () => {
await loadConfig()
uni.stopPullDownRefresh()
})
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

@@ -0,0 +1,423 @@
<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-placeholder"></view>
</view>
</view>
<!-- 状态筛选 -->
<view class="tabs-wrap">
<view class="tabs">
<view class="tab" :class="{ active: filterStatus === '' }" @tap="switchStatus('')">全部</view>
<view class="tab" :class="{ active: filterStatus === 'pending' }" @tap="switchStatus('pending')">待处理</view>
<view class="tab" :class="{ active: filterStatus === 'processed' }" @tap="switchStatus('processed')">已处理</view>
<view class="tab" :class="{ active: filterStatus === 'ignored' }" @tap="switchStatus('ignored')">已忽略</view>
</view>
</view>
<!-- 列表 -->
<scroll-view class="list" scroll-y @scrolltolower="loadMore">
<view v-for="item in list" :key="item.id" class="feedback-card">
<view class="card-header">
<view class="user-info">
<image v-if="item.avatar_url" class="avatar" :src="getAvatarUrl(item.avatar_url)" mode="aspectFill" />
<view v-else class="avatar-placeholder">
<Icon name="user" :size="24" color="#BFB3B3" />
</view>
<text class="nickname">{{ item.nickname || '用户' + item.user_id }}</text>
</view>
<view class="status-badge" :class="item.status">
<text class="status-text">{{ statusMap[item.status] }}</text>
</view>
</view>
<view class="type-tag">
<text class="type-text">{{ typeMap[item.type] }}</text>
</view>
<text class="content">{{ item.content }}</text>
<view v-if="item.contact" class="contact-row">
<text class="contact-label">联系方式</text>
<text class="contact-value" @tap="copyContact(item.contact)">{{ item.contact }}</text>
</view>
<text class="time">{{ formatTime(item.created_at) }}</text>
<!-- 操作按钮 -->
<view class="actions">
<view v-if="item.status === 'pending'" class="action-btn process" @tap="updateStatus(item, 'processed')">
<text class="action-text">标记处理</text>
</view>
<view v-if="item.status === 'pending'" class="action-btn ignore" @tap="updateStatus(item, 'ignored')">
<text class="action-text">忽略</text>
</view>
<view v-if="item.status !== 'pending'" class="action-btn pending" @tap="updateStatus(item, 'pending')">
<text class="action-text">重新打开</text>
</view>
</view>
</view>
<view v-if="loading" class="loading-box">
<text class="loading-text">加载中...</text>
</view>
<view v-if="noMore && list.length > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
<view v-if="!loading && list.length === 0" class="empty-box">
<Icon name="edit" :size="48" color="#BFB3B3" />
<text class="empty-text">暂无反馈</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system'
import { getFeedbackList, updateFeedbackStatus } from '@/api/feedback'
import { API_BASE } from '@/config'
import Icon from '@/components/Icon/Icon.vue'
const typeMap: Record<string, string> = {
bug: '功能异常',
feature: '功能建议',
ux: '体验问题',
other: '其他'
}
const statusMap: Record<string, string> = {
pending: '待处理',
processed: '已处理',
ignored: '已忽略'
}
const list = ref<any[]>([])
const loading = ref(false)
const filterStatus = ref('')
const page = ref(1)
const pageSize = 20
const total = ref(0)
const initialLoaded = ref(false)
const noMore = computed(() => list.value.length >= total.value && total.value > 0)
onMounted(async () => {
await waitForReady()
loadList(true).finally(() => { initialLoaded.value = true })
})
onShow(() => {
if (initialLoaded.value) loadList(true)
})
onPullDownRefresh(async () => {
await loadList(true)
uni.stopPullDownRefresh()
})
function switchStatus(status: string) {
filterStatus.value = status
loadList(true)
}
async function loadList(reset = false) {
if (reset) {
page.value = 1
list.value = []
}
loading.value = true
try {
const params: any = { page: page.value, pageSize }
if (filterStatus.value) params.status = filterStatus.value
const data = await getFeedbackList(params)
if (reset) {
list.value = data.list
} else {
list.value = [...list.value, ...data.list]
}
total.value = data.total
} catch (e: any) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function loadMore() {
if (noMore.value || loading.value) return
page.value++
loadList(false)
}
async function updateStatus(item: any, status: string) {
try {
await updateFeedbackStatus(item.id, status)
item.status = status
uni.showToast({ title: '已更新', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
}
}
function copyContact(contact: string) {
uni.setClipboardData({
data: contact,
success: () => uni.showToast({ title: '已复制', icon: 'success' })
})
}
function getAvatarUrl(avatarUrl: string): string {
if (!avatarUrl) return ''
if (avatarUrl.startsWith('http') || avatarUrl.startsWith('blob:')) return avatarUrl
return `${API_BASE}/user/avatar/${avatarUrl}`
}
function formatTime(dateStr: string): string {
const d = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - d.getTime()
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
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-placeholder { width: 88rpx; }
.tabs-wrap {
@include tabs-wrap;
margin: 0 0 $space-md;
}
.tabs {
@include tabs;
}
.tab {
@include tab;
font-size: $font-md;
padding: $space-sm $space-md;
}
.list {
height: calc(100vh - 300rpx);
padding: 0 $space-xl;
}
.feedback-card {
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
padding: $space-lg;
margin-bottom: $space-md;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $space-sm;
}
.user-info {
display: flex;
align-items: center;
gap: $space-sm;
}
.avatar {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: $border;
}
.avatar-placeholder {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: $border;
display: flex;
align-items: center;
justify-content: center;
}
.nickname {
font-size: $font-md;
color: $text;
font-weight: 500;
}
.status-badge {
padding: 4rpx 12rpx;
border-radius: $radius-xs;
&.pending { background: #FFF3E0; }
&.processed { background: #E8F5E9; }
&.ignored { background: #F5F5F5; }
}
.status-text {
font-size: $font-xs;
.pending & { color: #F57C00; }
.processed & { color: #43A047; }
.ignored & { color: #9E9E9E; }
}
.type-tag {
display: inline-flex;
padding: 2rpx 12rpx;
background: $primary-light;
border-radius: $radius-xs;
margin-bottom: $space-sm;
}
.type-text {
font-size: $font-xs;
color: $primary;
}
.content {
font-size: $font-lg;
color: $text;
line-height: 1.6;
display: block;
margin-bottom: $space-sm;
}
.contact-row {
display: flex;
align-items: center;
margin-bottom: $space-xs;
}
.contact-label {
font-size: $font-sm;
color: $text-muted;
}
.contact-value {
font-size: $font-sm;
color: $primary;
text-decoration: underline;
}
.time {
font-size: $font-sm;
color: $text-muted;
display: block;
margin-bottom: $space-md;
}
.actions {
display: flex;
gap: $space-sm;
padding-top: $space-md;
border-top: 2rpx solid $border;
}
.action-btn {
padding: $space-xs $space-lg;
border-radius: $radius-lg;
border: 2rpx solid $border;
&.process {
background: $primary-light;
border-color: $primary;
}
&.ignore {
background: $bg;
}
&.pending {
background: $bg;
}
&:active { opacity: 0.7; }
}
.action-text {
font-size: $font-md;
.process & { color: $primary; font-weight: 500; }
.ignore & { color: $text-sec; }
.pending & { color: $text-sec; }
}
.loading-box, .empty-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $space-sm;
padding: 80rpx 0;
}
.loading-text, .empty-text {
font-size: $font-lg;
color: $text-muted;
}
.no-more {
text-align: center;
padding: $space-lg 0;
}
.no-more-text {
font-size: $font-md;
color: $text-muted;
}
</style>

View File

@@ -72,6 +72,16 @@
<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="goFeedbackManage">
<Icon name="edit" :size="32" color="#FF8C69" />
<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> </view>
</view> </view>
@@ -79,6 +89,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system' import { statusBarHeight } from '@/utils/system'
import { getDashboard } from '@/api/admin' import { getDashboard } from '@/api/admin'
@@ -111,9 +122,31 @@ onMounted(async () => {
} }
}) })
// 静默刷新(强制刷新缓存)
async function refreshDashboard(force = false) {
try {
if (force || !dashboardCache || Date.now() - dashboardCache.time >= CACHE_TTL) {
dashboard.value = await getDashboard()
dashboardCache = { data: dashboard.value, time: Date.now() }
}
} catch {}
}
// 返回页面时静默刷新
onShow(() => {
refreshDashboard()
})
onPullDownRefresh(async () => {
await refreshDashboard(true)
uni.stopPullDownRefresh()
})
function goBack() { uni.navigateBack() } 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 goConfig() { uni.navigateTo({ url: '/pages/admin/config' }) }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -299,8 +299,8 @@ function insertFormat(prefix: string, suffix: string) {
/** 上传图片 */ /** 上传图片 */
async function uploadImageIfNeeded(imageUrl: string): Promise<string> { async function uploadImageIfNeeded(imageUrl: string): Promise<string> {
if (!imageUrl) return '' if (!imageUrl) return ''
// 如果已经是完整 URL 或 blob URL直接返回 // 已经是服务器文件名notif_ 开头)、完整 URL 或 blob URL直接返回
if (imageUrl.startsWith('http') || imageUrl.startsWith('blob:')) { if (imageUrl.startsWith('notif_') || imageUrl.startsWith('http') || imageUrl.startsWith('blob:')) {
return imageUrl return imageUrl
} }
// 临时路径需要上传 // 临时路径需要上传

View File

@@ -54,7 +54,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { onReachBottom } from '@dcloudio/uni-app' import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system' import { statusBarHeight } from '@/utils/system'
import { API_BASE } from '@/config' import { API_BASE } from '@/config'
@@ -71,9 +71,22 @@ const page = ref(1)
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
const initialLoaded = ref(false)
onMounted(async () => { onMounted(async () => {
await waitForReady() await waitForReady()
await loadUsers(true) await loadUsers(true)
initialLoaded.value = true
})
// 返回页面时静默刷新
onShow(() => {
if (initialLoaded.value) loadUsers(true)
})
onPullDownRefresh(async () => {
await loadUsers(true)
uni.stopPullDownRefresh()
}) })
async function loadUsers(reset = false) { async function loadUsers(reset = false) {

View File

@@ -75,7 +75,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app' import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { useBudgetStore } from '@/stores/budget' import { useBudgetStore } from '@/stores/budget'
import { useGroupStore } from '@/stores/group' import { useGroupStore } from '@/stores/group'
import { getOverview } from '@/api/stats' import { getOverview } from '@/api/stats'
@@ -150,6 +150,15 @@ onShow(async () => {
} catch {} } catch {}
}) })
onPullDownRefresh(async () => {
try {
await budgetStore.fetchBudget(currentMonth)
syncBudgetData()
fetchMyExpense()
} catch {}
uni.stopPullDownRefresh()
})
function selectPreset(val: number) { function selectPreset(val: number) {
amountStr.value = String(val) amountStr.value = String(val)
} }

View File

@@ -111,7 +111,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app' import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { useCategoryStore, type Category } from '@/stores/category' import { useCategoryStore, type Category } from '@/stores/category'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { getCategoryTransactionCount } from '@/api/category' import { getCategoryTransactionCount } from '@/api/category'
@@ -158,6 +158,11 @@ onShow(() => {
if (initialLoaded.value) catStore.fetchCategories() if (initialLoaded.value) catStore.fetchCategories()
}) })
onPullDownRefresh(async () => {
await catStore.fetchCategories()
uni.stopPullDownRefresh()
})
function goBack() { function goBack() {
uni.navigateBack() uni.navigateBack()
} }

View File

@@ -0,0 +1,262 @@
<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="36" color="#2D1B1B" />
</view>
<text class="nav-title">意见反馈</text>
<view class="nav-placeholder"></view>
</view>
</view>
<view class="content">
<!-- 反馈类型 -->
<view class="section">
<text class="label">反馈类型</text>
<view class="type-grid">
<view
v-for="t in types"
:key="t.value"
class="type-item"
:class="{ active: form.type === t.value }"
@tap="form.type = t.value"
>
<text class="type-text">{{ t.label }}</text>
</view>
</view>
</view>
<!-- 反馈内容 -->
<view class="section">
<text class="label">问题描述</text>
<textarea
class="textarea"
v-model="form.content"
placeholder="请详细描述您遇到的问题或建议..."
maxlength="500"
:show-confirm-bar="false"
/>
<text class="char-count">{{ form.content.length }}/500</text>
</view>
<!-- 联系方式 -->
<view class="section">
<text class="label">联系方式选填</text>
<input
class="input"
v-model="form.contact"
placeholder="微信号或邮箱,方便我们联系您"
maxlength="100"
/>
</view>
<!-- 提交按钮 -->
<view class="submit-btn" :class="{ disabled: submitting || !form.content.trim() }" @tap="onSubmit">
<text class="submit-text">{{ submitting ? '提交中...' : '提交反馈' }}</text>
</view>
<text class="tip">感谢您的反馈我们会认真处理每一条意见</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { statusBarHeight } from '@/utils/system'
import { submitFeedback } from '@/api/feedback'
import Icon from '@/components/Icon/Icon.vue'
const types = [
{ label: '功能异常', value: 'bug' },
{ label: '功能建议', value: 'feature' },
{ label: '体验问题', value: 'ux' },
{ label: '其他', value: 'other' }
]
const form = reactive({
type: 'bug',
content: '',
contact: ''
})
const submitting = ref(false)
async function onSubmit() {
if (submitting.value || !form.content.trim()) return
submitting.value = true
try {
await submitFeedback({
type: form.type,
content: form.content.trim(),
contact: form.contact.trim() || undefined
})
uni.showToast({ title: '提交成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 1500)
} catch (e: any) {
uni.showToast({ title: e.message || '提交失败', icon: 'none' })
} finally {
submitting.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-lg;
}
.nav-back {
@include nav-back;
border-radius: 50%;
&:active { background: $border; }
}
.nav-title {
flex: 1;
text-align: center;
font-size: $font-2xl;
font-weight: 600;
color: $text;
}
.nav-placeholder {
width: 72rpx;
}
.content {
padding: $space-xl;
}
.section {
margin-bottom: $space-2xl;
}
.label {
display: block;
font-size: $font-lg;
font-weight: 500;
color: $text;
margin-bottom: $space-md;
}
.type-grid {
display: flex;
flex-wrap: wrap;
gap: $space-md;
}
.type-item {
padding: $space-sm $space-lg;
background: $surface;
border-radius: $radius-lg;
border: 2rpx solid $border;
transition: all $transition-normal;
&.active {
background: $primary-light;
border-color: $primary;
}
&:active { opacity: 0.7; }
}
.type-text {
font-size: $font-lg;
color: $text-sec;
.active & {
color: $primary;
font-weight: 500;
}
}
.textarea {
width: 100%;
height: 300rpx;
padding: $space-lg;
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
font-size: $font-lg;
color: $text;
line-height: 1.6;
box-sizing: border-box;
}
.char-count {
display: block;
text-align: right;
font-size: $font-sm;
color: $text-muted;
margin-top: $space-xs;
}
.input {
width: 100%;
height: 96rpx;
padding: 0 $space-lg;
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
font-size: $font-lg;
color: $text;
box-sizing: border-box;
}
.submit-btn {
width: 100%;
height: 96rpx;
background: linear-gradient(135deg, $primary, #E67355);
border-radius: $radius-xl;
display: flex;
align-items: center;
justify-content: center;
margin-top: $space-2xl;
&.disabled {
opacity: 0.5;
pointer-events: none;
}
&:active { opacity: 0.8; }
}
.submit-text {
font-size: $font-xl;
font-weight: 600;
color: $surface;
}
.tip {
display: block;
text-align: center;
font-size: $font-md;
color: $text-muted;
margin-top: $space-lg;
}
</style>

View File

@@ -140,8 +140,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' import { onShow, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { useGroupStore } from '@/stores/group' import { useGroupStore } from '@/stores/group'
import { useConfigStore } from '@/stores/config'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight, capsuleRight } from '@/utils/system' import { statusBarHeight, capsuleRight } from '@/utils/system'
import { API_BASE } from '@/config' import { API_BASE } from '@/config'
@@ -149,6 +150,7 @@ import Icon from '@/components/Icon/Icon.vue'
import type { GroupMember, Group } from '@/api/group' import type { GroupMember, Group } from '@/api/group'
const groupStore = useGroupStore() const groupStore = useGroupStore()
const configStore = useConfigStore()
const actionTab = ref<'create' | 'join'>('create') const actionTab = ref<'create' | 'join'>('create')
const newGroupName = ref('') const newGroupName = ref('')
@@ -174,6 +176,11 @@ onShow(() => {
if (initialLoaded.value) groupStore.fetchGroups() if (initialLoaded.value) groupStore.fetchGroups()
}) })
onPullDownRefresh(async () => {
await groupStore.fetchGroups()
uni.stopPullDownRefresh()
})
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
onShareAppMessage(() => { onShareAppMessage(() => {
// 空值保护:通过右上角菜单触发时 invite_code 可能为空 // 空值保护:通过右上角菜单触发时 invite_code 可能为空
@@ -188,7 +195,7 @@ onShareAppMessage(() => {
}) })
onShareTimeline(() => ({ onShareTimeline(() => ({
title: '小菜记账 — 一起来记账吧!' title: `${configStore.appName} — 一起来记账吧!`
})) }))
// #endif // #endif

View File

@@ -0,0 +1,155 @@
<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="36" color="#2D1B1B" />
</view>
<text class="nav-title">隐私政策</text>
<view class="nav-placeholder"></view>
</view>
</view>
<scroll-view class="content" scroll-y>
<text class="update-date">更新日期2026年6月8日</text>
<text class="section-title">引言</text>
<text class="paragraph">小菜记账以下简称"我们"非常重视用户的隐私保护本隐私政策旨在向您说明我们如何收集使用存储和保护您的个人信息请您仔细阅读</text>
<text class="section-title">我们收集的信息</text>
<text class="paragraph">在您使用本小程序时我们可能收集以下信息</text>
<text class="item">1. 微信 openid用于识别用户身份实现登录功能</text>
<text class="item">2. 用户昵称用于个性化展示您可以自行设置或修改</text>
<text class="item">3. 用户头像用于个性化展示您可以选择上传自定义头像</text>
<text class="item">4. 记账数据包括金额分类备注日期等仅用于为您提供记账服务</text>
<text class="section-title">信息的使用目的</text>
<text class="paragraph">我们收集的信息仅用于以下目的</text>
<text class="item">1. 提供记账统计预算等核心功能服务</text>
<text class="item">2. 实现群组记账功能与您邀请的好友共享账单数据</text>
<text class="item">3. 发送系统公告和重要通知</text>
<text class="item">4. 改善产品功能和用户体验</text>
<text class="section-title">信息的存储与保护</text>
<text class="paragraph">1. 您的数据存储在我们的服务器数据库中采用加密传输和访问控制</text>
<text class="paragraph">2. 我们不会将您的个人信息出售给第三方</text>
<text class="paragraph">3. 除法律法规要求外我们不会向任何第三方披露您的个人信息</text>
<text class="section-title">群组功能说明</text>
<text class="paragraph">1. 群组功能仅用于聚合展示成员的记账数据记录始终属于记录者本人</text>
<text class="paragraph">2. 群组成员可以看到其他成员的记账记录金额分类日期</text>
<text class="paragraph">3. 您可以随时退出群组退出后您的记录将回到个人账本</text>
<text class="section-title">您的权利</text>
<text class="paragraph">您享有以下权利</text>
<text class="item">1. 查看和修改您的个人信息昵称头像</text>
<text class="item">2. 删除您的记账记录</text>
<text class="item">3. 退出或解散群组</text>
<text class="item">4. 导出您的账单数据</text>
<text class="section-title">未成年人保护</text>
<text class="paragraph">如果您是未满 18 周岁的未成年人请在监护人的陪同下阅读本政策并在监护人同意后使用本服务</text>
<text class="section-title">政策更新</text>
<text class="paragraph">我们可能会不时更新本隐私政策更新后的政策将在小程序内公布继续使用本服务即视为您同意更新后的政策</text>
<text class="section-title">联系我们</text>
<text class="paragraph">如您对本隐私政策有任何疑问请通过小程序内的关于小菜联系我们</text>
<view class="bottom-space"></view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { statusBarHeight } from '@/utils/system'
import Icon from '@/components/Icon/Icon.vue'
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-lg;
}
.nav-back {
@include nav-back;
border-radius: 50%;
&:active { background: $border; }
}
.nav-title {
flex: 1;
text-align: center;
font-size: $font-2xl;
font-weight: 600;
color: $text;
}
.nav-placeholder {
width: 72rpx;
}
.content {
height: calc(100vh - 200rpx);
padding: $space-xl;
}
.update-date {
display: block;
font-size: $font-md;
color: $text-muted;
margin-bottom: $space-xl;
}
.section-title {
display: block;
font-size: $font-xl;
font-weight: 600;
color: $text;
margin-top: $space-2xl;
margin-bottom: $space-md;
}
.paragraph {
display: block;
font-size: $font-lg;
color: $text-sec;
line-height: 1.8;
margin-bottom: $space-sm;
}
.item {
display: block;
font-size: $font-lg;
color: $text-sec;
line-height: 1.8;
padding-left: $space-md;
}
.bottom-space {
height: 120rpx;
}
</style>

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)" />
@@ -58,10 +58,18 @@
</view> </view>
<view class="menu-card mt"> <view class="menu-card mt">
<view class="menu-item" @tap="goFeedback">
<text class="mi-label">意见反馈</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="showAbout = true"> <view class="menu-item" @tap="showAbout = true">
<text class="mi-label">关于小菜</text> <text class="mi-label">关于小菜</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" /> <Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view> </view>
<view class="menu-item" @tap="goPrivacy">
<text class="mi-label">隐私政策</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view> </view>
<!-- 管理员入口 --> <!-- 管理员入口 -->
@@ -76,10 +84,10 @@
<!-- 关于弹窗 --> <!-- 关于弹窗 -->
<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">{{ configStore.appName }}</text>
<text class="about-version">v1.0.0</text> <text class="about-version">v{{ configStore.config.app_version || '1.0.0' }}</text>
<text class="about-desc">温暖 x 轻松的个人记账小程序</text> <text class="about-desc">{{ configStore.appSlogan }}</text>
<text class="about-desc">让记账从负担变成享受</text> <text class="about-desc">{{ configStore.appDescription }}</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>
@@ -127,12 +135,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app' import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { useTransactionStore } from '@/stores/transaction' import { useTransactionStore } from '@/stores/transaction'
import { useStatsStore } from '@/stores/stats' import { useStatsStore } from '@/stores/stats'
import { useBudgetStore } from '@/stores/budget' import { useBudgetStore } from '@/stores/budget'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group' import { useGroupStore } from '@/stores/group'
import { useConfigStore } from '@/stores/config'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { getTransactions } from '@/api/transaction' import { getTransactions } from '@/api/transaction'
import { formatAmount } from '@/utils/format' import { formatAmount } from '@/utils/format'
@@ -144,6 +153,7 @@ const statsStore = useStatsStore()
const budgetStore = useBudgetStore() const budgetStore = useBudgetStore()
const userStore = useUserStore() const userStore = useUserStore()
const groupStore = useGroupStore() const groupStore = useGroupStore()
const configStore = useConfigStore()
const overview = computed(() => statsStore.overview) const overview = computed(() => statsStore.overview)
const budget = computed(() => budgetStore.budget) const budget = computed(() => budgetStore.budget)
@@ -185,6 +195,20 @@ onShow(async () => {
} catch {} } catch {}
}) })
onPullDownRefresh(async () => {
try {
await Promise.all([
statsStore.fetchOverview(),
budgetStore.fetchBudget(),
txStore.fetchTransactions({ page: 1, pageSize: 1 }),
groupStore.fetchGroups(),
userStore.fetchUserInfo(),
configStore.fetchConfig()
])
} catch {}
uni.stopPullDownRefresh()
})
function goBudget() { function goBudget() {
uni.navigateTo({ url: '/pages/budget/index' }) uni.navigateTo({ url: '/pages/budget/index' })
} }
@@ -193,6 +217,14 @@ function goCategoryManage() {
uni.navigateTo({ url: '/pages/category-manage/index' }) uni.navigateTo({ url: '/pages/category-manage/index' })
} }
function goPrivacy() {
uni.navigateTo({ url: '/pages/privacy/index' })
}
function goFeedback() {
uni.navigateTo({ url: '/pages/feedback/index' })
}
function goProfileEdit() { function goProfileEdit() {
uni.navigateTo({ url: '/pages/profile-edit/index' }) uni.navigateTo({ url: '/pages/profile-edit/index' })
} }

View File

@@ -123,6 +123,7 @@ import { storeToRefs } from 'pinia'
import { onShow, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' import { onShow, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { useStatsStore } from '@/stores/stats' import { useStatsStore } from '@/stores/stats'
import { useGroupStore } from '@/stores/group' import { useGroupStore } from '@/stores/group'
import { useConfigStore } from '@/stores/config'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { getTransactions } from '@/api/transaction' import { getTransactions } from '@/api/transaction'
import { getOverview } from '@/api/stats' import { getOverview } from '@/api/stats'
@@ -134,6 +135,7 @@ import ChartWrapper from '@/components/ChartWrapper/ChartWrapper.vue'
const statsStore = useStatsStore() const statsStore = useStatsStore()
const groupStore = useGroupStore() const groupStore = useGroupStore()
const configStore = useConfigStore()
const currentMonth = ref(getCurrentMonth()) const currentMonth = ref(getCurrentMonth())
const tabType = ref<'expense' | 'income'>('expense') const tabType = ref<'expense' | 'income'>('expense')
@@ -322,7 +324,7 @@ onShareAppMessage(() => {
}) })
onShareTimeline(() => ({ onShareTimeline(() => ({
title: `小菜记账 — 我的${formatMonth(currentMonth.value)}账单` title: `${configStore.appName} — 我的${formatMonth(currentMonth.value)}账单`
})) }))
// #endif // #endif

View File

@@ -0,0 +1,28 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { getConfig } from '@/api/config'
/** 系统配置 store全局缓存品牌文案等配置 */
export const useConfigStore = defineStore('config', () => {
const config = ref<Record<string, string>>({})
const loaded = ref(false)
/** 品牌名称(后端返回前为空) */
const appName = computed(() => config.value.app_name || '')
/** 品牌标语 */
const appSlogan = computed(() => config.value.app_slogan || '')
/** 品牌描述 */
const appDescription = computed(() => config.value.app_description || '')
async function fetchConfig() {
try {
const data = await getConfig()
config.value = data
loaded.value = true
} catch {
// 配置加载失败
}
}
return { config, loaded, appName, appSlogan, appDescription, fetchConfig }
})

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
@@ -114,3 +115,25 @@ CREATE TABLE IF NOT EXISTS saved_filters (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
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 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;
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

@@ -18,6 +18,8 @@ import groupRoutes from './routes/group'
import notificationRoutes from './routes/notification' 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 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
@@ -149,6 +151,8 @@ app.use('/api/groups', apiLimiter, groupRoutes)
app.use('/api/notifications', apiLimiter, notificationRoutes) 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/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.query(
'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

@@ -0,0 +1,139 @@
import { Router } from 'express'
import pool from '../db/connection'
import { authMiddleware } from '../middleware/auth'
const router = Router()
/** 用户提交反馈 */
router.post('/', authMiddleware, async (req, res) => {
try {
const userId = (req as any).userId
const { type, content, contact } = req.body
if (!content || !content.trim()) {
return res.status(400).json({ error: '请填写反馈内容' })
}
const validTypes = ['bug', 'feature', 'ux', 'other']
const feedbackType = validTypes.includes(type) ? type : 'other'
await pool.execute(
'INSERT INTO feedbacks (user_id, type, content, contact) VALUES (?, ?, ?, ?)',
[userId, feedbackType, content.trim(), contact || '']
)
res.json({ success: true })
} catch (error) {
console.error('Submit feedback error:', error)
res.status(500).json({ error: '提交失败' })
}
})
/** 管理员获取反馈列表 */
router.get('/', 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 page = Math.max(1, parseInt(req.query.page as string) || 1)
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20))
const status = req.query.status as string
const offset = (page - 1) * pageSize
let where = '1=1'
const params: any[] = []
if (status && ['pending', 'processed', 'ignored'].includes(status)) {
where += ' AND f.status = ?'
params.push(status)
}
const [countResult] = await pool.execute(
`SELECT COUNT(*) as total FROM feedbacks f WHERE ${where}`,
params
)
const total = (countResult as any[])[0].total
const [rows] = await pool.execute(
`SELECT f.*, u.nickname, u.avatar_url
FROM feedbacks f
LEFT JOIN users u ON f.user_id = u.id
WHERE ${where}
ORDER BY f.created_at DESC
LIMIT ? OFFSET ?`,
[...params, pageSize, offset]
)
res.json({ list: rows, total, page, pageSize })
} catch (error) {
console.error('Get feedback list error:', error)
res.status(500).json({ error: '获取失败' })
}
})
/** 管理员更新反馈状态 */
router.put('/:id/status', 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 { id } = req.params
const { status, admin_reply } = req.body
const validStatuses = ['pending', 'processed', 'ignored']
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: '无效的状态' })
}
// 查询反馈信息(用于发送通知)
const [feedbackRows] = await pool.execute(
'SELECT user_id, content FROM feedbacks WHERE id = ?',
[id]
)
const feedback = (feedbackRows as any[])[0]
await pool.execute(
'UPDATE feedbacks SET status = ?, admin_reply = ? WHERE id = ?',
[status, admin_reply || '', id]
)
// 如果有管理员回复,发送通知给反馈提交者
if (feedback && admin_reply && admin_reply.trim()) {
const statusText: Record<string, string> = {
processed: '已处理',
ignored: '已忽略',
pending: '待处理'
}
const replyPreview = admin_reply.trim().length > 50
? admin_reply.trim().slice(0, 50) + '...'
: admin_reply.trim()
await pool.execute(
`INSERT INTO notifications (user_id, type, title, content)
VALUES (?, 'personal', ?, ?)`,
[
feedback.user_id,
`您的反馈已${statusText[status] || '更新'}`,
`反馈内容:${feedback.content.slice(0, 30)}...\n\n管理员回复${replyPreview}`
]
)
}
res.json({ success: true })
} catch (error) {
console.error('Update feedback status 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: '没有需要更新的字段' })
} }