feat: initialize independent Sense product skeleton (#21)

This commit is contained in:
QiuSW
2026-08-12 16:49:17 +08:00
parent be2c1e3c81
commit d2169990d3
33 changed files with 9140 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
<template>
<router-view />
</template>
<script setup>
</script>
+55
View File
@@ -0,0 +1,55 @@
<template>
<section class="page-container">
<div class="page-heading">
<div>
<h1>工作台</h1>
<p>查看设备、视频接入和服务状态,按提示处理异常。</p>
</div>
<el-tag type="success" effect="light">Sense 独立运行</el-tag>
</div>
<el-row :gutter="16">
<el-col v-for="item in overview" :key="item.label" :xs="24" :sm="12" :lg="6">
<el-card shadow="never" class="overview-card">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
<small>{{ item.description }}</small>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" class="next-step-card">
<template #header>
<div class="card-header">
<span>开始使用</span>
<el-tag type="info">按步骤配置</el-tag>
</div>
</template>
<el-steps :active="0" finish-status="success" simple>
<el-step title="添加设备" />
<el-step title="检查视频" />
<el-step title="配置区域" />
</el-steps>
<el-empty description="功能模块会按工单逐步启用,不显示框架演示页面。" :image-size="92" />
</el-card>
</section>
</template>
<script setup>
const overview = [
{ label: '设备总数', value: '—', description: '添加设备后显示' },
{ label: '视频正常', value: '—', description: '接入验证后显示' },
{ label: '需要处理', value: '—', description: '异常状态集中提示' },
{ label: '区域配置', value: '—', description: '发布后显示版本' }
]
</script>
<style scoped>
.overview-card { margin-bottom: 16px; }
.overview-card :deep(.el-card__body) { display: flex; flex-direction: column; gap: 8px; }
.overview-card strong { color: #1d2129; font-size: 28px; }
.overview-card small { color: #86909c; }
.next-step-card { margin-top: 4px; }
.card-header { display: flex; align-items: center; justify-content: space-between; }
</style>
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>Sense 管理端需要启用 JavaScript。</noscript>
<div id="app"></div>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import store from './store'
import './theme.css'
const app = createApp(App)
Object.entries(ElementPlusIconsVue).forEach(([name, component]) => {
app.component(name, component)
})
app.use(ElementPlus)
app.use(store)
app.use(router)
app.mount('#app')
+16
View File
@@ -0,0 +1,16 @@
import axios from 'axios'
const request = axios.create({
baseURL: '/api/v1',
timeout: 15000,
withCredentials: true,
headers: { 'X-Product': 'sense' }
})
request.interceptors.response.use(
(response) => response.data,
(error) => Promise.reject(error.response?.data?.error || error)
)
export default request
+45
View File
@@ -0,0 +1,45 @@
import { createRouter, createWebHistory } from 'vue-router'
import AppLayout from '../layout/AppLayout.vue'
import Dashboard from './Dashboard.vue'
const featureContext = require.context('../', true, /router\/modules\/sense-.*\.js$/)
const featureRoutes = featureContext.keys().flatMap((key) => {
const module = featureContext(key).default
return Array.isArray(module) ? module : [module]
})
const publicRoutes = featureRoutes.filter((route) => route.meta?.public)
const protectedRoutes = featureRoutes.filter((route) => !route.meta?.public)
const router = createRouter({
history: createWebHistory(),
routes: [
...publicRoutes,
{
path: '/',
component: AppLayout,
children: [
{
path: '',
name: 'sense-dashboard',
component: Dashboard,
meta: { title: '工作台', icon: 'Odometer', order: 0 }
},
...protectedRoutes
]
},
{ path: '/:pathMatch(.*)*', redirect: '/' }
]
})
router.beforeEach((to) => {
if (to.meta.public) return true
const session = window.sessionStorage.getItem('sense_session')
const hasIdentityModule = router.hasRoute('sense-login')
if (hasIdentityModule && !session) return { name: 'sense-login', query: { redirect: to.fullPath } }
return true
})
export default router
+11
View File
@@ -0,0 +1,11 @@
import { createStore } from 'vuex'
const moduleContext = require.context('../', true, /store\/modules\/sense-.*\.js$/)
const modules = {}
moduleContext.keys().forEach((key) => {
const name = key.split('/').pop().replace('.js', '')
modules[name] = moduleContext(key).default
})
export default createStore({ modules })
+25
View File
@@ -0,0 +1,25 @@
:root {
--sense-bg: #f2f3f5;
--sense-sidebar: #001529;
--sense-primary: #1890ff;
--sense-text: #1d2129;
--sense-muted: #86909c;
--sense-border: #e5e6eb;
}
* { box-sizing: border-box; }
html, body, #app { min-width: 1080px; min-height: 100%; margin: 0; }
body {
background: var(--sense-bg);
color: var(--sense-text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
}
.page-container { padding: 20px; }
.page-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
.page-heading h1 { margin: 0 0 6px; font-size: 22px; font-weight: 600; }
.page-heading p { margin: 0; color: var(--sense-muted); font-size: 14px; }
.search-toolbar { margin-bottom: 16px; padding: 16px 16px 0; background: #fff; border: 1px solid var(--sense-border); border-radius: 4px; }
.table-container { padding: 16px; background: #fff; border: 1px solid var(--sense-border); border-radius: 4px; }
.table-actions { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.pagination { display: flex; justify-content: flex-end; margin-top: 16px; }
+97
View File
@@ -0,0 +1,97 @@
<template>
<el-container class="app-shell">
<el-aside :width="collapsed ? '64px' : '208px'" class="app-sidebar">
<div class="brand" :class="{ collapsed }">
<span class="brand-mark">S</span>
<span v-if="!collapsed" class="brand-name">Sense</span>
</div>
<el-menu
:default-active="$route.path"
router
background-color="#001529"
text-color="rgba(255,255,255,.72)"
active-text-color="#fff"
:collapse="collapsed"
:collapse-transition="false"
>
<el-menu-item v-for="item in navigation" :key="item.path" :index="item.path">
<el-icon><component :is="item.icon || 'Menu'" /></el-icon>
<template #title>{{ item.title }}</template>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="app-header">
<el-button text class="collapse-button" :aria-label="collapsed ? '展开菜单' : '收起菜单'" @click="collapsed = !collapsed">
<el-icon size="20"><Expand v-if="collapsed" /><Fold v-else /></el-icon>
</el-button>
<div class="header-title">视频感知管理</div>
<div class="header-actions">
<el-tag type="success" effect="plain">服务正常</el-tag>
<el-dropdown>
<el-button text>
<el-icon><User /></el-icon>
<span class="user-name">当前用户</span>
<el-icon><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item disabled>个人信息</el-dropdown-item>
<el-dropdown-item divided @click="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<div class="tab-bar">
<el-tag closable :disable-transitions="true" type="primary" effect="plain">{{ $route.meta.title || '工作台' }}</el-tag>
</div>
<el-main class="app-main"><router-view /></el-main>
</el-container>
</el-container>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const collapsed = ref(false)
const navigation = computed(() => {
const root = router.getRoutes().find((route) => route.path === '/')
return (root?.children || [])
.filter((route) => route.meta?.title && !route.meta?.hidden)
.map((route) => ({
path: route.path ? `/${route.path}`.replace('//', '/') : '/',
title: route.meta.title,
icon: route.meta.icon,
order: route.meta.order || 0
}))
.sort((left, right) => left.order - right.order)
})
function logout() {
window.sessionStorage.removeItem('sense_session')
if (router.hasRoute('sense-login')) router.push({ name: 'sense-login' })
}
</script>
<style scoped>
.app-shell { min-height: 100vh; }
.app-sidebar { background: var(--sense-sidebar); transition: width .18s ease; }
.brand { display: flex; height: 56px; align-items: center; gap: 10px; padding: 0 18px; color: #fff; }
.brand.collapsed { justify-content: center; padding: 0; }
.brand-mark { display: inline-flex; width: 28px; height: 28px; align-items: center; justify-content: center; border-radius: 6px; background: var(--sense-primary); font-weight: 700; }
.brand-name { font-size: 18px; font-weight: 600; letter-spacing: .4px; }
.app-sidebar :deep(.el-menu) { border-right: 0; }
.app-sidebar :deep(.el-menu-item.is-active) { background: var(--sense-primary); }
.app-header { display: flex; height: 56px; align-items: center; padding: 0 16px; border-bottom: 1px solid var(--sense-border); background: #fff; }
.collapse-button { margin-right: 8px; }
.header-title { font-size: 16px; font-weight: 500; }
.header-actions { display: flex; align-items: center; gap: 12px; margin-left: auto; }
.user-name { margin: 0 6px; }
.tab-bar { display: flex; height: 40px; align-items: center; padding: 0 16px; border-bottom: 1px solid var(--sense-border); background: #fff; }
.app-main { padding: 0; background: var(--sense-bg); }
</style>