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:
zty 2026-06-11 14:41:12 +08:00
parent f79f0a1249
commit 3bbafc99d7
12 changed files with 222 additions and 162 deletions

View File

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

View File

@ -33,8 +33,10 @@ model Material {
brand String? // 材料品牌
manufacturer String? // 材料厂家
spec String? // 材料规格,如 2.7SE / 18mm
envGrade String? // 环保等级 E0/E1/E2
envGrade String? // 环保等级 E0/E1/E2甲醛释放量分级
healthGrade String? // 健康等级 A/B/C综合健康评级独立于环保等级
usageUnit String @default("m²") // 用量单位
sortOrder Int @default(0) // 手动排序权重(越小越靠前,为厂商竞价排名预留)
/// 污染物散发参数 Record<Pollutant, {y0,yp,b}>,对应 shared 的 EmissionParams
emissionParams Json
@ -51,7 +53,9 @@ model Material {
@@index([category])
@@index([brand])
@@index([envGrade])
@@index([healthGrade])
@@index([isPublic])
@@index([sortOrder])
@@map("materials")
}

View File

@ -37,10 +37,13 @@ async function main() {
});
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({
where: { id: m.id },
update: {},
update: { healthGrade, sortOrder }, // 回填已存在的材料
create: {
id: m.id,
name: m.name,
@ -49,6 +52,8 @@ async function main() {
manufacturer: m.manufacturer,
spec: m.spec ?? undefined,
envGrade: m.envGrade ?? undefined,
healthGrade,
sortOrder,
usageUnit: 'm²',
emissionParams: ep(m.y0),
isPublic: true,

View File

@ -29,7 +29,9 @@ export class CreateMaterialDto {
@IsOptional() @IsString() manufacturer?: string;
@IsOptional() @IsString() spec?: string;
@IsOptional() @IsString() envGrade?: string;
@IsOptional() @IsString() healthGrade?: string;
@IsOptional() @IsString() usageUnit?: string;
@IsOptional() @IsNumber() sortOrder?: number;
@IsObject()
@ValidateNested()

View File

@ -9,6 +9,7 @@ export class QueryMaterialsDto {
@IsOptional() @IsString() manufacturer?: string;
@IsOptional() @IsString() spec?: string;
@IsOptional() @IsString() envGrade?: string;
@IsOptional() @IsString() healthGrade?: string;
/** 公共库 public | 自建库 self */
@IsOptional() @IsIn(['public', 'self']) scope?: 'public' | 'self';

View File

@ -1,5 +1,5 @@
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';
export class UpdateMaterialDto {
@ -9,7 +9,9 @@ export class UpdateMaterialDto {
@IsOptional() @IsString() manufacturer?: string;
@IsOptional() @IsString() spec?: string;
@IsOptional() @IsString() envGrade?: string;
@IsOptional() @IsString() healthGrade?: string;
@IsOptional() @IsString() usageUnit?: string;
@IsOptional() @IsNumber() sortOrder?: number;
@IsOptional()
@IsObject()

View File

@ -26,6 +26,7 @@ export class MaterialsService {
if (q.manufacturer) where.manufacturer = { contains: q.manufacturer };
if (q.spec) where.spec = { contains: q.spec };
if (q.envGrade) where.envGrade = q.envGrade;
if (q.healthGrade) where.healthGrade = q.healthGrade;
if (q.favorited === 'true') {
const favIds = await this.favorites.idsOf(orgId, 'material');
@ -71,7 +72,9 @@ export class MaterialsService {
manufacturer: dto.manufacturer,
spec: dto.spec,
envGrade: dto.envGrade,
healthGrade: dto.healthGrade,
usageUnit: dto.usageUnit ?? 'm²',
sortOrder: dto.sortOrder ?? 0,
emissionParams: dto.emissionParams as unknown as Prisma.InputJsonValue,
isPublic: false,
ownerOrgId: orgId,
@ -110,11 +113,14 @@ export class MaterialsService {
return 'PM' + n;
}
private parseSort(sort?: string): Prisma.MaterialOrderByWithRelationInput {
if (!sort) return { updatedAt: 'desc' };
private parseSort(
sort?: string,
): Prisma.MaterialOrderByWithRelationInput | Prisma.MaterialOrderByWithRelationInput[] {
// 默认按手动排序权重(竞价排名)升序,其次最近更新
if (!sort) return [{ sortOrder: 'asc' }, { updatedAt: 'desc' }];
const [field, dir] = sort.split(':');
const allowed = ['updatedAt', 'name', 'id', 'envGrade'];
if (!allowed.includes(field)) return { updatedAt: 'desc' };
const allowed = ['updatedAt', 'name', 'id', 'envGrade', 'healthGrade', 'sortOrder'];
if (!allowed.includes(field)) return [{ sortOrder: 'asc' }, { updatedAt: 'desc' }];
return { [field]: dir === 'asc' ? 'asc' : 'desc' };
}
}

View File

@ -9,7 +9,9 @@ export interface Material {
manufacturer?: string;
spec?: string;
envGrade?: string;
healthGrade?: string;
usageUnit: string;
sortOrder: number;
emissionParams: Record<Pollutant, EmissionParams>;
isPublic: boolean;
ownerOrgId?: string;
@ -25,6 +27,7 @@ export interface MaterialQuery {
manufacturer?: string;
spec?: string;
envGrade?: string;
healthGrade?: string;
scope?: 'public' | 'self';
favorited?: string;
page?: number;
@ -46,7 +49,9 @@ export interface MaterialInput {
manufacturer?: string;
spec?: string;
envGrade?: string;
healthGrade?: string;
usageUnit?: string;
sortOrder?: number;
emissionParams: Record<Pollutant, EmissionParams>;
}

View File

@ -28,17 +28,24 @@
<a-col :span="12">
<a-form-item label="材料厂家"><a-input v-model:value="form.manufacturer" placeholder="请输入" /></a-form-item>
</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-col>
<a-col :span="8">
<a-col :span="6">
<a-form-item label="环保级别">
<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>
</a-form-item>
</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-select v-model:value="form.usageUnit">
<a-select-option v-for="u in units" :key="u" :value="u">{{ u }}</a-select-option>
@ -78,6 +85,7 @@ import {
POLLUTANT_LABELS,
MATERIAL_CATEGORIES,
ENV_GRADES,
HEALTH_GRADES,
USAGE_UNITS,
type Pollutant,
type EmissionParams,
@ -91,6 +99,7 @@ const pollutants = POLLUTANTS;
const labels = POLLUTANT_LABELS;
const categories = MATERIAL_CATEGORIES;
const envGrades = ENV_GRADES;
const healthGrades = HEALTH_GRADES;
const units = USAGE_UNITS;
const formRef = ref();
@ -111,6 +120,7 @@ const form = reactive<MaterialInput>({
manufacturer: '',
spec: '',
envGrade: undefined,
healthGrade: undefined,
usageUnit: 'm²',
emissionParams: emptyParams(),
});
@ -128,6 +138,7 @@ watch(
manufacturer: props.material.manufacturer,
spec: props.material.spec,
envGrade: props.material.envGrade,
healthGrade: props.material.healthGrade,
usageUnit: props.material.usageUnit,
emissionParams: JSON.parse(JSON.stringify(props.material.emissionParams)),
});
@ -135,7 +146,7 @@ watch(
isEdit.value = false;
Object.assign(form, {
name: '', category: '', brand: '', manufacturer: '', spec: '',
envGrade: undefined, usageUnit: 'm²', emissionParams: emptyParams(),
envGrade: undefined, healthGrade: undefined, usageUnit: 'm²', emissionParams: emptyParams(),
});
}
},

View File

@ -5,102 +5,105 @@
<a-button type="primary" @click="emit('cancel')"> 已加 {{ existingIds.length }}</a-button>
</template>
<!-- 顶部 + 分类勾选 -->
<div class="picker-head">
<div class="scope-row">
<span class="lbl">材料库</span>
<a-radio-group v-model:value="scope" size="small" button-style="solid" @change="onScopeChange">
<a-radio-button value="public">公共库</a-radio-button>
<a-radio-button value="self">自建库</a-radio-button>
</a-radio-group>
<span class="tip">勾选大类或二级分类下方生成对应窗口点击窗口放大快速录入</span>
</div>
<a-checkbox-group v-model:value="selectedCats" class="cat-group">
<div v-for="g in tree" :key="g.major" class="cat-line">
<a-checkbox :value="g.major" class="major">{{ g.major }}<template v-if="g.subs.length">整类</template></a-checkbox>
<span v-if="g.subs.length" class="arrow"></span>
<a-checkbox v-for="s in g.subs" :key="s" :value="g.major + '/' + s">{{ s }}</a-checkbox>
</div>
</a-checkbox-group>
<!-- 顶部筛选 -->
<div class="filters">
<span class="lbl">材料库</span>
<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="self">自建库</a-radio-button>
</a-radio-group>
<span class="lbl" style="margin-left: 20px">健康等级</span>
<a-select v-model:value="healthGrade" size="small" style="width: 110px" allow-clear placeholder="全部" @change="reload">
<a-select-option v-for="g in healthGrades" :key="g" :value="g">{{ g }} </a-select-option>
</a-select>
<span class="lbl" style="margin-left: 20px">环保等级</span>
<a-select v-model:value="envGrade" size="small" style="width: 100px" allow-clear placeholder="全部" @change="reload">
<a-select-option v-for="g in envGrades" :key="g" :value="g">{{ g }}</a-select-option>
</a-select>
</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>
<!-- 放大窗口 -->
<div v-if="enlarged" class="enlarged">
<div class="ehead">
<a @click="enlarged = null"> 返回平铺</a>
<b>{{ catLabel(enlarged) }}{{ (cache[enlarged] || []).length }} </b>
<a-button type="primary" size="small" :disabled="checkedCount === 0" @click="addSelected">
批量添加所选{{ checkedCount }}
</a-button>
</div>
<a-table
:columns="bigColumns"
:data-source="cache[enlarged] || []"
:loading="loading"
row-key="id"
size="small"
:pagination="false"
:scroll="{ y: 360 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'check'">
<a-checkbox
:checked="!!rowState[record.id]?.checked"
:disabled="picked.has(record.id)"
@change="(e: any) => toggleRow(record.id, e.target.checked)"
/>
</template>
<template v-else-if="column.key === 'envGrade'">
<a-tag v-if="record.envGrade">{{ record.envGrade }}</a-tag><span v-else>-</span>
</template>
<template v-else-if="column.key === 'area'">
<span v-if="picked.has(record.id)" class="added">已添加</span>
<a-input-number
v-else
:value="rowState[record.id]?.area"
:min="0"
size="small"
placeholder="面积"
style="width: 110px"
addon-after="m²"
@change="(v: any) => setArea(record.id, v)"
/>
</template>
<a-divider style="margin: 10px 0" />
<!-- 当前类别材料 -->
<div class="list-head">
<span>{{ major }}<template v-if="sub"> / {{ sub }}</template> · {{ list.length }} </span>
<a-button type="primary" size="small" :disabled="checkedCount === 0" @click="addSelected">
批量添加所选{{ checkedCount }}
</a-button>
</div>
<a-table
:columns="columns"
:data-source="list"
:loading="loading"
row-key="id"
size="small"
:pagination="false"
:scroll="{ y: 380 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'check'">
<a-checkbox
:checked="!!rowState[record.id]?.checked"
:disabled="picked.has(record.id)"
@change="(e: any) => toggleRow(record.id, e.target.checked)"
/>
</template>
</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>
<template v-else-if="column.key === 'envGrade'">
<a-tag v-if="record.envGrade">{{ record.envGrade }}</a-tag><span v-else>-</span>
</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'">
<span v-if="picked.has(record.id)" class="added">已添加</span>
<a-input-number
v-else
:value="rowState[record.id]?.area"
:min="0"
size="small"
placeholder="面积"
style="width: 110px"
addon-after="m²"
@change="(v: any) => setArea(record.id, v)"
/>
</template>
</template>
</a-table>
</a-modal>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from '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';
const props = defineProps<{ open: boolean; existingIds: string[] }>();
@ -109,119 +112,114 @@ const emit = defineEmits<{
(e: 'cancel'): void;
}>();
const healthGrades = HEALTH_GRADES;
const envGrades = ENV_GRADES;
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 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 picked = computed(() => new Set(props.existingIds));
// MATERIAL_CATEGORIES -> []
// -> []
const tree = computed(() => {
const map = new Map<string, string[]>();
for (const c of MATERIAL_CATEGORIES) {
const [major, sub] = c.split('/');
if (!map.has(major)) map.set(major, []);
if (sub && !map.get(major)!.includes(sub)) map.get(major)!.push(sub);
const [m, s] = c.split('/');
if (!map.has(m)) map.set(m, []);
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: '材料ID', dataIndex: 'id' },
{ title: '材料名称', dataIndex: 'name' },
{ title: '材料类别', dataIndex: 'category' },
{ title: '品牌', dataIndex: 'brand' },
{ title: '规格', dataIndex: 'spec' },
{ title: '环保', key: 'envGrade', width: 64 },
{ title: '健康', key: 'healthGrade', width: 64 },
{ title: '使用量(面积)', key: 'area', width: 150 },
];
const checkedCount = computed(
() => Object.values(rowState).filter((r) => r.checked).length,
);
const checkedCount = computed(() => Object.values(rowState).filter((r) => r.checked).length);
function catLabel(cat: string) {
return cat.includes('/') ? cat.split('/')[1] : cat + '(整类)';
function healthColor(g: string) {
return { A: 'green', B: 'blue', C: 'orange' }[g] || 'default';
}
async function loadCat(cat: string) {
if (loadingCats.has(cat)) return;
loadingCats.add(cat);
function selectMajor(m: string) {
major.value = m;
sub.value = null;
reload();
}
function selectSub(s: string | null) {
sub.value = s;
reload();
}
async function reload() {
if (!major.value) return;
loading.value = true;
for (const k of Object.keys(rowState)) delete rowState[k];
try {
const res = await listMaterials({ category: cat, scope: scope.value, page: 1, pageSize: 200 });
cache[cat] = res.items;
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 {
loadingCats.delete(cat);
loading.value = false;
}
}
watch(selectedCats, (cats) => {
for (const c of cats) if (!cache[c]) loadCat(c);
});
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 m of cache[cat] || []) rowState[m.id] = { checked: false, area: null };
}
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) {
if (!rowState[id]) rowState[id] = { checked: false, area: null };
rowState[id].area = v;
if (v && v > 0) rowState[id].checked = true; //
rowState[id] = { checked: !!(v && v > 0) || !!rowState[id]?.checked, area: v };
}
function addSelected() {
const cat = enlarged.value!;
const items = (cache[cat] || [])
const items = list.value
.filter((m) => rowState[m.id]?.checked && !picked.value.has(m.id))
.map((m) => ({ material: m, usageAmount: Number(rowState[m.id].area) || 0 }));
if (!items.length) return message.warning('请先勾选材料');
emit('add', items);
message.success(`已添加 ${items.length} 种材料`);
enlarged.value = null;
for (const k of Object.keys(rowState)) delete rowState[k];
}
watch(
() => props.open,
(o) => {
if (o) {
enlarged.value = null;
for (const k of Object.keys(cache)) delete cache[k];
for (const c of selectedCats.value) loadCat(c);
if (!major.value) major.value = tree.value[0]?.major || '';
sub.value = null;
reload();
}
},
);
</script>
<style scoped>
.picker-head { }
.scope-row { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
.scope-row .lbl { color: #555; }
.scope-row .tip { color: #999; font-size: 12px; }
.cat-group { display: flex; flex-direction: column; gap: 6px; max-height: 150px; overflow-y: auto; }
.cat-line { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 12px; padding: 2px 0; }
.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; }
.filters { display: flex; align-items: center; margin-bottom: 12px; }
.filters .lbl { color: #555; }
.cascade-row { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.cascade-lbl { color: #999; font-size: 12px; width: 32px; flex-shrink: 0; }
.cas-tag { cursor: pointer; user-select: none; margin: 0; }
.list-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; color: #555; }
.added { color: #999; }
</style>

View File

@ -18,6 +18,13 @@
<a-select-option value="E2">E2</a-select-option>
</a-select>
</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-button @click="reset"> </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>
<span v-else>-</span>
</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'">
<a @click="showDetail(record)">详情</a>
<template v-if="scope === 'self'">
@ -97,11 +108,12 @@ import MaterialFormModal from '../components/MaterialFormModal.vue';
const pollutants = POLLUTANTS;
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 loading = ref(false);
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 columns = [
@ -112,6 +124,7 @@ const columns = [
{ title: '材料厂家', dataIndex: 'manufacturer', key: 'manufacturer' },
{ title: '材料规格', dataIndex: 'spec', key: 'spec' },
{ title: '环保等级', key: 'envGrade' },
{ title: '健康等级', key: 'healthGrade' },
{ title: '操作', key: 'action', width: 160 },
{ title: '收藏', key: 'favorite', width: 70 },
];

View File

@ -11,10 +11,14 @@ export type SpaceType = (typeof SPACE_TYPES)[number];
/** 空间户型:等高 / 非等高 */
export type SpaceLayout = 'uniform' | 'non-uniform';
/** 环保等级 */
/** 环保等级(甲醛释放量国标分级) */
export const ENV_GRADES = ['E0', 'E1', 'E2'] as const;
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 type PredictionRating = (typeof PREDICTION_RATINGS)[number];