Add transfer tracking and secure admin access

This commit is contained in:
2026-07-26 11:57:57 +08:00
parent 0738953e6d
commit 7df25edd96
111 changed files with 6379 additions and 1934 deletions
+33 -7
View File
@@ -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>
+26 -9
View File
@@ -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
+66
View File
@@ -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())
+20
View File
@@ -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
+134
View File
@@ -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>
+47
View File
@@ -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>
+61
View File
@@ -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>
+57
View File
@@ -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>