feat: 初始化平台总后台与核心API

This commit is contained in:
2026-07-26 18:06:14 +08:00
parent 44122809b1
commit 4382641e19
38 changed files with 1837 additions and 25 deletions

7
backend/.env.example Normal file
View File

@@ -0,0 +1,7 @@
# PostgreSQL 为首期唯一必需的真实依赖;请仅在本地环境文件中填写真实连接串。
DATABASE_URL=postgres://postgres:change-me@localhost:5432/agent_dev?sslmode=disable
PORT=8080
# 其它外部能力在首期使用 Mock不要在仓库提交真实 Redis、支付或 IoT 凭证。
REDIS_URL=redis://default:change-me@127.0.0.1:6379/0
APP_ENV=development

11
backend/README.md Normal file
View File

@@ -0,0 +1,11 @@
# 平台核心后端
首期实现 M0 + M1 的平台总后台 API组织、服务人员、用户、安全事件和仪表盘。PostgreSQL 保存业务事实支付、地图、IoT、短信和 Redis 在本阶段均通过 Mock 边界预留,不连接生产外部服务。
## 运行
1.`.env.example` 复制为本地环境文件,设置 `DATABASE_URL`
2. 在 PowerShell 设置 `DATABASE_URL` 后执行 `go run ./cmd/api`
3. 服务启动时自动执行幂等迁移和演示数据初始化。
接口前缀为 `/api/v1`,例如:`GET /api/v1/dashboard/overview`

58
backend/cmd/api/main.go Normal file
View File

@@ -0,0 +1,58 @@
// 平台核心 API 的启动入口。
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git.apinb.com/heqiapp/platforms/backend/internal/config"
"git.apinb.com/heqiapp/platforms/backend/internal/database"
platformhttp "git.apinb.com/heqiapp/platforms/backend/internal/http"
)
func main() {
appConfig := config.Load()
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
pool, err := database.Connect(context.Background(), appConfig.DatabaseURL)
if err != nil {
logger.Error("连接 PostgreSQL 失败", "error", err)
os.Exit(1)
}
defer pool.Close()
if err := database.MigrateAndSeed(context.Background(), pool); err != nil {
logger.Error("初始化数据库失败", "error", err)
os.Exit(1)
}
server := &http.Server{
Addr: ":" + appConfig.Port,
Handler: platformhttp.NewRouter(pool, logger),
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
logger.Info("平台核心 API 已启动", "address", server.Addr)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("HTTP 服务异常退出", "error", err)
os.Exit(1)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownContext); err != nil {
logger.Error("HTTP 服务停止失败", "error", err)
}
}

16
backend/go.mod Normal file
View File

@@ -0,0 +1,16 @@
module git.apinb.com/heqiapp/platforms/backend
go 1.26.1
require (
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.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
)

28
backend/go.sum Normal file
View File

@@ -0,0 +1,28 @@
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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
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=

View File

@@ -0,0 +1,19 @@
// Package config 提供运行配置,不在代码或仓库中保存真实密钥。
package config
import "os"
// Config 是 API 进程的最小运行配置。
type Config struct {
DatabaseURL string
Port string
}
// Load 读取环境变量并提供适用于本地开发的端口默认值。
func Load() Config {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
return Config{DatabaseURL: os.Getenv("DATABASE_URL"), Port: port}
}

View File

@@ -0,0 +1,17 @@
// Package database 负责 PostgreSQL 连接、迁移和演示数据初始化。
package database
import (
"context"
"errors"
"github.com/jackc/pgx/v5/pgxpool"
)
// Connect 建立 PostgreSQL 连接池;数据库地址必须通过环境变量提供。
func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
if databaseURL == "" {
return nil, errors.New("缺少 DATABASE_URL 环境变量")
}
return pgxpool.New(ctx, databaseURL)
}

View File

@@ -0,0 +1,80 @@
package database
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// MigrateAndSeed 执行幂等结构迁移,并只在空库时创建演示数据。
func MigrateAndSeed(ctx context.Context, pool *pgxpool.Pool) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS idn_account (
identity uuid PRIMARY KEY, phone varchar(32) NOT NULL UNIQUE, account_type varchar(32) NOT NULL,
status varchar(32) NOT NULL, service_area varchar(128) NOT NULL, created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL, version integer NOT NULL DEFAULT 1
)`,
`CREATE TABLE IF NOT EXISTS org_gas_station (
identity uuid PRIMARY KEY, station_code varchar(32) NOT NULL UNIQUE, name varchar(128) NOT NULL,
principal varchar(64) NOT NULL, service_area varchar(128) NOT NULL, status varchar(32) NOT NULL,
created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, version integer NOT NULL DEFAULT 1
)`,
`CREATE TABLE IF NOT EXISTS org_delivery_point (
identity uuid PRIMARY KEY, delivery_code varchar(32) NOT NULL UNIQUE, gas_station_identity uuid REFERENCES org_gas_station(identity),
name varchar(128) NOT NULL, service_area varchar(128) NOT NULL, status varchar(32) NOT NULL,
created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, version integer NOT NULL DEFAULT 1
)`,
`CREATE TABLE IF NOT EXISTS org_service_person (
identity uuid PRIMARY KEY, account_identity uuid UNIQUE REFERENCES idn_account(identity), gas_station_identity uuid REFERENCES org_gas_station(identity),
delivery_point_identity uuid REFERENCES org_delivery_point(identity), name varchar(64) NOT NULL, roles varchar(128) NOT NULL,
work_status varchar(32) NOT NULL, credential_status varchar(32) NOT NULL, created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL, version integer NOT NULL DEFAULT 1
)`,
`CREATE TABLE IF NOT EXISTS org_user_service_relation (
identity uuid PRIMARY KEY, account_identity uuid NOT NULL REFERENCES idn_account(identity), gas_station_identity uuid REFERENCES org_gas_station(identity),
delivery_point_identity uuid REFERENCES org_delivery_point(identity), source varchar(32) NOT NULL, status varchar(32) NOT NULL,
created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, version integer NOT NULL DEFAULT 1
)`,
`CREATE TABLE IF NOT EXISTS dev_device (
identity uuid PRIMARY KEY, device_code varchar(64) NOT NULL UNIQUE, account_identity uuid REFERENCES idn_account(identity),
online_status varchar(32) NOT NULL, valve_status varchar(32) NOT NULL, created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL, version integer NOT NULL DEFAULT 1
)`,
`CREATE TABLE IF NOT EXISTS saf_event (
identity uuid PRIMARY KEY, event_code varchar(32) NOT NULL UNIQUE, device_identity uuid REFERENCES dev_device(identity),
level integer NOT NULL CHECK (level BETWEEN 1 AND 3), title varchar(256) NOT NULL, status varchar(32) NOT NULL,
created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, version integer NOT NULL DEFAULT 1
)`,
`CREATE TABLE IF NOT EXISTS aud_operation_log (
identity uuid PRIMARY KEY, operator_identity uuid, action varchar(64) NOT NULL, object_type varchar(64) NOT NULL,
object_identity uuid, detail jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_org_delivery_point_gas_station_identity ON org_delivery_point(gas_station_identity)`,
`CREATE INDEX IF NOT EXISTS idx_org_service_person_gas_station_identity ON org_service_person(gas_station_identity)`,
`CREATE INDEX IF NOT EXISTS idx_saf_event_status_level ON saf_event(status, level)`,
`COMMENT ON TABLE idn_account IS '身份账户主表'`,
`COMMENT ON COLUMN idn_account.identity IS '主键,应用生成的 UUID V7'`,
`COMMENT ON TABLE org_gas_station IS '可燃气体站主表'`,
`COMMENT ON COLUMN org_gas_station.identity IS '主键,应用生成的 UUID V7'`,
`COMMENT ON TABLE org_delivery_point IS '配送点主表'`,
`COMMENT ON COLUMN org_delivery_point.identity IS '主键,应用生成的 UUID V7'`,
`COMMENT ON TABLE org_service_person IS '服务人员主表'`,
`COMMENT ON COLUMN org_service_person.identity IS '主键,应用生成的 UUID V7'`,
`COMMENT ON TABLE org_user_service_relation IS '用户服务关系表'`,
`COMMENT ON COLUMN org_user_service_relation.identity IS '主键,应用生成的 UUID V7'`,
`COMMENT ON TABLE dev_device IS '智能瓶阀设备主表'`,
`COMMENT ON COLUMN dev_device.identity IS '主键,应用生成的 UUID V7'`,
`COMMENT ON TABLE saf_event IS '安全事件主表'`,
`COMMENT ON COLUMN saf_event.identity IS '主键,应用生成的 UUID V7'`,
`COMMENT ON TABLE aud_operation_log IS '不可变操作审计日志表'`,
`COMMENT ON COLUMN aud_operation_log.identity IS '主键,应用生成的 UUID V7'`,
}
for _, statement := range statements {
if _, err := pool.Exec(ctx, statement); err != nil {
return fmt.Errorf("执行数据库迁移失败: %w", err)
}
}
return seed(ctx, pool)
}

View File

@@ -0,0 +1,66 @@
package database
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// seed 仅为空数据库提供可用于平台总后台演示的初始数据。
func seed(ctx context.Context, pool *pgxpool.Pool) error {
var count int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM org_gas_station`).Scan(&count); err != nil {
return err
}
if count > 0 {
return nil
}
tx, err := pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
now := time.Now().UTC()
stationID := newIdentity()
deliveryID := newIdentity()
userID := newIdentity()
personAccountID := newIdentity()
personID := newIdentity()
deviceID := newIdentity()
eventID := newIdentity()
queries := []struct {
sql string
args []any
}{
{`INSERT INTO org_gas_station (identity, station_code, name, principal, service_area, status, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$7)`, []any{stationID, "GS-1001", "浦东可燃气体站", "张敏", "浦东新区", "enabled", now}},
{`INSERT INTO org_delivery_point (identity, delivery_code, gas_station_identity, name, service_area, status, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$7)`, []any{deliveryID, "DP-2001", stationID, "陆家嘴配送点", "陆家嘴片区", "enabled", now}},
{`INSERT INTO idn_account (identity, phone, account_type, status, service_area, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$6)`, []any{userID, "13800000001", "user", "enabled", "浦东新区", now}},
{`INSERT INTO idn_account (identity, phone, account_type, status, service_area, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$6)`, []any{personAccountID, "13900000001", "service_person", "enabled", "陆家嘴片区", now}},
{`INSERT INTO org_service_person (identity, account_identity, gas_station_identity, delivery_point_identity, name, roles, work_status, credential_status, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$9)`, []any{personID, personAccountID, stationID, deliveryID, "李强", "delivery", "on_duty", "valid", now}},
{`INSERT INTO org_user_service_relation (identity, account_identity, gas_station_identity, delivery_point_identity, source, status, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$7)`, []any{newIdentity(), userID, stationID, deliveryID, "seed", "active", now}},
{`INSERT INTO dev_device (identity, device_code, account_identity, online_status, valve_status, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$6)`, []any{deviceID, "DEV-1001", userID, "online", "closed", now}},
{`INSERT INTO saf_event (identity, event_code, device_identity, level, title, status, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$7)`, []any{eventID, "SAF-3001", deviceID, 1, "设备压力异常待处置", "pending", now}},
{`INSERT INTO aud_operation_log (identity, action, object_type, object_identity, detail, created_at) VALUES ($1,$2,$3,$4,$5,$6)`, []any{newIdentity(), "seed", "org_gas_station", stationID, `{"source":"development"}`, now}},
}
for _, query := range queries {
if _, err := tx.Exec(ctx, query.sql, query.args...); err != nil {
return fmt.Errorf("写入演示数据失败: %w", err)
}
}
return tx.Commit(ctx)
}
// newIdentity 生成符合全局命名规范的时间有序 UUID V7 主键。
func newIdentity() uuid.UUID {
identity, err := uuid.NewV7()
if err != nil {
panic(err)
}
return identity
}

View File

@@ -0,0 +1,107 @@
// Package http 提供平台总后台的 HTTP 路由和统一响应。
package http
import (
"encoding/json"
"log/slog"
"net/http"
"time"
"git.apinb.com/heqiapp/platforms/backend/internal/platform"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// NewRouter 注册首期平台总后台所需的只读列表、仪表盘与气站创建接口。
func NewRouter(pool *pgxpool.Pool, logger *slog.Logger) http.Handler {
repository := platform.NewRepository(pool)
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, request *http.Request) {
writeJSON(writer, http.StatusOK, map[string]any{"status": "ok"})
})
mux.HandleFunc("GET /api/v1/dashboard/overview", func(writer http.ResponseWriter, request *http.Request) {
respondRepository(writer, request, logger, func() (any, error) { return repository.DashboardOverview(request.Context()) })
})
mux.HandleFunc("GET /api/v1/org/gas-station", func(writer http.ResponseWriter, request *http.Request) {
respondRepository(writer, request, logger, func() (any, error) { return repository.ListOrgGasStation(request.Context()) })
})
mux.HandleFunc("POST /api/v1/org/gas-station", func(writer http.ResponseWriter, request *http.Request) {
var body struct {
StationCode string `json:"stationCode"`
Name string `json:"name"`
Principal string `json:"principal"`
ServiceArea string `json:"serviceArea"`
}
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.StationCode == "" || body.Name == "" || body.Principal == "" || body.ServiceArea == "" {
writeError(writer, http.StatusBadRequest, "参数不完整:站点编码、名称、负责人和服务区域均为必填")
return
}
respondRepositoryWithStatus(writer, request, logger, http.StatusCreated, func() (any, error) {
return repository.CreateOrgGasStation(request.Context(), body.StationCode, body.Name, body.Principal, body.ServiceArea)
})
})
mux.HandleFunc("GET /api/v1/org/delivery-point", func(writer http.ResponseWriter, request *http.Request) {
respondRepository(writer, request, logger, func() (any, error) { return repository.ListOrgDeliveryPoint(request.Context()) })
})
mux.HandleFunc("GET /api/v1/org/service-person", func(writer http.ResponseWriter, request *http.Request) {
respondRepository(writer, request, logger, func() (any, error) { return repository.ListOrgServicePerson(request.Context()) })
})
mux.HandleFunc("GET /api/v1/idn/account", func(writer http.ResponseWriter, request *http.Request) {
respondRepository(writer, request, logger, func() (any, error) { return repository.ListIdnAccount(request.Context()) })
})
mux.HandleFunc("GET /api/v1/saf/event", func(writer http.ResponseWriter, request *http.Request) {
respondRepository(writer, request, logger, func() (any, error) { return repository.ListSafEvent(request.Context()) })
})
return requestLogger(cors(mux), logger)
}
func respondRepository(writer http.ResponseWriter, request *http.Request, logger *slog.Logger, query func() (any, error)) {
respondRepositoryWithStatus(writer, request, logger, http.StatusOK, query)
}
func respondRepositoryWithStatus(writer http.ResponseWriter, request *http.Request, logger *slog.Logger, status int, query func() (any, error)) {
result, err := query()
if err != nil {
logger.Error("处理平台 API 请求失败", "method", request.Method, "path", request.URL.Path, "error", err)
writeError(writer, http.StatusInternalServerError, "服务暂不可用,请稍后重试")
return
}
writeJSON(writer, status, map[string]any{"data": result})
}
func writeJSON(writer http.ResponseWriter, status int, data any) {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(data)
}
func writeError(writer http.ResponseWriter, status int, message string) {
writeJSON(writer, status, map[string]any{"error": message})
}
func cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Access-Control-Allow-Origin", "http://localhost:5173")
writer.Header().Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type,Idempotency-Key")
if request.Method == http.MethodOptions {
writer.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(writer, request)
})
}
func requestLogger(next http.Handler, logger *slog.Logger) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requestID, err := uuid.NewV7()
if err == nil {
writer.Header().Set("X-Request-Id", requestID.String())
}
startedAt := time.Now()
next.ServeHTTP(writer, request)
logger.Info("平台 API 请求完成", "method", request.Method, "path", request.URL.Path, "duration", time.Since(startedAt).String())
})
}

View File

@@ -0,0 +1,10 @@
package platform
// IdnAccount 对应 idn_account表示平台用户的最小账户视图。
type IdnAccount struct {
Identity string `json:"identity"`
PhoneMasked string `json:"phoneMasked"`
AccountType string `json:"accountType"`
Status string `json:"status"`
ServiceArea string `json:"serviceArea"`
}

View File

@@ -0,0 +1,11 @@
package platform
// OrgDeliveryPoint 对应 org_delivery_point表示末端配送组织单元。
type OrgDeliveryPoint struct {
Identity string `json:"identity"`
DeliveryCode string `json:"deliveryCode"`
Name string `json:"name"`
GasStationName string `json:"gasStationName"`
ServiceArea string `json:"serviceArea"`
Status string `json:"status"`
}

View File

@@ -0,0 +1,15 @@
// Package platform 包含平台总后台的领域模型和数据访问。
package platform
import "time"
// OrgGasStation 对应 org_gas_station表示可燃气体站经营主体。
type OrgGasStation struct {
Identity string `json:"identity"`
StationCode string `json:"stationCode"`
Name string `json:"name"`
Principal string `json:"principal"`
ServiceArea string `json:"serviceArea"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
}

View File

@@ -0,0 +1,11 @@
package platform
// OrgServicePerson 对应 org_service_person表示具备一个或多个服务角色的人员。
type OrgServicePerson struct {
Identity string `json:"identity"`
Name string `json:"name"`
PhoneMasked string `json:"phoneMasked"`
Roles string `json:"roles"`
WorkStatus string `json:"workStatus"`
Credential string `json:"credentialStatus"`
}

View File

@@ -0,0 +1,181 @@
package platform
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// Repository 封装平台总后台首期需要的 PostgreSQL 查询。
type Repository struct {
pool *pgxpool.Pool
}
// NewRepository 创建平台领域数据访问实例。
func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
// DashboardOverview 返回平台总后台首页所需的聚合指标。
func (repository *Repository) DashboardOverview(ctx context.Context) (map[string]int, error) {
queries := map[string]string{
"gasStationCount": `SELECT count(*) FROM org_gas_station WHERE status = 'enabled'`,
"deliveryPointCount": `SELECT count(*) FROM org_delivery_point WHERE status = 'enabled'`,
"servicePersonCount": `SELECT count(*) FROM org_service_person WHERE work_status = 'on_duty'`,
"userCount": `SELECT count(*) FROM idn_account WHERE account_type = 'user' AND status = 'enabled'`,
"pendingSafetyCount": `SELECT count(*) FROM saf_event WHERE status = 'pending'`,
}
result := make(map[string]int, len(queries))
for key, query := range queries {
var count int
if err := repository.pool.QueryRow(ctx, query).Scan(&count); err != nil {
return nil, err
}
result[key] = count
}
return result, nil
}
// ListOrgGasStation 查询可燃气体站列表。
func (repository *Repository) ListOrgGasStation(ctx context.Context) ([]OrgGasStation, error) {
rows, err := repository.pool.Query(ctx, `SELECT identity, station_code, name, principal, service_area, status, created_at FROM org_gas_station ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]OrgGasStation, 0)
for rows.Next() {
var item OrgGasStation
if err := rows.Scan(&item.Identity, &item.StationCode, &item.Name, &item.Principal, &item.ServiceArea, &item.Status, &item.CreatedAt); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
// CreateOrgGasStation 创建可燃气体站并写入不可变审计记录。
func (repository *Repository) CreateOrgGasStation(ctx context.Context, stationCode, name, principal, serviceArea string) (OrgGasStation, error) {
identity, err := uuid.NewV7()
if err != nil {
return OrgGasStation{}, err
}
now := time.Now().UTC()
item := OrgGasStation{Identity: identity.String(), StationCode: stationCode, Name: name, Principal: principal, ServiceArea: serviceArea, Status: "draft", CreatedAt: now}
tx, err := repository.pool.Begin(ctx)
if err != nil {
return OrgGasStation{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
_, err = tx.Exec(ctx, `INSERT INTO org_gas_station (identity, station_code, name, principal, service_area, status, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$7)`, identity, stationCode, name, principal, serviceArea, item.Status, now)
if err != nil {
return OrgGasStation{}, fmt.Errorf("创建气站失败: %w", err)
}
_, err = tx.Exec(ctx, `INSERT INTO aud_operation_log (identity, action, object_type, object_identity, detail, created_at) VALUES ($1,$2,$3,$4,$5,$6)`, newIdentity(), "create", "org_gas_station", identity, `{"channel":"platform_admin"}`, now)
if err != nil {
return OrgGasStation{}, err
}
if err = tx.Commit(ctx); err != nil {
return OrgGasStation{}, err
}
return item, nil
}
// ListOrgDeliveryPoint 查询配送点及其气站归属。
func (repository *Repository) ListOrgDeliveryPoint(ctx context.Context) ([]OrgDeliveryPoint, error) {
rows, err := repository.pool.Query(ctx, `SELECT d.identity, d.delivery_code, d.name, COALESCE(g.name, ''), d.service_area, d.status FROM org_delivery_point d LEFT JOIN org_gas_station g ON d.gas_station_identity = g.identity ORDER BY d.created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]OrgDeliveryPoint, 0)
for rows.Next() {
var item OrgDeliveryPoint
if err := rows.Scan(&item.Identity, &item.DeliveryCode, &item.Name, &item.GasStationName, &item.ServiceArea, &item.Status); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
// ListOrgServicePerson 查询服务人员并在 API 层完成手机号脱敏。
func (repository *Repository) ListOrgServicePerson(ctx context.Context) ([]OrgServicePerson, error) {
rows, err := repository.pool.Query(ctx, `SELECT p.identity, p.name, a.phone, p.roles, p.work_status, p.credential_status FROM org_service_person p JOIN idn_account a ON p.account_identity = a.identity ORDER BY p.created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]OrgServicePerson, 0)
for rows.Next() {
var item OrgServicePerson
var phone string
if err := rows.Scan(&item.Identity, &item.Name, &phone, &item.Roles, &item.WorkStatus, &item.Credential); err != nil {
return nil, err
}
item.PhoneMasked = maskPhone(phone)
items = append(items, item)
}
return items, rows.Err()
}
// ListIdnAccount 查询用户最小必要视图,不返回完整手机号。
func (repository *Repository) ListIdnAccount(ctx context.Context) ([]IdnAccount, error) {
rows, err := repository.pool.Query(ctx, `SELECT identity, phone, account_type, status, service_area FROM idn_account WHERE account_type = 'user' ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]IdnAccount, 0)
for rows.Next() {
var item IdnAccount
var phone string
if err := rows.Scan(&item.Identity, &phone, &item.AccountType, &item.Status, &item.ServiceArea); err != nil {
return nil, err
}
item.PhoneMasked = maskPhone(phone)
items = append(items, item)
}
return items, rows.Err()
}
// ListSafEvent 查询安全事件列表,供平台安全运营中心使用。
func (repository *Repository) ListSafEvent(ctx context.Context) ([]SafEvent, error) {
rows, err := repository.pool.Query(ctx, `SELECT identity, event_code, level, title, status, created_at FROM saf_event ORDER BY level ASC, created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]SafEvent, 0)
for rows.Next() {
var item SafEvent
if err := rows.Scan(&item.Identity, &item.EventCode, &item.Level, &item.Title, &item.Status, &item.CreatedAt); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
// maskPhone 按最小必要原则返回脱敏手机号。
func maskPhone(phone string) string {
if len(phone) < 7 {
return "***"
}
return phone[:3] + "****" + phone[len(phone)-4:]
}
// newIdentity 生成审计记录使用的 UUID V7。
func newIdentity() uuid.UUID {
identity, err := uuid.NewV7()
if err != nil {
panic(err)
}
return identity
}

View File

@@ -0,0 +1,13 @@
package platform
import "time"
// SafEvent 对应 saf_event表示需要跟踪处置的安全事件。
type SafEvent struct {
Identity string `json:"identity"`
EventCode string `json:"eventCode"`
Level int `json:"level"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
}

View File

@@ -19,10 +19,10 @@
| 角色 | 主要职责 | 数据范围 |
| --- | --- | --- |
| 普通用户 | 管理智能瓶阀、接收告警、下单、预约、投诉 | 本人及授权家庭成员的设备、地址、订单 |
| 配送员 | 接收配送单、导航、配送取证、提现 | 分配给自己的订单与收益 |
| 安装维修员 | 安装、维修、环境验收、取证 | 分配给自己的工单与设备安装信息 |
| 安检员 | 上门安检、判定等级、复检 | 分配区域/工单内的用户与设备 |
| 普通用户 | 管理智能瓶阀、接收告警、下单、预约、投诉、押金/发票与紧急联系人 | 本人及授权家庭成员的设备、地址、订单 |
| 配送员 | 接收配送单、导航、轨迹上报、随瓶安检、交接取证、空瓶回收、提现 | 分配给自己的订单与收益 |
| 安装维修员 | 安装、维修、环境验收、设备激活、取证 | 分配给自己的工单与设备安装信息 |
| 安检员 | 上门安检、判定等级、整改复检 | 分配区域/工单内的用户与设备 |
| 可燃气体站管理员 | 管理本站商品、优惠券、订单核销、对账提现 | 所属可燃气体站及其服务范围 |
| 平台管理员 | 全局配置、用户/服务人员/气站、运营、资金审核 | 全平台,按后台权限进一步拆分 |
| 扩展:客服 | 工单受理、消息答复、回访 | 被分配的客户与工单,不能改资金和安全判定 |
@@ -60,12 +60,16 @@
| 整改 | 对不合格项采取维修、更换、通风、复检等措施并形成证据 |
| 服务订单 | 商品订单拆分出的配送、安装、维修、安检等履约任务 |
| 气站 | 销售、服务和结算的经营主体,可管理其商品与服务区域 |
| 用户服务关系 | 用户与气站、配送点之间可追溯的服务归属关系;不等同于用户全局账户归属 |
| 邀请注册二维码 | 仅承载可撤销、可过期的签名邀请令牌或短链接,用于注册归因和服务关系建立 |
| 配送轨迹 | 配送任务履约期间的状态节点与定位点记录;仅在履约目的和授权范围内使用 |
## 5. 范围清单
### 5.1 设备与安全
- 设备扫码绑定、列表、详情、远程开/关、控制结果确认。
- 设备分组、家庭成员查看/控制授权、紧急联系人及高风险告警后的通知。
- 上报并展示智能瓶阀状态、电量、电池温度、环境压力和可扩展传感器指标。
- 发生达到规则阈值的安全问题时,自动关闭智能瓶阀(由设备侧/云侧双重保障),创建安全事件并通知用户和平台。
- 安检申请、平台派单、上门检查、不合格分级、整改、复检、抽查与超时升级。
@@ -73,8 +77,9 @@
### 5.2 商城与履约
- 商品分类、商品详情、购物车、收藏、优惠券、收货/安装地址、下单与订单状态。
- 微信、支付宝、余额支付;电子合同第三方签署;配送/安装预约。
- 自动或规则化派单、导航、到达/完成取证、售后、投诉建议。
- 微信、支付宝、余额支付;电子合同第三方签署;配送/安装预约;气瓶押金、退瓶退押金和电子发票
- 自动或规则化派单、导航、到达/完成取证、配送轨迹、随瓶安检、空瓶回收、售后、投诉建议。
- 气站/配送点邀请二维码扫码注册、地址与服务范围校验、服务关系建立和来源归因。
### 5.3 平台运营
@@ -90,6 +95,9 @@
4. 一个订单可以包含多件商品,但配送、安装、维修可拆为多个服务任务;订单主状态由所有必需任务综合计算。
5. 服务人员只能处理被分配且处于工作时间/服务区域内的任务;异常改派须说明原因并保留记录。
6. 用户注销前应校验未完成订单、未关闭安全事件、余额和数据保留义务;满足条件后执行去标识化而非直接删除审计记录。
7. 服务人员自主注册或由组织创建后均须经过角色、资质、培训、上班状态及授权设备校验;任一前置条件不满足时不得开始任务。
8. 邀请注册二维码必须由服务端校验状态、范围、有效期、次数、地址和用户登录状态;不得重复创建用户账户或覆盖既有服务关系。
9. 配送定位仅在履约期间按最小必要原则采集和展示;用户仅查看本人订单的简化轨迹,精确轨迹回放、导出和跨组织访问须审批审计。
## 7. 待确认事项

View File

@@ -66,14 +66,36 @@ SLA 从事件创建还是用户确认开始计时须由业务方确认;系统
6. 服务人员接单、导航、到达并完成取证。安装订单还须提交环境验收。
7. 所有必需任务完成并通过必要审核,订单转“已完成”;用户可在售后期内发起售后。
## 4. 安装环境不合格
### 3.1 配送、随瓶安检与空瓶回收
1. 配送员仅在资质、每日培训、上班状态、服务区域、车辆与库存能力均通过校验后接单。
2. 到达后,配送员扫描或通过蓝牙读取气瓶/智能瓶阀设备编码,校验规格、所属、状态和是否已被其他任务占用。
3. 按任务规则完成随瓶安检、分类照片/视频、地理围栏、用户签名或签收码;不合格时创建安全事件或整改任务,禁止直接完成配送。
4. 配送员仅能确认已核验的线上支付结果或平台授权的线下收款,金额差异进入对账异常,不能由配送员修改订单金额。
5. 空瓶回收时记录气瓶编码、回收状态、时间、操作者和去向,分别更新气瓶、库存、押金和订单状态;拒收、损坏、逾期或编码不符均进入异常处理。
## 4. 邀请注册与服务关系建立
1. 气站或配送点创建二维码,平台生成带签名、可撤销的邀请码或短链接,并配置归属组织、服务区域、有效期、使用次数与活动来源。
2. 用户在用户端 App 或浏览器扫码后,服务端校验二维码状态、地址服务范围、风控结果和登录状态;失效、越界或停用时明确提示原因。
3. 新用户完成手机号验证、协议同意和服务地址确认后创建全局账户;已有用户登录确认后只建立或变更服务关系,不重复建账号。
4. 系统记录二维码、来源、扫码、注册、地址校验、服务关系与转化结果;重复扫码不得覆盖既有服务关系,变更须展示影响并取得用户确认。
## 5. 服务人员准入与作业闭环
1. 服务人员可在服务端 App 提交配送、安装维修或安检角色申请,也可由气站/配送点创建;申请成功仅生成待审核账户。
2. 平台或授权组织核验实名信息、角色资质、培训、车辆/工具、服务区域和组织归属后授予对应角色能力;安全相关角色须安全主管复核。
3. 每日首次进入工作台前完成角色化安全培训;开始任务前校验上班状态、服务区域、授权设备、资质有效期和任务前置条件。
4. App 弱网时可加密暂存已分配任务、定位和现场材料,恢复后按原始采集时间补传;服务端校验幂等键、时间顺序、完整性与冲突,不得篡改采集时间。
## 6. 安装环境不合格
- 安装维修员按检查表填写不合格项、风险等级、图像/视频证据和处理建议。
- 系统创建或关联安全事件,禁止将设备标记为“已安装可用”;对需要关阀的场景执行安全策略。
- 用户和平台收到明确的整改项、时限和预约入口;必要时分派维修/安检复查任务。
- 复查合格后才允许激活设备、解除限制或继续订单完成流转。
## 5. 资金与提现
## 7. 资金与提现
- 支付、充值、退款、佣金、提现均以不可变资金流水记录,余额是流水聚合结果而非唯一事实来源。
- 提现流程:服务人员/气站提交申请 -> 系统校验可提现余额、实名与风控 -> 财务审核 -> 线下打款/三方打款 -> 回填凭证和结果。

View File

@@ -9,11 +9,12 @@
| 功能域 | 核心功能 | 数据边界 |
| --- | --- | --- |
| 组织权限 | 管理员、角色、权限、可燃气体站、配送点、生产单位、服务区域、数据范围 | 全局;支持按组织、区域、对象归属授权 |
| 用户与服务人员 | 用户 360、服务人员资质、角色、冻结、分账、跨组织调配 | 查看敏感数据受字段权限和审计控制 |
| 用户与服务人员 | 用户 360、服务关系、服务人员准入/培训/打卡、资质、角色、冻结、分账、跨组织调配 | 查看敏感数据受字段权限和审计控制 |
| 设备与安全 | 设备模型、绑定、遥测、命令、规则、事件、SLA、安检/整改、强制升级 | 全局安全规则和高风险动作仅授权角色可操作 |
| 商品交易 | 全局商品主数据、价格策略、订单、售后、支付、合同、优惠规则 | 站点商品与库存由站点系统主责,平台可治理与审计 |
| 资金结算 | 充值、支付、退款、分账、结算、提现、对账、打款凭证 | 财务角色分离,关键审批支持双人复核 |
| 内容与客服 | 轮播、公告、协议、宣教内容、消息模板、客服配置、投诉工单 | 发布有审核、版本和撤回能力 |
| 邀请与轨迹治理 | 气站/配送点邀请二维码、注册归因、配送轨迹、定位访问与导出审批 | 二维码、服务关系和定位均按最小权限、可撤销与审计治理 |
| 全局运营 | 用户/订单/设备/事件/履约指标,地图和异常看板 | 支持跨组织下钻但遵循权限和脱敏 |
| 审计合规 | 操作审计、导出审计、敏感访问、数据留存、注销与合规工单 | 不可由普通管理员删除或篡改 |
@@ -61,9 +62,9 @@
#### 服务人员关键操作
- 平台可批量导入、审核、分配组织/区域、授予角色、设置分账比例、调整可接单状态和发起跨组织调配;气站可在本站授权范围内创建、维护和提交服务人员审核。
- 平台可批量导入、审核、分配组织/区域、授予角色、设置分账比例、调整可接单状态和发起跨组织调配;气站可在本站授权范围内创建、维护和提交服务人员审核。服务人员通过 App 自主注册时,后台应展示申请来源、角色、材料、审核意见和生效记录。
- 平台不得直接替服务人员完成工单;改派、取消、强制下线和资质豁免必须记录原因、审批依据和受影响任务。
- 安装维修与安检等安全相关角色的资质、培训和违规记录应成为派单前置校验;配送任务还应校验配送点、班次、车辆/库存能力。
- 安装维修与安检等安全相关角色的资质、培训、每日培训、上/下班和违规记录应成为派单前置校验;配送任务还应校验配送点、班次、车辆/库存能力。后台可查看授权设备、打卡异常、培训未完成和离线补传异常,但不得替代服务人员提交现场事实。
### 3.4 用户统一管理
@@ -99,11 +100,21 @@ flowchart TB
- 跨组织协作通过任务、调拨、结算和事件记录实现,不允许任一系统直接修改另一主体的核心主档或资金流水。
- 组织合并、区域调整、服务人员调动、用户设备转移均需要影响评估,至少校验未完成任务、在途库存、安全事件、余额和结算状态。
### 3.6 邀请注册二维码与配送轨迹治理
| 管理项 | 平台要求 |
| --- | --- |
| 邀请二维码 | 查看气站/配送点二维码的归属、类型、服务区域、适用活动、有效期、次数、状态、扫码/注册/首单转化及异常拦截;平台可按权限停用、冻结或重新生成,不得直接暴露令牌明文、用户隐私或接口密钥 |
| 服务关系 | 查看用户与气站/配送点的建立来源、二维码归因、生效时间、历史变更和确认记录;重复扫码不重复建档,变更关系须保留原关系和影响评估 |
| 轨迹运营 | 按订单、组织、配送员、区域、时间和异常类型查看轨迹质量、时效和任务事件;用户端只读简化轨迹,完整轨迹遵循组织边界 |
| 精确轨迹访问 | 地图回放、精确坐标和轨迹导出须申请用途、审批期限、字段范围和水印;记录访问人、对象、用途、时间、导出结果及二次传播责任 |
## 4. 安全运营中心
- 实时显示设备离线、低电量、压力/温度异常、人工检查不合格等事件,按等级、超时、是否已关阀和责任方排序。
- 管理告警规则版本、阈值、持续时间、适用设备型号、自动关阀、消息模板、静默期和灰度范围。
- 触发后生成事件、通知、责任任务和升级链路。对强制开阀、降级、关闭高风险事件要求二次确认和完整审计。
- 用户端首次登录或安全宣教更新后的强制阅读内容、展示范围、版本、频率和阅读确认由平台配置;紧急告警不得被宣教弹窗阻断。
## 5. 平台与其他 Web 系统的权责

View File

@@ -10,7 +10,7 @@
| --- | --- | --- |
| 站点与人员 | 站点资料、营业时间、服务区、站内账号、服务人员归属与排班 | 账号与数据仅限本组织;跨站调配需平台审批 |
| 配送点管理 | 创建、编辑、启停本站配送点;负责人、覆盖区域、服务能力、配送仓和配送人员 | 配送点自动归属本站;编码全局唯一;跨站迁移、删除和超范围区域须平台审批 |
| 服务人员管理 | 创建和管理配送员、安装维修员、安检员;角色、资质、排班、服务范围、接单状态与绩效 | 仅限本站人员;安全相关角色须平台/安全主管审核后可接单;不能授予超出站点授权的角色 |
| 服务人员管理 | 创建、导入或审核 App 自主注册的配送员、安装维修员、安检员;角色、资质、培训、打卡、服务范围、接单状态与绩效 | 仅限本站人员;安全相关角色须平台/安全主管审核后可接单;不能授予超出站点授权的角色 |
| 商品与库存 | 本站可售商品/SKU、价格、库存、批次、上下架、库存预警、盘点 | 全局商品主数据由平台治理;已履约商品不可物理删除 |
| 营销 | 满减券、折扣券、发放、核销、适用范围与统计 | 受平台营销政策和预算控制,券核销可追溯 |
| 订单与服务 | 订单、安装/维修/安检任务、预约、异常、售后、核销 | 只处理本站订单;高风险安全事件自动同步平台总后台 |
@@ -37,10 +37,10 @@
| 功能 | 需求与规则 |
| --- | --- |
| 创建导入 | 录入实名资料、联系方式、紧急联系人、所属配送点/站点、服务区域和入职信息;手机号重复时提示关联现有人员或提交平台合并申请 |
| 创建导入与自主申请 | 录入实名资料、联系方式、紧急联系人、所属配送点/站点、服务区域和入职信息;接收 App 自主注册申请并审核角色、材料与来源;手机号重复时提示关联现有人员或提交平台合并申请 |
| 角色配置 | 可授予配送员、安装维修员、安检员角色;分别维护证书、培训、技能、保险、设备/车辆能力和到期时间 |
| 审核与生效 | 配送角色可按平台规则自动/人工审核;安装维修、安检等安全相关角色须平台或安全主管审核通过后才能接收任务 |
| 排班与调度 | 设置上班状态、班次、最大负载、预约时段和服务区域;可对本站任务派单、改派和暂停接单,不可跨站直接派单 |
| 作业前置与调度 | 查看每日培训、上/下班、授权设备、定位权限和离线补传异常;设置班次、最大负载、预约时段和服务区域;任一作业前置不满足时不可派单或开始任务 |
| 绩效与处置 | 查看任务、时效、评分、投诉、违规、收益和分账;可发起冻结、离职、调动申请,最终生效遵循平台审批规则 |
## 5. 用户管理
@@ -74,6 +74,7 @@
- 站点创建或确认需配送的任务,向配送点发布履约需求;配送点确认人员和配送计划后回传状态。
- 商品从站点仓转至配送点仓时,创建调拨单、出入库和签收记录;在途库存独立核算。
- 配送异常、货损、拒收、退货回流均必须同步订单、库存和资金待处理状态。
- 站点可查看本站任务的配送轨迹质量、随瓶安检、气瓶扫码/蓝牙识别、收款确认、空瓶回收与异常对账状态;现场原始证据只能由服务端 App 提交,后台不得补录为已完成。
## 7. 数据边界、审核与审计

View File

@@ -9,7 +9,7 @@
| 模块 | 功能 |
| --- | --- |
| 配送点基础资料 | 配送点地址、覆盖区域、营业时段、车辆、容量、联系人和状态 |
| 服务人员管理 | 创建和管理配送员、安装维修员、安检员的本站/本点归属、资质、班次、可接单状态、负载、评分和异常率;安全相关角色须经平台审核后生效 |
| 服务人员管理 | 创建、导入或审核 App 自主注册的配送员、安装维修员、安检员的本站/本点归属、资质、培训、打卡、班次、可接单状态、负载、评分和异常率;安全相关角色须经平台审核后生效 |
| 用户管理 | 创建线下用户待激活档案、邀请注册、管理本点服务用户、地址、配送偏好、订单、预约、回访与投诉协同 |
| 邀请注册二维码 | 创建、下载、分享、停用和分析本点邀请注册二维码;扫码注册后自动建立本点用户服务关系 |
| 订单调度 | 接收站点/平台配送需求,自动分单、人工改派、预约、路线、配送轨迹、时效预警 |
@@ -23,10 +23,10 @@
| 功能 | 需求与规则 |
| --- | --- |
| 创建关联 | 录入姓名、手机号、实名资料、紧急联系人、服务区域、所属气站/配送点和入职信息;已有平台人员可申请关联到本点,不重复创建账号 |
| 创建关联与自主申请 | 录入姓名、手机号、实名资料、紧急联系人、服务区域、所属气站/配送点和入职信息;审核 App 自主注册申请的角色、材料与来源;已有平台人员可申请关联到本点,不重复创建账号 |
| 角色与能力 | 配置配送、安装维修、安检角色,以及车辆、工具、技能、证书、培训、保险和有效期;角色能力独立生效 |
| 审核与启用 | 配送角色按平台规则审核;安装维修和安检等安全相关角色必须经平台或安全主管审核后才可接收对应任务 |
| 班次与调度 | 设置班次、上班状态、最大负载、预约窗口、服务区域和可服务商品/设备型号;可对本点任务派单、改派、暂停接单 |
| 作业前置与调度 | 查看每日培训、上/下班、授权设备、定位权限和离线补传异常;设置班次、最大负载、预约窗口、服务区域和可服务商品/设备型号;任一作业前置不满足时不可派单或开始任务 |
| 绩效与处置 | 查看任务时效、签收/完工质量、评分、投诉、违规、收益和分账;可发起离职、冻结、跨点支援或调动申请,最终生效按气站/平台审批规则执行 |
## 4. 用户管理
@@ -61,6 +61,7 @@
- 高优先级安全相关配送任务可插单,但应记录影响订单、调度人或规则版本。
- 配送员未在时限内接单、长期无定位/无进度、异常率超过阈值时触发回收、改派或人工干预。
- 签收成功才扣减配送点实际库存;发生拒收或货损时先冻结对应库存与结算,待审核后再回流或报损。
- 配送完成前,调度审核视图必须显示到达校验、气瓶扫码/蓝牙识别、随瓶安检、照片/视频、签收、收款确认和空瓶回收的完成或异常状态;后台只能审核异常,不能补造现场证据。
### 配送轨迹管理

View File

@@ -29,6 +29,14 @@ flowchart LR
G --> H[调拨至站点/配送点或销售库存]
```
### 3.1 出厂设备主档同步契约
生产系统向平台设备域同步的最小信息必须包括:设备序列号、二维码状态、设备型号、硬件/固件版本、设备证书状态、生产批次、出厂质检结论、激活资格和同步时间。平台仅允许“质检合格且具备激活资格”的设备进入销售、安装或用户绑定流程。
- 设备二维码在出厂、作废、换码、召回时均须同步状态;用户端绑定、服务端 App 安装激活和后台设备查询必须校验该状态。
- 生产系统不写入用户、订单、服务关系或现场履约事实;发现设备质量问题时通过设备身份、批次和召回任务与平台安全/履约域关联。
- 同步失败、重复同步、型号/批次不一致或证书失效时进入可追踪异常队列,不得以“已同步”掩盖失败结果。
## 4. 扩展能力
- MES/ERP 集成、供应商质量管理、电子作业指导书、自动测试台、碳足迹、预测性质量分析和召回演练。

View File

@@ -8,7 +8,7 @@ API 中心管理系统用于治理平台向可燃气体站、生产合作方、
| 模块 | 功能 | 规则 |
| --- | --- | --- |
| API 目录 | 设备查询、遥测订阅、订单同步、库存调拨、售后、内容等 API 产品 | 按业务域、版本、环境、受众、数据等级和 SLA 管理 |
| API 目录 | 设备查询、遥测订阅、订单同步、库存调拨、售后、邀请注册归因、配送轨迹摘要、内容等 API 产品 | 按业务域、版本、环境、受众、数据等级和 SLA 管理 |
| 生命周期 | 设计、评审、发布、灰度、废弃、下线 | OpenAPI/AsyncAPI 契约版本化;破坏性变更需弃用期和通知 |
| 应用接入 | 合作方组织、应用、回调地址、用途、负责人、资质审核 | 一应用一身份;生产环境需审核后开通 |
| 认证授权 | OAuth 2.1/OIDC、客户端凭证、mTLS、签名、密钥轮换和吊销 | 最小权限、短期令牌、密钥不可明文回显 |
@@ -21,6 +21,8 @@ API 中心管理系统用于治理平台向可燃气体站、生产合作方、
- 默认不开放远程开阀、强制解除安全限制、提现审核等高危能力;确需开放须独立 API 产品、最小范围、双重鉴权、审批和实时审计。
- 对外查询仅返回调用方业务所需字段,用户手机号、地址、定位、视频、资金信息按数据分类和授权协议最小化提供。
- Webhook 必须校验回调地址、签名、超时、重试和幂等;失败事件进入可追踪的死信队列。
- 邀请注册 API 仅允许校验短链接、查询脱敏转化统计或经授权建立服务关系;不得返回邀请码明文、用户账户凭证、完整地址或组织管理员信息。
- 配送轨迹 API 默认只提供订单履约状态、预计到达和用户可见的简化轨迹。精确坐标、轨迹回放、服务人员非履约位置、签名和现场视频属于受限数据,须独立产品、审批、短期授权和用途审计。
## 4. 开发者体验与扩展

View File

@@ -26,6 +26,7 @@
| 缓存与任务 | Redis | 限流、会话、幂等、短期状态、延迟任务;关键事实仍落 PostgreSQL |
| 消息/事件 | Redis Streams + Consumer Group | 遥测流、告警、订单状态、通知、对账异步化;使用消费组、重试、死信与幂等消费者保障可恢复处理 |
| 文件与证据 | S3 兼容对象存储MinIO/云 OSS | 商品图、安装/安检证据、签收图片、视频和合同;采用短期签名 URL 与生命周期策略 |
| 移动端离线与定位 | Flutter 安全存储 + 本地加密数据库 + 地图/定位 SDK | 服务端 App 的已分配任务、现场材料和配送轨迹可弱网暂存;保留原始采集时间并按幂等键补传 |
| Web 入口与部署 | Caddy + Shell 脚本 | TLS 终止、反向代理、静态文件、压缩、健康检查与受控发布;配置和脚本纳入版本控制 |
| 可观测性 | OpenTelemetry + Prometheus + Grafana + Loki | 指标、链路、日志、告警与审计关联 |
@@ -66,6 +67,12 @@ flowchart LR
业务项目应通过共享包、模板或上游同步机制复用标准库,禁止将标准库目录复制到每个子项目后自行漂移。标准库升级需要记录版本、影响范围、兼容策略和回滚方式。
### 4.1 移动端定位、离线与取证基线
- 服务端 App 仅在配送或现场任务执行期间申请并采集定位;必须显式展示定位授权、后台运行、最后更新时间和权限失效状态。用户端仅消费本人订单所需的简化轨迹。
- 已分配任务、轨迹点、照片/视频元数据、签名、扫描结果和收款确认可在弱网下加密暂存。补传必须携带原始采集时间、服务端接收时间、任务 `identity`、操作者 `identity`、来源、完整性标记与幂等键,禁止以补传时间覆盖采集时间。
- 后端负责乱序校正、重复去除、异常速度/精度标记、证据哈希与状态机校验;前端离线缓存、按钮禁用或页面显示不能替代服务端权限、金额、地理围栏和完成条件校验。
## 5. 研发目录规划(建议)
```text
@@ -128,13 +135,13 @@ platforms/
| 模块 | 表前缀 | 典型表 |
| --- | --- | --- |
| 身份与权限 | `idn_` | `idn_account``idn_role``idn_permission` |
| 组织与服务关系 | `org_` | `org_gas_station``org_delivery_point``org_service_person``org_user_service_relation` |
| 组织与服务关系 | `org_` | `org_gas_station``org_delivery_point``org_service_person``org_user_service_relation``org_invitation_qr_code` |
| 设备与物联网 | `dev_` | `dev_device``dev_telemetry``dev_command` |
| 安全与安检 | `saf_` | `saf_event``saf_rule``saf_inspection``saf_rectification` |
| 商品与营销 | `cat_` | `cat_product``cat_sku``cat_coupon``cat_cart` |
| 订单与履约 | `ord_` | `ord_order``ord_payment``ord_service_task``ord_delivery_track` |
| 调度与配送 | `dsp_` | `dsp_assignment``dsp_shift``dsp_delivery_track_point` |
| 钱包与结算 | `wal_` | `wal_ledger``wal_settlement``wal_withdrawal` |
| 钱包与结算 | `wal_` | `wal_wallet_ledger``wal_settlement``wal_withdrawal` |
| 内容与通知 | `cnt_``ntf_` | `cnt_article``cnt_banner``ntf_message``ntf_template` |
| 生产与质量 | `mfg_` | `mfg_work_order``mfg_batch``mfg_quality_check` |
| 开放接口 | `api_` | `api_product``api_client``api_subscription` |
@@ -160,6 +167,8 @@ platforms/
- 所有实体一律使用单数:一个 `org_gas_station` 既可表示单个站点模型,也可作为列表返回项的模型名称。列表、批量和分页仅在 API 动词或响应字段表达,例如 `GET /org/gas-station/list``items: []`;不改变实体名。
- 关联表使用参与实体的单数词根和明确关系词,例如 `org_user_service_relation``idn_account_role_relation`,不得使用 `users_roles``user_roles` 等复数或含糊名称。
- `ord_delivery_track` 是配送任务的状态轨迹主表,`dsp_delivery_track_point` 是其定位点明细表;二者均为独立实体,不得再创建同义的 `delivery_tracks``track_points` 等表或模型。定位点通过 `delivery_track_identity` 关联主表。
- 钱包事实流水的唯一实体名为 `wal_wallet_ledger`;用户和服务人员的资金归属通过关联对象字段区分,禁止另建同义的 `wal_ledger``wallet_ledgers``service_wallet_ledger`
- 文件目录可以按业务模块组织,但目录名不参与实体命名;模型、迁移、契约、测试文件都必须能从其文件名唯一定位到同名的数据库表和模型。
- 新增实体前应先登记规范名称;重命名须同时修改表、模型、文件、契约、迁移和中文注释,并进行全仓引用检查,禁止仅改其中一层。

View File

@@ -11,12 +11,12 @@
| 数据域 | 核心实体 | 关键约束 |
| --- | --- | --- |
| 身份组织 | `idn_account``idn_role``idn_permission``org_gas_station``org_service_area``idn_emergency_contact` | 手机号/账号唯一;角色和数据范围均生效;紧急联系人授权范围单独记录 |
| 身份组织 | `idn_account``idn_role``idn_permission``org_gas_station``org_service_area``org_service_person``org_user_service_relation``org_invitation_qr_code``idn_emergency_contact` | 手机号/账号唯一;角色和数据范围均生效;紧急联系人授权范围、二维码归因和服务关系历史单独记录 |
| 设备 | `dev_device``dev_device_binding``dev_device_group``dev_cylinder``dev_telemetry``dev_command` | 设备序列号唯一;绑定有有效期与历史;命令含幂等键与回执;气瓶信息保留来源和有效期 |
| 安全 | `saf_rule``saf_event``saf_repair_request``saf_inspection``saf_rectification` | 事件编号唯一等级、状态、SLA、证据、操作者完整可追溯报修图片/定位需有采集时间 |
| 商品交易 | `cat_category``cat_product``cat_sku``cat_cart``cat_coupon``ord_order``ord_payment``ord_invoice` | 金额使用最小货币单位整数;库存扣减有事务/预占规则;预约时段、库存快照和开票状态可追溯 |
| 履约 | `ord_service_task``dsp_assignment``ord_delivery_track``dsp_delivery_track_point` | 任务状态转换受限;定位、轨迹和证据均有采集时间 |
| 资金与押金 | `wal_wallet_ledger``wal_deposit``wal_deposit_refund``wal_settlement``wal_withdrawal``wal_reconciliation` | 流水不可更新删除;金额方向、关联对象和余额快照可校验;退押金保留验收、扣减和退款去向 |
| 资金与押金 | `wal_wallet_ledger``wal_deposit``wal_deposit_refund``wal_settlement``wal_withdrawal``wal_reconciliation` | `wal_wallet_ledger` 为唯一钱包事实流水;金额方向、关联对象和余额快照可校验;退押金保留验收、扣减和退款去向 |
| 用气统计 | `dev_usage_stat``dev_usage_report` | 明确统计周期、单位、来源、计算版本和最后更新时间;报表导出留痕 |
| 内容审计 | `cnt_article``cnt_banner``cnt_notice``aud_operation_log` | 发布有版本;审计日志追加写入且设置留存期限 |
@@ -64,7 +64,13 @@
- 用户端只返回本人订单所需的简化轨迹、最近有效位置、预计到达和事件节点;配送点/气站/平台按组织和职责获取更详细数据。精确轨迹回放及导出应审批并记录用途。
- 轨迹属于敏感定位数据,传输与存储加密、最小化留存、访问审计;订单完结后按留存策略降精度展示或限制访问。严禁将配送员非履约时间的位置用于无关用途。
## 3.3 服务端 App 工单、取证与结算要求
### 3.3 移动端离线、定位与现场证据要求
- 服务端 App 只有在任务执行期间可采集后台定位;定位权限撤销、后台运行受限、精度不足或长时间无位置时必须提示服务人员并向任务记录写入状态,不得伪造实时轨迹。
- 离线队列中的任务材料、定位点、照片/视频元数据、签名、扫描结果和收款确认必须本地加密。补传请求携带原始采集时间、服务端接收时间、`identity`、幂等键、来源和完整性标记;服务端按任务状态和证据哈希进行去重、乱序校正和冲突处置。
- 定位、签名和现场证据的采集开关、保留时长、可见角色、精度降级与导出权限由平台配置;客户端展示或本地删除不能绕过服务端留存、审计和安全事件证据义务。
### 3.4 服务端 App 工单、取证与结算要求
| 场景 | 核心实体 | 必要数据与规则 |
| --- | --- | --- |
@@ -73,7 +79,7 @@
| 维修工单 | `ord_service_task``ord_repair_evidence``ord_repair_material``ord_repair_receipt` | 维修前中后证据、材料、价格快照、应收/实收、签字和支付差异可追溯;不能由服务人员修改订单定价 |
| 安装工单 | `ord_installation_condition``ord_installation_material``ord_installation_step``ord_installation_test``ord_installation_receipt` | 使用条件、备料、安装步骤、测试、前期安检、设备激活和收款按顺序记录;不合格或测试失败不能完成/激活 |
| 配送与回收 | `dsp_delivery_cylinder_scan``dsp_delivery_evidence``dsp_delivery_payment_confirmation``dsp_cylinder_return` | 气瓶/设备编码、蓝牙/扫码来源、随瓶安检照片视频、收款确认、空瓶回收、押金和库存状态均与配送任务关联 |
| 服务收入 | `wal_service_income``wal_service_withdrawal``wal_bank_card` | 收入由已完成且符合结算规则的任务生成;提现以可提现余额、银行卡验证、审核和打款回执为准 |
| 服务收入 | `wal_service_income``wal_withdrawal``wal_bank_card` | 收入由已完成且符合结算规则的任务生成;`wal_withdrawal` 是所有钱包提现的唯一实体,以可提现余额、银行卡验证、审核和打款回执为准 |
- 现场照片、视频、电子签字、蓝牙扫描、定位和收款确认属于取证数据。必须记录原始采集时间、服务端接收时间、任务、操作者、来源、完整性标记和对象存储哈希;客户端离线补传不得覆盖原始采集时间。
- 强制取证项由任务类型、设备型号、风险等级和组织规则确定。服务端在工单完成前校验必填检查项、照片/视频数量、签字、地理围栏、材料和支付状态,前端按钮禁用不能替代服务端校验。

View File

@@ -6,7 +6,7 @@
| 版本 | 必须交付 | 可后置 |
| --- | --- | --- |
| M0 基础底座 | 账号/RBAC、气站/区域、设备接入、审计、通知、基础后台 | 多站点结算、复杂 BI |
| M0 基础底座 | 账号/RBAC、气站/区域、服务关系、设备接入、审计、通知、基础后台 | 多站点结算、复杂 BI |
| M1 安全闭环 | 绑定、状态/遥测、开关控制回执、告警分级、自动关阀、安检/整改/复检、用户/平台通知 | 设备共享、固件 OTA、视频会诊 |
| M2 交易履约 | 商品、购物车、订单、支付、地址、配送/安装任务、配送轨迹、核销、售后基础 | 发票、订阅、复杂促销 |
| M3 资金运营 | 钱包充值、佣金、提现审核、对账、数据看板、内容运营 | 自动打款、预测模型、合作方开放平台 |
@@ -34,6 +34,11 @@
| AC-17 | 安装/维修工单闭环 | 新装任务按使用条件、备料、安装、测试、安检、完成、收款顺序流转;维修任务保存前中后证据、材料和签字;不合格、测试失败或收费差异均不能直接完成 |
| AC-18 | 配送、随瓶安检与回收 | 配送员完成到达校验、气瓶蓝牙/扫码、随瓶安检照片/视频、结论和收款确认后才能完成配送;空瓶回收同步更新气瓶、押金和库存状态,异常可追溯 |
| AC-19 | 服务人员资质与收入 | 角色注册后处于待审核;证照、培训或上班状态不满足时无法接单;完成任务后收入按结算规则进入可提现余额,提现和银行卡信息受审核与审计保护 |
| AC-20 | 邀请注册与服务关系 | 气站/配送点二维码可按范围、有效期和次数生效或停用;扫码地址越界、二维码失效及风控失败均被拦截;已有用户不重复建档,服务关系变更须确认且归因可追溯 |
| AC-21 | 服务人员作业前置与离线补传 | 自主注册仅生成待审核账户;资质、每日培训、上班、区域和授权设备任一不满足时不能开始任务;弱网补传保留原始采集时间并去重,不能伪造轨迹或覆盖现场事实 |
| AC-22 | 设备共享与紧急联系人 | 设备所有者可独立授予或撤销成员查看/控制权限;高风险告警自动关阀后仅通知已授权紧急联系人,且审计完整 |
| AC-23 | 二维码与轨迹隐私 | 二维码不包含用户隐私、账号凭证或接口密钥;用户只看本人订单简化轨迹,精确轨迹回放/导出须经审批、水印和审计,非履约位置不可访问 |
| AC-24 | 钱包实体命名一致性 | 数据库迁移、表、Go/Flutter/Vue 模型和契约均使用 `wal_wallet_ledger`;不得出现 `wal_ledger`、复数表名或同义钱包流水实体 |
## 3. 非功能验收

View File

@@ -23,6 +23,8 @@
- “应/必须”表示上线验收项;“建议”表示增强项;“可选”表示扩展能力。
- 需求变更应先更新本目录中的对应文档,并记录版本、变更人、变更原因和影响范围。
- 两类 App 的最新需求以 [03-用户端 App 需求](03-用户端App需求.md) 与 [04-服务端 App 需求](04-服务端App需求.md) 为端侧基线。涉及邀请注册、服务关系、配送轨迹、现场取证、人员准入或离线定位的变更,必须同步审查总览、流程、后台、技术、接口与验收文档。
- 同一数据实体的表名、模型名、文件名、迁移和接口 Schema 必须遵循 [技术实现规划](10-技术实现规划.md) 的唯一命名;命名变更须在变更记录中说明旧名、目标名和兼容/迁移方案。
- 涉及阀门自动关闭、告警分级、支付、合同、提现、隐私信息的变更,须由产品、技术、安全/法务共同评审。
- 原附件只给出功能脑图,未明确的业务口径已在文档中标记为“待确认”或作为可配置规则提出,不应直接视为既定政策。

View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8080/api/v1

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>可燃气体平台总后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,20 @@
{
"name": "platform-admin",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"type:check": "vue-tsc --noEmit"
},
"dependencies": {
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"typescript": "^5.8.3",
"vite": "^8.0.0",
"vue-tsc": "^2.2.8"
}
}

798
frontend/platform_admin/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,798 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
vue:
specifier: ^3.5.13
version: 3.5.40(typescript@5.9.3)
devDependencies:
'@vitejs/plugin-vue':
specifier: ^6.0.0
version: 6.0.8(vite@8.1.5)(vue@3.5.40(typescript@5.9.3))
typescript:
specifier: ^5.8.3
version: 5.9.3
vite:
specifier: ^8.0.0
version: 8.1.5
vue-tsc:
specifier: ^2.2.8
version: 2.2.12(typescript@5.9.3)
packages:
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.7':
resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.29.7':
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
'@emnapi/core@1.11.1':
resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
'@emnapi/runtime@1.11.1':
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
'@emnapi/wasi-threads@1.2.2':
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@napi-rs/wasm-runtime@1.1.6':
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
peerDependencies:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
'@oxc-project/types@0.139.0':
resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==}
'@rolldown/binding-android-arm64@1.1.5':
resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/binding-darwin-arm64@1.1.5':
resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/binding-darwin-x64@1.1.5':
resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/binding-freebsd-x64@1.1.5':
resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/binding-linux-arm-gnueabihf@1.1.5':
resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/binding-linux-arm64-gnu@1.1.5':
resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.1.5':
resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.1.5':
resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.1.5':
resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.1.5':
resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.1.5':
resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.1.5':
resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/binding-wasm32-wasi@1.1.5':
resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [wasm32]
'@rolldown/binding-win32-arm64-msvc@1.1.5':
resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/binding-win32-x64-msvc@1.1.5':
resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@tybys/wasm-util@0.10.3':
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
'@vitejs/plugin-vue@6.0.8':
resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
vue: ^3.2.25
'@volar/language-core@2.4.15':
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
'@volar/source-map@2.4.15':
resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==}
'@volar/typescript@2.4.15':
resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==}
'@vue/compiler-core@3.5.40':
resolution: {integrity: sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==}
'@vue/compiler-dom@3.5.40':
resolution: {integrity: sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==}
'@vue/compiler-sfc@3.5.40':
resolution: {integrity: sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==}
'@vue/compiler-ssr@3.5.40':
resolution: {integrity: sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==}
'@vue/compiler-vue2@2.7.16':
resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
'@vue/language-core@2.2.12':
resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
'@vue/reactivity@3.5.40':
resolution: {integrity: sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==}
'@vue/runtime-core@3.5.40':
resolution: {integrity: sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==}
'@vue/runtime-dom@3.5.40':
resolution: {integrity: sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==}
'@vue/server-renderer@3.5.40':
resolution: {integrity: sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==}
'@vue/shared@3.5.40':
resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==}
alien-signals@1.0.13:
resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
brace-expansion@2.1.2:
resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
de-indent@1.0.2:
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
lightningcss-android-arm64@1.33.0:
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
lightningcss-darwin-arm64@1.33.0:
resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.33.0:
resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.33.0:
resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-linux-arm-gnueabihf@1.33.0:
resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm64-gnu@1.33.0:
resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.33.0:
resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.33.0:
resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.33.0:
resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.33.0:
resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-x64-msvc@1.33.0:
resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.33.0:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
minimatch@9.0.9:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
nanoid@3.3.16:
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@4.0.5:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'}
postcss@8.5.23:
resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
engines: {node: ^10 || ^12 || >=14}
rolldown@1.1.5:
resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
vite@8.1.5:
resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
'@vitejs/devtools': ^0.3.0
esbuild: ^0.27.0 || ^0.28.0
jiti: '>=1.21.0'
less: ^4.0.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
'@vitejs/devtools':
optional: true
esbuild:
optional: true
jiti:
optional: true
less:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
vue-tsc@2.2.12:
resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==}
hasBin: true
peerDependencies:
typescript: '>=5.0.0'
vue@3.5.40:
resolution: {integrity: sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
snapshots:
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-validator-identifier@7.29.7': {}
'@babel/parser@7.29.7':
dependencies:
'@babel/types': 7.29.7
'@babel/types@7.29.7':
dependencies:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@emnapi/core@1.11.1':
dependencies:
'@emnapi/wasi-threads': 1.2.2
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.11.1':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/wasi-threads@1.2.2':
dependencies:
tslib: 2.8.1
optional: true
'@jridgewell/sourcemap-codec@1.5.5': {}
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
dependencies:
'@emnapi/core': 1.11.1
'@emnapi/runtime': 1.11.1
'@tybys/wasm-util': 0.10.3
optional: true
'@oxc-project/types@0.139.0': {}
'@rolldown/binding-android-arm64@1.1.5':
optional: true
'@rolldown/binding-darwin-arm64@1.1.5':
optional: true
'@rolldown/binding-darwin-x64@1.1.5':
optional: true
'@rolldown/binding-freebsd-x64@1.1.5':
optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.1.5':
optional: true
'@rolldown/binding-linux-arm64-gnu@1.1.5':
optional: true
'@rolldown/binding-linux-arm64-musl@1.1.5':
optional: true
'@rolldown/binding-linux-ppc64-gnu@1.1.5':
optional: true
'@rolldown/binding-linux-s390x-gnu@1.1.5':
optional: true
'@rolldown/binding-linux-x64-gnu@1.1.5':
optional: true
'@rolldown/binding-linux-x64-musl@1.1.5':
optional: true
'@rolldown/binding-openharmony-arm64@1.1.5':
optional: true
'@rolldown/binding-wasm32-wasi@1.1.5':
dependencies:
'@emnapi/core': 1.11.1
'@emnapi/runtime': 1.11.1
'@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
optional: true
'@rolldown/binding-win32-arm64-msvc@1.1.5':
optional: true
'@rolldown/binding-win32-x64-msvc@1.1.5':
optional: true
'@rolldown/pluginutils@1.0.1': {}
'@tybys/wasm-util@0.10.3':
dependencies:
tslib: 2.8.1
optional: true
'@vitejs/plugin-vue@6.0.8(vite@8.1.5)(vue@3.5.40(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.1.5
vue: 3.5.40(typescript@5.9.3)
'@volar/language-core@2.4.15':
dependencies:
'@volar/source-map': 2.4.15
'@volar/source-map@2.4.15': {}
'@volar/typescript@2.4.15':
dependencies:
'@volar/language-core': 2.4.15
path-browserify: 1.0.1
vscode-uri: 3.1.0
'@vue/compiler-core@3.5.40':
dependencies:
'@babel/parser': 7.29.7
'@vue/shared': 3.5.40
entities: 7.0.1
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/compiler-dom@3.5.40':
dependencies:
'@vue/compiler-core': 3.5.40
'@vue/shared': 3.5.40
'@vue/compiler-sfc@3.5.40':
dependencies:
'@babel/parser': 7.29.7
'@vue/compiler-core': 3.5.40
'@vue/compiler-dom': 3.5.40
'@vue/compiler-ssr': 3.5.40
'@vue/shared': 3.5.40
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.23
source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.40':
dependencies:
'@vue/compiler-dom': 3.5.40
'@vue/shared': 3.5.40
'@vue/compiler-vue2@2.7.16':
dependencies:
de-indent: 1.0.2
he: 1.2.0
'@vue/language-core@2.2.12(typescript@5.9.3)':
dependencies:
'@volar/language-core': 2.4.15
'@vue/compiler-dom': 3.5.40
'@vue/compiler-vue2': 2.7.16
'@vue/shared': 3.5.40
alien-signals: 1.0.13
minimatch: 9.0.9
muggle-string: 0.4.1
path-browserify: 1.0.1
optionalDependencies:
typescript: 5.9.3
'@vue/reactivity@3.5.40':
dependencies:
'@vue/shared': 3.5.40
'@vue/runtime-core@3.5.40':
dependencies:
'@vue/reactivity': 3.5.40
'@vue/shared': 3.5.40
'@vue/runtime-dom@3.5.40':
dependencies:
'@vue/reactivity': 3.5.40
'@vue/runtime-core': 3.5.40
'@vue/shared': 3.5.40
csstype: 3.2.3
'@vue/server-renderer@3.5.40':
dependencies:
'@vue/compiler-ssr': 3.5.40
'@vue/runtime-dom': 3.5.40
'@vue/shared': 3.5.40
'@vue/shared@3.5.40': {}
alien-signals@1.0.13: {}
balanced-match@1.0.2: {}
brace-expansion@2.1.2:
dependencies:
balanced-match: 1.0.2
csstype@3.2.3: {}
de-indent@1.0.2: {}
detect-libc@2.1.2: {}
entities@7.0.1: {}
estree-walker@2.0.2: {}
fdir@6.5.0(picomatch@4.0.5):
optionalDependencies:
picomatch: 4.0.5
fsevents@2.3.3:
optional: true
he@1.2.0: {}
lightningcss-android-arm64@1.33.0:
optional: true
lightningcss-darwin-arm64@1.33.0:
optional: true
lightningcss-darwin-x64@1.33.0:
optional: true
lightningcss-freebsd-x64@1.33.0:
optional: true
lightningcss-linux-arm-gnueabihf@1.33.0:
optional: true
lightningcss-linux-arm64-gnu@1.33.0:
optional: true
lightningcss-linux-arm64-musl@1.33.0:
optional: true
lightningcss-linux-x64-gnu@1.33.0:
optional: true
lightningcss-linux-x64-musl@1.33.0:
optional: true
lightningcss-win32-arm64-msvc@1.33.0:
optional: true
lightningcss-win32-x64-msvc@1.33.0:
optional: true
lightningcss@1.33.0:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
lightningcss-android-arm64: 1.33.0
lightningcss-darwin-arm64: 1.33.0
lightningcss-darwin-x64: 1.33.0
lightningcss-freebsd-x64: 1.33.0
lightningcss-linux-arm-gnueabihf: 1.33.0
lightningcss-linux-arm64-gnu: 1.33.0
lightningcss-linux-arm64-musl: 1.33.0
lightningcss-linux-x64-gnu: 1.33.0
lightningcss-linux-x64-musl: 1.33.0
lightningcss-win32-arm64-msvc: 1.33.0
lightningcss-win32-x64-msvc: 1.33.0
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
minimatch@9.0.9:
dependencies:
brace-expansion: 2.1.2
muggle-string@0.4.1: {}
nanoid@3.3.16: {}
path-browserify@1.0.1: {}
picocolors@1.1.1: {}
picomatch@4.0.5: {}
postcss@8.5.23:
dependencies:
nanoid: 3.3.16
picocolors: 1.1.1
source-map-js: 1.2.1
rolldown@1.1.5:
dependencies:
'@oxc-project/types': 0.139.0
'@rolldown/pluginutils': 1.0.1
optionalDependencies:
'@rolldown/binding-android-arm64': 1.1.5
'@rolldown/binding-darwin-arm64': 1.1.5
'@rolldown/binding-darwin-x64': 1.1.5
'@rolldown/binding-freebsd-x64': 1.1.5
'@rolldown/binding-linux-arm-gnueabihf': 1.1.5
'@rolldown/binding-linux-arm64-gnu': 1.1.5
'@rolldown/binding-linux-arm64-musl': 1.1.5
'@rolldown/binding-linux-ppc64-gnu': 1.1.5
'@rolldown/binding-linux-s390x-gnu': 1.1.5
'@rolldown/binding-linux-x64-gnu': 1.1.5
'@rolldown/binding-linux-x64-musl': 1.1.5
'@rolldown/binding-openharmony-arm64': 1.1.5
'@rolldown/binding-wasm32-wasi': 1.1.5
'@rolldown/binding-win32-arm64-msvc': 1.1.5
'@rolldown/binding-win32-x64-msvc': 1.1.5
source-map-js@1.2.1: {}
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.5)
picomatch: 4.0.5
tslib@2.8.1:
optional: true
typescript@5.9.3: {}
vite@8.1.5:
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.5
postcss: 8.5.23
rolldown: 1.1.5
tinyglobby: 0.2.17
optionalDependencies:
fsevents: 2.3.3
vscode-uri@3.1.0: {}
vue-tsc@2.2.12(typescript@5.9.3):
dependencies:
'@volar/typescript': 2.4.15
'@vue/language-core': 2.2.12(typescript@5.9.3)
typescript: 5.9.3
vue@3.5.40(typescript@5.9.3):
dependencies:
'@vue/compiler-dom': 3.5.40
'@vue/compiler-sfc': 3.5.40
'@vue/runtime-dom': 3.5.40
'@vue/server-renderer': 3.5.40
'@vue/shared': 3.5.40
optionalDependencies:
typescript: 5.9.3

View File

@@ -0,0 +1,144 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import {
platformAPI,
type DashboardOverview,
type IdnAccount,
type OrgDeliveryPoint,
type OrgGasStation,
type OrgServicePerson,
type SafEvent,
} from './api/platform';
type Tab = 'dashboard' | 'station' | 'delivery' | 'person' | 'user' | 'safety';
const currentTab = ref<Tab>('dashboard');
const loading = ref(false);
const errorMessage = ref('');
const overview = ref<DashboardOverview>({});
const stations = ref<OrgGasStation[]>([]);
const deliveryPoints = ref<OrgDeliveryPoint[]>([]);
const servicePeople = ref<OrgServicePerson[]>([]);
const users = ref<IdnAccount[]>([]);
const safetyEvents = ref<SafEvent[]>([]);
const form = reactive({ stationCode: '', name: '', principal: '', serviceArea: '' });
const tabs: Array<{ key: Tab; label: string }> = [
{ key: 'dashboard', label: '运营概览' },
{ key: 'station', label: '气站管理' },
{ key: 'delivery', label: '配送点管理' },
{ key: 'person', label: '服务人员' },
{ key: 'user', label: '用户管理' },
{ key: 'safety', label: '安全运营' },
];
const overviewCards = computed(() => [
['启用气站', overview.value.gasStationCount ?? 0],
['启用配送点', overview.value.deliveryPointCount ?? 0],
['在岗服务人员', overview.value.servicePersonCount ?? 0],
['服务用户', overview.value.userCount ?? 0],
['待处理安全事件', overview.value.pendingSafetyCount ?? 0],
]);
async function loadData() {
loading.value = true;
errorMessage.value = '';
try {
[overview.value, stations.value, deliveryPoints.value, servicePeople.value, users.value, safetyEvents.value] = await Promise.all([
platformAPI.getDashboard(),
platformAPI.listOrgGasStation(),
platformAPI.listOrgDeliveryPoint(),
platformAPI.listOrgServicePerson(),
platformAPI.listIdnAccount(),
platformAPI.listSafEvent(),
]);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '加载数据失败';
} finally {
loading.value = false;
}
}
async function createStation() {
if (!form.stationCode || !form.name || !form.principal || !form.serviceArea) {
errorMessage.value = '请完整填写气站资料';
return;
}
loading.value = true;
errorMessage.value = '';
try {
await platformAPI.createOrgGasStation({ ...form });
Object.assign(form, { stationCode: '', name: '', principal: '', serviceArea: '' });
await loadData();
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '创建气站失败';
loading.value = false;
}
}
onMounted(loadData);
</script>
<template>
<main class="shell">
<aside class="sidebar">
<div class="brand">
<span class="brand-mark"></span>
<div><strong>可燃气体平台</strong><small>平台总后台</small></div>
</div>
<nav>
<button v-for="tab in tabs" :key="tab.key" :class="{ active: currentTab === tab.key }" @click="currentTab = tab.key">
{{ tab.label }}
</button>
</nav>
<div class="mock-note">支付地图IoT 与消息服务均使用 Mock 边界</div>
</aside>
<section class="content">
<header>
<div><p class="eyebrow">M0 + M1</p><h1>{{ tabs.find((tab) => tab.key === currentTab)?.label }}</h1></div>
<button class="secondary" :disabled="loading" @click="loadData">{{ loading ? '同步中' : '刷新数据' }}</button>
</header>
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
<template v-if="currentTab === 'dashboard'">
<div class="cards"><article v-for="([label, value]) in overviewCards" :key="label"><span>{{ label }}</span><strong>{{ value }}</strong></article></div>
<article class="panel"><h2>安全处置提示</h2><p>高风险安全事件由安全运营中心统一升级审计和派发当前待处理事件{{ overview.pendingSafetyCount ?? 0 }} </p></article>
</template>
<template v-if="currentTab === 'station'">
<article class="panel"><h2>创建气站</h2><form @submit.prevent="createStation"><input v-model.trim="form.stationCode" placeholder="站点编码,例如 GS-1002" /><input v-model.trim="form.name" placeholder="气站名称" /><input v-model.trim="form.principal" placeholder="负责人" /><input v-model.trim="form.serviceArea" placeholder="服务区域" /><button :disabled="loading" type="submit">提交审核</button></form></article>
<DataTable :headers="['编码', '名称', '负责人', '服务区域', '状态']" :rows="stations.map((item) => [item.stationCode, item.name, item.principal, item.serviceArea, item.status])" />
</template>
<template v-if="currentTab === 'delivery'">
<DataTable :headers="['编码', '配送点', '归属气站', '服务区域', '状态']" :rows="deliveryPoints.map((item) => [item.deliveryCode, item.name, item.gasStationName, item.serviceArea, item.status])" />
</template>
<template v-if="currentTab === 'person'">
<DataTable :headers="['姓名', '手机号', '角色', '工作状态', '资质状态']" :rows="servicePeople.map((item) => [item.name, item.phoneMasked, item.roles, item.workStatus, item.credentialStatus])" />
</template>
<template v-if="currentTab === 'user'">
<DataTable :headers="['手机号', '账户类型', '状态', '服务区域']" :rows="users.map((item) => [item.phoneMasked, item.accountType, item.status, item.serviceArea])" />
</template>
<template v-if="currentTab === 'safety'">
<DataTable :headers="['事件编码', '等级', '事件说明', '状态', '创建时间']" :rows="safetyEvents.map((item) => [item.eventCode, `${item.level} 级`, item.title, item.status, new Date(item.createdAt).toLocaleString()])" />
</template>
</section>
</main>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue';
export default defineComponent({
components: {
DataTable: defineComponent({
props: { headers: { type: Array as PropType<string[]>, required: true }, rows: { type: Array as PropType<string[][]>, required: true } },
template: `<article class="panel table-panel"><table><thead><tr><th v-for="header in headers" :key="header">{{ header }}</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="index"><td v-for="(cell, cellIndex) in row" :key="cellIndex">{{ cell }}</td></tr><tr v-if="rows.length === 0"><td :colspan="headers.length">暂无数据</td></tr></tbody></table></article>`,
}),
},
});
</script>

View File

@@ -0,0 +1,70 @@
// 平台总后台 API 客户端,仅封装首期 M0 + M1 接口。
const apiBaseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1';
export type OrgGasStation = {
identity: string;
stationCode: string;
name: string;
principal: string;
serviceArea: string;
status: string;
createdAt: string;
};
export type OrgDeliveryPoint = {
identity: string;
deliveryCode: string;
name: string;
gasStationName: string;
serviceArea: string;
status: string;
};
export type OrgServicePerson = {
identity: string;
name: string;
phoneMasked: string;
roles: string;
workStatus: string;
credentialStatus: string;
};
export type IdnAccount = {
identity: string;
phoneMasked: string;
accountType: string;
status: string;
serviceArea: string;
};
export type SafEvent = {
identity: string;
eventCode: string;
level: number;
title: string;
status: string;
createdAt: string;
};
export type DashboardOverview = Record<string, number>;
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${apiBaseURL}${path}`, {
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
...init,
});
const payload = (await response.json()) as { data?: T; error?: string };
if (!response.ok || payload.error) throw new Error(payload.error || '请求失败');
return payload.data as T;
}
export const platformAPI = {
getDashboard: () => request<DashboardOverview>('/dashboard/overview'),
listOrgGasStation: () => request<OrgGasStation[]>('/org/gas-station'),
createOrgGasStation: (body: Pick<OrgGasStation, 'stationCode' | 'name' | 'principal' | 'serviceArea'>) =>
request<OrgGasStation>('/org/gas-station', { method: 'POST', body: JSON.stringify(body) }),
listOrgDeliveryPoint: () => request<OrgDeliveryPoint[]>('/org/delivery-point'),
listOrgServicePerson: () => request<OrgServicePerson[]>('/org/service-person'),
listIdnAccount: () => request<IdnAccount[]>('/idn/account'),
listSafEvent: () => request<SafEvent[]>('/saf/event'),
};

View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue';
import App from './App.vue';
import './style.css';
createApp(App).mount('#app');

View File

@@ -0,0 +1,15 @@
:root { color: #18212f; background: #f4f7fb; font-family: Inter, "Microsoft YaHei", sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; }
button, input { font: inherit; }
.shell { min-height: 100vh; display: grid; grid-template-columns: 240px 1fr; }
.sidebar { background: #102a43; color: #e8f1fb; padding: 28px 16px; display: flex; flex-direction: column; }
.brand { display: flex; align-items: center; gap: 12px; padding: 0 10px 30px; }
.brand-mark { background: #33a474; display: grid; place-items: center; border-radius: 10px; height: 38px; width: 38px; font-weight: 800; }
.brand strong, .brand small { display: block; }.brand small { color: #a8c2dc; margin-top: 3px; }
nav { display: grid; gap: 6px; } nav button { border: 0; border-radius: 8px; background: transparent; color: inherit; text-align: left; padding: 11px 14px; cursor: pointer; } nav button:hover, nav button.active { background: #1e4b70; }
.mock-note { font-size: 12px; color: #a8c2dc; line-height: 1.6; margin-top: auto; padding: 12px; background: #173b59; border-radius: 8px; }
.content { padding: 34px; max-width: 1500px; width: 100%; }.content header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 25px; }.eyebrow { color: #33a474; font-size: 12px; font-weight: 700; margin: 0 0 6px; }.content h1, .content h2 { margin: 0; }.content h1 { font-size: 28px; }.content h2 { font-size: 17px; }
.cards { display: grid; grid-template-columns: repeat(5, minmax(140px, 1fr)); gap: 16px; margin-bottom: 18px; }.cards article, .panel { border: 1px solid #e1e9f0; background: #fff; border-radius: 12px; box-shadow: 0 4px 12px rgb(15 42 67 / 4%); }.cards article { padding: 20px; }.cards span { color: #66788a; font-size: 13px; }.cards strong { display: block; font-size: 28px; margin-top: 12px; color: #0f3859; }.panel { padding: 22px; margin-bottom: 18px; }.panel p { color: #536575; line-height: 1.7; }
form { display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)) auto; gap: 10px; margin-top: 16px; } input { min-width: 0; border: 1px solid #cbd7e3; border-radius: 7px; padding: 10px 11px; } button { border: 0; border-radius: 7px; padding: 10px 15px; color: #fff; background: #1677c8; cursor: pointer; }button:disabled { opacity: .65; cursor: wait; }.secondary { background: #fff; color: #1677c8; border: 1px solid #a9c9e7; }.table-panel { padding: 0; overflow-x: auto; } table { border-collapse: collapse; width: 100%; min-width: 650px; } th, td { padding: 14px 18px; border-bottom: 1px solid #edf1f5; text-align: left; font-size: 14px; } th { background: #f8fafc; color: #4c6376; font-weight: 600; } td { color: #233446; }.error { background: #fff0f0; color: #b42318; padding: 11px 14px; border-radius: 8px; }
@media (max-width: 900px) { .shell { grid-template-columns: 1fr; }.sidebar { min-height: auto; }.mock-note { display: none; } nav { grid-template-columns: repeat(3, 1fr); }.content { padding: 20px; }.cards { grid-template-columns: repeat(2, 1fr); } form { grid-template-columns: 1fr; } }

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"skipLibCheck": true,
"allowImportingTsExtensions": false,
"noEmit": true,
"lib": ["ES2022", "DOM"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: { port: 5173 },
});