初始版本
This commit is contained in:
516
app/pages/console/account/index.vue
Normal file
516
app/pages/console/account/index.vue
Normal file
@@ -0,0 +1,516 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="账号信息" :sub-title="pageSubTitle" />
|
||||
|
||||
<a-spin :spinning="loading" tip="加载中...">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :bordered="false" class="card" title="基本资料">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 可点击更换头像 -->
|
||||
<div class="avatar-wrapper" @click="triggerAvatarUpload" :title="'点击更换头像'">
|
||||
<a-avatar :size="56" :src="avatarPreview || avatarUrl">
|
||||
<template v-if="!avatarPreview && !avatarUrl" #icon>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-avatar>
|
||||
<div class="avatar-overlay">
|
||||
<a-spin v-if="avatarUploading" :size="'small'" />
|
||||
<CameraOutlined v-else />
|
||||
</div>
|
||||
<input
|
||||
ref="avatarInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
@change="onAvatarFileChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-base font-semibold text-gray-900">
|
||||
{{ user?.nickname || user?.username || '未命名用户' }}
|
||||
</div>
|
||||
<div class="text-gray-500">
|
||||
{{ user?.phone || (user as any)?.mobile || user?.email || '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="用户ID">{{ user?.userId ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="账号">{{ user?.username ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="昵称">{{ user?.nickname ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="手机号">{{ user?.phone || (user as any)?.mobile || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="邮箱">{{ user?.email ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="租户ID">{{ user?.tenantId ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="租户名称">{{ user?.tenantName ?? '-' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<a-button @click="reload" :loading="loading">刷新</a-button>
|
||||
<a-button type="primary" :disabled="!user" @click="openEditUser">编辑</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12" v-if="isCompanyUser">
|
||||
<a-card :bordered="false" class="card" title="企业信息">
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="企业ID">{{ company?.companyId ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="企业简称">{{ company?.shortName ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="企业全称">{{ company?.companyName ?? company?.tenantName ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="绑定域名">{{ company?.domain ?? company?.freeDomain ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="联系电话">{{ company?.phone ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="邮箱">{{ company?.email ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="地址">
|
||||
{{ companyAddress || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="实名认证">
|
||||
<a-tag v-if="company?.authentication" color="green">已认证</a-tag>
|
||||
<a-tag v-else color="default">未认证</a-tag>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<a-button @click="reload" :loading="loading">刷新</a-button>
|
||||
<a-button type="primary" :disabled="!company" @click="openEditCompany">编辑</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
</a-spin>
|
||||
|
||||
<a-modal
|
||||
v-model:open="editUserOpen"
|
||||
title="编辑基本资料"
|
||||
:confirm-loading="savingUser"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
@ok="submitUser"
|
||||
>
|
||||
<a-form ref="userFormRef" layout="vertical" :model="userForm" :rules="userRules">
|
||||
<a-form-item label="头像" name="avatarUrl">
|
||||
<div class="modal-avatar-wrap">
|
||||
<div class="modal-avatar-btn" @click="triggerAvatarUpload">
|
||||
<a-avatar :size="64" :src="avatarPreview || avatarUrl">
|
||||
<template v-if="!avatarPreview && !avatarUrl" #icon><UserOutlined /></template>
|
||||
</a-avatar>
|
||||
<div class="modal-avatar-overlay">
|
||||
<a-spin v-if="avatarUploading" :size="'small'" />
|
||||
<CameraOutlined v-else />
|
||||
</div>
|
||||
</div>
|
||||
<span class="modal-avatar-hint">点击头像更换图片</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="昵称" name="nickname">
|
||||
<a-input v-model:value="userForm.nickname" placeholder="请输入昵称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="邮箱" name="email">
|
||||
<a-input v-model:value="userForm.email" placeholder="例如:name@example.com" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="editCompanyOpen"
|
||||
title="编辑企业信息"
|
||||
:confirm-loading="savingCompany"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
@ok="submitCompany"
|
||||
>
|
||||
<a-form ref="companyFormRef" layout="vertical" :model="companyForm" :rules="companyRules">
|
||||
<a-form-item label="企业简称" name="shortName">
|
||||
<a-input v-model:value="companyForm.shortName" placeholder="例如:某某科技" />
|
||||
</a-form-item>
|
||||
<a-form-item label="企业全称" name="companyName">
|
||||
<a-input v-model:value="companyForm.companyName" placeholder="例如:某某科技有限公司" />
|
||||
</a-form-item>
|
||||
<a-form-item label="绑定域名" name="domain">
|
||||
<a-input v-model:value="companyForm.domain" placeholder="例如:example.com" />
|
||||
</a-form-item>
|
||||
<a-form-item label="联系电话" name="phone">
|
||||
<a-input v-model:value="companyForm.phone" placeholder="请输入联系电话" />
|
||||
</a-form-item>
|
||||
<a-form-item label="邮箱" name="email">
|
||||
<a-input v-model:value="companyForm.email" placeholder="例如:service@example.com" />
|
||||
</a-form-item>
|
||||
<a-form-item label="地址" name="address">
|
||||
<a-textarea v-model:value="companyForm.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
|
||||
</a-form-item>
|
||||
<a-form-item label="发票抬头" name="invoiceHeader">
|
||||
<a-input v-model:value="companyForm.invoiceHeader" placeholder="用于开票" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { message, type FormInstance } from 'ant-design-vue'
|
||||
import { UserOutlined, CameraOutlined } from '@ant-design/icons-vue'
|
||||
import { getTenantInfo, getUserInfo, updateLoginUser } from '@/api/layout'
|
||||
import { updateUser } from '@/api/system/user'
|
||||
import { uploadOssAvatar } from '@/api/system/file'
|
||||
import { updateCompany } from '@/api/system/company'
|
||||
import type { Company } from '@/api/system/company/model'
|
||||
import type { User } from '@/api/system/user/model'
|
||||
|
||||
definePageMeta({ layout: 'console', ssr: false })
|
||||
|
||||
const loading = ref(false)
|
||||
const savingUser = ref(false)
|
||||
const savingCompany = ref(false)
|
||||
|
||||
const user = ref<User | null>(null)
|
||||
const company = ref<Company | null>(null)
|
||||
|
||||
// ===== 头像上传 =====
|
||||
const avatarInputRef = ref<HTMLInputElement | null>(null)
|
||||
const avatarUploading = ref(false)
|
||||
const avatarPreview = ref('') // 本地预览 / 上传后的新 URL
|
||||
|
||||
function triggerAvatarUpload() {
|
||||
avatarInputRef.value?.click()
|
||||
}
|
||||
|
||||
async function onAvatarFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// 类型校验
|
||||
if (!file.type.startsWith('image/')) {
|
||||
message.error('请选择图片文件')
|
||||
return
|
||||
}
|
||||
// 大小校验 5MB
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
message.error('图片大小不能超过 5MB')
|
||||
return
|
||||
}
|
||||
|
||||
// 本地预览
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
avatarPreview.value = ev.target?.result as string
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
|
||||
// 上传
|
||||
avatarUploading.value = true
|
||||
try {
|
||||
const userId = user.value?.userId
|
||||
const ext = file.name.split('.').pop() || 'jpg'
|
||||
const fileName = `avatar_${userId}_${Date.now()}.${ext}`
|
||||
const record = await uploadOssAvatar(file, fileName)
|
||||
const url = (record?.url || record?.downloadUrl || '').trim()
|
||||
if (!url) throw new Error('上传成功但未返回文件地址')
|
||||
|
||||
// 立即保存到用户信息
|
||||
await updateUser({ userId, avatarUrl: url } as User)
|
||||
avatarPreview.value = url
|
||||
userForm.avatarUrl = url
|
||||
if (user.value) user.value.avatarUrl = url
|
||||
message.success('头像更新成功')
|
||||
} catch (e) {
|
||||
avatarPreview.value = ''
|
||||
message.error(e instanceof Error ? e.message : '上传失败,请重试')
|
||||
} finally {
|
||||
avatarUploading.value = false
|
||||
// 清空 input,允许重复选同一文件
|
||||
if (avatarInputRef.value) avatarInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 是否为企业或开发者用户(非普通用户)
|
||||
const isCompanyUser = computed(() => {
|
||||
const type = user.value?.type
|
||||
return type === 1 || type === 2
|
||||
})
|
||||
|
||||
// 动态副标题
|
||||
const pageSubTitle = computed(() => {
|
||||
if (isCompanyUser.value) {
|
||||
return '基本资料与企业信息'
|
||||
}
|
||||
return '基本资料'
|
||||
})
|
||||
|
||||
const avatarUrl = computed(() => {
|
||||
const candidate =
|
||||
user.value?.avatarUrl ||
|
||||
user.value?.avatar ||
|
||||
user.value?.merchantAvatar ||
|
||||
user.value?.logo ||
|
||||
''
|
||||
if (typeof candidate !== 'string') return ''
|
||||
const normalized = candidate.trim()
|
||||
if (!normalized || normalized === 'null' || normalized === 'undefined') return ''
|
||||
return normalized
|
||||
})
|
||||
|
||||
const companyAddress = computed(() => {
|
||||
const parts = [company.value?.province, company.value?.city, company.value?.region, company.value?.address]
|
||||
.map((v) => (typeof v === 'string' ? v.trim() : ''))
|
||||
.filter(Boolean)
|
||||
return parts.join(' ')
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [uRes, cRes] = await Promise.allSettled([getUserInfo(), getTenantInfo()])
|
||||
if (uRes.status === 'fulfilled') {
|
||||
user.value = uRes.value
|
||||
} else {
|
||||
console.error(uRes.reason)
|
||||
message.error(uRes.reason instanceof Error ? uRes.reason.message : '获取用户信息失败')
|
||||
}
|
||||
|
||||
if (cRes.status === 'fulfilled') {
|
||||
company.value = cRes.value
|
||||
} else {
|
||||
console.error(cRes.reason)
|
||||
message.error(cRes.reason instanceof Error ? cRes.reason.message : '获取企业信息失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
await load()
|
||||
}
|
||||
|
||||
const editUserOpen = ref(false)
|
||||
const userFormRef = ref<FormInstance>()
|
||||
const userForm = reactive<{ nickname?: string; email?: string; avatarUrl?: string }>({
|
||||
nickname: '',
|
||||
email: '',
|
||||
avatarUrl: ''
|
||||
})
|
||||
const userRules = reactive({
|
||||
nickname: [{ required: true, message: '请输入昵称', type: 'string' }],
|
||||
email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }]
|
||||
})
|
||||
|
||||
function openEditUser() {
|
||||
if (!user.value) return
|
||||
userForm.nickname = user.value.nickname ?? ''
|
||||
userForm.email = user.value.email ?? ''
|
||||
userForm.avatarUrl = user.value.avatarUrl ?? avatarUrl.value ?? ''
|
||||
editUserOpen.value = true
|
||||
}
|
||||
|
||||
async function submitUser() {
|
||||
if (!user.value) return
|
||||
try {
|
||||
await userFormRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const nickname = (userForm.nickname ?? '').trim()
|
||||
if (!nickname) {
|
||||
message.error('请输入昵称')
|
||||
return
|
||||
}
|
||||
const email = (userForm.email ?? '').trim()
|
||||
const avatar = (userForm.avatarUrl ?? '').trim()
|
||||
savingUser.value = true
|
||||
try {
|
||||
await updateLoginUser({
|
||||
userId: user.value.userId,
|
||||
nickname,
|
||||
email: email || undefined,
|
||||
avatarUrl: avatar || undefined
|
||||
} as User)
|
||||
message.success('保存成功')
|
||||
editUserOpen.value = false
|
||||
await load()
|
||||
} catch (e: unknown) {
|
||||
console.error(e)
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
savingUser.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const editCompanyOpen = ref(false)
|
||||
const companyFormRef = ref<FormInstance>()
|
||||
const companyForm = reactive<{
|
||||
companyId?: number
|
||||
shortName?: string
|
||||
companyName?: string
|
||||
domain?: string
|
||||
phone?: string
|
||||
email?: string
|
||||
address?: string
|
||||
invoiceHeader?: string
|
||||
}>({
|
||||
companyId: undefined,
|
||||
shortName: '',
|
||||
companyName: '',
|
||||
domain: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
invoiceHeader: ''
|
||||
})
|
||||
|
||||
const companyRules = reactive({
|
||||
companyName: [{ required: true, message: '请输入企业全称', type: 'string' }],
|
||||
email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }],
|
||||
phone: [
|
||||
{
|
||||
validator: (_rule: unknown, value: unknown) => {
|
||||
const normalized = typeof value === 'string' ? value.trim() : ''
|
||||
if (!normalized) return Promise.resolve()
|
||||
const mobileReg = /^1[3-9]\d{9}$/
|
||||
if (mobileReg.test(normalized)) return Promise.resolve()
|
||||
return Promise.reject(new Error('手机号格式不正确'))
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
function openEditCompany() {
|
||||
if (!company.value) return
|
||||
companyForm.companyId = company.value.companyId
|
||||
companyForm.shortName = company.value.shortName ?? ''
|
||||
companyForm.companyName = company.value.companyName ?? company.value.tenantName ?? ''
|
||||
companyForm.domain = company.value.domain ?? ''
|
||||
companyForm.phone = company.value.phone ?? ''
|
||||
companyForm.email = company.value.email ?? ''
|
||||
companyForm.address = company.value.address ?? ''
|
||||
companyForm.invoiceHeader = company.value.invoiceHeader ?? ''
|
||||
editCompanyOpen.value = true
|
||||
}
|
||||
|
||||
async function submitCompany() {
|
||||
if (!company.value) return
|
||||
try {
|
||||
await companyFormRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!companyForm.companyId) {
|
||||
message.error('企业ID缺失,无法保存')
|
||||
return
|
||||
}
|
||||
const companyName = (companyForm.companyName ?? '').trim()
|
||||
if (!companyName) {
|
||||
message.error('请输入企业全称')
|
||||
return
|
||||
}
|
||||
savingCompany.value = true
|
||||
try {
|
||||
const domain = (companyForm.domain ?? '').trim()
|
||||
const phone = (companyForm.phone ?? '').trim()
|
||||
const email = (companyForm.email ?? '').trim()
|
||||
const address = (companyForm.address ?? '').trim()
|
||||
const invoiceHeader = (companyForm.invoiceHeader ?? '').trim()
|
||||
await updateCompany({
|
||||
companyId: companyForm.companyId,
|
||||
shortName: (companyForm.shortName ?? '').trim() || undefined,
|
||||
companyName,
|
||||
domain: domain || undefined,
|
||||
phone: phone || undefined,
|
||||
email: email || undefined,
|
||||
address: address || undefined,
|
||||
invoiceHeader: invoiceHeader || undefined
|
||||
} as Company)
|
||||
message.success('保存成功')
|
||||
editCompanyOpen.value = false
|
||||
await load()
|
||||
} catch (e: unknown) {
|
||||
console.error(e)
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
savingCompany.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* ===== 头像上传 ===== */
|
||||
.avatar-wrapper {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.avatar-wrapper:hover .avatar-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 弹窗内头像 */
|
||||
.modal-avatar-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-avatar-btn {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-avatar-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.modal-avatar-btn:hover .modal-avatar-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal-avatar-hint {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
</style>
|
||||
262
app/pages/console/account/kyc.vue
Normal file
262
app/pages/console/account/kyc.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="实名认证" sub-title="基于阿里云实人认证的身份核验">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-tag v-if="verifyStatus !== 'none'" :color="statusTagColor">{{ statusText }}</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<!-- 已认证状态 -->
|
||||
<a-card v-if="verifyStatus === 'approved'" :bordered="false" class="card">
|
||||
<a-result status="success" title="已完成实名认证" sub-title="您的身份信息已通过核验">
|
||||
<template #extra>
|
||||
<a-descriptions :column="1" size="small" bordered class="max-w-md mx-auto">
|
||||
<a-descriptions-item label="认证类型">个人</a-descriptions-item>
|
||||
<a-descriptions-item label="姓名">{{ maskedRealName }}</a-descriptions-item>
|
||||
<a-descriptions-item label="身份证号">{{ maskedIdCard }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</template>
|
||||
</a-result>
|
||||
</a-card>
|
||||
|
||||
<!-- 未认证 / 认证表单 -->
|
||||
<a-card v-else :bordered="false" class="card">
|
||||
<a-alert
|
||||
show-icon
|
||||
:type="verifyStatus === 'rejected' ? 'error' : 'info'"
|
||||
:message="alertMessage"
|
||||
:description="alertDescription"
|
||||
class="mb-6"
|
||||
/>
|
||||
|
||||
<div class="max-w-lg">
|
||||
<a-form
|
||||
ref="formRef"
|
||||
layout="vertical"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
:disabled="submitting"
|
||||
@finish="handleVerify"
|
||||
>
|
||||
<a-form-item label="真实姓名" name="realName">
|
||||
<a-input
|
||||
v-model:value="form.realName"
|
||||
placeholder="请输入身份证上的真实姓名"
|
||||
size="large"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="身份证号码" name="idCard">
|
||||
<a-input
|
||||
v-model:value="form.idCard"
|
||||
placeholder="请输入18位身份证号码"
|
||||
size="large"
|
||||
:maxlength="18"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
size="large"
|
||||
:loading="submitting"
|
||||
:disabled="submitting"
|
||||
>
|
||||
{{ submitting ? '核验中...' : '开始核验' }}
|
||||
</a-button>
|
||||
<a-button size="large" @click="resetForm" :disabled="submitting">重置</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-alert
|
||||
type="warning"
|
||||
show-icon
|
||||
message="温馨提示"
|
||||
description="实名认证基于阿里云实人认证服务,您的姓名与身份证号将进行二要素核验。请确保信息真实有效,核验通过后不可更改。"
|
||||
class="mt-6"
|
||||
/>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { message, type FormInstance } from 'ant-design-vue'
|
||||
import { getUserInfo } from '@/api/layout'
|
||||
import { verifyIdCard } from '@/api/system/idVerification'
|
||||
import { listUserVerify } from '@/api/system/userVerify'
|
||||
import type { UserVerify } from '@/api/system/userVerify/model'
|
||||
|
||||
definePageMeta({ layout: 'console' })
|
||||
|
||||
type VerifyStatus = 'none' | 'approved' | 'rejected'
|
||||
|
||||
const verifyStatus = ref<VerifyStatus>('none')
|
||||
const submitting = ref(false)
|
||||
const currentRecord = ref<UserVerify | null>(null)
|
||||
|
||||
// 表单
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = reactive({
|
||||
realName: '',
|
||||
idCard: ''
|
||||
})
|
||||
|
||||
const idCardRegex = /^[1-9]\d{5}(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/
|
||||
|
||||
const rules = {
|
||||
realName: [
|
||||
{ required: true, message: '请输入真实姓名', type: 'string' },
|
||||
{ min: 2, max: 20, message: '姓名长度2-20个字符', type: 'string' }
|
||||
],
|
||||
idCard: [
|
||||
{ required: true, message: '请输入身份证号码', type: 'string' },
|
||||
{
|
||||
validator: (_rule: unknown, value: string) => {
|
||||
if (!value) return Promise.resolve()
|
||||
const normalized = value.trim().toUpperCase()
|
||||
if (!idCardRegex.test(normalized)) {
|
||||
return Promise.reject(new Error('请输入正确的18位身份证号码'))
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 状态展示
|
||||
const statusText = computed(() => {
|
||||
if (verifyStatus.value === 'approved') return '已认证'
|
||||
if (verifyStatus.value === 'rejected') return '核验失败'
|
||||
return '未认证'
|
||||
})
|
||||
|
||||
const statusTagColor = computed(() => {
|
||||
if (verifyStatus.value === 'approved') return 'green'
|
||||
if (verifyStatus.value === 'rejected') return 'red'
|
||||
return 'default'
|
||||
})
|
||||
|
||||
const alertMessage = computed(() => {
|
||||
if (verifyStatus.value === 'rejected') {
|
||||
return '身份核验未通过'
|
||||
}
|
||||
return '请填写身份信息完成实名认证'
|
||||
})
|
||||
|
||||
const alertDescription = computed(() => {
|
||||
if (verifyStatus.value === 'rejected') {
|
||||
const reason = (currentRecord.value?.description || '').trim()
|
||||
return reason
|
||||
? `上一次核验失败原因:${reason}。请核实姓名和身份证号后重新提交。`
|
||||
: '请核实姓名和身份证号后重新提交。'
|
||||
}
|
||||
return '请输入您的真实姓名和身份证号码,系统将通过阿里云实人认证进行二要素核验。'
|
||||
})
|
||||
|
||||
// 脱敏展示
|
||||
const maskedRealName = computed(() => {
|
||||
const name = currentRecord.value?.realName || ''
|
||||
if (name.length <= 1) return name
|
||||
return name[0] + '*'.repeat(name.length - 1)
|
||||
})
|
||||
|
||||
const maskedIdCard = computed(() => {
|
||||
const id = currentRecord.value?.idCard || ''
|
||||
if (id.length < 8) return id
|
||||
return id.substring(0, 4) + '**********' + id.substring(id.length - 4)
|
||||
})
|
||||
|
||||
// 加载已有认证状态
|
||||
async function loadVerifyStatus() {
|
||||
try {
|
||||
const user = await getUserInfo()
|
||||
const userId = user.userId
|
||||
if (!userId) return
|
||||
|
||||
const list = await listUserVerify({ userId })
|
||||
const latest = Array.isArray(list)
|
||||
? [...list].sort((a, b) => Number(b.id ?? 0) - Number(a.id ?? 0))[0]
|
||||
: undefined
|
||||
|
||||
if (latest) {
|
||||
currentRecord.value = latest
|
||||
verifyStatus.value = latest.status === 1 ? 'approved' : 'rejected'
|
||||
}
|
||||
} catch {
|
||||
// 静默处理,默认显示未认证
|
||||
}
|
||||
}
|
||||
|
||||
// 提交核验
|
||||
async function handleVerify() {
|
||||
const realName = form.realName.trim()
|
||||
const idCard = form.idCard.trim().toUpperCase()
|
||||
|
||||
if (!realName || !idCard) {
|
||||
message.warning('请填写完整的身份信息')
|
||||
return
|
||||
}
|
||||
|
||||
if (!idCardRegex.test(idCard)) {
|
||||
message.warning('请输入正确的18位身份证号码')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const result = await verifyIdCard(realName, idCard)
|
||||
|
||||
if (result.isMatch) {
|
||||
verifyStatus.value = 'approved'
|
||||
currentRecord.value = {
|
||||
realName,
|
||||
idCard,
|
||||
status: 1,
|
||||
type: 0
|
||||
}
|
||||
message.success('实名认证通过!您的身份信息已核验成功。')
|
||||
} else {
|
||||
verifyStatus.value = 'rejected'
|
||||
currentRecord.value = {
|
||||
realName,
|
||||
idCard,
|
||||
status: 2,
|
||||
type: 0,
|
||||
description: result.message || '身份信息不一致'
|
||||
}
|
||||
message.error(result.message || '身份信息不一致,请核实后重新填写')
|
||||
}
|
||||
} catch (e) {
|
||||
const errMsg = e instanceof Error ? e.message : '核验服务异常,请稍后重试'
|
||||
message.error(errMsg)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.realName = ''
|
||||
form.idCard = ''
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadVerifyStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
442
app/pages/console/account/members.vue
Normal file
442
app/pages/console/account/members.vue
Normal file
@@ -0,0 +1,442 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="成员管理" sub-title="成员邀请、角色与权限">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-input
|
||||
v-model:value="keywords"
|
||||
allow-clear
|
||||
placeholder="搜索账号/昵称/手机号"
|
||||
class="w-64"
|
||||
@press-enter="doSearch"
|
||||
/>
|
||||
<a-button :loading="loading" @click="reload">刷新</a-button>
|
||||
<a-button type="primary" @click="openInvite">邀请成员</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :bordered="false" class="card" title="成员配额">
|
||||
<a-descriptions :column="2" size="small" bordered>
|
||||
<a-descriptions-item label="成员上限">
|
||||
{{ company?.members ?? '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="当前人数">
|
||||
{{ company?.users ?? '-' }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<div class="mt-4 text-sm text-gray-500">
|
||||
成员数据来自系统用户(租户维度),可进行邀请、禁用、重置密码与角色设置。
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :bordered="false" class="card" title="快速操作">
|
||||
<a-space wrap>
|
||||
<a-button @click="openInvite">邀请成员</a-button>
|
||||
<a-button @click="reload">刷新列表</a-button>
|
||||
</a-space>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card :bordered="false" class="card">
|
||||
<a-alert v-if="error" class="mb-4" show-icon type="error" :message="error" />
|
||||
|
||||
<a-table
|
||||
:data-source="list"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
size="middle"
|
||||
:row-key="(r: any) => r.userId ?? r.username"
|
||||
>
|
||||
<a-table-column title="ID" data-index="userId" width="90" />
|
||||
<a-table-column title="账号" data-index="username" width="180" />
|
||||
<a-table-column title="昵称" data-index="nickname" width="160" />
|
||||
<a-table-column title="手机号" data-index="phone" width="140" />
|
||||
<a-table-column title="角色" key="roleName" width="160">
|
||||
<template #default="{ record }">
|
||||
<span>{{ resolveRoleName(record) }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="状态" key="status" width="120">
|
||||
<template #default="{ record }">
|
||||
<a-tag v-if="record.status === 0 || record.status === undefined" color="green">正常</a-tag>
|
||||
<a-tag v-else color="default">冻结</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="创建时间" data-index="createTime" width="180" />
|
||||
<a-table-column title="操作" key="actions" width="260" fixed="right">
|
||||
<template #default="{ record }">
|
||||
<a-space>
|
||||
<a-button size="small" @click="openRole(record)" :disabled="!record.userId">设置角色</a-button>
|
||||
<a-button size="small" @click="openReset(record)" :disabled="!record.userId">重置密码</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
:loading="busyUserId === record.userId"
|
||||
@click="toggleStatus(record)"
|
||||
:disabled="!record.userId"
|
||||
>
|
||||
{{ record.status === 1 ? '解冻' : '冻结' }}
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="确定删除该成员?"
|
||||
ok-text="删除"
|
||||
cancel-text="取消"
|
||||
@confirm="remove(record)"
|
||||
>
|
||||
<a-button size="small" danger :disabled="!record.userId">删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
|
||||
<div class="mt-4 flex items-center justify-end">
|
||||
<a-pagination
|
||||
:current="page"
|
||||
:page-size="limit"
|
||||
:total="total"
|
||||
show-size-changer
|
||||
:page-size-options="['10', '20', '50', '100']"
|
||||
@change="onPageChange"
|
||||
@show-size-change="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
v-model:open="inviteOpen"
|
||||
title="邀请成员"
|
||||
ok-text="创建账号"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="inviting"
|
||||
@ok="submitInvite"
|
||||
>
|
||||
<a-form ref="inviteFormRef" layout="vertical" :model="inviteForm" :rules="inviteRules">
|
||||
<a-form-item label="账号" name="username">
|
||||
<a-input v-model:value="inviteForm.username" placeholder="例如:tom / tom@example.com" />
|
||||
</a-form-item>
|
||||
<a-form-item label="昵称" name="nickname">
|
||||
<a-input v-model:value="inviteForm.nickname" placeholder="例如:Tom" />
|
||||
</a-form-item>
|
||||
<a-form-item label="手机号" name="phone">
|
||||
<a-input v-model:value="inviteForm.phone" placeholder="例如:13800000000" />
|
||||
</a-form-item>
|
||||
<a-form-item label="角色" name="roleId">
|
||||
<a-select
|
||||
v-model:value="inviteForm.roleId"
|
||||
placeholder="请选择角色"
|
||||
allow-clear
|
||||
:options="roleOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="初始密码" name="password">
|
||||
<a-input-password v-model:value="inviteForm.password" placeholder="请输入初始密码" />
|
||||
</a-form-item>
|
||||
<a-form-item label="确认密码" name="password2">
|
||||
<a-input-password v-model:value="inviteForm.password2" placeholder="再次输入密码" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-alert
|
||||
class="mt-2"
|
||||
type="info"
|
||||
show-icon
|
||||
message="创建后可在本页进行冻结/解冻、重置密码与角色设置。"
|
||||
/>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="roleOpen"
|
||||
title="设置角色"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="savingRole"
|
||||
@ok="submitRole"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="成员">
|
||||
<a-input :value="selectedUser?.nickname || selectedUser?.username || ''" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="角色">
|
||||
<a-select v-model:value="selectedRoleId" placeholder="请选择角色" :options="roleOptions" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="resetOpen"
|
||||
title="重置密码"
|
||||
ok-text="确认重置"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="resetting"
|
||||
@ok="submitReset"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="成员">
|
||||
<a-input :value="selectedUser?.nickname || selectedUser?.username || ''" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="新密码">
|
||||
<a-input-password v-model:value="resetPassword" placeholder="请输入新密码" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-alert class="mt-2" type="warning" show-icon message="重置后请尽快通知成员修改密码。" />
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { message, type FormInstance } from 'ant-design-vue'
|
||||
import { getTenantInfo } from '@/api/layout'
|
||||
import { listRoles } from '@/api/system/role'
|
||||
import { addUser, pageUsers, removeUser, updateUserPassword, updateUserStatus } from '@/api/system/user'
|
||||
import { addUserRole, listUserRole, updateUserRole } from '@/api/system/userRole'
|
||||
import type { Company } from '@/api/system/company/model'
|
||||
import type { Role } from '@/api/system/role/model'
|
||||
import type { User } from '@/api/system/user/model'
|
||||
|
||||
definePageMeta({ layout: 'console' })
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string>('')
|
||||
|
||||
const company = ref<Company | null>(null)
|
||||
const roles = ref<Role[]>([])
|
||||
|
||||
const list = ref<User[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const keywords = ref('')
|
||||
|
||||
const roleOptions = computed(() =>
|
||||
roles.value.map((r) => ({ label: r.roleName ?? String(r.roleId ?? ''), value: r.roleId }))
|
||||
)
|
||||
|
||||
function resolveRoleName(user: User) {
|
||||
const direct = typeof user.roleName === 'string' ? user.roleName.trim() : ''
|
||||
if (direct) return direct
|
||||
const hit = roles.value.find((r) => r.roleId === user.roleId)
|
||||
return hit?.roleName ?? '-'
|
||||
}
|
||||
|
||||
async function loadCompany() {
|
||||
try {
|
||||
company.value = await getTenantInfo()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRolesOnce() {
|
||||
if (roles.value.length) return
|
||||
try {
|
||||
roles.value = await listRoles()
|
||||
} catch {
|
||||
roles.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMembers() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await pageUsers({
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
keywords: keywords.value || undefined
|
||||
})
|
||||
list.value = res?.list ?? []
|
||||
total.value = res?.count ?? 0
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '成员列表加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
await Promise.all([loadCompany(), loadRolesOnce(), loadMembers()])
|
||||
}
|
||||
|
||||
function doSearch() {
|
||||
page.value = 1
|
||||
loadMembers()
|
||||
}
|
||||
|
||||
function onPageChange(nextPage: number) {
|
||||
page.value = nextPage
|
||||
loadMembers()
|
||||
}
|
||||
|
||||
function onPageSizeChange(_current: number, nextSize: number) {
|
||||
limit.value = nextSize
|
||||
page.value = 1
|
||||
loadMembers()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await reload()
|
||||
})
|
||||
|
||||
const busyUserId = ref<number | null>(null)
|
||||
async function toggleStatus(user: User) {
|
||||
if (!user.userId) return
|
||||
const next = user.status === 1 ? 0 : 1
|
||||
busyUserId.value = user.userId
|
||||
try {
|
||||
await updateUserStatus(user.userId, next)
|
||||
message.success(next === 0 ? '已解冻' : '已冻结')
|
||||
await loadMembers()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败')
|
||||
} finally {
|
||||
busyUserId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(user: User) {
|
||||
if (!user.userId) return
|
||||
busyUserId.value = user.userId
|
||||
try {
|
||||
await removeUser(user.userId)
|
||||
message.success('已删除')
|
||||
await loadMembers()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
} finally {
|
||||
busyUserId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const inviteOpen = ref(false)
|
||||
const inviting = ref(false)
|
||||
const inviteFormRef = ref<FormInstance>()
|
||||
const inviteForm = reactive<{ username: string; nickname: string; phone: string; roleId?: number; password: string; password2: string }>({
|
||||
username: '',
|
||||
nickname: '',
|
||||
phone: '',
|
||||
roleId: undefined,
|
||||
password: '',
|
||||
password2: ''
|
||||
})
|
||||
const inviteRules = reactive({
|
||||
username: [{ required: true, type: 'string', message: '请输入账号' }],
|
||||
nickname: [{ required: true, type: 'string', message: '请输入昵称' }],
|
||||
password: [{ required: true, type: 'string', message: '请输入初始密码' }],
|
||||
password2: [{ required: true, type: 'string', message: '请再次输入密码' }]
|
||||
})
|
||||
|
||||
function openInvite() {
|
||||
inviteForm.username = ''
|
||||
inviteForm.nickname = ''
|
||||
inviteForm.phone = ''
|
||||
inviteForm.roleId = undefined
|
||||
inviteForm.password = ''
|
||||
inviteForm.password2 = ''
|
||||
inviteOpen.value = true
|
||||
}
|
||||
|
||||
async function submitInvite() {
|
||||
try {
|
||||
await inviteFormRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (inviteForm.password !== inviteForm.password2) {
|
||||
message.error('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
inviting.value = true
|
||||
try {
|
||||
await addUser({
|
||||
username: inviteForm.username.trim(),
|
||||
nickname: inviteForm.nickname.trim(),
|
||||
phone: inviteForm.phone.trim() || undefined,
|
||||
password: inviteForm.password,
|
||||
password2: inviteForm.password2,
|
||||
roleId: inviteForm.roleId
|
||||
})
|
||||
message.success('成员已创建')
|
||||
inviteOpen.value = false
|
||||
await loadMembers()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败')
|
||||
} finally {
|
||||
inviting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const roleOpen = ref(false)
|
||||
const savingRole = ref(false)
|
||||
const selectedUser = ref<User | null>(null)
|
||||
const selectedRoleId = ref<number | undefined>(undefined)
|
||||
function openRole(user: User) {
|
||||
selectedUser.value = user
|
||||
selectedRoleId.value = user.roleId
|
||||
roleOpen.value = true
|
||||
}
|
||||
|
||||
async function submitRole() {
|
||||
if (!selectedUser.value?.userId) return
|
||||
if (!selectedRoleId.value) {
|
||||
message.error('请选择角色')
|
||||
return
|
||||
}
|
||||
savingRole.value = true
|
||||
try {
|
||||
const mappings = await listUserRole({ userId: selectedUser.value.userId })
|
||||
const first = Array.isArray(mappings) ? mappings[0] : undefined
|
||||
if (first?.id) {
|
||||
await updateUserRole({ ...first, roleId: selectedRoleId.value })
|
||||
} else {
|
||||
await addUserRole({ userId: selectedUser.value.userId, roleId: selectedRoleId.value })
|
||||
}
|
||||
message.success('角色已更新')
|
||||
roleOpen.value = false
|
||||
await loadMembers()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '更新失败')
|
||||
} finally {
|
||||
savingRole.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetOpen = ref(false)
|
||||
const resetting = ref(false)
|
||||
const resetPassword = ref('')
|
||||
function openReset(user: User) {
|
||||
selectedUser.value = user
|
||||
resetPassword.value = ''
|
||||
resetOpen.value = true
|
||||
}
|
||||
|
||||
async function submitReset() {
|
||||
if (!selectedUser.value?.userId) return
|
||||
const pwd = resetPassword.value.trim()
|
||||
if (!pwd) {
|
||||
message.error('请输入新密码')
|
||||
return
|
||||
}
|
||||
resetting.value = true
|
||||
try {
|
||||
await updateUserPassword(selectedUser.value.userId, pwd)
|
||||
message.success('密码已重置')
|
||||
resetOpen.value = false
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '重置失败')
|
||||
} finally {
|
||||
resetting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
377
app/pages/console/account/security.vue
Normal file
377
app/pages/console/account/security.vue
Normal file
@@ -0,0 +1,377 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="账号安全" sub-title="密码、登录设备与安全设置">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button danger @click="logout">退出登录</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<!-- 安全概览 -->
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :sm="8" v-for="s in securityOverview" :key="s.label">
|
||||
<div class="security-overview-card" :class="s.colorClass">
|
||||
<div class="overview-icon">{{ s.icon }}</div>
|
||||
<div class="overview-info">
|
||||
<div class="overview-value">{{ s.value }}</div>
|
||||
<div class="overview-label">{{ s.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :bordered="false" class="card" title="修改密码">
|
||||
<a-form ref="formRef" layout="vertical" :model="form" :rules="rules">
|
||||
<a-form-item label="原密码" name="oldPassword">
|
||||
<a-input-password v-model:value="form.oldPassword" placeholder="请输入原密码" />
|
||||
</a-form-item>
|
||||
<a-form-item label="新密码" name="password">
|
||||
<a-input-password v-model:value="form.password" placeholder="请输入新密码(至少 6 位)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="确认新密码" name="password2">
|
||||
<a-input-password v-model:value="form.password2" placeholder="再次输入新密码" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div class="mt-2 flex justify-end gap-2">
|
||||
<a-button @click="resetForm" :disabled="pending">重置</a-button>
|
||||
<a-button type="primary" :loading="pending" @click="submit">保存</a-button>
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
class="mt-4"
|
||||
show-icon
|
||||
type="info"
|
||||
message="修改密码后建议重新登录,以确保所有会话状态一致。"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :bordered="false" class="card" title="安全建议">
|
||||
<div class="security-tips">
|
||||
<div v-for="(tip, index) in securityTips" :key="index" class="tip-item">
|
||||
<div class="tip-icon" :class="tip.level">{{ tip.icon }}</div>
|
||||
<div class="tip-content">
|
||||
<div class="tip-title">{{ tip.title }}</div>
|
||||
<div class="tip-desc">{{ tip.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 登录日志 -->
|
||||
<a-card :bordered="false" class="card" title="最近登录记录">
|
||||
<template #extra>
|
||||
<a-button size="small" @click="loadLoginRecords">刷新</a-button>
|
||||
</template>
|
||||
<a-table
|
||||
:data-source="loginRecords"
|
||||
:loading="loginLoading"
|
||||
:pagination="{ pageSize: 5, showTotal: (t: number) => `共 ${t} 条` }"
|
||||
size="small"
|
||||
:row-key="(r: any) => r.id"
|
||||
>
|
||||
<a-table-column title="时间" data-index="createTime" width="180">
|
||||
<template #default="{ record }">
|
||||
{{ formatTime(record.createTime) }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="类型" key="loginType" width="120">
|
||||
<template #default="{ record }">
|
||||
<a-tag :color="loginTypeColor(record.loginType)" size="small">
|
||||
{{ loginTypeText(record.loginType) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="设备" key="device" width="160">
|
||||
<template #default="{ record }">
|
||||
<span class="text-sm">{{ record.device || record.os || '-' }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="浏览器" data-index="browser" width="120" />
|
||||
<a-table-column title="IP 地址" data-index="ip" width="140">
|
||||
<template #default="{ record }">
|
||||
<a-typography-text :copyable="{ text: record.ip || '', tooltips: ['复制', '已复制'] }">
|
||||
{{ record.ip || '-' }}
|
||||
</a-typography-text>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="备注" data-index="description" ellipsis />
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { message, type FormInstance } from 'ant-design-vue'
|
||||
import { updatePassword, getUserInfo } from '@/api/layout'
|
||||
import { removeToken } from '@/utils/token-util'
|
||||
import { clearAuthz } from '@/utils/permission'
|
||||
import { pageLoginRecords } from '@/api/system/login-record'
|
||||
import type { LoginRecord } from '@/api/system/login-record/model'
|
||||
|
||||
definePageMeta({ layout: 'console' })
|
||||
|
||||
// ─── 修改密码 ─────────────────────────────────────────────────
|
||||
const pending = ref(false)
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = reactive<{ oldPassword: string; password: string; password2: string }>({
|
||||
oldPassword: '',
|
||||
password: '',
|
||||
password2: ''
|
||||
})
|
||||
|
||||
const rules = reactive({
|
||||
oldPassword: [{ required: true, type: 'string', message: '请输入原密码' }],
|
||||
password: [
|
||||
{ required: true, type: 'string', message: '请输入新密码' },
|
||||
{ min: 6, type: 'string', message: '新密码至少 6 位', trigger: 'blur' }
|
||||
],
|
||||
password2: [{ required: true, type: 'string', message: '请再次输入新密码' }]
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
form.oldPassword = ''
|
||||
form.password = ''
|
||||
form.password2 = ''
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (form.password !== form.password2) {
|
||||
message.error('两次输入的新密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
pending.value = true
|
||||
try {
|
||||
await updatePassword({ oldPassword: form.oldPassword, password: form.password })
|
||||
message.success('密码修改成功')
|
||||
resetForm()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '密码修改失败')
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
removeToken()
|
||||
try {
|
||||
localStorage.removeItem('TenantId')
|
||||
localStorage.removeItem('UserId')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
clearAuthz()
|
||||
navigateTo('/login')
|
||||
}
|
||||
|
||||
// ─── 安全概览 ─────────────────────────────────────────────────
|
||||
const securityOverview = computed(() => [
|
||||
{
|
||||
icon: '🔐',
|
||||
label: '密码强度',
|
||||
value: '基础',
|
||||
colorClass: 'overview-warn',
|
||||
},
|
||||
{
|
||||
icon: '📱',
|
||||
label: '登录设备',
|
||||
value: `${loginRecords.value.length} 次登录`,
|
||||
colorClass: 'overview-info',
|
||||
},
|
||||
{
|
||||
icon: '⚠️',
|
||||
label: '异常登录',
|
||||
value: failedLogins.value > 0 ? `${failedLogins.value} 次` : '无',
|
||||
colorClass: failedLogins.value > 0 ? 'overview-danger' : 'overview-success',
|
||||
},
|
||||
])
|
||||
|
||||
// ─── 安全建议 ─────────────────────────────────────────────────
|
||||
const securityTips = [
|
||||
{
|
||||
icon: '🔑',
|
||||
level: 'level-info',
|
||||
title: '定期修改密码',
|
||||
desc: '建议每 3 个月更换一次密码,避免与其他平台重复使用。',
|
||||
},
|
||||
{
|
||||
icon: '🛡️',
|
||||
level: 'level-info',
|
||||
title: '使用强密码',
|
||||
desc: '密码至少 6 位,建议混合使用大小写字母、数字和特殊字符。',
|
||||
},
|
||||
{
|
||||
icon: '📱',
|
||||
level: 'level-warn',
|
||||
title: '关注登录记录',
|
||||
desc: '定期检查登录日志,如发现异常设备登录请立即修改密码。',
|
||||
},
|
||||
{
|
||||
icon: '🔐',
|
||||
level: 'level-info',
|
||||
title: '保护账号安全',
|
||||
desc: '不要将账号/密码分享给他人,如怀疑账号被盗用请立即修改密码并退出登录。',
|
||||
},
|
||||
]
|
||||
|
||||
// ─── 登录日志 ─────────────────────────────────────────────────
|
||||
const loginLoading = ref(false)
|
||||
const loginRecords = ref<LoginRecord[]>([])
|
||||
const failedLogins = computed(() => loginRecords.value.filter(r => r.loginType === 1).length)
|
||||
|
||||
async function loadLoginRecords() {
|
||||
loginLoading.value = true
|
||||
try {
|
||||
const data = await pageLoginRecords({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
})
|
||||
loginRecords.value = data?.list || []
|
||||
} catch (e) {
|
||||
console.error('加载登录日志失败', e)
|
||||
loginRecords.value = []
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loginTypeText(type?: number) {
|
||||
const map: Record<number, string> = {
|
||||
0: '登录成功',
|
||||
1: '登录失败',
|
||||
2: '退出登录',
|
||||
3: 'Token 续签',
|
||||
}
|
||||
return type !== undefined ? (map[type] || `类型${type}`) : '-'
|
||||
}
|
||||
|
||||
function loginTypeColor(type?: number) {
|
||||
const map: Record<number, string> = {
|
||||
0: 'green',
|
||||
1: 'red',
|
||||
2: 'default',
|
||||
3: 'blue',
|
||||
}
|
||||
return type !== undefined ? (map[type] || 'default') : 'default'
|
||||
}
|
||||
|
||||
function formatTime(value?: string) {
|
||||
if (!value) return '-'
|
||||
try {
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return value
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 初始化 ──────────────────────────────────────────────────
|
||||
onMounted(() => {
|
||||
loadLoginRecords()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 安全概览 */
|
||||
.security-overview-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.overview-icon {
|
||||
font-size: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.overview-info { flex: 1; }
|
||||
|
||||
.overview-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.overview-label {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.overview-info { background: #f9fafb; border-color: #e5e7eb; }
|
||||
.overview-warn { background: #fffbeb; border-color: #fde68a; }
|
||||
.overview-info { background: #eff6ff; border-color: #bfdbfe; }
|
||||
.overview-success { background: #f0fdf4; border-color: #bbf7d0; }
|
||||
.overview-danger { background: #fef2f2; border-color: #fecaca; }
|
||||
|
||||
/* 安全建议 */
|
||||
.security-tips {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.tip-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.tip-icon {
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tip-icon.level-info { background: #eff6ff; }
|
||||
.tip-icon.level-warn { background: #fff7ed; }
|
||||
|
||||
.tip-content { flex: 1; }
|
||||
|
||||
.tip-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.tip-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user