784 lines
19 KiB
Vue
784 lines
19 KiB
Vue
<script setup lang="ts">
|
||
import type { llmType } from '#/api';
|
||
import type { ModelAbilityItem } from '#/views/ai/model/modelUtils/model-ability';
|
||
import type { VerifyButtonStatus } from '#/views/ai/model/modelUtils/model-verification';
|
||
|
||
import { computed, onMounted, reactive, ref } from 'vue';
|
||
|
||
import {
|
||
CircleCheck,
|
||
CircleClose,
|
||
Delete,
|
||
Edit,
|
||
Loading,
|
||
Select,
|
||
Warning,
|
||
} from '@element-plus/icons-vue';
|
||
import {
|
||
ElButton,
|
||
ElEmpty,
|
||
ElIcon,
|
||
ElInput,
|
||
ElMessage,
|
||
ElMessageBox,
|
||
ElOption,
|
||
ElSelect,
|
||
ElTable,
|
||
ElTableColumn,
|
||
ElTag,
|
||
} from 'element-plus';
|
||
|
||
import { deleteLlm, getModelList, verifyModelConfig } from '#/api/ai/llm';
|
||
import { $t } from '#/locales';
|
||
import ModelProviderBadge from '#/views/ai/model/ModelProviderBadge.vue';
|
||
import { getDefaultModelAbility } from '#/views/ai/model/modelUtils/model-ability';
|
||
import { mapLlmToModelAbility } from '#/views/ai/model/modelUtils/model-ability-utils';
|
||
import {
|
||
getVerifyButtonText as getStatusButtonText,
|
||
resolveModelVerificationFeedback,
|
||
} from '#/views/ai/model/modelUtils/model-verification';
|
||
|
||
interface ProviderOption {
|
||
id: string;
|
||
providerName: string;
|
||
}
|
||
|
||
interface FilterState {
|
||
keyword: string;
|
||
modelType: string;
|
||
providerId: string;
|
||
}
|
||
|
||
interface BatchDeleteResult {
|
||
failed: Array<{ id: string; message: string }>;
|
||
successIds: string[];
|
||
}
|
||
|
||
const props = defineProps<{
|
||
providers: ProviderOption[];
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'createModel', modelType?: string): void;
|
||
(e: 'editModel', id: string): void;
|
||
(e: 'refreshProviderStats'): void;
|
||
}>();
|
||
|
||
const isLoading = ref(false);
|
||
const isActionLoading = ref(false);
|
||
const modelRows = ref<llmType[]>([]);
|
||
const selectedRows = ref<llmType[]>([]);
|
||
const lastErrorMessage = ref('');
|
||
const verifyStatusMap = ref<Record<string, VerifyButtonStatus>>({});
|
||
|
||
const filterState = reactive<FilterState>({
|
||
keyword: '',
|
||
modelType: '',
|
||
providerId: '',
|
||
});
|
||
|
||
const modelTypeOptions = [
|
||
{
|
||
label: $t('llmProvider.chatModel'),
|
||
value: 'chatModel',
|
||
},
|
||
{
|
||
label: $t('llmProvider.embeddingModel'),
|
||
value: 'embeddingModel',
|
||
},
|
||
{
|
||
label: $t('llmProvider.rerankModel'),
|
||
value: 'rerankModel',
|
||
},
|
||
];
|
||
|
||
const modelTypeLabelMap: Record<string, string> = {
|
||
chatModel: $t('llmProvider.chatModel'),
|
||
embeddingModel: $t('llmProvider.embeddingModel'),
|
||
rerankModel: $t('llmProvider.rerankModel'),
|
||
};
|
||
|
||
const getProviderName = (row: llmType) =>
|
||
row.modelProvider?.providerName || row.aiLlmProvider?.providerName || '-';
|
||
|
||
const getProviderType = (row: llmType) =>
|
||
row.modelProvider?.providerType || row.aiLlmProvider?.providerType || '';
|
||
|
||
const getProviderIcon = (row: llmType) =>
|
||
row.modelProvider?.icon || row.aiLlmProvider?.icon || '';
|
||
|
||
const getModelName = (row: llmType) =>
|
||
row.llmModel || (row as any).modelName || '-';
|
||
|
||
const getModelTypeLabel = (row: llmType) =>
|
||
modelTypeLabelMap[row.modelType] || row.modelType || '-';
|
||
|
||
const canVerify = (row: llmType) => row.modelType !== 'rerankModel';
|
||
const getModelId = (row: llmType) =>
|
||
String((row as any).id || (row as any).llmId || (row as any).modelId || '');
|
||
const getVerifyStatus = (row: llmType): VerifyButtonStatus =>
|
||
verifyStatusMap.value[getModelId(row)] || 'idle';
|
||
const setVerifyStatus = (id: string, status: VerifyButtonStatus) => {
|
||
if (!id) {
|
||
return;
|
||
}
|
||
verifyStatusMap.value[id] = status;
|
||
};
|
||
const isVerifying = (row: llmType) => getVerifyStatus(row) === 'loading';
|
||
const getVerifyButtonText = (row: llmType) => {
|
||
return getStatusButtonText(getVerifyStatus(row));
|
||
};
|
||
const getVerifyButtonIcon = (row: llmType) => {
|
||
const status = getVerifyStatus(row);
|
||
if (status === 'loading') {
|
||
return Loading;
|
||
}
|
||
if (status === 'success') {
|
||
return CircleCheck;
|
||
}
|
||
if (status === 'warning') {
|
||
return Warning;
|
||
}
|
||
if (status === 'error') {
|
||
return CircleClose;
|
||
}
|
||
return Select;
|
||
};
|
||
|
||
const getAbilityTags = (row: llmType): ModelAbilityItem[] =>
|
||
mapLlmToModelAbility(row, getDefaultModelAbility()).filter(
|
||
(tag) => tag.selected,
|
||
);
|
||
|
||
const totalCount = computed(() => modelRows.value.length);
|
||
|
||
const filteredRows = computed(() => {
|
||
const keyword = filterState.keyword.trim().toLowerCase();
|
||
|
||
return modelRows.value.filter((item) => {
|
||
if (
|
||
filterState.providerId &&
|
||
String((item as any).providerId) !== filterState.providerId
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
if (filterState.modelType && item.modelType !== filterState.modelType) {
|
||
return false;
|
||
}
|
||
|
||
if (!keyword) {
|
||
return true;
|
||
}
|
||
|
||
const searchTargets = [
|
||
item.title,
|
||
getModelName(item),
|
||
item.groupName,
|
||
getProviderName(item),
|
||
]
|
||
.filter(Boolean)
|
||
.map((text) => String(text).toLowerCase());
|
||
|
||
return searchTargets.some((text) => text.includes(keyword));
|
||
});
|
||
});
|
||
|
||
const selectableRowCount = computed(() => selectedRows.value.length);
|
||
|
||
const loadModels = async () => {
|
||
isLoading.value = true;
|
||
lastErrorMessage.value = '';
|
||
|
||
try {
|
||
const res = await getModelList();
|
||
|
||
if (res.errorCode === 0) {
|
||
modelRows.value = res.data || [];
|
||
verifyStatusMap.value = {};
|
||
} else {
|
||
modelRows.value = [];
|
||
verifyStatusMap.value = {};
|
||
lastErrorMessage.value =
|
||
res.message || $t('ui.actionMessage.operationFailed');
|
||
}
|
||
} catch (error) {
|
||
modelRows.value = [];
|
||
verifyStatusMap.value = {};
|
||
lastErrorMessage.value =
|
||
(error as Error)?.message || $t('ui.actionMessage.operationFailed');
|
||
} finally {
|
||
isLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const reloadAndNotify = async () => {
|
||
await loadModels();
|
||
emit('refreshProviderStats');
|
||
selectedRows.value = [];
|
||
};
|
||
|
||
const handleSelectionChange = (rows: llmType[]) => {
|
||
selectedRows.value = rows;
|
||
};
|
||
|
||
const handleEdit = (row: llmType) => {
|
||
const modelId = getModelId(row);
|
||
if (!modelId) {
|
||
ElMessage.warning('当前模型缺少ID,无法编辑');
|
||
return;
|
||
}
|
||
emit('editModel', modelId);
|
||
};
|
||
|
||
const handleDelete = async (row: llmType) => {
|
||
const modelId = getModelId(row);
|
||
if (!modelId) {
|
||
ElMessage.warning('当前模型缺少ID,无法删除');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`确认删除模型「${row.title}」吗?该操作不可恢复。`,
|
||
$t('message.noticeTitle'),
|
||
{
|
||
cancelButtonText: $t('message.cancel'),
|
||
confirmButtonText: $t('message.ok'),
|
||
type: 'warning',
|
||
},
|
||
);
|
||
} catch {
|
||
return;
|
||
}
|
||
|
||
isActionLoading.value = true;
|
||
|
||
try {
|
||
const res = await deleteLlm({ id: modelId });
|
||
|
||
if (res.errorCode === 0) {
|
||
ElMessage.success(res.message || '模型已删除');
|
||
await reloadAndNotify();
|
||
} else {
|
||
ElMessage.error(res.message || $t('ui.actionMessage.operationFailed'));
|
||
}
|
||
} finally {
|
||
isActionLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const handleVerify = async (row: llmType) => {
|
||
const modelId = getModelId(row);
|
||
|
||
if (!canVerify(row) || !modelId || isVerifying(row)) {
|
||
if (!modelId) {
|
||
ElMessage.warning('当前模型缺少ID,无法验证配置');
|
||
}
|
||
return;
|
||
}
|
||
|
||
setVerifyStatus(modelId, 'loading');
|
||
|
||
try {
|
||
const res = await verifyModelConfig(modelId);
|
||
|
||
const feedback = resolveModelVerificationFeedback(res, row.modelType);
|
||
setVerifyStatus(modelId, feedback.status);
|
||
|
||
if (feedback.status === 'success') {
|
||
ElMessage.success(feedback.message);
|
||
} else if (feedback.status === 'warning') {
|
||
ElMessage.warning(feedback.message);
|
||
} else {
|
||
if (!res.message) {
|
||
ElMessage.error(feedback.message);
|
||
}
|
||
}
|
||
} catch {
|
||
setVerifyStatus(modelId, 'error');
|
||
// error toast is already handled by global response interceptors
|
||
} finally {
|
||
// keep final status to show explicit success/failure state
|
||
}
|
||
};
|
||
|
||
const runBatchDelete = async (
|
||
ids: string[],
|
||
concurrency = 5,
|
||
): Promise<BatchDeleteResult> => {
|
||
const queue = [...ids];
|
||
const successIds: string[] = [];
|
||
const failed: Array<{ id: string; message: string }> = [];
|
||
|
||
const worker = async () => {
|
||
while (queue.length > 0) {
|
||
const id = queue.shift();
|
||
if (!id) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await deleteLlm({ id });
|
||
if (res.errorCode === 0) {
|
||
successIds.push(id);
|
||
} else {
|
||
failed.push({ id, message: res.message || '删除失败' });
|
||
}
|
||
} catch (error) {
|
||
failed.push({ id, message: (error as Error)?.message || '网络错误' });
|
||
}
|
||
}
|
||
};
|
||
|
||
const workerCount = Math.max(1, Math.min(concurrency, ids.length || 1));
|
||
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
||
|
||
return {
|
||
failed,
|
||
successIds,
|
||
};
|
||
};
|
||
|
||
const handleBatchDelete = async () => {
|
||
const ids = selectedRows.value
|
||
.map((item) => getModelId(item))
|
||
.filter(Boolean);
|
||
|
||
if (ids.length === 0) {
|
||
ElMessage.warning('请先选择要删除的模型');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`确认批量删除 ${ids.length} 个模型吗?该操作不可恢复。`,
|
||
$t('message.noticeTitle'),
|
||
{
|
||
cancelButtonText: $t('message.cancel'),
|
||
confirmButtonText: $t('message.ok'),
|
||
type: 'warning',
|
||
},
|
||
);
|
||
} catch {
|
||
return;
|
||
}
|
||
|
||
isActionLoading.value = true;
|
||
|
||
try {
|
||
const result = await runBatchDelete(ids, 5);
|
||
const successCount = result.successIds.length;
|
||
const failCount = result.failed.length;
|
||
|
||
if (failCount === 0) {
|
||
ElMessage.success(`批量删除完成,共 ${successCount} 个模型`);
|
||
} else {
|
||
ElMessage.warning(
|
||
`批量删除完成,成功 ${successCount} 个,失败 ${failCount} 个`,
|
||
);
|
||
}
|
||
|
||
await reloadAndNotify();
|
||
} finally {
|
||
isActionLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const resetFilters = () => {
|
||
filterState.keyword = '';
|
||
filterState.providerId = '';
|
||
filterState.modelType = '';
|
||
};
|
||
|
||
onMounted(loadModels);
|
||
|
||
defineExpose({
|
||
async reloadData() {
|
||
await loadModels();
|
||
},
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<section class="active-workspace">
|
||
<header class="active-workspace__header">
|
||
<div class="active-workspace__summary">
|
||
<h3>已配置模型</h3>
|
||
<p>共 {{ totalCount }} 个模型</p>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="active-workspace__filters">
|
||
<ElInput
|
||
v-model.trim="filterState.keyword"
|
||
clearable
|
||
placeholder="搜索模型名、模型ID、服务商、分组"
|
||
/>
|
||
<ElSelect
|
||
v-model="filterState.providerId"
|
||
clearable
|
||
placeholder="全部服务商"
|
||
>
|
||
<ElOption
|
||
v-for="provider in props.providers"
|
||
:key="provider.id"
|
||
:label="provider.providerName"
|
||
:value="provider.id"
|
||
/>
|
||
</ElSelect>
|
||
<ElSelect
|
||
v-model="filterState.modelType"
|
||
clearable
|
||
placeholder="全部模型类型"
|
||
>
|
||
<ElOption
|
||
v-for="type in modelTypeOptions"
|
||
:key="type.value"
|
||
:label="type.label"
|
||
:value="type.value"
|
||
/>
|
||
</ElSelect>
|
||
<ElButton @click="resetFilters">重置</ElButton>
|
||
</div>
|
||
|
||
<div v-if="selectableRowCount > 0" class="active-workspace__batch-bar">
|
||
<span>已选 {{ selectableRowCount }} 项</span>
|
||
<div class="active-workspace__batch-actions">
|
||
<ElButton
|
||
class="is-danger"
|
||
:disabled="isActionLoading"
|
||
@click="handleBatchDelete"
|
||
>
|
||
批量删除
|
||
</ElButton>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="isLoading" class="active-workspace__state">
|
||
正在加载模型数据...
|
||
</div>
|
||
|
||
<div v-else-if="lastErrorMessage" class="active-workspace__state is-error">
|
||
{{ lastErrorMessage }}
|
||
</div>
|
||
|
||
<div v-else-if="modelRows.length === 0" class="active-workspace__empty">
|
||
<ElEmpty description="还没有模型,先添加一个模型开始使用。">
|
||
<ElButton type="primary" @click="emit('createModel', 'chatModel')">
|
||
添加模型
|
||
</ElButton>
|
||
</ElEmpty>
|
||
</div>
|
||
|
||
<div v-else-if="filteredRows.length === 0" class="active-workspace__empty">
|
||
<ElEmpty description="没有符合筛选条件的模型,试试调整筛选项。" />
|
||
</div>
|
||
|
||
<ElTable
|
||
v-else
|
||
row-key="id"
|
||
:data="filteredRows"
|
||
class="active-workspace__table"
|
||
@selection-change="handleSelectionChange"
|
||
>
|
||
<ElTableColumn type="selection" width="48" />
|
||
|
||
<ElTableColumn label="模型 ID" min-width="220">
|
||
<template #default="{ row }">
|
||
<div class="active-workspace__name-with-logo">
|
||
<ModelProviderBadge
|
||
:icon="getProviderIcon(row)"
|
||
:provider-name="getProviderName(row)"
|
||
:provider-type="getProviderType(row)"
|
||
:size="30"
|
||
/>
|
||
<div class="active-workspace__name-cell">
|
||
<strong>{{ row.title }}</strong>
|
||
<span>{{ getModelName(row) }}</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</ElTableColumn>
|
||
|
||
<ElTableColumn label="服务商" min-width="140">
|
||
<template #default="{ row }">
|
||
{{ getProviderName(row) }}
|
||
</template>
|
||
</ElTableColumn>
|
||
|
||
<ElTableColumn label="类型" width="110">
|
||
<template #default="{ row }">
|
||
{{ getModelTypeLabel(row) }}
|
||
</template>
|
||
</ElTableColumn>
|
||
|
||
<ElTableColumn label="分组" min-width="120">
|
||
<template #default="{ row }">
|
||
{{ row.groupName || '-' }}
|
||
</template>
|
||
</ElTableColumn>
|
||
|
||
<ElTableColumn label="能力" min-width="220">
|
||
<template #default="{ row }">
|
||
<div class="active-workspace__ability">
|
||
<ElTag
|
||
v-for="tag in getAbilityTags(row)"
|
||
:key="tag.value"
|
||
effect="plain"
|
||
size="small"
|
||
>
|
||
{{ tag.label }}
|
||
</ElTag>
|
||
<span v-if="getAbilityTags(row).length === 0">-</span>
|
||
</div>
|
||
</template>
|
||
</ElTableColumn>
|
||
|
||
<ElTableColumn label="操作" min-width="290" fixed="right">
|
||
<template #default="{ row }">
|
||
<div class="active-workspace__actions">
|
||
<ElButton
|
||
v-if="canVerify(row)"
|
||
text
|
||
size="small"
|
||
class="active-workspace__verify-btn"
|
||
:class="`is-${getVerifyStatus(row)}`"
|
||
:disabled="
|
||
isVerifying(row) || isActionLoading || !getModelId(row)
|
||
"
|
||
@click="handleVerify(row)"
|
||
>
|
||
<template #icon>
|
||
<ElIcon
|
||
class="active-workspace__verify-icon"
|
||
:class="`is-${getVerifyStatus(row)}`"
|
||
>
|
||
<component :is="getVerifyButtonIcon(row)" />
|
||
</ElIcon>
|
||
</template>
|
||
{{ getVerifyButtonText(row) }}
|
||
</ElButton>
|
||
<ElButton text size="small" :icon="Edit" @click="handleEdit(row)">
|
||
编辑
|
||
</ElButton>
|
||
<ElButton
|
||
text
|
||
size="small"
|
||
class="is-danger"
|
||
:icon="Delete"
|
||
@click="handleDelete(row)"
|
||
>
|
||
删除
|
||
</ElButton>
|
||
</div>
|
||
</template>
|
||
</ElTableColumn>
|
||
</ElTable>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.active-workspace {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
height: 100%;
|
||
min-height: 0;
|
||
}
|
||
|
||
.active-workspace__header {
|
||
display: flex;
|
||
gap: 16px;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
padding-bottom: 12px;
|
||
border-bottom: 1px solid hsl(var(--divider-faint) / 58%);
|
||
}
|
||
|
||
.active-workspace__summary h3 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: hsl(var(--text-strong));
|
||
}
|
||
|
||
.active-workspace__summary p,
|
||
.active-workspace__state {
|
||
margin: 6px 0 0;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
color: hsl(var(--text-muted));
|
||
}
|
||
|
||
.active-workspace__filters {
|
||
display: grid;
|
||
grid-template-columns: minmax(220px, 1.6fr) repeat(2, minmax(0, 1fr)) auto;
|
||
gap: 12px;
|
||
}
|
||
|
||
.active-workspace__batch-bar {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 10px 12px;
|
||
background: hsl(var(--surface-contrast-soft) / 68%);
|
||
border: 1px solid hsl(var(--divider-faint) / 58%);
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.active-workspace__batch-bar span {
|
||
font-size: 13px;
|
||
color: hsl(var(--text-muted));
|
||
}
|
||
|
||
.active-workspace__batch-actions {
|
||
display: inline-flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.active-workspace__batch-actions .is-danger,
|
||
.active-workspace__actions .is-danger {
|
||
color: hsl(var(--destructive));
|
||
}
|
||
|
||
.active-workspace__state {
|
||
padding: 14px 0;
|
||
}
|
||
|
||
.active-workspace__state.is-error {
|
||
color: hsl(var(--destructive));
|
||
}
|
||
|
||
.active-workspace__empty {
|
||
display: flex;
|
||
flex: 1;
|
||
}
|
||
|
||
.active-workspace__empty :deep(.el-empty) {
|
||
margin: auto;
|
||
}
|
||
|
||
.active-workspace__table {
|
||
flex: 1;
|
||
}
|
||
|
||
.active-workspace__name-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.active-workspace__name-with-logo {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.active-workspace__name-cell strong {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: hsl(var(--text-strong));
|
||
}
|
||
|
||
.active-workspace__name-cell span {
|
||
font-size: 12px;
|
||
color: hsl(var(--text-muted));
|
||
}
|
||
|
||
.active-workspace__ability {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
align-items: center;
|
||
}
|
||
|
||
.active-workspace__actions {
|
||
display: inline-flex;
|
||
gap: 4px;
|
||
align-items: center;
|
||
}
|
||
|
||
.active-workspace__verify-btn.is-idle {
|
||
color: hsl(var(--text-muted));
|
||
}
|
||
|
||
.active-workspace__verify-btn.is-loading {
|
||
color: hsl(var(--primary));
|
||
}
|
||
|
||
.active-workspace__verify-btn.is-success {
|
||
color: hsl(var(--success));
|
||
}
|
||
|
||
.active-workspace__verify-btn.is-warning {
|
||
color: hsl(var(--warning));
|
||
}
|
||
|
||
.active-workspace__verify-btn.is-error {
|
||
color: hsl(var(--destructive));
|
||
}
|
||
|
||
.active-workspace__verify-icon {
|
||
transition:
|
||
color 0.24s ease,
|
||
transform 0.24s ease;
|
||
}
|
||
|
||
.active-workspace__verify-icon.is-loading {
|
||
animation: active-workspace-verify-spin 0.9s linear infinite;
|
||
}
|
||
|
||
.active-workspace__verify-icon.is-success,
|
||
.active-workspace__verify-icon.is-warning,
|
||
.active-workspace__verify-icon.is-error {
|
||
animation: active-workspace-verify-pop 0.32s ease;
|
||
}
|
||
|
||
@keyframes active-workspace-verify-spin {
|
||
from {
|
||
transform: rotate(0deg);
|
||
}
|
||
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
@keyframes active-workspace-verify-pop {
|
||
0% {
|
||
opacity: 0.72;
|
||
transform: scale(0.82);
|
||
}
|
||
|
||
100% {
|
||
opacity: 1;
|
||
transform: scale(1);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 1024px) {
|
||
.active-workspace__filters {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.active-workspace__header {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.active-workspace__filters {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
|
||
.active-workspace__batch-bar {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
}
|
||
}
|
||
</style>
|