feat: add Sense identity RBAC and audit (#22)

This commit is contained in:
QiuSW
2026-08-12 17:06:26 +08:00
parent 2c0e37eff7
commit b9416ebc08
24 changed files with 1438 additions and 11 deletions
+10
View File
@@ -0,0 +1,10 @@
import request from '../../../bootstrap/request'
export const login = (data) => request.post('/identity/login', data)
export const logout = () => request.post('/identity/logout')
export const currentUser = () => request.get('/identity/me')
export const listUsers = () => request.get('/identity/users')
export const createUser = (data) => request.post('/identity/users', data)
export const updateUser = (id, data) => request.patch(`/identity/users/${id}`, data)
export const listAudit = () => request.get('/identity/audit')
+8 -3
View File
@@ -56,13 +56,17 @@
<script setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
const router = useRouter()
const store = useStore()
const collapsed = ref(false)
const navigation = computed(() => {
const root = router.getRoutes().find((route) => route.path === '/')
const permissions = store.getters['sense-identity/permissions'] || []
return (root?.children || [])
.filter((route) => route.meta?.title && !route.meta?.hidden)
.filter((route) => !route.meta?.permission || permissions.includes(route.meta.permission))
.map((route) => ({
path: route.path ? `/${route.path}`.replace('//', '/') : '/',
title: route.meta.title,
@@ -72,9 +76,10 @@ const navigation = computed(() => {
.sort((left, right) => left.order - right.order)
})
function logout() {
window.sessionStorage.removeItem('sense_session')
if (router.hasRoute('sense-login')) router.push({ name: 'sense-login' })
async function logout() {
if (store.hasModule('sense-identity')) await store.dispatch('sense-identity/logout')
else window.sessionStorage.removeItem('sense_session')
if (router.hasRoute('sense-login')) await router.push({ name: 'sense-login' })
}
</script>
@@ -0,0 +1,25 @@
import Login from '../../views/sense/identity/Login.vue'
import Users from '../../views/sense/identity/Users.vue'
import Audit from '../../views/sense/identity/Audit.vue'
export default [
{
path: '/login',
name: 'sense-login',
component: Login,
meta: { title: '登录', public: true, hidden: true }
},
{
path: 'identity/users',
name: 'sense-users',
component: Users,
meta: { title: '用户与权限', icon: 'UserFilled', order: 90, permission: 'identity.users.read' }
},
{
path: 'identity/audit',
name: 'sense-audit',
component: Audit,
meta: { title: '操作记录', icon: 'Tickets', order: 91, permission: 'identity.audit.read' }
}
]
@@ -0,0 +1,45 @@
import * as identityApi from '../../api/sense/identity'
function loadPrincipal() {
try {
return JSON.parse(window.sessionStorage.getItem('sense_session'))
} catch {
return null
}
}
export default {
namespaced: true,
state: () => ({ principal: loadPrincipal() }),
getters: {
permissions: (state) => state.principal?.permissions || [],
hasPermission: (state) => (permission) => (state.principal?.permissions || []).includes(permission)
},
mutations: {
setPrincipal(state, principal) {
state.principal = principal
if (principal) window.sessionStorage.setItem('sense_session', JSON.stringify(principal))
else window.sessionStorage.removeItem('sense_session')
}
},
actions: {
async login({ commit }, credentials) {
const principal = await identityApi.login(credentials)
commit('setPrincipal', principal)
return principal
},
async refresh({ commit }) {
const principal = await identityApi.currentUser()
commit('setPrincipal', principal)
return principal
},
async logout({ commit }) {
try {
await identityApi.logout()
} finally {
commit('setPrincipal', null)
}
}
}
}
@@ -0,0 +1,30 @@
<template>
<section class="page-container">
<div class="page-heading"><div><h1>操作记录</h1><p>查看登录和权限变更结果;记录中不会保存密码、Cookie 或令牌。</p></div></div>
<div class="table-container">
<div class="table-actions"><strong>最近 100 条</strong><el-button :icon="Refresh" @click="load">刷新</el-button></div>
<el-table v-loading="loading" :data="entries" border>
<el-table-column prop="created_at" label="时间" width="190" />
<el-table-column prop="action" label="操作" min-width="180" />
<el-table-column prop="actor_id" label="操作人" min-width="180" />
<el-table-column prop="target" label="对象" min-width="180" />
<el-table-column label="结果" width="100"><template #default="scope"><el-tag :type="scope.row.outcome === 'success' ? 'success' : 'danger'">{{ scope.row.outcome === 'success' ? '成功' : '失败' }}</el-tag></template></el-table-column>
</el-table>
</div>
</section>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { listAudit } from '../../../api/sense/identity'
const loading = ref(false)
const entries = ref([])
async function load() {
loading.value = true
try { entries.value = (await listAudit()).items || [] } finally { loading.value = false }
}
onMounted(load)
</script>
@@ -0,0 +1,81 @@
<template>
<main class="login-page">
<section class="login-panel">
<div class="product-block">
<div class="product-mark">S</div>
<h1>Sense 视频感知管理</h1>
<p>面向网管与现场人员的设备、视频和区域配置工具</p>
</div>
<el-card shadow="never" class="login-card">
<template #header>
<div>
<h2>登录</h2>
<span>使用 Sense 独立账户,不接受 Bell 登录状态</span>
</div>
</template>
<el-alert v-if="errorMessage" :title="errorMessage" type="error" show-icon :closable="false" />
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent="submit">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" autocomplete="username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" type="password" autocomplete="current-password" show-password placeholder="请输入密码" @keyup.enter="submit" />
</el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" class="login-button" @click="submit">登录</el-button>
</el-form>
<el-divider />
<p class="bootstrap-hint">首次部署请由实施人员使用仓库外一次性令牌完成安全初始化;系统不提供默认账户或默认密码。</p>
</el-card>
</section>
</main>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useStore } from 'vuex'
const route = useRoute()
const router = useRouter()
const store = useStore()
const formRef = ref()
const loading = ref(false)
const errorMessage = ref('')
const form = reactive({ username: '', password: '' })
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
async function submit() {
if (loading.value) return
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
errorMessage.value = ''
try {
await store.dispatch('sense-identity/login', form)
const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/') ? route.query.redirect : '/'
await router.replace(redirect)
} catch (error) {
errorMessage.value = error.message || '用户名或密码不正确'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page { min-height: 100vh; padding: 48px; background: linear-gradient(135deg, #001529, #073b66); }
.login-panel { display: grid; grid-template-columns: minmax(360px, 1fr) 420px; max-width: 1060px; min-height: 620px; margin: 0 auto; align-items: center; gap: 80px; }
.product-block { color: #fff; }
.product-mark { display: inline-flex; width: 56px; height: 56px; align-items: center; justify-content: center; border-radius: 12px; background: #1890ff; font-size: 32px; font-weight: 700; }
.product-block h1 { margin: 24px 0 12px; font-size: 34px; }
.product-block p { color: rgba(255, 255, 255, .72); font-size: 16px; }
.login-card { border: 0; }
.login-card h2 { margin: 0 0 6px; font-size: 22px; }
.login-card span, .bootstrap-hint { color: #86909c; font-size: 13px; line-height: 1.7; }
.login-card :deep(.el-alert) { margin-bottom: 16px; }
.login-button { width: 100%; }
</style>
+102
View File
@@ -0,0 +1,102 @@
<template>
<section class="page-container">
<div class="page-heading">
<div><h1>用户与权限</h1><p>管理谁可以配置设备、视频和区域;权限由后端最终校验。</p></div>
</div>
<div class="search-toolbar">
<el-form :inline="true">
<el-form-item label="用户名"><el-input v-model="keyword" clearable placeholder="搜索用户名或姓名" /></el-form-item>
<el-form-item><el-button type="primary" :icon="Search">查询</el-button><el-button @click="keyword = ''">重置</el-button></el-form-item>
</el-form>
</div>
<div class="table-container">
<div class="table-actions">
<strong>账户列表</strong>
<el-button v-if="canWrite" type="primary" :icon="Plus" @click="openCreate">新增用户</el-button>
</div>
<el-table v-loading="loading" :data="filteredUsers" border>
<el-table-column prop="username" label="用户名" min-width="130" />
<el-table-column prop="display_name" label="姓名" min-width="140" />
<el-table-column label="角色" width="150"><template #default="scope"><el-tag>{{ roleLabel(scope.row.role) }}</el-tag></template></el-table-column>
<el-table-column label="状态" width="100"><template #default="scope"><el-tag :type="scope.row.enabled ? 'success' : 'info'">{{ scope.row.enabled ? '启用' : '停用' }}</el-tag></template></el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="190" />
<el-table-column v-if="canWrite" label="操作" width="100" fixed="right"><template #default="scope"><el-button link type="primary" @click="openEdit(scope.row)">编辑</el-button></template></el-table-column>
</el-table>
<div class="pagination"><el-pagination background layout="total, prev, pager, next" :total="filteredUsers.length" :page-size="20" /></div>
</div>
<el-dialog v-model="dialogVisible" :title="editingID ? '编辑用户' : '新增用户'" width="520px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="92px">
<el-form-item label="用户名" prop="username"><el-input v-model="form.username" :disabled="Boolean(editingID)" /></el-form-item>
<el-form-item label="姓名" prop="display_name"><el-input v-model="form.display_name" /></el-form-item>
<el-form-item label="角色" prop="role"><el-select v-model="form.role" style="width: 100%"><el-option v-for="role in roles" :key="role.value" :label="role.label" :value="role.value" /></el-select></el-form-item>
<el-form-item v-if="!editingID" label="初始密码" prop="password"><el-input v-model="form.password" type="password" show-password /><div class="field-hint">至少 12 位,包含大小写字母和数字,且不含用户名。</div></el-form-item>
<el-form-item v-else label="状态"><el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" /></el-form-item>
</el-form>
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
</el-dialog>
</section>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import { useStore } from 'vuex'
import { createUser, listUsers, updateUser } from '../../../api/sense/identity'
const store = useStore()
const loading = ref(false)
const saving = ref(false)
const keyword = ref('')
const users = ref([])
const dialogVisible = ref(false)
const editingID = ref('')
const formRef = ref()
const roles = [
{ value: 'administrator', label: '系统管理员' },
{ value: 'operator', label: '实施/运维' },
{ value: 'site_admin', label: '站点管理员' },
{ value: 'viewer', label: '只读用户' }
]
const form = reactive({ username: '', display_name: '', role: 'viewer', password: '', enabled: true })
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
display_name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
password: [{ required: true, min: 12, message: '请输入至少 12 位初始密码', trigger: 'blur' }]
}
const canWrite = computed(() => store.getters['sense-identity/hasPermission']?.('identity.users.write'))
const filteredUsers = computed(() => {
const value = keyword.value.trim().toLowerCase()
return value ? users.value.filter((item) => `${item.username} ${item.display_name}`.toLowerCase().includes(value)) : users.value
})
function roleLabel(value) { return roles.find((item) => item.value === value)?.label || value }
function resetForm() { Object.assign(form, { username: '', display_name: '', role: 'viewer', password: '', enabled: true }) }
function openCreate() { editingID.value = ''; resetForm(); dialogVisible.value = true }
function openEdit(user) { editingID.value = user.id; Object.assign(form, { ...user, password: '' }); dialogVisible.value = true }
async function load() {
loading.value = true
try { users.value = (await listUsers()).items || [] } finally { loading.value = false }
}
async function save() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
if (editingID.value) await updateUser(editingID.value, { display_name: form.display_name, role: form.role, enabled: form.enabled })
else await createUser(form)
ElMessage.success('保存成功')
dialogVisible.value = false
await load()
} catch (error) { ElMessage.error(error.message || '保存失败') } finally { saving.value = false }
}
onMounted(load)
</script>
<style scoped>
.field-hint { color: #86909c; font-size: 12px; line-height: 1.5; }
</style>