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:
2026-06-03 17:04:22 +08:00
parent 9162b2c87c
commit 21f4d81959
45 changed files with 3145 additions and 348 deletions

View File

@@ -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>