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
+15
View File
@@ -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.
+22
View File
@@ -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.
+22
View File
@@ -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.
+55
View File
@@ -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.
+7
View File
@@ -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.'
+38
View File
@@ -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()
}
+79
View File
@@ -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
}
}
+5
View File
@@ -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.
+13
View File
@@ -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
)
+26
View File
@@ -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=
+103
View File
@@ -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)
}
}
}
+63
View File
@@ -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)
}
}
+11
View File
@@ -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
}
+39
View File
@@ -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()
}
+13
View File
@@ -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)
}
}
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.eslintcache
+53
View File
@@ -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"
]
}
+8091
View File
File diff suppressed because it is too large Load Diff
+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>
+24
View File
@@ -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
}
}
}
})