Compare commits
6 Commits
8cf240f76b
...
8b5589192d
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b5589192d | |||
| 5124b390a7 | |||
| 6af876a8f4 | |||
| 62ca400b64 | |||
| 2705335002 | |||
| 29614fdb70 |
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)
|
||||||
|
|
||||||
### 金额处理
|
### 金额处理
|
||||||
- 存储单位: 分 (整数,避免浮点精度问题)
|
- 存储单位: 分 (整数,避免浮点精度问题)
|
||||||
|
|||||||
@@ -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
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 })
|
||||||
|
}
|
||||||
20
client/src/api/feedback.ts
Normal file
20
client/src/api/feedback.ts
Normal 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 } })
|
||||||
|
}
|
||||||
@@ -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) */
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
260
client/src/pages/admin/config.vue
Normal file
260
client/src/pages/admin/config.vue
Normal 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>
|
||||||
423
client/src/pages/admin/feedback.vue
Normal file
423
client/src/pages/admin/feedback.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
// 临时路径需要上传
|
// 临时路径需要上传
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
262
client/src/pages/feedback/index.vue
Normal file
262
client/src/pages/feedback/index.vue
Normal 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>
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
155
client/src/pages/privacy/index.vue
Normal file
155
client/src/pages/privacy/index.vue
Normal 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>
|
||||||
@@ -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)" />
|
||||||
@@ -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' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
28
client/src/stores/config.ts
Normal file
28
client/src/stores/config.ts
Normal 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 }
|
||||||
|
})
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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', '让记账从负担变成享受');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
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.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
|
||||||
139
server/src/routes/feedback.ts
Normal file
139
server/src/routes/feedback.ts
Normal 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
|
||||||
@@ -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