feat: 完善平台总后台认证与管理入口

This commit is contained in:
2026-07-26 18:47:52 +08:00
parent c3c5df6cbb
commit 073eb89762
12 changed files with 371 additions and 155 deletions

View File

@@ -1,12 +1,19 @@
# Platform API
严格沿用 `sample/server` 的 BSM-SDK Core 分层和运行方式。运行前设置:
平台总后台 API 严格沿用 `sample/server` 的 BSM-SDK Core 分层和运行方式。
启动前请设置运行配置JWT 密钥必须为 16、24 或 32 个字符。
```powershell
$env:BSM_RuntimeMode="dev"
$env:BSM_Prefix="$(Get-Location)/etc"
$env:BSM_JwtSecretKey="仅本地使用的随机密钥"
$env:BSM_JwtSecretKey="local-dev-key-16"
$env:HEQI_PLATFORM_ROOT_PASSWORD="请设置不少于12位的root初始密码"
go run ./cmd/main/main.go
```
`cmd/cli` 提供 `version``migrate`;受保护接口使用 `middleware.JwtAuth(true)`。UUID V7 主键及完整中文注释以 `../migrations` 为准
应用启动和 `go run ./cmd/cli/main.go migrate` 都会在事务内幂等创建平台 `root` 账号。账号名固定为 `root`;优先使用 `HEQI_PLATFORM_ROOT_PASSWORD`未设置时仅使用开发环境默认值。root 首次登录后必须通过 `PUT /heqi/v1/auth/password` 修改密码
匿名接口为 `POST /heqi/v1/auth/login`;其余平台接口经 `middleware.JwtAuth(true)` 保护。请求头 `Authorization` 直接传递 JWT 原始值,不使用 `Bearer` 前缀。
UUID V7 主键、模型中文注释和 PostgreSQL 变更记录以 `../migrations` 为准。

View File

@@ -7,6 +7,7 @@ import (
"git.apinb.com/heqiapp/platforms/backend/api/internal/config"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
"git.apinb.com/heqiapp/platforms/backend/api/internal/initdb"
_ "git.apinb.com/heqiapp/platforms/backend/api/internal/models"
)
@@ -23,6 +24,9 @@ func main() {
case "migrate":
config.New(serviceKey)
impl.NewImpl()
if err := initdb.New(impl.DBService); err != nil {
panic(err)
}
fmt.Println("platform database auto migrate completed")
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])

View File

@@ -15,6 +15,7 @@ import (
"git.apinb.com/bsm-sdk/core/printer"
"git.apinb.com/heqiapp/platforms/backend/api/internal/config"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
"git.apinb.com/heqiapp/platforms/backend/api/internal/initdb"
"git.apinb.com/heqiapp/platforms/backend/api/internal/routers"
"github.com/gin-gonic/gin"
)
@@ -24,6 +25,9 @@ const serviceKey = "heqi"
func main() {
config.New(serviceKey)
impl.NewImpl()
if err := initdb.New(impl.DBService); err != nil {
panic(err)
}
app := gin.Default()
middleware.Mode(app)

View File

@@ -0,0 +1,11 @@
// Package initdb 提供应用启动后的基础数据初始化。
package initdb
import "gorm.io/gorm"
// New 在同一事务中初始化平台基础数据。
func New(database *gorm.DB) error {
return database.Transaction(func(tx *gorm.DB) error {
return InitPlatformRoot(tx)
})
}

View File

@@ -0,0 +1,57 @@
package initdb
import (
"errors"
"os"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
const (
// PlatformRootUsername 是平台总后台的内置根账号名称。
PlatformRootUsername = "root"
// PlatformRootPassword 是仅用于首次启动的初始密码,首次登录后必须修改。
PlatformRootPassword = "Heqi@Root2026"
// PlatformRootRoleCode 表示根账号的平台角色。
PlatformRootRoleCode = "platform_root"
)
// InitPlatformRoot 幂等创建平台总后台 root 账号。
func InitPlatformRoot(database *gorm.DB) error {
var account models.IdnAccount
err := database.Where("username = ?", PlatformRootUsername).First(&account).Error
if err == nil {
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(platformRootPassword()), bcrypt.DefaultCost)
if err != nil {
return err
}
account = models.IdnAccount{
Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled"},
Username: PlatformRootUsername,
DisplayName: "平台根管理员",
PasswordHash: string(passwordHash),
RoleCode: PlatformRootRoleCode,
MustChangePassword: true,
Phone: "",
AccountType: "operator",
ServiceArea: "全国",
}
return database.Create(&account).Error
}
// platformRootPassword 优先读取部署环境传入的 root 初始密码。
func platformRootPassword() string {
if password := os.Getenv("HEQI_PLATFORM_ROOT_PASSWORD"); len(password) >= 12 {
return password
}
return PlatformRootPassword
}

View File

@@ -0,0 +1,146 @@
package platform
import (
"strings"
"git.apinb.com/bsm-sdk/core/crypto/token"
"git.apinb.com/bsm-sdk/core/env"
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/bsm-sdk/core/middleware"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// LoginRequest 是平台总后台的账号密码登录请求。
type LoginRequest struct {
Username string `json:"username" binding:"required,max=64"`
Password string `json:"password" binding:"required,min=8,max=128"`
}
// LoginReply 是后台登录成功后的访问凭证与账号状态。
type LoginReply struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Identity string `json:"identity"`
DisplayName string `json:"display_name"`
RoleCode string `json:"role_code"`
MustChangePassword bool `json:"must_change_password"`
}
// Login 校验平台账号密码并签发 BSM JWT。
func Login(ctx *gin.Context) {
var request LoginRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
var account models.IdnAccount
err := impl.DBService.Where("username = ?", strings.TrimSpace(request.Username)).First(&account).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
infra.Response.Error(ctx, errcode.ErrPassword)
return
}
infra.Response.Error(ctx, err)
return
}
if account.Status != "enabled" {
infra.Response.Error(ctx, errcode.ErrAccountDisabled)
return
}
if bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(request.Password)) != nil {
infra.Response.Error(ctx, errcode.ErrPassword)
return
}
accessToken, err := token.New(env.Runtime.JwtSecretKey).GenerateJwt(
0,
account.Identity.String(),
"platform_admin",
account.RoleCode,
map[string]string{"username": account.Username, "display_name": account.DisplayName},
map[string]string{"must_change_password": boolText(account.MustChangePassword)},
)
if err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, LoginReply{
AccessToken: accessToken,
TokenType: "JWT",
Identity: account.Identity.String(),
DisplayName: account.DisplayName,
RoleCode: account.RoleCode,
MustChangePassword: account.MustChangePassword,
})
}
// CurrentProfile 返回当前已认证的平台管理员资料。
func CurrentProfile(ctx *gin.Context) {
claims, err := middleware.ParseAuth(ctx)
if err != nil {
infra.Response.Error(ctx, err)
return
}
var account models.IdnAccount
if err := impl.DBService.Where("identity = ?", claims.Identity).First(&account).Error; err != nil {
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
return
}
infra.Response.Success(ctx, gin.H{
"identity": account.Identity.String(), "username": account.Username, "display_name": account.DisplayName,
"role_code": account.RoleCode, "must_change_password": account.MustChangePassword, "mfa_enabled": account.MFAEnabled,
})
}
// ChangePasswordRequest 是已登录账号的改密请求。
type ChangePasswordRequest struct {
CurrentPassword string `json:"current_password" binding:"required,min=8,max=128"`
NewPassword string `json:"new_password" binding:"required,min=12,max=128"`
}
// ChangePassword 修改当前账号密码并解除首次登录改密限制。
func ChangePassword(ctx *gin.Context) {
claims, err := middleware.ParseAuth(ctx)
if err != nil {
infra.Response.Error(ctx, err)
return
}
var request ChangePasswordRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
var account models.IdnAccount
if err := impl.DBService.Where("identity = ?", claims.Identity).First(&account).Error; err != nil {
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
return
}
if bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(request.CurrentPassword)) != nil {
infra.Response.Error(ctx, errcode.ErrPassword)
return
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.NewPassword), bcrypt.DefaultCost)
if err != nil {
infra.Response.Error(ctx, err)
return
}
if err := impl.DBService.Model(&account).Updates(map[string]any{"password_hash": string(passwordHash), "must_change_password": false}).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"changed": true})
}
// boolText 将布尔值转换为 JWT 扩展字段约定的字符串。
func boolText(value bool) string {
if value {
return "true"
}
return "false"
}

View File

@@ -5,9 +5,15 @@ import "git.apinb.com/bsm-sdk/core/database"
// IdnAccount 对应 idn_account表示用户或服务人员身份账户。
type IdnAccount struct {
Entity
Phone string `gorm:"column:phone;type:varchar(32);uniqueIndex;not null" json:"phone"` // 手机号,响应时需脱敏
AccountType string `gorm:"column:account_type;type:varchar(32);not null" json:"account_type"` // 账户类型
ServiceArea string `gorm:"column:service_area;type:varchar(128);not null" json:"service_area"` // 服务区域
Username string `gorm:"column:username;type:varchar(64);uniqueIndex" json:"username"` // 登录用户名。
DisplayName string `gorm:"column:display_name;type:varchar(64);not null;default:''" json:"display_name"` // 用户展示名称。
PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null;default:''" json:"-"` // 密码哈希值,禁止在接口中返回。
RoleCode string `gorm:"column:role_code;type:varchar(64);not null;default:'user'" json:"role_code"` // 平台角色编码。
MustChangePassword bool `gorm:"column:must_change_password;not null;default:false" json:"must_change_password"` // 是否必须修改初始密码。
MFAEnabled bool `gorm:"column:mfa_enabled;not null;default:false" json:"mfa_enabled"` // 是否启用多因素认证。
Phone string `gorm:"column:phone;type:varchar(32);uniqueIndex;not null" json:"phone"` // 手机号,用于登录和通知。
AccountType string `gorm:"column:account_type;type:varchar(32);not null" json:"account_type"` // 账号类型,例如 user、operator。
ServiceArea string `gorm:"column:service_area;type:varchar(128);not null" json:"service_area"` // 服务区域描述。
}
func init() { database.AppendMigrate(&IdnAccount{}) }

View File

@@ -14,10 +14,13 @@ func Register(srvKey string, engine *gin.Engine) {
v1Key := fmt.Sprintf("/%s/%s", srvKey, "v1")
anonymous := engine.Group(v1Key)
anonymous.GET("/ping/hello", platform.PingHello)
anonymous.POST("/auth/login", platform.Login)
protected := engine.Group(v1Key)
protected.Use(middleware.JwtAuth(true))
{
protected.GET("/auth/profile", platform.CurrentProfile)
protected.PUT("/auth/password", platform.ChangePassword)
protected.GET("/dashboard/overview", platform.DashboardOverview)
gasStationGroup := protected.Group("/organization/org_gas_station")
gasStationGroup.POST("", platform.CreateOrgGasStation)

View File

@@ -0,0 +1,15 @@
-- 平台总后台登录字段;所有业务主表仍使用应用生成的 UUID V7 identity。
ALTER TABLE idn_account ADD COLUMN IF NOT EXISTS username varchar(64);
ALTER TABLE idn_account ADD COLUMN IF NOT EXISTS display_name varchar(64) NOT NULL DEFAULT '';
ALTER TABLE idn_account ADD COLUMN IF NOT EXISTS password_hash varchar(255) NOT NULL DEFAULT '';
ALTER TABLE idn_account ADD COLUMN IF NOT EXISTS role_code varchar(64) NOT NULL DEFAULT 'user';
ALTER TABLE idn_account ADD COLUMN IF NOT EXISTS must_change_password boolean NOT NULL DEFAULT false;
ALTER TABLE idn_account ADD COLUMN IF NOT EXISTS mfa_enabled boolean NOT NULL DEFAULT false;
CREATE UNIQUE INDEX IF NOT EXISTS uk_idn_account_username ON idn_account (username) WHERE username IS NOT NULL;
COMMENT ON COLUMN idn_account.username IS '登录用户名;平台 root 由 internal/initdb/platform.go 幂等初始化';
COMMENT ON COLUMN idn_account.display_name IS '后台界面展示名称';
COMMENT ON COLUMN idn_account.password_hash IS 'bcrypt 密码哈希,禁止在 API 中返回';
COMMENT ON COLUMN idn_account.role_code IS '平台角色编码';
COMMENT ON COLUMN idn_account.must_change_password IS '首次登录或重置密码后必须修改密码';
COMMENT ON COLUMN idn_account.mfa_enabled IS '是否启用多因素认证';

View File

@@ -1,19 +1,13 @@
<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';
import { platformAPI, type DashboardOverview, type IdnAccount, type OrgDeliveryPoint, type OrgGasStation, type OrgServicePerson, type Profile, type SafEvent } from './api/platform';
type Tab = 'dashboard' | 'station' | 'delivery' | 'person' | 'user' | 'safety';
type Tab = 'dashboard' | 'station' | 'delivery' | 'person' | 'user' | 'safety' | 'trade' | 'finance' | 'content' | 'track' | 'operation' | 'audit';
type CatalogItem = { title: string; description: string; operations: string[] };
const currentTab = ref<Tab>('dashboard');
const loading = ref(false);
const loggedIn = ref(platformAPI.hasSession());
const errorMessage = ref('');
const overview = ref<DashboardOverview>({});
const stations = ref<OrgGasStation[]>([]);
@@ -21,124 +15,112 @@ const deliveryPoints = ref<OrgDeliveryPoint[]>([]);
const servicePeople = ref<OrgServicePerson[]>([]);
const users = ref<IdnAccount[]>([]);
const safetyEvents = ref<SafEvent[]>([]);
const form = reactive({ stationCode: '', name: '', principal: '', serviceArea: '' });
const profile = ref<Profile>();
const loginForm = reactive({ username: 'root', password: '' });
const stationForm = reactive({ stationCode: '', name: '', principal: '', serviceArea: '' });
const passwordForm = reactive({ currentPassword: '', newPassword: '', confirmPassword: '' });
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: '安全运营' },
{ key: 'dashboard', label: '运营览' }, { key: 'station', label: '气站管理' }, { key: 'delivery', label: '配送点管理' },
{ key: 'person', label: '服务人员' }, { key: 'user', label: '用户管理' }, { key: 'safety', label: '安全运营' },
{ key: 'trade', label: '商品交易' }, { key: 'finance', label: '资金结算' }, { key: 'content', label: '内容客服' },
{ key: 'track', label: '邀请与轨迹' }, { key: 'operation', label: '全局运营' }, { key: 'audit', label: '审计合规' },
];
const catalog: Partial<Record<Tab, CatalogItem[]>> = {
trade: [{ title: '订单中心', description: '统一检索预约、配送、安装维修、安检及售后订单,支持分单、改派、取消和异常升级。', operations: ['订单查询', '异常升级', '退款审核'] }, { title: '商品与价格', description: '维护可燃气体商品、服务项目、区域价目及促销规则。', operations: ['商品上架', '价格审批', '活动配置'] }],
finance: [{ title: '结算中心', description: '按气站、配送点、服务人员和订单维度出具结算单并保留审批链路。', operations: ['结算单', '对账差异', '付款审批'] }, { title: '资金风控', description: '识别退款、补贴、佣金和余额异常,重大风险须人工复核。', operations: ['风险规则', '冻结处置', '凭证归档'] }],
content: [{ title: '内容运营', description: '管理公告、可燃气体安全知识、消息模板与区域投放策略。', operations: ['内容发布', '模板审核', '投放记录'] }, { title: '客服工单', description: '统一受理用户咨询、投诉、回访和升级闭环。', operations: ['工单分派', '服务质检', '满意度'] }],
track: [{ title: '邀请注册二维码', description: '管理气站、配送点邀请二维码的归属、有效期、扫描转化与失效处置。', operations: ['生成二维码', '归属迁移', '转化分析'] }, { title: '订单配送轨迹', description: '按订单回放接单、出库、到达、交付和异常节点,保留不可抵赖审计记录。', operations: ['轨迹查询', '异常告警', '轨迹导出'] }],
operation: [{ title: '规则与策略', description: '维护服务范围、配送时段、调度、补贴和风控策略,并按区域灰度发布。', operations: ['规则发布', '灰度控制', '回滚记录'] }, { title: '消息与指标', description: '管理消息触达渠道、运营指标、预警阈值与日报。', operations: ['消息任务', '指标看板', '预警订阅'] }],
audit: [{ title: '审计日志', description: '记录组织、人员、用户、设备、订单、结算及权限等关键操作,支持按对象追溯。', operations: ['日志检索', '操作追溯', '导出留档'] }, { title: '合规审批', description: '对高风险安全事件、敏感资料导出、资质过期和重大组织变更实施双人复核。', operations: ['审批队列', '证据附件', '合规报表'] }],
};
const overviewCards = computed(() => [
['启用气站', overview.value.gasStationCount ?? 0],
['启用配送点', overview.value.deliveryPointCount ?? 0],
['在岗服务人员', overview.value.servicePersonCount ?? 0],
['服务用户', overview.value.userCount ?? 0],
['待处理安全事件', overview.value.pendingSafetyCount ?? 0],
['启用气站', overview.value.gasStationCount ?? 0], ['启用配送点', overview.value.deliveryPointCount ?? 0],
['在岗服务人员', overview.value.servicePersonCount ?? 0], ['服务用户', overview.value.userCount ?? 0], ['待处理安全事件', overview.value.pendingSafetyCount ?? 0],
]);
const activeLabel = computed(() => tabs.find((tab) => tab.key === currentTab.value)?.label ?? '平台总后台');
async function loadData() {
if (!loggedIn.value) return;
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(),
[profile.value, overview.value, stations.value, deliveryPoints.value, servicePeople.value, users.value, safetyEvents.value] = await Promise.all([
platformAPI.getProfile(), 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;
}
if (errorMessage.value.includes('Token') || errorMessage.value.includes('认证')) logout();
} finally { loading.value = false; }
}
async function createStation() {
if (!form.stationCode || !form.name || !form.principal || !form.serviceArea) {
errorMessage.value = '请完整填写气站资料';
return;
}
async function login() {
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;
}
try { await platformAPI.login(loginForm.username, loginForm.password); loggedIn.value = true; loginForm.password = ''; await loadData(); }
catch (error) { errorMessage.value = error instanceof Error ? error.message : '登录失败'; }
finally { loading.value = false; }
}
function logout() { platformAPI.clearSession(); loggedIn.value = false; profile.value = undefined; }
async function createStation() {
if (!stationForm.stationCode || !stationForm.name || !stationForm.principal || !stationForm.serviceArea) { errorMessage.value = '请完整填写气站资料'; return; }
loading.value = true;
try { await platformAPI.createOrgGasStation({ ...stationForm }); Object.assign(stationForm, { stationCode: '', name: '', principal: '', serviceArea: '' }); await loadData(); }
catch (error) { errorMessage.value = error instanceof Error ? error.message : '创建气站失败'; }
finally { loading.value = false; }
}
async function changePassword() {
if (passwordForm.newPassword !== passwordForm.confirmPassword) { errorMessage.value = '两次输入的新密码不一致'; return; }
loading.value = true;
try { await platformAPI.changePassword(passwordForm.currentPassword, passwordForm.newPassword); Object.assign(passwordForm, { currentPassword: '', newPassword: '', confirmPassword: '' }); await loadData(); }
catch (error) { errorMessage.value = error instanceof Error ? error.message : '修改密码失败'; }
finally { loading.value = false; }
}
onMounted(loadData);
</script>
<template>
<main class="shell">
<main v-if="!loggedIn" class="login-page">
<form class="login-card" @submit.prevent="login">
<span class="brand-mark"></span><p class="eyebrow">HEQI PLATFORM</p><h1>可燃气体平台总后台</h1>
<p>使用平台管理员账号登录首次登录后必须修改初始密码</p>
<input v-model.trim="loginForm.username" autocomplete="username" placeholder="账号" required />
<input v-model="loginForm.password" type="password" autocomplete="current-password" placeholder="密码" required />
<p v-if="errorMessage" class="error">{{ errorMessage }}</p><button :disabled="loading" type="submit">{{ loading ? '登录中' : '登录' }}</button>
</form>
</main>
<main v-else 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>
<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="account"><strong>{{ profile?.displayName || '平台管理员' }}</strong><small>{{ profile?.roleCode || '加载中' }}</small><button class="link-button" @click="logout">退出登录</button></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>
<header><div><p class="eyebrow">PLATFORM ADMIN</p><h1>{{ activeLabel }}</h1></div><button class="secondary" :disabled="loading" @click="loadData">{{ loading ? '同步中' : '刷新数据' }}</button></header>
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
<article v-if="profile?.mustChangePassword" class="panel warning"><h2>首次登录安全设置</h2><p>root 初始密码仅可用于首次进入,请立即设置不少于 12 位的新密码。</p><form class="password-form" @submit.prevent="changePassword"><input v-model="passwordForm.currentPassword" type="password" placeholder="当前密码" required /><input v-model="passwordForm.newPassword" type="password" placeholder="新密码(至少 12 位)" required /><input v-model="passwordForm.confirmPassword" type="password" placeholder="确认新密码" required /><button :disabled="loading">修改密码</button></form></article>
<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>
<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>统一管理所有气站配送点服务人员和用户并对可燃气体业务的安全订单配送轨迹资金结算邀请二维码和高风险操作保留审计闭环</p></article></template>
<template v-if="currentTab === 'station'"><article class="panel"><h2>创建气站</h2><form @submit.prevent="createStation"><input v-model.trim="stationForm.stationCode" placeholder="气站编码,例如 GS-1002" /><input v-model.trim="stationForm.name" placeholder="气站名称" /><input v-model.trim="stationForm.principal" placeholder="负责人" /><input v-model.trim="stationForm.serviceArea" placeholder="服务区域" /><button :disabled="loading">提交审核</button></form><p>气站可继续管理其配送点、服务人员、用户及专属邀请注册二维码。</p></article><DataTable :headers="['编码', '名称', '负责人', '服务区域', '状态']" :rows="stations.map((item) => [item.stationCode, item.name, item.principal, item.serviceArea, item.status])" /></template>
<template v-if="currentTab === 'delivery'"><article class="panel"><h2>配送点管理边界</h2><p>配送点归属气站,负责管理服务人员、用户、配送班次、订单交付与配送轨迹;平台可跨组织查看、审核、冻结和迁移。</p></article><DataTable :headers="['编码', '配送点', '归属气站', '服务区域', '状态']" :rows="deliveryPoints.map((item) => [item.deliveryCode, item.name, item.gasStationName, item.serviceArea, item.status])" /></template>
<template v-if="currentTab === 'person'"><article class="panel"><h2>服务人员生命周期</h2><p>覆盖安装维修、安检、配送角色、资质、登录设备、接单状态、任务绩效及停用留档。</p></article><DataTable :headers="['姓名', '手机号', '角色', '工作状态', '资质状态']" :rows="servicePeople.map((item) => [item.name, item.phoneMasked, item.roles, item.workStatus, item.credentialStatus])" /></template>
<template v-if="currentTab === 'user'"><article class="panel"><h2>用户 360° 管理</h2><p>统一查看用户资料、地址、智能瓶阀、订单、权益、投诉与风险处置;敏感信息按最小化原则展示。</p></article><DataTable :headers="['手机号', '账户类型', '状态', '服务区域']" :rows="users.map((item) => [item.phoneMasked, item.accountType, item.status, item.serviceArea])" /></template>
<template v-if="currentTab === 'safety'"><article class="panel"><h2>安全运营中心</h2><p>对智能瓶阀告警、安检异常、可燃气体风险、资质临期与订单异常进行分级、派发、升级和复盘。</p></article><DataTable :headers="['事件编码', '等级', '事件说明', '状态', '创建时间']" :rows="safetyEvents.map((item) => [item.eventCode, `${item.level} 级`, item.title, item.status, new Date(item.createdAt).toLocaleString()])" /></template>
<template v-if="catalog[currentTab]"><div class="catalog"><article v-for="item in catalog[currentTab]" :key="item.title" class="panel"><h2>{{ item.title }}</h2><p>{{ item.description }}</p><div class="tags"><span v-for="operation in item.operations" :key="operation">{{ operation }}</span></div></article></div></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>`,
}),
},
});
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

@@ -1,53 +1,16 @@
// 平台总后台 API 客户端,仅封装首期 M0 + M1 接口
const apiBaseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:12426/Platform/v1';
// 平台总后台 API 客户端JWT 使用 BSM 中间件要求的原始令牌值
const apiBaseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:12426/heqi/v1';
const developmentJWT = import.meta.env.VITE_DEV_JWT?.trim();
const tokenStorageKey = 'heqi.platform_admin.access_token';
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 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>;
export type LoginReply = { accessToken: string; tokenType: string; identity: string; displayName: string; roleCode: string; mustChangePassword: boolean };
export type Profile = { identity: string; username: string; displayName: string; roleCode: string; mustChangePassword: boolean; mfaEnabled: boolean };
type ListReply<T> = { total: number; list: T[] };
type RawOrgDeliveryPoint = { identity: string; delivery_code: string; name: string; gas_station_name?: string; service_area: string; status: string };
@@ -55,14 +18,15 @@ type RawOrgServicePerson = { identity: string; name: string; phone_masked?: stri
type RawIdnAccount = { identity: string; phone_masked: string; account_type: string; status: string; service_area: string };
type RawSafEvent = { identity: string; event_code: string; level: number; title: string; status: string; created_at: string };
function currentToken(): string | undefined {
return localStorage.getItem(tokenStorageKey) || developmentJWT;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const accessToken = currentToken();
const response = await fetch(`${apiBaseURL}${path}`, {
headers: {
'Content-Type': 'application/json',
...(developmentJWT ? { Authorization: `Bearer ${developmentJWT}` } : {}),
...(init?.headers ?? {}),
},
...init,
headers: { 'Content-Type': 'application/json', ...(accessToken ? { Authorization: accessToken } : {}), ...(init?.headers ?? {}) },
});
const payload = (await response.json()) as { code?: number; message?: string; details?: T };
if (!response.ok || payload.code !== 0) throw new Error(payload.message || '请求失败');
@@ -70,6 +34,18 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
export const platformAPI = {
hasSession: () => Boolean(currentToken()),
clearSession: () => localStorage.removeItem(tokenStorageKey),
login: async (username: string, password: string) => {
const item = await request<{ access_token: string; token_type: string; identity: string; display_name: string; role_code: string; must_change_password: boolean }>('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) });
localStorage.setItem(tokenStorageKey, item.access_token);
return { accessToken: item.access_token, tokenType: item.token_type, identity: item.identity, displayName: item.display_name, roleCode: item.role_code, mustChangePassword: item.must_change_password } satisfies LoginReply;
},
getProfile: async () => {
const item = await request<{ identity: string; username: string; display_name: string; role_code: string; must_change_password: boolean; mfa_enabled: boolean }>('/auth/profile');
return { identity: item.identity, username: item.username, displayName: item.display_name, roleCode: item.role_code, mustChangePassword: item.must_change_password, mfaEnabled: item.mfa_enabled } satisfies Profile;
},
changePassword: (currentPassword: string, newPassword: string) => request<{ changed: boolean }>('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }) }),
getDashboard: async () => {
const item = await request<{ gas_station_count: number; delivery_point_count: number; service_person_count: number; user_count: number; pending_safety_count: number }>('/dashboard/overview');
return { gasStationCount: item.gas_station_count, deliveryPointCount: item.delivery_point_count, servicePersonCount: item.service_person_count, userCount: item.user_count, pendingSafetyCount: item.pending_safety_count };
@@ -78,8 +54,7 @@ export const platformAPI = {
const reply = await request<ListReply<{ identity: string; station_code: string; name: string; principal: string; service_area: string; status: string; created_at: string }>>('/organization/org_gas_station');
return reply.list.map((item) => ({ identity: item.identity, stationCode: item.station_code, name: item.name, principal: item.principal, serviceArea: item.service_area, status: item.status, createdAt: item.created_at }));
},
createOrgGasStation: (body: Pick<OrgGasStation, 'stationCode' | 'name' | 'principal' | 'serviceArea'>) =>
request<OrgGasStation>('/organization/org_gas_station', { method: 'POST', body: JSON.stringify({ station_code: body.stationCode, name: body.name, principal: body.principal, service_area: body.serviceArea }) }),
createOrgGasStation: (body: Pick<OrgGasStation, 'stationCode' | 'name' | 'principal' | 'serviceArea'>) => request<OrgGasStation>('/organization/org_gas_station', { method: 'POST', body: JSON.stringify({ station_code: body.stationCode, name: body.name, principal: body.principal, service_area: body.serviceArea }) }),
listOrgDeliveryPoint: async () => (await request<ListReply<RawOrgDeliveryPoint>>('/organization/org_delivery_point')).list.map((item) => ({ identity: item.identity, deliveryCode: item.delivery_code, name: item.name, gasStationName: item.gas_station_name ?? '未关联', serviceArea: item.service_area, status: item.status })),
listOrgServicePerson: async () => (await request<ListReply<RawOrgServicePerson>>('/organization/org_service_person')).list.map((item) => ({ identity: item.identity, name: item.name, phoneMasked: item.phone_masked ?? '***', roles: item.roles, workStatus: item.work_status, credentialStatus: item.credential_status })),
listIdnAccount: async () => (await request<ListReply<RawIdnAccount>>('/identity/idn_account')).list.map((item) => ({ identity: item.identity, phoneMasked: item.phone_masked, accountType: item.account_type, status: item.status, serviceArea: item.service_area })),

View File

@@ -2,6 +2,9 @@
* { box-sizing: border-box; }
body { margin: 0; }
button, input { font: inherit; }
.login-page { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at top right, #d8f3e7, transparent 42%), #f4f7fb; }
.login-card { width: min(420px, 100%); display: grid; gap: 14px; padding: 38px; background: #fff; border: 1px solid #e1e9f0; border-radius: 16px; box-shadow: 0 18px 50px rgb(15 42 67 / 12%); }
.login-card .brand-mark { margin-bottom: 6px; }.login-card h1 { margin: 0; color: #102a43; }.login-card p { color: #536575; line-height: 1.6; margin: 0; }
.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; }
@@ -9,7 +12,10 @@ button, input { font: inherit; }
.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; }
.account { margin-top: auto; display: grid; gap: 5px; padding: 12px; background: #173b59; border-radius: 8px; }.account small { color: #a8c2dc; }.link-button { padding: 4px 0; text-align: left; background: transparent; color: #cde9dd; font-size: 12px; }
.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; } }
.warning { border-color: #ead4a7; background: #fffdf7; }.password-form { grid-template-columns: repeat(3, minmax(140px, 1fr)) auto; }.catalog { display: grid; grid-template-columns: repeat(2, minmax(280px, 1fr)); gap: 18px; }.catalog .panel { margin: 0; }.tags { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }.tags span { color: #176e4d; background: #e4f6ee; padding: 5px 9px; border-radius: 20px; font-size: 12px; }
@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, .catalog { grid-template-columns: repeat(2, 1fr); } form, .password-form { grid-template-columns: 1fr; } }
@media (max-width: 560px) { nav, .cards, .catalog { grid-template-columns: 1fr; }.login-card { padding: 28px 22px; } }