refactor(iter-v2): T02 code quality - shared components, transactions, utils

- C2: ColorPicker and EditModal reusable components extracted
- C2: tag-manage and category-manage pages use EditModal (remove ~120 dup lines)
- C3: Upload logic extracted to server/src/utils/upload.ts
- C4: /recurring/sync wrapped in DB transaction
- C7: Backup download uses API_BASE (no hardcoded URLs)
- C9: getAvatarUrl() utility function, used in 5 files
- C1: getCurrentMonth() documented as aligned with server date.ts
This commit is contained in:
2026-06-11 09:49:17 +08:00
parent d170df5c51
commit 5a0ae0b28d
17 changed files with 350 additions and 338 deletions

View File

@@ -0,0 +1,47 @@
<template>
<view class="color-picker">
<view
v-for="c in colors" :key="c"
class="color-dot"
:class="{ selected: modelValue === c }"
:style="{ background: c }"
@tap="$emit('update:modelValue', c)"
/>
</view>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
modelValue: string
colors?: string[]
}>(), {
colors: () => ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
})
defineEmits<{
'update:modelValue': [color: string]
}>()
</script>
<style lang="scss" scoped>
.color-picker {
display: flex;
gap: $space-lg;
margin-bottom: $space-md;
flex-wrap: wrap;
}
.color-dot {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
border: 4rpx solid transparent;
transition: all $transition-normal;
&.selected {
border-color: $text;
transform: scale(1.15);
}
&:active { opacity: 0.7; }
}
</style>

View File

@@ -0,0 +1,146 @@
<template>
<view class="modal-mask" v-if="visible" @tap="$emit('update:visible', false)">
<view class="modal" @tap.stop>
<text class="modal-title">{{ title }}</text>
<template v-for="field in fields" :key="field.key">
<input
v-if="field.type === 'text'"
class="modal-input"
:value="form[field.key]"
:placeholder="field.placeholder || field.label"
:maxlength="field.maxlength || 50"
@input="form[field.key] = ($event as any).detail.value"
/>
<ColorPicker
v-if="field.type === 'color'"
:modelValue="form[field.key] || colors[0]"
@update:modelValue="form[field.key] = $event"
:colors="colors"
/>
</template>
<view class="modal-btns">
<view class="modal-btn cancel" @tap="$emit('update:visible', false)">取消</view>
<view class="modal-btn confirm" :class="{ disabled: !canConfirm }" @tap="onConfirm">确定</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { reactive, computed, watch } from 'vue'
import ColorPicker from '@/components/ColorPicker/ColorPicker.vue'
export interface EditField {
key: string
label: string
type: 'text' | 'color'
placeholder?: string
maxlength?: number
}
const props = withDefaults(defineProps<{
visible: boolean
title: string
fields: EditField[]
modelValue: Record<string, any>
colors?: string[]
}>(), {
colors: () => ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
})
const emit = defineEmits<{
'update:visible': [val: boolean]
'confirm': [data: Record<string, any>]
}>()
const form = reactive<Record<string, any>>({})
// 当 visible 变为 true 时,用 modelValue 初始化 form
watch(() => props.visible, (val) => {
if (val) {
Object.keys(form).forEach(k => delete form[k])
Object.assign(form, { ...props.modelValue })
}
})
const canConfirm = computed(() => {
// 至少有一个 text 字段不为空
return props.fields
.filter(f => f.type === 'text')
.every(f => (form[f.key] || '').toString().trim().length > 0)
})
function onConfirm() {
if (!canConfirm.value) return
// 只返回 fields 中定义的字段
const data: Record<string, any> = {}
props.fields.forEach(f => { data[f.key] = form[f.key] || '' })
emit('confirm', data)
}
</script>
<style lang="scss" scoped>
.modal-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal {
width: 600rpx;
background: $surface;
border-radius: $space-lg;
padding: $space-2xl;
}
.modal-title {
font-size: $font-xl;
font-weight: 600;
color: $text;
display: block;
text-align: center;
margin-bottom: $space-lg;
}
.modal-input {
height: 96rpx;
background: $bg;
border-radius: $space-md;
padding: 0 $space-lg;
font-size: $font-lg;
margin-bottom: $space-md;
}
.modal-btns {
display: flex;
gap: $space-md;
}
.modal-btn {
flex: 1;
height: 88rpx;
border-radius: $space-md;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-lg;
font-weight: 600;
&.cancel {
background: $bg;
color: $text-sec;
}
&.confirm {
background: linear-gradient(135deg, $primary, #E67355);
color: $surface;
}
&.disabled { opacity: 0.5; }
&:active { opacity: 0.8; }
}
</style>

View File

@@ -37,7 +37,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { formatAmount, formatDate } from '@/utils/format' import { formatAmount, formatDate } from '@/utils/format'
import { API_BASE } from '@/config' import { getAvatarUrl } from '@/utils/avatar'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue' import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
@@ -74,11 +74,7 @@ const isOtherCreator = computed(() => {
defineEmits(['item-tap', 'delete']) defineEmits(['item-tap', 'delete'])
const creatorAvatarUrl = computed(() => { const creatorAvatarUrl = computed(() => {
if (!props.item.creator_avatar) return '' return getAvatarUrl(props.item.creator_avatar || '')
if (props.item.creator_avatar.startsWith('http') || props.item.creator_avatar.startsWith('blob:')) return props.item.creator_avatar
// 截取服务端根地址(去掉 /api
const base = API_BASE.replace(/\/api$/, '')
return base + '/api/user/avatar/' + props.item.creator_avatar
}) })
const startX = ref(0) const startX = ref(0)

View File

@@ -120,7 +120,7 @@ import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system' import { statusBarHeight } from '@/utils/system'
import { getFeedbackList, updateFeedbackStatus } from '@/api/feedback' import { getFeedbackList, updateFeedbackStatus } from '@/api/feedback'
import type { Feedback } from '@/api/feedback' import type { Feedback } from '@/api/feedback'
import { API_BASE } from '@/config' import { getAvatarUrl } from '@/utils/avatar'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
const typeMap: Record<string, string> = { const typeMap: Record<string, string> = {
@@ -245,12 +245,6 @@ function copyContact(contact: string) {
}) })
} }
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 { function formatTime(dateStr: string): string {
const d = new Date(dateStr) const d = new Date(dateStr)
const now = new Date() const now = new Date()

View File

@@ -57,7 +57,7 @@ import { ref, onMounted } from 'vue'
import { onShow, onReachBottom, onPullDownRefresh } 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 { getAvatarUrl } from '@/utils/avatar'
import { getUsers, updateUserRole, deleteUser } from '@/api/admin' import { getUsers, updateUserRole, deleteUser } from '@/api/admin'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
@@ -112,12 +112,6 @@ async function loadUsers(reset = false) {
} }
} }
function getAvatarUrl(avatarUrl: string): string {
if (!avatarUrl) return ''
if (avatarUrl.startsWith('http') || avatarUrl.startsWith('blob:')) return avatarUrl
return `${API_BASE}/user/avatar/${avatarUrl}`
}
function formatDate(dateStr: string) { function formatDate(dateStr: string) {
const d = new Date(dateStr) const d = new Date(dateStr)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`

View File

@@ -60,6 +60,7 @@ import type { Backup } from '@/api/backup'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system' import { statusBarHeight } from '@/utils/system'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import { API_BASE } from '@/config'
const backups = ref<Backup[]>([]) const backups = ref<Backup[]>([])
const loading = ref(true) const loading = ref(true)
@@ -121,7 +122,7 @@ function onDownload(item: Backup) {
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
uni.downloadFile({ uni.downloadFile({
url: `https://xiaocai.j35.site/api/backup/${item.id}/download?token=${encodeURIComponent(item.downloadToken)}`, url: `${API_BASE}/backup/${item.id}/download?token=${encodeURIComponent(item.downloadToken)}`,
success: (res) => { success: (res) => {
if (res.statusCode === 200) { if (res.statusCode === 200) {
uni.openDocument({ uni.openDocument({

View File

@@ -45,46 +45,22 @@
</view> </view>
<!-- 添加弹窗 --> <!-- 添加弹窗 -->
<view class="modal-mask" v-if="showAddModal" @tap="showAddModal = false"> <EditModal
<view class="modal" @tap.stop> v-model:visible="showAddModal"
<text class="modal-title">添加自定义分类</text> title="添加自定义分类"
<input class="modal-input" v-model="newName" placeholder="分类名称" maxlength="10" /> :fields="catFields"
<view class="color-picker"> :modelValue="addModel"
<view @confirm="onAdd"
v-for="c in colorOptions" :key="c"
class="color-dot"
:class="{ selected: selectedColor === c }"
:style="{ background: c }"
@tap="selectedColor = c"
/> />
</view>
<view class="modal-btns">
<view class="modal-btn cancel" @tap="showAddModal = false">取消</view>
<view class="modal-btn confirm" :class="{ disabled: !newName.trim() }" @tap="onAdd">添加</view>
</view>
</view>
</view>
<!-- 编辑弹窗 --> <!-- 编辑弹窗 -->
<view class="modal-mask" v-if="showEditModal" @tap="showEditModal = false"> <EditModal
<view class="modal" @tap.stop> v-model:visible="showEditModal"
<text class="modal-title">编辑分类</text> title="编辑分类"
<input class="modal-input" v-model="editName" placeholder="分类名称" maxlength="10" /> :fields="catFields"
<view class="color-picker"> :modelValue="editModel"
<view @confirm="onEditConfirm"
v-for="c in colorOptions" :key="c"
class="color-dot"
:class="{ selected: editColor === c }"
:style="{ background: c }"
@tap="editColor = c"
/> />
</view>
<view class="modal-btns">
<view class="modal-btn cancel" @tap="showEditModal = false">取消</view>
<view class="modal-btn confirm" @tap="onEditConfirm">确定</view>
</view>
</view>
</view>
<!-- 迁移弹窗 --> <!-- 迁移弹窗 -->
<view class="modal-mask" v-if="showMigrateModal" @tap="showMigrateModal = false"> <view class="modal-mask" v-if="showMigrateModal" @tap="showMigrateModal = false">
@@ -120,14 +96,23 @@ import { waitForReady } from '@/utils/app-ready'
import { getCategoryTransactionCount } from '@/api/category' import { getCategoryTransactionCount } from '@/api/category'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue' import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import EditModal from '@/components/EditModal/EditModal.vue'
import type { EditField } from '@/components/EditModal/EditModal.vue'
import { statusBarHeight, capsuleRight } from '@/utils/system' import { statusBarHeight, capsuleRight } from '@/utils/system'
const catStore = useCategoryStore() const catStore = useCategoryStore()
const tabType = ref<'expense' | 'income'>('expense') const tabType = ref<'expense' | 'income'>('expense')
const showAddModal = ref(false) const showAddModal = ref(false)
const newName = ref('') const showEditModal = ref(false)
const colorOptions = ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
const selectedColor = ref(colorOptions[0]) /** 分类添加/编辑弹窗字段配置 */
const catFields: EditField[] = [
{ key: 'name', label: '分类名称', type: 'text', placeholder: '分类名称', maxlength: 10 },
{ key: 'color', label: '颜色', type: 'color' },
]
const addModel = ref({ name: '', color: '#FF8C69' })
const editModel = ref({ name: '', color: '#FF8C69' })
// 拖拽排序相关 // 拖拽排序相关
let dragStartY = 0 let dragStartY = 0
@@ -135,10 +120,7 @@ let dragCurrentIndex = -1
let dragItemEl: any = null let dragItemEl: any = null
// 编辑相关 // 编辑相关
const showEditModal = ref(false)
const editingCat = ref<Category | null>(null) const editingCat = ref<Category | null>(null)
const editName = ref('')
const editColor = ref('')
// 迁移相关 // 迁移相关
const showMigrateModal = ref(false) const showMigrateModal = ref(false)
@@ -175,18 +157,18 @@ function goBack() {
uni.navigateBack() uni.navigateBack()
} }
async function onAdd() { async function onAdd(data: Record<string, any>) {
const name = newName.value.trim() const name = (data.name || '').toString().trim()
if (!name) return if (!name) return
try { try {
await catStore.addCategory({ await catStore.addCategory({
name, name,
icon: name[0], icon: name[0],
color: selectedColor.value, color: data.color,
type: tabType.value type: tabType.value
}) })
newName.value = '' addModel.value = { name: '', color: '#FF8C69' }
showAddModal.value = false showAddModal.value = false
uni.showToast({ title: '添加成功', icon: 'success' }) uni.showToast({ title: '添加成功', icon: 'success' })
} catch { } catch {
@@ -196,21 +178,20 @@ async function onAdd() {
function onEdit(cat: Category) { function onEdit(cat: Category) {
editingCat.value = cat editingCat.value = cat
editName.value = cat.name editModel.value = { name: cat.name, color: cat.color }
editColor.value = cat.color
showEditModal.value = true showEditModal.value = true
} }
async function onEditConfirm() { async function onEditConfirm(data: Record<string, any>) {
if (!editingCat.value) return if (!editingCat.value) return
const name = editName.value.trim() const name = (data.name || '').toString().trim()
if (!name) { if (!name) {
uni.showToast({ title: '请输入名称', icon: 'none' }) uni.showToast({ title: '请输入名称', icon: 'none' })
return return
} }
try { try {
await catStore.updateCategory(editingCat.value.id, { name, color: editColor.value }) await catStore.updateCategory(editingCat.value.id, { name, color: data.color })
showEditModal.value = false showEditModal.value = false
uni.showToast({ title: '修改成功', icon: 'success' }) uni.showToast({ title: '修改成功', icon: 'success' })
} catch { } catch {
@@ -454,27 +435,7 @@ function onDragEnd(e: any) {
&:active { transform: scale(0.95); } &:active { transform: scale(0.95); }
} }
.color-picker { /* 迁移弹窗样式 */
display: flex;
gap: $space-lg;
margin-bottom: $space-md;
flex-wrap: wrap;
}
.color-dot {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
border: 4rpx solid transparent;
transition: all $transition-normal;
&.selected {
border-color: $text;
transform: scale(1.15);
}
&:active { opacity: 0.7; }
}
.modal-mask { .modal-mask {
position: fixed; position: fixed;
top: 0; left: 0; right: 0; bottom: 0; top: 0; left: 0; right: 0; bottom: 0;
@@ -508,15 +469,6 @@ function onDragEnd(e: any) {
margin-bottom: $space-md; margin-bottom: $space-md;
} }
.modal-input {
height: 96rpx;
background: $bg;
border-radius: $space-md;
padding: 0 $space-lg;
font-size: $font-lg;
margin-bottom: $space-md;
}
.migrate-list { .migrate-list {
max-height: 400rpx; max-height: 400rpx;
margin-bottom: $space-md; margin-bottom: $space-md;

View File

@@ -154,7 +154,7 @@ import { useGroupStore } from '@/stores/group'
import { useConfigStore } from '@/stores/config' 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 { getAvatarUrl } from '@/utils/avatar'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue' import Skeleton from '@/components/Skeleton/Skeleton.vue'
import type { GroupMember, Group } from '@/api/group' import type { GroupMember, Group } from '@/api/group'
@@ -220,13 +220,6 @@ function copyInviteCode(code: string) {
}) })
} }
/** 获取头像 URL */
function getAvatarUrl(avatarUrl: string): string {
if (!avatarUrl) return ''
if (avatarUrl.startsWith('http') || avatarUrl.startsWith('blob:')) return avatarUrl
return `${API_BASE}/user/avatar/${avatarUrl}`
}
async function handleCreate() { async function handleCreate() {
if (saving.value) return if (saving.value) return
const name = newGroupName.value.trim() const name = newGroupName.value.trim()

View File

@@ -39,47 +39,23 @@
</view> </view>
<!-- 添加弹窗 --> <!-- 添加弹窗 -->
<view class="modal-mask" v-if="showAddModal" @tap="showAddModal = false"> <EditModal
<view class="modal" @tap.stop> v-model:visible="showAddModal"
<text class="modal-title">添加标签</text> title="添加标签"
<input class="modal-input" v-model="newName" placeholder="标签名称" maxlength="20" /> :fields="addFields"
<view class="color-picker"> :modelValue="addModel"
<view @confirm="onAdd"
v-for="c in colorOptions" :key="c"
class="color-dot"
:class="{ selected: selectedColor === c }"
:style="{ background: c }"
@tap="selectedColor = c"
/> />
</view>
<view class="modal-btns">
<view class="modal-btn cancel" @tap="showAddModal = false">取消</view>
<view class="modal-btn confirm" :class="{ disabled: !newName.trim() }" @tap="onAdd">添加</view>
</view>
</view>
</view>
<!-- 编辑弹窗 --> <!-- 编辑弹窗 -->
<view class="modal-mask" v-if="showEditModal" @tap="showEditModal = false"> <EditModal
<view class="modal" @tap.stop> v-model:visible="showEditModal"
<text class="modal-title">编辑标签</text> title="编辑标签"
<input class="modal-input" v-model="editName" placeholder="标签名称" maxlength="20" /> :fields="editFields"
<view class="color-picker"> :modelValue="editModel"
<view @confirm="onEditConfirm"
v-for="c in colorOptions" :key="c"
class="color-dot"
:class="{ selected: editColor === c }"
:style="{ background: c }"
@tap="editColor = c"
/> />
</view> </view>
<view class="modal-btns">
<view class="modal-btn cancel" @tap="showEditModal = false">取消</view>
<view class="modal-btn confirm" @tap="onEditConfirm">确定</view>
</view>
</view>
</view>
</view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -90,18 +66,24 @@ import type { Tag } from '@/api/tag'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system' import { statusBarHeight } from '@/utils/system'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import EditModal from '@/components/EditModal/EditModal.vue'
import type { EditField } from '@/components/EditModal/EditModal.vue'
const tagStore = useTagStore() const tagStore = useTagStore()
const showAddModal = ref(false) const showAddModal = ref(false)
const newName = ref('')
const colorOptions = ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
const selectedColor = ref(colorOptions[0])
const showEditModal = ref(false) const showEditModal = ref(false)
const editingTag = ref<Tag | null>(null) const editingTag = ref<Tag | null>(null)
const editName = ref('')
const editColor = ref('') /** 添加/编辑弹窗字段配置 */
const addFields: EditField[] = [
{ key: 'name', label: '标签名称', type: 'text', placeholder: '标签名称', maxlength: 20 },
{ key: 'color', label: '颜色', type: 'color' },
]
const editFields: EditField[] = addFields
const addModel = ref({ name: '', color: '#FF8C69' })
const editModel = ref({ name: '', color: '#FF8C69' })
const initialLoaded = ref(false) const initialLoaded = ref(false)
@@ -124,13 +106,13 @@ function goBack() {
uni.navigateBack() uni.navigateBack()
} }
async function onAdd() { async function onAdd(data: Record<string, any>) {
const name = newName.value.trim() const name = (data.name || '').toString().trim()
if (!name) return if (!name) return
try { try {
await tagStore.addTag({ name, color: selectedColor.value }) await tagStore.addTag({ name, color: data.color })
newName.value = '' addModel.value = { name: '', color: '#FF8C69' }
showAddModal.value = false showAddModal.value = false
uni.showToast({ title: '添加成功', icon: 'success' }) uni.showToast({ title: '添加成功', icon: 'success' })
} catch { } catch {
@@ -140,21 +122,20 @@ async function onAdd() {
function onEdit(tag: Tag) { function onEdit(tag: Tag) {
editingTag.value = tag editingTag.value = tag
editName.value = tag.name editModel.value = { name: tag.name, color: tag.color }
editColor.value = tag.color
showEditModal.value = true showEditModal.value = true
} }
async function onEditConfirm() { async function onEditConfirm(data: Record<string, any>) {
if (!editingTag.value) return if (!editingTag.value) return
const name = editName.value.trim() const name = (data.name || '').toString().trim()
if (!name) { if (!name) {
uni.showToast({ title: '请输入名称', icon: 'none' }) uni.showToast({ title: '请输入名称', icon: 'none' })
return return
} }
try { try {
await tagStore.updateTag(editingTag.value.id, { name, color: editColor.value }) await tagStore.updateTag(editingTag.value.id, { name, color: data.color })
showEditModal.value = false showEditModal.value = false
uni.showToast({ title: '修改成功', icon: 'success' }) uni.showToast({ title: '修改成功', icon: 'success' })
} catch { } catch {
@@ -283,89 +264,4 @@ async function onDelete(tag: Tag) {
z-index: 50; z-index: 50;
&:active { transform: scale(0.95); } &:active { transform: scale(0.95); }
} }
.modal-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal {
width: 600rpx;
background: $surface;
border-radius: $space-lg;
padding: $space-2xl;
}
.modal-title {
font-size: $font-xl;
font-weight: 600;
color: $text;
display: block;
text-align: center;
margin-bottom: $space-lg;
}
.modal-input {
height: 96rpx;
background: $bg;
border-radius: $space-md;
padding: 0 $space-lg;
font-size: $font-lg;
margin-bottom: $space-md;
}
.color-picker {
display: flex;
gap: $space-lg;
margin-bottom: $space-md;
flex-wrap: wrap;
}
.color-dot {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
border: 4rpx solid transparent;
transition: all $transition-normal;
&.selected {
border-color: $text;
transform: scale(1.15);
}
&:active { opacity: 0.7; }
}
.modal-btns {
display: flex;
gap: $space-md;
}
.modal-btn {
flex: 1;
height: 88rpx;
border-radius: $space-md;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-lg;
font-weight: 600;
&.cancel {
background: $bg;
color: $text-sec;
}
&.confirm {
background: linear-gradient(135deg, $primary, #E67355);
color: $surface;
}
&.disabled { opacity: 0.5; }
&:active { opacity: 0.8; }
}
</style> </style>

View File

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import * as api from '@/api/user' import * as api from '@/api/user'
import { API_BASE } from '@/config' import { getAvatarUrl } from '@/utils/avatar'
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore('user', () => {
const userInfo = ref<api.UserInfo | null>(null) const userInfo = ref<api.UserInfo | null>(null)
@@ -16,11 +16,7 @@ export const useUserStore = defineStore('user', () => {
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 '' return getAvatarUrl(filename)
// 已是完整 URLhttp/blob直接返回
if (filename.startsWith('http') || filename.startsWith('blob:')) return filename
// 拼接API_BASE + /user/avatar/ + 文件名
return `${API_BASE}/user/avatar/${filename}`
}) })
async function fetchUserInfo() { async function fetchUserInfo() {

View File

@@ -0,0 +1,8 @@
import { API_BASE } from '@/config'
/** 将头像文件名转为完整 URL */
export function getAvatarUrl(filename: string): string {
if (!filename) return ''
if (filename.startsWith('http') || filename.startsWith('blob:')) return filename
return `${API_BASE}/user/avatar/${filename}`
}

View File

@@ -48,7 +48,7 @@ export function formatMonth(date: string): string {
return `${year}${parseInt(month)}` return `${year}${parseInt(month)}`
} }
/** 获取当前月份UTC+8 时区,与服务端保持一致) */ /** 获取当前月份UTC+8 时区,与服务端 date.ts 逻辑一致) */
export function getCurrentMonth(): string { export function getCurrentMonth(): string {
const d = new Date() const d = new Date()
const offset = 8 * 60 * 60 * 1000 const offset = 8 * 60 * 60 * 1000

View File

@@ -22,4 +22,9 @@ const pool = mysql.createPool({
console.error('[DB] Pool error:', err?.message || err) console.error('[DB] Pool error:', err?.message || err)
}) })
/** 获取单个连接(用于事务) */
export async function getConnection() {
return pool.getConnection()
}
export default pool export default pool

View File

@@ -1,48 +1,13 @@
import { Router, Response, NextFunction } from 'express' import { Router, Response, NextFunction } from 'express'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
import pool from '../db/connection' import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth' import { AuthRequest } from '../middleware/auth'
import { requireAdmin } from '../middleware/requireAdmin' import { requireAdmin } from '../middleware/requireAdmin'
import { createUploadConfig } from '../utils/upload'
const router = Router() const router = Router()
// 通知图片上传配置 // 通知图片上传配置5MB
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads' const upload = createUploadConfig('notifications', 5 * 1024 * 1024)
const NOTIFICATION_DIR = path.resolve(UPLOAD_DIR, 'notifications')
if (!fs.existsSync(NOTIFICATION_DIR)) {
fs.mkdirSync(NOTIFICATION_DIR, { recursive: true })
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
if (!fs.existsSync(NOTIFICATION_DIR)) {
fs.mkdirSync(NOTIFICATION_DIR, { recursive: true })
}
cb(null, NOTIFICATION_DIR)
},
filename: (req: AuthRequest, file, cb) => {
const extMap: Record<string, string> = {
'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp'
}
const ext = extMap[file.mimetype] || '.jpg'
cb(null, `notif_${req.userId}_${Date.now()}${ext}`)
}
})
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 最大 5MB
fileFilter: (_req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp']
if (allowed.includes(file.mimetype)) {
cb(null, true)
} else {
cb(new Error('仅支持 JPEG、PNG、WebP 格式'))
}
}
})
/** 公告编辑权限检查(管理员可编辑所有公告,群主可编辑自己群组的公告) */ /** 公告编辑权限检查(管理员可编辑所有公告,群主可编辑自己群组的公告) */
async function requireNotificationEditAuth(req: AuthRequest, res: Response, next: NextFunction) { async function requireNotificationEditAuth(req: AuthRequest, res: Response, next: NextFunction) {

View File

@@ -127,12 +127,16 @@ router.delete('/:id', async (req, res) => {
} }
}) })
/** 同步:检查所有活跃模板,生成到期交易 */ /** 同步:检查所有活跃模板,生成到期交易(事务包裹) */
router.post('/sync', async (req, res) => { router.post('/sync', async (req, res) => {
const conn = await pool.getConnection()
try { try {
const userId = (req as any).userId const userId = (req as any).userId
await conn.beginTransaction()
// 获取该用户所有活跃且到期next_date <= 今天)的模板 // 获取该用户所有活跃且到期next_date <= 今天)的模板
const [templates] = await pool.execute( const [templates] = await conn.execute(
`SELECT * FROM recurring_templates `SELECT * FROM recurring_templates
WHERE user_id = ? AND is_active = 1 AND next_date <= CURDATE() WHERE user_id = ? AND is_active = 1 AND next_date <= CURDATE()
ORDER BY next_date ASC`, ORDER BY next_date ASC`,
@@ -141,7 +145,6 @@ router.post('/sync', async (req, res) => {
let generated = 0 let generated = 0
const today = new Date() const today = new Date()
const todayStr = today.toISOString().slice(0, 10)
for (const t of templates as any[]) { for (const t of templates as any[]) {
let next = new Date(t.next_date) let next = new Date(t.next_date)
@@ -152,13 +155,13 @@ router.post('/sync', async (req, res) => {
if (end && next > end) break if (end && next > end) break
// 检查是否已生成过(避免重复) // 检查是否已生成过(避免重复)
const [existing] = await pool.execute( const [existing] = await conn.execute(
`SELECT id FROM transactions `SELECT id FROM transactions
WHERE user_id = ? AND recurring_id = ? AND date = ?`, WHERE user_id = ? AND recurring_id = ? AND date = ?`,
[userId, t.id, next.toISOString().slice(0, 10)] [userId, t.id, next.toISOString().slice(0, 10)]
) )
if ((existing as any[]).length === 0) { if ((existing as any[]).length === 0) {
await pool.execute( await conn.execute(
`INSERT INTO transactions (user_id, amount, type, category_id, note, date, group_id, recurring_id) `INSERT INTO transactions (user_id, amount, type, category_id, note, date, group_id, recurring_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[userId, t.amount, t.type, t.category_id, t.note, [userId, t.amount, t.type, t.category_id, t.note,
@@ -178,16 +181,20 @@ router.post('/sync', async (req, res) => {
} }
// 更新 next_date循环结束后 next 已推进到今天之后的下一周期,直接使用 // 更新 next_date循环结束后 next 已推进到今天之后的下一周期,直接使用
await pool.execute( await conn.execute(
'UPDATE recurring_templates SET next_date = ? WHERE id = ?', 'UPDATE recurring_templates SET next_date = ? WHERE id = ?',
[next.toISOString().slice(0, 10), t.id] [next.toISOString().slice(0, 10), t.id]
) )
} }
await conn.commit()
res.json({ code: 0, data: { generated } }) res.json({ code: 0, data: { generated } })
} catch (error) { } catch (error) {
await conn.rollback()
console.error('Sync recurring error:', error) console.error('Sync recurring error:', error)
res.status(500).json({ code: 50000, message: '同步失败' }) res.status(500).json({ code: 50000, message: '同步失败' })
} finally {
conn.release()
} }
}) })

View File

@@ -1,48 +1,15 @@
import { Router, Response } from 'express' import { Router, Response } from 'express'
import multer from 'multer'
import path from 'path' import path from 'path'
import fs from 'fs' import fs from 'fs'
import pool from '../db/connection' import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth' import { AuthRequest } from '../middleware/auth'
import { createUploadConfig } from '../utils/upload'
const router = Router() const router = Router()
// 头像上传配置 // 头像上传配置2MB
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads' const AVATAR_DIR = path.resolve(process.env.UPLOAD_DIR || './uploads', 'avatars')
const AVATAR_DIR = path.resolve(UPLOAD_DIR, 'avatars') const upload = createUploadConfig('avatars', 2 * 1024 * 1024)
if (!fs.existsSync(AVATAR_DIR)) {
fs.mkdirSync(AVATAR_DIR, { recursive: true })
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
// 确保目录存在(可能被手动删除)
if (!fs.existsSync(AVATAR_DIR)) {
fs.mkdirSync(AVATAR_DIR, { recursive: true })
}
cb(null, AVATAR_DIR)
},
filename: (req: AuthRequest, file, cb) => {
const extMap: Record<string, string> = {
'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp'
}
const ext = extMap[file.mimetype] || '.jpg'
cb(null, `${req.userId}_${Date.now()}${ext}`)
}
})
const upload = multer({
storage,
limits: { fileSize: 2 * 1024 * 1024 }, // 最大 2MB
fileFilter: (_req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp']
if (allowed.includes(file.mimetype)) {
cb(null, true)
} else {
cb(new Error('仅支持 JPEG、PNG、WebP 格式'))
}
}
})
/** 获取当前用户信息 */ /** 获取当前用户信息 */
router.get('/me', async (req: AuthRequest, res: Response) => { router.get('/me', async (req: AuthRequest, res: Response) => {

View File

@@ -0,0 +1,45 @@
import multer from 'multer'
import path from 'path'
import crypto from 'crypto'
import fs from 'fs'
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads'
/** 创建指定子目录的 multer 配置 */
export function createUploadConfig(subDir: string, maxFileSize = 5 * 1024 * 1024) {
const targetDir = path.resolve(UPLOAD_DIR, subDir)
// 确保目录存在
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true })
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
// 运行时再次确认(可能被手动删除)
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true })
}
cb(null, targetDir)
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase() || '.jpg'
const prefix = subDir === 'avatars' ? '' : 'notif_'
cb(null, `${prefix}${Date.now()}_${crypto.randomInt(1000, 9999)}${ext}`)
}
})
return multer({
storage,
limits: { fileSize: maxFileSize },
fileFilter: (_req, file, cb) => {
const allowed = ['.jpg', '.jpeg', '.png', '.webp']
const ext = path.extname(file.originalname).toLowerCase()
if (allowed.includes(ext)) {
cb(null, true)
} else {
cb(new Error('仅支持 JPEG、PNG、WebP 格式'))
}
}
})
}