feat: 用户信息完善 + 一起记功能
- users 表添加 nickname、avatar_url 字段 - 用户信息 API(GET/PUT /me)+ 头像上传(multer) - 头像通过 API 路由获取(公开,不经过 auth) - 登录接口返回用户昵称和头像 - 用户信息 Store + 个人资料编辑页面 - 我的页面和首页显示真实用户信息 - 群组表(groups、group_members)+ transactions 添加 group_id - 群组 API(创建/加入/退出/解散) - 交易和统计 API 支持 group_id 视图切换 - 群组客户端 Store + 身份切换 - 群组管理页面 - App 初始化群组 Store - UPLOAD_DIR 配置化 - Node.js 备份替代 mysqldump - 统一前端 BASE_URL(config.ts) - uploads 加入 .gitignore - 修复 trust proxy 警告
This commit is contained in:
@@ -1,22 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { onLaunch } from '@dcloudio/uni-app'
|
||||
import { wxLogin, demoLogin } from '@/api/auth'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { saveLoginResult } from '@/utils/request'
|
||||
import { markReady } from '@/utils/app-ready'
|
||||
|
||||
onLaunch(() => {
|
||||
const groupStore = useGroupStore()
|
||||
groupStore.init()
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: async (loginRes) => {
|
||||
if (loginRes.code) {
|
||||
try {
|
||||
const data = await wxLogin(loginRes.code)
|
||||
uni.setStorageSync('xc:token', data.token)
|
||||
uni.setStorageSync('xc:userId', data.userId)
|
||||
saveLoginResult(await wxLogin(loginRes.code))
|
||||
groupStore.fetchGroups()
|
||||
console.log('[Auth] Login success')
|
||||
} catch (e) {
|
||||
console.error('[Auth] Login failed:', e)
|
||||
}
|
||||
}
|
||||
markReady()
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
@@ -24,15 +30,16 @@ onLaunch(() => {
|
||||
// #ifdef H5
|
||||
const token = uni.getStorageSync('xc:token')
|
||||
if (!token) {
|
||||
// 同步等待 token,避免页面首次请求因无 token 而 401
|
||||
demoLogin().then((data) => {
|
||||
uni.setStorageSync('xc:token', data.token)
|
||||
uni.setStorageSync('xc:userId', data.userId)
|
||||
saveLoginResult(data)
|
||||
groupStore.fetchGroups()
|
||||
console.log('[Auth] H5 demo login success')
|
||||
}).catch((e) => {
|
||||
console.error('[Auth] H5 demo login failed:', e)
|
||||
})
|
||||
}).finally(() => { markReady() })
|
||||
} else {
|
||||
groupStore.fetchGroups()
|
||||
markReady()
|
||||
console.log('[Auth] H5 token exists')
|
||||
}
|
||||
// #endif
|
||||
|
||||
@@ -4,6 +4,8 @@ import { request } from '@/utils/request'
|
||||
export interface LoginResult {
|
||||
token: string
|
||||
userId: number
|
||||
nickname: string
|
||||
avatar_url: string
|
||||
}
|
||||
|
||||
/** H5 演示登录 */
|
||||
|
||||
@@ -8,8 +8,8 @@ export interface Budget {
|
||||
}
|
||||
|
||||
/** 获取预算 */
|
||||
export function getBudget(month?: string) {
|
||||
return request<Budget | null>({ url: '/budget', data: month ? { month } : {} })
|
||||
export function getBudget(month?: string, group_id?: number | null) {
|
||||
return request<Budget | null>({ url: '/budget', data: { month, group_id } })
|
||||
}
|
||||
|
||||
/** 设置预算 */
|
||||
|
||||
58
client/src/api/group.ts
Normal file
58
client/src/api/group.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
/** 群组信息 */
|
||||
export interface Group {
|
||||
id: number
|
||||
name: string
|
||||
invite_code: string
|
||||
created_by: number
|
||||
created_at: string
|
||||
role: 'owner' | 'member'
|
||||
my_nickname: string
|
||||
member_count: number
|
||||
}
|
||||
|
||||
/** 群组详情(含成员) */
|
||||
export interface GroupDetail extends Group {
|
||||
members: GroupMember[]
|
||||
}
|
||||
|
||||
/** 群组成员 */
|
||||
export interface GroupMember {
|
||||
user_id: number
|
||||
role: 'owner' | 'member'
|
||||
nickname: string
|
||||
user_nickname: string
|
||||
avatar_url: string
|
||||
joined_at: string
|
||||
}
|
||||
|
||||
/** 创建群组 */
|
||||
export function createGroup(data: { name: string }) {
|
||||
return request<{ id: number; invite_code: string }>({ url: '/groups', method: 'POST', data })
|
||||
}
|
||||
|
||||
/** 获取群组列表 */
|
||||
export function getGroups() {
|
||||
return request<Group[]>({ url: '/groups' })
|
||||
}
|
||||
|
||||
/** 获取群组详情 */
|
||||
export function getGroupDetail(id: number) {
|
||||
return request<GroupDetail>({ url: `/groups/${id}` })
|
||||
}
|
||||
|
||||
/** 加入群组 */
|
||||
export function joinGroup(invite_code: string) {
|
||||
return request<{ group_id: number; name: string }>({ url: '/groups/join', method: 'POST', data: { invite_code } })
|
||||
}
|
||||
|
||||
/** 退出群组 */
|
||||
export function leaveGroup(id: number) {
|
||||
return request({ url: `/groups/${id}/leave`, method: 'POST' })
|
||||
}
|
||||
|
||||
/** 解散群组 */
|
||||
export function deleteGroup(id: number) {
|
||||
return request({ url: `/groups/${id}`, method: 'DELETE' })
|
||||
}
|
||||
@@ -25,16 +25,16 @@ export interface TrendPoint {
|
||||
}
|
||||
|
||||
/** 获取概览数据 */
|
||||
export function getOverview(params?: { month?: string; startDate?: string; endDate?: string }) {
|
||||
export function getOverview(params?: { month?: string; startDate?: string; endDate?: string; group_id?: number | null }) {
|
||||
return request<Overview>({ url: '/stats/overview', data: params })
|
||||
}
|
||||
|
||||
/** 获取分类统计 */
|
||||
export function getCategoryStats(month?: string, type: string = 'expense') {
|
||||
return request<CategoryStat[]>({ url: '/stats/category', data: { month, type } })
|
||||
export function getCategoryStats(month?: string, type: string = 'expense', group_id?: number | null) {
|
||||
return request<CategoryStat[]>({ url: '/stats/category', data: { month, type, group_id } })
|
||||
}
|
||||
|
||||
/** 获取趋势数据 */
|
||||
export function getTrend(month?: string, type: string = 'expense') {
|
||||
return request<TrendPoint[]>({ url: '/stats/trend', data: { month, type } })
|
||||
export function getTrend(month?: string, type: string = 'expense', group_id?: number | null) {
|
||||
return request<TrendPoint[]>({ url: '/stats/trend', data: { month, type, group_id } })
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface Transaction {
|
||||
category_name?: string
|
||||
category_icon?: string
|
||||
category_color?: string
|
||||
group_id?: number | null
|
||||
creator_nickname?: string
|
||||
}
|
||||
|
||||
/** 交易列表响应 */
|
||||
@@ -29,6 +31,7 @@ export interface TransactionParams {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
sortBy?: string
|
||||
group_id?: number | null
|
||||
}
|
||||
|
||||
/** 获取交易列表 */
|
||||
@@ -48,6 +51,7 @@ export function createTransaction(data: {
|
||||
category_id?: number
|
||||
note?: string
|
||||
date: string
|
||||
group_id?: number | null
|
||||
}) {
|
||||
return request<{ id: number }>({ url: '/transactions', method: 'POST', data })
|
||||
}
|
||||
|
||||
46
client/src/api/user.ts
Normal file
46
client/src/api/user.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { request } from '@/utils/request'
|
||||
import { API_BASE } from '@/config'
|
||||
|
||||
/** 用户信息 */
|
||||
export interface UserInfo {
|
||||
id: number
|
||||
nickname: string
|
||||
avatar_url: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 获取当前用户信息 */
|
||||
export function getUserInfo() {
|
||||
return request<UserInfo>({ url: '/user/me' })
|
||||
}
|
||||
|
||||
/** 更新昵称 */
|
||||
export function updateNickname(nickname: string) {
|
||||
return request({ url: '/user/me', method: 'PUT', data: { nickname } })
|
||||
}
|
||||
|
||||
/** 上传头像(返回新 URL) */
|
||||
export function uploadAvatar(filePath: string): Promise<{ avatar_url: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = uni.getStorageSync('xc:token')
|
||||
uni.uploadFile({
|
||||
url: `${API_BASE}/user/avatar`,
|
||||
filePath,
|
||||
name: 'file',
|
||||
header: { Authorization: `Bearer ${token}` },
|
||||
success: (res) => {
|
||||
try {
|
||||
const data = JSON.parse(res.data)
|
||||
if (data.code === 0) {
|
||||
resolve(data.data)
|
||||
} else {
|
||||
reject(new Error(data.message || '上传失败'))
|
||||
}
|
||||
} catch {
|
||||
reject(new Error('上传响应解析失败'))
|
||||
}
|
||||
},
|
||||
fail: () => reject(new Error('网络错误'))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,10 @@
|
||||
<text class="key-text">{{ key }}</text>
|
||||
</view>
|
||||
<view class="key fn" @tap="onDelete">
|
||||
<Icon name="trash" :size="36" color="#FF8C69" />
|
||||
<view class="backspace-icon">
|
||||
<view class="backspace-arrow"></view>
|
||||
<view class="backspace-line"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="key eq" @tap="onConfirm">
|
||||
<Icon name="check" :size="36" color="#FFFFFF" />
|
||||
@@ -13,26 +16,71 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 金额字符串(v-model) */
|
||||
modelValue?: string
|
||||
/** 最大金额(元) */
|
||||
max?: number
|
||||
}>(), {
|
||||
modelValue: '',
|
||||
max: 9999999.99
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'confirm'): void
|
||||
}>()
|
||||
|
||||
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '00']
|
||||
|
||||
const emit = defineEmits(['input', 'delete', 'confirm'])
|
||||
const amountStr = computed(() => props.modelValue)
|
||||
|
||||
function vibrate() {
|
||||
try {
|
||||
uni.vibrateShort({ type: 'light' })
|
||||
} catch {}
|
||||
try { uni.vibrateShort({ type: 'light' }) } catch {}
|
||||
}
|
||||
|
||||
function emitValue(val: string) {
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
|
||||
function onKeyTap(key: string) {
|
||||
vibrate()
|
||||
emit('input', key)
|
||||
const current = amountStr.value
|
||||
|
||||
if (key === '.') {
|
||||
if (current.includes('.')) return
|
||||
if (!current) { emitValue('0.'); return }
|
||||
}
|
||||
|
||||
let next: string
|
||||
if (!current || current === '0') {
|
||||
next = (key === '0' || key === '00') ? '0' : key
|
||||
} else {
|
||||
next = current + key
|
||||
}
|
||||
|
||||
if (next.includes('.')) {
|
||||
const decimals = next.split('.')[1]
|
||||
if (decimals && decimals.length > 2) return
|
||||
}
|
||||
if (next.length > 10) return
|
||||
const num = parseFloat(next)
|
||||
if (!isNaN(num) && num > props.max) return
|
||||
|
||||
emitValue(next)
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
vibrate()
|
||||
emit('delete')
|
||||
const current = amountStr.value
|
||||
if (current.length > 1) {
|
||||
emitValue(current.slice(0, -1))
|
||||
} else {
|
||||
emitValue('')
|
||||
}
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
@@ -57,9 +105,7 @@ function onConfirm() {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:active {
|
||||
background: #F0E0D6;
|
||||
}
|
||||
&:active { background: #F0E0D6; }
|
||||
}
|
||||
|
||||
.key-text {
|
||||
@@ -71,18 +117,41 @@ function onConfirm() {
|
||||
|
||||
.key.fn {
|
||||
background: #FFE8E0;
|
||||
|
||||
&:active {
|
||||
background: #FFD0C0;
|
||||
}
|
||||
&:active { background: #FFD0C0; }
|
||||
}
|
||||
|
||||
.key.eq {
|
||||
background: linear-gradient(135deg, #FF8C69, #E67355);
|
||||
box-shadow: 0 4rpx 16rpx rgba(255, 140, 105, 0.3);
|
||||
&:active { background: #E67355; }
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #E67355;
|
||||
}
|
||||
// 自绘 backspace 图标
|
||||
.backspace-icon {
|
||||
width: 40rpx;
|
||||
height: 28rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.backspace-arrow {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-left: 4rpx solid #FF8C69;
|
||||
border-bottom: 4rpx solid #FF8C69;
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.backspace-line {
|
||||
position: absolute;
|
||||
left: 12rpx;
|
||||
top: 50%;
|
||||
width: 26rpx;
|
||||
height: 4rpx;
|
||||
background: #FF8C69;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
26
client/src/config.ts
Normal file
26
client/src/config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 全局配置 — API 基础地址
|
||||
*
|
||||
* 小程序开发版 → dev.xiaocai.j35.site
|
||||
* 小程序正式版 / H5 → 线上服务
|
||||
*/
|
||||
|
||||
declare const wx: any
|
||||
|
||||
function resolveApiBase(): string {
|
||||
// H5 环境:同源相对路径
|
||||
if (typeof window !== 'undefined') return '/api'
|
||||
|
||||
// 小程序环境:根据版本号判断
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
const info = (wx as any).getAccountInfoSync?.()?.miniProgram
|
||||
if (info?.envVersion === 'develop') {
|
||||
return 'https://dev.xiaocai.j35.site/api'
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
return 'https://xiaocai.j35.site/api'
|
||||
}
|
||||
|
||||
export const API_BASE = resolveApiBase()
|
||||
@@ -51,6 +51,20 @@
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "预算设置"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile-edit/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "编辑资料"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/group-manage/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "一起记"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<view class="amount-display">
|
||||
<text class="currency">¥</text>
|
||||
<text class="digits">{{ displayAmount }}</text>
|
||||
<text class="cursor">|</text>
|
||||
</view>
|
||||
|
||||
<scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat">
|
||||
@@ -46,7 +47,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<Numpad @input="onInput" @delete="onDelete" @confirm="save" />
|
||||
<Numpad v-model="amountStr" @confirm="save" />
|
||||
|
||||
<SaveSuccess v-model:visible="showSuccess" :text="editId ? '修改成功' : '记账成功'" />
|
||||
</view>
|
||||
@@ -86,8 +87,8 @@ const dateLabel = computed(() => {
|
||||
})
|
||||
|
||||
const displayAmount = computed(() => {
|
||||
const num = parseFloat(amountStr.value) || 0
|
||||
return num.toFixed(2)
|
||||
if (!amountStr.value) return ''
|
||||
return amountStr.value
|
||||
})
|
||||
|
||||
const currentCategories = computed(() => catStore.getByType(type.value))
|
||||
@@ -151,33 +152,6 @@ function onDateChange(e: any) {
|
||||
selectedDate.value = e.detail.value
|
||||
}
|
||||
|
||||
function onInput(key: string) {
|
||||
if (key === '.' && amountStr.value.includes('.')) return
|
||||
// When current value is '0', replace with new key (but '0' and '00' should stay as '0')
|
||||
let next: string
|
||||
if (amountStr.value === '0') {
|
||||
next = (key === '0' || key === '00') ? '0' : key
|
||||
} else {
|
||||
next = amountStr.value + key
|
||||
}
|
||||
const num = parseFloat(next)
|
||||
if (!isNaN(num) && num > 9999999.99) return
|
||||
// Check decimal limit on the resulting value (handles "00" adding 2 chars)
|
||||
if (next.includes('.')) {
|
||||
const decimals = next.split('.')[1]
|
||||
if (decimals && decimals.length > 2) return
|
||||
}
|
||||
amountStr.value = next
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (amountStr.value.length > 1) {
|
||||
amountStr.value = amountStr.value.slice(0, -1)
|
||||
} else {
|
||||
amountStr.value = '0'
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saving.value) return
|
||||
const amount = Math.round(parseFloat(amountStr.value) * 100)
|
||||
@@ -329,6 +303,18 @@ function goBack() {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
font-family: 'Fredoka', monospace;
|
||||
font-size: 88rpx;
|
||||
font-weight: 300;
|
||||
color: #FF8C69;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.category-scroll {
|
||||
max-height: 320rpx;
|
||||
}
|
||||
|
||||
@@ -71,8 +71,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { useTransactionStore } from '@/stores/transaction'
|
||||
import { waitForReady } from '@/utils/app-ready'
|
||||
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
import Skeleton from '@/components/Skeleton/Skeleton.vue'
|
||||
@@ -106,7 +107,17 @@ const groupedTx = computed(() => {
|
||||
return Object.values(groups).sort((a, b) => b.date.localeCompare(a.date))
|
||||
})
|
||||
|
||||
onMounted(() => loadTx(true))
|
||||
const initialLoaded = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
loadTx(true).finally(() => { initialLoaded.value = true })
|
||||
})
|
||||
|
||||
// 静默刷新(切换群组/返回时)
|
||||
onShow(() => {
|
||||
if (initialLoaded.value) loadTx(true)
|
||||
})
|
||||
|
||||
function switchFilter(type: string) {
|
||||
filterType.value = type
|
||||
|
||||
@@ -49,18 +49,13 @@
|
||||
<view class="section-title">自定义金额</view>
|
||||
<view class="custom-input">
|
||||
<text class="input-prefix">¥</text>
|
||||
<text class="input-value" :class="{ placeholder: !amountStr }">
|
||||
{{ amountStr || '0.00' }}
|
||||
</text>
|
||||
<text class="input-value" :class="{ placeholder: !amountStr }">{{ amountStr }}</text>
|
||||
<text class="input-cursor">|</text>
|
||||
<text class="input-unit">元</text>
|
||||
</view>
|
||||
|
||||
<!-- Numpad -->
|
||||
<Numpad
|
||||
@input="onInput"
|
||||
@delete="onDelete"
|
||||
@confirm="onConfirm"
|
||||
/>
|
||||
<Numpad v-model="amountStr" @confirm="onConfirm" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -113,32 +108,6 @@ function selectPreset(val: number) {
|
||||
amountStr.value = String(val)
|
||||
}
|
||||
|
||||
function onInput(key: string) {
|
||||
if (key === '.' && amountStr.value.includes('.')) return
|
||||
if (key === '.' && !amountStr.value) {
|
||||
amountStr.value = '0.'
|
||||
return
|
||||
}
|
||||
let next: string
|
||||
if (amountStr.value === '0') {
|
||||
next = (key === '0' || key === '00') ? '0' : key
|
||||
} else {
|
||||
next = amountStr.value + key
|
||||
}
|
||||
const parts = next.split('.')
|
||||
if (parts[1] && parts[1].length > 2) return
|
||||
if (next.length > 10) return
|
||||
amountStr.value = next
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (amountStr.value.length > 1) {
|
||||
amountStr.value = amountStr.value.slice(0, -1)
|
||||
} else {
|
||||
amountStr.value = '0'
|
||||
}
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
@@ -153,6 +122,7 @@ async function onConfirm() {
|
||||
await budgetStore.setBudget(amount, currentMonth)
|
||||
budget.value = budgetStore.budget
|
||||
uni.showToast({ title: '预算已设置', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 1500)
|
||||
} catch {
|
||||
uni.showToast({ title: '保存失败,请重试', icon: 'none' })
|
||||
}
|
||||
@@ -348,4 +318,16 @@ function goBack() {
|
||||
font-size: 28rpx;
|
||||
color: #8B7E7E;
|
||||
}
|
||||
|
||||
.input-cursor {
|
||||
font-family: 'Fredoka', monospace;
|
||||
font-size: 56rpx;
|
||||
font-weight: 300;
|
||||
color: #FF8C69;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
380
client/src/pages/group-manage/index.vue
Normal file
380
client/src/pages/group-manage/index.vue
Normal file
@@ -0,0 +1,380 @@
|
||||
<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 style="width: 72rpx;"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 创建群组 -->
|
||||
<view class="action-card">
|
||||
<text class="card-title">创建新群组</text>
|
||||
<view class="action-row">
|
||||
<input class="action-input" v-model="newGroupName" placeholder="群组名称" maxlength="100" />
|
||||
<view class="action-btn" @tap="handleCreate">
|
||||
<text class="action-btn-text">创建</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加入群组 -->
|
||||
<view class="action-card">
|
||||
<text class="card-title">加入群组</text>
|
||||
<view class="action-row">
|
||||
<input class="action-input" v-model="inviteCode" placeholder="输入邀请码" maxlength="6" />
|
||||
<view class="action-btn" @tap="handleJoin">
|
||||
<text class="action-btn-text">加入</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我的群组列表 -->
|
||||
<view class="section-header">
|
||||
<text class="section-title">我的群组</text>
|
||||
</view>
|
||||
|
||||
<view v-if="groupStore.loading" class="loading-box">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="groupStore.groups.length === 0" class="empty-box">
|
||||
<Icon name="user" :size="48" color="#BFB3B3" />
|
||||
<text class="empty-text">还没有加入任何群组</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="group-list">
|
||||
<view v-for="group in groupStore.groups" :key="group.id" class="group-item">
|
||||
<view class="group-icon">
|
||||
<Icon name="user" :size="32" color="#FF8C69" />
|
||||
</view>
|
||||
<view class="group-info">
|
||||
<text class="group-name">{{ group.name }}</text>
|
||||
<text class="group-meta">{{ group.member_count }} 人 · {{ group.role === 'owner' ? '群主' : '成员' }}</text>
|
||||
<view class="invite-row" @tap="copyInviteCode(group.invite_code)">
|
||||
<text class="invite-label">邀请码:</text>
|
||||
<text class="invite-code">{{ group.invite_code }}</text>
|
||||
<text class="invite-copy">复制</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="group-actions">
|
||||
<view v-if="group.role === 'owner'" class="group-btn dissolve" @tap="handleDissolve(group)">
|
||||
<text class="group-btn-text dissolve-text">解散</text>
|
||||
</view>
|
||||
<view v-else class="group-btn leave" @tap="handleLeave(group)">
|
||||
<text class="group-btn-text leave-text">退出</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { statusBarHeight } from '@/utils/system'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const newGroupName = ref('')
|
||||
const inviteCode = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
groupStore.fetchGroups()
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function copyInviteCode(code: string) {
|
||||
uni.setClipboardData({
|
||||
data: code,
|
||||
success: () => uni.showToast({ title: '已复制', icon: 'success' })
|
||||
})
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const name = newGroupName.value.trim()
|
||||
if (!name) {
|
||||
uni.showToast({ title: '请输入群组名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await groupStore.createGroup(name)
|
||||
newGroupName.value = ''
|
||||
uni.setClipboardData({
|
||||
data: result.invite_code,
|
||||
success: () => {
|
||||
uni.showModal({
|
||||
title: '创建成功',
|
||||
content: `邀请码 ${result.invite_code} 已复制,分享给好友即可加入`,
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e.message || '创建失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleJoin() {
|
||||
const code = inviteCode.value.trim().toUpperCase()
|
||||
if (!code) {
|
||||
uni.showToast({ title: '请输入邀请码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await groupStore.joinGroup(code)
|
||||
inviteCode.value = ''
|
||||
uni.showToast({ title: `已加入「${result.name}」`, icon: 'success' })
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e.message || '加入失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
function handleLeave(group: any) {
|
||||
uni.showModal({
|
||||
title: '退出群组',
|
||||
content: `确定退出「${group.name}」?你在该群组的记录将回到个人账本。`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await groupStore.leaveGroup(group.id)
|
||||
uni.showToast({ title: '已退出', icon: 'success' })
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e.message || '退出失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleDissolve(group: any) {
|
||||
uni.showModal({
|
||||
title: '解散群组',
|
||||
content: `确定解散「${group.name}」?群组记录将回到各成员的个人账本。`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await groupStore.deleteGroup(group.id)
|
||||
uni.showToast({ title: '已解散', icon: 'success' })
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e.message || '解散失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #FFF8F0;
|
||||
padding: 0 0 180rpx;
|
||||
}
|
||||
|
||||
.header-fixed {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: #FFF8F0;
|
||||
}
|
||||
|
||||
.status-bar { background: #FFF8F0; }
|
||||
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 40rpx 24rpx;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
margin: 24rpx 40rpx 0;
|
||||
padding: 32rpx 40rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 40rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
display: block;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.action-input {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
padding: 0 24rpx;
|
||||
background: #FFF8F0;
|
||||
border-radius: 20rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
font-size: 28rpx;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
height: 80rpx;
|
||||
padding: 0 32rpx;
|
||||
background: linear-gradient(135deg, #FF8C69, #E67355);
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 40rpx 40rpx 16rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #8B7E7E;
|
||||
}
|
||||
|
||||
.loading-box, .empty-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
padding: 80rpx 0;
|
||||
}
|
||||
|
||||
.loading-text, .empty-text {
|
||||
font-size: 28rpx;
|
||||
color: #BFB3B3;
|
||||
}
|
||||
|
||||
.group-list {
|
||||
padding: 0 40rpx;
|
||||
}
|
||||
|
||||
.group-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28rpx 32rpx;
|
||||
margin-bottom: 16rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 28rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.group-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background: #FFF0E6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.group-meta {
|
||||
font-size: 24rpx;
|
||||
color: #BFB3B3;
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.invite-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 8rpx;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.invite-label {
|
||||
font-size: 24rpx;
|
||||
color: #8B7E7E;
|
||||
}
|
||||
|
||||
.invite-code {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: #FF8C69;
|
||||
font-family: 'Fredoka', monospace;
|
||||
}
|
||||
|
||||
.invite-copy {
|
||||
font-size: 22rpx;
|
||||
color: #FF8C69;
|
||||
margin-left: 8rpx;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.group-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-btn {
|
||||
padding: 12rpx 24rpx;
|
||||
border-radius: 16rpx;
|
||||
|
||||
&.leave { background: #FFF0E6; }
|
||||
&.dissolve { background: #FFE8E8; }
|
||||
}
|
||||
|
||||
.group-btn-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
|
||||
&.leave-text { color: #FF8C69; }
|
||||
&.dissolve-text { color: #FF6B6B; }
|
||||
}
|
||||
</style>
|
||||
@@ -2,11 +2,12 @@
|
||||
<view class="page">
|
||||
<view class="header-fixed">
|
||||
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
|
||||
<view class="header">
|
||||
<view class="header" :style="{ paddingRight: capsuleRight + 'px' }">
|
||||
<view class="avatar-wrap">
|
||||
<Icon name="user" :size="36" color="#8B7E7E" />
|
||||
<image v-if="userStore.avatarUrl" :src="userStore.avatarUrl" class="avatar-img" mode="aspectFill" />
|
||||
<Icon v-else name="user" :size="36" color="#8B7E7E" />
|
||||
</view>
|
||||
<text class="greeting">{{ greeting }},小菜</text>
|
||||
<text class="greeting">{{ greeting }},{{ userStore.nickname }}</text>
|
||||
<view class="bell" @tap="showNotification">
|
||||
<Icon name="bell" :size="40" color="#8B7E7E" />
|
||||
</view>
|
||||
@@ -117,9 +118,12 @@ import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { useTransactionStore } from '@/stores/transaction'
|
||||
import { useStatsStore } from '@/stores/stats'
|
||||
import { useBudgetStore } from '@/stores/budget'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { waitForReady } from '@/utils/app-ready'
|
||||
import { getOverview } from '@/api/stats'
|
||||
import { formatAmount, formatAmountRaw } from '@/utils/format'
|
||||
import { statusBarHeight } from '@/utils/system'
|
||||
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
||||
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
|
||||
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
@@ -128,11 +132,13 @@ import Skeleton from '@/components/Skeleton/Skeleton.vue'
|
||||
const txStore = useTransactionStore()
|
||||
const statsStore = useStatsStore()
|
||||
const budgetStore = useBudgetStore()
|
||||
const userStore = useUserStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const DEFAULT_BUDGET = 500000 // 5000元(分)
|
||||
|
||||
const overview = ref({ expense: 0, income: 0, count: 0, daily: 0 })
|
||||
const budget = ref<any>(null)
|
||||
const overview = computed(() => statsStore.overview)
|
||||
const budget = computed(() => budgetStore.budget)
|
||||
const recentTx = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const loadError = ref(false)
|
||||
@@ -149,9 +155,10 @@ const greeting = computed(() => {
|
||||
return '晚上好'
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
async function loadData(silent = false) {
|
||||
await waitForReady()
|
||||
try {
|
||||
loading.value = true
|
||||
if (!silent) loading.value = true
|
||||
loadError.value = false
|
||||
const now = new Date()
|
||||
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
@@ -159,14 +166,13 @@ async function loadData() {
|
||||
statsStore.fetchOverview(),
|
||||
budgetStore.fetchBudget(),
|
||||
txStore.fetchTransactions({ page: 1 }),
|
||||
fetchTodayData(today)
|
||||
fetchTodayData(today),
|
||||
userStore.fetchUserInfo()
|
||||
])
|
||||
overview.value = statsStore.overview
|
||||
budget.value = budgetStore.budget
|
||||
recentTx.value = txStore.transactions.slice(0, 5)
|
||||
} catch (e) {
|
||||
console.error('Load error:', e)
|
||||
loadError.value = true
|
||||
if (!silent) loadError.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -174,7 +180,7 @@ async function loadData() {
|
||||
|
||||
async function fetchTodayData(today: string) {
|
||||
try {
|
||||
const data = await getOverview({ startDate: today, endDate: today })
|
||||
const data = await getOverview({ startDate: today, endDate: today, group_id: groupStore.currentGroupId })
|
||||
todayExpense.value = data.expense || 0
|
||||
todayIncome.value = data.income || 0
|
||||
todayCount.value = data.count || 0
|
||||
@@ -192,7 +198,7 @@ onMounted(() => {
|
||||
|
||||
// Refresh data when returning from other pages (e.g., after adding a transaction)
|
||||
onShow(() => {
|
||||
if (initialLoaded.value && !loading.value) loadData()
|
||||
if (initialLoaded.value && !loading.value) loadData(true)
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
@@ -249,6 +255,12 @@ function goBills() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
|
||||
250
client/src/pages/profile-edit/index.vue
Normal file
250
client/src/pages/profile-edit/index.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="header-fixed">
|
||||
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
|
||||
<view class="nav-bar" :style="{ paddingRight: capsuleRight + 'px' }">
|
||||
<view class="nav-back" @tap="goBack">
|
||||
<Icon name="arrowLeft" :size="40" color="#2D1B1B" />
|
||||
</view>
|
||||
<text class="nav-title">编辑资料</text>
|
||||
<view class="nav-save" @tap="saveProfile">
|
||||
<text class="save-text">保存</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="avatar-section">
|
||||
<view class="avatar-wrap" @tap="chooseAvatar">
|
||||
<image v-if="previewUrl" :src="previewUrl" class="avatar-img" mode="aspectFill" />
|
||||
<Icon v-else name="user" :size="64" color="#FFFFFF" />
|
||||
<view class="avatar-badge">
|
||||
<Icon name="edit" :size="24" color="#FFFFFF" />
|
||||
</view>
|
||||
</view>
|
||||
<text class="avatar-hint">点击更换头像</text>
|
||||
</view>
|
||||
|
||||
<view class="form-card">
|
||||
<view class="form-item">
|
||||
<text class="form-label">昵称</text>
|
||||
<input class="form-input" v-model="formNickname" placeholder="请输入昵称" maxlength="50" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="tips-card">
|
||||
<text class="tips-title">提示</text>
|
||||
<text class="tips-text">• 昵称最长 50 个字符</text>
|
||||
<text class="tips-text">• 头像支持 JPG、PNG 格式,最大 2MB</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const formNickname = ref('')
|
||||
const previewUrl = ref('')
|
||||
const uploading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
formNickname.value = userStore.nickname
|
||||
previewUrl.value = userStore.avatarUrl
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function chooseAvatar() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: async (res) => {
|
||||
const tempPath = res.tempFilePaths[0]
|
||||
previewUrl.value = tempPath
|
||||
uploading.value = true
|
||||
try {
|
||||
await userStore.uploadAvatar(tempPath)
|
||||
uni.showToast({ title: '头像已更新', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '头像上传失败', icon: 'none' })
|
||||
previewUrl.value = userStore.avatarUrl
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
if (saving.value || uploading.value) return
|
||||
const nickname = formNickname.value.trim()
|
||||
if (!nickname) {
|
||||
uni.showToast({ title: '请输入昵称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await userStore.updateProfile(nickname)
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 1500)
|
||||
} catch {
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #FFF8F0;
|
||||
padding: 0 0 180rpx;
|
||||
}
|
||||
|
||||
.header-fixed {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: #FFF8F0;
|
||||
}
|
||||
|
||||
.status-bar { background: #FFF8F0; }
|
||||
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 40rpx 24rpx;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.nav-save {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.save-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #FF8C69;
|
||||
}
|
||||
|
||||
.avatar-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 48rpx 0 32rpx;
|
||||
}
|
||||
|
||||
.avatar-wrap {
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
border-radius: 50%;
|
||||
border: 6rpx solid rgba(255, 255, 255, 0.5);
|
||||
background: linear-gradient(135deg, #FF8C69, #FFB89A);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar-img { width: 100%; height: 100%; }
|
||||
|
||||
.avatar-badge {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-hint {
|
||||
font-size: 24rpx;
|
||||
color: #BFB3B3;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
margin: 32rpx 40rpx 0;
|
||||
background: #FFFFFF;
|
||||
border-radius: 40rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 32rpx 40rpx;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
width: 120rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #2D1B1B;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.tips-card {
|
||||
margin: 32rpx 40rpx 0;
|
||||
padding: 32rpx 40rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 40rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
||||
}
|
||||
|
||||
.tips-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
display: block;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.tips-text {
|
||||
font-size: 24rpx;
|
||||
color: #8B7E7E;
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -5,15 +5,27 @@
|
||||
<text class="page-title">我的</text>
|
||||
</view>
|
||||
|
||||
<view class="profile-card">
|
||||
<view class="profile-card" @tap="goProfileEdit">
|
||||
<view class="p-avatar">
|
||||
<Icon name="user" :size="64" color="#FFFFFF" />
|
||||
<image v-if="userStore.avatarUrl" :src="userStore.avatarUrl" class="p-avatar-img" mode="aspectFill" />
|
||||
<Icon v-else name="user" :size="64" color="#FFFFFF" />
|
||||
</view>
|
||||
<view class="p-info">
|
||||
<text class="p-name">小菜</text>
|
||||
<text class="p-name">{{ userStore.nickname }}</text>
|
||||
<text class="p-tagline">记账小能手</text>
|
||||
<text class="p-streak">累计 {{ txStore.total }} 笔记录</text>
|
||||
</view>
|
||||
<Icon name="chevronRight" :size="28" color="rgba(255,255,255,0.6)" />
|
||||
</view>
|
||||
|
||||
<!-- 身份切换 -->
|
||||
<view class="identity-card" @tap="showIdentityPicker">
|
||||
<Icon name="user" :size="32" color="#FF8C69" />
|
||||
<view class="id-info">
|
||||
<text class="id-name">{{ groupStore.isGroupMode ? groupStore.currentGroup?.name : '个人账本' }}</text>
|
||||
<text class="id-desc">{{ groupStore.isGroupMode ? groupStore.currentGroup?.member_count + ' 人共享' : '仅自己可见' }}</text>
|
||||
</view>
|
||||
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||
</view>
|
||||
|
||||
<view class="quick-stats">
|
||||
@@ -64,14 +76,54 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 身份选择弹窗 -->
|
||||
<view class="modal-mask" v-if="showIdentity" @tap="showIdentity = false">
|
||||
<view class="identity-modal" @tap.stop>
|
||||
<text class="modal-title">切换账本</text>
|
||||
|
||||
<view class="id-option" :class="{ active: !groupStore.isGroupMode }" @tap="switchToPersonal">
|
||||
<Icon name="user" :size="36" :color="!groupStore.isGroupMode ? '#FF8C69' : '#8B7E7E'" />
|
||||
<text class="id-option-name">个人账本</text>
|
||||
<Icon v-if="!groupStore.isGroupMode" name="check" :size="28" color="#FF8C69" />
|
||||
</view>
|
||||
|
||||
<view v-for="g in groupStore.groups" :key="g.id"
|
||||
class="id-option" :class="{ active: groupStore.currentGroupId === g.id }"
|
||||
@tap="switchToGroup(g.id)">
|
||||
<Icon name="user" :size="36" :color="groupStore.currentGroupId === g.id ? '#FF8C69' : '#8B7E7E'" />
|
||||
<view class="id-option-info">
|
||||
<text class="id-option-name">{{ g.name }}</text>
|
||||
<text class="id-option-desc">{{ g.member_count }} 人</text>
|
||||
</view>
|
||||
<Icon v-if="groupStore.currentGroupId === g.id" name="check" :size="28" color="#FF8C69" />
|
||||
</view>
|
||||
|
||||
<view class="id-actions">
|
||||
<view class="id-action-btn" @tap="promptCreateGroup">
|
||||
<text class="id-action-text">创建群组</text>
|
||||
</view>
|
||||
<view class="id-action-btn" @tap="promptJoinGroup">
|
||||
<text class="id-action-text">加入群组</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="id-manage" @tap="goGroupManage">
|
||||
<text class="id-manage-text">管理群组</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useTransactionStore } from '@/stores/transaction'
|
||||
import { useStatsStore } from '@/stores/stats'
|
||||
import { useBudgetStore } from '@/stores/budget'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { waitForReady } from '@/utils/app-ready'
|
||||
import { getTransactions } from '@/api/transaction'
|
||||
import { formatAmount } from '@/utils/format'
|
||||
import { statusBarHeight } from '@/utils/system'
|
||||
@@ -80,22 +132,26 @@ import Icon from '@/components/Icon/Icon.vue'
|
||||
const txStore = useTransactionStore()
|
||||
const statsStore = useStatsStore()
|
||||
const budgetStore = useBudgetStore()
|
||||
const userStore = useUserStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const overview = ref({ expense: 0, income: 0, count: 0, daily: 0 })
|
||||
const budget = ref<any>(null)
|
||||
const overview = computed(() => statsStore.overview)
|
||||
const budget = computed(() => budgetStore.budget)
|
||||
const loading = ref(true)
|
||||
const showAbout = ref(false)
|
||||
const showIdentity = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
loading.value = true
|
||||
try {
|
||||
await Promise.all([
|
||||
statsStore.fetchOverview(),
|
||||
budgetStore.fetchBudget(),
|
||||
txStore.fetchTransactions({ page: 1, pageSize: 1 })
|
||||
txStore.fetchTransactions({ page: 1, pageSize: 1 }),
|
||||
userStore.fetchUserInfo(),
|
||||
groupStore.fetchGroups()
|
||||
])
|
||||
overview.value = statsStore.overview
|
||||
budget.value = budgetStore.budget
|
||||
} catch (e) {
|
||||
console.error('Profile load error:', e)
|
||||
} finally {
|
||||
@@ -111,6 +167,94 @@ function goCategoryManage() {
|
||||
uni.navigateTo({ url: '/pages/category-manage/index' })
|
||||
}
|
||||
|
||||
function goProfileEdit() {
|
||||
uni.navigateTo({ url: '/pages/profile-edit/index' })
|
||||
}
|
||||
|
||||
function showIdentityPicker() {
|
||||
showIdentity.value = true
|
||||
}
|
||||
|
||||
function switchToPersonal() {
|
||||
groupStore.switchToPersonal()
|
||||
showIdentity.value = false
|
||||
}
|
||||
|
||||
function switchToGroup(id: number) {
|
||||
groupStore.switchToGroup(id)
|
||||
showIdentity.value = false
|
||||
}
|
||||
|
||||
function goGroupManage() {
|
||||
showIdentity.value = false
|
||||
uni.navigateTo({ url: '/pages/group-manage/index' })
|
||||
}
|
||||
|
||||
function promptCreateGroup() {
|
||||
// #ifdef MP-WEIXIN
|
||||
// 小程序不支持 prompt,使用输入框页面
|
||||
uni.showModal({
|
||||
title: '创建群组',
|
||||
editable: true,
|
||||
placeholderText: '请输入群组名称',
|
||||
success: async (res) => {
|
||||
if (res.confirm && res.content) {
|
||||
try {
|
||||
const result = await groupStore.createGroup(res.content.trim())
|
||||
uni.setClipboardData({
|
||||
data: result.invite_code,
|
||||
success: () => uni.showToast({ title: '邀请码已复制', icon: 'success' })
|
||||
})
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e.message || '创建失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
const name = window.prompt('请输入群组名称')
|
||||
if (name) {
|
||||
groupStore.createGroup(name.trim()).then((result) => {
|
||||
navigator.clipboard?.writeText(result.invite_code)
|
||||
uni.showToast({ title: `邀请码 ${result.invite_code} 已复制`, icon: 'success' })
|
||||
}).catch((e: any) => {
|
||||
uni.showToast({ title: e.message || '创建失败', icon: 'none' })
|
||||
})
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
function promptJoinGroup() {
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.showModal({
|
||||
title: '加入群组',
|
||||
editable: true,
|
||||
placeholderText: '请输入邀请码',
|
||||
success: async (res) => {
|
||||
if (res.confirm && res.content) {
|
||||
try {
|
||||
const result = await groupStore.joinGroup(res.content.trim())
|
||||
uni.showToast({ title: `已加入「${result.name}」`, icon: 'success' })
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e.message || '加入失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
const code = window.prompt('请输入邀请码')
|
||||
if (code) {
|
||||
groupStore.joinGroup(code.trim()).then((result) => {
|
||||
uni.showToast({ title: `已加入「${result.name}」`, icon: 'success' })
|
||||
}).catch((e: any) => {
|
||||
uni.showToast({ title: e.message || '加入失败', icon: 'none' })
|
||||
})
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
function getLocalDateStr(): string {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
@@ -119,15 +263,21 @@ function getLocalDateStr(): string {
|
||||
async function exportData() {
|
||||
uni.showLoading({ title: '导出中...' })
|
||||
try {
|
||||
const data = await getTransactions({ page: 1, pageSize: 9999 })
|
||||
const list = data.list
|
||||
if (!list || list.length === 0) {
|
||||
// 分页获取全部数据(服务端 pageSize 上限 100)
|
||||
const allList: any[] = []
|
||||
let page = 1
|
||||
let total = 0
|
||||
do {
|
||||
const data = await getTransactions({ page, pageSize: 100, group_id: groupStore.currentGroupId })
|
||||
allList.push(...data.list)
|
||||
total = data.total
|
||||
page++
|
||||
} while (allList.length < total)
|
||||
|
||||
if (allList.length === 0) {
|
||||
uni.showToast({ title: '暂无数据', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (data.total > list.length) {
|
||||
uni.showToast({ title: `共${data.total}条,仅导出前${list.length}条`, icon: 'none' })
|
||||
}
|
||||
|
||||
function csvEscape(val: string): string {
|
||||
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
|
||||
@@ -136,7 +286,7 @@ async function exportData() {
|
||||
return val
|
||||
}
|
||||
const header = '日期,类型,分类,金额(元),备注\n'
|
||||
const rows = list.map(t => {
|
||||
const rows = allList.map(t => {
|
||||
const amount = (t.amount / 100).toFixed(2)
|
||||
const type = t.type === 'expense' ? '支出' : '收入'
|
||||
return [t.date, type, t.category_name || '未分类', amount, t.note || ''].map(csvEscape).join(',')
|
||||
@@ -379,4 +529,129 @@ async function exportData() {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
}
|
||||
|
||||
.p-avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.identity-card {
|
||||
margin: 24rpx 40rpx 0;
|
||||
padding: 28rpx 32rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 28rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.id-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.id-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.id-desc {
|
||||
font-size: 24rpx;
|
||||
color: #BFB3B3;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.identity-modal {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #FFFFFF;
|
||||
border-radius: 40rpx 40rpx 0 0;
|
||||
padding: 40rpx;
|
||||
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.id-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
border-radius: 20rpx;
|
||||
margin-bottom: 12rpx;
|
||||
|
||||
&.active { background: #FFF0E6; }
|
||||
&:active { background: #FFF8F0; }
|
||||
}
|
||||
|
||||
.id-option-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.id-option-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.id-option-desc {
|
||||
font-size: 24rpx;
|
||||
color: #BFB3B3;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
|
||||
.id-actions {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
margin-top: 24rpx;
|
||||
padding-top: 24rpx;
|
||||
border-top: 1rpx solid #F0E0D6;
|
||||
}
|
||||
|
||||
.id-action-btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border-radius: 20rpx;
|
||||
background: #FFF0E6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:active { background: #FFE8D6; }
|
||||
}
|
||||
|
||||
.id-action-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #FF8C69;
|
||||
}
|
||||
|
||||
.id-manage {
|
||||
margin-top: 16rpx;
|
||||
padding: 20rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.id-manage-text {
|
||||
font-size: 26rpx;
|
||||
color: #8B7E7E;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -119,8 +119,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { useStatsStore } from '@/stores/stats'
|
||||
import { waitForReady } from '@/utils/app-ready'
|
||||
import { getTransactions } from '@/api/transaction'
|
||||
import { getOverview } from '@/api/stats'
|
||||
import { formatAmount, formatMonth, getCurrentMonth } from '@/utils/format'
|
||||
@@ -133,9 +134,9 @@ const statsStore = useStatsStore()
|
||||
|
||||
const currentMonth = ref(getCurrentMonth())
|
||||
const tabType = ref<'expense' | 'income'>('expense')
|
||||
const overview = ref({ expense: 0, income: 0, count: 0, daily: 0 })
|
||||
const categoryStats = ref<any[]>([])
|
||||
const trendData = ref<any[]>([])
|
||||
const overview = computed(() => statsStore.overview)
|
||||
const categoryStats = computed(() => statsStore.categoryStats)
|
||||
const trendData = computed(() => statsStore.trendData)
|
||||
const loading = ref(true)
|
||||
const loadError = ref(false)
|
||||
const maxSingle = ref(0)
|
||||
@@ -221,15 +222,24 @@ const monthCompareText = computed(() => {
|
||||
return '持平'
|
||||
})
|
||||
|
||||
onMounted(() => loadData())
|
||||
const initialLoaded = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
loadData().finally(() => { initialLoaded.value = true })
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
if (initialLoaded.value) loadData(true)
|
||||
})
|
||||
|
||||
// Only re-fetch category/trend on tab switch; re-fetch all on month change
|
||||
watch(currentMonth, () => loadData())
|
||||
watch(tabType, () => loadTabData())
|
||||
|
||||
async function loadData() {
|
||||
async function loadData(silent = false) {
|
||||
try {
|
||||
loading.value = true
|
||||
if (!silent) loading.value = true
|
||||
loadError.value = false
|
||||
await Promise.all([
|
||||
statsStore.fetchOverview(currentMonth.value),
|
||||
@@ -238,9 +248,6 @@ async function loadData() {
|
||||
fetchMaxSingle(),
|
||||
fetchPrevMonthData()
|
||||
])
|
||||
overview.value = statsStore.overview
|
||||
categoryStats.value = statsStore.categoryStats
|
||||
trendData.value = statsStore.trendData
|
||||
} catch (e) {
|
||||
console.error('Stats load error:', e)
|
||||
loadError.value = true
|
||||
@@ -259,9 +266,6 @@ async function loadTabData() {
|
||||
fetchMaxSingle(),
|
||||
fetchPrevMonthData()
|
||||
])
|
||||
overview.value = statsStore.overview
|
||||
categoryStats.value = statsStore.categoryStats
|
||||
trendData.value = statsStore.trendData
|
||||
} catch (e) {
|
||||
console.error('Stats tab load error:', e)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as api from '@/api/budget'
|
||||
import { getCurrentMonth } from '@/utils/format'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
|
||||
// 重新导出类型以保持向后兼容
|
||||
export type Budget = api.Budget
|
||||
@@ -13,7 +14,8 @@ export const useBudgetStore = defineStore('budget', () => {
|
||||
async function fetchBudget(month?: string) {
|
||||
loading.value = true
|
||||
try {
|
||||
budget.value = await api.getBudget(month || getCurrentMonth())
|
||||
const groupStore = useGroupStore()
|
||||
budget.value = await api.getBudget(month || getCurrentMonth(), groupStore.currentGroupId)
|
||||
} catch {
|
||||
budget.value = null
|
||||
} finally {
|
||||
|
||||
113
client/src/stores/group.ts
Normal file
113
client/src/stores/group.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import * as api from '@/api/group'
|
||||
import { useStatsStore } from '@/stores/stats'
|
||||
import { useBudgetStore } from '@/stores/budget'
|
||||
import { useTransactionStore } from '@/stores/transaction'
|
||||
|
||||
export const useGroupStore = defineStore('group', () => {
|
||||
const groups = ref<api.Group[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
/** 当前选中的群组 ID,null 表示个人模式 */
|
||||
const currentGroupId = ref<number | null>(null)
|
||||
|
||||
/** 当前群组信息 */
|
||||
const currentGroup = computed(() => {
|
||||
if (currentGroupId.value === null) return null
|
||||
return groups.value.find(g => g.id === currentGroupId.value) || null
|
||||
})
|
||||
|
||||
/** 是否处于群组模式 */
|
||||
const isGroupMode = computed(() => currentGroupId.value !== null)
|
||||
|
||||
/** 切换到个人模式 */
|
||||
function switchToPersonal() {
|
||||
currentGroupId.value = null
|
||||
uni.removeStorageSync('xc:currentGroupId')
|
||||
refreshAll()
|
||||
}
|
||||
|
||||
/** 切换到群组模式 */
|
||||
function switchToGroup(groupId: number) {
|
||||
currentGroupId.value = groupId
|
||||
uni.setStorageSync('xc:currentGroupId', groupId)
|
||||
refreshAll()
|
||||
}
|
||||
|
||||
/** 切换身份后全局刷新所有数据 */
|
||||
function refreshAll() {
|
||||
const statsStore = useStatsStore()
|
||||
const budgetStore = useBudgetStore()
|
||||
const txStore = useTransactionStore()
|
||||
statsStore.fetchOverview()
|
||||
budgetStore.fetchBudget()
|
||||
txStore.fetchTransactions({ page: 1 })
|
||||
}
|
||||
|
||||
/** 获取群组列表 */
|
||||
async function fetchGroups() {
|
||||
loading.value = true
|
||||
try {
|
||||
groups.value = await api.getGroups()
|
||||
} catch {
|
||||
groups.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 验证 currentGroupId 是否仍有效(群组可能已被解散或退出)
|
||||
if (currentGroupId.value !== null) {
|
||||
const stillValid = groups.value.some(g => g.id === currentGroupId.value)
|
||||
if (!stillValid) {
|
||||
switchToPersonal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建群组 */
|
||||
async function createGroup(name: string) {
|
||||
const result = await api.createGroup({ name })
|
||||
await fetchGroups()
|
||||
return result
|
||||
}
|
||||
|
||||
/** 加入群组 */
|
||||
async function joinGroup(inviteCode: string) {
|
||||
const result = await api.joinGroup(inviteCode)
|
||||
await fetchGroups()
|
||||
return result
|
||||
}
|
||||
|
||||
/** 退出群组 */
|
||||
async function leaveGroup(groupId: number) {
|
||||
await api.leaveGroup(groupId)
|
||||
if (currentGroupId.value === groupId) {
|
||||
switchToPersonal()
|
||||
}
|
||||
await fetchGroups()
|
||||
}
|
||||
|
||||
/** 解散群组 */
|
||||
async function deleteGroup(groupId: number) {
|
||||
await api.deleteGroup(groupId)
|
||||
if (currentGroupId.value === groupId) {
|
||||
switchToPersonal()
|
||||
}
|
||||
await fetchGroups()
|
||||
}
|
||||
|
||||
/** 初始化:从本地存储恢复选中状态 */
|
||||
function init() {
|
||||
const saved = uni.getStorageSync('xc:currentGroupId')
|
||||
if (saved) {
|
||||
currentGroupId.value = saved
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
groups, loading, currentGroupId, currentGroup, isGroupMode,
|
||||
switchToPersonal, switchToGroup, refreshAll,
|
||||
fetchGroups, createGroup, joinGroup, leaveGroup, deleteGroup, init
|
||||
}
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as api from '@/api/stats'
|
||||
import { getCurrentMonth } from '@/utils/format'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
|
||||
// 重新导出类型以保持向后兼容
|
||||
export type Overview = api.Overview
|
||||
@@ -15,8 +16,11 @@ export const useStatsStore = defineStore('stats', () => {
|
||||
|
||||
async function fetchOverview(month?: string) {
|
||||
try {
|
||||
const data = await api.getOverview({ month: month || getCurrentMonth() })
|
||||
// 确保数据字段是数字类型
|
||||
const groupStore = useGroupStore()
|
||||
const data = await api.getOverview({
|
||||
month: month || getCurrentMonth(),
|
||||
group_id: groupStore.currentGroupId
|
||||
})
|
||||
overview.value = {
|
||||
expense: Number(data?.expense) || 0,
|
||||
income: Number(data?.income) || 0,
|
||||
@@ -30,8 +34,8 @@ export const useStatsStore = defineStore('stats', () => {
|
||||
|
||||
async function fetchCategoryStats(month?: string, type: string = 'expense') {
|
||||
try {
|
||||
const data = await api.getCategoryStats(month || getCurrentMonth(), type)
|
||||
// 确保返回的是数组,且金额是数字
|
||||
const groupStore = useGroupStore()
|
||||
const data = await api.getCategoryStats(month || getCurrentMonth(), type, groupStore.currentGroupId)
|
||||
categoryStats.value = Array.isArray(data) ? data.map(item => ({
|
||||
...item,
|
||||
amount: Number(item.amount) || 0,
|
||||
@@ -44,8 +48,8 @@ export const useStatsStore = defineStore('stats', () => {
|
||||
|
||||
async function fetchTrend(month?: string, type: string = 'expense') {
|
||||
try {
|
||||
const data = await api.getTrend(month || getCurrentMonth(), type)
|
||||
// 确保返回的是数组,且金额是数字
|
||||
const groupStore = useGroupStore()
|
||||
const data = await api.getTrend(month || getCurrentMonth(), type, groupStore.currentGroupId)
|
||||
trendData.value = Array.isArray(data) ? data.map(item => ({
|
||||
...item,
|
||||
amount: Number(item.amount) || 0
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as api from '@/api/transaction'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
|
||||
export const useTransactionStore = defineStore('transaction', () => {
|
||||
const transactions = ref<api.Transaction[]>([])
|
||||
@@ -11,7 +12,12 @@ export const useTransactionStore = defineStore('transaction', () => {
|
||||
async function fetchTransactions(params?: api.TransactionParams) {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.getTransactions({ page: 1, ...params })
|
||||
const groupStore = useGroupStore()
|
||||
const data = await api.getTransactions({
|
||||
page: 1,
|
||||
...params,
|
||||
group_id: groupStore.currentGroupId
|
||||
})
|
||||
transactions.value = data.list
|
||||
total.value = data.total
|
||||
currentPage.value = data.page
|
||||
@@ -30,7 +36,11 @@ export const useTransactionStore = defineStore('transaction', () => {
|
||||
note?: string
|
||||
date: string
|
||||
}) {
|
||||
const result = await api.createTransaction(data)
|
||||
const groupStore = useGroupStore()
|
||||
const result = await api.createTransaction({
|
||||
...data,
|
||||
group_id: groupStore.currentGroupId
|
||||
})
|
||||
return result.id
|
||||
}
|
||||
|
||||
|
||||
52
client/src/stores/user.ts
Normal file
52
client/src/stores/user.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import * as api from '@/api/user'
|
||||
import { API_BASE } from '@/config'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const userInfo = ref<api.UserInfo | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const nickname = computed(() => {
|
||||
return userInfo.value?.nickname || uni.getStorageSync('xc:nickname') || '小菜'
|
||||
})
|
||||
|
||||
const avatarUrl = computed(() => {
|
||||
const filename = userInfo.value?.avatar_url || uni.getStorageSync('xc:avatar_url') || ''
|
||||
if (!filename) return ''
|
||||
// 已是完整 URL(http/blob)直接返回
|
||||
if (filename.startsWith('http') || filename.startsWith('blob:')) return filename
|
||||
// 拼接:API_BASE + /user/avatar/ + 文件名
|
||||
return `${API_BASE}/user/avatar/${filename}`
|
||||
})
|
||||
|
||||
async function fetchUserInfo() {
|
||||
loading.value = true
|
||||
try {
|
||||
userInfo.value = await api.getUserInfo()
|
||||
if (userInfo.value) {
|
||||
uni.setStorageSync('xc:nickname', userInfo.value.nickname)
|
||||
uni.setStorageSync('xc:avatar_url', userInfo.value.avatar_url)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[UserStore] fetchUserInfo error:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProfile(nickname: string) {
|
||||
await api.updateNickname(nickname)
|
||||
if (userInfo.value) userInfo.value.nickname = nickname
|
||||
uni.setStorageSync('xc:nickname', nickname)
|
||||
}
|
||||
|
||||
async function uploadAvatar(filePath: string) {
|
||||
const result = await api.uploadAvatar(filePath)
|
||||
if (userInfo.value) userInfo.value.avatar_url = result.avatar_url
|
||||
uni.setStorageSync('xc:avatar_url', result.avatar_url)
|
||||
return avatarUrl.value
|
||||
}
|
||||
|
||||
return { userInfo, loading, nickname, avatarUrl, fetchUserInfo, updateProfile, uploadAvatar }
|
||||
})
|
||||
20
client/src/utils/app-ready.ts
Normal file
20
client/src/utils/app-ready.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* App 启动就绪状态
|
||||
*
|
||||
* App.vue onLaunch 中调用 markReady()
|
||||
* 页面 onMounted 中调用 waitForReady() 等待登录完成
|
||||
*/
|
||||
|
||||
let _resolve: () => void
|
||||
const _ready = new Promise<void>(resolve => { _resolve = resolve })
|
||||
|
||||
/** 标记 App 启动完成(登录成功) */
|
||||
export function markReady() { _resolve() }
|
||||
|
||||
/** 等待 App 就绪,最多等 3 秒超时 */
|
||||
export function waitForReady(): Promise<void> {
|
||||
return Promise.race([
|
||||
_ready,
|
||||
new Promise<void>(resolve => setTimeout(resolve, 3000))
|
||||
])
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
// API 基础地址
|
||||
const BASE_URL = 'https://xiaocai.j35.site/api'
|
||||
import { API_BASE } from '@/config'
|
||||
|
||||
interface RequestOptions {
|
||||
url: string
|
||||
@@ -7,13 +6,49 @@ interface RequestOptions {
|
||||
data?: any
|
||||
}
|
||||
|
||||
interface LoginResult {
|
||||
token: string
|
||||
userId: number
|
||||
nickname: string
|
||||
avatar_url: string
|
||||
}
|
||||
|
||||
let isRedirectingToLogin = false
|
||||
/** 重登录期间挂起的请求,登录成功后统一重试 */
|
||||
let pendingRetries: Array<{ resolve: (v: any) => void; reject: (e: any) => void; options: RequestOptions }> = []
|
||||
|
||||
/** 保存登录结果到本地存储 */
|
||||
export function saveLoginResult(data: LoginResult) {
|
||||
uni.setStorageSync('xc:token', data.token)
|
||||
uni.setStorageSync('xc:userId', data.userId)
|
||||
uni.setStorageSync('xc:nickname', data.nickname)
|
||||
uni.setStorageSync('xc:avatar_url', data.avatar_url)
|
||||
}
|
||||
|
||||
/** 执行重登录 */
|
||||
async function reLogin(): Promise<void> {
|
||||
// H5 环境:demo 登录
|
||||
if (typeof window !== 'undefined') {
|
||||
const data = await request<LoginResult>({ url: '/auth/demo-login', method: 'POST' })
|
||||
saveLoginResult(data)
|
||||
return
|
||||
}
|
||||
|
||||
// 小程序环境:wx.login
|
||||
const loginRes = await new Promise<UniApp.LoginRes>((resolve, reject) => {
|
||||
uni.login({ provider: 'weixin', success: resolve, fail: reject })
|
||||
})
|
||||
if (!loginRes.code) throw new Error('wx.login failed')
|
||||
|
||||
const data = await request<LoginResult>({ url: '/auth/login', method: 'POST', data: { code: loginRes.code } })
|
||||
saveLoginResult(data)
|
||||
}
|
||||
|
||||
export function request<T = any>(options: RequestOptions): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = uni.getStorageSync('xc:token')
|
||||
uni.request({
|
||||
url: BASE_URL + options.url,
|
||||
url: API_BASE + options.url,
|
||||
method: options.method || 'GET',
|
||||
data: options.data,
|
||||
header: {
|
||||
@@ -23,45 +58,36 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
|
||||
success: (res: any) => {
|
||||
const { statusCode, data } = res
|
||||
|
||||
// HTTP 401 - token invalid or expired
|
||||
if (statusCode === 401) {
|
||||
uni.removeStorageSync('xc:token')
|
||||
uni.removeStorageSync('xc:userId')
|
||||
|
||||
// 加入重试队列
|
||||
pendingRetries.push({ resolve, reject, options })
|
||||
|
||||
if (!isRedirectingToLogin) {
|
||||
isRedirectingToLogin = true
|
||||
// H5 环境自动获取 demo token 并重试
|
||||
if (typeof window !== 'undefined') {
|
||||
request<{ token: string; userId: number }>({
|
||||
url: '/auth/demo-login',
|
||||
method: 'POST'
|
||||
}).then((data) => {
|
||||
uni.setStorageSync('xc:token', data.token)
|
||||
uni.setStorageSync('xc:userId', data.userId)
|
||||
// 重试原请求
|
||||
request(options).then(resolve).catch(reject)
|
||||
}).catch(() => {
|
||||
const retries = [...pendingRetries]
|
||||
pendingRetries = []
|
||||
|
||||
reLogin()
|
||||
.then(() => retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject)))
|
||||
.catch(() => {
|
||||
uni.showToast({ title: '登录失败,请刷新页面', icon: 'none' })
|
||||
reject({ code: 40100, message: '未登录' })
|
||||
}).finally(() => { isRedirectingToLogin = false })
|
||||
} else {
|
||||
// 小程序环境自动重新登录
|
||||
reLogin().then(() => {
|
||||
request(options).then(resolve).catch(reject)
|
||||
}).catch(() => {
|
||||
reject({ code: 40100, message: '未登录' })
|
||||
}).finally(() => { isRedirectingToLogin = false })
|
||||
}
|
||||
} else {
|
||||
reject({ code: 40100, message: '未登录' })
|
||||
retries.forEach(r => r.reject({ code: 40100, message: '未登录' }))
|
||||
})
|
||||
.finally(() => { isRedirectingToLogin = false })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Business logic success
|
||||
if (data.code === 0) {
|
||||
resolve(data.data)
|
||||
} else {
|
||||
uni.showToast({ title: data.message || '请求失败', icon: 'none' })
|
||||
// "用户不存在" 不弹 toast(首次登录竞态)
|
||||
if (data.code !== 40400 || data.message !== '用户不存在') {
|
||||
uni.showToast({ title: data.message || '请求失败', icon: 'none' })
|
||||
}
|
||||
reject(data)
|
||||
}
|
||||
},
|
||||
@@ -72,40 +98,3 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 微信小程序自动重新登录
|
||||
function reLogin(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: async (loginRes) => {
|
||||
if (loginRes.code) {
|
||||
try {
|
||||
const data = await request<{ token: string; userId: number }>({
|
||||
url: '/auth/login',
|
||||
method: 'POST',
|
||||
data: { code: loginRes.code }
|
||||
})
|
||||
uni.setStorageSync('xc:token', data.token)
|
||||
uni.setStorageSync('xc:userId', data.userId)
|
||||
resolve()
|
||||
} catch (e) {
|
||||
console.error('[Auth] Re-login failed:', e)
|
||||
reject(e)
|
||||
}
|
||||
} else {
|
||||
reject(new Error('wx.login failed'))
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('[Auth] wx.login failed:', err)
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
resolve()
|
||||
// #endif
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
let statusBarHeight = 0
|
||||
/** 胶囊按钮右侧到屏幕右边的距离(含边距) */
|
||||
let capsuleRight = 0
|
||||
|
||||
try {
|
||||
const info = uni.getSystemInfoSync()
|
||||
statusBarHeight = info.statusBarHeight || 0
|
||||
|
||||
// 小程序环境:获取胶囊位置
|
||||
const menuRect = (uni as any).getMenuButtonBoundingClientRect?.()
|
||||
if (menuRect) {
|
||||
capsuleRight = info.screenWidth - menuRect.left + 8
|
||||
}
|
||||
} catch {
|
||||
statusBarHeight = 20
|
||||
}
|
||||
|
||||
export { statusBarHeight }
|
||||
export { statusBarHeight, capsuleRight }
|
||||
|
||||
Reference in New Issue
Block a user