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)