feat: 选择材料改三级级联 + 健康等级/排序权重
- 数据模型: Material 加 healthGrade(A/B/C) 和 sortOrder(竞价排名预留) - 选择材料: 平铺窗口改为 大类→子类→材料列表三级级联,点类别下方显示该类材料 - 新增健康等级独立筛选(+环保等级),材料按 sortOrder 排序 - 新建材料表单、材料库列表均加健康等级字段 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f79f0a1249
commit
3bbafc99d7
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "materials" ADD COLUMN "healthGrade" TEXT,
|
||||||
|
ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "materials_healthGrade_idx" ON "materials"("healthGrade");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "materials_sortOrder_idx" ON "materials"("sortOrder");
|
||||||
|
|
@ -33,8 +33,10 @@ model Material {
|
||||||
brand String? // 材料品牌
|
brand String? // 材料品牌
|
||||||
manufacturer String? // 材料厂家
|
manufacturer String? // 材料厂家
|
||||||
spec String? // 材料规格,如 2.7SE / 18mm
|
spec String? // 材料规格,如 2.7SE / 18mm
|
||||||
envGrade String? // 环保等级 E0/E1/E2
|
envGrade String? // 环保等级 E0/E1/E2(甲醛释放量分级)
|
||||||
|
healthGrade String? // 健康等级 A/B/C(综合健康评级,独立于环保等级)
|
||||||
usageUnit String @default("m²") // 用量单位
|
usageUnit String @default("m²") // 用量单位
|
||||||
|
sortOrder Int @default(0) // 手动排序权重(越小越靠前,为厂商竞价排名预留)
|
||||||
|
|
||||||
/// 污染物散发参数 Record<Pollutant, {y0,yp,b}>,对应 shared 的 EmissionParams
|
/// 污染物散发参数 Record<Pollutant, {y0,yp,b}>,对应 shared 的 EmissionParams
|
||||||
emissionParams Json
|
emissionParams Json
|
||||||
|
|
@ -51,7 +53,9 @@ model Material {
|
||||||
@@index([category])
|
@@index([category])
|
||||||
@@index([brand])
|
@@index([brand])
|
||||||
@@index([envGrade])
|
@@index([envGrade])
|
||||||
|
@@index([healthGrade])
|
||||||
@@index([isPublic])
|
@@index([isPublic])
|
||||||
|
@@index([sortOrder])
|
||||||
@@map("materials")
|
@@map("materials")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,13 @@ async function main() {
|
||||||
});
|
});
|
||||||
console.log('组织已就绪:', org.username, org.name);
|
console.log('组织已就绪:', org.username, org.name);
|
||||||
|
|
||||||
for (const m of MATERIALS) {
|
for (let i = 0; i < MATERIALS.length; i++) {
|
||||||
|
const m = MATERIALS[i];
|
||||||
|
const healthGrade = ['A', 'B', 'C'][i % 3]; // 示例健康等级
|
||||||
|
const sortOrder = (i + 1) * 10; // 预留竞价排名(越小越靠前)
|
||||||
await prisma.material.upsert({
|
await prisma.material.upsert({
|
||||||
where: { id: m.id },
|
where: { id: m.id },
|
||||||
update: {},
|
update: { healthGrade, sortOrder }, // 回填已存在的材料
|
||||||
create: {
|
create: {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
|
|
@ -49,6 +52,8 @@ async function main() {
|
||||||
manufacturer: m.manufacturer,
|
manufacturer: m.manufacturer,
|
||||||
spec: m.spec ?? undefined,
|
spec: m.spec ?? undefined,
|
||||||
envGrade: m.envGrade ?? undefined,
|
envGrade: m.envGrade ?? undefined,
|
||||||
|
healthGrade,
|
||||||
|
sortOrder,
|
||||||
usageUnit: 'm²',
|
usageUnit: 'm²',
|
||||||
emissionParams: ep(m.y0),
|
emissionParams: ep(m.y0),
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,9 @@ export class CreateMaterialDto {
|
||||||
@IsOptional() @IsString() manufacturer?: string;
|
@IsOptional() @IsString() manufacturer?: string;
|
||||||
@IsOptional() @IsString() spec?: string;
|
@IsOptional() @IsString() spec?: string;
|
||||||
@IsOptional() @IsString() envGrade?: string;
|
@IsOptional() @IsString() envGrade?: string;
|
||||||
|
@IsOptional() @IsString() healthGrade?: string;
|
||||||
@IsOptional() @IsString() usageUnit?: string;
|
@IsOptional() @IsString() usageUnit?: string;
|
||||||
|
@IsOptional() @IsNumber() sortOrder?: number;
|
||||||
|
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export class QueryMaterialsDto {
|
||||||
@IsOptional() @IsString() manufacturer?: string;
|
@IsOptional() @IsString() manufacturer?: string;
|
||||||
@IsOptional() @IsString() spec?: string;
|
@IsOptional() @IsString() spec?: string;
|
||||||
@IsOptional() @IsString() envGrade?: string;
|
@IsOptional() @IsString() envGrade?: string;
|
||||||
|
@IsOptional() @IsString() healthGrade?: string;
|
||||||
|
|
||||||
/** 公共库 public | 自建库 self */
|
/** 公共库 public | 自建库 self */
|
||||||
@IsOptional() @IsIn(['public', 'self']) scope?: 'public' | 'self';
|
@IsOptional() @IsIn(['public', 'self']) scope?: 'public' | 'self';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
|
import { IsNumber, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||||
import { EmissionParamsDto } from './create-material.dto';
|
import { EmissionParamsDto } from './create-material.dto';
|
||||||
|
|
||||||
export class UpdateMaterialDto {
|
export class UpdateMaterialDto {
|
||||||
|
|
@ -9,7 +9,9 @@ export class UpdateMaterialDto {
|
||||||
@IsOptional() @IsString() manufacturer?: string;
|
@IsOptional() @IsString() manufacturer?: string;
|
||||||
@IsOptional() @IsString() spec?: string;
|
@IsOptional() @IsString() spec?: string;
|
||||||
@IsOptional() @IsString() envGrade?: string;
|
@IsOptional() @IsString() envGrade?: string;
|
||||||
|
@IsOptional() @IsString() healthGrade?: string;
|
||||||
@IsOptional() @IsString() usageUnit?: string;
|
@IsOptional() @IsString() usageUnit?: string;
|
||||||
|
@IsOptional() @IsNumber() sortOrder?: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsObject()
|
@IsObject()
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export class MaterialsService {
|
||||||
if (q.manufacturer) where.manufacturer = { contains: q.manufacturer };
|
if (q.manufacturer) where.manufacturer = { contains: q.manufacturer };
|
||||||
if (q.spec) where.spec = { contains: q.spec };
|
if (q.spec) where.spec = { contains: q.spec };
|
||||||
if (q.envGrade) where.envGrade = q.envGrade;
|
if (q.envGrade) where.envGrade = q.envGrade;
|
||||||
|
if (q.healthGrade) where.healthGrade = q.healthGrade;
|
||||||
|
|
||||||
if (q.favorited === 'true') {
|
if (q.favorited === 'true') {
|
||||||
const favIds = await this.favorites.idsOf(orgId, 'material');
|
const favIds = await this.favorites.idsOf(orgId, 'material');
|
||||||
|
|
@ -71,7 +72,9 @@ export class MaterialsService {
|
||||||
manufacturer: dto.manufacturer,
|
manufacturer: dto.manufacturer,
|
||||||
spec: dto.spec,
|
spec: dto.spec,
|
||||||
envGrade: dto.envGrade,
|
envGrade: dto.envGrade,
|
||||||
|
healthGrade: dto.healthGrade,
|
||||||
usageUnit: dto.usageUnit ?? 'm²',
|
usageUnit: dto.usageUnit ?? 'm²',
|
||||||
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
emissionParams: dto.emissionParams as unknown as Prisma.InputJsonValue,
|
emissionParams: dto.emissionParams as unknown as Prisma.InputJsonValue,
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
ownerOrgId: orgId,
|
ownerOrgId: orgId,
|
||||||
|
|
@ -110,11 +113,14 @@ export class MaterialsService {
|
||||||
return 'PM' + n;
|
return 'PM' + n;
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseSort(sort?: string): Prisma.MaterialOrderByWithRelationInput {
|
private parseSort(
|
||||||
if (!sort) return { updatedAt: 'desc' };
|
sort?: string,
|
||||||
|
): Prisma.MaterialOrderByWithRelationInput | Prisma.MaterialOrderByWithRelationInput[] {
|
||||||
|
// 默认按手动排序权重(竞价排名)升序,其次最近更新
|
||||||
|
if (!sort) return [{ sortOrder: 'asc' }, { updatedAt: 'desc' }];
|
||||||
const [field, dir] = sort.split(':');
|
const [field, dir] = sort.split(':');
|
||||||
const allowed = ['updatedAt', 'name', 'id', 'envGrade'];
|
const allowed = ['updatedAt', 'name', 'id', 'envGrade', 'healthGrade', 'sortOrder'];
|
||||||
if (!allowed.includes(field)) return { updatedAt: 'desc' };
|
if (!allowed.includes(field)) return [{ sortOrder: 'asc' }, { updatedAt: 'desc' }];
|
||||||
return { [field]: dir === 'asc' ? 'asc' : 'desc' };
|
return { [field]: dir === 'asc' ? 'asc' : 'desc' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ export interface Material {
|
||||||
manufacturer?: string;
|
manufacturer?: string;
|
||||||
spec?: string;
|
spec?: string;
|
||||||
envGrade?: string;
|
envGrade?: string;
|
||||||
|
healthGrade?: string;
|
||||||
usageUnit: string;
|
usageUnit: string;
|
||||||
|
sortOrder: number;
|
||||||
emissionParams: Record<Pollutant, EmissionParams>;
|
emissionParams: Record<Pollutant, EmissionParams>;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
ownerOrgId?: string;
|
ownerOrgId?: string;
|
||||||
|
|
@ -25,6 +27,7 @@ export interface MaterialQuery {
|
||||||
manufacturer?: string;
|
manufacturer?: string;
|
||||||
spec?: string;
|
spec?: string;
|
||||||
envGrade?: string;
|
envGrade?: string;
|
||||||
|
healthGrade?: string;
|
||||||
scope?: 'public' | 'self';
|
scope?: 'public' | 'self';
|
||||||
favorited?: string;
|
favorited?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|
@ -46,7 +49,9 @@ export interface MaterialInput {
|
||||||
manufacturer?: string;
|
manufacturer?: string;
|
||||||
spec?: string;
|
spec?: string;
|
||||||
envGrade?: string;
|
envGrade?: string;
|
||||||
|
healthGrade?: string;
|
||||||
usageUnit?: string;
|
usageUnit?: string;
|
||||||
|
sortOrder?: number;
|
||||||
emissionParams: Record<Pollutant, EmissionParams>;
|
emissionParams: Record<Pollutant, EmissionParams>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,17 +28,24 @@
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
<a-form-item label="材料厂家"><a-input v-model:value="form.manufacturer" placeholder="请输入" /></a-form-item>
|
<a-form-item label="材料厂家"><a-input v-model:value="form.manufacturer" placeholder="请输入" /></a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="8">
|
<a-col :span="6">
|
||||||
<a-form-item label="材料规格"><a-input v-model:value="form.spec" placeholder="请输入" /></a-form-item>
|
<a-form-item label="材料规格"><a-input v-model:value="form.spec" placeholder="请输入" /></a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="8">
|
<a-col :span="6">
|
||||||
<a-form-item label="环保级别">
|
<a-form-item label="环保级别">
|
||||||
<a-select v-model:value="form.envGrade" allow-clear placeholder="请选择">
|
<a-select v-model:value="form.envGrade" allow-clear placeholder="请选择">
|
||||||
<a-select-option v-for="g in envGrades" :key="g" :value="g">{{ g }}</a-select-option>
|
<a-select-option v-for="g in envGrades" :key="g" :value="g">{{ g }}</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="8">
|
<a-col :span="6">
|
||||||
|
<a-form-item label="健康等级">
|
||||||
|
<a-select v-model:value="form.healthGrade" allow-clear placeholder="请选择">
|
||||||
|
<a-select-option v-for="g in healthGrades" :key="g" :value="g">{{ g }} 级</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="6">
|
||||||
<a-form-item label="用量单位">
|
<a-form-item label="用量单位">
|
||||||
<a-select v-model:value="form.usageUnit">
|
<a-select v-model:value="form.usageUnit">
|
||||||
<a-select-option v-for="u in units" :key="u" :value="u">{{ u }}</a-select-option>
|
<a-select-option v-for="u in units" :key="u" :value="u">{{ u }}</a-select-option>
|
||||||
|
|
@ -78,6 +85,7 @@ import {
|
||||||
POLLUTANT_LABELS,
|
POLLUTANT_LABELS,
|
||||||
MATERIAL_CATEGORIES,
|
MATERIAL_CATEGORIES,
|
||||||
ENV_GRADES,
|
ENV_GRADES,
|
||||||
|
HEALTH_GRADES,
|
||||||
USAGE_UNITS,
|
USAGE_UNITS,
|
||||||
type Pollutant,
|
type Pollutant,
|
||||||
type EmissionParams,
|
type EmissionParams,
|
||||||
|
|
@ -91,6 +99,7 @@ const pollutants = POLLUTANTS;
|
||||||
const labels = POLLUTANT_LABELS;
|
const labels = POLLUTANT_LABELS;
|
||||||
const categories = MATERIAL_CATEGORIES;
|
const categories = MATERIAL_CATEGORIES;
|
||||||
const envGrades = ENV_GRADES;
|
const envGrades = ENV_GRADES;
|
||||||
|
const healthGrades = HEALTH_GRADES;
|
||||||
const units = USAGE_UNITS;
|
const units = USAGE_UNITS;
|
||||||
|
|
||||||
const formRef = ref();
|
const formRef = ref();
|
||||||
|
|
@ -111,6 +120,7 @@ const form = reactive<MaterialInput>({
|
||||||
manufacturer: '',
|
manufacturer: '',
|
||||||
spec: '',
|
spec: '',
|
||||||
envGrade: undefined,
|
envGrade: undefined,
|
||||||
|
healthGrade: undefined,
|
||||||
usageUnit: 'm²',
|
usageUnit: 'm²',
|
||||||
emissionParams: emptyParams(),
|
emissionParams: emptyParams(),
|
||||||
});
|
});
|
||||||
|
|
@ -128,6 +138,7 @@ watch(
|
||||||
manufacturer: props.material.manufacturer,
|
manufacturer: props.material.manufacturer,
|
||||||
spec: props.material.spec,
|
spec: props.material.spec,
|
||||||
envGrade: props.material.envGrade,
|
envGrade: props.material.envGrade,
|
||||||
|
healthGrade: props.material.healthGrade,
|
||||||
usageUnit: props.material.usageUnit,
|
usageUnit: props.material.usageUnit,
|
||||||
emissionParams: JSON.parse(JSON.stringify(props.material.emissionParams)),
|
emissionParams: JSON.parse(JSON.stringify(props.material.emissionParams)),
|
||||||
});
|
});
|
||||||
|
|
@ -135,7 +146,7 @@ watch(
|
||||||
isEdit.value = false;
|
isEdit.value = false;
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
name: '', category: '', brand: '', manufacturer: '', spec: '',
|
name: '', category: '', brand: '', manufacturer: '', spec: '',
|
||||||
envGrade: undefined, usageUnit: 'm²', emissionParams: emptyParams(),
|
envGrade: undefined, healthGrade: undefined, usageUnit: 'm²', emissionParams: emptyParams(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -5,44 +5,68 @@
|
||||||
<a-button type="primary" @click="emit('cancel')">完 成(已加 {{ existingIds.length }})</a-button>
|
<a-button type="primary" @click="emit('cancel')">完 成(已加 {{ existingIds.length }})</a-button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 顶部:库 + 分类勾选 -->
|
<!-- 顶部筛选 -->
|
||||||
<div class="picker-head">
|
<div class="filters">
|
||||||
<div class="scope-row">
|
|
||||||
<span class="lbl">材料库:</span>
|
<span class="lbl">材料库:</span>
|
||||||
<a-radio-group v-model:value="scope" size="small" button-style="solid" @change="onScopeChange">
|
<a-radio-group v-model:value="scope" size="small" button-style="solid" @change="reload">
|
||||||
<a-radio-button value="public">公共库</a-radio-button>
|
<a-radio-button value="public">公共库</a-radio-button>
|
||||||
<a-radio-button value="self">自建库</a-radio-button>
|
<a-radio-button value="self">自建库</a-radio-button>
|
||||||
</a-radio-group>
|
</a-radio-group>
|
||||||
<span class="tip">勾选大类或二级分类,下方生成对应窗口,点击窗口放大快速录入</span>
|
<span class="lbl" style="margin-left: 20px">健康等级:</span>
|
||||||
</div>
|
<a-select v-model:value="healthGrade" size="small" style="width: 110px" allow-clear placeholder="全部" @change="reload">
|
||||||
<a-checkbox-group v-model:value="selectedCats" class="cat-group">
|
<a-select-option v-for="g in healthGrades" :key="g" :value="g">{{ g }} 级</a-select-option>
|
||||||
<div v-for="g in tree" :key="g.major" class="cat-line">
|
</a-select>
|
||||||
<a-checkbox :value="g.major" class="major">{{ g.major }}<template v-if="g.subs.length">(整类)</template></a-checkbox>
|
<span class="lbl" style="margin-left: 20px">环保等级:</span>
|
||||||
<span v-if="g.subs.length" class="arrow">▸</span>
|
<a-select v-model:value="envGrade" size="small" style="width: 100px" allow-clear placeholder="全部" @change="reload">
|
||||||
<a-checkbox v-for="s in g.subs" :key="s" :value="g.major + '/' + s">{{ s }}</a-checkbox>
|
<a-select-option v-for="g in envGrades" :key="g" :value="g">{{ g }}</a-select-option>
|
||||||
</div>
|
</a-select>
|
||||||
</a-checkbox-group>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a-divider style="margin: 12px 0" />
|
<!-- 级联:大类 -->
|
||||||
|
<div class="cascade-row">
|
||||||
|
<span class="cascade-lbl">大类</span>
|
||||||
|
<a-tag
|
||||||
|
v-for="g in tree"
|
||||||
|
:key="g.major"
|
||||||
|
:color="major === g.major ? '#b4232a' : 'default'"
|
||||||
|
class="cas-tag"
|
||||||
|
@click="selectMajor(g.major)"
|
||||||
|
>{{ g.major }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<!-- 级联:子类 -->
|
||||||
|
<div class="cascade-row" v-if="currentSubs.length">
|
||||||
|
<span class="cascade-lbl">子类</span>
|
||||||
|
<a-tag
|
||||||
|
:color="sub === null ? '#b4232a' : 'default'"
|
||||||
|
class="cas-tag"
|
||||||
|
@click="selectSub(null)"
|
||||||
|
>全部</a-tag>
|
||||||
|
<a-tag
|
||||||
|
v-for="s in currentSubs"
|
||||||
|
:key="s"
|
||||||
|
:color="sub === s ? '#b4232a' : 'default'"
|
||||||
|
class="cas-tag"
|
||||||
|
@click="selectSub(s)"
|
||||||
|
>{{ s }}</a-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 放大窗口 -->
|
<a-divider style="margin: 10px 0" />
|
||||||
<div v-if="enlarged" class="enlarged">
|
|
||||||
<div class="ehead">
|
<!-- 当前类别材料 -->
|
||||||
<a @click="enlarged = null">← 返回平铺</a>
|
<div class="list-head">
|
||||||
<b>{{ catLabel(enlarged) }}({{ (cache[enlarged] || []).length }} 种)</b>
|
<span>{{ major }}<template v-if="sub"> / {{ sub }}</template> · 共 {{ list.length }} 种</span>
|
||||||
<a-button type="primary" size="small" :disabled="checkedCount === 0" @click="addSelected">
|
<a-button type="primary" size="small" :disabled="checkedCount === 0" @click="addSelected">
|
||||||
批量添加所选({{ checkedCount }})
|
批量添加所选({{ checkedCount }})
|
||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
<a-table
|
<a-table
|
||||||
:columns="bigColumns"
|
:columns="columns"
|
||||||
:data-source="cache[enlarged] || []"
|
:data-source="list"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
size="small"
|
size="small"
|
||||||
:pagination="false"
|
:pagination="false"
|
||||||
:scroll="{ y: 360 }"
|
:scroll="{ y: 380 }"
|
||||||
>
|
>
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.key === 'check'">
|
<template v-if="column.key === 'check'">
|
||||||
|
|
@ -55,6 +79,9 @@
|
||||||
<template v-else-if="column.key === 'envGrade'">
|
<template v-else-if="column.key === 'envGrade'">
|
||||||
<a-tag v-if="record.envGrade">{{ record.envGrade }}</a-tag><span v-else>-</span>
|
<a-tag v-if="record.envGrade">{{ record.envGrade }}</a-tag><span v-else>-</span>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="column.key === 'healthGrade'">
|
||||||
|
<a-tag v-if="record.healthGrade" :color="healthColor(record.healthGrade)">{{ record.healthGrade }}</a-tag><span v-else>-</span>
|
||||||
|
</template>
|
||||||
<template v-else-if="column.key === 'area'">
|
<template v-else-if="column.key === 'area'">
|
||||||
<span v-if="picked.has(record.id)" class="added">已添加</span>
|
<span v-if="picked.has(record.id)" class="added">已添加</span>
|
||||||
<a-input-number
|
<a-input-number
|
||||||
|
|
@ -70,37 +97,13 @@
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 平铺窗口 -->
|
|
||||||
<div v-else>
|
|
||||||
<a-empty v-if="!selectedCats.length" description="请在上方勾选材料类别" />
|
|
||||||
<a-row v-else :gutter="[12, 12]">
|
|
||||||
<a-col v-for="cat in selectedCats" :key="cat" :span="6">
|
|
||||||
<a-card hoverable size="small" class="win" @click="enlarge(cat)">
|
|
||||||
<template #title>
|
|
||||||
<span class="win-title">{{ catLabel(cat) }}</span>
|
|
||||||
<a-badge :count="(cache[cat] || []).length" :number-style="{ backgroundColor: '#b4232a' }" show-zero />
|
|
||||||
</template>
|
|
||||||
<div v-if="loadingCats.has(cat)" class="prev muted">加载中…</div>
|
|
||||||
<template v-else>
|
|
||||||
<div v-for="m in (cache[cat] || []).slice(0, 4)" :key="m.id" class="prev">
|
|
||||||
· {{ m.brand || m.name }} {{ m.spec || '' }}
|
|
||||||
</div>
|
|
||||||
<div v-if="!(cache[cat] || []).length" class="prev muted">该类暂无材料</div>
|
|
||||||
</template>
|
|
||||||
<div class="more">点击放大 →</div>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</div>
|
|
||||||
</a-modal>
|
</a-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, ref, watch } from 'vue';
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
import { MATERIAL_CATEGORIES } from '@airpredict/shared';
|
import { MATERIAL_CATEGORIES, HEALTH_GRADES, ENV_GRADES } from '@airpredict/shared';
|
||||||
import { listMaterials, type Material } from '../api/materials';
|
import { listMaterials, type Material } from '../api/materials';
|
||||||
|
|
||||||
const props = defineProps<{ open: boolean; existingIds: string[] }>();
|
const props = defineProps<{ open: boolean; existingIds: string[] }>();
|
||||||
|
|
@ -109,119 +112,114 @@ const emit = defineEmits<{
|
||||||
(e: 'cancel'): void;
|
(e: 'cancel'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const healthGrades = HEALTH_GRADES;
|
||||||
|
const envGrades = ENV_GRADES;
|
||||||
|
|
||||||
const scope = ref<'public' | 'self'>('public');
|
const scope = ref<'public' | 'self'>('public');
|
||||||
|
const healthGrade = ref<string | undefined>(undefined);
|
||||||
|
const envGrade = ref<string | undefined>(undefined);
|
||||||
|
const major = ref<string>('');
|
||||||
|
const sub = ref<string | null>(null);
|
||||||
|
const list = ref<Material[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const selectedCats = ref<string[]>([]);
|
|
||||||
const enlarged = ref<string | null>(null);
|
|
||||||
const cache = reactive<Record<string, Material[]>>({});
|
|
||||||
const loadingCats = reactive<Set<string>>(new Set());
|
|
||||||
const rowState = reactive<Record<string, { checked: boolean; area: number | null }>>({});
|
const rowState = reactive<Record<string, { checked: boolean; area: number | null }>>({});
|
||||||
|
|
||||||
const picked = computed(() => new Set(props.existingIds));
|
const picked = computed(() => new Set(props.existingIds));
|
||||||
|
|
||||||
// 由 MATERIAL_CATEGORIES 解析出 大类 -> 二级[]
|
// 大类 -> 子类[]
|
||||||
const tree = computed(() => {
|
const tree = computed(() => {
|
||||||
const map = new Map<string, string[]>();
|
const map = new Map<string, string[]>();
|
||||||
for (const c of MATERIAL_CATEGORIES) {
|
for (const c of MATERIAL_CATEGORIES) {
|
||||||
const [major, sub] = c.split('/');
|
const [m, s] = c.split('/');
|
||||||
if (!map.has(major)) map.set(major, []);
|
if (!map.has(m)) map.set(m, []);
|
||||||
if (sub && !map.get(major)!.includes(sub)) map.get(major)!.push(sub);
|
if (s && !map.get(m)!.includes(s)) map.get(m)!.push(s);
|
||||||
}
|
}
|
||||||
return [...map.entries()].map(([major, subs]) => ({ major, subs }));
|
return [...map.entries()].map(([m, subs]) => ({ major: m, subs }));
|
||||||
});
|
});
|
||||||
|
const currentSubs = computed(() => tree.value.find((g) => g.major === major.value)?.subs || []);
|
||||||
|
|
||||||
const bigColumns = [
|
const columns = [
|
||||||
{ title: '', key: 'check', width: 40 },
|
{ title: '', key: 'check', width: 40 },
|
||||||
{ title: '材料ID', dataIndex: 'id' },
|
{ title: '材料ID', dataIndex: 'id' },
|
||||||
{ title: '材料名称', dataIndex: 'name' },
|
{ title: '材料名称', dataIndex: 'name' },
|
||||||
{ title: '材料类别', dataIndex: 'category' },
|
|
||||||
{ title: '品牌', dataIndex: 'brand' },
|
{ title: '品牌', dataIndex: 'brand' },
|
||||||
{ title: '规格', dataIndex: 'spec' },
|
{ title: '规格', dataIndex: 'spec' },
|
||||||
{ title: '环保', key: 'envGrade', width: 64 },
|
{ title: '环保', key: 'envGrade', width: 64 },
|
||||||
|
{ title: '健康', key: 'healthGrade', width: 64 },
|
||||||
{ title: '使用量(面积)', key: 'area', width: 150 },
|
{ title: '使用量(面积)', key: 'area', width: 150 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const checkedCount = computed(
|
const checkedCount = computed(() => Object.values(rowState).filter((r) => r.checked).length);
|
||||||
() => Object.values(rowState).filter((r) => r.checked).length,
|
|
||||||
);
|
|
||||||
|
|
||||||
function catLabel(cat: string) {
|
function healthColor(g: string) {
|
||||||
return cat.includes('/') ? cat.split('/')[1] : cat + '(整类)';
|
return { A: 'green', B: 'blue', C: 'orange' }[g] || 'default';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCat(cat: string) {
|
function selectMajor(m: string) {
|
||||||
if (loadingCats.has(cat)) return;
|
major.value = m;
|
||||||
loadingCats.add(cat);
|
sub.value = null;
|
||||||
try {
|
reload();
|
||||||
const res = await listMaterials({ category: cat, scope: scope.value, page: 1, pageSize: 200 });
|
|
||||||
cache[cat] = res.items;
|
|
||||||
} finally {
|
|
||||||
loadingCats.delete(cat);
|
|
||||||
}
|
}
|
||||||
|
function selectSub(s: string | null) {
|
||||||
|
sub.value = s;
|
||||||
|
reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(selectedCats, (cats) => {
|
async function reload() {
|
||||||
for (const c of cats) if (!cache[c]) loadCat(c);
|
if (!major.value) return;
|
||||||
});
|
loading.value = true;
|
||||||
|
|
||||||
function onScopeChange() {
|
|
||||||
for (const k of Object.keys(cache)) delete cache[k];
|
|
||||||
for (const c of selectedCats.value) loadCat(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
function enlarge(cat: string) {
|
|
||||||
enlarged.value = cat;
|
|
||||||
for (const k of Object.keys(rowState)) delete rowState[k];
|
for (const k of Object.keys(rowState)) delete rowState[k];
|
||||||
for (const m of cache[cat] || []) rowState[m.id] = { checked: false, area: null };
|
try {
|
||||||
|
const category = sub.value ? `${major.value}/${sub.value}` : major.value;
|
||||||
|
const res = await listMaterials({
|
||||||
|
category,
|
||||||
|
healthGrade: healthGrade.value,
|
||||||
|
envGrade: envGrade.value,
|
||||||
|
scope: scope.value,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 300,
|
||||||
|
});
|
||||||
|
list.value = res.items;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleRow(id: string, checked: boolean) {
|
function toggleRow(id: string, checked: boolean) {
|
||||||
if (rowState[id]) rowState[id].checked = checked;
|
rowState[id] = { checked, area: rowState[id]?.area ?? null };
|
||||||
}
|
}
|
||||||
function setArea(id: string, v: number | null) {
|
function setArea(id: string, v: number | null) {
|
||||||
if (!rowState[id]) rowState[id] = { checked: false, area: null };
|
rowState[id] = { checked: !!(v && v > 0) || !!rowState[id]?.checked, area: v };
|
||||||
rowState[id].area = v;
|
|
||||||
if (v && v > 0) rowState[id].checked = true; // 填了面积自动勾选
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function addSelected() {
|
function addSelected() {
|
||||||
const cat = enlarged.value!;
|
const items = list.value
|
||||||
const items = (cache[cat] || [])
|
|
||||||
.filter((m) => rowState[m.id]?.checked && !picked.value.has(m.id))
|
.filter((m) => rowState[m.id]?.checked && !picked.value.has(m.id))
|
||||||
.map((m) => ({ material: m, usageAmount: Number(rowState[m.id].area) || 0 }));
|
.map((m) => ({ material: m, usageAmount: Number(rowState[m.id].area) || 0 }));
|
||||||
if (!items.length) return message.warning('请先勾选材料');
|
if (!items.length) return message.warning('请先勾选材料');
|
||||||
emit('add', items);
|
emit('add', items);
|
||||||
message.success(`已添加 ${items.length} 种材料`);
|
message.success(`已添加 ${items.length} 种材料`);
|
||||||
enlarged.value = null;
|
for (const k of Object.keys(rowState)) delete rowState[k];
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.open,
|
() => props.open,
|
||||||
(o) => {
|
(o) => {
|
||||||
if (o) {
|
if (o) {
|
||||||
enlarged.value = null;
|
if (!major.value) major.value = tree.value[0]?.major || '';
|
||||||
for (const k of Object.keys(cache)) delete cache[k];
|
sub.value = null;
|
||||||
for (const c of selectedCats.value) loadCat(c);
|
reload();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.picker-head { }
|
.filters { display: flex; align-items: center; margin-bottom: 12px; }
|
||||||
.scope-row { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
|
.filters .lbl { color: #555; }
|
||||||
.scope-row .lbl { color: #555; }
|
.cascade-row { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||||
.scope-row .tip { color: #999; font-size: 12px; }
|
.cascade-lbl { color: #999; font-size: 12px; width: 32px; flex-shrink: 0; }
|
||||||
.cat-group { display: flex; flex-direction: column; gap: 6px; max-height: 150px; overflow-y: auto; }
|
.cas-tag { cursor: pointer; user-select: none; margin: 0; }
|
||||||
.cat-line { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 12px; padding: 2px 0; }
|
.list-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; color: #555; }
|
||||||
.cat-line .major { font-weight: 600; }
|
|
||||||
.cat-line .arrow { color: #ccc; }
|
|
||||||
.win { cursor: pointer; min-height: 150px; }
|
|
||||||
.win-title { font-weight: 600; margin-right: 8px; }
|
|
||||||
.prev { font-size: 12px; color: #666; line-height: 1.8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.prev.muted { color: #bbb; }
|
|
||||||
.more { margin-top: 8px; color: #b4232a; font-size: 12px; }
|
|
||||||
.enlarged .ehead { display: flex; align-items: center; gap: 16px; margin-bottom: 10px; }
|
|
||||||
.enlarged .ehead b { flex: 1; }
|
|
||||||
.added { color: #999; }
|
.added { color: #999; }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,13 @@
|
||||||
<a-select-option value="E2">E2</a-select-option>
|
<a-select-option value="E2">E2</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item label="健康等级">
|
||||||
|
<a-select v-model:value="q.healthGrade" allow-clear style="width: 100px" placeholder="全部" @change="reload">
|
||||||
|
<a-select-option value="A">A 级</a-select-option>
|
||||||
|
<a-select-option value="B">B 级</a-select-option>
|
||||||
|
<a-select-option value="C">C 级</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
<a-button @click="reset">重 置</a-button>
|
<a-button @click="reset">重 置</a-button>
|
||||||
<a-button type="primary" style="margin-left: 8px" @click="reload">查询</a-button>
|
<a-button type="primary" style="margin-left: 8px" @click="reload">查询</a-button>
|
||||||
|
|
@ -40,6 +47,10 @@
|
||||||
<a-tag v-if="record.envGrade">{{ record.envGrade }}</a-tag>
|
<a-tag v-if="record.envGrade">{{ record.envGrade }}</a-tag>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="column.key === 'healthGrade'">
|
||||||
|
<a-tag v-if="record.healthGrade" :color="healthColor(record.healthGrade)">{{ record.healthGrade }} 级</a-tag>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
<template v-else-if="column.key === 'action'">
|
<template v-else-if="column.key === 'action'">
|
||||||
<a @click="showDetail(record)">详情</a>
|
<a @click="showDetail(record)">详情</a>
|
||||||
<template v-if="scope === 'self'">
|
<template v-if="scope === 'self'">
|
||||||
|
|
@ -97,11 +108,12 @@ import MaterialFormModal from '../components/MaterialFormModal.vue';
|
||||||
|
|
||||||
const pollutants = POLLUTANTS;
|
const pollutants = POLLUTANTS;
|
||||||
const labels = POLLUTANT_LABELS;
|
const labels = POLLUTANT_LABELS;
|
||||||
|
const healthColor = (g: string) => ({ A: 'green', B: 'blue', C: 'orange' } as Record<string, string>)[g] || 'default';
|
||||||
|
|
||||||
const scope = ref<'public' | 'self'>('public');
|
const scope = ref<'public' | 'self'>('public');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const data = ref<Paged<Material>>({ total: 0, page: 1, pageSize: 10, items: [] });
|
const data = ref<Paged<Material>>({ total: 0, page: 1, pageSize: 10, items: [] });
|
||||||
const q = reactive<any>({ id: '', name: '', category: '', brand: '', envGrade: undefined });
|
const q = reactive<any>({ id: '', name: '', category: '', brand: '', envGrade: undefined, healthGrade: undefined });
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
|
|
@ -112,6 +124,7 @@ const columns = [
|
||||||
{ title: '材料厂家', dataIndex: 'manufacturer', key: 'manufacturer' },
|
{ title: '材料厂家', dataIndex: 'manufacturer', key: 'manufacturer' },
|
||||||
{ title: '材料规格', dataIndex: 'spec', key: 'spec' },
|
{ title: '材料规格', dataIndex: 'spec', key: 'spec' },
|
||||||
{ title: '环保等级', key: 'envGrade' },
|
{ title: '环保等级', key: 'envGrade' },
|
||||||
|
{ title: '健康等级', key: 'healthGrade' },
|
||||||
{ title: '操作', key: 'action', width: 160 },
|
{ title: '操作', key: 'action', width: 160 },
|
||||||
{ title: '收藏', key: 'favorite', width: 70 },
|
{ title: '收藏', key: 'favorite', width: 70 },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,14 @@ export type SpaceType = (typeof SPACE_TYPES)[number];
|
||||||
/** 空间户型:等高 / 非等高 */
|
/** 空间户型:等高 / 非等高 */
|
||||||
export type SpaceLayout = 'uniform' | 'non-uniform';
|
export type SpaceLayout = 'uniform' | 'non-uniform';
|
||||||
|
|
||||||
/** 环保等级 */
|
/** 环保等级(甲醛释放量国标分级) */
|
||||||
export const ENV_GRADES = ['E0', 'E1', 'E2'] as const;
|
export const ENV_GRADES = ['E0', 'E1', 'E2'] as const;
|
||||||
export type EnvGrade = (typeof ENV_GRADES)[number];
|
export type EnvGrade = (typeof ENV_GRADES)[number];
|
||||||
|
|
||||||
|
/** 健康等级(综合健康评级,独立于环保等级) */
|
||||||
|
export const HEALTH_GRADES = ['A', 'B', 'C'] as const;
|
||||||
|
export type HealthGrade = (typeof HEALTH_GRADES)[number];
|
||||||
|
|
||||||
/** 预测评级 */
|
/** 预测评级 */
|
||||||
export const PREDICTION_RATINGS = ['A', 'B', 'C', 'D'] as const;
|
export const PREDICTION_RATINGS = ['A', 'B', 'C', 'D'] as const;
|
||||||
export type PredictionRating = (typeof PREDICTION_RATINGS)[number];
|
export type PredictionRating = (typeof PREDICTION_RATINGS)[number];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue