bug fix
This commit is contained in:
parent
ad2c4fa47a
commit
4e39953253
116
CLAUDE.md
Normal file
116
CLAUDE.md
Normal 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.
|
||||
50
README.md
50
README.md
@ -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>
|
||||
|
||||

|
||||
|
||||
## 安装
|
||||
|
||||
您需要提前在本地安装[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>
|
||||

|
||||
|
||||
<br>
|
||||
<br>
|
||||
@ -1,5 +0,0 @@
|
||||
import { withInstall } from "@/utils/utils";
|
||||
import RenDeptTree from "./src/ren-dept-tree.vue";
|
||||
|
||||
RenDeptTree.name = "RenDeptTree";
|
||||
export default withInstall(RenDeptTree);
|
||||
@ -1,4 +0,0 @@
|
||||
import { withInstall } from "@/utils/utils";
|
||||
import RenRadioGroup from "./src/ren-radio-group.vue";
|
||||
|
||||
export default withInstall(RenRadioGroup);
|
||||
@ -1,5 +0,0 @@
|
||||
import { withInstall } from "@/utils/utils";
|
||||
import RenRegionTree from "./src/ren-region-tree.vue";
|
||||
|
||||
RenRegionTree.name = "RenRegionTree";
|
||||
export default withInstall(RenRegionTree);
|
||||
@ -1,4 +0,0 @@
|
||||
import { withInstall } from "@/utils/utils";
|
||||
import RenSelect from "./src/ren-select.vue";
|
||||
|
||||
export default withInstall(RenSelect);
|
||||
5
src/components/sys-dept-tree/index.ts
Normal file
5
src/components/sys-dept-tree/index.ts
Normal 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);
|
||||
4
src/components/sys-radio-group/index.ts
Normal file
4
src/components/sys-radio-group/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { withInstall } from "@/utils/utils";
|
||||
import SysRadioGroup from "./src/sys-radio-group.vue";
|
||||
|
||||
export default withInstall(SysRadioGroup);
|
||||
@ -8,7 +8,7 @@ import { getDictDataList } from "@/utils/utils";
|
||||
import { computed, defineComponent } from "vue";
|
||||
import { useAppStore } from "@/store";
|
||||
export default defineComponent({
|
||||
name: "RenRadioGroup",
|
||||
name: "SysRadioGroup",
|
||||
props: {
|
||||
modelValue: [Number, String],
|
||||
dictType: String
|
||||
5
src/components/sys-region-tree/index.ts
Normal file
5
src/components/sys-region-tree/index.ts
Normal 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);
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="ren-region">
|
||||
<div class="sys-region">
|
||||
<el-input v-model="showName" :placeholder="placeholder" @click="treeDialog">
|
||||
<template v-slot:append>
|
||||
<el-button icon="search" @click="treeDialog"></el-button>
|
||||
@ -116,7 +116,7 @@ const commitHandle = () => {
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.ren-region {
|
||||
.sys-region {
|
||||
.filter-tree {
|
||||
max-height: 230px;
|
||||
overflow: auto;
|
||||
4
src/components/sys-select/index.ts
Normal file
4
src/components/sys-select/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { withInstall } from "@/utils/utils";
|
||||
import SysSelect from "./src/sys-select.vue";
|
||||
|
||||
export default withInstall(SysSelect);
|
||||
@ -8,7 +8,7 @@ import { computed, defineComponent } from "vue";
|
||||
import { getDictDataList } from "@/utils/utils";
|
||||
import { useAppStore } from "@/store";
|
||||
export default defineComponent({
|
||||
name: "RenSelect",
|
||||
name: "SysSelect",
|
||||
props: {
|
||||
modelValue: [Number, String],
|
||||
dictType: String,
|
||||
@ -10,6 +10,7 @@ import Breadcrumb from "./breadcrumb.vue";
|
||||
import CollapseSidebarBtn from "./collapse-sidebar-btn.vue";
|
||||
import Expand from "./expand.vue";
|
||||
import HeaderMixNavMenus from "./header-mix-nav-menus.vue";
|
||||
import ImportTaskIndicator from "./import-task-indicator.vue";
|
||||
import Logo from "./logo.vue";
|
||||
import "@/assets/css/header.less";
|
||||
|
||||
@ -18,7 +19,7 @@ import "@/assets/css/header.less";
|
||||
*/
|
||||
export default defineComponent({
|
||||
name: "Header",
|
||||
components: { BaseSidebar, Breadcrumb, CollapseSidebarBtn, Expand, HeaderMixNavMenus, Logo },
|
||||
components: { BaseSidebar, Breadcrumb, CollapseSidebarBtn, Expand, HeaderMixNavMenus, ImportTaskIndicator, Logo },
|
||||
setup() {
|
||||
const store = useAppStore();
|
||||
const state = reactive({
|
||||
@ -55,7 +56,8 @@ export default defineComponent({
|
||||
<breadcrumb v-else></breadcrumb>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
143
src/layout/header/import-task-indicator.vue
Normal file
143
src/layout/header/import-task-indicator.vue
Normal 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>
|
||||
@ -1,8 +1,8 @@
|
||||
import "@/assets/icons/iconfont/iconfont.js";
|
||||
import RenDeptTree from "@/components/ren-dept-tree";
|
||||
import RenRadioGroup from "@/components/ren-radio-group";
|
||||
import RenRegionTree from "@/components/ren-region-tree";
|
||||
import RenSelect from "@/components/ren-select";
|
||||
import RenDeptTree from "@/components/sys-dept-tree";
|
||||
import RenRadioGroup from "@/components/sys-radio-group";
|
||||
import RenRegionTree from "@/components/sys-region-tree";
|
||||
import RenSelect from "@/components/sys-select";
|
||||
import ElementPlus from "element-plus";
|
||||
import "element-plus/theme-chalk/display.css";
|
||||
import "element-plus/theme-chalk/index.css";
|
||||
|
||||
47
src/store/importTasks.ts
Normal file
47
src/store/importTasks.ts
Normal 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
36
src/utils/chartBuilder.ts
Normal 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
33
src/utils/exportReport.ts
Normal 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");
|
||||
}
|
||||
@ -132,6 +132,36 @@ export const isURL = (s: string): boolean => {
|
||||
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
|
||||
|
||||
@ -1,10 +1,23 @@
|
||||
<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-row :gutter="20">
|
||||
<el-row :gutter="24">
|
||||
<el-divider content-position="left">基本信息</el-divider>
|
||||
<el-col :span="12">
|
||||
<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-col>
|
||||
<el-col :span="12">
|
||||
@ -13,31 +26,42 @@
|
||||
</el-form-item>
|
||||
</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-input-number v-model="dataForm.avgTemp" :precision="1" :step="0.1" controls-position="right" style="width: 100%"></el-input-number>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最高气温" prop="maxTemp" label-width="80px">
|
||||
<el-input-number v-model="dataForm.maxTemp" :precision="1" :step="0.1" controls-position="right" style="width: 100%"></el-input-number>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<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-col :span="12">
|
||||
<el-form-item label="最低气温" prop="minTemp" label-width="80px">
|
||||
<el-input-number v-model="dataForm.minTemp" :precision="1" :step="0.1" controls-position="right" style="width: 100%"></el-input-number>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="出现时刻" prop="minTempTime" label-width="80px">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最高气温出现时间" prop="maxTempTime" label-width="125px">
|
||||
<el-input v-model="dataForm.maxTempTime" placeholder="HHmm"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最低气温出现时间" prop="minTempTime" label-width="125px">
|
||||
<el-input v-model="dataForm.minTempTime" placeholder="HHmm"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@ -56,15 +80,49 @@
|
||||
</el-col>
|
||||
|
||||
<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-form-item>
|
||||
</el-col>
|
||||
<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-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12">
|
||||
<el-form-item label="极大风速" prop="maxWindSpeed">
|
||||
<el-input-number v-model="dataForm.extremeWindSpeed" :precision="1" :min="0" style="width: 100%"></el-input-number>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="极大风速风向" prop="maxWindSpeed">
|
||||
<el-input-number v-model="dataForm.extremeWindDirection" :precision="1" :min="0" style="width: 100%"></el-input-number>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12">
|
||||
<el-form-item label="极大风速出现时间" prop="minTempTime" label-width="125px">
|
||||
<el-input v-model="dataForm.extremeWindTime" placeholder="HHmm"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最大风速出现时间" prop="minTempTime" label-width="125px">
|
||||
<el-input v-model="dataForm.maxWindTime" placeholder="HHmm"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@ -75,50 +133,72 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from "vue";
|
||||
import { reactive, ref, onMounted } from "vue";
|
||||
import baseService from "@/service/baseService";
|
||||
import { ElMessage } from "element-plus";
|
||||
|
||||
const emit = defineEmits(["refreshDataList"]);
|
||||
const visible = ref(false);
|
||||
const dataFormRef = ref();
|
||||
const stationOptions = ref<any[]>([]);
|
||||
const manualInput = ref(false);
|
||||
|
||||
const dataForm = reactive({
|
||||
id: '',
|
||||
stationId: '',
|
||||
observeDate: '',
|
||||
id: "",
|
||||
stationId: "",
|
||||
observeDate: "",
|
||||
avgTemp: 0,
|
||||
maxTemp: 0,
|
||||
maxTempTime: '',
|
||||
maxTempTime: "",
|
||||
minTemp: 0,
|
||||
minTempTime: '',
|
||||
minTempTime: "",
|
||||
rain2020: 0,
|
||||
rain0808: 0,
|
||||
relativeHumidity: 0,
|
||||
atmospheres: 0,
|
||||
dayAvgWindSpeed: 0,
|
||||
dayAvgWindDirection: 0,
|
||||
maxWindSpeed: 0,
|
||||
maxWindDirection: 0,
|
||||
maxWindTime: '',
|
||||
maxWindTime: "",
|
||||
extremeWindSpeed: 0,
|
||||
extremeWindDirection: 0,
|
||||
extremeWindTime: '',
|
||||
createDate: ''
|
||||
extremeWindTime: "",
|
||||
createDate: ""
|
||||
});
|
||||
|
||||
// 校验规则优化
|
||||
const rules = ref({
|
||||
stationId: [{ required: true, message: '站点不能为空', trigger: 'blur' }],
|
||||
observeDate: [{ required: true, message: '观测日期不能为空', trigger: 'change' }],
|
||||
stationId: [{ required: true, message: "站点不能为空", trigger: "blur" }],
|
||||
observeDate: [{ required: true, message: "观测日期不能为空", trigger: "change" }],
|
||||
// 时刻格式简单校验 (HHmm)
|
||||
maxTempTime: [{ pattern: /^([01]\d|2[0-3])[0-5]\d$/, message: '格式应为HHmm(如0715)', trigger: 'blur' }],
|
||||
minTempTime: [{ pattern: /^([01]\d|2[0-3])[0-5]\d$/, message: '格式应为HHmm(如1430)', trigger: 'blur' }]
|
||||
maxTempTime: [{ pattern: /^([01]\d|2[0-3])[0-5]\d$/, message: "格式应为HHmm(如0715)", trigger: "blur" }],
|
||||
minTempTime: [{ pattern: /^([01]\d|2[0-3])[0-5]\d$/, message: "格式应为HHmm(如1430)", trigger: "blur" }]
|
||||
});
|
||||
|
||||
function getStationList() {
|
||||
return baseService.get("/station/weatherstation/list").then((res) => {
|
||||
stationOptions.value = res.data || [];
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getStationList();
|
||||
});
|
||||
|
||||
const init = (id?: number) => {
|
||||
visible.value = true;
|
||||
// 重置表单,清空之前的 ID 和数据
|
||||
manualInput.value = false;
|
||||
dataForm.id = "";
|
||||
Object.assign(dataForm, {
|
||||
avgTemp: 0, maxTemp: 0, minTemp: 0, rain2020: 0, rain0808: 0,
|
||||
maxWindSpeed: 0, maxWindDirection: 0, extremeWindSpeed: 0
|
||||
avgTemp: 0,
|
||||
maxTemp: 0,
|
||||
minTemp: 0,
|
||||
rain2020: 0,
|
||||
rain0808: 0,
|
||||
maxWindSpeed: 0,
|
||||
maxWindDirection: 0,
|
||||
extremeWindSpeed: 0
|
||||
});
|
||||
|
||||
if (dataFormRef.value) {
|
||||
@ -143,7 +223,7 @@ const dataFormSubmitHandle = () => {
|
||||
const action = !dataForm.id ? baseService.post : baseService.put;
|
||||
action("/dailyweather/weatherdailydata", dataForm).then((res) => {
|
||||
ElMessage.success({
|
||||
message: '提交成功',
|
||||
message: "提交成功",
|
||||
duration: 800,
|
||||
onClose: () => {
|
||||
visible.value = false;
|
||||
|
||||
@ -11,19 +11,20 @@
|
||||
:limit="1"
|
||||
:on-exceed="handleExceed"
|
||||
accept=".xlsx, .xls"
|
||||
:disabled="loading"
|
||||
>
|
||||
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
||||
<div class="el-upload__text">将文件拖到此处,或 <em>点击选择</em></div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip" style="text-align: center;">
|
||||
只能上传 Excel 文件,且不超过 10MB
|
||||
只能上传 Excel 文件
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
|
||||
<template #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>
|
||||
</span>
|
||||
</template>
|
||||
@ -33,7 +34,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
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 loading = ref(false);
|
||||
@ -41,49 +46,100 @@ const uploadRef = ref();
|
||||
const fileRaw = ref<File | null>(null);
|
||||
const emit = defineEmits(['refreshDataList']);
|
||||
|
||||
// 初始化
|
||||
const init = () => {
|
||||
visible.value = true;
|
||||
fileRaw.value = null;
|
||||
if(uploadRef.value) uploadRef.value.clearFiles();
|
||||
};
|
||||
|
||||
// 监听文件选择状态
|
||||
const handleChange = (file: any) => {
|
||||
fileRaw.value = file.raw;
|
||||
};
|
||||
|
||||
// 限制只能选一个文件,选新文件替换旧文件
|
||||
const handleExceed = (files: any) => {
|
||||
uploadRef.value!.clearFiles();
|
||||
const file = files[0];
|
||||
uploadRef.value!.handleStart(file);
|
||||
};
|
||||
|
||||
function pollProgress(taskId: string, localTaskId: string) {
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const res = await baseService.get(`/dailyweather/weatherdailydata/import/progress/${taskId}`);
|
||||
if (res?.code === 0 && res.data) {
|
||||
const { totalRows, processedRows, errorMessage } = res.data;
|
||||
const st = (res.data.status || "").toUpperCase();
|
||||
taskStore.updateTask(localTaskId, {
|
||||
totalRows: totalRows || 0,
|
||||
processedRows: typeof processedRows === "object" ? 0 : (processedRows || 0)
|
||||
});
|
||||
if (st === "COMPLETED") {
|
||||
clearInterval(timer);
|
||||
taskStore.updateTask(localTaskId, { status: "success", message: "导入成功", completedAt: Date.now() });
|
||||
emit("refreshDataList");
|
||||
} else if (st === "FAILED" || st === "ERROR") {
|
||||
clearInterval(timer);
|
||||
taskStore.updateTask(localTaskId, { status: "error", message: errorMessage || "导入失败", completedAt: Date.now() });
|
||||
} else {
|
||||
const processed = typeof processedRows === "object" ? 0 : (processedRows || 0);
|
||||
const total = totalRows || 0;
|
||||
const pct = total > 0 ? Math.round((processed / total) * 100) : 0;
|
||||
taskStore.updateTask(localTaskId, { message: `正在导入数据 ${pct}% (${processed}/${total})` });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// keep polling on network error
|
||||
}
|
||||
}, POLL_INTERVAL);
|
||||
return timer;
|
||||
}
|
||||
|
||||
const dataFormSubmitHandle = () => {
|
||||
if (!fileRaw.value) {
|
||||
return ElMessage.warning("请先选择文件");
|
||||
}
|
||||
|
||||
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();
|
||||
// 这里的 "file" 必须和后端 Controller 的 @RequestParam("file") 名字一致
|
||||
formData.append("file", fileRaw.value);
|
||||
|
||||
// 注意:很多封装好的 baseService 在 post 时会自动序列化 data
|
||||
// 尝试直接发送 formData,不要加额外的 {} 包装
|
||||
baseService.upload("/dailyweather/weatherdailydata/import", formData)
|
||||
.then(() => {
|
||||
ElMessage.success("导入成功");
|
||||
visible.value = false;
|
||||
emit("refreshDataList");
|
||||
.then((res: any) => {
|
||||
if (res?.code === 0) {
|
||||
const backendTaskId = res.data?.taskId || res.data?.toString() || "";
|
||||
if (backendTaskId) {
|
||||
taskStore.updateTask(localTaskId, {
|
||||
status: "processing",
|
||||
message: "正在导入数据...",
|
||||
backendTaskId
|
||||
});
|
||||
pollProgress(backendTaskId, localTaskId);
|
||||
} else {
|
||||
taskStore.updateTask(localTaskId, { status: "success", message: "导入成功", completedAt: Date.now() });
|
||||
emit("refreshDataList");
|
||||
}
|
||||
} else {
|
||||
taskStore.updateTask(localTaskId, { status: "error", message: res?.msg || "导入失败", completedAt: Date.now() });
|
||||
ElMessage.error(res?.msg || "导入失败");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
// 捕获具体报错
|
||||
.catch((err: any) => {
|
||||
taskStore.updateTask(localTaskId, { status: "error", message: err?.message || "网络错误", completedAt: Date.now() });
|
||||
console.error("上传细节错误:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -1,6 +1,20 @@
|
||||
<template>
|
||||
<div class="mod-dailyweather__weatherdailydata">
|
||||
<el-form :inline="true" :model="state.dataForm" @keyup.enter="state.getDataList()">
|
||||
<el-form-item>
|
||||
<el-select v-model="realDataForm.stationId" multiple filterable remote reserve-keyword clearable placeholder="请输入区站名称" :remote-method="remoteSearchStation" :loading="loading" style="width: 300px">
|
||||
<el-option v-for="item in stationOptions" :key="item.stationCode" :label="`${item.stationName}`" :value="item.stationCode" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- <el-form-item>-->
|
||||
<!-- <el-date-picker v-model="realDataForm.observeDate" type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" value-format="YYYY-MM-DD" :default-time="[new Date(2000, 1, 1), new Date(2000, 1, 1)]" style="width: 360px" />-->
|
||||
<!-- </el-form-item>-->
|
||||
|
||||
<el-form-item>
|
||||
<el-button @click="state.getDataList">查询</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button v-if="state.hasPermission('dailyweather:weatherdailydata:save')" type="primary" icon="Plus" @click="addOrUpdateHandle()">新增</el-button>
|
||||
<el-button v-if="state.hasPermission('dailyweather:weatherdailydata:delete')" type="danger" icon="Delete" @click="state.deleteHandle()">批量删除</el-button>
|
||||
@ -16,12 +30,7 @@
|
||||
<div v-for="group in columnGroups" :key="group.title" class="column-group">
|
||||
<div class="group-title">{{ group.title }}</div>
|
||||
<el-checkbox-group v-model="selectedColumns">
|
||||
<el-checkbox
|
||||
v-for="col in group.columns"
|
||||
:key="col.prop"
|
||||
:label="col.prop"
|
||||
style="width: 120px"
|
||||
>
|
||||
<el-checkbox v-for="col in group.columns" :key="col.prop" :label="col.prop" style="width: 120px">
|
||||
{{ col.label }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
@ -35,27 +44,12 @@
|
||||
</el-form-item>
|
||||
</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%">
|
||||
<el-table-column type="selection" header-align="center" align="center" width="50" fixed="left"></el-table-column>
|
||||
|
||||
<template v-for="group in columnGroups">
|
||||
<template v-for="item in group.columns">
|
||||
<el-table-column
|
||||
v-if="selectedColumns.includes(item.prop)"
|
||||
:key="item.prop"
|
||||
:prop="item.prop"
|
||||
:label="item.label"
|
||||
header-align="center"
|
||||
align="center"
|
||||
min-width="120"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<el-table-column v-if="selectedColumns.includes(item.prop)" :key="item.prop" :prop="item.prop" :label="item.label" header-align="center" align="center" min-width="120" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span v-if="item.prop === 'observeDate'">
|
||||
{{ formatObserveDate(scope.row.observeDate) }}
|
||||
@ -68,7 +62,10 @@
|
||||
<span v-else>--</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>
|
||||
</el-table-column>
|
||||
</template>
|
||||
@ -82,15 +79,7 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
:current-page="state.page"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:page-size="state.limit"
|
||||
:total="state.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="state.pageSizeChangeHandle"
|
||||
@current-change="state.pageCurrentChangeHandle"
|
||||
/>
|
||||
<el-pagination :current-page="state.page" :page-sizes="[10, 20, 50, 100]" :page-size="state.limit" :total="state.total" layout="total, sizes, prev, pager, next, jumper" @size-change="state.pageSizeChangeHandle" @current-change="state.pageCurrentChangeHandle" />
|
||||
|
||||
<add-or-update ref="addOrUpdateRef" @refreshDataList="state.getDataList" />
|
||||
<import-excel ref="importExcelRef" @refreshDataList="state.getDataList" />
|
||||
@ -99,94 +88,171 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
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 ImportExcel from "./weatherdailydata-import.vue";
|
||||
import baseService from "@/service/baseService";
|
||||
import { useAppStore } from "@/store";
|
||||
|
||||
const _store = useAppStore();
|
||||
function _windDirectionCode(deg: any) {
|
||||
if (deg == null || deg === "" || isNaN(deg)) return null;
|
||||
const d = Number(deg);
|
||||
if (d < 0 || d > 360) return null;
|
||||
return ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Math.floor((d + 22.5) / 45) % 8];
|
||||
}
|
||||
function windDirectionLabel(deg: any) {
|
||||
const code = _windDirectionCode(deg);
|
||||
if (!code) return "—";
|
||||
const type = _store.state.dicts.find((d: any) => d.dictType === "wind_direction_type");
|
||||
const entry = type?.dataList?.find((e: any) => e.dictValue === code);
|
||||
return entry?.dictLabel || code;
|
||||
}
|
||||
|
||||
const columnGroups = [
|
||||
{
|
||||
title: '基础信息',
|
||||
title: "基础信息",
|
||||
columns: [
|
||||
{ prop: 'stationId', label: '区站号', default: true },
|
||||
{ prop: 'stationName', label: '站名', default: true },
|
||||
{ prop: 'observeDate', label: '观测日期', default: true },
|
||||
{ prop: 'createDate', label: '创建时间', default: false },
|
||||
{ prop: 'id', label: '系统编号', default: false }
|
||||
{ prop: "stationId", label: "区站号", default: true },
|
||||
{ prop: "stationName", label: "站名", default: true },
|
||||
{ prop: "observeDate", label: "观测日期", default: true },
|
||||
{ prop: "createDate", label: "创建时间", default: false },
|
||||
{ prop: "id", label: "系统编号", default: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '气温数据',
|
||||
title: "温湿度气压数据",
|
||||
columns: [
|
||||
{ prop: 'avgTemp', label: '平均气温', default: true },
|
||||
{ prop: 'maxTemp', label: '最高气温', default: true },
|
||||
{ prop: 'maxTempTime', label: '最高气温时间', default: false },
|
||||
{ prop: 'minTemp', label: '最低气温', default: true },
|
||||
{ prop: 'minTempTime', label: '最低气温时间', default: false }
|
||||
{ prop: "avgTemp", label: "平均气温", default: true },
|
||||
{ prop: "maxTemp", label: "最高气温", default: true },
|
||||
{ prop: "minTemp", label: "最低气温", default: true },
|
||||
{ prop: "maxTempTime", label: "最高气温时间", default: false },
|
||||
{ prop: "minTempTime", label: "最低气温时间", default: false },
|
||||
{ prop: "relativeHumidity", label: "相对湿度", default: true },
|
||||
{ prop: "atmospheres", label: "气压", default: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '降水/风力数据',
|
||||
title: "降水/风力数据",
|
||||
columns: [
|
||||
{ prop: 'rain2020', label: '20-20降水', default: true },
|
||||
{ prop: 'rain0808', label: '08-08降水', default: true },
|
||||
{ prop: 'maxWindSpeed', label: '最大风速', default: false },
|
||||
{ prop: 'maxWindDirection', label: '最大风向', default: false },
|
||||
{ prop: 'maxWindTime', label: '最大风时间', default: false },
|
||||
{ prop: 'extremeWindSpeed', label: '极大风速', default: false },
|
||||
{ prop: 'extremeWindTime', label: '极大风时间', default: false }
|
||||
{ prop: "rain2020", label: "20-20降水", default: false },
|
||||
{ prop: "rain0808", label: "08-08降水", default: false },
|
||||
{ prop: "dayAvgWindSpeed", label: "日平均风速", default: true },
|
||||
{ prop: "dayAvgWindDirection", label: "日平均风向", default: true },
|
||||
{ prop: "maxWindSpeed", label: "最大风速", default: false },
|
||||
{ prop: "maxWindDirection", label: "最大风向", default: false },
|
||||
{ prop: "maxWindTime", label: "最大风时间", default: false },
|
||||
{ prop: "extremeWindSpeed", label: "极大风速", default: false },
|
||||
{ prop: "extremeWindTime", label: "极大风时间", default: false }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// 智能日期截取
|
||||
const formatObserveDate = (dateStr: any) => {
|
||||
if (!dateStr) return '--';
|
||||
if (!dateStr) return "--";
|
||||
const str = String(dateStr);
|
||||
const separator = str.includes('T') ? 'T' : ' ';
|
||||
const separator = str.includes("T") ? "T" : " ";
|
||||
return str.split(separator)[0];
|
||||
};
|
||||
|
||||
// 校验有效时间数字
|
||||
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
|
||||
const formatWeatherTime = (time: any) => {
|
||||
let timeStr = String(time);
|
||||
if (timeStr.length === 3) timeStr = '0' + timeStr;
|
||||
if (timeStr.length === 3) timeStr = "0" + timeStr;
|
||||
if (timeStr.length === 4) {
|
||||
return `${timeStr.substring(0, 2)}:${timeStr.substring(2, 4)}`;
|
||||
}
|
||||
return timeStr;
|
||||
};
|
||||
interface WeatherStation {
|
||||
stationName: string;
|
||||
stationCode: string;
|
||||
}
|
||||
const loading = ref(false);
|
||||
const stationOptions = ref<WeatherStation[]>([]); // 明确这是一个 WeatherStation 对象的数组
|
||||
/**
|
||||
* 远程搜索区站
|
||||
*/
|
||||
const remoteSearchStation = async (keyword: string) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await baseService.get("/station/weatherstation/list");
|
||||
if (res.code === 0) {
|
||||
// TypeScript 自动知道 item 是 WeatherStation 类型
|
||||
stationOptions.value = res.data.filter((item: { stationName: string | string[]; stationCode: string | string[] }) => !keyword || item.stationName.includes(keyword) || item.stationCode.includes(keyword));
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 1. 定义真实的表单数据(保持数组,供 <el-select multiple> 正常绑定)
|
||||
const realDataForm = reactive({
|
||||
stationId: "",
|
||||
// observeDate: Date,
|
||||
// startTime: "",
|
||||
// endTime:""
|
||||
});
|
||||
|
||||
// 2. 使用 computed 动态派生出“用于请求的 dataForm”
|
||||
// 只要 realDataForm 发生变化,这个 computed 就会自动重新计算
|
||||
const computedDataForm = computed(() => {
|
||||
const params = { ...realDataForm };
|
||||
|
||||
// 核心转换逻辑:如果 stationId 是数组,就 join 成字符串
|
||||
if (Array.isArray(params.stationId)) {
|
||||
params.stationId = params.stationId.join(",");
|
||||
}
|
||||
// 处理时间区间:将数组拆分为两个独立字段
|
||||
if (Array.isArray(params.observeDate) && params.observeDate.length === 2) {
|
||||
params.startTime = params.observeDate[0];
|
||||
params.endTime = params.observeDate[1];
|
||||
} else {
|
||||
params.startTime = "";
|
||||
params.endTime = "";
|
||||
}
|
||||
|
||||
return params;
|
||||
});
|
||||
|
||||
const view = reactive({
|
||||
deleteIsBatch: true,
|
||||
getDataListURL: "/dailyweather/weatherdailydata/page",
|
||||
getDataListIsPage: true,
|
||||
exportURL: "/dailyweather/weatherdailydata/export",
|
||||
deleteURL: "/dailyweather/weatherdailydata"
|
||||
deleteURL: "/dailyweather/weatherdailydata",
|
||||
dataForm: computedDataForm
|
||||
});
|
||||
|
||||
const state = reactive({ ...useView(view), ...toRefs(view) });
|
||||
|
||||
const getDefaultColumns = () => {
|
||||
const defaultCols: string[] = [];
|
||||
columnGroups.forEach(g => {
|
||||
g.columns.forEach(c => { if(c.default) defaultCols.push(c.prop) });
|
||||
columnGroups.forEach((g) => {
|
||||
g.columns.forEach((c) => {
|
||||
if (c.default) defaultCols.push(c.prop);
|
||||
});
|
||||
});
|
||||
return defaultCols;
|
||||
};
|
||||
|
||||
const selectedColumns = ref<string[]>(getDefaultColumns());
|
||||
|
||||
watch(selectedColumns, (newVal) => {
|
||||
localStorage.setItem('weather_column_pref', JSON.stringify(newVal));
|
||||
}, { deep: true });
|
||||
watch(
|
||||
selectedColumns,
|
||||
(newVal) => {
|
||||
localStorage.setItem("weather_column_pref", JSON.stringify(newVal));
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
const cache = localStorage.getItem('weather_column_pref');
|
||||
const cache = localStorage.getItem("weather_column_pref");
|
||||
if (cache) {
|
||||
selectedColumns.value = JSON.parse(cache);
|
||||
}
|
||||
|
||||
2423
src/views/home.vue
2423
src/views/home.vue
File diff suppressed because it is too large
Load Diff
@ -29,8 +29,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<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><a href="https://www.renren.io/" target="_blank">人人开源</a>{{ state.year }} © renren.io</p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
</template>
|
||||
<template v-else-if="dataForm.type === 2">
|
||||
<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 prop="aliyunPrefix" label="路径前缀">
|
||||
<el-input v-model="dataForm.aliyunPrefix" placeholder="不设置默认为空"></el-input>
|
||||
@ -82,7 +82,7 @@
|
||||
</template>
|
||||
<template v-else-if="dataForm.type === 4">
|
||||
<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 prop="aliyunPrefix" label="路径前缀">
|
||||
<el-input v-model="dataForm.aliyunPrefix" placeholder="不设置默认为空"></el-input>
|
||||
|
||||
@ -113,6 +113,15 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-divider>管理部门</el-divider>
|
||||
<el-row>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="选择部门" prop="deptId">
|
||||
<el-tree :data="deptList" :props="{ label: 'name', children: 'children' }" node-key="id" ref="deptTreeRef" accordion highlight-current @node-click="onDeptClick" style="max-height: 240px; overflow-y: auto; width: 100%; border: 1px solid var(--border); border-radius: 6px; padding: 8px"></el-tree>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
@ -129,7 +138,7 @@ import { ElMessage } from "element-plus";
|
||||
|
||||
// 接收父页面传来的 CSV 原始数组和 Map 索引
|
||||
const props = defineProps<{
|
||||
regionsData: any[],
|
||||
regionsData: any[]
|
||||
regionMap: Map<number, any>
|
||||
}>();
|
||||
|
||||
@ -137,6 +146,8 @@ const emit = defineEmits(["refreshDataList"]);
|
||||
|
||||
const visible = ref(false);
|
||||
const dataFormRef = ref();
|
||||
const deptTreeRef = ref();
|
||||
const deptList = ref<any[]>([]);
|
||||
|
||||
const dataForm = reactive({
|
||||
id: "",
|
||||
@ -149,7 +160,8 @@ const dataForm = reactive({
|
||||
provinceName: "", // 存放省级 ID 字符串
|
||||
cityName: "", // 存放市级 ID 字符串
|
||||
countyName: "", // 存放区级 ID 字符串
|
||||
townName: ""
|
||||
townName: "",
|
||||
deptId: ""
|
||||
});
|
||||
|
||||
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) => {
|
||||
@ -209,12 +231,15 @@ const init = (id?: number) => {
|
||||
provinceName: "",
|
||||
cityName: "",
|
||||
countyName: "",
|
||||
townName: ""
|
||||
townName: "",
|
||||
deptId: ""
|
||||
});
|
||||
|
||||
if (id) {
|
||||
getInfo(id);
|
||||
}
|
||||
getDeptList().then(() => {
|
||||
if (id) {
|
||||
getInfo(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@ -233,6 +258,13 @@ const getInfo = async (id: number) => {
|
||||
if (data.cityName) {
|
||||
countyOptions.value = props.regionsData.filter(r => Number(r.parentId) === Number(data.cityName));
|
||||
}
|
||||
|
||||
// 3. 回显选择的管理部门
|
||||
if (data.deptId) {
|
||||
nextTick(() => {
|
||||
deptTreeRef.value?.setCurrentKey(data.deptId);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 提交
|
||||
|
||||
@ -1,15 +1,22 @@
|
||||
<template>
|
||||
<div class="mod-station__weatherstation">
|
||||
<el-form :inline="true" :model="state.dataForm" @keyup.enter="state.getDataList()">
|
||||
<el-form-item>
|
||||
<el-input v-model="state.dataForm.stationName" placeholder="区站名称" clearable></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="state.dataForm.stationCode" placeholder="区站编号" clearable></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="state.getDataList()">查询</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button v-if="state.hasPermission('station:weatherstation:save')" type="primary" @click="addOrUpdateHandle()">新增</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button v-if="state.hasPermission('station:weatherstation:delete')" type="danger" @click="state.deleteHandle()">删除</el-button>
|
||||
</el-form-item>
|
||||
<!-- <el-form-item>-->
|
||||
<!-- <el-button v-if="state.hasPermission('station:weatherstation:save')" type="primary" @click="batchInsert()">快速导入</el-button>-->
|
||||
<!-- </el-form-item>-->
|
||||
</el-form>
|
||||
|
||||
<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="stationCode" label="区站号" header-align="center" align="center"></el-table-column>
|
||||
<el-table-column prop="stationName" label="站点" header-align="center" align="center"></el-table-column>
|
||||
<el-table-column label="归属部门" header-align="center" align="center">
|
||||
<template #default="{ row }">{{ deptMap.get(Number(row.deptId)) || "" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="stationLevel" label="测站级别" header-align="center" align="center">
|
||||
<template v-slot="row">
|
||||
{{ state.getDictLabel("station_level", row.row.stationLevel) }}
|
||||
@ -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>
|
||||
|
||||
<!-- 弹窗, 新增 / 修改 -->
|
||||
<add-or-update
|
||||
ref="addOrUpdateRef"
|
||||
:regions-data="regionsData"
|
||||
:region-map="regionMap"
|
||||
@refreshDataList="state.getDataList">
|
||||
</add-or-update>
|
||||
<add-or-update ref="addOrUpdateRef" :regions-data="regionsData" :region-map="regionMap" @refreshDataList="state.getDataList"> </add-or-update>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -62,6 +67,7 @@
|
||||
import useView from "@/hooks/useView";
|
||||
import { reactive, ref, toRefs, onMounted } from "vue";
|
||||
import AddOrUpdate from "./weatherstation-add-or-update.vue";
|
||||
import baseService from "@/service/baseService";
|
||||
|
||||
// 表格 state
|
||||
const view = reactive({
|
||||
@ -69,9 +75,17 @@ const view = reactive({
|
||||
getDataListURL: "/station/weatherstation/page",
|
||||
getDataListIsPage: true,
|
||||
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
|
||||
const addOrUpdateRef = ref();
|
||||
@ -87,21 +101,39 @@ const addOrUpdateHandle = (id?: number) => {
|
||||
const regionsData = ref<any[]>([]);
|
||||
const regionMap = reactive(new Map<number, any>());
|
||||
|
||||
// ---------------- 部门数据加载 ----------------
|
||||
const deptMap = reactive(new Map<number, string>());
|
||||
async function loadDepts() {
|
||||
try {
|
||||
const res = await baseService.get("/sys/dept/list");
|
||||
const flat = (list: any[]) => {
|
||||
list.forEach((d) => {
|
||||
deptMap.set(Number(d.id), d.name);
|
||||
if (d.children) flat(d.children);
|
||||
});
|
||||
};
|
||||
flat(res.data || []);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadDepts();
|
||||
const res = await fetch("/region.csv");
|
||||
const text = await res.text();
|
||||
const parseCSV = (csvText: string) => {
|
||||
const lines = csvText.split("\n").filter(l => l.trim());
|
||||
const lines = csvText.split("\n").filter((l) => l.trim());
|
||||
const headers = lines[0].split(",");
|
||||
return lines.slice(1).map(line => {
|
||||
return lines.slice(1).map((line) => {
|
||||
const cols = line.split(",");
|
||||
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;
|
||||
});
|
||||
};
|
||||
const data = parseCSV(text);
|
||||
data.forEach(r=>{
|
||||
data.forEach((r) => {
|
||||
const id = Number(r.id);
|
||||
const parentId = Number(r.parentId);
|
||||
const level = Number(r.level);
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<el-input v-model="dataForm.username" placeholder="用户名"></el-input>
|
||||
</el-form-item>
|
||||
<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 prop="password" label="密码" :class="{ 'is-required': !dataForm.id }">
|
||||
<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-form-item>
|
||||
<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 prop="email" label="邮箱">
|
||||
<el-input v-model="dataForm.email" placeholder="邮箱"></el-input>
|
||||
@ -137,8 +137,8 @@ const getRoleList = () => {
|
||||
const getInfo = (id: number) => {
|
||||
baseService.get(`/sys/user/${id}`).then((res) => {
|
||||
Object.assign(dataForm, res.data);
|
||||
dataForm.highRoleIdList = dataForm.roleIdList.filter(id => !roleList.value.some(role => role.id === id));
|
||||
dataForm.roleIdList = dataForm.roleIdList.filter(id => !dataForm.highRoleIdList.includes(id))
|
||||
dataForm.highRoleIdList = dataForm.roleIdList.filter((id) => !roleList.value.some((role) => role.id === id));
|
||||
dataForm.roleIdList = dataForm.roleIdList.filter((id) => !dataForm.highRoleIdList.includes(id));
|
||||
});
|
||||
};
|
||||
|
||||
@ -150,7 +150,7 @@ const dataFormSubmitHandle = () => {
|
||||
}
|
||||
(!dataForm.id ? baseService.post : baseService.put)("/sys/user", {
|
||||
...dataForm,
|
||||
roleIdList: [...dataForm.roleIdList,...dataForm.highRoleIdList]
|
||||
roleIdList: [...dataForm.roleIdList, ...dataForm.highRoleIdList]
|
||||
}).then((res) => {
|
||||
ElMessage.success({
|
||||
message: "成功",
|
||||
|
||||
@ -5,10 +5,10 @@
|
||||
<el-input v-model="state.dataForm.username" placeholder="用户名" clearable></el-input>
|
||||
</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>
|
||||
<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-button @click="state.getDataList()">查询</el-button>
|
||||
|
||||
596
src/views/weather/631weather-data.vue
Normal file
596
src/views/weather/631weather-data.vue
Normal 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>
|
||||
516
src/views/weather/prediction.vue
Normal file
516
src/views/weather/prediction.vue
Normal 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>
|
||||
816
src/views/weather/realtime-monitoring.vue
Normal file
816
src/views/weather/realtime-monitoring.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user