feat: 支持注销用户并展示普通用户使用情况

- 新增管理员注销用户接口,要求输入用户名二次确认
- 注销时清理用户当前图片、视频、文件、标签、会话和自身日志
- 保留备份任务、管理员审计数据和全局配置
- 用户列表展示图片、视频、文件占用,管理员行隐藏使用情况和操作按钮
- 补充注销流程与用量统计测试
This commit is contained in:
tianmo committed 2026-10-19 17:48:44 +08:00
1 parent dd0005b7ba
commit ccfdac2e1b
8 files changed
+394 -24

No files matched your search

+138 -15
View File
@@ -1,10 +1,17 @@
<script setup lang="ts">
import { formatFileSize } from '~/composables/useFileSize'
interface AdminUser {
id: number
username: string
role: 'admin' | 'user'
created_at: string
password_reset: number
usage?: {
imageBytes: number
videoBytes: number
fileBytes: number
}
}
const {
@@ -21,6 +28,18 @@ const { t } = useI18n()
const users = ref<AdminUser[]>([])
const loading = ref(false)
const deleteTarget = ref<AdminUser | null>(null)
const deleteConfirm = ref('')
const deleting = ref(false)
const canDelete = computed(() =>
Boolean(
deleteTarget.value
&& deleteTarget.value.role !== 'admin'
&& deleteConfirm.value.trim() === deleteTarget.value.username
&& !deleting.value
)
)
async function loadUsers() {
if (!isAdmin.value) return
@@ -57,6 +76,51 @@ async function clearPassword(user: AdminUser) {
}
}
function openDeleteDialog(user: AdminUser) {
if (user.role === 'admin') return
deleteTarget.value = user
deleteConfirm.value = ''
}
function closeDeleteDialog() {
if (deleting.value) return
deleteTarget.value = null
deleteConfirm.value = ''
}
function onDeleteOpenChange(open: boolean) {
if (!open) closeDeleteDialog()
}
async function deleteUser() {
const target = deleteTarget.value
if (!target || !canDelete.value) return
deleting.value = true
try {
await $fetch(`/api/admin/users/${target.id}/delete`, {
method: 'POST',
credentials: 'include',
body: { confirmation: deleteConfirm.value.trim() }
})
toast.add({
title: t('users.userDeleted', { name: target.username }),
color: 'success'
})
deleteTarget.value = null
deleteConfirm.value = ''
await loadUsers()
} catch (error: unknown) {
handleAuthError(error)
toast.add({
title: getFetchErrorMessage(error, t('users.deleteFailed')),
color: 'error'
})
} finally {
deleting.value = false
}
}
function formatDate(value: string) {
try {
return new Date(value).toLocaleString()
@@ -150,24 +214,83 @@ onMounted(async () => {
</p>
</div>
<UBadge
v-if="user.password_reset === 1"
color="warning"
variant="subtle"
size="sm"
<div
v-if="user.role !== 'admin'"
class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted"
>
{{ t('users.resetPending') }}
</UBadge>
<UButton
v-else
variant="outline"
color="warning"
size="sm"
:label="t('users.clearPassword')"
@click="clearPassword(user)"
/>
<span>{{ t('users.usageImages') }} {{ formatFileSize(user.usage?.imageBytes ?? 0) }}</span>
<span>{{ t('users.usageVideos') }} {{ formatFileSize(user.usage?.videoBytes ?? 0) }}</span>
<span>{{ t('users.usageFiles') }} {{ formatFileSize(user.usage?.fileBytes ?? 0) }}</span>
</div>
<div
v-if="user.role !== 'admin'"
class="flex items-center gap-2"
>
<UBadge
v-if="user.password_reset === 1"
color="warning"
variant="subtle"
size="sm"
>
{{ t('users.resetPending') }}
</UBadge>
<UButton
v-if="user.password_reset !== 1"
variant="outline"
color="warning"
size="sm"
:label="t('users.clearPassword')"
@click="clearPassword(user)"
/>
<UButton
variant="outline"
color="error"
size="sm"
:label="t('users.deleteUser')"
@click="openDeleteDialog(user)"
/>
</div>
</li>
</ul>
</AppShell>
<UModal
:open="Boolean(deleteTarget)"
:title="t('users.deleteTitle')"
:description="t('users.deleteWarning')"
@update:open="onDeleteOpenChange"
>
<template #body>
<div class="space-y-2">
<p class="text-sm text-muted">
{{ t('users.deleteConfirmLabel', { name: deleteTarget?.username ?? '' }) }}
</p>
<UInput
v-model="deleteConfirm"
:placeholder="deleteTarget?.username"
class="w-full"
autocomplete="off"
/>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
:disabled="deleting"
@click="closeDeleteDialog"
/>
<UButton
:label="t('users.deleteSubmit')"
color="error"
:loading="deleting"
:disabled="!canDelete"
@click="deleteUser"
/>
</div>
</template>
</UModal>
</div>
</template>