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:
47
client/src/components/ColorPicker/ColorPicker.vue
Normal file
47
client/src/components/ColorPicker/ColorPicker.vue
Normal 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>
|
||||
146
client/src/components/EditModal/EditModal.vue
Normal file
146
client/src/components/EditModal/EditModal.vue
Normal 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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')}`
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ''
|
||||
// 已是完整 URL(http/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() {
|
||||
|
||||
8
client/src/utils/avatar.ts
Normal file
8
client/src/utils/avatar.ts
Normal 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}`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user