新增配送点资料只读卡片

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

@@ -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();