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