feat: initialize independent Sense product skeleton (#21)
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
# Upstream sources
|
||||
|
||||
Sense is an independent product derived from the frozen GoAdmin baseline in
|
||||
the repository root `goadmin-baseline.json`.
|
||||
|
||||
- go-admin: `https://github.com/go-admin-team/go-admin`, commit
|
||||
`f06540883b41d03782bb6b2c4150f298f328c6b6`, MIT.
|
||||
- go-admin-ui: `https://github.com/go-admin-team/go-admin-ui`, commit
|
||||
`67d393d713877572fab0b897296a4c1d525fc81d`, MIT.
|
||||
- go-admin-doc: documentation reference only, commit
|
||||
`424855aacf6905f3fde860c3331385cb25529a0d`, MIT.
|
||||
|
||||
The local copies under `D:\github\goadmin` are read-only references and are
|
||||
not a distributable or reproducible source identifier.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 go-admin-team
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 wenjianzhang
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Sense
|
||||
|
||||
Sense is the independently deployable intelligent NVR and video-sensing
|
||||
management product in YoVision. It uses Go 1.26.5, PostgreSQL, Vue 3 and
|
||||
Element Plus. Brain and Bell are not required for startup or smoke tests.
|
||||
|
||||
## Toolchain
|
||||
|
||||
Use the exact versions from the repository root `goadmin-baseline.json`:
|
||||
|
||||
- Go 1.26.5
|
||||
- Node.js 22.22.1
|
||||
- pnpm 9.15.1
|
||||
|
||||
The repositories under `D:\github\goadmin` are read-only references. Do not
|
||||
develop Sense inside them.
|
||||
|
||||
```powershell
|
||||
$env:PATH = 'C:\Users\ila20\AppData\Local\Temp\yovision-tools\go1.26.5\go\bin;' + $env:PATH
|
||||
powershell -ExecutionPolicy Bypass -File .\Sense\scripts\bootstrap\check-toolchain.ps1
|
||||
```
|
||||
|
||||
## Backend
|
||||
|
||||
Production/default startup requires an independent PostgreSQL URL. No default
|
||||
password or signing secret is provided.
|
||||
|
||||
```powershell
|
||||
cd Sense\server
|
||||
$env:SENSE_DATABASE_MODE = 'postgres'
|
||||
$env:SENSE_DATABASE_URL = '<provided outside the repository>'
|
||||
go run .
|
||||
```
|
||||
|
||||
For an isolated smoke test only:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\Sense\scripts\bootstrap\smoke.ps1
|
||||
```
|
||||
|
||||
Health endpoints are `GET /healthz` and `GET /readyz`. Upstream demo and code
|
||||
generation routes are deliberately not registered.
|
||||
|
||||
## Frontend
|
||||
|
||||
```powershell
|
||||
cd Sense\ui
|
||||
corepack pnpm@9.15.1 install --frozen-lockfile
|
||||
corepack pnpm@9.15.1 lint
|
||||
corepack pnpm@9.15.1 build
|
||||
```
|
||||
|
||||
The frontend automatically discovers project-local route and store modules.
|
||||
Feature tickets add one route module without exposing unrelated GoAdmin demo
|
||||
pages.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Copy values into a repository-external environment store. Do not commit secrets.
|
||||
SENSE_HTTP_ADDRESS=127.0.0.1:18080
|
||||
SENSE_DATABASE_MODE=postgres
|
||||
SENSE_DATABASE_URL=
|
||||
SENSE_LOG_LEVEL=info
|
||||
SENSE_UI_STATIC_DIR=
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$expectedGo = 'go version go1.26.5 windows/amd64'
|
||||
$actualGo = (& go version).Trim()
|
||||
if ($actualGo -ne $expectedGo) {
|
||||
throw "Go toolchain mismatch. Expected '$expectedGo', got '$actualGo'."
|
||||
}
|
||||
|
||||
$actualNode = (& node --version).Trim()
|
||||
if ($actualNode -ne 'v22.22.1') {
|
||||
throw "Node.js mismatch. Expected 'v22.22.1', got '$actualNode'."
|
||||
}
|
||||
|
||||
$actualPnpm = (& corepack pnpm@9.15.1 --version).Trim()
|
||||
if ($actualPnpm -ne '9.15.1') {
|
||||
throw "pnpm mismatch. Expected '9.15.1', got '$actualPnpm'."
|
||||
}
|
||||
|
||||
Write-Output 'Sense toolchain matches goadmin-baseline.json.'
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$senseRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$serverRoot = Join-Path $senseRoot 'server'
|
||||
$env:SENSE_DATABASE_MODE = 'memory'
|
||||
$env:SENSE_HTTP_ADDRESS = '127.0.0.1:18080'
|
||||
|
||||
$process = Start-Process -FilePath 'go' -ArgumentList @('run', '.') -WorkingDirectory $serverRoot -PassThru -WindowStyle Hidden
|
||||
try {
|
||||
$ready = $false
|
||||
for ($attempt = 0; $attempt -lt 30; $attempt++) {
|
||||
try {
|
||||
$health = Invoke-RestMethod -Uri 'http://127.0.0.1:18080/healthz' -TimeoutSec 1
|
||||
$readiness = Invoke-RestMethod -Uri 'http://127.0.0.1:18080/readyz' -TimeoutSec 1
|
||||
if ($health.status -eq 'ok' -and $readiness.status -eq 'ready') {
|
||||
$ready = $true
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
}
|
||||
if (-not $ready) {
|
||||
throw 'Sense smoke server did not become ready.'
|
||||
}
|
||||
|
||||
try {
|
||||
Invoke-WebRequest -Uri 'http://127.0.0.1:18080/api/v1/demo' -TimeoutSec 2 | Out-Null
|
||||
throw 'Disabled demo route unexpectedly returned success.'
|
||||
} catch {
|
||||
if ($_.Exception.Response.StatusCode.value__ -ne 404) { throw }
|
||||
}
|
||||
Write-Output 'Sense independent smoke test passed.'
|
||||
} finally {
|
||||
if (-not $process.HasExited) { Stop-Process -Id $process.Id }
|
||||
$process.WaitForExit()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package sense
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"yovision.local/sense/internal/platform"
|
||||
)
|
||||
|
||||
type moduleRegistrar func(*platform.App) error
|
||||
|
||||
var moduleRegistrars []moduleRegistrar
|
||||
|
||||
// registerModule is intentionally package-local. Feature integration files in
|
||||
// this directory append one registrar without changing the shared bootstrap.
|
||||
func registerModule(registrar moduleRegistrar) {
|
||||
moduleRegistrars = append(moduleRegistrars, registrar)
|
||||
}
|
||||
|
||||
func Run() error {
|
||||
cfg, err := platform.LoadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel()}))
|
||||
database, err := platform.OpenDatabase(context.Background(), cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if database != nil {
|
||||
defer database.Close()
|
||||
}
|
||||
|
||||
app := platform.NewApp(cfg, database, logger)
|
||||
for _, registrar := range moduleRegistrars {
|
||||
if err := registrar(app); err != nil {
|
||||
return fmt.Errorf("register Sense module: %w", err)
|
||||
}
|
||||
}
|
||||
if err := app.ApplyMigrations(context.Background()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.HTTPAddress,
|
||||
Handler: app.Handler(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
logger.Info("Sense server started", "address", cfg.HTTPAddress, "database_mode", cfg.DatabaseMode)
|
||||
errCh <- server.ListenAndServe()
|
||||
}()
|
||||
|
||||
stopCh := make(chan os.Signal, 1)
|
||||
signal.Notify(stopCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
select {
|
||||
case <-stopCh:
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return server.Shutdown(ctx)
|
||||
case serveErr := <-errCh:
|
||||
if serveErr == http.ErrServerClosed {
|
||||
return nil
|
||||
}
|
||||
return serveErr
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package config
|
||||
|
||||
// Package config exists as the stable public configuration boundary for Sense.
|
||||
// Runtime parsing is implemented in internal/platform so feature packages do
|
||||
// not read process environment variables directly.
|
||||
@@ -0,0 +1,13 @@
|
||||
module yovision.local/sense
|
||||
|
||||
go 1.26.5
|
||||
|
||||
require github.com/jackc/pgx/v5 v5.8.0
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,103 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
config Config
|
||||
database *sql.DB
|
||||
logger *slog.Logger
|
||||
mux *http.ServeMux
|
||||
migrations []Migration
|
||||
}
|
||||
|
||||
func NewApp(cfg Config, database *sql.DB, logger *slog.Logger) *App {
|
||||
app := &App{config: cfg, database: database, logger: logger, mux: http.NewServeMux()}
|
||||
app.registerPlatformRoutes()
|
||||
return app
|
||||
}
|
||||
|
||||
func (a *App) Config() Config { return a.config }
|
||||
func (a *App) Database() *sql.DB { return a.database }
|
||||
func (a *App) Logger() *slog.Logger { return a.logger }
|
||||
|
||||
func (a *App) Handle(pattern string, handler http.Handler) {
|
||||
a.mux.Handle(pattern, handler)
|
||||
}
|
||||
|
||||
func (a *App) HandleFunc(pattern string, handler http.HandlerFunc) {
|
||||
a.mux.HandleFunc(pattern, handler)
|
||||
}
|
||||
|
||||
func (a *App) Handler() http.Handler {
|
||||
return requestSecurityHeaders(a.mux)
|
||||
}
|
||||
|
||||
func (a *App) registerPlatformRoutes() {
|
||||
a.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok", "product": "sense"})
|
||||
})
|
||||
a.mux.HandleFunc("GET /readyz", a.ready)
|
||||
a.mux.HandleFunc("GET /api/v1/system/info", func(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"product": "Sense",
|
||||
"database": a.config.DatabaseMode,
|
||||
"modules": []string{},
|
||||
})
|
||||
})
|
||||
// Deliberately do not register upstream demo/code-generation routes.
|
||||
if a.config.StaticDir != "" {
|
||||
a.registerStaticFiles(a.config.StaticDir)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) ready(w http.ResponseWriter, r *http.Request) {
|
||||
if a.database == nil {
|
||||
WriteJSON(w, http.StatusOK, map[string]string{"status": "ready", "database": "memory"})
|
||||
return
|
||||
}
|
||||
ctx, cancel := timeLimitedContext(r, 2*time.Second)
|
||||
defer cancel()
|
||||
if err := a.database.PingContext(ctx); err != nil {
|
||||
WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "not_ready", "database": "unavailable"})
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, map[string]string{"status": "ready", "database": "postgres"})
|
||||
}
|
||||
|
||||
func (a *App) registerStaticFiles(directory string) {
|
||||
absolute, err := filepath.Abs(directory)
|
||||
if err != nil {
|
||||
a.logger.Warn("invalid UI static directory", "error", err)
|
||||
return
|
||||
}
|
||||
fileSystem := os.DirFS(absolute)
|
||||
a.mux.Handle("GET /assets/", http.StripPrefix("/", http.FileServer(http.FS(fileSystem))))
|
||||
a.mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(filepath.ToSlash(filepath.Clean(r.URL.Path)), "/")
|
||||
if path == "." || path == "" {
|
||||
path = "index.html"
|
||||
}
|
||||
if _, err := fs.Stat(fileSystem, path); err != nil {
|
||||
path = "index.html"
|
||||
}
|
||||
http.ServeFileFS(w, r, fileSystem, path)
|
||||
})
|
||||
}
|
||||
|
||||
func requestSecurityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPlatformRoutesAndDisabledDemo(t *testing.T) {
|
||||
app := NewApp(Config{DatabaseMode: DatabaseModeMemory}, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
for _, test := range []struct {
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
{path: "/healthz", want: http.StatusOK},
|
||||
{path: "/readyz", want: http.StatusOK},
|
||||
{path: "/api/v1/system/info", want: http.StatusOK},
|
||||
{path: "/api/v1/demo", want: http.StatusNotFound},
|
||||
{path: "/api/v1/system/tools", want: http.StatusNotFound},
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, test.path, nil)
|
||||
res := httptest.NewRecorder()
|
||||
app.Handler().ServeHTTP(res, req)
|
||||
if res.Code != test.want {
|
||||
t.Errorf("GET %s status = %d, want %d", test.path, res.Code, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
DatabaseModePostgres = "postgres"
|
||||
DatabaseModeMemory = "memory"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTPAddress string
|
||||
DatabaseMode string
|
||||
DatabaseURL string
|
||||
StaticDir string
|
||||
LogLevelName string
|
||||
}
|
||||
|
||||
func LoadConfig() (Config, error) {
|
||||
cfg := Config{
|
||||
HTTPAddress: valueOrDefault("SENSE_HTTP_ADDRESS", "127.0.0.1:18080"),
|
||||
DatabaseMode: strings.ToLower(valueOrDefault("SENSE_DATABASE_MODE", DatabaseModePostgres)),
|
||||
DatabaseURL: strings.TrimSpace(os.Getenv("SENSE_DATABASE_URL")),
|
||||
StaticDir: strings.TrimSpace(os.Getenv("SENSE_UI_STATIC_DIR")),
|
||||
LogLevelName: strings.ToLower(valueOrDefault("SENSE_LOG_LEVEL", "info")),
|
||||
}
|
||||
|
||||
switch cfg.DatabaseMode {
|
||||
case DatabaseModePostgres:
|
||||
if cfg.DatabaseURL == "" {
|
||||
return Config{}, fmt.Errorf("SENSE_DATABASE_URL is required when SENSE_DATABASE_MODE=postgres")
|
||||
}
|
||||
case DatabaseModeMemory:
|
||||
// Explicitly limited to local smoke tests and automated tests.
|
||||
default:
|
||||
return Config{}, fmt.Errorf("unsupported SENSE_DATABASE_MODE %q", cfg.DatabaseMode)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c Config) LogLevel() slog.Level {
|
||||
switch c.LogLevelName {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
func valueOrDefault(key, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package platform
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLoadConfigRequiresPostgresURL(t *testing.T) {
|
||||
t.Setenv("SENSE_DATABASE_MODE", "postgres")
|
||||
t.Setenv("SENSE_DATABASE_URL", "")
|
||||
if _, err := LoadConfig(); err == nil {
|
||||
t.Fatal("expected missing PostgreSQL URL to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigAllowsExplicitMemoryMode(t *testing.T) {
|
||||
t.Setenv("SENSE_DATABASE_MODE", "memory")
|
||||
t.Setenv("SENSE_DATABASE_URL", "")
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
if cfg.DatabaseMode != DatabaseModeMemory {
|
||||
t.Fatalf("DatabaseMode = %q", cfg.DatabaseMode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func timeLimitedContext(r *http.Request, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(r.Context(), timeout)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
func OpenDatabase(ctx context.Context, cfg Config) (*sql.DB, error) {
|
||||
if cfg.DatabaseMode == DatabaseModeMemory {
|
||||
return nil, nil
|
||||
}
|
||||
database, err := sql.Open("pgx", cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open Sense PostgreSQL: %w", err)
|
||||
}
|
||||
database.SetMaxOpenConns(20)
|
||||
database.SetMaxIdleConns(5)
|
||||
database.SetConnMaxLifetime(30 * time.Minute)
|
||||
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := database.PingContext(pingCtx); err != nil {
|
||||
database.Close()
|
||||
return nil, fmt.Errorf("connect Sense PostgreSQL: %w", err)
|
||||
}
|
||||
return database, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type APIError struct {
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string { return e.Message }
|
||||
|
||||
func WriteJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func WriteError(w http.ResponseWriter, err error) {
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
WriteJSON(w, apiErr.Status, map[string]any{"error": map[string]string{"code": apiErr.Code, "message": apiErr.Message}})
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "internal_error", "message": "服务暂时不可用"}})
|
||||
}
|
||||
|
||||
func DecodeJSON(r *http.Request, target any) error {
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return &APIError{Status: http.StatusBadRequest, Code: "invalid_request", Message: "请求内容格式不正确"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type Migration struct {
|
||||
Version int64
|
||||
Name string
|
||||
SQL string
|
||||
}
|
||||
|
||||
func (a *App) RegisterMigration(migration Migration) {
|
||||
a.migrations = append(a.migrations, migration)
|
||||
}
|
||||
|
||||
func (a *App) ApplyMigrations(ctx context.Context) error {
|
||||
if a.database == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := a.database.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS sense_schema_migrations (
|
||||
version BIGINT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create Sense migration table: %w", err)
|
||||
}
|
||||
|
||||
sort.Slice(a.migrations, func(i, j int) bool { return a.migrations[i].Version < a.migrations[j].Version })
|
||||
for _, migration := range a.migrations {
|
||||
if err := applyMigration(ctx, a.database, migration); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyMigration(ctx context.Context, database *sql.DB, migration Migration) error {
|
||||
tx, err := database.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %d: %w", migration.Version, err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var exists bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM sense_schema_migrations WHERE version = $1)`, migration.Version).Scan(&exists); err != nil {
|
||||
return fmt.Errorf("check migration %d: %w", migration.Version, err)
|
||||
}
|
||||
if exists {
|
||||
return tx.Commit()
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, migration.SQL); err != nil {
|
||||
return fmt.Errorf("apply migration %d (%s): %w", migration.Version, migration.Name, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO sense_schema_migrations(version, name) VALUES($1, $2)`, migration.Version, migration.Name); err != nil {
|
||||
return fmt.Errorf("record migration %d: %w", migration.Version, err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
sensecmd "yovision.local/sense/cmd/sense"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := sensecmd.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.eslintcache
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "yovision-sense-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.15.1",
|
||||
"engines": {
|
||||
"node": "22.22.1",
|
||||
"pnpm": "9.15.1"
|
||||
},
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "eslint \"src/**/*.{js,vue}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "2.3.2",
|
||||
"axios": "1.13.6",
|
||||
"element-plus": "2.14.4",
|
||||
"vue": "3.5.41",
|
||||
"vue-router": "4.6.4",
|
||||
"vuex": "4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "7.28.6",
|
||||
"@vue/cli-plugin-babel": "5.0.9",
|
||||
"@vue/cli-service": "5.0.9",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-plugin-vue": "9.33.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:vue/vue3-recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"parser": "@babel/eslint-parser",
|
||||
"requireConfigFile": false
|
||||
},
|
||||
"rules": {
|
||||
"vue/multi-word-component-names": "off"
|
||||
}
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not dead"
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+8091
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,24 @@
|
||||
const { defineConfig } = require('@vue/cli-service')
|
||||
|
||||
module.exports = defineConfig({
|
||||
transpileDependencies: true,
|
||||
publicPath: '/',
|
||||
outputDir: 'dist',
|
||||
pages: {
|
||||
index: {
|
||||
entry: 'src/bootstrap/main.js',
|
||||
template: 'src/bootstrap/index.html',
|
||||
title: 'Sense 视频感知管理'
|
||||
}
|
||||
},
|
||||
devServer: {
|
||||
port: 18081,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.SENSE_API_PROXY || 'http://127.0.0.1:18080',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user