41968384b5
1、为后台用户列表增加关键词和状态筛选,后端同步支持过滤与总数统计。 2、创建用户时要求填写初始密码,并补充前端校验和自动填充隔离。 3、优化项目管理列表角色展示、项目详情抽屉脏数据保护和审计详情抽屉关闭体验。 4、清理旧费用附件关联、统一测试模型注册,并更新迁移端点和注册筛选测试。
583 lines
19 KiB
Vue
583 lines
19 KiB
Vue
<template>
|
|
<div class="page page--flush">
|
|
<div class="main-content-flat unified-shell">
|
|
<!-- 统计卡片 -->
|
|
<div class="stats-row">
|
|
<div class="stat-card stat-card--total">
|
|
<div class="stat-icon">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
|
|
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
|
|
</svg>
|
|
</div>
|
|
<div class="stat-body">
|
|
<span class="stat-value">{{ projects.length }}</span>
|
|
<span class="stat-label">全部项目</span>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card stat-card--active">
|
|
<div class="stat-icon">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
|
<polyline points="22 4 12 14.01 9 11.01"/>
|
|
</svg>
|
|
</div>
|
|
<div class="stat-body">
|
|
<span class="stat-value">{{ activeProjectCount }}</span>
|
|
<span class="stat-label">进行中</span>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card stat-card--draft">
|
|
<div class="stat-icon">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
|
</svg>
|
|
</div>
|
|
<div class="stat-body">
|
|
<span class="stat-value">{{ draftProjectCount }}</span>
|
|
<span class="stat-label">草稿</span>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card stat-card--locked">
|
|
<div class="stat-icon">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
|
</svg>
|
|
</div>
|
|
<div class="stat-body">
|
|
<span class="stat-value">{{ lockedProjectCount }}</span>
|
|
<span class="stat-label">已锁定</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 操作栏 -->
|
|
<div class="unified-action-bar actions-only-bar">
|
|
<div class="filter-spacer"></div>
|
|
<el-button v-if="isAdmin" type="primary" :icon="Plus" @click="openCreate">
|
|
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjects.projectLabel }}
|
|
</el-button>
|
|
</div>
|
|
|
|
<!-- 项目表格 -->
|
|
<div class="unified-section project-table-section">
|
|
<el-table :data="projects" v-loading="loading" class="project-table" style="width: 100%" table-layout="fixed">
|
|
<el-table-column :label="TEXT.common.fields.projectName">
|
|
<template #default="scope">
|
|
<div class="project-cell">
|
|
<div class="project-icon" :class="'icon--' + (scope.row.status || 'draft').toLowerCase()">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
|
|
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
|
|
</svg>
|
|
</div>
|
|
<div class="project-info">
|
|
<el-link v-if="canProject(scope.row, 'setup_config', 'read')" type="primary" @click="goProject(scope.row)" class="project-name">{{ scope.row.name }}</el-link>
|
|
<span v-else class="project-name project-name--disabled">{{ scope.row.name }}</span>
|
|
<span class="project-code">{{ scope.row.code || '-' }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column :label="TEXT.common.fields.protocolNo || '方案号'" show-overflow-tooltip>
|
|
<template #default="scope">
|
|
<span class="text-muted">{{ scope.row.protocol_no || '-' }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column v-if="!isAdmin" label="我的角色" align="center">
|
|
<template #default="scope">
|
|
<span class="role-badge">{{ roleLabel(scope.row.role_in_study) || '-' }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column :label="TEXT.common.fields.status" align="center">
|
|
<template #default="scope">
|
|
<span class="status-badge" :class="'badge--' + (scope.row.status || 'draft').toLowerCase()">
|
|
<span class="badge-dot"></span>
|
|
{{ statusLabel(scope.row.status) }}
|
|
</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="锁定状态" align="center">
|
|
<template #default="scope">
|
|
<span v-if="scope.row.is_locked" class="lock-indicator locked">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13">
|
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
|
</svg>
|
|
已锁定
|
|
</span>
|
|
<span v-else class="lock-indicator unlocked">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13">
|
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
|
<path d="M7 11V7a5 5 0 0 1 9.9-1"/>
|
|
</svg>
|
|
未锁定
|
|
</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column :label="TEXT.common.labels.actions" align="center">
|
|
<template #default="scope">
|
|
<div class="action-row">
|
|
<el-tooltip v-if="canProject(scope.row, 'project_members', 'read')" :content="TEXT.modules.adminProjects.members" placement="top">
|
|
<el-button link type="primary" :icon="User" class="action-btn" @click="goMembers(scope.row)" />
|
|
</el-tooltip>
|
|
<el-tooltip v-if="canProject(scope.row, 'project_members', 'write')" content="权限管理" placement="top">
|
|
<el-button link type="primary" :icon="Key" class="action-btn" @click="goPermissions(scope.row)" />
|
|
</el-tooltip>
|
|
<el-tooltip v-if="canProject(scope.row, 'sites', 'read')" :content="TEXT.modules.adminProjects.sites" placement="top">
|
|
<el-button link type="primary" :icon="OfficeBuilding" class="action-btn" @click="goSites(scope.row)" />
|
|
</el-tooltip>
|
|
<el-tooltip v-if="canProject(scope.row, 'audit_export', 'read')" :content="TEXT.menu.auditLogs" placement="top">
|
|
<el-button link type="primary" :icon="Document" class="action-btn" @click="goAuditLogs(scope.row)" />
|
|
</el-tooltip>
|
|
<el-tooltip :content="TEXT.modules.adminProjects.enter" placement="top">
|
|
<el-button link type="success" :icon="ArrowRight" class="action-btn enter-btn" @click="enterStudy(scope.row)" />
|
|
</el-tooltip>
|
|
<el-tooltip v-if="isAdmin && !scope.row.is_locked" content="锁定项目" placement="top">
|
|
<el-button link type="info" :icon="Lock" class="action-btn" @click="handleLockToggle(scope.row)" />
|
|
</el-tooltip>
|
|
<el-tooltip v-if="isAdmin" :content="TEXT.common.actions.delete" placement="top">
|
|
<el-button link type="danger" :icon="Delete" class="action-btn" @click="handleDelete(scope.row)" />
|
|
</el-tooltip>
|
|
</div>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
</div>
|
|
</div>
|
|
<ProjectForm v-if="formVisible" v-model:visible="formVisible" :project="editingProject" @saved="loadProjects" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from "vue";
|
|
import { useRouter } from "vue-router";
|
|
import { ElMessage, ElMessageBox } from "element-plus";
|
|
import { User, OfficeBuilding, ArrowRight, Delete, Lock, Key, Document, Plus } from "@element-plus/icons-vue";
|
|
import { fetchStudies, deleteStudy, lockStudy } from "../../api/studies";
|
|
import { fetchMyApiEndpointPermissions } from "../../api/projectPermissions";
|
|
import type { ApiEndpointPermissionsResponse, Study } from "../../types/api";
|
|
import ProjectForm from "./ProjectForm.vue";
|
|
import { useStudyStore } from "../../store/study";
|
|
import { useAuthStore } from "../../store/auth";
|
|
import { isSystemAdmin } from "../../utils/roles";
|
|
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
|
import { useRoleTemplateMeta } from "../../composables/useRoleTemplateMeta";
|
|
import { TEXT } from "../../locales";
|
|
|
|
const projects = ref<Study[]>([]);
|
|
const loading = ref(false);
|
|
const formVisible = ref(false);
|
|
const editingProject = ref<Study | null>(null);
|
|
const router = useRouter();
|
|
const studyStore = useStudyStore();
|
|
const auth = useAuthStore();
|
|
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
|
const permissionsByProject = ref<Record<string, ApiEndpointPermissionsResponse>>({});
|
|
const { roleLabel, loadRoleTemplates } = useRoleTemplateMeta();
|
|
|
|
const activeProjectCount = computed(() => projects.value.filter(p => p.status === 'ACTIVE').length);
|
|
const draftProjectCount = computed(() => projects.value.filter(p => p.status === 'DRAFT').length);
|
|
const lockedProjectCount = computed(() => projects.value.filter(p => p.is_locked).length);
|
|
const loadProjects = async () => {
|
|
loading.value = true;
|
|
try {
|
|
const { data } = await fetchStudies();
|
|
const items = (data as any).items || [];
|
|
projects.value = items;
|
|
if (!isAdmin.value) {
|
|
const entries = await Promise.all(
|
|
items.map(async (item: Study) => {
|
|
try {
|
|
const { data: permissions } = await fetchMyApiEndpointPermissions(item.id, { suppressErrorMessage: true });
|
|
return [item.id, permissions] as const;
|
|
} catch {
|
|
return [item.id, null] as const;
|
|
}
|
|
})
|
|
);
|
|
permissionsByProject.value = Object.fromEntries(entries.filter((entry) => entry[1]));
|
|
}
|
|
} catch (e: any) {
|
|
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjects.loadFailed);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const openCreate = () => {
|
|
if (!isAdmin.value) return;
|
|
editingProject.value = null;
|
|
formVisible.value = true;
|
|
};
|
|
|
|
const MODULE_TO_OPERATION: Record<string, Record<string, string>> = {
|
|
project_members: { read: "project_members:read", write: "project_members:update" },
|
|
sites: { read: "sites:read", write: "sites:update" },
|
|
audit_export: { read: "audit_logs:read", write: "audit_logs:export" },
|
|
setup_config: { read: "setup_config:read", write: "setup_config:update" },
|
|
};
|
|
|
|
const canProject = (row: Study, module: string, action: "read" | "write") => {
|
|
if (isAdmin.value) return true;
|
|
const operationKey = MODULE_TO_OPERATION[module]?.[action];
|
|
if (!operationKey) return false;
|
|
const perms = permissionsByProject.value[row.id];
|
|
if (!perms) return false;
|
|
const rolePerms = Object.values(perms)[0];
|
|
if (!rolePerms) return false;
|
|
return isApiPermissionAllowed(rolePerms[operationKey]);
|
|
};
|
|
|
|
const goMembers = (row: Study) => {
|
|
router.push(`/admin/permissions/project?projectId=${row.id}&sub=members`);
|
|
};
|
|
|
|
const goSites = (row: Study) => {
|
|
router.push(`/admin/projects/${row.id}/sites`);
|
|
};
|
|
|
|
const goPermissions = (row: Study) => {
|
|
router.push(`/admin/permissions/project?projectId=${row.id}`);
|
|
};
|
|
|
|
const goAuditLogs = async (row: Study) => {
|
|
await enterStudy(row, "/admin/audit-logs");
|
|
};
|
|
|
|
const handleDelete = async (study: Study) => {
|
|
if (!isAdmin.value) return;
|
|
try {
|
|
await ElMessageBox.confirm(
|
|
`确定要删除项目\"${study.name}\"吗?该操作将永久删除项目及其所有关联数据(成员、站点等),且不可恢复!`,
|
|
TEXT.common.actions.delete,
|
|
{
|
|
confirmButtonText: "继续",
|
|
cancelButtonText: TEXT.common.actions.cancel,
|
|
type: "error",
|
|
dangerouslyUseHTMLString: false,
|
|
}
|
|
);
|
|
const { value } = await ElMessageBox.prompt(
|
|
`请输入项目名称 "${study.name}" 以确认删除`,
|
|
"删除确认",
|
|
{
|
|
confirmButtonText: TEXT.common.actions.confirm,
|
|
cancelButtonText: TEXT.common.actions.cancel,
|
|
inputPattern: new RegExp(`^${study.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`),
|
|
inputErrorMessage: "项目名称不匹配",
|
|
inputPlaceholder: "请输入项目名称",
|
|
type: "error",
|
|
}
|
|
);
|
|
if (value !== study.name) {
|
|
ElMessage.warning("项目名称不匹配,已取消删除");
|
|
return;
|
|
}
|
|
const isDeletingCurrentStudy = studyStore.currentStudy?.id === study.id;
|
|
await deleteStudy(study.id);
|
|
if (isDeletingCurrentStudy) {
|
|
await studyStore.rehydrateStudyForLastUser({ preferActive: true });
|
|
}
|
|
ElMessage.success("项目已删除");
|
|
await loadProjects();
|
|
} catch (err: any) {
|
|
if (err !== "cancel") {
|
|
const errorMsg = err?.response?.data?.detail || err?.message || "删除项目失败";
|
|
console.error("删除项目错误:", err);
|
|
ElMessage.error(errorMsg);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleLockToggle = async (study: Study) => {
|
|
if (!isAdmin.value) return;
|
|
if (study.is_locked) {
|
|
ElMessage.warning("项目已锁定,不可解锁");
|
|
return;
|
|
}
|
|
try {
|
|
await ElMessageBox.confirm(
|
|
`确定要锁定项目\"${study.name}\"吗?锁定后项目内数据将无法编辑,且不可解锁!`,
|
|
"锁定项目",
|
|
{
|
|
confirmButtonText: TEXT.common.actions.confirm,
|
|
cancelButtonText: TEXT.common.actions.cancel,
|
|
type: "warning",
|
|
}
|
|
);
|
|
const { value } = await ElMessageBox.prompt(TEXT.modules.adminProjects.lockConfirmPrompt, TEXT.modules.adminProjects.lockConfirmTitle, {
|
|
confirmButtonText: TEXT.common.actions.confirm,
|
|
cancelButtonText: TEXT.common.actions.cancel,
|
|
inputPlaceholder: TEXT.modules.adminProjects.lockConfirmPlaceholder,
|
|
inputValidator: (input: string) => input === "确认锁定" || TEXT.modules.adminProjects.lockConfirmMismatch,
|
|
type: "warning",
|
|
});
|
|
if (value !== "确认锁定") {
|
|
ElMessage.warning(TEXT.modules.adminProjects.lockConfirmMismatchWarning);
|
|
return;
|
|
}
|
|
await lockStudy(study.id);
|
|
ElMessage.success("项目已锁定");
|
|
await loadProjects();
|
|
} catch (err) {
|
|
if (err !== "cancel" && err !== "close") {
|
|
const detail = (err as any)?.response?.data?.detail || "锁定项目失败";
|
|
ElMessage.error(detail);
|
|
}
|
|
}
|
|
};
|
|
|
|
const enterStudy = async (row: Study, path = "/project/overview") => {
|
|
studyStore.setCurrentStudy(row);
|
|
await studyStore.loadCurrentStudyPermissions().catch(() => {});
|
|
router.push(path);
|
|
};
|
|
|
|
const goDetail = (row: Study) => {
|
|
router.push(`/admin/projects/${row.id}`);
|
|
};
|
|
|
|
const goProject = (row: Study) => {
|
|
if (!canProject(row, "setup_config", "read")) {
|
|
ElMessage.warning("当前角色无权访问该项目的立项配置");
|
|
return;
|
|
}
|
|
goDetail(row);
|
|
};
|
|
|
|
const statusLabel = (status: string) =>
|
|
TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status;
|
|
|
|
onMounted(() => {
|
|
loadRoleTemplates().catch(() => {});
|
|
loadProjects();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.stats-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 12px;
|
|
padding: 16px;
|
|
border-bottom: 1px solid var(--unified-shell-divider);
|
|
}
|
|
|
|
.stat-card {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 14px 16px;
|
|
border-radius: 12px;
|
|
background: var(--ctms-neutral-100);
|
|
transition: var(--ctms-transition);
|
|
}
|
|
|
|
.stat-card:hover {
|
|
transform: translateY(-1px);
|
|
box-shadow: var(--ctms-shadow-sm);
|
|
}
|
|
|
|
.stat-icon {
|
|
width: 38px;
|
|
height: 38px;
|
|
border-radius: 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.stat-icon svg {
|
|
width: 20px;
|
|
height: 20px;
|
|
}
|
|
|
|
.stat-card--total .stat-icon { background: #e8edf3; color: var(--ctms-primary); }
|
|
.stat-card--active .stat-icon { background: #e6f4ed; color: var(--ctms-success); }
|
|
.stat-card--draft .stat-icon { background: #eef2f6; color: var(--ctms-info); }
|
|
.stat-card--locked .stat-icon { background: #fef3e2; color: var(--ctms-warning); }
|
|
|
|
.stat-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
line-height: 1.2;
|
|
color: var(--ctms-text-main);
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 12px;
|
|
color: var(--ctms-text-secondary);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.actions-only-bar {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
align-items: center;
|
|
}
|
|
|
|
.filter-spacer {
|
|
flex: 1;
|
|
}
|
|
|
|
/* 项目单元格 */
|
|
.project-cell {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.project-icon {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.project-icon.icon--active { background: #e6f4ed; color: var(--ctms-success); }
|
|
.project-icon.icon--draft { background: #eef2f6; color: var(--ctms-info); }
|
|
.project-icon.icon--closed { background: #fef3e2; color: var(--ctms-warning); }
|
|
|
|
.project-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
}
|
|
|
|
.project-name {
|
|
font-weight: 600;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.project-name--disabled {
|
|
font-weight: 600;
|
|
font-size: 13px;
|
|
color: var(--ctms-text-secondary);
|
|
cursor: default;
|
|
}
|
|
|
|
.project-code {
|
|
font-size: 12px;
|
|
color: var(--ctms-text-secondary);
|
|
}
|
|
|
|
.text-muted {
|
|
color: var(--ctms-text-secondary);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.role-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 64px;
|
|
max-width: 104px;
|
|
min-height: 28px;
|
|
padding: 4px 10px;
|
|
border-radius: 6px;
|
|
background: var(--ctms-neutral-100);
|
|
color: var(--ctms-text-primary);
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
line-height: 1.2;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* 状态徽章 */
|
|
.status-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
padding: 3px 10px;
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.badge-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: currentColor;
|
|
}
|
|
|
|
.badge--active { background: #e6f4ed; color: #166534; }
|
|
.badge--draft { background: #eef2f6; color: #475569; }
|
|
.badge--closed { background: #fef3e2; color: #92400e; }
|
|
|
|
/* 锁定指示器 */
|
|
.lock-indicator {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.lock-indicator.locked { color: var(--ctms-warning); }
|
|
.lock-indicator.unlocked { color: var(--ctms-text-disabled); }
|
|
|
|
/* 操作按钮 */
|
|
.action-row {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
justify-content: center;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.action-btn {
|
|
width: 28px;
|
|
height: 28px;
|
|
min-height: 28px;
|
|
font-size: 16px;
|
|
padding: 0;
|
|
margin: 0;
|
|
border-radius: 6px;
|
|
transition: var(--ctms-transition);
|
|
}
|
|
|
|
.action-row :deep(.el-button + .el-button) {
|
|
margin-left: 0;
|
|
}
|
|
|
|
.action-btn:hover {
|
|
transform: translateY(-1px);
|
|
background-color: var(--ctms-neutral-100);
|
|
}
|
|
|
|
.enter-btn {
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
}
|
|
|
|
/* 表格 */
|
|
.project-table-section {
|
|
padding: 0;
|
|
}
|
|
|
|
.project-table :deep(.el-table__inner-wrapper::before) {
|
|
display: none;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.stats-row {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
}
|
|
</style>
|