From fe4468516ff1c61ae3a6ce37b26753453a05254b Mon Sep 17 00:00:00 2001 From: erio Date: Thu, 12 Mar 2026 20:24:37 +0800 Subject: [PATCH] feat(groups): local edit mode with cancel/save for rate multipliers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rate column is now editable input (local state, not auto-saved) - Add/delete/clear-all are local operations until Save is clicked - Batch adjust shows "最终倍率" preview column before applying - Bottom cancel/save bar appears when there are unsaved changes - Save sends all entries at once via batch API --- backend/cmd/server/VERSION | 2 +- .../admin/group/GroupRateMultipliersModal.vue | 254 +++++++++++------- frontend/src/i18n/locales/en.ts | 3 + frontend/src/i18n/locales/zh.ts | 3 + 4 files changed, 171 insertions(+), 91 deletions(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 844056b340..7f67293269 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.96.4 +0.1.96.5 diff --git a/frontend/src/components/admin/group/GroupRateMultipliersModal.vue b/frontend/src/components/admin/group/GroupRateMultipliersModal.vue index 404167b6db..1bf32410a4 100644 --- a/frontend/src/components/admin/group/GroupRateMultipliersModal.vue +++ b/frontend/src/components/admin/group/GroupRateMultipliersModal.vue @@ -1,5 +1,5 @@ @@ -218,6 +244,8 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import Pagination from '@/components/common/Pagination.vue' import Icon from '@/components/icons/Icon.vue' +interface LocalEntry extends GroupRateMultiplierEntry {} + const props = defineProps<{ show: boolean group: AdminGroup | null @@ -232,36 +260,58 @@ const { t } = useI18n() const appStore = useAppStore() const loading = ref(false) -const entries = ref([]) +const saving = ref(false) +const serverEntries = ref([]) +const localEntries = ref([]) const searchQuery = ref('') const searchResults = ref([]) const showDropdown = ref(false) const selectedUser = ref(null) const newRate = ref(null) -const addingRate = ref(false) -const clearing = ref(false) const showClearConfirm = ref(false) const currentPage = ref(1) const pageSize = ref(10) const batchFactor = ref(null) -const adjusting = ref(false) let searchTimeout: ReturnType -const paginatedEntries = computed(() => { - const start = (currentPage.value - 1) * pageSize.value - return entries.value.slice(start, start + pageSize.value) +// 是否显示"最终倍率"预览列 +const showFinalRate = computed(() => { + return batchFactor.value != null && batchFactor.value > 0 && batchFactor.value !== 1 }) +// 计算最终倍率预览 +const computeFinalRate = (rate: number) => { + if (!batchFactor.value) return rate + return parseFloat((rate * batchFactor.value).toFixed(6)) +} + +// 检测是否有未保存的修改 +const isDirty = computed(() => { + if (localEntries.value.length !== serverEntries.value.length) return true + const serverMap = new Map(serverEntries.value.map(e => [e.user_id, e.rate_multiplier])) + return localEntries.value.some(e => { + const serverRate = serverMap.get(e.user_id) + return serverRate === undefined || serverRate !== e.rate_multiplier + }) +}) + +const paginatedLocalEntries = computed(() => { + const start = (currentPage.value - 1) * pageSize.value + return localEntries.value.slice(start, start + pageSize.value) +}) + +const cloneEntries = (entries: GroupRateMultiplierEntry[]): LocalEntry[] => { + return entries.map(e => ({ ...e })) +} + const loadEntries = async () => { if (!props.group) return loading.value = true try { - entries.value = await adminAPI.groups.getGroupRateMultipliers(props.group.id) - const totalPages = Math.max(1, Math.ceil(entries.value.length / pageSize.value)) - if (currentPage.value > totalPages) { - currentPage.value = totalPages - } + serverEntries.value = await adminAPI.groups.getGroupRateMultipliers(props.group.id) + localEntries.value = cloneEntries(serverEntries.value) + adjustPage() } catch (error) { appStore.showError(t('admin.groups.failedToLoad')) console.error('Error loading group rate multipliers:', error) @@ -270,15 +320,22 @@ const loadEntries = async () => { } } +const adjustPage = () => { + const totalPages = Math.max(1, Math.ceil(localEntries.value.length / pageSize.value)) + if (currentPage.value > totalPages) { + currentPage.value = totalPages + } +} + watch(() => props.show, (val) => { if (val && props.group) { currentPage.value = 1 - loadEntries() + batchFactor.value = null searchQuery.value = '' searchResults.value = [] selectedUser.value = null newRate.value = null - batchFactor.value = null + loadEntries() } }) @@ -313,80 +370,97 @@ const selectUser = (user: AdminUser) => { searchResults.value = [] } -const handleAddRate = async () => { - if (!selectedUser.value || !newRate.value || !props.group) return - addingRate.value = true - try { - await adminAPI.users.update(selectedUser.value.id, { - group_rates: { [props.group.id]: newRate.value } - }) - appStore.showSuccess(t('admin.groups.rateAdded')) - searchQuery.value = '' - selectedUser.value = null - newRate.value = null - await loadEntries() - emit('success') - } catch (error) { - appStore.showError(t('admin.groups.failedToSave')) - console.error('Error adding rate multiplier:', error) - } finally { - addingRate.value = false +// 本地添加(或覆盖已有用户) +const handleAddLocal = () => { + if (!selectedUser.value || !newRate.value) return + const user = selectedUser.value + const idx = localEntries.value.findIndex(e => e.user_id === user.id) + const entry: LocalEntry = { + user_id: user.id, + user_name: user.username || '', + user_email: user.email, + user_notes: user.notes || '', + user_status: user.status || 'active', + rate_multiplier: newRate.value + } + if (idx >= 0) { + localEntries.value[idx] = entry + } else { + localEntries.value.push(entry) + } + searchQuery.value = '' + selectedUser.value = null + newRate.value = null + adjustPage() +} + +// 本地修改倍率 +const updateLocalRate = (userId: number, value: string) => { + const num = parseFloat(value) + if (isNaN(num)) return + const entry = localEntries.value.find(e => e.user_id === userId) + if (entry) { + entry.rate_multiplier = num } } -const handleBatchAdjust = async () => { - if (!props.group || !batchFactor.value || entries.value.length === 0) return - adjusting.value = true - try { - const newEntries = entries.value.map(e => ({ - user_id: e.user_id, - rate_multiplier: parseFloat((e.rate_multiplier * batchFactor.value!).toFixed(6)) - })) - await adminAPI.groups.batchSetGroupRateMultipliers(props.group.id, newEntries) - appStore.showSuccess(t('admin.groups.rateAdjusted')) - batchFactor.value = null - await loadEntries() - emit('success') - } catch (error) { - appStore.showError(t('admin.groups.failedToSave')) - console.error('Error batch adjusting rate multipliers:', error) - } finally { - adjusting.value = false - } +// 本地删除 +const removeLocal = (userId: number) => { + localEntries.value = localEntries.value.filter(e => e.user_id !== userId) + adjustPage() } -const handleDeleteRate = async (entry: GroupRateMultiplierEntry) => { - if (!props.group) return - try { - await adminAPI.users.update(entry.user_id, { - group_rates: { [props.group.id]: null } - }) - appStore.showSuccess(t('admin.groups.rateDeleted')) - await loadEntries() - emit('success') - } catch (error) { - appStore.showError(t('admin.groups.failedToSave')) - console.error('Error deleting rate multiplier:', error) +// 批量乘数应用到本地 +const applyBatchFactor = () => { + if (!batchFactor.value || batchFactor.value <= 0) return + for (const entry of localEntries.value) { + entry.rate_multiplier = parseFloat((entry.rate_multiplier * batchFactor.value).toFixed(6)) } + batchFactor.value = null } -const handleClearAll = async () => { - if (!props.group || entries.value.length === 0) return +// 本地清空 +const clearAllLocal = () => { showClearConfirm.value = false - clearing.value = true + localEntries.value = [] +} + +// 取消:恢复到服务器数据 +const handleCancel = () => { + localEntries.value = cloneEntries(serverEntries.value) + batchFactor.value = null + adjustPage() +} + +// 保存:一次性提交所有数据 +const handleSave = async () => { + if (!props.group) return + saving.value = true try { - await adminAPI.groups.clearGroupRateMultipliers(props.group.id) - appStore.showSuccess(t('admin.groups.rateCleared')) + const entries = localEntries.value.map(e => ({ + user_id: e.user_id, + rate_multiplier: e.rate_multiplier + })) + await adminAPI.groups.batchSetGroupRateMultipliers(props.group.id, entries) + appStore.showSuccess(t('admin.groups.rateSaved')) await loadEntries() emit('success') } catch (error) { appStore.showError(t('admin.groups.failedToSave')) - console.error('Error clearing rate multipliers:', error) + console.error('Error saving rate multipliers:', error) } finally { - clearing.value = false + saving.value = false } } +// 关闭时如果有未保存修改,先恢复 +const handleClose = () => { + if (isDirty.value) { + localEntries.value = cloneEntries(serverEntries.value) + } + emit('close') +} + // 点击外部关闭下拉 const handleClickOutside = () => { showDropdown.value = false diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index fbfbcb991c..8b4daa767d 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1430,6 +1430,9 @@ export default { multiplierFactor: 'Factor', applyMultiplier: 'Apply', rateAdjusted: 'Rates adjusted successfully', + rateSaved: 'Rate multipliers saved', + finalRate: 'Final Rate', + unsavedChanges: 'Unsaved changes', userInfo: 'User Info', platforms: { all: 'All Platforms', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index a67b36312d..df43bdcadf 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1529,6 +1529,9 @@ export default { multiplierFactor: '乘数', applyMultiplier: '应用', rateAdjusted: '倍率已批量调整', + rateSaved: '专属倍率已保存', + finalRate: '最终倍率', + unsavedChanges: '有未保存的修改', userInfo: '用户信息', subscription: { title: '订阅设置',