feat: add Sense identity RBAC and audit (#22)
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user