feat: 选择材料-子类支持多选组合筛选

子类从单选改为多选,可同时勾选多个子类(带✓标记),下方材料列表
显示所选子类的合集(客户端组合过滤,切换即时生效)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zty 2026-06-11 15:04:40 +08:00
parent 3bbafc99d7
commit 7f03b11a95
1 changed files with 33 additions and 17 deletions

View File

@ -33,28 +33,31 @@
@click="selectMajor(g.major)" @click="selectMajor(g.major)"
>{{ g.major }}</a-tag> >{{ g.major }}</a-tag>
</div> </div>
<!-- 级联子类 --> <!-- 级联子类可多选组合 -->
<div class="cascade-row" v-if="currentSubs.length"> <div class="cascade-row" v-if="currentSubs.length">
<span class="cascade-lbl">子类</span> <span class="cascade-lbl">子类</span>
<a-tag <a-tag
:color="sub === null ? '#b4232a' : 'default'" :color="!subs.length ? '#b4232a' : 'default'"
class="cas-tag" class="cas-tag"
@click="selectSub(null)" @click="clearSubs"
>全部</a-tag> >全部</a-tag>
<a-tag <a-tag
v-for="s in currentSubs" v-for="s in currentSubs"
:key="s" :key="s"
:color="sub === s ? '#b4232a' : 'default'" :color="subs.includes(s) ? '#b4232a' : 'default'"
class="cas-tag" class="cas-tag"
@click="selectSub(s)" @click="toggleSub(s)"
>{{ s }}</a-tag> >
<CheckOutlined v-if="subs.includes(s)" /> {{ s }}
</a-tag>
<span v-if="subs.length" class="multi-tip">已选 {{ subs.length }} 个子类组合</span>
</div> </div>
<a-divider style="margin: 10px 0" /> <a-divider style="margin: 10px 0" />
<!-- 当前类别材料 --> <!-- 当前类别材料 -->
<div class="list-head"> <div class="list-head">
<span>{{ major }}<template v-if="sub"> / {{ sub }}</template> · {{ list.length }} </span> <span>{{ major }}<template v-if="subs.length"> / {{ subs.join('') }}</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>
@ -103,6 +106,7 @@
<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 { CheckOutlined } from '@ant-design/icons-vue';
import { MATERIAL_CATEGORIES, HEALTH_GRADES, ENV_GRADES } 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';
@ -119,9 +123,16 @@ const scope = ref<'public' | 'self'>('public');
const healthGrade = ref<string | undefined>(undefined); const healthGrade = ref<string | undefined>(undefined);
const envGrade = ref<string | undefined>(undefined); const envGrade = ref<string | undefined>(undefined);
const major = ref<string>(''); const major = ref<string>('');
const sub = ref<string | null>(null); const subs = ref<string[]>([]); //
const list = ref<Material[]>([]); const fullList = ref<Material[]>([]); // /
const loading = ref(false); const loading = ref(false);
// =
const list = computed(() => {
if (!subs.value.length) return fullList.value;
const set = new Set(subs.value.map((s) => `${major.value}/${s}`));
return fullList.value.filter((m) => set.has(m.category));
});
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));
@ -157,29 +168,33 @@ function healthColor(g: string) {
function selectMajor(m: string) { function selectMajor(m: string) {
major.value = m; major.value = m;
sub.value = null; subs.value = [];
reload(); reload();
} }
function selectSub(s: string | null) { function toggleSub(s: string) {
sub.value = s; const i = subs.value.indexOf(s);
reload(); if (i >= 0) subs.value.splice(i, 1);
else subs.value.push(s);
}
function clearSubs() {
subs.value = [];
} }
// ///
async function reload() { async function reload() {
if (!major.value) return; if (!major.value) return;
loading.value = true; loading.value = true;
for (const k of Object.keys(rowState)) delete rowState[k]; for (const k of Object.keys(rowState)) delete rowState[k];
try { try {
const category = sub.value ? `${major.value}/${sub.value}` : major.value;
const res = await listMaterials({ const res = await listMaterials({
category, category: major.value,
healthGrade: healthGrade.value, healthGrade: healthGrade.value,
envGrade: envGrade.value, envGrade: envGrade.value,
scope: scope.value, scope: scope.value,
page: 1, page: 1,
pageSize: 300, pageSize: 300,
}); });
list.value = res.items; fullList.value = res.items;
} finally { } finally {
loading.value = false; loading.value = false;
} }
@ -207,7 +222,7 @@ watch(
(o) => { (o) => {
if (o) { if (o) {
if (!major.value) major.value = tree.value[0]?.major || ''; if (!major.value) major.value = tree.value[0]?.major || '';
sub.value = null; subs.value = [];
reload(); reload();
} }
}, },
@ -220,6 +235,7 @@ watch(
.cascade-row { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } .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; } .cascade-lbl { color: #999; font-size: 12px; width: 32px; flex-shrink: 0; }
.cas-tag { cursor: pointer; user-select: none; margin: 0; } .cas-tag { cursor: pointer; user-select: none; margin: 0; }
.multi-tip { color: #b4232a; font-size: 12px; margin-left: 8px; }
.list-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; color: #555; } .list-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; color: #555; }
.added { color: #999; } .added { color: #999; }
</style> </style>