已完成用户APP首期功能开发

交付用户端首期页面、配套接口、后台资源及测试文档。用户APP构建、静态分析和三个管理后台构建通过;完整测试仍有2项失败,后端模型注释检查未通过,详见交付记录。
This commit is contained in:
czl231
2026-09-13 00:57:32 +08:00
parent a0a4bb1218
commit 3ef33b531d
793 changed files with 40959 additions and 1204 deletions

View File

@@ -103,6 +103,19 @@ const pageRules: Record<string, ResourcePageRule> = {
'produced_at',
],
},
dev_usage_stat: {
editKeys: [
'stat_date',
'usage_kg',
'breakfast_kg',
'lunch_kg',
'dinner_kg',
'source',
'calc_version',
'calculated_at',
],
editSubmitOnlyKeys: ['product_info_identity'],
},
product_repair: {
editKeys: [
'repair_no',
@@ -144,6 +157,9 @@ const pageRules: Record<string, ResourcePageRule> = {
ec_product_image: {
editKeys: ['ec_product_identity', 'image_uri', 'sort_no', 'is_cover'],
},
deposit_policy: {
editKeys: ['product_type_identity', 'name', 'amount', 'rule_text'],
},
fin_settlement: {
editKeys: [
'settlement_no',

View File

@@ -149,6 +149,14 @@ export type ResourceUiDefinition = {
};
const fieldLabels: Record<string, string> = {
// 用气统计字段须注册到公共字典,资源初始化和详情展示共用此名称。
stat_date: '统计日期',
usage_kg: '用气量kg',
breakfast_kg: '早餐用气kg',
lunch_kg: '午餐用气kg',
dinner_kg: '晚餐用气kg',
calc_version: '计算口径版本',
calculated_at: '计算时间',
code: '编码',
producer_code: '生产商编码',
name: '名称',
@@ -190,6 +198,7 @@ const fieldLabels: Record<string, string> = {
started_at: '开始时间',
completed_at: '完成时间',
appointment_at: '预约时间',
deposit_amount: '气瓶押金(分)',
result: '检修结果',
target_product_status: '目标产品状态',
content: '内容',
@@ -218,6 +227,22 @@ const fieldLabels: Record<string, string> = {
pay_channel: '支付渠道',
pay_type: '支付类型',
amount: '金额(元)',
rule_text: '退押规则',
deposit_no: '押金编号',
deposit_status: '押金状态',
refunded_at: '退回时间',
policy_identity: '押金规则',
gasorder_identity: '来源供气订单',
return_status: '退瓶状态',
address_snapshot: '上门地址',
appointment_start: '预约开始时间',
appointment_end: '预约结束时间',
estimated_amount: '预计退款金额(分)',
deduction_amount: '验收扣减金额(分)',
refund_amount: '实际退款金额(分)',
inspection_remark: '验收说明',
picked_up_at: '实际回收时间',
inspected_at: '完成验收时间',
fee: '手续费(元)',
args: '支付参数',
callback_msg: '回调信息',
@@ -270,6 +295,8 @@ const fieldLabels: Record<string, string> = {
sort_no: '排序',
product_code: '商品编码',
product_type_name: '产品类型名称',
device_kind: '设备分类',
vendor_device_id: '厂商设备编号',
product_params: '产品规格参数',
price_amount: '售价(元)',
stock_quantity: '库存',
@@ -549,7 +576,14 @@ export const resources: ResourceUiDefinition[] = [
define('producer_account', '生产商管理', 'writable', [f('producer_code', { required: true }), f('name', { required: true }), f('credit_code'), f('principal'), f('phone'), f('address', { emptyText: '未填写', placeholder: '请输入地址' }), f('username', { required: true }), f('password', { required: true }), f('display_name'), producerAdminRole(), f('remark')]),
define('product_type', '智能气阀类型', 'editable', [f('code', { required: true }), f('name', { required: true })]),
define('product_warehouse', '智能气阀库房', 'editable', [f('code', { required: true }), f('name', { required: true }), f('address', { emptyText: '未填写', placeholder: '请输入地址' }), f('manager'), f('phone')]),
define('product_info', '智能气阀', 'editable', [f('code', { required: true }), f('name', { required: true }), f('product_status', { type: 'select', options: productStatusOptions, unknownValueLabel: '未知状态' }), relation('producer_account_identity', '/producer_account', true, { label: '生产商', listLabel: '生产商', listRelationNameOnly: true, displayRelationLabel: true, showIdentityCopy: true, emptyText: '未填写', placeholder: '请选择生产商' }), relation('product_type_identity', '/product_type', true, { label: '智能气阀类型', listLabel: '智能气阀类型', listRelationNameOnly: true, displayRelationLabel: true, showIdentityCopy: true, emptyText: '未填写', placeholder: '请选择智能气阀类型' }), f('params', { required: true }), relation('warehouse_identity', '/product_warehouse', false, { label: '库房', emptyText: '未填写', placeholder: '请选择库房' }), relation('gas_basic_identity', '/gas_basic', false, { label: '气站', emptyText: '未填写', placeholder: '请选择气站' }), relation('delivery_basic_identity', '/delivery_basic', false, { label: '配送点', emptyText: '未填写', placeholder: '请选择配送点' }), relation('user_account_identity', '/user_account', false, { label: '用户', emptyText: '未填写', placeholder: '请选择用户' }), f('produced_at', { required: true })], 'list', [
define('product_info', '智能气阀', 'editable', [
// 明确的后台分类和厂商编号用于后续用户设备查询,不推断在线或控制能力。
f('device_kind', { label: '设备分类', type: 'select', options: [
{ label: '未分类', value: 'unknown' }, { label: '智能阀', value: 'valve' },
{ label: '报警器', value: 'alarm' }, { label: '钢瓶', value: 'cylinder' },
], emptyText: '未分类', placeholder: '请选择设备分类' }),
f('vendor_device_id', { label: '厂商设备编号', emptyText: '未关联', placeholder: '16位数字非物联网产品留空' }),
f('code', { required: true }), f('name', { required: true }), f('product_status', { type: 'select', options: productStatusOptions, unknownValueLabel: '未知状态' }), relation('producer_account_identity', '/producer_account', true, { label: '生产商', listLabel: '生产商', listRelationNameOnly: true, displayRelationLabel: true, showIdentityCopy: true, emptyText: '未填写', placeholder: '请选择生产商' }), relation('product_type_identity', '/product_type', true, { label: '智能气阀类型', listLabel: '智能气阀类型', listRelationNameOnly: true, displayRelationLabel: true, showIdentityCopy: true, emptyText: '未填写', placeholder: '请选择智能气阀类型' }), f('params', { required: true }), relation('warehouse_identity', '/product_warehouse', false, { label: '库房', emptyText: '未填写', placeholder: '请选择库房' }), relation('gas_basic_identity', '/gas_basic', false, { label: '气站', emptyText: '未填写', placeholder: '请选择气站' }), relation('delivery_basic_identity', '/delivery_basic', false, { label: '配送点', emptyText: '未填写', placeholder: '请选择配送点' }), relation('user_account_identity', '/user_account', false, { label: '用户', emptyText: '未填写', placeholder: '请选择用户' }), f('produced_at', { required: true })], 'list', [
{ name: '修改智能气阀状态', resource: '/product_info/:identity/lifecycle', method: 'PATCH', fields: [f('product_status', { label: '目标状态', required: true, type: 'select', options: productStatusOptions, placeholder: '请选择目标状态' })] },
{ name: '变更智能气阀归属', resource: '/product_info/:identity', method: 'PUT', fields: [relation('warehouse_identity', '/product_warehouse'), relation('gas_basic_identity', '/gas_basic'), relation('delivery_basic_identity', '/delivery_basic'), relation('user_account_identity', '/user_account'), f('action', { required: true, type: 'select', options: [{ label: '入库', value: 'warehouse' }, { label: '分配', value: 'assigned' }, { label: '归还', value: 'returned' }, { label: '人工调整', value: 'manual' }] }), f('reason', { required: true }), f('remark')] },
]),
@@ -699,7 +733,11 @@ export const resources: ResourceUiDefinition[] = [
define('ec_category', '商品分类', 'writable', [relation('parent_identity', '/ec_category'), f('name', { required: true }), f('sort_no')], 'tree'),
define('ec_product', '商品', 'writable', [relation('ec_category_identity', '/ec_category', true), f('product_code', { required: true }), f('name', { required: true }), f('price_amount', { required: true }), f('stock_quantity')]),
define('ec_product_attribute', '商品属性', 'writable', [relation('ec_product_identity', '/ec_product', true), f('name', { required: true }), f('value', { required: true }), f('sort_no')]),
define('ec_product_image', '商品图片', 'writable', [relation('ec_product_identity', '/ec_product', true), f('image_uri', { required: true }), f('sort_no'), f('is_cover')]),
// 商品图片关联显示商品名称,列表沿用关系详情导航,内部标识仍用于保存。
define('ec_product_image', '商品图片', 'writable', [relation('ec_product_identity', '/ec_product', true, {
label: '商品', listLabel: '商品', listRelationNameOnly: true,
displayRelationLabel: true, showIdentityCopy: true,
}), f('image_uri', { required: true, label: '商品图片' }), f('sort_no'), f('is_cover')]),
define('ec_cart', '购物车', 'readonly', [
relation('user_account_identity', '/user_account', false, {
label: '用户', listLabel: '用户', listRelationNameOnly: true,
@@ -747,6 +785,54 @@ export const resources: ResourceUiDefinition[] = [
displayRelationLabel: true, showIdentityCopy: true,
}), f('score'), f('content'),
]),
define('dev_usage_stat', '用气统计数据', 'editable', [
relation('product_info_identity', '/product_info', true, { label: '智能设备', listLabel: '智能设备', listRelationNameOnly: true, displayRelationLabel: true, showIdentityCopy: true }),
f('stat_date', { label: '统计日期', required: true }),
f('usage_kg', { label: '用气量kg', required: true }),
f('breakfast_kg', { label: '早餐用气kg', required: true }),
f('lunch_kg', { label: '午餐用气kg', required: true }),
f('dinner_kg', { label: '晚餐用气kg', required: true }),
f('source', { label: '数据来源', required: true, type: 'select', options: [
{ label: '计量表', value: 'meter' }, { label: '设备上报', value: 'device' }, { label: '人工导入', value: 'manual' },
] }),
f('calc_version', { label: '计算口径版本', required: true }),
f('calculated_at', { label: '计算时间', required: true }),
]),
define('deposit_policy', '押金规则', 'writable', [
relation('product_type_identity', '/product_type', true, { label: '气瓶类型', listLabel: '气瓶类型' }),
f('name', { required: true, label: '规则名称' }),
f('amount', { required: true, label: '单瓶押金(分)' }),
f('rule_text', { required: true, label: '退押规则' }),
]),
define('deposit_record', '押金记录', 'readonly', [
relation('user_account_identity', '/user_account', false, { label: '用户' }),
relation('product_info_identity', '/product_info', false, { label: '绑定气瓶' }),
relation('policy_identity', '/deposit_policy', false, { label: '押金规则' }),
relation('gasorder_identity', '/gasorder_basic', false, { label: '来源供气订单', emptyText: '历史导入' }),
f('deposit_no', { label: '押金编号' }),
f('amount', { label: '押金金额(分)' }),
f('deposit_status', { type: 'select', label: '押金状态', options: [
{ label: '使用中', value: 10 }, { label: '退款中', value: 20 }, { label: '已退回', value: 23 },
] }),
f('paid_at', { label: '缴纳时间' }), f('refunded_at', { label: '退回时间', emptyText: '尚未退回' }),
]),
define('deposit_return_request', '退瓶处理', 'readonly', [
f('request_no', { label: '申请编号' }),
f('return_status', { type: 'select', options: [
{ label: '待上门', value: 10 }, { label: '已回收待验收', value: 20 },
{ label: '已验收待退款', value: 30 }, { label: '已完成', value: 40 }, { label: '已取消', value: 50 },
] }),
f('address_snapshot'), f('contact_name'), f('contact_phone'),
f('appointment_start'), f('appointment_end'), f('estimated_amount'),
f('deduction_amount'), f('refund_amount'), f('inspection_remark'),
f('operator_identity'), f('picked_up_at'), f('inspected_at'), f('completed_at'),
], 'list', [
{ name: '确认已上门回收', resource: '/deposit_return_request/:identity/confirm-pickup', visibleFor: { field: 'return_status', values: [10] } },
{ name: '提交验收结果', resource: '/deposit_return_request/:identity/inspect', fields: [
f('deduction_amount', { required: true }), f('remark', { required: true, label: '验收说明' }),
], visibleFor: { field: 'return_status', values: [20] } },
{ name: '退入用户余额', resource: '/deposit_return_request/:identity/complete-refund', visibleFor: { field: 'return_status', values: [30] } },
]),
define('wallet_basic', '钱包', 'readonly', [f('owner_type'), f('owner_identity', {
type: 'identity', label: '归属主体', listRelationNameOnly: true,
@@ -892,6 +978,8 @@ export const resources: ResourceUiDefinition[] = [
options: [
{ label: '公告', value: 'notice' },
{ label: '协议', value: 'agreement' },
{ label: '安全宣传', value: 'safety_article' },
{ label: '法律法规', value: 'law' },
],
unknownValueLabel: '未知内容类型',
}),

File diff suppressed because one or more lines are too long

View File

@@ -223,6 +223,14 @@ const routes: AppRouteRecordRaw[] = [
'/product_info',
'product_info',
),
child(
'product',
'usage-statistics',
'usage-statistics',
'用气统计数据',
'/dev_usage_stat',
'dev_usage_stat',
),
child(
'product',
'repair',
@@ -386,8 +394,6 @@ const routes: AppRouteRecordRaw[] = [
'商品图片',
'/ec_product_image',
'ec_product',
true,
'ec-products',
),
child('ec', 'carts', 'carts', '购物车', '/ec_cart', 'ec_cart'),
child('ec', 'orders', 'orders', '商城订单', '/ec_order', 'ec_order'),
@@ -402,6 +408,9 @@ const routes: AppRouteRecordRaw[] = [
'ec-orders',
),
child('ec', 'reviews', 'reviews', '商品评价', '/ec_review', 'ec_review'),
child('ec', 'deposit-policies', 'deposit-policies', '押金规则', '/deposit_policy', 'deposit_policy'),
child('ec', 'deposit-records', 'deposit-records', '押金记录', '/deposit_record', 'deposit_record'),
child('ec', 'deposit-returns', 'deposit-returns', '退瓶处理', '/deposit_return_request', 'deposit_return_request'),
]),
financeRoute,
group('content', 'content', '内容管理', 'icon-file', 100, [

View File

@@ -0,0 +1,67 @@
<!-- 功能直接上传并预览商品图片存储地址仅在内部回填版本1.1.0 -->
<template>
<div class="product-image-field">
<img v-if="modelValue" :src="previewURL" alt="商品图片预览" @error="previewFailed = true" />
<span v-if="previewFailed">图片预览失败请重新上传</span>
<input ref="picker" type="file" accept="image/jpeg,image/png" hidden @change="selectFile" />
<a-button :loading="busy" :disabled="disabled" @click="picker?.click()">
{{ modelValue ? '重新上传图片' : '上传商品图片' }}
</a-button>
<small>支持 JPGPNG最大 2MB</small>
<span v-if="error" role="alert">{{ error }}</span>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { getToken } from '@/utils/auth';
const props = defineProps<{ modelValue?: string; disabled?: boolean }>();
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
const picker = ref<HTMLInputElement>();
const busy = ref(false);
const error = ref('');
const previewFailed = ref(false);
let active = true;
let uploadController: AbortController | undefined;
// 关闭编辑窗口后取消请求,迟到响应不能回填已离开的商品表单。
onBeforeUnmount(() => { active = false; uploadController?.abort(); });
const origin = new URL(import.meta.env.VITE_API_BASE_URL || 'http://localhost:12426/heqi/platform/v1', window.location.origin).origin;
const previewURL = computed(() => {
try { const url = new URL(props.modelValue || '', origin); return ['http:', 'https:'].includes(url.protocol) ? url.href : ''; }
catch { return ''; }
});
watch(() => props.modelValue, () => { previewFailed.value = false; });
/** 上传成功后只回填URI商品绑定沿用资源保存接口失败保留旧图片。 */
async function selectFile(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file || busy.value || props.disabled) return;
error.value = '';
if (!['image/jpeg', 'image/png'].includes(file.type) || file.size <= 0 || file.size > 2 * 1024 * 1024) {
error.value = '请选择不超过2MB的JPG或PNG图片'; return;
}
const token = getToken();
busy.value = true;
uploadController = new AbortController();
try {
const body = new FormData(); body.append('file', file);
const response = await fetch(`${origin}/upload/product-image`, { method: 'POST', headers: { Authorization: token || '' }, body, signal: uploadController.signal });
const payload = await response.json();
if (!response.ok || payload.code !== 0 || typeof payload.details?.uri !== 'string' ||
!payload.details.uri.startsWith('/uploads/product-images/')) throw new Error(payload.message || '商品图片上传失败');
if (getToken() !== token) throw new Error('登录状态已变化,请重新打开页面');
if (!active) return;
emit('update:modelValue', payload.details.uri);
} catch (reason) { if (active) error.value = reason instanceof Error ? reason.message : '图片上传失败,请重试'; }
finally { if (active) busy.value = false; uploadController = undefined; }
}
</script>
<style scoped>
.product-image-field { display: flex; flex-direction: column; gap: 8px; width: 100%; }
.product-image-field img { width: 160px; height: 160px; object-fit: contain; border: 1px solid var(--color-border-2); border-radius: 6px; }
.product-image-field small { color: var(--color-text-3); }
[role='alert'] { color: rgb(var(--danger-6)); }
</style>

View File

@@ -0,0 +1,21 @@
<!-- 功能展示公开商品图片及大图预览不向用户展示存储地址版本1.0.0 -->
<template>
<a-image v-if="source" :src="source" :width="size" :height="size" fit="contain" alt="商品图片">
<template #error>图片加载失败</template>
</a-image>
<span v-else>暂无图片</span>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(defineProps<{ uri?: string; size?: number }>(), { size: 80 });
// 相对素材路径使用 API 源站,兼容旧记录;禁止非 HTTP 图片协议。
const source = computed(() => {
if (!props.uri) return '';
try {
const origin = new URL(import.meta.env.VITE_API_BASE_URL || 'http://localhost:12426/heqi/platform/v1', window.location.origin).origin;
const url = new URL(props.uri, origin);
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
} catch { return ''; }
});
</script>

View File

@@ -8,7 +8,8 @@
<div class="detail-grid">
<div v-for="entry in entries" :key="entry.key" class="detail-item" :class="{ 'detail-item-wide': entry.wide }">
<span class="detail-label">{{ entry.label }}</span>
<a-popover v-if="entry.jsonValue" position="left">
<ProductImagePreview v-if="definition.name === 'ec_product_image' && entry.key === 'image_uri'" :uri="String(entry.value)" :size="160" />
<a-popover v-else-if="entry.jsonValue" position="left">
<a-button type="text" size="mini">查看内容</a-button>
<template #content><pre class="json-value">{{ entry.value }}</pre></template>
</a-popover>
@@ -88,6 +89,7 @@ import {
import type { ResourceRow } from '@/api/resource-page-rules';
import type { ResourceUiDefinition } from '@/api/resources';
import IdentityText from '@/components/IdentityText.vue';
import ProductImagePreview from './ProductImagePreview.vue';
import RelationNameText from '@/views/shared/RelationNameText.vue';
const props = defineProps<{

View File

@@ -140,6 +140,11 @@
<IdentityText :value="String(model[field.key])" />
</div>
</div>
<ProductImageField
v-else-if="field.key === 'image_uri' && fields.some((item) => item.key === 'ec_product_identity')"
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
/>
<a-input
v-else
v-model="model[field.key]"
@@ -162,6 +167,7 @@ import ContractAttachmentField, {
type ContractAttachmentFieldState,
} from './ContractAttachmentField.vue';
import MenuPermissionTree from './MenuPermissionTree.vue';
import ProductImageField from './ProductImageField.vue';
const props = withDefaults(
defineProps<{

View File

@@ -22,6 +22,10 @@
<a-button @click="resetSearch">重置</a-button>
</a-form>
<a-space class="list-actions">
<!-- 商品图片复用商品权限通过商品列表提供固定入口不依赖服务端菜单去重 -->
<a-button v-if="definition.name === 'ec_product'" @click="router.push({ name: 'ec-images' })">
<template #icon><icon-image /></template>商品图片
</a-button>
<a-button v-if="managedOwnerIdentity" @click="returnManagedList">
<template #icon><icon-left /></template>返回来源列表
</a-button>
@@ -45,8 +49,12 @@
:tooltip="!isProtectedListAvatarField(definition.name, field.key) && !field.listRelationNameOnly"
>
<template #cell="{ record }">
<ProductImagePreview
v-if="definition.name === 'ec_product_image' && field.key === 'image_uri'"
:uri="String(record[field.key] ?? '')"
/>
<ProtectedAvatarThumbnail
v-if="isProtectedListAvatarField(definition.name, field.key)"
v-else-if="isProtectedListAvatarField(definition.name, field.key)"
:resource="definition.resource"
:identity="String(record.identity ?? '')"
:display-name="protectedListAvatarDisplayName(record)"
@@ -184,6 +192,7 @@ import {
} from 'vue';
import { useRoute, useRouter } from 'vue-router';
import IdentityText from '@/components/IdentityText.vue';
import ProductImagePreview from '@/views/resource/ProductImagePreview.vue';
import {
displayRawValue,
displayResourceField,