2026-05-07 19:16:49 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<Dialog
|
|
|
|
|
|
:model-value="open"
|
2026-06-30 14:14:12 +08:00
|
|
|
|
: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">
|
2026-06-30 14:14:12 +08:00
|
|
|
|
<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">
|
2026-06-30 14:14:12 +08:00
|
|
|
|
<UserBatchGroupPicker
|
|
|
|
|
|
v-model="selectedGroupIds"
|
|
|
|
|
|
:groups="groups"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<UserBatchActionCards
|
|
|
|
|
|
v-model="selectedAction"
|
|
|
|
|
|
:actions="USER_BATCH_ACTION_OPTIONS"
|
|
|
|
|
|
/>
|
2026-05-07 22:22:55 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-06-30 14:14:12 +08:00
|
|
|
|
<UserBatchRolePanel
|
2026-05-07 22:22:55 +08:00
|
|
|
|
v-if="selectedAction === 'update_role'"
|
2026-06-30 14:14:12 +08:00
|
|
|
|
v-model="targetRole"
|
|
|
|
|
|
:warning-text="targetRoleWarning"
|
|
|
|
|
|
/>
|
2026-05-07 22:22:55 +08:00
|
|
|
|
|
2026-06-30 14:14:12 +08:00
|
|
|
|
<UserBatchQuotaPanel
|
2026-05-07 19:16:49 +08:00
|
|
|
|
v-if="selectedAction === 'update_access_control'"
|
2026-06-30 14:14:12 +08:00
|
|
|
|
v-model="quotaMode"
|
|
|
|
|
|
/>
|
2026-05-07 19:16:49 +08:00
|
|
|
|
|
2026-06-30 14:14:12 +08:00
|
|
|
|
<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')"
|
|
|
|
|
|
>
|
2026-06-30 14:14:12 +08:00
|
|
|
|
{{ legacyT('关闭') }}
|
2026-05-07 19:16:49 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
:disabled="!canExecute"
|
|
|
|
|
|
@click="executeBatchAction"
|
|
|
|
|
|
>
|
2026-06-30 14:14:12 +08:00
|
|
|
|
{{ executing ? legacyT('执行中...') : executeButtonLabel }}
|
2026-05-07 19:16:49 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-06-30 14:14:12 +08:00
|
|
|
|
import { computed, ref, watch } from 'vue'
|
2026-05-07 19:16:49 +08:00
|
|
|
|
import {
|
|
|
|
|
|
Dialog,
|
|
|
|
|
|
Button,
|
|
|
|
|
|
} from '@/components/ui'
|
|
|
|
|
|
import { useUsersStore } from '@/stores/users'
|
|
|
|
|
|
import { useToast } from '@/composables/useToast'
|
|
|
|
|
|
import { parseApiError } from '@/utils/errorParser'
|
2026-06-30 14:14:12 +08:00
|
|
|
|
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'
|
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,
|
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,
|
2026-05-09 21:47:33 +08:00
|
|
|
|
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
|
2026-05-09 21:47:33 +08:00
|
|
|
|
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()
|
2026-06-30 14:14:12 +08:00
|
|
|
|
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')
|
2026-06-30 14:14:12 +08:00
|
|
|
|
const quotaMode = ref<UserBatchQuotaMode>('skip')
|
2026-05-09 21:47:33 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-05-09 21:47:33 +08:00
|
|
|
|
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)
|
2026-05-09 21:47:33 +08:00
|
|
|
|
const canExecute = computed(() => hasAnyTarget.value && !previewLoading.value && !executing.value)
|
2026-05-07 22:22:55 +08:00
|
|
|
|
const selectedActionLabel = computed(() => (
|
2026-06-30 14:14:12 +08:00
|
|
|
|
USER_BATCH_ACTION_OPTIONS.find((action) => action.value === selectedAction.value)?.label ?? '批量操作'
|
2026-05-07 22:22:55 +08:00
|
|
|
|
))
|
2026-06-30 14:14:12 +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} ${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'
|
2026-05-09 21:47:33 +08:00
|
|
|
|
selectedGroupIds.value = []
|
2026-05-07 19:16:49 +08:00
|
|
|
|
lastResult.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildSelection(): UserBatchSelection {
|
2026-05-09 21:47:33 +08:00
|
|
|
|
const group_ids = selectedGroupIds.value.length > 0 ? [...selectedGroupIds.value] : undefined
|
2026-05-07 19:16:49 +08:00
|
|
|
|
if (props.selectAllFiltered) {
|
2026-05-09 21:47:33 +08:00
|
|
|
|
return { filters: props.filters, group_ids }
|
2026-05-07 19:16:49 +08:00
|
|
|
|
}
|
2026-05-09 21:47:33 +08:00
|
|
|
|
return { user_ids: [...props.selectedIds], group_ids }
|
2026-05-07 19:16:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function resolvePreview(): Promise<void> {
|
2026-05-09 21:47:33 +08:00
|
|
|
|
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 = []
|
2026-06-30 14:14:12 +08:00
|
|
|
|
error(parseApiError(err, '解析用户选择失败'), legacyT('解析用户选择失败'))
|
2026-05-07 19:16:49 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
previewLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 21:47:33 +08:00
|
|
|
|
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) {
|
2026-06-30 14:14:12 +08:00
|
|
|
|
warning(legacyT('请选择要修改的额度'))
|
2026-05-07 22:22:55 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
request = { selection, action: 'update_access_control', payload }
|
|
|
|
|
|
} 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
|
2026-06-30 14:14:12 +08:00
|
|
|
|
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) {
|
2026-06-30 14:14:12 +08:00
|
|
|
|
error(parseApiError(err, '批量操作失败'), legacyT('批量操作失败'))
|
2026-05-07 19:16:49 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
executing.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|