前端部分更新

This commit is contained in:
2026-06-26 17:39:57 +08:00
parent 628a63a250
commit d38cbdbf07
165 changed files with 35393 additions and 284 deletions
@@ -0,0 +1,238 @@
<template>
<el-dialog v-model="visible" :title="!dataForm.id ? '新增数据' : '修改数据'" width="900px" :close-on-click-modal="false">
<el-form :model="dataForm" :rules="rules" ref="dataFormRef" label-width="140px" style="padding-right: 20px">
<el-row :gutter="24">
<el-divider content-position="left">基本信息</el-divider>
<el-col :span="12">
<el-form-item label="站点ID" prop="stationId">
<el-select v-if="!manualInput" v-model="dataForm.stationId" filterable placeholder="从列表选择或输入" style="width: 100%" clearable>
<el-option v-for="s in stationOptions" :key="s.stationCode" :label="`${s.stationCode} ${s.stationName || ''}`" :value="s.stationCode" />
<template #empty>
<div style="padding: 8px; text-align: center">
<el-button size="small" type="primary" link @click="manualInput = true">未找到点击手动输入</el-button>
</div>
</template>
</el-select>
<el-input v-else v-model="dataForm.stationId" placeholder="手动输入区站号">
<template #suffix>
<el-button size="small" link @click="manualInput = false">列表选择</el-button>
</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="观测日期" prop="observeDate">
<el-date-picker v-model="dataForm.observeDate" type="date" placeholder="选择日期" value-format="YYYY-MM-DD 00:00:00" style="width: 100%"></el-date-picker>
</el-form-item>
</el-col>
<el-divider content-position="left">温湿度与气压</el-divider>
<el-col :span="12">
<el-form-item label="相对湿度" prop="avgTemp" label-width="80px">
<el-input-number v-model="dataForm.relativeHumidity" :precision="1" :step="0.1" controls-position="right" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="气压" prop="avgTemp" label-width="80px">
<el-input-number v-model="dataForm.atmospheres" :precision="1" :step="0.1" controls-position="right" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="平均气温" prop="avgTemp" label-width="80px">
<el-input-number v-model="dataForm.avgTemp" :precision="1" :step="0.1" controls-position="right" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="最高气温" prop="maxTemp" label-width="80px">
<el-input-number v-model="dataForm.maxTemp" :precision="1" :step="0.1" controls-position="right" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="最低气温" prop="minTemp" label-width="80px">
<el-input-number v-model="dataForm.minTemp" :precision="1" :step="0.1" controls-position="right" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="最高气温出现时间" prop="maxTempTime" label-width="125px">
<el-input v-model="dataForm.maxTempTime" placeholder="HHmm"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="最低气温出现时间" prop="minTempTime" label-width="125px">
<el-input v-model="dataForm.minTempTime" placeholder="HHmm"></el-input>
</el-form-item>
</el-col>
<el-divider content-position="left">降水与风力</el-divider>
<el-col :span="12">
<el-form-item label="20-20时降水量" prop="rain2020">
<el-input-number v-model="dataForm.rain2020" :precision="1" :min="0" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="08-08时降水量" prop="rain0808">
<el-input-number v-model="dataForm.rain0808" :precision="1" :min="0" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="日平均风速" prop="maxWindSpeed">
<el-input-number v-model="dataForm.dayAvgWindSpeed" :precision="1" :min="0" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="日平均风向" prop="maxWindSpeed">
<el-input-number v-model="dataForm.dayAvgWindDirection" :precision="1" :min="0" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="最大风速" prop="maxWindSpeed">
<el-input-number v-model="dataForm.maxWindSpeed" :precision="1" :min="0" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="最大风速风向" prop="maxWindDirection">
<el-input-number v-model="dataForm.maxWindDirection" :min="0" :max="360" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="极大风速" prop="maxWindSpeed">
<el-input-number v-model="dataForm.extremeWindSpeed" :precision="1" :min="0" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="极大风速风向" prop="maxWindSpeed">
<el-input-number v-model="dataForm.extremeWindDirection" :precision="1" :min="0" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="极大风速出现时间" prop="minTempTime" label-width="125px">
<el-input v-model="dataForm.extremeWindTime" placeholder="HHmm"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="最大风速出现时间" prop="minTempTime" label-width="125px">
<el-input v-model="dataForm.maxWindTime" placeholder="HHmm"></el-input>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="dataFormSubmitHandle()">确定</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { reactive, ref, onMounted } from "vue";
import baseService from "@/service/baseService";
import { ElMessage } from "element-plus";
const emit = defineEmits(["refreshDataList"]);
const visible = ref(false);
const dataFormRef = ref();
const stationOptions = ref<any[]>([]);
const manualInput = ref(false);
const dataForm = reactive({
id: "",
stationId: "",
observeDate: "",
avgTemp: 0,
maxTemp: 0,
maxTempTime: "",
minTemp: 0,
minTempTime: "",
rain2020: 0,
rain0808: 0,
relativeHumidity: 0,
atmospheres: 0,
dayAvgWindSpeed: 0,
dayAvgWindDirection: 0,
maxWindSpeed: 0,
maxWindDirection: 0,
maxWindTime: "",
extremeWindSpeed: 0,
extremeWindDirection: 0,
extremeWindTime: "",
createDate: ""
});
// 校验规则优化
const rules = ref({
stationId: [{ required: true, message: "站点不能为空", trigger: "blur" }],
observeDate: [{ required: true, message: "观测日期不能为空", trigger: "change" }],
// 时刻格式简单校验 (HHmm)
maxTempTime: [{ pattern: /^([01]\d|2[0-3])[0-5]\d$/, message: "格式应为HHmm(如0715)", trigger: "blur" }],
minTempTime: [{ pattern: /^([01]\d|2[0-3])[0-5]\d$/, message: "格式应为HHmm(如1430)", trigger: "blur" }]
});
function getStationList() {
return baseService.get("/station/weatherstation/list").then((res) => {
stationOptions.value = res.data || [];
});
}
onMounted(() => {
getStationList();
});
const init = (id?: number) => {
visible.value = true;
manualInput.value = false;
dataForm.id = "";
Object.assign(dataForm, {
avgTemp: 0,
maxTemp: 0,
minTemp: 0,
rain2020: 0,
rain0808: 0,
maxWindSpeed: 0,
maxWindDirection: 0,
extremeWindSpeed: 0
});
if (dataFormRef.value) {
dataFormRef.value.resetFields();
}
if (id) {
getInfo(id);
}
};
const getInfo = (id: number) => {
baseService.get("/dailyweather/weatherdailydata/" + id).then((res) => {
Object.assign(dataForm, res.data);
});
};
const dataFormSubmitHandle = () => {
dataFormRef.value.validate((valid: boolean) => {
if (!valid) return false;
const action = !dataForm.id ? baseService.post : baseService.put;
action("/dailyweather/weatherdailydata", dataForm).then((res) => {
ElMessage.success({
message: "提交成功",
duration: 800,
onClose: () => {
visible.value = false;
emit("refreshDataList");
}
});
});
});
};
defineExpose({ init });
</script>
@@ -0,0 +1,148 @@
<template>
<el-dialog v-model="visible" title="批量导入气象数据" width="450px" :close-on-click-modal="false">
<el-upload
ref="uploadRef"
class="upload-demo"
drag
action="#"
:auto-upload="false"
:on-change="handleChange"
:http-request="uploadFileRequest"
:limit="1"
:on-exceed="handleExceed"
accept=".xlsx, .xls"
:disabled="loading"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">将文件拖到此处 <em>点击选择</em></div>
<template #tip>
<div class="el-upload__tip" style="text-align: center;">
只能上传 Excel 文件
</div>
</template>
</el-upload>
<template #footer>
<span class="dialog-footer">
<el-button :disabled="loading" @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="dataFormSubmitHandle">确定</el-button>
</span>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { ElMessage } from 'element-plus';
import baseService from "@/service/baseService";
import { useImportTaskStore } from "@/store/importTasks";
const taskStore = useImportTaskStore();
const POLL_INTERVAL = 3000;
const visible = ref(false);
const loading = ref(false);
const uploadRef = ref();
const fileRaw = ref<File | null>(null);
const emit = defineEmits(['refreshDataList']);
const init = () => {
visible.value = true;
fileRaw.value = null;
if(uploadRef.value) uploadRef.value.clearFiles();
};
const handleChange = (file: any) => {
fileRaw.value = file.raw;
};
const handleExceed = (files: any) => {
uploadRef.value!.clearFiles();
const file = files[0];
uploadRef.value!.handleStart(file);
};
function pollProgress(taskId: string, localTaskId: string) {
const timer = setInterval(async () => {
try {
const res = await baseService.get(`/dailyweather/weatherdailydata/import/progress/${taskId}`);
if (res?.code === 0 && res.data) {
const { totalRows, processedRows, errorMessage } = res.data;
const st = (res.data.status || "").toUpperCase();
taskStore.updateTask(localTaskId, {
totalRows: totalRows || 0,
processedRows: typeof processedRows === "object" ? 0 : (processedRows || 0)
});
if (st === "COMPLETED") {
clearInterval(timer);
taskStore.updateTask(localTaskId, { status: "success", message: "导入成功", completedAt: Date.now() });
emit("refreshDataList");
} else if (st === "FAILED" || st === "ERROR") {
clearInterval(timer);
taskStore.updateTask(localTaskId, { status: "error", message: errorMessage || "导入失败", completedAt: Date.now() });
} else {
const processed = typeof processedRows === "object" ? 0 : (processedRows || 0);
const total = totalRows || 0;
const pct = total > 0 ? Math.round((processed / total) * 100) : 0;
taskStore.updateTask(localTaskId, { message: `正在导入数据 ${pct}% (${processed}/${total})` });
}
}
} catch {
// keep polling on network error
}
}, POLL_INTERVAL);
return timer;
}
const dataFormSubmitHandle = () => {
if (!fileRaw.value) {
return ElMessage.warning("请先选择文件");
}
const localTaskId = Date.now().toString() + Math.random().toString(36).slice(2, 6);
taskStore.addTask({
id: localTaskId,
fileName: fileRaw.value.name,
status: "uploading",
message: "正在上传文件...",
totalRows: 0,
processedRows: 0,
createdAt: Date.now(),
completedAt: null,
backendTaskId: ""
});
visible.value = false;
ElMessage.success("导入已开始,可在通知中心查看进度");
const formData = new FormData();
formData.append("file", fileRaw.value);
baseService.upload("/dailyweather/weatherdailydata/import", formData)
.then((res: any) => {
if (res?.code === 0) {
const backendTaskId = res.data?.taskId || res.data?.toString() || "";
if (backendTaskId) {
taskStore.updateTask(localTaskId, {
status: "processing",
message: "正在导入数据...",
backendTaskId
});
pollProgress(backendTaskId, localTaskId);
} else {
taskStore.updateTask(localTaskId, { status: "success", message: "导入成功", completedAt: Date.now() });
emit("refreshDataList");
}
} else {
taskStore.updateTask(localTaskId, { status: "error", message: res?.msg || "导入失败", completedAt: Date.now() });
ElMessage.error(res?.msg || "导入失败");
}
})
.catch((err: any) => {
taskStore.updateTask(localTaskId, { status: "error", message: err?.message || "网络错误", completedAt: Date.now() });
console.error("上传细节错误:", err);
});
};
defineExpose({ init });
</script>
@@ -0,0 +1,293 @@
<template>
<div class="mod-dailyweather__weatherdailydata">
<el-form :inline="true" :model="state.dataForm" @keyup.enter="state.getDataList()">
<el-form-item>
<el-select v-model="realDataForm.stationId" multiple filterable remote reserve-keyword clearable placeholder="请输入区站名称" :remote-method="remoteSearchStation" :loading="loading" style="width: 300px">
<el-option v-for="item in stationOptions" :key="item.stationCode" :label="`${item.stationName}`" :value="item.stationCode" />
</el-select>
</el-form-item>
<!-- <el-form-item>-->
<!-- <el-date-picker v-model="realDataForm.observeDate" type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" value-format="YYYY-MM-DD" :default-time="[new Date(2000, 1, 1), new Date(2000, 1, 1)]" style="width: 360px" />-->
<!-- </el-form-item>-->
<el-form-item>
<el-button @click="state.getDataList">查询</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="state.hasPermission('dailyweather:weatherdailydata:save')" type="primary" icon="Plus" @click="addOrUpdateHandle()">新增</el-button>
<el-button v-if="state.hasPermission('dailyweather:weatherdailydata:delete')" type="danger" icon="Delete" @click="state.deleteHandle()">批量删除</el-button>
<el-button type="success" icon="Upload" @click="importHandle()">批量导入</el-button>
</el-form-item>
<el-form-item style="float: right; margin-right: 0">
<el-popover placement="bottom-end" :width="300" trigger="click">
<template #reference>
<el-button icon="Grid">展示列设置</el-button>
</template>
<div class="column-setting-container">
<div v-for="group in columnGroups" :key="group.title" class="column-group">
<div class="group-title">{{ group.title }}</div>
<el-checkbox-group v-model="selectedColumns">
<el-checkbox v-for="col in group.columns" :key="col.prop" :label="col.prop" style="width: 120px">
{{ col.label }}
</el-checkbox>
</el-checkbox-group>
<el-divider style="margin: 8px 0" />
</div>
<div style="text-align: right">
<el-button size="small" type="primary" link @click="resetColumns">恢复默认</el-button>
</div>
</div>
</el-popover>
</el-form-item>
</el-form>
<el-table v-loading="state.dataListLoading" :data="state.dataList" border @selection-change="state.dataListSelectionChangeHandle" style="width: 100%">
<el-table-column type="selection" header-align="center" align="center" width="50" fixed="left"></el-table-column>
<template v-for="group in columnGroups">
<template v-for="item in group.columns">
<el-table-column v-if="selectedColumns.includes(item.prop)" :key="item.prop" :prop="item.prop" :label="item.label" header-align="center" align="center" min-width="120" show-overflow-tooltip>
<template #default="scope">
<span v-if="item.prop === 'observeDate'">
{{ formatObserveDate(scope.row.observeDate) }}
</span>
<span v-else-if="item.prop.endsWith('Time') && item.prop !== 'createTime'">
<el-tag v-if="isValidTime(scope.row[item.prop])" size="small" effect="plain" type="info">
{{ formatWeatherTime(scope.row[item.prop]) }}
</el-tag>
<span v-else>--</span>
</span>
<span v-else-if="item.prop === 'dayAvgWindDirection' || item.prop === 'maxWindDirection'">
{{ windDirectionLabel(scope.row[item.prop]) }}
</span>
<span v-else>{{ scope.row[item.prop] ?? "--" }}</span>
</template>
</el-table-column>
</template>
</template>
<el-table-column label="操作" fixed="right" header-align="center" align="center" width="150">
<template v-slot="scope">
<el-button v-if="state.hasPermission('dailyweather:weatherdailydata:update')" type="primary" link @click="addOrUpdateHandle(scope.row.id)">修改</el-button>
<el-button v-if="state.hasPermission('dailyweather:weatherdailydata:delete')" type="primary" link @click="state.deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination :current-page="state.page" :page-sizes="[10, 20, 50, 100]" :page-size="state.limit" :total="state.total" layout="total, sizes, prev, pager, next, jumper" @size-change="state.pageSizeChangeHandle" @current-change="state.pageCurrentChangeHandle" />
<add-or-update ref="addOrUpdateRef" @refreshDataList="state.getDataList" />
<import-excel ref="importExcelRef" @refreshDataList="state.getDataList" />
</div>
</template>
<script lang="ts" setup>
import useView from "@/hooks/useView";
import { reactive, ref, toRefs, watch, onMounted, computed } from "vue";
import AddOrUpdate from "./weatherdailydata-add-or-update.vue";
import ImportExcel from "./weatherdailydata-import.vue";
import baseService from "@/service/baseService";
import { useAppStore } from "@/store";
const _store = useAppStore();
function _windDirectionCode(deg: any) {
if (deg == null || deg === "" || isNaN(deg)) return null;
const d = Number(deg);
if (d < 0 || d > 360) return null;
return ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Math.floor((d + 22.5) / 45) % 8];
}
function windDirectionLabel(deg: any) {
const code = _windDirectionCode(deg);
if (!code) return "—";
const type = _store.state.dicts.find((d: any) => d.dictType === "wind_direction_type");
const entry = type?.dataList?.find((e: any) => e.dictValue === code);
return entry?.dictLabel || code;
}
const columnGroups = [
{
title: "基础信息",
columns: [
{ prop: "stationId", label: "区站号", default: true },
{ prop: "stationName", label: "站名", default: true },
{ prop: "observeDate", label: "观测日期", default: true },
{ prop: "createDate", label: "创建时间", default: false },
{ prop: "id", label: "系统编号", default: false }
]
},
{
title: "温湿度气压数据",
columns: [
{ prop: "avgTemp", label: "平均气温", default: true },
{ prop: "maxTemp", label: "最高气温", default: true },
{ prop: "minTemp", label: "最低气温", default: true },
{ prop: "maxTempTime", label: "最高气温时间", default: false },
{ prop: "minTempTime", label: "最低气温时间", default: false },
{ prop: "relativeHumidity", label: "相对湿度", default: true },
{ prop: "atmospheres", label: "气压", default: true }
]
},
{
title: "降水/风力数据",
columns: [
{ prop: "rain2020", label: "20-20降水", default: false },
{ prop: "rain0808", label: "08-08降水", default: false },
{ prop: "dayAvgWindSpeed", label: "日平均风速", default: true },
{ prop: "dayAvgWindDirection", label: "日平均风向", default: true },
{ prop: "maxWindSpeed", label: "最大风速", default: false },
{ prop: "maxWindDirection", label: "最大风向", default: false },
{ prop: "maxWindTime", label: "最大风时间", default: false },
{ prop: "extremeWindSpeed", label: "极大风速", default: false },
{ prop: "extremeWindTime", label: "极大风时间", default: false }
]
}
];
// 智能日期截取
const formatObserveDate = (dateStr: any) => {
if (!dateStr) return "--";
const str = String(dateStr);
const separator = str.includes("T") ? "T" : " ";
return str.split(separator)[0];
};
// 校验有效时间数字
const isValidTime = (val: any) => {
return val !== null && val !== undefined && val !== "" && !isNaN(Number(val));
};
// 时刻格式化:712 -> 07:12
const formatWeatherTime = (time: any) => {
let timeStr = String(time);
if (timeStr.length === 3) timeStr = "0" + timeStr;
if (timeStr.length === 4) {
return `${timeStr.substring(0, 2)}:${timeStr.substring(2, 4)}`;
}
return timeStr;
};
interface WeatherStation {
stationName: string;
stationCode: string;
}
const loading = ref(false);
const stationOptions = ref<WeatherStation[]>([]); // 明确这是一个 WeatherStation 对象的数组
/**
* 远程搜索区站
*/
const remoteSearchStation = async (keyword: string) => {
loading.value = true;
try {
const res = await baseService.get("/station/weatherstation/list");
if (res.code === 0) {
// TypeScript 自动知道 item 是 WeatherStation 类型
stationOptions.value = res.data.filter((item: { stationName: string | string[]; stationCode: string | string[] }) => !keyword || item.stationName.includes(keyword) || item.stationCode.includes(keyword));
}
} finally {
loading.value = false;
}
};
// 1. 定义真实的表单数据(保持数组,供 <el-select multiple> 正常绑定)
const realDataForm = reactive({
stationId: "",
// observeDate: Date,
// startTime: "",
// endTime:""
});
// 2. 使用 computed 动态派生出“用于请求的 dataForm”
// 只要 realDataForm 发生变化,这个 computed 就会自动重新计算
const computedDataForm = computed(() => {
const params = { ...realDataForm };
// 核心转换逻辑:如果 stationId 是数组,就 join 成字符串
if (Array.isArray(params.stationId)) {
params.stationId = params.stationId.join(",");
}
// 处理时间区间:将数组拆分为两个独立字段
if (Array.isArray(params.observeDate) && params.observeDate.length === 2) {
params.startTime = params.observeDate[0];
params.endTime = params.observeDate[1];
} else {
params.startTime = "";
params.endTime = "";
}
return params;
});
const view = reactive({
deleteIsBatch: true,
getDataListURL: "/dailyweather/weatherdailydata/page",
getDataListIsPage: true,
exportURL: "/dailyweather/weatherdailydata/export",
deleteURL: "/dailyweather/weatherdailydata",
dataForm: computedDataForm
});
const state = reactive({ ...useView(view), ...toRefs(view) });
const getDefaultColumns = () => {
const defaultCols: string[] = [];
columnGroups.forEach((g) => {
g.columns.forEach((c) => {
if (c.default) defaultCols.push(c.prop);
});
});
return defaultCols;
};
const selectedColumns = ref<string[]>(getDefaultColumns());
watch(
selectedColumns,
(newVal) => {
localStorage.setItem("weather_column_pref", JSON.stringify(newVal));
},
{ deep: true }
);
onMounted(() => {
const cache = localStorage.getItem("weather_column_pref");
if (cache) {
selectedColumns.value = JSON.parse(cache);
}
});
const resetColumns = () => {
selectedColumns.value = getDefaultColumns();
};
const addOrUpdateRef = ref();
const addOrUpdateHandle = (id?: number) => {
addOrUpdateRef.value.init(id);
};
const importExcelRef = ref();
const importHandle = () => {
importExcelRef.value.init();
};
</script>
<style scoped>
.column-setting-container {
padding: 5px;
}
.group-title {
font-weight: bold;
font-size: 14px;
margin-bottom: 8px;
color: #409eff;
}
.column-group {
margin-bottom: 10px;
}
:deep(.el-checkbox) {
margin-right: 15px;
margin-bottom: 5px;
}
</style>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import { IObject } from "@/types/interface";
import { defineComponent } from "vue";
import { useRoute, useRouter } from "vue-router";
interface ITip {
title: string;
message: string;
}
export default defineComponent({
setup() {
const route = useRoute();
const router = useRouter();
const { to } = route.query;
const tips: IObject = {
404: {
title: "404",
message: "访问页面不存在"
},
error: {
title: "错误",
message: "访问出错了"
}
};
const tip: ITip = tips[to?.toString() ?? "error"];
const onBack = () => {
router.back();
};
const onToHome = () => {
router.replace("/");
};
return { tip, onBack, onToHome };
}
});
</script>
<template>
<div class="rr-error">
<el-result>
<template v-slot:icon>
<el-icon style="font-size: 64px; color: #f5222d"><warning /></el-icon>
</template>
<template v-slot:title>
<span style="font-size: 48px; font-weight: 800">{{ tip.title }}</span>
</template>
<template v-slot:subTitle>
<span style="font-size: 36px">{{ tip.message }}</span>
</template>
<template v-slot:extra>
<el-space :size="30">
<el-button type="info" plain @click="onBack"> 返回 </el-button>
<el-button type="primary" @click="onToHome"> 主页 </el-button>
</el-space>
</template>
</el-result>
</div>
</template>
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
<template>
<div v-loading="loading">
<iframe class="iframe" :src="url" @load="load"></iframe>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref, watch } from "vue";
import { RouteLocationNormalizedLoaded, useRoute } from "vue-router";
export default defineComponent({
setup() {
const route = useRoute();
const url = ref("about:_blank");
const loading = ref(false);
watch(
() => route,
(vl) => {
if (vl.path === "/iframe") {
setUrl(vl);
}
},
{ deep: true }
);
onMounted(() => {
setUrl(route);
});
const setUrl = (route: RouteLocationNormalizedLoaded): void => {
const { meta, query } = route;
loading.value = true;
if (query.url) {
url.value = query.url as string;
} else {
if (meta && meta.isIframe) {
url.value = meta.url as string;
}
}
};
const load = () => {
loading.value = false;
};
return { url, loading, load };
}
});
</script>
<style lang="less" scoped>
.iframe {
min-height: calc(100vh - 130px);
width: 100%;
border: 0;
}
</style>
@@ -0,0 +1,100 @@
<template>
<el-dialog v-model="visible" :title="!dataForm.id ? '新增' : '修改'" :close-on-click-modal="false" :close-on-press-escape="false">
<el-form :model="dataForm" :rules="rules" ref="dataFormRef" @keyup.enter="dataFormSubmitHandle()" label-width="120px">
<el-form-item prop="beanName" label="bean名称">
<el-input v-model="dataForm.beanName" placeholder="spring bean名称, 如: testTask"></el-input>
</el-form-item>
<el-form-item prop="params" label="参数">
<el-input v-model="dataForm.params" placeholder="参数"></el-input>
</el-form-item>
<el-form-item prop="cronExpression" label="cron表达式">
<el-input v-model="dataForm.cronExpression" placeholder="如: 0 0 12 * * ?"></el-input>
</el-form-item>
<el-form-item prop="remark" label="备注">
<el-input v-model="dataForm.remark" placeholder="备注"></el-input>
</el-form-item>
</el-form>
<template v-slot:footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="dataFormSubmitHandle()">确定</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { reactive, ref } from "vue";
import baseService from "@/service/baseService";
import { ElMessage } from "element-plus";
const emit = defineEmits(["refreshDataList"]);
const visible = ref(false);
const dataFormRef = ref();
const dataForm = reactive({
id: "",
beanName: "",
params: "",
cronExpression: "",
remark: "",
status: 0
});
const rules = ref({
beanName: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
cronExpression: [{ required: true, message: "必填项不能为空", trigger: "blur" }]
});
const init = (id?: number) => {
visible.value = true;
dataForm.id = "";
// 重置表单数据
if (dataFormRef.value) {
dataFormRef.value.resetFields();
}
if (id) {
getInfo(id);
}
};
// 获取信息
const getInfo = (id: number) => {
baseService.get(`/sys/schedule/${id}`).then((res) => {
Object.assign(dataForm, res.data);
});
};
// 表单提交
const dataFormSubmitHandle = () => {
dataFormRef.value.validate((valid: boolean) => {
if (!valid) {
return false;
}
const fn = !dataForm.id ? baseService.post("/sys/schedule", dataForm) : baseService.put("/sys/schedule", dataForm);
fn.then((res) => {
ElMessage.success({
message: "成功",
duration: 500,
onClose: () => {
visible.value = false;
emit("refreshDataList");
}
});
});
});
};
defineExpose({
init
});
</script>
<style lang="less">
.schedule-cron {
&-popover {
width: auto !important;
min-width: 550px !important;
}
}
</style>
@@ -0,0 +1,61 @@
<template>
<el-dialog v-model="visible" title="日志列表" :close-on-click-modal="false" :close-on-press-escape="false" width="75%">
<el-form :inline="true" :model="state.dataForm" @keyup.enter="state.getDataList()">
<el-form-item>
<el-input v-model="state.dataForm.jobId" placeholder="任务ID" clearable></el-input>
</el-form-item>
<el-form-item>
<el-button @click="state.getDataList()">查询</el-button>
</el-form-item>
</el-form>
<el-table v-loading="state.dataListLoading" :data="state.dataList" border @sort-change="state.dataListSortChangeHandle" height="460" style="width: 100%">
<el-table-column prop="jobId" label="任务ID" header-align="center" align="center" width="180"></el-table-column>
<el-table-column prop="beanName" label="bean名称" header-align="center" align="center"></el-table-column>
<el-table-column prop="params" label="参数" header-align="center" align="center"></el-table-column>
<el-table-column prop="status" label="状态" header-align="center" align="center">
<template v-slot="scope">
<el-tag v-if="scope.row.status === 1" size="small">成功</el-tag>
<el-tag v-else type="danger" size="small" @click="showErrorInfo(scope.row.id)" style="cursor: pointer">失败</el-tag>
</template>
</el-table-column>
<el-table-column prop="times" label="耗时(单位: 毫秒)" header-align="center" align="center"></el-table-column>
<el-table-column prop="createDate" label="执行时间" header-align="center" align="center" width="180"></el-table-column>
</el-table>
<el-pagination :current-page="state.page" :page-sizes="[10, 20, 50, 100]" :page-size="state.limit" :total="state.total" layout="total, sizes, prev, pager, next, jumper" @size-change="state.pageSizeChangeHandle" @current-change="state.pageCurrentChangeHandle"> </el-pagination>
</el-dialog>
</template>
<script lang="ts" setup>
import useView from "@/hooks/useView";
import { reactive, ref, toRefs } from "vue";
import baseService from "@/service/baseService";
import { ElMessageBox } from "element-plus";
const visible = ref(false);
const view = reactive({
getDataListURL: "/sys/scheduleLog/page",
getDataListIsPage: true,
dataForm: {
jobId: ""
}
});
const state = reactive({ ...useView(view), ...toRefs(view) });
const init = () => {
visible.value = true;
state.getDataList();
};
// 失败信息
const showErrorInfo = (id: string) => {
baseService.get(`/sys/scheduleLog/${id}`).then((res) => {
ElMessageBox.alert(res.data.error);
});
};
defineExpose({
init
});
</script>
+182
View File
@@ -0,0 +1,182 @@
<template>
<div class="mod-job__schedule">
<el-form :inline="true" :model="state.dataForm" @keyup.enter="state.getDataList()">
<el-form-item>
<el-input v-model="state.dataForm.beanName" placeholder="bean名称" clearable></el-input>
</el-form-item>
<el-form-item>
<el-button @click="state.getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="state.hasPermission('sys:schedule:save')" type="primary" @click="addOrUpdateHandle()">新增</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="state.hasPermission('sys:schedule:delete')" type="danger" @click="state.deleteHandle()">删除</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="state.hasPermission('sys:schedule:pause')" type="danger" @click="pauseHandle()">暂停</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="state.hasPermission('sys:schedule:resume')" type="danger" @click="resumeHandle()">恢复</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="state.hasPermission('sys:schedule:run')" type="danger" @click="runHandle()">执行</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="state.hasPermission('sys:schedule:log')" type="success" @click="logHandle()">日志列表</el-button>
</el-form-item>
</el-form>
<el-table v-loading="state.dataListLoading" :data="state.dataList" border @selection-change="state.dataListSelectionChangeHandle" @sort-change="state.dataListSortChangeHandle" style="width: 100%">
<el-table-column type="selection" header-align="center" align="center" width="50"></el-table-column>
<el-table-column prop="beanName" label="bean名称" header-align="center" align="center"></el-table-column>
<el-table-column prop="params" label="参数" show-overflow-tooltip header-align="center" align="center"></el-table-column>
<el-table-column prop="cronExpression" label="cron表达式" header-align="center" align="center"></el-table-column>
<el-table-column prop="remark" label="备注" show-overflow-tooltip header-align="center" align="center"></el-table-column>
<el-table-column prop="status" label="状态" sortable="custom" header-align="center" align="center">
<template v-slot="scope">
<el-tag v-if="scope.row.status === 1" size="small">正常</el-tag>
<el-tag v-else size="small" type="danger">暂停</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" header-align="center" align="center" width="248">
<template v-slot="scope">
<el-button v-if="state.hasPermission('sys:schedule:update')" type="primary" link @click="addOrUpdateHandle(scope.row.id)">修改</el-button>
<el-button v-if="state.hasPermission('sys:schedule:pause')" type="primary" link @click="pauseHandle(scope.row.id)">暂停</el-button>
<el-button v-if="state.hasPermission('sys:schedule:resume')" type="primary" link @click="resumeHandle(scope.row.id)">恢复</el-button>
<el-button v-if="state.hasPermission('sys:schedule:run')" type="primary" link @click="runHandle(scope.row.id)">执行</el-button>
<el-button v-if="state.hasPermission('sys:schedule:delete')" type="primary" link @click="state.deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination :current-page="state.page" :page-sizes="[10, 20, 50, 100]" :page-size="state.limit" :total="state.total" layout="total, sizes, prev, pager, next, jumper" @size-change="state.pageSizeChangeHandle" @current-change="state.pageCurrentChangeHandle"> </el-pagination>
<!-- 弹窗, 新增 / 修改 -->
<add-or-update ref="addOrUpdateRef" @refreshDataList="state.getDataList"></add-or-update>
<!-- 弹窗, 日志列表 -->
<log ref="logRef"></log>
</div>
</template>
<script lang="ts" setup>
import useView from "@/hooks/useView";
import { reactive, ref, toRefs } from "vue";
import baseService from "@/service/baseService";
import { IObject } from "@/types/interface";
import AddOrUpdate from "./schedule-add-or-update.vue";
import Log from "./schedule-log.vue";
import { ElMessage, ElMessageBox } from "element-plus";
const emit = defineEmits(["refreshDataList"]);
const logRef = ref();
const view = reactive({
getDataListURL: "/sys/schedule/page",
getDataListIsPage: true,
deleteURL: "/sys/schedule",
deleteIsBatch: true,
dataForm: {
beanName: ""
}
});
const state = reactive({ ...useView(view), ...toRefs(view) });
// 暂停
const pauseHandle = (id?: string) => {
if (!id && state.dataListSelections && state.dataListSelections.length <= 0) {
return ElMessage({
message: "请选择操作项",
type: "warning",
duration: 500
});
}
ElMessageBox.confirm("确定进行[暂停]操作?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
})
.then(() => {
baseService.put("/sys/schedule/pause", id ? [id] : state.dataListSelections && state.dataListSelections.map((item: IObject) => item.id)).then((res) => {
ElMessage.success({
message: "成功",
duration: 500,
onClose: () => {
state.getDataList();
}
});
});
})
.catch(() => {
//
});
};
// 恢复
const resumeHandle = (id?: string) => {
if (!id && state.dataListSelections && state.dataListSelections.length <= 0) {
return ElMessage({
message: "请选择操作项",
type: "warning",
duration: 500
});
}
ElMessageBox.confirm("确定进行[恢复]操作?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
})
.then(() => {
baseService.put("/sys/schedule/resume", id ? [id] : state.dataListSelections && state.dataListSelections.map((item: IObject) => item.id)).then((res) => {
ElMessage.success({
message: "成功",
duration: 500,
onClose: () => {
state.getDataList();
}
});
});
})
.catch(() => {
//
});
};
// 执行
const runHandle = (id?: string) => {
if (!id && state.dataListSelections && state.dataListSelections.length <= 0) {
return ElMessage({
message: "请选择操作项",
type: "warning",
duration: 500
});
}
ElMessageBox.confirm("确定进行[执行]操作?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
})
.then(() => {
baseService.put("/sys/schedule/run", id ? [id] : state.dataListSelections && state.dataListSelections.map((item: IObject) => item.id)).then((res) => {
ElMessage.success({
message: "成功",
duration: 500,
onClose: () => {
state.getDataList();
}
});
});
})
.catch(() => {
//
});
};
// 日志列表
const logHandle = () => {
logRef.value.init();
};
const addOrUpdateRef = ref();
const addOrUpdateHandle = (id?: number) => {
addOrUpdateRef.value.init(id);
};
</script>
+276
View File
@@ -0,0 +1,276 @@
<template>
<div class="rr-login">
<div class="rr-login-wrap">
<div class="rr-login-left hidden-sm-and-down">
<p class="rr-login-left-title">气象数据管理系统</p>
</div>
<div class="rr-login-right">
<div class="rr-login-right-main">
<h4 class="rr-login-right-main-title">登录</h4>
<el-form ref="formRef" label-width="80px" :status-icon="true" :model="login" :rules="rules" @keyup.enter="onLogin">
<el-form-item label-width="0" prop="username">
<el-input v-model="login.username" placeholder="用户名" prefix-icon="user" autocomplete="off"></el-input>
</el-form-item>
<el-form-item label-width="0" prop="password">
<el-input placeholder="密码" v-model="login.password" prefix-icon="lock" autocomplete="off" show-password></el-input>
</el-form-item>
<el-form-item label-width="0" prop="captcha">
<el-space class="rr-login-right-main-code">
<el-input v-model="login.captcha" placeholder="验证码" prefix-icon="first-aid-kit"></el-input>
<img style="vertical-align: middle; height: 40px; cursor: pointer" :src="state.captchaUrl" @click="onRefreshCode" alt="" />
</el-space>
</el-form-item>
<el-form-item label-width="0">
<el-button type="primary" size="small" :disabled="state.loading" @click="onLogin" class="rr-login-right-main-btn"> 登录 </el-button>
</el-form-item>
</el-form>
</div>
</div>
</div>
<div class="login-footer">
<p></p>
<p></p>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, reactive, ref } from "vue";
import { CacheToken } from "@/constants/cacheKey";
import baseService from "@/service/baseService";
import { setCache } from "@/utils/cache";
import { ElMessage } from "element-plus";
import { getUuid, isAlphanumeric } from "@/utils/utils";
import app from "@/constants/app";
import SvgIcon from "@/components/base/svg-icon/index";
import { useAppStore } from "@/store";
import { useRouter } from "vue-router";
const store = useAppStore();
const router = useRouter();
const state = reactive({
captchaUrl: "",
loading: false,
year: new Date().getFullYear()
});
const login = reactive({ username: "", password: "", captcha: "", uuid: "" });
onMounted(() => {
//清理数据
store.logout();
getCaptchaUrl();
});
const formRef = ref();
const validateUsername = (rule: any, value: string, callback: (e?: Error) => any): any => {
if (value && !isAlphanumeric(value)) {
return callback(new Error("用户名只能为英文或数字组合"));
}
callback();
};
const rules = ref({
username: [
{ required: true, message: "必填项不能为空", trigger: "blur" },
{ validator: validateUsername, trigger: "blur" }
],
password: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
captcha: [{ required: true, message: "必填项不能为空", trigger: "blur" }]
});
const getCaptchaUrl = () => {
login.uuid = getUuid();
state.captchaUrl = `${app.api}/captcha?uuid=${login.uuid}`;
};
const onRefreshCode = () => {
getCaptchaUrl();
};
const onLogin = () => {
formRef.value.validate((valid: boolean) => {
if (valid) {
state.loading = true;
baseService
.post("/login", login)
.then((res) => {
state.loading = false;
if (res.code === 0) {
setCache(CacheToken, res.data, true);
ElMessage.success("登录成功");
router.push("/");
} else {
ElMessage.error(res.msg);
}
})
.catch(() => {
state.loading = false;
onRefreshCode();
});
}
});
};
</script>
<style lang="less" scoped>
@import url("@/assets/theme/base.less");
.rr-login {
width: 100vw;
height: 100vh;
background: #019ec4;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
@media only screen and (max-width: 992px) {
.rr-login-wrap {
width: 96% !important;
}
.rr-login-right {
width: 100% !important;
}
}
&-wrap {
margin: 0 auto;
width: 1000px;
box-shadow: -4px 5px 10px rgba(0, 0, 0, 0.4);
animation-duration: 1s;
animation-fill-mode: both;
border-radius: 5px;
overflow: hidden;
}
&-left {
justify-content: center;
flex-direction: column;
background-color: @--color-primary;
color: #fff;
float: left;
width: 50%;
&-title {
text-align: center;
color: #fff;
font-weight: 300;
letter-spacing: 2px;
font-size: 32px;
}
}
&-right {
border-left: none;
color: #fff;
background-color: #fff;
width: 50%;
float: left;
&-main {
margin: 0 auto;
width: 65%;
&-title {
color: #333;
margin-bottom: 40px;
font-weight: 500;
font-size: 24px;
text-align: center;
letter-spacing: 4px;
}
&-lang .iconfont {
font-size: 20px;
color: #606266;
font-weight: 800;
width: 20px;
height: 20px;
}
.el-input__inner {
border-width: 0;
border-radius: 0;
border-bottom: 1px solid #dcdfe6;
}
&-code {
width: 100%;
.el-space__item:first-child {
flex: 1;
}
}
&-btn {
width: 100%;
height: 45px;
font-size: 18px !important;
letter-spacing: 2px;
font-weight: 300 !important;
cursor: pointer;
margin-top: 30px;
font-family: neo, sans-serif;
transition: 0.25s;
}
}
}
.login-footer {
text-align: center;
position: absolute;
bottom: 0;
padding: 20px;
color: rgba(255, 255, 255, 0.6);
p {
margin: 10px 0;
}
a {
padding: 0 5px;
color: rgba(255, 255, 255, 0.6);
&:focus,
&:hover {
color: #fff;
}
}
}
&-left,
&-right {
position: relative;
min-height: 500px;
align-items: center;
display: flex;
}
@keyframes animate-down {
0%,
60%,
75%,
90%,
to {
animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
}
0% {
opacity: 0;
transform: translate3d(0, -3000px, 0);
}
60% {
opacity: 1;
transform: translate3d(0, 25px, 0);
}
75% {
transform: translate3d(0, -10px, 0);
}
90% {
transform: translate3d(0, 5px, 0);
}
to {
transform: none;
}
}
.animate-down {
animation-name: animate-down;
}
}
</style>
@@ -0,0 +1,200 @@
<template>
<el-dialog v-model="visible" title="云存储配置" :close-on-click-modal="false" :close-on-press-escape="false">
<el-form :model="dataForm" :rules="rules" ref="dataFormRef" @keyup.enter="dataFormSubmitHandle()" label-width="140px">
<el-form-item label="类型">
<el-radio-group v-model="dataForm.type">
<el-radio :label="1">七牛</el-radio>
<el-radio :label="2">阿里云</el-radio>
<el-radio :label="3">腾讯云</el-radio>
<el-radio :label="4">自定义</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="dataForm.type === 1">
<el-form-item prop="qiniuDomain" label="域名">
<el-input v-model="dataForm.qiniuDomain" placeholder="七牛绑定的域名"></el-input>
</el-form-item>
<el-form-item prop="qiniuPrefix" label="路径前缀">
<el-input v-model="dataForm.qiniuPrefix" placeholder="不设置默认为空"></el-input>
</el-form-item>
<el-form-item prop="qiniuAccessKey" label="AccessKey">
<el-input v-model="dataForm.qiniuAccessKey" placeholder="七牛AccessKey"></el-input>
</el-form-item>
<el-form-item prop="qiniuSecretKey" label="SecretKey">
<el-input v-model="dataForm.qiniuSecretKey" placeholder="七牛SecretKey"></el-input>
</el-form-item>
<el-form-item prop="qiniuBucketName" label="空间名">
<el-input v-model="dataForm.qiniuBucketName" placeholder="七牛存储空间名"></el-input>
</el-form-item>
</template>
<template v-else-if="dataForm.type === 2">
<el-form-item prop="aliyunDomain" label="域名">
<el-input v-model="dataForm.aliyunDomain" placeholder="阿里云绑定的域名,如:http://cdn.123.io"></el-input>
</el-form-item>
<el-form-item prop="aliyunPrefix" label="路径前缀">
<el-input v-model="dataForm.aliyunPrefix" placeholder="不设置默认为空"></el-input>
</el-form-item>
<el-form-item prop="aliyunEndPoint" label="EndPoint">
<el-input v-model="dataForm.aliyunEndPoint" placeholder="阿里云EndPoint"></el-input>
</el-form-item>
<el-form-item prop="aliyunAccessKeyId" label="AccessKeyId">
<el-input v-model="dataForm.aliyunAccessKeyId" placeholder="阿里云AccessKeyId"></el-input>
</el-form-item>
<el-form-item prop="aliyunAccessKeySecret" label="AccessKeySecret">
<el-input v-model="dataForm.aliyunAccessKeySecret" placeholder="阿里云AccessKeySecret"></el-input>
</el-form-item>
<el-form-item prop="aliyunBucketName" label="BucketName">
<el-input v-model="dataForm.aliyunBucketName" placeholder="阿里云BucketName"></el-input>
</el-form-item>
</template>
<template v-else-if="dataForm.type === 3">
<el-form-item prop="qcloudDomain" label="域名">
<el-input v-model="dataForm.qcloudDomain" placeholder="腾讯云绑定的域名"></el-input>
</el-form-item>
<el-form-item prop="qcloudPrefix" label="路径前缀">
<el-input v-model="dataForm.qcloudPrefix" placeholder="不设置默认为空"></el-input>
</el-form-item>
<el-form-item prop="qcloudAppId" label="AppId">
<el-input v-model="dataForm.qcloudAppId" placeholder="腾讯云AppId"></el-input>
</el-form-item>
<el-form-item prop="qcloudSecretId" label="SecretId">
<el-input v-model="dataForm.qcloudSecretId" placeholder="腾讯云SecretId"></el-input>
</el-form-item>
<el-form-item prop="qcloudSecretKey" label="SecretKey">
<el-input v-model="dataForm.qcloudSecretKey" placeholder="腾讯云SecretKey"></el-input>
</el-form-item>
<el-form-item prop="qcloudBucketName" label="BucketName">
<el-input v-model="dataForm.qcloudBucketName" placeholder="腾讯云BucketName"></el-input>
</el-form-item>
<el-form-item prop="qcloudRegion" label="所属地区">
<el-select v-model="dataForm.qcloudRegion" clearable placeholder="请选择" class="w-percent-100">
<el-option value="ap-beijing-1" label="北京一区(华北)"></el-option>
<el-option value="ap-beijing" label="北京"></el-option>
<el-option value="ap-shanghai" label="上海(华东)"></el-option>
<el-option value="ap-guangzhou" label="广州(华南)"></el-option>
<el-option value="ap-chengdu" label="成都(西南)"></el-option>
<el-option value="ap-chongqing" label="重庆"></el-option>
<el-option value="ap-singapore" label="新加坡"></el-option>
<el-option value="ap-hongkong" label="香港"></el-option>
<el-option value="na-toronto" label="多伦多"></el-option>
<el-option value="eu-frankfurt" label="法兰克福"></el-option>
</el-select>
</el-form-item>
</template>
<template v-else-if="dataForm.type === 4">
<el-form-item prop="aliyunDomain" label="域名">
<el-input v-model="dataForm.aliyunDomain" placeholder="阿里云绑定的域名,如:http://cdn.123.io"></el-input>
</el-form-item>
<el-form-item prop="aliyunPrefix" label="路径前缀">
<el-input v-model="dataForm.aliyunPrefix" placeholder="不设置默认为空"></el-input>
</el-form-item>
<el-form-item prop="aliyunEndPoint" label="EndPoint">
<el-input v-model="dataForm.aliyunEndPoint" placeholder="阿里云EndPoint"></el-input>
</el-form-item>
<el-form-item prop="aliyunAccessKeyId" label="AccessKeyId">
<el-input v-model="dataForm.aliyunAccessKeyId" placeholder="阿里云AccessKeyId"></el-input>
</el-form-item>
<el-form-item prop="aliyunAccessKeySecret" label="AccessKeySecret">
<el-input v-model="dataForm.aliyunAccessKeySecret" placeholder="阿里云AccessKeySecret"></el-input>
</el-form-item>
<el-form-item prop="aliyunBucketName" label="BucketName">
<el-input v-model="dataForm.aliyunBucketName" placeholder="阿里云BucketName"></el-input>
</el-form-item>
</template>
</el-form>
<template v-slot:footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="dataFormSubmitHandle()">确定</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { reactive, ref } from "vue";
import baseService from "@/service/baseService";
import { ElMessage } from "element-plus";
const emit = defineEmits(["refreshDataList"]);
const visible = ref(false);
const dataFormRef = ref();
const dataForm = reactive({
type: 0,
qiniuDomain: "",
qiniuPrefix: "",
qiniuAccessKey: "",
qiniuSecretKey: "",
qiniuBucketName: "",
aliyunDomain: "",
aliyunPrefix: "",
aliyunEndPoint: "",
aliyunAccessKeyId: "",
aliyunAccessKeySecret: "",
aliyunBucketName: "",
qcloudDomain: "",
qcloudPrefix: "",
qcloudAppId: 0,
qcloudSecretId: "",
qcloudSecretKey: "",
qcloudBucketName: "",
qcloudRegion: ""
});
const rules = ref({
qiniuDomain: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
qiniuAccessKey: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
qiniuSecretKey: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
qiniuBucketName: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
aliyunDomain: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
aliyunEndPoint: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
aliyunAccessKeyId: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
aliyunAccessKeySecret: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
aliyunBucketName: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
qcloudDomain: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
qcloudAppId: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
qcloudSecretId: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
qcloudSecretKey: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
qcloudBucketName: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
qcloudRegion: [{ required: true, message: "必填项不能为空", trigger: "blur" }]
});
const init = () => {
visible.value = true;
// 重置表单数据
if (dataFormRef.value) {
dataFormRef.value.resetFields();
}
getInfo();
};
// 获取信息
const getInfo = () => {
baseService.get("/sys/oss/info").then((res) => {
Object.assign(dataForm, res.data);
});
};
// 表单提交
const dataFormSubmitHandle = () => {
dataFormRef.value.validate((valid: boolean) => {
if (!valid) {
return false;
}
baseService.post("/sys/oss", dataForm).then((res) => {
ElMessage.success({
message: "成功",
duration: 500,
onClose: () => {
visible.value = false;
emit("refreshDataList");
}
});
});
});
};
defineExpose({
init
});
</script>
@@ -0,0 +1,65 @@
<template>
<el-dialog v-model="visible" title="上传文件" :close-on-click-modal="false" :close-on-press-escape="false">
<el-upload :action="url" :file-list="fileList" drag multiple :before-upload="beforeUploadHandle" :on-success="successHandle" class="text-center">
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">将文件拖到此处或点击上传</div>
<template v-slot:tip>
<div class="el-upload__tip">只支持jpgpnggif格式文件</div>
</template>
</el-upload>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { getToken } from "@/utils/cache";
import { IObject } from "@/types/interface";
import app from "@/constants/app";
import { ElMessage } from "element-plus";
const emit = defineEmits(["refreshDataList"]);
const visible = ref(false);
const url = ref("");
const num = ref(0);
const fileList = ref<IObject[]>();
const init = () => {
visible.value = true;
url.value = `${app.api}/sys/oss/upload?token=${getToken()}`;
num.value = 0;
fileList.value = [];
};
// 上传之前
const beforeUploadHandle = (file: IObject) => {
if (file.type !== "image/jpg" && file.type !== "image/jpeg" && file.type !== "image/png" && file.type !== "image/gif") {
ElMessage.error("只支持jpg、png、gif格式文件!");
return false;
}
num.value++;
};
// 上传成功
const successHandle = (res: IObject, file: IObject, list: IObject[]) => {
if (res.code !== 0) {
return ElMessage.error(res.msg);
}
fileList.value = list;
num.value--;
if (num.value === 0) {
ElMessage.success({
message: "成功",
duration: 500,
onClose: () => {
visible.value = false;
emit("refreshDataList");
}
});
}
};
defineExpose({
init
});
</script>
+68
View File
@@ -0,0 +1,68 @@
<template>
<div class="mod-oss__oss">
<el-form :inline="true" :model="state.dataForm">
<el-form-item>
<el-button type="primary" @click="configHandle()">云存储配置</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="uploadHandle()">上传文件</el-button>
</el-form-item>
<el-form-item>
<el-button type="danger" @click="state.deleteHandle()">删除</el-button>
</el-form-item>
</el-form>
<el-table v-loading="state.dataListLoading" :data="state.dataList" border @selection-change="state.dataListSelectionChangeHandle" @sort-change="state.dataListSortChangeHandle" style="width: 100%">
<el-table-column type="selection" header-align="center" align="center" width="50"></el-table-column>
<el-table-column prop="url" label="URL地址" header-align="center" align="center"></el-table-column>
<el-table-column prop="createDate" label="创建时间" sortable="custom" header-align="center" align="center" width="180"></el-table-column>
<el-table-column label="操作" fixed="right" header-align="center" align="center" width="150">
<template v-slot="scope">
<el-button type="primary" link @click="state.deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination :current-page="state.page" :page-sizes="[10, 20, 50, 100]" :page-size="state.limit" :total="state.total" layout="total, sizes, prev, pager, next, jumper" @size-change="state.pageSizeChangeHandle" @current-change="state.pageCurrentChangeHandle"> </el-pagination>
<!-- 弹窗, 云存储配置 -->
<config v-if="state.configVisible" ref="configRef"></config>
<!-- 弹窗, 上传文件 -->
<upload v-if="state.uploadVisible" ref="uploadRef" @refreshDataList="state.getDataList"></upload>
</div>
</template>
<script lang="ts" setup>
import useView from "@/hooks/useView";
import { nextTick, reactive, ref, toRefs } from "vue";
import Config from "./oss-config.vue";
import Upload from "./oss-upload.vue";
const configRef = ref();
const uploadRef = ref();
const view = reactive({
getDataListURL: "/sys/oss/page",
getDataListIsPage: true,
deleteURL: "/sys/oss",
deleteIsBatch: true,
dataForm: {},
configVisible: false,
uploadVisible: false
});
const state = reactive({ ...useView(view), ...toRefs(view) });
// 云存储配置
const configHandle = () => {
state.configVisible = true;
nextTick(() => {
configRef.value.init();
});
};
// 上传文件
const uploadHandle = () => {
state.uploadVisible = true;
nextTick(() => {
uploadRef.value.init();
});
};
</script>
+108
View File
@@ -0,0 +1,108 @@
<template>
<div class="region-viewer" style="padding: 20px;">
<el-card shadow="never">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-weight: bold;">地区数据</span>
<el-tag type="info">总数据量: {{ rawListCount }} </el-tag>
</div>
</template>
<!-- 树形表格 -->
<el-table
v-if="treeData.length > 0"
:data="treeData"
style="width: 100%; max-height: 600px; overflow: auto;"
row-key="id"
border
:tree-props="{ children: 'children' }"
>
<el-table-column prop="id" label="编号 (邮编)" width="220" />
<el-table-column prop="name" label="地名" />
</el-table>
<el-empty v-else :description="loadingStatus" />
</el-card>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, computed } from 'vue';
const rawList = ref<any[]>([]);
const treeData = ref<any[]>([]);
const loadingStatus = ref('正在解析数据...');
const rawListCount = computed(() => rawList.value.length);
// 优化后的构建树函数
const buildTreeMap = (list: any[]) => {
const map = new Map<number, any[]>();
// 构建 parentId -> children 的 Map
list.forEach(item => {
const pid = item.parentid;
if (!map.has(pid)) map.set(pid, []);
map.get(pid)!.push({ ...item });
});
// 递归构建树
const createTree = (pId: number): any[] => {
const children = map.get(pId) || [];
children.forEach(child => {
const sub = createTree(child.id);
if (sub.length > 0) child.children = sub;
});
return children;
};
// 自动探测根节点
const rootParentId = Math.min(...list.map(r => r.parentid));
return createTree(rootParentId);
};
const initRegionTree = async () => {
try {
const res = await fetch("/region.csv");
if (!res.ok) throw new Error("无法读取数据,请检查文件");
const text = await res.text();
const lines = text.split("\n").map(l => l.trim()).filter(l => l);
if (lines.length < 2) throw new Error("内容为空或格式错误");
const headers = lines[0].split(",").map(h => h.trim().toLowerCase());
rawList.value = lines.slice(1).map(line => {
const cols = line.split(",").map(c => c.trim());
const obj: any = {};
headers.forEach((h, i) => {
obj[h] = h === 'name' ? cols[i] : Number(cols[i]);
});
return obj;
});
// 构建树
const tree = buildTreeMap(rawList.value);
if (tree.length === 0) {
loadingStatus.value = '构建失败:根节点下无数据';
} else {
treeData.value = tree;
loadingStatus.value = '加载完成';
}
} catch (error: any) {
loadingStatus.value = `错误: ${error.message}`;
console.error(error);
}
};
onMounted(initRegionTree);
</script>
<style scoped>
.region-viewer .el-table {
/* 虚拟滚动样式 */
max-height: 600px;
overflow-y: auto;
}
</style>
@@ -0,0 +1,289 @@
<template>
<el-dialog
v-model="visible"
:title="!dataForm.id ? '新增气象站' : '修改气象站'"
width="720px"
:close-on-click-modal="false"
>
<el-form ref="dataFormRef" :model="dataForm" :rules="rules" label-width="110px">
<!-- ... 前面经纬度等字段保持不变 ... -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="气象站号" prop="stationCode">
<el-input v-model="dataForm.stationCode" placeholder="例如 57972"/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="站点名称" prop="stationName">
<el-input v-model="dataForm.stationName"/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="测站级别" prop="stationLevel">
<el-select v-model="dataForm.stationLevel" placeholder="选择级别">
<el-option label="国家基准气候站(11" :value="11"/>
<el-option label="国家基本气象站(12" :value="12"/>
<el-option label="国家一般气象站(13" :value="13"/>
<el-option label="区域气象站(14" :value="14"/>
<el-option label="其他气象站(15" :value="15"/>
<el-option label="国家站(16" :value="16"/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="海拔高度(m)" prop="elevation">
<el-input-number
v-model="dataForm.elevation"
:precision="1"
:step="1"
style="width:100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="纬度" prop="latitude">
<el-input-number
v-model="dataForm.latitude"
:precision="6"
:step="0.0001"
:min="-90"
:max="90"
style="width:100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="经度" prop="longitude">
<el-input-number
v-model="dataForm.longitude"
:precision="6"
:step="0.0001"
:min="-180"
:max="180"
style="width:100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-divider>行政区域</el-divider>
<el-row :gutter="10">
<el-col :span="8">
<el-form-item label="省份" prop="provinceName">
<el-select v-model="dataForm.provinceName" placeholder="请选择省" clearable @change="onProvinceChange">
<el-option
v-for="item in provinceOptions"
:key="item.id"
:label="item.name"
:value="String(item.id)"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="地市" prop="cityName">
<el-select v-model="dataForm.cityName" placeholder="请选择市" clearable @change="onCityChange">
<el-option
v-for="item in cityOptions"
:key="item.id"
:label="item.name"
:value="String(item.id)"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="区县" prop="countyName">
<el-select v-model="dataForm.countyName" placeholder="请选择区" clearable>
<el-option
v-for="item in countyOptions"
:key="item.id"
:label="item.name"
:value="String(item.id)"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24">
<el-form-item label="乡镇名称">
<el-input v-model="dataForm.townName" placeholder="具体乡镇"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-divider>管理部门</el-divider>
<el-row>
<el-col :span="24">
<el-form-item label="选择部门" prop="deptId">
<el-tree :data="deptList" :props="{ label: 'name', children: 'children' }" node-key="id" ref="deptTreeRef" accordion highlight-current @node-click="onDeptClick" style="max-height: 240px; overflow-y: auto; width: 100%; border: 1px solid var(--border); border-radius: 6px; padding: 8px"></el-tree>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="visible=false">取消</el-button>
<el-button type="primary" @click="dataFormSubmitHandle">确定</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { reactive, ref, computed, nextTick } from "vue";
import baseService from "@/service/baseService";
import { ElMessage } from "element-plus";
// 接收父页面传来的 CSV 原始数组和 Map 索引
const props = defineProps<{
regionsData: any[]
regionMap: Map<number, any>
}>();
const emit = defineEmits(["refreshDataList"]);
const visible = ref(false);
const dataFormRef = ref();
const deptTreeRef = ref();
const deptList = ref<any[]>([]);
const dataForm = reactive({
id: "",
stationCode: "",
stationName: "",
stationLevel: "",
latitude: null,
longitude: null,
elevation: null,
provinceName: "", // 存放省级 ID 字符串
cityName: "", // 存放市级 ID 字符串
countyName: "", // 存放区级 ID 字符串
townName: "",
deptId: ""
});
const rules = {
stationCode: [{ required: true, message: "请输入气象站号", trigger: "blur" }],
stationName: [{ required: true, message: "请输入站点名称", trigger: "blur" }],
stationLevel: [{ required: true, message: "请选择测站级别", trigger: "change" }]
};
// ----------------- 下拉框联动逻辑 -----------------
// 1. 获取省级列表 (根据你说的:parentId 为 1 是省级)
const provinceOptions = computed(() => {
const list = props.regionsData.filter(r => Number(r.parentId) === 1);
console.log("省级加载检查,匹配数量:", list.length);
return list;
});
// 市、区列表:动态变化
const cityOptions = ref<any[]>([]);
const countyOptions = ref<any[]>([]);
// 切换省:清空市/区,加载新市列表
const onProvinceChange = (val: string) => {
dataForm.cityName = "";
dataForm.countyName = "";
cityOptions.value = [];
countyOptions.value = [];
if (val) {
cityOptions.value = props.regionsData.filter(r => Number(r.parentId) === Number(val));
}
};
// 切换市:清空区,加载新区列表
const onCityChange = (val: string) => {
dataForm.countyName = "";
countyOptions.value = [];
if (val) {
countyOptions.value = props.regionsData.filter(r => Number(r.parentId) === Number(val));
}
};
function getDeptList() {
return baseService.get("/sys/dept/list").then((res) => {
deptList.value = res.data || [];
});
}
function onDeptClick(data: any) {
dataForm.deptId = data.id;
}
// ----------------- 弹窗操作 -----------------
const init = (id?: number) => {
visible.value = true;
// 重置表单和选项
cityOptions.value = [];
countyOptions.value = [];
nextTick(() => {
if (dataFormRef.value) dataFormRef.value.resetFields();
Object.assign(dataForm, {
id: "",
provinceName: "",
cityName: "",
countyName: "",
townName: "",
deptId: ""
});
getDeptList().then(() => {
if (id) {
getInfo(id);
}
});
});
};
// 编辑回显
const getInfo = async (id: number) => {
const res = await baseService.get(`/station/weatherstation/${id}`);
const data = res.data;
// 1. 基础数据赋值
Object.assign(dataForm, data);
// 2. 关键:手动触发联动列表加载,否则 Select 只有 ID 没有数据源,无法显示名字
if (data.provinceName) {
cityOptions.value = props.regionsData.filter(r => Number(r.parentId) === Number(data.provinceName));
}
if (data.cityName) {
countyOptions.value = props.regionsData.filter(r => Number(r.parentId) === Number(data.cityName));
}
// 3. 回显选择的管理部门
if (data.deptId) {
nextTick(() => {
deptTreeRef.value?.setCurrentKey(data.deptId);
});
}
};
// 提交
const dataFormSubmitHandle = () => {
dataFormRef.value.validate(valid => {
if (!valid) return;
const request = !dataForm.id
? baseService.post("/station/weatherstation", dataForm)
: baseService.put("/station/weatherstation", dataForm);
request.then(() => {
ElMessage.success("操作成功");
visible.value = false;
emit("refreshDataList");
});
});
};
defineExpose({ init });
</script>
@@ -0,0 +1,145 @@
<template>
<div class="mod-station__weatherstation">
<el-form :inline="true" :model="state.dataForm" @keyup.enter="state.getDataList()">
<el-form-item>
<el-input v-model="state.dataForm.stationName" placeholder="区站名称" clearable></el-input>
</el-form-item>
<el-form-item>
<el-input v-model="state.dataForm.stationCode" placeholder="区站编号" clearable></el-input>
</el-form-item>
<el-form-item>
<el-button @click="state.getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="state.hasPermission('station:weatherstation:save')" type="primary" @click="addOrUpdateHandle()">新增</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="state.hasPermission('station:weatherstation:delete')" type="danger" @click="state.deleteHandle()">删除</el-button>
</el-form-item>
</el-form>
<el-table v-loading="state.dataListLoading" :data="state.dataList" border @selection-change="state.dataListSelectionChangeHandle" style="width: 100%">
<el-table-column type="selection" header-align="center" align="center" width="50"></el-table-column>
<el-table-column prop="id" label="编号" header-align="center" align="center"></el-table-column>
<el-table-column prop="stationCode" label="区站号" header-align="center" align="center"></el-table-column>
<el-table-column prop="stationName" label="站点" header-align="center" align="center"></el-table-column>
<el-table-column label="归属部门" header-align="center" align="center">
<template #default="{ row }">{{ deptMap.get(Number(row.deptId)) || "" }}</template>
</el-table-column>
<el-table-column prop="stationLevel" label="测站级别" header-align="center" align="center">
<template v-slot="row">
{{ state.getDictLabel("station_level", row.row.stationLevel) }}
</template>
</el-table-column>
<el-table-column prop="latitude" label="纬度" header-align="center" align="center"></el-table-column>
<el-table-column prop="longitude" label="经度" header-align="center" align="center"></el-table-column>
<el-table-column prop="elevation" label="测站高度(米)" header-align="center" align="center"></el-table-column>
<!-- 使用 map 转换 id -> 中文 -->
<el-table-column label="省名" header-align="center" align="center">
<template #default="{ row }">{{ regionMap.get(Number(row.provinceName))?.name || "" }}</template>
</el-table-column>
<el-table-column label="地市名" header-align="center" align="center">
<template #default="{ row }">{{ regionMap.get(Number(row.cityName))?.name || "" }}</template>
</el-table-column>
<el-table-column label="区县名" header-align="center" align="center">
<template #default="{ row }">{{ regionMap.get(Number(row.countyName))?.name || "" }}</template>
</el-table-column>
<el-table-column prop="townName" label="乡镇名" header-align="center" align="center"></el-table-column>
<el-table-column prop="createDate" label="创建时间" header-align="center" align="center"></el-table-column>
<el-table-column label="操作" fixed="right" header-align="center" align="center" width="150">
<template v-slot="scope">
<el-button v-if="state.hasPermission('station:weatherstation:update')" type="primary" link @click="addOrUpdateHandle(scope.row.id)">修改</el-button>
<el-button v-if="state.hasPermission('station:weatherstation:delete')" type="primary" link @click="state.deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination :current-page="state.page" :page-sizes="[10, 20, 50, 100]" :page-size="state.limit" :total="state.total" layout="total, sizes, prev, pager, next, jumper" @size-change="state.pageSizeChangeHandle" @current-change="state.pageCurrentChangeHandle"> </el-pagination>
<!-- 弹窗, 新增 / 修改 -->
<add-or-update ref="addOrUpdateRef" :regions-data="regionsData" :region-map="regionMap" @refreshDataList="state.getDataList"> </add-or-update>
</div>
</template>
<script lang="ts" setup>
import useView from "@/hooks/useView";
import { reactive, ref, toRefs, onMounted } from "vue";
import AddOrUpdate from "./weatherstation-add-or-update.vue";
import baseService from "@/service/baseService";
// 表格 state
const view = reactive({
deleteIsBatch: true,
getDataListURL: "/station/weatherstation/page",
getDataListIsPage: true,
exportURL: "/station/weatherstation/export",
deleteURL: "/station/weatherstation",
dataForm: {
id: "",
stationName: "",
stationCode: ""
}
});
const state = reactive({
...useView(view),
...toRefs(view)
});
// 弹窗 ref
const addOrUpdateRef = ref();
const addOrUpdateHandle = (id?: number) => {
addOrUpdateRef.value.init(id);
};
// const batchInsert = ref();
// const batchInsert() = >{
//
// }
// ---------------- CSV 数据加载 ----------------
const regionsData = ref<any[]>([]);
const regionMap = reactive(new Map<number, any>());
// ---------------- 部门数据加载 ----------------
const deptMap = reactive(new Map<number, string>());
async function loadDepts() {
try {
const res = await baseService.get("/sys/dept/list");
const flat = (list: any[]) => {
list.forEach((d) => {
deptMap.set(Number(d.id), d.name);
if (d.children) flat(d.children);
});
};
flat(res.data || []);
} catch {
// ignore
}
}
onMounted(async () => {
loadDepts();
const res = await fetch("/region.csv");
const text = await res.text();
const parseCSV = (csvText: string) => {
const lines = csvText.split("\n").filter((l) => l.trim());
const headers = lines[0].split(",");
return lines.slice(1).map((line) => {
const cols = line.split(",");
const obj: any = {};
headers.forEach((h, i) => (obj[h.trim()] = cols[i].trim()));
return obj;
});
};
const data = parseCSV(text);
data.forEach((r) => {
const id = Number(r.id);
const parentId = Number(r.parentId);
const level = Number(r.level);
const name = r.name;
regionMap.set(id, { id, parentId, level, name });
regionsData.value.push({ id, parentId, level, name });
});
});
</script>
@@ -0,0 +1,146 @@
<template>
<el-dialog v-model="visible" :title="!dataForm.id ? '新增' : '修改'" :close-on-click-modal="false" :close-on-press-escape="false">
<el-form :model="dataForm" :rules="rules" ref="dataFormRef" @keyup.enter="dataFormSubmitHandle()" label-width="120px">
<el-form-item prop="name" label="名称">
<el-input v-model="dataForm.name" placeholder="名称"></el-input>
</el-form-item>
<el-form-item prop="parentName" label="上级部门" class="dept-list">
<el-popover :width="400" ref="deptListPopover" placement="bottom-start" trigger="click" popper-class="popover-pop">
<template v-slot:reference>
<el-input v-model="dataForm.parentName" :readonly="true" placeholder="上级部门">
<template v-slot:suffix>
<el-icon v-if="user.superAdmin === 1 && dataForm.pid !== '0'" @click.stop="deptListTreeSetDefaultHandle()" class="el-input__icon"><circle-close /></el-icon>
</template> </el-input
></template>
<div class="popover-pop-body"><el-tree :data="deptList" :props="{ label: 'name', children: 'children' }" node-key="id" ref="deptListTree" :highlight-current="true" :expand-on-click-node="false" accordion @current-change="deptListTreeCurrentChangeHandle"> </el-tree></div>
</el-popover>
</el-form-item>
<el-form-item prop="sort" label="排序">
<el-input-number v-model="dataForm.sort" controls-position="right" :min="0" label="排序"></el-input-number>
</el-form-item>
</el-form>
<template v-slot:footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="dataFormSubmitHandle()">确定</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from "vue";
import baseService from "@/service/baseService";
import { IObject } from "@/types/interface";
import { useAppStore } from "@/store";
import { ElMessage } from "element-plus";
const store = useAppStore();
const emit = defineEmits(["refreshDataList"]);
const visible = ref(false);
const dataFormRef = ref();
const deptList = ref([]);
const deptListPopover = ref();
const deptListTree = ref();
const dataForm = reactive({
id: "",
name: "",
pid: "",
parentName: "",
sort: 0
});
const user = computed(() => store.state.user);
const rules = ref({
name: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
parentName: [{ required: true, message: "必填项不能为空", trigger: "change" }]
});
const init = (id?: number) => {
visible.value = true;
dataForm.id = "";
// 重置表单数据
if (dataFormRef.value) {
dataFormRef.value.resetFields();
}
getDeptList().then(() => {
if (id) {
getInfo(id);
} else if (store.state.user.superAdmin === 1) {
deptListTreeSetDefaultHandle();
}
});
};
// 获取部门列表
const getDeptList = () => {
return baseService.get("/sys/dept/list").then((res) => {
if (res.code !== 0) {
return ElMessage.error(res.msg);
}
deptList.value = res.data;
});
};
// 获取信息
const getInfo = (id: number) => {
baseService.get(`/sys/dept/${id}`).then((res) => {
Object.assign(dataForm, res.data);
if (dataForm.pid === "0") {
return deptListTreeSetDefaultHandle();
}
deptListTree.value.setCurrentKey(dataForm.pid);
});
};
// 上级部门树, 设置默认值
const deptListTreeSetDefaultHandle = () => {
dataForm.pid = "0";
dataForm.parentName = "一级部门";
};
// 上级部门树, 选中
const deptListTreeCurrentChangeHandle = (data: IObject) => {
dataForm.pid = data.id;
dataForm.parentName = data.name;
deptListPopover.value.hide();
};
// 表单提交
const dataFormSubmitHandle = () => {
dataFormRef.value.validate((valid: boolean) => {
if (!valid) {
return false;
}
(!dataForm.id ? baseService.post : baseService.put)("/sys/dept", dataForm).then((res) => {
ElMessage.success({
message: "成功",
duration: 500,
onClose: () => {
visible.value = false;
emit("refreshDataList");
}
});
});
});
};
defineExpose({
init
});
</script>
<style lang="less" scoped>
.mod-sys__dept {
.dept-list {
.el-input__inner,
.el-input__suffix {
cursor: pointer;
}
}
}
</style>
+40
View File
@@ -0,0 +1,40 @@
<template>
<div class="mod-sys__dept">
<el-form :inline="true" :model="state.dataForm" @keyup.enter="state.getDataList()">
<el-form-item>
<el-button v-if="state.hasPermission('sys:dept:save')" type="primary" @click="addOrUpdateHandle()">新增</el-button>
</el-form-item>
</el-form>
<el-table v-loading="state.dataListLoading" :data="state.dataList" row-key="id" border style="width: 100%">
<el-table-column prop="name" label="名称" header-align="center" min-width="150"></el-table-column>
<el-table-column prop="parentName" label="上级部门" header-align="center" align="center"></el-table-column>
<el-table-column prop="sort" label="排序" header-align="center" align="center" width="80"></el-table-column>
<el-table-column label="操作" fixed="right" header-align="center" align="center" width="150">
<template v-slot="scope">
<el-button v-if="state.hasPermission('sys:dept:update')" type="primary" link @click="addOrUpdateHandle(scope.row.id)">修改</el-button>
<el-button v-if="state.hasPermission('sys:dept:delete')" type="primary" link @click="state.deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 弹窗, 新增 / 修改 -->
<add-or-update ref="addOrUpdateRef" @refreshDataList="state.getDataList"></add-or-update>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref, toRefs } from "vue";
import AddOrUpdate from "./dept-add-or-update.vue";
import useView from "@/hooks/useView";
const view = reactive({
getDataListURL: "/sys/dept/list",
deleteURL: "/sys/dept"
});
const state = reactive({ ...useView(view), ...toRefs(view) });
const addOrUpdateRef = ref();
const addOrUpdateHandle = (id?: number) => {
addOrUpdateRef.value.init(id);
};
</script>
@@ -0,0 +1,92 @@
<template>
<el-dialog v-model="visible" :title="!dataForm.id ? '新增' : '修改'" :close-on-click-modal="false" :close-on-press-escape="false">
<el-form :model="dataForm" :rules="rules" ref="dataFormRef" @keyup.enter="dataFormSubmitHandle()" label-width="120px">
<el-form-item prop="dictValue" label="字典值">
<el-input v-model="dataForm.dictValue" placeholder="字典值"></el-input>
</el-form-item>
<el-form-item prop="dictLabel" label="字典标签">
<el-input v-model="dataForm.dictLabel" placeholder="字典标签"></el-input>
</el-form-item>
<el-form-item prop="sort" label="排序">
<el-input-number v-model="dataForm.sort" controls-position="right" :min="0" label="排序"></el-input-number>
</el-form-item>
<el-form-item prop="remark" label="备注">
<el-input v-model="dataForm.remark" placeholder="备注"></el-input>
</el-form-item>
</el-form>
<template v-slot:footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="dataFormSubmitHandle()">确定</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { reactive, ref } from "vue";
import baseService from "@/service/baseService";
import { ElMessage } from "element-plus";
const emit = defineEmits(["refreshDataList"]);
const visible = ref(false);
const dataFormRef = ref();
const dataForm = reactive({
id: "",
dictTypeId: "",
dictLabel: "",
dictValue: "",
sort: 0,
remark: ""
});
const rules = ref({
dictLabel: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
dictValue: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
sort: [{ required: true, message: "必填项不能为空", trigger: "blur" }]
});
const init = (id?: number) => {
visible.value = true;
dataForm.id = "";
// 重置表单数据
if (dataFormRef.value) {
dataFormRef.value.resetFields();
}
if (id) {
getInfo(id);
}
};
// 获取信息
const getInfo = (id: number) => {
baseService.get(`/sys/dict/data/${id}`).then((res) => {
Object.assign(dataForm, res.data);
});
};
// 表单提交
const dataFormSubmitHandle = () => {
dataFormRef.value.validate((valid: boolean) => {
if (!valid) {
return false;
}
(!dataForm.id ? baseService.post : baseService.put)("/sys/dict/data", dataForm).then((res) => {
ElMessage.success({
message: "成功",
duration: 500,
onClose: () => {
visible.value = false;
emit("refreshDataList");
}
});
});
});
};
defineExpose({
init,
dataForm
});
</script>
@@ -0,0 +1,71 @@
<template>
<div class="mod-sys__dict">
<el-form :inline="true" :model="state.dataForm" @keyup.enter="state.getDataList()">
<el-form-item>
<el-input v-model="state.dataForm.dictValue" placeholder="字典值" clearable></el-input>
</el-form-item>
<el-form-item>
<el-input v-model="state.dataForm.dictLabel" placeholder="字典标签" clearable></el-input>
</el-form-item>
<el-form-item>
<el-button @click="state.getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="state.hasPermission('sys:dict:save')" type="primary" @click="addOrUpdateHandle()">新增</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="state.hasPermission('sys:dict:delete')" type="danger" @click="state.deleteHandle()">删除</el-button>
</el-form-item>
</el-form>
<el-table v-loading="state.dataListLoading" :data="state.dataList" border @selection-change="state.dataListSelectionChangeHandle" @sort-change="state.dataListSortChangeHandle" style="width: 100%">
<el-table-column type="selection" header-align="center" align="center" width="50"></el-table-column>
<el-table-column prop="dictValue" label="字典值" header-align="center" align="center"></el-table-column>
<el-table-column prop="dictLabel" label="字典标签" header-align="center" align="center"></el-table-column>
<el-table-column prop="sort" label="排序" sortable="custom" header-align="center" align="center"></el-table-column>
<el-table-column prop="remark" label="备注" header-align="center" align="center"></el-table-column>
<el-table-column prop="createDate" label="创建时间" sortable="custom" header-align="center" align="center" width="180"></el-table-column>
<el-table-column label="操作" fixed="right" header-align="center" align="center" width="150">
<template v-slot="scope">
<el-button v-if="state.hasPermission('sys:dict:update')" type="primary" link @click="addOrUpdateHandle(scope.row.id)">修改</el-button>
<el-button v-if="state.hasPermission('sys:dict:delete')" type="primary" link @click="state.deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination :current-page="state.page" :page-sizes="[10, 20, 50, 100]" :page-size="state.limit" :total="state.total" layout="total, sizes, prev, pager, next, jumper" @size-change="state.pageSizeChangeHandle" @current-change="state.pageCurrentChangeHandle"> </el-pagination>
<!-- 弹窗, 新增 / 修改 -->
<add-or-update ref="addOrUpdateRef" @refreshDataList="state.getDataList"></add-or-update>
</div>
</template>
<script lang="ts" setup>
import useView from "@/hooks/useView";
import { reactive, ref, toRefs } from "vue";
import AddOrUpdate from "./dict-data-add-or-update.vue";
const props = defineProps({
dictTypeId: {
type: String,
required: true
}
});
const view = reactive({
getDataListURL: "/sys/dict/data/page",
getDataListIsPage: true,
deleteURL: "/sys/dict/data",
deleteIsBatch: true,
dataForm: {
dictTypeId: props.dictTypeId,
dictLabel: "",
dictValue: ""
}
});
const state = reactive({ ...useView(view), ...toRefs(view) });
const addOrUpdateRef = ref();
const addOrUpdateHandle = (id?: number) => {
addOrUpdateRef.value.dataForm.dictTypeId = props.dictTypeId;
addOrUpdateRef.value.init(id);
};
</script>
@@ -0,0 +1,90 @@
<template>
<el-dialog v-model="visible" :title="!dataForm.id ? '新增' : '修改'" :close-on-click-modal="false" :close-on-press-escape="false">
<el-form :model="dataForm" :rules="rules" ref="dataFormRef" @keyup.enter="dataFormSubmitHandle()" label-width="120px">
<el-form-item prop="dictName" label="字典名称">
<el-input v-model="dataForm.dictName" placeholder="字典名称"></el-input>
</el-form-item>
<el-form-item prop="dictType" label="字典类型">
<el-input v-model="dataForm.dictType" placeholder="字典类型"></el-input>
</el-form-item>
<el-form-item prop="sort" label="排序">
<el-input-number v-model="dataForm.sort" controls-position="right" :min="0" label="排序"></el-input-number>
</el-form-item>
<el-form-item prop="remark" label="备注">
<el-input v-model="dataForm.remark" placeholder="备注"></el-input>
</el-form-item>
</el-form>
<template v-slot:footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="dataFormSubmitHandle()">确定</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { reactive, ref } from "vue";
import baseService from "@/service/baseService";
import { ElMessage } from "element-plus";
const emit = defineEmits(["refreshDataList"]);
const visible = ref(false);
const dataFormRef = ref();
const dataForm = reactive({
id: "",
dictName: "",
dictType: "",
sort: 0,
remark: ""
});
const init = (id?: number) => {
visible.value = true;
dataForm.id = "";
// 重置表单数据
if (dataFormRef.value) {
dataFormRef.value.resetFields();
}
if (id) {
getInfo(id);
}
};
// 获取信息
const getInfo = (id: number) => {
baseService.get(`/sys/dict/type/${id}`).then((res) => {
Object.assign(dataForm, res.data);
});
};
const rules = ref({
dictName: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
dictType: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
sort: [{ required: true, message: "必填项不能为空", trigger: "blur" }]
});
// 表单提交
const dataFormSubmitHandle = () => {
dataFormRef.value.validate((valid: boolean) => {
if (!valid) {
return false;
}
(!dataForm.id ? baseService.post : baseService.put)("/sys/dict/type", dataForm).then((res) => {
ElMessage.success({
message: "成功",
duration: 500,
onClose: () => {
visible.value = false;
emit("refreshDataList");
}
});
});
});
};
defineExpose({
init
});
</script>
@@ -0,0 +1,84 @@
<template>
<div class="mod-sys__dict">
<el-form :inline="true" :model="state.dataForm" @keyup.enter="state.getDataList()">
<el-form-item>
<el-input v-model="state.dataForm.dictName" placeholder="字典名称" clearable></el-input>
</el-form-item>
<el-form-item>
<el-input v-model="state.dataForm.dictType" placeholder="字典类型" clearable></el-input>
</el-form-item>
<el-form-item>
<el-button @click="state.getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="state.hasPermission('sys:dict:save')" type="primary" @click="addOrUpdateHandle()">新增</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="state.hasPermission('sys:dict:delete')" type="danger" @click="state.deleteHandle()">删除</el-button>
</el-form-item>
</el-form>
<el-table v-loading="state.dataListLoading" :data="state.dataList" border @selection-change="state.dataListSelectionChangeHandle" @sort-change="state.dataListSortChangeHandle" style="width: 100%">
<el-table-column type="selection" header-align="center" align="center" width="50"></el-table-column>
<el-table-column prop="dictName" label="字典名称" header-align="center" align="center"></el-table-column>
<el-table-column prop="dictType" label="字典类型" header-align="center" align="center">
<template v-slot="scope">
<el-button type="primary" link @click="showTypeList(scope.row)">{{ scope.row.dictType }}</el-button>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" sortable="custom" header-align="center" align="center"></el-table-column>
<el-table-column prop="remark" label="备注" header-align="center" align="center"></el-table-column>
<el-table-column prop="createDate" label="创建时间" sortable="custom" header-align="center" align="center" width="180"></el-table-column>
<el-table-column label="操作" fixed="right" header-align="center" align="center" width="180">
<template v-slot="scope">
<el-button type="primary" link @click="showTypeList(scope.row)">字典配置</el-button>
<el-button v-if="state.hasPermission('sys:dict:update')" type="primary" link @click="addOrUpdateHandle(scope.row.id)">修改</el-button>
<el-button v-if="state.hasPermission('sys:dict:delete')" type="primary" link @click="state.deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination :current-page="state.page" :page-sizes="[10, 20, 50, 100]" :page-size="state.limit" :total="state.total" layout="total, sizes, prev, pager, next, jumper" @size-change="state.pageSizeChangeHandle" @current-change="state.pageCurrentChangeHandle"> </el-pagination>
<!-- 弹窗, 新增 / 修改 -->
<add-or-update ref="addOrUpdateRef" @refreshDataList="state.getDataList"></add-or-update>
<!-- 字典类型数据 -->
<el-drawer v-if="dictDataVisible" v-model="dictDataVisible" :title="state.focusDictTypeTitle" :size="800" :close-on-press-escape="false" class="rr-drawer"> <DictTypeList :dictTypeId="state.focusDictTypeId"></DictTypeList></el-drawer>
</div>
</template>
<script lang="ts" setup>
import useView from "@/hooks/useView";
import { reactive, ref, toRefs } from "vue";
import AddOrUpdate from "./dict-type-add-or-update.vue";
import DictTypeList from "./dict-data.vue";
import { IObject } from "@/types/interface";
import { useRoute } from "vue-router";
const route = useRoute();
const dictDataVisible = ref(false);
const view = reactive({
getDataListURL: "/sys/dict/type/page",
getDataListIsPage: true,
deleteURL: "/sys/dict/type",
deleteIsBatch: true,
dataForm: {
id: "0",
dictName: "",
dictType: ""
},
focusDictTypeId: "",
focusDictTypeTitle: ""
});
const state = reactive({ ...useView(view), ...toRefs(view) });
const showTypeList = (row: IObject) => {
dictDataVisible.value = true;
state.focusDictTypeId = row.id;
state.focusDictTypeTitle = `${route.meta.title} - ${row.dictType}`;
};
const addOrUpdateRef = ref();
const addOrUpdateHandle = (id?: number) => {
addOrUpdateRef.value.init(id);
};
</script>
@@ -0,0 +1,60 @@
<template>
<div class="mod-sys__log-error">
<el-form :inline="true" :model="state.dataForm" @keyup.enter="state.getDataList()">
<el-form-item>
<el-button type="info" @click="state.exportHandle()">导出</el-button>
</el-form-item>
</el-form>
<el-table v-loading="state.dataListLoading" :data="state.dataList" border @sort-change="state.dataListSortChangeHandle" style="width: 100%">
<el-table-column prop="requestUri" label="请求URI" header-align="center" align="center" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="requestMethod" label="请求方式" header-align="center" align="center" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="requestParams" label="请求参数" header-align="center" align="center" width="150" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="ip" label="操作IP" header-align="center" align="center"></el-table-column>
<el-table-column prop="userAgent" label="用户代理" header-align="center" align="center" width="150" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="createDate" label="创建时间" sortable="custom" header-align="center" align="center" width="180"></el-table-column>
<el-table-column label="操作" fixed="right" header-align="center" align="center" width="150">
<template v-slot="scope">
<el-button type="primary" link @click="infoHandle(scope.row.errorInfo)">异常信息</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination :current-page="state.page" :page-sizes="[10, 20, 50, 100]" :page-size="state.limit" :total="state.total" layout="total, sizes, prev, pager, next, jumper" @size-change="state.pageSizeChangeHandle" @current-change="state.pageCurrentChangeHandle"> </el-pagination>
<el-dialog v-model="infoDialogVisible" title="异常信息" center>
<span>{{ infoMessage }}</span>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="infoDialogVisible = false">确定</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import useView from "@/hooks/useView";
import { reactive, toRefs, ref } from "vue";
const infoDialogVisible = ref(false)
const infoMessage = ref('')
const view = reactive({
getDataListURL: "/sys/log/error/page",
getDataListIsPage: true,
exportURL: "/sys/log/error/export"
});
const state = reactive({ ...useView(view), ...toRefs(view) });
const infoHandle = (info: string) => {
infoMessage.value = info
infoDialogVisible.value = true
};
</script>
<style lang="less" scoped>
.mod-sys__log-error {
&-view-info {
width: 80%;
}
}
</style>
@@ -0,0 +1,58 @@
<template>
<div class="mod-sys__log-login">
<el-form :inline="true" :model="state.dataForm" @keyup.enter="state.getDataList()">
<el-form-item>
<el-input v-model="state.dataForm.creatorName" placeholder="用户名" clearable></el-input>
</el-form-item>
<el-form-item>
<el-select v-model="state.dataForm.status" placeholder="状态" clearable>
<el-option label="失败" :value="0"></el-option>
<el-option label="成功" :value="1"></el-option>
<el-option label="账号已锁定" :value="2"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="state.getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button type="info" @click="state.exportHandle()">导出</el-button>
</el-form-item>
</el-form>
<el-table v-loading="state.dataListLoading" :data="state.dataList" border @sort-change="state.dataListSortChangeHandle" style="width: 100%">
<el-table-column prop="creatorName" label="用户名" header-align="center" align="center"></el-table-column>
<el-table-column prop="operation" label="操作类型" header-align="center" align="center">
<template v-slot="scope">
{{ scope.row.operation === 0 ? "登录" : "退出" }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" sortable="custom" header-align="center" align="center">
<template v-slot="scope">
<el-tag v-if="scope.row.status === 0" size="small" type="danger">失败</el-tag>
<el-tag v-else-if="scope.row.status === 1" size="small" type="success">成功</el-tag>
<el-tag v-else size="small" type="warning">账号已锁定</el-tag>
</template>
</el-table-column>
<el-table-column prop="ip" label="操作IP" header-align="center" align="center"></el-table-column>
<el-table-column prop="userAgent" label="用户代理" header-align="center" align="center" width="150" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="createDate" label="创建时间" sortable="custom" header-align="center" align="center" width="180"></el-table-column>
</el-table>
<el-pagination :current-page="state.page" :page-sizes="[10, 20, 50, 100]" :page-size="state.limit" :total="state.total" layout="total, sizes, prev, pager, next, jumper" @size-change="state.pageSizeChangeHandle" @current-change="state.pageCurrentChangeHandle"> </el-pagination>
</div>
</template>
<script lang="ts" setup>
import useView from "@/hooks/useView";
import { reactive, toRefs } from "vue";
const view = reactive({
getDataListURL: "/sys/log/login/page",
getDataListIsPage: true,
exportURL: "/sys/log/login/export",
dataForm: {
creatorName: "",
status: ""
}
});
const state = reactive({ ...useView(view), ...toRefs(view) });
</script>
@@ -0,0 +1,56 @@
<template>
<div class="mod-sys__log-operation">
<el-form :inline="true" :model="state.dataForm" @keyup.enter="state.getDataList()">
<el-form-item>
<el-select v-model="state.dataForm.status" placeholder="状态" clearable>
<el-option label="失败" :value="0"></el-option>
<el-option label="成功" :value="1"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="state.getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button type="info" @click="state.exportHandle()">导出</el-button>
</el-form-item>
</el-form>
<el-table v-loading="state.dataListLoading" :data="state.dataList" border @sort-change="state.dataListSortChangeHandle" style="width: 100%">
<el-table-column prop="creatorName" label="用户名" header-align="center" align="center"></el-table-column>
<el-table-column prop="operation" label="用户操作" header-align="center" align="center"></el-table-column>
<el-table-column prop="requestUri" label="请求URI" header-align="center" align="center" show-overflow-tooltip></el-table-column>
<el-table-column prop="requestMethod" label="请求方式" header-align="center" align="center"></el-table-column>
<el-table-column prop="requestParams" label="请求参数" header-align="center" align="center" width="150" show-overflow-tooltip></el-table-column>
<el-table-column prop="requestTime" label="请求时长" sortable="custom" header-align="center" align="center">
<template v-slot="scope">
{{ `${scope.row.requestTime}ms` }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" sortable="custom" header-align="center" align="center">
<template v-slot="scope">
<el-tag v-if="scope.row.status === 0" size="small" type="danger">失败</el-tag>
<el-tag v-else size="small" type="success">成功</el-tag>
</template>
</el-table-column>
<el-table-column prop="ip" label="操作IP" header-align="center" align="center" show-overflow-tooltip></el-table-column>
<el-table-column prop="userAgent" label="用户代理" header-align="center" align="center" width="150" show-overflow-tooltip></el-table-column>
<el-table-column prop="createDate" label="创建时间" sortable="custom" header-align="center" align="center" width="180"></el-table-column>
</el-table>
<el-pagination :current-page="state.page" :page-sizes="[10, 20, 50, 100]" :page-size="state.limit" :total="state.total" layout="total, sizes, prev, pager, next, jumper" @size-change="state.pageSizeChangeHandle" @current-change="state.pageCurrentChangeHandle"> </el-pagination>
</div>
</template>
<script lang="ts" setup>
import useView from "@/hooks/useView";
import { reactive, toRefs } from "vue";
const view = reactive({
getDataListURL: "/sys/log/operation/page",
getDataListIsPage: true,
exportURL: "/sys/log/operation/export",
dataForm: {
status: ""
}
});
const state = reactive({ ...useView(view), ...toRefs(view) });
</script>
@@ -0,0 +1,236 @@
<template>
<el-dialog v-model="visible" :title="!dataForm.id ? '新增' : '修改'" :close-on-click-modal="false" :close-on-press-escape="false">
<el-form :model="dataForm" :rules="rules" ref="dataFormRef" @keyup.enter="dataFormSubmitHandle()" label-width="120px">
<el-form-item prop="menuType" label="类型">
<el-radio-group v-model="dataForm.menuType" :disabled="!!dataForm.id">
<el-radio :label="0">菜单</el-radio>
<el-radio :label="1">按钮</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item prop="name" label="名称">
<el-input v-model="dataForm.name" placeholder="名称"></el-input>
</el-form-item>
<el-form-item prop="parentName" label="上级菜单" class="menu-list">
<el-popover ref="menuListPopover" placement="bottom-start" trigger="click" :width="400" popper-class="popover-pop">
<template v-slot:reference>
<el-input v-model="dataForm.parentName" :readonly="true" placeholder="上级菜单">
<template v-slot:suffix>
<el-icon v-if="dataForm.pid !== '0'" @click.stop="deptListTreeSetDefaultHandle()" class="el-input__icon"><circle-close /></el-icon
></template>
</el-input>
</template>
<div class="popover-pop-body">
<el-tree :data="menuList" :props="{ label: 'name', children: 'children' }" node-key="id" ref="menuListTree" :highlight-current="true" :expand-on-click-node="false" accordion @current-change="menuListTreeCurrentChangeHandle"> </el-tree>
</div>
</el-popover>
</el-form-item>
<el-form-item v-if="dataForm.menuType === 0" prop="url" label="路由">
<el-input v-model="dataForm.url" placeholder="路由"></el-input>
</el-form-item>
<el-form-item prop="sort" label="排序">
<el-input-number v-model="dataForm.sort" controls-position="right" :min="0" label="排序"></el-input-number>
</el-form-item>
<el-form-item v-if="dataForm.menuType === 0" prop="openStyle" label="打开方式">
<el-radio-group v-model="dataForm.openStyle">
<el-radio :label="0">内部打开</el-radio>
<el-radio :label="1">外部打开</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item prop="permissions" label="授权标识">
<el-input v-model="dataForm.permissions" placeholder="多个用逗号分隔,如:sys:menu:save,sys:menu:update"></el-input>
</el-form-item>
<el-form-item v-if="dataForm.menuType === 0" prop="icon" label="图标" class="icon-list">
<el-popover ref="iconListPopover" placement="top-start" trigger="click" popper-class="popover-pop mod-sys__menu-icon-popover">
<template v-slot:reference> <el-input v-model="dataForm.icon" :readonly="true" placeholder="图标"></el-input></template>
<div class="mod-sys__menu-icon-inner">
<div class="mod-sys__menu-icon-list">
<el-button v-for="(item, index) in iconList" :key="index" @click="iconListCurrentChangeHandle(item)" :class="{ 'is-active': dataForm.icon === item }">
<svg class="icon-svg" aria-hidden="true"><use :xlink:href="`#${item}`"></use></svg>
</el-button>
</div>
</div>
</el-popover>
</el-form-item>
</el-form>
<template v-slot:footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="dataFormSubmitHandle()">确定</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { reactive, ref } from "vue";
import baseService from "@/service/baseService";
import { getIconList } from "@/utils/utils";
import { IObject } from "@/types/interface";
import { ElMessage } from "element-plus";
const emit = defineEmits(["refreshDataList"]);
const visible = ref(false);
const menuList = ref([]);
const iconList = ref<string[]>([]);
const dataFormRef = ref();
const menuListTree = ref();
const menuListPopover = ref();
const iconListPopover = ref();
const dataForm = reactive({
id: "",
menuType: 0,
name: "",
pid: "0",
parentName: "",
url: "",
permissions: "",
sort: 0,
icon: "",
openStyle: 0
});
const rules = ref({
name: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
parentName: [{ required: true, message: "必填项不能为空", trigger: "change" }]
});
const init = (id: number) => {
visible.value = true;
dataForm.id = "";
// 重置表单数据
if (dataFormRef.value) {
dataFormRef.value.resetFields();
}
iconList.value = getIconList();
dataForm.pid = "0";
dataForm.parentName = "一级菜单";
getMenuList().then(() => {
if (id) {
getInfo(id);
}
});
};
const init2 = (row: IObject) => {
visible.value = true;
// 重置表单数据
if (dataFormRef.value) {
dataFormRef.value.resetFields();
}
iconList.value = getIconList();
dataForm.id = "";
dataForm.pid = row.id;
dataForm.parentName = row.name;
getMenuList();
};
// 获取菜单列表
const getMenuList = () => {
return baseService.get("/sys/menu/list?type=0").then((res) => {
menuList.value = res.data;
});
};
// 获取信息
const getInfo = (id: number) => {
baseService.get(`/sys/menu/${id}`).then((res) => {
Object.assign(dataForm, res.data);
if (dataForm.pid === "0") {
return deptListTreeSetDefaultHandle();
}
menuListTree.value.setCurrentKey(dataForm.pid);
});
};
// 上级菜单树, 设置默认值
const deptListTreeSetDefaultHandle = () => {
dataForm.pid = "0";
dataForm.parentName = "一级菜单";
};
// 上级菜单树, 选中
const menuListTreeCurrentChangeHandle = (data: IObject) => {
dataForm.pid = data.id;
dataForm.parentName = data.name;
menuListPopover.value.hide();
};
// 图标, 选中
const iconListCurrentChangeHandle = (icon: string) => {
dataForm.icon = icon;
iconListPopover.value.hide();
};
// 表单提交
const dataFormSubmitHandle = () => {
dataFormRef.value.validate((valid: boolean) => {
if (!valid) {
return false;
}
(!dataForm.id ? baseService.post : baseService.put)("/sys/menu", dataForm).then((res) => {
ElMessage.success({
message: "成功",
duration: 500,
onClose: () => {
visible.value = false;
emit("refreshDataList");
}
});
});
});
};
defineExpose({
init,
init2
});
</script>
<style lang="less">
.el-popover.el-popper {
overflow-x: hidden;
}
.mod-sys__menu {
.menu-list,
.icon-list {
.el-input__inner,
.el-input__suffix {
cursor: pointer;
}
}
&-icon-popover {
width: 458px !important;
overflow-y: hidden !important;
}
&-icon-inner {
width: 100%;
max-height: 260px;
overflow-x: hidden;
overflow-y: auto;
}
&-icon-list {
width: 458px !important;
padding: 0;
margin: -8px 0 0 -8px;
> .el-button {
padding: 8px;
margin: 8px 0 0 8px;
> span {
display: inline-block;
vertical-align: middle;
width: 18px;
height: 18px;
font-size: 18px;
}
}
}
}
</style>
+66
View File
@@ -0,0 +1,66 @@
<template>
<div class="mod-sys__menu">
<el-form :inline="true" :model="state.dataForm" @keyup.enter="state.getDataList()">
<el-form-item>
<el-button v-if="state.hasPermission('sys:menu:save')" type="primary" @click="addOrUpdateHandle()">新增</el-button>
</el-form-item>
</el-form>
<el-table v-loading="state.dataListLoading" :data="state.dataList" show-overflow-tooltip row-key="id" border style="width: 100%">
<el-table-column prop="name" label="名称" header-align="center" min-width="150"></el-table-column>
<el-table-column prop="icon" label="图标" header-align="center" align="center">
<template v-slot="scope">
<svg class="iconfont" aria-hidden="true">
<use :xlink:href="`#${scope.row.icon}`"></use>
</svg>
</template>
</el-table-column>
<el-table-column prop="menuType" label="类型" header-align="center" align="center">
<template v-slot="scope">
<el-tag v-if="scope.row.menuType === 0">菜单</el-tag>
<el-tag v-else type="info">按钮</el-tag>
</template>
</el-table-column>
<el-table-column prop="openStyle" label="打开方式" header-align="center" align="center">
<template v-slot="scope">
<span v-if="scope.row.menuType !== 0"></span>
<el-tag v-else-if="scope.row.openStyle === 1">外部打开</el-tag>
<el-tag v-else type="info">内部打开</el-tag>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" header-align="center" align="center"></el-table-column>
<el-table-column prop="url" label="路由" header-align="center" align="center" width="150"></el-table-column>
<el-table-column prop="permissions" label="授权标识" header-align="center" align="center" width="150"></el-table-column>
<el-table-column label="操作" fixed="right" header-align="center" align="center" width="170">
<template v-slot="scope">
<el-button v-if="state.hasPermission('sys:menu:save') && scope.row.menuType === 0" type="primary" link @click="addHandle(scope.row)">新增</el-button>
<el-button v-if="state.hasPermission('sys:menu:update')" type="primary" link @click="addOrUpdateHandle(scope.row.id)">修改</el-button>
<el-button v-if="state.hasPermission('sys:menu:delete')" type="primary" link @click="state.deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 弹窗, 新增 / 修改 -->
<add-or-update ref="addOrUpdateRef" @refreshDataList="state.getDataList"></add-or-update>
</div>
</template>
<script lang="ts" setup>
import useView from "@/hooks/useView";
import { reactive, ref, toRefs } from "vue";
import AddOrUpdate from "./menu-add-or-update.vue";
const view = reactive({
getDataListURL: "/sys/menu/list",
deleteURL: "/sys/menu"
});
const state = reactive({ ...useView(view), ...toRefs(view) });
const addOrUpdateRef = ref();
const addOrUpdateHandle = (id?: number) => {
addOrUpdateRef.value.init(id);
};
const addHandle = (row: any) => {
addOrUpdateRef.value.init2(row);
};
</script>
@@ -0,0 +1,85 @@
<template>
<el-dialog v-model="visible" :title="!dataForm.id ? '新增' : '修改'" :close-on-click-modal="false" :close-on-press-escape="false">
<el-form :model="dataForm" :rules="rules" ref="dataFormRef" @keyup.enter="dataFormSubmitHandle()" label-width="120px">
<el-form-item prop="paramCode" label="编码">
<el-input v-model="dataForm.paramCode" placeholder="编码"></el-input>
</el-form-item>
<el-form-item prop="paramValue" label="值">
<el-input v-model="dataForm.paramValue" placeholder="值"></el-input>
</el-form-item>
<el-form-item prop="remark" label="备注">
<el-input v-model="dataForm.remark" placeholder="备注"></el-input>
</el-form-item>
</el-form>
<template v-slot:footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="dataFormSubmitHandle()">确定</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { reactive, ref } from "vue";
import baseService from "@/service/baseService";
import { ElMessage } from "element-plus";
const emit = defineEmits(["refreshDataList"]);
const visible = ref(false);
const dataFormRef = ref();
const dataForm = reactive({
id: "",
paramCode: "",
paramValue: "",
remark: ""
});
const rules = ref({
paramCode: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
paramValue: [{ required: true, message: "必填项不能为空", trigger: "blur" }]
});
const init = (id?: number) => {
visible.value = true;
dataForm.id = "";
// 重置表单数据
if (dataFormRef.value) {
dataFormRef.value.resetFields();
}
if (id) {
getInfo(id);
}
};
// 获取信息
const getInfo = (id: number) => {
baseService.get(`/sys/params/${id}`).then((res) => {
Object.assign(dataForm, res.data);
});
};
// 表单提交
const dataFormSubmitHandle = () => {
dataFormRef.value.validate((valid: boolean) => {
if (!valid) {
return false;
}
(!dataForm.id ? baseService.post : baseService.put)("/sys/params", dataForm).then((res) => {
ElMessage.success({
message: "成功",
duration: 500,
onClose: () => {
visible.value = false;
emit("refreshDataList");
}
});
});
});
};
defineExpose({
init
});
</script>
+56
View File
@@ -0,0 +1,56 @@
<template>
<div class="mod-sys__params">
<el-form :inline="true" :model="state.dataForm" @keyup.enter="state.getDataList()">
<el-form-item>
<el-input v-model="state.dataForm.paramCode" placeholder="编码" clearable></el-input>
</el-form-item>
<el-form-item>
<el-button @click="state.getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="state.hasPermission('sys:params:save')" type="primary" @click="addOrUpdateHandle()">新增</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="state.hasPermission('sys:params:delete')" type="danger" @click="state.deleteHandle()">删除</el-button>
</el-form-item>
</el-form>
<el-table v-loading="state.dataListLoading" :data="state.dataList" border @selection-change="state.dataListSelectionChangeHandle" style="width: 100%">
<el-table-column type="selection" header-align="center" align="center" width="50"></el-table-column>
<el-table-column prop="paramCode" label="编码" header-align="center" align="center"></el-table-column>
<el-table-column prop="paramValue" label="值" header-align="center" align="center"></el-table-column>
<el-table-column prop="remark" label="备注" header-align="center" align="center"></el-table-column>
<el-table-column label="操作" fixed="right" header-align="center" align="center" width="150">
<template v-slot="scope">
<el-button v-if="state.hasPermission('sys:params:update')" type="primary" link @click="addOrUpdateHandle(scope.row.id)">修改</el-button>
<el-button v-if="state.hasPermission('sys:params:delete')" type="primary" link @click="state.deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination :current-page="state.page" :page-sizes="[10, 20, 50, 100]" :page-size="state.limit" :total="state.total" layout="total, sizes, prev, pager, next, jumper" @size-change="state.pageSizeChangeHandle" @current-change="state.pageCurrentChangeHandle"> </el-pagination>
<!-- 弹窗, 新增 / 修改 -->
<add-or-update ref="addOrUpdateRef" @refreshDataList="state.getDataList"></add-or-update>
</div>
</template>
<script lang="ts" setup>
import useView from "@/hooks/useView";
import { reactive, ref, toRefs } from "vue";
import AddOrUpdate from "./params-add-or-update.vue";
const view = reactive({
getDataListURL: "/sys/params/page",
getDataListIsPage: true,
deleteURL: "/sys/params",
deleteIsBatch: true,
dataForm: {
paramCode: ""
}
});
const state = reactive({ ...useView(view), ...toRefs(view) });
const addOrUpdateRef = ref();
const addOrUpdateHandle = (id?: number) => {
addOrUpdateRef.value.init(id);
};
</script>
@@ -0,0 +1,130 @@
<template>
<el-dialog v-model="visible" :title="!dataForm.id ? '新增' : '修改'" :close-on-click-modal="false" :close-on-press-escape="false">
<el-form :model="dataForm" :rules="rules" ref="dataFormRef" @keyup.enter="dataFormSubmitHandle()" label-width="120px">
<el-form-item prop="name" label="名称">
<el-input v-model="dataForm.name" placeholder="名称"></el-input>
</el-form-item>
<el-form-item prop="remark" label="备注">
<el-input v-model="dataForm.remark" placeholder="备注"></el-input>
</el-form-item>
<el-row>
<el-col :span="12">
<el-form-item size="small" label="菜单授权">
<el-tree :data="menuList" :props="{ label: 'name', children: 'children' }" node-key="id" ref="menuListTree" accordion show-checkbox> </el-tree>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item size="small" label="数据授权">
<el-tree :data="deptList" :props="{ label: 'name', children: 'children' }" node-key="id" ref="deptListTree" accordion show-checkbox> </el-tree>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template v-slot:footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="dataFormSubmitHandle()">确定</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { nextTick, reactive, ref } from "vue";
import baseService from "@/service/baseService";
import { IObject } from "@/types/interface";
import { ElMessage } from "element-plus";
const emit = defineEmits(["refreshDataList"]);
const visible = ref(false);
const menuList = ref([]);
const deptList = ref([]);
const dataFormRef = ref();
const menuListTree = ref();
const deptListTree = ref();
const dataForm = reactive({
id: "",
name: "",
menuIdList: [] as IObject[],
deptIdList: [],
remark: ""
});
const rules = ref({
name: [{ required: true, message: "必填项不能为空", trigger: "blur" }]
});
const init = (id?: number) => {
visible.value = true;
dataForm.id = "";
// 重置表单数据
if (dataFormRef.value) {
dataFormRef.value.resetFields();
}
nextTick(() => {
if (menuListTree.value) {
menuListTree.value.setCheckedKeys([]);
}
if (deptListTree.value) {
deptListTree.value.setCheckedKeys([]);
}
Promise.all([getMenuList(), getDeptList()]).then(() => {
if (id) {
getInfo(id);
}
});
});
};
// 获取菜单列表
const getMenuList = () => {
return baseService.get("/sys/menu/select").then((res) => {
menuList.value = res.data;
});
};
// 获取部门列表
const getDeptList = () => {
return baseService.get("/sys/dept/list").then((res) => {
deptList.value = res.data;
});
};
// 获取信息
const getInfo = (id: number) => {
baseService.get(`/sys/role/${id}`).then((res) => {
Object.assign(dataForm, res.data);
dataForm.menuIdList.forEach((item: IObject) => menuListTree.value.setChecked(item, true));
deptListTree.value.setCheckedKeys(dataForm.deptIdList);
});
};
// 表单提交
const dataFormSubmitHandle = () => {
dataFormRef.value.validate((valid: boolean) => {
if (!valid) {
return false;
}
dataForm.menuIdList = [...menuListTree.value.getHalfCheckedKeys(), ...menuListTree.value.getCheckedKeys()];
dataForm.deptIdList = deptListTree.value.getCheckedKeys();
(!dataForm.id ? baseService.post : baseService.put)("/sys/role", dataForm).then((res) => {
ElMessage.success({
message: "成功",
duration: 500,
onClose: () => {
visible.value = false;
emit("refreshDataList");
}
});
});
});
};
defineExpose({
init
});
</script>
+56
View File
@@ -0,0 +1,56 @@
<template>
<div class="mod-sys__role">
<el-form :inline="true" :model="state.dataForm" @keyup.enter="state.getDataList()">
<el-form-item>
<el-input v-model="state.dataForm.name" placeholder="名称" clearable></el-input>
</el-form-item>
<el-form-item>
<el-button @click="state.getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="state.hasPermission('sys:role:save')" type="primary" @click="addOrUpdateHandle()">新增</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="state.hasPermission('sys:role:delete')" type="danger" @click="state.deleteHandle()">删除</el-button>
</el-form-item>
</el-form>
<el-table v-loading="state.dataListLoading" :data="state.dataList" border @selection-change="state.dataListSelectionChangeHandle" @sort-change="state.dataListSortChangeHandle" style="width: 100%">
<el-table-column type="selection" header-align="center" align="center" width="50"></el-table-column>
<el-table-column prop="name" label="名称" header-align="center" align="center"></el-table-column>
<el-table-column prop="remark" label="备注" header-align="center" align="center"></el-table-column>
<el-table-column prop="createDate" label="创建时间" sortable="custom" header-align="center" align="center" width="180"></el-table-column>
<el-table-column label="操作" fixed="right" header-align="center" align="center" width="150">
<template v-slot="scope">
<el-button v-if="state.hasPermission('sys:role:update')" type="primary" link @click="addOrUpdateHandle(scope.row.id)">修改</el-button>
<el-button v-if="state.hasPermission('sys:role:delete')" type="primary" link @click="state.deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination :current-page="state.page" :page-sizes="[10, 20, 50, 100]" :page-size="state.limit" :total="state.total" layout="total, sizes, prev, pager, next, jumper" @size-change="state.pageSizeChangeHandle" @current-change="state.pageCurrentChangeHandle"> </el-pagination>
<!-- 弹窗, 新增 / 修改 -->
<add-or-update ref="addOrUpdateRef" @refreshDataList="state.getDataList"></add-or-update>
</div>
</template>
<script lang="ts" setup>
import useView from "@/hooks/useView";
import { reactive, ref, toRefs } from "vue";
import AddOrUpdate from "./role-add-or-update.vue";
const view = reactive({
getDataListURL: "/sys/role/page",
getDataListIsPage: true,
deleteURL: "/sys/role",
deleteIsBatch: true,
dataForm: {
name: ""
}
});
const state = reactive({ ...useView(view), ...toRefs(view) });
const addOrUpdateRef = ref();
const addOrUpdateHandle = (id?: number) => {
addOrUpdateRef.value.init(id);
};
</script>
@@ -0,0 +1,189 @@
<template>
<el-dialog v-model="visible" :title="!dataForm.id ? '新增' : '修改'" :close-on-click-modal="false" :close-on-press-escape="false">
<el-form :model="dataForm" :rules="rules" ref="dataFormRef" @keyup.enter="dataFormSubmitHandle()" label-width="120px">
<el-form-item prop="username" label="用户名">
<el-input v-model="dataForm.username" placeholder="用户名"></el-input>
</el-form-item>
<el-form-item prop="deptName" label="所属部门">
<sys-dept-tree v-model="dataForm.deptId" placeholder="选择部门" v-model:deptName="dataForm.deptName"></sys-dept-tree>
</el-form-item>
<el-form-item prop="password" label="密码" :class="{ 'is-required': !dataForm.id }">
<el-input v-model="dataForm.password" type="password" placeholder="密码"></el-input>
</el-form-item>
<el-form-item prop="confirmPassword" label="确认密码" :class="{ 'is-required': !dataForm.id }">
<el-input v-model="dataForm.confirmPassword" type="password" placeholder="确认密码"></el-input>
</el-form-item>
<el-form-item prop="realName" label="真实姓名">
<el-input v-model="dataForm.realName" placeholder="真实姓名"></el-input>
</el-form-item>
<el-form-item prop="gender" label="性别">
<sys-radio-group v-model="dataForm.gender" dict-type="gender"></sys-radio-group>
</el-form-item>
<el-form-item prop="email" label="邮箱">
<el-input v-model="dataForm.email" placeholder="邮箱"></el-input>
</el-form-item>
<el-form-item prop="mobile" label="手机号">
<el-input v-model="dataForm.mobile" placeholder="手机号"></el-input>
</el-form-item>
<el-form-item prop="roleIdList" label="角色配置" class="role-list">
<el-select v-model="dataForm.roleIdList" multiple placeholder="角色配置">
<el-option v-for="role in roleList" :key="role.id" :label="role.name" :value="role.id"></el-option>
</el-select>
</el-form-item>
<el-form-item prop="status" label="状态">
<el-radio-group v-model="dataForm.status">
<el-radio :label="0">停用</el-radio>
<el-radio :label="1">正常</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template v-slot:footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="dataFormSubmitHandle()">确定</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { reactive, ref } from "vue";
import baseService from "@/service/baseService";
import { isAlphanumeric, isEmail, isMobile } from "@/utils/utils";
import { IObject } from "@/types/interface";
import { ElMessage } from "element-plus";
const emit = defineEmits(["refreshDataList"]);
const visible = ref(false);
const roleList = ref<any[]>([]);
const dataFormRef = ref();
const dataForm = reactive({
id: "",
username: "",
deptId: "",
deptName: "",
password: "",
confirmPassword: "",
realName: "",
gender: 0,
email: "",
mobile: "",
roleIdList: [] as IObject[],
highRoleIdList: [] as IObject[],
status: 1
});
const validateUsername = (rule: any, value: string, callback: (e?: Error) => any): any => {
if (value && !isAlphanumeric(value)) {
return callback(new Error("用户名只能为英文或数字组合"));
}
callback();
};
const validatePassword = (rule: any, value: string, callback: (e?: Error) => any): any => {
if (!dataForm.id && !/\S/.test(value)) {
return callback(new Error("必填项不能为空"));
}
callback();
};
const validateConfirmPassword = (rule: any, value: string, callback: (e?: Error) => any): any => {
if (!dataForm.id && !/\S/.test(value)) {
return callback(new Error("必填项不能为空"));
}
if (dataForm.password !== value) {
return callback(new Error("确认密码与密码输入不一致"));
}
callback();
};
const validateEmail = (rule: any, value: string, callback: (e?: Error) => any): any => {
if (value && !isEmail(value)) {
return callback(new Error("邮箱格式错误"));
}
callback();
};
const validateMobile = (rule: any, value: string, callback: (e?: Error) => any): any => {
if (value && !isMobile(value)) {
return callback(new Error("手机格式错误"));
}
callback();
};
const rules = ref({
username: [
{ required: true, message: "必填项不能为空", trigger: "blur" },
{ validator: validateUsername, trigger: "blur" }
],
deptName: [{ required: true, message: "必填项不能为空", trigger: "change" }],
password: [{ validator: validatePassword, trigger: "blur" }],
confirmPassword: [{ validator: validateConfirmPassword, trigger: "blur" }],
realName: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
email: [{ validator: validateEmail, trigger: "blur" }],
mobile: [{ validator: validateMobile, trigger: "blur" }]
});
const init = (id?: number) => {
visible.value = true;
dataForm.id = "";
dataForm.deptId = "";
// 重置表单数据
if (dataFormRef.value) {
dataFormRef.value.resetFields();
}
Promise.all([getRoleList()]).then(() => {
if (id) {
getInfo(id);
}
});
};
// 获取角色列表
const getRoleList = () => {
return baseService.get("/sys/role/list").then((res) => {
roleList.value = res.data;
});
};
// 获取信息
const getInfo = (id: number) => {
baseService.get(`/sys/user/${id}`).then((res) => {
Object.assign(dataForm, res.data);
dataForm.highRoleIdList = dataForm.roleIdList.filter((id) => !roleList.value.some((role) => role.id === id));
dataForm.roleIdList = dataForm.roleIdList.filter((id) => !dataForm.highRoleIdList.includes(id));
});
};
// 表单提交
const dataFormSubmitHandle = () => {
dataFormRef.value.validate((valid: boolean) => {
if (!valid) {
return false;
}
(!dataForm.id ? baseService.post : baseService.put)("/sys/user", {
...dataForm,
roleIdList: [...dataForm.roleIdList, ...dataForm.highRoleIdList]
}).then((res) => {
ElMessage.success({
message: "成功",
duration: 500,
onClose: () => {
visible.value = false;
emit("refreshDataList");
}
});
});
});
};
defineExpose({
init
});
</script>
<style lang="less" scoped>
.mod-sys__user {
.role-list {
.el-select {
width: 100%;
}
}
}
</style>
@@ -0,0 +1,73 @@
<template>
<el-form :model="dataForm" :rules="rules" ref="dataFormRef" @keyup.enter="dataFormSubmitHandle()" label-width="120px">
<el-form-item label="账号">
<span>{{ user.username }}</span>
</el-form-item>
<el-form-item prop="password" label="原密码">
<el-input v-model="dataForm.password" type="password" placeholder="原密码"></el-input>
</el-form-item>
<el-form-item prop="newPassword" label="新密码">
<el-input v-model="dataForm.newPassword" type="password" placeholder="新密码"></el-input>
</el-form-item>
<el-form-item prop="confirmPassword" label="确认密码">
<el-input v-model="dataForm.confirmPassword" type="password" placeholder="确认密码"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="dataFormSubmitHandle">确定</el-button>
</el-form-item>
</el-form>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from "vue";
import { IObject } from "@/types/interface";
import baseService from "@/service/baseService";
import { useAppStore } from "@/store";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
const router = useRouter();
const dataFormRef = ref();
const dataForm = reactive({
password: "",
newPassword: "",
confirmPassword: ""
});
const store = useAppStore();
const user = computed(() => store.state.user);
const validateConfirmPassword = (rule: IObject, value: string, callback: (e?: Error) => any) => {
if (dataForm.newPassword !== value) {
return callback(new Error("确认密码与新密码输入不一致"));
}
callback();
};
const rules = ref({
password: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
newPassword: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
confirmPassword: [
{ required: true, message: "必填项不能为空", trigger: "blur" },
{ validator: validateConfirmPassword, trigger: "blur" }
]
});
// 表单提交
const dataFormSubmitHandle = () => {
dataFormRef.value.validate((valid: boolean) => {
if (!valid) {
return false;
}
baseService.put("/sys/user/password", dataForm).then((res) => {
ElMessage.success({
message: "成功",
duration: 500,
onClose: () => {
router.replace("/login");
}
});
});
});
};
</script>
+82
View File
@@ -0,0 +1,82 @@
<template>
<div class="mod-sys__user">
<el-form :inline="true" :model="state.dataForm" @keyup.enter="state.getDataList()">
<el-form-item>
<el-input v-model="state.dataForm.username" placeholder="用户名" clearable></el-input>
</el-form-item>
<el-form-item>
<sys-select v-model="state.dataForm.gender" dict-type="gender" placeholder="性别"></sys-select>
</el-form-item>
<el-form-item>
<sys-dept-tree v-model="state.dataForm.deptId" placeholder="选择部门" :query="true"></sys-dept-tree>
</el-form-item>
<el-form-item>
<el-button @click="state.getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="state.hasPermission('sys:user:save')" type="primary" @click="addOrUpdateHandle()">新增</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="state.hasPermission('sys:user:delete')" type="danger" @click="state.deleteHandle()">删除</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="state.hasPermission('sys:user:export')" type="info" @click="state.exportHandle()">导出</el-button>
</el-form-item>
</el-form>
<el-table v-loading="state.dataListLoading" :data="state.dataList" border @selection-change="state.dataListSelectionChangeHandle" @sort-change="state.dataListSortChangeHandle" style="width: 100%">
<el-table-column type="selection" header-align="center" align="center" width="50"></el-table-column>
<el-table-column prop="username" label="用户名" sortable="custom" header-align="center" align="center"></el-table-column>
<el-table-column prop="deptName" label="所属部门" header-align="center" align="center"></el-table-column>
<el-table-column prop="email" label="邮箱" header-align="center" align="center"></el-table-column>
<el-table-column prop="mobile" label="手机号" sortable="custom" header-align="center" align="center"></el-table-column>
<el-table-column prop="gender" label="性别" sortable="custom" header-align="center" align="center">
<template v-slot="scope">
{{ state.getDictLabel("gender", scope.row.gender) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" sortable="custom" header-align="center" align="center">
<template v-slot="scope">
<el-tag v-if="scope.row.status === 0" size="small" type="danger">停用</el-tag>
<el-tag v-else size="small" type="success">正常</el-tag>
</template>
</el-table-column>
<el-table-column prop="createDate" label="创建时间" sortable="custom" header-align="center" align="center" width="180"></el-table-column>
<el-table-column label="操作" fixed="right" header-align="center" align="center" width="150">
<template v-slot="scope">
<el-button v-if="state.hasPermission('sys:user:update')" type="primary" link @click="addOrUpdateHandle(scope.row.id)">修改</el-button>
<el-button v-if="state.hasPermission('sys:user:delete')" type="primary" link @click="state.deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination :current-page="state.page" :page-sizes="[10, 20, 50, 100]" :page-size="state.limit" :total="state.total" layout="total, sizes, prev, pager, next, jumper" @size-change="state.pageSizeChangeHandle" @current-change="state.pageCurrentChangeHandle"> </el-pagination>
<!-- 弹窗, 新增 / 修改 -->
<add-or-update ref="addOrUpdateRef" @refreshDataList="state.getDataList"></add-or-update>
</div>
</template>
<script lang="ts" setup>
import useView from "@/hooks/useView";
import { reactive, ref, toRefs } from "vue";
import AddOrUpdate from "./user-add-or-update.vue";
const view = reactive({
getDataListURL: "/sys/user/page",
getDataListIsPage: true,
deleteURL: "/sys/user",
deleteIsBatch: true,
exportURL: "/sys/user/export",
dataForm: {
username: "",
deptId: "",
postId: "",
gender: ""
}
});
const state = reactive({ ...useView(view), ...toRefs(view) });
const addOrUpdateRef = ref();
const addOrUpdateHandle = (id?: number) => {
addOrUpdateRef.value.init(id);
};
</script>
+110
View File
@@ -0,0 +1,110 @@
<template>
<el-card class="color-card">
<h3>调色盘</h3>
<div class="color-picker-row">
<div class="static-picker-container">
<el-color-picker
v-model="color"
show-alpha
:predefine="predefineColors"
:teleported="false"
popper-class="inline-picker-popper"
/>
</div>
<div class="color-info">
<el-input
v-model="hexInput"
placeholder="输入 HEX"
@change="updateColorFromHex"
size="small"
style="margin-bottom: 10px;"
/>
<div class="preview" :style="{ background: color }"></div>
<p>HEX: {{ toHex(color) }}</p>
<p>RGB: {{ hexToRgb(color) }}</p>
</div>
</div>
</el-card>
</template>
<script setup>
import { ref, watch } from "vue"
// 注意:这里不需要 import 任何 Panel,让插件自动处理 el-color-picker
const color = ref("#409EFF")
const hexInput = ref(color.value)
const predefineColors = ["#409EFF", "#67C23A", "#E6A23C", "#F56C6C", "#909399"]
// --- 转换逻辑保持不变 ---
watch(color, (val) => {
hexInput.value = toHex(val)
})
function updateColorFromHex() {
if (/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(hexInput.value)) {
color.value = hexInput.value
}
}
function hexToRgb(hex) {
if (!hex || typeof hex !== 'string') return ''
if (hex.startsWith('rgb')) return hex
let c = hex.substring(1).split('')
if (c.length === 3) c = [c[0], c[0], c[1], c[1], c[2], c[2]]
const r = parseInt(c[0] + c[1], 16)
const g = parseInt(c[2] + c[3], 16)
const b = parseInt(c[4] + c[5], 16)
return `rgb(${r}, ${g}, ${b})`
}
function toHex(value) {
if (!value || typeof value !== 'string') return ''
if (value.startsWith("rgb")) {
const nums = value.match(/\d+/g)
if (nums && nums.length >= 3) {
return "#" + nums.slice(0, 3).map(n => parseInt(n).toString(16).padStart(2, "0")).join("").toUpperCase()
}
}
return value
}
</script>
<style scoped>
/* 1. 隐藏选择器本身的按钮小方块 */
.static-picker-container :deep(.el-color-picker__trigger) {
display: none;
}
/* 2. 强行让本应弹出的面板显示出来 */
.static-picker-container :deep(.el-color-picker__panel),
.static-picker-container :deep(.inline-picker-popper) {
display: block !important;
position: relative !important;
top: 0 !important;
left: 0 !important;
box-shadow: none !important;
border: 1px solid #dcdfe6;
}
/* 3. 布局微调 */
.color-picker-row {
display: flex;
align-items: flex-start;
gap: 20px;
}
.preview {
width: 100px;
height: 100px;
border-radius: 8px;
border: 1px solid #ddd;
margin-bottom: 10px;
}
.color-card {
max-width: 100%;
margin: 20px auto;
}
</style>
@@ -0,0 +1,596 @@
<template>
<div class="mod-weather__real-time-monitoring" v-loading="state.loading">
<div class="page-header">
<div>
<h2 class="page-title">631气象信息</h2>
</div>
<div class="page-header-actions">
<el-tag type="info">层级 {{ state.selectedPath.length }} </el-tag>
<el-tag type="success">文件 {{ state.fileItems.length }}</el-tag>
<el-button type="primary" plain icon="Refresh" @click="refresh">刷新</el-button>
</div>
</div>
<el-alert v-if="state.errorMessage" :title="state.errorMessage" type="error" :closable="false" show-icon class="mb-16" />
<div class="dept-bar" v-if="levelRows.length">
<div class="level-row" v-for="(row, rowIdx) in levelRows" :key="rowIdx">
<span class="level-label" v-if="row.label">{{ row.label }}</span>
<div class="level-tabs">
<div v-for="tab in row.tabs" :key="tab.id" class="level-tab" :class="[{ 'is-active': row.selectedId === tab.id }, `level-${rowIdx}`]" @click="selectTab(rowIdx, tab)">
{{ tab.name }}
</div>
</div>
</div>
</div>
<div class="page-body" v-if="state.fileItems.length">
<aside class="file-list-panel">
<div v-for="(item, index) in state.fileItems" :key="item.anchorId" class="file-list-item" :class="{ 'is-active': state.selectedFileIndex === index }" @click="selectFile(index)">
<div class="file-list-item__thumb">
<span class="file-list-item__icon file-list-item__icon--txt"></span>
</div>
<span class="file-list-item__name">{{ item.displayName || `文件-${item.fileId}` }}</span>
</div>
</aside>
<section class="preview-panel" v-if="previewItem">
<div class="preview-panel__header">
<h3 class="preview-panel__title">{{ previewItem.displayName || `文件-${previewItem.fileId}` }}</h3>
<el-button type="primary" plain size="small" :loading="state.copyingId === previewItem.anchorId" @click="copyText(previewItem)"> 复制全部文本 </el-button>
</div>
<div class="preview-panel__body">
<div class="preview-panel__text preview-panel__text--full">{{ previewItem.content || "暂无文本内容" }}</div>
</div>
</section>
<div class="preview-panel preview-panel--empty" v-else>
<el-empty description="请在左侧选择要预览的文件" :image-size="120" />
</div>
</div>
<el-empty v-else-if="!state.loading && !state.errorMessage" description="暂无可供展示的文件" />
</div>
</template>
<script lang="ts" setup>
import app from "@/constants/app";
import baseService from "@/service/baseService";
import { copyToClipboard } from "@/utils/utils";
import { ElMessage } from "element-plus";
import { computed, onMounted, reactive } from "vue";
interface DeptNode {
id: string;
name: string;
children?: DeptNode[];
}
interface DeptTab {
id: string;
name: string;
}
interface DeptFileGroupVO {
deptId: string;
deptName: string;
fileList: { displayName: string; fileId: string; type: string; content: string }[];
}
interface FileItem {
deptId: string;
deptName: string;
displayName: string;
fileId: string;
type: string;
content: string;
anchorId: string;
}
const IMAGE_EXTS = ["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg"];
const isImageFile = (type: string) => {
if (!type) return true;
const lower = type.toLowerCase();
return IMAGE_EXTS.some((ext) => lower.includes(ext));
};
const state = reactive({
loading: true,
errorMessage: "",
fileItems: [] as FileItem[],
copyingId: "",
deptTreeData: [] as DeptNode[],
selectedPath: [] as string[],
selectedFileIndex: 0
});
const previewItem = computed(() => {
if (state.selectedFileIndex >= 0 && state.selectedFileIndex < state.fileItems.length) {
return state.fileItems[state.selectedFileIndex];
}
return state.fileItems.length > 0 ? state.fileItems[0] : null;
});
const transformGroups = (list: DeptFileGroupVO[]) => {
const items: FileItem[] = [];
list.forEach((group) => {
(group.fileList || []).forEach((file) => {
if (isImageFile(file.type)) return;
items.push({
deptId: group.deptId,
deptName: group.deptName,
displayName: file.displayName,
fileId: file.fileId,
type: file.type,
content: file.content || "",
anchorId: `txt-${file.fileId}`
});
});
});
state.fileItems = items;
state.selectedFileIndex = 0;
};
const activeDeptId = computed(() => {
if (state.selectedPath.length > 0) {
return state.selectedPath[state.selectedPath.length - 1];
}
return null;
});
interface LevelRow {
tabs: DeptTab[];
selectedId: string | null;
label: string;
}
const LEVEL_LABELS = ["一级", "二级", "三级", "四级", "五级", "六级"];
const levelRows = computed<LevelRow[]>(() => {
const rows: LevelRow[] = [];
let nodes: DeptNode[] = state.deptTreeData;
for (let i = 0; i < state.selectedPath.length; i++) {
const selId = state.selectedPath[i];
rows.push({
tabs: nodes.map((n) => ({ id: n.id, name: n.name })),
selectedId: selId,
label: LEVEL_LABELS[i] || `${i + 1}`
});
const selNode = nodes.find((n) => n.id === selId);
if (selNode?.children?.length) {
nodes = selNode.children;
} else {
nodes = [];
break;
}
}
if (nodes.length > 0) {
rows.push({
tabs: nodes.map((n) => ({ id: n.id, name: n.name })),
selectedId: null,
label: LEVEL_LABELS[rows.length] || `${rows.length + 1}`
});
}
return rows;
});
const loadData = async (deptId: string) => {
state.loading = true;
state.errorMessage = "";
try {
const res = await baseService.get("/filescan/record/tree", { deptId });
transformGroups((res.data || []) as DeptFileGroupVO[]);
selectFile(0);
} catch (error: any) {
state.errorMessage = error?.message || "加载失败";
state.fileItems = [];
} finally {
state.loading = false;
}
};
const loadDeptTree = async (): Promise<string | null> => {
try {
const res = await baseService.get("/sys/dept/list");
const treeData = (res.data || []) as DeptNode[];
state.deptTreeData = treeData;
if (!treeData.length) return null;
const firstRoot = treeData[0];
const firstChild = firstRoot.children?.length ? firstRoot.children[0] : firstRoot;
const buildPath = (nodes: DeptNode[], targetId: string, path: string[]): string[] | null => {
for (const node of nodes) {
const newPath = [...path, node.id];
if (node.id === targetId) return newPath;
if (node.children?.length) {
const found = buildPath(node.children, targetId, newPath);
if (found) return found;
}
}
return null;
};
const path = buildPath(treeData, firstChild.id, []);
state.selectedPath = path || [firstRoot.id];
return firstChild.id;
} catch {
state.deptTreeData = [];
return null;
}
};
const selectTab = (levelIdx: number, tab: DeptTab) => {
state.selectedPath = [...state.selectedPath.slice(0, levelIdx), tab.id];
state.fileItems = [];
state.selectedFileIndex = 0;
loadData(tab.id);
};
const refresh = () => {
if (activeDeptId.value !== null) {
loadData(activeDeptId.value);
}
};
const selectFile = (index: number) => {
state.selectedFileIndex = index;
};
const copyText = (item: FileItem) => {
state.copyingId = item.anchorId;
copyToClipboard(item.content || "");
ElMessage.success("文本已复制成功");
state.copyingId = "";
};
onMounted(async () => {
const deptId = await loadDeptTree();
if (deptId !== null) {
loadData(deptId);
}
});
</script>
<style scoped>
.mod-weather__real-time-monitoring {
min-height: calc(100vh - 140px);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.page-title {
margin: 0;
font-size: 24px;
color: #303133;
}
.page-desc {
margin: 8px 0 0;
color: #909399;
line-height: 1.6;
}
.page-header-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.mb-16 {
margin-bottom: 16px;
}
/* ==== 部门选择栏 ==== */
.dept-bar {
margin-bottom: 16px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 10px 16px 8px;
}
.level-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.level-row:last-child {
margin-bottom: 4px;
}
.level-label {
font-size: 13px;
color: #909399;
flex-shrink: 0;
min-width: 32px;
}
.level-tabs {
display: flex;
gap: 0;
flex-wrap: wrap;
}
.level-tab {
padding: 4px 16px;
font-size: 13px;
color: #606266;
background: #f5f7fa;
border: 1px solid #e4e7ed;
border-radius: 6px;
cursor: pointer;
user-select: none;
transition: color 0.2s, background 0.2s, border-color 0.2s;
}
.level-tab + .level-tab {
margin-left: 6px;
}
.level-tab:hover {
color: var(--el-color-primary);
background: #ecf5ff;
}
.level-tab.is-active {
color: #fff;
font-weight: 500;
}
.level-tab.is-active.level-0 {
background: var(--el-color-primary);
border-color: var(--el-color-primary);
}
.level-tab.is-active.level-1 {
background: var(--el-color-primary-light-3);
border-color: var(--el-color-primary-light-3);
}
.level-tab.is-active.level-2 {
background: var(--el-color-success);
border-color: var(--el-color-success);
}
.level-tab.is-active.level-3 {
background: var(--el-color-warning);
border-color: var(--el-color-warning);
}
.level-tab.is-active.level-4 {
background: var(--el-color-danger);
border-color: var(--el-color-danger);
}
.level-tab.is-active.level-5 {
background: #8b5cf6;
border-color: #8b5cf6;
}
/* ==== 主体两栏 ==== */
.page-body {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 12px;
align-items: start;
}
/* ==== 左侧文件列表 ==== */
.file-list-panel {
position: sticky;
top: 16px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 4px;
max-height: calc(100vh - 320px);
overflow-y: auto;
}
.file-list-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
font-size: 13px;
color: #606266;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.file-list-item:hover {
background: #f5f7fa;
}
.file-list-item.is-active {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
font-weight: 500;
}
.file-list-item__thumb {
flex-shrink: 0;
width: 44px;
height: 44px;
border-radius: 6px;
overflow: hidden;
background: #f0f2f5;
display: flex;
align-items: center;
justify-content: center;
}
.file-list-item__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.file-list-item__icon {
font-size: 13px;
font-weight: 600;
color: #fff;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.file-list-item__icon--img {
background: var(--el-color-success);
}
.file-list-item__icon--txt {
background: var(--el-color-warning);
}
.file-list-item__icon--other {
background: #c0c4cc;
}
.file-list-item__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.5;
}
/* ==== 右侧预览面板 ==== */
.preview-panel {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 10px;
padding: 20px;
}
.preview-panel--empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
}
.preview-panel__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.preview-panel__actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.preview-panel__title {
margin: 0;
font-size: 20px;
color: #303133;
word-break: break-all;
}
.preview-panel__body {
display: flex;
flex-direction: column;
gap: 12px;
}
.preview-panel__loading {
text-align: center;
color: #909399;
padding: 60px 0;
font-size: 14px;
}
.preview-zoom-area {
overflow: hidden;
border-radius: 8px;
background: #f5f7fa;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.preview-panel__img {
display: block;
max-width: 100%;
max-height: 60vh;
border-radius: 8px;
user-select: none;
pointer-events: auto;
}
.preview-panel__text--full {
min-height: 200px;
max-height: 60vh;
overflow-y: auto;
}
.preview-panel__text {
font-size: 13px;
color: #606266;
line-height: 1.8;
white-space: pre-wrap;
word-break: break-word;
padding: 12px 14px;
background: #f5f7fa;
border-radius: 8px;
}
.image-card__error,
.image-card__unsupported {
color: #e6a23c;
font-size: 14px;
line-height: 1.6;
padding: 12px 14px;
background: #fdf6ec;
border-radius: 8px;
}
/* ==== 响应式 ==== */
@media (max-width: 992px) {
.page-body {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.page-header,
.preview-panel__header,
.preview-panel__actions {
flex-direction: column;
align-items: stretch;
}
.level-tab {
padding: 4px 10px;
font-size: 12px;
}
}
</style>
@@ -0,0 +1,583 @@
<template>
<div class="mod-weather__prediction" v-loading="state.loading">
<div class="page-header">
<div>
<h2 class="page-title">中尺度模式回波预报</h2>
</div>
<div class="page-header-actions">
<el-tag type="success">文件 {{ state.fileItems.length }}</el-tag>
<el-button type="primary" plain icon="Refresh" @click="loadData">刷新</el-button>
</div>
</div>
<el-alert v-if="state.errorMessage" :title="state.errorMessage" type="error" :closable="false" show-icon class="mb-16" />
<div class="page-body" v-if="state.fileItems.length">
<aside class="file-list-panel">
<div v-for="(item, index) in state.fileItems" :key="item.anchorId" class="file-list-item" :class="{ 'is-active': state.selectedFileIndex === index }" @click="selectFile(index)">
<div class="file-list-item__thumb">
<img v-if="blobUrlCache[item.fileId]" :src="blobUrlCache[item.fileId]" class="file-list-item__img" alt="{{item.displayName}}}" />
<span v-else-if="isImageFile(item.type)" class="file-list-item__icon file-list-item__icon--img">GIF</span>
<span v-else class="file-list-item__icon file-list-item__icon--other"></span>
</div>
<span class="file-list-item__name">{{ item.displayName || `文件-${item.fileId}` }}</span>
</div>
</aside>
<section class="preview-panel" v-if="previewItem">
<div class="preview-panel__header">
<h3 class="preview-panel__title">{{ previewItem.displayName || `文件-${previewItem.fileId}` }}</h3>
<div class="preview-panel__actions">
<el-button type="primary" plain size="small" :loading="state.copyingId === previewItem.anchorId" @click="copyImage(previewItem)"> 复制图片 </el-button>
<el-button type="success" plain size="small" @click="downloadFile(previewItem)"> 下载原图 </el-button>
<el-button type="warning" plain size="small" @click="openFullscreen"> 全屏预览 </el-button>
</div>
</div>
<div class="preview-panel__body">
<div v-if="!previewBlobUrl && selectedFileId" class="preview-panel__loading">加载中...</div>
<div v-show="previewBlobUrl" class="preview-zoom-area">
<img :src="previewBlobUrl" :alt="previewItem.displayName" class="preview-panel__img" draggable="false" @click="openFullscreen" />
</div>
</div>
</section>
<div class="preview-panel preview-panel--empty" v-else>
<el-empty description="请在左侧选择要预览的文件" :image-size="120" />
</div>
</div>
<el-empty v-else-if="!state.loading && !state.errorMessage" description="暂无可供展示的文件" />
<!-- 全屏预览 -->
<teleport to="body">
<div v-if="fullscreenVisible" class="fullscreen-overlay" @click="closeFullscreen">
<div class="fullscreen-overlay__toolbar">
<span class="fullscreen-overlay__hint">滚轮缩放 · 拖拽移动 · 双击重置</span>
<el-button circle size="large" @click="closeFullscreen"></el-button>
</div>
<img
:src="previewBlobUrl"
:alt="previewItem?.displayName"
:style="fsZoomStyle"
class="fullscreen-overlay__img"
@click.stop
@wheel.prevent="fsOnWheel"
@mousedown="fsOnPanStart"
@dblclick="fsResetZoom"
draggable="false"
/>
</div>
</teleport>
</div>
</template>
<script lang="ts" setup>
import app from "@/constants/app";
import { CacheToken } from "@/constants/cacheKey";
import baseService from "@/service/baseService";
import { getCache } from "@/utils/cache";
import { ElMessage } from "element-plus";
import axios from "axios";
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
interface ModelFileItemVO {
type: string;
display_name: string;
file_name: string;
file_id: string;
}
interface ModelFileGroupVO {
type: string;
file_list: ModelFileItemVO[];
}
interface FileItem {
type: string;
displayName: string;
fileId: string;
anchorId: string;
}
const state = reactive({
loading: true,
errorMessage: "",
fileItems: [] as FileItem[],
copyingId: "",
selectedFileIndex: 0
});
const blobUrlCache = {} as Record<string, string>;
const blobPromises = {} as Record<string, Promise<string> | undefined>;
const previewBlobUrl = ref("");
const selectedFileId = ref("");
const IMAGE_EXTS = ["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg"];
const isImageFile = (type: string) => {
if (!type) return true;
const lower = type.toLowerCase();
return IMAGE_EXTS.some((ext) => lower.includes(ext));
};
const previewItem = computed(() => {
if (state.selectedFileIndex >= 0 && state.selectedFileIndex < state.fileItems.length) {
return state.fileItems[state.selectedFileIndex];
}
return state.fileItems.length > 0 ? state.fileItems[0] : null;
});
const loadBlobUrl = (fileId: string): Promise<string> => {
if (blobUrlCache[fileId]) return Promise.resolve(blobUrlCache[fileId]);
if (blobPromises[fileId]) return blobPromises[fileId];
const base = (app.api || "").replace(/\/$/, "");
const url = `${base}/filescan/file/display/${fileId}`;
const token = getCache(CacheToken, { isSessionStorage: true }, {})?.token;
const headers: Record<string, string> = {};
if (token) headers.token = token;
const promise = axios
.get(url, { responseType: "blob", headers })
.then((res) => {
const objectUrl = URL.createObjectURL(res.data as Blob);
blobUrlCache[fileId] = objectUrl;
return objectUrl;
})
.finally(() => {
delete blobPromises[fileId];
});
blobPromises[fileId] = promise;
return promise;
};
const selectFile = async (index: number) => {
state.selectedFileIndex = index;
const item = state.fileItems[index];
if (!item) return;
selectedFileId.value = item.fileId;
previewBlobUrl.value = "";
previewBlobUrl.value = await loadBlobUrl(item.fileId);
};
// 全屏预览
const fullscreenVisible = ref(false);
const fsZoomScale = ref(1);
const fsPanX = ref(0);
const fsPanY = ref(0);
const fsIsPanning = ref(false);
let fsPanStartX = 0;
let fsPanStartY = 0;
let fsPanOriginX = 0;
let fsPanOriginY = 0;
const fsZoomStyle = computed(() => ({
transform: `translate(${fsPanX.value}px, ${fsPanY.value}px) scale(${fsZoomScale.value})`,
cursor: fsIsPanning.value ? "grabbing" : fsZoomScale.value > 1 ? "grab" : "default",
transition: fsIsPanning.value ? "none" : "transform 0.15s ease"
}));
const fsResetZoom = () => {
fsZoomScale.value = 1;
fsPanX.value = 0;
fsPanY.value = 0;
};
const openFullscreen = () => {
fsResetZoom();
fullscreenVisible.value = true;
};
const closeFullscreen = () => {
fullscreenVisible.value = false;
};
const fsOnWheel = (e: WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.15 : 0.15;
const newScale = Math.max(0.2, Math.min(8, fsZoomScale.value + delta));
fsZoomScale.value = newScale;
if (newScale <= 1) {
fsPanX.value = 0;
fsPanY.value = 0;
}
};
const fsOnPanStart = (e: MouseEvent) => {
if (fsZoomScale.value <= 1) return;
fsIsPanning.value = true;
fsPanStartX = e.clientX;
fsPanStartY = e.clientY;
fsPanOriginX = fsPanX.value;
fsPanOriginY = fsPanY.value;
document.addEventListener("mousemove", fsOnPanMove);
document.addEventListener("mouseup", fsOnPanEnd);
};
const fsOnPanMove = (e: MouseEvent) => {
if (!fsIsPanning.value) return;
fsPanX.value = fsPanOriginX + (e.clientX - fsPanStartX);
fsPanY.value = fsPanOriginY + (e.clientY - fsPanStartY);
};
const fsOnPanEnd = () => {
fsIsPanning.value = false;
document.removeEventListener("mousemove", fsOnPanMove);
document.removeEventListener("mouseup", fsOnPanEnd);
};
const downloadFile = async (item: FileItem) => {
const filename = item.displayName || `file-${item.fileId}`;
await loadBlobUrl(item.fileId);
const a = document.createElement("a");
a.href = blobUrlCache[item.fileId];
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const copyImage = async (item: FileItem) => {
if (!isImageFile(item.type)) {
ElMessage.warning("当前文件不是图片类型,无法复制");
return;
}
state.copyingId = item.anchorId;
try {
await loadBlobUrl(item.fileId);
const blobUrl = blobUrlCache[item.fileId];
const res = await fetch(blobUrl);
const blob = await res.blob();
try {
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
} catch (e: any) {
if (e.name === "NotAllowedError" || e.message?.includes?.("not supported")) {
await convertToPngAndCopy(blob);
} else {
throw e;
}
}
ElMessage.success("图片已复制成功");
} catch (error: any) {
ElMessage.error(error?.message || "复制图片失败");
} finally {
state.copyingId = "";
}
};
const convertToPngAndCopy = (blob: Blob): Promise<void> => {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return reject(new Error("Canvas 不可用"));
ctx.drawImage(img, 0, 0);
canvas.toBlob((pngBlob) => {
if (pngBlob) {
navigator.clipboard
.write([new ClipboardItem({ "image/png": pngBlob })])
.then(resolve)
.catch(reject);
} else {
reject(new Error("转 PNG 失败"));
}
}, "image/png");
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("图片加载失败"));
};
img.src = url;
});
};
const loadData = async () => {
state.loading = true;
state.errorMessage = "";
try {
const res = await baseService.get("/filescan/record/model");
const data = res.data as ModelFileGroupVO;
const items: FileItem[] = (data.file_list || []).map((file) => ({
type: file.type || "",
displayName: file.display_name || file.file_name || "",
fileId: String(file.file_id),
anchorId: `pred-${file.file_id}`
}));
state.fileItems = items;
state.selectedFileIndex = 0;
const preloads = items.filter((item) => isImageFile(item.type)).map((item) => loadBlobUrl(item.fileId));
if (items.length > 0) {
selectFile(0);
}
await Promise.allSettled(preloads);
} catch (error: any) {
state.errorMessage = error?.message || "加载模式预报文件失败";
state.fileItems = [];
} finally {
state.loading = false;
}
};
onUnmounted(() => {
Object.values(blobUrlCache).forEach((url) => URL.revokeObjectURL(url));
});
onMounted(() => {
loadData();
});
</script>
<style scoped>
.mod-weather__prediction {
min-height: calc(100vh - 140px);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.page-title {
margin: 0;
font-size: 24px;
color: #303133;
}
.page-header-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.mb-16 {
margin-bottom: 16px;
}
/* ==== 主体两栏 ==== */
.page-body {
display: grid;
grid-template-columns: 25% minmax(0, 1fr);
gap: 12px;
}
/* ==== 左侧文件列表 ==== */
.file-list-panel {
position: sticky;
top: 16px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 4px;
max-height: calc(100vh - 220px);
overflow-y: auto;
}
.file-list-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
font-size: 13px;
color: #606266;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.file-list-item:hover {
background: #f5f7fa;
}
.file-list-item.is-active {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
font-weight: 500;
}
.file-list-item__thumb {
flex-shrink: 0;
width: 44px;
height: 44px;
border-radius: 6px;
overflow: hidden;
background: #f0f2f5;
display: flex;
align-items: center;
justify-content: center;
}
.file-list-item__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.file-list-item__icon {
font-size: 12px;
font-weight: 600;
color: #fff;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.file-list-item__icon--img {
background: var(--el-color-success);
}
.file-list-item__icon--other {
background: #c0c4cc;
}
.file-list-item__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.5;
}
/* ==== 右侧预览面板 ==== */
.preview-panel {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 10px;
padding: 20px;
}
.preview-panel--empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
}
.preview-panel__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.preview-panel__actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.preview-panel__title {
margin: 0;
font-size: 20px;
color: #303133;
word-break: break-all;
}
.preview-panel__body {
display: flex;
flex-direction: column;
gap: 12px;
}
.preview-panel__loading {
text-align: center;
color: #909399;
padding: 60px 0;
font-size: 14px;
}
.preview-zoom-area {
overflow: hidden;
border-radius: 8px;
background: #f5f7fa;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.preview-panel__img {
display: block;
max-width: 100%;
max-height: 70vh;
border-radius: 8px;
cursor: pointer;
}
/* ==== 全屏预览遮罩 ==== */
.fullscreen-overlay {
position: fixed;
inset: 0;
z-index: 3000;
background: rgba(0, 0, 0, 0.92);
display: flex;
align-items: center;
justify-content: center;
}
.fullscreen-overlay__toolbar {
position: absolute;
top: 20px;
right: 20px;
z-index: 3001;
display: flex;
align-items: center;
gap: 16px;
}
.fullscreen-overlay__hint {
color: rgba(255, 255, 255, 0.55);
font-size: 13px;
user-select: none;
}
.fullscreen-overlay__img {
display: block;
max-width: 95vw;
max-height: 95vh;
object-fit: contain;
border-radius: 4px;
user-select: none;
}
/* ==== 响应式 ==== */
@media (max-width: 992px) {
.page-body {
grid-template-columns: 1fr;
}
.file-list-panel {
position: static;
max-height: 200px;
}
}
@media (max-width: 768px) {
.page-header,
.preview-panel__header,
.preview-panel__actions {
flex-direction: column;
align-items: stretch;
}
}
</style>
@@ -0,0 +1,815 @@
<template>
<div class="mod-weather__real-time-monitoring" v-loading="state.loading">
<div class="page-header">
<div>
<h2 class="page-title">气候实时监测</h2>
</div>
<div class="page-header-actions">
<el-tag type="info">层级 {{ state.selectedPath.length }} </el-tag>
<el-tag type="success">文件 {{ state.imageItems.length }}</el-tag>
<el-button type="primary" plain icon="Refresh" @click="refresh">刷新</el-button>
</div>
</div>
<el-alert v-if="state.errorMessage" :title="state.errorMessage" type="error" :closable="false" show-icon class="mb-16" />
<div class="dept-bar" v-if="levelRows.length">
<div class="level-row" v-for="(row, rowIdx) in levelRows" :key="rowIdx">
<span class="level-label" v-if="row.label">{{ row.label }}</span>
<div class="level-tabs">
<div v-for="tab in row.tabs" :key="tab.id" class="level-tab" :class="[{ 'is-active': row.selectedId === tab.id }, `level-${rowIdx}`]" @click="selectTab(rowIdx, tab)">
{{ tab.name }}
</div>
</div>
</div>
</div>
<div class="page-body" v-if="state.imageItems.length">
<aside class="file-list-panel">
<div v-for="(item, index) in state.imageItems" :key="item.anchorId" class="file-list-item" :class="{ 'is-active': state.selectedFileIndex === index }" @click="selectFile(index)">
<div class="file-list-item__thumb">
<img v-if="blobUrlCache[item.fileId]" :src="blobUrlCache[item.fileId]" class="file-list-item__img" />
<span v-else-if="isImageFile(item.type)" class="file-list-item__icon file-list-item__icon--img"></span>
<span v-else-if="isTextFile(item.type)" class="file-list-item__icon file-list-item__icon--txt"></span>
<span v-else class="file-list-item__icon file-list-item__icon--other"></span>
</div>
<span class="file-list-item__name">{{ item.displayName || `文件-${item.fileId}` }}</span>
</div>
</aside>
<section class="preview-panel" v-if="previewItem">
<div class="preview-panel__header">
<h3 class="preview-panel__title">{{ previewItem.displayName || `文件-${previewItem.fileId}` }}</h3>
<div class="preview-panel__actions">
<el-button v-if="fileMode === 'image'" type="primary" plain size="small" :loading="state.copyingId === previewItem.anchorId" @click="copyImage(previewItem)"> 复制图片 </el-button>
<el-button v-else-if="fileMode === 'text'" type="primary" plain size="small" :loading="state.copyingId === previewItem.anchorId" @click="copyText(previewItem)"> 复制全部文本 </el-button>
<el-button v-if="fileMode === 'image'" type="success" plain size="small" @click="downloadFile(previewItem)"> 下载原图 </el-button>
</div>
</div>
<div class="preview-panel__body" v-if="fileMode === 'image'">
<div v-if="!previewBlobUrl && selectedFileId" class="preview-panel__loading">加载中...</div>
<div v-show="previewBlobUrl" class="preview-zoom-area" @wheel.prevent="onWheel" @mousedown="onPanStart" @dblclick="resetZoom">
<img :src="previewBlobUrl" :alt="previewItem.displayName" :style="zoomStyle" class="preview-panel__img" draggable="false" />
</div>
<div v-if="previewItem.content" class="preview-panel__text">{{ previewItem.content }}</div>
</div>
<div class="preview-panel__body" v-else-if="fileMode === 'text'">
<div class="preview-panel__text preview-panel__text--full">{{ previewItem.content || "暂无文本内容" }}</div>
</div>
<div class="preview-panel__body" v-else-if="fileMode === 'unsupported'">
<div class="image-card__unsupported">当前文件类型暂不支持预览</div>
</div>
</section>
<div class="preview-panel preview-panel--empty" v-else>
<el-empty description="请在左侧选择要预览的文件" :image-size="120" />
</div>
</div>
<el-empty v-else-if="!state.loading && !state.errorMessage" description="暂无可展示图片" />
</div>
</template>
<script lang="ts" setup>
import app from "@/constants/app";
import { CacheToken } from "@/constants/cacheKey";
import baseService from "@/service/baseService";
import { getCache } from "@/utils/cache";
import { copyToClipboard } from "@/utils/utils";
import { ElMessage } from "element-plus";
import axios from "axios";
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
interface DeptNode {
id: string;
name: string;
children?: DeptNode[];
}
interface DeptTab {
id: string;
name: string;
}
interface DeptFileItemVO {
displayName: string;
fileId: string;
type: string;
content: string;
}
interface DeptFileGroupVO {
deptId: string;
deptName: string;
fileList: DeptFileItemVO[];
}
interface ImageItem {
deptId: string;
deptName: string;
displayName: string;
fileId: string;
type: string;
content: string;
anchorId: string;
}
const state = reactive({
loading: true,
errorMessage: "",
imageItems: [] as ImageItem[],
copyingId: "",
deptTreeData: [] as DeptNode[],
selectedPath: [] as string[],
selectedFileIndex: 0
});
const blobUrlCache = {} as Record<string, string>;
const blobPromises = {} as Record<string, Promise<string> | undefined>;
const previewBlobUrl = ref("");
const loadBlobUrl = (fileId: string): Promise<string> => {
if (blobUrlCache[fileId]) return Promise.resolve(blobUrlCache[fileId]);
if (blobPromises[fileId]) return blobPromises[fileId];
const base = (app.api || "").replace(/\/$/, "");
const url = `${base}/filescan/file/display/${fileId}`;
const token = getCache(CacheToken, { isSessionStorage: true }, {})?.token;
const headers: Record<string, string> = {};
if (token) headers.token = token;
const promise = axios
.get(url, { responseType: "blob", headers })
.then((res) => {
const objectUrl = URL.createObjectURL(res.data as Blob);
blobUrlCache[fileId] = objectUrl;
return objectUrl;
})
.finally(() => {
delete blobPromises[fileId];
});
blobPromises[fileId] = promise;
return promise;
};
const activeDeptId = computed(() => {
if (state.selectedPath.length > 0) {
return state.selectedPath[state.selectedPath.length - 1];
}
return null;
});
interface LevelRow {
tabs: DeptTab[];
selectedId: string | null;
label: string;
}
const LEVEL_LABELS = ["一级", "二级", "三级", "四级", "五级", "六级"];
const levelRows = computed<LevelRow[]>(() => {
const rows: LevelRow[] = [];
let nodes: DeptNode[] = state.deptTreeData;
for (let i = 0; i < state.selectedPath.length; i++) {
const selId = state.selectedPath[i];
rows.push({
tabs: nodes.map((n) => ({ id: n.id, name: n.name })),
selectedId: selId,
label: LEVEL_LABELS[i] || `${i + 1}`
});
const selNode = nodes.find((n) => n.id === selId);
if (selNode?.children?.length) {
nodes = selNode.children;
} else {
nodes = [];
break;
}
}
if (nodes.length > 0) {
rows.push({
tabs: nodes.map((n) => ({ id: n.id, name: n.name })),
selectedId: null,
label: LEVEL_LABELS[rows.length] || `${rows.length + 1}`
});
}
return rows;
});
const previewItem = computed(() => {
if (state.selectedFileIndex >= 0 && state.selectedFileIndex < state.imageItems.length) {
return state.imageItems[state.selectedFileIndex];
}
return state.imageItems.length > 0 ? state.imageItems[0] : null;
});
const IMAGE_EXTS = ["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg"];
const TEXT_EXTS = ["txt", "md", "log", "csv", "json", "xml", "html", "htm", "yaml", "yml"];
const isImageFile = (type: string) => {
if (!type) return true;
const lower = type.toLowerCase();
return IMAGE_EXTS.some((ext) => lower.includes(ext));
};
const isTextFile = (type: string) => {
if (!type) return false;
const lower = type.toLowerCase();
return TEXT_EXTS.some((ext) => lower.includes(ext));
};
const fileMode = computed(() => {
const item = previewItem.value;
if (!item) return "none";
if (isImageFile(item.type)) return "image";
if (isTextFile(item.type)) return "text";
return "unsupported";
});
const zoomScale = ref(1);
const panX = ref(0);
const panY = ref(0);
const isPanning = ref(false);
let panStartX = 0;
let panStartY = 0;
let panOriginX = 0;
let panOriginY = 0;
const zoomStyle = computed(() => ({
transform: `translate(${panX.value}px, ${panY.value}px) scale(${zoomScale.value})`,
cursor: isPanning.value ? "grabbing" : "grab",
transition: isPanning.value ? "none" : "transform 0.15s ease"
}));
const resetZoom = () => {
zoomScale.value = 1;
panX.value = 0;
panY.value = 0;
};
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.15 : 0.15;
const newScale = Math.max(0.2, Math.min(5, zoomScale.value + delta));
zoomScale.value = newScale;
if (newScale <= 1) {
panX.value = 0;
panY.value = 0;
}
};
const onPanStart = (e: MouseEvent) => {
if (zoomScale.value <= 1) return;
isPanning.value = true;
panStartX = e.clientX;
panStartY = e.clientY;
panOriginX = panX.value;
panOriginY = panY.value;
document.addEventListener("mousemove", onPanMove);
document.addEventListener("mouseup", onPanEnd);
};
const onPanMove = (e: MouseEvent) => {
if (!isPanning.value) return;
panX.value = panOriginX + (e.clientX - panStartX);
panY.value = panOriginY + (e.clientY - panStartY);
};
const onPanEnd = () => {
isPanning.value = false;
document.removeEventListener("mousemove", onPanMove);
document.removeEventListener("mouseup", onPanEnd);
};
const transformGroups = (list: DeptFileGroupVO[]) => {
const imageItems: ImageItem[] = [];
list.forEach((group) => {
(group.fileList || []).forEach((file) => {
imageItems.push({
deptId: group.deptId,
deptName: group.deptName,
displayName: file.displayName,
fileId: file.fileId,
type: file.type,
content: file.content || "",
anchorId: `img-${file.fileId}`
});
});
});
state.imageItems = imageItems;
state.selectedFileIndex = 0;
};
const loadData = async (deptId: string) => {
state.loading = true;
state.errorMessage = "";
try {
const res = await baseService.get("/filescan/record/tree", { deptId });
transformGroups((res.data || []) as DeptFileGroupVO[]);
const preloads = state.imageItems.filter((item) => isImageFile(item.type)).map((item) => loadBlobUrl(item.fileId));
selectFile(0);
await Promise.allSettled(preloads);
} catch (error: any) {
state.errorMessage = error?.message || "加载实时监测图片失败";
state.imageItems = [];
} finally {
state.loading = false;
}
};
const loadDeptTree = async (): Promise<string | null> => {
try {
const res = await baseService.get("/sys/dept/list");
const treeData = (res.data || []) as DeptNode[];
state.deptTreeData = treeData;
if (!treeData.length) return null;
const firstRoot = treeData[0];
const firstChild = firstRoot.children?.length ? firstRoot.children[0] : firstRoot;
const buildPath = (nodes: DeptNode[], targetId: string, path: string[]): string[] | null => {
for (const node of nodes) {
const newPath = [...path, node.id];
if (node.id === targetId) return newPath;
if (node.children?.length) {
const found = buildPath(node.children, targetId, newPath);
if (found) return found;
}
}
return null;
};
const path = buildPath(treeData, firstChild.id, []);
state.selectedPath = path || [firstRoot.id];
return firstChild.id;
} catch {
state.deptTreeData = [];
return null;
}
};
const selectTab = (levelIdx: number, tab: DeptTab) => {
state.selectedPath = [...state.selectedPath.slice(0, levelIdx), tab.id];
state.imageItems = [];
state.selectedFileIndex = 0;
loadData(tab.id);
};
const refresh = () => {
if (activeDeptId.value !== null) {
loadData(activeDeptId.value);
}
};
const selectedFileId = ref("");
const selectFile = async (index: number) => {
state.selectedFileIndex = index;
const item = state.imageItems[index];
if (!item) return;
selectedFileId.value = item.fileId;
resetZoom();
if (isImageFile(item.type)) {
previewBlobUrl.value = "";
const objectUrl = await loadBlobUrl(item.fileId);
previewBlobUrl.value = objectUrl;
} else {
previewBlobUrl.value = "";
}
};
const downloadFile = async (item: ImageItem) => {
const filename = item.displayName || `file-${item.fileId}`;
await loadBlobUrl(item.fileId);
const a = document.createElement("a");
a.href = blobUrlCache[item.fileId];
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const copyText = (item: ImageItem) => {
state.copyingId = item.anchorId;
copyToClipboard(item.content || "");
ElMessage.success("文本已复制成功");
state.copyingId = "";
};
const copyImage = async (item: ImageItem) => {
if (!isImageFile(item.type)) {
ElMessage.warning("当前文件不是图片类型,无法复制");
return;
}
state.copyingId = item.anchorId;
try {
await loadBlobUrl(item.fileId);
const blobUrl = blobUrlCache[item.fileId];
const res = await fetch(blobUrl);
const blob = await res.blob();
try {
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
} catch (e: any) {
if (e.name === "NotAllowedError" || e.message?.includes?.("not supported")) {
await convertToPngAndCopy(blob);
} else {
throw e;
}
}
ElMessage.success("图片已复制成功");
} catch (error: any) {
ElMessage.error(error?.message || "复制图片失败");
} finally {
state.copyingId = "";
}
};
const convertToPngAndCopy = (blob: Blob): Promise<void> => {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return reject(new Error("Canvas 不可用"));
ctx.drawImage(img, 0, 0);
canvas.toBlob((pngBlob) => {
if (pngBlob) {
navigator.clipboard.write([new ClipboardItem({ "image/png": pngBlob })]).then(resolve).catch(reject);
} else {
reject(new Error("转 PNG 失败"));
}
}, "image/png");
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("图片加载失败"));
};
img.src = url;
});
};
onUnmounted(() => {
Object.values(blobUrlCache).forEach((url) => URL.revokeObjectURL(url));
});
onMounted(async () => {
const deptId = await loadDeptTree();
if (deptId !== null) {
loadData(deptId);
}
});
</script>
<style scoped>
.mod-weather__real-time-monitoring {
min-height: calc(100vh - 140px);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.page-title {
margin: 0;
font-size: 24px;
color: #303133;
}
.page-desc {
margin: 8px 0 0;
color: #909399;
line-height: 1.6;
}
.page-header-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.mb-16 {
margin-bottom: 16px;
}
/* ==== 部门选择栏 ==== */
.dept-bar {
margin-bottom: 16px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 10px 16px 8px;
}
.level-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.level-row:last-child {
margin-bottom: 4px;
}
.level-label {
font-size: 13px;
color: #909399;
flex-shrink: 0;
min-width: 32px;
}
.level-tabs {
display: flex;
gap: 0;
flex-wrap: wrap;
}
.level-tab {
padding: 4px 16px;
font-size: 13px;
color: #606266;
background: #f5f7fa;
border: 1px solid #e4e7ed;
border-radius: 6px;
cursor: pointer;
user-select: none;
transition: color 0.2s, background 0.2s, border-color 0.2s;
}
.level-tab + .level-tab {
margin-left: 6px;
}
.level-tab:hover {
color: var(--el-color-primary);
background: #ecf5ff;
}
.level-tab.is-active {
color: #fff;
font-weight: 500;
}
.level-tab.is-active.level-0 {
background: var(--el-color-primary);
border-color: var(--el-color-primary);
}
.level-tab.is-active.level-1 {
background: var(--el-color-primary-light-3);
border-color: var(--el-color-primary-light-3);
}
.level-tab.is-active.level-2 {
background: var(--el-color-success);
border-color: var(--el-color-success);
}
.level-tab.is-active.level-3 {
background: var(--el-color-warning);
border-color: var(--el-color-warning);
}
.level-tab.is-active.level-4 {
background: var(--el-color-danger);
border-color: var(--el-color-danger);
}
.level-tab.is-active.level-5 {
background: #8b5cf6;
border-color: #8b5cf6;
}
/* ==== 主体两栏 ==== */
.page-body {
display: grid;
grid-template-columns: 25% minmax(0, 1fr);
gap: 12px;
}
/* ==== 左侧文件列表 ==== */
.file-list-panel {
position: sticky;
top: 16px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 4px;
max-height: calc(100vh - 320px);
overflow-y: auto;
}
.file-list-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
font-size: 13px;
color: #606266;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.file-list-item:hover {
background: #f5f7fa;
}
.file-list-item.is-active {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
font-weight: 500;
}
.file-list-item__thumb {
flex-shrink: 0;
width: 44px;
height: 44px;
border-radius: 6px;
overflow: hidden;
background: #f0f2f5;
display: flex;
align-items: center;
justify-content: center;
}
.file-list-item__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.file-list-item__icon {
font-size: 13px;
font-weight: 600;
color: #fff;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.file-list-item__icon--img {
background: var(--el-color-success);
}
.file-list-item__icon--txt {
background: var(--el-color-warning);
}
.file-list-item__icon--other {
background: #c0c4cc;
}
.file-list-item__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.5;
}
/* ==== 右侧预览面板 ==== */
.preview-panel {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 10px;
padding: 20px;
}
.preview-panel--empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
}
.preview-panel__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.preview-panel__actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.preview-panel__title {
margin: 0;
font-size: 20px;
color: #303133;
word-break: break-all;
}
.preview-panel__body {
display: flex;
flex-direction: column;
gap: 12px;
}
.preview-panel__loading {
text-align: center;
color: #909399;
padding: 60px 0;
font-size: 14px;
}
.preview-zoom-area {
overflow: hidden;
border-radius: 8px;
background: #f5f7fa;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.preview-panel__img {
display: block;
max-width: 100%;
max-height: 60vh;
border-radius: 8px;
user-select: none;
pointer-events: auto;
}
.preview-panel__text--full {
min-height: 200px;
max-height: 60vh;
overflow-y: auto;
}
.preview-panel__text {
font-size: 13px;
color: #606266;
line-height: 1.8;
white-space: pre-wrap;
word-break: break-word;
padding: 12px 14px;
background: #f5f7fa;
border-radius: 8px;
}
.image-card__error,
.image-card__unsupported {
color: #e6a23c;
font-size: 14px;
line-height: 1.6;
padding: 12px 14px;
background: #fdf6ec;
border-radius: 8px;
}
/* ==== 响应式 ==== */
@media (max-width: 992px) {
.page-body {
grid-template-columns: 1fr;
}
.file-list-panel {
position: static;
max-height: 200px;
}
}
@media (max-width: 768px) {
.page-header,
.preview-panel__header,
.preview-panel__actions {
flex-direction: column;
align-items: stretch;
}
.level-tab {
padding: 4px 10px;
font-size: 12px;
}
}
</style>