2026-05-07 19:16:49 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<Dialog
|
|
|
|
|
|
:model-value="open"
|
2026-06-30 14:14:12 +08:00
|
|
|
|
:title="legacyT('用户批量操作')"
|
2026-09-23 11:30:33 +08:00
|
|
|
|
: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-09-23 11:30:33 +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>
|
|
|
|
|
|
|
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,
|
2026-09-23 11:30:33 +08:00
|
|
|
|
Input,
|
|
|
|
|
|
Label,
|
2026-05-07 19:16:49 +08:00
|
|
|
|
} from '@/components/ui'
|
2026-09-23 11:30:33 +08:00
|
|
|
|
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'
|
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-09-23 11:30:33 +08:00
|
|
|
|
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,
|
2026-09-23 11:30:33 +08:00
|
|
|
|
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,
|
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-09-23 11:30:33 +08:00
|
|
|
|
const balanceOperation = ref<UserBatchBalanceOperation>('add')
|
|
|
|
|
|
const balanceAmount = ref('')
|
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-09-23 11:30:33 +08:00
|
|
|
|
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(() => (
|
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 ''
|
2026-09-23 11:30:33 +08:00
|
|
|
|
const failures = lastResult.value.failures.slice(0, 3)
|
|
|
|
|
|
.map((item) => `${item.user_id} ${legacyT(item.reason)}`)
|
|
|
|
|
|
.join(locale.value === 'en-US' ? '; ' : ';')
|
2026-06-30 14:14:12 +08:00
|
|
|
|
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-09-23 11:30:33 +08:00
|
|
|
|
balanceOperation.value = 'add'
|
|
|
|
|
|
balanceAmount.value = ''
|
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 }
|
2026-09-23 11:30:33 +08:00
|
|
|
|
} 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
|
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-09-23 11:30:33 +08:00
|
|
|
|
error(legacyT(parseApiError(err, '批量操作失败')), legacyT('批量操作失败'))
|
2026-05-07 19:16:49 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
executing.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|