新增配送点资料只读卡片

This commit is contained in:
czl231
2026-08-22 19:49:28 +08:00
parent 79749685ee
commit da0185d1aa
9 changed files with 401 additions and 4 deletions

View File

@@ -3,6 +3,7 @@ package delivery
import (
"net/url"
"reflect"
"time"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/heqiapp/platforms/backend/api/internal/config"
@@ -13,6 +14,21 @@ import (
"gorm.io/gorm"
)
// deliveryProfileView 表示配送点管理员可查看的本点只读资料。
type deliveryProfileView struct {
ID uint64 `json:"id"`
Identity string `json:"identity"`
DeliveryCode string `json:"delivery_code"`
Name string `json:"name"`
Principal string `json:"principal"`
Address string `json:"address"`
GasBasicIdentity string `json:"gas_basic_identity"`
GasBasicName string `json:"gas_basic_name"`
Status int `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func currentScope(ctx *gin.Context) (models.DeliveryBasic, models.GasBasic, bool) {
_, point, station, ok := CurrentDeliveryAccount(ctx)
return point, station, ok
@@ -40,12 +56,33 @@ func InvitationQRCode(ctx *gin.Context) {
infra.Response.Success(ctx, gin.H{"register_url": registerURL.String()})
}
// ListProfile 返回 JWT 数据范围内唯一配送点的只读资料。
func ListProfile(ctx *gin.Context) {
_, point, _, ok := CurrentDeliveryAccount(ctx)
_, point, station, ok := CurrentDeliveryAccount(ctx)
if !ok {
return
}
respondList(ctx, []models.DeliveryBasic{point}, 1)
infra.Response.Success(ctx, gin.H{
"total": 1,
"list": []deliveryProfileView{buildDeliveryProfileView(point, station)},
})
}
// buildDeliveryProfileView 组合配送点与所属气站的公开展示字段。
func buildDeliveryProfileView(point models.DeliveryBasic, station models.GasBasic) deliveryProfileView {
return deliveryProfileView{
ID: point.ID,
Identity: point.Identity,
DeliveryCode: point.DeliveryCode,
Name: point.Name,
Principal: point.Principal,
Address: point.Address,
GasBasicIdentity: station.Identity,
GasBasicName: station.Name,
Status: point.Status,
CreatedAt: point.CreatedAt,
UpdatedAt: point.UpdatedAt,
}
}
func respondList(ctx *gin.Context, list any, total int64) {

View File

@@ -0,0 +1,33 @@
// 功能描述验证配送点资料响应仅暴露本点只读展示所需字段。版本v1.0.0。
package delivery
import (
"testing"
"time"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
)
// TestBuildDeliveryProfileView 验证资料响应包含所属气站名称且保持公开标识。
func TestBuildDeliveryProfileView(t *testing.T) {
now := time.Date(2026, time.August, 22, 10, 30, 0, 0, time.UTC)
point := models.DeliveryBasic{
Entity: models.Entity{ID: 19, Identity: "delivery-identity", Status: 1, CreatedAt: now, UpdatedAt: now},
DeliveryCode: "D-001",
Name: "测试配送点",
Principal: "负责人",
Address: "测试地址",
}
station := models.GasBasic{
Entity: models.Entity{ID: 7, Identity: "gas-identity"},
Name: "测试气站",
}
view := buildDeliveryProfileView(point, station)
if view.ID != point.ID || view.Identity != point.Identity || view.GasBasicIdentity != station.Identity {
t.Fatalf("资料标识映射错误:%#v", view)
}
if view.GasBasicName != station.Name || view.Name != point.Name || view.Status != point.Status {
t.Fatalf("资料展示字段映射错误:%#v", view)
}
}

View File

@@ -0,0 +1,52 @@
# 操作日志:配送点资料只读卡片
操作时间2026-08-22 19:42:41
操作类型:扩展
影响模块:配送点后台本点资料、配送点 API 资料响应、HTTP 错误处理
## 操作前状态
- 本点资料套用通用列表页面,只显示 ID、唯一标识和“详情”按钮。
- 点击详情请求不存在的 `/delivery_profile/:identity`,纯文本 404 被当作 JSON 解析并显示底层异常。
- 页面未直接展示需求规定的配送点资料字段。
## 具体操作
1. 根据需求文档确认本点资料是 JWT 范围内的单一只读视图。
2. 新增专用资料卡并在资源页面入口中进行兼容分发。
3. 扩展现有 `/delivery_profile` 响应,补充所属气站名称。
4. 增强 HTTP 客户端的非 JSON 响应处理。
5. 增加后端单元测试和前端专项静态检查。
6. 执行构建、测试和浏览器核心路径回归。
## 操作后状态
- 页面直接展示本点资料,不再发起不存在的详情请求。
- 刷新成功且不再出现 JSON 解析异常。
- 后端保持 JWT 数据范围、现有接口路径和列表响应结构。
- HTTP 404 或非 JSON 响应转换为可读错误。
## 代码变更
- `backend/api/internal/logic/delivery/base.go`:新增资料视图与字段组装。
- `backend/api/internal/logic/delivery/profile_test.go`:新增资料字段映射测试。
- `frontend/delivery_admin/src/views/shared/DeliveryProfilePage.vue`:新增专用资料卡。
- `frontend/delivery_admin/src/views/shared/ResourcePage.vue`:扩展本点资料页面分发。
- `frontend/delivery_admin/src/api/http.ts`:增强响应解析。
- `frontend/delivery_admin/scripts/check-delivery-profile-page.mjs`:新增专项检查。
- `frontend/delivery_admin/package.json`:新增 `profile:check` 命令。
## 验证结果
- `go test ./internal/logic/delivery`:通过。
- `npm run profile:check`:通过。
- `npm run build`:通过,共转换 2606 个模块。
- 浏览器回归:资料卡正常展示;查询、分页、详情入口已移除;刷新正常;控制台无错误或警告。
- 后端运行态:已备份旧二进制、部署新版本并恢复 `12426` 端口监听;重新登录后确认所属气站名称正确显示为“薛海”,刷新正常且控制台无错误或警告。
## 风险评估
- 影响范围仅限配送点后台的本点资料页面和通用 HTTP 错误文案。
- API 仅新增响应字段,旧客户端可忽略,兼容性风险低。
- HTTP 客户端仍保持 `request<T>` 签名;成功 JSON 响应行为不变。
- 缓解措施:已增加专项检查、单元测试、完整构建和浏览器回归;后端重启后需补验气站名称。

View File

@@ -0,0 +1,67 @@
# 项目文档:配送点资料只读卡片 v1.0
## 1. 项目概述
- 项目:和气配送点管理系统。
- 功能:将“本点资料”从通用资源列表改为当前配送点专用只读资料卡。
- 技术栈Vue 3、TypeScript、Arco Design、Go、Gin。
- 运行要求:前端 Node.js 20.19.0 及以上;后端沿用项目现有 Go 运行环境。
## 2. 目录结构
```text
platforms/
├── backend/api/internal/logic/delivery/
│ ├── base.go # 配送点资料响应组装
│ └── profile_test.go # 配送点资料字段映射测试
├── frontend/delivery_admin/
│ ├── scripts/check-delivery-profile-page.mjs # 资料卡静态契约检查
│ └── src/
│ ├── api/http.ts # 配送点后台 HTTP 客户端
│ └── views/shared/
│ ├── DeliveryProfilePage.vue # 本点资料专用只读卡片
│ └── ResourcePage.vue # 资源页面分发入口
└── docs/
├── 项目文档_配送点资料只读卡片_v1.0.md
└── 操作日志_配送点资料只读卡片_20260822.md
```
## 3. 核心文件说明
### `backend/api/internal/logic/delivery/base.go`
- `ListProfile` 继续依据 JWT 中的配送点范围返回唯一记录。
- `buildDeliveryProfileView` 显式组合配送点公开字段和所属气站名称。
- API 路径和 `{ total, list }` 响应结构保持不变,不增加按客户端 identity 查询本点资料的接口。
### `frontend/delivery_admin/src/views/shared/DeliveryProfilePage.vue`
- 直接展示配送点编码、名称、负责人、地址、所属气站和状态。
- 配送点及气站完整 identity 可复制。
- 提供刷新、空数据、页面级错误和重新加载状态。
- 不提供查询、分页、详情或编辑入口。
### `frontend/delivery_admin/src/api/http.ts`
- `request<T>` 公共签名保持不变。
- 对纯文本 404、网关错误和其他非 JSON 响应生成可读错误,避免暴露 `JSON.parse` 异常。
## 4. 变更记录
- 新增本点资料专用只读卡片。
- 扩展 `/delivery_profile` 响应,新增 `gas_basic_name`
- 移除本点资料页面的通用列表交互。
- 增加非 JSON HTTP 响应保护、后端字段映射测试和前端专项检查。
- 未新增依赖,未修改现有 API 路径和公共请求函数签名。
## 5. 维护指南
- 新增资料字段时,先在 `deliveryProfileView` 中声明公开字段,再同步前端 `DeliveryProfile` 类型和资料卡。
- 不得把 `/delivery_profile` 改为允许客户端选择任意配送点;数据范围必须来自 JWT。
- 修改资料卡后运行 `npm run profile:check``npm run build`
- 修改资料响应后运行 `go test ./internal/logic/delivery`
- 本地后端代码更新后必须重启 API 服务,再执行浏览器刷新回归。
## 6. 已知事项
- API 服务已更新并重启,重新登录后的浏览器回归确认所属气站名称、资料刷新和错误状态均正常。

View File

@@ -13,6 +13,7 @@
"type:check": "vue-tsc -p tsconfig.build.json --noEmit --skipLibCheck",
"contract:sync": "node scripts/sync-backend-contract.mjs",
"contract:check": "node scripts/check-backend-contract.mjs",
"profile:check": "node scripts/check-delivery-profile-page.mjs",
"audit:platform": "node scripts/check-backend-contract.mjs",
"lint": "biome lint .",
"lint:fix": "biome lint --write .",

View File

@@ -0,0 +1,19 @@
/** 功能描述:静态检查本点资料专用卡片和非 JSON 错误保护。版本v1.0.0。 */
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const root = resolve(import.meta.dirname, '..');
const profilePage = readFileSync(resolve(root, 'src/views/shared/DeliveryProfilePage.vue'), 'utf8');
const resourcePage = readFileSync(resolve(root, 'src/views/shared/ResourcePage.vue'), 'utf8');
const httpClient = readFileSync(resolve(root, 'src/api/http.ts'), 'utf8');
assert.match(resourcePage, /definition\.name === 'delivery_profile'/, '本点资料未使用专用页面');
for (const field of ['配送点编码', '负责人', '配送点地址', '所属气站', '配送点标识']) {
assert.ok(profilePage.includes(field), `资料卡缺少字段:${field}`);
}
assert.doesNotMatch(profilePage, /详情|分页|关键字/, '资料卡不应保留通用列表交互');
assert.match(httpClient, /response\.text\(\)/, 'HTTP 客户端未兼容非 JSON 响应');
assert.match(httpClient, /HTTP \$\{response\.status\}/, 'HTTP 客户端未提供状态码错误');
console.log('配送点资料页检查通过:专用只读卡片与非 JSON 错误保护已启用。');

View File

@@ -5,6 +5,11 @@ const apiBaseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:12426/
export type PageResult<T> = { total: number; list: T[] };
/**
* 发送配送点后台请求。
* 参数path 为 API 相对路径init 为标准 Fetch 请求选项。
* 返回值:统一响应中的 details网络、协议或业务失败时抛出可读错误。
*/
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = getToken();
let response: Response;
@@ -20,7 +25,15 @@ export async function request<T>(path: string, init?: RequestInit): Promise<T> {
} catch {
throw new Error('无法连接服务器,请确认服务已启动');
}
const payload = (await response.json()) as { code?: number; message?: string; details?: T };
const responseText = await response.text();
let payload: { code?: number; message?: string; details?: T };
try {
payload = responseText ? JSON.parse(responseText) : {};
} catch {
// 网关、代理和不存在的路由可能返回纯文本,不能把底层 JSON 异常暴露给用户。
if (!response.ok) throw new Error(`请求失败HTTP ${response.status}`);
throw new Error('服务器返回了无法识别的数据格式');
}
if (!response.ok || payload.code !== 0) throw new Error(payload.message || '请求失败');
return payload.details as T;
}

View File

@@ -0,0 +1,169 @@
<!-- 功能描述展示当前登录管理员所属配送点的只读资料卡版本v1.0.0 -->
<template>
<a-card class="profile-card" :bordered="false">
<template #title>配送点资料</template>
<template #extra>
<a-button :loading="loading" @click="loadProfile">
<template #icon><icon-refresh /></template>
刷新
</a-button>
</template>
<a-spin :loading="loading" class="profile-loading">
<a-result v-if="errorMessage" status="error" title="配送点资料加载失败" :subtitle="errorMessage">
<template #extra>
<a-button type="primary" @click="loadProfile">重新加载</a-button>
</template>
</a-result>
<a-empty v-else-if="!profile" description="未找到当前账号关联的配送点,请联系平台管理员" />
<template v-else>
<div class="profile-heading">
<div>
<div class="profile-name">{{ profile.name || '未命名配送点' }}</div>
<div class="profile-code">配送点编码{{ displayText(profile.delivery_code) }}</div>
</div>
<a-tag :color="statusColor(profile.status)" size="large">
{{ statusLabel(profile.status) }}
</a-tag>
</div>
<a-descriptions :column="2" bordered size="large" class="profile-details">
<a-descriptions-item label="配送点编码">{{ displayText(profile.delivery_code) }}</a-descriptions-item>
<a-descriptions-item label="负责人">{{ displayText(profile.principal) }}</a-descriptions-item>
<a-descriptions-item label="配送点地址" :span="2">{{ displayText(profile.address) }}</a-descriptions-item>
<a-descriptions-item label="所属气站">
<div class="identity-value">
<span>{{ displayText(profile.gas_basic_name, '关联气站已失效') }}</span>
<IdentityText v-if="profile.gas_basic_identity" :value="profile.gas_basic_identity" />
</div>
</a-descriptions-item>
<a-descriptions-item label="配送点标识">
<IdentityText :value="profile.identity" />
</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ formatTime(profile.created_at) }}</a-descriptions-item>
<a-descriptions-item label="更新时间">{{ formatTime(profile.updated_at) }}</a-descriptions-item>
</a-descriptions>
</template>
</a-spin>
</a-card>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import dayjs from 'dayjs';
import { onMounted, ref } from 'vue';
import { resourceApi } from '@/api/resource';
import IdentityText from '@/components/IdentityText.vue';
/** 配送点资料接口返回的只读展示模型。 */
type DeliveryProfile = {
id: number;
identity: string;
delivery_code: string;
name: string;
principal: string;
address: string;
gas_basic_identity: string;
gas_basic_name: string;
status: number;
created_at: string;
updated_at: string;
};
const loading = ref(false);
const errorMessage = ref('');
const profile = ref<DeliveryProfile>();
/** 加载 JWT 数据范围内唯一配送点的资料。 */
async function loadProfile() {
loading.value = true;
errorMessage.value = '';
try {
const result = await resourceApi.list<DeliveryProfile>('/delivery_profile', 1, 1);
profile.value = result.list[0];
} catch (error) {
profile.value = undefined;
errorMessage.value = (error as Error).message;
Message.error(errorMessage.value);
} finally {
loading.value = false;
}
}
/** 将空白资料字段转换为明确占位文案。 */
function displayText(value: unknown, emptyText = '暂未填写') {
const text = String(value ?? '').trim();
return text || emptyText;
}
/** 将接口时间转换为本地可读格式。 */
function formatTime(value: string) {
const time = dayjs(value);
return time.isValid() ? time.format('YYYY-MM-DD HH:mm:ss') : '暂无记录';
}
/** 返回通用记录状态的中文名称。 */
function statusLabel(status: number) {
return { 0: '待审核', 1: '启用', 2: '停用', 3: '已归档', 4: '已冻结' }[status] ?? '未知状态';
}
/** 返回通用记录状态对应的标签颜色。 */
function statusColor(status: number) {
return { 0: 'orange', 1: 'green', 2: 'red', 3: 'gray', 4: 'purple' }[status] ?? 'gray';
}
onMounted(loadProfile);
</script>
<style scoped>
.profile-card {
min-height: 360px;
}
.profile-loading {
display: block;
width: 100%;
min-height: 260px;
}
.profile-heading {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0 24px;
}
.profile-name {
color: var(--color-text-1);
font-size: 24px;
font-weight: 600;
line-height: 1.4;
}
.profile-code {
margin-top: 6px;
color: var(--color-text-3);
}
.profile-details {
width: 100%;
}
.identity-value {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
@media (max-width: 768px) {
.profile-heading {
align-items: flex-start;
gap: 16px;
}
.profile-name {
font-size: 20px;
}
}
</style>

View File

@@ -1,6 +1,11 @@
<!-- 功能描述按资源类型分发配送点后台页面配送点资料使用专用只读卡片版本v1.1.0 -->
<template>
<DeliveryProfilePage
v-if="definition.name === 'delivery_profile'"
:key="String(route.name)"
/>
<TreePage
v-if="definition.pageKind === 'tree'"
v-else-if="definition.pageKind === 'tree'"
:key="String(route.name)"
:definition="definition"
/>
@@ -16,6 +21,7 @@ import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { getResource } from '@/api/resources';
import CrudListPage from './CrudListPage.vue';
import DeliveryProfilePage from './DeliveryProfilePage.vue';
import TreePage from './TreePage.vue';
const route = useRoute();