feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal-fade">
|
||||
<div v-if="show" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4" @click.self="closeModal">
|
||||
<div class="w-full max-w-md max-h-[90vh] overflow-y-auto bg-bg-primary rounded-2xl border border-border-secondary shadow-2xl">
|
||||
<!-- 头部 -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-border-secondary">
|
||||
<h3 class="text-lg font-black text-text-primary flex items-center gap-2">
|
||||
<ClipboardCheck :size="18" />
|
||||
{{ locale.approval.title }}
|
||||
</h3>
|
||||
<button type="button" class="p-1.5 text-text-tertiary hover:text-text-primary transition-colors" @click="closeModal">
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-5 space-y-4">
|
||||
<!-- 注册基本信息 -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="bg-bg-secondary-40 rounded-xl px-4 py-3">
|
||||
<p class="text-[11px] font-black uppercase tracking-widest text-text-tertiary">{{ locale.approval.username }}</p>
|
||||
<p class="text-sm font-bold text-text-primary mt-1 break-all">@{{ user?.username || '-' }}</p>
|
||||
</div>
|
||||
<div class="bg-bg-secondary-40 rounded-xl px-4 py-3">
|
||||
<p class="text-[11px] font-black uppercase tracking-widest text-text-tertiary">{{ locale.approval.registeredAt }}</p>
|
||||
<p class="text-sm font-bold text-text-primary mt-1">{{ formatDate(user?.createdAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 注册备注(只读展示) -->
|
||||
<div class="bg-bg-secondary-40 rounded-xl px-4 py-3">
|
||||
<p class="text-[11px] font-black uppercase tracking-widest text-text-tertiary">{{ locale.approval.remark }}</p>
|
||||
<p class="text-sm font-bold text-text-primary mt-1 whitespace-pre-wrap break-all">{{ user?.remark || locale.approval.emptyRemark }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 可编辑字段 -->
|
||||
<div class="space-y-3">
|
||||
<p class="text-xs text-text-secondary">{{ locale.approval.editHint }}</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="block text-xs font-bold text-text-secondary mb-1.5" for="approval-name">{{ locale.approval.name }}</label>
|
||||
<input
|
||||
id="approval-name"
|
||||
v-model="form.name"
|
||||
class="w-full px-3 py-2.5 rounded-xl bg-bg-secondary border border-border-secondary text-text-primary text-sm focus:outline-none focus:border-primary transition-colors"
|
||||
type="text"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<CustomSelect v-model="form.grade" :options="gradeSelectOptions" :label="locale.approval.grade" @change="handleGradeChange" />
|
||||
<CustomSelect
|
||||
v-model="form.class"
|
||||
:options="classSelectOptions"
|
||||
:label="locale.approval.class"
|
||||
:disabled="!form.grade"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="block text-xs font-bold text-text-secondary mb-1.5" for="approval-remark">{{ locale.approval.remark }}</label>
|
||||
<input
|
||||
id="approval-remark"
|
||||
v-model="form.remark"
|
||||
class="w-full px-3 py-2.5 rounded-xl bg-bg-secondary border border-border-secondary text-text-primary text-sm focus:outline-none focus:border-primary transition-colors"
|
||||
type="text"
|
||||
maxlength="200"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 拒绝理由(可选) -->
|
||||
<div class="form-group">
|
||||
<label class="block text-xs font-bold text-text-secondary mb-1.5" for="approval-reason">{{ locale.approval.rejectReason }}</label>
|
||||
<input
|
||||
id="approval-reason"
|
||||
v-model="rejectReason"
|
||||
class="w-full px-3 py-2.5 rounded-xl bg-bg-secondary border border-border-secondary text-text-primary text-sm focus:outline-none focus:border-primary transition-colors"
|
||||
type="text"
|
||||
:placeholder="locale.approval.rejectReasonPlaceholder"
|
||||
maxlength="200"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-xs text-error font-bold">{{ error }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<div class="flex items-center justify-end gap-2 px-6 py-4 border-t border-border-secondary">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
class="px-4 py-2 rounded-xl bg-bg-secondary border border-border-secondary text-text-tertiary hover:text-text-primary transition-colors disabled:opacity-40"
|
||||
@click="closeModal"
|
||||
>
|
||||
{{ locale.actions.cancel }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
class="px-4 py-2 rounded-xl bg-error-10 text-error border border-error-20 hover:bg-error-20 transition-colors disabled:opacity-40"
|
||||
@click="handleReject"
|
||||
>
|
||||
{{ locale.approval.reject }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
class="px-4 py-2 rounded-xl bg-primary text-white hover:opacity-90 transition-opacity disabled:opacity-40 flex items-center gap-1.5"
|
||||
@click="handleApprove"
|
||||
>
|
||||
<RefreshCw v-if="loading" :size="14" class="animate-spin" />
|
||||
{{ locale.approval.approve }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<ConfirmDialog
|
||||
:show="showRejectConfirm"
|
||||
:title="locale.approval.title"
|
||||
:message="locale.approval.confirmReject"
|
||||
type="danger"
|
||||
:confirm-text="locale.approval.reject"
|
||||
:loading="loading"
|
||||
@confirm="confirmReject"
|
||||
@close="showRejectConfirm = false"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ClipboardCheck, RefreshCw, X } from '@lucide/vue'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import { useServerErrors } from '~/composables/useLocaleText'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
user: { type: Object, default: null }
|
||||
})
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
|
||||
const { admin } = useLocale()
|
||||
const locale = computed(() => admin.value?.userManager || {})
|
||||
const { localize: localizeServerError } = useServerErrors()
|
||||
|
||||
const form = ref({ name: '', grade: '', class: '', remark: '' })
|
||||
const rejectReason = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const showRejectConfirm = ref(false)
|
||||
|
||||
const classOptions = ref([])
|
||||
const classOptionsLoaded = ref(false)
|
||||
|
||||
const gradeSelectOptions = computed(() => [
|
||||
{ label: locale.value?.approval?.gradeNotSet || '不设置', value: '' },
|
||||
...[...new Set(classOptions.value.map((item) => item.grade))].map((option) => ({ label: option, value: option }))
|
||||
])
|
||||
|
||||
const classSelectOptions = computed(() =>
|
||||
classOptions.value
|
||||
.filter((item) => item.grade === form.value.grade)
|
||||
.map((item) => ({ label: item.class, value: item.class }))
|
||||
)
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '-'
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return '-'
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
const handleGradeChange = () => {
|
||||
form.value.class = ''
|
||||
}
|
||||
|
||||
const fetchClassOptions = async () => {
|
||||
if (classOptionsLoaded.value) return
|
||||
try {
|
||||
const response = await $fetch('/api/auth/grade-class-options')
|
||||
if (response.success) {
|
||||
classOptions.value = response.classes || []
|
||||
classOptionsLoaded.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取年级班级选项失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.value = {
|
||||
name: props.user?.name || '',
|
||||
grade: props.user?.grade || '',
|
||||
class: props.user?.class || '',
|
||||
remark: props.user?.remark || ''
|
||||
}
|
||||
rejectReason.value = ''
|
||||
error.value = ''
|
||||
showRejectConfirm.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
resetForm()
|
||||
void fetchClassOptions()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const closeModal = () => {
|
||||
if (loading.value) return
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!props.user) return
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await $fetch(`/api/admin/users/${props.user.id}/approval`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
action: 'approve',
|
||||
name: form.value.name.trim(),
|
||||
grade: form.value.grade,
|
||||
class: form.value.class,
|
||||
remark: form.value.remark.trim()
|
||||
}
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
emit('success', { action: 'approve', user: props.user })
|
||||
emit('close')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = localizeServerError(err, locale.value.approval?.approveFailed)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = () => {
|
||||
if (!props.user) return
|
||||
error.value = ''
|
||||
showRejectConfirm.value = true
|
||||
}
|
||||
|
||||
const confirmReject = async () => {
|
||||
if (!props.user) return
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await $fetch(`/api/admin/users/${props.user.id}/approval`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
action: 'reject',
|
||||
reason: rejectReason.value.trim()
|
||||
}
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
showRejectConfirm.value = false
|
||||
emit('success', { action: 'reject', user: props.user })
|
||||
emit('close')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = localizeServerError(err, locale.value.approval?.rejectFailed)
|
||||
showRejectConfirm.value = false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-fade-enter-active,
|
||||
.modal-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-fade-enter-from,
|
||||
.modal-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user