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? // 材料品牌
|
||||
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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Reference in New Issue