This commit is contained in:
三千 2026-06-21 20:23:36 +08:00
parent ad2c4fa47a
commit 4e39953253
34 changed files with 4755 additions and 677 deletions

116
CLAUDE.md Normal file
View File

@ -0,0 +1,116 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
- Install dependencies: `npm install`
- Start the Vite dev server: `npm run dev`
- Build production assets: `npm run build` or `npm run build:prod`
- Preview the production build locally: `npm run serve`
- Lint Vue/TS sources with autofix: `npm run lint`
- Type-check the project manually: `npx vue-tsc --noEmit`
## Tests
- There is currently no project test script in `package.json` and no repository test suite outside dependency `node_modules`.
- Because no test runner is configured, there is no supported “run a single test” command yet.
## Stack and runtime model
- Vite 5 + Vue 3 + TypeScript SPA.
- UI is built with Element Plus and Element Plus icons.
- Routing uses `vue-router` with hash history.
- Global state uses Pinia.
- HTTP is centralized through Axios wrappers in `src/utils/http.ts` and `src/service/baseService.ts`.
- API base URL comes from `VITE_APP_API`, but can be overridden at runtime by `window.SITE_CONFIG.apiURL` (`src/constants/app.ts`).
## Application bootstrapping
- `src/main.ts` is the entry point. It mounts the app, installs Pinia/router/Element Plus, and globally registers the reusable `sys-*` selector/tree/radio components.
- `src/constants/app.ts` contains runtime app flags such as API URL, request timeout, keep-alive enablement, and fullscreen pages.
## Routing and menu architecture
This app does **not** rely only on static frontend routes.
- `src/router/base.ts` defines the small set of base routes that always exist: `/`, `/home`, `/login`, password update, iframe, and error pages.
- `src/router/index.ts` performs most of the real routing work in `beforeEach`:
- checks auth token
- initializes app data on first authenticated navigation
- dynamically registers backend-provided routes
- pushes visited routes into the tab system via the event bus
- `useAppStore().initApp()` (`src/store/index.ts`) fetches menus, permissions, user info, and dictionaries from the backend before the app is considered ready.
- `src/utils/router.ts` converts backend menu records into router records:
- maps backend `url` values to Vue files under `src/views`
- handles internal pages vs iframe pages vs “open in new page” links
- flattens nested routes during registration because keep-alive does not support the original multi-level structure well
- `getSysRouteMap()` in `src/router/index.ts` uses `import.meta.glob('/src/views/**/*.vue')`, so backend menu URLs are expected to correspond to files in `src/views` after `toSysViewComponentPath()` normalization.
When adding a new backend-driven page, the frontend usually needs a matching file in `src/views/...` whose path matches the menu URL convention.
## State management
There are two important Pinia stores:
- `src/store/index.ts` (`useAppStore`): global application state
- login/readiness flags
- permissions
- current user
- dictionaries
- dynamically built routes and route metadata
- tab state
- `src/store/importTasks.ts`: transient UI state for long-running weather data import tasks shown in the header.
## Layout, tabs, and UI coordination
The layout framework is event-driven.
- `src/layout/index.vue` is the main authenticated shell with header, sidebar/mobile sidebar, and content area.
- `src/layout/header/base-header.vue` wires header actions, including the import-task indicator.
- `src/layout/view/base-view.vue` wraps routed pages in `keep-alive` and supports forced tab refresh by changing component keys.
- `src/layout/sidebar/base-sidebar.vue` renders navigation from the dynamically built route tree and adapts across left/top/mixed layouts.
- `src/utils/emits.ts` exports a global `mitt` event bus.
- `src/constants/enum.ts` defines the event names (`EMitt`) and theme/layout enums used across header/sidebar/view coordination.
If you change navigation, tab refresh, sidebar behavior, or theme/layout switching, trace both the Pinia store and the `mitt` events.
## HTTP and authentication flow
- `src/utils/http.ts` owns the Axios instance and interceptors.
- Every request gets the `token` header when present.
- GET requests get an automatic `_t` timestamp query param to avoid caching.
- Business success is defined as `response.data.code === 0`; other codes surface Element Plus error messages.
- HTTP 401 and business-code 401 both redirect to `/login`.
- `src/service/baseService.ts` is the thin CRUD wrapper used throughout views and shared components.
## Common page pattern
Many admin-style pages follow the same hook-based CRUD structure.
- `src/hooks/useView.ts` provides the shared list-page workflow: query, paging, sorting, delete, export, permission checks, dictionary label lookup, and route helpers.
- Views under `src/views/sys`, `src/views/job`, `src/views/oss`, etc. commonly build their page state around this hook rather than reimplementing list behavior.
Before refactoring one of these screens, check whether the behavior is coming from `useView` rather than the page file itself.
## Weather-specific feature areas
The repository has been extended beyond the generic admin shell with weather-focused functionality:
- `src/views/home.vue` is a large custom analytics dashboard for “historical same-day weather” presentation rather than a simple CRUD page.
- `src/utils/chartBuilder.ts` and `src/utils/exportReport.ts` support chart/report/export behavior used by weather-facing screens.
- `src/views/station/*` manages weather station data.
- `src/views/dailyweather/*` manages daily weather records, including import flows.
- `src/views/dailyweather/weatherdailydata-import.vue` uploads Excel files and polls backend task progress.
- `src/layout/header/import-task-indicator.vue` + `src/store/importTasks.ts` expose those long-running import tasks in the global header.
## Component conventions worth knowing
- The reusable selector/tree controls are registered globally in `src/main.ts` and imported from `src/components/sys-*`.
- Some files still use legacy `Ren*` local variable names while importing `sys-*` components; this is naming drift, not a separate component family.
- SVG icons are loaded through `vite-plugin-svg-icons` from `src/assets/icons/svg` and registered by `virtual:svg-icons-register`.
## Repository notes
- There is no existing `.cursorrules`, `.cursor/rules/`, or `.github/copilot-instructions.md` content to carry forward.
- `README.md` currently does not contain substantive project guidance, so the main operational context lives in code and this file.

View File

@ -1,50 +0,0 @@
## renren-ui
- renren-ui是基于Vue3、TypeScript、Element Plus、Vue Router、Pinia、Axios、Vite等开发实现 【[renren-security](https://gitee.com/renrenio/renren-security)】 后台管理前端功能,提供一套更优的前端解决方案
- 前后端分离通过token进行数据交互可独立部署
- 动态菜单,通过菜单管理统一管理访问路由
- 后端地址https://gitee.com/renrenio/renren-security
- 演示地址http://demo.open.renren.io/renren-security (账号密码admin/admin)
<br>
![输入图片说明](public/1.png)
## 安装
您需要提前在本地安装[Node.js](https://nodejs.org/en/),版本号为:[18、20],再使用[Git](https://git-scm.com/)克隆项目或者直接下载项目后,然后通过`终端命令行`执行以下命令。
```bash
# 切换到项目根目录
# 安装插件
npm install
# 启动项目
npm run dev
```
> 如网络不稳定,安装时出错或进度过慢!请移步 [cnpm](https://npmmirror.com/) 淘宝镜像进行安装。
启动完成后,会自动打开浏览器访问 [http://localhost:8001](http://localhost:8001),如您看到下面的页面代表`前端项目`运行成功!因为前后端分离项目,需保证`前端项目`和`后台项目`分别独立正常运行。
请留意下面的页面,其中`验证码`未能正常显示,控制台有`API请求`报错信息!这时需检查`后台项目`是否正常运行。
## 如何交流、反馈、参与贡献?
- 开发文档https://www.renren.io/guide/security
- 官方社区https://www.renren.io/community
- [人人开源](https://www.renren.io)https://www.renren.io
- 如需关注项目最新动态请Watch、Star项目同时也是对项目最好的支持
- 技术讨论、二次开发等咨询、问题和建议,请移步到官方社区,我会在第一时间进行解答和回复!
- 微信扫码并关注【人人开源】,获得项目最新动态及更新提醒<br>
<br>
## 微信交流群
我们提供了微信交流群,扫码下面的二维码,关注【人人开源】公众号,回复【加群】,即可根据提示加入微信群!
<br><br>
![输入图片说明](public/wechat.jpg)
<br>
<br>

View File

@ -1,5 +0,0 @@
import { withInstall } from "@/utils/utils";
import RenDeptTree from "./src/ren-dept-tree.vue";
RenDeptTree.name = "RenDeptTree";
export default withInstall(RenDeptTree);

View File

@ -1,4 +0,0 @@
import { withInstall } from "@/utils/utils";
import RenRadioGroup from "./src/ren-radio-group.vue";
export default withInstall(RenRadioGroup);

View File

@ -1,5 +0,0 @@
import { withInstall } from "@/utils/utils";
import RenRegionTree from "./src/ren-region-tree.vue";
RenRegionTree.name = "RenRegionTree";
export default withInstall(RenRegionTree);

View File

@ -1,4 +0,0 @@
import { withInstall } from "@/utils/utils";
import RenSelect from "./src/ren-select.vue";
export default withInstall(RenSelect);

View File

@ -0,0 +1,5 @@
import { withInstall } from "@/utils/utils";
import SysDeptTree from "./src/sys-dept-tree.vue";
SysDeptTree.name = "SysDeptTree";
export default withInstall(SysDeptTree);

View File

@ -0,0 +1,4 @@
import { withInstall } from "@/utils/utils";
import SysRadioGroup from "./src/sys-radio-group.vue";
export default withInstall(SysRadioGroup);

View File

@ -8,7 +8,7 @@ import { getDictDataList } from "@/utils/utils";
import { computed, defineComponent } from "vue"; import { computed, defineComponent } from "vue";
import { useAppStore } from "@/store"; import { useAppStore } from "@/store";
export default defineComponent({ export default defineComponent({
name: "RenRadioGroup", name: "SysRadioGroup",
props: { props: {
modelValue: [Number, String], modelValue: [Number, String],
dictType: String dictType: String

View File

@ -0,0 +1,5 @@
import { withInstall } from "@/utils/utils";
import SysRegionTree from "./src/sys-region-tree.vue";
SysRegionTree.name = "SysRegionTree";
export default withInstall(SysRegionTree);

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="ren-region"> <div class="sys-region">
<el-input v-model="showName" :placeholder="placeholder" @click="treeDialog"> <el-input v-model="showName" :placeholder="placeholder" @click="treeDialog">
<template v-slot:append> <template v-slot:append>
<el-button icon="search" @click="treeDialog"></el-button> <el-button icon="search" @click="treeDialog"></el-button>
@ -116,7 +116,7 @@ const commitHandle = () => {
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.ren-region { .sys-region {
.filter-tree { .filter-tree {
max-height: 230px; max-height: 230px;
overflow: auto; overflow: auto;

View File

@ -0,0 +1,4 @@
import { withInstall } from "@/utils/utils";
import SysSelect from "./src/sys-select.vue";
export default withInstall(SysSelect);

View File

@ -8,7 +8,7 @@ import { computed, defineComponent } from "vue";
import { getDictDataList } from "@/utils/utils"; import { getDictDataList } from "@/utils/utils";
import { useAppStore } from "@/store"; import { useAppStore } from "@/store";
export default defineComponent({ export default defineComponent({
name: "RenSelect", name: "SysSelect",
props: { props: {
modelValue: [Number, String], modelValue: [Number, String],
dictType: String, dictType: String,

View File

@ -10,6 +10,7 @@ import Breadcrumb from "./breadcrumb.vue";
import CollapseSidebarBtn from "./collapse-sidebar-btn.vue"; import CollapseSidebarBtn from "./collapse-sidebar-btn.vue";
import Expand from "./expand.vue"; import Expand from "./expand.vue";
import HeaderMixNavMenus from "./header-mix-nav-menus.vue"; import HeaderMixNavMenus from "./header-mix-nav-menus.vue";
import ImportTaskIndicator from "./import-task-indicator.vue";
import Logo from "./logo.vue"; import Logo from "./logo.vue";
import "@/assets/css/header.less"; import "@/assets/css/header.less";
@ -18,7 +19,7 @@ import "@/assets/css/header.less";
*/ */
export default defineComponent({ export default defineComponent({
name: "Header", name: "Header",
components: { BaseSidebar, Breadcrumb, CollapseSidebarBtn, Expand, HeaderMixNavMenus, Logo }, components: { BaseSidebar, Breadcrumb, CollapseSidebarBtn, Expand, HeaderMixNavMenus, ImportTaskIndicator, Logo },
setup() { setup() {
const store = useAppStore(); const store = useAppStore();
const state = reactive({ const state = reactive({
@ -55,7 +56,8 @@ export default defineComponent({
<breadcrumb v-else></breadcrumb> <breadcrumb v-else></breadcrumb>
</div> </div>
</div> </div>
<div style="flex-shrink: 0"> <div style="display: flex; align-items: center; flex-shrink: 0">
<import-task-indicator></import-task-indicator>
<expand :userName="store.state.user.username"></expand> <expand :userName="store.state.user.username"></expand>
</div> </div>
</div> </div>

View File

@ -0,0 +1,143 @@
<script lang="ts">
import { defineComponent } from "vue";
import { useImportTaskStore } from "@/store/importTasks";
export default defineComponent({
name: "ImportTaskIndicator",
setup() {
const taskStore = useImportTaskStore();
const statusIcon = (status: string) => {
if (status === "pending" || status === "uploading" || status === "processing") return "loading";
if (status === "success") return "circle-check";
return "circle-close";
};
const statusClass = (status: string) => {
if (status === "success") return "task-success";
if (status === "error") return "task-error";
return "task-running";
};
const progressLabel = (task: any) => {
if (task.totalRows > 0) {
const pct = Math.round((task.processedRows / task.totalRows) * 100);
return ` ${pct}%`;
}
return "";
};
const formatTime = (ts: number) => {
const d = new Date(ts);
return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}:${d.getSeconds().toString().padStart(2, "0")}`;
};
return { taskStore, statusIcon, statusClass, formatTime, progressLabel };
}
});
</script>
<template>
<div v-if="taskStore.hasActiveTasks" class="rr-header-right-items rr-header-action">
<el-popover placement="bottom" :width="360" trigger="click" popper-class="import-task-popover">
<template #reference>
<div class="import-task-trigger">
<el-badge :value="taskStore.activeTasks.length" :max="99" class="task-badge">
<el-icon class="is-loading"><refresh-right /></el-icon>
</el-badge>
</div>
</template>
<div class="import-task-panel">
<div class="panel-header">
<span>导入任务</span>
<el-button size="small" text @click="taskStore.clearCompleted()">清除已完成</el-button>
</div>
<div class="panel-body">
<div v-for="task in taskStore.recentTasks" :key="task.id" class="task-item">
<el-icon :class="[statusClass(task.status), { 'is-loading': task.status === 'uploading' || task.status === 'processing' }]">
<component :is="statusIcon(task.status)" />
</el-icon>
<div class="task-info">
<span class="task-name">{{ task.fileName }}</span>
<span class="task-message">{{ task.message }}</span>
</div>
<span v-if="task.status === 'processing' && task.totalRows > 0" class="task-progress">{{ Math.round((task.processedRows / task.totalRows) * 100) }}%</span>
<span class="task-time">{{ formatTime(task.createdAt) }}</span>
<el-button v-if="task.status === 'success' || task.status === 'error'" size="small" text @click="taskStore.removeTask(task.id)">
<el-icon><close /></el-icon>
</el-button>
</div>
<div v-if="taskStore.recentTasks.length === 0" class="panel-empty">暂无任务</div>
</div>
</div>
</el-popover>
</div>
</template>
<style scoped>
.import-task-trigger {
padding: 0 12px;
height: 50px;
line-height: 56px;
cursor: pointer;
}
.task-badge {
line-height: normal;
}
.import-task-panel .panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 8px;
border-bottom: 1px solid #ebeef5;
font-weight: 600;
font-size: 14px;
}
.import-task-panel .panel-body {
max-height: 300px;
overflow-y: auto;
margin-top: 8px;
}
.import-task-panel .task-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 4px;
border-bottom: 1px solid #f2f2f2;
}
.import-task-panel .task-item:last-child {
border-bottom: none;
}
.import-task-panel .task-info {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.import-task-panel .task-name {
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.import-task-panel .task-message {
font-size: 12px;
color: #909399;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.import-task-panel .task-time {
font-size: 12px;
color: #c0c4cc;
white-space: nowrap;
flex-shrink: 0;
}
.task-success { color: #67c23a; }
.task-error { color: #f56c6c; }
.task-running { color: #409eff; }
.panel-empty {
text-align: center;
color: #c0c4cc;
padding: 24px 0;
font-size: 13px;
}
</style>

View File

@ -1,8 +1,8 @@
import "@/assets/icons/iconfont/iconfont.js"; import "@/assets/icons/iconfont/iconfont.js";
import RenDeptTree from "@/components/ren-dept-tree"; import RenDeptTree from "@/components/sys-dept-tree";
import RenRadioGroup from "@/components/ren-radio-group"; import RenRadioGroup from "@/components/sys-radio-group";
import RenRegionTree from "@/components/ren-region-tree"; import RenRegionTree from "@/components/sys-region-tree";
import RenSelect from "@/components/ren-select"; import RenSelect from "@/components/sys-select";
import ElementPlus from "element-plus"; import ElementPlus from "element-plus";
import "element-plus/theme-chalk/display.css"; import "element-plus/theme-chalk/display.css";
import "element-plus/theme-chalk/index.css"; import "element-plus/theme-chalk/index.css";

47
src/store/importTasks.ts Normal file
View File

@ -0,0 +1,47 @@
import { defineStore } from "pinia";
export interface ImportTask {
id: string;
fileName: string;
status: "pending" | "uploading" | "processing" | "success" | "error";
message: string;
totalRows: number;
processedRows: number;
createdAt: number;
completedAt: number | null;
backendTaskId: string;
}
export const useImportTaskStore = defineStore("useImportTaskStore", {
state: () => ({
tasks: [] as ImportTask[]
}),
getters: {
activeTasks(state): ImportTask[] {
return state.tasks.filter((t) => t.status === "pending" || t.status === "uploading" || t.status === "processing");
},
hasActiveTasks(state): boolean {
return state.tasks.some((t) => t.status === "pending" || t.status === "uploading" || t.status === "processing");
},
recentTasks(state): ImportTask[] {
return [...state.tasks].sort((a, b) => b.createdAt - a.createdAt);
}
},
actions: {
addTask(task: ImportTask) {
this.tasks.push(task);
},
updateTask(id: string, data: Partial<ImportTask>) {
const idx = this.tasks.findIndex((t) => t.id === id);
if (idx !== -1) {
this.tasks[idx] = { ...this.tasks[idx], ...data };
}
},
removeTask(id: string) {
this.tasks = this.tasks.filter((t) => t.id !== id);
},
clearCompleted() {
this.tasks = this.tasks.filter((t) => t.status === "pending" || t.status === "uploading" || t.status === "processing");
}
}
});

36
src/utils/chartBuilder.ts Normal file
View File

@ -0,0 +1,36 @@
// utils/chartBuilder.ts
import type { WeatherRecord } from "../service/weatherDataService";
import type { WeatherExtremes } from "../service/weatherStatsService";
export function buildWeatherOption(rows: WeatherRecord[], stats: WeatherExtremes) {
return {
tooltip: { trigger: "axis" },
xAxis: {
type: "category",
data: rows.map((r) => r.year)
},
yAxis: {
type: "value"
},
series: [
{
name: "最高温",
type: "bar",
data: rows.map((r) => {
const isMax = r.year === stats.maxTmaxYear;
return {
value: r.tmax,
itemStyle: {
color: isMax ? "#ff4d4f" : "#5b8ff9"
},
label: {
show: isMax,
position: "top",
fontWeight: 600
}
};
})
}
]
};
}

33
src/utils/exportReport.ts Normal file
View File

@ -0,0 +1,33 @@
// utils/exportReport.ts
import html2canvas from "html2canvas";
import jsPDF from "jspdf";
export async function exportPNG(el: HTMLElement) {
const canvas = await html2canvas(el, {
scale: 2,
useCORS: true,
backgroundColor: "#fff"
});
const link = document.createElement("a");
link.download = "weather-report.png";
link.href = canvas.toDataURL("image/png");
link.click();
}
export async function exportPDF(el: HTMLElement) {
const canvas = await html2canvas(el, {
scale: 2,
useCORS: true,
backgroundColor: "#fff"
});
const img = canvas.toDataURL("image/png");
const pdf = new jsPDF("p", "mm", "a4");
const pageWidth = 210;
const imgHeight = (canvas.height * pageWidth) / canvas.width;
pdf.addImage(img, "PNG", 0, 0, pageWidth, imgHeight);
pdf.save("weather-report.pdf");
}

View File

@ -132,6 +132,36 @@ export const isURL = (s: string): boolean => {
return /^http[s]?:\/\/.*/.test(s); return /^http[s]?:\/\/.*/.test(s);
}; };
/**
*
* @param url
* @param headers
*/
export const copyImageFromUrl = async (
url: string,
headers?: Record<string, string>
): Promise<void> => {
if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") {
return Promise.reject(new Error("当前浏览器不支持复制图片"));
}
const response = await fetch(url, {
method: "GET",
headers
});
if (!response.ok) {
throw new Error("图片获取失败");
}
const blob = await response.blob();
if (!blob.type.startsWith("image/")) {
throw new Error("当前文件不是图片,无法复制");
}
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob
})
]);
};
/** /**
* *
* @param {*} s * @param {*} s

View File

@ -1,10 +1,23 @@
<template> <template>
<el-dialog v-model="visible" :title="!dataForm.id ? '新增观测数据' : '修改观测数据'" width="800px" :close-on-click-modal="false"> <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-form :model="dataForm" :rules="rules" ref="dataFormRef" label-width="140px" style="padding-right: 20px">
<el-row :gutter="20"> <el-row :gutter="24">
<el-divider content-position="left">基本信息</el-divider>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="站点ID" prop="stationId"> <el-form-item label="站点ID" prop="stationId">
<el-input v-model="dataForm.stationId" placeholder="请输入区站号"></el-input> <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-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
@ -13,31 +26,42 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-divider content-position="left">气温数据 ()</el-divider> <el-divider content-position="left">温湿度与气压</el-divider>
<el-col :span="8"> <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-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-input-number v-model="dataForm.avgTemp" :precision="1" :step="0.1" controls-position="right" style="width: 100%"></el-input-number>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="8"> <el-col :span="12">
<el-form-item label="最高气温" prop="maxTemp" label-width="80px"> <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-input-number v-model="dataForm.maxTemp" :precision="1" :step="0.1" controls-position="right" style="width: 100%"></el-input-number>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="8"> <el-col :span="12">
<el-form-item label="出现时刻" prop="maxTempTime" label-width="80px">
<el-input v-model="dataForm.maxTempTime" placeholder="HHmm"></el-input>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="最低气温" prop="minTemp" label-width="80px"> <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-input-number v-model="dataForm.minTemp" :precision="1" :step="0.1" controls-position="right" style="width: 100%"></el-input-number>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="8"> <el-col :span="12">
<el-form-item label="出现时刻" prop="minTempTime" label-width="80px"> <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-input v-model="dataForm.minTempTime" placeholder="HHmm"></el-input>
</el-form-item> </el-form-item>
</el-col> </el-col>
@ -56,15 +80,49 @@
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="最大风速(m/s)" prop="maxWindSpeed"> <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-input-number v-model="dataForm.maxWindSpeed" :precision="1" :min="0" style="width: 100%"></el-input-number>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="风向(角度)" prop="maxWindDirection"> <el-form-item label="最大风速风向" prop="maxWindDirection">
<el-input-number v-model="dataForm.maxWindDirection" :min="0" :max="360" style="width: 100%"></el-input-number> <el-input-number v-model="dataForm.maxWindDirection" :min="0" :max="360" style="width: 100%"></el-input-number>
</el-form-item> </el-form-item>
</el-col> </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-row>
</el-form> </el-form>
<template #footer> <template #footer>
@ -75,50 +133,72 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { reactive, ref } from "vue"; import { reactive, ref, onMounted } from "vue";
import baseService from "@/service/baseService"; import baseService from "@/service/baseService";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
const emit = defineEmits(["refreshDataList"]); const emit = defineEmits(["refreshDataList"]);
const visible = ref(false); const visible = ref(false);
const dataFormRef = ref(); const dataFormRef = ref();
const stationOptions = ref<any[]>([]);
const manualInput = ref(false);
const dataForm = reactive({ const dataForm = reactive({
id: '', id: "",
stationId: '', stationId: "",
observeDate: '', observeDate: "",
avgTemp: 0, avgTemp: 0,
maxTemp: 0, maxTemp: 0,
maxTempTime: '', maxTempTime: "",
minTemp: 0, minTemp: 0,
minTempTime: '', minTempTime: "",
rain2020: 0, rain2020: 0,
rain0808: 0, rain0808: 0,
relativeHumidity: 0,
atmospheres: 0,
dayAvgWindSpeed: 0,
dayAvgWindDirection: 0,
maxWindSpeed: 0, maxWindSpeed: 0,
maxWindDirection: 0, maxWindDirection: 0,
maxWindTime: '', maxWindTime: "",
extremeWindSpeed: 0, extremeWindSpeed: 0,
extremeWindDirection: 0, extremeWindDirection: 0,
extremeWindTime: '', extremeWindTime: "",
createDate: '' createDate: ""
}); });
// //
const rules = ref({ const rules = ref({
stationId: [{ required: true, message: '站点不能为空', trigger: 'blur' }], stationId: [{ required: true, message: "站点不能为空", trigger: "blur" }],
observeDate: [{ required: true, message: '观测日期不能为空', trigger: 'change' }], observeDate: [{ required: true, message: "观测日期不能为空", trigger: "change" }],
// (HHmm) // (HHmm)
maxTempTime: [{ pattern: /^([01]\d|2[0-3])[0-5]\d$/, message: '格式应为HHmm(如0715)', trigger: 'blur' }], 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' }] 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) => { const init = (id?: number) => {
visible.value = true; visible.value = true;
// ID manualInput.value = false;
dataForm.id = ""; dataForm.id = "";
Object.assign(dataForm, { Object.assign(dataForm, {
avgTemp: 0, maxTemp: 0, minTemp: 0, rain2020: 0, rain0808: 0, avgTemp: 0,
maxWindSpeed: 0, maxWindDirection: 0, extremeWindSpeed: 0 maxTemp: 0,
minTemp: 0,
rain2020: 0,
rain0808: 0,
maxWindSpeed: 0,
maxWindDirection: 0,
extremeWindSpeed: 0
}); });
if (dataFormRef.value) { if (dataFormRef.value) {
@ -143,7 +223,7 @@ const dataFormSubmitHandle = () => {
const action = !dataForm.id ? baseService.post : baseService.put; const action = !dataForm.id ? baseService.post : baseService.put;
action("/dailyweather/weatherdailydata", dataForm).then((res) => { action("/dailyweather/weatherdailydata", dataForm).then((res) => {
ElMessage.success({ ElMessage.success({
message: '提交成功', message: "提交成功",
duration: 800, duration: 800,
onClose: () => { onClose: () => {
visible.value = false; visible.value = false;

View File

@ -11,19 +11,20 @@
:limit="1" :limit="1"
:on-exceed="handleExceed" :on-exceed="handleExceed"
accept=".xlsx, .xls" accept=".xlsx, .xls"
:disabled="loading"
> >
<el-icon class="el-icon--upload"><upload-filled /></el-icon> <el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">将文件拖到此处 <em>点击选择</em></div> <div class="el-upload__text">将文件拖到此处 <em>点击选择</em></div>
<template #tip> <template #tip>
<div class="el-upload__tip" style="text-align: center;"> <div class="el-upload__tip" style="text-align: center;">
只能上传 Excel 文件且不超过 10MB 只能上传 Excel 文件
</div> </div>
</template> </template>
</el-upload> </el-upload>
<template #footer> <template #footer>
<span class="dialog-footer"> <span class="dialog-footer">
<el-button @click="visible = false">取消</el-button> <el-button :disabled="loading" @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="dataFormSubmitHandle">确定</el-button> <el-button type="primary" :loading="loading" @click="dataFormSubmitHandle">确定</el-button>
</span> </span>
</template> </template>
@ -33,7 +34,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import baseService from "@/service/baseService"; // import baseService from "@/service/baseService";
import { useImportTaskStore } from "@/store/importTasks";
const taskStore = useImportTaskStore();
const POLL_INTERVAL = 3000;
const visible = ref(false); const visible = ref(false);
const loading = ref(false); const loading = ref(false);
@ -41,49 +46,100 @@ const uploadRef = ref();
const fileRaw = ref<File | null>(null); const fileRaw = ref<File | null>(null);
const emit = defineEmits(['refreshDataList']); const emit = defineEmits(['refreshDataList']);
//
const init = () => { const init = () => {
visible.value = true; visible.value = true;
fileRaw.value = null; fileRaw.value = null;
if(uploadRef.value) uploadRef.value.clearFiles(); if(uploadRef.value) uploadRef.value.clearFiles();
}; };
//
const handleChange = (file: any) => { const handleChange = (file: any) => {
fileRaw.value = file.raw; fileRaw.value = file.raw;
}; };
//
const handleExceed = (files: any) => { const handleExceed = (files: any) => {
uploadRef.value!.clearFiles(); uploadRef.value!.clearFiles();
const file = files[0]; const file = files[0];
uploadRef.value!.handleStart(file); 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 = () => { const dataFormSubmitHandle = () => {
if (!fileRaw.value) { if (!fileRaw.value) {
return ElMessage.warning("请先选择文件"); return ElMessage.warning("请先选择文件");
} }
loading.value = true; 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;
const formData = new FormData(); const formData = new FormData();
// "file" Controller @RequestParam("file")
formData.append("file", fileRaw.value); formData.append("file", fileRaw.value);
// baseService post data
// formData {}
baseService.upload("/dailyweather/weatherdailydata/import", formData) baseService.upload("/dailyweather/weatherdailydata/import", formData)
.then(() => { .then((res: any) => {
ElMessage.success("导入成功"); if (res?.code === 0) {
visible.value = false; 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"); emit("refreshDataList");
}
} else {
taskStore.updateTask(localTaskId, { status: "error", message: res?.msg || "导入失败", completedAt: Date.now() });
ElMessage.error(res?.msg || "导入失败");
}
}) })
.catch((err) => { .catch((err: any) => {
// taskStore.updateTask(localTaskId, { status: "error", message: err?.message || "网络错误", completedAt: Date.now() });
console.error("上传细节错误:", err); console.error("上传细节错误:", err);
})
.finally(() => {
loading.value = false;
}); });
}; };

View File

@ -1,6 +1,20 @@
<template> <template>
<div class="mod-dailyweather__weatherdailydata"> <div class="mod-dailyweather__weatherdailydata">
<el-form :inline="true" :model="state.dataForm" @keyup.enter="state.getDataList()"> <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-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: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 v-if="state.hasPermission('dailyweather:weatherdailydata:delete')" type="danger" icon="Delete" @click="state.deleteHandle()">批量删除</el-button>
@ -16,12 +30,7 @@
<div v-for="group in columnGroups" :key="group.title" class="column-group"> <div v-for="group in columnGroups" :key="group.title" class="column-group">
<div class="group-title">{{ group.title }}</div> <div class="group-title">{{ group.title }}</div>
<el-checkbox-group v-model="selectedColumns"> <el-checkbox-group v-model="selectedColumns">
<el-checkbox <el-checkbox v-for="col in group.columns" :key="col.prop" :label="col.prop" style="width: 120px">
v-for="col in group.columns"
:key="col.prop"
:label="col.prop"
style="width: 120px"
>
{{ col.label }} {{ col.label }}
</el-checkbox> </el-checkbox>
</el-checkbox-group> </el-checkbox-group>
@ -35,27 +44,12 @@
</el-form-item> </el-form-item>
</el-form> </el-form>
<el-table <el-table v-loading="state.dataListLoading" :data="state.dataList" border @selection-change="state.dataListSelectionChangeHandle" style="width: 100%">
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> <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="group in columnGroups">
<template v-for="item in group.columns"> <template v-for="item in group.columns">
<el-table-column <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>
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"> <template #default="scope">
<span v-if="item.prop === 'observeDate'"> <span v-if="item.prop === 'observeDate'">
{{ formatObserveDate(scope.row.observeDate) }} {{ formatObserveDate(scope.row.observeDate) }}
@ -68,7 +62,10 @@
<span v-else>--</span> <span v-else>--</span>
</span> </span>
<span v-else>{{ scope.row[item.prop] ?? '--' }}</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> </template>
</el-table-column> </el-table-column>
</template> </template>
@ -82,15 +79,7 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<el-pagination <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" />
: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" /> <add-or-update ref="addOrUpdateRef" @refreshDataList="state.getDataList" />
<import-excel ref="importExcelRef" @refreshDataList="state.getDataList" /> <import-excel ref="importExcelRef" @refreshDataList="state.getDataList" />
@ -99,94 +88,171 @@
<script lang="ts" setup> <script lang="ts" setup>
import useView from "@/hooks/useView"; import useView from "@/hooks/useView";
import { reactive, ref, toRefs, watch, onMounted } from "vue"; import { reactive, ref, toRefs, watch, onMounted, computed } from "vue";
import AddOrUpdate from "./weatherdailydata-add-or-update.vue"; import AddOrUpdate from "./weatherdailydata-add-or-update.vue";
import ImportExcel from "./weatherdailydata-import.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 = [ const columnGroups = [
{ {
title: '基础信息', title: "基础信息",
columns: [ columns: [
{ prop: 'stationId', label: '区站号', default: true }, { prop: "stationId", label: "区站号", default: true },
{ prop: 'stationName', label: '站名', default: true }, { prop: "stationName", label: "站名", default: true },
{ prop: 'observeDate', label: '观测日期', default: true }, { prop: "observeDate", label: "观测日期", default: true },
{ prop: 'createDate', label: '创建时间', default: false }, { prop: "createDate", label: "创建时间", default: false },
{ prop: 'id', label: '系统编号', default: false } { prop: "id", label: "系统编号", default: false }
] ]
}, },
{ {
title: '气温数据', title: "温湿度气压数据",
columns: [ columns: [
{ prop: 'avgTemp', label: '平均气温', default: true }, { prop: "avgTemp", label: "平均气温", default: true },
{ prop: 'maxTemp', label: '最高气温', default: true }, { prop: "maxTemp", label: "最高气温", default: true },
{ prop: 'maxTempTime', label: '最高气温时间', default: false }, { prop: "minTemp", label: "最低气温", default: true },
{ prop: 'minTemp', label: '最低气温', default: true }, { prop: "maxTempTime", label: "最高气温时间", default: false },
{ prop: 'minTempTime', label: '最低气温时间', default: false } { prop: "minTempTime", label: "最低气温时间", default: false },
{ prop: "relativeHumidity", label: "相对湿度", default: true },
{ prop: "atmospheres", label: "气压", default: true }
] ]
}, },
{ {
title: '降水/风力数据', title: "降水/风力数据",
columns: [ columns: [
{ prop: 'rain2020', label: '20-20降水', default: true }, { prop: "rain2020", label: "20-20降水", default: false },
{ prop: 'rain0808', label: '08-08降水', default: true }, { prop: "rain0808", label: "08-08降水", default: false },
{ prop: 'maxWindSpeed', label: '最大风速', default: false }, { prop: "dayAvgWindSpeed", label: "日平均风速", default: true },
{ prop: 'maxWindDirection', label: '最大风向', default: false }, { prop: "dayAvgWindDirection", label: "日平均风向", default: true },
{ prop: 'maxWindTime', label: '最大风时间', default: false }, { prop: "maxWindSpeed", label: "最大风速", default: false },
{ prop: 'extremeWindSpeed', label: '极大风速', default: false }, { prop: "maxWindDirection", label: "最大风向", default: false },
{ prop: 'extremeWindTime', label: '极大风时间', default: false } { prop: "maxWindTime", label: "最大风时间", default: false },
{ prop: "extremeWindSpeed", label: "极大风速", default: false },
{ prop: "extremeWindTime", label: "极大风时间", default: false }
] ]
} }
]; ];
// //
const formatObserveDate = (dateStr: any) => { const formatObserveDate = (dateStr: any) => {
if (!dateStr) return '--'; if (!dateStr) return "--";
const str = String(dateStr); const str = String(dateStr);
const separator = str.includes('T') ? 'T' : ' '; const separator = str.includes("T") ? "T" : " ";
return str.split(separator)[0]; return str.split(separator)[0];
}; };
// //
const isValidTime = (val: any) => { const isValidTime = (val: any) => {
return val !== null && val !== undefined && val !== '' && !isNaN(Number(val)); return val !== null && val !== undefined && val !== "" && !isNaN(Number(val));
}; };
// 712 -> 07:12 // 712 -> 07:12
const formatWeatherTime = (time: any) => { const formatWeatherTime = (time: any) => {
let timeStr = String(time); let timeStr = String(time);
if (timeStr.length === 3) timeStr = '0' + timeStr; if (timeStr.length === 3) timeStr = "0" + timeStr;
if (timeStr.length === 4) { if (timeStr.length === 4) {
return `${timeStr.substring(0, 2)}:${timeStr.substring(2, 4)}`; return `${timeStr.substring(0, 2)}:${timeStr.substring(2, 4)}`;
} }
return timeStr; 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({ const view = reactive({
deleteIsBatch: true, deleteIsBatch: true,
getDataListURL: "/dailyweather/weatherdailydata/page", getDataListURL: "/dailyweather/weatherdailydata/page",
getDataListIsPage: true, getDataListIsPage: true,
exportURL: "/dailyweather/weatherdailydata/export", exportURL: "/dailyweather/weatherdailydata/export",
deleteURL: "/dailyweather/weatherdailydata" deleteURL: "/dailyweather/weatherdailydata",
dataForm: computedDataForm
}); });
const state = reactive({ ...useView(view), ...toRefs(view) }); const state = reactive({ ...useView(view), ...toRefs(view) });
const getDefaultColumns = () => { const getDefaultColumns = () => {
const defaultCols: string[] = []; const defaultCols: string[] = [];
columnGroups.forEach(g => { columnGroups.forEach((g) => {
g.columns.forEach(c => { if(c.default) defaultCols.push(c.prop) }); g.columns.forEach((c) => {
if (c.default) defaultCols.push(c.prop);
});
}); });
return defaultCols; return defaultCols;
}; };
const selectedColumns = ref<string[]>(getDefaultColumns()); const selectedColumns = ref<string[]>(getDefaultColumns());
watch(selectedColumns, (newVal) => { watch(
localStorage.setItem('weather_column_pref', JSON.stringify(newVal)); selectedColumns,
}, { deep: true }); (newVal) => {
localStorage.setItem("weather_column_pref", JSON.stringify(newVal));
},
{ deep: true }
);
onMounted(() => { onMounted(() => {
const cache = localStorage.getItem('weather_column_pref'); const cache = localStorage.getItem("weather_column_pref");
if (cache) { if (cache) {
selectedColumns.value = JSON.parse(cache); selectedColumns.value = JSON.parse(cache);
} }

File diff suppressed because it is too large Load Diff

View File

@ -29,8 +29,8 @@
</div> </div>
</div> </div>
<div class="login-footer"> <div class="login-footer">
<p><a href="https://www.renren.io/enterprise" target="_blank">企业版</a> | <a href="https://www.renren.io/cloud" target="_blank">微服务版</a></p> <p></p>
<p><a href="https://www.renren.io/" target="_blank">人人开源</a>{{ state.year }} © renren.io</p> <p></p>
</div> </div>
</div> </div>
</template> </template>

View File

@ -28,7 +28,7 @@
</template> </template>
<template v-else-if="dataForm.type === 2"> <template v-else-if="dataForm.type === 2">
<el-form-item prop="aliyunDomain" label="域名"> <el-form-item prop="aliyunDomain" label="域名">
<el-input v-model="dataForm.aliyunDomain" placeholder="阿里云绑定的域名http://cdn.renren.io"></el-input> <el-input v-model="dataForm.aliyunDomain" placeholder="阿里云绑定的域名http://cdn.123.io"></el-input>
</el-form-item> </el-form-item>
<el-form-item prop="aliyunPrefix" label="路径前缀"> <el-form-item prop="aliyunPrefix" label="路径前缀">
<el-input v-model="dataForm.aliyunPrefix" placeholder="不设置默认为空"></el-input> <el-input v-model="dataForm.aliyunPrefix" placeholder="不设置默认为空"></el-input>
@ -82,7 +82,7 @@
</template> </template>
<template v-else-if="dataForm.type === 4"> <template v-else-if="dataForm.type === 4">
<el-form-item prop="aliyunDomain" label="域名"> <el-form-item prop="aliyunDomain" label="域名">
<el-input v-model="dataForm.aliyunDomain" placeholder="阿里云绑定的域名http://cdn.renren.io"></el-input> <el-input v-model="dataForm.aliyunDomain" placeholder="阿里云绑定的域名http://cdn.123.io"></el-input>
</el-form-item> </el-form-item>
<el-form-item prop="aliyunPrefix" label="路径前缀"> <el-form-item prop="aliyunPrefix" label="路径前缀">
<el-input v-model="dataForm.aliyunPrefix" placeholder="不设置默认为空"></el-input> <el-input v-model="dataForm.aliyunPrefix" placeholder="不设置默认为空"></el-input>

View File

@ -113,6 +113,15 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </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> </el-form>
<template #footer> <template #footer>
@ -129,7 +138,7 @@ import { ElMessage } from "element-plus";
// CSV Map // CSV Map
const props = defineProps<{ const props = defineProps<{
regionsData: any[], regionsData: any[]
regionMap: Map<number, any> regionMap: Map<number, any>
}>(); }>();
@ -137,6 +146,8 @@ const emit = defineEmits(["refreshDataList"]);
const visible = ref(false); const visible = ref(false);
const dataFormRef = ref(); const dataFormRef = ref();
const deptTreeRef = ref();
const deptList = ref<any[]>([]);
const dataForm = reactive({ const dataForm = reactive({
id: "", id: "",
@ -149,7 +160,8 @@ const dataForm = reactive({
provinceName: "", // ID provinceName: "", // ID
cityName: "", // ID cityName: "", // ID
countyName: "", // ID countyName: "", // ID
townName: "" townName: "",
deptId: ""
}); });
const rules = { const rules = {
@ -193,6 +205,16 @@ const onCityChange = (val: string) => {
} }
}; };
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) => { const init = (id?: number) => {
@ -209,13 +231,16 @@ const init = (id?: number) => {
provinceName: "", provinceName: "",
cityName: "", cityName: "",
countyName: "", countyName: "",
townName: "" townName: "",
deptId: ""
}); });
getDeptList().then(() => {
if (id) { if (id) {
getInfo(id); getInfo(id);
} }
}); });
});
}; };
// //
@ -233,6 +258,13 @@ const getInfo = async (id: number) => {
if (data.cityName) { if (data.cityName) {
countyOptions.value = props.regionsData.filter(r => Number(r.parentId) === Number(data.cityName)); countyOptions.value = props.regionsData.filter(r => Number(r.parentId) === Number(data.cityName));
} }
// 3.
if (data.deptId) {
nextTick(() => {
deptTreeRef.value?.setCurrentKey(data.deptId);
});
}
}; };
// //

View File

@ -1,15 +1,22 @@
<template> <template>
<div class="mod-station__weatherstation"> <div class="mod-station__weatherstation">
<el-form :inline="true" :model="state.dataForm" @keyup.enter="state.getDataList()"> <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-form-item>
<el-button v-if="state.hasPermission('station:weatherstation:save')" type="primary" @click="addOrUpdateHandle()">新增</el-button> <el-button v-if="state.hasPermission('station:weatherstation:save')" type="primary" @click="addOrUpdateHandle()">新增</el-button>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button v-if="state.hasPermission('station:weatherstation:delete')" type="danger" @click="state.deleteHandle()">删除</el-button> <el-button v-if="state.hasPermission('station:weatherstation:delete')" type="danger" @click="state.deleteHandle()">删除</el-button>
</el-form-item> </el-form-item>
<!-- <el-form-item>-->
<!-- <el-button v-if="state.hasPermission('station:weatherstation:save')" type="primary" @click="batchInsert()">快速导入</el-button>-->
<!-- </el-form-item>-->
</el-form> </el-form>
<el-table v-loading="state.dataListLoading" :data="state.dataList" border @selection-change="state.dataListSelectionChangeHandle" style="width: 100%"> <el-table v-loading="state.dataListLoading" :data="state.dataList" border @selection-change="state.dataListSelectionChangeHandle" style="width: 100%">
@ -17,6 +24,9 @@
<el-table-column prop="id" label="编号" header-align="center" align="center"></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="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 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"> <el-table-column prop="stationLevel" label="测站级别" header-align="center" align="center">
<template v-slot="row"> <template v-slot="row">
{{ state.getDictLabel("station_level", row.row.stationLevel) }} {{ state.getDictLabel("station_level", row.row.stationLevel) }}
@ -49,12 +59,7 @@
<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-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 <add-or-update ref="addOrUpdateRef" :regions-data="regionsData" :region-map="regionMap" @refreshDataList="state.getDataList"> </add-or-update>
ref="addOrUpdateRef"
:regions-data="regionsData"
:region-map="regionMap"
@refreshDataList="state.getDataList">
</add-or-update>
</div> </div>
</template> </template>
@ -62,6 +67,7 @@
import useView from "@/hooks/useView"; import useView from "@/hooks/useView";
import { reactive, ref, toRefs, onMounted } from "vue"; import { reactive, ref, toRefs, onMounted } from "vue";
import AddOrUpdate from "./weatherstation-add-or-update.vue"; import AddOrUpdate from "./weatherstation-add-or-update.vue";
import baseService from "@/service/baseService";
// state // state
const view = reactive({ const view = reactive({
@ -69,9 +75,17 @@ const view = reactive({
getDataListURL: "/station/weatherstation/page", getDataListURL: "/station/weatherstation/page",
getDataListIsPage: true, getDataListIsPage: true,
exportURL: "/station/weatherstation/export", exportURL: "/station/weatherstation/export",
deleteURL: "/station/weatherstation" deleteURL: "/station/weatherstation",
dataForm: {
id: "",
stationName: "",
stationCode: ""
}
});
const state = reactive({
...useView(view),
...toRefs(view)
}); });
const state = reactive({ ...useView(view), ...toRefs(view) });
// ref // ref
const addOrUpdateRef = ref(); const addOrUpdateRef = ref();
@ -87,21 +101,39 @@ const addOrUpdateHandle = (id?: number) => {
const regionsData = ref<any[]>([]); const regionsData = ref<any[]>([]);
const regionMap = reactive(new Map<number, 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 () => { onMounted(async () => {
loadDepts();
const res = await fetch("/region.csv"); const res = await fetch("/region.csv");
const text = await res.text(); const text = await res.text();
const parseCSV = (csvText: string) => { const parseCSV = (csvText: string) => {
const lines = csvText.split("\n").filter(l => l.trim()); const lines = csvText.split("\n").filter((l) => l.trim());
const headers = lines[0].split(","); const headers = lines[0].split(",");
return lines.slice(1).map(line => { return lines.slice(1).map((line) => {
const cols = line.split(","); const cols = line.split(",");
const obj: any = {}; const obj: any = {};
headers.forEach((h,i)=> obj[h.trim()]=cols[i].trim()); headers.forEach((h, i) => (obj[h.trim()] = cols[i].trim()));
return obj; return obj;
}); });
}; };
const data = parseCSV(text); const data = parseCSV(text);
data.forEach(r=>{ data.forEach((r) => {
const id = Number(r.id); const id = Number(r.id);
const parentId = Number(r.parentId); const parentId = Number(r.parentId);
const level = Number(r.level); const level = Number(r.level);

View File

@ -5,7 +5,7 @@
<el-input v-model="dataForm.username" placeholder="用户名"></el-input> <el-input v-model="dataForm.username" placeholder="用户名"></el-input>
</el-form-item> </el-form-item>
<el-form-item prop="deptName" label="所属部门"> <el-form-item prop="deptName" label="所属部门">
<ren-dept-tree v-model="dataForm.deptId" placeholder="选择部门" v-model:deptName="dataForm.deptName"></ren-dept-tree> <sys-dept-tree v-model="dataForm.deptId" placeholder="选择部门" v-model:deptName="dataForm.deptName"></sys-dept-tree>
</el-form-item> </el-form-item>
<el-form-item prop="password" label="密码" :class="{ 'is-required': !dataForm.id }"> <el-form-item prop="password" label="密码" :class="{ 'is-required': !dataForm.id }">
<el-input v-model="dataForm.password" type="password" placeholder="密码"></el-input> <el-input v-model="dataForm.password" type="password" placeholder="密码"></el-input>
@ -17,7 +17,7 @@
<el-input v-model="dataForm.realName" placeholder="真实姓名"></el-input> <el-input v-model="dataForm.realName" placeholder="真实姓名"></el-input>
</el-form-item> </el-form-item>
<el-form-item prop="gender" label="性别"> <el-form-item prop="gender" label="性别">
<ren-radio-group v-model="dataForm.gender" dict-type="gender"></ren-radio-group> <sys-radio-group v-model="dataForm.gender" dict-type="gender"></sys-radio-group>
</el-form-item> </el-form-item>
<el-form-item prop="email" label="邮箱"> <el-form-item prop="email" label="邮箱">
<el-input v-model="dataForm.email" placeholder="邮箱"></el-input> <el-input v-model="dataForm.email" placeholder="邮箱"></el-input>
@ -137,8 +137,8 @@ const getRoleList = () => {
const getInfo = (id: number) => { const getInfo = (id: number) => {
baseService.get(`/sys/user/${id}`).then((res) => { baseService.get(`/sys/user/${id}`).then((res) => {
Object.assign(dataForm, res.data); Object.assign(dataForm, res.data);
dataForm.highRoleIdList = dataForm.roleIdList.filter(id => !roleList.value.some(role => role.id === id)); dataForm.highRoleIdList = dataForm.roleIdList.filter((id) => !roleList.value.some((role) => role.id === id));
dataForm.roleIdList = dataForm.roleIdList.filter(id => !dataForm.highRoleIdList.includes(id)) dataForm.roleIdList = dataForm.roleIdList.filter((id) => !dataForm.highRoleIdList.includes(id));
}); });
}; };

View File

@ -5,10 +5,10 @@
<el-input v-model="state.dataForm.username" placeholder="用户名" clearable></el-input> <el-input v-model="state.dataForm.username" placeholder="用户名" clearable></el-input>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<ren-select v-model="state.dataForm.gender" dict-type="gender" placeholder="性别"></ren-select> <sys-select v-model="state.dataForm.gender" dict-type="gender" placeholder="性别"></sys-select>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<ren-dept-tree v-model="state.dataForm.deptId" placeholder="选择部门" :query="true"></ren-dept-tree> <sys-dept-tree v-model="state.dataForm.deptId" placeholder="选择部门" :query="true"></sys-dept-tree>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button @click="state.getDataList()">查询</el-button> <el-button @click="state.getDataList()">查询</el-button>

View File

@ -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>

View File

@ -0,0 +1,516 @@
<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>
</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" @wheel.prevent="onWheel" @mousedown="onPanStart" @dblclick="resetZoom">
<img :src="previewBlobUrl" :alt="previewItem.displayName" :style="zoomStyle" class="preview-panel__img" draggable="false" />
</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 { 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 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 selectFile = async (index: number) => {
state.selectedFileIndex = index;
const item = state.fileItems[index];
if (!item) return;
selectedFileId.value = item.fileId;
resetZoom();
previewBlobUrl.value = "";
previewBlobUrl.value = await loadBlobUrl(item.fileId);
};
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;
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 - 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: 60vh;
border-radius: 8px;
user-select: none;
pointer-events: auto;
}
/* ==== 响应式 ==== */
@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>

View File

@ -0,0 +1,816 @@
<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;
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;
}
.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>