Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ccce95d44 | ||
|
|
23703a66c2 | ||
|
|
a19777a2b8 | ||
|
|
4f7cb2d085 |
@@ -1,32 +0,0 @@
|
||||
---
|
||||
name: frontend-api-types
|
||||
description: 前端 API 与类型定义专家。用于 plus-ui 前端项目中的 src/api 层、types.ts、返回结构、Query/Form/VO/InfoVO 定义,以及前后端接口映射任务;默认基线为 Gitee 仓库 JavaLionLi/plus-ui 的 6.X-Vue 分支。
|
||||
---
|
||||
|
||||
你负责 plus-ui 前端项目中的 API 层和类型定义。
|
||||
|
||||
基线仓库:`https://gitee.com/JavaLionLi/plus-ui`
|
||||
默认分支:`6.X-Vue`
|
||||
远端引用必须同时标记仓库、分支和文件路径;不要写本机绝对路径。
|
||||
|
||||
## 核心原则
|
||||
|
||||
1. 先看当前模块已有 `src/api/<module>/<business>`。
|
||||
2. API 路径、返回类型、命名风格与当前模块保持一致。
|
||||
3. 能明确写出类型时,不要偷懒用 `any`。
|
||||
4. 如果当前模块已有 `export default { ... }`,继续保持一致。
|
||||
|
||||
## 重点关注
|
||||
|
||||
- `Query`
|
||||
- `VO`
|
||||
- `Form`
|
||||
- `InfoVO`
|
||||
- `AxiosPromise<PageResult<T>>`
|
||||
- 详情接口与列表接口返回结构
|
||||
|
||||
## 自检
|
||||
|
||||
- API 路径是否与后端一致
|
||||
- 类型是否覆盖接口真实结构
|
||||
- 是否不必要地把类型写宽了
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
name: frontend-crud-coding
|
||||
description: 前端总入口。用于 plus-ui 前端项目中的标准 CRUD 页面、新增 API/types、复杂列表页增强、树筛选、导入导出、权限按钮与弹窗表单等任务,并根据任务类型选择合适的前端子 agent;默认基线为 Gitee 仓库 JavaLionLi/plus-ui 的 6.X-Vue 分支。
|
||||
---
|
||||
|
||||
你是 plus-ui 前端项目的总入口 agent。
|
||||
|
||||
基线仓库:`https://gitee.com/JavaLionLi/plus-ui`
|
||||
默认分支:`6.X-Vue`
|
||||
远端引用必须同时标记仓库、分支和文件路径;不要在 agent 文档中写本机绝对路径。
|
||||
|
||||
先判断任务类型,再按下面规则处理:
|
||||
|
||||
1. 如果是新增标准 CRUD 页面、补 `src/api`、`types.ts`、`index.vue`,优先使用 `frontend-crud-page.md`。
|
||||
2. 如果是修改已有列表页、增强导入导出、树筛选、更多菜单、状态切换,优先使用 `frontend-page-enhancement.md`。
|
||||
3. 如果只改接口层和类型定义,优先使用 `frontend-api-types.md`。
|
||||
|
||||
通用要求:
|
||||
|
||||
- 先读当前目录下最近似页面和 API,再动代码。
|
||||
- 冲突时优先相信当前项目真实页面,其次是公共组件和工具,再其次才是 plus-ui Gitee 仓库 `6.X-Vue` 分支的 `gen` 模板。
|
||||
- 默认直接产出可落地代码,而不是只给抽象建议。
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
name: frontend-crud-page
|
||||
description: 前端标准 CRUD 页面专家。用于 plus-ui 前端项目中的新建列表页、弹窗表单页、标准 API/types/index.vue 骨架,以及 gen 模板到项目风格的落地任务;默认参考 Gitee 仓库 JavaLionLi/plus-ui 的 6.X-Vue 分支。
|
||||
---
|
||||
|
||||
你负责 plus-ui 前端项目中的标准 CRUD 页面实现。
|
||||
|
||||
基线仓库:`https://gitee.com/JavaLionLi/plus-ui`
|
||||
默认分支:`6.X-Vue`
|
||||
远端模板引用必须同时标记仓库、分支和路径,例如 `branch=6.X-Vue, path=gen/index.vue.ftl`;不要写本机绝对路径。
|
||||
|
||||
## 核心原则
|
||||
|
||||
1. 先看当前模块最近似页面。
|
||||
2. 再参考 plus-ui Gitee 仓库 `6.X-Vue` 分支中的 `gen` 模板。
|
||||
3. 默认同时维护:
|
||||
`src/api/<module>/<business>/index.ts`
|
||||
`src/api/<module>/<business>/types.ts`
|
||||
`src/views/<module>/<business>/index.vue`
|
||||
|
||||
## 页面规则
|
||||
|
||||
- 页面优先使用 `<script setup name="Xxx" lang="ts">`
|
||||
- 标准结构通常包含:
|
||||
搜索区、表格区、工具栏、分页、编辑弹窗
|
||||
- 常见状态:
|
||||
`loading`、`showSearch`、`ids`、`single`、`multiple`、`total`
|
||||
- 查询与表单优先使用 `reactive<PageData<Form, Query>>({...})`
|
||||
|
||||
## API / types 规则
|
||||
|
||||
- 请求统一通过 `@/utils/request`
|
||||
- 同目录维护 `index.ts` 与 `types.ts`
|
||||
- 标准 CRUD 通常包含:列表、详情、新增、修改、删除
|
||||
- 列表接口通常返回 `AxiosPromise<PageResult<XxxVO>>`
|
||||
|
||||
## 自检
|
||||
|
||||
- API 路径是否与后端一致
|
||||
- `index.ts` 与 `types.ts` 是否同步补齐
|
||||
- 页面是否只是模板裸输出,如果是要继续补强到当前项目风格
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
name: frontend-page-enhancement
|
||||
description: 复杂前端页面增强专家。用于修改 plus-ui 前端项目中已经存在的列表页、树筛选页、带导入导出和更多菜单的页面,强调增量修改和保留现有交互能力;默认基线为 Gitee 仓库 JavaLionLi/plus-ui 的 6.X-Vue 分支。
|
||||
---
|
||||
|
||||
你负责 plus-ui 前端项目中已有页面的增强,不是重写页面。
|
||||
|
||||
基线仓库:`https://gitee.com/JavaLionLi/plus-ui`
|
||||
默认分支:`6.X-Vue`
|
||||
远端引用必须同时标记仓库、分支和文件路径;不要写本机绝对路径。
|
||||
|
||||
## 核心原则
|
||||
|
||||
1. 优先阅读当前页面完整实现。
|
||||
2. 增量修改,不重写整页。
|
||||
3. 保留已有树筛选、导入导出、列显隐、更多菜单、状态切换、路由跳转、SCSS 页面壳。
|
||||
4. 不要把复杂页面退化成 generator 式基础列表页。
|
||||
|
||||
## 常见任务
|
||||
|
||||
- 调整工具栏和更多菜单
|
||||
- 增加筛选条件和日期范围
|
||||
- 增加导入导出能力
|
||||
- 增加状态切换、快捷操作、确认弹窗
|
||||
- 补复杂页面的小型子功能
|
||||
|
||||
## 自检
|
||||
|
||||
- 是否破坏了原页面结构和样式
|
||||
- 是否误删了已有权限控制或交互能力
|
||||
- 是否应该拆成子组件而不是继续堆主页面
|
||||
@@ -1,151 +0,0 @@
|
||||
---
|
||||
name: frontend-crud-coding
|
||||
description: 在 plus-ui 前端项目中按真实 Vue 3 + TypeScript + Element Plus + oxlint/oxfmt 代码风格生成或修改页面、API、types、hooks 接入和样式壳。用于新增或修改标准 CRUD 列表页、树表页、系统管理页、监控页、workflow 页面、demo 页面,补齐与后端接口对应的 src/api、types 和 src/views 代码;默认参考 Gitee 仓库 JavaLionLi/plus-ui 的 6.X-Vue 分支,触发后应先读取适用 references,再阅读目标模块真实代码和项目内 gen 代码生成模板。
|
||||
---
|
||||
|
||||
# 前端编码规范
|
||||
|
||||
先对齐当前前端项目里的真实实现,再参考项目内 `gen` 目录下的代码生成模板。不要只套通用 Vue 模板,也不要把 generator 模板原样复制进来而忽略当前项目已经演进出的 hooks、页面壳、类型入口和下载方式。
|
||||
|
||||
## 项目基线
|
||||
|
||||
- 基线仓库:`https://gitee.com/JavaLionLi/plus-ui`
|
||||
- 默认分支:`6.X-Vue`
|
||||
- 远端引用必须同时标记仓库、分支和文件路径,例如 `https://gitee.com/JavaLionLi/plus-ui/blob/6.X-Vue/gen/index.vue.ftl` 或 `branch=6.X-Vue, path=gen/index.vue.ftl`。
|
||||
- 不要在 skill、reference 或 agent 文档中写本机绝对路径;本地文件读取以运行时工作目录为准。
|
||||
- 如果用户指定其他分支,先按用户分支读取同一仓库的对应文件,并在结果中说明使用的分支。
|
||||
|
||||
## 执行流程
|
||||
|
||||
1. 判断任务类型:新增标准 CRUD、树表、已有页面增强、复杂业务页、只补 API/types。
|
||||
2. 按“文档读取规则”读取必要 reference,不一次性展开所有资料。
|
||||
3. 阅读目标目录下最近似的真实代码:
|
||||
- 标准单表优先看 `src/views/demo/demo/index.vue`、`src/api/demo/demo/*`。
|
||||
- 树表优先看 `src/views/demo/tree/index.vue`、`src/views/workflow/category/index.vue`。
|
||||
- 系统复杂页优先看 `src/views/system/user/index.vue`、`system/role`、`system/post`、`system/config`。
|
||||
- workflow 业务页优先看 `src/views/workflow/*` 同类页面。
|
||||
4. 新增标准页面前,对照项目内模板确认基础骨架:
|
||||
- API 模板:`gen/api.ts.ftl`
|
||||
- types 模板:`gen/types.ts.ftl`
|
||||
- 标准单表页模板:`gen/index.vue.ftl`
|
||||
- 树表页模板:`gen/index-tree.vue.ftl`
|
||||
5. 新增代码时通常同步维护 `src/api/<module>/<business>/index.ts`、`types.ts`、`src/views/<module>/<business>/index.vue`。
|
||||
6. 增强已有页面时只做增量修改,保留原页面的树筛选、导入导出、列显隐、权限、字典、弹窗和路由跳转能力。
|
||||
7. 修改完成后按影响范围运行验证:优先 `pnpm exec vue-tsc --noEmit`,改动页面或导入时再跑 `pnpm lint`,大范围变更再跑 `pnpm build`。
|
||||
|
||||
## 文档读取规则
|
||||
|
||||
- 前端 API、types、页面、hooks、样式和验证规则,先读 [references/frontend.md](references/frontend.md)。
|
||||
- 不确定任务边界、需要标准用例或提问方式时,再读 [references/examples.md](references/examples.md)。
|
||||
- reference 只约束实现方式和自检范围;发生冲突时,以当前模块真实代码和实际调用点为准。
|
||||
|
||||
## 优先级规则
|
||||
|
||||
发生冲突时按下面顺序决策:
|
||||
|
||||
1. 目标目录下最近似页面、API、types 的真实实现。
|
||||
2. 当前项目公共 hooks、组件、工具和样式约定。
|
||||
3. 项目内 `gen` 代码生成模板。
|
||||
4. 通用 Vue 3 / Element Plus 习惯。
|
||||
|
||||
也就是说:
|
||||
|
||||
- 同模块已有页面怎么写,优先怎么写。
|
||||
- 没有现成页面时,使用项目内 `gen` 模板作为骨架,再改成当前项目风格。
|
||||
- 复杂模块不能为了“标准 CRUD”退化成裸模板页。
|
||||
|
||||
## 仓库通用规则
|
||||
|
||||
- 遵循 [`.editorconfig`](../../../.editorconfig):UTF-8、LF、2 空格缩进;Markdown 例外。
|
||||
- 当前仓库没有 `.prettierrc`,格式脚本是 `pnpm run fmt` 调用 `oxfmt .`,lint 脚本是 `pnpm lint` 调用 `oxlint src`。
|
||||
- 页面优先使用 `<script setup name="Xxx" lang="ts">`。
|
||||
- API 返回类型优先从 `@/utils/api-types` 引入 `AxiosPromise`,分页结果从 `@/api/types` 引入 `PageResult`。
|
||||
- 请求统一通过 `@/utils/request`,导出下载使用 `import { download as requestDownload } from '@/utils/request';`。
|
||||
- 标准列表页优先复用 `useLoading`、`useSearchToggle`、`useSearchReset`、`useTableSelection`、`useFormDialog`、`useDateRangeQuery`。
|
||||
- 页面壳优先使用 `p-2 app-container <module>-<business>-page`、`search-panel`、`toolbar-shell`、`data-table`、`right-toolbar`、`pagination`。
|
||||
- 新页面不要无故引入另一套状态管理、请求封装、样式体系或权限写法。
|
||||
|
||||
## 目录映射规则
|
||||
|
||||
通常按下面关系组织代码:
|
||||
|
||||
- 后端 `/system/user/*` 对应 `src/api/system/user/*` 与 `src/views/system/user/*`
|
||||
- 后端 `/monitor/xxx/*` 对应 `src/api/monitor/xxx/*` 与 `src/views/monitor/xxx/*`
|
||||
- 后端 `/workflow/xxx/*` 对应 `src/api/workflow/xxx/*` 与 `src/views/workflow/xxx/*`
|
||||
- 后端 `/demo/xxx/*` 对应 `src/api/demo/xxx/*` 与 `src/views/demo/xxx/*`
|
||||
|
||||
标准新增通常至少包含:
|
||||
|
||||
- `src/api/<module>/<business>/index.ts`
|
||||
- `src/api/<module>/<business>/types.ts`
|
||||
- `src/views/<module>/<business>/index.vue`
|
||||
|
||||
按业务复杂度,可能继续补:
|
||||
|
||||
- 导入弹窗
|
||||
- 详情抽屉或详情页
|
||||
- 树筛选面板
|
||||
- 列显隐配置
|
||||
- 分配/授权子页面
|
||||
- 自定义 SCSS 样式
|
||||
|
||||
## 任务分型
|
||||
|
||||
### 1. 标准单表 CRUD
|
||||
|
||||
以 `gen/index.vue.ftl`、`gen/api.ts.ftl`、`gen/types.ts.ftl` 和 `src/views/demo/demo/index.vue` 为主要起点,补齐列表、搜索、分页、新增、编辑、删除、导出、权限、类型和验证。
|
||||
|
||||
### 2. 树表 CRUD
|
||||
|
||||
以 `src/views/demo/tree/index.vue`、`src/views/workflow/category/index.vue` 为主要起点。列表接口通常返回数组而不是 `PageResult`,页面使用 `handleTree`、`useTreeTableExpand`,`Query` 通常不继承 `PageQuery`。
|
||||
|
||||
### 3. 强业务页面
|
||||
|
||||
如果页面包含树筛选、导入导出、更多菜单、状态切换、角色分配、详情抽屉、复杂校验、联动选择或独立路由,优先增量修改现有页面。不要重写成简单 CRUD。
|
||||
|
||||
### 4. 工作流页面
|
||||
|
||||
workflow 目录优先参考 `src/views/workflow/*`。流程定义、流程实例、任务列表、请假申请等页面通常有业务按钮、弹窗和路由跳转,不要硬套 system 模块。
|
||||
|
||||
### 5. 只补 API 和 types
|
||||
|
||||
只维护 `src/api/<module>/<business>/index.ts` 与 `types.ts`,但仍要与后端路由、返回结构、当前模块导入方式和类型入口一致。
|
||||
|
||||
## 输出要求
|
||||
|
||||
使用本 skill 时,默认期望产出应满足:
|
||||
|
||||
- 类型完整,不把页面逻辑大量写成 `any`。
|
||||
- API 路径、函数名、权限标识与后端接口保持一致。
|
||||
- 标准页查询、重置、分页、弹窗、提交、删除、导出流程闭环完整。
|
||||
- 复杂页面保留原有交互能力和业务约束。
|
||||
- 代码体现当前项目 hooks、页面壳和下载方式,而不是 `gen` 模板裸输出。
|
||||
- 交付前说明运行过的验证命令;如果无法验证,说明原因。
|
||||
|
||||
## 快速检查清单
|
||||
|
||||
- `AxiosPromise` 是否来自 `@/utils/api-types`。
|
||||
- `PageResult` 是否来自 `@/api/types`。
|
||||
- API `params` 和 `data` 是否与后端方法一致。
|
||||
- 日期范围是否通过 `useDateRangeQuery` 或附近页面现有方式处理。
|
||||
- 列表 loading 是否通过 `useLoading` 或原页面方式维护。
|
||||
- 弹窗是否通过 `useFormDialog` 或原页面方式维护。
|
||||
- 多选状态是否通过 `useTableSelection` 或原页面方式维护。
|
||||
- 权限指令是否保持同文件一致,默认使用当前项目主流 `v-hasPermi`。
|
||||
- 导出是否使用 `requestDownload('<module>/<business>/export', { ...queryParams.value }, '<name>_<time>.xlsx')`。
|
||||
- 页面壳是否保留 `search-panel`、`table-panel`、`toolbar-shell`、`data-table`、`right-toolbar`、`pagination`。
|
||||
|
||||
## 推荐提问方式
|
||||
|
||||
推荐把请求描述到下面粒度:
|
||||
|
||||
- 目标模块和业务名
|
||||
- 后端接口前缀
|
||||
- 是新增页面、修改页面,还是只补 API/types
|
||||
- 是否需要导入、导出、树筛选、树表、状态切换、字典、权限按钮
|
||||
- 希望参考哪个现有页面
|
||||
|
||||
例如:
|
||||
|
||||
- 使用 `$frontend-crud-coding` 为 `/system/client` 补一套标准 CRUD 页面,参考 `gen` 模板、`demo/demo` 和 `system/client`。
|
||||
- 使用 `$frontend-crud-coding` 修改 `workflow/category` 列表页,增加导出按钮和状态筛选,保持当前 workflow 风格。
|
||||
@@ -1,7 +0,0 @@
|
||||
interface:
|
||||
display_name: "前端编码"
|
||||
short_description: "按 plus-ui 6.X-Vue 真实代码和 gen 模板编写前端 CRUD"
|
||||
default_prompt: "使用 $frontend-crud-coding 先读取适用 reference,再按 plus-ui Gitee 仓库 6.X-Vue 分支的真实页面、hooks、API/types 约定和项目内 gen 模板实现前端修改。"
|
||||
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -1,126 +0,0 @@
|
||||
# 使用案例
|
||||
|
||||
## 案例 1:新增标准 CRUD 页面
|
||||
|
||||
### 用户提问示例
|
||||
|
||||
```text
|
||||
使用 $frontend-crud-coding 为 system/client 补一套前端 CRUD 页面。
|
||||
后端接口已经有 /system/client/list、/system/client/{id}、POST /system/client、PUT /system/client、DELETE /system/client/{ids}。
|
||||
请参考 plus-ui Gitee 仓库 6.X-Vue 分支的项目内 gen 模板、src/views/demo/demo/index.vue 和现有 system/client 风格实现。
|
||||
```
|
||||
|
||||
### 期望执行方式
|
||||
|
||||
- 先看 `src/api/system/client/*` 和 `src/views/system/client/index.vue` 是否已存在。
|
||||
- 再看 `src/views/demo/demo/index.vue` 的标准 hooks 版 CRUD 骨架。
|
||||
- 对照项目内 `gen/api.ts.ftl`、`gen/types.ts.ftl`、`gen/index.vue.ftl`。
|
||||
- 生成或修改 `api/index.ts`、`types.ts`、`views/.../index.vue`。
|
||||
- 使用 `AxiosPromise` from `@/utils/api-types`、`PageResult` from `@/api/types`、`useLoading`、`useFormDialog`、`useSearchReset`、`useTableSelection`。
|
||||
|
||||
## 案例 2:新增树表页面
|
||||
|
||||
### 用户提问示例
|
||||
|
||||
```text
|
||||
使用 $frontend-crud-coding 为 demo/tree2 新增树表 CRUD,接口返回数组,字段包含 id、parentId、name、orderNum。
|
||||
参考 src/views/demo/tree/index.vue 和 workflow/category。
|
||||
```
|
||||
|
||||
### 期望执行方式
|
||||
|
||||
- 优先判断这是树表,不生成分页 `PageResult` 页面。
|
||||
- API 列表返回 `AxiosPromise<Tree2VO[]>`。
|
||||
- `Query` 不继承 `PageQuery`。
|
||||
- 页面使用 `handleTree`、`row-key`、`tree-props`、`useTreeTableExpand`、`el-tree-select`。
|
||||
- 新增子节点时从当前行带入 `parentId`。
|
||||
|
||||
## 案例 3:修改已有复杂列表页
|
||||
|
||||
### 用户提问示例
|
||||
|
||||
```text
|
||||
使用 $frontend-crud-coding 修改 system/user 页面:
|
||||
1. 新增一个创建时间快捷筛选
|
||||
2. 导出按钮保留在更多菜单中
|
||||
3. 保持现有树筛选、导入、列显隐、详情抽屉和角色分配不变
|
||||
```
|
||||
|
||||
### 期望执行方式
|
||||
|
||||
- 判断这是“已有复杂页面增强”,不是重新生成 CRUD。
|
||||
- 优先阅读 `src/views/system/user/index.vue`。
|
||||
- 保留 `TreePanel`、导入弹窗、`right-toolbar` 列显隐、`UserViewDrawer`、角色分配路由、权限控制。
|
||||
- 只增量修改搜索和查询参数处理。
|
||||
|
||||
## 案例 4:修改 workflow 页面
|
||||
|
||||
### 用户提问示例
|
||||
|
||||
```text
|
||||
使用 $frontend-crud-coding 为 workflow/category 增加状态筛选和导出按钮,保持 workflow 模块自己的树表风格。
|
||||
```
|
||||
|
||||
### 期望执行方式
|
||||
|
||||
- 优先看 `src/views/workflow/category/index.vue` 和 `src/api/workflow/category/*`。
|
||||
- 判断是否需要后端新增导出接口;前端导出路径保持 `workflow/category/export`。
|
||||
- 不迁移 system/user 的用户专属逻辑。
|
||||
- 保留树表、`useTreeTableExpand`、`handleTree` 和分类弹窗逻辑。
|
||||
|
||||
## 案例 5:只补 API 和 types
|
||||
|
||||
### 用户提问示例
|
||||
|
||||
```text
|
||||
使用 $frontend-crud-coding 为 monitor/cache 补全前端 API 和 types,页面先不改。
|
||||
```
|
||||
|
||||
### 期望执行方式
|
||||
|
||||
- 只维护 `src/api/monitor/cache/index.ts` 和 `src/api/monitor/cache/types.ts`。
|
||||
- 仍然检查同目录 monitor API 的 `export function` / `export const` 风格。
|
||||
- 返回类型使用 `AxiosPromise` from `@/utils/api-types`。
|
||||
- 不创建页面,不改路由。
|
||||
|
||||
## 案例 6:接入后端新增状态切换接口
|
||||
|
||||
### 用户提问示例
|
||||
|
||||
```text
|
||||
使用 $frontend-crud-coding 给 system/client 页面接入 PUT /system/client/changeStatus,状态字段 status,参考 gen 模板。
|
||||
```
|
||||
|
||||
### 期望执行方式
|
||||
|
||||
- API 增加 `changeClientStatus(id, status)`。
|
||||
- types 确认 `status` 类型是 string、number 还是 boolean。
|
||||
- 表格列用 `el-switch`,active/inactive 值跟后端字段类型一致。
|
||||
- 切换失败时回滚原状态。
|
||||
- 权限使用 `system:client:edit` 或后端实际权限。
|
||||
|
||||
## 推荐的高质量任务描述
|
||||
|
||||
```text
|
||||
使用 $frontend-crud-coding 在当前前端项目中新增 `/system/notice` 列表页增强:
|
||||
1. 保留现有页面
|
||||
2. 新增状态筛选和导出
|
||||
3. API 路径沿用后端接口
|
||||
4. 参考 system/config 的工具栏与导出交互
|
||||
5. 参考 plus-ui Gitee 仓库 6.X-Vue 分支的 gen 模板补齐缺失 types
|
||||
```
|
||||
|
||||
## 不推荐的任务描述
|
||||
|
||||
```text
|
||||
帮我写个后台页面
|
||||
```
|
||||
|
||||
更好的写法至少补充:
|
||||
|
||||
- 模块名
|
||||
- 业务名
|
||||
- 后端接口前缀
|
||||
- 是新增还是修改
|
||||
- 是否需要分页、导出、树表、字典、权限
|
||||
- 想参考哪个现有页面
|
||||
@@ -1,156 +0,0 @@
|
||||
# 前端约定
|
||||
|
||||
## 优先参考的代码来源
|
||||
|
||||
- 默认远端基线:`https://gitee.com/JavaLionLi/plus-ui`,分支 `6.X-Vue`。引用远端文件时必须同时写明 `branch=6.X-Vue` 和仓库内路径。
|
||||
- 当前目标目录下最近似页面、API、types。
|
||||
- 标准单表:`src/views/demo/demo/index.vue`、`src/api/demo/demo/index.ts`、`src/api/demo/demo/types.ts`。
|
||||
- 树表:`src/views/demo/tree/index.vue`、`src/views/workflow/category/index.vue`。
|
||||
- 复杂系统页:`src/views/system/user/index.vue`、`src/views/system/role/index.vue`、`src/views/system/post/index.vue`、`src/views/system/config/index.vue`。
|
||||
- workflow 页:`src/views/workflow/*`、`src/api/workflow/*`。
|
||||
- 监控页:`src/views/monitor/*`、`src/api/monitor/*`。
|
||||
- 公共 hooks:`src/hooks/async/useLoading.ts`、`src/hooks/dialog/*`、`src/hooks/form/*`、`src/hooks/table/*`、`src/hooks/tree/*`。
|
||||
- 项目内 generator 模板:
|
||||
`gen/api.ts.ftl`
|
||||
`gen/types.ts.ftl`
|
||||
`gen/index.vue.ftl`
|
||||
`gen/index-tree.vue.ftl`
|
||||
|
||||
## 基础栈与格式
|
||||
|
||||
- 技术栈是 Vue 3 + TypeScript + Element Plus + Vite + Pinia。
|
||||
- 包管理按仓库现状使用 pnpm。
|
||||
- `.editorconfig` 要求 UTF-8、LF、2 空格缩进。
|
||||
- 当前仓库没有 `.prettierrc`;格式化使用 `pnpm run fmt`,lint 使用 `pnpm lint`。
|
||||
- 不要在一个页面里混入与仓库不一致的格式和写法。
|
||||
- 不要把本机绝对路径写进 skill 输出或参考文档;跨环境参考统一使用 Gitee 仓库 URL、分支和仓库内相对路径。
|
||||
|
||||
## API 文件规则
|
||||
|
||||
- 标准 API 文件放在 `src/api/<module>/<business>/index.ts`,同目录维护 `types.ts`。
|
||||
- import 顺序优先跟随附近文件,标准生成页常见形式:
|
||||
`import type { XxxForm, XxxQuery, XxxVO } from '@/api/<module>/<business>/types';`
|
||||
`import type { PageResult } from '@/api/types';`
|
||||
`import type { AxiosPromise } from '@/utils/api-types';`
|
||||
`import request from '@/utils/request';`
|
||||
- 不要从 `axios` 引入 `AxiosPromise`。
|
||||
- 列表分页接口通常返回 `AxiosPromise<PageResult<XxxVO>>`。
|
||||
- 树表列表接口通常返回 `AxiosPromise<XxxVO[]>`。
|
||||
- 详情接口返回 `AxiosPromise<XxxVO>`;复杂详情返回单独的 `InfoVO`。
|
||||
- 标准函数命名:
|
||||
`listXxx` -> `GET /<module>/<business>/list`
|
||||
`getXxx` -> `GET /<module>/<business>/{id}`
|
||||
`addXxx` -> `POST /<module>/<business>`
|
||||
`updateXxx` -> `PUT /<module>/<business>`
|
||||
`delXxx` -> `DELETE /<module>/<business>/{id or ids}`
|
||||
`changeXxxStatus` -> `PUT /<module>/<business>/changeStatus`
|
||||
- query string 用 `params`,请求体用 `data`。
|
||||
- 加密、防重复提交等 headers 直接写在请求配置里,例如用户重置密码中的 `isEncrypt`、`repeatSubmit`。
|
||||
- 当前仓库有些 API 使用 `export const`,有些使用 `export function`;新增标准 CRUD 优先跟随 `gen/api.ts.ftl` 和相邻模块。
|
||||
- 只有相邻模块已有 `export default { ... }` 聚合时才新增默认导出。
|
||||
|
||||
## 类型文件规则
|
||||
|
||||
- 标准类型定义 `VO`、`Form`、`Query`,必要时补 `InfoVO`、`TreeVO`、`ResetPwdForm` 等扩展类型。
|
||||
- `Form` 通常继承 `BaseEntity`。
|
||||
- 非树表 `Query` 通常继承 `PageQuery`。
|
||||
- 树表 `Query` 通常不继承 `PageQuery`。
|
||||
- ID 字段通常使用 `string | number`,批量删除参数使用 `string | number | Array<string | number>`。
|
||||
- Java 数值类型映射为 `number`,Boolean 映射为 `boolean`,日期/文本默认 `string`。
|
||||
- 日期范围查询保留 `params?: any`,不要因为它看起来宽松就删掉。
|
||||
- 列表对象、表单对象、查询对象职责分开;字段不一致时不要强行复用一个接口。
|
||||
- 能明确写出类型时不要用 `any`;组件库、字典或历史接口确实无法收窄时再保留。
|
||||
|
||||
## Vue 页面结构规则
|
||||
|
||||
- 页面优先使用 `<script setup name="Xxx" lang="ts">`。
|
||||
- 标准根节点使用 `class="p-2 app-container <module>-<business>-page"`;已有页面是特殊布局时保持原样。
|
||||
- 标准列表页结构:
|
||||
搜索卡片 `search-panel`
|
||||
表格卡片 `table-panel`
|
||||
工具栏 `toolbar-shell`
|
||||
表格 `data-table`
|
||||
`right-toolbar`
|
||||
`pagination`
|
||||
新增/编辑 `el-dialog`
|
||||
- 搜索区通过 `useSearchToggle` 控制 `showSearch`,头部点击切换。
|
||||
- 列表 loading 通过 `useLoading(true)` 和 `withLoading`。
|
||||
- 选择状态通过 `useTableSelection<XxxVO>(item => item.id)` 返回 `ids`、`single`、`multiple`、`handleSelectionChange`。
|
||||
- 表单弹窗优先使用 `useFormDialog({ form, formRef, initialFormData })`,返回 `dialog`、`resetForm`、`openDialog`、`showDialog`、`closeDialog`。
|
||||
- 仅需要简单弹窗状态或一个页面多个弹窗时使用 `useDialogState`。
|
||||
- 日期范围查询优先使用 `useDateRangeQuery()`;带后端参数名时使用 `useDateRangeQuery('CreateTime')` 等相邻页面模式。
|
||||
- 查询和表单状态通常放在 `reactive<PageData<Form, Query>>({ form, queryParams, rules })`,再 `toRefs(data)`。
|
||||
|
||||
## 页面行为规则
|
||||
|
||||
- `getList` 负责设置 loading、调用列表接口、回填列表和 `total`。
|
||||
- `handleQuery` 先把 `queryParams.value.pageNum = 1`,再调用 `getList()`;树表无分页时只调用 `getList()`。
|
||||
- `resetQuery` 使用 `useSearchReset`,分页页传 `pageNumKey: 'pageNum'`,需要时传 `pageSizeKey` 和 `resetExtras`。
|
||||
- `handleAdd` 使用 `openDialog('添加xxx')`;如果有树/联动选项,打开前后按现有页面加载选项。
|
||||
- `handleUpdate` 先 `reset()`,再按行或 `ids.value[0]` 查详情,`Object.assign(form.value, res.data)`,最后 `showDialog('修改xxx')`。
|
||||
- `submitForm` 表单校验通过后设置 `buttonLoading`,根据主键判断新增或修改,成功后 `modal.msgSuccess('操作成功')`、关闭弹窗、刷新列表。
|
||||
- `handleDelete` 使用 `modal.confirm(...)` 二次确认,再调用删除接口,成功提示并刷新。
|
||||
- `handleExport` 使用 `requestDownload('<module>/<business>/export', { ...queryParams.value }, '<business>_<timestamp>.xlsx')`。
|
||||
- 状态切换失败时要把 switch 值回滚,参考 generator 模板和现有 `system/user`、`system/role`。
|
||||
- 导入上传使用 `globalHeaders()`、`import.meta.env.VITE_APP_BASE_API`、`ElUpload`,优先参考 `system/user` 或流程定义页面。
|
||||
|
||||
## 字典、权限与公共工具
|
||||
|
||||
- 字典使用 `useDict`:
|
||||
`const { sys_normal_disable } = toRefs<any>(useDict('sys_normal_disable'));`
|
||||
- 新增代码默认使用当前项目主流 `v-hasPermi`。
|
||||
- 如果正在修改的文件已经混用或使用 `v-has-permi`,保持同文件现状,不为统一写法而重排无关代码。
|
||||
- `el-dropdown-item` 延迟加载导致权限指令不可靠时,使用 `v-if="checkPermi([...])"`,参考 `system/user`。
|
||||
- 常用工具:
|
||||
`modal` from `@/plugins/modal`
|
||||
`download as requestDownload` from `@/utils/request`
|
||||
`useDict` from `@/utils/dict`
|
||||
`checkPermi` from `@/utils/permission`
|
||||
`handleTree`、`parseStrEmpty` from `@/utils/ruoyi`
|
||||
|
||||
## 组件与样式规则
|
||||
|
||||
- 优先复用公共组件:`right-toolbar`、`pagination`、`DictTag` / `dict-tag`、`ImageUpload` / `image-upload`、`ImagePreview` / `image-preview`、`FileUpload` / `file-upload`、`Editor` / `editor`、`TreePanel`。
|
||||
- 标准页面尽量使用已有页面壳类,不堆大量内联样式。
|
||||
- 复杂页面需要 scoped SCSS 时优先复用:
|
||||
`@use '@/assets/styles/components/page-shell' as pageShell;`
|
||||
再按现有 mixin 使用。
|
||||
- 不要为了单页需求修改全局组件样式。
|
||||
- 类名保持模块语义:`system-client-page`、`workflow-category-page`、`demo-demo-page`。
|
||||
|
||||
## 树表规则
|
||||
|
||||
- 树表列表接口通常返回数组,页面通过 `handleTree<T>(res.data, 'id', 'parentId')` 组树。
|
||||
- 使用 `row-key`、`:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"`。
|
||||
- 展开/折叠使用 `useTreeTableExpand`。
|
||||
- 表单中上级节点使用 `el-tree-select`。
|
||||
- 新增子节点时从当前行回填 `parentId`。
|
||||
- 删除确认文案优先使用业务名称,而不是批量 ID 文案。
|
||||
|
||||
## 与 gen 模板的关系
|
||||
|
||||
- `gen` 是 plus-ui 前端项目内的生成模板;默认远端位置是 `https://gitee.com/JavaLionLi/plus-ui/tree/6.X-Vue/gen`,分支必须标记为 `6.X-Vue`。
|
||||
- 本地仓库存在对应文件时优先读本地 `gen`,但不要把本地绝对路径写入 skill 或交付内容。
|
||||
- 新增标准单表页面时读取 `gen/index.vue.ftl`、`gen/api.ts.ftl`、`gen/types.ts.ftl`。
|
||||
- 新增树表页面时读取 `gen/index-tree.vue.ftl`、`gen/api.ts.ftl`、`gen/types.ts.ftl`。
|
||||
- `gen` 模板是标准骨架,不是最终答案;落地时仍要对照目标模块真实页面和公共 hooks。
|
||||
- 当前前端项目已经把生成页升级为 hooks 版:`useLoading`、`useFormDialog`、`useSearchReset`、`useTableSelection`、`useDateRangeQuery`。
|
||||
- 新增标准 CRUD 时,先从 `gen` 确认字段、权限、导出、状态切换、排序、日期范围等,再落成当前项目的实际页面壳。
|
||||
- 修改已有页面时,不要把现有强业务逻辑替换回 `gen` 的简化逻辑。
|
||||
|
||||
## 验证规则
|
||||
|
||||
- 只改文档或 skill:运行 skill 基础校验即可。
|
||||
- 改前端 TS/Vue/API/types:优先运行 `pnpm exec vue-tsc --noEmit`。
|
||||
- 改页面模板、import、权限或较多文件:再运行 `pnpm lint`。
|
||||
- 改公共 hooks、组件、构建相关或大范围页面:再运行 `pnpm build`。
|
||||
- 如果验证因为环境、依赖或权限失败,交付时说明失败命令和原因。
|
||||
|
||||
## 避免事项
|
||||
|
||||
- 不要从 `axios` 引入 `AxiosPromise`。
|
||||
- 不要绕开 `request` 或 `requestDownload` 自造请求/下载封装。
|
||||
- 不要跳过 `types.ts`,把类型全写在页面里。
|
||||
- 不要删除日期范围 `params`、权限指令、导出、导入、树筛选、列显隐等现有能力。
|
||||
- 不要为了“更整洁”重写复杂页面的大块业务逻辑。
|
||||
- 不要在新增标准页里使用与仓库不一致的 UI 壳或状态管理方式。
|
||||
@@ -1,42 +1,49 @@
|
||||
# 页面标题
|
||||
VITE_APP_TITLE = 智净云 · 智能家居管理后台
|
||||
VITE_APP_LOGO_TITLE = RuoYi-Vue-Plus
|
||||
VITE_APP_TITLE='智净云 · 智能家居管理后台'
|
||||
VITE_APP_LOGO_TITLE=RuoYi-Vue-Plus
|
||||
|
||||
# 开发环境配置
|
||||
VITE_APP_ENV = 'development'
|
||||
VITE_APP_ENV='development'
|
||||
|
||||
# 开发环境
|
||||
VITE_APP_BASE_API = '/dev-api'
|
||||
VITE_APP_BASE_API='/dev-api'
|
||||
|
||||
# 本机环境
|
||||
VITE_LOCALHOUSET=true
|
||||
# 本地开发环境
|
||||
VITE_BASE_API='http://192.168.0.212:8082'
|
||||
# 默认头像
|
||||
VITE_DEFAULT_USER_IMAGE='https://ts3.tc.mm.bing.net/th?id=ORMS.48a27ca8353fa47bf0dc8df38854d0e1&pid=Wdp&w=312&h=172&qlt=90&c=1&rs=1&dpr=1&p=0'
|
||||
|
||||
# 应用访问路径 例如使用前缀 /admin/
|
||||
VITE_APP_CONTEXT_PATH = '/'
|
||||
VITE_APP_CONTEXT_PATH='/'
|
||||
|
||||
# 监控地址
|
||||
VITE_APP_MONITOR_ADMIN = 'http://localhost:9090/admin/applications'
|
||||
VITE_APP_MONITOR_ADMIN='http://localhost:9090/admin/applications'
|
||||
|
||||
# SnailJob 控制台地址
|
||||
VITE_APP_SNAILJOB_ADMIN = 'http://localhost:8800/snail-job'
|
||||
VITE_APP_SNAILJOB_ADMIN='http://localhost:8800/snail-job'
|
||||
|
||||
# SnailAI 控制台地址
|
||||
VITE_APP_SNAILAI_ADMIN = 'http://localhost:8900/snail-ai'
|
||||
VITE_APP_SNAILAI_ADMIN='http://localhost:8900/snail-ai'
|
||||
|
||||
VITE_APP_PORT = 54123
|
||||
VITE_APP_PORT=5412
|
||||
|
||||
# 接口加密功能开关(如需关闭 后端也必须对应关闭)
|
||||
VITE_APP_ENCRYPT = true
|
||||
VITE_APP_ENCRYPT=true
|
||||
# 接口加密传输 RSA 公钥与后端解密私钥对应 如更换需前后端一同更换
|
||||
VITE_APP_RSA_PUBLIC_KEY = 'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDvEDuRIOM3oZPWj9Ukoc5pQklR4PFH6/clnjeFqjDLIgDyQvjxhgqAZQA+E9eD6qu6FsXPmK8djcL+nh3cFHz4pX473jDvO3Sve+8yL3VRQ0n2pRgQ2a01MJsy+WwTZCBYWf0VnLRIvANUoWQgy9vz94q7Va44dg7A1/3ICf+xAwIDAQAB'
|
||||
VITE_APP_RSA_PUBLIC_KEY='MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDvEDuRIOM3oZPWj9Ukoc5pQklR4PFH6/clnjeFqjDLIgDyQvjxhgqAZQA+E9eD6qu6FsXPmK8djcL+nh3cFHz4pX473jDvO3Sve+8yL3VRQ0n2pRgQ2a01MJsy+WwTZCBYWf0VnLRIvANUoWQgy9vz94q7Va44dg7A1/3ICf+xAwIDAQAB'
|
||||
# 接口响应解密 RSA 私钥与后端加密公钥对应 如更换需前后端一同更换
|
||||
VITE_APP_RSA_PRIVATE_KEY = 'MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAObq7yrxfvyieZtTjAYyrdvi59tYTXxjO5ajmPCRSXBY9M9wQ1tli297JN6mnY53UJMNyOFNSZVi8WSFoIXjpR87FmvChJlzeN/dZdd3SEs48Ee66XKeSePYqxa8oO5GKDsnajgpsOHKXSeeVSIysiIPS2/WsEqk0In9P4w3RsRFAgMBAAECgYBiMEWwce24SPICnRzuScBpvmsudrbEDIH7BOd0a6LZlcnLJwZNJ7mJlshPsHNQb+WgEf135+BBGEhioPtn0yuTdEuKP4kB9UdYUKiayWCoWhJpesv7sAD4RDClV7dhuV+gcd1AXD+YzyRIPbGm0VC2U+4q8/+UPRpVjqskbLVTgQJBAPRpou7g3S8n4XB527kq0D8I3+ZYwMxZhszwhrCDpJU319+ucmpLVwYIzDmZVeID2QQdUaDfIEViFHu95xDrGiUCQQDx3YOKn3yaEctk/ERVn7hDAyAXUbd8/pv2b24/M/l1ZevlsFem8U4Jk5Mu64t3z3YGJoymEjQmbucwT01iKhehAkEAxlnccsRmfFh/KkqauKE4M4++NTAd9zlInpUsmZ+cN8UEGnF2RTEzRKBrLOt1uWCqBB7PGiE6DVTVjr7FAQPrSQJAT5yeY87DcONSk9cFlzmPqV8p/QME5rvYEnHzVBKDlkUKNPyqnWToTvaoh9U4fyNmsfeWbEOprszqhFhWHG3GgQJAK8+ynmyFhaw63+Hx2KU5zR4hVuQso2IzrEurGxCxybV6mR7VBerb4502+EPx3PmOgxQL+niUFhcMWxcvBFP9+A=='
|
||||
VITE_APP_RSA_PRIVATE_KEY='MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAObq7yrxfvyieZtTjAYyrdvi59tYTXxjO5ajmPCRSXBY9M9wQ1tli297JN6mnY53UJMNyOFNSZVi8WSFoIXjpR87FmvChJlzeN/dZdd3SEs48Ee66XKeSePYqxa8oO5GKDsnajgpsOHKXSeeVSIysiIPS2/WsEqk0In9P4w3RsRFAgMBAAECgYBiMEWwce24SPICnRzuScBpvmsudrbEDIH7BOd0a6LZlcnLJwZNJ7mJlshPsHNQb+WgEf135+BBGEhioPtn0yuTdEuKP4kB9UdYUKiayWCoWhJpesv7sAD4RDClV7dhuV+gcd1AXD+YzyRIPbGm0VC2U+4q8/+UPRpVjqskbLVTgQJBAPRpou7g3S8n4XB527kq0D8I3+ZYwMxZhszwhrCDpJU319+ucmpLVwYIzDmZVeID2QQdUaDfIEViFHu95xDrGiUCQQDx3YOKn3yaEctk/ERVn7hDAyAXUbd8/pv2b24/M/l1ZevlsFem8U4Jk5Mu64t3z3YGJoymEjQmbucwT01iKhehAkEAxlnccsRmfFh/KkqauKE4M4++NTAd9zlInpUsmZ+cN8UEGnF2RTEzRKBrLOt1uWCqBB7PGiE6DVTVjr7FAQPrSQJAT5yeY87DcONSk9cFlzmPqV8p/QME5rvYEnHzVBKDlkUKNPyqnWToTvaoh9U4fyNmsfeWbEOprszqhFhWHG3GgQJAK8+ynmyFhaw63+Hx2KU5zR4hVuQso2IzrEurGxCxybV6mR7VBerb4502+EPx3PmOgxQL+niUFhcMWxcvBFP9+A=='
|
||||
|
||||
# 客户端id
|
||||
VITE_APP_CLIENT_ID = 'e5cd7e4891bf95d1d19206ce24a7b32e'
|
||||
VITE_APP_CLIENT_ID='e5cd7e4891bf95d1d19206ce24a7b32e'
|
||||
|
||||
# 统一消息推送开关
|
||||
VITE_APP_MESSAGE_ENABLED = true
|
||||
VITE_APP_MESSAGE_ENABLED=true
|
||||
|
||||
# sse / websocket
|
||||
VITE_APP_MESSAGE_TRANSPORT = 'sse'
|
||||
VITE_APP_MESSAGE_TRANSPORT='sse'
|
||||
|
||||
# 统一消息推送路径
|
||||
VITE_APP_MESSAGE_PATH = '/resource/message'
|
||||
VITE_APP_MESSAGE_PATH='/resource/message'
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
# 页面标题
|
||||
VITE_APP_TITLE = RuoYi-Vue-Plus后台管理系统
|
||||
VITE_APP_LOGO_TITLE = RuoYi-Vue-Plus
|
||||
VITE_APP_TITLE=RuoYi-Vue-Plus后台管理系统
|
||||
VITE_APP_LOGO_TITLE=RuoYi-Vue-Plus
|
||||
|
||||
# 生产环境配置
|
||||
VITE_APP_ENV = 'production'
|
||||
VITE_APP_ENV='production'
|
||||
|
||||
# 应用访问路径 例如使用前缀 /admin/
|
||||
VITE_APP_CONTEXT_PATH = '/'
|
||||
VITE_APP_CONTEXT_PATH='/'
|
||||
|
||||
# 监控地址
|
||||
VITE_APP_MONITOR_ADMIN = '/admin/applications'
|
||||
VITE_APP_MONITOR_ADMIN='/admin/applications'
|
||||
|
||||
# SnailJob 控制台地址
|
||||
VITE_APP_SNAILJOB_ADMIN = '/snail-job'
|
||||
VITE_APP_SNAILJOB_ADMIN='/snail-job'
|
||||
|
||||
# SnailAI 控制台地址
|
||||
VITE_APP_SNAILAI_ADMIN = '/snail-ai'
|
||||
VITE_APP_SNAILAI_ADMIN='/snail-ai'
|
||||
|
||||
# 生产环境
|
||||
VITE_APP_BASE_API = '/prod-api'
|
||||
VITE_APP_BASE_API='/prod-api'
|
||||
|
||||
# 是否在打包时开启压缩,支持 gzip 和 brotli
|
||||
VITE_BUILD_COMPRESS = gzip
|
||||
VITE_BUILD_COMPRESS=gzip
|
||||
|
||||
VITE_APP_PORT = 80
|
||||
VITE_APP_PORT=80
|
||||
|
||||
# 接口加密功能开关(如需关闭 后端也必须对应关闭)
|
||||
VITE_APP_ENCRYPT = true
|
||||
VITE_APP_ENCRYPT=true
|
||||
# 接口加密传输 RSA 公钥与后端解密私钥对应 如更换需前后端一同更换
|
||||
VITE_APP_RSA_PUBLIC_KEY = 'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDvEDuRIOM3oZPWj9Ukoc5pQklR4PFH6/clnjeFqjDLIgDyQvjxhgqAZQA+E9eD6qu6FsXPmK8djcL+nh3cFHz4pX473jDvO3Sve+8yL3VRQ0n2pRgQ2a01MJsy+WwTZCBYWf0VnLRIvANUoWQgy9vz94q7Va44dg7A1/3ICf+xAwIDAQAB'
|
||||
VITE_APP_RSA_PUBLIC_KEY='MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDvEDuRIOM3oZPWj9Ukoc5pQklR4PFH6/clnjeFqjDLIgDyQvjxhgqAZQA+E9eD6qu6FsXPmK8djcL+nh3cFHz4pX473jDvO3Sve+8yL3VRQ0n2pRgQ2a01MJsy+WwTZCBYWf0VnLRIvANUoWQgy9vz94q7Va44dg7A1/3ICf+xAwIDAQAB'
|
||||
# 接口响应解密 RSA 私钥与后端加密公钥对应 如更换需前后端一同更换
|
||||
VITE_APP_RSA_PRIVATE_KEY = 'MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAObq7yrxfvyieZtTjAYyrdvi59tYTXxjO5ajmPCRSXBY9M9wQ1tli297JN6mnY53UJMNyOFNSZVi8WSFoIXjpR87FmvChJlzeN/dZdd3SEs48Ee66XKeSePYqxa8oO5GKDsnajgpsOHKXSeeVSIysiIPS2/WsEqk0In9P4w3RsRFAgMBAAECgYBiMEWwce24SPICnRzuScBpvmsudrbEDIH7BOd0a6LZlcnLJwZNJ7mJlshPsHNQb+WgEf135+BBGEhioPtn0yuTdEuKP4kB9UdYUKiayWCoWhJpesv7sAD4RDClV7dhuV+gcd1AXD+YzyRIPbGm0VC2U+4q8/+UPRpVjqskbLVTgQJBAPRpou7g3S8n4XB527kq0D8I3+ZYwMxZhszwhrCDpJU319+ucmpLVwYIzDmZVeID2QQdUaDfIEViFHu95xDrGiUCQQDx3YOKn3yaEctk/ERVn7hDAyAXUbd8/pv2b24/M/l1ZevlsFem8U4Jk5Mu64t3z3YGJoymEjQmbucwT01iKhehAkEAxlnccsRmfFh/KkqauKE4M4++NTAd9zlInpUsmZ+cN8UEGnF2RTEzRKBrLOt1uWCqBB7PGiE6DVTVjr7FAQPrSQJAT5yeY87DcONSk9cFlzmPqV8p/QME5rvYEnHzVBKDlkUKNPyqnWToTvaoh9U4fyNmsfeWbEOprszqhFhWHG3GgQJAK8+ynmyFhaw63+Hx2KU5zR4hVuQso2IzrEurGxCxybV6mR7VBerb4502+EPx3PmOgxQL+niUFhcMWxcvBFP9+A=='
|
||||
VITE_APP_RSA_PRIVATE_KEY='MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAObq7yrxfvyieZtTjAYyrdvi59tYTXxjO5ajmPCRSXBY9M9wQ1tli297JN6mnY53UJMNyOFNSZVi8WSFoIXjpR87FmvChJlzeN/dZdd3SEs48Ee66XKeSePYqxa8oO5GKDsnajgpsOHKXSeeVSIysiIPS2/WsEqk0In9P4w3RsRFAgMBAAECgYBiMEWwce24SPICnRzuScBpvmsudrbEDIH7BOd0a6LZlcnLJwZNJ7mJlshPsHNQb+WgEf135+BBGEhioPtn0yuTdEuKP4kB9UdYUKiayWCoWhJpesv7sAD4RDClV7dhuV+gcd1AXD+YzyRIPbGm0VC2U+4q8/+UPRpVjqskbLVTgQJBAPRpou7g3S8n4XB527kq0D8I3+ZYwMxZhszwhrCDpJU319+ucmpLVwYIzDmZVeID2QQdUaDfIEViFHu95xDrGiUCQQDx3YOKn3yaEctk/ERVn7hDAyAXUbd8/pv2b24/M/l1ZevlsFem8U4Jk5Mu64t3z3YGJoymEjQmbucwT01iKhehAkEAxlnccsRmfFh/KkqauKE4M4++NTAd9zlInpUsmZ+cN8UEGnF2RTEzRKBrLOt1uWCqBB7PGiE6DVTVjr7FAQPrSQJAT5yeY87DcONSk9cFlzmPqV8p/QME5rvYEnHzVBKDlkUKNPyqnWToTvaoh9U4fyNmsfeWbEOprszqhFhWHG3GgQJAK8+ynmyFhaw63+Hx2KU5zR4hVuQso2IzrEurGxCxybV6mR7VBerb4502+EPx3PmOgxQL+niUFhcMWxcvBFP9+A=='
|
||||
|
||||
# 客户端id
|
||||
VITE_APP_CLIENT_ID = 'e5cd7e4891bf95d1d19206ce24a7b32e'
|
||||
VITE_APP_CLIENT_ID='e5cd7e4891bf95d1d19206ce24a7b32e'
|
||||
|
||||
# 统一消息推送开关
|
||||
VITE_APP_MESSAGE_ENABLED = true
|
||||
VITE_APP_MESSAGE_ENABLED=true
|
||||
|
||||
# sse / websocket
|
||||
VITE_APP_MESSAGE_TRANSPORT = 'sse'
|
||||
VITE_APP_MESSAGE_TRANSPORT='sse'
|
||||
|
||||
# 统一消息推送路径
|
||||
VITE_APP_MESSAGE_PATH = '/resource/message'
|
||||
VITE_APP_MESSAGE_PATH='/resource/message'
|
||||
|
||||
9
.prettierignore
Normal file
9
.prettierignore
Normal file
@@ -0,0 +1,9 @@
|
||||
/dist/*
|
||||
.local
|
||||
.output.js
|
||||
/node_modules/**
|
||||
|
||||
**/*.svg
|
||||
**/*.sh
|
||||
|
||||
/public/*
|
||||
20
.prettierrc
Normal file
20
.prettierrc
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"printWidth": 150,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"quoteProps": "preserve",
|
||||
"jsxSingleQuote": false,
|
||||
"bracketSameLine": false,
|
||||
"trailingComma": "none",
|
||||
"bracketSpacing": true,
|
||||
"embeddedLanguageFormatting": "auto",
|
||||
"arrowParens": "always",
|
||||
"requirePragma": false,
|
||||
"insertPragma": false,
|
||||
"proseWrap": "preserve",
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"vueIndentScriptAndStyle": false,
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
64
src/api/system/agreement/index.ts
Normal file
64
src/api/system/agreement/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type {AgreementForm, AgreementQuery, AgreementVO} from '@/api/system/agreement/types';
|
||||
import type {PageResult} from '@/api/types';
|
||||
import type {AxiosPromise} from '@/utils/api-types';
|
||||
import request from '@/utils/request';
|
||||
|
||||
/**
|
||||
* 查询协议配置列表
|
||||
* @param query
|
||||
* @returns {*}
|
||||
*/
|
||||
export const listAgreement = (query?: AgreementQuery): AxiosPromise<PageResult<AgreementVO>> => {
|
||||
return request({
|
||||
url: '/system/agreement/list',
|
||||
method: 'get',
|
||||
params: query
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 查询协议配置详细
|
||||
* @param agreementId
|
||||
*/
|
||||
export const getAgreement = (agreementId: string | number): AxiosPromise<AgreementVO> => {
|
||||
return request({
|
||||
url: '/system/agreement/' + agreementId,
|
||||
method: 'get'
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 新增协议配置
|
||||
* @param data
|
||||
*/
|
||||
export const addAgreement = (data: AgreementForm) => {
|
||||
return request({
|
||||
url: '/system/agreement',
|
||||
method: 'post',
|
||||
data: data
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 修改协议配置
|
||||
* @param data
|
||||
*/
|
||||
export const updateAgreement = (data: AgreementForm) => {
|
||||
return request({
|
||||
url: '/system/agreement',
|
||||
method: 'put',
|
||||
data: data
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 删除协议配置
|
||||
* @param agreementId
|
||||
*/
|
||||
export const delAgreement = (agreementId: string | number | Array<string | number>) => {
|
||||
return request({
|
||||
url: '/system/agreement/' + agreementId,
|
||||
method: 'delete'
|
||||
});
|
||||
};
|
||||
156
src/api/system/agreement/types.ts
Normal file
156
src/api/system/agreement/types.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
export interface AgreementVO {
|
||||
/**
|
||||
* 主键(雪花)
|
||||
*/
|
||||
agreementId: string | number;
|
||||
/**
|
||||
* 协议编号
|
||||
*/
|
||||
agreementNo: string;
|
||||
/**
|
||||
* 类型(user/privacy/service)
|
||||
*/
|
||||
agreementType: string;
|
||||
/**
|
||||
* 协议名称
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* 版本号
|
||||
*/
|
||||
versionName: string;
|
||||
/**
|
||||
* 协议正文
|
||||
*/
|
||||
content: string;
|
||||
/**
|
||||
* 状态(0草稿 1已生效 2停用)
|
||||
*/
|
||||
status: boolean;
|
||||
/**
|
||||
* 生效时间
|
||||
*/
|
||||
publishTime: string;
|
||||
/**
|
||||
* 已生效类型;同类型仅一条,非生效或已删除为 NULL
|
||||
*/
|
||||
activeType: string;
|
||||
}
|
||||
|
||||
export interface AgreementForm extends BaseEntity {
|
||||
/**
|
||||
* 主键(雪花)
|
||||
*/
|
||||
agreementId?: string | number;
|
||||
/**
|
||||
* 协议编号
|
||||
*/
|
||||
agreementNo?: string;
|
||||
/**
|
||||
* 类型(user/privacy/service)
|
||||
*/
|
||||
agreementType?: string;
|
||||
/**
|
||||
* 协议名称
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* 版本号
|
||||
*/
|
||||
versionName?: string;
|
||||
/**
|
||||
* 协议正文
|
||||
*/
|
||||
content?: string;
|
||||
/**
|
||||
* 状态(0草稿 1已生效 2停用)
|
||||
*/
|
||||
status?: boolean;
|
||||
/**
|
||||
* 生效时间
|
||||
*/
|
||||
publishTime?: string;
|
||||
/**
|
||||
* 创建部门
|
||||
*/
|
||||
createDept?: number;
|
||||
/**
|
||||
* 创建者
|
||||
*/
|
||||
createBy?: number;
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
createTime?: string;
|
||||
/**
|
||||
* 更新者
|
||||
*/
|
||||
updateBy?: number;
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
updateTime?: string;
|
||||
/**
|
||||
* 已生效类型;同类型仅一条,非生效或已删除为 NULL
|
||||
*/
|
||||
activeType?: string;
|
||||
}
|
||||
|
||||
export interface AgreementQuery extends PageQuery {
|
||||
/**
|
||||
* 协议编号
|
||||
*/
|
||||
agreementNo?: string;
|
||||
/**
|
||||
* 类型(user/privacy/service)
|
||||
*/
|
||||
agreementType?: string;
|
||||
/**
|
||||
* 协议名称
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* 版本号
|
||||
*/
|
||||
versionName?: string;
|
||||
/**
|
||||
* 协议正文
|
||||
*/
|
||||
content?: string;
|
||||
/**
|
||||
* 状态(0草稿 1已生效 2停用)
|
||||
*/
|
||||
status?: boolean;
|
||||
/**
|
||||
* 生效时间
|
||||
*/
|
||||
publishTime?: string;
|
||||
/**
|
||||
* 创建部门
|
||||
*/
|
||||
createDept?: number;
|
||||
/**
|
||||
* 创建者
|
||||
*/
|
||||
createBy?: number;
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
createTime?: string;
|
||||
/**
|
||||
* 更新者
|
||||
*/
|
||||
updateBy?: number;
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
updateTime?: string;
|
||||
/**
|
||||
* 已生效类型;同类型仅一条,非生效或已删除为 NULL
|
||||
*/
|
||||
activeType?: string;
|
||||
/**
|
||||
* 日期范围参数
|
||||
*/
|
||||
params?: any;
|
||||
}
|
||||
74
src/api/system/conversation/index.ts
Normal file
74
src/api/system/conversation/index.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type {CsSessionForm, CsSessionQuery, CsSessionVO} from '@/api/system/conversation/types';
|
||||
import type {PageResult} from '@/api/types';
|
||||
import type {AxiosPromise} from '@/utils/api-types';
|
||||
import request from '@/utils/request';
|
||||
|
||||
/**
|
||||
* 查询客服会话列表
|
||||
* @param query
|
||||
* @returns {*}
|
||||
*/
|
||||
export const listCsSession = (query?: CsSessionQuery): AxiosPromise<PageResult<CsSessionVO>> => {
|
||||
return request({
|
||||
url: '/cs/session/list',
|
||||
method: 'get',
|
||||
params: query
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 查询客服会话详细
|
||||
* @param sessionId
|
||||
*/
|
||||
export const getCsSession = (sessionId: string | number): AxiosPromise<CsSessionVO> => {
|
||||
return request({
|
||||
url: '/cs/session/' + sessionId,
|
||||
method: 'get'
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 新增客服会话
|
||||
* @param data
|
||||
*/
|
||||
export const addCsSession = (data: CsSessionForm) => {
|
||||
return request({
|
||||
url: '/cs/session',
|
||||
method: 'post',
|
||||
data: data
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 修改客服会话
|
||||
* @param data
|
||||
*/
|
||||
export const updateCsSession = (data: CsSessionForm) => {
|
||||
return request({
|
||||
url: '/cs/session',
|
||||
method: 'put',
|
||||
data: data
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 删除客服会话
|
||||
* @param sessionId
|
||||
*/
|
||||
export const delCsSession = (sessionId: string | number | Array<string | number>) => {
|
||||
return request({
|
||||
url: '/cs/session/' + sessionId,
|
||||
method: 'delete'
|
||||
});
|
||||
};
|
||||
/**
|
||||
* 关闭客服会话
|
||||
* @param sessionId
|
||||
*/
|
||||
export const closeCsSession = (sessionId: string) => {
|
||||
return request({
|
||||
url: '/cs/session/' + sessionId + '/end',
|
||||
method: 'delete'
|
||||
});
|
||||
};
|
||||
172
src/api/system/conversation/types.ts
Normal file
172
src/api/system/conversation/types.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
export interface CsSessionVO {
|
||||
/**
|
||||
* 主键(雪花)
|
||||
*/
|
||||
sessionId: string;
|
||||
/**
|
||||
* 会话编号
|
||||
*/
|
||||
sessionNo: string;
|
||||
/**
|
||||
* APP 用户
|
||||
*/
|
||||
userId: string;
|
||||
/**
|
||||
* 关联设备,可空
|
||||
*/
|
||||
deviceId: string;
|
||||
/**
|
||||
* 问题类型
|
||||
*/
|
||||
problemType: string;
|
||||
/**
|
||||
* 用户问题摘要
|
||||
*/
|
||||
summary: string;
|
||||
/**
|
||||
* 状态(0机器人 1排队 2人工 3已结束)
|
||||
*/
|
||||
status: boolean;
|
||||
/**
|
||||
* 当前接待客服
|
||||
*/
|
||||
agentId: string;
|
||||
/**
|
||||
* 接入方式(bot/bot_to_human/human)
|
||||
*/
|
||||
accessType: string;
|
||||
/**
|
||||
* 排队序号
|
||||
*/
|
||||
queueNo: number;
|
||||
/**
|
||||
* 会话开始时间
|
||||
*/
|
||||
startTime: string;
|
||||
/**
|
||||
* 人工接入时间
|
||||
*/
|
||||
acceptTime: string;
|
||||
/**
|
||||
* 结束时间
|
||||
*/
|
||||
endTime: string;
|
||||
/**
|
||||
* 最后消息时间
|
||||
*/
|
||||
lastMessageTime: string;
|
||||
}
|
||||
|
||||
export interface CsSessionForm extends BaseEntity {
|
||||
/**
|
||||
* 主键(雪花)
|
||||
*/
|
||||
sessionId?: string;
|
||||
/**
|
||||
* 会话编号
|
||||
*/
|
||||
sessionNo?: string;
|
||||
/**
|
||||
* APP 用户
|
||||
*/
|
||||
userId?: string;
|
||||
/**
|
||||
* 关联设备,可空
|
||||
*/
|
||||
deviceId?: string;
|
||||
/**
|
||||
* 问题类型
|
||||
*/
|
||||
problemType?: string;
|
||||
/**
|
||||
* 用户问题摘要
|
||||
*/
|
||||
summary?: string;
|
||||
/**
|
||||
* 状态(0机器人 1排队 2人工 3已结束)
|
||||
*/
|
||||
status?: boolean;
|
||||
/**
|
||||
* 当前接待客服
|
||||
*/
|
||||
agentId?: string;
|
||||
/**
|
||||
* 接入方式(bot/bot_to_human/human)
|
||||
*/
|
||||
accessType?: string;
|
||||
/**
|
||||
* 排队序号
|
||||
*/
|
||||
queueNo?: number;
|
||||
/**
|
||||
* 会话开始时间
|
||||
*/
|
||||
startTime?: string;
|
||||
/**
|
||||
* 人工接入时间
|
||||
*/
|
||||
acceptTime?: string;
|
||||
/**
|
||||
* 结束时间
|
||||
*/
|
||||
endTime?: string;
|
||||
/**
|
||||
* 最后消息时间
|
||||
*/
|
||||
lastMessageTime?: string;
|
||||
}
|
||||
|
||||
export interface CsSessionQuery extends PageQuery {
|
||||
/**
|
||||
* 会话编号
|
||||
*/
|
||||
sessionNo?: string;
|
||||
/**
|
||||
* APP 用户
|
||||
*/
|
||||
userId?: string;
|
||||
/**
|
||||
* 关联设备,可空
|
||||
*/
|
||||
deviceId?: string;
|
||||
/**
|
||||
* 问题类型
|
||||
*/
|
||||
problemType?: string;
|
||||
/**
|
||||
* 用户问题摘要
|
||||
*/
|
||||
summary?: string;
|
||||
/**
|
||||
* 状态(0机器人 1排队 2人工 3已结束)
|
||||
*/
|
||||
status?: boolean;
|
||||
/**
|
||||
* 当前接待客服
|
||||
*/
|
||||
agentId?: string;
|
||||
/**
|
||||
* 接入方式(bot/bot_to_human/human)
|
||||
*/
|
||||
accessType?: string;
|
||||
/**
|
||||
* 排队序号
|
||||
*/
|
||||
queueNo?: number;
|
||||
/**
|
||||
* 会话开始时间
|
||||
*/
|
||||
startTime?: string;
|
||||
/**
|
||||
* 人工接入时间
|
||||
*/
|
||||
acceptTime?: string;
|
||||
/**
|
||||
* 结束时间
|
||||
*/
|
||||
endTime?: string;
|
||||
/**
|
||||
* 最后消息时间
|
||||
*/
|
||||
lastMessageTime?: string;
|
||||
}
|
||||
88
src/api/system/cskb/index.ts
Normal file
88
src/api/system/cskb/index.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type {CsKbForm, CsKbQuery, CsKbVO} from '@/api/system/cskb/types';
|
||||
import type {PageResult} from '@/api/types';
|
||||
import type {AxiosPromise} from '@/utils/api-types';
|
||||
import request from '@/utils/request';
|
||||
|
||||
/**
|
||||
* 查询客服知识库列表
|
||||
* @param query
|
||||
* @returns {*}
|
||||
*/
|
||||
export const listCsKb = (query?: CsKbQuery): AxiosPromise<PageResult<CsKbVO>> => {
|
||||
return request({
|
||||
url: '/cs/kb/list',
|
||||
method: 'get',
|
||||
params: query
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 查询客服知识库详细
|
||||
* @param kbId
|
||||
*/
|
||||
export const getCsKb = (kbId: string | number): AxiosPromise<CsKbVO> => {
|
||||
return request({
|
||||
url: '/cs/kb/' + kbId,
|
||||
method: 'get'
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 新增客服知识库
|
||||
* @param data
|
||||
*/
|
||||
export const addCsKb = (data: CsKbForm) => {
|
||||
return request({
|
||||
url: '/cs/kb',
|
||||
method: 'post',
|
||||
data: data
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 修改客服知识库
|
||||
* @param data
|
||||
*/
|
||||
export const updateCsKb = (data: CsKbForm) => {
|
||||
return request({
|
||||
url: '/cs/kb',
|
||||
method: 'put',
|
||||
data: data
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 删除客服知识库
|
||||
* @param kbId
|
||||
*/
|
||||
export const delCsKb = (kbId: string | number | Array<string | number>) => {
|
||||
return request({
|
||||
url: '/cs/kb/' + kbId,
|
||||
method: 'delete'
|
||||
});
|
||||
};
|
||||
/**
|
||||
* 移动客服知识库
|
||||
* @param kbId
|
||||
* @param direction
|
||||
*/
|
||||
export const moveSkb = (kbId: string, direction: string = 'up'): AxiosPromise<{ moved: boolean }> => {
|
||||
return request({
|
||||
url: '/cs/kb/move',
|
||||
method: 'put',
|
||||
data: {
|
||||
kbId,
|
||||
direction,
|
||||
}
|
||||
});
|
||||
};
|
||||
/**
|
||||
* 知识库总览
|
||||
*/
|
||||
export const overViewCsKb = (): AxiosPromise<{ "enabledCount": number, "stoppedCount": number }> => {
|
||||
return request({
|
||||
url: '/cs/kb/overview',
|
||||
method: 'get',
|
||||
});
|
||||
};
|
||||
172
src/api/system/cskb/types.ts
Normal file
172
src/api/system/cskb/types.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
export interface CsKbVO {
|
||||
/**
|
||||
* 快捷按钮
|
||||
*/
|
||||
buttons: CzCsKbButton[];
|
||||
/**
|
||||
* 分类
|
||||
*/
|
||||
category: string;
|
||||
/**
|
||||
* 内容
|
||||
*/
|
||||
content: string;
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
createTime: string;
|
||||
/**
|
||||
* 知识库主键
|
||||
*/
|
||||
kbId: string;
|
||||
/**
|
||||
* 知识编号
|
||||
*/
|
||||
kbNo: string;
|
||||
/**
|
||||
* 排序
|
||||
*/
|
||||
sort: number;
|
||||
/**
|
||||
* 状态(0停用 1启用)
|
||||
*/
|
||||
status: string;
|
||||
/**
|
||||
* 状态名称
|
||||
*/
|
||||
statusName: string;
|
||||
/**
|
||||
* 标题
|
||||
*/
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识库快捷按钮 cz_cs_kb_button。
|
||||
*
|
||||
* CzCsKbButton
|
||||
*/
|
||||
export type CzCsKbButton = {
|
||||
/**
|
||||
* 按钮编码
|
||||
*/
|
||||
buttonCode: string;
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
buttonId: string;
|
||||
/**
|
||||
* 知识库ID
|
||||
*/
|
||||
kbId: string;
|
||||
/**
|
||||
* 按钮文案
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* 排序
|
||||
*/
|
||||
sort: number;
|
||||
}
|
||||
|
||||
export interface CsKbForm extends BaseEntity {
|
||||
/**
|
||||
* 主键(雪花)
|
||||
*/
|
||||
kbId?: string | number;
|
||||
/**
|
||||
* 知识编号,如 KB-001
|
||||
*/
|
||||
kbNo?: string;
|
||||
/**
|
||||
* 问题标题
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* 分类(设备配网/耗材滤芯/固件升级/设备报修;固件升级仅文案分类)
|
||||
*/
|
||||
category?: string;
|
||||
/**
|
||||
* 机器人回复内容
|
||||
*/
|
||||
content?: string;
|
||||
/**
|
||||
* 排序权重,数字越小越靠前
|
||||
*/
|
||||
sort?: number;
|
||||
/**
|
||||
* 状态(0停用 1启用)
|
||||
*/
|
||||
status?: string;
|
||||
/**
|
||||
* 创建部门
|
||||
*/
|
||||
createDept?: number;
|
||||
/**
|
||||
* 创建者
|
||||
*/
|
||||
createBy?: number;
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
createTime?: string;
|
||||
/**
|
||||
* 更新者
|
||||
*/
|
||||
updateBy?: number;
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
export interface CsKbQuery extends PageQuery {
|
||||
/**
|
||||
* 知识编号,如 KB-001
|
||||
*/
|
||||
kbNo?: string;
|
||||
/**
|
||||
* 问题标题
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* 分类(设备配网/耗材滤芯/固件升级/设备报修;固件升级仅文案分类)
|
||||
*/
|
||||
category?: string;
|
||||
/**
|
||||
* 机器人回复内容
|
||||
*/
|
||||
content?: string;
|
||||
/**
|
||||
* 排序权重,数字越小越靠前
|
||||
*/
|
||||
sort?: number;
|
||||
/**
|
||||
* 状态(0停用 1启用)
|
||||
*/
|
||||
status?: string;
|
||||
/**
|
||||
* 创建部门
|
||||
*/
|
||||
createDept?: number;
|
||||
/**
|
||||
* 创建者
|
||||
*/
|
||||
createBy?: number;
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
createTime?: string;
|
||||
/**
|
||||
* 更新者
|
||||
*/
|
||||
updateBy?: number;
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
updateTime?: string;
|
||||
/**
|
||||
* 日期范围参数
|
||||
*/
|
||||
params?: any;
|
||||
}
|
||||
108
src/api/system/cssAgent/index.ts
Normal file
108
src/api/system/cssAgent/index.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type {CsAgentForm, CsAgentQuery, CsAgentVO} from '@/api/system/cssAgent/types';
|
||||
import type {PageResult} from '@/api/types';
|
||||
import type {AxiosPromise} from '@/utils/api-types';
|
||||
import request from '@/utils/request';
|
||||
|
||||
/**
|
||||
* 查询客服人员列表
|
||||
* @param query
|
||||
* @returns {*}
|
||||
*/
|
||||
export const listCsAgent = (query?: CsAgentQuery): AxiosPromise<PageResult<CsAgentVO>> => {
|
||||
return request({
|
||||
url: '/cs/agent/list',
|
||||
method: 'get',
|
||||
params: query
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 查询客服人员详细
|
||||
* @param agentId
|
||||
*/
|
||||
export const getCsAgent = (agentId: string | number): AxiosPromise<CsAgentVO> => {
|
||||
return request({
|
||||
url: '/cs/agent/' + agentId,
|
||||
method: 'get'
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 新增客服人员
|
||||
* @param data
|
||||
*/
|
||||
export const addCsAgent = (data: CsAgentForm) => {
|
||||
return request({
|
||||
url: '/cs/agent',
|
||||
method: 'post',
|
||||
data: data
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 修改客服人员
|
||||
* @param data
|
||||
*/
|
||||
export const updateCsAgent = (data: CsAgentForm) => {
|
||||
return request({
|
||||
url: '/cs/agent',
|
||||
method: 'put',
|
||||
data: data
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 删除客服人员
|
||||
* @param agentId
|
||||
*/
|
||||
export const delCsAgent = (agentId: string | number | Array<string | number>) => {
|
||||
return request({
|
||||
url: '/cs/agent/' + agentId,
|
||||
method: 'delete'
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 客服人员 在线状态汇总
|
||||
*/
|
||||
export const statCsAgent = (): AxiosPromise<{
|
||||
total: number,
|
||||
onlineCount: number,
|
||||
busyCount: number,
|
||||
offlineCount: number
|
||||
}> => {
|
||||
return request({
|
||||
url: '/cs/agent/stat',
|
||||
method: 'get',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 客服人员 重置密码
|
||||
*/
|
||||
export const ResetPwdCsAgent = (agentId: string, password: string = '12345678') => {
|
||||
return request({
|
||||
url: '/cs/agent/resetPwd',
|
||||
method: 'put',
|
||||
data: {
|
||||
agentId: agentId,
|
||||
password: password
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 客服人员 批量禁用账号
|
||||
*/
|
||||
export const BatchDisableCsAgent = (agentIds: string[]) => {
|
||||
return request({
|
||||
url: '/cs/agent/batchDisable',
|
||||
method: 'put',
|
||||
data: {
|
||||
agentIds: agentIds,
|
||||
}
|
||||
});
|
||||
};
|
||||
168
src/api/system/cssAgent/types.ts
Normal file
168
src/api/system/cssAgent/types.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
export interface CsAgentVO {
|
||||
/**
|
||||
* 主键(雪花)
|
||||
*/
|
||||
agentId: string;
|
||||
/**
|
||||
* 客服编号,如 KF-001
|
||||
*/
|
||||
agentNo: string;
|
||||
/**
|
||||
* 工号
|
||||
*/
|
||||
jobNo: string;
|
||||
/**
|
||||
* 客服姓名
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* 登录账号
|
||||
*/
|
||||
account: string;
|
||||
/**
|
||||
* 密码(BCrypt)
|
||||
*/
|
||||
password: string;
|
||||
/**
|
||||
* 手机号
|
||||
*/
|
||||
phone: string;
|
||||
/**
|
||||
* 头像 oss_id
|
||||
*/
|
||||
avatarOssId: string | number;
|
||||
/**
|
||||
* 在线状态(0离线 1在线 2忙碌)
|
||||
*/
|
||||
onlineStatus: string;
|
||||
/**
|
||||
* 账号状态(0启用 1禁用)
|
||||
*/
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface CsAgentForm extends BaseEntity {
|
||||
/**
|
||||
* 主键(雪花)
|
||||
*/
|
||||
agentId?: string;
|
||||
/**
|
||||
* 客服编号,如 KF-001
|
||||
*/
|
||||
agentNo?: string;
|
||||
/**
|
||||
* 工号
|
||||
*/
|
||||
jobNo?: string;
|
||||
/**
|
||||
* 客服姓名
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* 登录账号
|
||||
*/
|
||||
account?: string;
|
||||
/**
|
||||
* 密码(BCrypt)
|
||||
*/
|
||||
password?: string;
|
||||
/**
|
||||
* 手机号
|
||||
*/
|
||||
phone?: string;
|
||||
/**
|
||||
* 头像 oss_id
|
||||
*/
|
||||
avatarOssId?: string | number;
|
||||
/**
|
||||
* 在线状态(0离线 1在线 2忙碌)
|
||||
*/
|
||||
onlineStatus?: string;
|
||||
/**
|
||||
* 账号状态(0启用 1禁用)
|
||||
*/
|
||||
status?: string;
|
||||
/**
|
||||
* 创建部门
|
||||
*/
|
||||
createDept?: number;
|
||||
/**
|
||||
* 创建者
|
||||
*/
|
||||
createBy?: number;
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
createTime?: string;
|
||||
/**
|
||||
* 更新者
|
||||
*/
|
||||
updateBy?: number;
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
export interface CsAgentQuery extends PageQuery {
|
||||
/**
|
||||
* 客服编号,如 KF-001
|
||||
*/
|
||||
agentNo?: string;
|
||||
/**
|
||||
* 工号
|
||||
*/
|
||||
jobNo?: string;
|
||||
/**
|
||||
* 客服姓名
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* 登录账号
|
||||
*/
|
||||
account?: string;
|
||||
/**
|
||||
* 密码(BCrypt)
|
||||
*/
|
||||
password?: string;
|
||||
/**
|
||||
* 手机号
|
||||
*/
|
||||
phone?: string;
|
||||
/**
|
||||
* 头像 oss_id
|
||||
*/
|
||||
avatarOssId?: string | number;
|
||||
/**
|
||||
* 在线状态(0离线 1在线 2忙碌)
|
||||
*/
|
||||
onlineStatus?: string;
|
||||
/**
|
||||
* 账号状态(0启用 1禁用)
|
||||
*/
|
||||
status?: string;
|
||||
/**
|
||||
* 创建部门
|
||||
*/
|
||||
createDept?: number;
|
||||
/**
|
||||
* 创建者
|
||||
*/
|
||||
createBy?: number;
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
createTime?: string;
|
||||
/**
|
||||
* 更新者
|
||||
*/
|
||||
updateBy?: number;
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
updateTime?: string;
|
||||
/**
|
||||
* 日期范围参数
|
||||
*/
|
||||
params?: any;
|
||||
}
|
||||
135
src/api/system/customer/index.ts
Normal file
135
src/api/system/customer/index.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type {UserForm, UserQuery, UserVO} from '@/api/system/customer/types';
|
||||
import type {PageResult} from '@/api/types';
|
||||
import type {AxiosPromise} from '@/utils/api-types';
|
||||
import request from '@/utils/request';
|
||||
import {blobValidate} from "@/utils/ruoyi";
|
||||
import {saveBlob} from "@/utils/save";
|
||||
import type {LoadingInstance} from "element-plus";
|
||||
|
||||
/**
|
||||
* 查询App用户列表
|
||||
* @param query
|
||||
* @returns {*}
|
||||
*/
|
||||
export const listUser = (query?: UserQuery): AxiosPromise<PageResult<UserVO>> => {
|
||||
return request({
|
||||
url: '/customer/user/list',
|
||||
method: 'get',
|
||||
params: query
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 查询App用户详细
|
||||
* @param userId
|
||||
*/
|
||||
export const getUser = (userId: string): AxiosPromise<UserVO> => {
|
||||
return request({
|
||||
url: '/customer/user/' + userId,
|
||||
method: 'get'
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 新增App用户
|
||||
* @param data
|
||||
*/
|
||||
export const addUser = (data: UserForm) => {
|
||||
return request({
|
||||
url: '/customer/user',
|
||||
method: 'post',
|
||||
data: data
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 修改App用户
|
||||
* @param data
|
||||
*/
|
||||
export const updateUser = (data: UserForm) => {
|
||||
return request({
|
||||
url: '/customer/user',
|
||||
method: 'put',
|
||||
data: data
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量禁用App用户
|
||||
* @param userIds
|
||||
*/
|
||||
export const batchDisableUsers = (userIds: string[]) => {
|
||||
return request({
|
||||
url: '/customer/user/batchDisable',
|
||||
method: 'put',
|
||||
data: {
|
||||
userIds: userIds
|
||||
}
|
||||
});
|
||||
};
|
||||
/**
|
||||
* 批量导出选择的App用户
|
||||
* @param userIds
|
||||
* @param fileName
|
||||
*/
|
||||
export const exportSelectedUsers = (userIds: string[], fileName: string) => {
|
||||
request({
|
||||
url: '/customer/user/exportSelected',
|
||||
method: 'post',
|
||||
data: {
|
||||
userIds: userIds
|
||||
}
|
||||
}).then(async (resp: any) => {
|
||||
|
||||
})
|
||||
.catch((r: any) => {
|
||||
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 修改App用户 状态 启用/停用
|
||||
* @param userId
|
||||
* @param status
|
||||
*/
|
||||
export const updateUserStatus = (userId: string, status: '0' | '1') => {
|
||||
return request({
|
||||
url: '/customer/user/changeStatus',
|
||||
method: 'put',
|
||||
data: {
|
||||
status: status,
|
||||
userId: userId
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 删除App用户
|
||||
* @param userId
|
||||
*/
|
||||
export const delUser = (userId: string | number | Array<string | number>) => {
|
||||
return request({
|
||||
url: '/customer/user/' + userId,
|
||||
method: 'delete'
|
||||
});
|
||||
};
|
||||
/**
|
||||
* 重置App用户密码
|
||||
* @param userId
|
||||
* @param password
|
||||
*/
|
||||
export const resetPwdUser = (userId: string, password: string) => {
|
||||
return request({
|
||||
url: '/customer/user/resetPwd',
|
||||
method: 'put',
|
||||
data: {
|
||||
userId: userId,
|
||||
password: password
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
};
|
||||
251
src/api/system/customer/types.ts
Normal file
251
src/api/system/customer/types.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
export interface UserVO {
|
||||
/**
|
||||
* 用户ID(雪花ID)
|
||||
*/
|
||||
userId: string;
|
||||
/**
|
||||
* 用户账号(默认使用手机号)
|
||||
*/
|
||||
userName: string;
|
||||
/**
|
||||
* 用户展示编号,如 U20260918001
|
||||
*/
|
||||
userNo: string;
|
||||
/**
|
||||
* 手机号码
|
||||
*/
|
||||
phonenumber: string;
|
||||
/**
|
||||
* 用户昵称
|
||||
*/
|
||||
nickName: string;
|
||||
/**
|
||||
* 头像 oss_id
|
||||
*/
|
||||
avatarOssId: string;
|
||||
/**
|
||||
* 头像 URL
|
||||
*/
|
||||
avatarUrl: string;
|
||||
/**
|
||||
* 密码(BCrypt 加密)
|
||||
*/
|
||||
password: string;
|
||||
/**
|
||||
* 最近一次修改密码时间
|
||||
*/
|
||||
passwordUpdateTime: string;
|
||||
/**
|
||||
* 性别(0未知 1男 2女)
|
||||
*/
|
||||
gender: string;
|
||||
/**
|
||||
* 生日
|
||||
*/
|
||||
birthday: string;
|
||||
/**
|
||||
* 省份
|
||||
*/
|
||||
province: string;
|
||||
/**
|
||||
* 城市
|
||||
*/
|
||||
city: string;
|
||||
/**
|
||||
* 区县
|
||||
*/
|
||||
district: string;
|
||||
/**
|
||||
* 账号状态(0正常 1停用)
|
||||
*/
|
||||
status: string;
|
||||
/**
|
||||
* 注销时间;停用仍用 status
|
||||
*/
|
||||
cancelTime: string;
|
||||
/**
|
||||
* 最后登录IP
|
||||
*/
|
||||
loginIp: string;
|
||||
/**
|
||||
* 最后登录时间
|
||||
*/
|
||||
loginDate: string;
|
||||
/**
|
||||
* 登录次数
|
||||
*/
|
||||
loginCount: number;
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface UserForm extends BaseEntity {
|
||||
/**
|
||||
* 用户ID(雪花ID)
|
||||
*/
|
||||
userId?: string;
|
||||
/**
|
||||
* 用户账号(默认使用手机号)
|
||||
*/
|
||||
userName?: string;
|
||||
/**
|
||||
* 用户展示编号,如 U20260918001
|
||||
*/
|
||||
userNo?: string;
|
||||
/**
|
||||
* 手机号码
|
||||
*/
|
||||
phonenumber?: string;
|
||||
/**
|
||||
* 用户昵称
|
||||
*/
|
||||
nickName?: string;
|
||||
/**
|
||||
* 头像 oss_id
|
||||
*/
|
||||
avatarOssId?: string;
|
||||
/**
|
||||
* 头像 URL
|
||||
*/
|
||||
avatarUrl?: string;
|
||||
/**
|
||||
* 密码(BCrypt 加密)
|
||||
*/
|
||||
password?: string;
|
||||
/**
|
||||
* 最近一次修改密码时间
|
||||
*/
|
||||
passwordUpdateTime?: string;
|
||||
/**
|
||||
* 性别(0未知 1男 2女)
|
||||
*/
|
||||
gender?: string;
|
||||
/**
|
||||
* 生日
|
||||
*/
|
||||
birthday?: string;
|
||||
/**
|
||||
* 省份
|
||||
*/
|
||||
province?: string;
|
||||
/**
|
||||
* 城市
|
||||
*/
|
||||
city?: string;
|
||||
/**
|
||||
* 区县
|
||||
*/
|
||||
district?: string;
|
||||
/**
|
||||
* 账号状态(0正常 1停用)
|
||||
*/
|
||||
status?: string;
|
||||
/**
|
||||
* 注销时间;停用仍用 status
|
||||
*/
|
||||
cancelTime?: string;
|
||||
/**
|
||||
* 最后登录IP
|
||||
*/
|
||||
loginIp?: string;
|
||||
/**
|
||||
* 最后登录时间
|
||||
*/
|
||||
loginDate?: string;
|
||||
/**
|
||||
* 登录次数
|
||||
*/
|
||||
loginCount?: number;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
createTime?: string;
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface UserQuery extends PageQuery {
|
||||
endTime?: string;
|
||||
beginTime?: string;
|
||||
/**
|
||||
* 用户账号(默认使用手机号)
|
||||
*/
|
||||
userName?: string;
|
||||
/**
|
||||
* 用户展示编号,如 U20260918001
|
||||
*/
|
||||
userNo?: string;
|
||||
/**
|
||||
* 手机号码
|
||||
*/
|
||||
phonenumber?: string;
|
||||
/**
|
||||
* 用户昵称
|
||||
*/
|
||||
nickName?: string;
|
||||
/**
|
||||
* 头像 oss_id
|
||||
*/
|
||||
avatarOssId?: string | number;
|
||||
/**
|
||||
* 头像 URL
|
||||
*/
|
||||
avatarUrl?: string;
|
||||
/**
|
||||
* 密码(BCrypt 加密)
|
||||
*/
|
||||
password?: string;
|
||||
/**
|
||||
* 最近一次修改密码时间
|
||||
*/
|
||||
passwordUpdateTime?: string;
|
||||
/**
|
||||
* 性别(0未知 1男 2女)
|
||||
*/
|
||||
gender?: string;
|
||||
/**
|
||||
* 生日
|
||||
*/
|
||||
birthday?: string;
|
||||
/**
|
||||
* 省份
|
||||
*/
|
||||
province?: string;
|
||||
/**
|
||||
* 城市
|
||||
*/
|
||||
city?: string;
|
||||
/**
|
||||
* 区县
|
||||
*/
|
||||
district?: string;
|
||||
/**
|
||||
* 账号状态(0正常 1停用)
|
||||
*/
|
||||
status?: string;
|
||||
/**
|
||||
* 注销时间;停用仍用 status
|
||||
*/
|
||||
cancelTime?: string;
|
||||
/**
|
||||
* 最后登录IP
|
||||
*/
|
||||
loginIp?: string;
|
||||
/**
|
||||
* 最后登录时间
|
||||
*/
|
||||
loginDate?: string;
|
||||
/**
|
||||
* 登录次数
|
||||
*/
|
||||
loginCount?: number;
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
createTime?: string;
|
||||
}
|
||||
16
src/api/system/dashboard/index.ts
Normal file
16
src/api/system/dashboard/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// 查询部门列表
|
||||
import request from "@/utils/request";
|
||||
|
||||
export const dashboardHome = () => {
|
||||
return request({
|
||||
url: '/dashboard/home',
|
||||
method: 'get',
|
||||
});
|
||||
};
|
||||
|
||||
export const dashboardTvoc = (range: string = '24h') => {
|
||||
return request({
|
||||
url: '/dashboard/tvoc?range=' + range,
|
||||
method: 'get',
|
||||
});
|
||||
};
|
||||
64
src/api/system/device/index.ts
Normal file
64
src/api/system/device/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type {DeviceForm, DeviceQuery, DeviceVO} from '@/api/system/device/types';
|
||||
import type {PageResult} from '@/api/types';
|
||||
import type {AxiosPromise} from '@/utils/api-types';
|
||||
import request from '@/utils/request';
|
||||
|
||||
/**
|
||||
* 查询设备主列表
|
||||
* @param query
|
||||
* @returns {*}
|
||||
*/
|
||||
export const listDevice = (query?: DeviceQuery): AxiosPromise<PageResult<DeviceVO>> => {
|
||||
return request({
|
||||
url: '/device/device/list',
|
||||
method: 'get',
|
||||
params: query
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 查询设备主详细
|
||||
* @param deviceId
|
||||
*/
|
||||
export const getDevice = (deviceId: string | number): AxiosPromise<DeviceVO> => {
|
||||
return request({
|
||||
url: '/device/device/' + deviceId,
|
||||
method: 'get'
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 新增设备主
|
||||
* @param data
|
||||
*/
|
||||
export const addDevice = (data: DeviceForm) => {
|
||||
return request({
|
||||
url: '/device/device',
|
||||
method: 'post',
|
||||
data: data
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 修改设备主
|
||||
* @param data
|
||||
*/
|
||||
export const updateDevice = (data: DeviceForm) => {
|
||||
return request({
|
||||
url: '/device/device',
|
||||
method: 'put',
|
||||
data: data
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 删除设备主
|
||||
* @param deviceId
|
||||
*/
|
||||
export const delDevice = (deviceId: string | number | Array<string | number>) => {
|
||||
return request({
|
||||
url: '/device/device/' + deviceId,
|
||||
method: 'delete'
|
||||
});
|
||||
};
|
||||
428
src/api/system/device/types.ts
Normal file
428
src/api/system/device/types.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
export interface DeviceVO {
|
||||
/**
|
||||
* 主键(雪花,仅内部关联)
|
||||
*/
|
||||
deviceId: string | number;
|
||||
/**
|
||||
* 业务编号,未删除记录内唯一;逻辑删除后可再次使用
|
||||
*/
|
||||
deviceNo: string;
|
||||
/**
|
||||
* 绑定码IMEI,可空;未删除记录内唯一,逻辑删除后可再次使用
|
||||
*/
|
||||
imei: string;
|
||||
/**
|
||||
* 用户侧名称≤20字符
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* 产品模板
|
||||
*/
|
||||
productId: string | number;
|
||||
/**
|
||||
* 型号快照,新增时取自产品模板
|
||||
*/
|
||||
model: string;
|
||||
/**
|
||||
* 出厂序列号
|
||||
*/
|
||||
sn: string;
|
||||
/**
|
||||
* 硬件版本
|
||||
*/
|
||||
hwVersion: string;
|
||||
/**
|
||||
* 软件版本
|
||||
*/
|
||||
swVersion: string;
|
||||
/**
|
||||
* 固件版本
|
||||
*/
|
||||
fwVersion: string;
|
||||
/**
|
||||
* 数据包版本
|
||||
*/
|
||||
pkgVersion: string;
|
||||
/**
|
||||
* 设备IP
|
||||
*/
|
||||
ip: string;
|
||||
/**
|
||||
* MAC,未删除记录内唯一;逻辑删除后可再次添加
|
||||
*/
|
||||
mac: string;
|
||||
/**
|
||||
* WiFi SSID
|
||||
*/
|
||||
wifiSsid: string | number;
|
||||
/**
|
||||
* 蓝牙(0未连接 1已连接)
|
||||
*/
|
||||
bluetoothConnected: string;
|
||||
/**
|
||||
* 状态(0未激活 1在线 2离线)
|
||||
*/
|
||||
status: boolean;
|
||||
/**
|
||||
* 当前绑定APP用户
|
||||
*/
|
||||
bindUserId: string | number;
|
||||
/**
|
||||
* 最近一次绑定时间
|
||||
*/
|
||||
bindTime: string;
|
||||
/**
|
||||
* 当前家庭
|
||||
*/
|
||||
homeId: string | number;
|
||||
/**
|
||||
* 当前房间
|
||||
*/
|
||||
locationId: string | number;
|
||||
/**
|
||||
* 信号等级(0离线 1弱 2一般 3强)
|
||||
*/
|
||||
signalLevel: number;
|
||||
/**
|
||||
* 最新经度
|
||||
*/
|
||||
lng: number;
|
||||
/**
|
||||
* 最新纬度
|
||||
*/
|
||||
lat: number;
|
||||
/**
|
||||
* 地址(设备上报)
|
||||
*/
|
||||
address: string;
|
||||
/**
|
||||
* 充电(0否 1充电中;无电池设备为空)
|
||||
*/
|
||||
charging: string;
|
||||
/**
|
||||
* 设备电量0-100
|
||||
*/
|
||||
deviceBattery: number;
|
||||
/**
|
||||
* 最近一次运行数据上报时间
|
||||
*/
|
||||
lastReportTime: string;
|
||||
/**
|
||||
* 最后在线时间
|
||||
*/
|
||||
lastOnlineTime: string;
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
remark: string;
|
||||
/**
|
||||
* 未删除时的业务编号
|
||||
*/
|
||||
activeDeviceNo: string;
|
||||
/**
|
||||
* 未删除时的IMEI
|
||||
*/
|
||||
activeImei: string;
|
||||
/**
|
||||
* 未删除时的MAC
|
||||
*/
|
||||
activeMac: string;
|
||||
}
|
||||
|
||||
export interface DeviceForm extends BaseEntity {
|
||||
/**
|
||||
* 主键(雪花,仅内部关联)
|
||||
*/
|
||||
deviceId?: string | number;
|
||||
/**
|
||||
* 业务编号,未删除记录内唯一;逻辑删除后可再次使用
|
||||
*/
|
||||
deviceNo?: string;
|
||||
/**
|
||||
* 绑定码IMEI,可空;未删除记录内唯一,逻辑删除后可再次使用
|
||||
*/
|
||||
imei?: string;
|
||||
/**
|
||||
* 用户侧名称≤20字符
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* 产品模板
|
||||
*/
|
||||
productId?: string | number;
|
||||
/**
|
||||
* 型号快照,新增时取自产品模板
|
||||
*/
|
||||
model?: string;
|
||||
/**
|
||||
* 出厂序列号
|
||||
*/
|
||||
sn?: string;
|
||||
/**
|
||||
* 硬件版本
|
||||
*/
|
||||
hwVersion?: string;
|
||||
/**
|
||||
* 软件版本
|
||||
*/
|
||||
swVersion?: string;
|
||||
/**
|
||||
* 固件版本
|
||||
*/
|
||||
fwVersion?: string;
|
||||
/**
|
||||
* 数据包版本
|
||||
*/
|
||||
pkgVersion?: string;
|
||||
/**
|
||||
* 设备IP
|
||||
*/
|
||||
ip?: string;
|
||||
/**
|
||||
* MAC,未删除记录内唯一;逻辑删除后可再次添加
|
||||
*/
|
||||
mac?: string;
|
||||
/**
|
||||
* WiFi SSID
|
||||
*/
|
||||
wifiSsid?: string | number;
|
||||
/**
|
||||
* 蓝牙(0未连接 1已连接)
|
||||
*/
|
||||
bluetoothConnected?: string;
|
||||
/**
|
||||
* 状态(0未激活 1在线 2离线)
|
||||
*/
|
||||
status?: boolean;
|
||||
/**
|
||||
* 当前绑定APP用户
|
||||
*/
|
||||
bindUserId?: string | number;
|
||||
/**
|
||||
* 最近一次绑定时间
|
||||
*/
|
||||
bindTime?: string;
|
||||
/**
|
||||
* 当前家庭
|
||||
*/
|
||||
homeId?: string | number;
|
||||
/**
|
||||
* 当前房间
|
||||
*/
|
||||
locationId?: string | number;
|
||||
/**
|
||||
* 信号等级(0离线 1弱 2一般 3强)
|
||||
*/
|
||||
signalLevel?: number;
|
||||
/**
|
||||
* 最新经度
|
||||
*/
|
||||
lng?: number;
|
||||
/**
|
||||
* 最新纬度
|
||||
*/
|
||||
lat?: number;
|
||||
/**
|
||||
* 地址(设备上报)
|
||||
*/
|
||||
address?: string;
|
||||
/**
|
||||
* 充电(0否 1充电中;无电池设备为空)
|
||||
*/
|
||||
charging?: string;
|
||||
/**
|
||||
* 设备电量0-100
|
||||
*/
|
||||
deviceBattery?: number;
|
||||
/**
|
||||
* 最近一次运行数据上报时间
|
||||
*/
|
||||
lastReportTime?: string;
|
||||
/**
|
||||
* 最后在线时间
|
||||
*/
|
||||
lastOnlineTime?: string;
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
remark?: string;
|
||||
/**
|
||||
* 创建部门
|
||||
*/
|
||||
createDept?: number;
|
||||
/**
|
||||
* 创建者
|
||||
*/
|
||||
createBy?: number;
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
createTime?: string;
|
||||
/**
|
||||
* 更新者
|
||||
*/
|
||||
updateBy?: number;
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
updateTime?: string;
|
||||
/**
|
||||
* 未删除时的业务编号
|
||||
*/
|
||||
activeDeviceNo?: string;
|
||||
/**
|
||||
* 未删除时的IMEI
|
||||
*/
|
||||
activeImei?: string;
|
||||
/**
|
||||
* 未删除时的MAC
|
||||
*/
|
||||
activeMac?: string;
|
||||
}
|
||||
|
||||
export interface DeviceQuery extends PageQuery {
|
||||
/**
|
||||
* 业务编号,未删除记录内唯一;逻辑删除后可再次使用
|
||||
*/
|
||||
deviceNo?: string;
|
||||
/**
|
||||
* 绑定码IMEI,可空;未删除记录内唯一,逻辑删除后可再次使用
|
||||
*/
|
||||
imei?: string;
|
||||
/**
|
||||
* 用户侧名称≤20字符
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* 产品模板
|
||||
*/
|
||||
productId?: string | number;
|
||||
/**
|
||||
* 型号快照,新增时取自产品模板
|
||||
*/
|
||||
model?: string;
|
||||
/**
|
||||
* 出厂序列号
|
||||
*/
|
||||
sn?: string;
|
||||
/**
|
||||
* 硬件版本
|
||||
*/
|
||||
hwVersion?: string;
|
||||
/**
|
||||
* 软件版本
|
||||
*/
|
||||
swVersion?: string;
|
||||
/**
|
||||
* 固件版本
|
||||
*/
|
||||
fwVersion?: string;
|
||||
/**
|
||||
* 数据包版本
|
||||
*/
|
||||
pkgVersion?: string;
|
||||
/**
|
||||
* 设备IP
|
||||
*/
|
||||
ip?: string;
|
||||
/**
|
||||
* MAC,未删除记录内唯一;逻辑删除后可再次添加
|
||||
*/
|
||||
mac?: string;
|
||||
/**
|
||||
* WiFi SSID
|
||||
*/
|
||||
wifiSsid?: string | number;
|
||||
/**
|
||||
* 蓝牙(0未连接 1已连接)
|
||||
*/
|
||||
bluetoothConnected?: string;
|
||||
/**
|
||||
* 状态(0未激活 1在线 2离线)
|
||||
*/
|
||||
status?: boolean;
|
||||
/**
|
||||
* 当前绑定APP用户
|
||||
*/
|
||||
bindUserId?: string | number;
|
||||
/**
|
||||
* 最近一次绑定时间
|
||||
*/
|
||||
bindTime?: string;
|
||||
/**
|
||||
* 当前家庭
|
||||
*/
|
||||
homeId?: string | number;
|
||||
/**
|
||||
* 当前房间
|
||||
*/
|
||||
locationId?: string | number;
|
||||
/**
|
||||
* 信号等级(0离线 1弱 2一般 3强)
|
||||
*/
|
||||
signalLevel?: number;
|
||||
/**
|
||||
* 最新经度
|
||||
*/
|
||||
lng?: number;
|
||||
/**
|
||||
* 最新纬度
|
||||
*/
|
||||
lat?: number;
|
||||
/**
|
||||
* 地址(设备上报)
|
||||
*/
|
||||
address?: string;
|
||||
/**
|
||||
* 充电(0否 1充电中;无电池设备为空)
|
||||
*/
|
||||
charging?: string;
|
||||
/**
|
||||
* 设备电量0-100
|
||||
*/
|
||||
deviceBattery?: number;
|
||||
/**
|
||||
* 最近一次运行数据上报时间
|
||||
*/
|
||||
lastReportTime?: string;
|
||||
/**
|
||||
* 最后在线时间
|
||||
*/
|
||||
lastOnlineTime?: string;
|
||||
/**
|
||||
* 创建部门
|
||||
*/
|
||||
createDept?: number;
|
||||
/**
|
||||
* 创建者
|
||||
*/
|
||||
createBy?: number;
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
createTime?: string;
|
||||
/**
|
||||
* 更新者
|
||||
*/
|
||||
updateBy?: number;
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
updateTime?: string;
|
||||
/**
|
||||
* 未删除时的业务编号
|
||||
*/
|
||||
activeDeviceNo?: string;
|
||||
/**
|
||||
* 未删除时的IMEI
|
||||
*/
|
||||
activeImei?: string;
|
||||
/**
|
||||
* 未删除时的MAC
|
||||
*/
|
||||
activeMac?: string;
|
||||
/**
|
||||
* 日期范围参数
|
||||
*/
|
||||
params?: any;
|
||||
}
|
||||
@@ -8,8 +8,7 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
background: var(--app-shell-bg);
|
||||
font-family:
|
||||
'MiSans', 'HarmonyOS Sans SC', 'PingFang SC', 'Source Han Sans SC', 'Noto Sans SC', 'Hiragino Sans GB',
|
||||
font-family: 'MiSans', 'HarmonyOS Sans SC', 'PingFang SC', 'Source Han Sans SC', 'Noto Sans SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
@@ -88,3 +87,18 @@ h6 {
|
||||
line-height: 1.1;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
ul, li {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
// 通用卡片
|
||||
.commonCard {
|
||||
padding: 24px;
|
||||
border-radius: 16px 16px 16px 16px;
|
||||
border: 1px solid rgba(238, 240, 243, 1);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* 业务列表/卡片页:分页、树、列表、el-card 等(与 vendors/element-plus 通用覆盖叠加,入口顺序见 index.scss)。
|
||||
/* 业务列表/卡片页:分页、树、列表、el-card 等(与 vendors/element-plus 通用覆盖叠加,入口顺序见 index.vue.scss)。
|
||||
* 属「页面模式」而非 EP 公共主题,故放在 components。 */
|
||||
|
||||
// --- 历史「表单分区标题」样式 ---
|
||||
@@ -51,8 +51,7 @@
|
||||
box-shadow: var(--app-shadow-sm);
|
||||
border-color: var(--app-surface-border);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
transition: box-shadow 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
background: var(--app-surface-bg);
|
||||
}
|
||||
|
||||
@@ -2,16 +2,20 @@
|
||||
|
||||
// --- 表格工具列链接按钮(全局) ---
|
||||
.data-table .el-button.is-link {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
width: unset !important;
|
||||
height: unset !important;
|
||||
min-width: 28px !important;
|
||||
padding: 0 !important;
|
||||
border-radius: var(--app-radius-md) !important;
|
||||
background: var(--app-accent-soft) !important;
|
||||
//background: var(--app-accent-soft) !important;
|
||||
}
|
||||
|
||||
.data-table .el-button--primary.is-link {
|
||||
color: #4a7cff;
|
||||
}
|
||||
|
||||
.data-table .el-button.is-link + .el-button.is-link {
|
||||
margin-left: 4px !important;
|
||||
margin-left: 12px !important;
|
||||
}
|
||||
|
||||
.search-wrap {
|
||||
@@ -37,8 +41,7 @@
|
||||
overflow: hidden;
|
||||
max-height: 560px;
|
||||
opacity: 1;
|
||||
transition:
|
||||
max-height 0.32s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transition: max-height 0.32s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 0.24s ease,
|
||||
padding-top 0.24s ease,
|
||||
padding-bottom 0.24s ease;
|
||||
@@ -73,8 +76,7 @@
|
||||
border-bottom: 2px solid currentColor;
|
||||
color: var(--app-text-muted);
|
||||
transform: rotate(-135deg);
|
||||
transition:
|
||||
transform 0.24s ease,
|
||||
transition: transform 0.24s ease,
|
||||
color 0.24s ease;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
& .el-sub-menu {
|
||||
overflow: hidden;
|
||||
border-radius: var(--app-radius-md);
|
||||
//border-radius: var(--app-radius-md);
|
||||
|
||||
.el-sub-menu__title,
|
||||
.el-sub-menu__title.el-tooltip__trigger {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
.sidebar-container {
|
||||
.theme-dark .submenu-title-noDropdown,
|
||||
.theme-dark .el-sub-menu__title {
|
||||
border-radius: var(--app-radius-md);
|
||||
//border-radius: var(--app-radius-md);
|
||||
margin: 3px 8px;
|
||||
height: 40px;
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
|
||||
.submenu-title-noDropdown,
|
||||
.el-sub-menu__title {
|
||||
border-radius: var(--app-radius-md);
|
||||
margin: 3px 8px;
|
||||
//border-radius: var(--app-radius-md);
|
||||
margin: 0;
|
||||
height: 40px;
|
||||
color: var(--side-menu-text) !important;
|
||||
|
||||
@@ -36,10 +36,11 @@
|
||||
& .nest-menu .el-sub-menu > .el-sub-menu__title,
|
||||
& .el-sub-menu .el-menu-item {
|
||||
min-width: calc($base-sidebar-width - 16px) !important;
|
||||
border-radius: var(--app-radius-md);
|
||||
//border-radius: var(--app-radius-md);
|
||||
height: 40px;
|
||||
margin: 3px 8px;
|
||||
background: transparent !important;
|
||||
//margin: 3px 8px;
|
||||
//background: transparent !important;
|
||||
background: #f7f7f7 !important;
|
||||
|
||||
&:not(.is-active):hover {
|
||||
background-color: var(--side-menu-hover-bg) !important;
|
||||
@@ -49,16 +50,16 @@
|
||||
|
||||
& .theme-dark .nest-menu .el-sub-menu > .el-sub-menu__title,
|
||||
& .theme-dark .el-sub-menu .el-menu-item {
|
||||
border-radius: var(--app-radius-md);
|
||||
//border-radius: var(--app-radius-md);
|
||||
height: 40px;
|
||||
margin: 3px 8px;
|
||||
background: transparent !important;
|
||||
//margin: 3px 8px;
|
||||
//background: transparent !important;
|
||||
background: #f7f7f7 !important;
|
||||
|
||||
&.is-active {
|
||||
background: var(--side-menu-active-bg) !important;
|
||||
color: var(--side-menu-active-text) !important;
|
||||
box-shadow:
|
||||
inset 3px 0 0 var(--side-menu-active-line),
|
||||
box-shadow: inset 3px 0 0 var(--side-menu-active-line),
|
||||
inset 0 0 0 1px var(--side-menu-active-border);
|
||||
}
|
||||
|
||||
@@ -70,16 +71,16 @@
|
||||
|
||||
& .theme-dark .nest-menu .el-sub-menu > .el-sub-menu__title,
|
||||
& .theme-dark .el-menu-item {
|
||||
border-radius: var(--app-radius-md);
|
||||
//border-radius: var(--app-radius-md);
|
||||
height: 40px;
|
||||
margin: 3px 8px;
|
||||
background: transparent !important;
|
||||
//margin: 3px 8px;
|
||||
//background: transparent !important;
|
||||
background: #f7f7f7 !important;
|
||||
|
||||
&.is-active {
|
||||
background: var(--side-menu-active-bg) !important;
|
||||
color: var(--side-menu-active-text) !important;
|
||||
box-shadow:
|
||||
inset 3px 0 0 var(--side-menu-active-line),
|
||||
box-shadow: inset 3px 0 0 var(--side-menu-active-line),
|
||||
inset 0 0 0 1px var(--side-menu-active-border);
|
||||
}
|
||||
|
||||
@@ -91,17 +92,17 @@
|
||||
|
||||
& .nest-menu .el-sub-menu > .el-sub-menu__title,
|
||||
& .el-menu-item {
|
||||
border-radius: var(--app-radius-md);
|
||||
//border-radius: var(--app-radius-md);
|
||||
height: 40px;
|
||||
margin: 3px 8px;
|
||||
//margin: 0 8px;
|
||||
background: transparent !important;
|
||||
|
||||
&.is-active {
|
||||
background: var(--side-menu-active-bg) !important;
|
||||
color: var(--side-menu-active-text) !important;
|
||||
box-shadow:
|
||||
inset 3px 0 0 var(--side-menu-active-line),
|
||||
inset 0 0 0 1px var(--side-menu-active-border);
|
||||
//box-shadow:
|
||||
// inset 3px 0 0 var(--side-menu-active-line),
|
||||
// inset 0 0 0 1px var(--side-menu-active-border);
|
||||
}
|
||||
|
||||
&:not(.is-active):hover {
|
||||
@@ -111,3 +112,48 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
// 侧边栏滚动容器,假设侧边栏滚动区域 class 为 sidebar-scroll
|
||||
.beautify_scroll {
|
||||
// Firefox 滚动条
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--side-scrollbar-thumb) var(--side-scrollbar-bg);
|
||||
|
||||
// webkit 滚动条
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px; // 滚动条宽度
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: var(--side-scrollbar-bg);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--side-scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: var(--side-scrollbar-thumb-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
// 浅色主题变量
|
||||
--side-scrollbar-bg: transparent;
|
||||
--side-scrollbar-thumb: #c0c4cc;
|
||||
--side-scrollbar-thumb-hover: #909399;
|
||||
|
||||
// 暗黑主题覆盖变量
|
||||
&.theme-dark {
|
||||
--side-scrollbar-bg: transparent;
|
||||
--side-scrollbar-thumb: #414243;
|
||||
--side-scrollbar-thumb-hover: #58595b;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
position: fixed;
|
||||
font-size: 0;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
//left: 12px;
|
||||
z-index: 1001;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -39,4 +39,4 @@ $--color-warning: $color-warning;
|
||||
$--color-danger: $color-danger;
|
||||
$--color-info: $color-info;
|
||||
|
||||
$base-sidebar-width: 200px;
|
||||
$base-sidebar-width: 244px;
|
||||
|
||||
@@ -64,4 +64,6 @@
|
||||
--el-box-shadow-light: var(--app-shadow-sm);
|
||||
--el-box-shadow: var(--app-shadow-md);
|
||||
--el-bg-color-page: var(--app-shell-bg);
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -28,12 +28,21 @@
|
||||
|
||||
// --- primary 品牌色(亮色;暗色见 tokens/_theme-dark) ---
|
||||
.el-button--primary {
|
||||
--el-button-bg-color: var(--app-accent-strong);
|
||||
border: none;
|
||||
--el-button-bg-color: #2563eb;
|
||||
--el-button-border-color: var(--app-accent-strong);
|
||||
--el-button-hover-bg-color: #4a7cff;
|
||||
--el-button-hover-border-color: #4a7cff;
|
||||
--el-button-active-bg-color: #2e5ce0;
|
||||
--el-button-active-border-color: #2e5ce0;
|
||||
|
||||
&.is-plain {
|
||||
//color: rgba(37, 99, 235, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.el-button--info {
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
// --- 表格 CSS 变量接业务 token ---
|
||||
@@ -82,3 +91,4 @@ html.dark .el-table {
|
||||
.el-select__wrapper {
|
||||
box-shadow: 0 0 0 1px var(--el-border-color) inset;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
<el-breadcrumb class="app-breadcrumb" separator="/">
|
||||
<transition-group name="breadcrumb">
|
||||
<el-breadcrumb-item v-for="(item, index) in levelList" :key="item.path">
|
||||
<span v-if="item.redirect === 'noRedirect' || index == levelList.length - 1" class="no-redirect">
|
||||
{{ item.meta?.title }}
|
||||
</span>
|
||||
<span v-if="item.redirect === 'noRedirect' || index == levelList.length - 1"
|
||||
class="no-redirect"> {{ item.meta?.title }} </span>
|
||||
<a v-else @click.prevent="handleLink(item)">{{ item.meta?.title }}</a>
|
||||
</el-breadcrumb-item>
|
||||
</transition-group>
|
||||
@@ -12,8 +11,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouteLocationMatched } from 'vue-router';
|
||||
import { usePermissionStore } from '@/store/modules/permission';
|
||||
import {RouteLocationMatched} from 'vue-router';
|
||||
import {usePermissionStore} from '@/store/modules/permission';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -37,7 +36,7 @@ const getBreadcrumb = () => {
|
||||
}
|
||||
// 判断是否为首页
|
||||
if (!isDashboard(matched[0])) {
|
||||
matched = [{ path: '/index', meta: { title: '首页' } }].concat(matched);
|
||||
// matched = [{ path: '/index.vue', meta: { title: '首页' } }].concat(matched);
|
||||
}
|
||||
levelList.value = matched.filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false);
|
||||
};
|
||||
@@ -63,7 +62,7 @@ const isDashboard = (route: RouteLocationMatched) => {
|
||||
return name.trim() === 'Index';
|
||||
};
|
||||
const handleLink = item => {
|
||||
const { redirect, path } = item;
|
||||
const {redirect, path} = item;
|
||||
redirect ? router.push(redirect) : router.push(path);
|
||||
};
|
||||
|
||||
@@ -82,14 +81,22 @@ onMounted(() => {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
margin-left: 4px;
|
||||
color: var(--app-text-muted);
|
||||
|
||||
:deep(.el-breadcrumb__separator) {
|
||||
margin: 0 3px;
|
||||
}
|
||||
|
||||
:deep(.el-breadcrumb__inner) {
|
||||
color: inherit;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.el-breadcrumb__item .no-redirect) {
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.no-redirect {
|
||||
color: var(--app-text-title);
|
||||
cursor: text;
|
||||
|
||||
126
src/components/Echarts/index.vue
Normal file
126
src/components/Echarts/index.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div ref="chartRef" :style="{ width, height }" class="echarts-container"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, onMounted, onBeforeUnmount, watch, nextTick, shallowRef} from 'vue';
|
||||
import * as echarts from 'echarts';
|
||||
import type {EChartsOption, ECharts} from 'echarts';
|
||||
|
||||
interface Props {
|
||||
width?: string;
|
||||
height?: string;
|
||||
options: EChartsOption;
|
||||
theme?: string;
|
||||
autoResize?: boolean;
|
||||
immediate?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
theme: '',
|
||||
autoResize: true,
|
||||
immediate: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
ready: [chart: ECharts];
|
||||
click: [params: echarts.ECElementEvent];
|
||||
rendered: [];
|
||||
}>();
|
||||
|
||||
const chartRef = ref<HTMLDivElement>();
|
||||
const chartInstance = shallowRef<ECharts | null>(null);
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const initChart = () => {
|
||||
if (!chartRef.value) return;
|
||||
|
||||
// 如果实例已存在,先销毁重建
|
||||
if (chartInstance.value) {
|
||||
chartInstance.value.dispose();
|
||||
chartInstance.value = null;
|
||||
}
|
||||
|
||||
const instance = echarts.init(chartRef.value, props.theme);
|
||||
chartInstance.value = instance;
|
||||
chartInstance.value.setOption(props.options);
|
||||
|
||||
emit('ready', instance);
|
||||
emit('rendered');
|
||||
|
||||
instance.on('click', (params: echarts.ECElementEvent) => {
|
||||
emit('click', params);
|
||||
});
|
||||
};
|
||||
|
||||
const updateChart = () => {
|
||||
if (!chartInstance.value) return;
|
||||
// clear: true 清除不存在的系列/坐标轴,切换不同数量数据时防止残留旧图形
|
||||
chartInstance.value.setOption(props.options, {
|
||||
notMerge: false,
|
||||
lazyUpdate: false,
|
||||
clear: true
|
||||
});
|
||||
emit('rendered');
|
||||
};
|
||||
|
||||
const resizeChart = () => {
|
||||
if (!chartInstance.value) return;
|
||||
chartInstance.value.resize();
|
||||
};
|
||||
|
||||
const getInstance = () => chartInstance.value;
|
||||
|
||||
const exportAsImage = (type: 'png' | 'svg' | 'jpeg' = 'png') => {
|
||||
return chartInstance.value?.getDataURL({
|
||||
type,
|
||||
pixelRatio: 2,
|
||||
backgroundColor: '#fff'
|
||||
}) || '';
|
||||
};
|
||||
|
||||
// 深度监听 options 变化自动更新图表
|
||||
watch(() => props.options, updateChart, {deep: true});
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (props.immediate) {
|
||||
initChart();
|
||||
}
|
||||
});
|
||||
|
||||
// 开启自适应时监听容器尺寸
|
||||
if (props.autoResize && chartRef.value) {
|
||||
resizeObserver = new ResizeObserver(resizeChart);
|
||||
resizeObserver.observe(chartRef.value);
|
||||
}
|
||||
window.addEventListener('resize', resizeChart);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 【重点修复】顺序:先销毁监听,再销毁图表实例,防止内存泄漏和控制台警告
|
||||
window.removeEventListener('resize', resizeChart);
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
|
||||
chartInstance.value?.dispose();
|
||||
chartInstance.value = null;
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
resizeChart,
|
||||
updateChart,
|
||||
getInstance,
|
||||
exportAsImage
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.echarts-container {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
345
src/components/Editor/cskb.vue
Normal file
345
src/components/Editor/cskb.vue
Normal file
@@ -0,0 +1,345 @@
|
||||
<template>
|
||||
<div class="editor-shell">
|
||||
<EditorToolbar
|
||||
:default-config="toolbarConfig"
|
||||
:editor="editorRef"
|
||||
class="editor-toolbar"
|
||||
mode="default"
|
||||
/>
|
||||
<div :style="styles" class="editor-body">
|
||||
<WangEditor
|
||||
v-model="content"
|
||||
:default-config="editorConfig"
|
||||
class="editor-content"
|
||||
mode="default"
|
||||
@onChange="handleChange"
|
||||
@onCreated="handleCreated"
|
||||
/>
|
||||
</div>
|
||||
<!-- 底部提示、示例、字数统计 -->
|
||||
<div v-if="simpleToolbar" class="editor-tip">
|
||||
<p>请输入标准回复话术,可使用「有序步骤」插入分步指引,例如:</p>
|
||||
<div class="demo-box">
|
||||
<ol>
|
||||
<li>确认设备已通电并处于配网模式(指示灯橙色快闪)</li>
|
||||
<li>打开APP「我的设备」→ 添加设备,选择对应型号</li>
|
||||
<li>输入2.4G Wi-Fi密码并等待约30秒完成连接</li>
|
||||
</ol>
|
||||
</div>
|
||||
<p>以上为示例格式</p>
|
||||
<p>支持插入图片与链接;回复中可引用设备型号,帮助用户快速对号入座</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import '@wangeditor-next/editor/dist/css/style.css';
|
||||
import type {IDomEditor, IEditorConfig, IToolbarConfig} from '@wangeditor-next/editor';
|
||||
import {Editor as WangEditor, Toolbar as EditorToolbar} from '@wangeditor-next/editor-for-vue';
|
||||
import modal from '@/plugins/modal';
|
||||
import {propTypes} from '@/utils/propTypes';
|
||||
import {globalHeaders} from '@/utils/request';
|
||||
import {shallowRef, ref, computed, watch, onBeforeUnmount} from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
/* 编辑器的内容 */
|
||||
modelValue: propTypes.string,
|
||||
/* 高度 */
|
||||
height: propTypes.number.def(400),
|
||||
/* 最小高度 */
|
||||
minHeight: propTypes.number.def(400),
|
||||
/* 只读 */
|
||||
readOnly: propTypes.bool.def(false),
|
||||
/* 图片上传文件大小限制(MB) */
|
||||
fileSize: propTypes.number.def(5),
|
||||
/* 视频上传文件大小限制(MB) */
|
||||
videoSize: propTypes.number.def(100),
|
||||
/* 类型(base64格式、url格式) */
|
||||
type: propTypes.string.def('url'),
|
||||
/* 精简工具栏(仅加粗/有序步骤/图片/链接/清除格式) */
|
||||
simpleToolbar: propTypes.bool.def(false)
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const editorRef = shallowRef<IDomEditor>();
|
||||
const content = ref('');
|
||||
const wordCount = ref(0);
|
||||
|
||||
const baseUrl = import.meta.env.VITE_APP_BASE_API;
|
||||
const uploadOssUrl = baseUrl + '/resource/oss/upload';
|
||||
|
||||
const styles = computed(() => {
|
||||
const style: Record<string, string> = {};
|
||||
if (props.minHeight) {
|
||||
style.minHeight = `${props.minHeight}px`;
|
||||
}
|
||||
if (props.height) {
|
||||
style.height = `${props.height}px`;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
const toolbarConfig = computed<Partial<IToolbarConfig>>(() => {
|
||||
if (props.simpleToolbar) {
|
||||
return {
|
||||
toolbarKeys: [
|
||||
'bold',
|
||||
'numberedList',
|
||||
'uploadImage',
|
||||
'insertLink',
|
||||
'clearStyle',
|
||||
],
|
||||
excludeKeys: []
|
||||
};
|
||||
} else {
|
||||
const excludeKeys: string[] = ['fullScreen'];
|
||||
if (!props.type) {
|
||||
excludeKeys.push('uploadImage');
|
||||
excludeKeys.push('uploadVideo');
|
||||
}
|
||||
return {excludeKeys};
|
||||
}
|
||||
});
|
||||
|
||||
/* ==================== OSS 上传 ==================== */
|
||||
|
||||
const uploadToOss = async (file: File): Promise<{ url: string; fileName: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const res = await fetch(uploadOssUrl, {
|
||||
method: 'POST',
|
||||
headers: globalHeaders(),
|
||||
body: formData
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.code === 200) {
|
||||
return data.data;
|
||||
}
|
||||
throw new Error(data.msg || '上传失败');
|
||||
};
|
||||
|
||||
/* ==================== 文件校验 ==================== */
|
||||
|
||||
const validateImageFile = (file: File) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/svg', 'image/svg+xml'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
modal.msgError('图片格式错误!');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (props.fileSize) {
|
||||
const isLt = file.size / 1024 / 1024 < props.fileSize;
|
||||
if (!isLt) {
|
||||
modal.msgError(`上传图片大小不能超过 ${props.fileSize} MB!`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateVideoFile = (file: File) => {
|
||||
const allowedTypes = ['video/mp4', 'video/webm', 'video/ogg'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
modal.msgError('视频格式错误, 请上传 mp4/webm/ogg 格式!');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (props.videoSize) {
|
||||
const isLt = file.size / 1024 / 1024 < props.videoSize;
|
||||
if (!isLt) {
|
||||
modal.msgError(`上传视频大小不能超过 ${props.videoSize} MB!`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/* ==================== wangeditor 菜单配置 ==================== */
|
||||
|
||||
const getUploadImageMenuConfig = () => {
|
||||
if (props.type === 'base64') {
|
||||
return {
|
||||
allowedFileTypes: ['image/jpeg', 'image/jpg', 'image/png', 'image/svg+xml'],
|
||||
customUpload(file: File, insertFn: (url: string, alt?: string, href?: string) => void) {
|
||||
if (!validateImageFile(file)) return;
|
||||
|
||||
modal.loading('正在上传图片,请稍候...');
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
insertFn(reader.result as string, file.name);
|
||||
modal.closeLoading();
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
modal.msgError('图片插入失败');
|
||||
modal.closeLoading();
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
allowedFileTypes: ['image/jpeg', 'image/jpg', 'image/png', 'image/svg+xml'],
|
||||
async customUpload(file: File, insertFn: (url: string, alt?: string, href?: string) => void) {
|
||||
if (!validateImageFile(file)) return;
|
||||
|
||||
modal.loading('正在上传图片,请稍候...');
|
||||
try {
|
||||
const result = await uploadToOss(file);
|
||||
insertFn(result.url, file.name, result.url);
|
||||
} catch {
|
||||
modal.msgError('图片上传失败');
|
||||
} finally {
|
||||
modal.closeLoading();
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const getUploadVideoMenuConfig = () => ({
|
||||
allowedFileTypes: ['video/mp4', 'video/webm', 'video/ogg'],
|
||||
async customUpload(file: File, insertFn: (url: string, poster?: string) => void) {
|
||||
if (!validateVideoFile(file)) return;
|
||||
|
||||
modal.loading('正在上传视频,请稍候...');
|
||||
try {
|
||||
const result = await uploadToOss(file);
|
||||
insertFn(result.url);
|
||||
} catch {
|
||||
modal.msgError('视频上传失败');
|
||||
} finally {
|
||||
modal.closeLoading();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const editorConfig = computed<Partial<IEditorConfig>>(() => ({
|
||||
placeholder: props.simpleToolbar ? '请输入标准回复话术...' : '请输入内容',
|
||||
autoFocus: false,
|
||||
maxLength: 2000,
|
||||
MENU_CONF: {
|
||||
uploadImage: getUploadImageMenuConfig() as any,
|
||||
uploadVideo: getUploadVideoMenuConfig() as any
|
||||
},
|
||||
customMenu: [
|
||||
{
|
||||
key: 'numberedList',
|
||||
title: '有序步骤'
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
/* ==================== 事件处理 ==================== */
|
||||
|
||||
const handleChange = () => {
|
||||
if (!editorRef.value) return;
|
||||
wordCount.value = editorRef.value.getText().length;
|
||||
};
|
||||
|
||||
const syncReadOnly = () => {
|
||||
const editor = editorRef.value;
|
||||
if (!editor) return;
|
||||
|
||||
if (props.readOnly) {
|
||||
editor.disable();
|
||||
} else {
|
||||
editor.enable();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreated = (editor: IDomEditor) => {
|
||||
editorRef.value = editor;
|
||||
syncReadOnly();
|
||||
wordCount.value = editor.getText().length;
|
||||
};
|
||||
|
||||
/* ==================== 双向数据绑定(直接透传HTML) ==================== */
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
value => {
|
||||
if (value !== content.value) {
|
||||
content.value = value || '';
|
||||
}
|
||||
},
|
||||
{immediate: true}
|
||||
);
|
||||
|
||||
watch(content, value => {
|
||||
emit('update:modelValue', value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.readOnly,
|
||||
() => {
|
||||
syncReadOnly();
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editorRef.value?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editor-shell {
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
overflow: hidden;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
.editor-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.editor-shell :deep(.w-e-toolbar) {
|
||||
border: 0 !important;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.editor-shell :deep(.w-e-text-container) {
|
||||
border: 0 !important;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.editor-shell :deep(.w-e-text-container [data-slate-editor]) {
|
||||
min-height: inherit;
|
||||
}
|
||||
|
||||
.editor-tip {
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
border-top: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
.demo-box {
|
||||
background: #f5f7fa;
|
||||
padding: 8px 12px;
|
||||
margin: 6px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.word-count {
|
||||
text-align: right;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
400
src/components/FormItemTip/index.vue
Normal file
400
src/components/FormItemTip/index.vue
Normal file
@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<el-form-item
|
||||
:label="label"
|
||||
:show-message="false"
|
||||
class="form-item-tip-wrapper"
|
||||
v-bind="$attrs"
|
||||
:prop="prop"
|
||||
>
|
||||
<!-- label插槽,支持父组件自定义label -->
|
||||
<template #label>
|
||||
<slot name="label">{{ label }}</slot>
|
||||
</template>
|
||||
|
||||
<!-- 输入框模式 -->
|
||||
<el-input
|
||||
v-if="type === 'input'"
|
||||
:model-value="modelValue"
|
||||
class="inputItem"
|
||||
v-bind="inputAttrs"
|
||||
@input="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #suffix>
|
||||
<slot name="suffix"></slot>
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<!-- 输入框模式 -->
|
||||
<el-input
|
||||
v-if="type === 'inputNumber'"
|
||||
:model-value="modelValue"
|
||||
class="inputItem"
|
||||
type="number"
|
||||
v-bind="inputAttrs"
|
||||
@input="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #suffix>
|
||||
<slot name="suffix"></slot>
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<!-- 文本域模式 -->
|
||||
<el-input
|
||||
v-else-if="type === 'textarea'"
|
||||
:model-value="modelValue"
|
||||
:rows="5"
|
||||
class="inputItem"
|
||||
type="textarea"
|
||||
v-bind="inputAttrs"
|
||||
@input="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
<!-- 单选框模式(账号状态) -->
|
||||
<el-radio-group
|
||||
v-else-if="type === 'radio'"
|
||||
:model-value="modelValue"
|
||||
class="radioGroup"
|
||||
style="width: 100%"
|
||||
@change="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<el-radio
|
||||
v-for="item in radioOptions"
|
||||
:key="item.value"
|
||||
:class="{
|
||||
active: modelValue === item.value
|
||||
}"
|
||||
:label="item.value"
|
||||
class="radio"
|
||||
>{{ item.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
|
||||
<!-- 单个日期-->
|
||||
<el-date-picker
|
||||
v-else-if="type === 'date'"
|
||||
:model-value="modelValue"
|
||||
format="YYYY-MM-DD"
|
||||
placeholder="选择时间"
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
<!-- 头像-->
|
||||
<div v-else-if="type === 'uploadUserAvatar'" class="uploadUserAvatar">
|
||||
<div class="img">
|
||||
<el-image :src="imageUrl" style="width: 100%;height: 100%; object-fit: cover"></el-image>
|
||||
</div>
|
||||
<div>
|
||||
<el-upload
|
||||
ref="upload"
|
||||
:auto-upload="false"
|
||||
:before-upload="beforeAvatarUpload"
|
||||
:limit="1"
|
||||
:on-change="handleChange"
|
||||
:on-exceed="handleExceed"
|
||||
:show-file-list="false"
|
||||
accept=".jpg,.png"
|
||||
>
|
||||
<template #trigger>
|
||||
<div class="actionUpload">点击替换头像</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
<div class="tip">支持 jpg / png,建议 200×200,不超过 2MB</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 多选-->
|
||||
<el-checkbox-group
|
||||
v-else-if="type === 'checkBox'"
|
||||
:model-value="modelValue"
|
||||
style="width: 100%"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<el-checkbox
|
||||
v-for="item in radioOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
class="checkBox"
|
||||
>
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<!-- ========== 新增密码框 type="password" ========== -->
|
||||
<el-input
|
||||
v-else-if="type === 'password'"
|
||||
:model-value="modelValue"
|
||||
class="inputItem"
|
||||
show-password
|
||||
v-bind="inputAttrs"
|
||||
@input="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #suffix>
|
||||
<slot name="suffix"></slot>
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<div v-else-if="type === 'switch'" style="width: 100%">
|
||||
<el-switch
|
||||
:model-value="modelValue"
|
||||
size="large"
|
||||
class="custom-switch"
|
||||
v-bind="inputAttrs"
|
||||
style="margin-right: 12px;"
|
||||
@change="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
<slot name="switchRight"></slot>
|
||||
</div>
|
||||
<slot v-else-if="type==='default'" name="default"></slot>
|
||||
<!-- 底部提示文字 -->
|
||||
<div v-if="tip" class="form-item-tip-text">
|
||||
{{ tip }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref} from 'vue'
|
||||
import {genFileId} from 'element-plus'
|
||||
import type {UploadInstance, UploadProps, UploadRawFile} from 'element-plus'
|
||||
|
||||
|
||||
defineOptions({inheritAttrs: false})
|
||||
|
||||
const props = defineProps({
|
||||
prop: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number, Array] as PropType<string | number | Array<string | number>>,
|
||||
default: ''
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
tip: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'input'
|
||||
},
|
||||
|
||||
inputAttrs: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
radioOptions: {
|
||||
type: Array as PropType<Array<{ label: string, value: string | number }>>,
|
||||
default: () => []
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'file-change'])
|
||||
|
||||
|
||||
const upload = ref<UploadInstance>()
|
||||
const defaultImag = import.meta.env.VITE_DEFAULT_USER_IMAGE;
|
||||
const imageUrl = ref('')
|
||||
|
||||
watch(
|
||||
() => [props.type, props.modelValue],
|
||||
([type, val]) => {
|
||||
if (type === 'uploadUserAvatar') {
|
||||
const str = val as string
|
||||
imageUrl.value = str.trim() === '' ? defaultImag : str
|
||||
}
|
||||
},
|
||||
{immediate: true}
|
||||
)
|
||||
// 保存选中的文件,等表单提交时再传给后端
|
||||
const avatarFile = ref<File | null>(null)
|
||||
|
||||
// 成功获取 临时地址
|
||||
// ✅ on-change:选中文件立刻预览(重点!)
|
||||
const handleChange: UploadProps['onChange'] = (uploadFile) => {
|
||||
avatarFile.value = uploadFile.raw!
|
||||
imageUrl.value = URL.createObjectURL(uploadFile.raw!)
|
||||
emit('file-change', uploadFile.raw)
|
||||
|
||||
}
|
||||
// 超出 覆盖
|
||||
const handleExceed: UploadProps['onExceed'] = (files) => {
|
||||
upload.value!.clearFiles()
|
||||
const file = files[0] as UploadRawFile
|
||||
file.uid = genFileId()
|
||||
upload.value!.handleStart(file)
|
||||
}
|
||||
// 拦截格式
|
||||
const beforeAvatarUpload: UploadProps['beforeUpload'] = (rawFile) => {
|
||||
console.log(rawFile)
|
||||
// 修复:jpg mime是 image/jpeg,不是 image/jpg
|
||||
if (rawFile.type !== 'image/jpeg' && rawFile.type !== 'image/png') {
|
||||
ElMessage.error('只支持jpg,png格式图片')
|
||||
return false
|
||||
} else if (rawFile.size / 1024 / 1024 > 2) {
|
||||
ElMessage.error('图片大小不能大于2MB')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
:deep(.el-checkbox) {
|
||||
.el-checkbox__input {
|
||||
.el-checkbox__inner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
|
||||
&::after {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-checkbox__label {
|
||||
color: rgba(107, 114, 128, 1) !important;
|
||||
}
|
||||
|
||||
&.is-checked {
|
||||
.el-checkbox__label {
|
||||
color: #2563EB !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-checkbox__input.is-checked {
|
||||
.el-checkbox__inner {
|
||||
background-color: #2563EB !important
|
||||
}
|
||||
|
||||
.el-checkbox__inner:after {
|
||||
border-color: #2563EB !important
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-radio) {
|
||||
.el-radio__input {
|
||||
.el-radio__inner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid rgba(209, 213, 219, 1);
|
||||
|
||||
&::after {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-checked {
|
||||
.el-radio__inner {
|
||||
border: 1px solid #2563eb;
|
||||
background-color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-checked {
|
||||
background-color: rgba(239, 246, 255, 1) !important;
|
||||
|
||||
.el-radio__label {
|
||||
color: rgba(37, 99, 235, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.checkBox {
|
||||
padding: 14px 17px;
|
||||
min-height: 44px;
|
||||
|
||||
height: 44px;
|
||||
background-color: rgba(248, 250, 252, 1);
|
||||
border: 1px solid rgba(226, 232, 240, 1);
|
||||
|
||||
&.is-checked {
|
||||
background-color: rgba(239, 246, 255, 1);
|
||||
border: 1px solid rgba(191, 219, 254, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.form-item-tip-text {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.uploadUserAvatar {
|
||||
width: 100%;
|
||||
min-height: 90px;
|
||||
border-radius: 12px 12px 12px 12px;
|
||||
background-color: rgba(247, 249, 252, 1);
|
||||
border: 1px solid rgba(196, 201, 209, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 17px;
|
||||
|
||||
.img {
|
||||
width: 57px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(241, 245, 249, 1);
|
||||
margin-right: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.actionUpload {
|
||||
line-height: 18px;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
color: rgba(37, 99, 235, 1);
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin-top: 4px;
|
||||
line-height: 16px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.inputItem {
|
||||
min-height: 44px;
|
||||
border-radius: 12px 12px 12px 12px;
|
||||
background-color: rgba(247, 249, 252, 1);
|
||||
border: 1px solid rgba(229, 231, 235, 1);
|
||||
}
|
||||
|
||||
.radioGroup {
|
||||
.radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 80px;
|
||||
height: 44px;
|
||||
padding: 14px 21px;
|
||||
border-radius: 12px 12px 12px 12px;
|
||||
border: 1px solid rgba(229, 231, 235, 1);
|
||||
background-color: rgba(247, 249, 252, 1);
|
||||
|
||||
&.active {
|
||||
border-color: rgba(37, 99, 235, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
29
src/components/Pagecrumb/index.vue
Normal file
29
src/components/Pagecrumb/index.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<div class="router_title">{{ route.meta.title }}</div>
|
||||
<Breadcrumb class="breadcrumb"></Breadcrumb>
|
||||
</div>
|
||||
<div>
|
||||
<slot name="rightSlot"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="pageCrumb" setup>
|
||||
import Breadcrumb from '@/components/Breadcrumb/index.vue'
|
||||
|
||||
const route = useRoute();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.router_title {
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
border-radius: 0 0 0 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -15,14 +15,14 @@
|
||||
</template>
|
||||
|
||||
<script setup name="Pagination" lang="ts">
|
||||
import { propTypes } from '@/utils/propTypes';
|
||||
import { scrollTo } from '@/utils/scroll-to';
|
||||
import {propTypes} from '@/utils/propTypes';
|
||||
import {scrollTo} from '@/utils/scroll-to';
|
||||
|
||||
const props = defineProps({
|
||||
total: propTypes.number,
|
||||
page: propTypes.number.def(1),
|
||||
limit: propTypes.number.def(20),
|
||||
pageSizes: { type: Array<number>, default: () => [10, 20, 30, 50] },
|
||||
pageSizes: {type: Array<number>, default: () => [10, 20, 30, 50]},
|
||||
// 移动端页码按钮的数量端默认值5
|
||||
pagerCount: propTypes.number.def(document.body.clientWidth < 992 ? 5 : 7),
|
||||
layout: propTypes.string.def('total, sizes, prev, pager, next, jumper'),
|
||||
@@ -49,17 +49,19 @@ const pageSize = computed({
|
||||
emit('update:limit', val);
|
||||
}
|
||||
});
|
||||
|
||||
function handleSizeChange(val: number) {
|
||||
if (currentPage.value * val > props.total) {
|
||||
currentPage.value = 1;
|
||||
}
|
||||
emit('pagination', { page: currentPage.value, limit: val });
|
||||
emit('pagination', {page: currentPage.value, limit: val});
|
||||
if (props.autoScroll) {
|
||||
scrollTo(0, 800);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCurrentChange(val: number) {
|
||||
emit('pagination', { page: val, limit: pageSize.value });
|
||||
emit('pagination', {page: val, limit: pageSize.value});
|
||||
if (props.autoScroll) {
|
||||
scrollTo(0, 800);
|
||||
}
|
||||
@@ -73,12 +75,13 @@ function handleCurrentChange(val: number) {
|
||||
justify-content: flex-end;
|
||||
padding-top: 10px;
|
||||
margin-top: 18px;
|
||||
border-top: 1px solid var(--app-surface-border);
|
||||
//border-top: 1px solid var(--app-surface-border);
|
||||
|
||||
.el-pagination {
|
||||
float: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-container.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<template #title>更多菜单</template>
|
||||
<template v-for="(item, index) in topMenus">
|
||||
<el-menu-item v-if="index >= visibleNumber" :key="index" :index="item.path">
|
||||
<svg-icon :icon-class="item.meta ? item.meta.icon : ''" />
|
||||
<svg-icon :icon-class="item.meta ? item.meta.icon : ''"/>
|
||||
{{ item.meta?.title }}
|
||||
</el-menu-item>
|
||||
</template>
|
||||
@@ -35,17 +35,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouteRecordRaw } from 'vue-router';
|
||||
import { constantRoutes } from '@/router';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { usePermissionStore } from '@/store/modules/permission';
|
||||
import { useSettingsStore } from '@/store/modules/settings';
|
||||
import { isHttp } from '@/utils/validate';
|
||||
import {RouteRecordRaw} from 'vue-router';
|
||||
import {constantRoutes} from '@/router';
|
||||
import {useAppStore} from '@/store/modules/app';
|
||||
import {usePermissionStore} from '@/store/modules/permission';
|
||||
import {useSettingsStore} from '@/store/modules/settings';
|
||||
import {isHttp} from '@/utils/validate';
|
||||
|
||||
// 顶部栏初始数
|
||||
const visibleNumber = ref<number>(-1);
|
||||
// 隐藏侧边栏路由
|
||||
const hideList = ['/index', '/user/profile'];
|
||||
const hideList = ['/index.vue', '/user/profile'];
|
||||
|
||||
const appStore = useAppStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
@@ -97,9 +97,9 @@ const childrenMenus = computed(() => {
|
||||
|
||||
// 默认激活的菜单
|
||||
const activeMenu = computed(() => {
|
||||
const { meta } = route;
|
||||
const {meta} = route;
|
||||
let path = meta.activeMenu || route.path;
|
||||
if (path === '/index' || route.path === '/index') {
|
||||
if (path === '/index.vue' || route.path === '/index.vue') {
|
||||
path = '/system/user';
|
||||
}
|
||||
let activePath = path;
|
||||
@@ -135,9 +135,9 @@ const handleSelect = (key: string) => {
|
||||
const routeMenu = childrenMenus.value.find(item => item.path === key);
|
||||
if (routeMenu && routeMenu.query) {
|
||||
const query = JSON.parse(routeMenu.query);
|
||||
router.push({ path: key, query: query });
|
||||
router.push({path: key, query: query});
|
||||
} else {
|
||||
router.push({ path: key });
|
||||
router.push({path: key});
|
||||
}
|
||||
appStore.toggleSideBarHide(true);
|
||||
} else {
|
||||
@@ -214,8 +214,7 @@ onMounted(() => {
|
||||
border-radius: 13px;
|
||||
color: var(--topbar-pill-text) !important;
|
||||
background: transparent !important;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
transition: background-color 0.2s ease,
|
||||
color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
@@ -305,8 +304,7 @@ onMounted(() => {
|
||||
border-radius: 12px;
|
||||
color: var(--app-text-title) !important;
|
||||
background: transparent !important;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
transition: background-color 0.2s ease,
|
||||
color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
@@ -365,8 +363,7 @@ html.dark .mix-topnav-menu.el-menu--horizontal {
|
||||
html.dark .mix-topnav-popper .el-menu--popup {
|
||||
border-color: rgba(71, 85, 105, 0.34);
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0.98), rgba(30, 41, 59, 0.94)) !important;
|
||||
box-shadow:
|
||||
0 20px 42px rgba(0, 0, 0, 0.34),
|
||||
box-shadow: 0 20px 42px rgba(0, 0, 0, 0.34),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
|
||||
89
src/components/cardNav/index.vue
Normal file
89
src/components/cardNav/index.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<script lang="ts" name="cardNav" setup>
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
subTitle: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
lineColor: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '#2563eb'
|
||||
},
|
||||
showLine: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cardNav">
|
||||
<div class="flex flex-items-center">
|
||||
<div
|
||||
v-if="showLine"
|
||||
:style="{
|
||||
backgroundColor: props.lineColor
|
||||
}"
|
||||
class="line"
|
||||
></div>
|
||||
<div class="content">
|
||||
<div class="title">
|
||||
<!-- 有 title 就显示 span,没有就用 slot -->
|
||||
<span v-if="props.title" style="margin-right: 8px">{{ props.title }}</span>
|
||||
<slot v-else name="title"></slot>
|
||||
<slot name="rightTitle"></slot>
|
||||
</div>
|
||||
<div v-if="props.subTitle" class="subTitle">{{ props.subTitle }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<slot name="rightSlot"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.cardNav {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.line {
|
||||
width: 7px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background-color: rgba(37, 99, 235, 1);
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.content {
|
||||
|
||||
.title {
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 16px;
|
||||
text-align: left;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.subTitle {
|
||||
margin-top: 8px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
43
src/components/tableTag/index.vue
Normal file
43
src/components/tableTag/index.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts" name="tableTag" setup>
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String as PropType<"" | "default" | "text" | "primary" | "success" | "warning" | "info" | "danger">,
|
||||
default: 'info'
|
||||
},
|
||||
circleColor: {
|
||||
type: String,
|
||||
default: '#2563eb',
|
||||
},
|
||||
plain: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
num: Number,
|
||||
numColor: {
|
||||
type: String,
|
||||
default: '#111827',
|
||||
},
|
||||
title: String,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-button :plain="props.plain" :type="props.type">
|
||||
<i :style="{
|
||||
backgroundColor: props.circleColor,
|
||||
}" class="circle"></i>
|
||||
<span style="margin:0 8px;color: rgba(75, 85, 99, 1);">{{ props.title }}</span>
|
||||
<span :style="{
|
||||
color: props.numColor,
|
||||
}" style="font-weight: bold">{{ props.num }}</span>
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.circle {
|
||||
width: 7px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background-color: rgba(124, 58, 237, 1)
|
||||
}
|
||||
</style>
|
||||
@@ -1,21 +1,21 @@
|
||||
<template>
|
||||
<section class="app-main">
|
||||
<section class="app-main beautify_scroll">
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition :enter-active-class="animate" mode="out-in">
|
||||
<keep-alive :include="tagsViewStore.cachedViews">
|
||||
<component :is="Component" v-if="!route.meta.link" :key="route.path" />
|
||||
<component :is="Component" v-if="!route.meta.link" :key="route.path"/>
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
<iframe-toggle />
|
||||
<iframe-toggle/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup name="AppMain" lang="ts">
|
||||
import animateConfig from '@/animate';
|
||||
import { useFullHeightTable } from '@/hooks/table/useFullHeightTable';
|
||||
import { useSettingsStore } from '@/store/modules/settings';
|
||||
import { useTagsViewStore } from '@/store/modules/tagsView';
|
||||
import {useFullHeightTable} from '@/hooks/table/useFullHeightTable';
|
||||
import {useSettingsStore} from '@/store/modules/settings';
|
||||
import {useTagsViewStore} from '@/store/modules/tagsView';
|
||||
import IframeToggle from './IframeToggle/index.vue';
|
||||
|
||||
const route = useRoute();
|
||||
@@ -33,7 +33,7 @@ watch(
|
||||
animate.value = animateConfig.defaultAnimate as string;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
{immediate: true}
|
||||
);
|
||||
|
||||
watchEffect(() => {
|
||||
@@ -52,8 +52,8 @@ function addIframe() {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
padding: 0 12px;
|
||||
|
||||
&:fullscreen,
|
||||
&:-webkit-full-screen,
|
||||
@@ -66,6 +66,7 @@ function addIframe() {
|
||||
|
||||
.app-main:not(.with-fixed-header) {
|
||||
min-height: calc(100vh - 64px);
|
||||
max-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
.app-main.with-tags-view:not(.with-fixed-header) {
|
||||
|
||||
@@ -12,25 +12,17 @@
|
||||
<router-link v-else-if="showLogo" to="/" class="navtop-logo-shell">
|
||||
<img :src="appLogo" class="navtop-logo-icon" alt="logo" />
|
||||
</router-link>
|
||||
|
||||
<div class="nav-context">
|
||||
<breadcrumb v-if="navType == NavTypeEnum.LEFT" id="breadcrumb-container" class="breadcrumb-container" />
|
||||
<top-nav v-if="navType == NavTypeEnum.MIX" id="topmenu-container" class="topmenu-container" />
|
||||
|
||||
<template v-if="navType == NavTypeEnum.TOP">
|
||||
<top-bar id="topbar-container" class="topbar-container" />
|
||||
</template>
|
||||
<breadcrumb v-if="navType == NavTypeEnum.LEFT && isDashboard(route)" id="breadcrumb-container"
|
||||
class="breadcrumb-container"/>
|
||||
<!-- <top-nav v-if="navType == NavTypeEnum.MIX" id="topmenu-container" class="topmenu-container" />-->
|
||||
<!-- <template v-if="navType == NavTypeEnum.TOP">-->
|
||||
<!-- <top-bar id="topbar-container" class="topbar-container" />-->
|
||||
<!-- </template>-->
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-menu flex align-center">
|
||||
<template v-if="appStore.device !== 'mobile'">
|
||||
<search-menu ref="searchMenuRef" />
|
||||
<el-tooltip content="搜索" effect="dark" placement="bottom">
|
||||
<div class="right-menu-item hover-effect" @click="openSearchMenu">
|
||||
<svg-icon class-name="search-icon" icon-class="search" />
|
||||
</div>
|
||||
</el-tooltip>
|
||||
<!-- 消息 -->
|
||||
<el-tooltip :content="$t('navbar.message')" effect="dark" placement="bottom">
|
||||
<div>
|
||||
<el-popover placement="bottom" trigger="click" transition="el-zoom-in-top" :width="300" :persistent="false">
|
||||
@@ -47,25 +39,6 @@
|
||||
</el-popover>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="Github" effect="dark" placement="bottom">
|
||||
<ruo-yi-git id="ruoyi-git" class="right-menu-item hover-effect" />
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip :content="$t('navbar.document')" effect="dark" placement="bottom">
|
||||
<ruo-yi-doc id="ruoyi-doc" class="right-menu-item hover-effect" />
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip :content="$t('navbar.full')" effect="dark" placement="bottom">
|
||||
<screenfull id="screenfull" class="right-menu-item hover-effect" />
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip :content="$t('navbar.language')" effect="dark" placement="bottom">
|
||||
<lang-select id="lang-select" class="right-menu-item hover-effect" />
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip :content="$t('navbar.layoutSize')" effect="dark" placement="bottom">
|
||||
<size-select id="size-select" class="right-menu-item hover-effect" />
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<div class="avatar-container">
|
||||
<el-dropdown class="avatar-dropdown" trigger="click" @command="handleCommand">
|
||||
@@ -82,9 +55,9 @@
|
||||
<router-link to="/user/profile">
|
||||
<el-dropdown-item>{{ $t('navbar.personalCenter') }}</el-dropdown-item>
|
||||
</router-link>
|
||||
<el-dropdown-item v-if="settingsStore.showSettings" command="setLayout">
|
||||
<span>{{ $t('navbar.layoutSetting') }}</span>
|
||||
</el-dropdown-item>
|
||||
<!-- <el-dropdown-item v-if="settingsStore.showSettings" command="setLayout">-->
|
||||
<!-- <span>{{ $t('navbar.layoutSetting') }}</span>-->
|
||||
<!-- </el-dropdown-item>-->
|
||||
<el-dropdown-item divided command="logout">
|
||||
<span>{{ $t('navbar.logout') }}</span>
|
||||
</el-dropdown-item>
|
||||
@@ -110,7 +83,9 @@ import { useUserStore } from '@/store/modules/user';
|
||||
import notice from './notice/index.vue';
|
||||
import TopBar from './TopBar/index.vue';
|
||||
import SearchMenu from './TopBar/search.vue';
|
||||
import {RouteLocationMatched, RouteLocationNormalizedLoadedGeneric} from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const userStore = useUserStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
@@ -120,6 +95,13 @@ const navType = computed(() => settingsStore.navType);
|
||||
const showLogo = computed(() => settingsStore.sidebarLogo);
|
||||
const displayName = computed(() => userStore.nickname || '管理员');
|
||||
|
||||
const isDashboard = (route: RouteLocationNormalizedLoadedGeneric) => {
|
||||
const name = route && (route.name as string);
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
return name.trim() === 'Index';
|
||||
};
|
||||
// 搜索菜单
|
||||
const searchMenuRef = ref<InstanceType<typeof SearchMenu>>();
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ const logoTextColor = computed(() => (isDarkSide.value ? '#f8fbff' : 'var(--app-
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
height: 46px;
|
||||
line-height: 46px;
|
||||
//line-height: 46px;
|
||||
padding: 0 8px;
|
||||
margin-top: 8px;
|
||||
background: transparent;
|
||||
@@ -70,8 +70,8 @@ const logoTextColor = computed(() => (isDarkSide.value ? '#f8fbff' : 'var(--app-
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border-radius: 14px;
|
||||
background: v-bind(logoSurface);
|
||||
border: 1px solid v-bind(logoBorder);
|
||||
//background: v-bind(logoSurface);
|
||||
//border: 1px solid v-bind(logoBorder);
|
||||
|
||||
& .sidebar-logo {
|
||||
width: 28px;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
>
|
||||
<app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path, onlyOneChild.query)">
|
||||
<el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{ 'submenu-title-noDropdown': !isNest }">
|
||||
<svg-icon :icon-class="onlyOneChild.meta.icon || (item.meta && item.meta.icon)" />
|
||||
<svg-icon :icon-class="onlyOneChild.meta.icon || (item.meta && item.meta.icon) || '' "/>
|
||||
<template #title>
|
||||
<span class="menu-title" :title="hasTitle(onlyOneChild.meta.title)">{{ onlyOneChild.meta.title }}</span>
|
||||
</template>
|
||||
@@ -26,7 +26,7 @@
|
||||
teleported
|
||||
>
|
||||
<template v-if="item.meta" #title>
|
||||
<svg-icon :icon-class="item.meta ? item.meta.icon : ''" />
|
||||
<svg-icon :icon-class="item.meta ? item.meta.icon : ''"/>
|
||||
<span class="menu-title" :title="hasTitle(item.meta?.title)">{{ item.meta?.title }}</span>
|
||||
</template>
|
||||
|
||||
@@ -44,9 +44,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouteRecordRaw } from 'vue-router';
|
||||
import { getNormalPath } from '@/utils/ruoyi';
|
||||
import { isExternal } from '@/utils/validate';
|
||||
import {RouteRecordRaw} from 'vue-router';
|
||||
import {getNormalPath} from '@/utils/ruoyi';
|
||||
import {isExternal} from '@/utils/validate';
|
||||
import AppLink from './Link.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -89,7 +89,7 @@ const hasOneShowingChild = (parent: RouteRecordRaw, children?: RouteRecordRaw[])
|
||||
|
||||
// Show parent if there are no child router to display
|
||||
if (showingChildren.length === 0) {
|
||||
onlyOneChild.value = { ...parent, path: '', noShowingChildren: true };
|
||||
onlyOneChild.value = {...parent, path: '', noShowingChildren: true};
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="sidebar-shell" :class="{ 'has-logo': showLogo }" :style="menuStyle">
|
||||
<logo v-if="showLogo" :collapse="isCollapse" />
|
||||
<logo v-if="showLogo" :collapse="isCollapse"/>
|
||||
<el-scrollbar :class="sideTheme" wrap-class="scrollbar-wrapper">
|
||||
<transition :enter-active-class="animateConfig.menuSearchAnimate.enter" mode="out-in">
|
||||
<el-menu
|
||||
@@ -11,7 +11,7 @@
|
||||
:popper-offset="12"
|
||||
mode="vertical"
|
||||
>
|
||||
<sidebar-item v-for="(r, index) in sidebarRouters" :key="r.path + index" :item="r" :base-path="r.path" />
|
||||
<sidebar-item v-for="(r, index) in sidebarRouters" :key="r.path + index" :base-path="r.path" :item="r"/>
|
||||
</el-menu>
|
||||
</transition>
|
||||
</el-scrollbar>
|
||||
@@ -19,26 +19,34 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouteRecordRaw } from 'vue-router';
|
||||
import {RouteRecordRaw} from 'vue-router';
|
||||
import animateConfig from '@/animate';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { usePermissionStore } from '@/store/modules/permission';
|
||||
import { useSettingsStore } from '@/store/modules/settings';
|
||||
import {useAppStore} from '@/store/modules/app';
|
||||
import {usePermissionStore} from '@/store/modules/permission';
|
||||
import {useSettingsStore} from '@/store/modules/settings';
|
||||
import Logo from './Logo.vue';
|
||||
import SidebarItem from './SidebarItem.vue';
|
||||
import {useUserStore} from "@/store/modules/user";
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const userStore = useUserStore();
|
||||
const permissionStore = usePermissionStore();
|
||||
const sidebarRouters = computed<RouteRecordRaw[]>(() => permissionStore.getSidebarRoutes());
|
||||
const sidebarRouters = computed<RouteRecordRaw[]>(() => {
|
||||
let list = permissionStore.getSidebarRoutes();
|
||||
if (userStore.roles.includes('customer_service')) {
|
||||
list = list.filter((item) => item.path !== '')
|
||||
}
|
||||
return list;
|
||||
});
|
||||
const showLogo = computed(() => settingsStore.sidebarLogo);
|
||||
const sideTheme = computed(() => settingsStore.sideTheme);
|
||||
const theme = computed(() => settingsStore.theme);
|
||||
const isCollapse = computed(() => !appStore.sidebar.opened);
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
const { meta, path } = route;
|
||||
const {meta, path} = route;
|
||||
// if set path, the sidebar will highlight the path you set
|
||||
if (meta.activeMenu) {
|
||||
return meta.activeMenu;
|
||||
@@ -63,8 +71,8 @@ const menuStyle = computed(() => ({
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 8px 12px;
|
||||
border: 1px solid var(--app-sidebar-border);
|
||||
border-radius: var(--app-radius-base);
|
||||
//border: 1px solid var(--app-sidebar-border);
|
||||
//border-radius: var(--app-radius-base);
|
||||
box-shadow: var(--app-shadow-sm);
|
||||
background: v-bind(bgColor) !important;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div id="tags-view-container" class="tags-view-container">
|
||||
<span v-if="canScrollLeft" class="tags-nav-btn tags-nav-btn--left" @click="scrollLeft">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
<el-icon><ArrowLeft/></el-icon>
|
||||
</span>
|
||||
|
||||
<scroll-pane ref="scrollPaneRef" class="tags-view-wrapper" @scroll="handleScroll" @update-arrows="updateArrowState">
|
||||
@@ -16,21 +16,21 @@
|
||||
@click.middle="!isAffix(tag) ? closeSelectedTag(tag) : undefined"
|
||||
@contextmenu.prevent="openMenu(tag, $event)"
|
||||
>
|
||||
<svg-icon v-if="tagsIcon && tag.meta && tag.meta.icon && tag.meta.icon !== '#'" :icon-class="tag.meta.icon" />
|
||||
<svg-icon v-if="tagsIcon && tag.meta && tag.meta.icon && tag.meta.icon !== '#'" :icon-class="tag.meta.icon"/>
|
||||
<span class="tags-view-item-title">{{ tag.title || tag.meta?.title }}</span>
|
||||
<span v-if="!isAffix(tag)" @click.prevent.stop="closeSelectedTag(tag)">
|
||||
<Close class="el-icon-close" style="width: 1em; height: 1em; vertical-align: middle" />
|
||||
<Close class="el-icon-close" style="width: 1em; height: 1em; vertical-align: middle"/>
|
||||
</span>
|
||||
</router-link>
|
||||
</scroll-pane>
|
||||
|
||||
<span v-if="canScrollRight" class="tags-nav-btn tags-nav-btn--right" @click="scrollRight">
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
<el-icon><ArrowRight/></el-icon>
|
||||
</span>
|
||||
|
||||
<el-dropdown class="tags-action-dropdown" trigger="click" placement="bottom-end" @command="handleDropdownCommand">
|
||||
<span class="tags-action-btn">
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
<el-icon><ArrowDown/></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="tags-dropdown-menu">
|
||||
@@ -41,11 +41,11 @@
|
||||
<el-dropdown-item command="closeAll">全部关闭</el-dropdown-item>
|
||||
<el-dropdown-item command="fullscreen" divided>
|
||||
<template v-if="!isFullscreen">
|
||||
<FullScreen />
|
||||
<FullScreen/>
|
||||
<span>全屏显示</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<CloseBold />
|
||||
<CloseBold/>
|
||||
<span>退出全屏</span>
|
||||
</template>
|
||||
</el-dropdown-item>
|
||||
@@ -54,33 +54,33 @@
|
||||
</el-dropdown>
|
||||
|
||||
<span class="tags-action-btn tags-refresh-btn" title="刷新页面" @click="refreshSelectedTag(selectedDropdownTag)">
|
||||
<el-icon><RefreshRight /></el-icon>
|
||||
<el-icon><RefreshRight/></el-icon>
|
||||
<span>刷新</span>
|
||||
</span>
|
||||
|
||||
<ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu">
|
||||
<li @click="refreshSelectedTag(selectedTag)">
|
||||
<RefreshRight style="width: 1em; height: 1em" />
|
||||
<RefreshRight style="width: 1em; height: 1em"/>
|
||||
刷新页面
|
||||
</li>
|
||||
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
|
||||
<Close style="width: 1em; height: 1em" />
|
||||
<Close style="width: 1em; height: 1em"/>
|
||||
关闭当前
|
||||
</li>
|
||||
<li @click="closeOthersTags">
|
||||
<CircleClose style="width: 1em; height: 1em" />
|
||||
<CircleClose style="width: 1em; height: 1em"/>
|
||||
关闭其他
|
||||
</li>
|
||||
<li v-if="!isFirstView()" @click="closeLeftTags">
|
||||
<Back style="width: 1em; height: 1em" />
|
||||
<Back style="width: 1em; height: 1em"/>
|
||||
关闭左侧
|
||||
</li>
|
||||
<li v-if="!isLastView()" @click="closeRightTags">
|
||||
<Right style="width: 1em; height: 1em" />
|
||||
<Right style="width: 1em; height: 1em"/>
|
||||
关闭右侧
|
||||
</li>
|
||||
<li @click="closeAllTags(selectedTag)">
|
||||
<CircleClose style="width: 1em; height: 1em" />
|
||||
<CircleClose style="width: 1em; height: 1em"/>
|
||||
全部关闭
|
||||
</li>
|
||||
</ul>
|
||||
@@ -88,7 +88,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router';
|
||||
import type {RouteLocationNormalized, RouteRecordRaw} from 'vue-router';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
@@ -102,10 +102,10 @@ import {
|
||||
Right
|
||||
} from '@element-plus/icons-vue';
|
||||
import tab from '@/plugins/tab';
|
||||
import { usePermissionStore } from '@/store/modules/permission';
|
||||
import { useSettingsStore } from '@/store/modules/settings';
|
||||
import { useTagsViewStore } from '@/store/modules/tagsView';
|
||||
import { getNormalPath } from '@/utils/ruoyi';
|
||||
import {usePermissionStore} from '@/store/modules/permission';
|
||||
import {useSettingsStore} from '@/store/modules/settings';
|
||||
import {useTagsViewStore} from '@/store/modules/tagsView';
|
||||
import {getNormalPath} from '@/utils/ruoyi';
|
||||
import ScrollPane from './ScrollPane.vue';
|
||||
|
||||
const visible = ref(false);
|
||||
@@ -152,7 +152,7 @@ watch(
|
||||
updateArrowState();
|
||||
});
|
||||
},
|
||||
{ deep: true }
|
||||
{deep: true}
|
||||
);
|
||||
|
||||
const isActive = (currentRoute: RouteLocationNormalized): boolean => {
|
||||
@@ -181,7 +181,7 @@ const isFirstView = () => {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return tag.fullPath === '/index' || tag.fullPath === visitedViews.value[1]?.fullPath;
|
||||
return tag.fullPath === '/index.vue' || tag.fullPath === visitedViews.value[1]?.fullPath;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -213,7 +213,7 @@ const filterAffixTags = (routeList: RouteRecordRaw[], basePath = '') => {
|
||||
fullPath: tagPath,
|
||||
path: tagPath,
|
||||
name: item.name as string,
|
||||
meta: { ...item.meta },
|
||||
meta: {...item.meta},
|
||||
title: item.meta?.title || 'no-name'
|
||||
} as RouteLocationNormalized);
|
||||
}
|
||||
@@ -277,7 +277,7 @@ const closeSelectedTag = (view?: RouteLocationNormalized) => {
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
tab.closePage(view).then(({ visitedViews }: { visitedViews: RouteLocationNormalized[] }) => {
|
||||
tab.closePage(view).then(({visitedViews}: { visitedViews: RouteLocationNormalized[] }) => {
|
||||
if (isActive(view)) {
|
||||
toLastView(visitedViews, view);
|
||||
}
|
||||
@@ -313,14 +313,15 @@ const closeOthersTags = () => {
|
||||
if (!tag) {
|
||||
return;
|
||||
}
|
||||
router.push(tag.fullPath || tag.path || '/').catch(() => {});
|
||||
router.push(tag.fullPath || tag.path || '/').catch(() => {
|
||||
});
|
||||
tab.closeOtherPage(tag).then(() => {
|
||||
moveToCurrentTag();
|
||||
});
|
||||
};
|
||||
|
||||
const closeAllTags = (view?: RouteLocationNormalized) => {
|
||||
tab.closeAllPage().then(({ visitedViews: views }: { visitedViews: RouteLocationNormalized[] }) => {
|
||||
tab.closeAllPage().then(({visitedViews: views}: { visitedViews: RouteLocationNormalized[] }) => {
|
||||
if (affixTags.value.some(tag => tag.path === route.path)) {
|
||||
return;
|
||||
}
|
||||
@@ -333,7 +334,7 @@ const toLastView = (views: RouteLocationNormalized[], view?: RouteLocationNormal
|
||||
if (latestView?.fullPath) {
|
||||
router.push(latestView.fullPath);
|
||||
} else if (view?.name === 'Dashboard' && view.fullPath) {
|
||||
router.replace({ path: '/redirect' + view.fullPath });
|
||||
router.replace({path: '/redirect' + view.fullPath});
|
||||
} else {
|
||||
router.push('/');
|
||||
}
|
||||
@@ -505,8 +506,7 @@ onBeforeUnmount(() => {
|
||||
background-color: var(--app-surface-bg);
|
||||
border: 1px solid var(--app-surface-border);
|
||||
border-radius: var(--app-radius-md);
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
transition: box-shadow 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
@@ -549,8 +549,7 @@ onBeforeUnmount(() => {
|
||||
margin-left: 5px;
|
||||
margin-top: 4px;
|
||||
border-radius: var(--app-radius-md);
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
transition: box-shadow 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
@@ -620,8 +619,7 @@ onBeforeUnmount(() => {
|
||||
background-color: var(--app-surface-bg);
|
||||
border: 1px solid var(--app-surface-border);
|
||||
border-radius: var(--app-radius-md);
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
transition: box-shadow 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<template>
|
||||
<div :class="classObj" class="app-wrapper" :style="{ '--current-color': theme }">
|
||||
<div v-if="device === 'mobile' && sidebar.opened" class="drawer-bg" @click="handleClickOutside" />
|
||||
<side-bar v-if="showSidebar" class="sidebar-container" />
|
||||
<div v-if="device === 'mobile' && sidebar.opened" class="drawer-bg" @click="handleClickOutside"/>
|
||||
<side-bar v-if="showSidebar" class="sidebar-container"/>
|
||||
<div :class="{ hasTagsView: needTagsView, sidebarHide: sidebar.hide }" class="main-container">
|
||||
<div :class="{ 'fixed-header': fixedHeader }" class="layout-header">
|
||||
<navbar @set-layout="setLayout" />
|
||||
<tags-view v-if="needTagsView" />
|
||||
<navbar @set-layout="setLayout"/>
|
||||
<tags-view v-if="needTagsView"/>
|
||||
</div>
|
||||
<app-main :class="{ 'with-fixed-header': fixedHeader, 'with-tags-view': needTagsView }" />
|
||||
<settings ref="settingRef" />
|
||||
<app-main :class="{ 'with-fixed-header': fixedHeader, 'with-tags-view': needTagsView }"/>
|
||||
<settings ref="settingRef"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NavTypeEnum } from '@/enums/NavTypeEnum';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useSettingsStore } from '@/store/modules/settings';
|
||||
import { initMessageBox, initPush } from '@/utils/push';
|
||||
import { AppMain, Navbar, Settings, TagsView } from './components';
|
||||
import {NavTypeEnum} from '@/enums/NavTypeEnum';
|
||||
import {useAppStore} from '@/store/modules/app';
|
||||
import {useSettingsStore} from '@/store/modules/settings';
|
||||
import {initMessageBox, initPush} from '@/utils/push';
|
||||
import {AppMain, Navbar, Settings, TagsView} from './components';
|
||||
import SideBar from './components/Sidebar/index.vue';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
@@ -42,7 +42,7 @@ const classObj = computed(() => ({
|
||||
mobile: device.value === 'mobile'
|
||||
}));
|
||||
|
||||
const { width } = useWindowSize();
|
||||
const {width} = useWindowSize();
|
||||
const WIDTH = 992; // refer to Bootstrap's responsive design
|
||||
|
||||
watch(
|
||||
@@ -50,12 +50,12 @@ watch(
|
||||
w => {
|
||||
if (w - 1 < WIDTH) {
|
||||
useAppStore().toggleDevice('mobile');
|
||||
useAppStore().closeSideBar({ withoutAnimation: true });
|
||||
useAppStore().closeSideBar({withoutAnimation: true});
|
||||
} else {
|
||||
useAppStore().toggleDevice('desktop');
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
{immediate: true}
|
||||
);
|
||||
|
||||
const settingRef = ref<InstanceType<typeof Settings>>();
|
||||
@@ -69,7 +69,7 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
const handleClickOutside = () => {
|
||||
useAppStore().closeSideBar({ withoutAnimation: false });
|
||||
useAppStore().closeSideBar({withoutAnimation: false});
|
||||
};
|
||||
|
||||
const setLayout = () => {
|
||||
@@ -86,7 +86,8 @@ const setLayout = () => {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--app-shell-bg);
|
||||
//background: var(--app-shell-bg);
|
||||
background: #fff;
|
||||
|
||||
&.mobile.openSidebar {
|
||||
position: fixed;
|
||||
@@ -110,7 +111,7 @@ const setLayout = () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 12px 12px 0;
|
||||
padding: 0 12px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { to as tos } from 'await-to-js';
|
||||
import { ElMessage } from 'element-plus/es';
|
||||
import {to as tos} from 'await-to-js';
|
||||
import {ElMessage} from 'element-plus/es';
|
||||
import * as NProgressModule from 'nprogress';
|
||||
import 'nprogress/nprogress.css';
|
||||
import { usePermissionStore } from '@/store/modules/permission';
|
||||
import { useSettingsStore } from '@/store/modules/settings';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import { getToken } from '@/utils/auth';
|
||||
import { isHandledRequestError, isRelogin } from '@/utils/request';
|
||||
import { isHttp, isPathMatch } from '@/utils/validate';
|
||||
import {usePermissionStore} from '@/store/modules/permission';
|
||||
import {useSettingsStore} from '@/store/modules/settings';
|
||||
import {useUserStore} from '@/store/modules/user';
|
||||
import {getToken} from '@/utils/auth';
|
||||
import {isHandledRequestError, isRelogin} from '@/utils/request';
|
||||
import {isHttp, isPathMatch} from '@/utils/validate';
|
||||
import router from './router';
|
||||
|
||||
const NProgress = ('default' in NProgressModule ? NProgressModule.default : NProgressModule) as typeof NProgressModule;
|
||||
|
||||
NProgress.configure({ showSpinner: false });
|
||||
NProgress.configure({showSpinner: false});
|
||||
const whiteList = ['/login', '/register', '/social-callback', '/register*', '/register/*'];
|
||||
|
||||
const isWhiteList = (path: string) => {
|
||||
@@ -26,20 +26,22 @@ router.beforeEach(async (to, from) => {
|
||||
/* has token*/
|
||||
if (to.path === '/login') {
|
||||
NProgress.done();
|
||||
return { path: '/' };
|
||||
return {path: '/'};
|
||||
} else if (isWhiteList(to.path)) {
|
||||
return true;
|
||||
} else {
|
||||
if (useUserStore().roles.length === 0) {
|
||||
isRelogin.show = true;
|
||||
// 判断当前用户是否已拉取完user_info信息
|
||||
const [err] = await tos(useUserStore().getInfo());
|
||||
const [err, res] = await tos(useUserStore().getInfo());
|
||||
console.log(res);
|
||||
|
||||
if (err) {
|
||||
await useUserStore().logout();
|
||||
if (!isHandledRequestError(err)) {
|
||||
ElMessage.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
return { path: '/' };
|
||||
return {path: '/'};
|
||||
} else {
|
||||
isRelogin.show = false;
|
||||
const accessRoutes = await usePermissionStore().generateRoutes();
|
||||
@@ -50,6 +52,21 @@ router.beforeEach(async (to, from) => {
|
||||
}
|
||||
});
|
||||
// hack方法 确保addRoutes已完成
|
||||
if (res.data.roles.includes('customer_service')) {
|
||||
if (to.path === '/index') {
|
||||
return {
|
||||
path: '/conversation/conversationList',
|
||||
replace: true
|
||||
}
|
||||
}
|
||||
// 客服登录默认首页:如果原本跳转目标是根路径 /,也跳会话列表
|
||||
if (to.path === '/') {
|
||||
return {
|
||||
path: '/conversation/conversationList',
|
||||
replace: true
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
path: to.path,
|
||||
replace: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
||||
import {createRouter, createWebHistory, type RouteRecordRaw} from 'vue-router';
|
||||
/* Layout */
|
||||
import Layout from '@/layout/index.vue';
|
||||
|
||||
@@ -69,9 +69,9 @@ export const constantRoutes: RouteRecordRaw[] = [
|
||||
children: [
|
||||
{
|
||||
path: '/index',
|
||||
component: () => import('@/views/index.vue'),
|
||||
component: () => import('@/views/index/index.vue'),
|
||||
name: 'Index',
|
||||
meta: { title: '首页', icon: 'dashboard', affix: true }
|
||||
meta: {title: '首页', icon: 'dashboard', affix: true}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -85,7 +85,7 @@ export const constantRoutes: RouteRecordRaw[] = [
|
||||
path: 'profile',
|
||||
component: () => import('@/views/system/user/profile/index.vue'),
|
||||
name: 'Profile',
|
||||
meta: { title: '个人中心', icon: 'user' }
|
||||
meta: {title: '个人中心', icon: 'user'}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -105,7 +105,7 @@ const router = createRouter({
|
||||
if (savedPosition) {
|
||||
return savedPosition;
|
||||
}
|
||||
return { top: 0 };
|
||||
return {top: 0};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ const setting: DefaultSettings = {
|
||||
/**
|
||||
* 侧边栏主题 深色主题theme-dark,浅色主题theme-light
|
||||
*/
|
||||
sideTheme: 'theme-dark',
|
||||
sideTheme: 'theme-light',
|
||||
/**
|
||||
* 是否系统布局配置
|
||||
*/
|
||||
@@ -26,7 +26,7 @@ const setting: DefaultSettings = {
|
||||
/**
|
||||
* 是否显示 tagsView
|
||||
*/
|
||||
tagsView: true,
|
||||
tagsView: false,
|
||||
|
||||
/**
|
||||
* 持久化标签页
|
||||
@@ -36,12 +36,12 @@ const setting: DefaultSettings = {
|
||||
/**
|
||||
* 显示页签图标
|
||||
*/
|
||||
tagsIcon: true,
|
||||
tagsIcon: false,
|
||||
|
||||
/**
|
||||
* 是否固定头部
|
||||
*/
|
||||
fixedHeader: true,
|
||||
fixedHeader: false,
|
||||
|
||||
/**
|
||||
* 是否显示logo
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { LocationQuery, RouteLocationNormalized, RouteMeta } from 'vue-router';
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { useSettingsStore } from './settings';
|
||||
import type {
|
||||
LocationQuery,
|
||||
RouteLocationNormalized,
|
||||
RouteMeta,
|
||||
} from "vue-router";
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { useSettingsStore } from "./settings";
|
||||
|
||||
const PERSIST_KEY = 'tags-view-visited';
|
||||
const PERSIST_KEY = "tags-view-visited";
|
||||
|
||||
type PersistedTagView = {
|
||||
path: string;
|
||||
@@ -24,7 +28,7 @@ const isPersistEnabled = () => {
|
||||
|
||||
const normalizeVisitedView = (view: RouteLocationNormalized): TagView => {
|
||||
return Object.assign({}, view, {
|
||||
title: view.meta?.title || 'no-name'
|
||||
title: view.meta?.title || "no-name",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -34,14 +38,14 @@ const saveVisitedViews = (views: any[]) => {
|
||||
}
|
||||
|
||||
const payload: PersistedTagView[] = (views as TagView[])
|
||||
.filter(view => !view.meta?.affix)
|
||||
.map(view => ({
|
||||
.filter((view) => !view.meta?.affix)
|
||||
.map((view) => ({
|
||||
path: view.path,
|
||||
fullPath: view.fullPath,
|
||||
name: view.name,
|
||||
title: (view as any).title || view.meta?.title || 'no-name',
|
||||
title: (view as any).title || view.meta?.title || "no-name",
|
||||
query: view.query,
|
||||
meta: view.meta
|
||||
meta: view.meta,
|
||||
}));
|
||||
|
||||
localStorage.setItem(PERSIST_KEY, JSON.stringify(payload));
|
||||
@@ -65,7 +69,7 @@ const clearVisitedViews = () => {
|
||||
localStorage.removeItem(PERSIST_KEY);
|
||||
};
|
||||
|
||||
export const useTagsViewStore = defineStore('tagsView', () => {
|
||||
export const useTagsViewStore = defineStore("tagsView", () => {
|
||||
const visitedViews = ref<TagView[]>([]);
|
||||
const cachedViews = ref<string[]>([]);
|
||||
const iframeViews = ref<TagView[]>([]);
|
||||
@@ -86,26 +90,49 @@ export const useTagsViewStore = defineStore('tagsView', () => {
|
||||
};
|
||||
|
||||
const addIframeView = (view: RouteLocationNormalized): void => {
|
||||
if (iframeViews.value.some((v: RouteLocationNormalized) => v.path === view.path)) return;
|
||||
iframeViews.value.push(normalizeVisitedView(view) as RouteLocationNormalized);
|
||||
if (
|
||||
iframeViews.value.some(
|
||||
(v: RouteLocationNormalized) => v.path === view.path,
|
||||
)
|
||||
)
|
||||
return;
|
||||
iframeViews.value.push(
|
||||
normalizeVisitedView(view) as RouteLocationNormalized,
|
||||
);
|
||||
};
|
||||
|
||||
const delIframeView = (view: RouteLocationNormalized): Promise<RouteLocationNormalized[]> => {
|
||||
return new Promise(resolve => {
|
||||
iframeViews.value = iframeViews.value.filter((item: RouteLocationNormalized) => item.path !== view.path);
|
||||
const delIframeView = (
|
||||
view: RouteLocationNormalized,
|
||||
): Promise<RouteLocationNormalized[]> => {
|
||||
return new Promise((resolve) => {
|
||||
iframeViews.value = iframeViews.value.filter(
|
||||
(item: RouteLocationNormalized) => item.path !== view.path,
|
||||
);
|
||||
resolve(iframeViews.value.slice() as RouteLocationNormalized[]);
|
||||
});
|
||||
};
|
||||
|
||||
const addVisitedView = (view: RouteLocationNormalized): void => {
|
||||
if (visitedViews.value.some((v: RouteLocationNormalized) => v.path === view.path)) return;
|
||||
if (
|
||||
visitedViews.value.some(
|
||||
(v: RouteLocationNormalized) => v.path === view.path,
|
||||
)
|
||||
)
|
||||
return;
|
||||
visitedViews.value.push(normalizeVisitedView(view));
|
||||
saveVisitedViews(visitedViews.value);
|
||||
};
|
||||
|
||||
const addAffixView = (view: RouteLocationNormalized): void => {
|
||||
if (visitedViews.value.some((v: RouteLocationNormalized) => v.path === view.path)) return;
|
||||
const insertIndex = visitedViews.value.findIndex(item => !item.meta?.affix);
|
||||
if (
|
||||
visitedViews.value.some(
|
||||
(v: RouteLocationNormalized) => v.path === view.path,
|
||||
)
|
||||
)
|
||||
return;
|
||||
const insertIndex = visitedViews.value.findIndex(
|
||||
(item) => !item.meta?.affix,
|
||||
);
|
||||
const normalizedView = normalizeVisitedView(view);
|
||||
if (insertIndex === -1) {
|
||||
visitedViews.value.push(normalizedView);
|
||||
@@ -115,13 +142,13 @@ export const useTagsViewStore = defineStore('tagsView', () => {
|
||||
};
|
||||
|
||||
const loadPersistedViews = (): void => {
|
||||
loadVisitedViews().forEach(view => {
|
||||
if (visitedViews.value.some(item => item.path === view.path)) {
|
||||
loadVisitedViews().forEach((view) => {
|
||||
if (visitedViews.value.some((item) => item.path === view.path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
visitedViews.value.push({
|
||||
hash: '',
|
||||
hash: "",
|
||||
matched: [],
|
||||
params: {},
|
||||
redirectedFrom: undefined,
|
||||
@@ -130,29 +157,31 @@ export const useTagsViewStore = defineStore('tagsView', () => {
|
||||
query: view.query || {},
|
||||
name: view.name || undefined,
|
||||
meta: view.meta || {},
|
||||
title: view.title || view.meta?.title || 'no-name'
|
||||
title: view.title || view.meta?.title || "no-name",
|
||||
} as TagView);
|
||||
});
|
||||
};
|
||||
|
||||
const delView = (
|
||||
view: RouteLocationNormalized
|
||||
view: RouteLocationNormalized,
|
||||
): Promise<{
|
||||
visitedViews: RouteLocationNormalized[];
|
||||
cachedViews: string[];
|
||||
}> => {
|
||||
return new Promise(resolve => {
|
||||
return new Promise((resolve) => {
|
||||
delVisitedView(view);
|
||||
delCachedView(view);
|
||||
resolve({
|
||||
visitedViews: visitedViews.value.slice() as RouteLocationNormalized[],
|
||||
cachedViews: [...cachedViews.value]
|
||||
cachedViews: [...cachedViews.value],
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const delVisitedView = (view: RouteLocationNormalized): Promise<RouteLocationNormalized[]> => {
|
||||
return new Promise(resolve => {
|
||||
const delVisitedView = (
|
||||
view: RouteLocationNormalized,
|
||||
): Promise<RouteLocationNormalized[]> => {
|
||||
return new Promise((resolve) => {
|
||||
for (const [i, v] of visitedViews.value.entries()) {
|
||||
if (v.path === view.path) {
|
||||
visitedViews.value.splice(i, 1);
|
||||
@@ -165,11 +194,11 @@ export const useTagsViewStore = defineStore('tagsView', () => {
|
||||
};
|
||||
|
||||
const delCachedView = (view?: RouteLocationNormalized): Promise<string[]> => {
|
||||
let viewName = '';
|
||||
let viewName = "";
|
||||
if (view) {
|
||||
viewName = view.name as string;
|
||||
}
|
||||
return new Promise(resolve => {
|
||||
return new Promise((resolve) => {
|
||||
const index = cachedViews.value.indexOf(viewName);
|
||||
index > -1 && cachedViews.value.splice(index, 1);
|
||||
resolve([...cachedViews.value]);
|
||||
@@ -177,34 +206,40 @@ export const useTagsViewStore = defineStore('tagsView', () => {
|
||||
};
|
||||
|
||||
const delOthersViews = (
|
||||
view: RouteLocationNormalized
|
||||
view: RouteLocationNormalized,
|
||||
): Promise<{
|
||||
visitedViews: RouteLocationNormalized[];
|
||||
cachedViews: string[];
|
||||
}> => {
|
||||
return new Promise(resolve => {
|
||||
return new Promise((resolve) => {
|
||||
delOthersVisitedViews(view);
|
||||
delOthersCachedViews(view);
|
||||
resolve({
|
||||
visitedViews: visitedViews.value.slice() as RouteLocationNormalized[],
|
||||
cachedViews: [...cachedViews.value]
|
||||
cachedViews: [...cachedViews.value],
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const delOthersVisitedViews = (view: RouteLocationNormalized): Promise<RouteLocationNormalized[]> => {
|
||||
return new Promise(resolve => {
|
||||
visitedViews.value = visitedViews.value.filter((v: RouteLocationNormalized) => {
|
||||
const delOthersVisitedViews = (
|
||||
view: RouteLocationNormalized,
|
||||
): Promise<RouteLocationNormalized[]> => {
|
||||
return new Promise((resolve) => {
|
||||
visitedViews.value = visitedViews.value.filter(
|
||||
(v: RouteLocationNormalized) => {
|
||||
return v.meta?.affix || v.path === view.path;
|
||||
});
|
||||
},
|
||||
);
|
||||
saveVisitedViews(visitedViews.value);
|
||||
resolve(visitedViews.value.slice() as RouteLocationNormalized[]);
|
||||
});
|
||||
};
|
||||
|
||||
const delOthersCachedViews = (view: RouteLocationNormalized): Promise<string[]> => {
|
||||
const delOthersCachedViews = (
|
||||
view: RouteLocationNormalized,
|
||||
): Promise<string[]> => {
|
||||
const viewName = view.name as string;
|
||||
return new Promise(resolve => {
|
||||
return new Promise((resolve) => {
|
||||
const index = cachedViews.value.indexOf(viewName);
|
||||
if (index > -1) {
|
||||
cachedViews.value = cachedViews.value.slice(index, index + 1);
|
||||
@@ -219,26 +254,28 @@ export const useTagsViewStore = defineStore('tagsView', () => {
|
||||
visitedViews: RouteLocationNormalized[];
|
||||
cachedViews: string[];
|
||||
}> => {
|
||||
return new Promise(resolve => {
|
||||
return new Promise((resolve) => {
|
||||
delAllVisitedViews();
|
||||
delAllCachedViews();
|
||||
resolve({
|
||||
visitedViews: visitedViews.value.slice() as RouteLocationNormalized[],
|
||||
cachedViews: [...cachedViews.value]
|
||||
cachedViews: [...cachedViews.value],
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const delAllVisitedViews = (): Promise<RouteLocationNormalized[]> => {
|
||||
return new Promise(resolve => {
|
||||
visitedViews.value = visitedViews.value.filter((tag: RouteLocationNormalized) => tag.meta?.affix);
|
||||
return new Promise((resolve) => {
|
||||
visitedViews.value = visitedViews.value.filter(
|
||||
(tag: RouteLocationNormalized) => tag.meta?.affix,
|
||||
);
|
||||
clearVisitedViews();
|
||||
resolve(visitedViews.value.slice() as RouteLocationNormalized[]);
|
||||
});
|
||||
};
|
||||
|
||||
const delAllCachedViews = (): Promise<string[]> => {
|
||||
return new Promise(resolve => {
|
||||
return new Promise((resolve) => {
|
||||
cachedViews.value = [];
|
||||
resolve([...cachedViews.value]);
|
||||
});
|
||||
@@ -254,14 +291,19 @@ export const useTagsViewStore = defineStore('tagsView', () => {
|
||||
saveVisitedViews(visitedViews.value);
|
||||
};
|
||||
|
||||
const delRightTags = (view: RouteLocationNormalized): Promise<RouteLocationNormalized[]> => {
|
||||
return new Promise(resolve => {
|
||||
const index = visitedViews.value.findIndex((v: RouteLocationNormalized) => v.path === view.path);
|
||||
const delRightTags = (
|
||||
view: RouteLocationNormalized,
|
||||
): Promise<RouteLocationNormalized[]> => {
|
||||
return new Promise((resolve) => {
|
||||
const index = visitedViews.value.findIndex(
|
||||
(v: RouteLocationNormalized) => v.path === view.path,
|
||||
);
|
||||
if (index === -1) {
|
||||
resolve(visitedViews.value.slice() as RouteLocationNormalized[]);
|
||||
return;
|
||||
}
|
||||
visitedViews.value = visitedViews.value.filter((item: RouteLocationNormalized, idx: number) => {
|
||||
visitedViews.value = visitedViews.value.filter(
|
||||
(item: RouteLocationNormalized, idx: number) => {
|
||||
if (idx <= index || (item.meta && item.meta.affix)) {
|
||||
return true;
|
||||
}
|
||||
@@ -270,20 +312,26 @@ export const useTagsViewStore = defineStore('tagsView', () => {
|
||||
cachedViews.value.splice(i, 1);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
},
|
||||
);
|
||||
saveVisitedViews(visitedViews.value);
|
||||
resolve(visitedViews.value.slice() as RouteLocationNormalized[]);
|
||||
});
|
||||
};
|
||||
|
||||
const delLeftTags = (view: RouteLocationNormalized): Promise<RouteLocationNormalized[]> => {
|
||||
return new Promise(resolve => {
|
||||
const index = visitedViews.value.findIndex((v: RouteLocationNormalized) => v.path === view.path);
|
||||
const delLeftTags = (
|
||||
view: RouteLocationNormalized,
|
||||
): Promise<RouteLocationNormalized[]> => {
|
||||
return new Promise((resolve) => {
|
||||
const index = visitedViews.value.findIndex(
|
||||
(v: RouteLocationNormalized) => v.path === view.path,
|
||||
);
|
||||
if (index === -1) {
|
||||
resolve(visitedViews.value.slice() as RouteLocationNormalized[]);
|
||||
return;
|
||||
}
|
||||
visitedViews.value = visitedViews.value.filter((item: RouteLocationNormalized, idx: number) => {
|
||||
visitedViews.value = visitedViews.value.filter(
|
||||
(item: RouteLocationNormalized, idx: number) => {
|
||||
if (idx >= index || (item.meta && item.meta.affix)) {
|
||||
return true;
|
||||
}
|
||||
@@ -292,7 +340,8 @@ export const useTagsViewStore = defineStore('tagsView', () => {
|
||||
cachedViews.value.splice(i, 1);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
},
|
||||
);
|
||||
saveVisitedViews(visitedViews.value);
|
||||
resolve(visitedViews.value.slice() as RouteLocationNormalized[]);
|
||||
});
|
||||
@@ -332,6 +381,6 @@ export const useTagsViewStore = defineStore('tagsView', () => {
|
||||
delLeftTags,
|
||||
addIframeView,
|
||||
delIframeView,
|
||||
loadPersistedViews
|
||||
loadPersistedViews,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { to } from 'await-to-js';
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import type { UserInfo } from '@/api/system/user/types';
|
||||
import type { LoginData, LoginResult } from '@/api/types';
|
||||
import type { RuoYiAjaxResult } from '@/utils/api-types';
|
||||
import { getInfo as getUserInfo, login as loginApi, logout as logoutApi } from '@/api/login';
|
||||
import {to} from 'await-to-js';
|
||||
import {defineStore} from 'pinia';
|
||||
import {ref} from 'vue';
|
||||
import type {UserInfo} from '@/api/system/user/types';
|
||||
import type {LoginData, LoginResult} from '@/api/types';
|
||||
import type {RuoYiAjaxResult} from '@/utils/api-types';
|
||||
import {getInfo as getUserInfo, login as loginApi, logout as logoutApi} from '@/api/login';
|
||||
import defAva from '@/assets/images/profile.jpg';
|
||||
import { getToken, removeToken, setToken } from '@/utils/auth';
|
||||
import {getToken, removeToken, setToken} from '@/utils/auth';
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref(getToken());
|
||||
@@ -37,7 +37,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
};
|
||||
|
||||
// 获取用户信息
|
||||
const getInfo = async (): Promise<void> => {
|
||||
const getInfo = async (): Promise<RuoYiAjaxResult<UserInfo>> => {
|
||||
const [err, res] = await to(getUserInfo());
|
||||
if (res) {
|
||||
const data = (res as RuoYiAjaxResult<UserInfo>).data;
|
||||
@@ -58,7 +58,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
nickname.value = user.nickName;
|
||||
avatar.value = profile;
|
||||
userId.value = user.userId;
|
||||
return Promise.resolve();
|
||||
return Promise.resolve(res);
|
||||
}
|
||||
return Promise.reject(err);
|
||||
};
|
||||
|
||||
15
src/types/env.d.ts
vendored
15
src/types/env.d.ts
vendored
@@ -1,11 +1,13 @@
|
||||
declare module '*.vue' {
|
||||
import { DefineComponent } from 'vue';
|
||||
import {DefineComponent} from 'vue';
|
||||
const Component: DefineComponent<{}, {}, any>;
|
||||
export default Component;
|
||||
}
|
||||
|
||||
declare module 'virtual:svg-icons-register' {}
|
||||
declare module 'virtual:*' {}
|
||||
declare module 'virtual:svg-icons-register' {
|
||||
}
|
||||
declare module 'virtual:*' {
|
||||
}
|
||||
|
||||
// 环境变量
|
||||
interface ImportMetaEnv {
|
||||
@@ -24,8 +26,15 @@ interface ImportMetaEnv {
|
||||
VITE_APP_CLIENT_ID: string;
|
||||
VITE_APP_MESSAGE_ENABLED: string;
|
||||
VITE_APP_MESSAGE_TRANSPORT: string;
|
||||
/** 默认图片 */
|
||||
VITE_DEFAULT_USER_IMAGE?: string;
|
||||
/** 本机环境 */
|
||||
VITE_LOCALHOUSET?: string;
|
||||
/** 基础地址 */
|
||||
VITE_BASE_API?: string;
|
||||
VITE_APP_MESSAGE_PATH: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
// readonly glob: any;
|
||||
|
||||
18
src/utils/phone.ts
Normal file
18
src/utils/phone.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 隐藏手机号
|
||||
* @params {number} phone
|
||||
* @return {string}
|
||||
* @example 136****9023
|
||||
* */
|
||||
export function hiddenPhone(phone: string): string {
|
||||
if (phone.length < 11) {
|
||||
return phone
|
||||
}
|
||||
let account = phone;
|
||||
const first = account.slice(0, 3);
|
||||
const last = account.slice(phone.length - 4, phone.length);
|
||||
if (first === '' || last === '') {
|
||||
return '---'
|
||||
}
|
||||
return `${first}****${last}`;
|
||||
}
|
||||
9
src/utils/reg.ts
Normal file
9
src/utils/reg.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 校验中国大陆手机号
|
||||
* @param phone 手机号字符串
|
||||
*/
|
||||
export function validatePhone(phone: string): boolean {
|
||||
if (!phone) return false
|
||||
const reg = /^1[3-9]\d{9}$/
|
||||
return reg.test(phone.trim())
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { LoadingInstance } from 'element-plus';
|
||||
import type {LoadingInstance} from 'element-plus';
|
||||
import axiosModule from 'axios';
|
||||
import { HttpStatus } from '@/enums/RespEnum';
|
||||
import { getLanguage } from '@/lang';
|
||||
import {HttpStatus} from '@/enums/RespEnum';
|
||||
import {getLanguage} from '@/lang';
|
||||
import cache from '@/plugins/cache';
|
||||
import router from '@/router';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import { getToken } from '@/utils/auth';
|
||||
import { decryptBase64, decryptWithAes, encryptBase64, encryptWithAes, generateAesKey } from '@/utils/crypto';
|
||||
import { errorCode } from '@/utils/errorCode';
|
||||
import { decrypt, encrypt } from '@/utils/jsencrypt';
|
||||
import { blobValidate, tansParams } from '@/utils/ruoyi';
|
||||
import { saveBlob } from '@/utils/save';
|
||||
import {useUserStore} from '@/store/modules/user';
|
||||
import {getToken} from '@/utils/auth';
|
||||
import {decryptBase64, decryptWithAes, encryptBase64, encryptWithAes, generateAesKey} from '@/utils/crypto';
|
||||
import {errorCode} from '@/utils/errorCode';
|
||||
import {decrypt, encrypt} from '@/utils/jsencrypt';
|
||||
import {blobValidate, tansParams} from '@/utils/ruoyi';
|
||||
import {saveBlob} from '@/utils/save';
|
||||
|
||||
/** axios 1.13 + TS6:默认导出在类型上会被解析为不可调用的 export= 形态 */
|
||||
const axios = axiosModule as any;
|
||||
@@ -18,7 +18,7 @@ const axios = axiosModule as any;
|
||||
const encryptHeader = 'encrypt-key';
|
||||
let downloadLoadingInstance: LoadingInstance | undefined;
|
||||
// 是否显示重新登录
|
||||
export const isRelogin = { show: false };
|
||||
export const isRelogin = {show: false};
|
||||
|
||||
function createHandledError(message: string) {
|
||||
const error = new Error(message) as Error & { isHandled?: boolean };
|
||||
@@ -236,13 +236,13 @@ service.interceptors.response.use(
|
||||
}
|
||||
return Promise.reject('无效的会话,或者会话已过期,请重新登录。');
|
||||
} else if (code === HttpStatus.SERVER_ERROR) {
|
||||
ElMessage({ message: msg, type: 'error' });
|
||||
ElMessage({message: msg, type: 'error'});
|
||||
return Promise.reject(createHandledError(msg));
|
||||
} else if (code === HttpStatus.WARN) {
|
||||
ElMessage({ message: msg, type: 'warning' });
|
||||
ElMessage({message: msg, type: 'warning'});
|
||||
return Promise.reject(createHandledError(msg));
|
||||
} else if (code !== HttpStatus.SUCCESS) {
|
||||
ElNotification.error({ title: msg });
|
||||
ElNotification.error({title: msg});
|
||||
return Promise.reject(createHandledError(msg));
|
||||
} else {
|
||||
return Promise.resolve(res.data);
|
||||
@@ -250,11 +250,12 @@ service.interceptors.response.use(
|
||||
},
|
||||
async (error: any) => {
|
||||
const message = (await extractErrorMessage(error)) || errorCode['default'];
|
||||
ElMessage({ message: message, type: 'error', duration: 5 * 1000 });
|
||||
ElMessage({message: message, type: 'error', duration: 5 * 1000});
|
||||
error.isHandled = true;
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 通用下载方法
|
||||
export function download(url: string, params: any, fileName: string) {
|
||||
downloadLoadingInstance = ElLoading.service({
|
||||
@@ -269,7 +270,7 @@ export function download(url: string, params: any, fileName: string) {
|
||||
return tansParams(params);
|
||||
},
|
||||
],
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
headers: {"Content-Type": "application/x-www-form-urlencoded"},
|
||||
responseType: "blob",
|
||||
})
|
||||
.then(async (resp: any) => {
|
||||
@@ -292,5 +293,6 @@ export function download(url: string, params: any, fileName: string) {
|
||||
downloadLoadingInstance?.close();
|
||||
});
|
||||
}
|
||||
|
||||
// 导出 axios 实例
|
||||
export default service;
|
||||
|
||||
23
src/utils/richtext.ts
Normal file
23
src/utils/richtext.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 富文本转预览摘要:img替换[图片],a替换[链接],去除标签后截断文本
|
||||
* @param html 原始富文本
|
||||
* @param maxLen 最大展示字符数
|
||||
* @returns 处理后的纯文本摘要
|
||||
*/
|
||||
export function getHtmlSummary(html: string, maxLen = 80): string {
|
||||
if (!html) return ''
|
||||
let str = html
|
||||
// 替换图片标签为 [图片]
|
||||
str = str.replace(/<img[^>]*>/gi, ' [图片] ')
|
||||
// 替换a链接标签为 [链接]
|
||||
str = str.replace(/<a\b[^>]*>(.*?)<\/a>/gi, ' [链接] ')
|
||||
// 移除剩余所有html标签
|
||||
str = str.replace(/<[^>]+>/g, '')
|
||||
// 去除多余换行空格
|
||||
str = str.replace(/\s+/g, ' ').trim()
|
||||
|
||||
if (str.length > maxLen) {
|
||||
return str.slice(0, maxLen) + '...'
|
||||
}
|
||||
return str
|
||||
}
|
||||
13
src/utils/time.ts
Normal file
13
src/utils/time.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 根据数字 1-7 生成对应星期
|
||||
* @param num 1=周一,2=周二...7=周日
|
||||
* @param options 选项对象
|
||||
* @param options.prefix 选项,可自定义前缀,默认 '周'
|
||||
* @returns 周一 / 周二 ... / 周日
|
||||
*/
|
||||
export function numToWeek(num: number,options = {
|
||||
prefix: '周',
|
||||
}): string {
|
||||
const weekMap = ['一', '二', '三', '四', '五', '六', '日'];
|
||||
return options.prefix + weekMap[num - 1] || '';
|
||||
}
|
||||
11
src/views/Customer/index.vue
Normal file
11
src/views/Customer/index.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="Customer">Customer</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
@@ -1,370 +0,0 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<section class="hero-panel">
|
||||
<div class="hero-copy">
|
||||
<h1>RuoYi-Vue-Plus 控制台</h1>
|
||||
<p>
|
||||
企业级后台管理系统 重写 RuoYi-Vue 所有功能 集成 Sa-Token、Mybatis-Plus、WarmFlow、SpringDoc、Hutool、OSS
|
||||
等组件
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<el-button type="primary" @click="goTarget('https://gitee.com/dromara/RuoYi-Vue-Plus')">查看源码</el-button>
|
||||
<el-button plain @click="goTarget('https://plus-doc.dromara.org/#/ruoyi-vue-plus/changlog')">
|
||||
更新日志
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="content-grid">
|
||||
<section class="section-card">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>项目矩阵</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-list">
|
||||
<article v-for="product in products" :key="product.name" class="product-card">
|
||||
<div class="product-top">
|
||||
<div>
|
||||
<h3>{{ product.name }}</h3>
|
||||
<p>{{ product.summary }}</p>
|
||||
</div>
|
||||
<span class="product-version">{{ product.version }}</span>
|
||||
</div>
|
||||
<div class="product-tags">
|
||||
<el-tag v-for="tag in product.tags" :key="tag" effect="plain">{{ tag }}</el-tag>
|
||||
</div>
|
||||
<div class="product-actions">
|
||||
<el-button type="primary" plain @click="goTarget(product.primaryUrl)">
|
||||
{{ product.primaryLabel }}
|
||||
</el-button>
|
||||
<el-button plain @click="goTarget(product.secondaryUrl)">{{ product.secondaryLabel }}</el-button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-card capability-card">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>能力地图</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="capability-groups">
|
||||
<article v-for="group in capabilityGroups" :key="group.title" class="capability-group">
|
||||
<h3>{{ group.title }}</h3>
|
||||
<ul>
|
||||
<li v-for="item in group.items" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="Index" lang="ts">
|
||||
const products = [
|
||||
{
|
||||
name: 'RuoYi-Vue-Plus',
|
||||
version: 'v6.0.0',
|
||||
summary: '面向分布式集群场景的后台管理系统,保持现有业务接口与权限逻辑,适合先完成前端壳升级。',
|
||||
tags: ['Vue 3', 'Element Plus', 'Spring Boot', 'Sa-Token'],
|
||||
primaryLabel: '访问 GitHub',
|
||||
primaryUrl: 'https://github.com/dromara/RuoYi-Vue-Plus',
|
||||
secondaryLabel: '查看更新日志',
|
||||
secondaryUrl: 'https://plus-doc.dromara.org/#/ruoyi-vue-plus/changlog'
|
||||
},
|
||||
{
|
||||
name: 'RuoYi-Cloud-Plus',
|
||||
version: 'v6.0.0',
|
||||
summary: '微服务通用权限管理系统,适合更复杂的服务治理场景,也可以沿用同样的前端升级思路。',
|
||||
tags: ['Spring Cloud', 'Gateway', 'Nacos', 'Dubbo'],
|
||||
primaryLabel: '访问 GitHub',
|
||||
primaryUrl: 'https://github.com/dromara/RuoYi-Cloud-Plus',
|
||||
secondaryLabel: '查看更新日志',
|
||||
secondaryUrl: 'https://plus-doc.dromara.org/#/ruoyi-cloud-plus/changlog'
|
||||
}
|
||||
];
|
||||
|
||||
const capabilityGroups = [
|
||||
{
|
||||
title: '后端基建',
|
||||
items: ['Spring Boot / Spring Cloud', 'Sa-Token 认证与权限', 'MySQL / Redis', '代码生成器']
|
||||
},
|
||||
{
|
||||
title: '平台能力',
|
||||
items: ['动态菜单与按钮权限', '监控、日志、在线用户', '任务调度与工作流', '文件存储与多云适配']
|
||||
},
|
||||
{
|
||||
title: '前端方向',
|
||||
items: ['UI 卡片化', '主题与布局统一', '通用页面容器规范化', '企业化布局']
|
||||
}
|
||||
];
|
||||
|
||||
const goTarget = (url: string) => {
|
||||
window.open(url, '__blank');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.home {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.hero-panel,
|
||||
.section-card {
|
||||
border-radius: 28px;
|
||||
border: 1px solid var(--app-surface-border);
|
||||
background: var(--app-surface-bg);
|
||||
box-shadow: var(--app-shadow-sm);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(280px, 0.7fr);
|
||||
gap: 18px;
|
||||
padding: 30px;
|
||||
background: radial-gradient(circle at top left, rgba(53, 109, 255, 0.16), transparent 30%), var(--app-surface-bg);
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(30px, 4vw, 46px);
|
||||
line-height: 1.06;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--app-text-title);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
max-width: 760px;
|
||||
color: var(--app-text-muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(53, 109, 255, 0.12);
|
||||
color: var(--app-accent-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 18px 20px;
|
||||
border-radius: 22px;
|
||||
background: var(--app-elevated-soft-bg);
|
||||
border: 1px solid var(--app-surface-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
strong {
|
||||
color: var(--app-text-title);
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--app-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(300px, 0.85fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18px;
|
||||
|
||||
h2 {
|
||||
margin: 6px 0 0;
|
||||
color: var(--app-text-title);
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
}
|
||||
|
||||
.section-kicker {
|
||||
color: var(--app-text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.product-list,
|
||||
.capability-groups {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
padding: 22px;
|
||||
border-radius: 24px;
|
||||
background: var(--app-elevated-soft-bg);
|
||||
border: 1px solid var(--app-surface-border);
|
||||
transition:
|
||||
transform 0.25s ease,
|
||||
box-shadow 0.25s ease,
|
||||
border-color 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--app-shadow-sm);
|
||||
border-color: rgba(53, 109, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.product-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
color: var(--app-text-title);
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--app-text-muted);
|
||||
line-height: 1.8;
|
||||
}
|
||||
}
|
||||
|
||||
.product-version {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(53, 109, 255, 0.12);
|
||||
color: var(--app-accent-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.product-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.product-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.capability-card {
|
||||
background: radial-gradient(circle at top right, rgba(14, 165, 233, 0.12), transparent 28%), var(--app-surface-bg);
|
||||
}
|
||||
|
||||
.capability-group {
|
||||
padding: 18px 18px 18px 20px;
|
||||
border-radius: 22px;
|
||||
background: var(--app-elevated-soft-bg);
|
||||
border: 1px solid var(--app-surface-border);
|
||||
|
||||
h3 {
|
||||
margin: 0 0 12px;
|
||||
color: var(--app-text-title);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
padding-left: 16px;
|
||||
color: var(--app-text-muted);
|
||||
line-height: 1.7;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 10px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--app-accent-strong);
|
||||
box-shadow: 0 0 0 5px rgba(53, 109, 255, 0.12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.hero-panel,
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero-panel,
|
||||
.section-card {
|
||||
padding: 20px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.product-top {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
html.dark {
|
||||
.hero-panel {
|
||||
background: radial-gradient(circle at top left, rgba(53, 109, 255, 0.18), transparent 30%), var(--app-surface-bg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
118
src/views/index/compontents/activeDevices.vue
Normal file
118
src/views/index/compontents/activeDevices.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<script lang="ts" name="activeDevices" setup>
|
||||
import Echarts from '@/components/Echarts/index.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
week: number;
|
||||
daily: number;
|
||||
points: Array<{ count: number, label: string, date: string }>
|
||||
}>()
|
||||
const options = computed(() => {
|
||||
const data = props.points.map(item => {
|
||||
return {
|
||||
name: item.label,
|
||||
value: item.count,
|
||||
}
|
||||
})
|
||||
return {
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
top: '12%',
|
||||
bottom: '18%',
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map(i => i.name)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: data.map(item => item.value),
|
||||
type: 'bar'
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="Box">
|
||||
<div class="NavBox">
|
||||
<span>近7天设备激活量</span>
|
||||
<div class="actions">
|
||||
<div>
|
||||
<div class="title">本周新增</div>
|
||||
<div class="num">{{ props.week }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="title">日均激活</div>
|
||||
<div class="num day">{{ props.daily }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ChartBox">
|
||||
<Echarts :options="options"></Echarts>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.Box {
|
||||
padding: 25px;
|
||||
height: 372px;
|
||||
border-radius: 16px 16px 16px 16px;
|
||||
border: 1px solid rgba(238, 240, 243, 1);
|
||||
|
||||
.NavBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 36px;
|
||||
|
||||
span {
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
border-radius: 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 25px;
|
||||
|
||||
.title {
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
border-radius: 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.num {
|
||||
width: 49px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
border-radius: 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 18px;
|
||||
|
||||
&.day {
|
||||
color: rgba(37, 99, 235, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ChartBox {
|
||||
height: 330px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
100
src/views/index/compontents/areaDevices.vue
Normal file
100
src/views/index/compontents/areaDevices.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts" name="AreaDevices" setup>
|
||||
import {ref} from 'vue';
|
||||
import {ElProgress} from 'element-plus';
|
||||
|
||||
|
||||
const props = defineProps<{
|
||||
total: number,
|
||||
items: Array<{ count: number, name: string, percent: number }>
|
||||
}>()
|
||||
const list = computed(() => {
|
||||
return props.items.map(item => {
|
||||
return {
|
||||
areaName: item.name,
|
||||
num: item.count,
|
||||
percent: item.percent,
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="Box">
|
||||
<div class="NavBox">
|
||||
<div class="TypeNavBox">区域设备分布</div>
|
||||
<div class="subTypeNavBox">共 {{ props.total }} 台在线设备</div>
|
||||
</div>
|
||||
<div class="ChartBox">
|
||||
<div v-for="(item,index) in list" :key="index" style="margin-bottom: 18px">
|
||||
<div class="flex justify-between progressInfo">
|
||||
<div>{{ item.areaName }}</div>
|
||||
<div class="flex">
|
||||
<div class="num">{{ item.num }}台</div>
|
||||
<div class="percent">{{ item.percent }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-progress :percentage="item.percent" :show-text="false"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.Box {
|
||||
padding: 25px;
|
||||
height: 372px;
|
||||
border-radius: 16px 16px 16px 16px;
|
||||
border: 1px solid rgba(238, 240, 243, 1);
|
||||
|
||||
.NavBox {
|
||||
height: 36px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.TypeNavBox {
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
border-radius: 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.subTypeNavBox {
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
border-radius: 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.ChartBox {
|
||||
height: 310px;
|
||||
overflow-y: auto;
|
||||
|
||||
.progressInfo {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.num {
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
border-radius: 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.percent {
|
||||
margin-left: 12px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
192
src/views/index/compontents/card.vue
Normal file
192
src/views/index/compontents/card.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<script lang="ts" name="Cards" setup>
|
||||
import {computed} from 'vue'
|
||||
|
||||
// 定义 props:只接收 num、rate 数组,父组件只传这两个字段
|
||||
export interface CardItemProps {
|
||||
num: number
|
||||
rate: string,
|
||||
value: listValue
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
list: CardItemProps[]
|
||||
}>()
|
||||
|
||||
type listValue = 'envDevice' | 'online' | 'avgTvoc' | 'todayActivated'
|
||||
const cardStaticConfig = ref([
|
||||
{
|
||||
title: '环境数据',
|
||||
unit: '台',
|
||||
upRate: true,
|
||||
circleColor: "#2563eb",
|
||||
color: "rgba(22, 163, 74, 1)",
|
||||
rateColor: "rgba(22, 163, 74, 0.1)",
|
||||
value: 'envDevice',
|
||||
},
|
||||
{
|
||||
title: '设备在线率',
|
||||
unit: '台',
|
||||
upRate: false,
|
||||
circleColor: "#6366f1",
|
||||
color: "#ed3822",
|
||||
rateColor: "rgb(217 80 64 / 0.27)",
|
||||
value: 'online',
|
||||
|
||||
},
|
||||
{
|
||||
title: '平均TVOC原始值',
|
||||
unit: '台',
|
||||
upRate: false,
|
||||
circleColor: "#6366f1",
|
||||
color: "#ed3822",
|
||||
rateColor: "rgb(217 80 64 / 0.27)",
|
||||
value: 'avgTvoc',
|
||||
|
||||
},
|
||||
{
|
||||
title: '今日设备激活',
|
||||
unit: '台',
|
||||
upRate: false,
|
||||
circleColor: "#6366f1",
|
||||
color: "#ed3822",
|
||||
rateColor: "rgb(217 80 64 / 0.27)",
|
||||
value: 'todayActivated',
|
||||
|
||||
},
|
||||
])
|
||||
// computed 合并:父传入的 num、rate + 子静态配置
|
||||
const cardList = computed(() => {
|
||||
return props.list.map((item, index) => {
|
||||
const find = cardStaticConfig.value.find(item2 => item2.value === item.value)
|
||||
return {
|
||||
...find,
|
||||
num: item.num,
|
||||
rate: item.rate !== null ? Number(item.rate) : null,
|
||||
upRate: item.rate !== null ? Number(item.rate) > 0 : false,
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="CardBox">
|
||||
<div v-for="(item,index) in cardList" :key="index" class="card">
|
||||
<div class="cardNav">
|
||||
<div class="cardTitle">
|
||||
<i :style="{backgroundColor:item.circleColor}" class="circle"></i>
|
||||
<span>{{ item.title }}</span>
|
||||
</div>
|
||||
<div v-if="item.rate !== null" :class="{
|
||||
upRate: item.upRate,
|
||||
downRate: !item.upRate,
|
||||
}" class="cardRate">
|
||||
<span v-if="item.upRate" class="up">+{{ item.rate }}%</span>
|
||||
<span v-else class="down">-{{ item.rate }}%</span>
|
||||
</div>
|
||||
<div v-else class="cardRate uncardRate">
|
||||
<span>未换算</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cardContent">
|
||||
<div class="cardNum">{{ item.num || 0 }}</div>
|
||||
<span>{{ item.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.CardBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
|
||||
.card {
|
||||
flex: 389px;
|
||||
max-width: 389px;
|
||||
height: 120px;
|
||||
border-radius: 16px 16px 16px 16px;
|
||||
border: 1px solid rgba(238, 240, 243, 1);
|
||||
padding: 21px;
|
||||
|
||||
.cardNav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.cardTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 120px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 13px;
|
||||
|
||||
.circle {
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(0, 0, 0);
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.cardRate {
|
||||
width: 52px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
|
||||
line-height: 24px;
|
||||
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
|
||||
&.uncardRate {
|
||||
background-color: rgba(241, 245, 249);
|
||||
color: #64748B;
|
||||
}
|
||||
|
||||
&.upRate {
|
||||
background-color: rgba(232, 247, 238, 1);
|
||||
color: rgba(22, 163, 74, 1);
|
||||
}
|
||||
|
||||
&.downRate {
|
||||
background-color: rgb(217 80 64 / 0.27);
|
||||
color: #ed3822;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
margin-top: 20px;
|
||||
|
||||
.cardNum {
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
border-radius: 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 30px;
|
||||
font-weight: 500;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
span {
|
||||
width: 15px;
|
||||
height: 17px;
|
||||
line-height: 17px;
|
||||
border-radius: 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
163
src/views/index/compontents/tvoc.vue
Normal file
163
src/views/index/compontents/tvoc.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<script lang="ts" name="Tvoc" setup>
|
||||
import Echarts from '@/components/Echarts/index.vue';
|
||||
import {dashboardTvoc} from "@/api/system/dashboard";
|
||||
import {ref, computed} from 'vue';
|
||||
|
||||
const list = [
|
||||
{label: '24小时', value: 24},
|
||||
{label: '7天', value: 7},
|
||||
{label: '30天', value: 30},
|
||||
];
|
||||
const activeDay = ref(24);
|
||||
const loading = ref(false);
|
||||
|
||||
const dayEnum = {
|
||||
"24": '24h',
|
||||
'7': '7d',
|
||||
'30': '30d'
|
||||
}
|
||||
const points = ref<any[]>([])
|
||||
|
||||
const options = computed(() => {
|
||||
const xData = points.value.map(item => item.time)
|
||||
const yData = points.value.map(item => item.value)
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
top: '12%',
|
||||
bottom: '18%',
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: xData
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: 'TVOC'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: yData,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
function changeActive(val: number) {
|
||||
activeDay.value = val;
|
||||
init()
|
||||
}
|
||||
|
||||
function init() {
|
||||
loading.value = true
|
||||
dashboardTvoc(dayEnum[activeDay.value])
|
||||
.then(res => {
|
||||
points.value = res.data.points.map(item => {
|
||||
if (activeDay.value === 24) {
|
||||
const yearMonthDay = item.time.split(' ')[0]
|
||||
const [_, month, day] = yearMonthDay.split('-')
|
||||
return {
|
||||
...item,
|
||||
value: item.value || 0,
|
||||
time: `${month}-${day} ${item.label}`
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
...item,
|
||||
value: item.value || 0,
|
||||
time: item.label
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('TVOC接口请求失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
init()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="TvocBox">
|
||||
<div class="TvocNavBox">
|
||||
<span>平均TVOC原始值</span>
|
||||
<div class="actions">
|
||||
<div
|
||||
v-for="(item,index) in list"
|
||||
:key="index"
|
||||
:class="{ active: activeDay === item.value }"
|
||||
class="actionItem"
|
||||
@click="changeActive(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="TvocChartBox">
|
||||
<Echarts :options="options"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.TvocBox {
|
||||
padding: 25px;
|
||||
height: 372px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(238, 240, 243, 1);
|
||||
|
||||
.TvocNavBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 36px;
|
||||
|
||||
span {
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 175px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(243, 245, 248, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
padding: 4px;
|
||||
|
||||
.actionItem {
|
||||
max-width: 64px;
|
||||
height: 28px;
|
||||
padding: 7px 12px;
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
color: rgba(37, 99, 235, 1);
|
||||
border-radius: 6px;
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.TvocChartBox {
|
||||
height: 330px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
82
src/views/index/compontents/type.vue
Normal file
82
src/views/index/compontents/type.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts" name="Type" setup>
|
||||
import Echarts from '@/components/Echarts/index.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
total: number;
|
||||
items: Array<{ count: number, name: string, percent: number }>
|
||||
}>()
|
||||
const options = computed(() => {
|
||||
const pieData = props.items.map(item => ({
|
||||
value: item.count,
|
||||
name: item.name
|
||||
}))
|
||||
return {
|
||||
legend: {
|
||||
type: 'scroll',
|
||||
orient: 'vertical',
|
||||
right: 10,
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
data: pieData.map(i => i.name)
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
center: ['38%', '50%'],
|
||||
radius: ['40%', '70%'],
|
||||
data: pieData,
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="TypeBox">
|
||||
<div class="TypeNavBox">设备类型分布</div>
|
||||
<div class="subTypeNavBox">共 {{ props.total }} 台在线设备</div>
|
||||
<div class="TypeChartBox">
|
||||
<Echarts :options="options"></Echarts>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.TypeBox {
|
||||
padding: 25px;
|
||||
height: 372px;
|
||||
border-radius: 16px 16px 16px 16px;
|
||||
border: 1px solid rgba(238, 240, 243, 1);
|
||||
|
||||
.TypeNavBox {
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
border-radius: 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.subTypeNavBox {
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
border-radius: 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.TypeChartBox {
|
||||
height: 330px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
203
src/views/index/index.vue
Normal file
203
src/views/index/index.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<div class="top">
|
||||
<div class=" updateTimeBox">
|
||||
<div class="updateTime">数据更新于 {{ form.updatedAt }}</div>
|
||||
<div class="tag">
|
||||
<div>{{ form.envNote }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 卡片-->
|
||||
<Cards :list="cardData"></Cards>
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="14" :xs="24">
|
||||
<Tvoc/>
|
||||
</el-col>
|
||||
<el-col :sm="10" :xs="24">
|
||||
<Type :items="typeData.items" :total="typeData.total"/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="14" :xs="24">
|
||||
<ActiveDevices :daily="activeDevices.dailyAvg" :points="activeDevices.points"
|
||||
:week="activeDevices.Week"></ActiveDevices>
|
||||
</el-col>
|
||||
<el-col :sm="10" :xs="24">
|
||||
<AreaDevices :items="areaDevices.items" :total="areaDevices.total"></AreaDevices>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="empty">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="Index" setup>
|
||||
import Cards from './compontents/card.vue';
|
||||
import Tvoc from './compontents/tvoc.vue';
|
||||
import Type from './compontents/type.vue';
|
||||
import AreaDevices from './compontents/areaDevices.vue';
|
||||
import ActiveDevices from './compontents/activeDevices.vue';
|
||||
import {dashboardHome} from '@/api/system/dashboard'
|
||||
import {CardItemProps} from './compontents/card.vue'
|
||||
|
||||
const form = ref({
|
||||
"activatedCount": 1,
|
||||
"activation": {
|
||||
"dailyAvg": 0,
|
||||
"points": [],
|
||||
"weekNew": 0
|
||||
},
|
||||
"avgTvoc": null,
|
||||
"avgTvocChangeRate": null,
|
||||
"deviceTypes": {
|
||||
"items": [
|
||||
{
|
||||
"count": 1,
|
||||
"name": "未分类",
|
||||
"percent": 100
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
},
|
||||
"envDeviceChangeRate": null,
|
||||
"envDeviceCount": 0,
|
||||
"envNote": "环境数据为原始读数",
|
||||
"onlineCount": 1,
|
||||
"onlineRate": "100.00",
|
||||
"regions": {
|
||||
"items": [
|
||||
{
|
||||
"count": 1,
|
||||
"name": "未知",
|
||||
"percent": 100
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
},
|
||||
"todayActivated": 0,
|
||||
"todayActivatedChangeRate": null,
|
||||
"tvocTrend": {
|
||||
"points": [],
|
||||
"range": "24h"
|
||||
},
|
||||
"updatedAt": "2026-10-10 08:05:02"
|
||||
})
|
||||
|
||||
|
||||
// card Data
|
||||
const cardData = ref<CardItemProps[]>([])
|
||||
|
||||
|
||||
// type data
|
||||
const typeData = ref({
|
||||
total: 0,
|
||||
items: []
|
||||
})
|
||||
|
||||
const activeDevices = ref({
|
||||
Week: 0,
|
||||
dailyAvg: 0,
|
||||
points: [],
|
||||
})
|
||||
|
||||
// area device
|
||||
const areaDevices = ref({
|
||||
total: 0,
|
||||
items: []
|
||||
})
|
||||
|
||||
|
||||
function init() {
|
||||
dashboardHome().then((res) => {
|
||||
form.value = res.data;
|
||||
cardData.value = [
|
||||
{
|
||||
num: res.data.envDeviceCount,
|
||||
rate: res.data.envDeviceChangeRate,
|
||||
value: 'envDevice'
|
||||
},
|
||||
{
|
||||
num: res.data.onlineCount,
|
||||
rate: res.data.onlineRate,
|
||||
value: 'online'
|
||||
},
|
||||
{
|
||||
num: res.data.avgTvoc,
|
||||
rate: res.data.avgTvocChangeRate,
|
||||
value: 'avgTvoc'
|
||||
},
|
||||
{
|
||||
num: res.data.todayActivated,
|
||||
rate: res.data.todayActivatedChangeRate,
|
||||
value: 'todayActivated'
|
||||
},
|
||||
];
|
||||
typeData.value = {
|
||||
total: res.data.deviceTypes.total,
|
||||
items: res.data.deviceTypes.items,
|
||||
};
|
||||
|
||||
activeDevices.value = {
|
||||
dailyAvg: res.data.activation.dailyAvg,
|
||||
Week: res.data.activation.weekNew,
|
||||
points: res.data.activation.points,
|
||||
};
|
||||
areaDevices.value = {
|
||||
total: res.data.regions.total,
|
||||
items: res.data.regions.items,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
init()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.home {
|
||||
.top {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.updateTimeBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.updateTime {
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
border-radius: 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
width: 116px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
border-radius: 10px;
|
||||
background-color: rgba(240, 253, 250, 1);
|
||||
margin-left: 4px;
|
||||
|
||||
div {
|
||||
text-align: center;
|
||||
color: rgba(15, 118, 110, 1);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.echartsBox {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
@@ -47,18 +47,12 @@
|
||||
<div
|
||||
class="metric-card"
|
||||
:class="{
|
||||
active: item.isActivcty,
|
||||
active: item.isAction,
|
||||
}"
|
||||
></div>
|
||||
<div>
|
||||
周{{
|
||||
new Intl.NumberFormat("zh-Hans-CN-u-nu-hanidec").format(
|
||||
item.formatlday + 1,
|
||||
)
|
||||
}}
|
||||
<span style="font-size: 12px"
|
||||
>({{ item.month }}.{{ item.date }})</span
|
||||
>
|
||||
<span>{{ numToWeek(item.formateDay + 1)}}</span>
|
||||
<span style="font-size: 12px">({{ item.month }}.{{ item.date }})</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -116,7 +110,7 @@
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<!-- <el-form-item v-if="captchaEnabled" prop="code" class="captcha-row">
|
||||
<el-form-item v-if="captchaEnabled" class="captcha-row" prop="code">
|
||||
<el-input
|
||||
v-model="loginForm.code"
|
||||
size="large"
|
||||
@@ -133,7 +127,7 @@
|
||||
<div class="login-code">
|
||||
<img :src="codeUrl" class="login-code-img" @click="getCode" />
|
||||
</div>
|
||||
</el-form-item> -->
|
||||
</el-form-item>
|
||||
<div class="form-meta">
|
||||
<el-checkbox v-model="loginForm.rememberMe">{{
|
||||
$t("login.rememberPassword")
|
||||
@@ -162,12 +156,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { to } from "await-to-js";
|
||||
import { GeoResult, getCurrentPosition } from "@/utils/geolocation";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { getCodeImg } from "@/api/login";
|
||||
import { LoginData } from "@/api/types";
|
||||
import { useUserStore } from "@/store/modules/user";
|
||||
import {to} from "await-to-js";
|
||||
import {GeoResult, getCurrentPosition} from "@/utils/geolocation";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {getCodeImg} from "@/api/login";
|
||||
import {LoginData} from "@/api/types";
|
||||
import {useUserStore} from "@/store/modules/user";
|
||||
import {numToWeek} from "@/utils/time";
|
||||
|
||||
const title = import.meta.env.VITE_APP_TITLE;
|
||||
const quickStats = ref([]);
|
||||
@@ -292,17 +287,14 @@ const handleGetLoc = async () => {
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
const arr = res.data.forecast.daily.pm10 ?? [];
|
||||
console.log(arr.slice(0, 7));
|
||||
const list = handleMaxWaqi(arr.slice(0, 7));
|
||||
quickStats.value = list;
|
||||
console.log(list);
|
||||
quickStats.value = handleMax(arr.slice(0, 7));
|
||||
});
|
||||
} catch (e) {
|
||||
console.error((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
function handleMaxWaqi(arr: { avg: number; day: string }[]) {
|
||||
function handleMax(arr: { avg: number; day: string }[]) {
|
||||
const maxVal = Math.max(...arr.map((item) => item.avg));
|
||||
const nowDate = new Date();
|
||||
return arr.map((item) => {
|
||||
@@ -312,9 +304,9 @@ function handleMaxWaqi(arr: { avg: number; day: string }[]) {
|
||||
...item,
|
||||
rate,
|
||||
month: newDate.getMonth() + 1,
|
||||
formatlday: newDate.getDay(),
|
||||
formateDay: newDate.getDay(),
|
||||
date: newDate.getDate(),
|
||||
isActivcty: newDate.getDate() === nowDate.getDate(),
|
||||
isAction: newDate.getDate() === nowDate.getDate(),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -369,7 +361,7 @@ onMounted(() => {
|
||||
.title {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
border-radius: 0px 0px 0px 0px;
|
||||
border-radius: 0 0 0 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(255, 255, 255, 1);
|
||||
font-size: 20px;
|
||||
@@ -377,7 +369,7 @@ onMounted(() => {
|
||||
.subTitle {
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
border-radius: 0px 0px 0px 0px;
|
||||
border-radius: 0 0 0 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(255, 255, 255, 0.722);
|
||||
font-size: 13px;
|
||||
@@ -451,7 +443,7 @@ onMounted(() => {
|
||||
gap: 16px;
|
||||
height: 105px;
|
||||
.metric-card {
|
||||
border-radius: 8px 8px 0px 0px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
display: flex;
|
||||
@@ -475,7 +467,7 @@ onMounted(() => {
|
||||
.brand-footer {
|
||||
margin-top: 157px;
|
||||
line-height: 16px;
|
||||
border-radius: 0px 0px 0px 0px;
|
||||
border-radius: 0 0 0 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(255, 255, 255, 0.502);
|
||||
font-size: 13px;
|
||||
@@ -540,7 +532,7 @@ onMounted(() => {
|
||||
.loginModel {
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
border-radius: 0px 0px 0px 0px;
|
||||
border-radius: 0 0 0 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(37, 99, 235, 1);
|
||||
font-size: 15px;
|
||||
@@ -558,6 +550,7 @@ onMounted(() => {
|
||||
background-color: rgba(247, 248, 250, 1);
|
||||
border: 1px solid rgba(238, 240, 243, 1);
|
||||
.actions {
|
||||
cursor: pointer;
|
||||
color: rgba(37, 99, 235, 1);
|
||||
}
|
||||
}
|
||||
@@ -636,7 +629,6 @@ onMounted(() => {
|
||||
|
||||
.login-form :deep(.el-input__wrapper) {
|
||||
min-height: 48px;
|
||||
background-color: var(--el-bg-color);
|
||||
background-color: rgba(247, 248, 250, 1);
|
||||
box-shadow: 0 0 0 1px var(--app-surface-border) inset;
|
||||
}
|
||||
|
||||
438
src/views/system/agreement/index.vue
Normal file
438
src/views/system/agreement/index.vue
Normal file
@@ -0,0 +1,438 @@
|
||||
<template>
|
||||
<div class="p-2 page-shell system-agreement-page">
|
||||
<div class="search-wrap">
|
||||
<el-card :class="{ 'is-collapsed': !showSearch }" class="search-panel" shadow="hover">
|
||||
<template #header>
|
||||
<div class="panel-heading search-panel-toggle" @click.stop="showSearch = !showSearch">
|
||||
<div><h3>筛选条件</h3></div>
|
||||
</div>
|
||||
</template>
|
||||
<el-form ref="queryFormRef" :inline="true" :model="queryParams" class="query-form">
|
||||
<el-form-item label="协议编号" prop="agreementNo">
|
||||
<el-input v-model="queryParams.agreementNo" clearable placeholder="请输入协议编号"
|
||||
@keyup.enter="handleQuery"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="类型" prop="agreementType">
|
||||
<el-select v-model="queryParams.agreementType" clearable placeholder="请选择类型">
|
||||
<el-option label="请选择字典生成" value=""/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="协议名称" prop="name">
|
||||
<el-input v-model="queryParams.name" clearable placeholder="请输入协议名称" @keyup.enter="handleQuery"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="版本号" prop="versionName">
|
||||
<el-input v-model="queryParams.versionName" clearable placeholder="请输入版本号"
|
||||
@keyup.enter="handleQuery"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="queryParams.status" clearable placeholder="请选择状态">
|
||||
<el-option :value="true" label="是"/>
|
||||
<el-option :value="false" label="否"/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="生效时间" style="width: 308px">
|
||||
<el-date-picker
|
||||
v-model="dateRangePublishTime"
|
||||
:default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 1, 1, 23, 59, 59)]"
|
||||
end-placeholder="结束日期"
|
||||
range-separator="-"
|
||||
start-placeholder="开始日期"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建部门" prop="createDept">
|
||||
<el-input-number v-model="queryParams.createDept" controls-position="right"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建者" prop="createBy">
|
||||
<el-input-number v-model="queryParams.createBy" controls-position="right"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间" style="width: 308px">
|
||||
<el-date-picker
|
||||
v-model="dateRangeCreateTime"
|
||||
:default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 1, 1, 23, 59, 59)]"
|
||||
end-placeholder="结束日期"
|
||||
range-separator="-"
|
||||
start-placeholder="开始日期"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="更新者" prop="updateBy">
|
||||
<el-input-number v-model="queryParams.updateBy" controls-position="right"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="更新时间" style="width: 308px">
|
||||
<el-date-picker
|
||||
v-model="dateRangeUpdateTime"
|
||||
:default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 1, 1, 23, 59, 59)]"
|
||||
end-placeholder="结束日期"
|
||||
range-separator="-"
|
||||
start-placeholder="开始日期"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="已生效类型;同类型仅一条,非生效或已删除为 NULL" prop="activeType">
|
||||
<el-select v-model="queryParams.activeType"
|
||||
clearable placeholder="请选择已生效类型;同类型仅一条,非生效或已删除为 NULL">
|
||||
<el-option label="请选择字典生成" value=""/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button icon="Search" type="primary" @click="handleQuery">搜索</el-button>
|
||||
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-card class="table-panel" shadow="hover">
|
||||
<template #header>
|
||||
<div class="toolbar-shell">
|
||||
<div class="table-heading">
|
||||
<h3>协议配置列表</h3>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button v-hasPermi="['system:agreement:add']" icon="Plus" plain type="primary" @click="handleAdd">新增
|
||||
</el-button>
|
||||
<el-button v-hasPermi="['system:agreement:edit']" :disabled="single" icon="Edit" plain type="success"
|
||||
@click="handleUpdate()">修改
|
||||
</el-button>
|
||||
<el-button v-hasPermi="['system:agreement:remove']" :disabled="multiple" icon="Delete" plain type="danger"
|
||||
@click="handleDelete()">删除
|
||||
</el-button>
|
||||
<el-button v-hasPermi="['system:agreement:export']" icon="Download" plain type="warning"
|
||||
@click="handleExport">导出
|
||||
</el-button>
|
||||
<right-toolbar v-model:show-search="showSearch" :search="false" @query-table="getList"></right-toolbar>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table v-loading="loading" :data="agreementList" border class="data-table"
|
||||
@selection-change="handleSelectionChange">
|
||||
<el-table-column align="center" type="selection" width="55"/>
|
||||
<el-table-column align="center" label="主键" prop="agreementId"/>
|
||||
<el-table-column align="center" label="协议编号" prop="agreementNo"/>
|
||||
<el-table-column align="center" label="类型" prop="agreementType"/>
|
||||
<el-table-column align="center" label="协议名称" prop="name"/>
|
||||
<el-table-column align="center" label="版本号" prop="versionName"/>
|
||||
<el-table-column align="center" label="协议正文" prop="content"/>
|
||||
<el-table-column align="center" label="状态" prop="status" width="120">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
v-model="scope.row.status"
|
||||
:active-value="true"
|
||||
:inactive-value="false"
|
||||
disabled
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="生效时间" prop="publishTime" width="180">
|
||||
<template #default="scope">
|
||||
<span>{{ parseTime(scope.row.publishTime, '{y}-{m}-{d}') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="已生效类型;同类型仅一条,非生效或已删除为 NULL" prop="activeType"/>
|
||||
<el-table-column align="center" class-name="small-padding fixed-width" label="操作">
|
||||
<template #default="scope">
|
||||
<el-tooltip content="修改" placement="top">
|
||||
<el-button v-hasPermi="['system:agreement:edit']" icon="Edit" link type="primary"
|
||||
@click="handleUpdate(scope.row)"></el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除" placement="top">
|
||||
<el-button v-hasPermi="['system:agreement:remove']" icon="Delete" link type="primary"
|
||||
@click="handleDelete(scope.row)"></el-button>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total > 0" v-model:limit="queryParams.pageSize" v-model:page="queryParams.pageNum"
|
||||
:total="total" @pagination="getList"/>
|
||||
</el-card>
|
||||
<!-- 添加或修改协议配置对话框 -->
|
||||
<el-dialog v-model="dialog.visible" :title="dialog.title" append-to-body width="500px">
|
||||
<el-form ref="agreementFormRef" :model="form" :rules="rules" label-width="80px">
|
||||
<el-form-item label="协议编号" prop="agreementNo">
|
||||
<el-input v-model="form.agreementNo" placeholder="请输入协议编号"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="类型" prop="agreementType">
|
||||
<el-select v-model="form.agreementType" placeholder="请选择类型">
|
||||
<el-option label="请选择字典生成" value=""/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="协议名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入协议名称"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="版本号" prop="versionName">
|
||||
<el-input v-model="form.versionName" placeholder="请输入版本号"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="协议正文">
|
||||
<editor v-model="form.content" :min-height="192"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-switch
|
||||
v-model="form.status"
|
||||
:active-value="true"
|
||||
:inactive-value="false"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="生效时间" prop="publishTime">
|
||||
<el-date-picker v-model="form.publishTime"
|
||||
clearable
|
||||
placeholder="请选择生效时间"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss">
|
||||
</el-date-picker>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建部门" prop="createDept">
|
||||
<el-input-number v-model="form.createDept" controls-position="right"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建者" prop="createBy">
|
||||
<el-input-number v-model="form.createBy" controls-position="right"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间" prop="createTime">
|
||||
<el-date-picker v-model="form.createTime"
|
||||
clearable
|
||||
placeholder="请选择创建时间"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss">
|
||||
</el-date-picker>
|
||||
</el-form-item>
|
||||
<el-form-item label="更新者" prop="updateBy">
|
||||
<el-input-number v-model="form.updateBy" controls-position="right"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="更新时间" prop="updateTime">
|
||||
<el-date-picker v-model="form.updateTime"
|
||||
clearable
|
||||
placeholder="请选择更新时间"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss">
|
||||
</el-date-picker>
|
||||
</el-form-item>
|
||||
<el-form-item label="已生效类型;同类型仅一条,非生效或已删除为 NULL" prop="activeType">
|
||||
<el-select v-model="form.activeType" placeholder="请选择已生效类型;同类型仅一条,非生效或已删除为 NULL">
|
||||
<el-option label="请选择字典生成" value=""/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button :loading="buttonLoading" type="primary" @click="submitForm">确 定</el-button>
|
||||
<el-button @click="cancel">取 消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="Agreement" setup>
|
||||
import {
|
||||
addAgreement,
|
||||
delAgreement,
|
||||
getAgreement,
|
||||
listAgreement,
|
||||
updateAgreement
|
||||
} from '@/api/system/agreement';
|
||||
import {AgreementForm, AgreementQuery, AgreementVO} from '@/api/system/agreement/types';
|
||||
import {useLoading} from '@/hooks/async/useLoading';
|
||||
import {useFormDialog} from '@/hooks/dialog/useFormDialog';
|
||||
import {useDateRangeQuery} from '@/hooks/form/useDateRangeQuery';
|
||||
import {useSearchReset} from '@/hooks/form/useSearchReset';
|
||||
import {useSearchToggle} from '@/hooks/form/useSearchToggle';
|
||||
import {useTableSelection} from '@/hooks/table/useTableSelection';
|
||||
import {parseTime} from '@/utils/ruoyi';
|
||||
import modal from '@/plugins/modal';
|
||||
import {download as requestDownload} from '@/utils/request';
|
||||
|
||||
|
||||
const agreementList = ref<AgreementVO[]>([]);
|
||||
const buttonLoading = ref(false);
|
||||
const {loading, withLoading} = useLoading(true);
|
||||
const {showSearch} = useSearchToggle();
|
||||
const total = ref(0);
|
||||
const {
|
||||
dateRange: dateRangePublishTime,
|
||||
applyDateRange: applyPublishTimeDateRange,
|
||||
resetDateRange: resetPublishTimeDateRange
|
||||
} = useDateRangeQuery('PublishTime');
|
||||
const {
|
||||
dateRange: dateRangeCreateTime,
|
||||
applyDateRange: applyCreateTimeDateRange,
|
||||
resetDateRange: resetCreateTimeDateRange
|
||||
} = useDateRangeQuery('CreateTime');
|
||||
const {
|
||||
dateRange: dateRangeUpdateTime,
|
||||
applyDateRange: applyUpdateTimeDateRange,
|
||||
resetDateRange: resetUpdateTimeDateRange
|
||||
} = useDateRangeQuery('UpdateTime');
|
||||
|
||||
const queryFormRef = ref<ElFormInstance>();
|
||||
const agreementFormRef = ref<ElFormInstance>();
|
||||
|
||||
const initFormData: AgreementForm = {
|
||||
agreementId: undefined,
|
||||
agreementNo: undefined,
|
||||
agreementType: undefined,
|
||||
name: undefined,
|
||||
versionName: undefined,
|
||||
content: undefined,
|
||||
status: undefined,
|
||||
publishTime: undefined,
|
||||
createDept: undefined,
|
||||
createBy: undefined,
|
||||
createTime: undefined,
|
||||
updateBy: undefined,
|
||||
updateTime: undefined,
|
||||
activeType: undefined,
|
||||
}
|
||||
const data = reactive<PageData<AgreementForm, AgreementQuery>>({
|
||||
form: {...initFormData},
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
agreementNo: undefined,
|
||||
agreementType: undefined,
|
||||
name: undefined,
|
||||
versionName: undefined,
|
||||
content: undefined,
|
||||
status: undefined,
|
||||
createDept: undefined,
|
||||
createBy: undefined,
|
||||
updateBy: undefined,
|
||||
activeType: undefined,
|
||||
params: {
|
||||
publishTime: undefined,
|
||||
createTime: undefined,
|
||||
updateTime: undefined,
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
agreementId: [
|
||||
{required: true, message: "主键不能为空", trigger: "change"}
|
||||
],
|
||||
agreementNo: [
|
||||
{required: true, message: "协议编号不能为空", trigger: "blur"}
|
||||
],
|
||||
agreementType: [
|
||||
{required: true, message: "类型不能为空", trigger: "change"}
|
||||
],
|
||||
name: [
|
||||
{required: true, message: "协议名称不能为空", trigger: "blur"}
|
||||
],
|
||||
versionName: [
|
||||
{required: true, message: "版本号不能为空", trigger: "blur"}
|
||||
],
|
||||
status: [
|
||||
{required: true, message: "状态不能为空", trigger: "change"}
|
||||
],
|
||||
}
|
||||
});
|
||||
|
||||
const {queryParams, form, rules} = toRefs(data);
|
||||
const {ids, single, multiple, handleSelectionChange} = useTableSelection<AgreementVO>(item => item.agreementId);
|
||||
const {dialog, resetForm: reset, openDialog, showDialog, closeDialog} = useFormDialog({
|
||||
form,
|
||||
formRef: agreementFormRef,
|
||||
initialFormData: initFormData
|
||||
});
|
||||
|
||||
/** 查询协议配置列表 */
|
||||
const getList = async () => {
|
||||
await withLoading(async () => {
|
||||
let params = queryParams.value;
|
||||
params = applyPublishTimeDateRange(params);
|
||||
params = applyCreateTimeDateRange(params);
|
||||
params = applyUpdateTimeDateRange(params);
|
||||
const res = await listAgreement(params);
|
||||
agreementList.value = res.data?.rows;
|
||||
total.value = res.data?.total;
|
||||
});
|
||||
};
|
||||
|
||||
/** 取消按钮 */
|
||||
const cancel = () => {
|
||||
reset();
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.value.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const {resetQuery} = useSearchReset({
|
||||
queryFormRef,
|
||||
queryParams,
|
||||
pageNumKey: 'pageNum',
|
||||
pageSizeKey: 'pageSize',
|
||||
initialPageSize: 10,
|
||||
resetExtras: () => {
|
||||
resetPublishTimeDateRange();
|
||||
resetCreateTimeDateRange();
|
||||
resetUpdateTimeDateRange();
|
||||
},
|
||||
afterReset: () => {
|
||||
handleQuery();
|
||||
}
|
||||
});
|
||||
|
||||
/** 新增按钮操作 */
|
||||
const handleAdd = () => {
|
||||
openDialog('添加协议配置');
|
||||
};
|
||||
|
||||
/** 修改按钮操作 */
|
||||
const handleUpdate = async (row?: Partial<AgreementVO>) => {
|
||||
reset();
|
||||
const _agreementId = row?.agreementId || ids.value[0];
|
||||
const res = await getAgreement(_agreementId);
|
||||
Object.assign(form.value, res.data);
|
||||
showDialog('修改协议配置');
|
||||
};
|
||||
|
||||
/** 提交按钮 */
|
||||
const submitForm = () => {
|
||||
agreementFormRef.value?.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
buttonLoading.value = true;
|
||||
if (form.value.agreementId) {
|
||||
await updateAgreement(form.value).finally(() => (buttonLoading.value = false));
|
||||
} else {
|
||||
await addAgreement(form.value).finally(() => (buttonLoading.value = false));
|
||||
}
|
||||
modal.msgSuccess('操作成功');
|
||||
closeDialog();
|
||||
await getList();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (row?: Partial<AgreementVO>) => {
|
||||
const _agreementIds = row?.agreementId || ids.value;
|
||||
await modal.confirm('是否确认删除协议配置编号为"' + _agreementIds + '"的数据项?');
|
||||
await delAgreement(_agreementIds);
|
||||
modal.msgSuccess('删除成功');
|
||||
await getList();
|
||||
};
|
||||
|
||||
/** 导出按钮操作 */
|
||||
const handleExport = () => {
|
||||
requestDownload(
|
||||
'system/agreement/export',
|
||||
{
|
||||
...queryParams.value
|
||||
},
|
||||
`agreement_${new Date().getTime()}.xlsx`
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div class="p-2 app-container system-client-page">
|
||||
<Pagecrumb></Pagecrumb>
|
||||
<div class="search-wrap">
|
||||
<el-card shadow="hover" class="search-panel" :class="{ 'is-collapsed': !showSearch }">
|
||||
<template #header>
|
||||
@@ -26,7 +27,7 @@
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="queryParams.status" placeholder="状态" clearable>
|
||||
<el-option v-for="dict in sys_normal_disable" :key="dict.value" :label="dict.label" :value="dict.value" />
|
||||
<el-option v-for="dict in sys_normal_disable" :key="dict.value" :label="dict.label" :value="dict.value"/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
@@ -36,7 +37,6 @@
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-card shadow="hover" class="table-panel">
|
||||
<template #header>
|
||||
<div class="toolbar-shell">
|
||||
@@ -82,19 +82,19 @@
|
||||
class="data-table"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column v-if="false" label="id" align="center" prop="id" />
|
||||
<el-table-column label="客户端id" align="center" prop="clientId" />
|
||||
<el-table-column label="客户端key" align="center" prop="clientKey" />
|
||||
<el-table-column label="客户端秘钥" align="center" prop="clientSecret" />
|
||||
<el-table-column align="center" type="selection" width="55"/>
|
||||
<el-table-column v-if="false" align="center" label="id" prop="id"/>
|
||||
<el-table-column align="center" label="客户端id" prop="clientId"/>
|
||||
<el-table-column align="center" label="客户端key" prop="clientKey"/>
|
||||
<el-table-column align="center" label="客户端秘钥" prop="clientSecret"/>
|
||||
<el-table-column label="授权类型" align="center">
|
||||
<template #default="scope">
|
||||
<dict-tag class="grant-type-tag" :options="sys_grant_type" :value="scope.row.grantTypeList" />
|
||||
<dict-tag :options="sys_grant_type" :value="scope.row.grantTypeList" class="grant-type-tag"/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="设备类型" align="center">
|
||||
<template #default="scope">
|
||||
<dict-tag :options="sys_device_type" :value="scope.row.deviceType" />
|
||||
<dict-tag :options="sys_device_type" :value="scope.row.deviceType"/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="白名单路径" align="center">
|
||||
@@ -132,8 +132,8 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Token活跃超时时间" align="center" prop="activeTimeout" />
|
||||
<el-table-column label="Token固定超时时间" align="center" prop="timeout" />
|
||||
<el-table-column align="center" label="Token活跃超时时间" prop="activeTimeout"/>
|
||||
<el-table-column align="center" label="Token固定超时时间" prop="timeout"/>
|
||||
<el-table-column key="status" label="状态" align="center">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
@@ -180,10 +180,10 @@
|
||||
<el-dialog v-model="dialog.visible" :title="dialog.title" width="760px" append-to-body>
|
||||
<el-form ref="clientFormRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="客户端key" prop="clientKey">
|
||||
<el-input v-model="form.clientKey" :disabled="form.id != null" placeholder="请输入客户端key" />
|
||||
<el-input v-model="form.clientKey" :disabled="form.id != null" placeholder="请输入客户端key"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="客户端秘钥" prop="clientSecret">
|
||||
<el-input v-model="form.clientSecret" :disabled="form.id != null" placeholder="请输入客户端秘钥" />
|
||||
<el-input v-model="form.clientSecret" :disabled="form.id != null" placeholder="请输入客户端秘钥"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="授权类型" prop="grantTypeList">
|
||||
<el-select v-model="form.grantTypeList" multiple placeholder="请输入授权类型">
|
||||
@@ -214,7 +214,7 @@
|
||||
允许访问路径
|
||||
</span>
|
||||
</template>
|
||||
<el-input v-model="form.accessPath" type="textarea" :rows="4" placeholder="示例:/app/**" />
|
||||
<el-input v-model="form.accessPath" :rows="4" placeholder="示例:/app/**" type="textarea"/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="ipWhitelist" label-width="auto">
|
||||
<template #label>
|
||||
@@ -244,7 +244,7 @@
|
||||
Token活跃超时时间
|
||||
</span>
|
||||
</template>
|
||||
<el-input v-model="form.activeTimeout" placeholder="请输入Token活跃超时时间" />
|
||||
<el-input v-model="form.activeTimeout" placeholder="请输入Token活跃超时时间"/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="timeout" label-width="auto">
|
||||
<template #label>
|
||||
@@ -255,7 +255,7 @@
|
||||
Token固定超时时间
|
||||
</span>
|
||||
</template>
|
||||
<el-input v-model="form.timeout" placeholder="请输入Token固定超时时间" />
|
||||
<el-input v-model="form.timeout" placeholder="请输入Token固定超时时间"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="form.status">
|
||||
@@ -286,6 +286,8 @@ import { useTableSelection } from '@/hooks/table/useTableSelection';
|
||||
import modal from '@/plugins/modal';
|
||||
import { useDict } from '@/utils/dict';
|
||||
import { download as requestDownload } from '@/utils/request';
|
||||
import Pagecrumb from '@/components/Pagecrumb/index.vue';
|
||||
|
||||
|
||||
const { sys_normal_disable } = toRefs<any>(useDict('sys_normal_disable'));
|
||||
const { sys_grant_type } = toRefs<any>(useDict('sys_grant_type'));
|
||||
|
||||
146
src/views/system/conversation/compontents/ChatInput.vue
Normal file
146
src/views/system/conversation/compontents/ChatInput.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="chat-input-wrap">
|
||||
<div class="input-area">
|
||||
<!-- 多行输入框 -->
|
||||
<el-input
|
||||
ref="inputRef"
|
||||
v-model="inputText"
|
||||
:autosize="{ minRows:5, maxRows:8 }"
|
||||
placeholder="请输入回复内容"
|
||||
type="textarea"
|
||||
@keydown="handleKeyDown"
|
||||
/>
|
||||
<!-- 发送按钮 -->
|
||||
<el-button
|
||||
:disabled="!canSend || sending"
|
||||
class="btn-send"
|
||||
size="large"
|
||||
type="primary"
|
||||
@click="handleSend"
|
||||
>
|
||||
{{ sending ? '发送中' : '发送' }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div>
|
||||
<!-- 图片上传按钮 -->
|
||||
<el-button
|
||||
class="btn-img"
|
||||
plain
|
||||
type="info"
|
||||
@click="handleSelectImage"
|
||||
>
|
||||
上传图片
|
||||
</el-button>
|
||||
</div>
|
||||
<!-- 隐藏input用于选图 -->
|
||||
<input
|
||||
ref="fileRef"
|
||||
accept="image/*"
|
||||
style="display:none"
|
||||
type="file"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, computed} from 'vue'
|
||||
import type {PropType} from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
/** 是否正在发送中(外部控制,防止重复点击) */
|
||||
sending: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 发送文本消息 */
|
||||
(e: 'send', text: string): void
|
||||
/** 选中图片文件,交给父组件上传oss */
|
||||
(e: 'uploadImage', file: File): void
|
||||
}>()
|
||||
|
||||
const inputRef = ref<InstanceType<typeof elInput>>()
|
||||
const fileRef = ref<HTMLInputElement | null>(null)
|
||||
const inputText = ref('')
|
||||
|
||||
// 是否可以发送
|
||||
const canSend = computed(() => {
|
||||
return !!inputText.value.trim()
|
||||
})
|
||||
|
||||
// 点击图片图标,唤起文件选择
|
||||
const handleSelectImage = () => {
|
||||
fileRef.value?.click()
|
||||
}
|
||||
|
||||
// 文件选择回调
|
||||
const onFileChange = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file) return
|
||||
emit('uploadImage', file)
|
||||
// 清空file,选同一个图片也能触发change
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
// 键盘事件:回车发送,Shift+Enter换行
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.shiftKey) {
|
||||
// shift+enter 原生换行,不拦截
|
||||
return
|
||||
}
|
||||
// 直接回车发送
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
const handleSend = () => {
|
||||
const rawText = inputText.value.trim()
|
||||
if (!rawText) return
|
||||
// 把换行 \n 替换成 <br/>
|
||||
const htmlText = rawText.replaceAll('\n', '<br/>')
|
||||
emit('send', htmlText)
|
||||
// 发送成功后清空输入框
|
||||
inputText.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-input-wrap {
|
||||
flex-shrink: 0;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
|
||||
:deep(.el-textarea__inner) {
|
||||
background-color: #f7f9fc;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-img {
|
||||
flex-shrink: 0;
|
||||
margin-top: 12px;
|
||||
padding: 9px 13px;
|
||||
color: rgba(75, 85, 99, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-send {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.el-textarea__inner) {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
</style>
|
||||
91
src/views/system/conversation/compontents/ChatNavBar.vue
Normal file
91
src/views/system/conversation/compontents/ChatNavBar.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div :class="currentClass" class="chat-tip-bar">
|
||||
<!-- 提示条区域,放在MessageList内部最顶部,跟随滚动 -->
|
||||
<span>{{ currentText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed} from 'vue'
|
||||
import {ChatNavBarType} from './types'
|
||||
import type {PropType} from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: Number as PropType<ChatNavBarType>,
|
||||
required: true,
|
||||
},
|
||||
time: String,
|
||||
awaitNum: Number,
|
||||
agentInfo: {
|
||||
type: Object as PropType<{
|
||||
name: string,
|
||||
no: string,
|
||||
}>,
|
||||
}
|
||||
})
|
||||
|
||||
// 根据type自动切换文本
|
||||
const currentText = computed(() => {
|
||||
switch (props.type) {
|
||||
case ChatNavBarType.agent:
|
||||
return `${props.time} 用户点击「仍未解决,转人工」`
|
||||
case ChatNavBarType.await:
|
||||
return `排队提醒:当前排队第 ${props.awaitNum} 位,已等待 ${props.time}`
|
||||
case ChatNavBarType.accept:
|
||||
return `客服${props.agentInfo?.name}(工号 ${props.agentInfo?.no})已接入会话`
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
// 样式类
|
||||
const currentClass = computed(() => {
|
||||
switch (props.type) {
|
||||
case ChatNavBarType.agent:
|
||||
return 'agent'
|
||||
case ChatNavBarType.await:
|
||||
return 'await'
|
||||
case ChatNavBarType.accept:
|
||||
return 'accept'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-tip-bar {
|
||||
min-width: 250px;
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
margin: 7px auto;
|
||||
min-height: 24px;
|
||||
padding: 5px 12px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
text-wrap: wrap;
|
||||
}
|
||||
|
||||
.chat-tip-bar span {
|
||||
text-wrap: wrap;
|
||||
}
|
||||
|
||||
.agent {
|
||||
color: rgba(156, 163, 175, 1);
|
||||
background-color: rgba(233, 236, 241, 1);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.await {
|
||||
color: rgba(234, 88, 12, 1);
|
||||
background-color: rgba(255, 247, 237, 1);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.accept {
|
||||
background-color: rgba(232, 247, 238, 1);
|
||||
color: rgba(22, 163, 74, 1);
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="agent-message">
|
||||
<img :src="avatar" class="avatar"/>
|
||||
<div class="msg-body">
|
||||
<div class="msg-header">
|
||||
<span class="name">客服{{ name }}</span>
|
||||
<span v-if="tag" class="tag agent-tag">{{ tag }}</span>
|
||||
<span class="time">{{ time }}</span>
|
||||
</div>
|
||||
<!-- 文本消息 -->
|
||||
<div v-if="type === 'text'" class="bubble" v-html="content"></div>
|
||||
<!-- 图片消息 -->
|
||||
<div v-else-if="type === 'image'" class="bubble img-bubble">
|
||||
<img :src="content" class="chat-img" @click="$emit('preview', content)"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
name: {type: String, default: '客服'},
|
||||
avatar: {type: String, default: ''},
|
||||
content: {type: String, required: true},
|
||||
type: {type: String as () => 'text' | 'image', default: 'text'},
|
||||
tag: {type: String, default: '人工客服'},
|
||||
time: {type: String, default: ''}
|
||||
})
|
||||
defineEmits(['preview'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.agent-message {
|
||||
display: flex;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.msg-body {
|
||||
margin-left: 8px;
|
||||
max-width: 75%;
|
||||
}
|
||||
|
||||
.msg-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.agent-tag {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
font-weight: bold;
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
background: #fff;
|
||||
border-radius: 4px 16px 16px 16px;
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: rgba(31, 41, 55, 1);
|
||||
}
|
||||
|
||||
.img-bubble {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.chat-img {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div class="robot-message">
|
||||
<div class="avatar">智</div>
|
||||
<div class="msg-body">
|
||||
<!-- 头部:客服名 + 标签 + 时间 -->
|
||||
<div class="msg-header">
|
||||
<span class="name">智能客服 小智</span>
|
||||
<span class="tag robot-tag">机器人</span>
|
||||
<span class="time">{{ time }}</span>
|
||||
</div>
|
||||
<!-- 气泡 -->
|
||||
<div class="bubble" v-html="content"></div>
|
||||
<!-- 快捷按钮 -->
|
||||
<div v-if="quickActions?.length" class="quick-actions">
|
||||
<button
|
||||
v-for="btn in quickActions"
|
||||
:key="btn.action"
|
||||
:class="{ primary: btn.primary }"
|
||||
class="action-btn"
|
||||
@click="$emit('quickAction', btn.action)"
|
||||
>
|
||||
{{ btn.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
interface QuickAction {
|
||||
label: string
|
||||
action: string
|
||||
primary?: boolean
|
||||
}
|
||||
|
||||
defineProps({
|
||||
name: {type: String, default: '智能客服'},
|
||||
avatar: {type: String, default: ''},
|
||||
content: {type: String, required: true},
|
||||
time: {type: String, default: ''},
|
||||
quickActions: {type: Array as () => QuickAction[], default: () => []}
|
||||
})
|
||||
defineEmits(['quickAction'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.robot-message {
|
||||
display: flex;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
background-color: rgba(15, 118, 110, 1);
|
||||
}
|
||||
|
||||
.msg-body {
|
||||
margin-left: 8px;
|
||||
max-width: 75%;
|
||||
}
|
||||
|
||||
.msg-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 12px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.robot-tag {
|
||||
background: #f3f0ff;
|
||||
color: #8b50ef;
|
||||
font-weight: bold;
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
background: #fff;
|
||||
border-radius: 12px 12px 12px 2px;
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 4px 14px;
|
||||
border: 1px solid #2563eb;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="system-message">
|
||||
<span class="system-text">{{ text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
text: {type: String, required: true}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.system-message {
|
||||
text-align: center;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.system-text {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
background: transparent;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="user-message">
|
||||
<div class="msg-body">
|
||||
<!-- 文本消息 -->
|
||||
<div v-if="type === 'text'" class="bubble" v-html="content"></div>
|
||||
<!-- 图片消息 -->
|
||||
<div v-else-if="type === 'image'" class="bubble img-bubble">
|
||||
<img :src="content" class="chat-img"/>
|
||||
</div>
|
||||
<div class="time">{{ time }}</div>
|
||||
</div>
|
||||
<img :src="avatar" class="avatar"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
content: {type: String, required: true},
|
||||
type: {type: String as () => 'text' | 'image', default: 'text'},
|
||||
avatar: {type: String, default: ''},
|
||||
time: {type: String, default: ''}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-message {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.msg-body {
|
||||
margin-right: 8px;
|
||||
max-width: 363px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
background-color: rgba(37, 99, 235, 1);
|
||||
color: #fff;
|
||||
border-radius: 16px 4px 16px 16px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
|
||||
|
||||
line-height: 22px;
|
||||
color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.img-bubble {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.chat-img {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
260
src/views/system/conversation/compontents/MessageList/index.vue
Normal file
260
src/views/system/conversation/compontents/MessageList/index.vue
Normal file
@@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<div
|
||||
ref="listRef"
|
||||
class="message-list "
|
||||
@scroll.passive="handleScroll"
|
||||
>
|
||||
<!-- 加载历史消息提示 -->
|
||||
<div v-if="loadingHistory" class="load-more-tip">
|
||||
<span class="spinner"></span> 加载中...
|
||||
</div>
|
||||
|
||||
<!-- 消息列表:key 必须用唯一 id,禁止 index -->
|
||||
<template v-for="msg in messages" :key="msg.id">
|
||||
<!-- 2. 用户消息 -->
|
||||
<UserMessage
|
||||
v-if="msg.role === 'user'"
|
||||
:avatar="userAvatar"
|
||||
:content="msg.content"
|
||||
:msg-type="msg.msgType"
|
||||
:status="msg.status"
|
||||
:time="msg.time"
|
||||
@retry="handleRetry(msg)"
|
||||
/>
|
||||
|
||||
<!-- 3. 机器人消息 -->
|
||||
<RobotMessage
|
||||
v-else-if="msg.agentType === 'robot'"
|
||||
:avatar="msg.avatar"
|
||||
:content="msg.content"
|
||||
:msg-type="msg.msgType"
|
||||
:name="msg.agentName"
|
||||
:quick-actions="msg.quickActions"
|
||||
:time="msg.time"
|
||||
@quick-action="handleQuickAction"
|
||||
/>
|
||||
|
||||
<!-- 4. 人工客服消息 -->
|
||||
<AgentMessage
|
||||
v-else
|
||||
:avatar="msg.avatar"
|
||||
:content="msg.content"
|
||||
:msg-type="msg.msgType"
|
||||
:name="msg.agentName"
|
||||
:time="msg.time"
|
||||
@preview="handlePreview"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 底部占位 -->
|
||||
<div class="bottom-space"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, watch, nextTick, onMounted} from 'vue'
|
||||
import SystemMessage from './SystemMessage.vue'
|
||||
import UserMessage from './UserMessage.vue'
|
||||
import RobotMessage from './RobotMessage.vue'
|
||||
import AgentMessage from './AgentMessage.vue'
|
||||
|
||||
interface ChatMessage {
|
||||
id: string | number
|
||||
type: 'system' | 'chat'
|
||||
role?: 'user' | 'agent'
|
||||
agentType?: 'robot' | 'human'
|
||||
content: string
|
||||
msgType?: 'text' | 'image'
|
||||
agentName?: string
|
||||
avatar?: string
|
||||
time: string
|
||||
status?: 'sending' | 'sent' | 'failed'
|
||||
quickActions?: { label: string; action: string; primary?: boolean }[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
messages: ChatMessage[]
|
||||
userAvatar: string
|
||||
loadingHistory?: boolean
|
||||
/** 是否还有更多历史可加载(可选,建议父组件传) */
|
||||
hasMoreHistory?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'loadMore'): void
|
||||
(e: 'quickAction', action: string): void
|
||||
(e: 'retry', msg: ChatMessage): void
|
||||
(e: 'preview', url: string): void
|
||||
}>()
|
||||
|
||||
const listRef = ref<HTMLElement>()
|
||||
|
||||
/** 用户是否贴近底部(阈值 50px) */
|
||||
const isAtBottom = ref(true)
|
||||
|
||||
/** 首屏标记:首次渲染不走平滑滚动 */
|
||||
let isFirstRender = true
|
||||
|
||||
/** 加载历史前的 scrollHeight 快照 */
|
||||
let prevScrollHeight = 0
|
||||
|
||||
/** 加载历史防抖锁 */
|
||||
let isLoadingMore = false
|
||||
|
||||
/** 阈值常量 */
|
||||
const BOTTOM_THRESHOLD = 50
|
||||
const TOP_THRESHOLD = 30
|
||||
|
||||
/* ========== 判断是否贴近底部 ========== */
|
||||
const checkAtBottom = (): boolean => {
|
||||
const el = listRef.value
|
||||
if (!el) return true
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD
|
||||
}
|
||||
|
||||
/* ========== 滚动到底部 ========== */
|
||||
const scrollToBottom = (smooth = false) => {
|
||||
const el = listRef.value
|
||||
if (!el) return
|
||||
el.scrollTo({
|
||||
top: el.scrollHeight,
|
||||
behavior: smooth ? 'smooth' : 'auto'
|
||||
})
|
||||
}
|
||||
|
||||
/* ========== 滚动事件 ========== */
|
||||
const handleScroll = () => {
|
||||
const el = listRef.value
|
||||
if (!el) return
|
||||
|
||||
// 1. 更新底部状态
|
||||
isAtBottom.value = checkAtBottom()
|
||||
|
||||
// 2. 触顶加载历史
|
||||
if (
|
||||
el.scrollTop <= TOP_THRESHOLD &&
|
||||
!isLoadingMore &&
|
||||
!props.loadingHistory &&
|
||||
props.hasMoreHistory !== false
|
||||
) {
|
||||
isLoadingMore = true
|
||||
// 关键:在 emit 之前记录当前 scrollHeight 快照
|
||||
prevScrollHeight = el.scrollHeight
|
||||
emit('loadMore')
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 首屏 + 新消息滚动 ========== */
|
||||
watch(
|
||||
() => props.messages?.length ?? 0,
|
||||
async (newLen, oldLen) => {
|
||||
await nextTick()
|
||||
|
||||
if (isFirstRender) {
|
||||
// 场景 ①:首次进入页面,无动画直接滚到底
|
||||
scrollToBottom(false)
|
||||
isFirstRender = false
|
||||
isAtBottom.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// 场景 ②:新消息到达 && 用户在底部 → 平滑滚到底
|
||||
// 场景 ③:用户在翻历史 → 不滚动
|
||||
if (newLen > oldLen && isAtBottom.value) {
|
||||
scrollToBottom(true)
|
||||
}
|
||||
},
|
||||
{immediate: true}
|
||||
)
|
||||
|
||||
/* ========== 加载历史后,保持视觉位置 ========== */
|
||||
watch(
|
||||
() => props.loadingHistory,
|
||||
async (loading, wasLoading) => {
|
||||
// 只在 loading 从 true → false 的瞬间处理
|
||||
if (wasLoading && !loading) {
|
||||
await nextTick()
|
||||
const el = listRef.value
|
||||
if (!el) return
|
||||
|
||||
// 场景 ④:内容变高了,把 scrollTop 平移到等价位置
|
||||
const delta = el.scrollHeight - prevScrollHeight
|
||||
el.scrollTop = el.scrollTop + delta
|
||||
|
||||
// 重算底部状态(此时肯定不在底部)
|
||||
isAtBottom.value = checkAtBottom()
|
||||
|
||||
// 解锁,允许下一次加载
|
||||
isLoadingMore = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/* ========== 挂载后兜底:图片等异步内容渲染后可能改变 scrollHeight ========== */
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
scrollToBottom(false)
|
||||
isAtBottom.value = true
|
||||
isFirstRender = false
|
||||
})
|
||||
})
|
||||
|
||||
/* ========== 对外暴露手动滚底(如发送消息成功后) ========== */
|
||||
defineExpose({
|
||||
scrollToBottom: (smooth = true) => scrollToBottom(smooth)
|
||||
})
|
||||
|
||||
/* ========== 事件转发 ========== */
|
||||
const handleQuickAction = (action: string) => emit('quickAction', action)
|
||||
const handleRetry = (msg: ChatMessage) => emit('retry', msg)
|
||||
const handlePreview = (url: string) => emit('preview', url)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-list {
|
||||
flex: 1;
|
||||
min-height: 0; /* ⚠️ 关键:flex 子项内滚动必须加 */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
/* 隐藏滚动条(Chrome / Firefox / IE) */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.message-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.load-more-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid #ddd;
|
||||
border-top-color: #409eff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
height: 8px;
|
||||
}
|
||||
</style>
|
||||
287
src/views/system/conversation/compontents/data.ts
Normal file
287
src/views/system/conversation/compontents/data.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
// src/mock/chatData.ts
|
||||
|
||||
export interface QuickAction {
|
||||
label: string
|
||||
action: string
|
||||
primary?: boolean
|
||||
}
|
||||
|
||||
import {ChatMessage} from './types'
|
||||
|
||||
/** 用户头像(也可以从 store / 用户信息里取) */
|
||||
export const userAvatar = 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix'
|
||||
|
||||
/** 机器人头像 */
|
||||
export const robotAvatar = 'https://api.dicebear.com/7.x/bottts/svg?seed=Robot'
|
||||
|
||||
/** 人工客服头像 */
|
||||
export const agentAvatar = 'https://api.dicebear.com/7.x/avataaars/svg?seed=Aneka'
|
||||
|
||||
/**
|
||||
* 初始消息(首屏展示)
|
||||
* 涵盖:系统消息 / 机器人消息(含快捷按钮) / 用户消息(各种状态) / 人工客服消息(文本+图片)
|
||||
*/
|
||||
export const initMessages: ChatMessage[] = [
|
||||
// ========== 系统消息:时间分割 ==========
|
||||
{
|
||||
id: 'sys_001',
|
||||
type: 'system',
|
||||
content: '2026-01-15 14:00',
|
||||
time: '14:00',
|
||||
},
|
||||
|
||||
// ========== 系统消息:排队提示 ==========
|
||||
{
|
||||
id: 'sys_002',
|
||||
type: 'system',
|
||||
content: '您已进入排队,当前前面有 2 位用户,预计等待 1 分钟',
|
||||
time: '14:00',
|
||||
},
|
||||
|
||||
// ========== 机器人欢迎消息 ==========
|
||||
{
|
||||
id: 'robot_001',
|
||||
type: 'chat',
|
||||
role: 'agent',
|
||||
agentType: 'robot',
|
||||
agentName: '智能助手',
|
||||
avatar: robotAvatar,
|
||||
content: '您好!我是智能客服小助手 🤖\n请问有什么可以帮您的?\n\n您可以选择下面的常见问题,或直接输入您的问题。',
|
||||
msgType: 'text',
|
||||
time: '14:01',
|
||||
quickActions: [
|
||||
{label: '查询订单', action: 'query_order', primary: true},
|
||||
{label: '退换货政策', action: 'refund_policy'},
|
||||
{label: '发票问题', action: 'invoice'},
|
||||
{label: '联系人工', action: 'transfer_agent'},
|
||||
],
|
||||
},
|
||||
|
||||
// ========== 用户消息:正常文本 ==========
|
||||
{
|
||||
id: 'user_001',
|
||||
type: 'chat',
|
||||
role: 'user',
|
||||
content: '我想查一下我的订单状态',
|
||||
msgType: 'text',
|
||||
time: '14:02',
|
||||
status: 'sent',
|
||||
},
|
||||
|
||||
// ========== 机器人回复 + 快捷按钮 ==========
|
||||
{
|
||||
id: 'robot_002',
|
||||
type: 'chat',
|
||||
role: 'agent',
|
||||
agentType: 'robot',
|
||||
agentName: '智能助手',
|
||||
avatar: robotAvatar,
|
||||
content: '好的,请提供您的订单编号,我帮您查询。\n订单编号格式为:ORD-XXXXXXXX',
|
||||
msgType: 'text',
|
||||
time: '14:02',
|
||||
quickActions: [
|
||||
{label: '不知道订单号', action: 'no_order_id'},
|
||||
{label: '转人工', action: 'transfer_agent', primary: true},
|
||||
],
|
||||
},
|
||||
|
||||
// ========== 用户消息:图片 ==========
|
||||
{
|
||||
id: 'user_002',
|
||||
type: 'chat',
|
||||
role: 'user',
|
||||
content: 'https://picsum.photos/400/300?random=1',
|
||||
msgType: 'image',
|
||||
time: '14:03',
|
||||
status: 'sent',
|
||||
},
|
||||
|
||||
// ========== 系统消息:转接人工 ==========
|
||||
{
|
||||
id: 'sys_003',
|
||||
type: 'system',
|
||||
content: '正在为您转接人工客服,请稍候...',
|
||||
time: '14:03',
|
||||
},
|
||||
|
||||
// ========== 系统消息:人工接入 ==========
|
||||
{
|
||||
id: 'sys_004',
|
||||
type: 'system',
|
||||
content: '人工客服已接入,正在为您服务',
|
||||
time: '14:04',
|
||||
},
|
||||
|
||||
// ========== 人工客服:文本 ==========
|
||||
{
|
||||
id: 'agent_001',
|
||||
type: 'chat',
|
||||
role: 'agent',
|
||||
agentType: 'human',
|
||||
agentName: '客服小林',
|
||||
avatar: agentAvatar,
|
||||
content: '您好,我是人工客服小林,很高兴为您服务!\n请把订单号发给我,我马上帮您查。',
|
||||
msgType: 'text',
|
||||
time: '14:04',
|
||||
},
|
||||
|
||||
// ========== 人工客服:图片(订单截图示例) ==========
|
||||
{
|
||||
id: 'agent_002',
|
||||
type: 'chat',
|
||||
role: 'agent',
|
||||
agentType: 'human',
|
||||
agentName: '客服小林',
|
||||
avatar: agentAvatar,
|
||||
content: 'https://picsum.photos/400/300?random=2',
|
||||
msgType: 'image',
|
||||
time: '14:05',
|
||||
},
|
||||
|
||||
// ========== 用户消息:发送中 ==========
|
||||
{
|
||||
id: 'user_003',
|
||||
type: 'chat',
|
||||
role: 'user',
|
||||
content: '好的,我看到了,谢谢',
|
||||
msgType: 'text',
|
||||
time: '14:05',
|
||||
status: 'sending',
|
||||
},
|
||||
|
||||
// ========== 用户消息:发送成功 ==========
|
||||
{
|
||||
id: 'user_004',
|
||||
type: 'chat',
|
||||
role: 'user',
|
||||
content: '请问大概什么时候能到?',
|
||||
msgType: 'text',
|
||||
time: '14:06',
|
||||
status: 'sent',
|
||||
},
|
||||
|
||||
// ========== 用户消息:发送失败(红色感叹号,可重发) ==========
|
||||
{
|
||||
id: 'user_005',
|
||||
type: 'chat',
|
||||
role: 'user',
|
||||
content: '快递单号能再发一下吗?',
|
||||
msgType: 'text',
|
||||
time: '14:06',
|
||||
status: 'failed',
|
||||
},
|
||||
|
||||
// ========== 人工客服:富文本/长文本换行 ==========
|
||||
{
|
||||
id: 'agent_003',
|
||||
type: 'chat',
|
||||
role: 'agent',
|
||||
agentType: 'human',
|
||||
agentName: '客服小林',
|
||||
avatar: agentAvatar,
|
||||
content:
|
||||
'订单已于今天上午发货 📦\n物流单号:SF1234567890\n\n预计 1 月 16 日下午送达,请保持手机畅通。\n如未按时收到,可以随时联系我~',
|
||||
msgType: 'text',
|
||||
time: '14:07',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 加载历史消息(模拟分页返回,用于验证"保持视觉位置"逻辑)
|
||||
* 每次调用返回 5~8 条更早的消息,id 递增
|
||||
*/
|
||||
let historyBatch = 0
|
||||
|
||||
export function fetchMoreHistory(): ChatMessage[] {
|
||||
historyBatch += 1
|
||||
const base = 100 * historyBatch
|
||||
|
||||
return [
|
||||
// 时间分割
|
||||
{
|
||||
id: `history_${base}_00`,
|
||||
type: 'system',
|
||||
content: `2026-01-15 ${String(13 - historyBatch).padStart(2, '0')}:50`,
|
||||
time: `${String(13 - historyBatch).padStart(2, '0')}:50`,
|
||||
},
|
||||
|
||||
// 用户历史消息
|
||||
{
|
||||
id: `history_${base}_01`,
|
||||
type: 'chat',
|
||||
role: 'user',
|
||||
content: `这是第 ${historyBatch} 批历史消息中的用户提问 1`,
|
||||
msgType: 'text',
|
||||
time: `${String(13 - historyBatch).padStart(2, '0')}:50`,
|
||||
status: 'sent',
|
||||
},
|
||||
|
||||
// 机器人回复
|
||||
{
|
||||
id: `history_${base}_02`,
|
||||
type: 'chat',
|
||||
role: 'agent',
|
||||
agentType: 'robot',
|
||||
agentName: '智能助手',
|
||||
avatar: robotAvatar,
|
||||
content: `这是机器人对历史问题 ${historyBatch}-1 的回复。`,
|
||||
msgType: 'text',
|
||||
time: `${String(13 - historyBatch).padStart(2, '0')}:51`,
|
||||
},
|
||||
|
||||
// 用户历史图片
|
||||
{
|
||||
id: `history_${base}_03`,
|
||||
type: 'chat',
|
||||
role: 'user',
|
||||
content: `https://picsum.photos/400/300?random=${base + 10}`,
|
||||
msgType: 'image',
|
||||
time: `${String(13 - historyBatch).padStart(2, '0')}:51`,
|
||||
status: 'sent',
|
||||
},
|
||||
|
||||
// 人工客服历史回复
|
||||
{
|
||||
id: `history_${base}_04`,
|
||||
type: 'chat',
|
||||
role: 'agent',
|
||||
agentType: 'human',
|
||||
agentName: '客服小林',
|
||||
avatar: agentAvatar,
|
||||
content: `历史人工回复:已为您记录,请稍候。`,
|
||||
msgType: 'text',
|
||||
time: `${String(13 - historyBatch).padStart(2, '0')}:52`,
|
||||
},
|
||||
|
||||
// 系统消息
|
||||
{
|
||||
id: `history_${base}_05`,
|
||||
type: 'system',
|
||||
content: `—— 以上为第 ${historyBatch} 批历史消息 ——`,
|
||||
time: `${String(13 - historyBatch).padStart(2, '0')}:52`,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟机器人回复(发送消息后延迟触发)
|
||||
*/
|
||||
export function mockRobotReply(userText: string): ChatMessage {
|
||||
return {
|
||||
id: `robot_${Date.now()}`,
|
||||
type: 'chat',
|
||||
role: 'agent',
|
||||
agentType: 'robot',
|
||||
agentName: '智能助手',
|
||||
avatar: robotAvatar,
|
||||
content: `收到您的问题:"${userText}"\n正在为您查询,请稍候...`,
|
||||
msgType: 'text',
|
||||
time: formatNowTime(),
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化当前时间为 HH:mm */
|
||||
export function formatNowTime(): string {
|
||||
const d = new Date()
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
37
src/views/system/conversation/compontents/types.ts
Normal file
37
src/views/system/conversation/compontents/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// 消息类型
|
||||
type MessageType = 'text' | 'image' | 'system' | 'robot' | 'action'
|
||||
|
||||
|
||||
// chatNavBar
|
||||
// export type ChatNavBarType = {
|
||||
// /** 需要转人工 */
|
||||
// agent: 'agent',
|
||||
// /** 排队提醒 */
|
||||
// await: 'await',
|
||||
// /** 客服已接入会话*/
|
||||
// accept: 'accept',
|
||||
// }
|
||||
|
||||
export enum ChatNavBarType {
|
||||
/** 需要转人工 */
|
||||
agent,
|
||||
/** 排队提醒 */
|
||||
await,
|
||||
/** 客服已接入会话*/
|
||||
accept,
|
||||
}
|
||||
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string | number
|
||||
type: 'system' | 'chat'
|
||||
role?: 'user' | 'agent'
|
||||
agentType?: 'robot' | 'human'
|
||||
content: string
|
||||
msgType?: 'text' | 'image'
|
||||
agentName?: string
|
||||
avatar?: string
|
||||
time: string
|
||||
status?: 'sending' | 'sent' | 'failed'
|
||||
quickActions?: { label: string; action: string; primary?: boolean }[]
|
||||
}
|
||||
413
src/views/system/conversation/conversationMain.vue
Normal file
413
src/views/system/conversation/conversationMain.vue
Normal file
@@ -0,0 +1,413 @@
|
||||
<script lang="ts" setup>
|
||||
import pageCrumb from '@/components/Pagecrumb/index.vue'
|
||||
|
||||
|
||||
import ChatNavBar from "./compontents/ChatNavBar.vue";
|
||||
import {ChatNavBarType} from "./compontents/types";
|
||||
|
||||
import MessageList from './compontents/MessageList/index.vue'
|
||||
import {initMessages, userAvatar} from "./compontents/data";
|
||||
import ChatInput from "@/views/system/conversation/compontents/ChatInput.vue";
|
||||
|
||||
const defaultImage = import.meta.env.VITE_DEFAULT_USER_IMAGE;
|
||||
const userDevicesList = [
|
||||
{
|
||||
label: 'Z2 空气净化器 · 客厅',
|
||||
model: 'CZ-Z2023',
|
||||
mac: "AA:BB:CC:DD:A3:B6",
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
label: 'Z2 空气净化器 · 主卧',
|
||||
model: 'CZ-Z2023',
|
||||
mac: "AA:BB:CC:DD:A3:B1",
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
label: 'Z2 空气净化器 · 儿童房',
|
||||
model: 'CZ-Z2023',
|
||||
mac: "AA:BB:CC:DD:A3:B2",
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
label: 'Z2 空气净化器 · 次卧',
|
||||
model: 'CZ-Z2023',
|
||||
mac: "AA:BB:CC:DD:A3:B3",
|
||||
status: 0,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
const form = ref({
|
||||
status: ChatNavBarType.await as ChatNavBarType,
|
||||
time: new Date().toLocaleTimeString(),
|
||||
awaitNum: 3,
|
||||
agentInfo: {
|
||||
no: '8823',
|
||||
name: '小雨'
|
||||
},
|
||||
message: []
|
||||
})
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
form.value.message = [...initMessages]
|
||||
})
|
||||
|
||||
|
||||
const sending = ref(false)
|
||||
// 发送文本消息
|
||||
const onSendMsg = async (text: string) => {
|
||||
sending.value = true
|
||||
// 组装消息,推入messages列表
|
||||
const newMsg = {
|
||||
id: Date.now().toString(),
|
||||
type: 'chat',
|
||||
role: 'user',
|
||||
msgType: 'text',
|
||||
content: text,
|
||||
time: new Date().toLocaleTimeString(),
|
||||
status: 'sending'
|
||||
}
|
||||
console.log(text)
|
||||
form.value.message.push(newMsg)
|
||||
try {
|
||||
// 请求后端接口
|
||||
// await api.sendChat(...)
|
||||
newMsg.status = 'sent'
|
||||
} catch (err) {
|
||||
newMsg.status = 'failed'
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 图片上传,拿到File,上传oss拿到url,生成图片消息
|
||||
const onUploadImage = async (file: File) => {
|
||||
// 调用oss上传接口,拿到真实图片url
|
||||
// const url = await uploadOss(file)
|
||||
const url = 'https://xxx.oss-cn-beijing.aliyuncs.com/demo.jpg'
|
||||
const imgMsg = {
|
||||
id: Date.now().toString(),
|
||||
type: 'chat',
|
||||
role: 'user',
|
||||
msgType: 'image',
|
||||
content: url,
|
||||
time: new Date().toLocaleTimeString(),
|
||||
status: 'sending'
|
||||
}
|
||||
form.value.message.push(imgMsg)
|
||||
}
|
||||
|
||||
|
||||
// 1. 获取用户列表
|
||||
// 2. 点击用户,开始会话,ws
|
||||
// 3. 查看用户信息,
|
||||
// 4. 获取过往信息
|
||||
// 5. 自动发送开场白
|
||||
// 6. ws 发送消息
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="Main beautify_scroll">
|
||||
<pageCrumb>
|
||||
<template #rightSlot>
|
||||
<el-button plain type="info">返回会话列表</el-button>
|
||||
</template>
|
||||
</pageCrumb>
|
||||
<div class="commonCard">
|
||||
<card-nav
|
||||
:show-line="false"
|
||||
subTitle="开始时间 2026-09-10 12:44:52 · 等待时长 00:38 · 接入方式:机器人接待 → 转人工"
|
||||
title="CS20260918100038"
|
||||
>
|
||||
<template #rightTitle>
|
||||
<el-tag type="primary">人工接待</el-tag>
|
||||
</template>
|
||||
<template #rightSlot>
|
||||
<el-button plain type="default">导出聊天记录</el-button>
|
||||
<el-button plain type="danger">关闭会话</el-button>
|
||||
</template>
|
||||
</card-nav>
|
||||
</div>
|
||||
<el-row :gutter="20">
|
||||
<el-col :lg="16" :md="15" :span="24" :xl="18">
|
||||
<div class="commonCard userWindowBox">
|
||||
<div class="userWindowNav userWindowNavItem">
|
||||
<el-image :src="defaultImage" class="userAvatar"></el-image>
|
||||
<div class="userWindowNavItem-l">
|
||||
<div class="userWindowNavItem-l-info">
|
||||
<div>
|
||||
<span class="name">林筱曼</span>
|
||||
<span class="phone">138****6688</span>
|
||||
</div>
|
||||
<span class="time">会话时长 06:12</span>
|
||||
</div>
|
||||
<div class="userWindowNavItem-l-status">
|
||||
<i class="circle"></i>
|
||||
<span>用户在线</span>
|
||||
<span style="margin:0 0.5rem;">·</span>
|
||||
<span>正在输入</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content beautify_scroll">
|
||||
<ChatNavBar
|
||||
:agent-info="form.agentInfo"
|
||||
:await-num="form.awaitNum"
|
||||
:time="form.time"
|
||||
:type="form.status"
|
||||
></ChatNavBar>
|
||||
<MessageList :messages="form.message" :user-avatar="userAvatar"></MessageList>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<!-- 输入框组件 -->
|
||||
<ChatInput :sending="sending" @send="onSendMsg" @uploadImage="onUploadImage"/>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :lg="8" :md="9" :span="24" :xl="6">
|
||||
<div class="commonCard cardUserInfoBox">
|
||||
<card-nav title="用户信息">
|
||||
<template slot="rightTitle">
|
||||
<el-tag type="primary">用户详情</el-tag>
|
||||
</template>
|
||||
</card-nav>
|
||||
<ul class="cardUserInfo">
|
||||
<li class="cardUserInfo_item">
|
||||
<span class="label">用户ID</span>
|
||||
<span class="value">U20260918001</span>
|
||||
</li>
|
||||
<li class="cardUserInfo_item">
|
||||
<span class="label">昵称</span>
|
||||
<span class="value">林小满</span>
|
||||
</li>
|
||||
<li class="cardUserInfo_item">
|
||||
<span class="label">手机号</span>
|
||||
<span class="value">138****6688</span>
|
||||
</li>
|
||||
<li class="cardUserInfo_item">
|
||||
<span class="label">注册时间</span>
|
||||
<span class="value">2026-01-10 14:20</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="userDevicesBox">
|
||||
<card-nav :show-line="false">
|
||||
<template #title>
|
||||
<span class="titleName">名下设备(3台)</span>
|
||||
</template>
|
||||
<template #rightSlot>
|
||||
<span class="tip">点击可跳转设备详情</span>
|
||||
</template>
|
||||
</card-nav>
|
||||
<ul class="userDevicesList beautify_scroll">
|
||||
<li v-for="(item,index) in userDevicesList" :key="index" class="userDevicesItem">
|
||||
<div class="userDevicesItem_item">
|
||||
<div class="userDevicesName">
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
<el-tag v-if="item.status === 0">在线</el-tag>
|
||||
<el-tag v-else>离线</el-tag>
|
||||
</div>
|
||||
<div class="userDevices_describe">
|
||||
{{ item.model }} · {{ item.mac }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="commonCard">
|
||||
<card-nav line-color="#14b8a6" title="会话信息"></card-nav>
|
||||
<ul class="cardUserInfo" style="margin-bottom: 0">
|
||||
<li class="cardUserInfo_item">
|
||||
<span class="label">会话 ID</span>
|
||||
<span class="value">U20260918001</span>
|
||||
</li>
|
||||
<li class="cardUserInfo_item">
|
||||
<span class="label">会话开始时间</span>
|
||||
<span class="value">2026-09-10 12:44:52</span>
|
||||
</li>
|
||||
<li class="cardUserInfo_item">
|
||||
<span class="label">接入方式</span>
|
||||
<span class="value">机器人接待 → 转人工</span>
|
||||
</li>
|
||||
<li class="cardUserInfo_item">
|
||||
<span class="label">当前等待时长</span>
|
||||
<span class="value" style="color: #EA580C">00:38</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.Main {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-bottom: 40px;
|
||||
padding-top: 20px;
|
||||
|
||||
.cardUserInfoBox {
|
||||
.cardUserInfo {
|
||||
margin-bottom: 40px;
|
||||
min-height: 128px;
|
||||
margin-top: 27px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
.cardUserInfo_item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.label {
|
||||
height: 14px;
|
||||
line-height: 14px;
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.value {
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.userDevicesBox {
|
||||
.titleName {
|
||||
color: rgba(31, 41, 55, 1);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
line-height: 14px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.userDevicesList {
|
||||
padding-right: 10px;
|
||||
min-height: (75+8)*3px;
|
||||
max-height: (75+8)*3px;
|
||||
margin-top: 20px;
|
||||
overflow-y: auto;
|
||||
|
||||
.userDevicesItem {
|
||||
min-height: 64px;
|
||||
border-radius: 12px 12px 12px 12px;
|
||||
background-color: rgba(247, 249, 252, 1);
|
||||
border: 1px solid rgba(238, 240, 243, 1);
|
||||
padding: 15px 13px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.userDevicesItem_item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.userDevicesName {
|
||||
width: 230px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 16px;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.userDevices_describe {
|
||||
line-height: 14px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.userWindowBox {
|
||||
padding: 25px 0;
|
||||
background-color: #fff;
|
||||
|
||||
.userWindowNav.userWindowNavItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 21px;
|
||||
padding-bottom: 19px;
|
||||
|
||||
.userAvatar {
|
||||
width: 41px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
background-color: rgba(241, 245, 249, 1);
|
||||
object-fit: contain;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.userWindowNavItem-l {
|
||||
flex: 1;
|
||||
|
||||
.userWindowNavItem-l-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.name {
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.phone, .time {
|
||||
line-height: 14px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.userWindowNavItem-l-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 12px;
|
||||
|
||||
.circle {
|
||||
display: block;
|
||||
width: 7px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
margin-right: 6px;
|
||||
background-color: rgba(22, 163, 74, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 650px;
|
||||
background-color: rgba(247, 248, 250, 1);
|
||||
padding: 20px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 0 21px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
269
src/views/system/conversation/index.vue
Normal file
269
src/views/system/conversation/index.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div class="p-2 app-container monitor-online-page">
|
||||
<Pagecrumb style="margin-top:22px"></Pagecrumb>
|
||||
<div class="search-wrap">
|
||||
<el-card class="search-panel" shadow="hover">
|
||||
<el-form ref="queryFormRef" :inline="true" :model="queryParams" class="query-form" label-position="top">
|
||||
<el-form-item label="会话ID" prop="sessionNo">
|
||||
<el-input v-model="queryParams.sessionNo" clearable placeholder="请输入会话编号" size="large"
|
||||
style="width: 150px" @keyup.enter="handleQuery"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户手机号" prop="ipaddr">
|
||||
<el-input v-model="queryParams.ipaddr" clearable placeholder="请输入用户手机号" size="large"
|
||||
style="width: 150px" @keyup.enter="handleQuery"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="会话状态" prop="status">
|
||||
<el-select
|
||||
v-model="queryParams.status"
|
||||
:empty-values="[null, undefined]"
|
||||
:value-on-clear="null"
|
||||
clearable
|
||||
placeholder="请选择会话状态"
|
||||
size="large"
|
||||
style="width: 150px"
|
||||
>
|
||||
<el-option v-for="(item,index) in com_session_state " :key="index" :label="item.label"
|
||||
:value="item.value"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="问题类型" prop="problemType">
|
||||
<el-select
|
||||
v-model="queryParams.problemType"
|
||||
:empty-values="[null, undefined]"
|
||||
:value-on-clear="null"
|
||||
clearable
|
||||
placeholder="请选择问题类型"
|
||||
size="large"
|
||||
style="width: 150px"
|
||||
>
|
||||
<el-option label="在线" value="0"></el-option>
|
||||
<el-option label="离线" value="1"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联设备" prop="deviceId">
|
||||
<el-input v-model="queryParams.deviceId" clearable placeholder="请输入MAC/设备名称" size="large"
|
||||
style="width: 150px" @keyup.enter="handleQuery"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="会话创建时间" prop="startTime">
|
||||
<el-date-picker
|
||||
v-model="queryParams.startTime"
|
||||
end-placeholder="结束日期"
|
||||
range-separator="-"
|
||||
single-panel
|
||||
size="large"
|
||||
start-placeholder="开始日期"
|
||||
style="width: 220px"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="接待客服" prop="agentId">
|
||||
<el-select
|
||||
v-model="queryParams.agentId"
|
||||
:empty-values="[null, undefined]"
|
||||
:value-on-clear="null"
|
||||
clearable
|
||||
size="large"
|
||||
style="width: 150px"
|
||||
>
|
||||
<el-option label="在线" value="0"></el-option>
|
||||
<el-option label="离线" value="1"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button size="large" type="primary" @click="handleQuery">搜索</el-button>
|
||||
<el-button size="large" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
<el-card class="table-panel" shadow="hover">
|
||||
<template #header>
|
||||
<div class="flex justify-between">
|
||||
<div>
|
||||
<el-button type="primary">
|
||||
<span style="margin:0 8px;color: #fff;">全部</span>
|
||||
<span style="color: #fff;font-weight: bold">1286</span>
|
||||
</el-button>
|
||||
<tableTag :num="862" circle-color="#7c3aed" title="机器人接待"></tableTag>
|
||||
<tableTag :num="7" circle-color="#ea580c" title="排队等待"></tableTag>
|
||||
<tableTag :num="12" title="人工接待"></tableTag>
|
||||
<tableTag :num="405" circle-color="#94a3b8" title="已结束"></tableTag>
|
||||
</div>
|
||||
<div>
|
||||
<el-button plain type="info">批量导出</el-button>
|
||||
<el-button plain type="info">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="onlineList"
|
||||
border
|
||||
class="data-table"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column align="center" type="selection"></el-table-column>
|
||||
<el-table-column :show-overflow-tooltip="true" align="center" label="会话ID" prop="sessionNo"/>
|
||||
<el-table-column :show-overflow-tooltip="true" align="center" label="用户信息" prop="userNickName"/>
|
||||
<el-table-column :show-overflow-tooltip="true" align="center" label="关联设备" prop="deviceId"/>
|
||||
<el-table-column align="center" label="会话状态" width="100">
|
||||
<template #default="scope">
|
||||
<dict-tag :options="com_session_state" :value="scope.row.status"></dict-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :show-overflow-tooltip="true" align="center" label="当前接待人" prop="agentName" width="160">
|
||||
<template #default="scope">
|
||||
<div class="name">{{ scope.row.agentName }}</div>
|
||||
<div class="no"></div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :show-overflow-tooltip="true" align="center" label="用户问题摘要" prop="summary"/>
|
||||
<el-table-column :show-overflow-tooltip="true" align="center" label="排队序号" prop="queueNo" width="100"/>
|
||||
<el-table-column :show-overflow-tooltip="true" align="center" label="等待时长" prop="os" width="100"/>
|
||||
<el-table-column :show-overflow-tooltip="true" align="center" label="最后消息时间" prop="lastMessageTime"
|
||||
width="160"/>
|
||||
<el-table-column align="center" class-name="small-padding fixed-width" label="操作" min-width="200">
|
||||
<template #default="scope">
|
||||
<div>
|
||||
<el-button
|
||||
v-if="scope.row.staus !== '3'"
|
||||
v-hasPermi="['cs:session:query']"
|
||||
type="primary"
|
||||
@click="handleOpenConversation(scope.row)"
|
||||
>进入会话
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPermi="['monitor:online:forceLogout']"
|
||||
type="primary"
|
||||
v-else
|
||||
@click="handleMain(scope.row)"
|
||||
>查看会话
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPermi="['monitor:online:forceLogout']"
|
||||
plain
|
||||
type="info"
|
||||
@click="handleUserMain(scope.row)"
|
||||
>用户档案
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPermi="['monitor:online:forceLogout']"
|
||||
plain
|
||||
type="danger"
|
||||
v-if="scope.row.status === '2'"
|
||||
@click="handleCloseConversation(scope.row)"
|
||||
>关闭会话
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination
|
||||
v-show="total > 0"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
v-model:page="queryParams.pageNum"
|
||||
:total="total"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="Online" setup>
|
||||
import {to} from 'await-to-js';
|
||||
import {closeCsSession, listCsSession as initData} from '@/api/system/conversation/index';
|
||||
import {CsSessionQuery, CsSessionVO} from '@/api/system/conversation/types';
|
||||
import modal from '@/plugins/modal';
|
||||
import {useDict} from '@/utils/dict';
|
||||
import {parseTime} from '@/utils/ruoyi';
|
||||
|
||||
const {com_session_state} = toRefs<any>(useDict('com_session_state'));
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const onlineList = ref<CsSessionVO[]>([]);
|
||||
const loading = ref(true);
|
||||
const total = ref(0);
|
||||
|
||||
const queryFormRef = ref<ElFormInstance>();
|
||||
|
||||
const queryParams = ref<CsSessionQuery>({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
ipaddr: '',
|
||||
userName: ''
|
||||
});
|
||||
|
||||
/** 查询登录日志列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true;
|
||||
const res = await initData(queryParams.value);
|
||||
onlineList.value = res.data?.rows;
|
||||
total.value = res.data?.total;
|
||||
loading.value = false;
|
||||
};
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.value.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value?.resetFields();
|
||||
handleQuery();
|
||||
};
|
||||
|
||||
// 用户 档案
|
||||
function handleUserMain(row: Partial<CsSessionVO>) {
|
||||
router.push({
|
||||
path: '/customer/customerMain',
|
||||
query: {
|
||||
id: row.userId,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 进入会话
|
||||
function handleOpenConversation(row: Partial<CsSessionVO>) {
|
||||
router.push({
|
||||
path: '/conversation/conversationMain',
|
||||
query: {
|
||||
id: row.sessionId,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// close conversation
|
||||
function handleCloseConversation(row: Partial<CsSessionVO>) {
|
||||
closeCsSession(row.sessionId).then(() => {
|
||||
getList();
|
||||
modal.msgSuccess('关闭会话成功')
|
||||
})
|
||||
}
|
||||
|
||||
function handleMain(row: Partial<CsSessionVO>) {
|
||||
console.log(row);
|
||||
router.push({
|
||||
path: '/conversation/conversationMain'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '@/assets/styles/components/page-shell' as pageShell;
|
||||
|
||||
@include pageShell.table-crud-page;
|
||||
|
||||
|
||||
.circle {
|
||||
width: 7px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background-color: rgba(124, 58, 237, 1)
|
||||
}
|
||||
</style>
|
||||
408
src/views/system/conversation/main.vue
Normal file
408
src/views/system/conversation/main.vue
Normal file
@@ -0,0 +1,408 @@
|
||||
<script lang="ts" setup>
|
||||
import pageCrumb from '@/components/Pagecrumb/index.vue'
|
||||
|
||||
|
||||
import ChatNavBar from "./compontents/ChatNavBar.vue";
|
||||
import {ChatNavBarType} from "./compontents/types";
|
||||
|
||||
import MessageList from './compontents/MessageList/index.vue'
|
||||
import {initMessages, userAvatar} from "./compontents/data";
|
||||
import ChatInput from "@/views/system/conversation/compontents/ChatInput.vue";
|
||||
|
||||
const defaultImage = import.meta.env.VITE_DEFAULT_USER_IMAGE;
|
||||
const userDevicesList = [
|
||||
{
|
||||
label: 'Z2 空气净化器 · 客厅',
|
||||
model: 'CZ-Z2023',
|
||||
mac: "AA:BB:CC:DD:A3:B6",
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
label: 'Z2 空气净化器 · 主卧',
|
||||
model: 'CZ-Z2023',
|
||||
mac: "AA:BB:CC:DD:A3:B1",
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
label: 'Z2 空气净化器 · 儿童房',
|
||||
model: 'CZ-Z2023',
|
||||
mac: "AA:BB:CC:DD:A3:B2",
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
label: 'Z2 空气净化器 · 次卧',
|
||||
model: 'CZ-Z2023',
|
||||
mac: "AA:BB:CC:DD:A3:B3",
|
||||
status: 0,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
const form = ref({
|
||||
status: ChatNavBarType.await as ChatNavBarType,
|
||||
time: new Date().toLocaleTimeString(),
|
||||
awaitNum: 3,
|
||||
agentInfo: {
|
||||
no: '8823',
|
||||
name: '小雨'
|
||||
},
|
||||
message: []
|
||||
})
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
form.value.message = [...initMessages]
|
||||
})
|
||||
|
||||
|
||||
const sending = ref(false)
|
||||
// 发送文本消息
|
||||
const onSendMsg = async (text: string) => {
|
||||
sending.value = true
|
||||
// 组装消息,推入messages列表
|
||||
const newMsg = {
|
||||
id: Date.now().toString(),
|
||||
type: 'chat',
|
||||
role: 'user',
|
||||
msgType: 'text',
|
||||
content: text,
|
||||
time: new Date().toLocaleTimeString(),
|
||||
status: 'sending'
|
||||
}
|
||||
console.log(text)
|
||||
form.value.message.push(newMsg)
|
||||
try {
|
||||
// 请求后端接口
|
||||
// await api.sendChat(...)
|
||||
newMsg.status = 'sent'
|
||||
} catch (err) {
|
||||
newMsg.status = 'failed'
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 图片上传,拿到File,上传oss拿到url,生成图片消息
|
||||
const onUploadImage = async (file: File) => {
|
||||
// 调用oss上传接口,拿到真实图片url
|
||||
// const url = await uploadOss(file)
|
||||
const url = 'https://xxx.oss-cn-beijing.aliyuncs.com/demo.jpg'
|
||||
const imgMsg = {
|
||||
id: Date.now().toString(),
|
||||
type: 'chat',
|
||||
role: 'user',
|
||||
msgType: 'image',
|
||||
content: url,
|
||||
time: new Date().toLocaleTimeString(),
|
||||
status: 'sending'
|
||||
}
|
||||
form.value.message.push(imgMsg)
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="Main beautify_scroll">
|
||||
<pageCrumb>
|
||||
<template #rightSlot>
|
||||
<el-button plain type="info">返回会话列表</el-button>
|
||||
</template>
|
||||
</pageCrumb>
|
||||
<div class="commonCard">
|
||||
<card-nav
|
||||
:show-line="false"
|
||||
subTitle="开始时间 2026-09-10 12:44:52 · 等待时长 00:38 · 接入方式:机器人接待 → 转人工"
|
||||
title="CS20260918100038"
|
||||
>
|
||||
<template #rightTitle>
|
||||
<el-tag type="primary">人工接待</el-tag>
|
||||
</template>
|
||||
<template #rightSlot>
|
||||
<el-button plain type="default">导出聊天记录</el-button>
|
||||
<el-button plain type="danger">关闭会话</el-button>
|
||||
</template>
|
||||
</card-nav>
|
||||
</div>
|
||||
<el-row :gutter="20">
|
||||
<el-col :lg="16" :md="15" :span="24" :xl="18">
|
||||
<div class="commonCard userWindowBox">
|
||||
<div class="userWindowNav userWindowNavItem">
|
||||
<el-image :src="defaultImage" class="userAvatar"></el-image>
|
||||
<div class="userWindowNavItem-l">
|
||||
<div class="userWindowNavItem-l-info">
|
||||
<div>
|
||||
<span class="name">林筱曼</span>
|
||||
<span class="phone">138****6688</span>
|
||||
</div>
|
||||
<span class="time">会话时长 06:12</span>
|
||||
</div>
|
||||
<div class="userWindowNavItem-l-status">
|
||||
<i class="circle"></i>
|
||||
<span>用户在线</span>
|
||||
<span style="margin:0 0.5rem;">·</span>
|
||||
<span>正在输入</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content beautify_scroll">
|
||||
<ChatNavBar
|
||||
:agent-info="form.agentInfo"
|
||||
:await-num="form.awaitNum"
|
||||
:time="form.time"
|
||||
:type="form.status"
|
||||
></ChatNavBar>
|
||||
<MessageList :messages="form.message" :user-avatar="userAvatar"></MessageList>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<!-- 输入框组件 -->
|
||||
<ChatInput :sending="sending" @send="onSendMsg" @uploadImage="onUploadImage"/>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :lg="8" :md="9" :span="24" :xl="6">
|
||||
<div class="commonCard cardUserInfoBox">
|
||||
<card-nav title="用户信息">
|
||||
<template slot="rightTitle">
|
||||
<el-tag type="primary">用户详情</el-tag>
|
||||
</template>
|
||||
</card-nav>
|
||||
<ul class="cardUserInfo">
|
||||
<li class="cardUserInfo_item">
|
||||
<span class="label">用户ID</span>
|
||||
<span class="value">U20260918001</span>
|
||||
</li>
|
||||
<li class="cardUserInfo_item">
|
||||
<span class="label">昵称</span>
|
||||
<span class="value">林小满</span>
|
||||
</li>
|
||||
<li class="cardUserInfo_item">
|
||||
<span class="label">手机号</span>
|
||||
<span class="value">138****6688</span>
|
||||
</li>
|
||||
<li class="cardUserInfo_item">
|
||||
<span class="label">注册时间</span>
|
||||
<span class="value">2026-01-10 14:20</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="userDevicesBox">
|
||||
<card-nav :show-line="false">
|
||||
<template #title>
|
||||
<span class="titleName">名下设备(3台)</span>
|
||||
</template>
|
||||
<template #rightSlot>
|
||||
<span class="tip">点击可跳转设备详情</span>
|
||||
</template>
|
||||
</card-nav>
|
||||
<ul class="userDevicesList beautify_scroll">
|
||||
<li v-for="(item,index) in userDevicesList" :key="index" class="userDevicesItem">
|
||||
<div class="userDevicesItem_item">
|
||||
<div class="userDevicesName">
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
<el-tag v-if="item.status === 0">在线</el-tag>
|
||||
<el-tag v-else>离线</el-tag>
|
||||
</div>
|
||||
<div class="userDevices_describe">
|
||||
{{ item.model }} · {{ item.mac }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="commonCard">
|
||||
<card-nav line-color="#14b8a6" title="会话信息"></card-nav>
|
||||
<ul class="cardUserInfo" style="margin-bottom: 0">
|
||||
<li class="cardUserInfo_item">
|
||||
<span class="label">会话 ID</span>
|
||||
<span class="value">U20260918001</span>
|
||||
</li>
|
||||
<li class="cardUserInfo_item">
|
||||
<span class="label">会话开始时间</span>
|
||||
<span class="value">2026-09-10 12:44:52</span>
|
||||
</li>
|
||||
<li class="cardUserInfo_item">
|
||||
<span class="label">接入方式</span>
|
||||
<span class="value">机器人接待 → 转人工</span>
|
||||
</li>
|
||||
<li class="cardUserInfo_item">
|
||||
<span class="label">当前等待时长</span>
|
||||
<span class="value" style="color: #EA580C">00:38</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.Main {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-bottom: 40px;
|
||||
padding-top: 20px;
|
||||
|
||||
.cardUserInfoBox {
|
||||
.cardUserInfo {
|
||||
margin-bottom: 40px;
|
||||
min-height: 128px;
|
||||
margin-top: 27px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
.cardUserInfo_item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.label {
|
||||
height: 14px;
|
||||
line-height: 14px;
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.value {
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.userDevicesBox {
|
||||
.titleName {
|
||||
color: rgba(31, 41, 55, 1);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
line-height: 14px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.userDevicesList {
|
||||
padding-right: 10px;
|
||||
min-height: (75+8)*3px;
|
||||
max-height: (75+8)*3px;
|
||||
margin-top: 20px;
|
||||
overflow-y: auto;
|
||||
|
||||
.userDevicesItem {
|
||||
min-height: 64px;
|
||||
border-radius: 12px 12px 12px 12px;
|
||||
background-color: rgba(247, 249, 252, 1);
|
||||
border: 1px solid rgba(238, 240, 243, 1);
|
||||
padding: 15px 13px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.userDevicesItem_item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.userDevicesName {
|
||||
width: 230px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 16px;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.userDevices_describe {
|
||||
line-height: 14px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.userWindowBox {
|
||||
padding: 25px 0;
|
||||
background-color: #fff;
|
||||
|
||||
.userWindowNav.userWindowNavItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 21px;
|
||||
padding-bottom: 19px;
|
||||
|
||||
.userAvatar {
|
||||
width: 41px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
background-color: rgba(241, 245, 249, 1);
|
||||
object-fit: contain;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.userWindowNavItem-l {
|
||||
flex: 1;
|
||||
|
||||
.userWindowNavItem-l-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.name {
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.phone, .time {
|
||||
line-height: 14px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.userWindowNavItem-l-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 12px;
|
||||
|
||||
.circle {
|
||||
display: block;
|
||||
width: 7px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
margin-right: 6px;
|
||||
background-color: rgba(22, 163, 74, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 650px;
|
||||
background-color: rgba(247, 248, 250, 1);
|
||||
padding: 20px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 0 21px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
346
src/views/system/cskb/add.vue
Normal file
346
src/views/system/cskb/add.vue
Normal file
@@ -0,0 +1,346 @@
|
||||
<script lang="ts" setup>
|
||||
import FormItemTip from "@/components/FormItemTip/index.vue";
|
||||
import cskbEditor from '@/components/Editor/cskb.vue'
|
||||
import {FormInstance, FormRules} from "element-plus";
|
||||
import {addCsKb} from "@/api/system/cskb";
|
||||
import modal from "@/plugins/modal";
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const router = useRouter();
|
||||
const form = ref({
|
||||
"title": "",
|
||||
"category": "",
|
||||
"content": "",
|
||||
"sort": 1,
|
||||
"status": "1",
|
||||
"buttons": []
|
||||
})
|
||||
// ========= 表单校验 rules =========
|
||||
const rules: FormRules = {
|
||||
title: [
|
||||
{required: true, message: '请输入问题标题', trigger: 'blur'},
|
||||
{max: 30, message: '标题最多30个字符', trigger: 'blur'}
|
||||
],
|
||||
category: [
|
||||
{required: true, message: '请选择问题分类', trigger: 'change'}
|
||||
],
|
||||
content: [
|
||||
{required: true, message: '请填写回复内容', trigger: 'blur'}
|
||||
],
|
||||
sort: [
|
||||
{required: true, message: '请输入排序号', trigger: 'blur'},
|
||||
{type: 'number', min: 0, message: '排序号不能小于0', trigger: 'blur'}
|
||||
]
|
||||
}
|
||||
const actions = {
|
||||
resolved: {"buttonCode": "resolved", "label": "已解决", "sort": 1, value: 'resolved'},
|
||||
transfer_human: {"buttonCode": "transfer_human", "label": "转人工", "sort": 2, value: 'transfer_human'},
|
||||
}
|
||||
|
||||
|
||||
function handleBack() {
|
||||
router.replace({
|
||||
path: "/conversation/cskbList"
|
||||
})
|
||||
}
|
||||
|
||||
async function submit(isEnable = false) {
|
||||
if (!formRef.value) return
|
||||
console.log(form.value)
|
||||
await formRef.value.validate((valid) => {
|
||||
if (valid) {
|
||||
// isEnable=true 代表保存并启用
|
||||
form.value.buttons = form.value.buttons.map(item => {
|
||||
return actions[item]
|
||||
})
|
||||
addCsKb(form.value).then(() => {
|
||||
modal.msgSuccess('添加成功')
|
||||
if (isEnable) {
|
||||
handleBack()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function reset() {
|
||||
form.value = {
|
||||
"title": "",
|
||||
"category": "",
|
||||
"content": "",
|
||||
"sort": 1,
|
||||
"status": "1",
|
||||
"buttons": []
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="edit">
|
||||
<div class="flex justify-between" style="padding-top: 22px">
|
||||
<pagecrumb></pagecrumb>
|
||||
<el-button plain type="info" @click="handleBack"> 返回知识库管理</el-button>
|
||||
</div>
|
||||
<div class="tipBox">
|
||||
<div class="tip1">新增的知识条目保存后需要「启用」才会在 APP 端展示
|
||||
</div>
|
||||
<div class="tip2">回复内容支持有序步骤与图片,建议控制在 200 字以内,便于用户快速阅读。
|
||||
</div>
|
||||
</div>
|
||||
<!-- form -->
|
||||
<section class="commonCard">
|
||||
<div class="flex justify-between flex-items-center">
|
||||
<card-nav subTitle="配置问题标题、分类与标准回复话术" title="新增知识条目"></card-nav>
|
||||
<div class="unSave">
|
||||
<i class="square"></i>
|
||||
<span>未保存</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="formbox">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<div class="title">
|
||||
基础信息
|
||||
</div>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model.trim="form.title"
|
||||
:inputAttrs="{ placeholder: '请输入问题标题,如「连不上 Wi-Fi」', showWordLimit:true ,maxlength:30}"
|
||||
label="问题标题"
|
||||
prop="title"
|
||||
tip="建议使用用户口语化问法,便于模糊匹配"
|
||||
>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model.trim="form.category"
|
||||
:inputAttrs="{ placeholder: '请选择问题分类', maxlength:30}"
|
||||
label="问题分类"
|
||||
prop="category"
|
||||
>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="24">
|
||||
<FormItemTip
|
||||
label="回复内容"
|
||||
prop="content"
|
||||
type="default"
|
||||
>
|
||||
<template #default>
|
||||
<cskbEditor v-model="form.content" :simple-toolbar="true" style="width: 100%"></cskbEditor>
|
||||
</template>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="title">
|
||||
关联与排序
|
||||
</div>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="form.buttons"
|
||||
:radio-options="Object.values(actions)"
|
||||
label="关联快捷按钮"
|
||||
prop="buttons"
|
||||
tip="回复气泡下方展示的引导按钮,可多选"
|
||||
type="checkBox"
|
||||
>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model.number="form.sort"
|
||||
:inputAttrs="{ placeholder: '请输入排序'}"
|
||||
label="排序号"
|
||||
prop="sort"
|
||||
tip="决定该条目在 APP 常见问题列表中的展示顺序"
|
||||
type="inputNumber"
|
||||
>
|
||||
<template #suffix>越小越靠前</template>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="form.status"
|
||||
:inputAttrs="{
|
||||
activeValue: '1',
|
||||
inactiveValue: '0',
|
||||
}"
|
||||
label="启用状态"
|
||||
tip="关闭则保存为草稿,仅后台可见"
|
||||
type="switch"
|
||||
>
|
||||
<template #switchRight>
|
||||
<span
|
||||
style="color: rgba(17, 24, 39, 1);font-size: 13px;font-weight: bolder">启用后立即在APP端展示</span>
|
||||
</template>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</div>
|
||||
</section>
|
||||
<div class="footer">
|
||||
<div class="footer-tip">标题、分类与回复内容为必填项;排序号越小展示越靠前</div>
|
||||
<div>
|
||||
<el-button @click="reset">取消</el-button>
|
||||
<el-button plain type="primary" @click="submit(false)">保存并继续新增</el-button>
|
||||
<el-button type="primary" @click="submit(true)">保存并启用</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.edit {
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
min-height: 100%;
|
||||
padding-bottom: 50px;
|
||||
|
||||
.tipBox {
|
||||
margin-top: 16px;
|
||||
background-color: rgba(239, 246, 255, 1);
|
||||
border: 1px solid rgba(219, 234, 254, 1);
|
||||
padding: 13px 53px;
|
||||
|
||||
.tip1 {
|
||||
line-height: 18px;
|
||||
color: rgba(30, 64, 175, 1);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tip2 {
|
||||
line-height: 16px;
|
||||
color: rgba(59, 130, 246, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.customerInfoBox {
|
||||
margin-top: 16px;
|
||||
|
||||
.infoBox {
|
||||
.customer-Img {
|
||||
width: 73px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.userName {
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
font-weight: bold;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.circle {
|
||||
display: block;
|
||||
width: 7px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
margin-right: 6px;
|
||||
background-color: rgba(22, 163, 74, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.subInfoBox {
|
||||
line-height: 20px;
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
|
||||
.dat {
|
||||
margin: 0 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.subTitle {
|
||||
line-height: 14px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
|
||||
.unSave {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 79px;
|
||||
height: 28px;
|
||||
background-color: rgba(248, 250, 252, 1);
|
||||
border: 1px solid rgba(241, 245, 249, 1);
|
||||
|
||||
.square {
|
||||
display: block;
|
||||
width: 8px;
|
||||
height: 6px;
|
||||
margin-right: 8px;
|
||||
background-color: rgba(148, 163, 184, 1);
|
||||
}
|
||||
|
||||
span {
|
||||
height: 14px;
|
||||
line-height: 14px;
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.formbox {
|
||||
margin-top: 46px;
|
||||
|
||||
.title {
|
||||
line-height: 16px;
|
||||
color: rgba(55, 65, 81, 1);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
margin: 24px 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 27px;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.footer-tip {
|
||||
line-height: 14px;
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&:deep(.el-button) {
|
||||
padding: 14px 25px;
|
||||
min-height: 44px;
|
||||
border-radius: 0;
|
||||
border: 1px solid rgba(226, 232, 240, 1);
|
||||
|
||||
|
||||
&.el-button--primary.is-plain {
|
||||
color: #2563EB;
|
||||
}
|
||||
}
|
||||
|
||||
.el-button + .el-button {
|
||||
margin-left: 21px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
389
src/views/system/cskb/edit.vue
Normal file
389
src/views/system/cskb/edit.vue
Normal file
@@ -0,0 +1,389 @@
|
||||
<script lang="ts" setup>
|
||||
import FormItemTip from "@/components/FormItemTip/index.vue";
|
||||
import cskbEditor from '@/components/Editor/cskb.vue'
|
||||
import {getCsKb, updateCsKb} from "@/api/system/cskb";
|
||||
import {FormInstance, FormRules} from "element-plus";
|
||||
import modal from "@/plugins/modal";
|
||||
// 表单实例
|
||||
const formRef = ref<FormInstance>()
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const form = ref({
|
||||
"buttons": [],
|
||||
"category": "",
|
||||
"content": "",
|
||||
"createTime": "",
|
||||
"kbId": "",
|
||||
"kbNo": "",
|
||||
"sort": 1,
|
||||
"status": "1",
|
||||
"statusName": "",
|
||||
"title": ""
|
||||
})
|
||||
|
||||
const showForm = ref({
|
||||
...form.value,
|
||||
})
|
||||
// ========== 校验规则 rules ==========
|
||||
const rules: FormRules = {
|
||||
title: [
|
||||
{required: true, message: '请输入问题标题', trigger: 'blur'},
|
||||
{max: 30, message: '标题最多30个字符', trigger: 'blur'}
|
||||
],
|
||||
category: [
|
||||
{required: true, message: '请选择问题分类', trigger: 'change'}
|
||||
],
|
||||
content: [
|
||||
{required: true, message: '请填写回复内容', trigger: 'blur'}
|
||||
],
|
||||
sort: [
|
||||
{required: true, message: '请输入排序权重', trigger: 'blur'},
|
||||
{type: 'number', min: 0, message: '排序不能小于0', trigger: 'blur'}
|
||||
]
|
||||
// buttons、status非必填,不写规则
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (route.query.id) {
|
||||
getCsKb(route.query.id as string).then(res => {
|
||||
form.value = res.data;
|
||||
form.value.buttons = res.data.buttons.map(item => actions[item.buttonCode].value);
|
||||
|
||||
showForm.value = JSON.parse(JSON.stringify(form.value));
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const actions = {
|
||||
"resolved": {
|
||||
"buttonCode": "resolved",
|
||||
"buttonId": "2108377417902170114",
|
||||
"kbId": "1761700000000002001",
|
||||
"label": "已解决",
|
||||
'value': 'resolved',
|
||||
"sort": 1
|
||||
},
|
||||
transfer_human: {
|
||||
"buttonCode": "transfer_human",
|
||||
"buttonId": "2108377417910558722",
|
||||
"kbId": "1761700000000002001",
|
||||
"label": "转人工",
|
||||
'value': 'transfer_human',
|
||||
"sort": 2
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init()
|
||||
})
|
||||
|
||||
function handleBack() {
|
||||
router.replace({
|
||||
path: "/conversation/cskbList"
|
||||
})
|
||||
}
|
||||
|
||||
// 表单校验提交
|
||||
async function submit() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate((valid) => {
|
||||
if (valid) {
|
||||
// 这里调用新增/编辑接口
|
||||
form.value.buttons = form.value.buttons.map(item => {
|
||||
return actions[item]
|
||||
})
|
||||
console.log('校验通过,提交数据', form.value)
|
||||
updateCsKb(form.value).then(() => {
|
||||
modal.msgSuccess('编辑成功');
|
||||
handleBack()
|
||||
})
|
||||
} else {
|
||||
console.log('表单校验失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 取消按钮:重置表单,清空校验提示
|
||||
function handleCancel() {
|
||||
formRef.value?.clearValidate()
|
||||
handleBack()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="edit">
|
||||
<div class="flex justify-between" style="padding-top: 22px">
|
||||
<pagecrumb></pagecrumb>
|
||||
<el-button plain type="info" @click="handleBack"> 返回知识库</el-button>
|
||||
</div>
|
||||
<!-- form -->
|
||||
<section class="commonCard">
|
||||
<div class="flex justify-between flex-items-center">
|
||||
<card-nav :title="'知识条目 · '+ showForm.kbNo"
|
||||
subTitle="维护机器人自动回复内容,启用后将同步至 APP 智能客服常见问题入口"></card-nav>
|
||||
<div v-if="showForm.status === '1'" class="unSave">
|
||||
<i class="square"></i>
|
||||
<span>当前已启用</span>
|
||||
</div>
|
||||
<div v-else :class="{
|
||||
unActive: showForm.status === '0'
|
||||
}" class="unSave">
|
||||
<i class="square"></i>
|
||||
<span>未启用</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="formbox">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<div class="title">
|
||||
基础信息
|
||||
</div>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model.trim="form.title"
|
||||
:inputAttrs="{ placeholder: '请输入问题标题,如「连不上 Wi-Fi」', showWordLimit:true ,maxlength:30}"
|
||||
label="问题标题"
|
||||
prop="title"
|
||||
tip="必填,建议使用用户常见问法,如「Z2 空气净化器连不上 Wi-Fi」"
|
||||
>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="form.category"
|
||||
:inputAttrs="{ placeholder: '请选择问题分类'}"
|
||||
label="问题分类"
|
||||
prop="category"
|
||||
tip="必填,可选:设备配网 / 耗材滤芯 / 固件升级 / 设备报修"
|
||||
>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="24">
|
||||
<FormItemTip
|
||||
label="回复内容"
|
||||
prop="content"
|
||||
tip="支持图文混排与有序步骤,建议排查步骤不超过 5 步"
|
||||
type="default"
|
||||
>
|
||||
<template #default>
|
||||
<cskbEditor v-model="form.content" :simple-toolbar="true" style="width: 100%"></cskbEditor>
|
||||
</template>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="form.buttons"
|
||||
:radio-options="Object.values(actions)"
|
||||
label="底部快捷按钮"
|
||||
prop="buttons"
|
||||
tip="将展示在机器人回复气泡下方,用户可直接点击"
|
||||
type="checkBox"
|
||||
>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model.number="form.sort"
|
||||
:inputAttrs="{ placeholder: '请输入排序'}"
|
||||
label="排序权重"
|
||||
prop="sort"
|
||||
tip="控制该条目在 APP 常见问题列表中的展示顺序"
|
||||
type="inputNumber"
|
||||
>
|
||||
<template #suffix>越小越靠前</template>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
label="启用状态"
|
||||
v-model="form.status"
|
||||
:inputAttrs="{
|
||||
activeValue: '1',
|
||||
inactiveValue: '0',
|
||||
}"
|
||||
prop="status"
|
||||
tip="关闭后该条目不会出现在 APP 常见问题入口"
|
||||
type="switch"
|
||||
>
|
||||
<template #switchRight>
|
||||
<span
|
||||
style="color: rgba(17, 24, 39, 1);font-size: 13px;font-weight: bolder">启用后立即在APP端展示</span>
|
||||
</template>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</div>
|
||||
</section>
|
||||
<div class="footer">
|
||||
<div class="footer-tip">标题、分类与回复内容为必填项;排序号越小展示越靠前</div>
|
||||
<div>
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="submit">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.edit {
|
||||
overflow: auto;
|
||||
padding-bottom: 50px;
|
||||
|
||||
.tipBox {
|
||||
margin-top: 16px;
|
||||
background-color: rgba(239, 246, 255, 1);
|
||||
border: 1px solid rgba(219, 234, 254, 1);
|
||||
padding: 13px 53px;
|
||||
|
||||
.tip1 {
|
||||
line-height: 18px;
|
||||
color: rgba(30, 64, 175, 1);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tip2 {
|
||||
line-height: 16px;
|
||||
color: rgba(59, 130, 246, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.customerInfoBox {
|
||||
margin-top: 16px;
|
||||
|
||||
.infoBox {
|
||||
.customer-Img {
|
||||
width: 73px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.userName {
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
font-weight: bold;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.circle {
|
||||
display: block;
|
||||
width: 7px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
margin-right: 6px;
|
||||
background-color: rgba(22, 163, 74, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.subInfoBox {
|
||||
line-height: 20px;
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
|
||||
.dat {
|
||||
margin: 0 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.subTitle {
|
||||
line-height: 14px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
|
||||
.unSave {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 98px;
|
||||
padding: 0 10px;
|
||||
height: 28px;
|
||||
background-color: rgba(232, 247, 238, 1);
|
||||
color: rgba(22, 163, 74, 1);
|
||||
border: 1px solid rgba(241, 245, 249, 1);
|
||||
border-radius: 14px;
|
||||
|
||||
&.unActive {
|
||||
color: #4B5563;
|
||||
background-color: rgba(245, 247, 251, 1);
|
||||
|
||||
.square {
|
||||
background-color: #4B5563;
|
||||
}
|
||||
}
|
||||
|
||||
.square {
|
||||
display: block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-right: 8px;
|
||||
background-color: rgba(22, 163, 74, 1);
|
||||
}
|
||||
|
||||
span {
|
||||
height: 14px;
|
||||
line-height: 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.formbox {
|
||||
margin-top: 46px;
|
||||
|
||||
.title {
|
||||
line-height: 16px;
|
||||
color: rgba(55, 65, 81, 1);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
margin: 24px 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 27px;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.footer-tip {
|
||||
line-height: 14px;
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&:deep(.el-button) {
|
||||
padding: 14px 25px;
|
||||
min-height: 44px;
|
||||
border-radius: 0;
|
||||
border: 1px solid rgba(226, 232, 240, 1);
|
||||
|
||||
|
||||
&.el-button--primary.is-plain {
|
||||
color: #2563EB;
|
||||
}
|
||||
}
|
||||
|
||||
.el-button + .el-button {
|
||||
margin-left: 21px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
369
src/views/system/cskb/index.vue
Normal file
369
src/views/system/cskb/index.vue
Normal file
@@ -0,0 +1,369 @@
|
||||
<template>
|
||||
<div class="p-2 page-shell system-csKb-page">
|
||||
<div class="search-wrap" style="padding-top:22px">
|
||||
<pagecrumb></pagecrumb>
|
||||
<el-card :class="{ 'is-collapsed': !showSearch }" class="search-panel" shadow="hover" style="margin-top:12px;">
|
||||
<el-form ref="queryFormRef" :inline="true" :model="queryParams" class="query-form" label-position="top">
|
||||
<el-form-item label="问题标题" prop="title">
|
||||
<el-input v-model.trim="queryParams.title" clearable placeholder="请输入问题标题" size="large"
|
||||
@keyup.enter="handleQuery"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类" prop="category">
|
||||
<el-input v-model="queryParams.category" clearable placeholder="请输入分类" size="large"
|
||||
@keyup.enter="handleQuery"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="queryParams.status" clearable placeholder="请选择状态" size="large">
|
||||
<el-option label="启用" value="1"/>
|
||||
<el-option label="禁用" value="0"/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button size="large" type="primary" @click="handleQuery">搜索</el-button>
|
||||
<el-button icon="Refresh" size="large" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-card class="table-panel" shadow="hover">
|
||||
<template #header>
|
||||
<div class="toolbar-shell">
|
||||
<div>
|
||||
<el-button type="primary">
|
||||
<span style="margin:0 8px;color: #fff;">全部</span>
|
||||
<span style="color: #fff;font-weight: bold">{{ total }}</span>
|
||||
</el-button>
|
||||
<tableTag :num="overView.enabledCount " circle-color="#16A34A" title="启用"></tableTag>
|
||||
<tableTag :num="overView.stoppedCount" circle-color="#ef4444" num-color="#DC2626" title="禁用"></tableTag>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button :disabled="multiple" plain size="large" type="info" @click="handleDelete()">批量删除
|
||||
</el-button>
|
||||
<el-button size="large" type="primary" @click="handleAdd">新增问题
|
||||
</el-button>
|
||||
<right-toolbar v-model:show-search="showSearch" :search="false" @query-table="getList"></right-toolbar>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table v-loading="loading" :data="csKbList" border class="data-table"
|
||||
@selection-change="handleSelectionChange">
|
||||
<el-table-column align="center" type="selection" width="55"/>
|
||||
<el-table-column align="center" label="ID" prop="kbNo" width="80"/>
|
||||
<el-table-column align="center" label="问题标题" prop="title">
|
||||
<template #default="scope">
|
||||
<div class="title" style="text-align: left">{{ scope.row.title }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="分类" prop="category" width="100"/>
|
||||
<el-table-column align="center" label="回复内容" prop="content">
|
||||
<template #default="scope">
|
||||
<div class="content" @click="privew(scope.row)">
|
||||
<div v-html="getHtmlSummary(scope.row.content,60)"></div>
|
||||
<div v-if="scope.row.content.length > 60" class="more" style="text-align: left">...点击查看全部</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="关联快捷按钮" prop="sort" width="160">
|
||||
<template #default="scope">
|
||||
<el-tag v-for="(item,index) in scope.row.buttons" :key="index"
|
||||
:type="index % 2 === 0 ?'success' : 'primary'"
|
||||
style="margin-right: 4px">{{ item.label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="排序号" prop="sort" width="80"/>
|
||||
<el-table-column align="center" label="状态" prop="status" width="80">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.status === '1'" type="success">启用</el-tag>
|
||||
<el-tag v-else-if="scope.row.status === '0'" type="danger">禁用</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="创建时间" prop="status" width="180">
|
||||
<template #default="scope">
|
||||
<span>{{ scope.row.createTime }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" class-name="small-padding fixed-width" label="操作">
|
||||
<template #default="scope">
|
||||
<el-button v-hasPermi="['system:cskb:edit']" :disabled="scope.$index === 0" link type="info"
|
||||
@click="moveSort(scope.row,'up')">上移
|
||||
</el-button>
|
||||
<el-button v-hasPermi="['system:cskb:edit']" :disabled="scope.$index === csKbList.length-1" link
|
||||
type="info"
|
||||
@click="moveSort(scope.row,'down')">下移
|
||||
</el-button>
|
||||
<el-button v-hasPermi="['system:cskb:edit']" link type="primary"
|
||||
@click="handleUpdate(scope.row)">编辑
|
||||
</el-button>
|
||||
|
||||
<el-button v-if="scope.row.status === '0'" v-hasPermi="['system:cskb:edit']" link type="success"
|
||||
@click="handleEnableOrUnEnable(scope.row,'1')">启用
|
||||
</el-button>
|
||||
<el-button v-else v-hasPermi="['system:cskb:edit']" link type="info"
|
||||
@click="handleEnableOrUnEnable(scope.row,'0')">禁用
|
||||
</el-button>
|
||||
<el-button v-hasPermi="['system:cskb:remove']" link type="danger"
|
||||
@click="handleDelete(scope.row)">删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total > 0" v-model:limit="queryParams.pageSize" v-model:page="queryParams.pageNum"
|
||||
:total="total" @pagination="getList"/>
|
||||
</el-card>
|
||||
<!-- 添加或修改客服知识库对话框 -->
|
||||
<el-dialog v-model="privewForm.visible" :title="privewForm.title" append-to-body width="800px">
|
||||
<div style="min-height: 300px;max-height:800px;overflow-y: auto;overflow-x:hidden"
|
||||
v-html="privewForm.content"></div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="CsKb" setup>
|
||||
import {
|
||||
addCsKb,
|
||||
delCsKb,
|
||||
getCsKb,
|
||||
listCsKb, moveSkb, overViewCsKb,
|
||||
updateCsKb
|
||||
} from '@/api/system/cskb';
|
||||
import {CsKbForm, CsKbQuery, CsKbVO} from '@/api/system/cskb/types';
|
||||
import {useLoading} from '@/hooks/async/useLoading';
|
||||
import {useFormDialog} from '@/hooks/dialog/useFormDialog';
|
||||
import {getHtmlSummary} from '@/utils/richtext'
|
||||
import {useDateRangeQuery} from '@/hooks/form/useDateRangeQuery';
|
||||
import {useSearchReset} from '@/hooks/form/useSearchReset';
|
||||
import {useSearchToggle} from '@/hooks/form/useSearchToggle';
|
||||
import {useTableSelection} from '@/hooks/table/useTableSelection';
|
||||
import modal from '@/plugins/modal';
|
||||
import {download as requestDownload} from '@/utils/request';
|
||||
|
||||
|
||||
const csKbList = ref<CsKbVO[]>([]);
|
||||
const {loading, withLoading} = useLoading(true);
|
||||
const {showSearch} = useSearchToggle();
|
||||
const total = ref(0);
|
||||
const {
|
||||
dateRange: dateRangeCreateTime,
|
||||
applyDateRange: applyCreateTimeDateRange,
|
||||
resetDateRange: resetCreateTimeDateRange
|
||||
} = useDateRangeQuery('CreateTime');
|
||||
const {
|
||||
dateRange: dateRangeUpdateTime,
|
||||
applyDateRange: applyUpdateTimeDateRange,
|
||||
resetDateRange: resetUpdateTimeDateRange
|
||||
} = useDateRangeQuery('UpdateTime');
|
||||
|
||||
const queryFormRef = ref<ElFormInstance>();
|
||||
const csKbFormRef = ref<ElFormInstance>();
|
||||
|
||||
const initFormData: CsKbForm = {
|
||||
kbId: undefined,
|
||||
kbNo: undefined,
|
||||
title: undefined,
|
||||
category: undefined,
|
||||
content: undefined,
|
||||
sort: undefined,
|
||||
status: undefined,
|
||||
createDept: undefined,
|
||||
createBy: undefined,
|
||||
createTime: undefined,
|
||||
updateBy: undefined,
|
||||
updateTime: undefined,
|
||||
}
|
||||
const data = reactive<PageData<CsKbForm, CsKbQuery>>({
|
||||
form: {...initFormData},
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
kbNo: undefined,
|
||||
title: undefined,
|
||||
category: undefined,
|
||||
content: undefined,
|
||||
sort: undefined,
|
||||
status: undefined,
|
||||
createDept: undefined,
|
||||
createBy: undefined,
|
||||
updateBy: undefined,
|
||||
params: {
|
||||
createTime: undefined,
|
||||
updateTime: undefined,
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
kbId: [
|
||||
{required: true, message: "主键不能为空", trigger: "change"}
|
||||
],
|
||||
kbNo: [
|
||||
{required: true, message: "知识编号,如 KB-001不能为空", trigger: "blur"}
|
||||
],
|
||||
title: [
|
||||
{required: true, message: "问题标题不能为空", trigger: "blur"}
|
||||
],
|
||||
category: [
|
||||
{required: true, message: "分类不能为空", trigger: "blur"}
|
||||
],
|
||||
sort: [
|
||||
{required: true, message: "排序权重,数字越小越靠前不能为空", trigger: "change"}
|
||||
],
|
||||
status: [
|
||||
{required: true, message: "状态不能为空", trigger: "change"}
|
||||
],
|
||||
}
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
const {queryParams, form, rules} = toRefs(data);
|
||||
const {ids, single, multiple, handleSelectionChange} = useTableSelection<CsKbVO>(item => item.kbId);
|
||||
|
||||
|
||||
const privewForm = ref({
|
||||
visible: false,
|
||||
title: undefined,
|
||||
content: undefined,
|
||||
})
|
||||
|
||||
|
||||
function privew(row: Partial<CsKbVO>) {
|
||||
privewForm.value = {
|
||||
visible: true,
|
||||
title: row.title,
|
||||
content: row.content
|
||||
}
|
||||
console.log(privewForm)
|
||||
}
|
||||
|
||||
|
||||
/** 查询客服知识库列表 */
|
||||
const getList = async () => {
|
||||
getOverView()
|
||||
await withLoading(async () => {
|
||||
let params = queryParams.value;
|
||||
params = applyCreateTimeDateRange(params);
|
||||
params = applyUpdateTimeDateRange(params);
|
||||
const res = await listCsKb(params);
|
||||
csKbList.value = res.data?.rows;
|
||||
total.value = res.data?.total;
|
||||
});
|
||||
};
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.value.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const {resetQuery} = useSearchReset({
|
||||
queryFormRef,
|
||||
queryParams,
|
||||
pageNumKey: 'pageNum',
|
||||
pageSizeKey: 'pageSize',
|
||||
initialPageSize: 10,
|
||||
resetExtras: () => {
|
||||
resetCreateTimeDateRange();
|
||||
resetUpdateTimeDateRange();
|
||||
},
|
||||
afterReset: () => {
|
||||
handleQuery();
|
||||
}
|
||||
});
|
||||
|
||||
/** 新增按钮操作 */
|
||||
const handleAdd = () => {
|
||||
router.push({
|
||||
path: '/conversation/cskbAdd'
|
||||
})
|
||||
};
|
||||
|
||||
/** 修改按钮操作 */
|
||||
const handleUpdate = async (row?: Partial<CsKbVO>) => {
|
||||
router.push({
|
||||
path: '/conversation/cskbEdit',
|
||||
query: {
|
||||
id: row.kbId,
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
/** 禁用/启用知识库 */
|
||||
const handleEnableOrUnEnable = (row: Partial<CsKbVO>, status: '0' | '1' = '1') => {
|
||||
updateCsKb({
|
||||
...row,
|
||||
status,
|
||||
}).then(() => {
|
||||
modal.msgSuccess('操作成功');
|
||||
getList();
|
||||
})
|
||||
}
|
||||
|
||||
/** 上移/下移 */
|
||||
const moveSort = (row: Partial<CsKbVO>, arrow: 'down' | 'up' = 'up') => {
|
||||
moveSkb(row.kbId, arrow).then((res) => {
|
||||
if (res.data.moved) {
|
||||
getList();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const overView = ref({"enabledCount": 0, "stoppedCount": 0})
|
||||
|
||||
// 总览
|
||||
function getOverView() {
|
||||
overViewCsKb().then(res => {
|
||||
console.log(res)
|
||||
overView.value = res.data;
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (row?: Partial<CsKbVO>) => {
|
||||
const _kbIds = row?.kbNo || ids.value;
|
||||
await modal.confirm('是否确认删除客服知识库编号为"' + _kbIds + '"的数据项?');
|
||||
await delCsKb(_kbIds);
|
||||
modal.msgSuccess('删除成功');
|
||||
await getList();
|
||||
};
|
||||
|
||||
/** 导出按钮操作 */
|
||||
const handleExport = () => {
|
||||
requestDownload(
|
||||
'system/cskb/export',
|
||||
{
|
||||
...queryParams.value
|
||||
},
|
||||
`csKb_${new Date().getTime()}.xlsx`
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
ul > li {
|
||||
list-style: unset;
|
||||
}
|
||||
|
||||
.circle {
|
||||
width: 7px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background-color: rgba(124, 58, 237, 1)
|
||||
}
|
||||
|
||||
.content {
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
281
src/views/system/cssAgent/Add.vue
Normal file
281
src/views/system/cssAgent/Add.vue
Normal file
@@ -0,0 +1,281 @@
|
||||
<script lang="ts" setup>
|
||||
import FormItemTip from "@/components/FormItemTip/index.vue";
|
||||
import modal from "@/plugins/modal";
|
||||
import {addCsAgent} from "@/api/system/cssAgent";
|
||||
import {uploadOss} from "@/api/system/oss";
|
||||
import {validatePhone} from "@/utils/reg";
|
||||
import {useRouter} from "vue-router";
|
||||
import {ref} from "vue";
|
||||
import type {FormInstance, FormRules} from "element-plus";
|
||||
|
||||
const router = useRouter();
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const form = ref({
|
||||
"agentNo": "",
|
||||
"jobNo": "",
|
||||
"remark": '',
|
||||
"name": "",
|
||||
"account": "",
|
||||
"password": "12345678",
|
||||
"phone": "",
|
||||
"status": "0",
|
||||
"avatarUrl": "",
|
||||
avatarOssId: undefined
|
||||
})
|
||||
|
||||
// ========== 表单校验规则 ==========
|
||||
const rules: FormRules = {
|
||||
jobNo: [
|
||||
{required: true, message: '客服工号必填', trigger: 'blur'}
|
||||
],
|
||||
name: [
|
||||
{required: true, message: '客服名称必填', trigger: 'blur'}
|
||||
],
|
||||
account: [
|
||||
{required: true, message: '登录账号(手机号)必填', trigger: 'blur'}
|
||||
],
|
||||
password: [
|
||||
{required: true, message: '请输入登录密码', trigger: 'blur'},
|
||||
{min: 8, max: 20, message: '密码长度8~20位', trigger: 'blur'}
|
||||
],
|
||||
phone: [
|
||||
{required: true, message: '手机号必填', trigger: 'blur'},
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (!validatePhone(value)) {
|
||||
callback(new Error('手机号格式错误'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
status: [
|
||||
{required: true, message: '请选择账号状态', trigger: 'change'}
|
||||
],
|
||||
avatarUrl: []
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
router.replace({
|
||||
path: "/conversation/csAgent"
|
||||
})
|
||||
}
|
||||
|
||||
let files: File | null = null;
|
||||
|
||||
function handleChangeFile(file: File) {
|
||||
files = file
|
||||
}
|
||||
|
||||
async function handleSaveAvatar(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, 'avatar.png');
|
||||
return uploadOss(formData);
|
||||
}
|
||||
|
||||
// ========== 重写提交方法 ==========
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) {
|
||||
modal.msgWarning('请检查表单必填项');
|
||||
return
|
||||
}
|
||||
// 校验成功后执行业务逻辑
|
||||
form.value.agentNo = "KF-" + form.value.jobNo;
|
||||
form.value.phone = form.value.account;
|
||||
|
||||
if (files) {
|
||||
const res = await handleSaveAvatar(files);
|
||||
form.value.avatarUrl = res.data.url
|
||||
form.value.avatarOssId = res.data.ossId
|
||||
}
|
||||
addCsAgent(form.value).then(() => {
|
||||
router.replace({
|
||||
path: '/conversation/csAgent'
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="edit">
|
||||
<div class="flex justify-between" style="margin-top: 22px">
|
||||
<pagecrumb></pagecrumb>
|
||||
<el-button plain type="info" @click="handleBack"> 返回客服列表</el-button>
|
||||
</div>
|
||||
<!-- form -->
|
||||
<section class="commonCard">
|
||||
<div class="flex justify-between flex-items-center">
|
||||
<card-nav subTitle="填写客服基础信息、登录凭证与接待权限" title="新增客服账号"></card-nav>
|
||||
<el-tag style="color:rgba(107, 114, 128, 1)" type="info">
|
||||
<span> 带<span style="color: red;font-weight: bold;">* </span>为必填项</span>
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="formbox">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="form.jobNo"
|
||||
:inputAttrs="{ placeholder: '请输入工号,如 8823', disabled: false , showWordLimit:false ,maxlength:11}"
|
||||
label="客服工号"
|
||||
prop="jobNo"
|
||||
tip="建议使用 4 位数字,用于内部标识与排班识别"
|
||||
>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="form.name"
|
||||
:inputAttrs="{ placeholder: '请输入客服姓名', disabled: false , showWordLimit:true ,maxlength:20}"
|
||||
label="客服姓名"
|
||||
prop="name"
|
||||
tip="必填,将在会话列表与用户端展示"
|
||||
>
|
||||
<template #label>
|
||||
<span>客服姓名</span>
|
||||
</template>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="form.account"
|
||||
:inputAttrs="{ placeholder: '请输入登录账号', disabled: false, showWordLimit: false ,maxlength: 11}"
|
||||
label="登录账号(手机号)"
|
||||
prop="account"
|
||||
tip="该手机号同时作为账号找回与首次登录验证凭证"
|
||||
>
|
||||
<template #label>
|
||||
<span>登录账号(手机号)</span>
|
||||
</template>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="form.password"
|
||||
:inputAttrs="{ placeholder: '请输入登录密码', disabled: false, showWordLimit: false ,maxlength: 20, minlength:8}"
|
||||
label="初始密码"
|
||||
prop="password"
|
||||
type="password"
|
||||
>
|
||||
<template #label>
|
||||
<span>初始密码</span>
|
||||
</template>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="form.avatarUrl"
|
||||
label="客服头像"
|
||||
prop="avatarUrl"
|
||||
type="uploadUserAvatar"
|
||||
@file-change="handleChangeFile"
|
||||
>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="form.status"
|
||||
:inputAttrs="{ placeholder: '', disabled: false }"
|
||||
:radioOptions="[
|
||||
{label:'启用',value:'0'},
|
||||
{label:'禁用',value:'1'},
|
||||
]"
|
||||
label="账号状态"
|
||||
prop="status"
|
||||
tip="禁用后该客服无法登录工作台,其接待中会话将转回排队"
|
||||
type="radio"
|
||||
>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="24">
|
||||
<FormItemTip
|
||||
v-model="form.remark"
|
||||
:inputAttrs="{ placeholder: '可填写该客服的擅长领域、所属团队等信息,最多 200 字', disabled: false, maxlength:200, }"
|
||||
label="备注"
|
||||
prop="remark"
|
||||
type="textarea"
|
||||
>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</div>
|
||||
</section>
|
||||
<div class="footer">
|
||||
<el-button>取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.edit {
|
||||
.customerInfoBox {
|
||||
.infoBox {
|
||||
.customer-Img {
|
||||
width: 73px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.userName {
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
font-weight: bold;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.circle {
|
||||
display: block;
|
||||
width: 7px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
margin-right: 6px;
|
||||
background-color: rgba(22, 163, 74, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.subInfoBox {
|
||||
line-height: 20px;
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
|
||||
.dat {
|
||||
margin: 0 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.subTitle {
|
||||
line-height: 14px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.formbox {
|
||||
margin-top: 46px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 27px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
276
src/views/system/cssAgent/edit.vue
Normal file
276
src/views/system/cssAgent/edit.vue
Normal file
@@ -0,0 +1,276 @@
|
||||
<script lang="ts" setup>
|
||||
import FormItemTip from "@/components/FormItemTip/index.vue";
|
||||
import {getCsAgent, updateCsAgent} from "@/api/system/cssAgent";
|
||||
import modal from '@/plugins/modal';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const form = ref({
|
||||
"account": "",
|
||||
"agentId": "",
|
||||
"agentNo": "",
|
||||
"avatarUrl": "",
|
||||
"activeSessionCount": 0,
|
||||
"createTime": "",
|
||||
"jobNo": "",
|
||||
"name": "",
|
||||
"onlineStatus": "0",
|
||||
"onlineStatusName": "离线",
|
||||
"phone": null,
|
||||
"status": "0",
|
||||
"statusName": "启用",
|
||||
"password": ''
|
||||
})
|
||||
const editForm = ref({...form.value})
|
||||
|
||||
const defaultImag = computed(() => {
|
||||
if (form.value.avatarUrl) {
|
||||
return form.value.avatarUrl
|
||||
}
|
||||
return import.meta.env.VITE_DEFAULT_USER_IMAGE
|
||||
})
|
||||
|
||||
function init() {
|
||||
if (route.query.id) {
|
||||
getCsAgent(route.query.id as string).then(res => {
|
||||
form.value = {
|
||||
...form.value,
|
||||
...res.data,
|
||||
password: "",
|
||||
}
|
||||
editForm.value = JSON.parse(JSON.stringify(form.value))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
function handleBack() {
|
||||
router.replace({
|
||||
path: "/conversation/csAgent"
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function handleSubmit() {
|
||||
if (!editForm.value.account || editForm.value.account.trim() === '') {
|
||||
modal.msgWarning('登录账号不能为空')
|
||||
return;
|
||||
}
|
||||
if (!editForm.value.name || editForm.value.name.trim() === '') {
|
||||
modal.msgWarning('客服姓名不能为空')
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
editForm.value.phone = editForm.value.account;
|
||||
updateCsAgent(editForm.value).then(() => {
|
||||
handleBack()
|
||||
})
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editForm.value = JSON.parse(JSON.stringify(form.value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="edit">
|
||||
<div class="flex justify-between" style="margin-top: 22px">
|
||||
<pagecrumb></pagecrumb>
|
||||
<el-button plain type="info" @click="handleBack"> 返回客服列表</el-button>
|
||||
</div>
|
||||
<!-- 用户信息-->
|
||||
<section class="customerInfoBox commonCard flex justify-between flex-items-center">
|
||||
<div class="flex infoBox">
|
||||
<el-image :src="defaultImag" class="customer-Img"></el-image>
|
||||
<div class="flex flex-col justify-between">
|
||||
<div>
|
||||
<span class="userName">{{ form.name }}</span>
|
||||
<el-tag v-if="form.status === '0' " effect="light" round style="margin: 0 12px " type="success">
|
||||
<div class="flex flex-items-center">
|
||||
<span>启用</span></div>
|
||||
</el-tag>
|
||||
<el-tag v-else effect="light" round style="margin: 0 12px " type="danger">
|
||||
<div class="flex flex-items-center">
|
||||
<span>禁用</span></div>
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="subInfoBox">
|
||||
<span> {{ form.agentNo }} </span>
|
||||
<span class="dat">·</span>
|
||||
<span>工号 {{ form.account }} </span>
|
||||
<span class="dat">·</span>
|
||||
<span>138****6688</span>
|
||||
<span class="dat">·</span>
|
||||
<span>创建于 {{ form.createTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="{
|
||||
offline:form.onlineStatus === '0'
|
||||
}" class="onlineStatus">
|
||||
<span>当前{{ form.onlineStatusName }}</span>
|
||||
<span v-if="form.onlineStatus !== '0'">·接待中 {{ form.activeSessionCount }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- form -->
|
||||
<section class="commonCard">
|
||||
<card-nav subTitle="工号唯一且不可重复;账号状态切换后该客服将无法登录工作台" title="客服账号信息"></card-nav>
|
||||
<div class="formbox">
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="editForm.jobNo"
|
||||
:inputAttrs="{ placeholder: '请输入工号', disabled: true , showWordLimit:false ,maxlength:11}"
|
||||
:required="true"
|
||||
label="工号"
|
||||
prop="jobNo"
|
||||
tip="必填且全平台唯一,示例:8823"
|
||||
>
|
||||
<template #suffix>不可修改</template>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="editForm.name"
|
||||
:inputAttrs="{ placeholder: '请输入客服姓名', disabled: false , showWordLimit:true ,maxlength:20}"
|
||||
:required="true"
|
||||
prop="name"
|
||||
tip="必填,将在会话列表与用户端展示"
|
||||
>
|
||||
<template #label>
|
||||
<span>客服姓名</span>
|
||||
</template>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="editForm.account"
|
||||
:inputAttrs="{ placeholder: '请输入登录账号', disabled: false , showWordLimit:true ,maxlength:20}"
|
||||
:required="true"
|
||||
prop="account"
|
||||
tip="必填,手机号格式,全平台唯一,同时作为登录账号"
|
||||
>
|
||||
<template #label>
|
||||
<span>登录账号</span>
|
||||
</template>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="editForm.password"
|
||||
:inputAttrs="{ placeholder: '不修改请留空', disabled: false , showWordLimit:true ,maxlength:20, minlength:8}"
|
||||
:required="true"
|
||||
prop="password"
|
||||
tip="如需修改请重新输入,长度 8–20 位,含字母与数字"
|
||||
>
|
||||
<template #label>
|
||||
<span>登录密码</span>
|
||||
</template>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="editForm.status"
|
||||
:inputAttrs="{ placeholder: '', disabled: false }"
|
||||
:radioOptions="[
|
||||
{label:'启用',value:'0'},
|
||||
{label:'禁用',value:'1'},
|
||||
]"
|
||||
label="账号状态"
|
||||
prop="status"
|
||||
tip="禁用后该客服无法登录工作台,其接待中会话将转回排队"
|
||||
type="radio"
|
||||
>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</div>
|
||||
</section>
|
||||
<div class="footer">
|
||||
<el-button @click="resetForm">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.edit {
|
||||
.customerInfoBox {
|
||||
.infoBox {
|
||||
|
||||
.customer-Img {
|
||||
width: 73px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.userName {
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
font-weight: bold;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.circle {
|
||||
display: block;
|
||||
width: 7px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
margin-right: 6px;
|
||||
background-color: rgba(22, 163, 74, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.subInfoBox {
|
||||
line-height: 20px;
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
|
||||
.dat {
|
||||
margin: 0 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.onlineStatus {
|
||||
line-height: 18px;
|
||||
color: rgba(37, 99, 235, 1);
|
||||
font-size: 12px;
|
||||
|
||||
&.offline {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.subTitle {
|
||||
line-height: 14px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.formbox {
|
||||
margin-top: 46px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 27px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
527
src/views/system/cssAgent/index.vue
Normal file
527
src/views/system/cssAgent/index.vue
Normal file
@@ -0,0 +1,527 @@
|
||||
<template>
|
||||
<div class="p-2 page-shell system-csAgent-page">
|
||||
<div class="search-wrap" style="margin-top:22px;">
|
||||
<pagecrumb></pagecrumb>
|
||||
<el-card :class="{ 'is-collapsed': !showSearch }" class="search-panel" shadow="hover">
|
||||
<el-form ref="queryFormRef" :inline="true" :model="queryParams" class="query-form" label-position="top">
|
||||
|
||||
<el-form-item label="客服姓名" prop="name">
|
||||
<el-input v-model="queryParams.name" clearable placeholder="请输入客服姓名" size="large"
|
||||
style="width:240px;"
|
||||
@keyup.enter="handleQuery"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="客服工号" prop="jobNo">
|
||||
<el-input v-model="queryParams.jobNo" clearable placeholder="请输入客服工号" size="large"
|
||||
style="width:240px;"
|
||||
@keyup.enter="handleQuery"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="账号状态" prop="status">
|
||||
<el-select v-model="queryParams.status" clearable placeholder="请选择账号状态" size="large"
|
||||
style="width:180px;">
|
||||
<el-option label="启用" value="0"/>
|
||||
<el-option label="禁用" value="1"/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="在线状态" prop="onlineStatus">
|
||||
<el-select v-model="queryParams.onlineStatus" clearable placeholder="请选择在线状态" size="large"
|
||||
style="width:180px;">
|
||||
<el-option label="在线" value="1"/>
|
||||
<el-option label="离线" value="0"/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button size="large" type="primary" @click="handleQuery">查询</el-button>
|
||||
<el-button size="large" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-card class="table-panel" shadow="hover">
|
||||
<template #header>
|
||||
<div class="toolbar-shell">
|
||||
<div class="table-heading">
|
||||
<el-button type="primary">
|
||||
<span style="margin:0 8px;color: #fff;">全部</span>
|
||||
<span style="color: #fff;font-weight: bold">{{ statForm.total }}</span>
|
||||
</el-button>
|
||||
<tableTag :num="statForm.onlineCount" circle-color="#16a34a" title="在线"></tableTag>
|
||||
<tableTag :num="statForm.busyCount" circle-color="#ea580c" title="忙碌"></tableTag>
|
||||
<tableTag :num="statForm.offlineCount" circle-color="#94a3b8" title="离线"></tableTag>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button v-hasPermi="['system:csAgent:edit']" :disabled="multiple" plain type="info"
|
||||
@click="batchDisableSelection"
|
||||
>批量禁用账号
|
||||
</el-button>
|
||||
<el-button v-hasPermi="['system:csAgent:add']" type="primary" @click="handleAdd">新增客服
|
||||
</el-button>
|
||||
<right-toolbar v-model:show-search="showSearch" :search="false" @query-table="getList"></right-toolbar>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table v-loading="loading" :data="csAgentList" border class="data-table"
|
||||
@selection-change="handleSelectionChange">
|
||||
<el-table-column align="center" type="selection" width="55"/>
|
||||
<el-table-column align="center" label="客服ID" prop="agentNo"/>
|
||||
<el-table-column align="center" label="工号" prop="jobNo"/>
|
||||
<el-table-column align="center" label="客服姓名" prop="name">
|
||||
<template #default="scope">
|
||||
<div class="avatarBox">
|
||||
<el-image v-if="scope.row.avatarUrl" :preview-src-list="[scope.row.avatarUrl]" :src="scope.row.avatarUrl"
|
||||
class="avatar"
|
||||
preview-teleported></el-image>
|
||||
<span :class="{
|
||||
disable: scope.row.status === '1'
|
||||
}" class="name">{{ scope.row.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="登录账号" prop="account">
|
||||
<template #default="scope">
|
||||
<span>{{ hiddenPhone(scope.row.account) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="在线状态" prop="onlineStatus" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.onlineStatus === '1'" type="success">在线</el-tag>
|
||||
<el-tag v-else-if="scope.row.onlineStatus === '0'" type="info">离线</el-tag>
|
||||
<el-tag v-else type="warning">忙碌</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="接待中会话" prop="onlineStatus" width="120">
|
||||
<template #default="scope">
|
||||
<span :class="{
|
||||
disable: scope.row.status === '1'
|
||||
}" class="active">5 通</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="手机号" prop="phone" width="120">
|
||||
<template #default="scope">
|
||||
<span>{{ scope.row.phone || '---' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="账号状态" prop="status" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.status === '0'" type="success">启用</el-tag>
|
||||
<el-tag v-else type="danger">禁用</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="创建时间" prop="createTime" width="140"></el-table-column>
|
||||
<el-table-column align="center" class-name="small-padding fixed-width" label="操作" min-width="120">
|
||||
<template #default="scope">
|
||||
<el-button v-hasPermi="['system:csAgent:edit']" link type="primary"
|
||||
@click="handleUpdate(scope.row)">编辑
|
||||
</el-button>
|
||||
<el-button v-hasPermi="['system:csAgent:remove']" link type="info"
|
||||
@click="handleResetPwd(scope.row)">重置密码
|
||||
</el-button>
|
||||
<!-- 0启用 1禁用-->
|
||||
<el-button v-if="scope.row.status === '0'" v-hasPermi="['system:csAgent:remove']" link type="warning"
|
||||
@click="handleEnableOrUnEnable(scope.row,'1')">禁用
|
||||
</el-button>
|
||||
<el-button v-else v-hasPermi="['system:csAgent:remove']" link type="success"
|
||||
@click="handleEnableOrUnEnable(scope.row,'0')">启用
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total > 0" v-model:limit="queryParams.pageSize" v-model:page="queryParams.pageNum"
|
||||
:total="total" @pagination="getList"/>
|
||||
</el-card>
|
||||
<!-- 添加或修改客服人员对话框 -->
|
||||
<el-dialog v-model="dialog.visible" :title="dialog.title" append-to-body width="500px">
|
||||
<el-form ref="csAgentFormRef" :model="form" :rules="rules" label-width="80px">
|
||||
<el-form-item label="客服编号,如 KF-001" prop="agentNo">
|
||||
<el-input v-model="form.agentNo" placeholder="请输入客服编号,如 KF-001"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="工号" prop="jobNo">
|
||||
<el-input v-model="form.jobNo" placeholder="请输入工号"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="客服姓名" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入客服姓名"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="登录账号" prop="account">
|
||||
<el-input v-model="form.account" placeholder="请输入登录账号"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input v-model="form.password" placeholder="请输入密码"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input v-model="form.phone" placeholder="请输入手机号"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="头像 oss_id" prop="avatarOssId">
|
||||
<el-input-number v-model="form.avatarOssId" controls-position="right"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="在线状态" prop="onlineStatus">
|
||||
<el-switch
|
||||
v-model="form.onlineStatus"
|
||||
:active-value="true"
|
||||
:inactive-value="false"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="账号状态" prop="status">
|
||||
<el-switch
|
||||
v-model="form.status"
|
||||
:active-value="true"
|
||||
:inactive-value="false"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建部门" prop="createDept">
|
||||
<el-input-number v-model="form.createDept" controls-position="right"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建者" prop="createBy">
|
||||
<el-input-number v-model="form.createBy" controls-position="right"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间" prop="createTime">
|
||||
<el-date-picker v-model="form.createTime"
|
||||
clearable
|
||||
placeholder="请选择创建时间"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss">
|
||||
</el-date-picker>
|
||||
</el-form-item>
|
||||
<el-form-item label="更新者" prop="updateBy">
|
||||
<el-input-number v-model="form.updateBy" controls-position="right"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="更新时间" prop="updateTime">
|
||||
<el-date-picker v-model="form.updateTime"
|
||||
clearable
|
||||
placeholder="请选择更新时间"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss">
|
||||
</el-date-picker>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button :loading="buttonLoading" type="primary" @click="submitForm">确 定</el-button>
|
||||
<el-button @click="cancel">取 消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="CsAgent" setup>
|
||||
import tableTag from '@/components/tableTag/index.vue'
|
||||
import {
|
||||
addCsAgent, BatchDisableCsAgent,
|
||||
delCsAgent,
|
||||
getCsAgent,
|
||||
listCsAgent, ResetPwdCsAgent, statCsAgent,
|
||||
updateCsAgent
|
||||
} from '@/api/system/cssAgent';
|
||||
import {CsAgentForm, CsAgentQuery, CsAgentVO} from '@/api/system/cssAgent/types';
|
||||
import {useLoading} from '@/hooks/async/useLoading';
|
||||
import {useFormDialog} from '@/hooks/dialog/useFormDialog';
|
||||
import {useDateRangeQuery} from '@/hooks/form/useDateRangeQuery';
|
||||
import {useSearchReset} from '@/hooks/form/useSearchReset';
|
||||
import {useSearchToggle} from '@/hooks/form/useSearchToggle';
|
||||
import {useTableSelection} from '@/hooks/table/useTableSelection';
|
||||
import modal from '@/plugins/modal';
|
||||
import {download as requestDownload} from '@/utils/request';
|
||||
import {hiddenPhone} from "@/utils/phone";
|
||||
|
||||
|
||||
const csAgentList = ref<CsAgentVO[]>([]);
|
||||
const buttonLoading = ref(false);
|
||||
const {loading, withLoading} = useLoading(true);
|
||||
const {showSearch} = useSearchToggle();
|
||||
const total = ref(0);
|
||||
const {
|
||||
dateRange: dateRangeCreateTime,
|
||||
applyDateRange: applyCreateTimeDateRange,
|
||||
resetDateRange: resetCreateTimeDateRange
|
||||
} = useDateRangeQuery('CreateTime');
|
||||
const {
|
||||
dateRange: dateRangeUpdateTime,
|
||||
applyDateRange: applyUpdateTimeDateRange,
|
||||
resetDateRange: resetUpdateTimeDateRange
|
||||
} = useDateRangeQuery('UpdateTime');
|
||||
|
||||
const queryFormRef = ref<ElFormInstance>();
|
||||
const csAgentFormRef = ref<ElFormInstance>();
|
||||
|
||||
const initFormData: CsAgentForm = {
|
||||
agentId: undefined,
|
||||
agentNo: undefined,
|
||||
jobNo: undefined,
|
||||
name: undefined,
|
||||
account: undefined,
|
||||
password: undefined,
|
||||
phone: undefined,
|
||||
avatarOssId: undefined,
|
||||
onlineStatus: undefined,
|
||||
status: undefined,
|
||||
createDept: undefined,
|
||||
createBy: undefined,
|
||||
createTime: undefined,
|
||||
updateBy: undefined,
|
||||
updateTime: undefined,
|
||||
}
|
||||
const data = reactive<PageData<CsAgentForm, CsAgentQuery>>({
|
||||
form: {...initFormData},
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
agentNo: undefined,
|
||||
jobNo: undefined,
|
||||
name: undefined,
|
||||
account: undefined,
|
||||
password: undefined,
|
||||
phone: undefined,
|
||||
avatarOssId: undefined,
|
||||
onlineStatus: undefined,
|
||||
status: undefined,
|
||||
createDept: undefined,
|
||||
createBy: undefined,
|
||||
updateBy: undefined,
|
||||
params: {
|
||||
createTime: undefined,
|
||||
updateTime: undefined,
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
agentId: [
|
||||
{required: true, message: "主键不能为空", trigger: "change"}
|
||||
],
|
||||
agentNo: [
|
||||
{required: true, message: "客服编号,如 KF-001不能为空", trigger: "blur"}
|
||||
],
|
||||
jobNo: [
|
||||
{required: true, message: "工号不能为空", trigger: "blur"}
|
||||
],
|
||||
name: [
|
||||
{required: true, message: "客服姓名不能为空", trigger: "blur"}
|
||||
],
|
||||
account: [
|
||||
{required: true, message: "登录账号不能为空", trigger: "blur"}
|
||||
],
|
||||
password: [
|
||||
{required: true, message: "密码不能为空", trigger: "blur"}
|
||||
],
|
||||
onlineStatus: [
|
||||
{required: true, message: "在线状态不能为空", trigger: "change"}
|
||||
],
|
||||
status: [
|
||||
{required: true, message: "账号状态不能为空", trigger: "change"}
|
||||
],
|
||||
}
|
||||
});
|
||||
|
||||
const {queryParams, form, rules} = toRefs(data);
|
||||
const {ids, single, multiple, handleSelectionChange} = useTableSelection<CsAgentVO>(item => item.agentId);
|
||||
const {dialog, resetForm: reset, openDialog, showDialog, closeDialog} = useFormDialog({
|
||||
form,
|
||||
formRef: csAgentFormRef,
|
||||
initialFormData: initFormData
|
||||
});
|
||||
|
||||
/** 查询客服人员列表 */
|
||||
const getList = async () => {
|
||||
getStat()
|
||||
await withLoading(async () => {
|
||||
let params = queryParams.value;
|
||||
params = applyCreateTimeDateRange(params);
|
||||
params = applyUpdateTimeDateRange(params);
|
||||
const res = await listCsAgent(params);
|
||||
csAgentList.value = res.data?.rows;
|
||||
total.value = res.data?.total;
|
||||
});
|
||||
};
|
||||
|
||||
/** 取消按钮 */
|
||||
const cancel = () => {
|
||||
reset();
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.value.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const {resetQuery} = useSearchReset({
|
||||
queryFormRef,
|
||||
queryParams,
|
||||
pageNumKey: 'pageNum',
|
||||
pageSizeKey: 'pageSize',
|
||||
initialPageSize: 10,
|
||||
resetExtras: () => {
|
||||
resetCreateTimeDateRange();
|
||||
resetUpdateTimeDateRange();
|
||||
},
|
||||
afterReset: () => {
|
||||
handleQuery();
|
||||
}
|
||||
});
|
||||
const router = useRouter();
|
||||
/** 新增按钮操作 */
|
||||
const handleAdd = () => {
|
||||
// openDialog('添加客服人员');
|
||||
router.push({
|
||||
path: "/conversation/csAgentAdd",
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
/** 修改按钮操作 */
|
||||
const handleUpdate = async (row?: Partial<CsAgentVO>) => {
|
||||
router.push({
|
||||
path: '/conversation/csAgentEdit',
|
||||
query: {
|
||||
id: row.agentId
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
/** 重置密码 */
|
||||
const handleResetPwd = (row: Partial<CsAgentVO>) => {
|
||||
ElMessageBox.prompt("重置密码后,该客服人员登录失效,需重新登录。<br/> <span style='font-size:small;color:gray;'>不填默认12345678</span>", '重置密码', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
dangerouslyUseHTMLString: true,
|
||||
}).then(({value}) => {
|
||||
if (value.length > 20) {
|
||||
modal.msgWarning('重置密码过长!');
|
||||
return;
|
||||
}
|
||||
const pwd = value || '12345678';
|
||||
ResetPwdCsAgent(row.agentId, pwd).then(() => {
|
||||
modal.msgSuccess('重置成功');
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/** 启用/禁用 */
|
||||
const handleEnableOrUnEnable = (row: Partial<CsAgentVO>, status: '1' | '0' = '1') => {
|
||||
if (status === '1') {
|
||||
modal.confirm('禁用后,该客服将退出登录,未结束的会话重新排队,等待其他客服接入').then(() => {
|
||||
updateCsAgent({
|
||||
...row,
|
||||
status,
|
||||
}).then(() => {
|
||||
getList();
|
||||
})
|
||||
})
|
||||
} else {
|
||||
updateCsAgent({
|
||||
...row,
|
||||
status,
|
||||
}).then(() => {
|
||||
getList();
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** 批量禁用 */
|
||||
const batchDisableSelection = () => {
|
||||
if (ids.value.length <= 0) {
|
||||
modal.msgWarning('请选择禁用客服')
|
||||
return;
|
||||
}
|
||||
BatchDisableCsAgent(ids.value as string[]).then(() => {
|
||||
modal.msgSuccess('禁用成功');
|
||||
getList();
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/** 提交按钮 */
|
||||
const submitForm = () => {
|
||||
csAgentFormRef.value?.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
buttonLoading.value = true;
|
||||
if (form.value.agentId) {
|
||||
await updateCsAgent(form.value).finally(() => (buttonLoading.value = false));
|
||||
} else {
|
||||
await addCsAgent(form.value).finally(() => (buttonLoading.value = false));
|
||||
}
|
||||
modal.msgSuccess('操作成功');
|
||||
closeDialog();
|
||||
await getList();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (row?: Partial<CsAgentVO>) => {
|
||||
const _agentIds = row?.agentId || ids.value;
|
||||
await modal.confirm('是否确认删除客服人员编号为"' + _agentIds + '"的数据项?');
|
||||
await delCsAgent(_agentIds);
|
||||
modal.msgSuccess('删除成功');
|
||||
await getList();
|
||||
};
|
||||
|
||||
/** 导出按钮操作 */
|
||||
const handleExport = () => {
|
||||
requestDownload(
|
||||
'system/csAgent/export',
|
||||
{
|
||||
...queryParams.value
|
||||
},
|
||||
`csAgent_${new Date().getTime()}.xlsx`
|
||||
);
|
||||
};
|
||||
|
||||
const statForm = ref({
|
||||
"busyCount": 0,
|
||||
"offlineCount": 0,
|
||||
"onlineCount": 0,
|
||||
"total": 0
|
||||
})
|
||||
|
||||
function getStat() {
|
||||
statCsAgent().then(res => {
|
||||
statForm.value = res.data;
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.avatarBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.avatar {
|
||||
width: 29px;
|
||||
height: 28px;
|
||||
border-radius: 14px;
|
||||
object-fit: cover;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.name {
|
||||
line-height: 16px;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 13px;
|
||||
|
||||
&.disable {
|
||||
color: rgba(156, 163, 175, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.active {
|
||||
color: rgba(37, 99, 235, 1);
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
|
||||
&.disable {
|
||||
color: rgba(156, 163, 175, 1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
335
src/views/system/customer/edit.vue
Normal file
335
src/views/system/customer/edit.vue
Normal file
@@ -0,0 +1,335 @@
|
||||
<script lang="ts" setup>
|
||||
import FormItemTip from "@/components/FormItemTip/index.vue";
|
||||
import pageCrumb from "@/components/Pagecrumb/index.vue";
|
||||
import {getUser, updateUser} from "@/api/system/customer";
|
||||
import {hiddenPhone} from "@/utils/phone";
|
||||
import modal from "@/plugins/modal";
|
||||
import {uploadOss} from "@/api/system/oss";
|
||||
import {FormInstance, FormRules} from "element-plus";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const form = ref({
|
||||
"birthday": null,
|
||||
"city": null,
|
||||
"createTime": "",
|
||||
"district": null,
|
||||
"gender": "0",
|
||||
"genderName": "未知",
|
||||
"loginCount": 0,
|
||||
"loginDate": "",
|
||||
"loginIp": "",
|
||||
"nickName": "",
|
||||
"phonenumber": "",
|
||||
"province": null,
|
||||
"remark": "",
|
||||
"status": "0",
|
||||
"statusName": "正常",
|
||||
"updateTime": "",
|
||||
"userId": "",
|
||||
"userName": "",
|
||||
"userNo": "",
|
||||
"avatarOssId": "",
|
||||
"avatarUrl": "",
|
||||
})
|
||||
const editForm = ref({
|
||||
...form.value,
|
||||
})
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const rules: FormRules = {
|
||||
nickName: [
|
||||
{required: true, message: '用户昵称不能为空', trigger: 'blur'},
|
||||
{max: 20, message: '最多20个字符', trigger: 'blur'}
|
||||
],
|
||||
// gender、birthday、remark 选填,不用写required
|
||||
gender: [],
|
||||
birthday: [],
|
||||
status: [
|
||||
{required: true, message: '请选择账号状态', trigger: 'change'}
|
||||
],
|
||||
remark: [
|
||||
{max: 20, message: '最多20个字符', trigger: 'blur'}
|
||||
]
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
router.replace({
|
||||
path: "/customer/customerList"
|
||||
})
|
||||
}
|
||||
|
||||
let files = []
|
||||
|
||||
async function handleChangeFile(file: File) {
|
||||
files = []
|
||||
files.push(file)
|
||||
}
|
||||
|
||||
async function handleSaveAvatar(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, 'avatar.png');
|
||||
return uploadOss(formData);
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (route.query.id) {
|
||||
getUser(route.query.id as string).then(res => {
|
||||
form.value = {
|
||||
...form.value,
|
||||
...res.data
|
||||
}
|
||||
editForm.value = JSON.parse(JSON.stringify(form.value))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
|
||||
async function handleSave() {
|
||||
if (!formRef.value) return
|
||||
formRef.value.validate(async (valid) => {
|
||||
console.log('校验结果:', valid)
|
||||
if (!valid) {
|
||||
modal.msgWarning('请检查表单必填项');
|
||||
return
|
||||
}
|
||||
// 校验通过再执行上传和保存
|
||||
if (files.length != 0) {
|
||||
const res = await handleSaveAvatar(files.at(0));
|
||||
editForm.value.avatarUrl = res.data.url
|
||||
editForm.value.avatarOssId = res.data.ossId
|
||||
}
|
||||
|
||||
updateUser(editForm.value).then(() => {
|
||||
modal.msgSuccess('修改成功');
|
||||
handleBack()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editForm.value = JSON.parse(JSON.stringify(form.value))
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="edit">
|
||||
<div class="flex justify-between" style="padding-top:22px">
|
||||
<pageCrumb></pageCrumb>
|
||||
<el-button plain type="info" @click="handleBack"> 返回用户列表</el-button>
|
||||
</div>
|
||||
<!-- 用户信息-->
|
||||
<section class="customerInfoBox commonCard flex justify-between flex-items-center">
|
||||
<div class="flex infoBox">
|
||||
<el-image
|
||||
class="customer-Img"
|
||||
:src="form.avatarUrl"></el-image>
|
||||
<div class="flex flex-col justify-between">
|
||||
<div>
|
||||
<span class="userName">{{ form.nickName }}</span>
|
||||
<el-tag effect="light" round style="margin: 0 12px " type="success">
|
||||
<div class="flex flex-items-center"><i class="circle"></i>
|
||||
<span>正常</span></div>
|
||||
</el-tag>
|
||||
<el-tag effect="light" round type="info">{{ hiddenPhone(form.phonenumber) }}</el-tag>
|
||||
</div>
|
||||
<div class="subInfoBox">
|
||||
<span>用户ID: {{ form.userNo }} </span>
|
||||
<span class="dat">·</span>
|
||||
<span>注册时间: {{ form.createTime }}</span>
|
||||
<span class="dat">·</span>
|
||||
<span>最后登录: {{ form.loginDate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- form -->
|
||||
<section class="commonCard">
|
||||
<card-nav subTitle="手机号为账号唯一标识不可修改;个人资料与账号状态可维护" title="用户资料"></card-nav>
|
||||
<div class="formbox">
|
||||
<el-form :model="editForm" :rules="rules" label-position="top">
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="editForm.phonenumber"
|
||||
:inputAttrs="{ placeholder: '请输入手机号', disabled: true , showWordLimit:false ,maxlength:11}"
|
||||
label="手机号"
|
||||
prop="phonenumber"
|
||||
tip="账号唯一标识,如需更换手机号请联系用户重新注册"
|
||||
>
|
||||
<template #suffix>不可修改</template>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="editForm.nickName"
|
||||
prop="nickName"
|
||||
:inputAttrs="{ placeholder: '请输入用户昵称', disabled: false , showWordLimit:true ,maxlength:20}"
|
||||
:required="true"
|
||||
type="input"
|
||||
tip="必填,最多 20 个字符,将同步展示在 APP 端"
|
||||
>
|
||||
<template #label>
|
||||
<span>用户昵称</span>
|
||||
</template>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="editForm.gender"
|
||||
:radioOptions="[
|
||||
{label:'男',value:'1'},
|
||||
{label:'女',value:'2'},
|
||||
{label:'未填写',value:'0'},
|
||||
]"
|
||||
prop="gender"
|
||||
tip="选填,选择「未填写」时 APP 端不展示性别"
|
||||
type="radio"
|
||||
>
|
||||
<template #label>
|
||||
<span>性别</span>
|
||||
<span class="subTitle">(选填)</span>
|
||||
</template>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="editForm.birthday"
|
||||
:inputAttrs="{ placeholder: '请选择生日'}"
|
||||
label="生日"
|
||||
prop="birthday"
|
||||
tip="选填,修改后将同步至用户个人资料"
|
||||
type="date"
|
||||
>
|
||||
<template #label>
|
||||
<span>生日</span>
|
||||
<span class="subTitle">(选填)</span>
|
||||
</template>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="editForm.status"
|
||||
:inputAttrs="{ placeholder: '', disabled: false }"
|
||||
:radioOptions="[
|
||||
{label:'正常',value:'0'},
|
||||
{label:'禁用',value:'1'},
|
||||
]"
|
||||
label="账号状态"
|
||||
prop="status"
|
||||
tip="切换为「禁用」后该账号将无法登录 APP,且已登录设备将下线"
|
||||
type="radio"
|
||||
>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
:inputAttrs="{ placeholder: '请输入用户昵称', disabled: true }"
|
||||
:required="true"
|
||||
v-model="editForm.avatarUrl"
|
||||
label="头像"
|
||||
prop="avatarUrl"
|
||||
tip="必填,最多 20 个字符,将同步展示在 APP 端"
|
||||
type="uploadUserAvatar"
|
||||
@file-change="handleChangeFile"
|
||||
>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="24">
|
||||
<FormItemTip
|
||||
v-model="editForm.remark"
|
||||
:inputAttrs="{ placeholder: '请输入后台内部备注,如工单号、对接人、异常说明等信息', disabled: false , showWordLimit:true ,maxlength:20}"
|
||||
:required="false"
|
||||
label="备注"
|
||||
prop="remark"
|
||||
type="textarea"
|
||||
>
|
||||
<template #label>
|
||||
<span>备注 </span>
|
||||
<span>(选填 · 仅后台管理员可见)</span>
|
||||
</template>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</div>
|
||||
</section>
|
||||
<div class="footer">
|
||||
<el-button @click="resetForm">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.edit {
|
||||
.customerInfoBox {
|
||||
.infoBox {
|
||||
|
||||
.customer-Img {
|
||||
width: 73px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.userName {
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
font-weight: bold;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.circle {
|
||||
display: block;
|
||||
width: 7px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
margin-right: 6px;
|
||||
background-color: rgba(22, 163, 74, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.subInfoBox {
|
||||
line-height: 20px;
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
|
||||
.dat {
|
||||
margin: 0 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.subTitle {
|
||||
line-height: 14px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.formbox {
|
||||
margin-top: 46px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 27px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
313
src/views/system/customer/index.vue
Normal file
313
src/views/system/customer/index.vue
Normal file
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<div class="p-2 app-container monitor-online-page">
|
||||
<pageCrumb style="margin-top: 22px"></pageCrumb>
|
||||
<div class="search-wrap">
|
||||
<el-card class="search-panel" shadow="hover">
|
||||
<el-form ref="queryFormRef" :inline="true" :model="queryParams" class="query-form" label-position="top">
|
||||
<el-form-item label="手机号" prop="phonenumber">
|
||||
<el-input v-model="queryParams.phonenumber" clearable placeholder="请输入手机号" size="large"
|
||||
style="width: 190px;" @keyup.enter="handleQuery"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户昵称" prop="nickName">
|
||||
<el-input v-model="queryParams.nickName" clearable placeholder="请输入用户昵称" size="large"
|
||||
style="width: 190px;" @keyup.enter="handleQuery"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="性别" prop="gender">
|
||||
<el-select
|
||||
v-model="queryParams.gender"
|
||||
:value-on-clear="null"
|
||||
clearable
|
||||
size="large"
|
||||
style="width:100px;"
|
||||
>
|
||||
<el-option label="女" value="2"></el-option>
|
||||
<el-option label="男" value="1"></el-option>
|
||||
<el-option label="未知" value="0"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所在地区" prop="userName">
|
||||
<el-select
|
||||
v-model="queryParams.ipaddr"
|
||||
:empty-values="[null, undefined]"
|
||||
:value-on-clear="null"
|
||||
clearable
|
||||
size="large"
|
||||
style="width: 190px;"
|
||||
>
|
||||
<el-option label="在线" value="0"></el-option>
|
||||
<el-option label="离线" value="1"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="注册时间段" prop="createTime">
|
||||
<el-date-picker
|
||||
v-model="createTime"
|
||||
end-placeholder="结束日期"
|
||||
size="large"
|
||||
single-panel
|
||||
start-placeholder="开始日期"
|
||||
style="width:230px;"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
@update:model-value="handleChangeTime"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="账号状态" prop="status">
|
||||
<el-select
|
||||
v-model="queryParams.status"
|
||||
:value-on-clear="null"
|
||||
clearable
|
||||
size="large"
|
||||
style="width:100px;"
|
||||
>
|
||||
<el-option label="正常" value="0"></el-option>
|
||||
<el-option label="停用" value="1"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button size="large" type="primary" @click="handleQuery">查询</el-button>
|
||||
<el-button size="large" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
<el-card class="table-panel" shadow="hover">
|
||||
<template #header>
|
||||
<div class="flex justify-end">
|
||||
<el-button plain type="info" @click="exportSelectUser">批量导出用户信息</el-button>
|
||||
<el-button plain type="danger" @click="selectDisableUser">批量禁用账号</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="onlineList"
|
||||
class="data-table"
|
||||
@selection-change="handleSelectionChange"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column align="center" type="selection"></el-table-column>
|
||||
<el-table-column :show-overflow-tooltip="true" align="center" label="用户昵称" prop="nickName"/>
|
||||
<el-table-column :show-overflow-tooltip="true" align="center" label="手机号" prop="phonenumber">
|
||||
<template #default="scope">
|
||||
<span>{{ hiddenPhone(scope.row.phonenumber) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :show-overflow-tooltip="true" align="center" label="性别" prop="gender">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.gender === '1'">男</span>
|
||||
<span v-else-if="scope.row.gender==='2'">女</span>
|
||||
<span v-else style="color: #9CA3AF">未填写</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="生日" prop="birthday"></el-table-column>
|
||||
<el-table-column :show-overflow-tooltip="true" align="center" label="所在地区" prop="deptName"/>
|
||||
<el-table-column :show-overflow-tooltip="true" align="center" label="绑定设备量" prop="deviceCount"/>
|
||||
<el-table-column :show-overflow-tooltip="true" align="center" label="注册时间" prop="createTime"/>
|
||||
<el-table-column :show-overflow-tooltip="true" align="center" label="最后登录时间" prop="loginDate"/>
|
||||
<el-table-column :show-overflow-tooltip="true" align="center" label="账号状态" prop="status">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.status === '0'" type="success">正常</el-tag>
|
||||
<el-tag v-else type="danger">异常</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" class-name="small-padding fixed-width" label="操作" min-width="250">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-hasPermi="['monitor:online:forceLogout']"
|
||||
plain
|
||||
type="primary"
|
||||
@click="handleMain(scope.row)"
|
||||
>查看详情
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPermi="['monitor:online:forceLogout']"
|
||||
plain
|
||||
type="info"
|
||||
@click="handleEdit(scope.row)"
|
||||
>编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPermi="['monitor:online:forceLogout']"
|
||||
plain
|
||||
type="info"
|
||||
@click="handleReboot(scope.row)"
|
||||
>重置密码
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="scope.row.status === '0'"
|
||||
v-hasPermi="['monitor:online:forceLogout']"
|
||||
plain
|
||||
type="warning"
|
||||
@click="changeStatus(scope.row, '1')"
|
||||
>禁用
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="scope.row.status === '1'"
|
||||
v-hasPermi="['monitor:online:forceLogout']"
|
||||
plain
|
||||
type="success"
|
||||
@click="changeStatus(scope.row, '0')"
|
||||
>启用
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<pagination
|
||||
v-show="total > 0"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
v-model:page="queryParams.pageNum"
|
||||
:total="total"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {batchDisableUsers, listUser as initData, resetPwdUser, updateUserStatus} from '@/api/system/customer/index';
|
||||
import {UserQuery, UserVO} from "@/api/system/customer/types";
|
||||
import {hiddenPhone} from "@/utils/phone";
|
||||
import pageCrumb from "@/components/Pagecrumb/index.vue";
|
||||
import modal from "@/plugins/modal";
|
||||
import {download as requestDownload} from '@/utils/request';
|
||||
import {useTableSelection} from "@/hooks/table/useTableSelection";
|
||||
import {PostVO} from "@/api/system/post/types";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const onlineList = ref<UserVO[]>([]);
|
||||
const {ids, single, multiple, handleSelectionChange} = useTableSelection<UserVO>(item => item.userId as string);
|
||||
const loading = ref(true);
|
||||
const total = ref(0);
|
||||
|
||||
const queryFormRef = ref<ElFormInstance>();
|
||||
|
||||
const createTime = ref([])
|
||||
|
||||
function handleChangeTime(data) {
|
||||
if (data) {
|
||||
queryParams.value.beginTime = data[0] + ' 00:00:00';
|
||||
queryParams.value.endTime = data[1] + ' 23:59:59';
|
||||
} else {
|
||||
createTime.value = []
|
||||
queryParams.value.beginTime = undefined
|
||||
queryParams.value.endTime = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const queryParams = ref<UserQuery>({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
ipaddr: '',
|
||||
userName: ''
|
||||
});
|
||||
|
||||
/** 查询登录日志列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true;
|
||||
const res = await initData(queryParams.value);
|
||||
onlineList.value = res.data?.rows;
|
||||
total.value = res.data?.total;
|
||||
loading.value = false;
|
||||
};
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.value.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value?.resetFields();
|
||||
createTime.value = []
|
||||
queryParams.value.beginTime = undefined
|
||||
queryParams.value.endTime = undefined
|
||||
handleQuery();
|
||||
};
|
||||
|
||||
/// 详情
|
||||
function handleMain(row: Partial<UserVO>) {
|
||||
router.push({
|
||||
path: '/customer/customerMain',
|
||||
query: {
|
||||
id: row.userId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
function handleReboot(row: Partial<UserVO>) {
|
||||
console.log(row);
|
||||
modal.confirm('确认重置该用户的登录密码吗?系统将恢复默认密码,并踢掉该用户登陆状态').then(() => {
|
||||
resetPwdUser(row.userId, '123456').then(() => {
|
||||
modal.msgSuccess('重置成功');
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// 改变用户状态
|
||||
async function changeStatus(row: Partial<UserVO>, status: '0' | '1') {
|
||||
if (status === '1') {
|
||||
await modal.confirm('禁用账号会立即退出该用户的 APP 登录状态,确定要禁用该用户吗?')
|
||||
}
|
||||
updateUserStatus(row.userId, status).then(() => {
|
||||
modal.msgSuccess('操作成功');
|
||||
getList()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/** 导出按钮操作 */
|
||||
const handleExport = () => {
|
||||
requestDownload(
|
||||
'customer/user/export',
|
||||
{
|
||||
...queryParams.value
|
||||
},
|
||||
`APP用户_${new Date().getTime()}.xlsx`
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
async function selectDisableUser() {
|
||||
await modal.confirm('禁用账号会立即退出该用户的 APP 登录状态,确定要禁用这些选择的用户吗?')
|
||||
const userIds = ids.value || [];
|
||||
batchDisableUsers(userIds as string[]).then(() => {
|
||||
modal.msgSuccess('禁用成功');
|
||||
getList();
|
||||
});
|
||||
}
|
||||
|
||||
function exportSelectUser() {
|
||||
const userIds = ids.value || [];
|
||||
if (userIds.length > 0) {
|
||||
|
||||
requestDownload('customer/user/exportSelected',
|
||||
{
|
||||
userIds: userIds.join(',')
|
||||
},
|
||||
`APP用户_${Date.now()}.xlsx`
|
||||
)
|
||||
} else {
|
||||
modal.msgWarning('请选择导出的用户')
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑用户
|
||||
function handleEdit(row: Partial<UserVO>) {
|
||||
router.push({
|
||||
path: '/customer/customerEdit',
|
||||
query: {
|
||||
id: row.userId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
//@use '@/assets/styles/components/page-shell' as pageShell;
|
||||
//
|
||||
//@include pageShell.table-crud-page;
|
||||
</style>
|
||||
382
src/views/system/customer/main.vue
Normal file
382
src/views/system/customer/main.vue
Normal file
@@ -0,0 +1,382 @@
|
||||
<script lang="ts" setup>
|
||||
import pageCrumb from '@/components/Pagecrumb/index.vue';
|
||||
import {getUser, resetPwdUser, updateUserStatus} from '@/api/system/customer';
|
||||
import {hiddenPhone} from "@/utils/phone";
|
||||
import modal from "@/plugins/modal";
|
||||
|
||||
const defaultImag = import.meta.env.VITE_DEFAULT_USER_IMAGE;
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
function handleEdit() {
|
||||
router.push({
|
||||
path: '/customer/customerEdit',
|
||||
query: {
|
||||
id: form.value.userId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
function handleReboot() {
|
||||
modal.confirm('确认重置该用户的登录密码吗?系统将恢复默认密码,并踢掉该用户登陆状态').then(() => {
|
||||
resetPwdUser(form.value.userId, '123456').then(() => {
|
||||
modal.msgSuccess('重置成功');
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// 改变用户状态
|
||||
function changeStatus(status: '0' | '1') {
|
||||
updateUserStatus(form.value.userId, status).then(() => {
|
||||
modal.msgSuccess('操作成功');
|
||||
init()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function handleBack() {
|
||||
router.push({
|
||||
path: '/customer/customerList'
|
||||
})
|
||||
}
|
||||
|
||||
const form = ref({
|
||||
"birthday": null,
|
||||
"city": null,
|
||||
"createTime": "",
|
||||
"district": null,
|
||||
"gender": "0",
|
||||
"genderName": "未知",
|
||||
"loginCount": 19,
|
||||
"loginDate": "",
|
||||
"loginIp": "",
|
||||
"nickName": "",
|
||||
"phonenumber": "",
|
||||
"province": null,
|
||||
"deviceCount": null,
|
||||
"remark": "",
|
||||
"status": "0",
|
||||
"statusName": "正常",
|
||||
"updateTime": "",
|
||||
"userId": "",
|
||||
"userName": "",
|
||||
"userNo": "",
|
||||
"avatarOssId": "",
|
||||
"avatarUrl": "",
|
||||
})
|
||||
|
||||
function init() {
|
||||
if (route.query.id) {
|
||||
getUser(route.query.id as string).then(res => {
|
||||
form.value = {
|
||||
...form.value,
|
||||
...res.data
|
||||
}
|
||||
console.log(form.value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="Main">
|
||||
<div class="flex justify-between" style="padding-top:22px">
|
||||
<pageCrumb></pageCrumb>
|
||||
<el-button plain type="info" @click="handleBack">返回用户列表</el-button>
|
||||
</div>
|
||||
<!-- 用户信息-->
|
||||
<section class="customerInfoBox commonCard flex justify-between flex-items-center">
|
||||
<div class="flex infoBox">
|
||||
<el-image :src="form.avatarUrl || defaultImag" class="customer-Img"></el-image>
|
||||
<div class="flex flex-col justify-between">
|
||||
<div>
|
||||
<span class="userName">{{ form.nickName }}</span>
|
||||
<el-tag v-if="form.status === '0'" effect="light" round style="margin: 0 12px " type="success">
|
||||
<div class="flex flex-items-center"><i class="circle"></i>
|
||||
<span>正常</span></div>
|
||||
</el-tag>
|
||||
<el-tag v-else effect="light" round style="margin: 0 12px " type="danger">
|
||||
<div class="flex flex-items-center"><i class="circle" style="background-color: red"></i>
|
||||
<span>禁用</span></div>
|
||||
</el-tag>
|
||||
<el-tag effect="light" round type="info"> {{ hiddenPhone(form.phonenumber) }}</el-tag>
|
||||
</div>
|
||||
<div class="subInfoBox">
|
||||
<span>用户ID: {{ form.userNo }} </span>
|
||||
<span class="dat">·</span>
|
||||
<span>注册时间: {{ form.createTime }}</span>
|
||||
<span class="dat">·</span>
|
||||
<span>最后登录: {{ form.loginDate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<el-button size="large" type="primary" @click="handleEdit">编辑用户</el-button>
|
||||
<el-button plain size="large" type="primary" @click="handleReboot">重置密码</el-button>
|
||||
<el-button v-if="form.status === '0'" plain size="large" type="danger" @click="changeStatus('1')">禁用账号
|
||||
</el-button>
|
||||
<el-button v-else plain size="large" type="success" @click="changeStatus('0')">启用账号</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<!-- 账号信息-->
|
||||
<div class="userNameBox commonCard">
|
||||
<cardNav line-color="#2563eb" title="账号基础信息"></cardNav>
|
||||
<ul class=" userNameInfoBox">
|
||||
<li>
|
||||
<span class="label">用户ID</span>
|
||||
<span class="value">{{ form.userNo }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="label">手机号</span>
|
||||
<span class="value">{{ hiddenPhone(form.phonenumber) }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="label">账号状态</span>
|
||||
<el-tag v-if="form.status === '0'" type="success">正常</el-tag>
|
||||
<el-tag v-else type="danger">禁用</el-tag>
|
||||
</li>
|
||||
<li>
|
||||
<span class="label">注册时间</span>
|
||||
<span class="value">{{ form.createTime }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="label">最后登录时间</span>
|
||||
<span class="value">{{ form.loginDate }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="label">登录次数</span>
|
||||
<span class="value">{{ form.loginCount }}次</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<!-- 个人信息-->
|
||||
<div class="userInfoBox commonCard">
|
||||
<cardNav line-color="#14b8a6" title="个人资料"></cardNav>
|
||||
<div class="userInfoInfoBox">
|
||||
<div class="userInfoImageBox">
|
||||
<el-image :src="form.avatarUrl || defaultImag" class="userImage"></el-image>
|
||||
<div class="description">APP端头像</div>
|
||||
</div>
|
||||
<ul>
|
||||
<li>
|
||||
<span class="label">昵称</span>
|
||||
<span class="value">{{ form.nickName }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="label">性别</span>
|
||||
<span v-if="form.gender === '0'" class="value">男</span>
|
||||
<span v-else-if="form.gender === '1'" class="value">女</span>
|
||||
<span v-else class="value">未知</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="label">生日</span>
|
||||
<span class="value">{{ form.birthday }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="label"> 所在地区</span>
|
||||
<span class="value">上海市・浦东新区</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer">
|
||||
个人资料由用户在 APP 端自行维护,后台仅可查看;如需调整请在「编辑用户」中操作。
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
|
||||
<!-- 绑定设备清单-->
|
||||
<div class="bindDevicesListBox commonCard">
|
||||
<div class="flex justify-between">
|
||||
<card-nav line-color="#f59e0b" subTitle="该用户名下全部绑定设备,点击操作可跳转设备详情"
|
||||
title="绑定设备清单"></card-nav>
|
||||
<el-tag type="primary">共{{ form.deviceCount }}台</el-tag>
|
||||
</div>
|
||||
<el-table
|
||||
class="data-table"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column :show-overflow-tooltip="true" align="center" label="设备名称" prop="tokenId"/>
|
||||
<el-table-column :show-overflow-tooltip="true" align="center" label="MAC地址" prop="userName"/>
|
||||
<el-table-column :show-overflow-tooltip="true" align="center" label="型号" prop="clientKey"/>
|
||||
<el-table-column align="center" label="在线状态">
|
||||
<template #default="scope">
|
||||
<el-tag type="primary">在线</el-tag>
|
||||
<el-tag type="danger">离线</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :show-overflow-tooltip="true" align="center" label="绑定时间" prop="userName"/>
|
||||
<el-table-column align="center" label="操作">
|
||||
<template #default="scope">
|
||||
<el-button plain type="primary">查看设备详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.Main {
|
||||
.customerInfoBox {
|
||||
.infoBox {
|
||||
|
||||
.customer-Img {
|
||||
width: 73px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.userName {
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
font-weight: bold;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.circle {
|
||||
display: block;
|
||||
width: 7px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
margin-right: 6px;
|
||||
background-color: rgba(22, 163, 74, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.subInfoBox {
|
||||
line-height: 20px;
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
|
||||
.dat {
|
||||
margin: 0 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.userNameBox {
|
||||
min-height: 389px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.userNameInfoBox {
|
||||
flex: 1;
|
||||
margin-top: 25px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
|
||||
li {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.label {
|
||||
line-height: 16px;
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.value {
|
||||
line-height: 17px;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.userInfoBox {
|
||||
.userInfoInfoBox {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 32px;
|
||||
display: flex;
|
||||
|
||||
.userInfoImageBox {
|
||||
width: 100px;
|
||||
margin-right: 24px;
|
||||
|
||||
.userImage {
|
||||
width: 97px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.description {
|
||||
line-height: 16px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.label {
|
||||
line-height: 16px;
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.value {
|
||||
height: 17px;
|
||||
line-height: 17px;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
height: 44px;
|
||||
border-radius: 12px 12px 12px 12px;
|
||||
background-color: rgba(247, 249, 252, 1);
|
||||
border: 1px solid rgba(238, 240, 243, 1);
|
||||
line-height: 18px;
|
||||
|
||||
padding: 13px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.bindDevicesListBox {
|
||||
margin-top: 16px;
|
||||
|
||||
.data-table {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
</style>
|
||||
514
src/views/system/device/devicelist/add.vue
Normal file
514
src/views/system/device/devicelist/add.vue
Normal file
@@ -0,0 +1,514 @@
|
||||
<template>
|
||||
<div class="p-2 page-shell system-device-page">
|
||||
<el-card class="table-panel" shadow="hover">
|
||||
<template #header>
|
||||
<div class="toolbar-shell">
|
||||
<div class="table-heading">
|
||||
<h3>设备主列表</h3>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button v-hasPermi="['system:device:add']" icon="Plus" plain type="primary" @click="handleAdd">新增
|
||||
</el-button>
|
||||
<el-button v-hasPermi="['system:device:edit']" :disabled="single" icon="Edit" plain type="success"
|
||||
@click="handleUpdate()">修改
|
||||
</el-button>
|
||||
<el-button v-hasPermi="['system:device:remove']" :disabled="multiple" icon="Delete" plain type="danger"
|
||||
@click="handleDelete()">删除
|
||||
</el-button>
|
||||
<el-button v-hasPermi="['system:device:export']" icon="Download" plain type="warning" @click="handleExport">
|
||||
导出
|
||||
</el-button>
|
||||
<right-toolbar v-model:show-search="showSearch" :search="false" @query-table="getList"></right-toolbar>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table v-loading="loading" :data="deviceList" border class="data-table"
|
||||
@selection-change="handleSelectionChange">
|
||||
<el-table-column align="center" type="selection" width="55"/>
|
||||
<el-table-column align="center" label="主键" prop="deviceId"/>
|
||||
<el-table-column align="center" label="业务编号,未删除记录内唯一;逻辑删除后可再次使用" prop="deviceNo"/>
|
||||
<el-table-column align="center" label="绑定码IMEI,可空;未删除记录内唯一,逻辑删除后可再次使用" prop="imei"/>
|
||||
<el-table-column align="center" label="用户侧名称≤20字符" prop="name"/>
|
||||
<el-table-column align="center" label="产品模板" prop="productId"/>
|
||||
<el-table-column align="center" label="型号快照,新增时取自产品模板" prop="model"/>
|
||||
<el-table-column align="center" label="出厂序列号" prop="sn"/>
|
||||
<el-table-column align="center" label="硬件版本" prop="hwVersion"/>
|
||||
<el-table-column align="center" label="软件版本" prop="swVersion"/>
|
||||
<el-table-column align="center" label="固件版本" prop="fwVersion"/>
|
||||
<el-table-column align="center" label="数据包版本" prop="pkgVersion"/>
|
||||
<el-table-column align="center" label="设备IP" prop="ip"/>
|
||||
<el-table-column align="center" label="MAC,未删除记录内唯一;逻辑删除后可再次添加" prop="mac"/>
|
||||
<el-table-column align="center" label="WiFi SSID" prop="wifiSsid"/>
|
||||
<el-table-column align="center" label="蓝牙" prop="bluetoothConnected"/>
|
||||
<el-table-column align="center" label="状态" prop="status" width="120">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
v-model="scope.row.status"
|
||||
:active-value="true"
|
||||
:inactive-value="false"
|
||||
disabled
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="当前绑定APP用户" prop="bindUserId"/>
|
||||
<el-table-column align="center" label="最近一次绑定时间" prop="bindTime" width="180">
|
||||
<template #default="scope">
|
||||
<span>{{ parseTime(scope.row.bindTime, '{y}-{m}-{d}') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="当前家庭" prop="homeId"/>
|
||||
<el-table-column align="center" label="当前房间" prop="locationId"/>
|
||||
<el-table-column align="center" label="信号等级" prop="signalLevel"/>
|
||||
<el-table-column align="center" label="最新经度" prop="lng"/>
|
||||
<el-table-column align="center" label="最新纬度" prop="lat"/>
|
||||
<el-table-column align="center" label="地址" prop="address"/>
|
||||
<el-table-column align="center" label="充电" prop="charging"/>
|
||||
<el-table-column align="center" label="设备电量0-100" prop="deviceBattery"/>
|
||||
<el-table-column align="center" label="最近一次运行数据上报时间" prop="lastReportTime" width="180">
|
||||
<template #default="scope">
|
||||
<span>{{ parseTime(scope.row.lastReportTime, '{y}-{m}-{d}') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="最后在线时间" prop="lastOnlineTime" width="180">
|
||||
<template #default="scope">
|
||||
<span>{{ parseTime(scope.row.lastOnlineTime, '{y}-{m}-{d}') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="备注" prop="remark"/>
|
||||
<el-table-column align="center" label="未删除时的业务编号" prop="activeDeviceNo"/>
|
||||
<el-table-column align="center" label="未删除时的IMEI" prop="activeImei"/>
|
||||
<el-table-column align="center" label="未删除时的MAC" prop="activeMac"/>
|
||||
<el-table-column align="center" class-name="small-padding fixed-width" label="操作">
|
||||
<template #default="scope">
|
||||
<el-tooltip content="修改" placement="top">
|
||||
<el-button v-hasPermi="['system:device:edit']" icon="Edit" link type="primary"
|
||||
@click="handleUpdate(scope.row)"></el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除" placement="top">
|
||||
<el-button v-hasPermi="['system:device:remove']" icon="Delete" link type="primary"
|
||||
@click="handleDelete(scope.row)"></el-button>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total > 0" v-model:limit="queryParams.pageSize" v-model:page="queryParams.pageNum"
|
||||
:total="total" @pagination="getList"/>
|
||||
</el-card>
|
||||
<!-- 添加或修改设备主对话框 -->
|
||||
<el-dialog v-model="dialog.visible" :title="dialog.title" append-to-body width="800px">
|
||||
<el-form ref="deviceFormRef" :model="form" :rules="rules" label-width="250px">
|
||||
<el-form-item label="业务编号,未删除记录内唯一;逻辑删除后可再次使用" prop="deviceNo">
|
||||
<el-input v-model="form.deviceNo" placeholder="请输入业务编号,未删除记录内唯一;逻辑删除后可再次使用"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="绑定码IMEI,可空;未删除记录内唯一,逻辑删除后可再次使用" prop="imei">
|
||||
<el-input v-model="form.imei" placeholder="请输入绑定码IMEI,可空;未删除记录内唯一,逻辑删除后可再次使用"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户侧名称≤20字符" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入用户侧名称≤20字符"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="产品模板" prop="productId">
|
||||
<el-input v-model="form.productId"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="型号快照,新增时取自产品模板" prop="model">
|
||||
<el-input v-model="form.model" placeholder="请输入型号快照,新增时取自产品模板"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="出厂序列号" prop="sn">
|
||||
<el-input v-model="form.sn" placeholder="请输入出厂序列号"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="硬件版本" prop="hwVersion">
|
||||
<el-input v-model="form.hwVersion" placeholder="请输入硬件版本"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="软件版本" prop="swVersion">
|
||||
<el-input v-model="form.swVersion" placeholder="请输入软件版本"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="固件版本" prop="fwVersion">
|
||||
<el-input v-model="form.fwVersion" placeholder="请输入固件版本"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="数据包版本" prop="pkgVersion">
|
||||
<el-input v-model="form.pkgVersion" placeholder="请输入数据包版本"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="设备IP" prop="ip">
|
||||
<el-input v-model="form.ip" placeholder="请输入设备IP"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="MAC,未删除记录内唯一;逻辑删除后可再次添加" prop="mac">
|
||||
<el-input v-model="form.mac" placeholder="请输入MAC,未删除记录内唯一;逻辑删除后可再次添加"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="WiFi SSID" prop="wifiSsid">
|
||||
<el-input v-model="form.wifiSsid" placeholder="请输入WiFi SSID"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="蓝牙" prop="bluetoothConnected">
|
||||
<el-input v-model="form.bluetoothConnected" placeholder="请输入蓝牙"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-switch
|
||||
v-model="form.status"
|
||||
:active-value="true"
|
||||
:inactive-value="false"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="当前绑定APP用户" prop="bindUserId">
|
||||
<el-input-number v-model="form.bindUserId" controls-position="right"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="最近一次绑定时间" prop="bindTime">
|
||||
<el-date-picker v-model="form.bindTime"
|
||||
clearable
|
||||
placeholder="请选择最近一次绑定时间"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss">
|
||||
</el-date-picker>
|
||||
</el-form-item>
|
||||
<el-form-item label="当前家庭" prop="homeId">
|
||||
<el-input v-model="form.homeId" controls-position="right"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="当前房间" prop="locationId">
|
||||
<el-input v-model="form.locationId" controls-position="right"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="信号等级" prop="signalLevel">
|
||||
<el-input v-model="form.signalLevel" controls-position="right"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="最新经度" prop="lng">
|
||||
<el-input v-model="form.lng" controls-position="right"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="最新纬度" prop="lat">
|
||||
<el-input v-model="form.lat" controls-position="right"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="地址" prop="address">
|
||||
<el-input v-model="form.address" placeholder="请输入地址"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="充电" prop="charging">
|
||||
<el-input v-model="form.charging" placeholder="请输入充电"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="设备电量0-100" prop="deviceBattery">
|
||||
<el-input-number v-model="form.deviceBattery" controls-position="right"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="最近一次运行数据上报时间" prop="lastReportTime">
|
||||
<el-date-picker v-model="form.lastReportTime"
|
||||
clearable
|
||||
placeholder="请选择最近一次运行数据上报时间"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss">
|
||||
</el-date-picker>
|
||||
</el-form-item>
|
||||
<el-form-item label="最后在线时间" prop="lastOnlineTime">
|
||||
<el-date-picker v-model="form.lastOnlineTime"
|
||||
clearable
|
||||
placeholder="请选择最后在线时间"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss">
|
||||
</el-date-picker>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" placeholder="请输入内容" type="textarea"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建部门" prop="createDept">
|
||||
<el-input-number v-model="form.createDept" controls-position="right"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建者" prop="createBy">
|
||||
<el-input-number v-model="form.createBy" controls-position="right"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间" prop="createTime">
|
||||
<el-date-picker v-model="form.createTime"
|
||||
clearable
|
||||
placeholder="请选择创建时间"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss">
|
||||
</el-date-picker>
|
||||
</el-form-item>
|
||||
<el-form-item label="更新者" prop="updateBy">
|
||||
<el-input-number v-model="form.updateBy" controls-position="right"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="更新时间" prop="updateTime">
|
||||
<el-date-picker v-model="form.updateTime"
|
||||
clearable
|
||||
placeholder="请选择更新时间"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss">
|
||||
</el-date-picker>
|
||||
</el-form-item>
|
||||
<el-form-item label="未删除时的业务编号" prop="activeDeviceNo">
|
||||
<el-input v-model="form.activeDeviceNo" placeholder="请输入未删除时的业务编号"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="未删除时的IMEI" prop="activeImei">
|
||||
<el-input v-model="form.activeImei" placeholder="请输入未删除时的IMEI"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="未删除时的MAC" prop="activeMac">
|
||||
<el-input v-model="form.activeMac" placeholder="请输入未删除时的MAC"/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button :loading="buttonLoading" type="primary" @click="submitForm">确 定</el-button>
|
||||
<el-button @click="cancel">取 消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="Device" setup>
|
||||
import {
|
||||
addDevice,
|
||||
delDevice,
|
||||
getDevice,
|
||||
listDevice,
|
||||
updateDevice
|
||||
} from '@/api/system/device';
|
||||
import {DeviceForm, DeviceQuery, DeviceVO} from '@/api/system/device/types';
|
||||
import {useLoading} from '@/hooks/async/useLoading';
|
||||
import {useFormDialog} from '@/hooks/dialog/useFormDialog';
|
||||
import {useDateRangeQuery} from '@/hooks/form/useDateRangeQuery';
|
||||
import {useSearchReset} from '@/hooks/form/useSearchReset';
|
||||
import {useSearchToggle} from '@/hooks/form/useSearchToggle';
|
||||
import {useTableSelection} from '@/hooks/table/useTableSelection';
|
||||
import {parseTime} from '@/utils/ruoyi';
|
||||
import modal from '@/plugins/modal';
|
||||
import {download as requestDownload} from '@/utils/request';
|
||||
|
||||
|
||||
const deviceList = ref<DeviceVO[]>([]);
|
||||
const buttonLoading = ref(false);
|
||||
const {loading, withLoading} = useLoading(true);
|
||||
const {showSearch} = useSearchToggle();
|
||||
const total = ref(0);
|
||||
const {
|
||||
dateRange: dateRangeBindTime,
|
||||
applyDateRange: applyBindTimeDateRange,
|
||||
resetDateRange: resetBindTimeDateRange
|
||||
} = useDateRangeQuery('BindTime');
|
||||
const {
|
||||
dateRange: dateRangeLastReportTime,
|
||||
applyDateRange: applyLastReportTimeDateRange,
|
||||
resetDateRange: resetLastReportTimeDateRange
|
||||
} = useDateRangeQuery('LastReportTime');
|
||||
const {
|
||||
dateRange: dateRangeLastOnlineTime,
|
||||
applyDateRange: applyLastOnlineTimeDateRange,
|
||||
resetDateRange: resetLastOnlineTimeDateRange
|
||||
} = useDateRangeQuery('LastOnlineTime');
|
||||
const {
|
||||
dateRange: dateRangeCreateTime,
|
||||
applyDateRange: applyCreateTimeDateRange,
|
||||
resetDateRange: resetCreateTimeDateRange
|
||||
} = useDateRangeQuery('CreateTime');
|
||||
const {
|
||||
dateRange: dateRangeUpdateTime,
|
||||
applyDateRange: applyUpdateTimeDateRange,
|
||||
resetDateRange: resetUpdateTimeDateRange
|
||||
} = useDateRangeQuery('UpdateTime');
|
||||
|
||||
const queryFormRef = ref<ElFormInstance>();
|
||||
const deviceFormRef = ref<ElFormInstance>();
|
||||
|
||||
const initFormData: DeviceForm = {
|
||||
deviceId: undefined,
|
||||
deviceNo: undefined,
|
||||
imei: undefined,
|
||||
name: undefined,
|
||||
productId: undefined,
|
||||
model: undefined,
|
||||
sn: undefined,
|
||||
hwVersion: undefined,
|
||||
swVersion: undefined,
|
||||
fwVersion: undefined,
|
||||
pkgVersion: undefined,
|
||||
ip: undefined,
|
||||
mac: undefined,
|
||||
wifiSsid: undefined,
|
||||
bluetoothConnected: undefined,
|
||||
status: undefined,
|
||||
bindUserId: undefined,
|
||||
bindTime: undefined,
|
||||
homeId: undefined,
|
||||
locationId: undefined,
|
||||
signalLevel: undefined,
|
||||
lng: undefined,
|
||||
lat: undefined,
|
||||
address: undefined,
|
||||
charging: undefined,
|
||||
deviceBattery: undefined,
|
||||
lastReportTime: undefined,
|
||||
lastOnlineTime: undefined,
|
||||
remark: undefined,
|
||||
createDept: undefined,
|
||||
createBy: undefined,
|
||||
createTime: undefined,
|
||||
updateBy: undefined,
|
||||
updateTime: undefined,
|
||||
activeDeviceNo: undefined,
|
||||
activeImei: undefined,
|
||||
activeMac: undefined,
|
||||
}
|
||||
const data = reactive<PageData<DeviceForm, DeviceQuery>>({
|
||||
form: {...initFormData},
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
deviceNo: undefined,
|
||||
imei: undefined,
|
||||
name: undefined,
|
||||
productId: undefined,
|
||||
model: undefined,
|
||||
sn: undefined,
|
||||
hwVersion: undefined,
|
||||
swVersion: undefined,
|
||||
fwVersion: undefined,
|
||||
pkgVersion: undefined,
|
||||
ip: undefined,
|
||||
mac: undefined,
|
||||
wifiSsid: undefined,
|
||||
bluetoothConnected: undefined,
|
||||
status: undefined,
|
||||
bindUserId: undefined,
|
||||
homeId: undefined,
|
||||
locationId: undefined,
|
||||
signalLevel: undefined,
|
||||
lng: undefined,
|
||||
lat: undefined,
|
||||
address: undefined,
|
||||
charging: undefined,
|
||||
deviceBattery: undefined,
|
||||
createDept: undefined,
|
||||
createBy: undefined,
|
||||
updateBy: undefined,
|
||||
activeDeviceNo: undefined,
|
||||
activeImei: undefined,
|
||||
activeMac: undefined,
|
||||
params: {
|
||||
bindTime: undefined,
|
||||
lastReportTime: undefined,
|
||||
lastOnlineTime: undefined,
|
||||
createTime: undefined,
|
||||
updateTime: undefined,
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
deviceId: [
|
||||
{required: true, message: "主键不能为空", trigger: "change"}
|
||||
],
|
||||
deviceNo: [
|
||||
{required: true, message: "业务编号,未删除记录内唯一;逻辑删除后可再次使用不能为空", trigger: "blur"}
|
||||
],
|
||||
bluetoothConnected: [
|
||||
{required: true, message: "蓝牙不能为空", trigger: "blur"}
|
||||
],
|
||||
status: [
|
||||
{required: true, message: "状态不能为空", trigger: "change"}
|
||||
],
|
||||
signalLevel: [
|
||||
{required: true, message: "信号等级不能为空", trigger: "change"}
|
||||
],
|
||||
}
|
||||
});
|
||||
|
||||
const {queryParams, form, rules} = toRefs(data);
|
||||
const {ids, single, multiple, handleSelectionChange} = useTableSelection<DeviceVO>(item => item.deviceId);
|
||||
const {dialog, resetForm: reset, openDialog, showDialog, closeDialog} = useFormDialog({
|
||||
form,
|
||||
formRef: deviceFormRef,
|
||||
initialFormData: initFormData
|
||||
});
|
||||
|
||||
/** 查询设备主列表 */
|
||||
const getList = async () => {
|
||||
await withLoading(async () => {
|
||||
let params = queryParams.value;
|
||||
params = applyBindTimeDateRange(params);
|
||||
params = applyLastReportTimeDateRange(params);
|
||||
params = applyLastOnlineTimeDateRange(params);
|
||||
params = applyCreateTimeDateRange(params);
|
||||
params = applyUpdateTimeDateRange(params);
|
||||
const res = await listDevice(params);
|
||||
deviceList.value = res.data?.rows;
|
||||
total.value = res.data?.total;
|
||||
});
|
||||
};
|
||||
|
||||
/** 取消按钮 */
|
||||
const cancel = () => {
|
||||
reset();
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.value.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const {resetQuery} = useSearchReset({
|
||||
queryFormRef,
|
||||
queryParams,
|
||||
pageNumKey: 'pageNum',
|
||||
pageSizeKey: 'pageSize',
|
||||
initialPageSize: 10,
|
||||
resetExtras: () => {
|
||||
resetBindTimeDateRange();
|
||||
resetLastReportTimeDateRange();
|
||||
resetLastOnlineTimeDateRange();
|
||||
resetCreateTimeDateRange();
|
||||
resetUpdateTimeDateRange();
|
||||
},
|
||||
afterReset: () => {
|
||||
handleQuery();
|
||||
}
|
||||
});
|
||||
|
||||
/** 新增按钮操作 */
|
||||
const handleAdd = () => {
|
||||
openDialog('添加设备主');
|
||||
};
|
||||
|
||||
/** 修改按钮操作 */
|
||||
const handleUpdate = async (row?: Partial<DeviceVO>) => {
|
||||
reset();
|
||||
const _deviceId = row?.deviceId || ids.value[0];
|
||||
const res = await getDevice(_deviceId);
|
||||
Object.assign(form.value, res.data);
|
||||
showDialog('修改设备主');
|
||||
};
|
||||
|
||||
/** 提交按钮 */
|
||||
const submitForm = () => {
|
||||
deviceFormRef.value?.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
buttonLoading.value = true;
|
||||
if (form.value.deviceId) {
|
||||
await updateDevice(form.value).finally(() => (buttonLoading.value = false));
|
||||
} else {
|
||||
await addDevice(form.value).finally(() => (buttonLoading.value = false));
|
||||
}
|
||||
modal.msgSuccess('操作成功');
|
||||
closeDialog();
|
||||
await getList();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (row?: Partial<DeviceVO>) => {
|
||||
const _deviceIds = row?.deviceId || ids.value;
|
||||
await modal.confirm('是否确认删除设备主编号为"' + _deviceIds + '"的数据项?');
|
||||
await delDevice(_deviceIds);
|
||||
modal.msgSuccess('删除成功');
|
||||
await getList();
|
||||
};
|
||||
|
||||
/** 导出按钮操作 */
|
||||
const handleExport = () => {
|
||||
requestDownload(
|
||||
'device/device/export',
|
||||
{
|
||||
...queryParams.value
|
||||
},
|
||||
`device_${new Date().getTime()}.xlsx`
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
</script>
|
||||
|
||||
494
src/views/system/device/devicelist/edit.vue
Normal file
494
src/views/system/device/devicelist/edit.vue
Normal file
@@ -0,0 +1,494 @@
|
||||
<script lang="ts" setup>
|
||||
import cardNav from '@/components/cardNav/index.vue';
|
||||
import FormItemTip from '@/components/FormItemTip/index.vue';
|
||||
import {DeviceForm} from "@/api/system/device/types";
|
||||
import {getDevice, updateDevice} from "@/api/system/device";
|
||||
import modal from "@/plugins/modal";
|
||||
|
||||
function submitForm() {
|
||||
if (!form.value.name || form.value.name.trim() === "") {
|
||||
return ElMessage.warning('设备名称不能为空')
|
||||
}
|
||||
if (!form.value.locationName || form.value.locationName.trim() === "") {
|
||||
return ElMessage.warning('所在房间不能为空')
|
||||
}
|
||||
updateDevice(form.value).then(() => {
|
||||
modal.msgSuccess('修改成功');
|
||||
backMain()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function backMain() {
|
||||
router.replace(
|
||||
{
|
||||
path: '/device/deviceMain',
|
||||
query: {
|
||||
id: form.value.deviceId,
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function backList() {
|
||||
router.replace({
|
||||
path: '/device/deviceList',
|
||||
})
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const initFormData: DeviceForm = {
|
||||
deviceId: undefined,
|
||||
deviceNo: undefined,
|
||||
imei: undefined,
|
||||
name: undefined,
|
||||
productId: undefined,
|
||||
model: undefined,
|
||||
sn: undefined,
|
||||
hwVersion: undefined,
|
||||
swVersion: undefined,
|
||||
fwVersion: undefined,
|
||||
pkgVersion: undefined,
|
||||
ip: undefined,
|
||||
mac: undefined,
|
||||
wifiSsid: undefined,
|
||||
bluetoothConnected: undefined,
|
||||
status: undefined,
|
||||
bindUserId: undefined,
|
||||
bindTime: undefined,
|
||||
homeId: undefined,
|
||||
locationId: undefined,
|
||||
signalLevel: undefined,
|
||||
lng: undefined,
|
||||
lat: undefined,
|
||||
address: undefined,
|
||||
charging: undefined,
|
||||
deviceBattery: undefined,
|
||||
lastReportTime: undefined,
|
||||
lastOnlineTime: undefined,
|
||||
remark: undefined,
|
||||
createDept: undefined,
|
||||
createBy: undefined,
|
||||
createTime: undefined,
|
||||
updateBy: undefined,
|
||||
updateTime: undefined,
|
||||
activeDeviceNo: undefined,
|
||||
activeImei: undefined,
|
||||
activeMac: undefined,
|
||||
}
|
||||
|
||||
const form = ref();
|
||||
|
||||
function init() {
|
||||
form.value = {...initFormData};
|
||||
console.log(form.value);
|
||||
if (route.query.id) {
|
||||
getDevice(route.query.id as string).then(res => {
|
||||
Object.assign(form.value, res.data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="edit">
|
||||
<nav class="flex justify-between" style="padding-top:22px">
|
||||
<Pagecrumb></Pagecrumb>
|
||||
<div>
|
||||
<el-button plain type="info" @click="backList">返回设备列表</el-button>
|
||||
<el-button plain type="info" @click="backMain">返回设备详情</el-button>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="infoBox">
|
||||
<div class="info-item">
|
||||
<el-image class="info_img"
|
||||
src="https://ts2.tc.mm.bing.net/th/id/OIP-C.2KXRoOZ1EPBDMf6jrp6xeAHaE7?r=0&rs=1&pid=ImgDetedit&o=7&rm=3"></el-image>
|
||||
<div class="info">
|
||||
<div class="nav">
|
||||
<div class="title">Z2空气净化器</div>
|
||||
<div class="tag online">
|
||||
<i class="circle"></i>
|
||||
<span>在线</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="describe">DEV-2026-000138·AA:BB:CC:DD:EE:B·客厅·CZ-Z2023</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editCard commonCard">
|
||||
<card-nav line-color="#2563eb" subTitle="仅可修改设备名称、所在房间与备注,实时运行数据不可编辑"
|
||||
title="静态档案信息"></card-nav>
|
||||
|
||||
<el-form :model="form" label-position="top" style="margin-top:44px;">
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="form.name"
|
||||
:inputAttrs="{ placeholder: '请输入设备名称', disabled: false , showWordLimit:true ,maxlength:20}"
|
||||
:required="true"
|
||||
label="设备名称"
|
||||
prop="name"
|
||||
tip="必填,最多20个字符"
|
||||
>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="form.model"
|
||||
:inputAttrs="{ placeholder: '请输入产品型号', disabled: true }"
|
||||
:required="false"
|
||||
prop="model"
|
||||
tip="设备型号出厂固定,不支持修改"
|
||||
>
|
||||
<template #label>
|
||||
<span>产品型号</span>
|
||||
<el-tag style="margin-left: 4px" type="info">锁定</el-tag>
|
||||
</template>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="form.mac"
|
||||
:inputAttrs="{ placeholder: '请输入MAC地址', disabled: true }"
|
||||
:required="false"
|
||||
prop="mac"
|
||||
tip="MAC 地址为设备唯一标识,不支持修改"
|
||||
>
|
||||
<template #label>
|
||||
<span>MAC地址</span>
|
||||
<el-tag style="margin-left: 4px" type="info">锁定</el-tag>
|
||||
</template>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="form.locationName"
|
||||
:inputAttrs="{ placeholder: '请输入所在房间', }"
|
||||
:required="true"
|
||||
label="所在房间"
|
||||
prop="locationName"
|
||||
tip="必填,可选:客厅 / 洗衣间 / 阳台 / 厨房"
|
||||
>
|
||||
</FormItemTip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div>
|
||||
<FormItemTip
|
||||
v-model="form.remark"
|
||||
:inputAttrs="{ placeholder: '请输入备注信息,如采购批次、安装位置说明等' }"
|
||||
:required="false"
|
||||
prop="remark"
|
||||
type="textarea"
|
||||
>
|
||||
<template #label>
|
||||
<span>备注</span>
|
||||
<span style="color:rgba(156, 163, 175, 1);font-size: 12px">(选填)</span>
|
||||
</template>
|
||||
</FormItemTip>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<div v-if="form.purifier" class="readTimeInfo">
|
||||
<div class="nav flex justify-between">
|
||||
<div>
|
||||
<span class="title">实时运行数据</span>
|
||||
<el-tag style="margin-left:4px" type="info">只读</el-tag>
|
||||
</div>
|
||||
<span class="tip">由设备蓝牙自动上报,后台不可编辑 · 更新于 {{ form.updateTime }}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<ul>
|
||||
<li class="li-item">
|
||||
<div class="title">TVOC数值</div>
|
||||
<div class="value">
|
||||
<span class="Num">{{ form.tvoc }}</span>
|
||||
<el-tag type="success">优</el-tag>
|
||||
</div>
|
||||
</li>
|
||||
<li class="li-item">
|
||||
<div class="title">运行模式</div>
|
||||
<div class="value">
|
||||
<span v-if="form.runMode == '2'" class="Num">桌面模式</span>
|
||||
<span v-else-if="form.runMode == '1'" class="Num">车载模式</span>
|
||||
<span v-else class="Num">自动</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="li-item">
|
||||
<div class="title">风速挡位</div>
|
||||
<div class="value">
|
||||
<span v-if="form.fanLevel == '1'" class="Num">强力</span>
|
||||
<span v-else-if="form.fanLevel == '2'" class="Num">低档</span>
|
||||
<span v-else class="Num">标准</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="li-item">
|
||||
<div class="title">供电模式</div>
|
||||
<div class="value">
|
||||
<span v-if="form.powerMode == '0'" class="Num">USB供电</span>
|
||||
<span v-else class="Num">电池供电</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="li-item">
|
||||
<div class="title">滤芯寿命</div>
|
||||
<div class="value">
|
||||
<span class="Num">{{ form.filterLife }}%</span>
|
||||
</div>
|
||||
<div class="tip">剩余{{ form.filterRemainDays }}天</div>
|
||||
</li>
|
||||
<li class="li-item">
|
||||
<div class="title">电池电量</div>
|
||||
<div class="value">
|
||||
<span class="Num">85%</span>
|
||||
</div>
|
||||
<div class="tip">剩余128天</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tip">
|
||||
运行数据来源于设备上报,无法在后台修改;如需调整运行模式与风速,请在 APP 设备详情页操作。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<el-button size="large" type="default">取消</el-button>
|
||||
<el-button size="large" type="primary" @click="submitForm">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.edit {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
|
||||
.infoBox {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-height: 177px;
|
||||
//min-width: 750px;
|
||||
border-radius: 16px 16px 16px 16px;
|
||||
border: 1px solid rgba(238, 240, 243, 1);
|
||||
margin-top: 8px;
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.info_img {
|
||||
width: 57px;
|
||||
height: 56px;
|
||||
border-radius: 12px 12px 12px 12px;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
margin-left: 24px;
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.title {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 22px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tag {
|
||||
margin-left: 5px;
|
||||
width: 62px;
|
||||
height: 28px;
|
||||
border-radius: 14px;
|
||||
|
||||
&.online {
|
||||
background-color: rgba(232, 247, 238, 1);
|
||||
color: rgba(22, 163, 74, 1);
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.circle {
|
||||
margin-right: 6px;
|
||||
display: block;
|
||||
width: 7px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background-color: rgba(22, 163, 74, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.describe {
|
||||
line-height: 16px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.info-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-top: 28px;
|
||||
|
||||
.info-list-item {
|
||||
.title {
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.value {
|
||||
height: 40px;
|
||||
line-height: 20px;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeBox {
|
||||
.title {
|
||||
text-align: right;
|
||||
line-height: 16px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.time {
|
||||
line-height: 22px;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 16px;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
|
||||
span {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(37, 99, 235, 1);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.circle {
|
||||
margin-right: 6px;
|
||||
display: block;
|
||||
width: 7px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background-color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editCard {
|
||||
min-height: 763px;
|
||||
|
||||
.readTimeInfo {
|
||||
.nav {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.title {
|
||||
line-height: 17px;
|
||||
color: rgba(31, 41, 55, 1);
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tip {
|
||||
line-height: 16px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ul {
|
||||
gap: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
|
||||
.li-item {
|
||||
height: 102px;
|
||||
border-radius: 12px 12px 12px 12px;
|
||||
background-color: rgba(247, 249, 252, 1);
|
||||
border: 1px solid rgba(238, 240, 243, 1);
|
||||
padding: 17px;
|
||||
|
||||
.title {
|
||||
margin-bottom: 10px;
|
||||
line-height: 16px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.value {
|
||||
margin: 4px 0 8px 0;
|
||||
|
||||
.Num {
|
||||
line-height: 24px;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.tip {
|
||||
line-height: 14px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > .tip {
|
||||
margin-top: 16px;
|
||||
height: 42px;
|
||||
border-radius: 12px 12px 12px 12px;
|
||||
background-color: rgba(241, 245, 249, 1);
|
||||
padding: 12px;
|
||||
line-height: 18px;
|
||||
color: rgba(100, 116, 139, 1);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
368
src/views/system/device/devicelist/index.vue
Normal file
368
src/views/system/device/devicelist/index.vue
Normal file
@@ -0,0 +1,368 @@
|
||||
<template>
|
||||
<div class="p-2 page-shell system-device-page">
|
||||
<div class="search-wrap">
|
||||
<el-card :class="{ 'is-collapsed': !showSearch }" class="search-panel" shadow="hover">
|
||||
<el-form ref="queryFormRef" :inline="true" :model="queryParams" class="query-form" label-position="top">
|
||||
<el-form-item label="设备名称" prop="name">
|
||||
<el-input v-model="queryParams.name" clearable placeholder="请输入设备名称" size="large"
|
||||
@keyup.enter="handleQuery"/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="产品型号" prop="model">
|
||||
<el-input v-model="queryParams.model" clearable placeholder="请输入产品型号" size="large"
|
||||
@keyup.enter="handleQuery"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="在线状态" prop="status">
|
||||
<el-select v-model="queryParams.status" clearable placeholder="请选择状态" size="large">
|
||||
<el-option :value="true" label="是"/>
|
||||
<el-option :value="false" label="否"/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button size="large" type="primary" @click="handleQuery">搜索</el-button>
|
||||
<el-button size="large" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-card class="table-panel" shadow="hover">
|
||||
<template #header>
|
||||
<div class="toolbar-shell">
|
||||
<div class="table-heading">
|
||||
<el-button type="primary">
|
||||
<span style="margin:0 8px;color: #fff;">全部</span>
|
||||
<span style="color: #fff;font-weight: bold">{{ total }}</span>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button v-hasPermi="['device:device:export']" plain size="large" type="info" @click="handleExport">
|
||||
批量导出
|
||||
</el-button>
|
||||
<right-toolbar v-model:show-search="showSearch" :search="false" @query-table="getList"></right-toolbar>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table v-loading="loading" :data="deviceList" border class="data-table"
|
||||
@selection-change="handleSelectionChange">
|
||||
<el-table-column align="center" type="selection" width="55"/>
|
||||
<el-table-column align="center" label="设备名称" prop="name"/>
|
||||
<el-table-column align="center" label="产品型号" prop="model"/>
|
||||
<el-table-column align="center" label="所在房间" prop="homeName"/>
|
||||
<el-table-column align="center" label="在线状态" prop="status" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.status === '0'" type="success">在线</el-tag>
|
||||
<el-tag v-else type="danger">离线</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="当前TVOC" prop="tvoc">
|
||||
<template #default="scope">
|
||||
<div class="flex flex-items-center justify-center">
|
||||
<div>{{ scope.row.tvoc ?? '--' }}</div>
|
||||
<div>
|
||||
<el-tag v-if="scope.row.airLevel == '0'" type="primary">优</el-tag>
|
||||
<el-tag v-else-if="scope.row.airLevel == '1'" type="success">良</el-tag>
|
||||
<el-tag v-else-if="scope.row.airLevel == '2'" type="warning">差</el-tag>
|
||||
<el-tag v-else-if="scope.row.airLevel == '3'" type="danger">污染</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="滤芯寿命" prop="filterLife" width="100">
|
||||
<template #default="scope">
|
||||
<span :class="{
|
||||
lowLife: scope.row.filterLife < 50
|
||||
}" class="defaultLife">{{ scope.row.filterLife }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="电池电量" prop="deviceBattery" width="100">
|
||||
<template #default="scope">
|
||||
<span :class="{
|
||||
lowBattery: scope.row.filterLife < 50
|
||||
}" class="defaultLife">{{ scope.row.filterLife }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="运行模式" prop="runMode" width="100">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.runMode == '2'" style="color: rgba(17, 24, 39, 1);">桌面模式</span>
|
||||
<span v-else-if="scope.row.runMode == '1'" style="color: rgba(17, 24, 39, 1);">车载模式</span>
|
||||
<span v-else style="color: rgba(17, 24, 39, 1);">自动</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="风速挡位" prop="mac" width="100">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.fanLevel == '1'" style="color: rgba(17, 24, 39, 1);">强力</span>
|
||||
<span v-else-if="scope.row.fanLevel == '2'" style="color: rgba(17, 24, 39, 1);">低档</span>
|
||||
<span v-else style="color: rgba(17, 24, 39, 1);">标准</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="创建时间" prop="createTime"/>
|
||||
<el-table-column align="center" class-name="small-padding fixed-width" label="操作" min-width="100">
|
||||
<template #default="scope">
|
||||
<el-button v-hasPermi="['device:device:query']" plain type="primary"
|
||||
@click="handleUpdate(scope.row)">查看详情
|
||||
</el-button>
|
||||
<el-button v-hasPermi="['device:device:edit']" plain type="info"
|
||||
@click="handleEdit(scope.row)">编辑
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total > 0" v-model:limit="queryParams.pageSize" v-model:page="queryParams.pageNum"
|
||||
:total="total" @pagination="getList"/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="Device" setup>
|
||||
import {
|
||||
delDevice,
|
||||
listDevice,
|
||||
} from '@/api/system/device';
|
||||
import {DeviceForm, DeviceQuery, DeviceVO} from '@/api/system/device/types';
|
||||
import {useLoading} from '@/hooks/async/useLoading';
|
||||
import {useDateRangeQuery} from '@/hooks/form/useDateRangeQuery';
|
||||
import {useSearchReset} from '@/hooks/form/useSearchReset';
|
||||
import {useSearchToggle} from '@/hooks/form/useSearchToggle';
|
||||
import {useTableSelection} from '@/hooks/table/useTableSelection';
|
||||
import modal from '@/plugins/modal';
|
||||
import {download as requestDownload} from '@/utils/request';
|
||||
|
||||
|
||||
const router = useRouter();
|
||||
const deviceList = ref<DeviceVO[]>([]);
|
||||
const {loading, withLoading} = useLoading(true);
|
||||
const {showSearch} = useSearchToggle();
|
||||
const total = ref(0);
|
||||
const {
|
||||
applyDateRange: applyBindTimeDateRange,
|
||||
resetDateRange: resetBindTimeDateRange
|
||||
} = useDateRangeQuery('BindTime');
|
||||
const {
|
||||
applyDateRange: applyLastReportTimeDateRange,
|
||||
resetDateRange: resetLastReportTimeDateRange
|
||||
} = useDateRangeQuery('LastReportTime');
|
||||
const {
|
||||
applyDateRange: applyLastOnlineTimeDateRange,
|
||||
resetDateRange: resetLastOnlineTimeDateRange
|
||||
} = useDateRangeQuery('LastOnlineTime');
|
||||
const {
|
||||
applyDateRange: applyCreateTimeDateRange,
|
||||
resetDateRange: resetCreateTimeDateRange
|
||||
} = useDateRangeQuery('CreateTime');
|
||||
const {
|
||||
applyDateRange: applyUpdateTimeDateRange,
|
||||
resetDateRange: resetUpdateTimeDateRange
|
||||
} = useDateRangeQuery('UpdateTime');
|
||||
|
||||
const queryFormRef = ref<ElFormInstance>();
|
||||
|
||||
const initFormData: DeviceForm = {
|
||||
deviceId: undefined,
|
||||
deviceNo: undefined,
|
||||
imei: undefined,
|
||||
name: undefined,
|
||||
productId: undefined,
|
||||
model: undefined,
|
||||
sn: undefined,
|
||||
hwVersion: undefined,
|
||||
swVersion: undefined,
|
||||
fwVersion: undefined,
|
||||
pkgVersion: undefined,
|
||||
ip: undefined,
|
||||
mac: undefined,
|
||||
wifiSsid: undefined,
|
||||
bluetoothConnected: undefined,
|
||||
status: undefined,
|
||||
bindUserId: undefined,
|
||||
bindTime: undefined,
|
||||
homeId: undefined,
|
||||
locationId: undefined,
|
||||
signalLevel: undefined,
|
||||
lng: undefined,
|
||||
lat: undefined,
|
||||
address: undefined,
|
||||
charging: undefined,
|
||||
deviceBattery: undefined,
|
||||
lastReportTime: undefined,
|
||||
lastOnlineTime: undefined,
|
||||
remark: undefined,
|
||||
createDept: undefined,
|
||||
createBy: undefined,
|
||||
createTime: undefined,
|
||||
updateBy: undefined,
|
||||
updateTime: undefined,
|
||||
activeDeviceNo: undefined,
|
||||
activeImei: undefined,
|
||||
activeMac: undefined,
|
||||
}
|
||||
const data = reactive<PageData<DeviceForm, DeviceQuery>>({
|
||||
form: {...initFormData},
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
deviceNo: undefined,
|
||||
imei: undefined,
|
||||
name: undefined,
|
||||
productId: undefined,
|
||||
model: undefined,
|
||||
sn: undefined,
|
||||
hwVersion: undefined,
|
||||
swVersion: undefined,
|
||||
fwVersion: undefined,
|
||||
pkgVersion: undefined,
|
||||
ip: undefined,
|
||||
mac: undefined,
|
||||
wifiSsid: undefined,
|
||||
bluetoothConnected: undefined,
|
||||
status: undefined,
|
||||
bindUserId: undefined,
|
||||
homeId: undefined,
|
||||
locationId: undefined,
|
||||
signalLevel: undefined,
|
||||
lng: undefined,
|
||||
lat: undefined,
|
||||
address: undefined,
|
||||
charging: undefined,
|
||||
deviceBattery: undefined,
|
||||
createDept: undefined,
|
||||
createBy: undefined,
|
||||
updateBy: undefined,
|
||||
activeDeviceNo: undefined,
|
||||
activeImei: undefined,
|
||||
activeMac: undefined,
|
||||
params: {
|
||||
bindTime: undefined,
|
||||
lastReportTime: undefined,
|
||||
lastOnlineTime: undefined,
|
||||
createTime: undefined,
|
||||
updateTime: undefined,
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
deviceId: [
|
||||
{required: true, message: "主键不能为空", trigger: "change"}
|
||||
],
|
||||
deviceNo: [
|
||||
{required: true, message: "业务编号,未删除记录内唯一;逻辑删除后可再次使用不能为空", trigger: "blur"}
|
||||
],
|
||||
bluetoothConnected: [
|
||||
{required: true, message: "蓝牙不能为空", trigger: "blur"}
|
||||
],
|
||||
status: [
|
||||
{required: true, message: "状态不能为空", trigger: "change"}
|
||||
],
|
||||
signalLevel: [
|
||||
{required: true, message: "信号等级不能为空", trigger: "change"}
|
||||
],
|
||||
}
|
||||
});
|
||||
|
||||
const {queryParams,} = toRefs(data);
|
||||
const {ids, handleSelectionChange} = useTableSelection<DeviceVO>(item => item.deviceId);
|
||||
|
||||
|
||||
/** 查询设备主列表 */
|
||||
const getList = async () => {
|
||||
await withLoading(async () => {
|
||||
let params = queryParams.value;
|
||||
params = applyBindTimeDateRange(params);
|
||||
params = applyLastReportTimeDateRange(params);
|
||||
params = applyLastOnlineTimeDateRange(params);
|
||||
params = applyCreateTimeDateRange(params);
|
||||
params = applyUpdateTimeDateRange(params);
|
||||
const res = await listDevice(params);
|
||||
deviceList.value = res.data?.rows;
|
||||
total.value = res.data?.total;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.value.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const {resetQuery} = useSearchReset({
|
||||
queryFormRef,
|
||||
queryParams,
|
||||
pageNumKey: 'pageNum',
|
||||
pageSizeKey: 'pageSize',
|
||||
initialPageSize: 10,
|
||||
resetExtras: () => {
|
||||
resetBindTimeDateRange();
|
||||
resetLastReportTimeDateRange();
|
||||
resetLastOnlineTimeDateRange();
|
||||
resetCreateTimeDateRange();
|
||||
resetUpdateTimeDateRange();
|
||||
},
|
||||
afterReset: () => {
|
||||
handleQuery();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function handleUpdate(row: Partial<DeviceVO>) {
|
||||
router.push({
|
||||
path: '/device/deviceMain',
|
||||
query: {
|
||||
id: row.deviceId,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleEdit(row: Partial<DeviceVO>) {
|
||||
router.push({
|
||||
path: '/device/deviceEdit',
|
||||
query: {
|
||||
id: row.deviceId,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (row?: Partial<DeviceVO>) => {
|
||||
const _deviceIds = row?.deviceId || ids.value;
|
||||
await modal.confirm('是否确认删除该设备?');
|
||||
await delDevice(_deviceIds);
|
||||
modal.msgSuccess('删除成功');
|
||||
await getList();
|
||||
};
|
||||
|
||||
/** 导出按钮操作 */
|
||||
const handleExport = () => {
|
||||
requestDownload(
|
||||
'/device/device/export',
|
||||
{
|
||||
...queryParams.value
|
||||
},
|
||||
`设备管理_${new Date().getTime()}.xlsx`
|
||||
);
|
||||
};
|
||||
onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
.search-wrap {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.defaultLife {
|
||||
color: gray;
|
||||
|
||||
&.lowLife {
|
||||
color: red;
|
||||
}
|
||||
|
||||
&.lowBattery {
|
||||
color: #4B5563;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
616
src/views/system/device/devicelist/main.vue
Normal file
616
src/views/system/device/devicelist/main.vue
Normal file
@@ -0,0 +1,616 @@
|
||||
<script lang="ts" setup>
|
||||
import cardNav from '@/components/cardNav/index.vue';
|
||||
import {getDevice} from "@/api/system/device";
|
||||
import {DeviceForm} from "@/api/system/device/types";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function backList() {
|
||||
router.replace({
|
||||
path: '/device/deviceList'
|
||||
})
|
||||
}
|
||||
|
||||
function editDevice() {
|
||||
router.push({
|
||||
path: '/device/deviceEdit',
|
||||
query: {
|
||||
id: form.value.deviceId,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const initFormData: DeviceForm = {
|
||||
deviceId: undefined,
|
||||
deviceNo: undefined,
|
||||
imei: undefined,
|
||||
name: undefined,
|
||||
productId: undefined,
|
||||
model: undefined,
|
||||
sn: undefined,
|
||||
hwVersion: undefined,
|
||||
swVersion: undefined,
|
||||
fwVersion: undefined,
|
||||
pkgVersion: undefined,
|
||||
ip: undefined,
|
||||
mac: undefined,
|
||||
wifiSsid: undefined,
|
||||
bluetoothConnected: undefined,
|
||||
status: undefined,
|
||||
bindUserId: undefined,
|
||||
bindTime: undefined,
|
||||
homeId: undefined,
|
||||
locationId: undefined,
|
||||
signalLevel: undefined,
|
||||
lng: undefined,
|
||||
lat: undefined,
|
||||
address: undefined,
|
||||
charging: undefined,
|
||||
deviceBattery: undefined,
|
||||
lastReportTime: undefined,
|
||||
lastOnlineTime: undefined,
|
||||
remark: undefined,
|
||||
createDept: undefined,
|
||||
createBy: undefined,
|
||||
createTime: undefined,
|
||||
updateBy: undefined,
|
||||
updateTime: undefined,
|
||||
activeDeviceNo: undefined,
|
||||
activeImei: undefined,
|
||||
activeMac: undefined,
|
||||
}
|
||||
|
||||
const form = ref();
|
||||
|
||||
function init() {
|
||||
form.value = {...initFormData};
|
||||
console.log(form.value);
|
||||
if (route.query.id) {
|
||||
getDevice(route.query.id as string).then(res => {
|
||||
Object.assign(form.value, res.data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="Main">
|
||||
<nav class="flex justify-between" style="padding-top: 22px;">
|
||||
<Pagecrumb></Pagecrumb>
|
||||
<div>
|
||||
<el-button plain type="info" @click="backList">返回设备列表</el-button>
|
||||
<el-button type="primary" @click="editDevice">编辑设备</el-button>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="infoBox">
|
||||
<div class="info-item">
|
||||
<el-image class="info_img"
|
||||
src="https://ts2.tc.mm.bing.net/th/id/OIP-C.2KXRoOZ1EPBDMf6jrp6xeAHaE7?r=0&rs=1&pid=ImgDetMain&o=7&rm=3"></el-image>
|
||||
<div class="info">
|
||||
<div class="nav">
|
||||
<div class="title">{{ form.name }}</div>
|
||||
<div class="tag online">
|
||||
<i class="circle"></i>
|
||||
<span>在线</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-list">
|
||||
<div class="info-list-item">
|
||||
<div class="title">设备ID</div>
|
||||
<div class="value">{{ form.deviceId }}</div>
|
||||
</div>
|
||||
<div class="info-list-item">
|
||||
<div class="title">MAC地址</div>
|
||||
<div class="value">{{ form.mac }}</div>
|
||||
</div>
|
||||
<div class="info-list-item">
|
||||
<div class="title">所在房间</div>
|
||||
<div class="value">{{ form.locationName }}</div>
|
||||
</div>
|
||||
<div class="info-list-item">
|
||||
<div class="title">产品型号</div>
|
||||
<div class="value">{{ form.model }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeBox">
|
||||
<div class="title">最后上报时间</div>
|
||||
<div class="time">{{ form.updateTime }}</div>
|
||||
<div class="tip">
|
||||
<i class="circle"></i>
|
||||
<span>每30s自动刷新</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="baseInfo">
|
||||
<cardNav subTitle="静态档案信息,如需修改请前往「编辑设备」页" title="设备基础档案"></cardNav>
|
||||
<div class=" gridBox">
|
||||
<div class="grid-item">
|
||||
<div class="title">设备ID</div>
|
||||
<div class="value">{{ form.deviceId }}</div>
|
||||
</div>
|
||||
<div class="grid-item">
|
||||
<div class="title">设备名称</div>
|
||||
<div class="value">{{ form.name }}</div>
|
||||
</div>
|
||||
<div class="grid-item">
|
||||
<div class="title">MAC地址</div>
|
||||
<div class="value">{{ form.mac }}</div>
|
||||
</div>
|
||||
<div class="grid-item">
|
||||
<div class="title">产品型号</div>
|
||||
<div class="value">{{ form.model }}</div>
|
||||
</div>
|
||||
<div class="grid-item">
|
||||
<div class="title">所在房间</div>
|
||||
<div class="value">{{ form.homeName }}</div>
|
||||
</div>
|
||||
<div class="grid-item">
|
||||
</div>
|
||||
<div class="grid-item">
|
||||
<div class="title">档案创建时间</div>
|
||||
<div class="value">{{ form.createTime }}</div>
|
||||
</div>
|
||||
<div class="grid-item">
|
||||
<div class="title">档案更新时间</div>
|
||||
<div class="value">{{ form.updateTime }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="form.purifier" class="readTimeBox">
|
||||
<cardNav line-color="#14b8a6" subTitle="设备蓝牙上报 · 只读 · 每 30 秒自动刷新"
|
||||
title="设备实时运行状态"></cardNav>
|
||||
<div class="flexBox">
|
||||
<div class="flex-item">
|
||||
<div class="flex-item-nav">
|
||||
<i class="circle"></i>
|
||||
<span>环境数据</span>
|
||||
</div>
|
||||
<div class="numBox">
|
||||
<div class="num">{{ form.tvoc }}</div>
|
||||
<div class="tag">
|
||||
<i class="circle"></i>
|
||||
<span>在线</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subTitle">TVOC 传感器原始读数(未换算为浓度值)</div>
|
||||
<div class="footer">
|
||||
<span>数据更新时间</span>
|
||||
<span style="color: rgba(17, 24, 39, 1);">2026-09-30 12:50:30</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-item flex flex-col">
|
||||
<div class="flex-item-nav">
|
||||
<i class="circle" style="background: #2563eb"></i>
|
||||
<span>运行数据</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col justify-evenly">
|
||||
<div class="footer" style="margin-top: 0">
|
||||
<span>运行模式</span>
|
||||
<div>
|
||||
<span v-if="form.runMode == '2'" style="color: rgba(17, 24, 39, 1);">桌面模式</span>
|
||||
<span v-else-if="form.runMode == '1'" style="color: rgba(17, 24, 39, 1);">车载模式</span>
|
||||
<span v-else style="color: rgba(17, 24, 39, 1);">自动</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer" style="margin-top: 0">
|
||||
<span>风速挡位</span>
|
||||
<div>
|
||||
<span v-if="form.fanLevel == '1'" style="color: rgba(17, 24, 39, 1);">强力</span>
|
||||
<span v-else-if="form.fanLevel == '2'" style="color: rgba(17, 24, 39, 1);">低档</span>
|
||||
<span v-else style="color: rgba(17, 24, 39, 1);">标准</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer" style="margin-top: 0">
|
||||
<span>供电模式</span>
|
||||
<div>
|
||||
<span v-if="form.powerMode == '0'" style="color: rgba(17, 24, 39, 1);">USB供电</span>
|
||||
<span v-else style="color: rgba(17, 24, 39, 1);">电池供电</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer" style="margin-top: 0">
|
||||
<span>停车关机模式</span>
|
||||
<el-tag v-if="form.parkingShutdown == '1'" type="success">开启</el-tag>
|
||||
<el-tag v-else type="danger">关闭</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-item flex flex-col ">
|
||||
<div class="flex-item-nav">
|
||||
<i class="circle" style="background: #f59e0b"></i>
|
||||
<span>耗材与电量</span>
|
||||
</div>
|
||||
<div class="flex-1 flex justify-evenly">
|
||||
<div class="ageBox">
|
||||
<div class="title">滤芯寿命</div>
|
||||
<div class="processInfo">
|
||||
<span class="num">{{ form.filterLife }}</span>
|
||||
<span class="unit">%</span>
|
||||
</div>
|
||||
<el-progress :percentage="form.filterLife" :show-text="false"></el-progress>
|
||||
<div class="tip">
|
||||
<span>预计剩余可用{{ form.filterRemainDays }}天</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
<div class="ageBox BatteryBox">
|
||||
<div class="title">电池电量</div>
|
||||
<div class="processInfo">
|
||||
<span class="num">76</span>
|
||||
<span class="unit">%</span>
|
||||
</div>
|
||||
<el-progress :percentage="30" :show-text="false" color="#22C55E"></el-progress>
|
||||
<el-tag type="success">充电中</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-item flex flex-col ">
|
||||
<div class="flex-item-nav">
|
||||
<i class="circle" style="background: #6366f1"></i>
|
||||
<span>策略说明 · 停车关机模式</span>
|
||||
</div>
|
||||
<ul style="margin-top: 16px;">
|
||||
<li class="modelLi">
|
||||
<el-tag type="success">开启</el-tag>
|
||||
<span class="describe">USB 外部供电断开后,电池供电继续运行 5 分钟消杀再自动关机;低电量 SOC<10% 时关闭等离子消杀;SOC<5% 整机锁机(防止过放)。</span>
|
||||
</li>
|
||||
<li class="modelLi">
|
||||
<el-tag type="info">关闭</el-tag>
|
||||
<span class="describe">不执行该套低电量策略,仅保留基础净化逻辑。</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.Main {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
|
||||
.infoBox {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-height: 177px;
|
||||
min-width: 750px;
|
||||
border-radius: 16px 16px 16px 16px;
|
||||
border: 1px solid rgba(238, 240, 243, 1);
|
||||
margin-top: 8px;
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.info_img {
|
||||
width: 97px;
|
||||
height: 97px;
|
||||
border-radius: 16px 16px 16px 16px;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
margin-left: 24px;
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
|
||||
.title {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 22px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tag {
|
||||
margin-left: 5px;
|
||||
width: 62px;
|
||||
height: 28px;
|
||||
border-radius: 14px;
|
||||
|
||||
&.online {
|
||||
background-color: rgba(232, 247, 238, 1);
|
||||
color: rgba(22, 163, 74, 1);
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.circle {
|
||||
margin-right: 6px;
|
||||
display: block;
|
||||
width: 7px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background-color: rgba(22, 163, 74, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.info-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-top: 28px;
|
||||
|
||||
.info-list-item {
|
||||
.title {
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.value {
|
||||
height: 40px;
|
||||
line-height: 20px;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeBox {
|
||||
.title {
|
||||
text-align: right;
|
||||
line-height: 16px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.time {
|
||||
line-height: 22px;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 16px;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
|
||||
span {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(37, 99, 235, 1);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.circle {
|
||||
margin-right: 6px;
|
||||
display: block;
|
||||
width: 7px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background-color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.baseInfo {
|
||||
padding: 25px;
|
||||
min-height: 248px;
|
||||
border-radius: 16px 16px 16px 16px;
|
||||
border: 1px solid rgba(238, 240, 243, 1);
|
||||
margin: 16px 0;
|
||||
|
||||
.gridBox {
|
||||
margin-top: 44px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(381px, 1fr));
|
||||
gap: 20px;
|
||||
|
||||
@media (max-width: 1659px) {
|
||||
grid-template-columns: repeat(3, minmax(381px, 1fr));
|
||||
}
|
||||
@media (max-width: 1250px) {
|
||||
grid-template-columns: repeat(2, minmax(381px, 1fr));
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
grid-template-columns: repeat(1, minmax(381px, 1fr));
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
max-width: 381px;
|
||||
|
||||
.title {
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.value {
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.readTimeBox {
|
||||
padding: 25px;
|
||||
border-radius: 16px 16px 16px 16px;
|
||||
border: 1px solid rgba(238, 240, 243, 1);
|
||||
|
||||
.flexBox {
|
||||
margin-top: 44px;
|
||||
gap: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(360px, 1fr));
|
||||
@media (max-width: 900px) {
|
||||
grid-template-columns: repeat(1, minmax(381px, 1fr));
|
||||
}
|
||||
|
||||
.flex-item {
|
||||
min-height: 201px;
|
||||
border-radius: 12px 12px 12px 12px;
|
||||
background-color: rgba(247, 249, 252, 1);
|
||||
border: 1px solid rgba(238, 240, 243, 1);
|
||||
padding: 21px;
|
||||
|
||||
.flex-item-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.circle {
|
||||
margin-right: 6px;
|
||||
display: block;
|
||||
width: 7px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background-color: rgba(22, 163, 74, 1);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.numBox {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
margin-top: 17px;
|
||||
|
||||
.num {
|
||||
line-height: 43px;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 36px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tag {
|
||||
margin-left: 5px;
|
||||
width: 62px;
|
||||
height: 28px;
|
||||
border-radius: 14px;
|
||||
|
||||
$backColor: rgba(232, 247, 238, 1);
|
||||
$textColor: rgba(22, 163, 74, 1);
|
||||
|
||||
background-color: $backColor;
|
||||
color: $textColor;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.circle {
|
||||
margin-right: 6px;
|
||||
display: block;
|
||||
width: 7px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background-color: $textColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.subTitle {
|
||||
margin-top: 12px;
|
||||
line-height: 16px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
line-height: 14px;
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
// 滤芯寿命
|
||||
.ageBox {
|
||||
flex: 1;
|
||||
margin-top: 16px;
|
||||
|
||||
.title {
|
||||
line-height: 18px;
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.processInfo {
|
||||
margin: 12px 0;
|
||||
|
||||
.num {
|
||||
line-height: 31px;
|
||||
color: rgba(17, 24, 39, 1);
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.unit {
|
||||
line-height: 17px;
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 14px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.tip {
|
||||
line-height: 16px;
|
||||
color: rgba(156, 163, 175, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.line {
|
||||
width: 2px;
|
||||
height: 96px;
|
||||
background-color: rgba(238, 240, 243, 1);
|
||||
margin: 15px 24px;
|
||||
}
|
||||
|
||||
.modelLi {
|
||||
margin-bottom: 12px;
|
||||
|
||||
span.describe {
|
||||
margin-left: 12px;
|
||||
line-height: 16px;
|
||||
color: rgba(75, 85, 99, 1);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
77
src/views/system/device/devicelist/type.ts
Normal file
77
src/views/system/device/devicelist/type.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**设备状态*/
|
||||
enum Status {
|
||||
/**未激活*/
|
||||
unActive = '0',
|
||||
/**在线*/
|
||||
online = '1',
|
||||
/**离线*/
|
||||
offline = '2',
|
||||
}
|
||||
|
||||
/**运行模式*/
|
||||
enum runMode {
|
||||
/**自动*/
|
||||
auto = 0,
|
||||
/**车载模式*/
|
||||
car = 0,
|
||||
/**桌面模式*/
|
||||
dask = 2
|
||||
}
|
||||
|
||||
/**
|
||||
* 风速
|
||||
* */
|
||||
enum fanLevel {
|
||||
/**
|
||||
* 标准
|
||||
*/
|
||||
normal = 0,
|
||||
/**
|
||||
* 强力
|
||||
* */
|
||||
power = 1,
|
||||
/**
|
||||
* 低档
|
||||
* */
|
||||
lower = 2
|
||||
}
|
||||
|
||||
/**
|
||||
* 空气质量
|
||||
* */
|
||||
enum airLevel {
|
||||
/**优*/
|
||||
Excellent,
|
||||
/**良*/
|
||||
good,
|
||||
/**差*/
|
||||
poor,
|
||||
/**污染*/
|
||||
polluted
|
||||
}
|
||||
|
||||
/**供电方式*/
|
||||
enum powerMode {
|
||||
/**USB*/
|
||||
USB,
|
||||
/**电池*/
|
||||
Battery
|
||||
}
|
||||
|
||||
/**蓝牙连接情况*/
|
||||
enum bluetoothConnected {
|
||||
/**未连接*/
|
||||
unConnected = 0,
|
||||
/**已连接*/
|
||||
Connected = 1,
|
||||
}
|
||||
|
||||
/**
|
||||
* @enum {number}
|
||||
* */
|
||||
enum parkingShutDown {
|
||||
/**开*/
|
||||
open,
|
||||
/**关*/
|
||||
close
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="p-2 app-container dict-page">
|
||||
<div class="dict-page">
|
||||
<el-row :gutter="16" class="dict-grid">
|
||||
<!-- 字典类型 -->
|
||||
<el-col :xs="24" :lg="12">
|
||||
@@ -18,7 +18,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<right-toolbar v-model:show-search="showTypeSearch" :search="false" @query-table="getTypeList" />
|
||||
<right-toolbar v-model:show-search="showTypeSearch" :search="false" @query-table="getTypeList"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -97,15 +97,13 @@
|
||||
ref="typeTableRef"
|
||||
v-loading="typeLoading"
|
||||
border
|
||||
class="data-table"
|
||||
:data="typeList"
|
||||
highlight-current-row
|
||||
@row-click="handleTypeRowClick"
|
||||
@selection-change="handleTypeSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column v-if="false" label="字典编号" align="center" prop="dictId" />
|
||||
<el-table-column label="字典名称" align="center" prop="dictName" width="120" />
|
||||
<el-table-column align="center" type="selection" width="55"/>
|
||||
<el-table-column v-if="false" align="center" label="字典编号" prop="dictId"/>
|
||||
<el-table-column align="center" label="字典名称" prop="dictName" width="120"/>
|
||||
<el-table-column label="字典类型" align="center" prop="dictType" width="160">
|
||||
<template #default="scope">
|
||||
<span class="link-type" @click.stop="handleTypeRowClick(scope.row)">
|
||||
@@ -113,7 +111,7 @@
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" align="center" prop="remark" width="160" />
|
||||
<el-table-column align="center" label="备注" prop="remark" width="160"/>
|
||||
<el-table-column label="创建时间" align="center" prop="createTime" width="180">
|
||||
<template #default="scope">
|
||||
<span>{{ parseTime(scope.row.createTime) }}</span>
|
||||
@@ -178,7 +176,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<right-toolbar v-model:show-search="showDataSearch" :search="false" @query-table="getDataList" />
|
||||
<right-toolbar v-model:show-search="showDataSearch" :search="false" @query-table="getDataList"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -250,12 +248,12 @@
|
||||
<el-table
|
||||
v-loading="dataLoading"
|
||||
border
|
||||
class="data-table"
|
||||
style="max-height:500px;"
|
||||
:data="dataList"
|
||||
@selection-change="handleDataSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column v-if="false" label="字典编码" align="center" prop="dictCode" />
|
||||
<el-table-column align="center" type="selection" width="55"/>
|
||||
<el-table-column v-if="false" align="center" label="字典编码" prop="dictCode"/>
|
||||
<el-table-column label="字典标签" align="center" prop="dictLabel" width="100">
|
||||
<template #default="scope">
|
||||
<span
|
||||
@@ -279,9 +277,9 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="字典键值" align="center" prop="dictValue" width="100" />
|
||||
<el-table-column label="字典排序" align="center" prop="dictSort" width="80" />
|
||||
<el-table-column label="备注" align="center" prop="remark" width="100" />
|
||||
<el-table-column align="center" label="字典键值" prop="dictValue" width="100"/>
|
||||
<el-table-column align="center" label="字典排序" prop="dictSort" width="80"/>
|
||||
<el-table-column align="center" label="备注" prop="remark" width="100"/>
|
||||
<el-table-column label="创建时间" align="center" prop="createTime" width="180">
|
||||
<template #default="scope">
|
||||
<span>{{ parseTime(scope.row.createTime) }}</span>
|
||||
@@ -333,7 +331,7 @@
|
||||
<el-dialog v-model="typeDialog.visible" :title="typeDialog.title" width="500px" append-to-body>
|
||||
<el-form ref="typeFormRef" :model="typeForm" :rules="typeRules" label-width="100px">
|
||||
<el-form-item label="字典名称" prop="dictName">
|
||||
<el-input v-model="typeForm.dictName" placeholder="请输入字典名称" />
|
||||
<el-input v-model="typeForm.dictName" placeholder="请输入字典名称"/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="dictType">
|
||||
<template #label>
|
||||
@@ -344,7 +342,7 @@
|
||||
字典类型
|
||||
</span>
|
||||
</template>
|
||||
<el-input v-model="typeForm.dictType" placeholder="请输入字典类型" maxlength="100" />
|
||||
<el-input v-model="typeForm.dictType" maxlength="100" placeholder="请输入字典类型"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="typeForm.remark" type="textarea" placeholder="请输入内容"></el-input>
|
||||
@@ -362,19 +360,19 @@
|
||||
<el-dialog v-model="dataDialog.visible" :title="dataDialog.title" width="500px" append-to-body>
|
||||
<el-form ref="dataFormRef" :model="dataForm" :rules="dataRules" label-width="80px">
|
||||
<el-form-item label="字典类型">
|
||||
<el-input v-model="dataForm.dictType" :disabled="true" />
|
||||
<el-input v-model="dataForm.dictType" :disabled="true"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="数据标签" prop="dictLabel">
|
||||
<el-input v-model="dataForm.dictLabel" placeholder="请输入数据标签" />
|
||||
<el-input v-model="dataForm.dictLabel" placeholder="请输入数据标签"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="数据键值" prop="dictValue">
|
||||
<el-input v-model="dataForm.dictValue" placeholder="请输入数据键值" />
|
||||
<el-input v-model="dataForm.dictValue" placeholder="请输入数据键值"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="样式属性" prop="cssClass">
|
||||
<el-input v-model="dataForm.cssClass" placeholder="请输入样式属性" />
|
||||
<el-input v-model="dataForm.cssClass" placeholder="请输入样式属性"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="显示排序" prop="dictSort">
|
||||
<el-input-number v-model="dataForm.dictSort" controls-position="right" :min="0" />
|
||||
<el-input-number v-model="dataForm.dictSort" :min="0" controls-position="right"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="回显样式" prop="listClass">
|
||||
<el-select v-model="dataForm.listClass">
|
||||
@@ -401,14 +399,14 @@
|
||||
</template>
|
||||
|
||||
<script setup name="Dict" lang="ts">
|
||||
import { listData, getData, delData, addData, updateData } from '@/api/system/dict/data';
|
||||
import { DictDataForm, DictDataQuery, DictDataVO } from '@/api/system/dict/data/types';
|
||||
import { listType, getType, delType, addType, updateType, refreshCache } from '@/api/system/dict/type';
|
||||
import { DictTypeForm, DictTypeQuery, DictTypeVO } from '@/api/system/dict/type/types';
|
||||
import {listData, getData, delData, addData, updateData} from '@/api/system/dict/data';
|
||||
import {DictDataForm, DictDataQuery, DictDataVO} from '@/api/system/dict/data/types';
|
||||
import {listType, getType, delType, addType, updateType, refreshCache} from '@/api/system/dict/type';
|
||||
import {DictTypeForm, DictTypeQuery, DictTypeVO} from '@/api/system/dict/type/types';
|
||||
import modal from '@/plugins/modal';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { download as requestDownload } from '@/utils/request';
|
||||
import { parseTime } from '@/utils/ruoyi';
|
||||
import {useDictStore} from '@/store/modules/dict';
|
||||
import {download as requestDownload} from '@/utils/request';
|
||||
import {parseTime} from '@/utils/ruoyi';
|
||||
|
||||
const typeList = ref<DictTypeVO[]>([]);
|
||||
const typeLoading = ref(true);
|
||||
@@ -435,7 +433,7 @@ const typeInitFormData: DictTypeForm = {
|
||||
};
|
||||
|
||||
const typeState = reactive<PageData<DictTypeForm, DictTypeQuery>>({
|
||||
form: { ...typeInitFormData },
|
||||
form: {...typeInitFormData},
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
@@ -443,12 +441,12 @@ const typeState = reactive<PageData<DictTypeForm, DictTypeQuery>>({
|
||||
dictType: ''
|
||||
},
|
||||
rules: {
|
||||
dictName: [{ required: true, message: '字典名称不能为空', trigger: 'blur' }],
|
||||
dictType: [{ required: true, message: '字典类型不能为空', trigger: 'blur' }]
|
||||
dictName: [{required: true, message: '字典名称不能为空', trigger: 'blur'}],
|
||||
dictType: [{required: true, message: '字典类型不能为空', trigger: 'blur'}]
|
||||
}
|
||||
});
|
||||
|
||||
const { queryParams: typeQueryParams, form: typeForm, rules: typeRules } = toRefs(typeState);
|
||||
const {queryParams: typeQueryParams, form: typeForm, rules: typeRules} = toRefs(typeState);
|
||||
|
||||
const currentDict = ref<DictTypeVO | null>(null);
|
||||
const hasCurrentDict = computed(() => !!currentDict.value);
|
||||
@@ -474,12 +472,12 @@ const dataDialog = reactive<DialogOption>({
|
||||
});
|
||||
|
||||
const listClassOptions = ref<Array<{ value: string; label: string }>>([
|
||||
{ value: 'default', label: '默认' },
|
||||
{ value: 'primary', label: '主要' },
|
||||
{ value: 'success', label: '成功' },
|
||||
{ value: 'info', label: '信息' },
|
||||
{ value: 'warning', label: '警告' },
|
||||
{ value: 'danger', label: '危险' }
|
||||
{value: 'default', label: '默认'},
|
||||
{value: 'primary', label: '主要'},
|
||||
{value: 'success', label: '成功'},
|
||||
{value: 'info', label: '信息'},
|
||||
{value: 'warning', label: '警告'},
|
||||
{value: 'danger', label: '危险'}
|
||||
]);
|
||||
|
||||
const dataInitFormData: DictDataForm = {
|
||||
@@ -493,7 +491,7 @@ const dataInitFormData: DictDataForm = {
|
||||
};
|
||||
|
||||
const dataState = reactive<PageData<DictDataForm, DictDataQuery>>({
|
||||
form: { ...dataInitFormData },
|
||||
form: {...dataInitFormData},
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
@@ -502,13 +500,13 @@ const dataState = reactive<PageData<DictDataForm, DictDataQuery>>({
|
||||
dictLabel: ''
|
||||
},
|
||||
rules: {
|
||||
dictLabel: [{ required: true, message: '数据标签不能为空', trigger: 'blur' }],
|
||||
dictValue: [{ required: true, message: '数据键值不能为空', trigger: 'blur' }],
|
||||
dictSort: [{ required: true, message: '数据顺序不能为空', trigger: 'blur' }]
|
||||
dictLabel: [{required: true, message: '数据标签不能为空', trigger: 'blur'}],
|
||||
dictValue: [{required: true, message: '数据键值不能为空', trigger: 'blur'}],
|
||||
dictSort: [{required: true, message: '数据顺序不能为空', trigger: 'blur'}]
|
||||
}
|
||||
});
|
||||
|
||||
const { queryParams: dataQueryParams, form: dataForm, rules: dataRules } = toRefs(dataState);
|
||||
const {queryParams: dataQueryParams, form: dataForm, rules: dataRules} = toRefs(dataState);
|
||||
|
||||
const getTypeList = () => {
|
||||
typeLoading.value = true;
|
||||
@@ -553,7 +551,7 @@ const cancelType = () => {
|
||||
};
|
||||
|
||||
const resetTypeForm = () => {
|
||||
typeForm.value = { ...typeInitFormData };
|
||||
typeForm.value = {...typeInitFormData};
|
||||
typeFormRef.value?.resetFields();
|
||||
};
|
||||
|
||||
@@ -643,7 +641,7 @@ const cancelData = () => {
|
||||
};
|
||||
|
||||
const resetDataForm = () => {
|
||||
dataForm.value = { ...dataInitFormData };
|
||||
dataForm.value = {...dataInitFormData};
|
||||
dataFormRef.value?.resetFields();
|
||||
};
|
||||
|
||||
@@ -734,30 +732,30 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dict-page {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dict-grid {
|
||||
row-gap: 16px;
|
||||
}
|
||||
|
||||
.dict-card {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column; // ✅ 关键:让card内部上下分区自动分配高度
|
||||
}
|
||||
|
||||
// el-card 默认内部是流式,加上这个,card内部分区自动挤压表格区域
|
||||
.dict-card :deep(.el-card__body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dict-card__header {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dict-title-toggle {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.dict-title-toggle.is-collapsed::after {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.dict-card__subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
flex-shrink: 0; // 头部搜索栏固定高度,不压缩
|
||||
}
|
||||
|
||||
.dict-search {
|
||||
@@ -765,10 +763,10 @@ onMounted(() => {
|
||||
max-height: 240px;
|
||||
opacity: 1;
|
||||
margin-bottom: 12px;
|
||||
transition:
|
||||
max-height 0.32s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transition: max-height 0.32s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 0.24s ease,
|
||||
margin-bottom 0.24s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dict-search.is-collapsed {
|
||||
@@ -785,6 +783,7 @@ onMounted(() => {
|
||||
.dict-actions {
|
||||
margin: 0 0 12px;
|
||||
padding-bottom: 0;
|
||||
flex-shrink: 0; // 按钮栏固定高度,不压缩
|
||||
}
|
||||
|
||||
.dict-actions :deep(.el-button) {
|
||||
@@ -799,5 +798,11 @@ onMounted(() => {
|
||||
|
||||
.dict-table-wrap {
|
||||
overflow-x: auto;
|
||||
max-height: 800px;
|
||||
}
|
||||
|
||||
// 表格设置最大高度:自动占满剩余空间,不要写死500px
|
||||
.dict-table-wrap :deep(.el-table) {
|
||||
max-height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</template>
|
||||
<el-form ref="queryFormRef" :model="queryParams" :inline="true" class="query-form">
|
||||
<el-form-item label="文件名" prop="fileName">
|
||||
<el-input v-model="queryParams.fileName" placeholder="请输入文件名" clearable @keyup.enter="handleQuery" />
|
||||
<el-input v-model="queryParams.fileName" clearable placeholder="请输入文件名" @keyup.enter="handleQuery"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="原名" prop="originalName">
|
||||
<el-input
|
||||
@@ -42,7 +42,7 @@
|
||||
></el-date-picker>
|
||||
</el-form-item>
|
||||
<el-form-item label="服务商" prop="service">
|
||||
<el-input v-model="queryParams.service" placeholder="请输入服务商" clearable @keyup.enter="handleQuery" />
|
||||
<el-input v-model="queryParams.service" clearable placeholder="请输入服务商" @keyup.enter="handleQuery"/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="search" @click="handleQuery">搜索</el-button>
|
||||
@@ -109,11 +109,11 @@
|
||||
@selection-change="handleSelectionChange"
|
||||
@header-click="handleHeaderCLick"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column v-if="false" label="对象存储主键" align="center" prop="ossId" />
|
||||
<el-table-column label="文件名" align="center" prop="fileName" />
|
||||
<el-table-column label="原名" align="center" prop="originalName" />
|
||||
<el-table-column label="文件后缀" align="center" prop="fileSuffix" />
|
||||
<el-table-column align="center" type="selection" width="55"/>
|
||||
<el-table-column v-if="false" align="center" label="对象存储主键" prop="ossId"/>
|
||||
<el-table-column align="center" label="文件名" prop="fileName"/>
|
||||
<el-table-column align="center" label="原名" prop="originalName"/>
|
||||
<el-table-column align="center" label="文件后缀" prop="fileSuffix"/>
|
||||
<el-table-column label="文件展示" align="center" prop="url">
|
||||
<template #default="scope">
|
||||
<ImagePreview
|
||||
@@ -123,7 +123,7 @@
|
||||
:src="scope.row.url"
|
||||
:preview-src-list="[scope.row.url]"
|
||||
/>
|
||||
<span v-if="!checkFileSuffix(scope.row.fileSuffix) || !previewListResource" v-text="scope.row.url" />
|
||||
<span v-if="!checkFileSuffix(scope.row.fileSuffix) || !previewListResource" v-text="scope.row.url"/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" align="center" prop="createTime" width="180" sortable="custom">
|
||||
@@ -131,8 +131,8 @@
|
||||
<span>{{ parseTime(scope.row.createTime, '{y}-{m}-{d}') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上传人" align="center" prop="createByName" />
|
||||
<el-table-column label="服务商" align="center" prop="service" sortable="custom" />
|
||||
<el-table-column align="center" label="上传人" prop="createByName"/>
|
||||
<el-table-column align="center" label="服务商" prop="service" sortable="custom"/>
|
||||
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
|
||||
<template #default="scope">
|
||||
<el-tooltip content="下载" placement="top">
|
||||
@@ -169,8 +169,8 @@
|
||||
<el-dialog v-model="dialog.visible" :title="dialog.title" width="500px" append-to-body>
|
||||
<el-form ref="ossFormRef" :model="form" :rules="rules" label-width="80px">
|
||||
<el-form-item label="文件名">
|
||||
<fileUpload v-if="type === 0" v-model="form.file" />
|
||||
<imageUpload v-if="type === 1" v-model="form.file" />
|
||||
<fileUpload v-if="type === 0" v-model="form.file"/>
|
||||
<imageUpload v-if="type === 1" v-model="form.file"/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -184,28 +184,28 @@
|
||||
</template>
|
||||
|
||||
<script setup name="Oss" lang="ts">
|
||||
import { useRouter } from 'vue-router';
|
||||
import { getConfigKey, updateConfigByKey } from '@/api/system/config';
|
||||
import { listOss, delOss } from '@/api/system/oss';
|
||||
import { OssForm, OssQuery, OssVO } from '@/api/system/oss/types';
|
||||
import {useRouter} from 'vue-router';
|
||||
import {getConfigKey, updateConfigByKey} from '@/api/system/config';
|
||||
import {listOss, delOss} from '@/api/system/oss';
|
||||
import {OssForm, OssQuery, OssVO} from '@/api/system/oss/types';
|
||||
import ImagePreview from '@/components/ImagePreview/index.vue';
|
||||
import { useLoading } from '@/hooks/async/useLoading';
|
||||
import { useFormDialog } from '@/hooks/dialog/useFormDialog';
|
||||
import { useDateRangeQuery } from '@/hooks/form/useDateRangeQuery';
|
||||
import { useSearchReset } from '@/hooks/form/useSearchReset';
|
||||
import { useSearchToggle } from '@/hooks/form/useSearchToggle';
|
||||
import { useTableSelection } from '@/hooks/table/useTableSelection';
|
||||
import {useLoading} from '@/hooks/async/useLoading';
|
||||
import {useFormDialog} from '@/hooks/dialog/useFormDialog';
|
||||
import {useDateRangeQuery} from '@/hooks/form/useDateRangeQuery';
|
||||
import {useSearchReset} from '@/hooks/form/useSearchReset';
|
||||
import {useSearchToggle} from '@/hooks/form/useSearchToggle';
|
||||
import {useTableSelection} from '@/hooks/table/useTableSelection';
|
||||
import download from '@/plugins/download';
|
||||
import modal from '@/plugins/modal';
|
||||
import { parseTime } from '@/utils/ruoyi';
|
||||
import {parseTime} from '@/utils/ruoyi';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const ossList = ref<OssVO[]>([]);
|
||||
const showTable = ref(true);
|
||||
const buttonLoading = ref(false);
|
||||
const { loading, setLoading, withLoading } = useLoading(true);
|
||||
const { showSearch } = useSearchToggle();
|
||||
const {loading, setLoading, withLoading} = useLoading(true);
|
||||
const {showSearch} = useSearchToggle();
|
||||
const total = ref(0);
|
||||
const type = ref(0);
|
||||
const previewListResource = ref(true);
|
||||
@@ -216,7 +216,7 @@ const {
|
||||
} = useDateRangeQuery('CreateTime');
|
||||
|
||||
// 默认排序
|
||||
const defaultSort = ref({ prop: 'createTime', order: 'ascending' });
|
||||
const defaultSort = ref({prop: 'createTime', order: 'ascending'});
|
||||
|
||||
const ossFormRef = ref<ElFormInstance>();
|
||||
const queryFormRef = ref<ElFormInstance>();
|
||||
@@ -225,7 +225,7 @@ const initFormData = {
|
||||
file: undefined
|
||||
};
|
||||
const data = reactive<PageData<OssForm, OssQuery>>({
|
||||
form: { ...initFormData },
|
||||
form: {...initFormData},
|
||||
// 查询参数
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
@@ -239,12 +239,12 @@ const data = reactive<PageData<OssForm, OssQuery>>({
|
||||
isAsc: defaultSort.value.order
|
||||
},
|
||||
rules: {
|
||||
file: [{ required: true, message: '文件不能为空', trigger: 'blur' }]
|
||||
file: [{required: true, message: '文件不能为空', trigger: 'blur'}]
|
||||
}
|
||||
});
|
||||
|
||||
const { queryParams, form, rules } = toRefs(data);
|
||||
const { ids, single, multiple, handleSelectionChange } = useTableSelection<OssVO>(item => item.ossId);
|
||||
const {queryParams, form, rules} = toRefs(data);
|
||||
const {ids, single, multiple, handleSelectionChange} = useTableSelection<OssVO>(item => item.ossId);
|
||||
const {
|
||||
dialog,
|
||||
resetForm: reset,
|
||||
@@ -267,22 +267,26 @@ const getList = async () => {
|
||||
showTable.value = true;
|
||||
});
|
||||
};
|
||||
|
||||
function checkFileSuffix(fileSuffix: string | string[]) {
|
||||
const arr = ['.png', '.jpg', '.jpeg'];
|
||||
const suffixArray = Array.isArray(fileSuffix) ? fileSuffix : [fileSuffix];
|
||||
return suffixArray.some(suffix => arr.includes(suffix.toLowerCase()));
|
||||
}
|
||||
|
||||
/** 取消按钮 */
|
||||
function cancel() {
|
||||
reset();
|
||||
closeDialog();
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
function handleQuery() {
|
||||
queryParams.value.pageNum = 1;
|
||||
getList();
|
||||
}
|
||||
const { resetQuery } = useSearchReset({
|
||||
|
||||
const {resetQuery} = useSearchReset({
|
||||
queryFormRef,
|
||||
queryParams,
|
||||
pageNumKey: 'pageNum',
|
||||
@@ -297,7 +301,7 @@ const { resetQuery } = useSearchReset({
|
||||
}
|
||||
});
|
||||
/** 设置列的排序为我们自定义的排序 */
|
||||
const handleHeaderClass = ({ column }: any): any => {
|
||||
const handleHeaderClass = ({column}: any): any => {
|
||||
column.order = column.multiOrder;
|
||||
};
|
||||
/** 点击表头进行排序 */
|
||||
@@ -343,7 +347,7 @@ const handleOrderChange = (prop: string, order: string) => {
|
||||
};
|
||||
/** 任务日志列表查询 */
|
||||
const handleOssConfig = () => {
|
||||
router.push('/system/oss-config/index');
|
||||
router.push('/system/oss-config/index.vue');
|
||||
};
|
||||
/** 文件按钮操作 */
|
||||
const handleFile = () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user