初始化2
This commit is contained in:
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