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">
|
<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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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')}`
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -39,46 +39,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="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 class="modal-btns">
|
|
||||||
<view class="modal-btn cancel" @tap="showEditModal = false">取消</view>
|
|
||||||
<view class="modal-btn confirm" @tap="onEditConfirm">确定</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
// 已是完整 URL(http/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() {
|
||||||
|
|||||||
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)}月`
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
45
server/src/utils/upload.ts
Normal file
45
server/src/utils/upload.ts
Normal 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 格式'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user