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

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" />