Add transfer tracking and secure admin access
This commit is contained in:
+33
-7
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { adminAuth } from './auth'
|
||||
import { DashboardOutlined, SettingOutlined, ControlOutlined, SmileOutlined,
|
||||
GithubOutlined, PictureOutlined, TeamOutlined, AppstoreOutlined,
|
||||
NotificationOutlined } from '@ant-design/icons-vue'
|
||||
NotificationOutlined, SafetyCertificateOutlined, AuditOutlined,
|
||||
LogoutOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -12,7 +14,7 @@ const selectedKeys = ref<string[]>([String(route.name)])
|
||||
|
||||
watch(() => route.name, (n) => { selectedKeys.value = [String(n)] })
|
||||
|
||||
const nav = [
|
||||
const nav = computed(() => [
|
||||
{ key: 'Dashboard', icon: DashboardOutlined, label: '仪表盘' },
|
||||
{ key: 'Settings', icon: ControlOutlined, label: '系统设置' },
|
||||
{ key: 'Configs', icon: SettingOutlined, label: '品牌配置' },
|
||||
@@ -22,11 +24,25 @@ const nav = [
|
||||
{ key: 'Stickers', icon: PictureOutlined, label: '表情包库' },
|
||||
{ key: 'Users', icon: TeamOutlined, label: '用户管理' },
|
||||
{ key: 'PushCampaigns', icon: NotificationOutlined, label: '推送管理' },
|
||||
]
|
||||
...(adminAuth.identity.value?.role === 'super_admin' ? [
|
||||
{ key: 'AdminAccounts', icon: SafetyCertificateOutlined, label: '管理员账号' },
|
||||
{ key: 'Audit', icon: AuditOutlined, label: '操作审计' },
|
||||
] : []),
|
||||
])
|
||||
|
||||
watch(adminAuth.identity, value => {
|
||||
if (!value && !route.meta.public) router.replace('/login')
|
||||
})
|
||||
|
||||
async function logout() {
|
||||
await adminAuth.logout()
|
||||
await router.replace('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout style="min-height: 100vh">
|
||||
<router-view v-if="route.meta.public || route.name === 'ChangePassword'" />
|
||||
<a-layout v-else style="min-height: 100vh">
|
||||
<a-layout-sider v-model:collapsed="collapsed" collapsible theme="light" :width="200"
|
||||
style="border-right: 1px solid #f0f0f0">
|
||||
<div style="padding: 18px 20px; font-size: 16px; font-weight: 700; white-space: nowrap; overflow: hidden;">
|
||||
@@ -39,10 +55,20 @@ const nav = [
|
||||
<span>{{ n.label }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
<div style="position:absolute;bottom:16px;left:20px;font-size:11px;color:#bbb">v20260726-0130</div>
|
||||
<div style="position:absolute;bottom:16px;left:16px;right:16px">
|
||||
<a-button type="text" block style="text-align:left" @click="logout">
|
||||
<template #icon><LogoutOutlined /></template>退出登录
|
||||
</a-button>
|
||||
</div>
|
||||
</a-layout-sider>
|
||||
<a-layout>
|
||||
<a-layout-content style="margin: 18px 20px; padding: 20px; background: #fff; border-radius: 10px; min-height: 360px;">
|
||||
<a-layout-header style="height:52px;padding:0 20px;background:#fff;border-bottom:1px solid #f0f0f0;display:flex;align-items:center;justify-content:flex-end">
|
||||
<a-space>
|
||||
<span>{{ adminAuth.identity.value?.username }}</span>
|
||||
<a-tag>{{ adminAuth.identity.value?.role }}</a-tag>
|
||||
</a-space>
|
||||
</a-layout-header>
|
||||
<a-layout-content style="margin: 18px 20px; padding: 20px; background: #fff; border-radius: 8px; min-height: 360px;">
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import axios from 'axios'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
const ADMIN_KEY = localStorage.getItem('miaoji_admin_key') || ''
|
||||
import axios from 'axios'
|
||||
|
||||
let csrfToken = ''
|
||||
|
||||
export function setCsrfToken(value: string) {
|
||||
csrfToken = value
|
||||
}
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE || '',
|
||||
headers: { 'X-Admin-Key': ADMIN_KEY },
|
||||
})
|
||||
baseURL: import.meta.env.VITE_API_BASE || '',
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
http.interceptors.request.use(config => {
|
||||
const method = config.method?.toLowerCase()
|
||||
if (csrfToken && method && !['get', 'head', 'options'].includes(method)) {
|
||||
config.headers.set('X-CSRF-Token', csrfToken)
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
r => r,
|
||||
err => {
|
||||
if (err.response?.status === 401) {
|
||||
message.error('管理密钥无效,请在 localStorage 设置 miaoji_admin_key')
|
||||
if (err.response?.status === 401) {
|
||||
window.dispatchEvent(new CustomEvent('admin-session-expired'))
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
@@ -55,6 +66,12 @@ export const api = {
|
||||
pushDevices: (params: { search?: string; limit?: number }) => http.get('/api/admin/push/devices', { params }).then(r => r.data),
|
||||
testPush: (data: any) => http.post('/api/admin/push/test', data).then(r => r.data),
|
||||
pushHealth: () => http.get('/api/admin/push/health').then(r => r.data),
|
||||
adminAccounts: () => http.get('/api/admin/security/accounts').then(r => r.data),
|
||||
createAdminAccount: (data: any) => http.post('/api/admin/security/accounts', data).then(r => r.data),
|
||||
updateAdminAccount: (id: number, data: any) => http.put(`/api/admin/security/accounts/${id}`, data).then(r => r.data),
|
||||
resetAdminPassword: (id: number, password: string) => http.put(`/api/admin/security/accounts/${id}/password`, { password }),
|
||||
revokeAdminSessions: (id: number) => http.post(`/api/admin/security/accounts/${id}/revoke-sessions`),
|
||||
auditLogs: (params: any) => http.get('/api/admin/security/audit', { params }).then(r => r.data),
|
||||
}
|
||||
|
||||
export default http
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import http, { setCsrfToken } from './api'
|
||||
|
||||
export interface AdminIdentity {
|
||||
id: number
|
||||
username: string
|
||||
role: 'super_admin' | 'operator' | 'viewer'
|
||||
mustChangePassword: boolean
|
||||
csrfToken: string
|
||||
}
|
||||
|
||||
const identity = ref<AdminIdentity | null>(null)
|
||||
let loaded = false
|
||||
let loading: Promise<boolean> | null = null
|
||||
|
||||
function apply(value: AdminIdentity | null) {
|
||||
identity.value = value
|
||||
setCsrfToken(value?.csrfToken || '')
|
||||
}
|
||||
|
||||
export const adminAuth = {
|
||||
identity,
|
||||
isAuthenticated: computed(() => identity.value !== null),
|
||||
async ensure() {
|
||||
if (loaded) return identity.value !== null
|
||||
if (loading) return loading
|
||||
loading = http.get('/api/admin/auth/me')
|
||||
.then(response => {
|
||||
apply(response.data as AdminIdentity)
|
||||
loaded = true
|
||||
return true
|
||||
})
|
||||
.catch(() => {
|
||||
apply(null)
|
||||
loaded = true
|
||||
return false
|
||||
})
|
||||
.finally(() => { loading = null })
|
||||
return loading
|
||||
},
|
||||
async login(username: string, password: string) {
|
||||
const response = await http.post('/api/admin/auth/login', { username, password })
|
||||
apply(response.data as AdminIdentity)
|
||||
loaded = true
|
||||
return identity.value!
|
||||
},
|
||||
async logout() {
|
||||
try {
|
||||
await http.post('/api/admin/auth/logout')
|
||||
} finally {
|
||||
apply(null)
|
||||
loaded = true
|
||||
}
|
||||
},
|
||||
async changePassword(currentPassword: string, newPassword: string) {
|
||||
const response = await http.put('/api/admin/auth/password', { currentPassword, newPassword })
|
||||
apply(response.data as AdminIdentity)
|
||||
return identity.value!
|
||||
},
|
||||
clear() {
|
||||
apply(null)
|
||||
loaded = true
|
||||
},
|
||||
}
|
||||
|
||||
window.addEventListener('admin-session-expired', () => adminAuth.clear())
|
||||
@@ -1,8 +1,11 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import { adminAuth } from '../auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{ path: '/login', name: 'Login', component: () => import('../views/Login.vue'), meta: { public: true } },
|
||||
{ path: '/change-password', name: 'ChangePassword', component: () => import('../views/ChangePassword.vue') },
|
||||
{ path: '/', redirect: '/dashboard' },
|
||||
{ path: '/dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue') },
|
||||
{ path: '/settings', name: 'Settings', component: () => import('../views/Settings.vue') },
|
||||
@@ -13,7 +16,24 @@ const router = createRouter({
|
||||
{ path: '/stickers', name: 'Stickers', component: () => import('../views/Stickers.vue') },
|
||||
{ path: '/users', name: 'Users', component: () => import('../views/Users.vue') },
|
||||
{ path: '/push', name: 'PushCampaigns', component: () => import('../views/PushCampaigns.vue') },
|
||||
{ path: '/admin-accounts', name: 'AdminAccounts', component: () => import('../views/AdminAccounts.vue'), meta: { role: 'super_admin' } },
|
||||
{ path: '/audit', name: 'Audit', component: () => import('../views/Audit.vue'), meta: { role: 'super_admin' } },
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach(async to => {
|
||||
if (to.meta.public) {
|
||||
if (to.name === 'Login' && await adminAuth.ensure()) {
|
||||
return adminAuth.identity.value?.mustChangePassword ? '/change-password' : '/dashboard'
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (!await adminAuth.ensure()) return { name: 'Login', query: { redirect: to.fullPath } }
|
||||
const user = adminAuth.identity.value!
|
||||
if (user.mustChangePassword && to.name !== 'ChangePassword') return { name: 'ChangePassword' }
|
||||
if (!user.mustChangePassword && to.name === 'ChangePassword') return { name: 'Dashboard' }
|
||||
if (to.meta.role && to.meta.role !== user.role) return { name: 'Dashboard' }
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { api } from '../api'
|
||||
import { formatShanghaiDate } from '../utils/time'
|
||||
|
||||
interface Account {
|
||||
id: number
|
||||
username: string
|
||||
role: string
|
||||
isActive: boolean
|
||||
mustChangePassword: boolean
|
||||
activeSessions: number
|
||||
lastLoginAt?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<Account[]>([])
|
||||
const createOpen = ref(false)
|
||||
const resetTarget = ref<Account | null>(null)
|
||||
const form = reactive({ username: '', password: '', role: 'operator' })
|
||||
const resetPassword = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try { rows.value = await api.adminAccounts() } finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function createAccount() {
|
||||
if (form.password.length < 12) return message.error('初始密码至少 12 位')
|
||||
await api.createAdminAccount(form)
|
||||
message.success('管理员已创建')
|
||||
createOpen.value = false
|
||||
Object.assign(form, { username: '', password: '', role: 'operator' })
|
||||
await load()
|
||||
}
|
||||
|
||||
async function update(account: Account, patch: Partial<Account>) {
|
||||
try {
|
||||
await api.updateAdminAccount(account.id, {
|
||||
role: patch.role ?? account.role,
|
||||
isActive: patch.isActive ?? account.isActive,
|
||||
mustChangePassword: patch.mustChangePassword ?? account.mustChangePassword,
|
||||
})
|
||||
await load()
|
||||
} catch (reason: any) {
|
||||
message.error(reason.response?.data?.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReset() {
|
||||
if (!resetTarget.value || resetPassword.value.length < 12) return message.error('新密码至少 12 位')
|
||||
await api.resetAdminPassword(resetTarget.value.id, resetPassword.value)
|
||||
message.success('密码已重置,现有会话已撤销')
|
||||
resetTarget.value = null
|
||||
resetPassword.value = ''
|
||||
await load()
|
||||
}
|
||||
|
||||
function revoke(account: Account) {
|
||||
Modal.confirm({
|
||||
title: `撤销 ${account.username} 的全部会话?`,
|
||||
async onOk() {
|
||||
await api.revokeAdminSessions(account.id)
|
||||
message.success('会话已撤销')
|
||||
await load()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toolbar">
|
||||
<div><h2>管理员账号</h2><span>角色、登录状态与会话</span></div>
|
||||
<a-button type="primary" @click="createOpen = true">新建管理员</a-button>
|
||||
</div>
|
||||
<a-table :data-source="rows" :loading="loading" row-key="id" :pagination="false" size="middle">
|
||||
<a-table-column title="账号" data-index="username" />
|
||||
<a-table-column title="角色">
|
||||
<template #default="{ record }">
|
||||
<a-select :value="record.role" style="width:138px" @change="(role: string) => update(record, { role })">
|
||||
<a-select-option value="super_admin">super_admin</a-select-option>
|
||||
<a-select-option value="operator">operator</a-select-option>
|
||||
<a-select-option value="viewer">viewer</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="状态">
|
||||
<template #default="{ record }">
|
||||
<a-switch :checked="record.isActive" @change="(isActive: boolean) => update(record, { isActive })" />
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="会话" data-index="activeSessions" />
|
||||
<a-table-column title="最近登录">
|
||||
<template #default="{ record }">{{ formatShanghaiDate(record.lastLoginAt) }}</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作" :width="230">
|
||||
<template #default="{ record }">
|
||||
<a-space>
|
||||
<a-button size="small" @click="resetTarget = record">重置密码</a-button>
|
||||
<a-button size="small" danger @click="revoke(record)">撤销会话</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
|
||||
<a-modal v-model:open="createOpen" title="新建管理员" ok-text="创建" @ok="createAccount">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="用户名"><a-input v-model:value="form.username" /></a-form-item>
|
||||
<a-form-item label="初始密码"><a-input-password v-model:value="form.password" /></a-form-item>
|
||||
<a-form-item label="角色">
|
||||
<a-select v-model:value="form.role">
|
||||
<a-select-option value="operator">operator</a-select-option>
|
||||
<a-select-option value="viewer">viewer</a-select-option>
|
||||
<a-select-option value="super_admin">super_admin</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal :open="!!resetTarget" title="重置密码" ok-text="重置并撤销会话"
|
||||
@cancel="resetTarget = null" @ok="submitReset">
|
||||
<a-input-password v-model:value="resetPassword" placeholder="至少 12 位的新密码" />
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { display:flex; align-items:center; justify-content:space-between; margin-bottom:18px; }
|
||||
h2 { margin:0 0 3px; font-size:20px; }
|
||||
.toolbar span { color:#8c8c8c; font-size:12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { formatShanghaiDate } from '../utils/time'
|
||||
|
||||
const loading = ref(false)
|
||||
const username = ref('')
|
||||
const rows = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
|
||||
async function load(nextPage = page.value) {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await api.auditLogs({ page: nextPage, limit: 50, username: username.value || undefined })
|
||||
rows.value = result.list
|
||||
total.value = result.total
|
||||
page.value = nextPage
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toolbar">
|
||||
<div><h2>操作审计</h2><span>登录、认证与后台写操作</span></div>
|
||||
<a-input-search v-model:value="username" placeholder="管理员用户名" style="width:260px" @search="load(1)" />
|
||||
</div>
|
||||
<a-table :data-source="rows" :loading="loading" row-key="id" :pagination="false" size="small">
|
||||
<a-table-column title="时间" :width="170"><template #default="{ record }">{{ formatShanghaiDate(record.createdAt) }}</template></a-table-column>
|
||||
<a-table-column title="管理员" data-index="username" :width="130" />
|
||||
<a-table-column title="动作" data-index="action" />
|
||||
<a-table-column title="状态" :width="90">
|
||||
<template #default="{ record }"><a-tag :color="record.success ? 'green' : 'red'">{{ record.statusCode }}</a-tag></template>
|
||||
</a-table-column>
|
||||
<a-table-column title="IP" data-index="ipAddress" :width="140" />
|
||||
</a-table>
|
||||
<a-pagination v-model:current="page" :total="total" :page-size="50" :show-size-changer="false"
|
||||
style="margin-top:16px;text-align:right" @change="load" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { display:flex; align-items:center; justify-content:space-between; margin-bottom:18px; }
|
||||
h2 { margin:0 0 3px; font-size:20px; }
|
||||
.toolbar span { color:#8c8c8c; font-size:12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { adminAuth } from '../auth'
|
||||
|
||||
const router = useRouter()
|
||||
const form = reactive({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
||||
const loading = ref(false)
|
||||
|
||||
async function submit() {
|
||||
if (form.newPassword.length < 12) return message.error('新密码至少 12 位')
|
||||
if (form.newPassword !== form.confirmPassword) return message.error('两次输入的新密码不一致')
|
||||
loading.value = true
|
||||
try {
|
||||
await adminAuth.changePassword(form.currentPassword, form.newPassword)
|
||||
message.success('密码已更新')
|
||||
await router.replace('/dashboard')
|
||||
} catch (reason: any) {
|
||||
message.error(reason.response?.data?.message || '密码修改失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await adminAuth.logout()
|
||||
await router.replace('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="auth-page">
|
||||
<section class="auth-panel">
|
||||
<div class="brand">记之 Admin</div>
|
||||
<h1>修改初始密码</h1>
|
||||
<a-form layout="vertical" @finish="submit">
|
||||
<a-form-item label="当前密码" required>
|
||||
<a-input-password v-model:value="form.currentPassword" autocomplete="current-password" />
|
||||
</a-form-item>
|
||||
<a-form-item label="新密码" required>
|
||||
<a-input-password v-model:value="form.newPassword" autocomplete="new-password" />
|
||||
</a-form-item>
|
||||
<a-form-item label="确认新密码" required>
|
||||
<a-input-password v-model:value="form.confirmPassword" autocomplete="new-password" />
|
||||
</a-form-item>
|
||||
<a-space style="width:100%;justify-content:flex-end">
|
||||
<a-button @click="logout">退出登录</a-button>
|
||||
<a-button type="primary" html-type="submit" :loading="loading">保存密码</a-button>
|
||||
</a-space>
|
||||
</a-form>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-page { min-height: 100vh; display: grid; place-items: center; background: #f5f6f7; padding: 24px; }
|
||||
.auth-panel { width: min(440px, 100%); background: #fff; border: 1px solid #e6e8eb; border-radius: 8px; padding: 32px; box-shadow: 0 12px 32px rgb(0 0 0 / 6%); }
|
||||
.brand { color: #1677ff; font-size: 14px; font-weight: 700; }
|
||||
h1 { margin: 8px 0 24px; font-size: 24px; }
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { adminAuth } from '../auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const form = reactive({ username: '', password: '' })
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function submit() {
|
||||
if (!form.username.trim() || !form.password) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const user = await adminAuth.login(form.username, form.password)
|
||||
if (user.mustChangePassword) {
|
||||
await router.replace('/change-password')
|
||||
} else {
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
|
||||
await router.replace(redirect)
|
||||
}
|
||||
} catch (reason: any) {
|
||||
error.value = reason.response?.data?.message || '登录失败,请检查用户名和密码'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="auth-page">
|
||||
<section class="auth-panel">
|
||||
<div class="brand">记之 Admin</div>
|
||||
<h1>管理后台登录</h1>
|
||||
<a-alert v-if="error" type="error" :message="error" show-icon />
|
||||
<a-form layout="vertical" @finish="submit">
|
||||
<a-form-item label="用户名" required>
|
||||
<a-input v-model:value="form.username" autocomplete="username" size="large" autofocus />
|
||||
</a-form-item>
|
||||
<a-form-item label="密码" required>
|
||||
<a-input-password v-model:value="form.password" autocomplete="current-password" size="large" />
|
||||
</a-form-item>
|
||||
<a-button type="primary" html-type="submit" size="large" block :loading="loading">登录</a-button>
|
||||
</a-form>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-page { min-height: 100vh; display: grid; place-items: center; background: #f5f6f7; padding: 24px; }
|
||||
.auth-panel { width: min(400px, 100%); background: #fff; border: 1px solid #e6e8eb; border-radius: 8px; padding: 32px; box-shadow: 0 12px 32px rgb(0 0 0 / 6%); }
|
||||
.brand { color: #1677ff; font-size: 14px; font-weight: 700; }
|
||||
h1 { margin: 8px 0 24px; font-size: 24px; }
|
||||
.ant-alert { margin-bottom: 18px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user