Compare commits

4 Commits
main ... dev

Author SHA1 Message Date
Zy
2ccce95d44 10/10
1. 设备管理界面完成
2. 用户管理界面完成
3. 会话列表完成
2026-10-10 17:02:31 +08:00
Zy
23703a66c2 9/30
1. 设备管理界面完成
2. 用户管理界面完成
3. 会话列表完成
2026-09-30 17:43:01 +08:00
Zy
a19777a2b8 9/28
1. 修改登录页面中:星期展示方法
2. 登录页面
3. 首页
2026-09-28 17:39:06 +08:00
Zy
4f7cb2d085 9/28
1. 修改登录页面中:星期展示方法
2. 登录页面
3.
2026-09-28 08:31:29 +08:00
104 changed files with 13039 additions and 1755 deletions

View File

@@ -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 路径是否与后端一致
- 类型是否覆盖接口真实结构
- 是否不必要地把类型写宽了

View File

@@ -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` 模板。
- 默认直接产出可落地代码,而不是只给抽象建议。

View File

@@ -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` 是否同步补齐
- 页面是否只是模板裸输出,如果是要继续补强到当前项目风格

View File

@@ -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 式基础列表页。
## 常见任务
- 调整工具栏和更多菜单
- 增加筛选条件和日期范围
- 增加导入导出能力
- 增加状态切换、快捷操作、确认弹窗
- 补复杂页面的小型子功能
## 自检
- 是否破坏了原页面结构和样式
- 是否误删了已有权限控制或交互能力
- 是否应该拆成子组件而不是继续堆主页面

View File

@@ -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 风格。

View File

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

View File

@@ -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
帮我写个后台页面
```
更好的写法至少补充:
- 模块名
- 业务名
- 后端接口前缀
- 是新增还是修改
- 是否需要分页、导出、树表、字典、权限
- 想参考哪个现有页面

View File

@@ -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 壳或状态管理方式。

View File

@@ -1,5 +1,5 @@
# 页面标题 # 页面标题
VITE_APP_TITLE = 智净云 · 智能家居管理后台 VITE_APP_TITLE='智净云 · 智能家居管理后台'
VITE_APP_LOGO_TITLE=RuoYi-Vue-Plus VITE_APP_LOGO_TITLE=RuoYi-Vue-Plus
# 开发环境配置 # 开发环境配置
@@ -8,6 +8,13 @@ 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/ # 应用访问路径 例如使用前缀 /admin/
VITE_APP_CONTEXT_PATH='/' VITE_APP_CONTEXT_PATH='/'
@@ -20,7 +27,7 @@ VITE_APP_SNAILJOB_ADMIN = 'http://localhost:8800/snail-job'
# SnailAI 控制台地址 # 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

9
.prettierignore Normal file
View File

@@ -0,0 +1,9 @@
/dist/*
.local
.output.js
/node_modules/**
**/*.svg
**/*.sh
/public/*

20
.prettierrc Normal file
View 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"
}

View 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'
});
};

View 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;
}

View 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'
});
};

View 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;
}

View 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',
});
};

View 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;
}

View 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,
}
});
};

View 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;
}

View 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'
}
});
};

View 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;
}

View 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',
});
};

View 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'
});
};

View 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;
}

View File

@@ -8,8 +8,7 @@ body {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
background: var(--app-shell-bg); background: var(--app-shell-bg);
font-family: font-family: 'MiSans', 'HarmonyOS Sans SC', 'PingFang SC', 'Source Han Sans SC', 'Noto Sans SC', 'Hiragino Sans GB',
'MiSans', 'HarmonyOS Sans SC', 'PingFang SC', 'Source Han Sans SC', 'Noto Sans SC', 'Hiragino Sans GB',
'Microsoft YaHei', sans-serif; 'Microsoft YaHei', sans-serif;
} }
@@ -88,3 +87,18 @@ h6 {
line-height: 1.1; line-height: 1.1;
color: inherit; 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;
}

View File

@@ -1,4 +1,4 @@
/* 业务列表/卡片页:分页、树、列表、el-card 等(与 vendors/element-plus 通用覆盖叠加,入口顺序见 index.scss)。 /* 业务列表/卡片页:分页、树、列表、el-card 等(与 vendors/element-plus 通用覆盖叠加,入口顺序见 index.vue.scss)。
* 属「页面模式」而非 EP 公共主题,故放在 components。 */ * 属「页面模式」而非 EP 公共主题,故放在 components。 */
// --- 历史「表单分区标题」样式 --- // --- 历史「表单分区标题」样式 ---
@@ -51,8 +51,7 @@
box-shadow: var(--app-shadow-sm); box-shadow: var(--app-shadow-sm);
border-color: var(--app-surface-border); border-color: var(--app-surface-border);
overflow: hidden; overflow: hidden;
transition: transition: box-shadow 0.2s ease,
box-shadow 0.2s ease,
border-color 0.2s ease; border-color 0.2s ease;
background: var(--app-surface-bg); background: var(--app-surface-bg);
} }

View File

@@ -2,16 +2,20 @@
// --- 表格工具列链接按钮(全局) --- // --- 表格工具列链接按钮(全局) ---
.data-table .el-button.is-link { .data-table .el-button.is-link {
width: 28px !important; width: unset !important;
height: 28px !important; height: unset !important;
min-width: 28px !important; min-width: 28px !important;
padding: 0 !important; padding: 0 !important;
border-radius: var(--app-radius-md) !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 { .data-table .el-button.is-link + .el-button.is-link {
margin-left: 4px !important; margin-left: 12px !important;
} }
.search-wrap { .search-wrap {
@@ -37,8 +41,7 @@
overflow: hidden; overflow: hidden;
max-height: 560px; max-height: 560px;
opacity: 1; opacity: 1;
transition: transition: max-height 0.32s cubic-bezier(0.22, 1, 0.36, 1),
max-height 0.32s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.24s ease, opacity 0.24s ease,
padding-top 0.24s ease, padding-top 0.24s ease,
padding-bottom 0.24s ease; padding-bottom 0.24s ease;
@@ -73,8 +76,7 @@
border-bottom: 2px solid currentColor; border-bottom: 2px solid currentColor;
color: var(--app-text-muted); color: var(--app-text-muted);
transform: rotate(-135deg); transform: rotate(-135deg);
transition: transition: transform 0.24s ease,
transform 0.24s ease,
color 0.24s ease; color 0.24s ease;
} }
} }

View File

@@ -53,7 +53,7 @@
& .el-sub-menu { & .el-sub-menu {
overflow: hidden; overflow: hidden;
border-radius: var(--app-radius-md); //border-radius: var(--app-radius-md);
.el-sub-menu__title, .el-sub-menu__title,
.el-sub-menu__title.el-tooltip__trigger { .el-sub-menu__title.el-tooltip__trigger {

View File

@@ -6,7 +6,7 @@
.sidebar-container { .sidebar-container {
.theme-dark .submenu-title-noDropdown, .theme-dark .submenu-title-noDropdown,
.theme-dark .el-sub-menu__title { .theme-dark .el-sub-menu__title {
border-radius: var(--app-radius-md); //border-radius: var(--app-radius-md);
margin: 3px 8px; margin: 3px 8px;
height: 40px; height: 40px;
@@ -18,8 +18,8 @@
.submenu-title-noDropdown, .submenu-title-noDropdown,
.el-sub-menu__title { .el-sub-menu__title {
border-radius: var(--app-radius-md); //border-radius: var(--app-radius-md);
margin: 3px 8px; margin: 0;
height: 40px; height: 40px;
color: var(--side-menu-text) !important; color: var(--side-menu-text) !important;
@@ -36,10 +36,11 @@
& .nest-menu .el-sub-menu > .el-sub-menu__title, & .nest-menu .el-sub-menu > .el-sub-menu__title,
& .el-sub-menu .el-menu-item { & .el-sub-menu .el-menu-item {
min-width: calc($base-sidebar-width - 16px) !important; min-width: calc($base-sidebar-width - 16px) !important;
border-radius: var(--app-radius-md); //border-radius: var(--app-radius-md);
height: 40px; height: 40px;
margin: 3px 8px; //margin: 3px 8px;
background: transparent !important; //background: transparent !important;
background: #f7f7f7 !important;
&:not(.is-active):hover { &:not(.is-active):hover {
background-color: var(--side-menu-hover-bg) !important; 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 .nest-menu .el-sub-menu > .el-sub-menu__title,
& .theme-dark .el-sub-menu .el-menu-item { & .theme-dark .el-sub-menu .el-menu-item {
border-radius: var(--app-radius-md); //border-radius: var(--app-radius-md);
height: 40px; height: 40px;
margin: 3px 8px; //margin: 3px 8px;
background: transparent !important; //background: transparent !important;
background: #f7f7f7 !important;
&.is-active { &.is-active {
background: var(--side-menu-active-bg) !important; background: var(--side-menu-active-bg) !important;
color: var(--side-menu-active-text) !important; color: var(--side-menu-active-text) !important;
box-shadow: box-shadow: inset 3px 0 0 var(--side-menu-active-line),
inset 3px 0 0 var(--side-menu-active-line),
inset 0 0 0 1px var(--side-menu-active-border); 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 .nest-menu .el-sub-menu > .el-sub-menu__title,
& .theme-dark .el-menu-item { & .theme-dark .el-menu-item {
border-radius: var(--app-radius-md); //border-radius: var(--app-radius-md);
height: 40px; height: 40px;
margin: 3px 8px; //margin: 3px 8px;
background: transparent !important; //background: transparent !important;
background: #f7f7f7 !important;
&.is-active { &.is-active {
background: var(--side-menu-active-bg) !important; background: var(--side-menu-active-bg) !important;
color: var(--side-menu-active-text) !important; color: var(--side-menu-active-text) !important;
box-shadow: box-shadow: inset 3px 0 0 var(--side-menu-active-line),
inset 3px 0 0 var(--side-menu-active-line),
inset 0 0 0 1px var(--side-menu-active-border); inset 0 0 0 1px var(--side-menu-active-border);
} }
@@ -91,17 +92,17 @@
& .nest-menu .el-sub-menu > .el-sub-menu__title, & .nest-menu .el-sub-menu > .el-sub-menu__title,
& .el-menu-item { & .el-menu-item {
border-radius: var(--app-radius-md); //border-radius: var(--app-radius-md);
height: 40px; height: 40px;
margin: 3px 8px; //margin: 0 8px;
background: transparent !important; background: transparent !important;
&.is-active { &.is-active {
background: var(--side-menu-active-bg) !important; background: var(--side-menu-active-bg) !important;
color: var(--side-menu-active-text) !important; color: var(--side-menu-active-text) !important;
box-shadow: //box-shadow:
inset 3px 0 0 var(--side-menu-active-line), // inset 3px 0 0 var(--side-menu-active-line),
inset 0 0 0 1px var(--side-menu-active-border); // inset 0 0 0 1px var(--side-menu-active-border);
} }
&:not(.is-active):hover { &: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;
}
}

View File

@@ -26,7 +26,7 @@
position: fixed; position: fixed;
font-size: 0; font-size: 0;
top: 12px; top: 12px;
left: 12px; //left: 12px;
z-index: 1001; z-index: 1001;
overflow: hidden; overflow: hidden;
box-sizing: border-box; box-sizing: border-box;

View File

@@ -39,4 +39,4 @@ $--color-warning: $color-warning;
$--color-danger: $color-danger; $--color-danger: $color-danger;
$--color-info: $color-info; $--color-info: $color-info;
$base-sidebar-width: 200px; $base-sidebar-width: 244px;

View File

@@ -64,4 +64,6 @@
--el-box-shadow-light: var(--app-shadow-sm); --el-box-shadow-light: var(--app-shadow-sm);
--el-box-shadow: var(--app-shadow-md); --el-box-shadow: var(--app-shadow-md);
--el-bg-color-page: var(--app-shell-bg); --el-bg-color-page: var(--app-shell-bg);
} }

View File

@@ -28,12 +28,21 @@
// --- primary 品牌色(亮色;暗色见 tokens/_theme-dark) --- // --- primary 品牌色(亮色;暗色见 tokens/_theme-dark) ---
.el-button--primary { .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-border-color: var(--app-accent-strong);
--el-button-hover-bg-color: #4a7cff; --el-button-hover-bg-color: #4a7cff;
--el-button-hover-border-color: #4a7cff; --el-button-hover-border-color: #4a7cff;
--el-button-active-bg-color: #2e5ce0; --el-button-active-bg-color: #2e5ce0;
--el-button-active-border-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 --- // --- 表格 CSS 变量接业务 token ---
@@ -82,3 +91,4 @@ html.dark .el-table {
.el-select__wrapper { .el-select__wrapper {
box-shadow: 0 0 0 1px var(--el-border-color) inset; box-shadow: 0 0 0 1px var(--el-border-color) inset;
} }

View File

@@ -2,9 +2,8 @@
<el-breadcrumb class="app-breadcrumb" separator="/"> <el-breadcrumb class="app-breadcrumb" separator="/">
<transition-group name="breadcrumb"> <transition-group name="breadcrumb">
<el-breadcrumb-item v-for="(item, index) in levelList" :key="item.path"> <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"> <span v-if="item.redirect === 'noRedirect' || index == levelList.length - 1"
{{ item.meta?.title }} class="no-redirect"> {{ item.meta?.title }} </span>
</span>
<a v-else @click.prevent="handleLink(item)">{{ item.meta?.title }}</a> <a v-else @click.prevent="handleLink(item)">{{ item.meta?.title }}</a>
</el-breadcrumb-item> </el-breadcrumb-item>
</transition-group> </transition-group>
@@ -37,7 +36,7 @@ const getBreadcrumb = () => {
} }
// 判断是否为首页 // 判断是否为首页
if (!isDashboard(matched[0])) { 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); levelList.value = matched.filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false);
}; };
@@ -82,14 +81,22 @@ onMounted(() => {
display: inline-block; display: inline-block;
font-size: 13px; font-size: 13px;
line-height: 1.2; line-height: 1.2;
margin-left: 4px;
color: var(--app-text-muted); color: var(--app-text-muted);
:deep(.el-breadcrumb__separator) {
margin: 0 3px;
}
:deep(.el-breadcrumb__inner) { :deep(.el-breadcrumb__inner) {
color: inherit; color: inherit;
font-weight: 500; font-weight: 500;
} }
:deep(.el-breadcrumb__item .no-redirect) {
color: rgba(156, 163, 175, 1);
font-size: 12px;
}
.no-redirect { .no-redirect {
color: var(--app-text-title); color: var(--app-text-title);
cursor: text; cursor: text;

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

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

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

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

View File

@@ -49,6 +49,7 @@ const pageSize = computed({
emit('update:limit', val); emit('update:limit', val);
} }
}); });
function handleSizeChange(val: number) { function handleSizeChange(val: number) {
if (currentPage.value * val > props.total) { if (currentPage.value * val > props.total) {
currentPage.value = 1; currentPage.value = 1;
@@ -58,6 +59,7 @@ function handleSizeChange(val: number) {
scrollTo(0, 800); scrollTo(0, 800);
} }
} }
function handleCurrentChange(val: number) { function handleCurrentChange(val: number) {
emit('pagination', {page: val, limit: pageSize.value}); emit('pagination', {page: val, limit: pageSize.value});
if (props.autoScroll) { if (props.autoScroll) {
@@ -73,12 +75,13 @@ function handleCurrentChange(val: number) {
justify-content: flex-end; justify-content: flex-end;
padding-top: 10px; padding-top: 10px;
margin-top: 18px; margin-top: 18px;
border-top: 1px solid var(--app-surface-border); //border-top: 1px solid var(--app-surface-border);
.el-pagination { .el-pagination {
float: none; float: none;
} }
} }
.pagination-container.hidden { .pagination-container.hidden {
display: none; display: none;
} }

View File

@@ -45,7 +45,7 @@ import { isHttp } from '@/utils/validate';
// 顶部栏初始数 // 顶部栏初始数
const visibleNumber = ref<number>(-1); const visibleNumber = ref<number>(-1);
// 隐藏侧边栏路由 // 隐藏侧边栏路由
const hideList = ['/index', '/user/profile']; const hideList = ['/index.vue', '/user/profile'];
const appStore = useAppStore(); const appStore = useAppStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
@@ -99,7 +99,7 @@ const childrenMenus = computed(() => {
const activeMenu = computed(() => { const activeMenu = computed(() => {
const {meta} = route; const {meta} = route;
let path = meta.activeMenu || route.path; let path = meta.activeMenu || route.path;
if (path === '/index' || route.path === '/index') { if (path === '/index.vue' || route.path === '/index.vue') {
path = '/system/user'; path = '/system/user';
} }
let activePath = path; let activePath = path;
@@ -214,8 +214,7 @@ onMounted(() => {
border-radius: 13px; border-radius: 13px;
color: var(--topbar-pill-text) !important; color: var(--topbar-pill-text) !important;
background: transparent !important; background: transparent !important;
transition: transition: background-color 0.2s ease,
background-color 0.2s ease,
color 0.2s ease, color 0.2s ease,
box-shadow 0.2s ease, box-shadow 0.2s ease,
border-color 0.2s ease, border-color 0.2s ease,
@@ -305,8 +304,7 @@ onMounted(() => {
border-radius: 12px; border-radius: 12px;
color: var(--app-text-title) !important; color: var(--app-text-title) !important;
background: transparent !important; background: transparent !important;
transition: transition: background-color 0.2s ease,
background-color 0.2s ease,
color 0.2s ease, color 0.2s ease,
box-shadow 0.2s ease, box-shadow 0.2s ease,
transform 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 { html.dark .mix-topnav-popper .el-menu--popup {
border-color: rgba(71, 85, 105, 0.34); 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; background: linear-gradient(180deg, rgba(15, 23, 42, 0.98), rgba(30, 41, 59, 0.94)) !important;
box-shadow: box-shadow: 0 20px 42px rgba(0, 0, 0, 0.34),
0 20px 42px rgba(0, 0, 0, 0.34),
inset 0 1px 0 rgba(255, 255, 255, 0.06); inset 0 1px 0 rgba(255, 255, 255, 0.06);
} }

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

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

View File

@@ -1,5 +1,5 @@
<template> <template>
<section class="app-main"> <section class="app-main beautify_scroll">
<router-view v-slot="{ Component, route }"> <router-view v-slot="{ Component, route }">
<transition :enter-active-class="animate" mode="out-in"> <transition :enter-active-class="animate" mode="out-in">
<keep-alive :include="tagsViewStore.cachedViews"> <keep-alive :include="tagsViewStore.cachedViews">
@@ -52,8 +52,8 @@ function addIframe() {
min-height: 100vh; min-height: 100vh;
width: 100%; width: 100%;
position: relative; position: relative;
overflow: hidden; overflow: auto;
padding: 12px; padding: 0 12px;
&:fullscreen, &:fullscreen,
&:-webkit-full-screen, &:-webkit-full-screen,
@@ -66,6 +66,7 @@ function addIframe() {
.app-main:not(.with-fixed-header) { .app-main:not(.with-fixed-header) {
min-height: calc(100vh - 64px); min-height: calc(100vh - 64px);
max-height: calc(100vh - 64px);
} }
.app-main.with-tags-view:not(.with-fixed-header) { .app-main.with-tags-view:not(.with-fixed-header) {

View File

@@ -12,25 +12,17 @@
<router-link v-else-if="showLogo" to="/" class="navtop-logo-shell"> <router-link v-else-if="showLogo" to="/" class="navtop-logo-shell">
<img :src="appLogo" class="navtop-logo-icon" alt="logo" /> <img :src="appLogo" class="navtop-logo-icon" alt="logo" />
</router-link> </router-link>
<div class="nav-context"> <div class="nav-context">
<breadcrumb v-if="navType == NavTypeEnum.LEFT" id="breadcrumb-container" class="breadcrumb-container" /> <breadcrumb v-if="navType == NavTypeEnum.LEFT && isDashboard(route)" id="breadcrumb-container"
<top-nav v-if="navType == NavTypeEnum.MIX" id="topmenu-container" class="topmenu-container" /> class="breadcrumb-container"/>
<!-- <top-nav v-if="navType == NavTypeEnum.MIX" id="topmenu-container" class="topmenu-container" />-->
<template v-if="navType == NavTypeEnum.TOP"> <!-- <template v-if="navType == NavTypeEnum.TOP">-->
<top-bar id="topbar-container" class="topbar-container" /> <!-- <top-bar id="topbar-container" class="topbar-container" />-->
</template> <!-- </template>-->
</div> </div>
</div> </div>
<div class="right-menu flex align-center"> <div class="right-menu flex align-center">
<template v-if="appStore.device !== 'mobile'"> <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"> <el-tooltip :content="$t('navbar.message')" effect="dark" placement="bottom">
<div> <div>
<el-popover placement="bottom" trigger="click" transition="el-zoom-in-top" :width="300" :persistent="false"> <el-popover placement="bottom" trigger="click" transition="el-zoom-in-top" :width="300" :persistent="false">
@@ -47,25 +39,6 @@
</el-popover> </el-popover>
</div> </div>
</el-tooltip> </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> </template>
<div class="avatar-container"> <div class="avatar-container">
<el-dropdown class="avatar-dropdown" trigger="click" @command="handleCommand"> <el-dropdown class="avatar-dropdown" trigger="click" @command="handleCommand">
@@ -82,9 +55,9 @@
<router-link to="/user/profile"> <router-link to="/user/profile">
<el-dropdown-item>{{ $t('navbar.personalCenter') }}</el-dropdown-item> <el-dropdown-item>{{ $t('navbar.personalCenter') }}</el-dropdown-item>
</router-link> </router-link>
<el-dropdown-item v-if="settingsStore.showSettings" command="setLayout"> <!-- <el-dropdown-item v-if="settingsStore.showSettings" command="setLayout">-->
<span>{{ $t('navbar.layoutSetting') }}</span> <!-- <span>{{ $t('navbar.layoutSetting') }}</span>-->
</el-dropdown-item> <!-- </el-dropdown-item>-->
<el-dropdown-item divided command="logout"> <el-dropdown-item divided command="logout">
<span>{{ $t('navbar.logout') }}</span> <span>{{ $t('navbar.logout') }}</span>
</el-dropdown-item> </el-dropdown-item>
@@ -110,7 +83,9 @@ import { useUserStore } from '@/store/modules/user';
import notice from './notice/index.vue'; import notice from './notice/index.vue';
import TopBar from './TopBar/index.vue'; import TopBar from './TopBar/index.vue';
import SearchMenu from './TopBar/search.vue'; import SearchMenu from './TopBar/search.vue';
import {RouteLocationMatched, RouteLocationNormalizedLoadedGeneric} from "vue-router";
const route = useRoute();
const appStore = useAppStore(); const appStore = useAppStore();
const userStore = useUserStore(); const userStore = useUserStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
@@ -120,6 +95,13 @@ const navType = computed(() => settingsStore.navType);
const showLogo = computed(() => settingsStore.sidebarLogo); const showLogo = computed(() => settingsStore.sidebarLogo);
const displayName = computed(() => userStore.nickname || '管理员'); 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>>(); const searchMenuRef = ref<InstanceType<typeof SearchMenu>>();

View File

@@ -54,7 +54,7 @@ const logoTextColor = computed(() => (isDarkSide.value ? '#f8fbff' : 'var(--app-
position: relative; position: relative;
flex-shrink: 0; flex-shrink: 0;
height: 46px; height: 46px;
line-height: 46px; //line-height: 46px;
padding: 0 8px; padding: 0 8px;
margin-top: 8px; margin-top: 8px;
background: transparent; background: transparent;
@@ -70,8 +70,8 @@ const logoTextColor = computed(() => (isDarkSide.value ? '#f8fbff' : 'var(--app-
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
border-radius: 14px; border-radius: 14px;
background: v-bind(logoSurface); //background: v-bind(logoSurface);
border: 1px solid v-bind(logoBorder); //border: 1px solid v-bind(logoBorder);
& .sidebar-logo { & .sidebar-logo {
width: 28px; width: 28px;

View File

@@ -9,7 +9,7 @@
> >
<app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path, onlyOneChild.query)"> <app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path, onlyOneChild.query)">
<el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{ 'submenu-title-noDropdown': !isNest }"> <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> <template #title>
<span class="menu-title" :title="hasTitle(onlyOneChild.meta.title)">{{ onlyOneChild.meta.title }}</span> <span class="menu-title" :title="hasTitle(onlyOneChild.meta.title)">{{ onlyOneChild.meta.title }}</span>
</template> </template>

View File

@@ -11,7 +11,7 @@
:popper-offset="12" :popper-offset="12"
mode="vertical" 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> </el-menu>
</transition> </transition>
</el-scrollbar> </el-scrollbar>
@@ -26,12 +26,20 @@ import { usePermissionStore } from '@/store/modules/permission';
import {useSettingsStore} from '@/store/modules/settings'; import {useSettingsStore} from '@/store/modules/settings';
import Logo from './Logo.vue'; import Logo from './Logo.vue';
import SidebarItem from './SidebarItem.vue'; import SidebarItem from './SidebarItem.vue';
import {useUserStore} from "@/store/modules/user";
const route = useRoute(); const route = useRoute();
const appStore = useAppStore(); const appStore = useAppStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const userStore = useUserStore();
const permissionStore = usePermissionStore(); 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 showLogo = computed(() => settingsStore.sidebarLogo);
const sideTheme = computed(() => settingsStore.sideTheme); const sideTheme = computed(() => settingsStore.sideTheme);
const theme = computed(() => settingsStore.theme); const theme = computed(() => settingsStore.theme);
@@ -63,8 +71,8 @@ const menuStyle = computed(() => ({
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
padding: 10px 8px 12px; padding: 10px 8px 12px;
border: 1px solid var(--app-sidebar-border); //border: 1px solid var(--app-sidebar-border);
border-radius: var(--app-radius-base); //border-radius: var(--app-radius-base);
box-shadow: var(--app-shadow-sm); box-shadow: var(--app-shadow-sm);
background: v-bind(bgColor) !important; background: v-bind(bgColor) !important;
overflow: hidden; overflow: hidden;

View File

@@ -181,7 +181,7 @@ const isFirstView = () => {
return false; return false;
} }
try { try {
return tag.fullPath === '/index' || tag.fullPath === visitedViews.value[1]?.fullPath; return tag.fullPath === '/index.vue' || tag.fullPath === visitedViews.value[1]?.fullPath;
} catch { } catch {
return false; return false;
} }
@@ -313,7 +313,8 @@ const closeOthersTags = () => {
if (!tag) { if (!tag) {
return; return;
} }
router.push(tag.fullPath || tag.path || '/').catch(() => {}); router.push(tag.fullPath || tag.path || '/').catch(() => {
});
tab.closeOtherPage(tag).then(() => { tab.closeOtherPage(tag).then(() => {
moveToCurrentTag(); moveToCurrentTag();
}); });
@@ -505,8 +506,7 @@ onBeforeUnmount(() => {
background-color: var(--app-surface-bg); background-color: var(--app-surface-bg);
border: 1px solid var(--app-surface-border); border: 1px solid var(--app-surface-border);
border-radius: var(--app-radius-md); border-radius: var(--app-radius-md);
transition: transition: box-shadow 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease, transform 0.2s ease,
border-color 0.2s ease, border-color 0.2s ease,
color 0.2s ease; color 0.2s ease;
@@ -549,8 +549,7 @@ onBeforeUnmount(() => {
margin-left: 5px; margin-left: 5px;
margin-top: 4px; margin-top: 4px;
border-radius: var(--app-radius-md); border-radius: var(--app-radius-md);
transition: transition: box-shadow 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease, transform 0.2s ease,
border-color 0.2s ease, border-color 0.2s ease,
color 0.2s ease; color 0.2s ease;
@@ -620,8 +619,7 @@ onBeforeUnmount(() => {
background-color: var(--app-surface-bg); background-color: var(--app-surface-bg);
border: 1px solid var(--app-surface-border); border: 1px solid var(--app-surface-border);
border-radius: var(--app-radius-md); border-radius: var(--app-radius-md);
transition: transition: box-shadow 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease, transform 0.2s ease,
border-color 0.2s ease, border-color 0.2s ease,
color 0.2s ease; color 0.2s ease;

View File

@@ -86,7 +86,8 @@ const setLayout = () => {
position: relative; position: relative;
height: 100%; height: 100%;
width: 100%; width: 100%;
background: var(--app-shell-bg); //background: var(--app-shell-bg);
background: #fff;
&.mobile.openSidebar { &.mobile.openSidebar {
position: fixed; position: fixed;
@@ -110,7 +111,7 @@ const setLayout = () => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 3px; gap: 3px;
padding: 12px 12px 0; padding: 0 12px;
background: transparent; background: transparent;
} }

View File

@@ -33,7 +33,9 @@ router.beforeEach(async (to, from) => {
if (useUserStore().roles.length === 0) { if (useUserStore().roles.length === 0) {
isRelogin.show = true; isRelogin.show = true;
// 判断当前用户是否已拉取完user_info信息 // 判断当前用户是否已拉取完user_info信息
const [err] = await tos(useUserStore().getInfo()); const [err, res] = await tos(useUserStore().getInfo());
console.log(res);
if (err) { if (err) {
await useUserStore().logout(); await useUserStore().logout();
if (!isHandledRequestError(err)) { if (!isHandledRequestError(err)) {
@@ -50,6 +52,21 @@ router.beforeEach(async (to, from) => {
} }
}); });
// hack方法 确保addRoutes已完成 // 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 { return {
path: to.path, path: to.path,
replace: true, replace: true,

View File

@@ -69,7 +69,7 @@ export const constantRoutes: RouteRecordRaw[] = [
children: [ children: [
{ {
path: '/index', path: '/index',
component: () => import('@/views/index.vue'), component: () => import('@/views/index/index.vue'),
name: 'Index', name: 'Index',
meta: {title: '首页', icon: 'dashboard', affix: true} meta: {title: '首页', icon: 'dashboard', affix: true}
} }

View File

@@ -12,7 +12,7 @@ const setting: DefaultSettings = {
/** /**
* 侧边栏主题 深色主题theme-dark,浅色主题theme-light * 侧边栏主题 深色主题theme-dark,浅色主题theme-light
*/ */
sideTheme: 'theme-dark', sideTheme: 'theme-light',
/** /**
* 是否系统布局配置 * 是否系统布局配置
*/ */
@@ -26,7 +26,7 @@ const setting: DefaultSettings = {
/** /**
* 是否显示 tagsView * 是否显示 tagsView
*/ */
tagsView: true, tagsView: false,
/** /**
* 持久化标签页 * 持久化标签页
@@ -36,12 +36,12 @@ const setting: DefaultSettings = {
/** /**
* 显示页签图标 * 显示页签图标
*/ */
tagsIcon: true, tagsIcon: false,
/** /**
* 是否固定头部 * 是否固定头部
*/ */
fixedHeader: true, fixedHeader: false,
/** /**
* 是否显示logo * 是否显示logo

View File

@@ -1,9 +1,13 @@
import type { LocationQuery, RouteLocationNormalized, RouteMeta } from 'vue-router'; import type {
import { defineStore } from 'pinia'; LocationQuery,
import { ref } from 'vue'; RouteLocationNormalized,
import { useSettingsStore } from './settings'; 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 = { type PersistedTagView = {
path: string; path: string;
@@ -24,7 +28,7 @@ const isPersistEnabled = () => {
const normalizeVisitedView = (view: RouteLocationNormalized): TagView => { const normalizeVisitedView = (view: RouteLocationNormalized): TagView => {
return Object.assign({}, view, { 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[]) const payload: PersistedTagView[] = (views as TagView[])
.filter(view => !view.meta?.affix) .filter((view) => !view.meta?.affix)
.map(view => ({ .map((view) => ({
path: view.path, path: view.path,
fullPath: view.fullPath, fullPath: view.fullPath,
name: view.name, 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, query: view.query,
meta: view.meta meta: view.meta,
})); }));
localStorage.setItem(PERSIST_KEY, JSON.stringify(payload)); localStorage.setItem(PERSIST_KEY, JSON.stringify(payload));
@@ -65,7 +69,7 @@ const clearVisitedViews = () => {
localStorage.removeItem(PERSIST_KEY); localStorage.removeItem(PERSIST_KEY);
}; };
export const useTagsViewStore = defineStore('tagsView', () => { export const useTagsViewStore = defineStore("tagsView", () => {
const visitedViews = ref<TagView[]>([]); const visitedViews = ref<TagView[]>([]);
const cachedViews = ref<string[]>([]); const cachedViews = ref<string[]>([]);
const iframeViews = ref<TagView[]>([]); const iframeViews = ref<TagView[]>([]);
@@ -86,26 +90,49 @@ export const useTagsViewStore = defineStore('tagsView', () => {
}; };
const addIframeView = (view: RouteLocationNormalized): void => { const addIframeView = (view: RouteLocationNormalized): void => {
if (iframeViews.value.some((v: RouteLocationNormalized) => v.path === view.path)) return; if (
iframeViews.value.push(normalizeVisitedView(view) as RouteLocationNormalized); iframeViews.value.some(
(v: RouteLocationNormalized) => v.path === view.path,
)
)
return;
iframeViews.value.push(
normalizeVisitedView(view) as RouteLocationNormalized,
);
}; };
const delIframeView = (view: RouteLocationNormalized): Promise<RouteLocationNormalized[]> => { const delIframeView = (
return new Promise(resolve => { view: RouteLocationNormalized,
iframeViews.value = iframeViews.value.filter((item: RouteLocationNormalized) => item.path !== view.path); ): Promise<RouteLocationNormalized[]> => {
return new Promise((resolve) => {
iframeViews.value = iframeViews.value.filter(
(item: RouteLocationNormalized) => item.path !== view.path,
);
resolve(iframeViews.value.slice() as RouteLocationNormalized[]); resolve(iframeViews.value.slice() as RouteLocationNormalized[]);
}); });
}; };
const addVisitedView = (view: RouteLocationNormalized): void => { 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)); visitedViews.value.push(normalizeVisitedView(view));
saveVisitedViews(visitedViews.value); saveVisitedViews(visitedViews.value);
}; };
const addAffixView = (view: RouteLocationNormalized): void => { const addAffixView = (view: RouteLocationNormalized): void => {
if (visitedViews.value.some((v: RouteLocationNormalized) => v.path === view.path)) return; if (
const insertIndex = visitedViews.value.findIndex(item => !item.meta?.affix); visitedViews.value.some(
(v: RouteLocationNormalized) => v.path === view.path,
)
)
return;
const insertIndex = visitedViews.value.findIndex(
(item) => !item.meta?.affix,
);
const normalizedView = normalizeVisitedView(view); const normalizedView = normalizeVisitedView(view);
if (insertIndex === -1) { if (insertIndex === -1) {
visitedViews.value.push(normalizedView); visitedViews.value.push(normalizedView);
@@ -115,13 +142,13 @@ export const useTagsViewStore = defineStore('tagsView', () => {
}; };
const loadPersistedViews = (): void => { const loadPersistedViews = (): void => {
loadVisitedViews().forEach(view => { loadVisitedViews().forEach((view) => {
if (visitedViews.value.some(item => item.path === view.path)) { if (visitedViews.value.some((item) => item.path === view.path)) {
return; return;
} }
visitedViews.value.push({ visitedViews.value.push({
hash: '', hash: "",
matched: [], matched: [],
params: {}, params: {},
redirectedFrom: undefined, redirectedFrom: undefined,
@@ -130,29 +157,31 @@ export const useTagsViewStore = defineStore('tagsView', () => {
query: view.query || {}, query: view.query || {},
name: view.name || undefined, name: view.name || undefined,
meta: view.meta || {}, meta: view.meta || {},
title: view.title || view.meta?.title || 'no-name' title: view.title || view.meta?.title || "no-name",
} as TagView); } as TagView);
}); });
}; };
const delView = ( const delView = (
view: RouteLocationNormalized view: RouteLocationNormalized,
): Promise<{ ): Promise<{
visitedViews: RouteLocationNormalized[]; visitedViews: RouteLocationNormalized[];
cachedViews: string[]; cachedViews: string[];
}> => { }> => {
return new Promise(resolve => { return new Promise((resolve) => {
delVisitedView(view); delVisitedView(view);
delCachedView(view); delCachedView(view);
resolve({ resolve({
visitedViews: visitedViews.value.slice() as RouteLocationNormalized[], visitedViews: visitedViews.value.slice() as RouteLocationNormalized[],
cachedViews: [...cachedViews.value] cachedViews: [...cachedViews.value],
}); });
}); });
}; };
const delVisitedView = (view: RouteLocationNormalized): Promise<RouteLocationNormalized[]> => { const delVisitedView = (
return new Promise(resolve => { view: RouteLocationNormalized,
): Promise<RouteLocationNormalized[]> => {
return new Promise((resolve) => {
for (const [i, v] of visitedViews.value.entries()) { for (const [i, v] of visitedViews.value.entries()) {
if (v.path === view.path) { if (v.path === view.path) {
visitedViews.value.splice(i, 1); visitedViews.value.splice(i, 1);
@@ -165,11 +194,11 @@ export const useTagsViewStore = defineStore('tagsView', () => {
}; };
const delCachedView = (view?: RouteLocationNormalized): Promise<string[]> => { const delCachedView = (view?: RouteLocationNormalized): Promise<string[]> => {
let viewName = ''; let viewName = "";
if (view) { if (view) {
viewName = view.name as string; viewName = view.name as string;
} }
return new Promise(resolve => { return new Promise((resolve) => {
const index = cachedViews.value.indexOf(viewName); const index = cachedViews.value.indexOf(viewName);
index > -1 && cachedViews.value.splice(index, 1); index > -1 && cachedViews.value.splice(index, 1);
resolve([...cachedViews.value]); resolve([...cachedViews.value]);
@@ -177,34 +206,40 @@ export const useTagsViewStore = defineStore('tagsView', () => {
}; };
const delOthersViews = ( const delOthersViews = (
view: RouteLocationNormalized view: RouteLocationNormalized,
): Promise<{ ): Promise<{
visitedViews: RouteLocationNormalized[]; visitedViews: RouteLocationNormalized[];
cachedViews: string[]; cachedViews: string[];
}> => { }> => {
return new Promise(resolve => { return new Promise((resolve) => {
delOthersVisitedViews(view); delOthersVisitedViews(view);
delOthersCachedViews(view); delOthersCachedViews(view);
resolve({ resolve({
visitedViews: visitedViews.value.slice() as RouteLocationNormalized[], visitedViews: visitedViews.value.slice() as RouteLocationNormalized[],
cachedViews: [...cachedViews.value] cachedViews: [...cachedViews.value],
}); });
}); });
}; };
const delOthersVisitedViews = (view: RouteLocationNormalized): Promise<RouteLocationNormalized[]> => { const delOthersVisitedViews = (
return new Promise(resolve => { view: RouteLocationNormalized,
visitedViews.value = visitedViews.value.filter((v: RouteLocationNormalized) => { ): Promise<RouteLocationNormalized[]> => {
return new Promise((resolve) => {
visitedViews.value = visitedViews.value.filter(
(v: RouteLocationNormalized) => {
return v.meta?.affix || v.path === view.path; return v.meta?.affix || v.path === view.path;
}); },
);
saveVisitedViews(visitedViews.value); saveVisitedViews(visitedViews.value);
resolve(visitedViews.value.slice() as RouteLocationNormalized[]); resolve(visitedViews.value.slice() as RouteLocationNormalized[]);
}); });
}; };
const delOthersCachedViews = (view: RouteLocationNormalized): Promise<string[]> => { const delOthersCachedViews = (
view: RouteLocationNormalized,
): Promise<string[]> => {
const viewName = view.name as string; const viewName = view.name as string;
return new Promise(resolve => { return new Promise((resolve) => {
const index = cachedViews.value.indexOf(viewName); const index = cachedViews.value.indexOf(viewName);
if (index > -1) { if (index > -1) {
cachedViews.value = cachedViews.value.slice(index, index + 1); cachedViews.value = cachedViews.value.slice(index, index + 1);
@@ -219,26 +254,28 @@ export const useTagsViewStore = defineStore('tagsView', () => {
visitedViews: RouteLocationNormalized[]; visitedViews: RouteLocationNormalized[];
cachedViews: string[]; cachedViews: string[];
}> => { }> => {
return new Promise(resolve => { return new Promise((resolve) => {
delAllVisitedViews(); delAllVisitedViews();
delAllCachedViews(); delAllCachedViews();
resolve({ resolve({
visitedViews: visitedViews.value.slice() as RouteLocationNormalized[], visitedViews: visitedViews.value.slice() as RouteLocationNormalized[],
cachedViews: [...cachedViews.value] cachedViews: [...cachedViews.value],
}); });
}); });
}; };
const delAllVisitedViews = (): Promise<RouteLocationNormalized[]> => { const delAllVisitedViews = (): Promise<RouteLocationNormalized[]> => {
return new Promise(resolve => { return new Promise((resolve) => {
visitedViews.value = visitedViews.value.filter((tag: RouteLocationNormalized) => tag.meta?.affix); visitedViews.value = visitedViews.value.filter(
(tag: RouteLocationNormalized) => tag.meta?.affix,
);
clearVisitedViews(); clearVisitedViews();
resolve(visitedViews.value.slice() as RouteLocationNormalized[]); resolve(visitedViews.value.slice() as RouteLocationNormalized[]);
}); });
}; };
const delAllCachedViews = (): Promise<string[]> => { const delAllCachedViews = (): Promise<string[]> => {
return new Promise(resolve => { return new Promise((resolve) => {
cachedViews.value = []; cachedViews.value = [];
resolve([...cachedViews.value]); resolve([...cachedViews.value]);
}); });
@@ -254,14 +291,19 @@ export const useTagsViewStore = defineStore('tagsView', () => {
saveVisitedViews(visitedViews.value); saveVisitedViews(visitedViews.value);
}; };
const delRightTags = (view: RouteLocationNormalized): Promise<RouteLocationNormalized[]> => { const delRightTags = (
return new Promise(resolve => { view: RouteLocationNormalized,
const index = visitedViews.value.findIndex((v: RouteLocationNormalized) => v.path === view.path); ): Promise<RouteLocationNormalized[]> => {
return new Promise((resolve) => {
const index = visitedViews.value.findIndex(
(v: RouteLocationNormalized) => v.path === view.path,
);
if (index === -1) { if (index === -1) {
resolve(visitedViews.value.slice() as RouteLocationNormalized[]); resolve(visitedViews.value.slice() as RouteLocationNormalized[]);
return; 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)) { if (idx <= index || (item.meta && item.meta.affix)) {
return true; return true;
} }
@@ -270,20 +312,26 @@ export const useTagsViewStore = defineStore('tagsView', () => {
cachedViews.value.splice(i, 1); cachedViews.value.splice(i, 1);
} }
return false; return false;
}); },
);
saveVisitedViews(visitedViews.value); saveVisitedViews(visitedViews.value);
resolve(visitedViews.value.slice() as RouteLocationNormalized[]); resolve(visitedViews.value.slice() as RouteLocationNormalized[]);
}); });
}; };
const delLeftTags = (view: RouteLocationNormalized): Promise<RouteLocationNormalized[]> => { const delLeftTags = (
return new Promise(resolve => { view: RouteLocationNormalized,
const index = visitedViews.value.findIndex((v: RouteLocationNormalized) => v.path === view.path); ): Promise<RouteLocationNormalized[]> => {
return new Promise((resolve) => {
const index = visitedViews.value.findIndex(
(v: RouteLocationNormalized) => v.path === view.path,
);
if (index === -1) { if (index === -1) {
resolve(visitedViews.value.slice() as RouteLocationNormalized[]); resolve(visitedViews.value.slice() as RouteLocationNormalized[]);
return; 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)) { if (idx >= index || (item.meta && item.meta.affix)) {
return true; return true;
} }
@@ -292,7 +340,8 @@ export const useTagsViewStore = defineStore('tagsView', () => {
cachedViews.value.splice(i, 1); cachedViews.value.splice(i, 1);
} }
return false; return false;
}); },
);
saveVisitedViews(visitedViews.value); saveVisitedViews(visitedViews.value);
resolve(visitedViews.value.slice() as RouteLocationNormalized[]); resolve(visitedViews.value.slice() as RouteLocationNormalized[]);
}); });
@@ -332,6 +381,6 @@ export const useTagsViewStore = defineStore('tagsView', () => {
delLeftTags, delLeftTags,
addIframeView, addIframeView,
delIframeView, delIframeView,
loadPersistedViews loadPersistedViews,
}; };
}); });

View File

@@ -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()); const [err, res] = await to(getUserInfo());
if (res) { if (res) {
const data = (res as RuoYiAjaxResult<UserInfo>).data; const data = (res as RuoYiAjaxResult<UserInfo>).data;
@@ -58,7 +58,7 @@ export const useUserStore = defineStore('user', () => {
nickname.value = user.nickName; nickname.value = user.nickName;
avatar.value = profile; avatar.value = profile;
userId.value = user.userId; userId.value = user.userId;
return Promise.resolve(); return Promise.resolve(res);
} }
return Promise.reject(err); return Promise.reject(err);
}; };

13
src/types/env.d.ts vendored
View File

@@ -4,8 +4,10 @@ declare module '*.vue' {
export default Component; export default Component;
} }
declare module 'virtual:svg-icons-register' {} declare module 'virtual:svg-icons-register' {
declare module 'virtual:*' {} }
declare module 'virtual:*' {
}
// 环境变量 // 环境变量
interface ImportMetaEnv { interface ImportMetaEnv {
@@ -24,8 +26,15 @@ interface ImportMetaEnv {
VITE_APP_CLIENT_ID: string; VITE_APP_CLIENT_ID: string;
VITE_APP_MESSAGE_ENABLED: string; VITE_APP_MESSAGE_ENABLED: string;
VITE_APP_MESSAGE_TRANSPORT: string; VITE_APP_MESSAGE_TRANSPORT: string;
/** 默认图片 */
VITE_DEFAULT_USER_IMAGE?: string;
/** 本机环境 */
VITE_LOCALHOUSET?: string;
/** 基础地址 */
VITE_BASE_API?: string;
VITE_APP_MESSAGE_PATH: string; VITE_APP_MESSAGE_PATH: string;
} }
interface ImportMeta { interface ImportMeta {
readonly env: ImportMetaEnv; readonly env: ImportMetaEnv;
// readonly glob: any; // readonly glob: any;

18
src/utils/phone.ts Normal file
View 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
View 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())
}

View File

@@ -255,6 +255,7 @@ service.interceptors.response.use(
return Promise.reject(error); return Promise.reject(error);
} }
); );
// 通用下载方法 // 通用下载方法
export function download(url: string, params: any, fileName: string) { export function download(url: string, params: any, fileName: string) {
downloadLoadingInstance = ElLoading.service({ downloadLoadingInstance = ElLoading.service({
@@ -292,5 +293,6 @@ export function download(url: string, params: any, fileName: string) {
downloadLoadingInstance?.close(); downloadLoadingInstance?.close();
}); });
} }
// 导出 axios 实例 // 导出 axios 实例
export default service; export default service;

23
src/utils/richtext.ts Normal file
View 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
View 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] || '';
}

View File

@@ -0,0 +1,11 @@
<script lang="ts" setup>
</script>
<template>
<div class="Customer">Customer</div>
</template>
<style lang="scss" scoped>
</style>

View File

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

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

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

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

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

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

View File

@@ -47,18 +47,12 @@
<div <div
class="metric-card" class="metric-card"
:class="{ :class="{
active: item.isActivcty, active: item.isAction,
}" }"
></div> ></div>
<div> <div>
周{{ <span>{{ numToWeek(item.formateDay + 1)}}</span>
new Intl.NumberFormat("zh-Hans-CN-u-nu-hanidec").format( <span style="font-size: 12px">({{ item.month }}.{{ item.date }})</span>
item.formatlday + 1,
)
}}
<span style="font-size: 12px"
>({{ item.month }}.{{ item.date }})</span
>
</div> </div>
</article> </article>
</div> </div>
@@ -116,7 +110,7 @@
</el-input> </el-input>
</div> </div>
</el-form-item> </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 <el-input
v-model="loginForm.code" v-model="loginForm.code"
size="large" size="large"
@@ -133,7 +127,7 @@
<div class="login-code"> <div class="login-code">
<img :src="codeUrl" class="login-code-img" @click="getCode" /> <img :src="codeUrl" class="login-code-img" @click="getCode" />
</div> </div>
</el-form-item> --> </el-form-item>
<div class="form-meta"> <div class="form-meta">
<el-checkbox v-model="loginForm.rememberMe">{{ <el-checkbox v-model="loginForm.rememberMe">{{
$t("login.rememberPassword") $t("login.rememberPassword")
@@ -168,6 +162,7 @@ import { useI18n } from "vue-i18n";
import {getCodeImg} from "@/api/login"; import {getCodeImg} from "@/api/login";
import {LoginData} from "@/api/types"; import {LoginData} from "@/api/types";
import {useUserStore} from "@/store/modules/user"; import {useUserStore} from "@/store/modules/user";
import {numToWeek} from "@/utils/time";
const title = import.meta.env.VITE_APP_TITLE; const title = import.meta.env.VITE_APP_TITLE;
const quickStats = ref([]); const quickStats = ref([]);
@@ -292,17 +287,14 @@ const handleGetLoc = async () => {
.then((res) => res.json()) .then((res) => res.json())
.then((res) => { .then((res) => {
const arr = res.data.forecast.daily.pm10 ?? []; const arr = res.data.forecast.daily.pm10 ?? [];
console.log(arr.slice(0, 7)); quickStats.value = handleMax(arr.slice(0, 7));
const list = handleMaxWaqi(arr.slice(0, 7));
quickStats.value = list;
console.log(list);
}); });
} catch (e) { } catch (e) {
console.error((e as Error).message); 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 maxVal = Math.max(...arr.map((item) => item.avg));
const nowDate = new Date(); const nowDate = new Date();
return arr.map((item) => { return arr.map((item) => {
@@ -312,9 +304,9 @@ function handleMaxWaqi(arr: { avg: number; day: string }[]) {
...item, ...item,
rate, rate,
month: newDate.getMonth() + 1, month: newDate.getMonth() + 1,
formatlday: newDate.getDay(), formateDay: newDate.getDay(),
date: newDate.getDate(), date: newDate.getDate(),
isActivcty: newDate.getDate() === nowDate.getDate(), isAction: newDate.getDate() === nowDate.getDate(),
}; };
}); });
} }
@@ -369,7 +361,7 @@ onMounted(() => {
.title { .title {
height: 28px; height: 28px;
line-height: 28px; line-height: 28px;
border-radius: 0px 0px 0px 0px; border-radius: 0 0 0 0;
background-color: rgba(0, 0, 0, 0); background-color: rgba(0, 0, 0, 0);
color: rgba(255, 255, 255, 1); color: rgba(255, 255, 255, 1);
font-size: 20px; font-size: 20px;
@@ -377,7 +369,7 @@ onMounted(() => {
.subTitle { .subTitle {
height: 18px; height: 18px;
line-height: 18px; line-height: 18px;
border-radius: 0px 0px 0px 0px; border-radius: 0 0 0 0;
background-color: rgba(0, 0, 0, 0); background-color: rgba(0, 0, 0, 0);
color: rgba(255, 255, 255, 0.722); color: rgba(255, 255, 255, 0.722);
font-size: 13px; font-size: 13px;
@@ -451,7 +443,7 @@ onMounted(() => {
gap: 16px; gap: 16px;
height: 105px; height: 105px;
.metric-card { .metric-card {
border-radius: 8px 8px 0px 0px; border-radius: 8px 8px 0 0;
background-color: rgba(255, 255, 255, 0.3); background-color: rgba(255, 255, 255, 0.3);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
display: flex; display: flex;
@@ -475,7 +467,7 @@ onMounted(() => {
.brand-footer { .brand-footer {
margin-top: 157px; margin-top: 157px;
line-height: 16px; line-height: 16px;
border-radius: 0px 0px 0px 0px; border-radius: 0 0 0 0;
background-color: rgba(0, 0, 0, 0); background-color: rgba(0, 0, 0, 0);
color: rgba(255, 255, 255, 0.502); color: rgba(255, 255, 255, 0.502);
font-size: 13px; font-size: 13px;
@@ -540,7 +532,7 @@ onMounted(() => {
.loginModel { .loginModel {
height: 22px; height: 22px;
line-height: 22px; line-height: 22px;
border-radius: 0px 0px 0px 0px; border-radius: 0 0 0 0;
background-color: rgba(0, 0, 0, 0); background-color: rgba(0, 0, 0, 0);
color: rgba(37, 99, 235, 1); color: rgba(37, 99, 235, 1);
font-size: 15px; font-size: 15px;
@@ -558,6 +550,7 @@ onMounted(() => {
background-color: rgba(247, 248, 250, 1); background-color: rgba(247, 248, 250, 1);
border: 1px solid rgba(238, 240, 243, 1); border: 1px solid rgba(238, 240, 243, 1);
.actions { .actions {
cursor: pointer;
color: rgba(37, 99, 235, 1); color: rgba(37, 99, 235, 1);
} }
} }
@@ -636,7 +629,6 @@ onMounted(() => {
.login-form :deep(.el-input__wrapper) { .login-form :deep(.el-input__wrapper) {
min-height: 48px; min-height: 48px;
background-color: var(--el-bg-color);
background-color: rgba(247, 248, 250, 1); background-color: rgba(247, 248, 250, 1);
box-shadow: 0 0 0 1px var(--app-surface-border) inset; box-shadow: 0 0 0 1px var(--app-surface-border) inset;
} }

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

View File

@@ -1,5 +1,6 @@
<template> <template>
<div class="p-2 app-container system-client-page"> <div class="p-2 app-container system-client-page">
<Pagecrumb></Pagecrumb>
<div class="search-wrap"> <div class="search-wrap">
<el-card shadow="hover" class="search-panel" :class="{ 'is-collapsed': !showSearch }"> <el-card shadow="hover" class="search-panel" :class="{ 'is-collapsed': !showSearch }">
<template #header> <template #header>
@@ -36,7 +37,6 @@
</el-form> </el-form>
</el-card> </el-card>
</div> </div>
<el-card shadow="hover" class="table-panel"> <el-card shadow="hover" class="table-panel">
<template #header> <template #header>
<div class="toolbar-shell"> <div class="toolbar-shell">
@@ -82,14 +82,14 @@
class="data-table" class="data-table"
@selection-change="handleSelectionChange" @selection-change="handleSelectionChange"
> >
<el-table-column type="selection" width="55" align="center" /> <el-table-column align="center" type="selection" width="55"/>
<el-table-column v-if="false" label="id" align="center" prop="id" /> <el-table-column v-if="false" align="center" label="id" prop="id"/>
<el-table-column label="客户端id" align="center" prop="clientId" /> <el-table-column align="center" label="客户端id" prop="clientId"/>
<el-table-column label="客户端key" align="center" prop="clientKey" /> <el-table-column align="center" label="客户端key" prop="clientKey"/>
<el-table-column label="客户端秘钥" align="center" prop="clientSecret" /> <el-table-column align="center" label="客户端秘钥" prop="clientSecret"/>
<el-table-column label="授权类型" align="center"> <el-table-column label="授权类型" align="center">
<template #default="scope"> <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> </template>
</el-table-column> </el-table-column>
<el-table-column label="设备类型" align="center"> <el-table-column label="设备类型" align="center">
@@ -132,8 +132,8 @@
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="Token活跃超时时间" align="center" prop="activeTimeout" /> <el-table-column align="center" label="Token活跃超时时间" prop="activeTimeout"/>
<el-table-column label="Token固定超时时间" align="center" prop="timeout" /> <el-table-column align="center" label="Token固定超时时间" prop="timeout"/>
<el-table-column key="status" label="状态" align="center"> <el-table-column key="status" label="状态" align="center">
<template #default="scope"> <template #default="scope">
<el-switch <el-switch
@@ -214,7 +214,7 @@
允许访问路径 允许访问路径
</span> </span>
</template> </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>
<el-form-item prop="ipWhitelist" label-width="auto"> <el-form-item prop="ipWhitelist" label-width="auto">
<template #label> <template #label>
@@ -286,6 +286,8 @@ import { useTableSelection } from '@/hooks/table/useTableSelection';
import modal from '@/plugins/modal'; import modal from '@/plugins/modal';
import { useDict } from '@/utils/dict'; import { useDict } from '@/utils/dict';
import { download as requestDownload } from '@/utils/request'; 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_normal_disable } = toRefs<any>(useDict('sys_normal_disable'));
const { sys_grant_type } = toRefs<any>(useDict('sys_grant_type')); const { sys_grant_type } = toRefs<any>(useDict('sys_grant_type'));

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

View 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} &nbsp; 用户点击「仍未解决,转人工」`
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>

View File

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

View File

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

View File

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

View File

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

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

View 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')}`
}

View 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 }[]
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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
}

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="p-2 app-container dict-page"> <div class="dict-page">
<el-row :gutter="16" class="dict-grid"> <el-row :gutter="16" class="dict-grid">
<!-- 字典类型 --> <!-- 字典类型 -->
<el-col :xs="24" :lg="12"> <el-col :xs="24" :lg="12">
@@ -97,15 +97,13 @@
ref="typeTableRef" ref="typeTableRef"
v-loading="typeLoading" v-loading="typeLoading"
border border
class="data-table"
:data="typeList" :data="typeList"
highlight-current-row
@row-click="handleTypeRowClick" @row-click="handleTypeRowClick"
@selection-change="handleTypeSelectionChange" @selection-change="handleTypeSelectionChange"
> >
<el-table-column type="selection" width="55" align="center" /> <el-table-column align="center" type="selection" width="55"/>
<el-table-column v-if="false" label="字典编号" align="center" prop="dictId" /> <el-table-column v-if="false" align="center" label="字典编号" prop="dictId"/>
<el-table-column label="字典名称" align="center" prop="dictName" width="120" /> <el-table-column align="center" label="字典名称" prop="dictName" width="120"/>
<el-table-column label="字典类型" align="center" prop="dictType" width="160"> <el-table-column label="字典类型" align="center" prop="dictType" width="160">
<template #default="scope"> <template #default="scope">
<span class="link-type" @click.stop="handleTypeRowClick(scope.row)"> <span class="link-type" @click.stop="handleTypeRowClick(scope.row)">
@@ -113,7 +111,7 @@
</span> </span>
</template> </template>
</el-table-column> </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"> <el-table-column label="创建时间" align="center" prop="createTime" width="180">
<template #default="scope"> <template #default="scope">
<span>{{ parseTime(scope.row.createTime) }}</span> <span>{{ parseTime(scope.row.createTime) }}</span>
@@ -250,12 +248,12 @@
<el-table <el-table
v-loading="dataLoading" v-loading="dataLoading"
border border
class="data-table" style="max-height:500px;"
:data="dataList" :data="dataList"
@selection-change="handleDataSelectionChange" @selection-change="handleDataSelectionChange"
> >
<el-table-column type="selection" width="55" align="center" /> <el-table-column align="center" type="selection" width="55"/>
<el-table-column v-if="false" label="字典编码" align="center" prop="dictCode" /> <el-table-column v-if="false" align="center" label="字典编码" prop="dictCode"/>
<el-table-column label="字典标签" align="center" prop="dictLabel" width="100"> <el-table-column label="字典标签" align="center" prop="dictLabel" width="100">
<template #default="scope"> <template #default="scope">
<span <span
@@ -279,9 +277,9 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="字典键值" align="center" prop="dictValue" width="100" /> <el-table-column align="center" label="字典键值" prop="dictValue" width="100"/>
<el-table-column label="字典排序" align="center" prop="dictSort" width="80" /> <el-table-column align="center" label="字典排序" prop="dictSort" width="80"/>
<el-table-column label="备注" align="center" prop="remark" width="100" /> <el-table-column align="center" label="备注" prop="remark" width="100"/>
<el-table-column label="创建时间" align="center" prop="createTime" width="180"> <el-table-column label="创建时间" align="center" prop="createTime" width="180">
<template #default="scope"> <template #default="scope">
<span>{{ parseTime(scope.row.createTime) }}</span> <span>{{ parseTime(scope.row.createTime) }}</span>
@@ -344,7 +342,7 @@
字典类型 字典类型
</span> </span>
</template> </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>
<el-form-item label="备注" prop="remark"> <el-form-item label="备注" prop="remark">
<el-input v-model="typeForm.remark" type="textarea" placeholder="请输入内容"></el-input> <el-input v-model="typeForm.remark" type="textarea" placeholder="请输入内容"></el-input>
@@ -374,7 +372,7 @@
<el-input v-model="dataForm.cssClass" placeholder="请输入样式属性"/> <el-input v-model="dataForm.cssClass" placeholder="请输入样式属性"/>
</el-form-item> </el-form-item>
<el-form-item label="显示排序" prop="dictSort"> <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>
<el-form-item label="回显样式" prop="listClass"> <el-form-item label="回显样式" prop="listClass">
<el-select v-model="dataForm.listClass"> <el-select v-model="dataForm.listClass">
@@ -734,30 +732,30 @@ onMounted(() => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.dict-page {
height: 100%;
}
.dict-grid { .dict-grid {
row-gap: 16px;
} }
.dict-card { .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 { .dict-card__header {
gap: 12px; gap: 12px;
} flex-shrink: 0; // 头部搜索栏固定高度,不压缩
.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);
} }
.dict-search { .dict-search {
@@ -765,10 +763,10 @@ onMounted(() => {
max-height: 240px; max-height: 240px;
opacity: 1; opacity: 1;
margin-bottom: 12px; margin-bottom: 12px;
transition: transition: max-height 0.32s cubic-bezier(0.22, 1, 0.36, 1),
max-height 0.32s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.24s ease, opacity 0.24s ease,
margin-bottom 0.24s ease; margin-bottom 0.24s ease;
flex-shrink: 0;
} }
.dict-search.is-collapsed { .dict-search.is-collapsed {
@@ -785,6 +783,7 @@ onMounted(() => {
.dict-actions { .dict-actions {
margin: 0 0 12px; margin: 0 0 12px;
padding-bottom: 0; padding-bottom: 0;
flex-shrink: 0; // 按钮栏固定高度,不压缩
} }
.dict-actions :deep(.el-button) { .dict-actions :deep(.el-button) {
@@ -799,5 +798,11 @@ onMounted(() => {
.dict-table-wrap { .dict-table-wrap {
overflow-x: auto; overflow-x: auto;
max-height: 800px;
}
// 表格设置最大高度:自动占满剩余空间,不要写死500px
.dict-table-wrap :deep(.el-table) {
max-height: 100%;
} }
</style> </style>

View File

@@ -12,7 +12,7 @@
</template> </template>
<el-form ref="queryFormRef" :model="queryParams" :inline="true" class="query-form"> <el-form ref="queryFormRef" :model="queryParams" :inline="true" class="query-form">
<el-form-item label="文件名" prop="fileName"> <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>
<el-form-item label="原名" prop="originalName"> <el-form-item label="原名" prop="originalName">
<el-input <el-input
@@ -42,7 +42,7 @@
></el-date-picker> ></el-date-picker>
</el-form-item> </el-form-item>
<el-form-item label="服务商" prop="service"> <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-form-item> <el-form-item>
<el-button type="primary" icon="search" @click="handleQuery">搜索</el-button> <el-button type="primary" icon="search" @click="handleQuery">搜索</el-button>
@@ -109,11 +109,11 @@
@selection-change="handleSelectionChange" @selection-change="handleSelectionChange"
@header-click="handleHeaderCLick" @header-click="handleHeaderCLick"
> >
<el-table-column type="selection" width="55" align="center" /> <el-table-column align="center" type="selection" width="55"/>
<el-table-column v-if="false" label="对象存储主键" align="center" prop="ossId" /> <el-table-column v-if="false" align="center" label="对象存储主键" prop="ossId"/>
<el-table-column label="文件名" align="center" prop="fileName" /> <el-table-column align="center" label="文件名" prop="fileName"/>
<el-table-column label="原名" align="center" prop="originalName" /> <el-table-column align="center" label="原名" prop="originalName"/>
<el-table-column label="文件后缀" align="center" prop="fileSuffix" /> <el-table-column align="center" label="文件后缀" prop="fileSuffix"/>
<el-table-column label="文件展示" align="center" prop="url"> <el-table-column label="文件展示" align="center" prop="url">
<template #default="scope"> <template #default="scope">
<ImagePreview <ImagePreview
@@ -131,8 +131,8 @@
<span>{{ parseTime(scope.row.createTime, '{y}-{m}-{d}') }}</span> <span>{{ parseTime(scope.row.createTime, '{y}-{m}-{d}') }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="上传人" align="center" prop="createByName" /> <el-table-column align="center" label="上传人" prop="createByName"/>
<el-table-column label="服务商" align="center" prop="service" sortable="custom" /> <el-table-column align="center" label="服务商" prop="service" sortable="custom"/>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width"> <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template #default="scope"> <template #default="scope">
<el-tooltip content="下载" placement="top"> <el-tooltip content="下载" placement="top">
@@ -267,21 +267,25 @@ const getList = async () => {
showTable.value = true; showTable.value = true;
}); });
}; };
function checkFileSuffix(fileSuffix: string | string[]) { function checkFileSuffix(fileSuffix: string | string[]) {
const arr = ['.png', '.jpg', '.jpeg']; const arr = ['.png', '.jpg', '.jpeg'];
const suffixArray = Array.isArray(fileSuffix) ? fileSuffix : [fileSuffix]; const suffixArray = Array.isArray(fileSuffix) ? fileSuffix : [fileSuffix];
return suffixArray.some(suffix => arr.includes(suffix.toLowerCase())); return suffixArray.some(suffix => arr.includes(suffix.toLowerCase()));
} }
/** 取消按钮 */ /** 取消按钮 */
function cancel() { function cancel() {
reset(); reset();
closeDialog(); closeDialog();
} }
/** 搜索按钮操作 */ /** 搜索按钮操作 */
function handleQuery() { function handleQuery() {
queryParams.value.pageNum = 1; queryParams.value.pageNum = 1;
getList(); getList();
} }
const {resetQuery} = useSearchReset({ const {resetQuery} = useSearchReset({
queryFormRef, queryFormRef,
queryParams, queryParams,
@@ -343,7 +347,7 @@ const handleOrderChange = (prop: string, order: string) => {
}; };
/** 任务日志列表查询 */ /** 任务日志列表查询 */
const handleOssConfig = () => { const handleOssConfig = () => {
router.push('/system/oss-config/index'); router.push('/system/oss-config/index.vue');
}; };
/** 文件按钮操作 */ /** 文件按钮操作 */
const handleFile = () => { const handleFile = () => {

View File

@@ -0,0 +1,258 @@
<script lang="ts" setup>
import {ArrowLeft, InfoFilled} from "@element-plus/icons-vue";
import FormItemTip from '@/components/FormItemTip/index.vue'
const roleslist = [
{
label: '超级管理员',
sutTitle: "拥有系统所有功能权限"
},
{
label: '设备管理员',
sutTitle: "管理设备列表、OTA升级等"
},
{
label: '普通用户',
sutTitle: "仅可查看设备信息"
},
{
label: '审计员',
sutTitle: "查看操作日志与审计"
},
]
</script>
<template>
<div class="Add">
<div class="AddNav">
<div class="addItem">
<div class="arrowBox">
<el-icon>
<arrow-left></arrow-left>
</el-icon>
</div>
<div class="title">新增账号</div>
</div>
<div class="describe">创建新的管理后台用户账号并配置角色权限</div>
</div>
<el-row :gutter="20">
<el-col :span="17">
<el-form label-position="top">
<div class="commonCard">
<card-nav :show-line="false" title="基本信息"></card-nav>
<el-row :gutter="20">
<el-col :span="12">
<FormItemTip
:inputAttrs="{placeholder:'请输入登录用户名'}"
:required="true"
label="登录用户名"
tip="4-20位字母、数字或下划线,创建后不可修改"
type="input"
></FormItemTip>
</el-col>
<el-col :span="12">
<FormItemTip
:inputAttrs="{placeholder:'请输入真实姓名'}"
:required="true"
label="用户姓名"
type="input"
></FormItemTip>
</el-col>
<el-col :span="12">
<FormItemTip
:inputAttrs="{placeholder:'请输入手机号码'}"
:required="true"
label="手机号码"
type="input"
></FormItemTip>
</el-col>
<el-col :span="12">
<FormItemTip
:inputAttrs="{placeholder:'请输入邮箱'}"
:required="false"
label="邮箱"
type="input"
></FormItemTip>
</el-col>
<el-col :span="12">
<FormItemTip
:radio-options="[
{label:'男',value:1},
{label:'女',value:0},
]"
:required="false"
label="性别"
type="radio"
></FormItemTip>
</el-col>
</el-row>
</div>
<div class="commonCard">
<el-row :gutter="20">
<el-col :span="12">
<FormItemTip
:required="true"
label="角色"
type="select"
></FormItemTip>
</el-col>
<el-col :span="12">
</el-col>
<el-col :span="12">
<FormItemTip
:input-attrs="{maxlength:20,minlength:8}"
:required="true"
label="初始密码"
tip="8-20位,包含字母和数字"
type="input"
></FormItemTip>
</el-col>
<el-col :span="12">
<FormItemTip
:input-attrs="{placeholder:'请再次输入密码',maxlength:20,minlength:8}"
:required="true"
label="重复初始密码"
type="input"
></FormItemTip>
</el-col>
<el-col :span="12">
<FormItemTip
:radio-options="[
{label:'启用',value:1},
{label:'禁用',value:0},
]"
:required="false"
label="性别"
type="radio"
></FormItemTip>
</el-col>
</el-row>
</div>
</el-form>
</el-col>
<el-col :span="7">
<div class="commonCard roleInfoBox">
<card-nav :show-line="false" title="角色说明"></card-nav>
<ul class="roleInfoListBox">
<li v-for="(item,index) in roleslist" :key="index" class="roleInfoListItem">
<div class="item">
<i class="circle"></i>
<span class="title">{{ item.label }}</span>
</div>
<div class="subTitle">{{ item.sutTitle }}</div>
</li>
</ul>
</div>
<div class="commonCard tipBox">
<div>
<el-icon>
<InfoFilled/>
</el-icon>
<span>操作提示</span>
</div>
<ul>
<li>1. 带 * 号为必填项</li>
<li>2. 用户名创建后不可修改</li>
</ul>
</div>
<div>
<el-button>确认创建</el-button>
<el-button>取消</el-button>
</div>
</el-col>
</el-row>
</div>
</template>
<style lang="scss" scoped>
.Add {
padding: 24px;
height: 100%;
overflow: auto;
.AddNav {
margin-bottom: 20px;
.addItem {
display: flex;
align-items: center;
.arrowBox {
width: 32px;
height: 32px;
border-radius: 4px 4px 4px 4px;
border: 1px solid rgba(209, 213, 219, 1);
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
}
.title {
font-weight: bold;
height: 24px;
line-height: 24px;
color: rgba(30, 41, 59, 1);
font-size: 20px;
}
}
.describe {
margin-top: 8px;
height: 20px;
line-height: 16px;
color: rgba(100, 116, 139, 1);
font-size: 13px;
}
}
.roleInfoBox {
.roleInfoListBox {
min-height: 219px;
margin-top: 12px;
display: flex;
flex-direction: column;
justify-content: space-between;
.roleInfoListItem {
.item {
display: flex;
align-items: center;
margin-bottom: 4px;
.circle {
display: block;
width: 9px;
height: 8px;
margin-right: 8px;
border-radius: 4px;
background-color: rgba(37, 99, 235, 1);
}
.title {
line-height: 16px;
color: rgba(30, 41, 59, 1);
font-size: 13px;
font-weight: 500;
}
}
.subTitle {
line-height: 13px;
color: rgba(100, 116, 139, 1);
font-size: 11px;
}
}
}
}
.tipBox {
margin-top: 20px;
border-radius: 8px 8px 8px 8px;
background-color: rgba(239, 246, 255, 1);
border: 1px solid rgba(191, 219, 254, 1);
padding: 17px;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More