refactor(frontend): 按需加载页面并清理未使用代码
- 将业务页面路由统一改为动态导入,拆分首屏入口与各功能模块构建产物。 - 将网页端和桌面端布局改为异步组件,避免两套平台布局同时进入初始包。 - 新增 Element Plus 按需安装入口,并通过全局配置组件统一注入中文语言包。 - 提取 API 运行时钩子,在应用启动时注入项目清理、令牌续期和认证失效退出能力。 - 将权限监控面板及地图资源改为延迟加载,补充地图加载状态、失败提示和切换竞态保护。 - 删除已被现有工作流替代的项目成员、接口权限、中心绑定、培训表单及旧项目首页等页面。 - 清理废弃的快捷操作、项目选择、用户选择、FAQ 表单、风险占位组件和旧地图辅助模块。 - 移除未使用的 API 方法、类型、字典、状态机、展示工具、样式和项目详情编辑逻辑。 - 开启 TypeScript 未使用变量与参数检查,并同步收紧相关测试和组件暴露类型。 - 移除未使用的 updater、date-fns 和 Sass 前端依赖,更新锁文件并删除旧 CSS 清洗插件。 - 更新路由、Axios、ETMF、通知、权限监控和桌面布局测试以覆盖重构后的边界。
This commit is contained in:
@@ -526,7 +526,6 @@ import {
|
||||
Key,
|
||||
Monitor,
|
||||
Notebook,
|
||||
Refresh,
|
||||
Search,
|
||||
Setting,
|
||||
Star,
|
||||
|
||||
@@ -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,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");
|
||||
};
|
||||
|
||||
@@ -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 "本机访问";
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user