refactor(frontend): 按需加载页面并清理未使用代码
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

- 将业务页面路由统一改为动态导入,拆分首屏入口与各功能模块构建产物。

- 将网页端和桌面端布局改为异步组件,避免两套平台布局同时进入初始包。

- 新增 Element Plus 按需安装入口,并通过全局配置组件统一注入中文语言包。

- 提取 API 运行时钩子,在应用启动时注入项目清理、令牌续期和认证失效退出能力。

- 将权限监控面板及地图资源改为延迟加载,补充地图加载状态、失败提示和切换竞态保护。

- 删除已被现有工作流替代的项目成员、接口权限、中心绑定、培训表单及旧项目首页等页面。

- 清理废弃的快捷操作、项目选择、用户选择、FAQ 表单、风险占位组件和旧地图辅助模块。

- 移除未使用的 API 方法、类型、字典、状态机、展示工具、样式和项目详情编辑逻辑。

- 开启 TypeScript 未使用变量与参数检查,并同步收紧相关测试和组件暴露类型。

- 移除未使用的 updater、date-fns 和 Sass 前端依赖,更新锁文件并删除旧 CSS 清洗插件。

- 更新路由、Axios、ETMF、通知、权限监控和桌面布局测试以覆盖重构后的边界。
This commit is contained in:
Cheng Zhou
2026-07-16 14:58:47 +08:00
parent 1d26646a96
commit 6b55c18610
65 changed files with 345 additions and 3639 deletions
@@ -526,7 +526,6 @@ import {
Key,
Monitor,
Notebook,
Refresh,
Search,
Setting,
Star,
-310
View File
@@ -1,310 +0,0 @@
<template>
<el-drawer
v-if="modelValue"
:model-value="modelValue"
direction="rtl"
size="520px"
:close-on-click-modal="true"
:show-close="false"
class="faq-category-editor-drawer"
@update:model-value="emit('update:modelValue', $event)"
@close="close"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ isEdit ? TEXT.modules.knowledgeMedicalConsult.editCategory : TEXT.modules.knowledgeMedicalConsult.newCategory }}</div>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="faq-category-form">
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-basic"></span>
{{ TEXT.modules.knowledgeMedicalConsult.title }}
</div>
<el-form-item :label="TEXT.modules.knowledgeMedicalConsult.categoryName" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.description">
<el-input v-model="form.description" type="textarea" :rows="2" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.sortOrder">
<el-input-number v-model="form.sort_order" :min="0" :step="1" />
</el-form-item>
<el-form-item label="图标">
<div class="icon-picker">
<span
v-for="icon in iconOptions"
:key="icon.cls"
:title="icon.label"
:class="['icon-option', {
'icon-option--selected': form.icon === icon.cls,
'icon-option--used': isIconUsedByOther(icon.cls),
}]"
@click="selectIcon(icon.cls)"
><FaqIcon :icon="icon.cls" /></span>
</div>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="drawer-footer" :class="{ 'drawer-footer--edit': isEdit }">
<el-button v-if="isEdit && canDelete" type="danger" plain :loading="deleting" @click="onDelete">
{{ TEXT.common.actions.delete }}
</el-button>
<div class="drawer-footer-spacer" v-if="isEdit"></div>
<el-button @click="close">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
</div>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
import FaqIcon from "./FaqIcon.vue";
import { createFaqCategory, updateFaqCategory, deleteFaqCategory } from "../api/faqs";
import { useStudyStore } from "../store/study";
import { TEXT, requiredMessage } from "../locales";
const props = defineProps<{ modelValue: boolean; category?: any; isAdmin: boolean; canDelete?: boolean; existingIcons?: string[] }>();
const emit = defineEmits(["update:modelValue", "success"]);
const study = useStudyStore();
const formRef = ref<FormInstance>();
const submitting = ref(false);
const deleting = ref(false);
const form = reactive({
study_id: "",
name: "",
description: "",
sort_order: 0,
icon: "",
});
const iconOptions = [
{ cls: "fa-th-large", label: "全部" },
{ cls: "fa-ruler-combined", label: "入排标准" },
{ cls: "fa-calendar-check", label: "访视" },
{ cls: "fa-filter", label: "筛选" },
{ cls: "fa-flag-checkered", label: "启动会" },
{ cls: "fa-capsules", label: "试验药物" },
{ cls: "fa-prescription-bottle", label: "药品" },
{ cls: "fa-vial", label: "实验室" },
{ cls: "fa-heartbeat", label: "AE" },
{ cls: "fa-pills", label: "合并用药" },
{ cls: "fa-file-medical", label: "门诊病历" },
{ cls: "fa-camera", label: "拍照" },
{ cls: "fa-notes-medical", label: "医疗记录" },
{ cls: "fa-clipboard-list", label: "检查表" },
{ cls: "fa-syringe", label: "注射" },
{ cls: "fa-flask", label: "研究" },
{ cls: "fa-book-medical", label: "医学书籍" },
{ cls: "fa-user-md", label: "医生" },
{ cls: "fa-hospital", label: "医院" },
{ cls: "fa-ellipsis-h", label: "其他" },
];
const rules: FormRules = {
name: [{ required: true, message: requiredMessage(TEXT.modules.knowledgeMedicalConsult.categoryName), trigger: "blur" }],
};
const isEdit = computed(() => !!props.category);
const isIconUsedByOther = (icon: string) => {
if (form.icon === icon) return false;
return (props.existingIcons || []).includes(icon);
};
const selectIcon = (icon: string) => {
if (isIconUsedByOther(icon)) return;
form.icon = form.icon === icon ? "" : icon;
};
const reset = () => {
form.name = "";
form.description = "";
form.sort_order = 0;
form.icon = "";
};
watch(
() => props.category,
(val) => {
if (val) {
form.name = val.name;
form.description = val.description || "";
form.sort_order = val.sort_order || 0;
form.icon = val.icon || "";
} else {
reset();
}
},
{ immediate: true }
);
watch(
() => props.modelValue,
(val) => {
if (val && !props.category) {
reset();
}
}
);
const close = () => emit("update:modelValue", false);
const onSubmit = async () => {
if (!formRef.value) return;
// 验证当前项目存在
if (!study.currentStudy?.id) {
ElMessage.warning(TEXT.common.empty.selectProject);
return;
}
await formRef.value.validate();
submitting.value = true;
try {
const payload: Record<string, any> = {
study_id: study.currentStudy.id,
name: form.name,
description: form.description || null,
sort_order: form.sort_order,
icon: form.icon || null,
};
if (isEdit.value && props.category) {
await updateFaqCategory(props.category.id, payload);
} else {
await createFaqCategory(payload);
}
ElMessage.success(TEXT.common.messages.saveSuccess);
emit("success");
close();
} catch (e: any) {
const errorMsg = e?.response?.data?.message || e?.response?.data?.detail || TEXT.common.messages.saveFailed;
ElMessage.error(typeof errorMsg === 'string' ? errorMsg : TEXT.common.messages.saveFailed);
} finally {
submitting.value = false;
}
};
const onDelete = async () => {
if (!props.category) return;
const ok = await ElMessageBox.confirm(
TEXT.modules.knowledgeMedicalConsult.confirmDeleteCategory.replace("{name}", props.category.name),
TEXT.common.labels.tips,
).catch(() => null);
if (!ok) return;
deleting.value = true;
try {
await deleteFaqCategory(props.category.id, study.currentStudy?.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
emit("success");
close();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
} finally {
deleting.value = false;
}
};
</script>
<style scoped>
.editor-header {
display: flex;
align-items: center;
}
.editor-title {
color: var(--ctms-text-main);
font-size: 18px;
font-weight: 700;
}
.faq-category-form {
padding: 0 4px;
}
.form-group {
padding: 16px 0;
}
.form-group-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
color: var(--ctms-text-main);
font-size: 15px;
font-weight: 700;
}
.group-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.group-dot-basic {
background: var(--ctms-primary);
}
.drawer-footer {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
}
.drawer-footer--edit {
justify-content: space-between;
}
.drawer-footer-spacer {
flex: 1;
}
.icon-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.icon-option {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border: 2px solid #e8eef6;
border-radius: 10px;
font-size: 18px;
color: #697982;
cursor: pointer;
transition: all 0.15s ease;
background: #ffffff;
}
.icon-option:hover {
border-color: #93c5fd;
background: #eff6ff;
transform: scale(1.08);
}
.icon-option--selected {
border-color: #3b82f6;
background: #dbeafe;
color: #1e40af;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.icon-option--used {
opacity: 0.3;
pointer-events: none;
cursor: not-allowed;
border-color: #e8eef6;
background: #f5f5f5;
}
</style>
@@ -97,7 +97,7 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { ElMessage } from "element-plus";
import { ArrowDown, ArrowUp, Delete, Plus } from "@element-plus/icons-vue";
import FaqIcon from "./FaqIcon.vue";
import { createFaqCategory, updateFaqCategory, deleteFaqCategory } from "../api/faqs";
@@ -35,6 +35,8 @@ describe("desktop layout shell", () => {
const app = readAppSource();
expect(source).toContain("const isDesktop = isTauriRuntime()");
expect(source).toContain('defineAsyncComponent(() => import("./DesktopLayout.vue"))');
expect(source).toContain('defineAsyncComponent(() => import("./WebLayout.vue"))');
expect(source).toContain("<DesktopLayout v-if=\"isDesktop\" />");
expect(source).toContain("<WebLayout v-else />");
expect(app).toContain("const isDesktopRuntime = isTauriRuntime();");
+4 -2
View File
@@ -4,9 +4,11 @@
</template>
<script setup lang="ts">
import { defineAsyncComponent } from "vue";
import { isTauriRuntime } from "../runtime";
import DesktopLayout from "./DesktopLayout.vue";
import WebLayout from "./WebLayout.vue";
const DesktopLayout = defineAsyncComponent(() => import("./DesktopLayout.vue"));
const WebLayout = defineAsyncComponent(() => import("./WebLayout.vue"));
const isDesktop = isTauriRuntime();
</script>
@@ -29,7 +29,7 @@ describe("PermissionAccessLogs", () => {
expect(source).toContain("IP访问排行");
expect(source).toContain("ipRankingDialogVisible");
expect(source).toContain("openIpRankingDialog");
expect(source).toContain("ipRankingPeriodOptions");
expect(source).toContain('<TimeRangeSelector v-model="ipRankingPeriod"');
expect(source).toContain('class="access-toolbar"');
expect(source).toContain('class="access-toolbar-actions"');
expect(source).toContain('@click="loadData">刷新');
@@ -320,8 +320,7 @@ describe("PermissionAccessLogs", () => {
const source = readSource();
expect(source).toContain('placeholder="搜索账号、IP、属地、接口或来源"');
expect(source).toContain("filter-time-presets");
expect(source).toContain("timeRangePresetOptions");
expect(source).toContain('<TimeRangeSelector v-model="timeRangePreset"');
expect(source).toContain("onTimePresetChange");
expect(source).toContain("buildTimeRangeFromPreset");
expect(source).toContain("全部");
@@ -341,7 +340,8 @@ describe("PermissionAccessLogs", () => {
expect(source).toContain("--filter-control-height: 26px");
expect(source).toContain("--filter-font-size: 12px");
expect(source).toContain(".filter-time-presets :deep(.el-radio-button__inner)");
expect(source).toContain('import TimeRangeSelector from "./TimeRangeSelector.vue"');
expect(source).not.toContain(".filter-time-presets :deep(.el-radio-button__inner)");
expect(source).toContain(".audit-filters :deep(.el-select__wrapper)");
expect(source).toContain(".audit-filters :deep(.el-input__wrapper)");
expect(source).toContain('class="filter-reset"');
@@ -432,8 +432,6 @@ const filters = reactive({
const ROLE_FILTER_KEYS = ["ADMIN", "PM", "CRA", "PV", "QA", "CTA"];
const roleFilterOptions = computed(() => roleOptionsFor(ROLE_FILTER_KEYS));
const timeRangePresetOptions = TIME_RANGE_PRESETS;
const ipRankingPeriodOptions = TIME_RANGE_PRESETS;
const selectedIpRankingPeriod = computed(() => TIME_RANGE_PRESETS.find((period) => period.value === ipRankingPeriod.value) || TIME_RANGE_PRESETS[1]);
const clientSourceOptions = [
{ value: "web", label: "网页端" },
@@ -1168,31 +1166,6 @@ defineExpose({ refresh, applyTrendFilter });
flex: 0 0 auto;
}
.filter-time-presets {
flex-shrink: 0;
}
.filter-time-presets :deep(.el-radio-button__inner) {
height: var(--filter-control-height);
padding: 0 12px;
border-radius: 0;
font-size: var(--filter-font-size);
font-weight: 500;
line-height: calc(var(--filter-control-height) - 2px);
}
.filter-time-presets :deep(.el-radio-button:first-child .el-radio-button__inner) {
border-radius: 7px 0 0 7px;
}
.filter-time-presets :deep(.el-radio-button:last-child .el-radio-button__inner) {
border-radius: 0 7px 7px 0;
}
.filter-time-presets :deep(.el-radio-button.is-active .el-radio-button__inner) {
font-weight: 600;
}
.filter-role {
width: 110px;
}
@@ -73,7 +73,9 @@ describe("PermissionIpLocations", () => {
it("uses backend coordinates and keeps unmapped locations in the ranking", () => {
const source = readSource();
expect(source).toContain('const mapView = ref<"china" | "flow">("flow");');
expect(source).toContain('const mapView = ref<MapView>("flow");');
expect(source).toContain('load: () => import("../assets/china.json")');
expect(source).toContain('load: () => import("../assets/world.json")');
expect(source).toContain("item.longitude");
expect(source).toContain("item.latitude");
expect(source).toContain("external_fallback_ip_count");
@@ -56,16 +56,20 @@
</div>
</div>
<div class="source-map-wrap">
<v-chart
v-if="!fullscreenVisible"
ref="sourceChartRef"
:key="mapView"
:option="sourceMapOption"
:init-options="chartInitOptions"
:update-options="chartUpdateOptions"
:autoresize="chartAutoresize"
class="source-map"
/>
<template v-if="!fullscreenVisible">
<v-chart
v-if="mapReady"
ref="sourceChartRef"
:key="mapView"
:option="sourceMapOption"
:init-options="chartInitOptions"
:update-options="chartUpdateOptions"
:autoresize="chartAutoresize"
class="source-map"
/>
<el-empty v-else-if="mapLoadError" :description="mapLoadError" :image-size="72" />
<div v-else v-loading="true" class="source-map" aria-label="地图加载中" />
</template>
<div class="flow-legend map-legend" aria-label="访问链路图例">
<span v-if="serverLocation" class="legend-item"><i class="legend-dot server"></i>服务器</span>
<template v-if="mapMetric === 'traffic'">
@@ -125,7 +129,7 @@
</template>
<div class="fullscreen-map-wrap">
<v-chart
v-if="fullscreenChartMounted"
v-if="fullscreenChartMounted && mapReady"
ref="fullscreenChartRef"
:key="fullscreenMapKey"
:option="fullscreenSourceMapOption"
@@ -152,21 +156,26 @@
</template>
<script setup lang="ts">
import { computed, h, nextTick, onMounted, ref } from "vue";
import { computed, h, nextTick, onMounted, ref, watch } from "vue";
import VChart from "vue-echarts";
import { registerMap, use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { EffectScatterChart, LinesChart, ScatterChart } from "echarts/charts";
import { GeoComponent, TooltipComponent } from "echarts/components";
import chinaMapGeoJson from "../assets/china.json";
import worldMapGeoJson from "../assets/world.json";
import { fetchIpLocations } from "../api/projectPermissions";
import type { IpLocationsResponse, IpLocationStatItem } from "../types/api";
import TimeRangeSelector from "./TimeRangeSelector.vue";
use([CanvasRenderer, LinesChart, ScatterChart, EffectScatterChart, GeoComponent, TooltipComponent]);
registerMap("ctms-china", chinaMapGeoJson as any);
registerMap("ctms-world", worldMapGeoJson as any);
type MapView = "china" | "flow";
const mapDefinitions: Record<MapView, { name: string; load: () => Promise<{ default: unknown }> }> = {
china: { name: "ctms-china", load: () => import("../assets/china.json") },
flow: { name: "ctms-world", load: () => import("../assets/world.json") },
};
const registeredMaps = new Set<string>();
const IconGlobe = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("circle", { cx: "12", cy: "12", r: "10" }),
@@ -192,12 +201,6 @@ const IconShield = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke:
const loading = ref(false);
type TimeRangePreset = "all" | "24h" | "7d" | "30d";
const timeRangePreset = ref<TimeRangePreset>("30d");
const timeRangeOptions = [
{ value: "all", label: "全部" },
{ value: "24h", label: "24小时" },
{ value: "7d", label: "7天" },
{ value: "30d", label: "30天" },
] as const;
const getIpLocationsDays = (preset: TimeRangePreset): number | undefined => {
const map: Record<TimeRangePreset, number | undefined> = {
@@ -208,7 +211,7 @@ const getIpLocationsDays = (preset: TimeRangePreset): number | undefined => {
};
return map[preset];
};
const mapView = ref<"china" | "flow">("flow");
const mapView = ref<MapView>("flow");
const mapMetric = ref<"traffic" | "risk">("traffic");
const items = ref<IpLocationStatItem[]>([]);
const summary = ref<IpLocationsResponse["summary"] | null>(null);
@@ -216,6 +219,8 @@ const periodMetadata = ref<IpLocationsResponse["period"] | null>(null);
const dataQuality = ref<IpLocationsResponse["data_quality"] | null>(null);
const serverLocation = ref<IpLocationsResponse["server_location"]>(null);
const loadError = ref("");
const mapLoadError = ref("");
const mapReady = ref(false);
const lastUpdatedAt = ref<Date | null>(null);
const getDevicePixelRatio = () => (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1);
const chartInitOptions = { devicePixelRatio: Math.min(getDevicePixelRatio(), 2), useDirtyRect: true };
@@ -270,6 +275,31 @@ const resizeSourceCharts = async () => {
sourceChartRef.value?.resize?.();
};
let mapLoadSequence = 0;
const loadMap = async (view: MapView) => {
const sequence = ++mapLoadSequence;
const definition = mapDefinitions[view];
mapReady.value = false;
mapLoadError.value = "";
try {
if (!registeredMaps.has(definition.name)) {
const geoJson = await definition.load();
registerMap(definition.name, geoJson.default as any);
registeredMaps.add(definition.name);
}
if (sequence !== mapLoadSequence) return;
mapReady.value = true;
await resizeSourceCharts();
} catch {
if (sequence !== mapLoadSequence) return;
mapLoadError.value = "地图资源加载失败,请刷新后重试";
}
};
watch(mapView, (view) => void loadMap(view), { immediate: true });
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
const formatLastUpdated = (value: Date) => {
return `${value.getFullYear()}/${value.getMonth() + 1}/${value.getDate()} ${String(value.getHours()).padStart(2, "0")}:${String(value.getMinutes()).padStart(2, "0")}:${String(value.getSeconds()).padStart(2, "0")}`;
@@ -267,7 +267,7 @@
</template>
<script setup lang="ts">
import { ref, computed, h, nextTick, onMounted, onUnmounted } from "vue";
import { computed, defineAsyncComponent, h, nextTick, onMounted, onUnmounted, ref } from "vue";
import type {
PermissionMetricsResponse,
AlertsResponse,
@@ -282,12 +282,13 @@ import {
fetchTopDenied,
fetchStatsSummary,
} from "@/api/projectPermissions";
import PermissionTrendCharts from "./PermissionTrendCharts.vue";
import PermissionAccessLogs from "./PermissionAccessLogs.vue";
import PermissionIpLocations from "./PermissionIpLocations.vue";
import SecurityCenter from "./SecurityCenter.vue";
import TimeRangeSelector from "./TimeRangeSelector.vue";
const PermissionTrendCharts = defineAsyncComponent(() => import("./PermissionTrendCharts.vue"));
const PermissionAccessLogs = defineAsyncComponent(() => import("./PermissionAccessLogs.vue"));
const PermissionIpLocations = defineAsyncComponent(() => import("./PermissionIpLocations.vue"));
const SecurityCenter = defineAsyncComponent(() => import("./SecurityCenter.vue"));
const IconCheck = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("polyline", { points: "20 6 9 17 4 12" }),
]);
@@ -328,12 +329,6 @@ const lastUpdatedAt = ref<Date | null>(null);
type TimeRangePreset = "all" | "24h" | "7d" | "30d";
const timeRangePreset = ref<TimeRangePreset>("7d");
const timeRangeOptions = [
{ value: "all", label: "全部" },
{ value: "24h", label: "24小时" },
{ value: "7d", label: "7天" },
{ value: "30d", label: "30天" },
] as const;
const getTopDeniedDays = (preset: TimeRangePreset): number => {
const map: Record<TimeRangePreset, number> = {
@@ -355,15 +350,6 @@ const currentDeniedPeriodLabel = computed(() => {
return map[timeRangePreset.value];
});
const trendChartsRef = ref<InstanceType<typeof PermissionTrendCharts>>();
const accessLogsRef = ref<InstanceType<typeof PermissionAccessLogs>>();
type IpLocationsExpose = {
refresh: () => void | Promise<void>;
resize: () => void | Promise<void>;
};
const ipLocationsRef = ref<IpLocationsExpose>();
interface TrendDrilldownPayload {
preset?: "24h" | "7d" | "30d";
startAt?: string;
@@ -374,13 +360,28 @@ interface TrendDrilldownPayload {
keyword?: string;
}
type RefreshablePanel = {
refresh: () => void | Promise<void>;
};
type AccessLogsExpose = RefreshablePanel & {
applyTrendFilter: (payload: TrendDrilldownPayload) => void | Promise<void>;
};
type IpLocationsExpose = RefreshablePanel & {
resize: () => void | Promise<void>;
};
const trendChartsRef = ref<RefreshablePanel>();
const accessLogsRef = ref<AccessLogsExpose>();
const ipLocationsRef = ref<IpLocationsExpose>();
const securityCenterRef = ref<RefreshablePanel>();
const openTrendDrilldown = async (payload: TrendDrilldownPayload) => {
activeTab.value = "logs";
await nextTick();
await accessLogsRef.value?.applyTrendFilter(payload);
};
const securityCenterRef = ref<InstanceType<typeof SecurityCenter>>();
const formatTime = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleString("zh-CN");
};
-145
View File
@@ -1,145 +0,0 @@
<template>
<div class="quick-actions-section">
<div class="section-header">
<el-icon class="header-icon"><Pointer /></el-icon>
<span class="header-title">{{ TEXT.common.labels.quickActions }}</span>
</div>
<div class="actions-grid">
<div
v-for="item in visibleActions"
:key="item.path"
class="action-item"
@click="go(item.path)"
>
<div class="action-icon-wrapper">
<el-icon><component :is="item.icon" /></el-icon>
</div>
<span class="action-label">{{ item.label }}</span>
<el-icon class="action-arrow"><ArrowRight /></el-icon>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useRouter } from "vue-router";
import {
Pointer, ArrowRight, Timer, UserFilled,
Management, Money, Collection
} from "@element-plus/icons-vue";
import { TEXT } from "../locales";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { isSystemAdmin } from "../utils/roles";
import { getProjectRoutePermission, hasProjectPermission } from "../utils/projectRoutePermissions";
const router = useRouter();
const auth = useAuthStore();
const study = useStudyStore();
const actions = [
{ label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics", icon: Timer },
{ label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth", icon: Timer },
{ label: TEXT.menu.subjects, path: "/subjects", icon: UserFilled },
{ label: TEXT.menu.drugShipments, path: "/drug/shipments", icon: Management },
{ label: TEXT.menu.finance, path: "/fees/contracts", icon: Money },
{ label: TEXT.menu.knowledgeMedicalConsult, path: "/knowledge/medical-consult", icon: Collection },
];
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const visibleActions = computed(() =>
actions.filter((item) =>
hasProjectPermission(
study.currentPermissions,
projectRole.value,
getProjectRoutePermission(item.path),
isSystemAdmin(auth.user),
)
)
);
const go = (path: string) => router.push(path);
</script>
<style scoped>
.quick-actions-section {
background-color: #fff;
border-radius: var(--ctms-radius);
padding: 24px;
border: 1px solid var(--ctms-border-color);
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
}
.header-icon {
font-size: 16px;
color: var(--ctms-text-secondary);
}
.header-title {
font-size: 15px;
font-weight: 600;
color: var(--ctms-text-main);
}
.actions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.action-item {
display: flex;
align-items: center;
padding: 12px 16px;
background-color: #ffffff;
border: 1px solid var(--ctms-border-color);
border-radius: 10px;
cursor: pointer;
transition: var(--ctms-transition);
position: relative;
overflow: hidden;
}
.action-item:hover {
background-color: #ffffff;
border-color: var(--ctms-border-color-hover);
}
.action-icon-wrapper {
width: 34px;
height: 34px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
margin-right: 12px;
color: var(--ctms-text-secondary);
background-color: var(--ctms-bg-muted);
}
.action-label {
font-size: 14px;
font-weight: 500;
color: var(--ctms-text-main);
flex: 1;
}
.action-arrow {
font-size: 14px;
color: var(--ctms-text-disabled);
opacity: 1;
transition: var(--ctms-transition);
}
.action-item:hover .action-arrow {
color: var(--ctms-text-secondary);
}
</style>
@@ -429,8 +429,6 @@ const formatLastUpdated = (value: Date) => {
return `${value.getFullYear()}/${value.getMonth() + 1}/${value.getDate()} ${String(value.getHours()).padStart(2, "0")}:${String(value.getMinutes()).padStart(2, "0")}:${String(value.getSeconds()).padStart(2, "0")}`;
};
const isHighRisk = (event: SecurityAccessLogItem) => event.severity === "CRITICAL" || event.severity === "HIGH";
const fallbackIpLocation = (ip: string | null) => {
if (!ip) return "未知位置";
if (ip === "127.0.0.1" || ip === "::1") return "本机访问";
-79
View File
@@ -1,79 +0,0 @@
<template>
<el-dropdown trigger="click" class="study-selector-dropdown" @command="onCommand">
<div class="selector-trigger">
<el-icon class="trigger-icon"><OfficeBuilding /></el-icon>
<span class="current-study-name">{{ study.currentStudy?.name || TEXT.common.empty.selectProject }}</span>
<el-icon class="arrow-icon"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu class="study-dropdown">
<el-dropdown-item command="workbench">
<el-icon><OfficeBuilding /></el-icon>返回工作台选择项目
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
import { useStudyStore } from "../store/study";
import { OfficeBuilding, ArrowDown } from "@element-plus/icons-vue";
import { TEXT } from "../locales";
const router = useRouter();
const study = useStudyStore();
const onCommand = async (cmd: any) => {
if (cmd === "workbench") {
study.clearCurrentStudy();
await router.push("/workbench");
}
};
</script>
<style scoped>
.selector-trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background-color: #fafafa;
border: 1px solid var(--ctms-border-color);
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
}
.selector-trigger:hover {
background-color: var(--ctms-primary-light);
border-color: var(--ctms-primary-hover);
}
.trigger-icon {
color: var(--ctms-primary);
font-size: 16px;
}
.current-study-name {
font-size: 14px;
font-weight: 500;
color: var(--ctms-text-main);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.arrow-icon {
font-size: 12px;
color: var(--ctms-text-secondary);
}
.study-dropdown {
max-height: 320px;
overflow-y: auto;
min-width: 220px;
}
</style>
+1 -1
View File
@@ -408,7 +408,7 @@ import { TEXT } from "../locales";
import {
User, Suitcase, House, Calendar, Flag,
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key, DataAnalysis, UserFilled, Bell, Setting,
Search, Star, StarFilled, Clock, Monitor, FullScreen, ScaleToOriginal
Search, Star, StarFilled, Monitor, FullScreen, ScaleToOriginal
} from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
@@ -1,14 +0,0 @@
// 地图数据来源:https://geojson.cn/api/china/1.6.3/china.json
// 组件直接导入本地缓存的 GeoJSON,避免运行时依赖外部网络。
export const normalizeProvinceName = (value?: string | null) => {
return String(value || "")
.trim()
.replace(/特别行政区$/u, "")
.replace(/维吾尔自治区$/u, "")
.replace(/壮族自治区$/u, "")
.replace(/回族自治区$/u, "")
.replace(/自治区$/u, "")
.replace(/省$/u, "")
.replace(/市$/u, "");
};
@@ -1,49 +0,0 @@
<template>
<el-select
:model-value="modelValue"
:multiple="multiple"
:placeholder="placeholder || TEXT.common.placeholders.select"
:filterable="filterable"
:clearable="clearable"
:loading="loading"
style="width: 100%"
@update:model-value="onUpdate"
>
<el-option v-for="opt in options" :key="String(opt.value)" :label="opt.label" :value="opt.value" />
<slot />
</el-select>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { TEXT } from "../../locales";
type OptionItem = {
label: string;
value: string | number;
};
const props = defineProps<{
modelValue?: string | number | Array<string | number> | null;
options?: OptionItem[];
multiple?: boolean;
placeholder?: string;
filterable?: boolean;
clearable?: boolean;
loading?: boolean;
}>();
const emit = defineEmits<{
(event: "update:modelValue", value: string | number | Array<string | number> | null): void;
}>();
const onUpdate = (value: string | number | Array<string | number> | null) => {
emit("update:modelValue", value);
};
const options = computed(() => props.options ?? []);
const multiple = computed(() => props.multiple ?? false);
const filterable = computed(() => props.filterable ?? true);
const clearable = computed(() => props.clearable ?? true);
const loading = computed(() => props.loading ?? false);
</script>