Files
Aether/frontend/src/features/users/components/UserBatchActionDialog.vue
T

337 lines
11 KiB
Vue
Raw Normal View History

2026-05-07 19:16:49 +08:00
<template>
<Dialog
:model-value="open"
:title="legacyT('用户批量操作')"
:description="legacyT('按当前选择批量调整用户状态、角色、额度和钱包余额')"
2026-05-07 19:16:49 +08:00
size="2xl"
persistent
@update:model-value="handleDialogUpdate"
>
2026-05-07 22:22:55 +08:00
<div class="space-y-5">
<UserBatchTargetSummary
:select-all-filtered="selectAllFiltered"
:impact-label="impactLabel"
:impact-count="impactCount"
:overflow-preview-label="overflowPreviewLabel"
:loading="previewLoading"
:preview-items="previewItems"
/>
2026-05-07 19:16:49 +08:00
2026-05-07 22:22:55 +08:00
<div class="space-y-2.5">
<UserBatchGroupPicker
v-model="selectedGroupIds"
:groups="groups"
/>
<UserBatchActionCards
v-model="selectedAction"
:actions="USER_BATCH_ACTION_OPTIONS"
/>
2026-05-07 22:22:55 +08:00
</div>
<UserBatchRolePanel
2026-05-07 22:22:55 +08:00
v-if="selectedAction === 'update_role'"
v-model="targetRole"
:warning-text="targetRoleWarning"
/>
2026-05-07 22:22:55 +08:00
<UserBatchQuotaPanel
2026-05-07 19:16:49 +08:00
v-if="selectedAction === 'update_access_control'"
v-model="quotaMode"
/>
2026-05-07 19:16:49 +08:00
<div
v-if="selectedAction === 'adjust_wallet_balance'"
class="space-y-3 rounded-xl border border-border bg-background p-4"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<Label for="user-batch-wallet-amount" class="text-sm font-medium">
{{ legacyT('调整金额 (USD)') }}
</Label>
<div class="inline-flex rounded-md border border-border p-0.5" role="group" :aria-label="legacyT('余额调整方式')">
<Button
type="button"
size="sm"
:variant="balanceOperation === 'add' ? 'default' : 'ghost'"
:aria-pressed="balanceOperation === 'add'"
@click="balanceOperation = 'add'"
>
<Plus class="mr-1.5 h-4 w-4" />
{{ legacyT('增加') }}
</Button>
<Button
type="button"
size="sm"
:variant="balanceOperation === 'deduct' ? 'default' : 'ghost'"
:aria-pressed="balanceOperation === 'deduct'"
@click="balanceOperation = 'deduct'"
>
<Minus class="mr-1.5 h-4 w-4" />
{{ legacyT('扣减') }}
</Button>
</div>
</div>
<Input
id="user-batch-wallet-amount"
:model-value="balanceAmount"
type="number"
min="0"
step="any"
inputmode="decimal"
:aria-invalid="balanceAmount !== '' && balancePayload === null"
@update:model-value="balanceAmount = String($event)"
/>
<p v-if="balanceAmount !== '' && balancePayload === null" class="text-xs text-destructive">
{{ legacyT('请输入大于 0 的有限金额') }}
</p>
<p class="text-xs leading-relaxed text-muted-foreground">
{{ legacyT('扣减超过单个用户可用余额时,该用户余额将归零。') }}
</p>
</div>
<UserBatchResultSummary
:result="lastResult"
:label="lastResultLabel"
:failures-label="lastResultFailuresLabel"
/>
2026-05-07 19:16:49 +08:00
</div>
<template #footer>
<Button
variant="outline"
:disabled="executing"
@click="emit('close')"
>
{{ legacyT('关闭') }}
2026-05-07 19:16:49 +08:00
</Button>
<Button
:disabled="!canExecute"
@click="executeBatchAction"
>
{{ executing ? legacyT('执行中...') : executeButtonLabel }}
2026-05-07 19:16:49 +08:00
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
2026-05-07 19:16:49 +08:00
import {
Dialog,
Button,
Input,
Label,
2026-05-07 19:16:49 +08:00
} from '@/components/ui'
import { Minus, Plus } from 'lucide-vue-next'
2026-05-07 19:16:49 +08:00
import { useUsersStore } from '@/stores/users'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { useI18n } from '@/i18n'
import UserBatchActionCards from './UserBatchActionCards.vue'
import UserBatchGroupPicker from './UserBatchGroupPicker.vue'
import UserBatchQuotaPanel from './UserBatchQuotaPanel.vue'
import UserBatchResultSummary from './UserBatchResultSummary.vue'
import UserBatchRolePanel from './UserBatchRolePanel.vue'
import UserBatchTargetSummary from './UserBatchTargetSummary.vue'
import { USER_BATCH_ACTION_OPTIONS } from './user-management-config'
import type { UserBatchQuotaMode } from './user-management-types'
import { buildUserBatchBalanceAdjustmentPayload } from '@/api/users'
2026-05-07 19:16:49 +08:00
import type {
UserBatchAccessControlPayload,
UserBatchAction,
2026-05-07 22:22:55 +08:00
UserBatchActionRequest,
2026-05-07 19:16:49 +08:00
UserBatchActionResponse,
UserBatchBalanceOperation,
2026-05-07 22:22:55 +08:00
UserBatchRolePayload,
2026-05-07 19:16:49 +08:00
UserBatchSelection,
UserBatchSelectionFilters,
UserBatchSelectionItem,
2026-05-07 22:22:55 +08:00
UserRole,
UserGroup,
2026-05-07 19:16:49 +08:00
} from '@/api/users'
const props = defineProps<{
open: boolean
selectedIds: string[]
selectAllFiltered: boolean
selectedCount: number
filters: UserBatchSelectionFilters
groups: UserGroup[]
2026-05-07 19:16:49 +08:00
}>()
const emit = defineEmits<{
close: []
completed: [result: UserBatchActionResponse]
}>()
const usersStore = useUsersStore()
const { success, warning, error } = useToast()
const { legacyT, locale } = useI18n()
2026-05-07 22:22:55 +08:00
2026-05-07 19:16:49 +08:00
const selectedAction = ref<UserBatchAction>('enable')
2026-05-07 22:22:55 +08:00
const targetRole = ref<UserRole>('user')
const quotaMode = ref<UserBatchQuotaMode>('skip')
const balanceOperation = ref<UserBatchBalanceOperation>('add')
const balanceAmount = ref('')
const selectedGroupIds = ref<string[]>([])
2026-05-07 19:16:49 +08:00
const previewLoading = ref(false)
const previewItems = ref<UserBatchSelectionItem[]>([])
const resolvedTotal = ref<number | null>(null)
const executing = ref(false)
const lastResult = ref<UserBatchActionResponse | null>(null)
const hasAnyTarget = computed(() => props.selectedCount > 0 || selectedGroupIds.value.length > 0)
2026-05-07 19:16:49 +08:00
const impactCount = computed(() => resolvedTotal.value ?? props.selectedCount)
const balancePayload = computed(() => buildUserBatchBalanceAdjustmentPayload(
balanceOperation.value,
balanceAmount.value,
))
const canExecute = computed(() => (
hasAnyTarget.value
&& !previewLoading.value
&& !executing.value
&& (selectedAction.value !== 'adjust_wallet_balance' || balancePayload.value !== null)
))
2026-05-07 22:22:55 +08:00
const selectedActionLabel = computed(() => (
USER_BATCH_ACTION_OPTIONS.find((action) => action.value === selectedAction.value)?.label ?? '批量操作'
2026-05-07 22:22:55 +08:00
))
const impactLabel = computed(() => locale.value === 'en-US'
? `Affected users: ${impactCount.value}`
: `影响用户:${impactCount.value} 个`)
const overflowPreviewLabel = computed(() => legacyT(`等 ${impactCount.value} 个用户`))
const targetRoleWarning = computed(() => {
if (targetRole.value === 'admin') {
return legacyT('提示:设置为管理员会授予用户完整后台管理能力。')
}
if (targetRole.value === 'audit_admin') {
return legacyT('提示:设置为审计管理员会授予后台只读查看能力。')
}
return legacyT('提示:设置为普通用户会移除目标用户的管理员权限。')
})
const executeButtonLabel = computed(() => legacyT(`确认${selectedActionLabel.value}(${impactCount.value})`))
const lastResultLabel = computed(() => {
if (!lastResult.value) return ''
return legacyT(`成功 ${lastResult.value.success} 个,失败 ${lastResult.value.failed} 个`)
})
const lastResultFailuresLabel = computed(() => {
if (!lastResult.value || lastResult.value.failures.length === 0) return ''
const failures = lastResult.value.failures.slice(0, 3)
.map((item) => `${item.user_id} ${legacyT(item.reason)}`)
.join(locale.value === 'en-US' ? '; ' : ';')
return locale.value === 'en-US' ? `: ${failures}` : `:${failures}`
})
2026-05-07 19:16:49 +08:00
watch(
() => props.open,
(open) => {
if (!open) return
resetLocalState()
void resolvePreview()
},
)
watch(
() => [props.selectedIds, props.selectAllFiltered, props.selectedCount, props.filters] as const,
() => {
if (props.open) void resolvePreview()
},
)
function handleDialogUpdate(value: boolean): void {
if (!value) emit('close')
}
function resetLocalState(): void {
selectedAction.value = 'enable'
2026-05-07 22:22:55 +08:00
targetRole.value = 'user'
quotaMode.value = 'skip'
balanceOperation.value = 'add'
balanceAmount.value = ''
selectedGroupIds.value = []
2026-05-07 19:16:49 +08:00
lastResult.value = null
}
function buildSelection(): UserBatchSelection {
const group_ids = selectedGroupIds.value.length > 0 ? [...selectedGroupIds.value] : undefined
2026-05-07 19:16:49 +08:00
if (props.selectAllFiltered) {
return { filters: props.filters, group_ids }
2026-05-07 19:16:49 +08:00
}
return { user_ids: [...props.selectedIds], group_ids }
2026-05-07 19:16:49 +08:00
}
async function resolvePreview(): Promise<void> {
if (!hasAnyTarget.value) {
2026-05-07 19:16:49 +08:00
resolvedTotal.value = 0
previewItems.value = []
return
}
previewLoading.value = true
try {
const result = await usersStore.resolveBatchSelection(buildSelection())
resolvedTotal.value = result.total
previewItems.value = result.items.slice(0, 6)
} catch (err) {
resolvedTotal.value = props.selectedCount
previewItems.value = []
error(parseApiError(err, '解析用户选择失败'), legacyT('解析用户选择失败'))
2026-05-07 19:16:49 +08:00
} finally {
previewLoading.value = false
}
}
watch(selectedGroupIds, () => {
if (props.open) void resolvePreview()
})
2026-05-07 19:16:49 +08:00
function buildAccessControlPayload(): UserBatchAccessControlPayload | null {
const payload: UserBatchAccessControlPayload = {}
2026-05-07 22:22:55 +08:00
if (quotaMode.value === 'wallet') payload.unlimited = false
if (quotaMode.value === 'unlimited') payload.unlimited = true
2026-05-07 19:16:49 +08:00
return Object.keys(payload).length > 0 ? payload : null
}
2026-05-07 22:22:55 +08:00
function buildRolePayload(): UserBatchRolePayload {
return { role: targetRole.value }
}
2026-05-07 19:16:49 +08:00
async function executeBatchAction(): Promise<void> {
if (!canExecute.value) return
2026-05-07 22:22:55 +08:00
const selection = buildSelection()
let request: UserBatchActionRequest
if (selectedAction.value === 'update_access_control') {
const payload = buildAccessControlPayload()
if (payload === null) {
warning(legacyT('请选择要修改的额度'))
2026-05-07 22:22:55 +08:00
return
}
request = { selection, action: 'update_access_control', payload }
} else if (selectedAction.value === 'adjust_wallet_balance') {
if (balancePayload.value === null) {
warning(legacyT('请输入大于 0 的有限金额'))
return
}
request = { selection, action: 'adjust_wallet_balance', payload: balancePayload.value }
2026-05-07 22:22:55 +08:00
} else if (selectedAction.value === 'update_role') {
request = { selection, action: 'update_role', payload: buildRolePayload() }
} else {
request = { selection, action: selectedAction.value }
2026-05-07 19:16:49 +08:00
}
executing.value = true
try {
2026-05-07 22:22:55 +08:00
const result = await usersStore.batchAction(request)
2026-05-07 19:16:49 +08:00
lastResult.value = result
const message = legacyT(`批量操作完成:成功 ${result.success} 个,失败 ${result.failed} 个`)
2026-05-07 22:22:55 +08:00
if (result.failed > 0) {
warning(message)
} else {
success(message)
}
2026-05-07 19:16:49 +08:00
emit('completed', result)
} catch (err) {
error(legacyT(parseApiError(err, '批量操作失败')), legacyT('批量操作失败'))
2026-05-07 19:16:49 +08:00
} finally {
executing.value = false
}
}
</script>