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">
import { ref, computed } from 'vue'
import { formatAmount, formatDate } from '@/utils/format'
import { API_BASE } from '@/config'
import { getAvatarUrl } from '@/utils/avatar'
import Icon from '@/components/Icon/Icon.vue'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
@@ -74,11 +74,7 @@ const isOtherCreator = computed(() => {
defineEmits(['item-tap', 'delete'])
const creatorAvatarUrl = computed(() => {
if (!props.item.creator_avatar) return ''
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
return getAvatarUrl(props.item.creator_avatar || '')
})
const startX = ref(0)

View File

@@ -120,7 +120,7 @@ import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system'
import { getFeedbackList, updateFeedbackStatus } 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'
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 {
const d = new Date(dateStr)
const now = new Date()

View File

@@ -57,7 +57,7 @@ import { ref, onMounted } from 'vue'
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system'
import { API_BASE } from '@/config'
import { getAvatarUrl } from '@/utils/avatar'
import { getUsers, updateUserRole, deleteUser } from '@/api/admin'
import { useUserStore } from '@/stores/user'
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) {
const d = new Date(dateStr)
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 { statusBarHeight } from '@/utils/system'
import Icon from '@/components/Icon/Icon.vue'
import { API_BASE } from '@/config'
const backups = ref<Backup[]>([])
const loading = ref(true)
@@ -121,7 +122,7 @@ function onDownload(item: Backup) {
// #ifdef MP-WEIXIN
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) => {
if (res.statusCode === 200) {
uni.openDocument({

View File

@@ -45,46 +45,22 @@
</view>
<!-- 添加弹窗 -->
<view class="modal-mask" v-if="showAddModal" @tap="showAddModal = false">
<view class="modal" @tap.stop>
<text class="modal-title">添加自定义分类</text>
<input class="modal-input" v-model="newName" placeholder="分类名称" maxlength="10" />
<view class="color-picker">
<view
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>
<EditModal
v-model:visible="showAddModal"
title="添加自定义分类"
:fields="catFields"
:modelValue="addModel"
@confirm="onAdd"
/>
<!-- 编辑弹窗 -->
<view class="modal-mask" v-if="showEditModal" @tap="showEditModal = false">
<view class="modal" @tap.stop>
<text class="modal-title">编辑分类</text>
<input class="modal-input" v-model="editName" placeholder="分类名称" maxlength="10" />
<view class="color-picker">
<view
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>
<EditModal
v-model:visible="showEditModal"
title="编辑分类"
:fields="catFields"
:modelValue="editModel"
@confirm="onEditConfirm"
/>
<!-- 迁移弹窗 -->
<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 CategoryIcon from '@/components/CategoryIcon/CategoryIcon.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'
const catStore = useCategoryStore()
const tabType = ref<'expense' | 'income'>('expense')
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 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
@@ -135,10 +120,7 @@ let dragCurrentIndex = -1
let dragItemEl: any = null
// 编辑相关
const showEditModal = ref(false)
const editingCat = ref<Category | null>(null)
const editName = ref('')
const editColor = ref('')
// 迁移相关
const showMigrateModal = ref(false)
@@ -175,18 +157,18 @@ function goBack() {
uni.navigateBack()
}
async function onAdd() {
const name = newName.value.trim()
async function onAdd(data: Record<string, any>) {
const name = (data.name || '').toString().trim()
if (!name) return
try {
await catStore.addCategory({
name,
icon: name[0],
color: selectedColor.value,
color: data.color,
type: tabType.value
})
newName.value = ''
addModel.value = { name: '', color: '#FF8C69' }
showAddModal.value = false
uni.showToast({ title: '添加成功', icon: 'success' })
} catch {
@@ -196,21 +178,20 @@ async function onAdd() {
function onEdit(cat: Category) {
editingCat.value = cat
editName.value = cat.name
editColor.value = cat.color
editModel.value = { name: cat.name, color: cat.color }
showEditModal.value = true
}
async function onEditConfirm() {
async function onEditConfirm(data: Record<string, any>) {
if (!editingCat.value) return
const name = editName.value.trim()
const name = (data.name || '').toString().trim()
if (!name) {
uni.showToast({ title: '请输入名称', icon: 'none' })
return
}
try {
await catStore.updateCategory(editingCat.value.id, { name, color: editColor.value })
await catStore.updateCategory(editingCat.value.id, { name, color: data.color })
showEditModal.value = false
uni.showToast({ title: '修改成功', icon: 'success' })
} catch {
@@ -454,27 +435,7 @@ function onDragEnd(e: any) {
&: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 {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
@@ -508,15 +469,6 @@ function onDragEnd(e: any) {
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 {
max-height: 400rpx;
margin-bottom: $space-md;

View File

@@ -154,7 +154,7 @@ import { useGroupStore } from '@/stores/group'
import { useConfigStore } from '@/stores/config'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight, capsuleRight } from '@/utils/system'
import { API_BASE } from '@/config'
import { getAvatarUrl } from '@/utils/avatar'
import Icon from '@/components/Icon/Icon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue'
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() {
if (saving.value) return
const name = newGroupName.value.trim()

View File

@@ -39,46 +39,22 @@
</view>
<!-- 添加弹窗 -->
<view class="modal-mask" v-if="showAddModal" @tap="showAddModal = false">
<view class="modal" @tap.stop>
<text class="modal-title">添加标签</text>
<input class="modal-input" v-model="newName" placeholder="标签名称" maxlength="20" />
<view class="color-picker">
<view
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>
<EditModal
v-model:visible="showAddModal"
title="添加标签"
:fields="addFields"
:modelValue="addModel"
@confirm="onAdd"
/>
<!-- 编辑弹窗 -->
<view class="modal-mask" v-if="showEditModal" @tap="showEditModal = false">
<view class="modal" @tap.stop>
<text class="modal-title">编辑标签</text>
<input class="modal-input" v-model="editName" placeholder="标签名称" maxlength="20" />
<view class="color-picker">
<view
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>
<EditModal
v-model:visible="showEditModal"
title="编辑标签"
:fields="editFields"
:modelValue="editModel"
@confirm="onEditConfirm"
/>
</view>
</template>
@@ -90,18 +66,24 @@ import type { Tag } from '@/api/tag'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system'
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 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 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)
@@ -124,13 +106,13 @@ function goBack() {
uni.navigateBack()
}
async function onAdd() {
const name = newName.value.trim()
async function onAdd(data: Record<string, any>) {
const name = (data.name || '').toString().trim()
if (!name) return
try {
await tagStore.addTag({ name, color: selectedColor.value })
newName.value = ''
await tagStore.addTag({ name, color: data.color })
addModel.value = { name: '', color: '#FF8C69' }
showAddModal.value = false
uni.showToast({ title: '添加成功', icon: 'success' })
} catch {
@@ -140,21 +122,20 @@ async function onAdd() {
function onEdit(tag: Tag) {
editingTag.value = tag
editName.value = tag.name
editColor.value = tag.color
editModel.value = { name: tag.name, color: tag.color }
showEditModal.value = true
}
async function onEditConfirm() {
async function onEditConfirm(data: Record<string, any>) {
if (!editingTag.value) return
const name = editName.value.trim()
const name = (data.name || '').toString().trim()
if (!name) {
uni.showToast({ title: '请输入名称', icon: 'none' })
return
}
try {
await tagStore.updateTag(editingTag.value.id, { name, color: editColor.value })
await tagStore.updateTag(editingTag.value.id, { name, color: data.color })
showEditModal.value = false
uni.showToast({ title: '修改成功', icon: 'success' })
} catch {
@@ -283,89 +264,4 @@ async function onDelete(tag: Tag) {
z-index: 50;
&: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>

View File

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import * as api from '@/api/user'
import { API_BASE } from '@/config'
import { getAvatarUrl } from '@/utils/avatar'
export const useUserStore = defineStore('user', () => {
const userInfo = ref<api.UserInfo | null>(null)
@@ -16,11 +16,7 @@ export const useUserStore = defineStore('user', () => {
const avatarUrl = computed(() => {
const filename = userInfo.value?.avatar_url || uni.getStorageSync('xc:avatar_url') || ''
if (!filename) return ''
// 已是完整 URLhttp/blob直接返回
if (filename.startsWith('http') || filename.startsWith('blob:')) return filename
// 拼接API_BASE + /user/avatar/ + 文件名
return `${API_BASE}/user/avatar/${filename}`
return getAvatarUrl(filename)
})
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)}`
}
/** 获取当前月份UTC+8 时区,与服务端保持一致) */
/** 获取当前月份UTC+8 时区,与服务端 date.ts 逻辑一致) */
export function getCurrentMonth(): string {
const d = new Date()
const offset = 8 * 60 * 60 * 1000