Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c49da6cc5 | |||
| 2b925a1d37 | |||
| d695281424 | |||
| 2c6a2dafec | |||
| 6ab4fc9b9a | |||
| 77ac384d29 | |||
| 0dfc9a8bcc | |||
| 6e23f83e78 | |||
| b3f6d591b5 | |||
| bd1524f4f1 | |||
| f809bf05e3 | |||
| 6f80f5126c | |||
| b5d414d9af | |||
| a275a98671 | |||
| 1d9ef001c1 | |||
| aa23d0949b | |||
| 76baee2fe5 | |||
| 786082305d | |||
| ce0e9cd377 | |||
| 75d44a8eae | |||
| cdece9fd79 | |||
| 7c5d0127cc | |||
| a11d090273 | |||
| 9b2776e533 | |||
| 437f5bb567 | |||
| 15e86e0f4f | |||
| 65b2cb1037 | |||
| 2648c49a3b | |||
| 3b396dc762 | |||
| 0ed507efd5 | |||
| 4281cb3740 | |||
| 7638eaf80f | |||
| 963a95732b | |||
| 6e2cd44f55 | |||
| 0e8cec4af0 | |||
| 6ae4fd872d | |||
| 58a6732f1b | |||
| b527715b3a | |||
| 0f7aae3c72 | |||
| 8e2dd0600f | |||
| 926823df32 | |||
| 37e75014d0 | |||
| a70ad7a65c | |||
| 631ca4ec30 | |||
| cd1bbca4a6 | |||
| 2ba64b94b0 | |||
| 3aa1b0f79b | |||
| ff0fb83bd0 | |||
| 222d07d483 | |||
| 16abb6364c | |||
| 153bb37d10 | |||
| 0b89bade20 | |||
| dee800f90f | |||
| 9925e2e2e4 | |||
| 4f18a1b8bf | |||
| a514bf4009 | |||
| 91d810b33a | |||
| 459d306504 | |||
| 8a0d4fc516 | |||
| 58f7d56f40 | |||
| a91e1d7820 | |||
| a9f8c5c4dc | |||
| aefd43f79d | |||
| 1bd9d163ec | |||
| be6093e57d | |||
| d83246ba8c | |||
| 329acaf68d | |||
| b319944007 | |||
| 70d26f0829 | |||
| 0e84bedb67 | |||
| f5deb7b8f7 | |||
| e797cb24de | |||
| 619311dfbe | |||
| b402e522bf | |||
| f950f665a9 | |||
| 8deecc8197 | |||
| 3e24d1905f | |||
| 7542d80e38 | |||
| e5548f95e4 | |||
| d30ea5b7bf | |||
| 0e4160c5ca | |||
| bd23288acd | |||
| 6bfb940881 | |||
| b50f650565 | |||
| ea1dbb7af4 | |||
| 1bfbb95bd9 | |||
| a9f5f76b35 | |||
| 3ea9d3d5df | |||
| 0cef82279f | |||
| 695ebbcc71 | |||
| ffc194a1c1 | |||
| 48b95e252f | |||
| 382011d592 | |||
| 0f790269db | |||
| f18cfdb69e | |||
| 858147fbbd | |||
| c333b907e6 | |||
| 7979a31e36 | |||
| 4a39387865 | |||
| fc4de02582 | |||
| 2ffe0c9753 | |||
| c3fb2c48d3 | |||
| c5b0a8265a | |||
| ca06c9bc46 | |||
| 3b6de4c7fa | |||
| 85e8b9b6b4 | |||
| d3bb4ec6e5 | |||
| d7ab6f1a11 | |||
| c7d890809c | |||
| 31cd18fade | |||
| 2c9695fcd1 | |||
| 59c9c97c08 | |||
| c129009b23 | |||
| 7c1766ec79 | |||
| 2a82a7ba95 |
@@ -309,6 +309,7 @@
|
||||
"watchWithFilter": true,
|
||||
"whenever": true,
|
||||
"ElMessage": true,
|
||||
"ElTag": true
|
||||
"ElTag": true,
|
||||
"ElMessageBox": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ VITE_BASE_URL = /
|
||||
|
||||
# API 地址前缀
|
||||
# VITE_API_URL = https://m1.apifoxmock.com/m1/6400575-6097373-default
|
||||
# VITE_API_URL = http://219.157.255.213:25702
|
||||
# VITE_API_URL = http://219.157.255.213:25703
|
||||
# VITE_API_URL = https://mzl.dev.hnxinyouhe.com
|
||||
# VITE_API_URL = https://fdesk.hnxinyouhe.com
|
||||
VITE_API_URL = https://x-server.hnxinyouhe.com
|
||||
|
||||
VITE_API_URL = https://fdesk.hnxinyouhe.com
|
||||
# VITE_API_URL = https://x-server.hnxinyouhe.com
|
||||
|
||||
# VITE_API_URL_WSS = wss://mzl.dev.hnxinyouhe.com
|
||||
# VITE_API_URL_WSS = wss://fdesk.hnxinyouhe.com
|
||||
VITE_API_URL_WSS = wss://x-server.hnxinyouhe.com
|
||||
# Delete console
|
||||
VITE_DROP_CONSOLE = false
|
||||
@@ -5,7 +5,9 @@ VITE_BASE_URL = /
|
||||
|
||||
# API 地址前缀
|
||||
# VITE_API_URL = https://fdesk.hnxinyouhe.com
|
||||
# VITE_API_URL = https://mzl.dev.hnxinyouhe.com
|
||||
VITE_API_URL = https://x-server.hnxinyouhe.com
|
||||
|
||||
# VITE_API_URL_WSS = wss://fdesk.hnxinyouhe.com
|
||||
VITE_API_URL_WSS = wss://x-server.hnxinyouhe.com
|
||||
# Delete console
|
||||
VITE_DROP_CONSOLE = true
|
||||
@@ -3,4 +3,9 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
*.env
|
||||
.cursorrules
|
||||
src/types/components.d.ts
|
||||
src/types/auto-imports.d.ts
|
||||
.vscode
|
||||
.auto-import.json
|
||||
@@ -1 +0,0 @@
|
||||
pnpm dlx commitlint --edit $1
|
||||
@@ -1 +0,0 @@
|
||||
pnpm run lint:lint-staged
|
||||
@@ -0,0 +1,237 @@
|
||||
# 备货单车辆明细附件上传 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在 `Add.vue` 车辆明细每行增加行内附件上传,附件随车辆提交到 `stock_order_items.attachment`。
|
||||
|
||||
**Architecture:** 数据层改共用的 `useStockingForm.ts`:`ItemRow` 加 `attachment_files`(展示用 FileItem[])、`newItem` 初始化、`mappedItems` 映射成 `attachment`(`{url, original_name}` 数组,与 `contract` 实际格式一致)、新增上传/删除方法并暴露 `uploadHeaders`/`itemUploadAction`。UI 在 `Add.vue` 明细行加一个 `ElCol`:`el-upload` 按钮 + 已传文件 `el-tag` 列表。上传走现成 `/ticket/upload?scene=stock_orders`,无需改上传接口。
|
||||
|
||||
**Tech Stack:** Vue 3 `<script setup>`、TypeScript、element-plus 2.8、`useStockingForm` composable、`useUploadHeaders`(已导入)。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **无前端测试框架**:验收 = `npm run build`(含 `vue-tsc --noEmit` 类型检查)通过 + `dev` 手动核对。
|
||||
- **attachment 提交格式**:`{ url, original_name }` 对象数组(参照 `baseForm.contract` 实际代码 useStockingForm.ts:697-700),**不是** file_name 数组——这样回填/预览有 url 可用。
|
||||
- `useUploadHeaders` 已在 useStockingForm.ts:6 导入、:155 取出 `uploadHeaders`,直接复用、暴露。
|
||||
- 后端 `stock_order_items.attachment` 字段已加(DB 就绪);接口层需对接 `veh_list[].attachment` 的接收与返回。
|
||||
- commit 规范:每任务一次 commit,`feat:` 开头。
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 责任 | 改动 |
|
||||
|---|---|---|
|
||||
| `src/composables/useStockingForm.ts` | 车辆表单数据/上传方法(Add/edit 共用) | Modify |
|
||||
| `src/views/crmpc/stocking/Add.vue` | 添加备货单页,车辆明细行内附件 UI | Modify |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: useStockingForm.ts 数据层 + 上传方法
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/composables/useStockingForm.ts`(`ItemRow` 接口 ~49、`newItem` ~218、上传方法区 ~706、`mappedItems` ~1234、`populateFromDetail` items 映射 ~974、`return` ~1515)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `useUploadHeaders`(已导入)、`items: ref<ItemRow[]>`(已有)、`/ticket/upload` 返回 `{code, data:{original_name, file_url, file_name}}`。
|
||||
- Produces: `ItemRow.attachment_files`;暴露 `uploadHeaders`/`itemUploadAction`/`beforeItemAttachmentUpload`/`handleItemAttachmentUpload`/`removeItemAttachment`。
|
||||
|
||||
- [ ] **Step 1: `ItemRow` 接口加字段**
|
||||
|
||||
在 `export interface ItemRow { ... }` 内(如 `remark: string` 之后)加:
|
||||
|
||||
```ts
|
||||
attachment_files?: { name: string; url: string; file_name: string }[]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `newItem()` 初始化**
|
||||
|
||||
在 `const newItem = (): ItemRow => ({ ... })` 对象内(如 `remark: ''` 之后)加:
|
||||
|
||||
```ts
|
||||
attachment_files: [],
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 新增上传 action 与方法**
|
||||
|
||||
在 `handleContractUpload` 之后(约 line 706 后)插入:
|
||||
|
||||
```ts
|
||||
// 车辆明细附件上传
|
||||
const itemUploadAction = `${import.meta.env.VITE_API_URL}/ticket/upload?scene=stock_orders`
|
||||
|
||||
const beforeItemAttachmentUpload = (file: File) => {
|
||||
const isValidType = /\.(jpg|jpeg|png|gif|pdf|doc|docx|xls|xlsx|txt)$/i.test(file.name)
|
||||
if (!isValidType) {
|
||||
ElMessage.error('只能上传图片或文档文件!')
|
||||
return false
|
||||
}
|
||||
const isLt10M = file.size / 1024 / 1024 < 10
|
||||
if (!isLt10M) {
|
||||
ElMessage.error('文件大小不能超过 10MB!')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const handleItemAttachmentUpload = (response: any, idx: number) => {
|
||||
if (response.code === 200) {
|
||||
const fileInfo = {
|
||||
name: response.data.original_name || '附件',
|
||||
url: response.data.file_url,
|
||||
file_name: response.data.file_name
|
||||
}
|
||||
if (!items.value[idx].attachment_files) {
|
||||
items.value[idx].attachment_files = []
|
||||
}
|
||||
items.value[idx].attachment_files!.push(fileInfo)
|
||||
ElMessage.success('附件上传成功!')
|
||||
} else {
|
||||
ElMessage.error(response.message || '附件上传失败!')
|
||||
}
|
||||
}
|
||||
|
||||
const removeItemAttachment = (idx: number, fileIdx: number) => {
|
||||
const list = items.value[idx].attachment_files
|
||||
if (list) {
|
||||
list.splice(fileIdx, 1)
|
||||
ElMessage.success('附件已删除')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: `mappedItems` 映射 `attachment`**
|
||||
|
||||
在 `mappedItems` 的 `increase_price: wrapMoney(...)` 之后(约 line 1234 后、`}))` 之前)加:
|
||||
|
||||
```ts
|
||||
attachment: (it.attachment_files || []).map((f) => ({
|
||||
url: f.url,
|
||||
original_name: f.name
|
||||
}))
|
||||
```
|
||||
|
||||
- [ ] **Step 5: `populateFromDetail` 回填**
|
||||
|
||||
在 `populateFromDetail` 中 `items.value = list.map((it: any) => ({ ... }))` 的映射对象内(与 `model_snapshot` 等同层)加,兼容后端两种返回格式:
|
||||
|
||||
```ts
|
||||
attachment_files: (it.attachment || []).map((f: any) => ({
|
||||
name: f.original_name || f.name || '附件',
|
||||
url: f.url || f.file_url || '',
|
||||
file_name: f.file_name || f.url || ''
|
||||
}))
|
||||
```
|
||||
|
||||
- [ ] **Step 6: `return` 暴露**
|
||||
|
||||
在 `return {`(约 line 1515)对象内加:
|
||||
|
||||
```ts
|
||||
uploadHeaders,
|
||||
itemUploadAction,
|
||||
beforeItemAttachmentUpload,
|
||||
handleItemAttachmentUpload,
|
||||
removeItemAttachment,
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 类型检查(阶段性)**
|
||||
|
||||
Run: `npx vue-tsc --noEmit 2>&1 | grep -E "useStockingForm|error TS" | head`
|
||||
Expected: 无 `useStockingForm.ts` 相关 error。(dayjs 等无关报错忽略)
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/composables/useStockingForm.ts
|
||||
git commit -m "feat(stocking): useStockingForm 支持车辆明细附件上传
|
||||
|
||||
ItemRow 增加 attachment_files,mappedItems 映射 attachment,
|
||||
新增车辆附件上传/删除方法。
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add.vue 行内附件 UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/views/crmpc/stocking/Add.vue`(解构 ~1189-1211、车辆明细行 ~116+)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 暴露的 `uploadHeaders`/`itemUploadAction`/`beforeItemAttachmentUpload`/`handleItemAttachmentUpload`/`removeItemAttachment`;`items`、`idx`(已有)。
|
||||
- Produces: 每行附件上传交互。
|
||||
|
||||
- [ ] **Step 1: 解构新方法**
|
||||
|
||||
在 Add.vue 从 `useStockingForm(false)` 解构的块内(`chooseVehicle` 等同处,约 line 1206-1210)加:
|
||||
|
||||
```ts
|
||||
uploadHeaders,
|
||||
itemUploadAction,
|
||||
beforeItemAttachmentUpload,
|
||||
handleItemAttachmentUpload,
|
||||
removeItemAttachment
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 明细行加「附件」ElCol**
|
||||
|
||||
在车辆明细 `v-for="(item, idx) in items"` 的行内(建议放在「里程」`ElCol` 之后,约 line 196 后)加:
|
||||
|
||||
```html
|
||||
<ElCol :sm="12" :md="12" :lg="6" :xl="4">
|
||||
<ElFormItem label="附件" label-width="85px">
|
||||
<el-upload
|
||||
:action="itemUploadAction"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:before-upload="beforeItemAttachmentUpload"
|
||||
:on-success="(res: any) => handleItemAttachmentUpload(res, idx)"
|
||||
multiple
|
||||
accept=".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.txt"
|
||||
>
|
||||
<el-button size="small" type="primary" plain>上传附件</el-button>
|
||||
</el-upload>
|
||||
<div
|
||||
v-if="item.attachment_files && item.attachment_files.length"
|
||||
style="margin-top: 4px"
|
||||
>
|
||||
<el-tag
|
||||
v-for="(f, fIdx) in item.attachment_files"
|
||||
:key="fIdx"
|
||||
closable
|
||||
@close="removeItemAttachment(idx, fIdx)"
|
||||
style="margin: 2px 4px 2px 0"
|
||||
>
|
||||
{{ f.name }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 类型检查 + 构建**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: vue-tsc 无 error,vite build 产出 dist。
|
||||
|
||||
- [ ] **Step 4: 手动核对(dev)**
|
||||
|
||||
Run: `npm run dev`,进入添加备货单页。
|
||||
核对:每辆车行有「上传附件」按钮;选文件上传后出现文件名 tag;tag × 可删;提交时 Network `veh_list[].attachment` 为 `{url, original_name}` 数组。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/views/crmpc/stocking/Add.vue
|
||||
git commit -m "feat(stocking): 添加备货单车辆明细行内附件上传
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 后端联调清单(交付后端)
|
||||
|
||||
- `stock_order_items.attachment` 字段已就绪。
|
||||
- store/update:接收 `veh_list[].attachment`(`{url, original_name}` 对象数组),写入 `attachment`(json 序列化)。
|
||||
- show/detail:返回 `items[].attachment`(同 `{url, original_name}` 结构),前端据此回显。
|
||||
@@ -0,0 +1,272 @@
|
||||
# 备货单 / 终版列表「全部 / 我的」视图切换 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在 `stocking_list.vue` 与 `final_list.vue` 给 `userId === 73` 的特权用户增加「全部 / 我的」视图切换,切换时给列表接口传 `view_scope=all|self`。
|
||||
|
||||
**Architecture:** 纯前端 UI + 传参改动。新增本地状态 `viewScope`,工具栏渲染条件 `canViewAll = userStore.getUserInfo.userId === 73`;`getList` 把 `viewScope.value` 作为 `view_scope` 随请求发出;切换时重置页码并重新请求。是否返回全量数据由后端按 token 鉴权决定(后端改造见 spec 第 3.4 节,不在本计划内)。
|
||||
|
||||
**Tech Stack:** Vue 3 `<script setup>` + TypeScript、element-plus 2.8、Pinia(`useUserStore`)、`unplugin-auto-import`(`ref/reactive/computed/onMounted/watch` 自动导入,无需手写 import)。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **无前端单元测试框架**:本项目 `package.json` 无 `test` 脚本。验收方式 = `lint` 通过 + 本地 `dev` 手动核对,不写自动化测试。
|
||||
- **后端是前置依赖**:前端「全部」真正生效需后端 `GET /crm/stock/order/list` 支持 `view_scope` 参数(见 spec 3.4)。前端可先行独立完成 UI 与传参;完整功能联调待后端就绪。
|
||||
- **安全边界在后端**:前端 `userId === 73` 仅控制按钮显隐,非授权依据。
|
||||
- **代码风格**:遵循现有写法——`<script setup lang="ts">`、element-plus 组件、snake_case 请求参数。
|
||||
- **commit 规范**:每任务一次 commit,信息以 `feat:` 开头。
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 责任 | 改动类型 |
|
||||
|---|---|---|
|
||||
| `src/views/crmpc/stocking/stocking_list.vue` | 备货单列表,加视图切换 + `view_scope` 传参 | Modify |
|
||||
| `src/views/crmpc/stocking/final_list.vue` | 终版列表,同构改动 | Modify |
|
||||
|
||||
两文件改动完全同构,仅 `is_final`(0 vs 1)不同。不新建文件。
|
||||
|
||||
---
|
||||
|
||||
## Task 1: stocking_list.vue 增加视图切换
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/views/crmpc/stocking/stocking_list.vue`
|
||||
- 模板工具栏(约 `line 9-22` 的 `toolbar-right`)
|
||||
- `getList` 参数(约 `line 339-344`)
|
||||
- script 状态(约 `line 280-281` `useAuth` 之后)
|
||||
- script import(约 `line 264-274`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `useUserStore`(来自 `@/store/modules/user`),其 `getUserInfo: computed => Partial<{ userId: number }>`;`CrmService.getStockList(params)`(已存在)。
|
||||
- Produces: 列表请求新增参数字段 `view_scope: 'all' | 'self'`。
|
||||
|
||||
- [ ] **Step 1: 添加 import**
|
||||
|
||||
在 `<script setup lang="ts">` 顶部 import 区(`import { useAuth } from '@/composables/useAuth'` 同处)增加一行:
|
||||
|
||||
```ts
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加状态与切换处理**
|
||||
|
||||
在 `const { hasAuth } = useAuth()`(约 line 281)之后插入:
|
||||
|
||||
```ts
|
||||
const userStore = useUserStore()
|
||||
// 仅 userId=73 的特权用户可切换「全部/我的」
|
||||
const canViewAll = computed(() => userStore.getUserInfo.userId === 73)
|
||||
const viewScope = ref<'all' | 'self'>('self')
|
||||
const onScopeChange = () => {
|
||||
pagination.currentPage = 1
|
||||
getList()
|
||||
}
|
||||
```
|
||||
|
||||
说明:`computed` / `ref` 由 `unplugin-auto-import` 提供,无需 import。`getUserInfo.userId` 类型为 `number | undefined`,`undefined === 73` 为 `false`,判空安全。
|
||||
|
||||
- [ ] **Step 3: 修改 `getList` 请求参数**
|
||||
|
||||
定位 `getList` 内(约 line 339-344):
|
||||
|
||||
```ts
|
||||
const params = {
|
||||
current: currentPage,
|
||||
size: pageSize,
|
||||
is_final: 0, // 只获取备货单,非终版
|
||||
keyword: keyword.value
|
||||
}
|
||||
```
|
||||
|
||||
改为(增加 `view_scope`):
|
||||
|
||||
```ts
|
||||
const params = {
|
||||
current: currentPage,
|
||||
size: pageSize,
|
||||
is_final: 0, // 只获取备货单,非终版
|
||||
keyword: keyword.value,
|
||||
view_scope: viewScope.value
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 工具栏添加切换控件**
|
||||
|
||||
定位模板 `toolbar-right`(约 line 9-21),在 `<el-input>` 之前插入 `el-radio-group`:
|
||||
|
||||
原:
|
||||
|
||||
```html
|
||||
<div class="toolbar-right">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```html
|
||||
<div class="toolbar-right">
|
||||
<el-radio-group
|
||||
v-if="canViewAll"
|
||||
v-model="viewScope"
|
||||
@change="onScopeChange"
|
||||
style="margin-right: 12px"
|
||||
>
|
||||
<el-radio-button value="self">我的</el-radio-button>
|
||||
<el-radio-button value="all">全部</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
```
|
||||
|
||||
说明:用 `value`(element-plus 2.6+ 推荐)而非 `label` 作值,避免 2.8 的 label 弃用告警。文字走默认插槽。
|
||||
|
||||
- [ ] **Step 5: Lint 校验**
|
||||
|
||||
Run: `npm run fix -- src/views/crmpc/stocking/stocking_list.vue`
|
||||
Expected: 无 error。若有,按提示修复(常见:prettier 格式)。
|
||||
|
||||
- [ ] **Step 6: 手动核对(前端独立部分)**
|
||||
|
||||
Run: `npm run dev`,浏览器打开备货单列表页。
|
||||
|
||||
核对项:
|
||||
1. 用**非 73 账号**登录:工具栏**不出现**「全部/我的」控件,列表行为同改动前。
|
||||
2. 打开 DevTools Network,看 `/crm/stock/order/list` 请求 Payload:无论是否 73,都应带上 `view_scope=self`(默认值)。
|
||||
3. (需 73 登录态)用 73 账号登录:出现「全部/我的」,默认选中「我的」;点「全部」后 Network 请求带 `view_scope=all`,点回「我的」带 `view_scope=self`,且每次切换分页回到第 1 页。
|
||||
4. 「全部」是否真返回全量数据,取决于后端是否已支持——后端未就绪时,切到「全部」数据可能不变,属预期。
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/views/crmpc/stocking/stocking_list.vue
|
||||
git commit -m "feat(stocking): 备货单列表为特权用户增加「全部/我的」视图切换
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: final_list.vue 增加视图切换(同构)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/views/crmpc/stocking/final_list.vue`
|
||||
- 模板工具栏(约 `line 7-19` 的 `toolbar-right`)
|
||||
- `getList` 参数(约 `line 278-283`)
|
||||
- script 状态(约 `line 229` `useAuth` 之后)
|
||||
- script import(约 `line 206-216`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 同 Task 1(`useUserStore`、`CrmService.getStockList`)。
|
||||
- Produces: 同 Task 1(`view_scope` 参数)。
|
||||
|
||||
- [ ] **Step 1: 添加 import**
|
||||
|
||||
在 `<script setup lang="ts">` 顶部 import 区增加:
|
||||
|
||||
```ts
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加状态与切换处理**
|
||||
|
||||
在 `const { hasAuth } = useAuth()`(约 line 229)之后插入:
|
||||
|
||||
```ts
|
||||
const userStore = useUserStore()
|
||||
// 仅 userId=73 的特权用户可切换「全部/我的」
|
||||
const canViewAll = computed(() => userStore.getUserInfo.userId === 73)
|
||||
const viewScope = ref<'all' | 'self'>('self')
|
||||
const onScopeChange = () => {
|
||||
pagination.currentPage = 1
|
||||
getList()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 修改 `getList` 请求参数**
|
||||
|
||||
定位 `getList` 内(约 line 278-283):
|
||||
|
||||
```ts
|
||||
const params = {
|
||||
current: currentPage,
|
||||
size: pageSize,
|
||||
is_final: 1, // 只获取备货单,非终版
|
||||
keyword: keyword.value
|
||||
}
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```ts
|
||||
const params = {
|
||||
current: currentPage,
|
||||
size: pageSize,
|
||||
is_final: 1, // 终版
|
||||
keyword: keyword.value,
|
||||
view_scope: viewScope.value
|
||||
}
|
||||
```
|
||||
|
||||
(注:原代码注释「非终版」是笔误,此处顺手修正为「终版」。)
|
||||
|
||||
- [ ] **Step 4: 工具栏添加切换控件**
|
||||
|
||||
定位模板 `toolbar-right`(约 line 7-19),在 `<el-input>` 之前插入:
|
||||
|
||||
原:
|
||||
|
||||
```html
|
||||
<div class="toolbar-right">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```html
|
||||
<div class="toolbar-right">
|
||||
<el-radio-group
|
||||
v-if="canViewAll"
|
||||
v-model="viewScope"
|
||||
@change="onScopeChange"
|
||||
style="margin-right: 12px"
|
||||
>
|
||||
<el-radio-button value="self">我的</el-radio-button>
|
||||
<el-radio-button value="all">全部</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Lint 校验**
|
||||
|
||||
Run: `npm run fix -- src/views/crmpc/stocking/final_list.vue`
|
||||
Expected: 无 error。
|
||||
|
||||
- [ ] **Step 6: 手动核对(前端独立部分)**
|
||||
|
||||
Run: `npm run dev`,浏览器打开终版列表页。核对项同 Task 1 Step 6(注意此处是 `is_final=1` 的终版页)。
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/views/crmpc/stocking/final_list.vue
|
||||
git commit -m "feat(stocking): 终版列表为特权用户增加「全部/我的」视图切换
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 后端联调清单(交付后端,不在本计划执行范围)
|
||||
|
||||
接口 `GET /crm/stock/order/list` 增加可选参数 `view_scope`:
|
||||
|
||||
| 请求 | 后端行为 |
|
||||
|---|---|
|
||||
| 不传 / `view_scope=self` | 按现状:依 token 用户过滤 |
|
||||
| `view_scope=all` 且 token 用户 id ∈ {73} | 返回全量 |
|
||||
| `view_scope=all` 且 token 用户 id ∉ {73} | 降级为 `self`(返回该用户自己的) |
|
||||
|
||||
要点:**只信 token,忽略前端任何用户标识**,防 IDOR。
|
||||
@@ -0,0 +1,120 @@
|
||||
# 备货单车辆明细附件上传 设计
|
||||
|
||||
- 日期:2026-08-06
|
||||
- 分支:feature/crm_pc
|
||||
- 涉及前端:`src/views/crmpc/stocking/Add.vue`、`src/composables/useStockingForm.ts`
|
||||
- 涉及后端:`stock_order_items.attachment`(已加字段)、备货单 store/update/show 接口
|
||||
|
||||
## 1. 背景
|
||||
|
||||
添加备货单(`Add.vue`)时,车辆明细(`items`,每行一辆车)目前只能录入车型 / 颜色 / 价格等字段,**没有附件**。业务需要每辆车能上传若干附件(如该车相关文件),随车辆一起保存。
|
||||
|
||||
后端 `stock_order_items` 表已新增 `attachment` 字段(本次由后端同事完成)。
|
||||
|
||||
## 2. 需求
|
||||
|
||||
- 添加备货单时,车辆明细**每辆车**可上传**多个**附件。
|
||||
- UI 为**行内紧凑**形态:每行一个「上传附件」入口,已传文件以文件名标签列出、可删除,不撑高行高。
|
||||
- 附件随车辆数据一起提交,存入 `stock_order_items.attachment`。
|
||||
- 字段名统一用 `attachment`,值为 `file_name` 字符串数组(与备货单级 `contract`/`pi_doc`/`transaction_slip` 的存储模式一致)。
|
||||
|
||||
## 3. 现状(复用基础)
|
||||
|
||||
- 车辆明细 `items` 由 `useStockingForm(false)` 提供,每行 `item` 是 `ItemRow`。
|
||||
- 备货单级文件已有成熟模式:前端维护 `FileItem[]`(`{name, url, file_name}`,带 url 供预览/下载),提交时映射成 `file_name[]` 存入 `baseForm.contract` 等。
|
||||
- 上传走通用接口 `${VITE_API_URL}/ticket/upload?scene=stock_orders`,header 带 token(`useUploadHeaders`),成功返回 `{ data: { original_name, file_url, file_name } }`。**此接口无需改动。**
|
||||
- 现成组件 `MultiFileUpload`(120px 大拖拽区)适合整页文件区,不适合塞进行内——故行内用 `el-upload` 直接内联。
|
||||
|
||||
## 4. 设计
|
||||
|
||||
### 4.1 数据层 `useStockingForm.ts`
|
||||
|
||||
- `ItemRow` 类型增加 `attachment_files: { name: string; url: string; file_name: string }[]`。
|
||||
- `newItem()` 初始化 `attachment_files: []`。
|
||||
- 提交映射 `mappedItems` 增加:
|
||||
```ts
|
||||
attachment: (it.attachment_files || []).map((f) => f.file_name)
|
||||
```
|
||||
- 详情回填 `populateFromDetail`:从后端返回的 `it.attachment` 反解为 `attachment_files`(格式见 4.4,Add 为新建不走回填,但 composable 与 edit 共享,一并支持)。
|
||||
- `copyRow` 已是 `JSON.parse(JSON.stringify(...))`,自动带上附件,无需改。
|
||||
- 草稿 `pickDraftPayload` 已含 `items: toRaw(items.value)`,附件自然入草稿,无需改。
|
||||
- 新增车辆附件上传/删除方法(仿现有 `handleContractUpload` 等):
|
||||
- `handleItemAttachmentUpload(response: any, idx: number)`:校验 `response.code===200`,把 `{ name: data.original_name, url: data.file_url, file_name: data.file_name }` push 进 `items.value[idx].attachment_files`。
|
||||
- `removeItemAttachment(idx: number, fileIdx: number)`:从 `items.value[idx].attachment_files` 删除指定项。
|
||||
|
||||
### 4.2 UI 层 `Add.vue`
|
||||
|
||||
在车辆明细 `v-for="(item, idx) in items"` 的行内末尾增加一个 `ElCol`「附件」:
|
||||
|
||||
```html
|
||||
<ElCol :sm="12" :md="12" :lg="6" :xl="4">
|
||||
<ElFormItem label="附件" label-width="85px">
|
||||
<el-upload
|
||||
:action="`${VITE_API_URL}/ticket/upload?scene=stock_orders`"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:before-upload="(file) => beforeItemAttachmentUpload(file)"
|
||||
:on-success="(res) => handleItemAttachmentUpload(res, idx)"
|
||||
multiple
|
||||
:accept="itemAttachmentAccept"
|
||||
>
|
||||
<el-button size="small" type="primary" plain>上传附件</el-button>
|
||||
</el-upload>
|
||||
<div class="item-attachment-tags" v-if="item.attachment_files?.length">
|
||||
<el-tag
|
||||
v-for="(f, fIdx) in item.attachment_files"
|
||||
:key="fIdx"
|
||||
closable
|
||||
@close="removeItemAttachment(idx, fIdx)"
|
||||
style="margin: 2px 4px 2px 0"
|
||||
>
|
||||
{{ f.name }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
```
|
||||
|
||||
- `VITE_API_URL`、`uploadHeaders`(`useUploadHeaders`)、`beforeItemAttachmentUpload`(类型/大小校验,复用 `MultiFileUpload` 的 accept 与 10MB 限制)、`handleItemAttachmentUpload`、`removeItemAttachment` 均由 `useStockingForm` return,模板解构使用,与现有 `contractFiles` 等用法一致。
|
||||
|
||||
### 4.3 后端配合
|
||||
|
||||
- `stock_order_items.attachment` 字段已加(DB 就绪)。
|
||||
- 接口层需对接:
|
||||
- **store / update**:接收 `items[].attachment`(`file_name` 数组),写入 `stock_order_items.attachment`(按 json/序列化存储)。
|
||||
- **show / detail**:返回 `items[].attachment`(`file_name` 数组,或含 `original_name`/`file_url` 的对象数组——见 4.4 约定)。
|
||||
|
||||
### 4.4 回填格式约定(需与后端对齐)
|
||||
|
||||
详情接口返回的 `items[].attachment` 有两种可能,择一与后端确认:
|
||||
- **A**:`file_name` 字符串数组(如 `["xxx.pdf"]`)——前端回填时 `url` 需另行拼接,预览受限。
|
||||
- **B(推荐)**:对象数组(如 `[{ original_name, file_url, file_name }]`,与 `/ticket/upload` 返回一致)——前端直接还原成 `attachment_files`,预览/下载可用。
|
||||
|
||||
实现(plan)阶段以前端实际请求详情接口的返回为准;若为 A,前端按现有 `populateFromDetail` 中文件字段的拼 url 方式处理。
|
||||
|
||||
## 5. 安全 / 限制
|
||||
|
||||
- 复用 `beforeUpload`:类型限制 `.jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.txt`,单文件 ≤ 10MB。
|
||||
- 上传 header 带 token(`useUploadHeaders`),鉴权由后端 `/ticket/upload` 既有机制处理。
|
||||
- `attachment` 只存 `file_name`,不含敏感信息。
|
||||
|
||||
## 6. 改动清单
|
||||
|
||||
| 端 | 文件 | 改动 |
|
||||
|---|---|---|
|
||||
| 前端 | `src/composables/useStockingForm.ts` | `ItemRow` 加 `attachment_files`;`newItem` 初始化;`mappedItems` 映射 `attachment`;`populateFromDetail` 回填;新增 `handleItemAttachmentUpload`/`removeItemAttachment`/`beforeItemAttachmentUpload`/暴露 `uploadHeaders` |
|
||||
| 前端 | `src/views/crmpc/stocking/Add.vue` | 车辆明细行末尾加「附件」`ElCol`(`el-upload` + 标签列表);解构新方法 |
|
||||
| 后端 | store/update/show 接口 | 对接 `items[].attachment`(DB 字段已就绪) |
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
1. 添加备货单:每辆车行有「上传附件」按钮,点选多文件逐个上传成功后,文件名以标签列出。
|
||||
2. 标签可点 × 删除;删除后提交不包含该文件。
|
||||
3. 提交备货单,Network 里 `items[].attachment` 为对应 `file_name` 数组;后端 `stock_order_items.attachment` 落库正确。
|
||||
4. (后端 show 返回后)进入编辑/详情,已存附件能回显为标签列表。
|
||||
5. 超格式 / 超 10MB 文件被拦截并提示。
|
||||
|
||||
## 8. 未做项 / 后续
|
||||
|
||||
- 本次只改 `Add.vue` 模板;`stocking_edit.vue` 共用 `useStockingForm`,数据层已支持,如需在编辑页也展示附件上传,后续加同款 `ElCol` 即可。
|
||||
- 终版(final_add/final_edit)是否需要同等能力,待业务确认。
|
||||
@@ -0,0 +1,99 @@
|
||||
# 备货单 / 终版列表「全部 / 我的」视图切换 设计
|
||||
|
||||
- 日期:2026-08-06
|
||||
- 分支:feature/crm_pc
|
||||
- 涉及页面:`src/views/crmpc/stocking/stocking_list.vue`、`src/views/crmpc/stocking/final_list.vue`
|
||||
- 涉及接口:`GET /crm/stock/order/list`
|
||||
|
||||
## 1. 背景
|
||||
|
||||
备货单列表(`stocking_list`,`is_final=0`)与终版列表(`final_list`,`is_final=1`)当前共用同一个列表接口 `GET /crm/stock/order/list`。后端依据请求 token 中的用户 id 过滤数据,即每个登录用户只能看到自己作为业务员的备货单 / 终版。前端请求参数只有 `current / size / is_final / keyword`,无任何范围控制。
|
||||
|
||||
业务上存在一个固定特权用户(user_id = 73),需要在「看自己」之外,额外能查看「全公司所有备货单 / 终版」。
|
||||
|
||||
## 2. 需求
|
||||
|
||||
- 给 user_id = 73 这一个用户,在两个列表页增加「全部 / 我的」视图切换。
|
||||
- 选「全部」时,该用户看到全公司数据;选「我的」时,行为同现状(只看自己)。
|
||||
- 其他用户(非 73)完全不受影响:不显示切换控件,行为同现状。
|
||||
- 默认视图为「我的」。
|
||||
|
||||
## 3. 设计
|
||||
|
||||
### 3.1 UI
|
||||
|
||||
- 在两个列表页的工具栏增加一组互斥切换控件「全部 / 我的」(`el-radio-group` button 样式,二选一)。
|
||||
- 控件位置:工具栏右侧、搜索框左边,两个页面位置保持一致(`final_list` 现有 `toolbar-left` 被注释,不在那里放,统一放右侧,改动更小)。
|
||||
- 渲染条件:`useUserStore().getUserInfo.userId === 73`。`getUserInfo` 是 `userStore.info` 的 computed,类型 `Partial<UserInfo>`,`userId` 可能为 `undefined`,`undefined === 73` 为 `false`,判断安全,无需额外判空。
|
||||
|
||||
### 3.2 前端逻辑
|
||||
|
||||
- 新增本地状态 `viewScope = ref<'all' | 'self'>('self')`,初值 `'self'`(即默认「我的」)。
|
||||
- 切换控件绑定 `viewScope`;`@change` 时:
|
||||
1. `pagination.currentPage = 1`(切回第一页,避免越界空页);
|
||||
2. 调用 `getList()`。
|
||||
- `getList()` 构造参数时,当 `userId === 73` 把 `viewScope.value` 作为 `view_scope` 一并传入:
|
||||
- 「全部」→ `view_scope: 'all'`
|
||||
- 「我的」→ `view_scope: 'self'`
|
||||
- 非 73 用户:不渲染控件,`viewScope` 恒为 `'self'`,传 `view_scope: 'self'`(或不传,见 3.3)。为简单起见统一传,后端对非特权用户会忽略该值。
|
||||
|
||||
### 3.3 请求参数
|
||||
|
||||
列表请求参数由
|
||||
|
||||
```jsonc
|
||||
{ "current": 1, "size": 10, "is_final": 0, "keyword": "" }
|
||||
```
|
||||
|
||||
扩展为(73 用户)
|
||||
|
||||
```jsonc
|
||||
{ "current": 1, "size": 10, "is_final": 0, "keyword": "", "view_scope": "all" }
|
||||
```
|
||||
|
||||
- 参数名 `view_scope` 沿用现有 snake_case 风格(与 `is_final` / `keyword` 一致)。
|
||||
- 取值为语义化的 `'all' | 'self'`,**不传用户 id**。
|
||||
|
||||
### 3.4 后端配合
|
||||
|
||||
`GET /crm/stock/order/list` 增加可选参数 `view_scope`,处理逻辑:
|
||||
|
||||
| 请求情况 | 后端行为 |
|
||||
|---|---|
|
||||
| 不传 / `view_scope=self` | 现状逻辑:按 token 用户过滤,返回自己的数据 |
|
||||
| `view_scope=all` 且 token 用户 id ∈ {73} | 返回全公司全量数据 |
|
||||
| `view_scope=all` 且 token 用户 id ∉ {73} | 降级:当作 `self` 处理,返回该用户自己的数据(忽略 `all`) |
|
||||
|
||||
后端「可看全部」白名单暂写死为 `{73}`,与前端判断一致。
|
||||
|
||||
### 3.5 默认值
|
||||
|
||||
73 用户打开页面,`viewScope` 初值 `'self'`,即默认「我的」。需看全部时手动切换。
|
||||
|
||||
## 4. 安全考量
|
||||
|
||||
- **后端只认 token**:是否返回全量数据,由后端依据 token 中的用户 id 判断,绝不信任前端传入的任何用户标识。前端 `userId` 存于 localStorage,可被篡改。
|
||||
- 前端写死 `userId === 73` 仅用于**控件显隐**的便捷实现,不是安全边界。即便有人改前端强制显示按钮并传 `view_scope=all`,后端鉴权不通过仍只返回其自己的数据。
|
||||
- 结论:语义化参数 + 后端 token 鉴权,可兜住水平越权(IDOR)风险。
|
||||
|
||||
## 5. 改动清单
|
||||
|
||||
| 端 | 文件 / 接口 | 改动 |
|
||||
|---|---|---|
|
||||
| 前端 | `stocking_list.vue` | 工具栏加「全部/我的」`el-radio-group`(仅 73 显示);`getList` 传 `view_scope`;切换重置页码并重新请求 |
|
||||
| 前端 | `final_list.vue` | 同上(`is_final=1`) |
|
||||
| 后端 | `GET /crm/stock/order/list` | 接收 `view_scope`;token 用户 ∈ {73} 且 `=all` 时返回全量,否则按现状过滤 |
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
1. 以非 73 账号登录两个列表页:不出现「全部/我的」控件,数据范围与改动前一致。
|
||||
2. 以 73 账号登录:
|
||||
- 默认显示「我的」选中态,列表为 73 自己的数据。
|
||||
- 切到「全部」,列表变为全公司数据;切回「我的」恢复。
|
||||
- 切换时分页重置到第 1 页。
|
||||
3. 用任意账号在浏览器手动把请求改成 `view_scope=all`:后端只返回该账号自己的数据(非 73 不越权)。
|
||||
|
||||
## 7. 未做项 / 后续 TODO
|
||||
|
||||
- 前端 `userId === 73` 为硬编码,加人需改前端发版。后续建议:`getUserInfo` 下发权限标记(如 `stock@view_all` / `final_stock@view_all`),前端用现有 `hasAuth(...)` 控制显隐;后端白名单同步改为权限点判断。本次保留此扩展点,先不动。
|
||||
- `view_scope` 选择不持久化(每次进页面回到默认「我的」)。
|
||||
@@ -0,0 +1,104 @@
|
||||
# 备货单车辆明细附件上传 — 后端对接说明
|
||||
|
||||
> 前端已完成:添加备货单页,每辆车行有「上传附件」入口。本文档仅说明后端需配合的部分。
|
||||
|
||||
## 1. 功能说明
|
||||
|
||||
备货单(`stock_orders`)的车辆明细(`stock_order_items`),**每辆车**需要支持上传若干附件(如该车相关单据)。附件随车辆一起保存、详情回显。
|
||||
|
||||
## 2. 数据库(已就绪)
|
||||
|
||||
`stock_order_items` 表已新增 `attachment` 字段。
|
||||
|
||||
- 字段名:`attachment`
|
||||
- 类型:建议 `varchar` / `text` / `json`,存 **JSON 字符串**
|
||||
- 含义:该车辆的附件列表
|
||||
|
||||
## 3. 字段格式约定
|
||||
|
||||
`attachment` 存「对象数组」的 JSON:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "url": "https://xxx.com/file.pdf", "original_name": "装箱单.pdf" }
|
||||
]
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `url` | string | 文件访问地址(即 `/ticket/upload` 返回的 `file_url`) |
|
||||
| `original_name` | string | 原始文件名 |
|
||||
|
||||
- 每辆车 0~N 个附件;无附件时为空数组 `[]` 或 `null`。
|
||||
|
||||
> **重要**:此格式与备货单级 `contract` 字段**完全一致**(`contract` 也是 `[{url, original_name}]`)。后端可直接复用现有处理 `contract` 的读写逻辑,区别仅在于:`attachment` 挂在 `veh_list` 每一项、落到 `stock_order_items` 表。
|
||||
|
||||
## 4. 需要修改的接口
|
||||
|
||||
均在前端调用路径 `/crm/stock/order/` 下。
|
||||
|
||||
### 4.1 新增 `POST /crm/stock/order/store`
|
||||
- 请求体 `veh_list` 每一项现在多一个 `attachment` 字段(`[{url, original_name}]`)。
|
||||
- 解析 `veh_list` 入库时,把每项 `attachment` 写入对应 `stock_order_items` 行的 `attachment` 字段(JSON 序列化)。
|
||||
|
||||
### 4.2 编辑 `POST /crm/stock/order/update`
|
||||
- 同 `store`。
|
||||
|
||||
### 4.3 详情 `GET /crm/stock/order/show`
|
||||
- 返回的 `veh_list` 每一项需带上 `attachment`(`[{url, original_name}]`)。
|
||||
|
||||
### 4.4 详情 `GET /crm/stock/order/detail`
|
||||
- 同 `show`,返回 `veh_list` 每一项带 `attachment`。
|
||||
|
||||
## 5. 请求 / 响应示例
|
||||
|
||||
**提交(store / update)payload:**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
// ...备货单级字段(含 contract / pi_doc / transaction_slip)
|
||||
"veh_list": [
|
||||
{
|
||||
"sku_id": "123",
|
||||
"model_snapshot": "丰田 卡罗拉",
|
||||
// ...其他车辆字段
|
||||
"attachment": [
|
||||
{ "url": "https://xxx/a.pdf", "original_name": "装箱单.pdf" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"account_list": [],
|
||||
"approval_status": 0
|
||||
}
|
||||
```
|
||||
|
||||
**详情(show / detail)响应:**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"veh_list": [
|
||||
{
|
||||
"sku_id": "123",
|
||||
"model_snapshot": "丰田 卡罗拉",
|
||||
"attachment": [
|
||||
{ "url": "https://xxx/a.pdf", "original_name": "装箱单.pdf" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 上传接口(无需改动)
|
||||
|
||||
`/ticket/upload`(通用文件上传)保持原样。前端已用它上传文件,拿到 `{ file_url, file_name, original_name }`,再自行拼成 `{ url, original_name }` 存进 `attachment`。**后端无需在上传接口做任何业务关联。**
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
1. 前端添加备货单、每辆车上传附件 → 提交 → `stock_order_items.attachment` 正确落库(JSON 数组)。
|
||||
2. 调 `show` / `detail`,返回的 `veh_list` 每项 `attachment` 与落库内容一致。
|
||||
3. (编辑页后续加 UI 后)编辑备货单时,已存附件可回显、可增删。
|
||||
|
||||
## 8. 备注
|
||||
|
||||
- 本次前端只在「添加备货单」页加了上传 UI;编辑页(`stocking_edit`)共用同一套提交/回填逻辑,数据层已支持 `attachment`,编辑页 UI 后续补,**不影响后端接口契约**。
|
||||
- 终版(final)备货单如需同等能力,接口字段一致,可一并支持。
|
||||
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@types/echarts": "^4.9.22",
|
||||
"@vue/reactivity": "^3.4.35",
|
||||
"@vueuse/core": "^11.0.0",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
@@ -16,6 +17,7 @@
|
||||
"axios": "^1.7.5",
|
||||
"crypto-js": "^4.2.0",
|
||||
"echarts": "^5.4.0",
|
||||
"echarts-gl": "^2.0.9",
|
||||
"element-plus": "^2.8.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"highlight.js": "^11.10.0",
|
||||
@@ -2982,6 +2984,15 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/echarts": {
|
||||
"version": "4.9.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/echarts/-/echarts-4.9.22.tgz",
|
||||
"integrity": "sha512-7Fo6XdWpoi8jxkwP7BARUOM7riq8bMhmsCtSG8gzUcJmFhLo387tihoBYS/y5j7jl3PENT5RxeWZdN9RiwO7HQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/zrender": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -3138,6 +3149,12 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
|
||||
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow=="
|
||||
},
|
||||
"node_modules/@types/zrender": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/zrender/-/zrender-4.0.6.tgz",
|
||||
"integrity": "sha512-1jZ9bJn2BsfmYFPBHtl5o3uV+ILejAtGrDcYSpT4qaVKEI/0YY+arw3XHU04Ebd8Nca3SQ7uNcLaqiL+tTFVMg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.34.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.34.1.tgz",
|
||||
@@ -5103,6 +5120,11 @@
|
||||
"consola": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/claygl": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/claygl/-/claygl-1.3.0.tgz",
|
||||
"integrity": "sha512-+gGtJjT6SSHD2l2yC3MCubW/sCV40tZuSs5opdtn79vFSGUgp/lH139RNEQ6Jy078/L0aV8odCw8RSrUcMfLaQ=="
|
||||
},
|
||||
"node_modules/cli-cursor": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
|
||||
@@ -6399,6 +6421,18 @@
|
||||
"zrender": "5.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts-gl": {
|
||||
"version": "2.0.9",
|
||||
"resolved": "https://registry.npmjs.org/echarts-gl/-/echarts-gl-2.0.9.tgz",
|
||||
"integrity": "sha512-oKeMdkkkpJGWOzjgZUsF41DOh6cMsyrGGXimbjK2l6Xeq/dBQu4ShG2w2Dzrs/1bD27b2pLTGSaUzouY191gzA==",
|
||||
"dependencies": {
|
||||
"claygl": "^1.2.1",
|
||||
"zrender": "^5.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"echarts": "^5.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.170",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.170.tgz",
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,vue}": [
|
||||
"eslint --fix --quiet",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{js,ts,mjs,mts,tsx}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
@@ -48,13 +52,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@types/echarts": "^4.9.22",
|
||||
"@vue/reactivity": "^3.4.35",
|
||||
"@vueuse/core": "^11.0.0",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "next",
|
||||
"axios": "^1.7.5",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"echarts": "^5.4.0",
|
||||
"echarts-gl": "^2.0.9",
|
||||
"element-plus": "^2.8.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"highlight.js": "^11.10.0",
|
||||
|
||||
@@ -12,16 +12,22 @@
|
||||
import { UserService } from './api/usersApi'
|
||||
import { setThemeTransitionClass } from './utils/theme/animation'
|
||||
import { checkStorageCompatibility } from './utils/storage'
|
||||
import { mittBus } from '@/utils/sys'
|
||||
import appLogo from '@/assets/img/common/logox.png'
|
||||
const router = useRouter()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const { language } = storeToRefs(userStore)
|
||||
const { language, accessToken } = storeToRefs(userStore)
|
||||
function setCompanyFromUrl() {
|
||||
const userStore = useUserStore()
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const companyid = params.get('corp_id')
|
||||
const companyabbr = params.get('corp_abbr')
|
||||
// const systemid = params.get('system_id')
|
||||
// const system_code = params.get('system_code')
|
||||
if (companyid) {
|
||||
userStore.setCompany(companyid, companyabbr || '')
|
||||
// userStore.setSystems(systemid || '', system_code || '')
|
||||
// 你也可以加 localStorage/sessionStorage 备份
|
||||
}
|
||||
}
|
||||
@@ -29,22 +35,160 @@
|
||||
zh: zh,
|
||||
en: en
|
||||
}
|
||||
const isSupported = ref(false)
|
||||
const permissionStatus = ref('default')
|
||||
// 检查浏览器支持情况
|
||||
const checkSupport = () => {
|
||||
if ('Notification' in window) {
|
||||
isSupported.value = true
|
||||
console.log('浏览器支持原生Notification API')
|
||||
} else {
|
||||
isSupported.value = false
|
||||
console.log('⚠️ 浏览器不支持原生Notification API')
|
||||
}
|
||||
}
|
||||
// 请求通知权限
|
||||
const requestPermission = async () => {
|
||||
if (!('Notification' in window)) {
|
||||
console.log('浏览器不支持原生Notification API')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const permission = await Notification.requestPermission()
|
||||
permissionStatus.value = permission
|
||||
if (permission === 'granted') {
|
||||
console.log('用户已授予通知权限')
|
||||
} else if (permission === 'denied') {
|
||||
console.log('用户拒绝了通知权限')
|
||||
} else {
|
||||
console.log('用户暂时未授予通知权限')
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`请求权限时出错:`)
|
||||
}
|
||||
}
|
||||
|
||||
// 显示基本通知(支持自定义标题与内容)
|
||||
const showBasicNotification = (
|
||||
title: string = '基本通知',
|
||||
body: string = '这是一个使用浏览器原生API的系统通知'
|
||||
) => {
|
||||
if (!('Notification' in window)) {
|
||||
ElMessage({
|
||||
message: '浏览器不支持原生Notification API',
|
||||
type: 'warning'
|
||||
})
|
||||
return
|
||||
}
|
||||
if (Notification.permission !== 'granted') {
|
||||
ElMessage({
|
||||
message: '请先授予通知权限',
|
||||
type: 'warning'
|
||||
})
|
||||
requestPermission()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const iconUrl = appLogo || '/favicon.ico'
|
||||
const notification = new Notification(title, {
|
||||
body,
|
||||
requireInteraction: true,
|
||||
icon: iconUrl
|
||||
})
|
||||
notification.onclick = () => {
|
||||
notification.close()
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
onBeforeMount(() => {
|
||||
setThemeTransitionClass(true)
|
||||
setCompanyFromUrl()
|
||||
useUserStore().restoreTokenTimer()
|
||||
})
|
||||
|
||||
// 页面刷新时重置WebSocket重连次数,同时清理事件监听
|
||||
onBeforeUnmount(() => {
|
||||
import('@/utils/sys/wsClient').then(({ resetWebSocketReconnect }) => {
|
||||
resetWebSocketReconnect()
|
||||
})
|
||||
mittBus.off('ws:message')
|
||||
})
|
||||
|
||||
const handleWsMessageNotification = (data: any) => {
|
||||
if (data && data.event === 'notify.receive') {
|
||||
const notice = data?.data?.notice_data
|
||||
const title = notice?.message?.title || '新通知'
|
||||
const rawContent = notice?.message?.content || ''
|
||||
const body = typeof rawContent === 'string' ? rawContent.replace(/<[^>]+>/g, '') : ''
|
||||
showBasicNotification(title, body)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 检查存储兼容性
|
||||
checkStorageCompatibility()
|
||||
checkSupport()
|
||||
requestPermission()
|
||||
// 提升暗黑主题下页面刷新视觉体验
|
||||
setThemeTransitionClass(false)
|
||||
// 系统升级
|
||||
systemUpgrade()
|
||||
// 获取用户信息
|
||||
getUserInfo()
|
||||
// 初始化 WebSocket
|
||||
// showEventNotification()
|
||||
if (accessToken.value) {
|
||||
import('@/utils/sys/wsClient').then(
|
||||
({ initWebSocket, resetWebSocketReconnect, getWebSocketStatus }) => {
|
||||
// 页面刷新时重置重连次数
|
||||
resetWebSocketReconnect()
|
||||
initWebSocket(accessToken.value)
|
||||
// 添加调试功能,可以在控制台查看WebSocket状态
|
||||
// @ts-ignore
|
||||
window.getWebSocketStatus = getWebSocketStatus
|
||||
// console.log('WebSocket状态查看: window.getWebSocketStatus()')
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 监听ws消息,用于系统级通知
|
||||
mittBus.on('ws:message', handleWsMessageNotification)
|
||||
|
||||
// 初始化菜单徽标系统(全局初始化,避免重复)
|
||||
import('@/composables/useMenuBadge').then(({ useMenuBadge }) => {
|
||||
const { initBadgeSystem } = useMenuBadge()
|
||||
initBadgeSystem()
|
||||
})
|
||||
|
||||
// 加载菜单徽标测试工具(仅开发环境)
|
||||
if (import.meta.env.DEV) {
|
||||
import('@/utils/menuBadgeTest')
|
||||
}
|
||||
})
|
||||
|
||||
// 登录后,accessToken 变更时立即初始化 WebSocket(无需刷新)
|
||||
watch(accessToken, async (token) => {
|
||||
if (token) {
|
||||
const { initWebSocket, getWebSocketStatus } = await import('@/utils/sys/wsClient')
|
||||
const status = getWebSocketStatus?.()
|
||||
// 避免重复初始化:已连接或正在连接时不再初始化
|
||||
if (
|
||||
!status?.readyState ||
|
||||
(status.readyState !== WebSocket.OPEN && status.readyState !== WebSocket.CONNECTING)
|
||||
) {
|
||||
initWebSocket(token)
|
||||
}
|
||||
|
||||
// 重新初始化徽标系统(用户重新登录时)
|
||||
import('@/composables/useMenuBadge').then(({ useMenuBadge }) => {
|
||||
const { initBadgeSystem } = useMenuBadge()
|
||||
initBadgeSystem()
|
||||
})
|
||||
} else {
|
||||
// 退出登录时,关闭并重置
|
||||
const { closeWebSocket, resetWebSocketReconnect } = await import('@/utils/sys/wsClient')
|
||||
closeWebSocket()
|
||||
resetWebSocketReconnect()
|
||||
}
|
||||
})
|
||||
|
||||
// 获取用户信息
|
||||
@@ -60,59 +204,62 @@
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.boxshadow {
|
||||
.boxshadow {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 10, 118, 0.1);
|
||||
background: var(--art-main-bg-color);
|
||||
padding: 10px;
|
||||
padding: 10px 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.border{
|
||||
}
|
||||
.border {
|
||||
width: 6px;
|
||||
height: 22px;
|
||||
border-radius: 3px;
|
||||
background: #3471FF;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
.el-dialog {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.el-dialog::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background: linear-gradient(180deg, #E4ECFF 0%, #FFFFFF 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.el-dialog__header,
|
||||
.el-dialog__body,
|
||||
.el-dialog__footer {
|
||||
background: transparent !important;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.el-dialog__header{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
padding-right: 40px;
|
||||
}
|
||||
}
|
||||
.el-dialog {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.el-dialog::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background: linear-gradient(180deg, #E4ECFF 0%, #FFFFFF 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.el-dialog__header,
|
||||
.el-dialog__body,
|
||||
.el-dialog__footer {
|
||||
background: transparent !important;
|
||||
position: relative;
|
||||
z-index: inherit;
|
||||
}
|
||||
.el-dialog__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.el-dialog__title{
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.el-dialog__title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.el-dialog__headerbtn{
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
</style>
|
||||
.el-dialog__headerbtn {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.item-row-wrap {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export class invitemService {
|
||||
// 物品列表
|
||||
static getinvitemlist(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/inv/item/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 添加物品
|
||||
static invitemadd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/inv/item/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 修改物品
|
||||
static invitemupdate(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/inv/item/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 删除物品
|
||||
static invitemdelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/inv/item/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 借用审批相关接口
|
||||
static getBorrowList(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/inv/requisition/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 借用申请详情
|
||||
static getBorrowDetail(params: { id: number }) {
|
||||
return request.get<any>({
|
||||
url: '/admin/inv/requisition/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 借用审批
|
||||
static borrowApproval(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/inv/requisition/approval',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 归还审批列表
|
||||
static getReturnList(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/mobile/inv/requisition/return/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 归还申请详情
|
||||
static getReturnDetail(params: { id: number }) {
|
||||
return request.get<any>({
|
||||
url: '/admin/inv/requisition/return/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 确认归还
|
||||
static returnconfirm(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/inv/requisition/return/confirm',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 生成二维码
|
||||
static getQrCode(params: { id: number; qr_claim_limit?: number }) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/inv/item/qr-code',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 删除二维码
|
||||
static deleteQrCode(params: { id: number }) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/inv/item/qr-code/delete',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 管理员带领
|
||||
static proxyClaim(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/requisition/proxy-claim',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 获取库存变化记录
|
||||
static getStockLogList(params: {
|
||||
item_id: number
|
||||
current?: number
|
||||
size?: number
|
||||
change_type?: number
|
||||
}) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/stock-log/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 获取扫码记录
|
||||
static getQrClaimsList(params: {
|
||||
item_id: number
|
||||
current?: number
|
||||
size?: number
|
||||
claim_typeid?: number
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
}) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/requisition/qr-claims',
|
||||
params
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export class dashboardService {
|
||||
//卡片统计
|
||||
static officeclerkcard() {
|
||||
return request.get<any>({
|
||||
url: '/admin/dashboard/officeclerk/card'
|
||||
})
|
||||
}
|
||||
//待审批
|
||||
static officeclerkpending() {
|
||||
return request.get<any>({
|
||||
url: '/admin/dashboard/officeclerk/approval'
|
||||
})
|
||||
}
|
||||
//业务员备货单统计
|
||||
static salestockingcard() {
|
||||
return request.get<any>({
|
||||
url: '/admin/dashboard/officeclerk/curve'
|
||||
})
|
||||
}
|
||||
//待认领
|
||||
static officeclerkunclaimed(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/dashboard/officeclerk/claim',
|
||||
params
|
||||
})
|
||||
}
|
||||
//财务仪表盘统计
|
||||
static officeclerkchart() {
|
||||
return request.get<any>({
|
||||
url: '/admin/fin/statistics'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export class financeService {
|
||||
// 收款记录-列表
|
||||
static getReceiptList(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/receipt/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 收款记录-详情
|
||||
static getReceiptDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/receipt/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 收款记录-添加
|
||||
static addReceipt(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/receipt/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 收款记录-更新
|
||||
static updateReceipt(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/receipt/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 收款记录-删除
|
||||
static deleteReceipt(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/receipt/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
//付款申请-列表
|
||||
static getPaymentList(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/payment/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
//付款申请-详情
|
||||
static getPaymentDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/payment/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
//付款申请-添加
|
||||
static addPayment(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/payment/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
//付款申请-更新
|
||||
static updatePayment(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/payment/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
//付款申请-删除
|
||||
static deletePayment(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/payment/delete',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 绑定付款记录到收款记录
|
||||
static bindPaymentRecord(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/receipt/payment',
|
||||
params
|
||||
})
|
||||
}
|
||||
//收款记录历史
|
||||
static getReceiptHistory(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/receipt/his/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
//确认收款
|
||||
static confirmReceipt(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/receipt/credit',
|
||||
params
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export class kpiService {
|
||||
// 月度-业绩 列表
|
||||
static getmonthlylist(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/targets/monthly/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 月度-业绩 添加
|
||||
static addmonthly(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/targets/monthly/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 月度-业绩 删除
|
||||
static deletemonthly(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/targets/monthly/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 月度-业绩 修改
|
||||
static updatemonthly(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/targets/monthly/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 月度-业绩 详情
|
||||
static detailmonthly(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/targets/monthly/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
//年度-业绩 列表
|
||||
static getyearlylist(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/targets/annual/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 年度-业绩 添加
|
||||
static addyearly(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/targets/annual/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 年度-业绩 删除
|
||||
static deleteyearly(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/targets/annual/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 年度-业绩 详情
|
||||
static detailyearly(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/targets/annual/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 年度-业绩 修改
|
||||
static updateyearly(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/targets/annual/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
//年度-公司目标完成率
|
||||
static statisticstotal(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/targets/statistics/total',
|
||||
params
|
||||
})
|
||||
}
|
||||
//年度-个人销售数量排名
|
||||
static statisticsranking(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/targets/statistics/ranking',
|
||||
params
|
||||
})
|
||||
}
|
||||
//年度-业务KPI分月完成数量表
|
||||
static statisticsmonthly(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/targets/statistics/user/month',
|
||||
params
|
||||
})
|
||||
}
|
||||
//年度-个人年度目标完成度(百分比)
|
||||
static statisticspercentage(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/targets/statistics/user/progressh',
|
||||
params
|
||||
})
|
||||
}
|
||||
//年度-个人累积完成数量(辆)
|
||||
static statisticscumulative(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/targets/statistics/user/achieved',
|
||||
params
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export class logService {
|
||||
// 更新日志-列表
|
||||
static getChangelogList(params:any) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/update/log/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 更新日志-添加
|
||||
static addChangelog(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/update/log/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 更新日志-删除
|
||||
static deleteChangelog(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/update/log/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 更新日志-详情
|
||||
static detailChangelog(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/update/log/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 更新日志-修改
|
||||
static updateChangelog(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/update/log/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
//操作日志-列表
|
||||
static getOperationLogList(params: any) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/op/log/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export class purchaseService {
|
||||
// 采购单预置
|
||||
static purchaseorderpredata(params: any) {
|
||||
return request.get<Api.Common.AddResponse>({
|
||||
url: '/admin/purchase/order/predata',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 采购单列表
|
||||
static purchaseorderlist(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/purchase/order/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 采购单添加
|
||||
static purchaseorderadd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/purchase/order/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 采购单更新
|
||||
static purchaseorderupdate(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/purchase/order/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 采购单删除
|
||||
static purchaseorderdelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/purchase/order/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 采购单详情
|
||||
static purchaseorderdetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/purchase/order/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export class userConService {
|
||||
//分组列表
|
||||
static getGroupList(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/user/group/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
//添加分组
|
||||
static addGroup(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/user/group/store',
|
||||
data: params
|
||||
})
|
||||
}
|
||||
//编辑分组
|
||||
static editGroup(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/user/group/update',
|
||||
data: params
|
||||
})
|
||||
}
|
||||
//删除分组
|
||||
static deleteGroup(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/user/group/destroy',
|
||||
data: params
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export class manageService {
|
||||
//仪表盘 总
|
||||
static statisticswhole() {
|
||||
return request.get<any>({
|
||||
// url: '/admin/inquiry/statistics/whole'
|
||||
url: 'admin/crm/statistics/whole'
|
||||
})
|
||||
}
|
||||
//各渠道统计
|
||||
static channelcount() {
|
||||
return request.get<any>({
|
||||
// url: '/admin/inquiry/statistics/channel/count'
|
||||
url: '/admin/crm/statistics/channel/count'
|
||||
})
|
||||
}
|
||||
//各国家统计
|
||||
static countrycount() {
|
||||
return request.get<any>({
|
||||
// url: '/admin/inquiry/statistics/country/count'
|
||||
url: '/admin/crm/statistics/country/count'
|
||||
})
|
||||
}
|
||||
//跟单人统计
|
||||
static trackcount() {
|
||||
return request.get<any>({
|
||||
// url: '/admin/inquiry/statistics/track/count'
|
||||
url: '/admin/crm/statistics/track/count'
|
||||
})
|
||||
}
|
||||
//每日录单统计
|
||||
static daycount() {
|
||||
return request.get<any>({
|
||||
// url: '/admin/inquiry/statistics/day/count'
|
||||
url: '/admin/crm/statistics/day/count'
|
||||
})
|
||||
}
|
||||
//每日录单统计
|
||||
static inquirycount(params: any) {
|
||||
return request.get<any>({
|
||||
// url: '/admin/inquiry/statistics/inquiry/count',
|
||||
url: '/admin/crm/statistics/customer/count',
|
||||
params
|
||||
})
|
||||
}
|
||||
//询单列表
|
||||
static getinquiryList(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/inquiry/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 获取详情
|
||||
static inquirydetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/inquiry/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
//添加询单
|
||||
static inquiryAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/inquiry/store',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//修改询单
|
||||
static inquiryedit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/inquiry/update',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//删除询单
|
||||
static inquirydelete(params: any) {
|
||||
return request.get<Api.Common.AddResponse>({
|
||||
url: '/admin/inquiry/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
//分配询单
|
||||
static inquiryallocation(params: any) {
|
||||
return request.post<Api.Work.AddResponse>({
|
||||
url: '/admin/inquiry/allocation',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//重新分配询单
|
||||
static inquiryreallocation(params: any) {
|
||||
return request.post<Api.Work.AddResponse>({
|
||||
url: '/admin/inquiry/reallocation',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
|
||||
//发布询单
|
||||
static inquiryrelease(params: any) {
|
||||
return request.post<Api.Work.AddResponse>({
|
||||
url: '/admin/inquiry/release',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//获取用户下拉框
|
||||
static presetusers() {
|
||||
return request.get<any>({
|
||||
url: '/common/preset/users'
|
||||
})
|
||||
}
|
||||
//crm 客户管理
|
||||
//客户列表
|
||||
static getcrmCustomerList(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListDatad>({
|
||||
url: '/admin/crm/customer/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
//客户导出
|
||||
static crmCustomerExport(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/crm/customer/export',
|
||||
params,
|
||||
responseType: 'blob' // 设置响应类型为 blob
|
||||
})
|
||||
}
|
||||
//客户审核
|
||||
static crmCustomerAudit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/crm/customer/audit',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//客户转移
|
||||
static crmCustomerTransfer(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/crm/customer/transfer',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//用户列表
|
||||
static getUserList(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/user/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
//用户编辑
|
||||
static userEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/user/update',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//用户设置角色
|
||||
static userSetRole(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/user/authorize',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//用户菜单权限设置
|
||||
static userMenuAuth(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/user/set/resources',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//清除缓存
|
||||
static clearCache() {
|
||||
return request.get<any>({
|
||||
url: '/common/preset/cache/clear'
|
||||
})
|
||||
}
|
||||
//公海客户分配
|
||||
static crmCustomerAllocation(params: any) {
|
||||
return request.get<Api.Common.AddResponse>({
|
||||
url: '/admin/crm/customer/cede',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//公海客户跟进记录
|
||||
static crmCustomerFollow(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/crm/customer/follow',
|
||||
params: params
|
||||
})
|
||||
}
|
||||
//公海客户删除
|
||||
static crmCustomerDelete(params: any) {
|
||||
return request.get<Api.Common.AddResponse>({
|
||||
url: '/admin/crm/customer/delete',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//回收站 恢复
|
||||
static customerRestore(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/crm/customer/restore',
|
||||
params
|
||||
})
|
||||
}
|
||||
//备货单审核列表
|
||||
static getstocklist(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/approval/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
//备货单审核
|
||||
static stockapproval(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/approval/store',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
// 审核更新
|
||||
static stockapprovalupdate(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/approval/update',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//审核详情
|
||||
static stockapprovaldetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/approval/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
//审核删除
|
||||
static stockapprovaldelete(params: any) {
|
||||
return request.get<Api.Common.AddResponse>({
|
||||
url: '/admin/approval/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
//审批记录
|
||||
static stockapprovalrecord(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/approval/records/todo',
|
||||
params
|
||||
})
|
||||
}
|
||||
//审批通过/拒绝
|
||||
static stockapprovalpass(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/approval/records/action',
|
||||
params
|
||||
})
|
||||
}
|
||||
//审批记录
|
||||
static stockapprovallogs(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/approval/records/logs',
|
||||
params
|
||||
})
|
||||
}
|
||||
//备货单
|
||||
static getStockList(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/stock/order/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
//添加备货单
|
||||
static addStock(params: any) {
|
||||
return request.post<Api.Work.AddResponse>({
|
||||
url: '/admin/stock/order/store',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//详情备货单
|
||||
static getStockDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/stock/order/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
//备货单查看
|
||||
static checkStock(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/stock/order/detail',
|
||||
params
|
||||
})
|
||||
}
|
||||
//备货单删除
|
||||
static delectStock(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/stock/order/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
//打印
|
||||
static printStock(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/stock/order/print',
|
||||
params
|
||||
})
|
||||
}
|
||||
//审核详情
|
||||
static stockapprovaldetailborder(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/approval/records/border',
|
||||
params
|
||||
})
|
||||
}
|
||||
//审批转交
|
||||
static stockapprovaltransfer(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/approval/records/transfer',
|
||||
params
|
||||
})
|
||||
}
|
||||
//审批时间轴
|
||||
static stockapprovaltimeline(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/approval/records/timeline',
|
||||
params
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export class commonService {
|
||||
//通知列表
|
||||
static getNoticeList(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/common/notice/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
//通知详情
|
||||
static getNoticeDetail(params: any) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/common/notice/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
//通知已读
|
||||
static readNotice(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/common/notice/read',
|
||||
params
|
||||
})
|
||||
}
|
||||
//通知删除
|
||||
static delNotice(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/common/notice/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
//通知未读列表
|
||||
static getUnReadNoticeList() {
|
||||
return request.get<any>({
|
||||
url: '/common/notice/unread'
|
||||
})
|
||||
}
|
||||
//货币下拉列表
|
||||
static getCurrencySelect() {
|
||||
return request.get<any>({
|
||||
url: '/common/preset/currency'
|
||||
})
|
||||
}
|
||||
|
||||
//国家下拉列表
|
||||
static getCountrySelect() {
|
||||
return request.get<any>({
|
||||
url: '/crm/customer/getCountry'
|
||||
})
|
||||
}
|
||||
//品牌下拉列表
|
||||
static getBrandSelect() {
|
||||
return request.get<any>({
|
||||
url: '/common/preset/brand'
|
||||
})
|
||||
}
|
||||
//车系下拉列表
|
||||
static getSeriesSelect(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/common/preset/brand/series',
|
||||
params
|
||||
})
|
||||
}
|
||||
//车型下拉列表
|
||||
static getModelsSelect(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/crm/vehicle/sku/preset',
|
||||
params
|
||||
})
|
||||
}
|
||||
//港口下拉列表
|
||||
static getPortSelect(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/common/preset/port',
|
||||
params
|
||||
})
|
||||
}
|
||||
//颜色下拉列表
|
||||
static getColorSelect(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/common/preset/color',
|
||||
params
|
||||
})
|
||||
}
|
||||
//改装下拉列表
|
||||
static getModificationSelect() {
|
||||
return request.get<any>({
|
||||
url: '/common/preset/modification'
|
||||
})
|
||||
}
|
||||
//字典下拉框
|
||||
static getDictSelect(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/dict/scope',
|
||||
params
|
||||
})
|
||||
}
|
||||
//字典项下拉框
|
||||
static getDictItemSelect(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/common/preset/dictItem',
|
||||
params
|
||||
})
|
||||
}
|
||||
//字典分类下拉框
|
||||
static getDictTypeSelect() {
|
||||
return request.get<any>({
|
||||
url: '/admin/dict/group'
|
||||
})
|
||||
}
|
||||
//账户下拉框
|
||||
static getAccountSelect() {
|
||||
return request.get<any>({
|
||||
url: '/common/preset/account'
|
||||
})
|
||||
}
|
||||
//名下客户下拉框
|
||||
static getCustomerSelect() {
|
||||
return request.get<any>({
|
||||
url: '/crm/customer/preset'
|
||||
})
|
||||
}
|
||||
//角色下拉框
|
||||
static getRoleSelect() {
|
||||
return request.get<any>({
|
||||
url: '/common/preset/roles'
|
||||
})
|
||||
}
|
||||
//用户下拉框
|
||||
static getUserSelect() {
|
||||
return request.get<any>({
|
||||
url: '/common/preset/users'
|
||||
})
|
||||
}
|
||||
//用户下拉框
|
||||
static getUserSelectparams(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/common/preset/users',
|
||||
params
|
||||
})
|
||||
}
|
||||
//签名 编辑
|
||||
static signatureEdit(params: any) {
|
||||
return request.post<any>({
|
||||
url: '/admin/user/update/sign',
|
||||
params
|
||||
})
|
||||
}
|
||||
//人员分组场景
|
||||
static getUserGroupScene() {
|
||||
return request.get<any>({
|
||||
url: '/common/preset/usergroup/scene'
|
||||
})
|
||||
}
|
||||
//人员分组
|
||||
static getUserGroupSelect(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/common/preset/usergroup',
|
||||
params
|
||||
})
|
||||
}
|
||||
//关键字抓取车辆
|
||||
static getcrawlKeyword(params: any) {
|
||||
return request.get<any>({
|
||||
// url: '/common/brand/series/crawlKeyword',
|
||||
url: '/common/brand/series/crawl',
|
||||
params
|
||||
})
|
||||
}
|
||||
//关键字抓取车辆保存
|
||||
static crawlModelCreate(params: any) {
|
||||
return request.post<Api.Work.AddResponse>({
|
||||
url: '/common/brand/series/crawlKeywordCreate',
|
||||
params
|
||||
})
|
||||
}
|
||||
//仪表盘选择下拉
|
||||
static getDashboardSelect() {
|
||||
return request.get<any>({
|
||||
url: '/admin/resources/list/menu?is_home=1'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -26,11 +26,19 @@ export class CrmService {
|
||||
}
|
||||
//删除客户
|
||||
static getcustomerdelete(params: any) {
|
||||
return request.get<Api.Crm.CrmListData>({
|
||||
return request.get<any>({
|
||||
url: '/crm/customer/delete',
|
||||
params
|
||||
})
|
||||
}
|
||||
//标记无效
|
||||
static customerReport(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/crm/customer/report',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//切换等级crm/customer/grade
|
||||
static customergrade(params: any) {
|
||||
return request.post<Api.Work.AddResponse>({
|
||||
@@ -39,16 +47,15 @@ export class CrmService {
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
|
||||
//渠道下拉
|
||||
static getChannel() {
|
||||
return request.get<Api.Brand.BrandInfo>({
|
||||
return request.get<any>({
|
||||
url: '/crm/customer/getChannel'
|
||||
})
|
||||
}
|
||||
//国家下拉
|
||||
static getCountry() {
|
||||
return request.get<Api.Brand.BrandInfo>({
|
||||
return request.get<any>({
|
||||
url: '/crm/customer/getCountry'
|
||||
})
|
||||
}
|
||||
@@ -65,9 +72,10 @@ export class CrmService {
|
||||
})
|
||||
}
|
||||
//标签列表/crm/tagGroup/list
|
||||
static tagGrouplist() {
|
||||
static tagGrouplist(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/crm/tagGroup/list'
|
||||
url: '/crm/tagGroup/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
//更新客户标签 /crm/tagGroup/updatePivot
|
||||
@@ -99,16 +107,16 @@ export class CrmService {
|
||||
params
|
||||
})
|
||||
}
|
||||
//设置下次跟进时间 followNext crm/customer/followNext
|
||||
static followNext(params: any) {
|
||||
return request.post<Api.Work.AddResponse>({
|
||||
url: '/crm/customer/followNext',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//设置下次跟进时间 followNext crm/customer/followNext
|
||||
static followNext(params: any) {
|
||||
return request.post<Api.Work.AddResponse>({
|
||||
url: '/crm/customer/followNext',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
|
||||
//crm 个人信息修改
|
||||
//crm 个人信息修改
|
||||
static crmuseredit(params: any) {
|
||||
return request.post<Api.Work.AddResponse>({
|
||||
url: '/crm/user/edit',
|
||||
@@ -153,4 +161,163 @@ static followNext(params: any) {
|
||||
params
|
||||
})
|
||||
}
|
||||
//从属客户列表
|
||||
static getCustomercsList(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/crm/customer/index',
|
||||
params
|
||||
})
|
||||
}
|
||||
//从属客户转移
|
||||
static cedeCustomer(params: any) {
|
||||
return request.post<Api.Work.AddResponse>({
|
||||
url: '/crm/customer/cede',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//所属员工
|
||||
static getCustomerUserList(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/common/user/team',
|
||||
params
|
||||
})
|
||||
}
|
||||
//标记离职
|
||||
static markLeave(params: any) {
|
||||
return request.get<Api.Work.AddResponse>({
|
||||
url: '/common/user/resign',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//客户删除
|
||||
static crmCustomerDelete(params: any) {
|
||||
return request.get<Api.Common.AddResponse>({
|
||||
url: '/crm/customer/delete',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//通知客户重复
|
||||
static repeatCustomer(params: any) {
|
||||
return request.post<Api.Work.AddResponse>({
|
||||
url: '/crm/customer/distinctNotice',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//备货单
|
||||
static getStockList(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/crm/stock/order/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
//添加备货单
|
||||
static addStock(params: any) {
|
||||
return request.post<Api.Work.AddResponse>({
|
||||
url: '/crm/stock/order/store',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//详情备货单
|
||||
static getStockDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/crm/stock/order/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
//编辑备货单
|
||||
static editStock(params: any) {
|
||||
return request.post<Api.Work.AddResponse>({
|
||||
url: '/crm/stock/order/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
//备货单查看
|
||||
static checkStock(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/crm/stock/order/detail',
|
||||
params
|
||||
})
|
||||
}
|
||||
//提交备货单
|
||||
static submitStock(params: any) {
|
||||
return request.post<Api.Work.AddResponse>({
|
||||
url: '/crm/stock/order/approval',
|
||||
params
|
||||
})
|
||||
}
|
||||
//打印
|
||||
static printStock(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/crm/stock/order/print',
|
||||
params
|
||||
})
|
||||
}
|
||||
//审批记录
|
||||
static stockapprovalrecord(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/crm/approval/records/todo',
|
||||
params
|
||||
})
|
||||
}
|
||||
//审批通过/拒绝
|
||||
static stockapprovalpass(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/crm/approval/records/action',
|
||||
params
|
||||
})
|
||||
}
|
||||
//审批记录
|
||||
static stockapprovallogs(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/crm/approval/records/logs',
|
||||
params
|
||||
})
|
||||
}
|
||||
//终极备货单 提交
|
||||
static stockultimateapproval(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/crm/stock/order/ultimate/approval',
|
||||
params
|
||||
})
|
||||
}
|
||||
//备货单撤回
|
||||
static stockrecall(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/crm/stock/order/revoke/approval',
|
||||
params
|
||||
})
|
||||
}
|
||||
//备货单作废
|
||||
static stockinvalid(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/crm/stock/order/nullify/approval',
|
||||
params
|
||||
})
|
||||
}
|
||||
//备货单删除
|
||||
static stockdelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/crm/stock/order/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
//审核详情
|
||||
static stockapprovaldetailborder(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/crm/approval/records/border',
|
||||
params
|
||||
})
|
||||
}
|
||||
//审批转交
|
||||
static stockapprovaltransfer(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/crm/approval/records/transfer',
|
||||
params
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export class vehicleService {
|
||||
// 颜色管理
|
||||
static getColorList(params: Api.Common.PaginatingParams & any) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/crm/color/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
static colorAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/crm/color/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
static colorDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/crm/color/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
static colorEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/crm/color/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
static colorDelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/crm/color/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 车辆列表
|
||||
static getVehicleList(params: Api.Common.PaginatingParams & any) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/crm/vehicle/sku/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
static vehicleAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/crm/vehicle/sku/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
static vehicleDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/crm/vehicle/sku/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
static vehicleEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/crm/vehicle/sku/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
static vehicleDelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/crm/vehicle/sku/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 改装管理
|
||||
static getModificationList(params: Api.Common.PaginatingParams & any) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/crm/modification/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
static modificationAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/crm/modification/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
static modificationDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/crm/modification/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
static modificationEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/crm/modification/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
static modificationDelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/crm/modification/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 港口管理
|
||||
static getPortList(params: Api.Common.PaginatingParams & any) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/common/port/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
static portAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/common/port/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
static portDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/common/port/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
static portEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/common/port/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
static portDelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/common/port/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 车辆品牌列表
|
||||
static getBrandList(params: Api.Common.PaginatingParams & any) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/common/brand/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 车辆品牌新增/详情/编辑/删除
|
||||
static brandAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/common/brand/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
static brandDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/common/brand/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
static brandEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/common/brand/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
static brandDelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/common/brand/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 车辆系列列表
|
||||
static getSeriesList(params: Api.Common.PaginatingParams & any) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/common/brand/series/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 车辆系列新增/详情/编辑/删除
|
||||
static seriesAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/common/brand/series/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
static seriesDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/common/brand/series/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
static seriesEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/common/brand/series/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
static seriesDelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/common/brand/series/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 车系采集相关接口
|
||||
static collectVehicleSeries(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/common/brand/crawlSeries',
|
||||
params
|
||||
})
|
||||
}
|
||||
static saveVehicleSeries(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/common/brand/crawlSeriesCreate',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 车型采集相关接口
|
||||
static collectVehicleModels(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/common/brand/series/crawlModels',
|
||||
params
|
||||
})
|
||||
}
|
||||
static saveVehicleModels(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/common/brand/series/crawlModelCreate',
|
||||
params
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export class financeService {
|
||||
// 货币列表
|
||||
static getCurrencyList(params: Api.Common.PaginatingParams & any) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/currency/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 货币添加
|
||||
static currencyAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/currency/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 货币详情
|
||||
static currencyDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/currency/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 货币编辑
|
||||
static currencyEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/currency/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 货币删除
|
||||
static currencyDelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/currency/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 收款账户列表
|
||||
static getAccountList(params: Api.Common.PaginatingParams & any) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/account/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 收款账户添加
|
||||
static accountAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/account/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 收款账户详情
|
||||
static accountDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/account/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 收款账户编辑
|
||||
static accountEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/account/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 收款账户删除
|
||||
static accountDelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/account/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,10 @@
|
||||
import { asyncRoutes } from '@/router/routes/asyncRoutes'
|
||||
import { menuDataToRouter } from '@/router/utils/menuToRouter'
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { UserService } from '@/api/usersApi'
|
||||
interface MenuResponse {
|
||||
menuList: AppRouteRecord[]
|
||||
}
|
||||
// 合并方法
|
||||
function mergeRoutes(dynamicRoutes: AppRouteRecord[] = [], staticRoutes: AppRouteRecord[] = []): AppRouteRecord[] {
|
||||
// 深度合并逻辑
|
||||
const merged = [...staticRoutes]
|
||||
|
||||
dynamicRoutes.forEach(dynamicRoute => {
|
||||
const existingIndex = merged.findIndex(r => r.path === dynamicRoute.path)
|
||||
if (existingIndex >= 0) {
|
||||
// 合并children(如果存在)
|
||||
if (dynamicRoute.children) {
|
||||
merged[existingIndex].children = [
|
||||
...(merged[existingIndex].children || []),
|
||||
...dynamicRoute.children
|
||||
]
|
||||
}
|
||||
} else {
|
||||
merged.push(dynamicRoute)
|
||||
}
|
||||
})
|
||||
|
||||
return merged
|
||||
}
|
||||
// 菜单接口
|
||||
export const menuService = {
|
||||
async getMenuList(delay = 300): Promise<MenuResponse> {
|
||||
@@ -36,30 +14,27 @@ export const menuService = {
|
||||
if (!userStore.isLogin) {
|
||||
throw new Error('用户未登录')
|
||||
}
|
||||
const { menu } = await UserService.getMenuList()
|
||||
const menuData = menu
|
||||
// const userStore = useUserStore()
|
||||
// 模拟接口返回的菜单数据
|
||||
const menuData = userStore.info.menu
|
||||
// 2. 合并静态菜单
|
||||
const finalRoutes = mergeRoutes(menuData, asyncRoutes)
|
||||
// 验证菜单数据
|
||||
if (!menuData || !Array.isArray(menuData) || menuData.length === 0) {
|
||||
ElMessage.error('暂无系统菜单权限')
|
||||
return { menuList: [] }
|
||||
}
|
||||
|
||||
// console.log('menuData', menuData)
|
||||
// 处理菜单数据
|
||||
const menuList = finalRoutes.map((route: any) => menuDataToRouter(route))
|
||||
|
||||
const menuList = menuData.map((route: any) => menuDataToRouter(route))
|
||||
// 验证处理后的菜单列表
|
||||
if (!menuList || menuList.length === 0) {
|
||||
ElMessage.error('菜单数据处理失败')
|
||||
return { menuList: [] }
|
||||
}
|
||||
|
||||
// 模拟接口延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
|
||||
return { menuList }
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取菜单失败:', error)
|
||||
throw error instanceof Error ? error : new Error('获取菜单失败')
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export class systemService {
|
||||
//系统列表
|
||||
static getSystemList(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/systems/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
//系统添加
|
||||
static systemAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/systems/store',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//系统详情
|
||||
static systemDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/systems/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
//系统编辑
|
||||
static systemEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/systems/update',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//系统删除
|
||||
static systemDelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/systems/destroy',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//飞书应用列表
|
||||
static getFeishuAppList(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/corp/feishu/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
//飞书应用添加
|
||||
static feishuAppAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/corp/feishu/store',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//飞书应用详情
|
||||
static feishuAppDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/corp/feishu/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
//飞书应用编辑
|
||||
static feishuAppEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/corp/feishu/update',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//飞书应用删除
|
||||
static feishuAppDelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/corp/feishu/destroy',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//公司列表
|
||||
static getCompanyList(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/corp/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
//公司添加
|
||||
static companyAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/corp/store',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//公司详情
|
||||
static companyDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/corp/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
//公司编辑
|
||||
static companyEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/corp/update',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//公司删除
|
||||
static companyDelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/corp/destroy',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//角色列表
|
||||
static getRoleList(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/role/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
//角色添加
|
||||
static roleAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/role/store',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//角色详情
|
||||
static roleDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/role/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
//角色编辑
|
||||
static roleEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/role/update',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//角色删除
|
||||
static roleDelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/role/destroy',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//角色权限设置
|
||||
static roleAuth(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/role/menu/resource',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//角色下拉框
|
||||
static getRoleSelectList() {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/common/preset/roles'
|
||||
})
|
||||
}
|
||||
//菜单列表
|
||||
static getMenutreeList(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/resources/list/tree',
|
||||
params
|
||||
})
|
||||
}
|
||||
static getMenuList() {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/resources/list/menu'
|
||||
})
|
||||
}
|
||||
//菜单添加
|
||||
static menuAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/resources/store',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//菜单详情
|
||||
static menuDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/resources/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
//菜单编辑
|
||||
static menuEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/resources/update',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//菜单删除
|
||||
static menuDelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/resources/destroy',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//权限菜单
|
||||
static getPermissionMenuList(params: any) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/resources/list/menu/tree',
|
||||
params
|
||||
})
|
||||
}
|
||||
//通知列表
|
||||
static getNoticeList(params: Api.Common.PaginatingParams) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/notice/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
//通知详情
|
||||
static getNoticeDetail(params: any) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/notice/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
//通知删除
|
||||
static delNotice(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/notice/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
//通知推送
|
||||
static sendNotice(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/notice/push',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 字典类型列表
|
||||
static getDictTypeList(params: Api.Common.PaginatingParams & any) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/dict/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 字典类型新增/详情/编辑/删除
|
||||
static dictTypeAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/dict/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
static dictTypeDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/dict/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
static dictTypeEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/dict/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
static dictTypeDelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/dict/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 字典项列表
|
||||
static getDictItemList(params: Api.Common.PaginatingParams & any) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/dict/item/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 字典项新增/详情/编辑/删除
|
||||
static dictItemAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/dict/item/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
static dictItemDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/dict/item/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
static dictItemEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/dict/item/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
static dictItemDelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/dict/item/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
//仪表盘设置列表
|
||||
static getDashboardList(params: any) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/dashboard/rule/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
//仪表盘设置新增
|
||||
static dashboardAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/dashboard/rule/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
//仪表盘设置详情
|
||||
static dashboardDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/dashboard/rule/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
//仪表盘设置编辑
|
||||
static dashboardEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/dashboard/rule/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
//仪表盘设置删除
|
||||
static dashboardDelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/dashboard/rule/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,12 @@ export class UserService {
|
||||
url: '/common/preset/corps'
|
||||
})
|
||||
}
|
||||
//获取系统
|
||||
static getsystemsList() {
|
||||
return request.get<Api.Brand.BrandInfo>({
|
||||
url: '/common/preset/systems'
|
||||
})
|
||||
}
|
||||
//注册
|
||||
static register(params: any) {
|
||||
return request.post<Api.Auth.registerResponse>({
|
||||
@@ -16,7 +22,7 @@ export class UserService {
|
||||
})
|
||||
}
|
||||
//获取 飞书信息
|
||||
static getfeishuAppList(params: { corp_id: string }) {
|
||||
static getfeishuAppList(params: any) {
|
||||
return request.get<Api.Brand.BrandInfo>({
|
||||
url: '/common/preset/feishu/app',
|
||||
params
|
||||
@@ -37,7 +43,7 @@ export class UserService {
|
||||
params
|
||||
})
|
||||
}
|
||||
//飞书凭证
|
||||
//飞书凭证
|
||||
static getfeishuscope() {
|
||||
return request.get<Api.Auth.Authfeishuscope>({
|
||||
url: '/common/auth/feishu/scope'
|
||||
@@ -66,4 +72,32 @@ export class UserService {
|
||||
params
|
||||
})
|
||||
}
|
||||
//获取菜单
|
||||
static getMenuList() {
|
||||
return request.get<Api.User.UserInfo>({
|
||||
url: '/common/preset/menus'
|
||||
})
|
||||
}
|
||||
//个人信息编辑
|
||||
static editUserInfo(params: any) {
|
||||
return request.post<Api.Work.AddResponse>({
|
||||
url: '/admin/user/edit',
|
||||
params
|
||||
})
|
||||
}
|
||||
//离职分配
|
||||
static leaveUser(params: any) {
|
||||
return request.get<Api.Work.AddResponse>({
|
||||
url: '/admin/crm/customer/allocation',
|
||||
params
|
||||
})
|
||||
}
|
||||
//客户删除
|
||||
static crmCustomerDelete(params: any) {
|
||||
return request.get<Api.Common.AddResponse>({
|
||||
url: '/admin/crm/customer/delete',
|
||||
params
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export class vehicleService {
|
||||
// 车辆品牌列表
|
||||
static getBrandList(params: Api.Common.PaginatingParams & any) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/brand/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 车辆品牌新增/详情/编辑/删除
|
||||
static brandAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/brand/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
static brandDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/brand/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
static brandEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/brand/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
static brandDelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/brand/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 车辆系列列表
|
||||
static getSeriesList(params: Api.Common.PaginatingParams & any) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/brand/series/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 车辆系列新增/详情/编辑/删除
|
||||
static seriesAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/brand/series/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
static seriesDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/brand/series/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
static seriesEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/brand/series/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
static seriesDelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/brand/series/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 颜色管理
|
||||
static getColorList(params: Api.Common.PaginatingParams & any) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/color/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
static colorAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/color/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
static colorDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/color/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
static colorEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/color/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
static colorDelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/color/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 改装管理
|
||||
static getModificationList(params: Api.Common.PaginatingParams & any) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/modification/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
static modificationAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/modification/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
static modificationDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/modification/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
static modificationEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/modification/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
static modificationDelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/modification/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 港口管理
|
||||
static getPortList(params: Api.Common.PaginatingParams & any) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/port/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
static portAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/port/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
static portDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/port/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
static portEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/port/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
static portDelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/port/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 车辆列表
|
||||
static getVehicleList(params: Api.Common.PaginatingParams & any) {
|
||||
return request.get<Api.Common.CommonListData>({
|
||||
url: '/admin/vehicle/sku/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
static vehicleAdd(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/vehicle/sku/store',
|
||||
params
|
||||
})
|
||||
}
|
||||
static vehicleDetail(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/admin/vehicle/sku/show',
|
||||
params
|
||||
})
|
||||
}
|
||||
static vehicleEdit(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/vehicle/sku/update',
|
||||
params
|
||||
})
|
||||
}
|
||||
static vehicleDelete(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/admin/vehicle/sku/destroy',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 车系采集相关接口
|
||||
static collectVehicleSeries(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/common/brand/crawlSeries',
|
||||
params
|
||||
})
|
||||
}
|
||||
static saveVehicleSeries(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/common/brand/crawlSeriesCreate',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 车型采集相关接口
|
||||
static collectVehicleModels(params: any) {
|
||||
return request.get<any>({
|
||||
url: '/common/brand/series/crawlModels',
|
||||
params
|
||||
})
|
||||
}
|
||||
static saveVehicleModels(params: any) {
|
||||
return request.post<Api.Common.AddResponse>({
|
||||
url: '/common/brand/series/crawlModelCreate',
|
||||
params
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -39,13 +39,14 @@ export class WorkService {
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
//工单状态下拉选择框
|
||||
static getStatus() {
|
||||
//工单状态下拉选择框
|
||||
static getStatus(params: any) {
|
||||
return request.get<Api.Brand.BrandInfo>({
|
||||
url: '/ticket/ticket/getStatus'
|
||||
url: '/ticket/ticket/getStatus',
|
||||
params
|
||||
})
|
||||
}
|
||||
//工单沟通记录
|
||||
//工单沟通记录
|
||||
static getconversationList(params: any) {
|
||||
return request.get<Api.Work.WorkListData>({
|
||||
url: '/ticket/conversation/list',
|
||||
@@ -53,7 +54,7 @@ export class WorkService {
|
||||
})
|
||||
}
|
||||
//添加工单记录 ticket/conversation/add
|
||||
static conversationAdd(params:any) {
|
||||
static conversationAdd(params: any) {
|
||||
return request.post<Api.Work.AddResponse>({
|
||||
url: '/ticket/conversation/add',
|
||||
params
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
{
|
||||
"wordGeoCoordMap":{
|
||||
"阿富汗": [67.709953, 33.93911],
|
||||
"安哥拉": [17.873887, -11.202692],
|
||||
"阿尔巴尼亚": [20.168331, 41.153332],
|
||||
"阿拉伯联合酋长国": [53.847818, 23.424076],
|
||||
"阿根廷": [-63.61667199999999, -38.416097],
|
||||
"亚美尼亚": [45.038189, 40.069099],
|
||||
"法属南半球和南极领地": [69.348557, -49.280366],
|
||||
"澳大利亚": [133.775136, -25.274398],
|
||||
"奥地利": [14.550072, 47.516231],
|
||||
"阿塞拜疆": [47.576927, 40.143105],
|
||||
"布隆迪": [29.918886, -3.373056],
|
||||
"比利时": [4.469936, 50.503887],
|
||||
"贝宁": [2.315834, 9.30769],
|
||||
"布基纳法索": [-1.561593, 12.238333],
|
||||
"孟加拉人民共和国": [90.356331, 23.684994],
|
||||
"保加利亚": [25.48583, 42.733883],
|
||||
"巴哈马": [-77.39627999999999, 25.03428],
|
||||
"波斯尼亚和黑塞哥维那": [17.679076, 43.915886],
|
||||
"白俄罗斯": [27.953389, 53.709807],
|
||||
"伯利兹": [-88.49765, 17.189877],
|
||||
"百慕大": [-64.7505, 32.3078],
|
||||
"玻利维亚": [-63.58865299999999, -16.290154],
|
||||
"巴西": [-51.92528, -14.235004],
|
||||
"文莱": [114.727669, 4.535277],
|
||||
"不丹": [90.433601, 27.514162],
|
||||
"博茨瓦纳": [24.684866, -22.328474],
|
||||
"中非共和国": [20.939444, 6.611110999999999],
|
||||
"加拿大": [-106.346771, 56.130366],
|
||||
"瑞士": [8.227511999999999, 46.818188],
|
||||
"智利": [-71.542969, -35.675147],
|
||||
"中华人民共和国": [104.195397, 35.86166],
|
||||
"象牙海岸": [-5.547079999999999, 7.539988999999999],
|
||||
"喀麦隆": [12.354722, 7.369721999999999],
|
||||
"刚果民主共和国": [21.758664, -4.038333],
|
||||
"刚果共和国": [15.827659, -0.228021],
|
||||
"哥伦比亚": [-74.297333, 4.570868],
|
||||
"哥斯达黎加共和国": [-83.753428, 9.748916999999999],
|
||||
"古巴": [-77.781167, 21.521757],
|
||||
"北塞浦路斯": [33.429859, 35.126413],
|
||||
"塞浦路斯": [33.429859, 35.126413],
|
||||
"捷克共和国": [15.472962, 49.81749199999999],
|
||||
"德国": [10.451526, 51.165691],
|
||||
"吉布提": [42.590275, 11.825138],
|
||||
"丹麦": [9.501785, 56.26392],
|
||||
"多明尼加共和国": [-70.162651, 18.735693],
|
||||
"阿尔及利亚": [1.659626, 28.033886],
|
||||
"厄瓜多尔": [-78.18340599999999, -1.831239],
|
||||
"埃及": [30.802498, 26.820553],
|
||||
"厄立特里亚": [39.782334, 15.179384],
|
||||
"西班牙": [-3.74922, 40.46366700000001],
|
||||
"爱沙尼亚": [25.013607, 58.595272],
|
||||
"埃塞俄比亚": [40.489673, 9.145000000000001],
|
||||
"芬兰": [25.748151, 61.92410999999999],
|
||||
"斐": [178.065032, -17.713371],
|
||||
"福克兰群岛": [-59.523613, -51.796253],
|
||||
"法国": [2.213749, 46.227638],
|
||||
"加蓬共和国": [11.609444, -0.803689],
|
||||
"英国": [-3.435973, 55.378051],
|
||||
"格鲁吉亚": [-82.9000751, 32.1656221],
|
||||
"加纳": [-1.023194, 7.946527],
|
||||
"几内亚": [-9.696645, 9.945587],
|
||||
"冈比亚": [-15.310139, 13.443182],
|
||||
"几内亚比绍": [-15.180413, 11.803749],
|
||||
"赤道几内亚": [10.267895, 1.650801],
|
||||
"希腊": [21.824312, 39.074208],
|
||||
"格陵兰": [-42.604303, 71.706936],
|
||||
"危地马拉": [-90.23075899999999, 15.783471],
|
||||
"法属圭亚那": [-53.125782, 3.933889],
|
||||
"圭亚那": [-58.93018, 4.860416],
|
||||
"洪都拉斯": [-86.241905, 15.199999],
|
||||
"克罗地亚": [15.2, 45.1],
|
||||
"海地": [-72.285215, 18.971187],
|
||||
"匈牙利共和国": [19.503304, 47.162494],
|
||||
"印尼": [113.921327, -0.789275],
|
||||
"爱尔兰": [-8.24389, 53.41291],
|
||||
"伊朗": [53.688046, 32.427908],
|
||||
"伊拉克": [43.679291, 33.223191],
|
||||
"冰岛": [-19.020835, 64.963051],
|
||||
"以色列": [34.851612, 31.046051],
|
||||
"意大利": [12.56738, 41.87194],
|
||||
"牙买加": [-77.297508, 18.109581],
|
||||
"约旦": [36.238414, 30.585164],
|
||||
"日本": [138.252924, 36.204824],
|
||||
"哈萨克斯坦": [66.923684, 48.019573],
|
||||
"肯尼亚": [37.906193, -0.023559],
|
||||
"吉尔吉斯斯坦": [74.766098, 41.20438],
|
||||
"柬埔寨": [104.990963, 12.565679],
|
||||
"韩国": [127.766922, 35.907757],
|
||||
"科索沃": [20.902977, 42.6026359],
|
||||
"科威特": [47.481766, 29.31166],
|
||||
"老挝": [102.495496, 19.85627],
|
||||
"黎巴嫩": [35.862285, 33.854721],
|
||||
"利比里亚共和国": [-9.429499000000002, 6.428055],
|
||||
"利比亚": [17.228331, 26.3351],
|
||||
"斯里兰卡": [80.77179699999999, 7.873053999999999],
|
||||
"莱索托": [28.233608, -29.609988],
|
||||
"立陶宛": [23.881275, 55.169438],
|
||||
"巴林": [50.30, 26.10],
|
||||
"卢森堡": [6.129582999999999, 49.815273],
|
||||
"拉脱维亚共和国": [24.603189, 56.879635],
|
||||
"摩洛哥": [-7.092619999999999, 31.791702],
|
||||
"摩尔多瓦": [28.369885, 47.411631],
|
||||
"马达加斯加": [46.869107, -18.766947],
|
||||
"墨西哥": [-102.552784, 23.634501],
|
||||
"马其顿": [21.745275, 41.608635],
|
||||
"马里": [-3.996166, 17.570692],
|
||||
"缅甸": [95.956223, 21.913965],
|
||||
"黑山共和国": [19.37439, 42.708678],
|
||||
"蒙古": [103.846656, 46.862496],
|
||||
"莫桑比克": [35.529562, -18.665695],
|
||||
"毛里塔尼亚": [-10.940835, 21.00789],
|
||||
"马拉维": [34.301525, -13.254308],
|
||||
"马来西亚": [101.975766, 4.210484],
|
||||
"纳米比亚": [18.49041, -22.95764],
|
||||
"新喀里多尼亚": [165.618042, -20.904305],
|
||||
"尼日尔": [8.081666, 17.607789],
|
||||
"尼日利亚": [8.675277, 9.081999],
|
||||
"尼加拉瓜": [-85.207229, 12.865416],
|
||||
"荷兰": [5.291265999999999, 52.132633],
|
||||
"挪威": [8.468945999999999, 60.47202399999999],
|
||||
"尼泊尔": [84.12400799999999, 28.394857],
|
||||
"新西兰": [174.885971, -40.900557],
|
||||
"阿曼": [55.923255, 21.512583],
|
||||
"巴基斯坦": [69.34511599999999, 30.375321],
|
||||
"巴拿马共和国": [-80.782127, 8.537981],
|
||||
"秘鲁": [-75.015152, -9.189967],
|
||||
"菲律宾": [121.774017, 12.879721],
|
||||
"巴布亚新几内亚": [143.95555, -6.314992999999999],
|
||||
"波兰": [19.145136, 51.919438],
|
||||
"波多黎各": [-66.590149, 18.220833],
|
||||
"北朝鲜": [127.510093, 40.339852],
|
||||
"葡萄牙": [-8.224454, 39.39987199999999],
|
||||
"巴拉圭": [-58.443832, -23.442503],
|
||||
"卡塔尔": [51.183884, 25.354826],
|
||||
"罗马尼亚": [24.96676, 45.943161],
|
||||
"俄罗斯": [105.318756, 61.52401],
|
||||
"俄罗斯联邦": [105.318756, 61.52401],
|
||||
"卢旺达": [29.873888, -1.940278],
|
||||
"西撒哈拉": [-12.885834, 24.215527],
|
||||
"沙特阿拉伯": [45.079162, 23.885942],
|
||||
"苏丹": [30.217636, 12.862807],
|
||||
"南苏丹": [31.3069788, 6.876991899999999],
|
||||
"塞内加尔": [-14.452362, 14.497401],
|
||||
"所罗门群岛": [160.156194, -9.64571],
|
||||
"塞拉利昂": [-11.779889, 8.460555],
|
||||
"萨尔瓦多": [-88.89653, 13.794185],
|
||||
"索马里兰": [46.8252838, 9.411743399999999],
|
||||
"索马里": [46.199616, 5.152149],
|
||||
"塞尔维亚": [21.005859, 44.016521],
|
||||
"苏里南": [-56.027783, 3.919305],
|
||||
"斯洛伐克共和国": [19.699024, 48.669026],
|
||||
"斯洛文尼亚": [14.995463, 46.151241],
|
||||
"瑞典": [18.643501, 60.12816100000001],
|
||||
"斯威士兰": [31.465866, -26.522503],
|
||||
"叙利亚": [38.996815, 34.80207499999999],
|
||||
"乍得": [18.732207, 15.454166],
|
||||
"多哥": [0.824782, 8.619543],
|
||||
"泰国": [100.992541, 15.870032],
|
||||
"塔吉克斯坦共和国": [71.276093, 38.861034],
|
||||
"土库曼斯坦": [59.556278, 38.969719],
|
||||
"东帝汶": [125.727539, -8.874217],
|
||||
"特里尼达和多巴哥": [-61.222503, 10.691803],
|
||||
"突尼斯": [9.537499, 33.886917],
|
||||
"土耳其": [35.243322, 38.963745],
|
||||
"坦桑尼亚": [34.888822, -6.369028],
|
||||
"乌干达": [32.290275, 1.373333],
|
||||
"乌克兰": [31.16558, 48.379433],
|
||||
"乌拉圭": [-55.765835, -32.522779],
|
||||
"美国": [-95.712891, 37.09024],
|
||||
"乌兹别克斯坦": [64.585262, 41.377491],
|
||||
"委内瑞拉": [-66.58973, 6.42375],
|
||||
"越南": [108.277199, 14.058324],
|
||||
"瓦努阿图": [166.959158, -15.376706],
|
||||
"西岸": [35.3027226, 31.9465703],
|
||||
"也门": [48.516388, 15.552727],
|
||||
"南非": [22.937506, -30.559482],
|
||||
"赞比亚": [27.849332, -13.133897],
|
||||
"津巴布韦": [29.154857, -19.015438],
|
||||
"科摩罗": [43.872219, -11.875001],
|
||||
"印度尼西亚": [106.515414, -6.103040],
|
||||
"印度": [78.96288, 20.593684],
|
||||
"刚果": [15.12, -4.09],
|
||||
"萨摩亚独立国": [-171.46, 13.5],
|
||||
"朝鲜": [125.30, 39.09],
|
||||
"新加坡": [103.87546, 1.30243],
|
||||
"中非": [18.33, 4.21],
|
||||
"巴勒斯坦": [35.25, 31.79],
|
||||
"圣卢西亚": [-60.58, 14.02],
|
||||
"安提瓜和巴布达": [-61.48, 17.20],
|
||||
"巴巴多斯": [-59.30, 13.05],
|
||||
"开曼群岛": [-81.24, 19.20],
|
||||
"法罗群岛": [-6.56, 62.05],
|
||||
"圣文森特和格林纳丁斯": [-61.10, 13.10],
|
||||
"特立尼达和多巴哥": [-60.32, 11.18],
|
||||
"台澎金马单独关税区": [121.21, 25.02],
|
||||
"中国香港": [114.12, 22.26],
|
||||
"中国澳门": [113.5, 22.2],
|
||||
"格林纳达": [-61.4, 12.7],
|
||||
"塞舌尔共和国": [55.28, -4.4],
|
||||
"多米尼克": [61.23, 15.18],
|
||||
"斐济": [178.30, -18.06],
|
||||
"科特迪瓦": [-5.17, 6.49],
|
||||
"欧盟": [4.47, 50.50]
|
||||
},
|
||||
"nameMap":{
|
||||
"Afghanistan": "阿富汗",
|
||||
"Singapore": "新加坡",
|
||||
"Angola": "安哥拉",
|
||||
"Albania": "阿尔巴尼亚",
|
||||
"United Arab Emirates": "阿拉伯联合酋长国",
|
||||
"Argentina": "阿根廷",
|
||||
"Armenia": "亚美尼亚",
|
||||
"French Southern and Antarctic Lands": "法属南半球和南极领地",
|
||||
"Australia": "澳大利亚",
|
||||
"Austria": "奥地利",
|
||||
"Azerbaijan": "阿塞拜疆",
|
||||
"Seychelles": "塞舌尔共和国",
|
||||
"Burundi": "布隆迪",
|
||||
"Belgium": "比利时",
|
||||
"Benin": "贝宁",
|
||||
"Burkina Faso": "布基纳法索",
|
||||
"Côte d Ivoire": "科特迪瓦",
|
||||
"Bangladesh": "孟加拉人民共和国",
|
||||
"Bulgaria": "保加利亚",
|
||||
"The Bahamas": "巴哈马",
|
||||
"Bosnia and Herzegovina": "波斯尼亚和黑塞哥维那",
|
||||
"Belarus": "白俄罗斯",
|
||||
"Belize": "伯利兹",
|
||||
"Bermuda": "百慕大",
|
||||
"Bolivia": "玻利维亚",
|
||||
"Brazil": "巴西",
|
||||
"Brunei": "文莱",
|
||||
"Bhutan": "不丹",
|
||||
"Botswana": "博茨瓦纳",
|
||||
"Central African Republic": "中非共和国",
|
||||
"Canada": "加拿大",
|
||||
"Switzerland": "瑞士",
|
||||
"Chile": "智利",
|
||||
"China": "中华人民共和国",
|
||||
"Ivory Coast": "象牙海岸",
|
||||
"Cameroon": "喀麦隆",
|
||||
"Dem. Rep. Congo": "刚果民主共和国",
|
||||
"Republic of the Congo": "刚果共和国",
|
||||
"Colombia": "哥伦比亚",
|
||||
"Costa Rica": "哥斯达黎加共和国",
|
||||
"Cuba": "古巴",
|
||||
"Northern Cyprus": "北塞浦路斯",
|
||||
"Cyprus": "塞浦路斯",
|
||||
"Czech Republic": "捷克共和国",
|
||||
"Germany": "德国",
|
||||
"Djibouti": "吉布提",
|
||||
"Denmark": "丹麦",
|
||||
"Dominican Republic": "多明尼加共和国",
|
||||
"Algeria": "阿尔及利亚",
|
||||
"Ecuador": "厄瓜多尔",
|
||||
"Egypt": "埃及",
|
||||
"Eritrea": "厄立特里亚",
|
||||
"Spain": "西班牙",
|
||||
"Estonia": "爱沙尼亚",
|
||||
"Ethiopia": "埃塞俄比亚",
|
||||
"Bahrain": "巴林",
|
||||
"Finland": "芬兰",
|
||||
"Fiji": "斐济",
|
||||
"Falkland Islands": "福克兰群岛",
|
||||
"France": "法国",
|
||||
"Gabon": "加蓬共和国",
|
||||
"United Kingdom": "英国",
|
||||
"Georgia": "格鲁吉亚",
|
||||
"Ghana": "加纳",
|
||||
"Guinea": "几内亚",
|
||||
"Gambia": "冈比亚",
|
||||
"Guinea Bissau": "几内亚比绍",
|
||||
"Greece": "希腊",
|
||||
"Greenland": "格陵兰",
|
||||
"Guatemala": "危地马拉",
|
||||
"French Guiana": "法属圭亚那",
|
||||
"Guyana": "圭亚那",
|
||||
"Honduras": "洪都拉斯",
|
||||
"Croatia": "克罗地亚",
|
||||
"Haiti": "海地",
|
||||
"Hungary": "匈牙利共和国",
|
||||
"Indonesia": "印度尼西亚",
|
||||
"India": "印度",
|
||||
"Ireland": "爱尔兰",
|
||||
"Iran": "伊朗",
|
||||
"Iraq": "伊拉克",
|
||||
"Iceland": "冰岛",
|
||||
"Israel": "以色列",
|
||||
"Italy": "意大利",
|
||||
"Jamaica": "牙买加",
|
||||
"Jordan": "约旦",
|
||||
"Japan": "日本",
|
||||
"Kazakhstan": "哈萨克斯坦",
|
||||
"Kenya": "肯尼亚",
|
||||
"Kyrgyzstan": "吉尔吉斯斯坦",
|
||||
"Cambodia": "柬埔寨",
|
||||
"Kosovo": "科索沃",
|
||||
"Kuwait": "科威特",
|
||||
"Laos": "老挝",
|
||||
"Lebanon": "黎巴嫩",
|
||||
"Liberia": "利比里亚共和国",
|
||||
"Libya": "利比亚",
|
||||
"Sri Lanka": "斯里兰卡",
|
||||
"Lesotho": "莱索托",
|
||||
"Lithuania": "立陶宛",
|
||||
"Luxembourg": "卢森堡",
|
||||
"Latvia": "拉脱维亚共和国",
|
||||
"Morocco": "摩洛哥",
|
||||
"Moldova": "摩尔多瓦",
|
||||
"Madagascar": "马达加斯加",
|
||||
"Mexico": "墨西哥",
|
||||
"Macedonia": "马其顿",
|
||||
"Mali": "马里",
|
||||
"Myanmar": "缅甸",
|
||||
"Montenegro": "黑山共和国",
|
||||
"Mongolia": "蒙古",
|
||||
"Mozambique": "莫桑比克",
|
||||
"Mauritania": "毛里塔尼亚",
|
||||
"Malawi": "马拉维",
|
||||
"Malaysia": "马来西亚",
|
||||
"Namibia": "纳米比亚",
|
||||
"New Caledonia": "新喀里多尼亚",
|
||||
"Niger": "尼日尔",
|
||||
"Nigeria": "尼日利亚",
|
||||
"Nicaragua": "尼加拉瓜",
|
||||
"Netherlands": "荷兰",
|
||||
"Norway": "挪威",
|
||||
"Nepal": "尼泊尔",
|
||||
"New Zealand": "新西兰",
|
||||
"Oman": "阿曼",
|
||||
"Pakistan": "巴基斯坦",
|
||||
"Panama": "巴拿马共和国",
|
||||
"Peru": "秘鲁",
|
||||
"Philippines": "菲律宾",
|
||||
"Papua New Guinea": "巴布亚新几内亚",
|
||||
"Poland": "波兰",
|
||||
"Puerto Rico": "波多黎各",
|
||||
"North Korea": "北朝鲜",
|
||||
"Portugal": "葡萄牙",
|
||||
"Paraguay": "巴拉圭",
|
||||
"Qatar": "卡塔尔",
|
||||
"Romania": "罗马尼亚",
|
||||
"Russia": "俄罗斯",
|
||||
"Rwanda": "卢旺达",
|
||||
"Western Sahara": "西撒哈拉",
|
||||
"Saudi Arabia": "沙特阿拉伯",
|
||||
"Sudan": "苏丹",
|
||||
"S. Sudan": "南苏丹",
|
||||
"Senegal": "塞内加尔",
|
||||
"Solomon Islands": "所罗门群岛",
|
||||
"Sierra Leone": "塞拉利昂",
|
||||
"El Salvador": "萨尔瓦多",
|
||||
"Somaliland": "索马里兰",
|
||||
"Somalia": "索马里",
|
||||
"Republic of Serbia": "塞尔维亚",
|
||||
"Suriname": "苏里南",
|
||||
"Slovakia": "斯洛伐克共和国",
|
||||
"Slovenia": "斯洛文尼亚",
|
||||
"Sweden": "瑞典",
|
||||
"Swaziland": "斯威士兰",
|
||||
"Syria": "叙利亚",
|
||||
"Chad": "乍得",
|
||||
"Togo": "多哥",
|
||||
"Thailand": "泰国",
|
||||
"Tajikistan": "塔吉克斯坦共和国",
|
||||
"Turkmenistan": "土库曼斯坦",
|
||||
"East Timor": "东帝汶",
|
||||
"Trinidad and Tobago": "特立尼达和多巴哥",
|
||||
"Tunisia": "突尼斯",
|
||||
"Turkey": "土耳其",
|
||||
"Uganda": "乌干达",
|
||||
"Ukraine": "乌克兰",
|
||||
"Uruguay": "乌拉圭",
|
||||
"United States": "美国",
|
||||
"Uzbekistan": "乌兹别克斯坦",
|
||||
"Venezuela": "委内瑞拉",
|
||||
"Vietnam": "越南",
|
||||
"Vanuatu": "瓦努阿图",
|
||||
"West Bank": "西岸",
|
||||
"Yemen": "也门",
|
||||
"South Africa": "南非",
|
||||
"Zambia": "赞比亚",
|
||||
"Korea": "韩国",
|
||||
"Tanzania": "坦桑尼亚",
|
||||
"Zimbabwe": "津巴布韦",
|
||||
"Congo": "刚果",
|
||||
"Central African Rep.": "中非",
|
||||
"Serbia": "塞尔维亚",
|
||||
"Bosnia and Herz.": "波黑",
|
||||
"Czech Rep.": "捷克",
|
||||
"W. Sahara": "西撒哈拉",
|
||||
"Lao PDR": "老挝",
|
||||
"Dem. Rep. Korea": "朝鲜",
|
||||
"Falkland Is.": "福克兰群岛",
|
||||
"Timor-Leste": "东帝汶",
|
||||
"Solomon Is.": "所罗门群岛",
|
||||
"Palestine": "巴勒斯坦",
|
||||
"N. Cyprus": "北塞浦路斯",
|
||||
"Aland": "奥兰群岛",
|
||||
"Fr. S. Antarctic Lands": "法属南半球和南极陆地",
|
||||
"Mauritius": "毛里求斯",
|
||||
"Comoros": "科摩罗",
|
||||
"Eq. Guinea": "赤道几内亚",
|
||||
"Guinea-Bissau": "几内亚比绍",
|
||||
"Dominican Rep.": "多米尼加共和国",
|
||||
"Saint Lucia": "圣卢西亚",
|
||||
"Dominica": "多米尼克",
|
||||
"Antigua and Barb.": "安提瓜和巴布达",
|
||||
"U.S. Virgin Is.": "美国原始岛屿",
|
||||
"Montserrat": "蒙塞拉特",
|
||||
"Grenada": "格林纳达",
|
||||
"Barbados": "巴巴多斯",
|
||||
"Samoa": "萨摩亚独立国",
|
||||
"Bahamas": "巴哈马",
|
||||
"Cayman Is.": "开曼群岛",
|
||||
"Faeroe Is.": "法罗群岛",
|
||||
"IsIe of Man": "马恩岛",
|
||||
"Malta": "马耳他共和国",
|
||||
"Jersey": "泽西",
|
||||
"Cape Verde": "佛得角共和国",
|
||||
"Turks and Caicos Is.": "特克斯和凯科斯群岛",
|
||||
"St. Vin. and Gren.": "圣文森特和格林纳丁斯"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 313 B |
|
After Width: | Height: | Size: 304 B |
|
After Width: | Height: | Size: 582 B |
|
After Width: | Height: | Size: 615 B |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -6,7 +6,7 @@
|
||||
//
|
||||
'white': #ffffff,
|
||||
'black': #000000,
|
||||
'success': ('base': #13deb9),
|
||||
'success': ('base': #00A43F),
|
||||
'warning': ('base': #ffae1f),
|
||||
'danger': ('base': #ff4d4f),
|
||||
'error': ('base': #fa896b)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//
|
||||
'white': #ffffff,
|
||||
'black': #000000,
|
||||
'success': ('base': #13deb9),
|
||||
'success': ('base': #00A43F),
|
||||
'warning': ('base': #ffae1f),
|
||||
'danger': ('base': #ff4d4f),
|
||||
'error': ('base': #fa896b)
|
||||
|
||||
@@ -400,3 +400,12 @@
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
// 修复 el-table 空数据块样式
|
||||
.el-table__empty-block {
|
||||
height: 100% !important;
|
||||
min-height: 200px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div class="multi-file-upload">
|
||||
<!-- 上传按钮 -->
|
||||
<el-upload
|
||||
class="upload-box"
|
||||
:action="actionImageUplode"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="handleUpload"
|
||||
:before-upload="beforeUpload"
|
||||
multiple
|
||||
:accept="accept"
|
||||
>
|
||||
<div class="upload-trigger">
|
||||
<img class="upload-icon" :src="iconSrc" />
|
||||
<div class="upload-text">
|
||||
<span>{{ uploadText }}</span>
|
||||
<span v-if="showHint" class="upload-hint">{{ hintText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
|
||||
<!-- 文件列表显示 -->
|
||||
<div v-if="fileList.length > 0" class="files-list">
|
||||
<div v-for="(file, index) in fileList" :key="index" class="file-item">
|
||||
<div class="file-info">
|
||||
<img v-if="isImageFile(file)" :src="file.url" class="file-preview-image" />
|
||||
<div v-else class="file-icon">
|
||||
<i class="el-icon-document"></i>
|
||||
</div>
|
||||
<span class="file-name">{{ file.name }}</span>
|
||||
</div>
|
||||
<div class="file-actions">
|
||||
<el-button type="text" size="small" @click="filePreview(file)" icon="el-icon-download">
|
||||
预览
|
||||
</el-button>
|
||||
<el-button type="text" size="small" @click="downloadFile(file)" icon="el-icon-download">
|
||||
下载
|
||||
</el-button>
|
||||
<el-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="removeFile(index)"
|
||||
icon="el-icon-delete"
|
||||
style="color: #f56c6c"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<files-previews v-model="fileshow" :file="filepviews" @close="onClose" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="MultiFileUpload">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUploadHeaders } from '@/composables/useUploadHeaders'
|
||||
import FilesPreviews from '@/components/custom/files_previews.vue'
|
||||
const actionImageUplode = `${import.meta.env.VITE_API_URL}/ticket/upload?scene=stock_orders`
|
||||
const { uploadHeaders } = useUploadHeaders()
|
||||
const fileshow = ref(false)
|
||||
const filepviews = ref({} as FileItem)
|
||||
function onClose() {
|
||||
console.log('文件预览已关闭')
|
||||
}
|
||||
interface FileItem {
|
||||
name: string
|
||||
url: string
|
||||
file_name: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: FileItem[]
|
||||
uploadText?: string
|
||||
hintText?: string
|
||||
showHint?: boolean
|
||||
iconSrc?: string
|
||||
accept?: string
|
||||
maxSize?: number // MB
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: FileItem[]): void
|
||||
(e: 'change', value: FileItem[]): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
uploadText: '点击上传文件',
|
||||
hintText: '支持多个文件上传',
|
||||
showHint: true,
|
||||
iconSrc: '/src/assets/img/crm/contract.png',
|
||||
accept: '.jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.txt',
|
||||
maxSize: 10
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const fileList = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
}
|
||||
})
|
||||
const filePreview = (file: FileItem) => {
|
||||
filepviews.value = file
|
||||
fileshow.value = true
|
||||
}
|
||||
const isImageFile = (file: FileItem) => {
|
||||
const stripQuery = (s?: string) => (s ? s.split('?')[0] : '')
|
||||
const byExt = (s?: string) => /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(stripQuery(s || ''))
|
||||
// Prefer extension checks on url, then file_name, then name
|
||||
if (byExt(file.url)) return true
|
||||
if (byExt(file.file_name)) return true
|
||||
if (byExt(file.name)) return true
|
||||
// Fallback: some backends may return mime in file_name like 'image/jpeg'
|
||||
return (file.file_name || '').toLowerCase().startsWith('image/')
|
||||
}
|
||||
|
||||
const beforeUpload = (file: File) => {
|
||||
const isValidType = new RegExp(props.accept.replace(/\./g, '\\.').replace(/,/g, '|'), 'i').test(
|
||||
file.name
|
||||
)
|
||||
if (!isValidType) {
|
||||
ElMessage.error(`只能上传${props.accept}格式的文件!`)
|
||||
return false
|
||||
}
|
||||
const isLtMaxSize = file.size / 1024 / 1024 < props.maxSize
|
||||
if (!isLtMaxSize) {
|
||||
ElMessage.error(`文件大小不能超过 ${props.maxSize}MB!`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const handleUpload = (response: any) => {
|
||||
if (response.code === 200) {
|
||||
const fileInfo: FileItem = {
|
||||
name: response.data.original_name || '文件',
|
||||
url: response.data.file_url,
|
||||
file_name: response.data.file_name
|
||||
}
|
||||
const newFileList = [...fileList.value, fileInfo]
|
||||
fileList.value = newFileList
|
||||
ElMessage.success('文件上传成功!')
|
||||
} else {
|
||||
ElMessage.error(response.message || '文件上传失败!')
|
||||
}
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
const newFileList = [...fileList.value]
|
||||
newFileList.splice(index, 1)
|
||||
fileList.value = newFileList
|
||||
ElMessage.success('文件已删除')
|
||||
}
|
||||
|
||||
const downloadFile = (file: FileItem) => {
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a')
|
||||
link.href = file.url
|
||||
link.download = file.name
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
// 导出组件
|
||||
defineExpose({})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.multi-file-upload {
|
||||
.upload-box {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
// border: 2px dashed #d9d9d9;
|
||||
// border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
// background: #fafafa;
|
||||
|
||||
&:hover {
|
||||
border-color: #409eff;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
// 文件列表样式
|
||||
.files-list {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
background: #fafafa;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #f0f9ff;
|
||||
border-color: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.file-preview-image {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
margin-right: 12px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
color: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -54,23 +54,33 @@
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- 通知 -->
|
||||
<div class="btn-box notice-btn" @click="visibleNotice">
|
||||
<div class="btn notice-button">
|
||||
<i
|
||||
class="iconfont-sys notice-btn"
|
||||
:class="{ 'bell-shake': isBellShaking }"
|
||||
:style="isBellShaking ? 'color: #5d87ff' : ''"
|
||||
></i
|
||||
>
|
||||
<span class="count notice-btn" v-if="isBellShaking"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-box screen-box" @click="clearCachefn">
|
||||
<div class="btn" title="清除缓存">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 全屏按钮 -->
|
||||
<div class="btn-box screen-box" @click="toggleFullScreen">
|
||||
<div
|
||||
:title="isFullscreen ? '退出全屏' : '全屏'"
|
||||
class="btn"
|
||||
:class="{ 'full-screen-btn': !isFullscreen, 'exit-full-screen-btn': isFullscreen }"
|
||||
>
|
||||
<i class="iconfont-sys">{{ isFullscreen ? '' : '' }}</i>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 通知 -->
|
||||
<!-- <div class="btn-box notice-btn" @click="visibleNotice">
|
||||
<div class="btn notice-button">
|
||||
<i class="iconfont-sys notice-btn"></i>
|
||||
<span class="count notice-btn"></span>
|
||||
</div>
|
||||
</div> -->
|
||||
<!-- 聊天 -->
|
||||
<!-- <div class="btn-box chat-btn" @click="openChat">
|
||||
<div class="btn chat-button">
|
||||
@@ -79,7 +89,7 @@
|
||||
</div>
|
||||
</div> -->
|
||||
<!-- 语言 -->
|
||||
<div class="btn-box" v-if="showLanguage">
|
||||
<!-- <div class="btn-box" v-if="showLanguage">
|
||||
<el-dropdown @command="changeLanguage" popper-class="langDropDownStyle">
|
||||
<div class="btn language-btn">
|
||||
<i class="iconfont-sys"></i>
|
||||
@@ -98,7 +108,7 @@
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div> -->
|
||||
<!-- 设置 -->
|
||||
<!-- <div class="btn-box" @click="openSetting">
|
||||
<el-popover :visible="showSettingGuide" placement="bottom-start" :width="190" :offset="0">
|
||||
@@ -119,7 +129,7 @@
|
||||
</div> -->
|
||||
<!-- 切换主题 -->
|
||||
<div class="btn-box" @click="themeAnimation">
|
||||
<div class="btn theme-btn">
|
||||
<div class="btn theme-btn" title="切换主题">
|
||||
<i class="iconfont-sys">{{ isDark ? '' : '' }}</i>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,7 +148,10 @@
|
||||
popper-style="border: 1px solid var(--art-border-dashed-color); border-radius: calc(var(--custom-radius) / 2 + 4px); padding: 5px 16px; 5px 16px;"
|
||||
>
|
||||
<template #reference>
|
||||
<img class="cover" src="@imgs/user/avatar.webp" />
|
||||
<img
|
||||
class="cover"
|
||||
:src="getUserInfo.user.avatar ? getUserInfo.user.avatar : '@imgs/user/avatar.webp'"
|
||||
/>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="user-menu-box">
|
||||
@@ -155,15 +168,19 @@
|
||||
<span class="email">{{ userInfo.user.email }}</span>
|
||||
</div>
|
||||
</div> -->
|
||||
<ul class="user-menu">
|
||||
<li @click="goPage('/system/user-center')">
|
||||
<ul class="user-menu" style="border: none">
|
||||
<li @click="gouserInfo()">
|
||||
<i class="menu-icon iconfont-sys"></i>
|
||||
<span class="menu-txt">{{ $t('topBar.user.userCenter') }}</span>
|
||||
</li>
|
||||
<li @click="goPage('/crmpc/recycle')">
|
||||
<li @click="goPage('/work/myOrder')">
|
||||
<i class="menu-icon iconfont-sys"></i>
|
||||
<span class="menu-txt">我的工单</span>
|
||||
</li>
|
||||
<!-- <li @click="goPage('/crmpc/recycle')">
|
||||
<i class="menu-icon iconfont-sys"></i>
|
||||
<span class="menu-txt">回收站</span>
|
||||
</li>
|
||||
</li> -->
|
||||
<!-- <li @click="goPage('/system/user-center')">
|
||||
<i class="menu-icon iconfont-sys"></i>
|
||||
<span class="menu-txt">{{ $t('topBar.user.userCenter') }}</span>
|
||||
@@ -176,10 +193,10 @@
|
||||
<i class="menu-icon iconfont-sys"></i>
|
||||
<span class="menu-txt">{{ $t('topBar.user.github') }}</span>
|
||||
</li> -->
|
||||
<li @click="lockScreen()">
|
||||
<!-- <li @click="lockScreen()">
|
||||
<i class="menu-icon iconfont-sys"></i>
|
||||
<span class="menu-txt">{{ $t('topBar.user.lockScreen') }}</span>
|
||||
</li>
|
||||
</li> -->
|
||||
<div class="line"></div>
|
||||
<div class="logout-btn" @click="loginOut">
|
||||
{{ $t('topBar.user.logout') }}
|
||||
@@ -208,14 +225,14 @@
|
||||
import { mittBus } from '@/utils/sys'
|
||||
import { useMenuStore } from '@/store/modules/menu'
|
||||
import AppConfig from '@/config'
|
||||
import { manageService } from '@/api/adminApi'
|
||||
import { languageOptions } from '@/locales'
|
||||
const isWindows = navigator.userAgent.includes('Windows')
|
||||
const { locale } = useI18n()
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
const { getUserInfo, home_page } = useUserStore()
|
||||
const {
|
||||
showMenuButton,
|
||||
showRefreshButton,
|
||||
@@ -229,13 +246,17 @@
|
||||
tabStyle
|
||||
} = storeToRefs(settingStore)
|
||||
|
||||
const { language, getUserInfo: userInfo } = storeToRefs(userStore)
|
||||
const { language } = storeToRefs(userStore)
|
||||
|
||||
const { menuList } = storeToRefs(useMenuStore())
|
||||
|
||||
const showNotice = ref(false)
|
||||
const notice = ref(null)
|
||||
const userMenuPopover = ref()
|
||||
// 控制铃铛是否晃动
|
||||
const isBellShaking = ref(false)
|
||||
// 轮询定时器句柄,防止重复创建
|
||||
let notificationPollTimer: number | undefined
|
||||
|
||||
const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT)
|
||||
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
|
||||
@@ -257,10 +278,20 @@
|
||||
onMounted(() => {
|
||||
initLanguage()
|
||||
document.addEventListener('click', bodyCloseNotice)
|
||||
// 监听通知数据变化,控制铃铛晃动
|
||||
watchNotificationData()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', bodyCloseNotice)
|
||||
// 清理事件监听器
|
||||
mittBus.off('notification-data-changed')
|
||||
mittBus.off('ws:message')
|
||||
// 清理通知轮询
|
||||
if (notificationPollTimer) {
|
||||
clearInterval(notificationPollTimer)
|
||||
notificationPollTimer = undefined
|
||||
}
|
||||
})
|
||||
|
||||
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
|
||||
@@ -268,7 +299,14 @@
|
||||
const toggleFullScreen = () => {
|
||||
toggleFullscreen()
|
||||
}
|
||||
|
||||
const clearCachefn = () => {
|
||||
manageService.clearCache().then((res) => {
|
||||
ElMessage.success(res.data?.message || '成功!')
|
||||
// setTimeout(() => {
|
||||
window.location.reload()
|
||||
// }, 5000)
|
||||
})
|
||||
}
|
||||
const topBarWidth = (): string => {
|
||||
const { TOP, DUAL_MENU, TOP_LEFT } = MenuTypeEnum
|
||||
const { getMenuOpenWidth } = settingStore
|
||||
@@ -296,17 +334,20 @@
|
||||
const goPage = (path: string) => {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
const toDocs = () => {
|
||||
window.open(WEB_LINKS.DOCS)
|
||||
const gouserInfo = () => {
|
||||
router.push('/admin/user/user_info')
|
||||
}
|
||||
|
||||
const toGithub = () => {
|
||||
window.open(WEB_LINKS.GITHUB)
|
||||
}
|
||||
// const toDocs = () => {
|
||||
// window.open(WEB_LINKS.DOCS)
|
||||
// }
|
||||
|
||||
// const toGithub = () => {
|
||||
// window.open(WEB_LINKS.GITHUB)
|
||||
// }
|
||||
|
||||
const toHome = () => {
|
||||
router.push(HOME_PAGE)
|
||||
router.push(home_page)
|
||||
}
|
||||
|
||||
const loginOut = () => {
|
||||
@@ -346,8 +387,6 @@
|
||||
if (showSettingGuide.value) {
|
||||
settingStore.hideSettingGuide()
|
||||
}
|
||||
// 打开设置引导
|
||||
// settingStore.openSettingGuide()
|
||||
}
|
||||
|
||||
const openSearchDialog = () => {
|
||||
@@ -385,9 +424,72 @@
|
||||
userMenuPopover.value.hide()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// 监听通知数据变化,控制铃铛晃动
|
||||
const watchNotificationData = () => {
|
||||
// 定期检查通知数据
|
||||
// const checkNotificationData = async () => {
|
||||
// try {
|
||||
// const { commonService } = await import('@/api/commonApi')
|
||||
// const res = await commonService.getUnReadNoticeList()
|
||||
// const noticeCount = res?.list?.length || 0
|
||||
// isBellShaking.value = noticeCount > 0
|
||||
// } catch (error) {
|
||||
// console.error('获取通知数据失败:', error)
|
||||
// isBellShaking.value = false
|
||||
// }
|
||||
// }
|
||||
// // 立即检查一次
|
||||
// checkNotificationData()
|
||||
// // 每30秒检查一次通知数据,先清理旧定时器,避免重复
|
||||
// if (notificationPollTimer) {
|
||||
// clearInterval(notificationPollTimer)
|
||||
// notificationPollTimer = undefined
|
||||
// }
|
||||
// notificationPollTimer = window.setInterval(checkNotificationData, 30000)
|
||||
// 监听通知数据变化事件
|
||||
mittBus.on('notification-data-changed', (count: unknown) => {
|
||||
isBellShaking.value = typeof count === 'number' ? count > 0 : false
|
||||
})
|
||||
// 监听WebSocket消息,处理实时通知
|
||||
mittBus.on('ws:message', (data: any) => {
|
||||
if (
|
||||
data &&
|
||||
data.event &&
|
||||
(data.event.includes('notice') ||
|
||||
data.event.includes('notification') ||
|
||||
data.event.includes('message'))
|
||||
) {
|
||||
isBellShaking.value = true
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use './style';
|
||||
@use './mobile';
|
||||
// 铃铛晃动动画
|
||||
.bell-shake {
|
||||
animation: bellShake 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes bellShake {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
10%,
|
||||
30%,
|
||||
50%,
|
||||
70%,
|
||||
90% {
|
||||
transform: rotate(-10deg);
|
||||
}
|
||||
20%,
|
||||
40%,
|
||||
60%,
|
||||
80% {
|
||||
transform: rotate(10deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -32,7 +32,10 @@
|
||||
v-html="item.meta.icon"
|
||||
></i>
|
||||
<span>{{ formatMenuTitle(item.meta.title) }}</span>
|
||||
<div class="badge" v-if="item.meta.showBadge"></div>
|
||||
<div v-if="isMenuBadgeVisible(item.path) && !getMenuTextBadge(item.path)" class="badge"></div>
|
||||
<div v-if="getMenuTextBadge(item.path)" class="text-badge">
|
||||
{{ getMenuTextBadge(item.path) }}
|
||||
</div>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
|
||||
@@ -41,6 +44,7 @@
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
import { handleMenuJump } from '@/utils/navigation'
|
||||
import { formatMenuTitle } from '@/router/utils/utils'
|
||||
import { useMenuBadge } from '@/composables/useMenuBadge'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
@@ -60,6 +64,9 @@
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 使用菜单徽标系统
|
||||
const { isMenuBadgeVisible, getMenuTextBadge } = useMenuBadge()
|
||||
|
||||
// 计算当前项是否有子菜单
|
||||
const hasChildren = computed(() => {
|
||||
return props.item.children && props.item.children.length > 0
|
||||
|
||||
@@ -112,11 +112,11 @@
|
||||
import { isIframe } from '@/utils/navigation'
|
||||
import { handleMenuJump } from '@/utils/navigation'
|
||||
import SidebarSubmenu from './widget/SidebarSubmenu.vue'
|
||||
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const { home_page } = useUserStore()
|
||||
const { getMenuOpenWidth, menuType, uniqueOpened, dualMenuShowText, menuOpen, getMenuTheme } =
|
||||
storeToRefs(settingStore)
|
||||
|
||||
@@ -210,7 +210,7 @@
|
||||
)
|
||||
|
||||
const toHome = () => {
|
||||
router.push(HOME_PAGE)
|
||||
router.push(home_page)
|
||||
}
|
||||
|
||||
let screenWidth = 0
|
||||
|
||||
@@ -112,16 +112,17 @@
|
||||
top: 0;
|
||||
right: 12px;
|
||||
bottom: 0;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
// padding: 0 5px;
|
||||
margin: auto;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
background: #fd4e4e;
|
||||
border-radius: 5px;
|
||||
// border-radius: 5px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.header {
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
<template #title>
|
||||
<MenuItemIcon :icon="item.meta.icon" :color="theme?.iconColor" />
|
||||
<span class="menu-name">{{ formatMenuTitle(item.meta.title) }}</span>
|
||||
<div v-if="item.meta.showBadge" class="badge" style="right: 35px" />
|
||||
<div v-if="isMenuBadgeVisible(item.path) && !getMenuTextBadge(item.path)" class="badge" style="right: 35px" />
|
||||
<div v-if="getMenuTextBadge(item.path)" class="text-badge" style="right: 35px">
|
||||
{{ getMenuTextBadge(item.path) }}
|
||||
</div>
|
||||
</template>
|
||||
<SidebarSubmenu
|
||||
:list="item.children"
|
||||
@@ -26,9 +29,9 @@
|
||||
<MenuItemIcon :icon="item.meta.icon" :color="theme?.iconColor" />
|
||||
<template #title>
|
||||
<span class="menu-name">{{ formatMenuTitle(item.meta.title) }}</span>
|
||||
<div v-if="item.meta.showBadge" class="badge" />
|
||||
<div v-if="item.meta.showTextBadge" class="text-badge">
|
||||
{{ item.meta.showTextBadge }}
|
||||
<div v-if="isMenuBadgeVisible(item.path) && !getMenuTextBadge(item.path)" class="badge" />
|
||||
<div v-if="getMenuTextBadge(item.path)" class="text-badge">
|
||||
{{ getMenuTextBadge(item.path) }}
|
||||
</div>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
@@ -40,6 +43,7 @@
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
import { formatMenuTitle } from '@/router/utils/utils'
|
||||
import { handleMenuJump } from '@/utils/navigation'
|
||||
import { useMenuBadge } from '@/composables/useMenuBadge'
|
||||
|
||||
// 类型定义
|
||||
interface Props {
|
||||
@@ -66,6 +70,9 @@
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
// 使用菜单徽标系统
|
||||
const { isMenuBadgeVisible, getMenuTextBadge } = useMenuBadge()
|
||||
|
||||
// 计算属性
|
||||
const filteredMenuItems = computed(() => filterRoutes(props.list))
|
||||
|
||||
|
||||
@@ -9,43 +9,46 @@
|
||||
@click.stop=""
|
||||
>
|
||||
<div class="header">
|
||||
<span class="text">{{ $t('notice.title') }}</span>
|
||||
<span class="btn">{{ $t('notice.btnRead') }}</span>
|
||||
<!-- <span class="text">{{ $t('notice.title') }}</span> -->
|
||||
<span class="text">消息</span>
|
||||
<span class="btn" @click="readAll">{{ $t('notice.btnRead') }}</span>
|
||||
</div>
|
||||
<ul class="bar">
|
||||
<li
|
||||
v-for="(item, index) in barList"
|
||||
:key="index"
|
||||
:class="{ active: barActiveIndex === index }"
|
||||
@click="changeBar(index)"
|
||||
>
|
||||
{{ item.name }} ({{ item.num }})
|
||||
{{ item.name }} ({{ noticeList.length }})
|
||||
</li>
|
||||
</ul>
|
||||
<div class="content">
|
||||
<div class="scroll">
|
||||
<!-- 通知 -->
|
||||
<ul class="notice-list" v-show="barActiveIndex === 0">
|
||||
<li v-for="(item, index) in noticeList" :key="index">
|
||||
<ul class="notice-list">
|
||||
<!-- <ul class="notice-list" v-show="barActiveIndex === 0"> -->
|
||||
<li v-for="(item, index) in noticeList" :key="index" @click="showfn(item)" v-ripple>
|
||||
<div
|
||||
class="icon"
|
||||
:style="{ background: getNoticeStyle(item.type).backgroundColor + '!important' }"
|
||||
:style="{
|
||||
background: getNoticeStyle(item.message.scene).backgroundColor + '!important'
|
||||
}"
|
||||
>
|
||||
<i
|
||||
class="iconfont-sys"
|
||||
:style="{ color: getNoticeStyle(item.type).iconColor + '!important' }"
|
||||
v-html="getNoticeStyle(item.type).icon"
|
||||
:style="{ color: getNoticeStyle(item.message.scene).iconColor + '!important' }"
|
||||
v-html="getNoticeStyle(item.message.scene).icon"
|
||||
>
|
||||
</i>
|
||||
</div>
|
||||
<div class="text">
|
||||
<h4>{{ item.title }}</h4>
|
||||
<p>{{ item.time }}</p>
|
||||
<h4>{{ item.message.title }}</h4>
|
||||
<p>{{ item.message.created_at }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- 消息 -->
|
||||
<ul class="user-list" v-show="barActiveIndex === 1">
|
||||
<!-- <ul class="user-list" v-show="barActiveIndex === 1">
|
||||
<li v-for="(item, index) in msgList" :key="index">
|
||||
<div class="avatar">
|
||||
<img :src="item.avatar" />
|
||||
@@ -55,27 +58,27 @@
|
||||
<p>{{ item.time }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</ul> -->
|
||||
<!-- 待办 -->
|
||||
<ul class="base" v-show="barActiveIndex === 3">
|
||||
<!-- <ul class="base" v-show="barActiveIndex === 3">
|
||||
<li v-for="(item, index) in pendingList" :key="index">
|
||||
<h4>{{ item.title }}</h4>
|
||||
<p>{{ item.time }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</ul> -->
|
||||
|
||||
<div class="empty-tips" v-show="barActiveIndex === 0 && noticeList.length === 0">
|
||||
<div class="empty-tips" v-show="noticeList.length === 0">
|
||||
<i class="iconfont-sys"></i>
|
||||
<p>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p>
|
||||
</div>
|
||||
<div class="empty-tips" v-show="barActiveIndex === 1 && msgList.length === 0">
|
||||
<!-- <div class="empty-tips" v-show="barActiveIndex === 1 && msgList.length === 0">
|
||||
<i class="iconfont-sys"></i>
|
||||
<p>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p>
|
||||
</div>
|
||||
<div class="empty-tips" v-show="barActiveIndex === 2 && pendingList.length === 0">
|
||||
<i class="iconfont-sys"></i>
|
||||
<p>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="btn-wrapper">
|
||||
<el-button class="view-all" @click="handleViewAll" v-ripple>
|
||||
@@ -86,6 +89,29 @@
|
||||
|
||||
<div style="height: 100px"></div>
|
||||
</div>
|
||||
<!-- 全局通知详情弹窗 -->
|
||||
<Teleport to="body">
|
||||
<ElDialog v-model="dialogVisible" title="通知详情" align-center :append-to-body="true" width="640px">
|
||||
<div class="detail-dialog">
|
||||
<div class="section">
|
||||
<div class="info-grid">
|
||||
<div class="label">发送人</div>
|
||||
<div class="value">{{ noticeDetail.sender_name || '-' }}</div>
|
||||
</div>
|
||||
<div class="info-grid">
|
||||
<div class="label">通知标题</div>
|
||||
<div class="value">{{ noticeDetail.title || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="label" style="margin-bottom: 8px">通知内容</div>
|
||||
<div class="content-box">
|
||||
<div class="message-text" v-html="noticeDetail.content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElDialog>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -97,7 +123,10 @@
|
||||
import avatar6 from '@/assets/img/avatar/avatar6.webp'
|
||||
import AppConfig from '@/config'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { commonService } from '@/api/commonApi'
|
||||
import { mittBus } from '@/utils/sys'
|
||||
const dialogVisible = ref(false)
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
@@ -123,85 +152,38 @@
|
||||
{
|
||||
name: computed(() => t('notice.bar[0]')),
|
||||
num: 1
|
||||
},
|
||||
{
|
||||
name: computed(() => t('notice.bar[1]')),
|
||||
num: 1
|
||||
},
|
||||
{
|
||||
name: computed(() => t('notice.bar[2]')),
|
||||
num: 0
|
||||
}
|
||||
])
|
||||
|
||||
const noticeList = [
|
||||
{
|
||||
title: '新增国际化',
|
||||
time: '2024-6-13 0:10',
|
||||
type: 'notice'
|
||||
},
|
||||
{
|
||||
title: '冷月呆呆给你发了一条消息',
|
||||
time: '2024-4-21 8:05',
|
||||
type: 'message'
|
||||
},
|
||||
{
|
||||
title: '小肥猪关注了你',
|
||||
time: '2020-3-17 21:12',
|
||||
type: 'collection'
|
||||
},
|
||||
{
|
||||
title: '新增使用文档',
|
||||
time: '2024-02-14 0:20',
|
||||
type: 'notice'
|
||||
},
|
||||
{
|
||||
title: '小肥猪给你发了一封邮件',
|
||||
time: '2024-1-20 0:15',
|
||||
type: 'email'
|
||||
},
|
||||
{
|
||||
title: '菜单mock本地真实数据',
|
||||
time: '2024-1-17 22:06',
|
||||
type: 'notice'
|
||||
}
|
||||
]
|
||||
const msgList: any = [
|
||||
{
|
||||
title: '池不胖 关注了你',
|
||||
time: '2021-2-26 23:50',
|
||||
avatar: avatar1
|
||||
},
|
||||
{
|
||||
title: '唐不苦 关注了你',
|
||||
time: '2021-2-21 8:05',
|
||||
avatar: avatar2
|
||||
},
|
||||
{
|
||||
title: '中小鱼 关注了你',
|
||||
time: '2020-1-17 21:12',
|
||||
avatar: avatar3
|
||||
},
|
||||
{
|
||||
title: '何小荷 关注了你',
|
||||
time: '2021-01-14 0:20',
|
||||
avatar: avatar4
|
||||
},
|
||||
{
|
||||
title: '誶誶淰 关注了你',
|
||||
time: '2020-12-20 0:15',
|
||||
avatar: avatar5
|
||||
},
|
||||
{
|
||||
title: '冷月呆呆 关注了你',
|
||||
time: '2020-12-17 22:06',
|
||||
avatar: avatar6
|
||||
}
|
||||
]
|
||||
|
||||
const changeBar = (index: number) => {
|
||||
barActiveIndex.value = index
|
||||
const noticeList = ref<any>([])
|
||||
const getNoticeList = async () => {
|
||||
await commonService.getUnReadNoticeList().then((res: any) => {
|
||||
noticeList.value = res.list
|
||||
// 通知头部组件更新铃铛状态
|
||||
mittBus.emit('notification-data-changed', res.list?.length || 0)
|
||||
})
|
||||
}
|
||||
onMounted(() => {
|
||||
getNoticeList()
|
||||
// 监听WebSocket消息:收到新通知实时插入到列表首位
|
||||
mittBus.on('ws:message', onWsMessage)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
mittBus.off('ws:message')
|
||||
})
|
||||
|
||||
const onWsMessage = (data: any) => {
|
||||
if (data && data.event === 'notify.receive') {
|
||||
// 将新通知插入到列表首位。后端数据结构需包含 type 与 message 字段
|
||||
// 若结构不同,可在此处做一次字段映射
|
||||
console.log(data)
|
||||
noticeList.value.unshift(data.data.notice_data)
|
||||
console.log(noticeList.value)
|
||||
mittBus.emit('notification-data-changed', noticeList.value.length)
|
||||
}
|
||||
}
|
||||
// const changeBar = (index: number) => {
|
||||
// barActiveIndex.value = index
|
||||
// }
|
||||
|
||||
const getRandomColor = () => {
|
||||
const index = Math.floor(Math.random() * AppConfig.systemMainColor.length)
|
||||
@@ -209,27 +191,27 @@
|
||||
}
|
||||
|
||||
const noticeStyleMap = {
|
||||
email: {
|
||||
4: {
|
||||
icon: '',
|
||||
iconColor: 'rgb(var(--art-warning))',
|
||||
backgroundColor: 'rgb(var(--art-bg-warning))'
|
||||
},
|
||||
message: {
|
||||
2: {
|
||||
icon: '',
|
||||
iconColor: 'rgb(var(--art-success))',
|
||||
backgroundColor: 'rgb(var(--art-bg-success))'
|
||||
},
|
||||
collection: {
|
||||
5: {
|
||||
icon: '',
|
||||
iconColor: 'rgb(var(--art-danger))',
|
||||
backgroundColor: 'rgb(var(--art-bg-danger))'
|
||||
},
|
||||
user: {
|
||||
3: {
|
||||
icon: '',
|
||||
iconColor: 'rgb(var(--art-info))',
|
||||
backgroundColor: 'rgb(var(--art-bg-info))'
|
||||
},
|
||||
notice: {
|
||||
1: {
|
||||
icon: '',
|
||||
iconColor: 'rgb(var(--art-primary))',
|
||||
backgroundColor: 'rgb(var(--art-bg-primary))'
|
||||
@@ -243,7 +225,11 @@
|
||||
backgroundColor: getRandomColor()
|
||||
}
|
||||
|
||||
const style = noticeStyleMap[type as keyof typeof noticeStyleMap] || defaultStyle
|
||||
// 先转成 unknown,再转成 keyof 类型,避免 TS 报错
|
||||
const key = type as unknown as keyof typeof noticeStyleMap
|
||||
const style = noticeStyleMap[key] || defaultStyle
|
||||
|
||||
// const style = noticeStyleMap[type as keyof typeof noticeStyleMap] || defaultStyle
|
||||
|
||||
return {
|
||||
...style,
|
||||
@@ -264,27 +250,78 @@
|
||||
}, 350)
|
||||
}
|
||||
}
|
||||
|
||||
const readAll = async () => {
|
||||
const { message } = await commonService.readNotice({
|
||||
msg_ids: 'all'
|
||||
})
|
||||
ElMessage.success(message)
|
||||
getNoticeList()
|
||||
}
|
||||
// 查看全部
|
||||
const handleViewAll = () => {
|
||||
switch (barActiveIndex.value) {
|
||||
case 0:
|
||||
handleNoticeAll()
|
||||
break
|
||||
case 1:
|
||||
handleMsgAll()
|
||||
break
|
||||
case 2:
|
||||
handlePendingAll()
|
||||
break
|
||||
}
|
||||
handleNoticeAll()
|
||||
}
|
||||
const noticeDetail = ref<any>({})
|
||||
const showfn = async (row: any) => {
|
||||
dialogVisible.value = true
|
||||
await commonService.getNoticeDetail({ id: row.id }).then((res: any) => {
|
||||
noticeDetail.value = res.message
|
||||
showNotice(false) // 关闭通知组件
|
||||
getNoticeList()
|
||||
})
|
||||
}
|
||||
const handleNoticeAll = () => {
|
||||
router.push('/common/notice')
|
||||
showNotice(false) // 关闭通知组件
|
||||
}
|
||||
|
||||
const handleNoticeAll = () => {}
|
||||
const handleMsgAll = () => {}
|
||||
const handlePendingAll = () => {}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use './style';
|
||||
.el-button {
|
||||
--el-button-border-color: #dcdfe6 !important;
|
||||
--el-button-text-color: var(--el-text-color-regular);
|
||||
}
|
||||
.message-text {
|
||||
padding: 10px 14px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-line;
|
||||
word-break: break-word;
|
||||
}
|
||||
.detail-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.section {
|
||||
background: var(--el-fill-color-blank);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr;
|
||||
gap: 6px 12px;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.value {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
.content-box {
|
||||
background: var(--el-fill-color-lighter);
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
<RouterView
|
||||
v-if="isRefresh"
|
||||
:key="$route.path"
|
||||
v-slot="{ Component, route }"
|
||||
:style="{ minHeight: containerMinHeight }"
|
||||
>
|
||||
@@ -15,8 +16,9 @@
|
||||
|
||||
<!-- 路由动画 -->
|
||||
<Transition :name="pageTransition" mode="out-in" appear>
|
||||
<!-- <KeepAlive :max="10" :include="['StockingAdd', 'StockingFinalAdd']"> -->
|
||||
<KeepAlive :max="10" :exclude="keepAliveExclude">
|
||||
<component :is="Component" :key="route.path" v-if="route.meta.keepAlive" />
|
||||
<component :is="Component" v-if="route.meta.keepAlive" />
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
|
||||
@@ -32,10 +34,11 @@
|
||||
import { useWorktabStore } from '@/store/modules/worktab'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
|
||||
// Store refs
|
||||
// Store refs1
|
||||
const { pageTransition, containerWidth, refresh } = storeToRefs(useSettingStore())
|
||||
const { keepAliveExclude } = storeToRefs(useWorktabStore())
|
||||
|
||||
|
||||
const { containerMinHeight } = useCommon()
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<div v-if="!isLock">
|
||||
<el-dialog v-model="visible" :width="370" :show-close="false" @open="handleDialogOpen">
|
||||
<div class="lock-content">
|
||||
<img class="cover" src="@imgs/user/avatar.webp" />
|
||||
<img
|
||||
class="cover"
|
||||
:src="getUserInfo.user.avatar ? getUserInfo.user.avatar : '@imgs/user/avatar.webp'"
|
||||
/>
|
||||
<div class="username">{{ userInfo.userName }}</div>
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" @submit.prevent="handleLock">
|
||||
<el-form-item prop="password">
|
||||
@@ -32,7 +35,10 @@
|
||||
|
||||
<div class="unlock-content" v-else>
|
||||
<div class="box">
|
||||
<img class="cover" src="@imgs/user/avatar.webp" />
|
||||
<img
|
||||
class="cover"
|
||||
:src="getUserInfo.user.avatar ? getUserInfo.user.avatar : '@imgs/user/avatar.webp'"
|
||||
/>
|
||||
<div class="username">{{ userInfo.userName }}</div>
|
||||
<el-form
|
||||
ref="unlockFormRef"
|
||||
@@ -77,7 +83,7 @@
|
||||
import { mittBus } from '@/utils/sys'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
|
||||
const { getUserInfo } = useUserStore()
|
||||
const ENCRYPT_KEY = import.meta.env.VITE_LOCK_ENCRYPT_KEY
|
||||
const userStore = useUserStore()
|
||||
const { info: userInfo, lockPassword, isLock } = storeToRefs(userStore)
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
v-loading="loading"
|
||||
:data="tableData"
|
||||
:row-key="rowKey"
|
||||
:row-class-name="rowClassName"
|
||||
:height="height"
|
||||
:max-height="maxHeight"
|
||||
:show-header="showHeader"
|
||||
@@ -123,6 +124,8 @@
|
||||
marginTop?: number
|
||||
/** 表格大小 */
|
||||
size?: 'small' | 'default' | 'large'
|
||||
/** 行样式类名函数,透传给 el-table 的 row-class-name */
|
||||
rowClassName?: (args: { row: any; rowIndex: number }) => string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TableProps>(), {
|
||||
@@ -146,7 +149,8 @@
|
||||
paginationSize: 'default',
|
||||
paginationLayout: '',
|
||||
showHeaderBackground: null,
|
||||
marginTop: 20
|
||||
marginTop: 20,
|
||||
rowClassName: undefined
|
||||
})
|
||||
|
||||
/*
|
||||
@@ -184,12 +188,11 @@
|
||||
const elTable = tableRef.value
|
||||
if (!elTable) return
|
||||
|
||||
// 递归处理树形数据
|
||||
// 支持普通与树形数据行展开
|
||||
const processRows = (rows: any[]) => {
|
||||
rows.forEach((row) => {
|
||||
const hasChildren = row.children?.length > 0
|
||||
if (hasChildren) {
|
||||
elTable.toggleRowExpansion(row, true)
|
||||
elTable.toggleRowExpansion(row, true)
|
||||
if (row.children?.length > 0) {
|
||||
processRows(row.children)
|
||||
}
|
||||
})
|
||||
@@ -203,12 +206,11 @@
|
||||
const elTable = tableRef.value
|
||||
if (!elTable) return
|
||||
|
||||
// 递归处理树形数据
|
||||
// 支持普通与树形数据行收起
|
||||
const processRows = (rows: any[]) => {
|
||||
rows.forEach((row) => {
|
||||
const hasChildren = row.children?.length > 0
|
||||
if (hasChildren) {
|
||||
elTable.toggleRowExpansion(row, false)
|
||||
elTable.toggleRowExpansion(row, false)
|
||||
if (row.children?.length > 0) {
|
||||
processRows(row.children)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<slot name="left"></slot>
|
||||
</div>
|
||||
<div class="right">
|
||||
<slot name="right"></slot>
|
||||
<div class="btn" @click="refresh">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
@@ -68,7 +69,6 @@
|
||||
}}</ElCheckbox>
|
||||
</div>
|
||||
</ElPopover>
|
||||
<slot name="right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { HOME_PAGE } from '@/router/routesAlias'
|
||||
// import { HOME_PAGE } from '@/router/routesAlias'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
const { home_page } = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
interface ExceptionData {
|
||||
/** 标题 */
|
||||
title: string
|
||||
@@ -33,7 +34,7 @@
|
||||
)
|
||||
|
||||
const backHome = () => {
|
||||
router.push(HOME_PAGE)
|
||||
router.push(home_page)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="approval-timeline">
|
||||
<div v-for="(rec, idx) in records" :key="idx" class="timeline-item">
|
||||
<div class="left">
|
||||
<div class="avatar-box">
|
||||
<span class="initial">{{ rec.name?.slice(0, 1) || '—' }}</span>
|
||||
<i v-if="rec.approved" class="status-dot iconfont-sys"></i>
|
||||
<i v-if="rec.rejected" class="status-dot rejected"></i>
|
||||
<i v-else-if="rec.pending" class="status-dot pending iconfont-sys"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<div class="row name-role">
|
||||
<span class="name">{{ rec.name }}</span>
|
||||
<span class="role">({{ rec.role }})</span>
|
||||
</div>
|
||||
<div >
|
||||
审核说明:{{ rec.comment }}
|
||||
</div>
|
||||
<div class="row time">{{ rec.time }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!records || !records.length" class="empty">暂无审批记录</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
interface ApprovalRecordItem {
|
||||
name?: string
|
||||
role?: string
|
||||
type?: string
|
||||
time?: string
|
||||
approved?: boolean
|
||||
rejected?: boolean
|
||||
pending?: boolean
|
||||
comment?: string
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ApprovalTimeline',
|
||||
props: {
|
||||
records: {
|
||||
type: Array as () => ApprovalRecordItem[],
|
||||
default: () => []
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 审批记录 时间线 */
|
||||
.approval-timeline {
|
||||
padding: 8px 4px 8px 0;
|
||||
}
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
}
|
||||
.timeline-item + .timeline-item {
|
||||
margin-top: 50px;
|
||||
}
|
||||
.left {
|
||||
width: 46px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
.avatar-box {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
background: #3d7bff;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
}
|
||||
.status-dot {
|
||||
position: absolute;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
font-size: 9px;
|
||||
padding: 3px 1px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.status-dot.pending {
|
||||
background: #3471ff;
|
||||
padding: 3px 3px;
|
||||
}
|
||||
.status-dot.rejected {
|
||||
background: #ff0000;
|
||||
padding: 3px 3px;
|
||||
}
|
||||
/* 连接下一个节点的虚线 */
|
||||
.timeline-item:not(:last-child) .left::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 52px; /* avatar 46px + 间距 6px */
|
||||
left: 23px; /* 盒子宽度一半,确保竖线居中 */
|
||||
width: 0;
|
||||
height: calc(100% + 16px); /* 加上相邻项之间的间距 */
|
||||
border-left: 2px dotted #bfbfbf;
|
||||
}
|
||||
.right {
|
||||
flex: 1;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.row.name-role {
|
||||
color: #222;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.role {
|
||||
color: #666;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.time {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.empty {
|
||||
color: #999;
|
||||
padding: 8px 0 4px 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="详情"
|
||||
width="800px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="payment-detail-dialog" v-loading="loading">
|
||||
<div v-if="paymentData" class="detail-content">
|
||||
<!-- 基本信息 -->
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
<span class="border"></span>
|
||||
<span class="title">基本信息</span>
|
||||
</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<label>创建日期:</label>
|
||||
<span>{{ formatDate(paymentData.created_at) }}</span>
|
||||
</div>
|
||||
<!-- <div class="info-item">
|
||||
<label>付款来源:</label>
|
||||
<span>{{ paymentData.country_name || '—' }}</span>
|
||||
</div> -->
|
||||
<div class="info-item">
|
||||
<label>汇款人:</label>
|
||||
<span>{{ paymentData.remitter || '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>账户:</label>
|
||||
<span>{{ paymentData.account_name || '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>币种:</label>
|
||||
<span>{{ paymentData.currency_name || '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>金额:</label>
|
||||
<span class="amount">{{ paymentData.amount || '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>外汇到账回单:</label>
|
||||
<span>
|
||||
<el-image
|
||||
v-if="paymentData.fx_receipt_file_full"
|
||||
:src="paymentData.fx_receipt_file_full"
|
||||
:preview-src-list="[paymentData.fx_receipt_file_full]"
|
||||
style="width: 100px; height: 100px"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关联信息 -->
|
||||
<div class="section" v-if="paymentData.payment_apply_id">
|
||||
<div class="section-title">
|
||||
<span class="border"></span>
|
||||
<span class="title">关联信息</span>
|
||||
</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<label>付款申请ID:</label>
|
||||
<span>{{ paymentData.payment_apply_id }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>阿里订单号/PI号:</label>
|
||||
<span>{{ paymentData.payment_apply.stock[0].pi_no }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>业务员:</label>
|
||||
<span>{{ paymentData.payment_apply.sales_user_name || '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>付款公司:</label>
|
||||
<span>{{ paymentData.payment_apply.account_name || '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>付款来源:</label>
|
||||
<span>{{ paymentData.payment_apply.country_name || '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>汇款人:</label>
|
||||
<span>{{ paymentData.payment_apply.remitter || '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>账户:</label>
|
||||
<span>{{ paymentData.payment_apply.account_short_desc || '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>币种:</label>
|
||||
<span>{{ paymentData.payment_apply.currency_name || '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>汇率:</label>
|
||||
<span>{{ paymentData.payment_apply.exchange_rate || '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>总金额:</label>
|
||||
<span>{{ paymentData.payment_apply.amount || '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>汇款行:</label>
|
||||
<span>{{ paymentData.payment_apply.swift || '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>客户付款水单:</label>
|
||||
<span>
|
||||
<el-image
|
||||
v-if="paymentData.payment_apply.voucher_file_full"
|
||||
:src="paymentData.payment_apply.voucher_file_full"
|
||||
:preview-src-list="[paymentData.payment_apply.voucher_file_full]"
|
||||
style="width: 100px; height: 100px"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>入账资料:</label>
|
||||
<div v-if="paymentData.payment_apply.submit_files.length > 0" class="files-list">
|
||||
<div
|
||||
v-for="(file, index) in paymentData.payment_apply.submit_files"
|
||||
:key="index"
|
||||
class="file-item"
|
||||
>
|
||||
<div v-if="isImageFile(file.file_full)" class="image-preview">
|
||||
<img
|
||||
:src="file.file_full"
|
||||
:alt="file.file_name"
|
||||
class="preview-image"
|
||||
@click="previewImage(file)"
|
||||
/>
|
||||
<div class="file-info">
|
||||
<span class="file-name">{{ file.file_name }}</span>
|
||||
<div class="file-actions">
|
||||
<!-- <el-button type="text" size="small" @click="previewImage(file)">预览</el-button> -->
|
||||
<el-button type="primary" size="small" @click="downloadFile(file)">下载</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="document-preview">
|
||||
<div class="file-icon">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
<div class="file-info">
|
||||
<span class="file-name">{{ file.file_name }}</span>
|
||||
<el-button type="primary" size="small" @click="downloadFile(file)">下载</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 收款记录关联 -->
|
||||
<div
|
||||
class="section"
|
||||
v-if="paymentData.receipt_records && paymentData.receipt_records.length"
|
||||
>
|
||||
<div class="section-title">
|
||||
<span class="border"></span>
|
||||
<span class="title">关联收款记录</span>
|
||||
</div>
|
||||
<el-table :data="paymentData.receipt_records" style="margin-top: 12px">
|
||||
<el-table-column label="收款记录ID" prop="id" min-width="120" />
|
||||
<el-table-column label="创建日期" prop="created_at" min-width="140">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" prop="amount" min-width="120">
|
||||
<template #default="scope">
|
||||
{{ formatAmount(scope.row.amount, scope.row.currency_name) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" prop="status" min-width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getStatusType(scope.row.status)">
|
||||
{{ getStatusText(scope.row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 备注信息 -->
|
||||
<div class="section" v-if="paymentData.remark">
|
||||
<div class="section-title">
|
||||
<span class="border"></span>
|
||||
<span class="title">备注信息</span>
|
||||
</div>
|
||||
<div class="remark-content">
|
||||
{{ paymentData.remark }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无数据状态 -->
|
||||
<div v-else-if="!loading" class="no-data">
|
||||
<i class="iconfont-sys"></i>
|
||||
<span>暂无数据</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { financeService } from '@/api/admin/finance'
|
||||
|
||||
interface PaymentRecord {
|
||||
id: string | number
|
||||
created_at: string
|
||||
country_name: string
|
||||
remitter: string
|
||||
account_name: string
|
||||
currency_name: string
|
||||
amount: number
|
||||
status: string
|
||||
payment_apply_id?: any
|
||||
receipt_records?: any[]
|
||||
payment_apply?: any
|
||||
remark?: string
|
||||
fx_receipt_file_full?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
paymentId?: string | number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const paymentData = ref<PaymentRecord | null>(null)
|
||||
|
||||
// 获取付款记录详情
|
||||
const getPaymentDetail = async () => {
|
||||
if (!props.paymentId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await financeService.getReceiptDetail({ id: props.paymentId })
|
||||
paymentData.value = res
|
||||
} catch (error) {
|
||||
console.error('获取付款记录详情失败:', error)
|
||||
paymentData.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
paymentData.value = null
|
||||
}
|
||||
|
||||
// 打印功能
|
||||
const handlePrint = () => {
|
||||
window.print()
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return '—'
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化金额
|
||||
const formatAmount = (amount: number, currency: string) => {
|
||||
return `${currency || ''} ${amount?.toLocaleString() || 0}`
|
||||
}
|
||||
|
||||
// 获取状态类型
|
||||
const getStatusType = (status: string): 'success' | 'info' | 'warning' | 'danger' | 'primary' => {
|
||||
const statusMap: Record<string, 'success' | 'info' | 'warning' | 'danger' | 'primary'> = {
|
||||
available: 'success',
|
||||
used: 'info',
|
||||
pending: 'warning',
|
||||
cancelled: 'danger',
|
||||
bound: 'success',
|
||||
unbound: 'info'
|
||||
}
|
||||
return statusMap[status] || 'info'
|
||||
}
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
const statusMap: Record<string, string> = {
|
||||
'available': '可用',
|
||||
'used': '已使用',
|
||||
'pending': '待处理',
|
||||
'cancelled': '已取消',
|
||||
'bound': '已绑定',
|
||||
'unbound': '未绑定'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
// 判断是否为图片文件
|
||||
const isImageFile = (file: any) => {
|
||||
// 检查文件名
|
||||
if (file.name && /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(file.name)) {
|
||||
return true
|
||||
}
|
||||
// 检查file_name字段
|
||||
if (file.file_name && file.file_name.startsWith('image/')) {
|
||||
return true
|
||||
}
|
||||
// 检查URL中的文件扩展名(去除查询参数)
|
||||
if (file.url) {
|
||||
const urlWithoutParams = file.url.split('?')[0] // 移除查询参数
|
||||
if (/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(urlWithoutParams)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 预览图片
|
||||
const previewImage = (file: any) => {
|
||||
// 创建图片预览弹窗
|
||||
const imagePreview = document.createElement('div')
|
||||
imagePreview.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
cursor: pointer;
|
||||
`
|
||||
const image = document.createElement('img')
|
||||
image.src = file.url
|
||||
image.alt = file.name
|
||||
image.style.cssText = `
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
`
|
||||
const closeBtn = document.createElement('div')
|
||||
closeBtn.innerHTML = '×'
|
||||
closeBtn.style.cssText = `
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 30px;
|
||||
color: white;
|
||||
font-size: 30px;
|
||||
cursor: pointer;
|
||||
z-index: 10000;
|
||||
`
|
||||
imagePreview.appendChild(image)
|
||||
imagePreview.appendChild(closeBtn)
|
||||
document.body.appendChild(imagePreview)
|
||||
// 点击关闭
|
||||
const closePreview = () => {
|
||||
document.body.removeChild(imagePreview)
|
||||
}
|
||||
imagePreview.addEventListener('click', closePreview)
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
closePreview()
|
||||
})
|
||||
// ESC键关闭
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
closePreview()
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
const downloadFile = async (file: any) => {
|
||||
try {
|
||||
// 对所有文件类型都使用 fetch 下载,确保真正的下载
|
||||
const response = await fetch(file.file_full, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: '*/*'
|
||||
}
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`下载失败: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = file.name || file.file_name || 'download'
|
||||
link.style.display = 'none'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
ElMessage.success('下载成功')
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误'
|
||||
ElMessage.error(`下载失败: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听对话框显示状态
|
||||
watch(visible, (newVal) => {
|
||||
if (newVal && props.paymentId) {
|
||||
getPaymentDetail()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听付款ID变化
|
||||
watch(
|
||||
() => props.paymentId,
|
||||
(newId) => {
|
||||
if (visible.value && newId) {
|
||||
getPaymentDetail()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.payment-detail-dialog {
|
||||
.detail-content {
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
|
||||
.border {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background: #3d7bff;
|
||||
margin-right: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
//padding: 8px 0;
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
min-width: 100px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #333;
|
||||
flex: 1;
|
||||
|
||||
&.amount {
|
||||
font-weight: 600;
|
||||
color: #3d7bff;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.remark-content {
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
min-height: 60px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 0;
|
||||
color: #999;
|
||||
|
||||
i {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
// 打印样式
|
||||
@media print {
|
||||
.dialog-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.payment-detail-dialog {
|
||||
.section-title {
|
||||
.border {
|
||||
background: #000 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,325 @@
|
||||
<template>
|
||||
<div class="file-preview-container">
|
||||
<div class="form-title">
|
||||
<span class="title">{{ title }}</span>
|
||||
</div>
|
||||
<div class="file-preview">
|
||||
<div v-if="files.length > 0" class="files-list">
|
||||
<div v-for="(file, index) in files" :key="index" class="file-item">
|
||||
<div v-if="isImageFile(file)" class="image-preview">
|
||||
<img
|
||||
:src="file.url"
|
||||
:alt="file.name"
|
||||
class="preview-image"
|
||||
@click="previewImage(file)"
|
||||
/>
|
||||
<div class="file-info">
|
||||
<span class="file-name">{{ file.name }}</span>
|
||||
<div class="file-actions">
|
||||
<!-- <el-button type="text" size="small" @click="previewImage(file)">预览</el-button> -->
|
||||
<el-button type="primary" size="small" @click="downloadFile(file)">下载</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="document-preview">
|
||||
<div class="file-icon">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
<div class="file-info">
|
||||
<span class="file-name">{{ file.name }}</span>
|
||||
<el-button type="text" size="small" @click="previewFile(file)">预览</el-button>
|
||||
<el-button type="primary" size="small" @click="downloadFile(file)">下载</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-file">
|
||||
<i class="iconfont-sys"></i>
|
||||
<span>{{ emptyText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<files-previews v-model="fileshow" :file="filepviews" @close="onClose" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import FilesPreviews from '@/components/custom/files_previews.vue'
|
||||
interface FileItem {
|
||||
name: string
|
||||
url: string
|
||||
file_name: string
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FilePreview',
|
||||
components: { FilesPreviews },
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
files: {
|
||||
type: Array as () => FileItem[],
|
||||
default: () => []
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: '暂无文件'
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
const fileshow = ref(false)
|
||||
const filepviews = ref({} as FileItem)
|
||||
function onClose() {
|
||||
fileshow.value = false
|
||||
console.log('文件预览已关闭')
|
||||
}
|
||||
// 判断是否为图片文件
|
||||
const isImageFile = (file: FileItem) => {
|
||||
// 检查文件名
|
||||
if (file.name && /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(file.name)) {
|
||||
return true
|
||||
}
|
||||
// 检查file_name字段
|
||||
if (file.file_name && file.file_name.startsWith('image/')) {
|
||||
return true
|
||||
}
|
||||
// 检查URL中的文件扩展名(去除查询参数)
|
||||
if (file.url) {
|
||||
const urlWithoutParams = file.url.split('?')[0] // 移除查询参数
|
||||
if (/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(urlWithoutParams)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 预览图片
|
||||
const previewImage = (file: FileItem) => {
|
||||
// 创建图片预览弹窗
|
||||
const imagePreview = document.createElement('div')
|
||||
imagePreview.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
cursor: pointer;
|
||||
`
|
||||
|
||||
const image = document.createElement('img')
|
||||
image.src = file.url
|
||||
image.alt = file.name
|
||||
image.style.cssText = `
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
`
|
||||
|
||||
const closeBtn = document.createElement('div')
|
||||
closeBtn.innerHTML = '×'
|
||||
closeBtn.style.cssText = `
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 30px;
|
||||
color: white;
|
||||
font-size: 30px;
|
||||
cursor: pointer;
|
||||
z-index: 10000;
|
||||
`
|
||||
imagePreview.appendChild(image)
|
||||
imagePreview.appendChild(closeBtn)
|
||||
document.body.appendChild(imagePreview)
|
||||
// 点击关闭
|
||||
const closePreview = () => {
|
||||
document.body.removeChild(imagePreview)
|
||||
}
|
||||
imagePreview.addEventListener('click', closePreview)
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
closePreview()
|
||||
})
|
||||
// ESC键关闭
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
closePreview()
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
const downloadFile = async (file: FileItem) => {
|
||||
try {
|
||||
// 对所有文件类型都使用 fetch 下载,确保真正的下载
|
||||
const response = await fetch(file.url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: '*/*'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`下载失败: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = file.name || file.file_name || 'download'
|
||||
link.style.display = 'none'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
ElMessage.success('下载成功')
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误'
|
||||
ElMessage.error(`下载失败: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
const previewFile = (file: FileItem) => {
|
||||
filepviews.value = file
|
||||
fileshow.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
fileshow,
|
||||
filepviews,
|
||||
previewFile,
|
||||
onClose,
|
||||
isImageFile,
|
||||
previewImage,
|
||||
downloadFile
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.file-preview-container {
|
||||
.form-title {
|
||||
font-weight: bold;
|
||||
color: #222;
|
||||
margin-bottom: 15px;
|
||||
|
||||
.title {
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.no-file {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.no-file i {
|
||||
font-size: 48px;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.files-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
flex: 0 0 auto;
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.file-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: 120px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
flex: 1;
|
||||
max-height: 70px;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.preview-image:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.document-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.file-icon i {
|
||||
font-size: 48px;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
padding: 8px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
// flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
@update:model-value="emit('update:visible', $event)"
|
||||
title="内勤专用"
|
||||
width="90%"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div style="margin-bottom: 12px; display: flex; align-items: center; gap: 12px">
|
||||
<span>显示币种:</span>
|
||||
<el-select v-model="selectedCurrency" placeholder="选择币种" style="width: 200px">
|
||||
<el-option
|
||||
v-for="opt in currencyOptions"
|
||||
:key="opt.currency_code"
|
||||
:label="opt.currency_name + ' (' + opt.currency_code + ')'"
|
||||
:value="opt.currency_code"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div>
|
||||
<el-table :data="displayData" style="width: 100%">
|
||||
<el-table-column label="编号" width="80" align="center">
|
||||
<template #default="scope">
|
||||
{{ scope.$index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="model" label="车型" min-width="160" />
|
||||
<el-table-column prop="qty" label="数量" width="80" />
|
||||
<el-table-column prop="clearance_price" label="报关价" min-width="140">
|
||||
<template #default="scope">{{ scope.row.clearance_price }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="fob_transport_fee" label="FOB运费" min-width="140">
|
||||
<template #default="scope">{{ scope.row.fob_transport_fee }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="car_price" label="车价" min-width="140">
|
||||
<template #default="scope">{{ scope.row.car_price }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ocean_freight" label="海运费" min-width="140">
|
||||
<template #default="scope">{{ scope.row.ocean_freight }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="commission" label="佣金" min-width="140">
|
||||
<template #default="scope">{{ scope.row.commission }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="retention_price" label="挂账" min-width="140">
|
||||
<template #default="scope">{{ scope.row.retention_price }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="port_misc_fee" label="港杂" min-width="140">
|
||||
<template #default="scope">{{ scope.row.port_misc_fee }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { commonService } from '@/api/commonApi'
|
||||
|
||||
defineOptions({
|
||||
name: 'InternalStaffView'
|
||||
})
|
||||
|
||||
interface CurrencyOption {
|
||||
currency_code: string
|
||||
currency_name: string
|
||||
rate?: number
|
||||
exchange_rate?: number
|
||||
}
|
||||
|
||||
interface VehicleData {
|
||||
model_snapshot?: string
|
||||
model?: string
|
||||
qty?: number
|
||||
clearance_price?: any
|
||||
clearance_price_currency?: string
|
||||
fob_transport_fee?: any
|
||||
fob_fee_currency?: string
|
||||
car_price?: any
|
||||
car_price_currency?: string
|
||||
ocean_freight?: any
|
||||
ocean_freight_currency?: string
|
||||
commission?: any
|
||||
commission_currency?: string
|
||||
retention_price?: any
|
||||
retention_price_currency?: string
|
||||
port_misc_fee?: any
|
||||
port_fee_currency?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
vehicleData: VehicleData[]
|
||||
defaultCurrency?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
}>()
|
||||
|
||||
const selectedCurrency = ref('')
|
||||
const currencyOptions = ref<CurrencyOption[]>([])
|
||||
|
||||
const loadCurrencyOptions = async () => {
|
||||
try {
|
||||
const res = await commonService.getCurrencySelect()
|
||||
currencyOptions.value = res || []
|
||||
} catch (e) {
|
||||
console.error('获取币种失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const rateOf = (c?: string) => {
|
||||
const found = (currencyOptions.value || []).find((x: any) => x.currency_code === c)
|
||||
return Number(found?.rate || found?.exchange_rate || 0) || 0
|
||||
}
|
||||
|
||||
const convert = (amount: any, fromCode?: string, toCode?: string): number => {
|
||||
const val = amount === undefined || amount === null || amount === '' ? 0 : Number(amount)
|
||||
if (Number.isNaN(val)) return 0
|
||||
const from = fromCode || 'USD'
|
||||
const to = toCode || 'USD'
|
||||
if (from === to) return val
|
||||
const rf = rateOf(from)
|
||||
const rt = rateOf(to) || 1
|
||||
if (!rf) return 0
|
||||
return (val * rf) / rt
|
||||
}
|
||||
|
||||
const fmt = (n: number, cur: string) => `${n.toFixed(2)} ${cur}`
|
||||
|
||||
const displayData = computed(() => {
|
||||
const code = selectedCurrency.value
|
||||
return (props.vehicleData || []).map((it: VehicleData) => {
|
||||
return {
|
||||
model: it.model_snapshot || it.model || '',
|
||||
qty: it.qty || 1,
|
||||
clearance_price: fmt(
|
||||
convert(
|
||||
it.clearance_price?.amount ?? it.clearance_price,
|
||||
it.clearance_price?.currency || it.clearance_price_currency,
|
||||
code
|
||||
),
|
||||
code
|
||||
),
|
||||
fob_transport_fee: fmt(
|
||||
convert(
|
||||
it.fob_transport_fee?.amount ?? it.fob_transport_fee,
|
||||
it.fob_transport_fee?.currency || it.fob_fee_currency || it.ocean_freight_currency,
|
||||
code
|
||||
),
|
||||
code
|
||||
),
|
||||
car_price: fmt(
|
||||
convert(
|
||||
it.car_price?.amount ?? it.car_price,
|
||||
it.car_price?.currency || it.car_price_currency,
|
||||
code
|
||||
),
|
||||
code
|
||||
),
|
||||
ocean_freight: fmt(
|
||||
convert(
|
||||
it.ocean_freight?.amount ?? it.ocean_freight,
|
||||
it.ocean_freight?.currency || it.ocean_freight_currency,
|
||||
code
|
||||
),
|
||||
code
|
||||
),
|
||||
commission: fmt(
|
||||
convert(
|
||||
it.commission?.amount ?? it.commission,
|
||||
it.commission?.currency || it.commission_currency,
|
||||
code
|
||||
),
|
||||
code
|
||||
),
|
||||
retention_price: fmt(
|
||||
convert(
|
||||
it.retention_price?.amount ?? it.retention_price,
|
||||
it.retention_price?.currency || it.retention_price_currency,
|
||||
code
|
||||
),
|
||||
code
|
||||
),
|
||||
port_misc_fee: fmt(
|
||||
convert(
|
||||
it.port_misc_fee?.amount ?? it.port_misc_fee,
|
||||
it.port_misc_fee?.currency || it.port_fee_currency,
|
||||
code
|
||||
),
|
||||
code
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 监听 visible 变化,加载币种选项
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVisible) => {
|
||||
if (newVisible) {
|
||||
loadCurrencyOptions()
|
||||
// 设置默认币种
|
||||
if (props.defaultCurrency) {
|
||||
selectedCurrency.value = props.defaultCurrency
|
||||
} else {
|
||||
selectedCurrency.value = 'USD'
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div style="margin-bottom: 12px; display: flex; align-items: center; gap: 12px">
|
||||
<span>显示币种:</span>
|
||||
<el-select v-model="selectedCurrency" placeholder="选择币种" style="width: 200px">
|
||||
<el-option
|
||||
v-for="opt in currencyOptions"
|
||||
:key="opt.currency_code"
|
||||
:label="opt.currency_name + ' (' + opt.currency_code + ')'"
|
||||
:value="opt.currency_code"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div>
|
||||
<el-table :data="displayData" style="width: 100%">
|
||||
<el-table-column label="编号" width="80" align="center">
|
||||
<template #default="scope">
|
||||
{{ scope.$index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="model" label="车型" min-width="160" />
|
||||
<el-table-column prop="qty" label="数量" width="80" />
|
||||
<el-table-column prop="clearance_price" label="报关价" min-width="140">
|
||||
<template #default="scope">{{ scope.row.clearance_price }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="fob_transport_fee" label="FOB运费" min-width="140">
|
||||
<template #default="scope">{{ scope.row.fob_transport_fee }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="car_price" label="车价" min-width="140">
|
||||
<template #default="scope">{{ scope.row.car_price }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ocean_freight" label="海运费" min-width="140">
|
||||
<template #default="scope">{{ scope.row.ocean_freight }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="commission" label="佣金" min-width="140">
|
||||
<template #default="scope">{{ scope.row.commission }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="retention_price" label="挂账" min-width="140">
|
||||
<template #default="scope">{{ scope.row.retention_price }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="port_misc_fee" label="港杂" min-width="140">
|
||||
<template #default="scope">{{ scope.row.port_misc_fee }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { commonService } from '@/api/commonApi'
|
||||
|
||||
defineOptions({
|
||||
name: 'InternalStaffViews'
|
||||
})
|
||||
|
||||
interface CurrencyOption {
|
||||
currency_code: string
|
||||
currency_name: string
|
||||
rate?: number
|
||||
exchange_rate?: number
|
||||
}
|
||||
|
||||
interface VehicleData {
|
||||
model_snapshot?: string
|
||||
model?: string
|
||||
qty?: number
|
||||
clearance_price?: any
|
||||
clearance_price_currency?: string
|
||||
fob_transport_fee?: any
|
||||
fob_fee_currency?: string
|
||||
car_price?: any
|
||||
car_price_currency?: string
|
||||
ocean_freight?: any
|
||||
ocean_freight_currency?: string
|
||||
commission?: any
|
||||
commission_currency?: string
|
||||
retention_price?: any
|
||||
retention_price_currency?: string
|
||||
port_misc_fee?: any
|
||||
port_fee_currency?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
vehicleData: VehicleData[]
|
||||
defaultCurrency?: string
|
||||
}>()
|
||||
|
||||
// const emit = defineEmits<{
|
||||
// 'update:visible': [value: boolean]
|
||||
// }>()
|
||||
|
||||
const selectedCurrency = ref('')
|
||||
const currencyOptions = ref<CurrencyOption[]>([])
|
||||
|
||||
const loadCurrencyOptions = async () => {
|
||||
try {
|
||||
const res = await commonService.getCurrencySelect()
|
||||
currencyOptions.value = res || []
|
||||
} catch (e) {
|
||||
console.error('获取币种失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const rateOf = (c?: string) => {
|
||||
const found = (currencyOptions.value || []).find((x: any) => x.currency_code === c)
|
||||
return Number(found?.rate || found?.exchange_rate || 0) || 0
|
||||
}
|
||||
|
||||
const convert = (amount: any, fromCode?: string, toCode?: string): number => {
|
||||
const val = amount === undefined || amount === null || amount === '' ? 0 : Number(amount)
|
||||
if (Number.isNaN(val)) return 0
|
||||
const from = fromCode || 'USD'
|
||||
const to = toCode || 'USD'
|
||||
if (from === to) return val
|
||||
const rf = rateOf(from)
|
||||
const rt = rateOf(to) || 1
|
||||
if (!rf) return 0
|
||||
return (val * rf) / rt
|
||||
}
|
||||
|
||||
const fmt = (n: number, cur: string) => `${n.toFixed(2)} ${cur}`
|
||||
|
||||
const displayData = computed(() => {
|
||||
const code = selectedCurrency.value
|
||||
return (props.vehicleData || []).map((it: VehicleData) => {
|
||||
return {
|
||||
model: it.model_snapshot || it.model || '',
|
||||
qty: it.qty || 1,
|
||||
clearance_price: fmt(
|
||||
convert(
|
||||
it.clearance_price?.amount ?? it.clearance_price,
|
||||
it.clearance_price?.currency || it.clearance_price_currency,
|
||||
code
|
||||
),
|
||||
code
|
||||
),
|
||||
fob_transport_fee: fmt(
|
||||
convert(
|
||||
it.fob_transport_fee?.amount ?? it.fob_transport_fee,
|
||||
it.fob_transport_fee?.currency || it.fob_fee_currency || it.ocean_freight_currency,
|
||||
code
|
||||
),
|
||||
code
|
||||
),
|
||||
car_price: fmt(
|
||||
convert(
|
||||
it.car_price?.amount ?? it.car_price,
|
||||
it.car_price?.currency || it.car_price_currency,
|
||||
code
|
||||
),
|
||||
code
|
||||
),
|
||||
ocean_freight: fmt(
|
||||
convert(
|
||||
it.ocean_freight?.amount ?? it.ocean_freight,
|
||||
it.ocean_freight?.currency || it.ocean_freight_currency,
|
||||
code
|
||||
),
|
||||
code
|
||||
),
|
||||
commission: fmt(
|
||||
convert(
|
||||
it.commission?.amount ?? it.commission,
|
||||
it.commission?.currency || it.commission_currency,
|
||||
code
|
||||
),
|
||||
code
|
||||
),
|
||||
retention_price: fmt(
|
||||
convert(
|
||||
it.retention_price?.amount ?? it.retention_price,
|
||||
it.retention_price?.currency || it.retention_price_currency,
|
||||
code
|
||||
),
|
||||
code
|
||||
),
|
||||
port_misc_fee: fmt(
|
||||
convert(
|
||||
it.port_misc_fee?.amount ?? it.port_misc_fee,
|
||||
it.port_misc_fee?.currency || it.port_fee_currency,
|
||||
code
|
||||
),
|
||||
code
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
loadCurrencyOptions()
|
||||
// 设置默认币种
|
||||
if (props.defaultCurrency) {
|
||||
selectedCurrency.value = props.defaultCurrency
|
||||
} else {
|
||||
selectedCurrency.value = 'USD'
|
||||
}
|
||||
// 监听 visible 变化,加载币种选项
|
||||
// watch(
|
||||
// () => props.visible,
|
||||
// (newVisible) => {
|
||||
// if (newVisible) {
|
||||
// loadCurrencyOptions()
|
||||
// // 设置默认币种
|
||||
// if (props.defaultCurrency) {
|
||||
// selectedCurrency.value = props.defaultCurrency
|
||||
// } else {
|
||||
// selectedCurrency.value = 'USD'
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
</script>
|
||||
@@ -0,0 +1,732 @@
|
||||
<template>
|
||||
<div class="order-detail-content">
|
||||
<!-- 基本信息 -->
|
||||
<div class="basic-info-section">
|
||||
<div class="basic-info-item">
|
||||
<span class="label">备货单编号:</span>
|
||||
<span class="value">{{ orderData.order_no || orderData.orderNo || '—' }}</span>
|
||||
</div>
|
||||
<div class="basic-info-item">
|
||||
<span class="label">业务员:</span>
|
||||
<span class="value">{{
|
||||
orderData.salesperson_snapshot || orderData.salesperson || '—'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 客户信息 -->
|
||||
<div class="customer-info-table">
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<td>
|
||||
<span class="info-label"> 阿里订单号/PI号:</span>
|
||||
<span class="info-value">{{ orderData.pi_no || orderData.piNo || '—' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="info-label"> 日期:</span>
|
||||
<span class="info-value">{{ orderData.created_at || orderData.date || '—' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="info-label"> 清关目的国:</span>
|
||||
<span class="info-value">{{
|
||||
orderData.clearance_country_snapshot || orderData.clearanceCountry || '—'
|
||||
}}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="info-label"> 客户来源:</span>
|
||||
<span class="info-value">{{
|
||||
orderData.customer_source_snapshot || orderData.customerSource || '—'
|
||||
}}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="info-label"> 客户名称:</span>
|
||||
<span class="info-value">{{
|
||||
orderData.customer_snapshot || orderData.customerName || '—'
|
||||
}}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="info-label"> 客户所在国:</span>
|
||||
<span class="info-value">{{
|
||||
orderData.customer_country_snapshot || orderData.customerCountry || '—'
|
||||
}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 订单详情表格(ArtTable) -->
|
||||
<div class="table-section">
|
||||
<ArtTable ref="tableRef" :data="vehItems" border :pagination="false">
|
||||
<template #default>
|
||||
<ElTableColumn type="expand" width="30">
|
||||
<template #default="scope">
|
||||
<div style="padding: 6px">
|
||||
<div style="display: flex">
|
||||
<div><strong>备注:</strong></div>
|
||||
<div><span v-html="scope.row.remark || '暂无备注'"></span></div>
|
||||
</div>
|
||||
<div
|
||||
v-if="getItemAttachments(scope.row).length"
|
||||
style="display: flex; margin-top: 6px"
|
||||
>
|
||||
<div><strong>附件:</strong></div>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 12px">
|
||||
<a
|
||||
v-for="(f, aIdx) in getItemAttachments(scope.row)"
|
||||
:key="aIdx"
|
||||
:href="f.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
style="color: #3471ff; text-decoration: none"
|
||||
>
|
||||
<i class="iconfont-sys"></i> {{ f.name }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="编号" width="60" align="center">
|
||||
<template #default="scope">{{ scope.$index + 1 }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="车型" width="160" align="center">
|
||||
<template #default="scope">
|
||||
<div>{{ scope.row.model_snapshot || scope.row.model || '—' }}</div>
|
||||
<div v-if="scope.row.mileage > 0">里程:{{ scope.row.mileage }}km</div>
|
||||
<div v-if="scope.row.vin && scope.row.vin != ''">VIN: {{ scope.row.vin }}</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="数量/台" width="90" align="center">
|
||||
<template #default="scope">{{ scope.row.num ?? scope.row.quantity ?? 1 }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="颜色" width="120" align="center">
|
||||
<template #default="scope">
|
||||
<div> 外饰:{{ scope.row.color_exterior_snapshot || '—' }} </div>
|
||||
<div> 内饰:{{ scope.row.color_interior_snapshot || '—' }} </div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="费用" min-width="200" align="center">
|
||||
<template #default="scope">
|
||||
<ElRow :gutter="12" class="item-row-wrap">
|
||||
<ElCol :span="8" class="item-row">
|
||||
<span>官方指导价/二手挂牌价:</span>
|
||||
<span v-if="scope.row.mileage > 0" class="price-num">
|
||||
{{
|
||||
scope.row.listing_price?.currency_info ||
|
||||
scope.row.listing_price?.amount ||
|
||||
scope.row.discount ||
|
||||
'—'
|
||||
}}
|
||||
</span>
|
||||
<span v-else class="price-num">
|
||||
{{
|
||||
scope.row.official_price?.currency_info ||
|
||||
scope.row.official_price?.amount ||
|
||||
scope.row.discount ||
|
||||
'—'
|
||||
}}
|
||||
</span>
|
||||
</ElCol>
|
||||
<ElCol :span="8" class="item-row">
|
||||
<span>优惠:</span>
|
||||
<span v-if="scope.row.mileage > 0" class="price-num">
|
||||
{{
|
||||
scope.row.reduction_price?.currency_info ||
|
||||
scope.row.reduction_price?.amount ||
|
||||
scope.row.discount ||
|
||||
'—'
|
||||
}}
|
||||
</span>
|
||||
<span v-else class="price-num">
|
||||
{{
|
||||
scope.row.discount_price?.currency_info ||
|
||||
scope.row.discount_price?.amount ||
|
||||
scope.row.discount ||
|
||||
'—'
|
||||
}}
|
||||
</span>
|
||||
</ElCol>
|
||||
<ElCol :span="8" class="item-row">
|
||||
<span>手续费:</span>
|
||||
<span class="price-num">
|
||||
{{
|
||||
scope.row.handling_fee?.currency_info ||
|
||||
scope.row.handling_fee?.amount ||
|
||||
scope.row.discount ||
|
||||
'—'
|
||||
}}
|
||||
</span>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<ElRow :gutter="12" class="item-row-wrap">
|
||||
<ElCol :span="8" class="item-row">
|
||||
<span>加价:</span>
|
||||
<span class="price-num">
|
||||
{{
|
||||
scope.row.increase_price?.currency_info ||
|
||||
scope.row.increase_price?.amount ||
|
||||
scope.row.discount ||
|
||||
'—'
|
||||
}}
|
||||
</span>
|
||||
</ElCol>
|
||||
<ElCol :span="8" class="item-row">
|
||||
<span>改装费:</span>
|
||||
<span class="price-num">
|
||||
{{
|
||||
scope.row.retrofit_fee?.currency_info ||
|
||||
scope.row.retrofit_fee?.amount ||
|
||||
scope.row.discount ||
|
||||
'—'
|
||||
}}
|
||||
</span>
|
||||
</ElCol>
|
||||
<ElCol :span="8" class="item-row">
|
||||
<span>配件费:</span>
|
||||
<span class="price-num">
|
||||
{{
|
||||
scope.row.accessory_price?.currency_info ||
|
||||
scope.row.accessory_price?.amount ||
|
||||
scope.row.discount ||
|
||||
'—'
|
||||
}}
|
||||
</span>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<ElRow :gutter="12" class="item-row-wrap">
|
||||
<ElCol :span="8" class="item-row">
|
||||
<span>FOB运费:</span>
|
||||
<span class="price-num">
|
||||
{{
|
||||
scope.row.fob_transport_fee?.currency_info ||
|
||||
scope.row.fob_transport_fee?.amount ||
|
||||
scope.row.discount ||
|
||||
'—'
|
||||
}}
|
||||
</span>
|
||||
</ElCol>
|
||||
<ElCol :span="8" class="item-row">
|
||||
<span>港杂费:</span>
|
||||
<span class="price-num">
|
||||
{{
|
||||
scope.row.port_misc_fee?.currency_info ||
|
||||
scope.row.port_misc_fee?.amount ||
|
||||
scope.row.discount ||
|
||||
'—'
|
||||
}}
|
||||
</span>
|
||||
</ElCol>
|
||||
<ElCol :span="8" class="item-row">
|
||||
<span>海运费:</span>
|
||||
<span class="price-num">
|
||||
{{
|
||||
scope.row.ocean_freight?.currency_info ||
|
||||
scope.row.ocean_freight?.amount ||
|
||||
scope.row.discount ||
|
||||
'—'
|
||||
}}
|
||||
</span>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<ElRow :gutter="12" class="item-row-wrap">
|
||||
<ElCol :span="8" class="item-row">
|
||||
<span>佣金:</span>
|
||||
<span class="price-num">
|
||||
{{
|
||||
scope.row.commission?.currency_info ||
|
||||
scope.row.commission?.amount ||
|
||||
scope.row.discount ||
|
||||
'—'
|
||||
}}
|
||||
</span>
|
||||
</ElCol>
|
||||
<ElCol :span="8" class="item-row">
|
||||
<span>挂账:</span>
|
||||
<span class="price-num">
|
||||
{{
|
||||
scope.row.retention_price?.currency_info ||
|
||||
scope.row.retention_price?.amount ||
|
||||
scope.row.discount ||
|
||||
'—'
|
||||
}}
|
||||
</span>
|
||||
</ElCol>
|
||||
<ElCol :span="8" class="item-row">
|
||||
<span>购置税:</span>
|
||||
<span class="price-num">
|
||||
{{
|
||||
scope.row.purchase_tax?.currency_info ||
|
||||
scope.row.purchase_tax?.amount ||
|
||||
scope.row.discount ||
|
||||
'—'
|
||||
}}
|
||||
</span>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<ElRow :gutter="12" class="item-row-wrap">
|
||||
<ElCol :span="8" class="item-row">
|
||||
<span>预估退税额:</span>
|
||||
<span class="price-num">
|
||||
{{ scope.row.tax_refund?.currency_info || scope.row.tax_refund?.amount || '—' }}
|
||||
</span>
|
||||
</ElCol>
|
||||
<ElCol :span="8" class="item-row">
|
||||
<span>车价:</span>
|
||||
<span class="price-num">
|
||||
{{ scope.row.car_price?.currency_info || scope.row.car_price?.amount || '—' }}
|
||||
</span>
|
||||
</ElCol>
|
||||
<ElCol :span="8" class="item-row">
|
||||
<span>收款价:</span>
|
||||
<span class="price-num">
|
||||
{{
|
||||
scope.row.unit_price?.currency_info ||
|
||||
scope.row.unit_price?.amount ||
|
||||
scope.row.discount ||
|
||||
'—'
|
||||
}}
|
||||
</span>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<ElRow :gutter="12" class="item-row-wrap">
|
||||
<ElCol :span="8" class="item-row">
|
||||
<span>总价:</span>
|
||||
<span class="price-num">
|
||||
{{
|
||||
scope.row.total_price?.currency_info ||
|
||||
scope.row.total_price?.amount ||
|
||||
scope.row.discount ||
|
||||
'—'
|
||||
}}
|
||||
</span>
|
||||
</ElCol>
|
||||
<ElCol :span="8" class="item-row">
|
||||
<span>报关价:</span>
|
||||
<span class="price-num">
|
||||
{{
|
||||
scope.row.clearance_price?.currency_info ||
|
||||
scope.row.clearance_price?.amount ||
|
||||
scope.row.clearance_price ||
|
||||
'—'
|
||||
}}
|
||||
</span>
|
||||
</ElCol>
|
||||
<ElCol :span="8" class="item-row"></ElCol>
|
||||
</ElRow>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn fixed="right" label="利润" width="150" align="center">
|
||||
<template #default="scope">{{
|
||||
scope.row.profit?.currency_info ||
|
||||
scope.row.profit?.amount ||
|
||||
scope.row.discount ||
|
||||
'—'
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
</template>
|
||||
</ArtTable>
|
||||
<div class="order-summary">
|
||||
<div class="summary-item">
|
||||
<span class="label">合同汇率:</span>
|
||||
<span class="value">{{
|
||||
orderData.contract_exchange_rate || orderData.contractRate || '—'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="label">合计到岸价:</span>
|
||||
<span class="value"> {{ orderData.cif_total?.currency_info || '—' }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="label">利润:</span>
|
||||
<span class="value">{{
|
||||
orderData.profit_total?.currency_info || orderData.profit_total?.amount || '—'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 物流信息 -->
|
||||
<div class="customer-info-table">
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<td>
|
||||
<span class="info-label"> 清关人:</span>
|
||||
<span class="info-value">{{
|
||||
orderData.declarant_name || orderData.declarant || '—'
|
||||
}}</span>
|
||||
</td>
|
||||
<td v-if="orderData.is_final == 0">
|
||||
<span class="info-label"> 预计发运日期:</span>
|
||||
<span class="info-value">{{
|
||||
orderData.eta_ship_date || orderData.etaShipDate || '—'
|
||||
}}</span>
|
||||
</td>
|
||||
<td v-else>
|
||||
<span class="info-label"> 全款收齐日:</span>
|
||||
<span class="info-value">{{ orderData.collect_all_date || '—' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="info-label"> 出运方式:</span>
|
||||
<span class="info-value">{{
|
||||
orderData.shipping_method_snapshot || orderData.shippingMethod || '—'
|
||||
}}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="info-label"> 国内收货港:</span>
|
||||
<span class="info-value">{{
|
||||
orderData.domestic_port_snapshot || orderData.domesticPort || '—'
|
||||
}}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="info-label"> 贸易条款:</span>
|
||||
<span class="info-value">{{
|
||||
orderData.trade_term_snapshot || orderData.tradeTerms || '—'
|
||||
}}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="info-label"> 清关目的港:</span>
|
||||
<span class="info-value">{{ orderData.clearance_port_snapshot || '—' }}</span>
|
||||
</td>
|
||||
<td v-if="orderData.is_final == 0">
|
||||
<span class="info-label"> 最迟到港时间:</span>
|
||||
<span class="info-value">{{
|
||||
orderData.latest_arrival_date || orderData.latestArrival || '—'
|
||||
}}</span>
|
||||
</td>
|
||||
<td v-else>
|
||||
<span class="info-label"> 提车日期:</span>
|
||||
<span class="info-value">{{ orderData.pick_up_date || '—' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 计划收款信息 -->
|
||||
<div
|
||||
class="payment-section"
|
||||
v-if="
|
||||
(orderData.account_list && orderData.account_list.length) ||
|
||||
(orderData.payments && orderData.payments.length)
|
||||
"
|
||||
>
|
||||
<div class="table-wrapper">
|
||||
<table class="payment-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 140px"></th>
|
||||
<th>序号</th>
|
||||
<th>汇款国</th>
|
||||
<th>日期</th>
|
||||
<th>币种</th>
|
||||
<th>账户</th>
|
||||
<th>汇款人</th>
|
||||
<th>金额</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in paymentTableData" :key="index">
|
||||
<td v-if="index === 0" :rowspan="paymentTableData.length">计划收款信息</td>
|
||||
<td>{{ row.serial }}</td>
|
||||
<td>{{ row.country }}</td>
|
||||
<td>{{ row.date }}</td>
|
||||
<td>{{ row.currency }}</td>
|
||||
<td>{{ row.account }}</td>
|
||||
<td>{{ row.remitter }}</td>
|
||||
<td>{{ row.amount }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, ref, onMounted, nextTick, watch } from 'vue'
|
||||
export default defineComponent({
|
||||
name: 'OrderDetailContent',
|
||||
props: {
|
||||
orderData: {
|
||||
type: Object as () => any,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const paymentTableData = computed(() => {
|
||||
const list = (props.orderData as any).account_list
|
||||
? (props.orderData as any).account_list.map((p: any, idx: number) => ({
|
||||
group: '计划收款信息',
|
||||
serial: `收款${idx + 1}`,
|
||||
country: p.remit_country_name_snapshot || '—',
|
||||
date: p.remit_date || '—',
|
||||
currency: p.currency_name_snapshot || '—',
|
||||
account: p.account_name_snapshot || '—',
|
||||
remitter: p.remitter_name || '—',
|
||||
amount: p.amount || '—'
|
||||
}))
|
||||
: (props.orderData as any).payments || []
|
||||
if (!list.length) return []
|
||||
return list
|
||||
})
|
||||
|
||||
const formatPrice = (price?: number): string => {
|
||||
if (price === undefined || price === null) return '0.00'
|
||||
return Number(price).toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
})
|
||||
}
|
||||
|
||||
const vehItems = computed(() => {
|
||||
return (props.orderData as any).veh_list || (props.orderData as any).items || []
|
||||
})
|
||||
|
||||
// 车辆附件(后端返回数组;兼容历史字符串数据)
|
||||
const getItemAttachments = (row: any) => {
|
||||
const raw = row?.attachment
|
||||
let list: any[] = []
|
||||
if (Array.isArray(raw)) {
|
||||
list = raw
|
||||
} else if (typeof raw === 'string' && raw.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) list = parsed
|
||||
} catch {
|
||||
list = []
|
||||
}
|
||||
}
|
||||
return list
|
||||
.filter((f: any) => f && f.url)
|
||||
.map((f: any) => ({ name: f.original_name || f.name || '附件', url: f.url }))
|
||||
}
|
||||
const tableRef = ref()
|
||||
const expandWhenReady = () => {
|
||||
nextTick(() => {
|
||||
nextTick(() => {
|
||||
if (vehItems.value && vehItems.value.length) {
|
||||
tableRef.value?.expandAll?.()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 默认展开所有行(等数据和表格渲染完成后再触发)
|
||||
expandWhenReady()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => vehItems.value?.length,
|
||||
(len) => {
|
||||
if (len && len > 0) expandWhenReady()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
return {
|
||||
paymentTableData,
|
||||
vehItems,
|
||||
formatPrice,
|
||||
tableRef,
|
||||
getItemAttachments
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.order-detail-content {
|
||||
font-family: 'Microsoft YaHei', Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.basic-info-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 40px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.basic-info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-right: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.value {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.customer-info-table {
|
||||
margin: 20px 0px;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
td {
|
||||
padding: 12px 12px;
|
||||
border: 1px solid #d9d9d9;
|
||||
font-size: 13px;
|
||||
}
|
||||
.info-value {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
text-align: right;
|
||||
}
|
||||
.info-label {
|
||||
color: #666;
|
||||
padding-right: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-section {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.order-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
font-size: 13px;
|
||||
|
||||
th {
|
||||
background: #f5f7fa;
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
padding: 12px 8px;
|
||||
text-align: center;
|
||||
border: 1px solid #d9d9d9;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 8px;
|
||||
text-align: center;
|
||||
border: 1px solid #d9d9d9;
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.col-no {
|
||||
width: 60px;
|
||||
}
|
||||
.col-model {
|
||||
max-width: 160px;
|
||||
// text-align: left;
|
||||
}
|
||||
tbody tr:hover {
|
||||
background: #e6f7ff;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-section {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* 订单底部汇总样式 */
|
||||
.order-summary {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.order-summary .summary-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
// background: #fff;
|
||||
// border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.order-summary .label {
|
||||
color: #666;
|
||||
}
|
||||
.order-summary .value {
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
/* ArtTable 表头背景色 */
|
||||
.table-section :deep(.el-table__header th) {
|
||||
background-color: #f5f7fa;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
.table-section :deep(.el-table__header .cell) {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
.payment-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
font-size: 13px;
|
||||
}
|
||||
.payment-table th {
|
||||
background: #f5f7fa;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
border: 1px solid #d9d9d9;
|
||||
padding: 12px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.payment-table td {
|
||||
text-align: center;
|
||||
color: #333;
|
||||
border: 1px solid #d9d9d9;
|
||||
padding: 10px 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.el-table__expand-cell {
|
||||
padding: 0;
|
||||
border-top: none;
|
||||
}
|
||||
.el-table__expand-wrapper {
|
||||
padding: 10px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.item-row-wrap {
|
||||
margin: 0 !important;
|
||||
}
|
||||
.item-row-wrap .item-row {
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 5px 0;
|
||||
// min-height: 40px;
|
||||
text-align: left;
|
||||
}
|
||||
.price-num {
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
margin-left: 5px;
|
||||
}
|
||||
/* 打印优化 */
|
||||
@media print {
|
||||
.order-detail-content {
|
||||
font-size: 12px;
|
||||
}
|
||||
.order-table {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,380 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="付款记录详情"
|
||||
width="800px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="payment-detail-dialog" v-loading="loading">
|
||||
<div v-if="paymentData" class="detail-content">
|
||||
<!-- 基本信息 -->
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
<span class="border"></span>
|
||||
<span class="title">基本信息</span>
|
||||
</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<label>创建日期:</label>
|
||||
<span>{{ formatDate(paymentData.created_at) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>付款来源:</label>
|
||||
<span>{{ paymentData.country_name || '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>汇款人:</label>
|
||||
<span>{{ paymentData.remitter || '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>账户:</label>
|
||||
<span>{{ paymentData.account_name || '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>币种:</label>
|
||||
<span>{{ paymentData.currency_name || '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>金额:</label>
|
||||
<span class="amount">{{ paymentData.amount }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>付款水单:</label>
|
||||
<div>
|
||||
<p v-for="(item, index) in paymentData.voucher_file" :key="index">
|
||||
<el-image
|
||||
v-if="
|
||||
/\.(png|jpe?g|gif|webp|bmp|svg)(\?|$)/i.test(
|
||||
(item.file_full || '').split('?')[0]
|
||||
)
|
||||
"
|
||||
:src="item.file_full"
|
||||
:preview-src-list="item.file_full ? [item.file_full] : []"
|
||||
style="width: 100px; height: 100px"
|
||||
/>
|
||||
<a
|
||||
v-else
|
||||
:href="item.file_full"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
style="
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 200px;
|
||||
"
|
||||
>{{ item.file_name }}</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关联信息 -->
|
||||
<div class="section" v-if="paymentData.payment_apply_id">
|
||||
<div class="section-title">
|
||||
<span class="border"></span>
|
||||
<span class="title">关联信息</span>
|
||||
</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<label>付款申请ID:</label>
|
||||
<span>{{ paymentData.payment_apply_id }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>关联状态:</label>
|
||||
<el-tag :type="getStatusType(paymentData.status)">
|
||||
{{ getStatusText(paymentData.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 收款记录关联 -->
|
||||
<div
|
||||
class="section"
|
||||
v-if="paymentData.receipt_records && paymentData.receipt_records.length"
|
||||
>
|
||||
<div class="section-title">
|
||||
<span class="border"></span>
|
||||
<span class="title">关联收款记录</span>
|
||||
</div>
|
||||
<el-table :data="paymentData.receipt_records" style="margin-top: 12px">
|
||||
<el-table-column label="收款记录ID" prop="id" min-width="120" />
|
||||
<el-table-column label="创建日期" prop="created_at" min-width="140">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" prop="amount" min-width="120">
|
||||
<template #default="scope">
|
||||
{{ formatAmount(scope.row.amount, scope.row.currency_name) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" prop="status" min-width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getStatusType(scope.row.status)">
|
||||
{{ getStatusText(scope.row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 备注信息 -->
|
||||
<div class="section" v-if="paymentData.remark">
|
||||
<div class="section-title">
|
||||
<span class="border"></span>
|
||||
<span class="title">备注信息</span>
|
||||
</div>
|
||||
<div class="remark-content">
|
||||
{{ paymentData.remark }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无数据状态 -->
|
||||
<div v-else-if="!loading" class="no-data">
|
||||
<i class="iconfont-sys"></i>
|
||||
<span>暂无数据</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="dialog-footer">
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
</div> -->
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { financeService } from '@/api/admin/finance'
|
||||
|
||||
interface PaymentRecord {
|
||||
id: string | number
|
||||
created_at: string
|
||||
country_name: string
|
||||
remitter: string
|
||||
account_name: string
|
||||
currency_name: string
|
||||
amount: number
|
||||
status: string
|
||||
payment_apply_id?: string | number
|
||||
receipt_records?: any[]
|
||||
voucher_file?: any[]
|
||||
submit_files?: string[]
|
||||
remark?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
paymentId?: string | number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const paymentData = ref<PaymentRecord | null>(null)
|
||||
|
||||
// 获取付款记录详情
|
||||
const getPaymentDetail = async () => {
|
||||
if (!props.paymentId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await financeService.getPaymentDetail({ id: props.paymentId })
|
||||
paymentData.value = res
|
||||
} catch (error) {
|
||||
console.error('获取付款记录详情失败:', error)
|
||||
paymentData.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
paymentData.value = null
|
||||
}
|
||||
|
||||
// 打印功能
|
||||
const handlePrint = () => {
|
||||
window.print()
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return '—'
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化金额
|
||||
const formatAmount = (amount: number, currency: string) => {
|
||||
return `${currency || ''} ${amount?.toLocaleString() || 0}`
|
||||
}
|
||||
|
||||
// 获取状态类型
|
||||
const getStatusType = (status: string): 'success' | 'info' | 'warning' | 'danger' | 'primary' => {
|
||||
const statusMap: Record<string, 'success' | 'info' | 'warning' | 'danger' | 'primary'> = {
|
||||
available: 'success',
|
||||
used: 'info',
|
||||
pending: 'warning',
|
||||
cancelled: 'danger',
|
||||
bound: 'success',
|
||||
unbound: 'info'
|
||||
}
|
||||
return statusMap[status] || 'info'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
const statusMap: Record<string, string> = {
|
||||
'available': '可用',
|
||||
'used': '已使用',
|
||||
'pending': '待处理',
|
||||
'cancelled': '已取消',
|
||||
'bound': '已绑定',
|
||||
'unbound': '未绑定'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
// 监听对话框显示状态
|
||||
watch(visible, (newVal) => {
|
||||
if (newVal && props.paymentId) {
|
||||
getPaymentDetail()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听付款ID变化
|
||||
watch(() => props.paymentId, (newId) => {
|
||||
if (visible.value && newId) {
|
||||
getPaymentDetail()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.payment-detail-dialog {
|
||||
.detail-content {
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
|
||||
.border {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background: #3d7bff;
|
||||
margin-right: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
// padding: 8px 0;
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
min-width: 100px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #333;
|
||||
flex: 1;
|
||||
|
||||
&.amount {
|
||||
font-weight: 600;
|
||||
color: #3d7bff;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.remark-content {
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
min-height: 60px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 0;
|
||||
color: #999;
|
||||
|
||||
i {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
// 打印样式
|
||||
@media print {
|
||||
.dialog-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.payment-detail-dialog {
|
||||
.section-title {
|
||||
.border {
|
||||
background: #000 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="选择付款记录"
|
||||
width="80%"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="payment-record-dialog">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="请输入汇款人、账户或金额"
|
||||
clearable
|
||||
style="width: 300px"
|
||||
@keyup.enter="getPaymentRecords"
|
||||
>
|
||||
<template #suffix>
|
||||
<el-icon class="clickable" @click="getPaymentRecords"><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="getPaymentRecords">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 付款记录表格 -->
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
:data="paymentRecords"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-click="handleRowClick"
|
||||
highlight-current-row
|
||||
style="margin-top: 16px"
|
||||
>
|
||||
<el-table-column label="创建日期" prop="created_at" min-width="160" />
|
||||
<el-table-column label="付款来源" prop="country_name" min-width="120" />
|
||||
<el-table-column label="汇款人" prop="remitter" min-width="120" />
|
||||
<el-table-column label="账户" prop="account_name" min-width="120" />
|
||||
<el-table-column label="账币" prop="currency_name" min-width="120" />
|
||||
<el-table-column label="金额" prop="amount" min-width="120">
|
||||
<template #default="scope">
|
||||
{{ formatAmount(scope.row.amount, scope.row.currency_name) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="选择" width="80">
|
||||
<template #default="scope">
|
||||
<el-checkbox
|
||||
v-model="checkboxStates[scope.row.id]"
|
||||
@change="(val) => handleCheckboxChange(scope.row, !!val)"
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.currentPage"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :disabled="!selectedRecord" @click="handleConfirm">
|
||||
确认绑定
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { financeService } from '@/api/admin/finance'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
interface PaymentRecord {
|
||||
id: string | number
|
||||
created_at: string
|
||||
country_name: string
|
||||
remitter: string
|
||||
account_name: string
|
||||
currency_name: string
|
||||
amount: number
|
||||
status: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
receiptId?: string | number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm', record: PaymentRecord): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
// const paymentRecords = ref<PaymentRecord[]>([])
|
||||
const paymentRecords = ref<any[]>([])
|
||||
const selectedRecord = ref<PaymentRecord | null>(null)
|
||||
// const selectedRecord = ref<any[]>([])
|
||||
const selectedRecordId = ref<any>(null)
|
||||
const checkboxStates = reactive<Record<string | number, boolean>>({})
|
||||
const tableRef = ref()
|
||||
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 获取付款记录列表
|
||||
const getPaymentRecords = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
current: pagination.currentPage,
|
||||
size: pagination.pageSize,
|
||||
keyword: searchKeyword.value,
|
||||
scene: 'correlation',
|
||||
receipt_id: props.receiptId,
|
||||
correlation: 'unbind' // 只显示可用的付款记录
|
||||
}
|
||||
|
||||
const { records, current, size, total } = await financeService.getPaymentList(params)
|
||||
paymentRecords.value = records || []
|
||||
// 初始化 checkbox 状态
|
||||
records?.forEach((record: any) => {
|
||||
checkboxStates[record.id] = false
|
||||
})
|
||||
Object.assign(pagination, { currentPage: current, pageSize: size, total })
|
||||
} catch (error) {
|
||||
console.error('获取付款记录失败:', error)
|
||||
ElMessage.error('获取付款记录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理选择变化(保留兼容性,单选模式下不使用此方法)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const handleSelectionChange = (_selection: PaymentRecord[]) => {
|
||||
// 单选模式下不使用此方法
|
||||
}
|
||||
|
||||
// 处理 checkbox 变化(单选逻辑)
|
||||
const handleCheckboxChange = (record: PaymentRecord, checked: boolean) => {
|
||||
if (checked) {
|
||||
// 先清除所有选中状态(单选逻辑)
|
||||
Object.keys(checkboxStates).forEach((id) => {
|
||||
checkboxStates[id] = false
|
||||
})
|
||||
// 选中当前项
|
||||
checkboxStates[record.id] = true
|
||||
selectedRecord.value = record
|
||||
selectedRecordId.value = record.id
|
||||
} else {
|
||||
// 取消选中
|
||||
selectedRecord.value = null
|
||||
selectedRecordId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 处理行点击
|
||||
const handleRowClick = (record: PaymentRecord) => {
|
||||
// 如果点击的是已选中的项,则取消选中;否则选中该项
|
||||
if (selectedRecordId.value === record.id) {
|
||||
// 取消选中
|
||||
selectedRecord.value = null
|
||||
selectedRecordId.value = null
|
||||
checkboxStates[record.id] = false
|
||||
} else {
|
||||
// 先清除所有选中状态
|
||||
Object.keys(checkboxStates).forEach((id) => {
|
||||
checkboxStates[id] = false
|
||||
})
|
||||
// 选中当前项
|
||||
selectedRecord.value = record
|
||||
selectedRecordId.value = record.id
|
||||
checkboxStates[record.id] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 分页处理
|
||||
const handleSizeChange = (size: number) => {
|
||||
pagination.pageSize = size
|
||||
pagination.currentPage = 1
|
||||
getPaymentRecords()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (page: number) => {
|
||||
pagination.currentPage = page
|
||||
getPaymentRecords()
|
||||
}
|
||||
|
||||
// 确认绑定
|
||||
const handleConfirm = () => {
|
||||
if (!selectedRecord.value) {
|
||||
ElMessage.warning('请选择一条付款记录')
|
||||
return
|
||||
}
|
||||
emit('confirm', selectedRecord.value)
|
||||
handleClose()
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
selectedRecord.value = null
|
||||
selectedRecordId.value = null
|
||||
Object.keys(checkboxStates).forEach((id) => {
|
||||
checkboxStates[id] = false
|
||||
})
|
||||
searchKeyword.value = ''
|
||||
pagination.currentPage = 1
|
||||
}
|
||||
|
||||
// 格式化金额显示
|
||||
const formatAmount = (amount: number, currency: string) => {
|
||||
return `${currency} ${amount?.toLocaleString() || 0}`
|
||||
}
|
||||
|
||||
// 获取状态类型(暂未使用)
|
||||
// const getStatusType = (status: string) => {
|
||||
// const statusMap: Record<string, string> = {
|
||||
// available: 'success',
|
||||
// used: 'info',
|
||||
// pending: 'warning',
|
||||
// cancelled: 'danger'
|
||||
// }
|
||||
// return statusMap[status] || 'info'
|
||||
// }
|
||||
|
||||
// 获取状态文本(暂未使用)
|
||||
// const getStatusText = (status: string) => {
|
||||
// const statusMap: Record<string, string> = {
|
||||
// available: '可用',
|
||||
// used: '已使用',
|
||||
// pending: '待处理',
|
||||
// cancelled: '已取消'
|
||||
// }
|
||||
// return statusMap[status] || status
|
||||
// }
|
||||
|
||||
// 监听对话框显示状态
|
||||
watch(visible, (newVal) => {
|
||||
if (newVal) {
|
||||
getPaymentRecords()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.payment-record-dialog {
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="print-preview-container">
|
||||
<div class="preview-header">
|
||||
<div class="header-actions">
|
||||
<el-button @click="download" v-if="showDownload">下载</el-button>
|
||||
<el-button @click="print" type="primary">打印</el-button>
|
||||
<el-button @click="close">关闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-content">
|
||||
<div
|
||||
v-if="pdf.images.length"
|
||||
class="images-container"
|
||||
>
|
||||
<img
|
||||
v-for="(img, idx) in pdf.images"
|
||||
:key="idx"
|
||||
:src="img"
|
||||
class="preview-image"
|
||||
/>
|
||||
</div>
|
||||
<iframe
|
||||
v-else-if="pdf.blobUrl || pdf.url"
|
||||
ref="frameRef"
|
||||
:src="pdf.blobUrl || pdf.url"
|
||||
class="preview-iframe"
|
||||
@load="onIframeLoad"
|
||||
@error="onIframeError"
|
||||
/>
|
||||
<div v-else class="no-content">
|
||||
<p>没有可预览的内容</p>
|
||||
<p>传入的 PDF URL: {{ pdfUrl }}</p>
|
||||
<p>PDF Object URL: {{ pdf.url }}</p>
|
||||
<p>PDF Blob URL: {{ pdf.blobUrl }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, watch } from 'vue'
|
||||
import { usePdfPreview } from '@/composables/usePdfPreview'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PrintPreview',
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showDownload: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
pdfUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['close'],
|
||||
setup(props, { emit }) {
|
||||
// 使用公共的 usePdfPreview
|
||||
const { pdf, frameRef, openWithResponse, download, print, preparePreview, prepareImages } = usePdfPreview()
|
||||
|
||||
// 监听 visible 变化,当显示时加载 PDF
|
||||
watch(() => props.visible, (newVisible) => {
|
||||
console.log('PrintPreview visible 变化:', newVisible)
|
||||
if (newVisible && props.pdfUrl) {
|
||||
// 构造响应对象,模拟 API 返回格式
|
||||
const response = {
|
||||
url: props.pdfUrl,
|
||||
file_url: props.pdfUrl,
|
||||
file_url_full: props.pdfUrl
|
||||
}
|
||||
openWithResponse(response)
|
||||
console.log('PrintPreview 设置 PDF URL:', props.pdfUrl)
|
||||
}
|
||||
})
|
||||
|
||||
// 监听 pdfUrl 变化
|
||||
watch(() => props.pdfUrl, (newUrl) => {
|
||||
console.log('PrintPreview pdfUrl 变化:', newUrl)
|
||||
if (newUrl) {
|
||||
// 构造响应对象,模拟 API 返回格式
|
||||
const response = {
|
||||
url: newUrl,
|
||||
file_url: newUrl,
|
||||
file_url_full: newUrl
|
||||
}
|
||||
openWithResponse(response)
|
||||
console.log('PrintPreview 更新 PDF URL:', newUrl)
|
||||
}
|
||||
})
|
||||
|
||||
const close = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// iframe 加载事件
|
||||
const onIframeLoad = () => {
|
||||
console.log('iframe 加载完成')
|
||||
}
|
||||
|
||||
// iframe 错误事件
|
||||
const onIframeError = (error: any) => {
|
||||
console.error('iframe 加载错误:', error)
|
||||
}
|
||||
|
||||
return {
|
||||
frameRef,
|
||||
pdf,
|
||||
download,
|
||||
print,
|
||||
close,
|
||||
onIframeLoad,
|
||||
onIframeError
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.print-preview-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
background: #fafafa;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.images-container {
|
||||
overflow: auto;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.preview-iframe {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.no-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
|
||||
p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 打印样式
|
||||
@media print {
|
||||
.print-preview-container {
|
||||
max-height: none;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.images-container {
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
margin-bottom: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.preview-iframe {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div class="detail-wrap">
|
||||
<div class="detail-grid">
|
||||
<div class="item">
|
||||
<span class="label">采购单号:</span>
|
||||
<span class="value">{{ detailData.order_no || '—' }}</span>
|
||||
</div>
|
||||
<div class="item">
|
||||
<span class="label">PI号:</span>
|
||||
<span class="value">{{ detailData.pi_no || '—' }}</span>
|
||||
</div>
|
||||
<div class="item">
|
||||
<span class="label">提交人:</span>
|
||||
<span class="value">{{ detailData.buyer_name || '—' }}</span>
|
||||
</div>
|
||||
<div class="item full">
|
||||
<span class="label">备注:</span>
|
||||
<span class="value">{{ detailData.remark || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-block">
|
||||
<div class="block-title">合同文件</div>
|
||||
<div
|
||||
v-if="detailData.contract_file_url && detailData.contract_file_url.length"
|
||||
class="files-list"
|
||||
>
|
||||
<div class="file-row" v-for="(f, idx) in detailData.contract_file_url" :key="idx">
|
||||
<el-icon class="file-icon"><Document /></el-icon>
|
||||
<span class="file-name">{{ f.name }}</span>
|
||||
<a class="el-link el-link--primary" :href="f.url" target="_blank" rel="noopener">查看</a>
|
||||
<a class="el-link el-link--info" :href="f.url" :download="f.name">下载</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-tip">无合同文件</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-block">
|
||||
<div class="block-title">备货单组</div>
|
||||
<div v-if="detailData.stock_order && detailData.stock_order.length" class="groups">
|
||||
<div class="group" v-for="(g, gi) in detailData.stock_order" :key="gi">
|
||||
<div class="group-head">
|
||||
<span class="order">{{ g.order_no || '备货单' }}</span>
|
||||
<span class="seller" v-if="g.salesperson_snapshot"
|
||||
>({{ g.salesperson_snapshot }})</span
|
||||
>
|
||||
<span class="date" v-if="g.biz_date">{{ g.biz_date }}</span>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip" v-for="(v, vi) in g.stock_order_items || []" :key="vi">
|
||||
{{ v.model_snapshot }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-tip">无备货单数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Document } from '@element-plus/icons-vue'
|
||||
import { PropType } from 'vue'
|
||||
|
||||
// 定义组件的数据类型
|
||||
interface DetailData {
|
||||
order_no?: string
|
||||
pi_no?: string
|
||||
buyer_name?: string
|
||||
remark?: string
|
||||
contract_file_url?: Array<{ name: string; url: string }>
|
||||
stock_order?: Array<{
|
||||
order_no?: string
|
||||
salesperson_snapshot?: string
|
||||
biz_date?: string
|
||||
stock_order_items?: Array<{ model_snapshot: string }>
|
||||
}>
|
||||
}
|
||||
|
||||
// 使用 Vue 的 props 传递数据
|
||||
export default {
|
||||
name: 'ProcurementDetailView',
|
||||
props: {
|
||||
detailData: {
|
||||
type: Object as PropType<DetailData>,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.detail-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px 16px;
|
||||
}
|
||||
.detail-grid .item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.detail-grid .item.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.detail-grid .label {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
.detail-grid .value {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.detail-block {
|
||||
border-top: 1px dashed var(--el-border-color-lighter);
|
||||
padding-top: 8px;
|
||||
}
|
||||
.block-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.files-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.file-name {
|
||||
flex: 1;
|
||||
}
|
||||
.empty-tip {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 38vh;
|
||||
overflow-y: auto;
|
||||
gap: 12px;
|
||||
}
|
||||
.group {
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 10px;
|
||||
padding: 14px 6px 8px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
.group-head {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
align-content: center;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.chip {
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
background: #f0f6ff;
|
||||
color: #3a7af3;
|
||||
border: 1px solid #e3edff;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="detail-wrap">
|
||||
<div class="detail-grid">
|
||||
<div class="item">
|
||||
<span class="label">采购单号:</span>
|
||||
<span class="value">{{ detailData.order_no || '—' }}</span>
|
||||
</div>
|
||||
<div class="item">
|
||||
<span class="label">PI号:</span>
|
||||
<span class="value">{{ detailData.pi_no || '—' }}</span>
|
||||
</div>
|
||||
<div class="item">
|
||||
<span class="label">提交人:</span>
|
||||
<span class="value">{{ detailData.buyer_name || '—' }}</span>
|
||||
</div>
|
||||
<div class="item full">
|
||||
<span class="label">备注:</span>
|
||||
<span class="value">{{ detailData.remark || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-block">
|
||||
<div class="block-title">合同文件</div>
|
||||
<div
|
||||
v-if="detailData.contract_file_url && detailData.contract_file_url.length"
|
||||
class="files-list"
|
||||
>
|
||||
<div class="file-row" v-for="(f, idx) in detailData.contract_file_url" :key="idx">
|
||||
<el-icon class="file-icon"><Document /></el-icon>
|
||||
<span class="file-name">{{ f.name }}</span>
|
||||
<a class="el-link el-link--primary" :href="f.url" target="_blank" rel="noopener">查看</a>
|
||||
<a class="el-link el-link--info" :href="f.url" :download="f.name">下载</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-tip">无合同文件</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-block">
|
||||
<div class="block-title">备货单组</div>
|
||||
<div v-if="detailData.stock_order && detailData.stock_order.length" class="groups">
|
||||
<div class="group" v-for="(g, gi) in detailData.stock_order" :key="gi">
|
||||
<div class="group-head">
|
||||
<span class="order">{{ g.order_no || '备货单' }}</span>
|
||||
<span class="seller" v-if="g.salesperson_snapshot"
|
||||
>({{ g.salesperson_snapshot }})</span
|
||||
>
|
||||
<span class="date" v-if="g.biz_date">{{ g.biz_date }}</span>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip" v-for="(v, vi) in g.stock_order_items || []" :key="vi">
|
||||
{{ v.model_snapshot }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-tip">无备货单数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Document } from '@element-plus/icons-vue'
|
||||
import { PropType } from 'vue'
|
||||
interface DetailData {
|
||||
order_no?: string
|
||||
pi_no?: string
|
||||
buyer_name?: string
|
||||
remark?: string
|
||||
contract_file_url?: Array<{ name: string; url: string }>
|
||||
stock_order?: Array<{
|
||||
order_no?: string
|
||||
salesperson_snapshot?: string
|
||||
biz_date?: string
|
||||
stock_order_items?: Array<{ model_snapshot: string }>
|
||||
}>
|
||||
}
|
||||
|
||||
defineProps({
|
||||
detailData: {
|
||||
type: Object as PropType<DetailData>,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.detail-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px 16px;
|
||||
}
|
||||
.detail-grid .item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.detail-grid .item.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.detail-grid .label {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
.detail-grid .value {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.detail-block {
|
||||
border-top: 1px dashed var(--el-border-color-lighter);
|
||||
padding-top: 8px;
|
||||
}
|
||||
.block-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.files-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.file-name {
|
||||
flex: 1;
|
||||
}
|
||||
.empty-tip {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 38vh;
|
||||
overflow-y: auto;
|
||||
gap: 12px;
|
||||
}
|
||||
.group {
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 10px;
|
||||
padding: 14px 6px 8px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
.group-head {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
align-content: center;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.chip {
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
background: #f0f6ff;
|
||||
color: #3a7af3;
|
||||
border: 1px solid #e3edff;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div class="approval-timeline">
|
||||
<div v-for="(rec, idx) in records" :key="idx" class="timeline-item">
|
||||
<div class="left">
|
||||
<div class="avatar-box">
|
||||
<span class="initial">{{ rec.updated_by?.slice(0, 1) || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<div class="row name-role">
|
||||
<span class="name">编辑人:{{ rec.updated_by }}</span>
|
||||
</div>
|
||||
<div class="row time">{{ rec.updated_at }}</div>
|
||||
<div class="card-box">
|
||||
<el-card>
|
||||
<h4>汇款人:{{ rec.remitter }}</h4>
|
||||
<p
|
||||
><span>付款来源:{{ rec.payment_apply_id }}</span>
|
||||
<span>账户:{{ rec.account_short_desc }}</span>
|
||||
</p>
|
||||
<p
|
||||
><span>金额:{{ rec.amount }} </span><span>币种:{{ rec.currency_name }}</span></p
|
||||
>
|
||||
<p
|
||||
><span>折让金额:{{ rec.discount_amount }}</span
|
||||
><span>实际汇率:{{ rec.exchange_rate }}</span>
|
||||
</p>
|
||||
<p>总金额(人民币)实际汇率:{{ rec.total_amount_cny }}</p>
|
||||
<p>收款时间:{{ rec.received_at }}</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface ApprovalRecordItem {
|
||||
account_id?: string
|
||||
account_name?: string
|
||||
amount?: string
|
||||
arrived_at?: string
|
||||
country_id?: string
|
||||
country_name?: string
|
||||
created_at?: string
|
||||
created_by?: string
|
||||
currency_id?: string
|
||||
currency_name?: string
|
||||
discount_amount?: string
|
||||
exchange_rate?: string
|
||||
fx_receipt_file_full?: string
|
||||
account_short_desc?: string
|
||||
id?: string
|
||||
is_domestic_return?: string
|
||||
payment_apply_id?: string
|
||||
receipt_id?: string
|
||||
receipt_no?: string
|
||||
received_at?: string
|
||||
remitter?: string
|
||||
total_amount_cny?: string
|
||||
updated_at?: string
|
||||
updated_by?: string
|
||||
voucher_no?: string
|
||||
}
|
||||
|
||||
defineProps<{ records: ApprovalRecordItem[] }>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 审批记录 时间线 */
|
||||
.approval-timeline {
|
||||
padding: 8px 4px 8px 0;
|
||||
max-height: 600px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
}
|
||||
.timeline-item + .timeline-item {
|
||||
margin-top: 50px;
|
||||
}
|
||||
.left {
|
||||
width: 46px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
.avatar-box {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
background: #3d7bff;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
}
|
||||
.status-dot {
|
||||
position: absolute;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
font-size: 9px;
|
||||
padding: 3px 1px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.status-dot.pending {
|
||||
background: #3471ff;
|
||||
padding: 3px 3px;
|
||||
}
|
||||
.status-dot.rejected {
|
||||
background: #ff0000;
|
||||
padding: 3px 3px;
|
||||
}
|
||||
/* 连接下一个节点的虚线 */
|
||||
.timeline-item:not(:last-child) .left::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 52px; /* avatar 46px + 间距 6px */
|
||||
left: 23px; /* 盒子宽度一半,确保竖线居中 */
|
||||
width: 0;
|
||||
height: calc(100% + 116px); /* 加上相邻项之间的间距 */
|
||||
border-left: 2px dotted #bfbfbf;
|
||||
}
|
||||
.right {
|
||||
flex: 1;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.row.name-role {
|
||||
color: #222;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.role {
|
||||
color: #666;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.time {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.empty {
|
||||
color: #999;
|
||||
padding: 8px 0 4px 4px;
|
||||
}
|
||||
.card-box p{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<el-drawer :with-header="false" :model-value="modelValue" :size="isMobile ? '100%' : '480px'" @close="$emit('update:modelValue', false)">
|
||||
<div class="chat-container">
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<span class="name">{{ title }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-icon class="icon-close" :size="20" @click="$emit('update:modelValue', false)">
|
||||
<Close />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-messages" ref="messageContainer">
|
||||
<template v-for="(message, index) in messages" :key="index">
|
||||
<div :class="['message-item', String(message.user_id) === String(currentUserId) ? 'message-right' : 'message-left']">
|
||||
<el-avatar :size="32" :src="message.user_avatar" class="message-avatar" />
|
||||
<div class="message-content">
|
||||
<div class="message-info">
|
||||
<span class="sender-name">{{ message.user_name }}</span>
|
||||
<span class="message-time">{{ message.updated_at }}</span>
|
||||
</div>
|
||||
<div class="message-text" v-html="message.content"></div>
|
||||
<div class="message-attachments" v-if="message.attachment && message.attachment.length > 0">
|
||||
<div class="attachment-item" v-for="(attachment, attIndex) in message.attachment" :key="attIndex">
|
||||
<template v-if="/\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(attachment.original_name)">
|
||||
<img :src="attachment.storage_path_full" :alt="attachment.original_name" class="attachment-thumb" @click="handleChatImageClick(attachment.storage_path_full)" />
|
||||
</template>
|
||||
<template v-else-if="/\.(mp4|webm|ogg)$/i.test(attachment.original_name)">
|
||||
<video :src="attachment.storage_path_full" controls class="attachment-thumb" @click.prevent="handleChatVideoClick(attachment.storage_path_full)"></video>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="file-attachment">
|
||||
<el-icon class="file-icon"><Document /></el-icon>
|
||||
<span class="file-name">{{ attachment.original_name }}</span>
|
||||
<el-button type="primary" size="small" link @click="$emit('download', attachment)">下载</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="chat-input" v-if="canAdd">
|
||||
<div class="chat-attachment-upload">
|
||||
<el-upload v-model:file-list="chatFileList" class="chat-upload" :action="actionImageUplode" :on-success="handleChatUploadSuccess" :headers="uploadHeaders" multiple :limit="5" :on-exceed="handleChatExceed" :show-file-list="false">
|
||||
<el-button type="primary" link>
|
||||
<el-icon><Paperclip /></el-icon>
|
||||
添加附件
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<div class="uploaded-attachments" v-if="chatFileList.length > 0">
|
||||
<div class="attachment-preview-item" v-for="(file, idx) in chatFileList" :key="idx">
|
||||
<template v-if="/\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(file.name)">
|
||||
<img :src="file.file_url || file.response?.data?.file_url" :alt="file.name" class="preview-thumb" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-icon class="file-icon"><Document /></el-icon>
|
||||
</template>
|
||||
<span class="file-name">{{ file.name }}</span>
|
||||
<el-button type="danger" size="small" link @click="removeChatFile(idx)">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-input v-model="messageText" type="textarea" :rows="3" placeholder="输入消息" resize="none" />
|
||||
<div class="chat-input-actions">
|
||||
<el-button type="primary" @click="emitSend">发送</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内置预览 -->
|
||||
<ElImageViewer v-if="imageViewerVisible" :url-list="chatImageList" :initial-index="imageViewerIndex" @close="closeImageViewer" />
|
||||
<ElDialog v-model="videoDialogVisible" width="60%" top="10vh" :show-close="true" append-to-body>
|
||||
<video v-if="currentVideoUrl" :src="currentVideoUrl" controls autoplay style="width: 100%; max-height: 70vh; display: block"></video>
|
||||
</ElDialog>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElImageViewer } from 'element-plus'
|
||||
import { Paperclip, Document, Close } from '@element-plus/icons-vue'
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
title?: string
|
||||
messages: any[]
|
||||
currentUserId: string | number
|
||||
canAdd?: boolean
|
||||
actionImageUplode?: string
|
||||
uploadHeaders?: Record<string, any>
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'send', payload: { text: string; attachments: any[] }): void
|
||||
(e: 'download', attachment: any): void
|
||||
}>()
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const isMobile = computed(() => width.value < 500)
|
||||
|
||||
const messageText = ref('')
|
||||
const chatFileList = ref<any[]>([])
|
||||
const messageContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.messages,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
if (messageContainer.value) {
|
||||
messageContainer.value.scrollTop = messageContainer.value.scrollHeight
|
||||
}
|
||||
})
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
const handleChatUploadSuccess = (response: any, uploadFile: any, uploadFiles: any[]) => {
|
||||
chatFileList.value = uploadFiles
|
||||
.filter((item) => item.status === 'success')
|
||||
.map((item) => {
|
||||
const data = item.response?.data
|
||||
if (data) {
|
||||
return {
|
||||
name: data.original_name,
|
||||
file_url: data.storage_path_full,
|
||||
status: 'success',
|
||||
response: { data }
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter(Boolean) as any[]
|
||||
}
|
||||
const handleChatExceed = () => {
|
||||
// no-op; parent can handle toast
|
||||
}
|
||||
const removeChatFile = (index: number) => {
|
||||
chatFileList.value.splice(index, 1)
|
||||
}
|
||||
const emitSend = () => {
|
||||
const text = messageText.value.trim()
|
||||
if (!text && chatFileList.value.length === 0) return
|
||||
const attachments = chatFileList.value.map((file) => {
|
||||
const data = file.response?.data
|
||||
if (data) {
|
||||
return {
|
||||
original_name: data.original_name,
|
||||
storage_path: data.storage_path || data.file_name
|
||||
}
|
||||
}
|
||||
return null
|
||||
}).filter(Boolean) as any[]
|
||||
emit('send', { text, attachments })
|
||||
messageText.value = ''
|
||||
chatFileList.value = []
|
||||
}
|
||||
|
||||
// 预览
|
||||
const chatImageList = ref<string[]>([])
|
||||
const imageViewerVisible = ref(false)
|
||||
const imageViewerIndex = ref(0)
|
||||
function handleChatImageClick(url: string) {
|
||||
chatImageList.value = [url]
|
||||
imageViewerIndex.value = 0
|
||||
imageViewerVisible.value = true
|
||||
}
|
||||
const currentVideoUrl = ref('')
|
||||
const videoDialogVisible = ref(false)
|
||||
function handleChatVideoClick(url: string) {
|
||||
currentVideoUrl.value = url
|
||||
videoDialogVisible.value = true
|
||||
}
|
||||
function closeImageViewer() {
|
||||
imageViewerVisible.value = false
|
||||
chatImageList.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
.header-left .name { font-size: 16px; font-weight: 500; }
|
||||
.header-right .icon-close { cursor: pointer; }
|
||||
}
|
||||
.chat-container { display: flex; flex-direction: column; height: calc(100% - 70px); }
|
||||
.chat-messages { flex: 1; padding: 30px 16px; overflow-y: auto; border-top: 1px solid var(--el-border-color-lighter); }
|
||||
.message-item { display: flex; gap: 8px; align-items: flex-start; width: 100%; margin-bottom: 30px; }
|
||||
.message-item.message-left { justify-content: flex-start; }
|
||||
.message-item.message-right { flex-direction: row-reverse; }
|
||||
.message-content { display: flex; flex-direction: column; max-width: 70%; }
|
||||
.message-info { display: flex; gap: 8px; margin-bottom: 4px; font-size: 12px; }
|
||||
.message-text { font-size: 14px; color: var(--art-gray-900); border-radius: 6px; padding: 10px 14px; line-height: 1.4; white-space: pre-line; }
|
||||
.message-left .message-text { background-color: #f8f5ff; }
|
||||
.message-right .message-text { background-color: #e9f3ff; }
|
||||
.message-attachments { margin-top: 8px; }
|
||||
.message-attachments .attachment-thumb { max-width: 200px; max-height: 150px; border-radius: 6px; cursor: pointer; border: 1px solid var(--el-border-color-lighter); }
|
||||
.chat-input { padding: 16px 16px 0; }
|
||||
.chat-input-actions { display: flex; justify-content: right; margin-top: 12px; }
|
||||
.chat-attachment-upload { margin-bottom: 12px; }
|
||||
.uploaded-attachments { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.attachment-preview-item { display: flex; align-items: center; gap: 6px; padding: 6px 10px; background: var(--el-fill-color-light); border-radius: 6px; border: 1px solid var(--el-border-color-lighter); max-width: 200px; }
|
||||
.preview-thumb { width: 40px; height: 40px; border-radius: 4px; object-fit: cover; }
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visibleLocal"
|
||||
:title="currentFile?.name || '文件预览'"
|
||||
:close-on-click-modal="false"
|
||||
:destroy-on-close="false"
|
||||
width="90%"
|
||||
:fullscreen="isFullscreen"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="file-preview-toolbar">
|
||||
<div class="left">
|
||||
<!-- <el-button-group>
|
||||
<el-button size="small" @click="prevFile" :disabled="!hasPrev">上一页</el-button>
|
||||
<el-button size="small" @click="nextFile" :disabled="!hasNext">下一页</el-button>
|
||||
</el-button-group>
|
||||
|
||||
<el-divider direction="vertical" />
|
||||
|
||||
<el-button size="small" @click="zoomOut">-</el-button>
|
||||
<el-button size="small" @click="resetZoom">重置</el-button>
|
||||
<el-button size="small" @click="zoomIn">+</el-button>
|
||||
|
||||
<el-divider direction="vertical" /> -->
|
||||
|
||||
<!-- <el-button size="small" @click="rotateLeft">⟲</el-button> -->
|
||||
<!-- <el-button size="small" @click="rotateRight">⟳</el-button> -->
|
||||
|
||||
<!-- <el-divider direction="vertical" />
|
||||
|
||||
<el-button size="small" @click="toggleFullscreen">{{
|
||||
isFullscreen ? '退出全屏' : '全屏'
|
||||
}}</el-button> -->
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<!-- <el-button size="small" type="primary" :href="currentFile?.url" target="_blank"
|
||||
>在新标签打开</el-button
|
||||
> -->
|
||||
<!-- <el-button size="small" @click="downloadCurrent">下载</el-button> -->
|
||||
<el-button size="small" type="danger" @click="handleClose">关闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-preview-body">
|
||||
<template v-if="!currentFile">
|
||||
<div class="no-file">无要预览的文件</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- IMAGE -->
|
||||
<div v-if="fileType === 'image'" class="viewer-wrap" :key="`image-${currentFile.url}`">
|
||||
<div class="viewer" :style="viewerStyle">
|
||||
<img
|
||||
:src="currentFile.url"
|
||||
:key="currentFile.url"
|
||||
@error="onError"
|
||||
@load="onLoadImage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PDF -->
|
||||
<div v-else-if="fileType === 'pdf'" class="viewer-wrap" :key="`pdf-${currentFile.url}`">
|
||||
<div class="viewer" :style="viewerStyle">
|
||||
<!-- 使用 object / iframe 作为预览,支持大多数现代浏览器 -->
|
||||
<object
|
||||
:key="`pdf-obj-${currentFile.url}`"
|
||||
:data="appendPdfParams(currentFile.url)"
|
||||
type="application/pdf"
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<iframe
|
||||
:key="`pdf-iframe-${currentFile.url}`"
|
||||
:src="appendPdfParams(currentFile.url)"
|
||||
width="100%"
|
||||
height="100%"
|
||||
></iframe>
|
||||
<div class="fallback"
|
||||
>无法在浏览器内预览 PDF。<a :href="currentFile.url" target="_blank"
|
||||
>点击打开</a
|
||||
></div
|
||||
>
|
||||
</object>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OFFICE (Word, Excel, PPT) 使用微软 Office 在线查看器,仅当文件对外可访问时有效 -->
|
||||
<div
|
||||
v-else-if="fileType === 'office'"
|
||||
class="viewer-wrap"
|
||||
:key="`office-${currentFile.url}`"
|
||||
>
|
||||
<div class="viewer" :style="viewerStyle">
|
||||
<template v-if="isHttpUrl(currentFile.url)">
|
||||
<iframe
|
||||
:key="`office-iframe-${currentFile.url}`"
|
||||
:src="officeViewerUrl(currentFile.url)"
|
||||
width="100%"
|
||||
height="100%"
|
||||
frameborder="0"
|
||||
></iframe>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="fallback"
|
||||
>Office 文件必须是可通过公网访问的 URL 才能使用在线预览。<a
|
||||
:href="currentFile.url"
|
||||
target="_blank"
|
||||
>点击打开或下载</a
|
||||
></div
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文本 / 代码 -->
|
||||
<div v-else-if="fileType === 'text'" class="viewer-wrap" :key="`text-${currentFile.url}`">
|
||||
<div class="viewer text-viewer" :style="viewerStyle">
|
||||
<pre v-if="textContent !== null">{{ textContent }}</pre>
|
||||
<div v-else class="loading">正在加载文本内容...</div>
|
||||
<div v-if="textError" class="error">加载文本失败:{{ textError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 未知 -->
|
||||
<div v-else class="viewer-wrap unknown-viewer" :key="`unknown-${currentFile.url}`">
|
||||
<div class="viewer" :style="viewerStyle">
|
||||
<div class="unknown">
|
||||
<p>无法在线预览此类型文件。</p>
|
||||
<p><a :href="currentFile.url" target="_blank">点击打开或下载文件</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="footer-info">{{ currentFile?.name || '' }}</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="FilesPreviews">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// --- Props ---
|
||||
interface FileItem {
|
||||
file_name: any
|
||||
name?: string
|
||||
url: string
|
||||
mime?: string // 可选,优先使用
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
file?: FileItem | null // 单文件
|
||||
files?: FileItem[] | null // 多文件
|
||||
startIndex?: number
|
||||
enableFullscreen?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
// --- Local state ---
|
||||
const visibleLocal = ref<boolean>(!!props.modelValue)
|
||||
const currentIndex = ref<number>(props.startIndex ?? 0)
|
||||
const isFullscreen = ref<boolean>(!!props.enableFullscreen)
|
||||
const zoom = ref<number>(1)
|
||||
const rotate = ref<number>(0) // deg
|
||||
const textContent = ref<string | null>(null)
|
||||
const textError = ref<string | null>(null)
|
||||
|
||||
// --- Computed ---
|
||||
const currentFile = computed((): FileItem | null => {
|
||||
if (props.files && props.files.length > 0) {
|
||||
const idx = Math.min(Math.max(0, currentIndex.value), props.files.length - 1)
|
||||
return props.files[idx]
|
||||
}
|
||||
return props.file ?? null
|
||||
})
|
||||
|
||||
function resetViewState(clearText = true) {
|
||||
zoom.value = 1
|
||||
rotate.value = 0
|
||||
if (clearText) {
|
||||
textContent.value = null
|
||||
textError.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// --- Watchers ---
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => (visibleLocal.value = v)
|
||||
)
|
||||
watch(visibleLocal, (v) => emit('update:modelValue', v))
|
||||
|
||||
watch(
|
||||
() => props.file,
|
||||
() => {
|
||||
currentIndex.value = 0
|
||||
resetViewState()
|
||||
loadTextIfNeeded()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.files,
|
||||
() => {
|
||||
currentIndex.value = props.startIndex ?? 0
|
||||
resetViewState()
|
||||
loadTextIfNeeded()
|
||||
}
|
||||
)
|
||||
|
||||
watch(currentIndex, () => {
|
||||
resetViewState(false)
|
||||
loadTextIfNeeded()
|
||||
})
|
||||
|
||||
// 监听当前文件变化,确保文件切换时正确更新
|
||||
watch(
|
||||
() => currentFile.value?.url,
|
||||
(newUrl, oldUrl) => {
|
||||
if (newUrl !== oldUrl && newUrl) {
|
||||
resetViewState()
|
||||
loadTextIfNeeded()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const hasNext = computed(
|
||||
() => !!(props.files && currentIndex.value < (props.files?.length ?? 0) - 1)
|
||||
)
|
||||
const hasPrev = computed(() => !!(props.files && currentIndex.value > 0))
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function prevFile() {
|
||||
if (hasPrev.value) currentIndex.value -= 1
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function nextFile() {
|
||||
if (hasNext.value) currentIndex.value += 1
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
visibleLocal.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// --- Viewer helpers ---
|
||||
function getExtension(name = '') {
|
||||
const m = name.split('.')
|
||||
return (m.length > 1 ? m[m.length - 1].toLowerCase() : '').trim()
|
||||
}
|
||||
|
||||
function mimeFromName(name = '') {
|
||||
const ext = getExtension(name)
|
||||
const map: Record<string, string> = {
|
||||
pdf: 'application/pdf',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
bmp: 'image/bmp',
|
||||
webp: 'image/webp',
|
||||
txt: 'text/plain',
|
||||
md: 'text/markdown',
|
||||
csv: 'text/csv',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||
}
|
||||
return map[ext] ?? ''
|
||||
}
|
||||
|
||||
const fileType = computed(() => {
|
||||
const f = currentFile.value
|
||||
if (!f) return 'unknown'
|
||||
let names = f.name
|
||||
if (names == '') {
|
||||
names = f.file_name
|
||||
}
|
||||
const mime = (f.mime || mimeFromName(names) || '').toLowerCase()
|
||||
const ext = getExtension(names)
|
||||
|
||||
if (mime.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].includes(ext))
|
||||
return 'image'
|
||||
if (mime === 'application/pdf' || ext === 'pdf') return 'pdf'
|
||||
if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)) return 'office'
|
||||
if (mime.startsWith('text/') || ['txt', 'md', 'csv', 'log', 'json', 'xml'].includes(ext))
|
||||
return 'text'
|
||||
return 'unknown'
|
||||
})
|
||||
|
||||
function isHttpUrl(u?: string) {
|
||||
return !!u && /^https?:\/\//i.test(u)
|
||||
}
|
||||
|
||||
function officeViewerUrl(url: string) {
|
||||
// 使用微软 Office 在线查看器
|
||||
return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(url)}`
|
||||
}
|
||||
|
||||
function appendPdfParams(url: string) {
|
||||
// 某些浏览器可以在 pdf 后加参数调整行为,例如 #toolbar=0
|
||||
return url
|
||||
}
|
||||
|
||||
// --- transform style for viewer ---
|
||||
const viewerStyle = computed(() => {
|
||||
return {
|
||||
transform: `scale(${zoom.value}) rotate(${rotate.value}deg)`,
|
||||
transformOrigin: 'center top'
|
||||
} as Record<string, string>
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function zoomIn() {
|
||||
zoom.value = Math.min(zoom.value + 0.25, 4)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function zoomOut() {
|
||||
zoom.value = Math.max(zoom.value - 0.25, 0.25)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function resetZoom() {
|
||||
zoom.value = 1
|
||||
rotate.value = 0
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function rotateLeft() {
|
||||
rotate.value = (rotate.value - 90) % 360
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function rotateRight() {
|
||||
rotate.value = (rotate.value + 90) % 360
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function toggleFullscreen() {
|
||||
isFullscreen.value = !isFullscreen.value
|
||||
}
|
||||
|
||||
// --- text loading ---
|
||||
async function loadTextIfNeeded() {
|
||||
if (!currentFile.value) return
|
||||
if (fileType.value !== 'text') return
|
||||
|
||||
textContent.value = null
|
||||
textError.value = null
|
||||
|
||||
try {
|
||||
const resp = await fetch(currentFile.value.url)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const txt = await resp.text()
|
||||
textContent.value = txt
|
||||
} catch (err: any) {
|
||||
textError.value = String(err?.message ?? err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- image handlers ---
|
||||
function onError() {
|
||||
ElMessage.warning('图片加载失败')
|
||||
}
|
||||
function onLoadImage() {
|
||||
// 可以在这里获取自然尺寸进行布局优化
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function downloadCurrent() {
|
||||
if (!currentFile.value) return
|
||||
// 打开下载链接(浏览器通常会下载)
|
||||
const a = document.createElement('a')
|
||||
a.href = currentFile.value.url
|
||||
a.download = currentFile.value.name || ''
|
||||
a.target = '_blank'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
// expose some controls externally if needed
|
||||
onMounted(() => {
|
||||
if (props.modelValue) visibleLocal.value = true
|
||||
if (fileType.value === 'text') loadTextIfNeeded()
|
||||
})
|
||||
// 导出组件
|
||||
defineExpose({})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.file-preview-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.file-preview-body {
|
||||
height: calc(80vh - 80px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.viewer-wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: auto;
|
||||
}
|
||||
.viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.viewer img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.text-viewer pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
.unknown-viewer .unknown {
|
||||
text-align: center;
|
||||
}
|
||||
.no-file {
|
||||
color: #999;
|
||||
}
|
||||
.footer-info {
|
||||
color: #666;
|
||||
}
|
||||
.loading {
|
||||
color: #999;
|
||||
}
|
||||
.error {
|
||||
color: #f56c6c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,320 @@
|
||||
import { computed } from 'vue'
|
||||
import { useMenuBadgeStore } from '@/store/modules/menuBadge'
|
||||
import { useMenuStore } from '@/store/modules/menu'
|
||||
import mittBus from '@/utils/sys/mittBus'
|
||||
|
||||
// 全局初始化状态
|
||||
let globalInitialized = false
|
||||
|
||||
// 重置全局初始化状态(用于退出登录后重新初始化)
|
||||
export const resetGlobalInitialized = () => {
|
||||
globalInitialized = false
|
||||
// console.log('徽标系统全局初始化状态已重置')
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单徽标管理 Composable
|
||||
* 提供菜单徽标的统一管理接口
|
||||
*/
|
||||
export function useMenuBadge() {
|
||||
const menuBadgeStore = useMenuBadgeStore()
|
||||
const menuStore = useMenuStore()
|
||||
|
||||
// 计算属性:获取菜单列表(带徽标状态)
|
||||
const menuListWithBadges = computed(() => {
|
||||
const menuList = menuStore.menuList
|
||||
if (!menuList.length) return menuList
|
||||
|
||||
// 同步徽标状态到菜单列表
|
||||
menuBadgeStore.syncBadgeWithMenuList(menuList)
|
||||
return menuList
|
||||
})
|
||||
|
||||
// 更新菜单徽标
|
||||
const updateMenuBadge = (path: string, count: number, type: 'dot' | 'text' = 'text') => {
|
||||
menuBadgeStore.updateBadgeData(path, count, type)
|
||||
}
|
||||
|
||||
// 批量更新菜单徽标
|
||||
const batchUpdateMenuBadges = (
|
||||
updates: Array<{ path: string; count: number; type?: 'dot' | 'text' }>
|
||||
) => {
|
||||
menuBadgeStore.batchUpdateBadgeData(updates)
|
||||
}
|
||||
|
||||
// 清除菜单徽标
|
||||
const clearMenuBadge = (path: string) => {
|
||||
menuBadgeStore.clearBadge(path)
|
||||
}
|
||||
|
||||
// 清除所有菜单徽标
|
||||
const clearAllMenuBadges = () => {
|
||||
menuBadgeStore.clearAllBadges()
|
||||
}
|
||||
|
||||
// 获取菜单徽标数量
|
||||
const getMenuBadgeCount = (path: string) => {
|
||||
return menuBadgeStore.getBadgeCount(path)
|
||||
}
|
||||
|
||||
// 检查菜单是否显示徽标
|
||||
const isMenuBadgeVisible = (path: string) => {
|
||||
return menuBadgeStore.shouldShowBadge(path)
|
||||
}
|
||||
|
||||
// 获取菜单文本徽标
|
||||
const getMenuTextBadge = (path: string) => {
|
||||
return menuBadgeStore.shouldShowTextBadge(path)
|
||||
}
|
||||
|
||||
// 处理WebSocket徽标更新消息
|
||||
const handleWebSocketBadgeUpdate = (data: any) => {
|
||||
// 防止重复处理
|
||||
if (data._badgeHandled) {
|
||||
return
|
||||
}
|
||||
data._badgeHandled = true
|
||||
|
||||
// 首先检查是否是数组格式的徽标数据
|
||||
if (Array.isArray(data) || (data.data && Array.isArray(data.data))) {
|
||||
handleMenuBadgeDataArray(data)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 根据消息类型处理不同的徽标更新
|
||||
if (data.event === 'menu.badge.update') {
|
||||
const { path, count, type } = data.data
|
||||
if (path && typeof count === 'number') {
|
||||
updateMenuBadge(path, count, type || 'text')
|
||||
}
|
||||
} else if (data.event === 'menu.badge.batch_update') {
|
||||
const { updates } = data.data
|
||||
if (Array.isArray(updates)) {
|
||||
batchUpdateMenuBadges(updates)
|
||||
}
|
||||
} else if (data.event === 'menu.badge.clear') {
|
||||
const { path } = data.data
|
||||
if (path) {
|
||||
clearMenuBadge(path)
|
||||
} else {
|
||||
clearAllMenuBadges()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('处理WebSocket徽标更新消息时出错:', error, data)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理菜单徽标数据数组格式
|
||||
const handleMenuBadgeDataArray = (data: any) => {
|
||||
// 防止重复处理
|
||||
if (data._arrayHandled) {
|
||||
return
|
||||
}
|
||||
data._arrayHandled = true
|
||||
|
||||
// 支持多种数据格式:
|
||||
// 1. { event: '...', data: [{ url: '...', badge: 3 }] }
|
||||
// 2. [{ url: '...', badge: 3 }] (直接是数组)
|
||||
let badgeArray: any[] = []
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
// 如果 data 本身就是数组
|
||||
badgeArray = data
|
||||
} else if (Array.isArray(data.data)) {
|
||||
// 如果 data.data 是数组
|
||||
badgeArray = data.data
|
||||
} else {
|
||||
console.warn('未找到数组格式的徽标数据:', data)
|
||||
return
|
||||
}
|
||||
|
||||
if (badgeArray.length > 0) {
|
||||
const updates = badgeArray
|
||||
.filter((item: any) => {
|
||||
const isValid = item.url && typeof item.badge === 'number'
|
||||
if (!isValid) {
|
||||
console.warn('跳过无效的徽标数据项:', item)
|
||||
}
|
||||
return isValid
|
||||
})
|
||||
.map((item: any) => ({
|
||||
path: item.url,
|
||||
count: item.badge,
|
||||
type: 'text' as const
|
||||
}))
|
||||
|
||||
if (updates.length > 0) {
|
||||
batchUpdateMenuBadges(updates)
|
||||
} else {
|
||||
console.warn('没有有效的徽标更新项')
|
||||
}
|
||||
} else {
|
||||
console.warn('徽标数据数组为空')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理通知相关的徽标更新
|
||||
const handleNotificationBadgeUpdate = (data: any) => {
|
||||
// console.log('收到通知徽标更新消息:', data)
|
||||
|
||||
// 根据通知类型更新对应菜单的徽标
|
||||
if (data.event === 'notify.receive') {
|
||||
// 通知接收,更新通知菜单徽标
|
||||
const currentCount = getMenuBadgeCount('/notification')
|
||||
updateMenuBadge('/notification', currentCount + 1, 'text')
|
||||
} else if (data.event === 'notify.read') {
|
||||
// 通知已读,减少通知菜单徽标
|
||||
const currentCount = getMenuBadgeCount('/notification')
|
||||
updateMenuBadge('/notification', Math.max(0, currentCount - 1), 'text')
|
||||
} else if (data.event === 'notify.clear') {
|
||||
// 清除所有通知,清除通知菜单徽标
|
||||
clearMenuBadge('/notification')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理审批相关的徽标更新
|
||||
const handleApprovalBadgeUpdate = (data: any) => {
|
||||
// 首先检查是否是数组格式的徽标数据
|
||||
if (Array.isArray(data) || Array.isArray(data.data)) {
|
||||
handleMenuBadgeDataArray(data)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.event === 'approval.new') {
|
||||
// 新审批,更新审批菜单徽标
|
||||
const currentCount = getMenuBadgeCount('/admin/approval')
|
||||
updateMenuBadge('/admin/approval', currentCount + 1, 'text')
|
||||
} else if (data.event === 'approval.processed') {
|
||||
// 审批已处理,减少审批菜单徽标
|
||||
const currentCount = getMenuBadgeCount('/admin/approval')
|
||||
updateMenuBadge('/admin/approval', Math.max(0, currentCount - 1), 'text')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理库存相关的徽标更新
|
||||
const handleStockBadgeUpdate = (data: any) => {
|
||||
// console.log('收到库存徽标更新消息:', data)
|
||||
|
||||
if (data.event === 'stock.low') {
|
||||
// 库存不足,更新库存菜单徽标
|
||||
const currentCount = getMenuBadgeCount('/admin/stock')
|
||||
updateMenuBadge('/admin/stock', currentCount + 1, 'dot')
|
||||
} else if (data.event === 'stock.normal') {
|
||||
// 库存正常,减少库存菜单徽标
|
||||
const currentCount = getMenuBadgeCount('/admin/stock')
|
||||
updateMenuBadge('/admin/stock', Math.max(0, currentCount - 1), 'dot')
|
||||
}
|
||||
}
|
||||
|
||||
// 统一的WebSocket消息处理
|
||||
const handleWebSocketMessage = (data: any) => {
|
||||
if (!data) return
|
||||
|
||||
// 如果已经被特定事件处理过(如 menu-badge-update),则跳过
|
||||
// 注意:_badgeProcessed 标记是在 wsClient.ts 中设置的
|
||||
if (data._badgeProcessed) {
|
||||
return
|
||||
}
|
||||
|
||||
// 首先检查是否是数组格式的徽标数据(无论是否有 event 字段)
|
||||
if (Array.isArray(data) || (data.data && Array.isArray(data.data))) {
|
||||
handleMenuBadgeDataArray(data)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果没有 event 字段,无法继续处理
|
||||
if (!data.event) return
|
||||
|
||||
// 处理菜单徽标数据数组格式(如 approval.todo.counts)
|
||||
if (data.event.includes('.todo.counts') || data.event.includes('.badge.counts')) {
|
||||
handleMenuBadgeDataArray(data)
|
||||
} else if (data.event.includes('menu.badge')) {
|
||||
handleWebSocketBadgeUpdate(data)
|
||||
} else if (data.event.includes('notify')) {
|
||||
handleNotificationBadgeUpdate(data)
|
||||
} else if (data.event.includes('approval')) {
|
||||
handleApprovalBadgeUpdate(data)
|
||||
} else if (data.event.includes('stock')) {
|
||||
handleStockBadgeUpdate(data)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化徽标系统
|
||||
const initBadgeSystem = () => {
|
||||
if (globalInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
globalInitialized = true
|
||||
|
||||
// 标记页面已加载完成
|
||||
menuBadgeStore.setPageReady(true)
|
||||
|
||||
// 先清理可能存在的旧监听器,避免重复注册
|
||||
mittBus.off('ws:message', handleWebSocketMessage)
|
||||
mittBus.off('menu-badge-update', handleWebSocketBadgeUpdate)
|
||||
mittBus.off('approval-badge-update', handleApprovalBadgeUpdate)
|
||||
mittBus.off('stock-badge-update', handleStockBadgeUpdate)
|
||||
mittBus.off('notification-badge-update', handleNotificationBadgeUpdate)
|
||||
mittBus.off('menu:updated')
|
||||
|
||||
// 监听WebSocket消息
|
||||
mittBus.on('ws:message', handleWebSocketMessage)
|
||||
|
||||
// 监听特定的徽标更新事件
|
||||
mittBus.on('menu-badge-update', handleWebSocketBadgeUpdate)
|
||||
mittBus.on('approval-badge-update', handleApprovalBadgeUpdate)
|
||||
mittBus.on('stock-badge-update', handleStockBadgeUpdate)
|
||||
mittBus.on('notification-badge-update', handleNotificationBadgeUpdate)
|
||||
|
||||
// 监听菜单数据变化,同步徽标状态
|
||||
mittBus.on('menu:updated', () => {
|
||||
menuBadgeStore.syncBadgeWithMenuList(menuStore.menuList)
|
||||
})
|
||||
}
|
||||
|
||||
// 清理徽标系统
|
||||
const cleanupBadgeSystem = () => {
|
||||
if (!globalInitialized) {
|
||||
// console.log('菜单徽标系统未全局初始化,跳过清理')
|
||||
return
|
||||
}
|
||||
|
||||
// console.log('清理菜单徽标系统')
|
||||
globalInitialized = false
|
||||
|
||||
// 移除事件监听
|
||||
mittBus.off('ws:message', handleWebSocketMessage)
|
||||
mittBus.off('menu-badge-update', handleWebSocketBadgeUpdate)
|
||||
mittBus.off('approval-badge-update', handleApprovalBadgeUpdate)
|
||||
mittBus.off('stock-badge-update', handleStockBadgeUpdate)
|
||||
mittBus.off('notification-badge-update', handleNotificationBadgeUpdate)
|
||||
mittBus.off('menu:updated')
|
||||
// 重置页面加载状态
|
||||
menuBadgeStore.setPageReady(false)
|
||||
}
|
||||
|
||||
// 注意:不在组件中自动初始化,避免重复初始化
|
||||
// 初始化应该在App.vue中全局调用一次
|
||||
|
||||
return {
|
||||
// 计算属性
|
||||
menuListWithBadges,
|
||||
|
||||
// 方法
|
||||
updateMenuBadge,
|
||||
batchUpdateMenuBadges,
|
||||
clearMenuBadge,
|
||||
clearAllMenuBadges,
|
||||
getMenuBadgeCount,
|
||||
isMenuBadgeVisible,
|
||||
getMenuTextBadge,
|
||||
|
||||
// 系统方法
|
||||
initBadgeSystem,
|
||||
cleanupBadgeSystem,
|
||||
handleWebSocketMessage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
export interface OrderItem {
|
||||
model?: string
|
||||
quantity?: number
|
||||
exteriorColor?: string
|
||||
interiorColor?: string
|
||||
modification?: string
|
||||
unitPrice?: number
|
||||
totalPrice?: number
|
||||
officialPrice?: number
|
||||
discount?: string
|
||||
}
|
||||
|
||||
export interface PaymentItem {
|
||||
country?: string
|
||||
date?: string
|
||||
currency?: string
|
||||
account?: string
|
||||
remitter?: string
|
||||
amount?: number
|
||||
}
|
||||
|
||||
export interface OrderData {
|
||||
contract_file_url: { name: string; url: string }[]
|
||||
orderNo?: string
|
||||
salesperson?: string
|
||||
piNo?: string
|
||||
date?: string
|
||||
clearanceCountry?: string
|
||||
customerSource?: string
|
||||
customerName?: string
|
||||
customerCountry?: string
|
||||
items?: OrderItem[]
|
||||
contractRate?: string
|
||||
totalCif?: number
|
||||
declarant?: string
|
||||
etaShipDate?: string
|
||||
shippingMethod?: string
|
||||
domesticPort?: string
|
||||
tradeTerms?: string
|
||||
latestArrival?: string
|
||||
payments?: PaymentItem[]
|
||||
}
|
||||
|
||||
export function useOrderDetailDialog() {
|
||||
const visible = ref(false)
|
||||
const title = ref('订单详情')
|
||||
const orderData = ref<OrderData>({
|
||||
contract_file_url: [],
|
||||
items: [],
|
||||
payments: []
|
||||
})
|
||||
|
||||
const show = (data: OrderData, dialogTitle?: string) => {
|
||||
orderData.value = data
|
||||
title.value = dialogTitle || '订单详情'
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
const print = () => {
|
||||
// 可以在这里添加打印逻辑
|
||||
window.print()
|
||||
}
|
||||
|
||||
return {
|
||||
visible,
|
||||
title,
|
||||
orderData,
|
||||
show,
|
||||
hide,
|
||||
print
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
export const usePdfPreview = () => {
|
||||
const state = reactive({
|
||||
visible: false,
|
||||
url: '',
|
||||
name: '',
|
||||
blobUrl: '',
|
||||
images: [] as string[]
|
||||
})
|
||||
const frameRef = ref<HTMLIFrameElement | null>(null)
|
||||
|
||||
// 动态加载 pdf.js(优先本地依赖,失败回退 CDN)
|
||||
const loadPdfJs = async (): Promise<any> => {
|
||||
// 统一使用 CDN,避免本地依赖缺失导致打包时报错
|
||||
const ensure = (src: string) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const s = document.createElement('script')
|
||||
s.src = src
|
||||
s.onload = () => resolve()
|
||||
s.onerror = () => reject(new Error('load script failed'))
|
||||
document.head.appendChild(s)
|
||||
})
|
||||
await ensure('https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js')
|
||||
await ensure('https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js')
|
||||
return (window as any).pdfjsLib
|
||||
}
|
||||
|
||||
const open = (url: string, name = '') => {
|
||||
if (!url) return
|
||||
state.url = url
|
||||
state.name = name
|
||||
state.visible = true
|
||||
state.images = []
|
||||
}
|
||||
|
||||
const openWithResponse = (res: any) => {
|
||||
const url = res?.data?.file_url_full || res?.file_url_full
|
||||
const name = res?.data?.file_url || res?.file_url || ''
|
||||
open(url, name)
|
||||
}
|
||||
|
||||
const inferFilenameFromUrl = (url: string): string => {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
const parts = u.pathname.split('/')
|
||||
return parts[parts.length - 1] || 'document.pdf'
|
||||
} catch (e) {
|
||||
const parts = (url || '').split('?')[0].split('/')
|
||||
return parts[parts.length - 1] || 'document.pdf'
|
||||
}
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
if (!state.url) return
|
||||
const filename = state.name || inferFilenameFromUrl(state.url)
|
||||
try {
|
||||
const resp = await fetch(state.url, { credentials: 'omit' })
|
||||
const blob = await resp.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
window.open(state.url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
// 预览时也用 blob,提升兼容性
|
||||
const preparePreview = async () => {
|
||||
if (!state.url) return
|
||||
try {
|
||||
const resp = await fetch(state.url, { credentials: 'omit' })
|
||||
const blob = await resp.blob()
|
||||
state.blobUrl && window.URL.revokeObjectURL(state.blobUrl)
|
||||
state.blobUrl = window.URL.createObjectURL(blob)
|
||||
} catch {
|
||||
state.blobUrl = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 生成图片以便无需浏览器 PDF 插件即可展示
|
||||
const prepareImages = async () => {
|
||||
if (!state.url) return
|
||||
state.images = []
|
||||
try {
|
||||
const resp = await fetch(state.url, { credentials: 'omit' })
|
||||
const arrayBuffer = await resp.arrayBuffer()
|
||||
const pdfjsLib = await loadPdfJs()
|
||||
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise
|
||||
const images: string[] = []
|
||||
for (let p = 1; p <= pdf.numPages; p++) {
|
||||
const page = await pdf.getPage(p)
|
||||
const viewport = page.getViewport({ scale: 1.5 })
|
||||
const canvas = document.createElement('canvas')
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) continue
|
||||
canvas.width = viewport.width
|
||||
canvas.height = viewport.height
|
||||
await page.render({ canvasContext: context, viewport }).promise
|
||||
images.push(canvas.toDataURL('image/png'))
|
||||
}
|
||||
state.images = images
|
||||
} catch {
|
||||
state.images = []
|
||||
}
|
||||
}
|
||||
|
||||
const print = () => {
|
||||
const frame = frameRef.value
|
||||
if (frame && frame.contentWindow) {
|
||||
frame.contentWindow.focus()
|
||||
frame.contentWindow.print()
|
||||
return
|
||||
}
|
||||
if (state.url) {
|
||||
const win = window.open(state.url, '_blank')
|
||||
if (win) {
|
||||
win.addEventListener('load', () => win.print())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pdf: state,
|
||||
frameRef,
|
||||
open,
|
||||
openWithResponse,
|
||||
download,
|
||||
print,
|
||||
preparePreview,
|
||||
prepareImages
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@ import { useUserStore } from '@/store/modules/user'
|
||||
export function useUploadHeaders() {
|
||||
const userStore = useUserStore()
|
||||
const uploadHeaders = computed(() => ({
|
||||
'Fdesk-Language': userStore.language,
|
||||
'Fdesk-Corp-Abbr': userStore.companyabbr,
|
||||
'Fdesk-System-Code': 'fdesk',
|
||||
'Fdesk-Login-Type': userStore.loginType,
|
||||
'Fdesk-Language': userStore.language || 'zh',
|
||||
'Fdesk-Corp-Abbr': userStore.companyabbr || 'default',
|
||||
'Fdesk-System-Code': userStore.systemsabbr || 'fdesk',
|
||||
'Fdesk-Login-Type': userStore.loginType || 'upwd',
|
||||
'Fdesk-Auth-Token': 'Bearer ' + userStore.accessToken
|
||||
}))
|
||||
return { uploadHeaders }
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,7 @@ const appConfig: SystemConfig = {
|
||||
] as const,
|
||||
// 系统其他项默认配置
|
||||
systemSetting: {
|
||||
defaultMenuWidth: 240, // 菜单宽度
|
||||
defaultMenuWidth: 200, // 菜单宽度
|
||||
defaultCustomRadius: '0.75', // 自定义圆角
|
||||
defaultTabStyle: 'tab-default' // 标签样式
|
||||
}
|
||||
|
||||
@@ -59,27 +59,65 @@ async function handleRouteGuard(
|
||||
// 设置系统主题
|
||||
setSystemTheme(to)
|
||||
|
||||
// 处理登录状态
|
||||
if (!(await handleLoginStatus(to, userStore, next))) {
|
||||
return
|
||||
// 特殊处理电子签名页面 - 优先处理,避免登录检查
|
||||
if (to.path === '/auth/signature/mobile') {
|
||||
// 设置认证信息
|
||||
if (to.query.accessToken || to.query.token) {
|
||||
const token = (to.query.accessToken as string) || (to.query.token as string)
|
||||
if (token) {
|
||||
userStore.setToken(token)
|
||||
userStore.setLoginStatus(true)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置其他必要的参数到userStore
|
||||
if (to.query.companyid && to.query.companyabbr) {
|
||||
userStore.setCompany(to.query.companyid as string, to.query.companyabbr as string)
|
||||
}
|
||||
|
||||
if (to.query.systemsid && to.query.systemsabbr) {
|
||||
userStore.setSystems(to.query.systemsid as string, to.query.systemsabbr as string)
|
||||
}
|
||||
|
||||
if (to.query.loginType) {
|
||||
userStore.setLoginType(to.query.loginType as 'feishu' | 'upwd')
|
||||
}
|
||||
|
||||
if (to.matched.length > 0) {
|
||||
setWorktab(to)
|
||||
setPageTitle(to)
|
||||
next()
|
||||
return
|
||||
} else {
|
||||
// 如果路由没有匹配,直接跳转到404
|
||||
next(RoutesAlias.Exception404)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 处理动态路由注册
|
||||
if (!isRouteRegistered.value && userStore.isLogin) {
|
||||
// 处理其他页面的登录状态(跳过电子签名页面)
|
||||
if (to.path !== '/auth/signature/mobile') {
|
||||
if (!(await handleLoginStatus(to, userStore, next))) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 处理动态路由注册(跳过电子签名页面)
|
||||
if (to.path !== '/auth/signature/mobile' && !isRouteRegistered.value && userStore.isLogin) {
|
||||
await handleDynamicRoutes(to, router, next)
|
||||
return
|
||||
}
|
||||
|
||||
// 处理已知的匹配路由
|
||||
if (to.matched.length > 0) {
|
||||
// 处理已知的匹配路由(跳过电子签名页面)
|
||||
if (to.path !== '/auth/signature/mobile' && to.matched.length > 0) {
|
||||
setWorktab(to)
|
||||
setPageTitle(to)
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试刷新路由重新注册
|
||||
if (userStore.isLogin) {
|
||||
// 尝试刷新路由重新注册(跳过电子签名页面)
|
||||
if (to.path !== '/auth/signature/mobile' && userStore.isLogin) {
|
||||
isRouteRegistered.value = false
|
||||
await handleDynamicRoutes(to, router, next)
|
||||
return
|
||||
@@ -97,6 +135,7 @@ async function handleLoginStatus(
|
||||
userStore: ReturnType<typeof useUserStore>,
|
||||
next: NavigationGuardNext
|
||||
): Promise<boolean> {
|
||||
// 检查其他页面的登录状态
|
||||
if (!userStore.isLogin && to.path !== RoutesAlias.Login && !to.meta.noLogin) {
|
||||
userStore.logOut()
|
||||
next({
|
||||
@@ -105,6 +144,7 @@ async function handleLoginStatus(
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -200,22 +240,17 @@ async function registerAndStoreMenu(
|
||||
*/
|
||||
function handleMenuError(error: unknown): void {
|
||||
console.error('菜单处理失败:', error)
|
||||
|
||||
// 清理所有状态
|
||||
const userStore = useUserStore()
|
||||
const menuStore = useMenuStore()
|
||||
const worktabStore = useWorktabStore()
|
||||
|
||||
// 清空菜单和工作台
|
||||
menuStore.clearMenuList()
|
||||
worktabStore.clearAll()
|
||||
|
||||
// 重置路由注册状态
|
||||
isRouteRegistered.value = false
|
||||
|
||||
// 登出用户
|
||||
userStore.logOut()
|
||||
|
||||
throw error instanceof Error ? error : new Error('获取菜单列表失败,请重新登录')
|
||||
}
|
||||
|
||||
@@ -251,20 +286,21 @@ function isValidMenuList(menuList: AppRouteRecord[]): boolean {
|
||||
*/
|
||||
export function resetRouterState(router: Router): void {
|
||||
isRouteRegistered.value = false
|
||||
|
||||
|
||||
// 清理所有动态注册的路由(不仅仅是带有dynamic标记的)
|
||||
const routesToRemove: string[] = []
|
||||
router.getRoutes().forEach((route) => {
|
||||
// 排除静态路由和登录页面
|
||||
if (route.name &&
|
||||
route.name !== 'Login' &&
|
||||
route.name !== 'Exception404' &&
|
||||
route.name !== 'Exception500' &&
|
||||
!route.path.startsWith('/exception/')) {
|
||||
if (
|
||||
route.name &&
|
||||
route.name !== 'Login' &&
|
||||
route.name !== 'Exception404' &&
|
||||
route.name !== 'Exception500' &&
|
||||
!route.path.startsWith('/exception/')
|
||||
) {
|
||||
routesToRemove.push(route.name as string)
|
||||
}
|
||||
})
|
||||
|
||||
// 移除动态路由
|
||||
routesToRemove.forEach((routeName) => {
|
||||
try {
|
||||
@@ -273,11 +309,9 @@ export function resetRouterState(router: Router): void {
|
||||
console.warn(`移除路由失败: ${routeName}`, error)
|
||||
}
|
||||
})
|
||||
|
||||
// 清空菜单数据
|
||||
const menuStore = useMenuStore()
|
||||
menuStore.clearMenuList()
|
||||
|
||||
// 清空工作台标签页
|
||||
const worktabStore = useWorktabStore()
|
||||
worktabStore.clearAll()
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { RoutesAlias } from '../routesAlias'
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
import { WEB_LINKS } from '@/utils/constants'
|
||||
|
||||
/**
|
||||
* 菜单列表、异步路由
|
||||
*
|
||||
@@ -13,13 +11,37 @@ import { WEB_LINKS } from '@/utils/constants'
|
||||
* 可以是 i18n 的 key,也可以是字符串,比如:'用户列表'
|
||||
*/
|
||||
export const asyncRoutes: AppRouteRecord[] = [
|
||||
{
|
||||
path: '/dashboard/console/finance',
|
||||
name: 'Consolefinance',
|
||||
component: RoutesAlias.consolefinance,
|
||||
meta: {
|
||||
title: '仪表盘'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/dashboard/console/backoffice',
|
||||
name: 'Backoffice',
|
||||
component: RoutesAlias.backoffice,
|
||||
meta: {
|
||||
title: '仪表盘'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/inquiry_system',
|
||||
name: 'Inquiry_system',
|
||||
component: RoutesAlias.inquirySystem,
|
||||
meta: {
|
||||
title: '询单系统'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/crmpc/customer/add',
|
||||
name: 'CrmpcustomerAdd',
|
||||
component: RoutesAlias.CrmpcustomerAdd,
|
||||
meta: {
|
||||
title: '新增客户资料',
|
||||
isHide: true,
|
||||
isHide: true
|
||||
// isHideTab: true
|
||||
}
|
||||
},
|
||||
@@ -29,7 +51,7 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
component: RoutesAlias.CrmpcustomerShow,
|
||||
meta: {
|
||||
title: '客户资料',
|
||||
isHide: true,
|
||||
isHide: true
|
||||
// isHideTab: true
|
||||
}
|
||||
},
|
||||
@@ -39,7 +61,7 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
component: RoutesAlias.CrmpcustomerEdit,
|
||||
meta: {
|
||||
title: '编辑客户资料',
|
||||
isHide: true,
|
||||
isHide: true
|
||||
// isHideTab: true
|
||||
}
|
||||
},
|
||||
@@ -49,7 +71,7 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
component: RoutesAlias.CrmpcustomerFollowadd,
|
||||
meta: {
|
||||
title: '添加跟进',
|
||||
isHide: true,
|
||||
isHide: true
|
||||
// isHideTab: true
|
||||
}
|
||||
},
|
||||
@@ -59,7 +81,115 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
component: RoutesAlias.recycle,
|
||||
meta: {
|
||||
title: '回收站',
|
||||
isHide: true,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
// 备货单相关路由
|
||||
{
|
||||
path: '/crmpc/stocking/stocking_list',
|
||||
name: 'StockingList',
|
||||
component: 'crmpc/stocking/stocking_list',
|
||||
meta: {
|
||||
title: '备货单列表',
|
||||
isHide: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/crmpc/stocking/Add',
|
||||
name: 'StockingAdd',
|
||||
component: 'crmpc/stocking/Add',
|
||||
meta: {
|
||||
title: '创建备货单',
|
||||
isHide: true,
|
||||
isHideTab: false,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/crmpc/stocking/show',
|
||||
name: 'StockingShow',
|
||||
component: 'crmpc/stocking/show',
|
||||
meta: {
|
||||
title: '备货单详情',
|
||||
isHide: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/crmpc/stocking/final_add',
|
||||
name: 'StockingFinalAdd',
|
||||
component: 'crmpc/stocking/final_add',
|
||||
meta: {
|
||||
title: '最终备货单',
|
||||
isHide: true,
|
||||
isHideTab: false,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/crmpc/stocking/final_show',
|
||||
name: 'StockingFinalShow',
|
||||
component: 'crmpc/stocking/final_show',
|
||||
meta: {
|
||||
title: '最终备货单详情',
|
||||
isHide: true
|
||||
}
|
||||
},
|
||||
//审批详情
|
||||
{
|
||||
path: '/crmpc/stocking/stocking_auth_detil',
|
||||
name: 'StockingAuth',
|
||||
component: 'crmpc/stocking/stocking_auth_detil',
|
||||
meta: {
|
||||
title: '最终备货单详情',
|
||||
isHide: true
|
||||
}
|
||||
},
|
||||
// 审批流程添加
|
||||
{
|
||||
path: '/admin/approval/approval_add',
|
||||
name: 'approvalAdd',
|
||||
component: 'admin/approval/approval_add',
|
||||
meta: {
|
||||
title: '审批流程添加',
|
||||
isHide: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/crmpc/approval/auth_detil',
|
||||
name: 'approvalDetil',
|
||||
component: 'crmpc/approval/auth_detil',
|
||||
meta: {
|
||||
title: '审核详情',
|
||||
isHide: true
|
||||
}
|
||||
},
|
||||
// 管理端备货单相关路由
|
||||
{
|
||||
path: '/admin/stock/stocking_list_admin',
|
||||
name: 'AdminStockingList',
|
||||
component: 'admin/stock/stocking_list_admin',
|
||||
meta: {
|
||||
title: '备货单列表',
|
||||
isHide: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/stock/show_admin',
|
||||
name: 'AdminStockingShow',
|
||||
component: 'admin/stock/show_admin',
|
||||
meta: {
|
||||
title: '备货单详情',
|
||||
isHide: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/stock/final_show_admin',
|
||||
name: 'AdminStockingFinalShow',
|
||||
component: 'admin/stock/final_show_admin',
|
||||
meta: {
|
||||
title: '最终备货单详情',
|
||||
isHide: true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -9,7 +9,7 @@ import Home from '@views/index/index.vue'
|
||||
export const staticRoutes: AppRouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: HOME_PAGE
|
||||
redirect: localStorage.getItem('home_page') || HOME_PAGE
|
||||
},
|
||||
{
|
||||
path: '/console',
|
||||
@@ -41,6 +41,12 @@ export const staticRoutes: AppRouteRecordRaw[] = [
|
||||
component: () => import('@/views/auth/feishu-callback.vue'),
|
||||
meta: { title: '飞书回调', noLogin: true, isHideTab: true }
|
||||
},
|
||||
{
|
||||
path: '/auth/signature/mobile',
|
||||
name: 'SignatureMobile',
|
||||
component: () => import('@views/auth/signature/mobile.vue'),
|
||||
meta: { title: '电子签名', isHideTab: true, noLogin: true, setTheme: true }
|
||||
},
|
||||
{
|
||||
path: '/exception',
|
||||
component: Home,
|
||||
|
||||
@@ -54,12 +54,23 @@ export enum RoutesAlias {
|
||||
Crmpchome = '/crmpc/home', // crm首页
|
||||
Crmphighseas = '/crmpc/highseas', // crm公海
|
||||
Crmpcustomer = '/crmpc/customer', // crm客户
|
||||
CrmpcustomerAdd = '/crmpc/customer/Add',//客户添加
|
||||
CrmpcustomerEdit = '/crmpc/customer/Edit',//客户编辑
|
||||
CrmpcustomerShow = '/crmpc/customer/Show',//客户详情
|
||||
CrmpcustomerFollowadd = '/crmpc/customer/Followadd',//添加跟进
|
||||
recycle = '/crmpc/recycle',//回收站
|
||||
CrmpcustomerAdd = '/crmpc/customer/Add', //客户添加
|
||||
CrmpcustomerEdit = '/crmpc/customer/Edit', //客户编辑
|
||||
CrmpcustomerShow = '/crmpc/customer/Show', //客户详情
|
||||
CrmpcustomerFollowadd = '/crmpc/customer/Followadd', //添加跟进
|
||||
recycle = '/crmpc/recycle', //回收站
|
||||
UserCenter = '/system/user-center', // 用户中心
|
||||
inquirySystem = '/admin/inquiry_system', // 询单系统
|
||||
customer = '/admin/customer', // 管理端客户列表
|
||||
customeraudit = '/admin/customer/customeraudit', // 管理端待审客户列表
|
||||
// 备货单相关路由
|
||||
StockingList = '/crmpc/stocking/stocking_list', // 备货单列表
|
||||
StockingAdd = '/crmpc/stocking/Add', // 创建备货单
|
||||
StockingShow = '/crmpc/stocking/show', // 备货单详情
|
||||
StockingFinalAdd = '/crmpc/stocking/final_add', // 最终备货单添加
|
||||
StockingFinalShow = '/crmpc/stocking/final_show', // 最终备货单详情
|
||||
consolefinance = '/dashboard/console/finance', //财务仪表盘
|
||||
backoffice = '/dashboard/console/backoffice' // 内勤仪表盘
|
||||
}
|
||||
|
||||
// 主页路由
|
||||
|
||||
@@ -130,15 +130,21 @@ function loadComponent(componentPath: string, routeName: string): () => Promise<
|
||||
}
|
||||
|
||||
// 构建可能的路径
|
||||
const fullPath = `../../views${componentPath}.vue`
|
||||
const fullPathWithIndex = `../../views${componentPath}/index.vue`
|
||||
const fullPathNoSlash = `../../views${componentPath}.vue`
|
||||
const fullPathWithIndexNoSlash = `../../views${componentPath}/index.vue`
|
||||
const fullPathWithSlash = `../../views/${componentPath}.vue`
|
||||
const fullPathWithIndexSlash = `../../views/${componentPath}/index.vue`
|
||||
|
||||
// 先尝试直接路径,再尝试添加/index的路径
|
||||
const module = modules[fullPath] || modules[fullPathWithIndex]
|
||||
// 依次尝试四种可能路径(兼容是否带斜杠的情况)
|
||||
const module =
|
||||
modules[fullPathNoSlash] ||
|
||||
modules[fullPathWithIndexNoSlash] ||
|
||||
modules[fullPathWithSlash] ||
|
||||
modules[fullPathWithIndexSlash]
|
||||
|
||||
if (!module) {
|
||||
console.error(
|
||||
`[路由错误] 未找到组件:${routeName},尝试过的路径: ${fullPath} 和 ${fullPathWithIndex}`
|
||||
`[路由错误] 未找到组件:${routeName},尝试过的路径: ${fullPathNoSlash}、${fullPathWithIndexNoSlash}、${fullPathWithSlash} 和 ${fullPathWithIndexSlash}`
|
||||
)
|
||||
return () =>
|
||||
Promise.resolve({
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
import { useUserStore } from './user'
|
||||
|
||||
// 菜单徽标数据类型
|
||||
export interface MenuBadgeData {
|
||||
/** 菜单路径 */
|
||||
path: string
|
||||
/** 徽标数量 */
|
||||
count: number
|
||||
/** 徽标类型:dot(红点) | text(数字) */
|
||||
type: 'dot' | 'text'
|
||||
/** 最后更新时间 */
|
||||
lastUpdated: number
|
||||
}
|
||||
|
||||
// 菜单徽标Store
|
||||
export const useMenuBadgeStore = defineStore('menuBadgeStore', () => {
|
||||
// 徽标数据映射 - 使用普通对象而不是Map,避免持久化问题
|
||||
const badgeData = ref<Record<string, MenuBadgeData>>({})
|
||||
|
||||
// 页面是否已加载完成
|
||||
const isPageReady = ref(false)
|
||||
|
||||
// 待处理的徽标更新队列(页面未加载完成时使用)
|
||||
const pendingUpdates = ref<Array<{ path: string; count: number; type: 'dot' | 'text' }>>([])
|
||||
|
||||
// 获取当前用户ID
|
||||
const getCurrentUserId = (): string => {
|
||||
try {
|
||||
const userStore = useUserStore()
|
||||
return userStore.info?.userId?.toString() || 'anonymous'
|
||||
} catch {
|
||||
return 'anonymous'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户特定的存储key
|
||||
const getUserStorageKey = (): string => {
|
||||
const userId = getCurrentUserId()
|
||||
return `menu-badge-${userId}`
|
||||
}
|
||||
|
||||
// 从localStorage加载用户特定的徽标数据
|
||||
const loadUserBadgeData = () => {
|
||||
try {
|
||||
const storageKey = getUserStorageKey()
|
||||
const stored = localStorage.getItem(storageKey)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored)
|
||||
badgeData.value = parsed.badgeData || {}
|
||||
// 注意:不恢复 isPageReady,因为页面状态应该在运行时由代码控制
|
||||
// isPageReady.value 应该在 setPageReady() 中设置,而不是从存储中恢复
|
||||
pendingUpdates.value = parsed.pendingUpdates || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('加载用户徽标数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存用户特定的徽标数据到localStorage
|
||||
const saveUserBadgeData = () => {
|
||||
try {
|
||||
const storageKey = getUserStorageKey()
|
||||
const data = {
|
||||
badgeData: badgeData.value,
|
||||
// 注意:不保存 isPageReady,因为页面状态应该在运行时由代码控制
|
||||
// 页面每次加载时都应该重新设置为 true
|
||||
pendingUpdates: pendingUpdates.value
|
||||
}
|
||||
localStorage.setItem(storageKey, JSON.stringify(data))
|
||||
} catch (error) {
|
||||
console.warn('保存用户徽标数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 清理其他用户的徽标数据
|
||||
const cleanupOtherUserData = () => {
|
||||
try {
|
||||
const currentUserId = getCurrentUserId()
|
||||
const keys = Object.keys(localStorage)
|
||||
keys.forEach((key) => {
|
||||
if (key.startsWith('menu-badge-') && !key.endsWith(`-${currentUserId}`)) {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('清理其他用户数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算属性:获取所有徽标数据
|
||||
const allBadgeData = computed(() => {
|
||||
return { ...badgeData.value }
|
||||
})
|
||||
|
||||
// 获取指定菜单的徽标数据
|
||||
const getBadgeData = (path: string): MenuBadgeData | null => {
|
||||
return badgeData.value[path] || null
|
||||
}
|
||||
|
||||
// 获取指定菜单的徽标数量
|
||||
const getBadgeCount = (path: string): number => {
|
||||
const data = badgeData.value[path]
|
||||
return data?.count || 0
|
||||
}
|
||||
|
||||
// 检查菜单是否应该显示徽标
|
||||
const shouldShowBadge = (path: string): boolean => {
|
||||
const data = badgeData.value[path]
|
||||
return Boolean(data && data.count > 0)
|
||||
}
|
||||
|
||||
// 检查菜单是否应该显示文本徽标
|
||||
const shouldShowTextBadge = (path: string): string | null => {
|
||||
const data = badgeData.value[path]
|
||||
if (data && data.count > 0 && data.type === 'text') {
|
||||
return data.count > 99 ? '99+' : data.count.toString()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 设置页面加载状态
|
||||
const setPageReady = (ready: boolean) => {
|
||||
isPageReady.value = ready
|
||||
|
||||
// 页面加载完成时,加载用户特定的徽标数据
|
||||
if (ready) {
|
||||
loadUserBadgeData()
|
||||
// 确保 isPageReady 保持为 true(防止被其他逻辑覆盖)
|
||||
isPageReady.value = true
|
||||
// 处理待处理的更新
|
||||
if (pendingUpdates.value.length > 0) {
|
||||
pendingUpdates.value.forEach((update) => {
|
||||
updateBadgeData(update.path, update.count, update.type)
|
||||
})
|
||||
pendingUpdates.value = []
|
||||
// 再次确认页面状态
|
||||
isPageReady.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新徽标数据
|
||||
const updateBadgeData = (path: string, count: number, type: 'dot' | 'text' = 'text') => {
|
||||
// 如果页面未加载完成,将更新加入队列
|
||||
if (!isPageReady.value) {
|
||||
// console.log('页面未加载完成,将徽标更新加入队列:', { path, count, type })
|
||||
// 检查是否已存在相同路径的待处理更新,避免重复
|
||||
const existingIndex = pendingUpdates.value.findIndex((item) => item.path === path)
|
||||
if (existingIndex >= 0) {
|
||||
pendingUpdates.value[existingIndex] = { path, count, type }
|
||||
} else {
|
||||
pendingUpdates.value.push({ path, count, type })
|
||||
}
|
||||
console.log('检查菜单是否应该显示徽标:', path, badgeData.value)
|
||||
// 保存到用户特定存储
|
||||
saveUserBadgeData()
|
||||
return
|
||||
}
|
||||
|
||||
if (count <= 0) {
|
||||
// 如果数量为0或负数,移除徽标
|
||||
delete badgeData.value[path]
|
||||
} else {
|
||||
// 更新或创建徽标数据
|
||||
badgeData.value[path] = {
|
||||
path,
|
||||
count,
|
||||
type,
|
||||
lastUpdated: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
// 保存到用户特定存储
|
||||
saveUserBadgeData()
|
||||
}
|
||||
|
||||
// 批量更新徽标数据
|
||||
const batchUpdateBadgeData = (
|
||||
updates: Array<{ path: string; count: number; type?: 'dot' | 'text' }>
|
||||
) => {
|
||||
if (!isPageReady.value) {
|
||||
updates.forEach((update) => {
|
||||
const existingIndex = pendingUpdates.value.findIndex((item) => item.path === update.path)
|
||||
if (existingIndex >= 0) {
|
||||
pendingUpdates.value[existingIndex] = {
|
||||
path: update.path,
|
||||
count: update.count,
|
||||
type: update.type || 'text'
|
||||
}
|
||||
} else {
|
||||
pendingUpdates.value.push({
|
||||
path: update.path,
|
||||
count: update.count,
|
||||
type: update.type || 'text'
|
||||
})
|
||||
}
|
||||
})
|
||||
// 保存到用户特定存储
|
||||
saveUserBadgeData()
|
||||
return
|
||||
}
|
||||
updates.forEach((update) => {
|
||||
updateBadgeData(update.path, update.count, update.type || 'text')
|
||||
})
|
||||
// 批量更新后保存
|
||||
saveUserBadgeData()
|
||||
}
|
||||
|
||||
// 清除指定菜单的徽标
|
||||
const clearBadge = (path: string) => {
|
||||
updateBadgeData(path, 0)
|
||||
}
|
||||
|
||||
// 清除所有徽标
|
||||
const clearAllBadges = () => {
|
||||
badgeData.value = {}
|
||||
pendingUpdates.value = []
|
||||
// 保存到用户特定存储
|
||||
saveUserBadgeData()
|
||||
}
|
||||
|
||||
// 从菜单列表同步徽标状态
|
||||
const syncBadgeWithMenuList = (menuList: AppRouteRecord[]) => {
|
||||
if (!isPageReady.value) {
|
||||
// console.log('页面未加载完成,跳过菜单徽标同步')
|
||||
return
|
||||
}
|
||||
|
||||
const updateMenuBadge = (menus: AppRouteRecord[]) => {
|
||||
menus.forEach((menu) => {
|
||||
const badgeData = getBadgeData(menu.path)
|
||||
if (badgeData) {
|
||||
// 更新菜单的徽标状态
|
||||
menu.meta.showBadge = badgeData.type === 'dot' && badgeData.count > 0
|
||||
menu.meta.showTextBadge =
|
||||
badgeData.type === 'text' && badgeData.count > 0
|
||||
? badgeData.count > 99
|
||||
? '99+'
|
||||
: badgeData.count.toString()
|
||||
: undefined
|
||||
} else {
|
||||
// 清除菜单的徽标状态
|
||||
menu.meta.showBadge = false
|
||||
menu.meta.showTextBadge = undefined
|
||||
}
|
||||
|
||||
// 递归处理子菜单
|
||||
if (menu.children) {
|
||||
updateMenuBadge(menu.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
updateMenuBadge(menuList)
|
||||
}
|
||||
|
||||
// 获取待处理更新的数量
|
||||
const getPendingUpdatesCount = () => pendingUpdates.value.length
|
||||
|
||||
// 获取待处理的更新列表
|
||||
const getPendingUpdates = () => [...pendingUpdates.value]
|
||||
|
||||
return {
|
||||
// 状态
|
||||
badgeData,
|
||||
isPageReady,
|
||||
pendingUpdates,
|
||||
|
||||
// 计算属性
|
||||
allBadgeData,
|
||||
|
||||
// 方法
|
||||
getBadgeData,
|
||||
getBadgeCount,
|
||||
shouldShowBadge,
|
||||
shouldShowTextBadge,
|
||||
setPageReady,
|
||||
updateBadgeData,
|
||||
batchUpdateBadgeData,
|
||||
clearBadge,
|
||||
clearAllBadges,
|
||||
syncBadgeWithMenuList,
|
||||
getPendingUpdatesCount,
|
||||
getPendingUpdates,
|
||||
|
||||
// 用户隔离相关方法
|
||||
loadUserBadgeData,
|
||||
saveUserBadgeData,
|
||||
cleanupOtherUserData,
|
||||
getCurrentUserId
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { LanguageEnum } from '@/enums/appEnum'
|
||||
import { router } from '@/router'
|
||||
import { useSettingStore } from './setting'
|
||||
import { useWorktabStore } from './worktab'
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
import { setPageTitle } from '@/router/utils/utils'
|
||||
import { resetRouterState } from '@/router/guards/beforeEach'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { UserService } from '@/api/usersApi' // 新增导入
|
||||
let refreshTimeout: ReturnType<typeof setTimeout> | null = null // 定时器变量
|
||||
|
||||
// 用户
|
||||
export const useUserStore = defineStore(
|
||||
'userStore',
|
||||
() => {
|
||||
const language = ref(LanguageEnum.ZH)
|
||||
const isLogin = ref(false)
|
||||
const isLock = ref(false)
|
||||
const lockPassword = ref('')
|
||||
const info = ref<Partial<Api.User.UserInfo>>({})
|
||||
const searchHistory = ref<AppRouteRecord[]>([])
|
||||
const accessToken = ref('')
|
||||
const refreshToken = ref('')
|
||||
const companyid = ref('')
|
||||
const companyabbr = ref('')
|
||||
const systemsid = ref('')
|
||||
const systemsabbr = ref('')
|
||||
const home_page = ref('')
|
||||
const loginType = ref<'feishu' | 'upwd'>('upwd')
|
||||
const tokenExpireAt = ref<number>(0)
|
||||
|
||||
const getUserInfo = computed(() => info.value)
|
||||
const getSettingState = computed(() => useSettingStore().$state)
|
||||
const getWorktabState = computed(() => useWorktabStore().$state)
|
||||
|
||||
const setUserInfo = (newInfo: Api.User.UserInfo) => {
|
||||
info.value = newInfo
|
||||
}
|
||||
|
||||
const setLoginStatus = (status: boolean) => {
|
||||
isLogin.value = status
|
||||
}
|
||||
|
||||
const setLanguage = (lang: LanguageEnum) => {
|
||||
setPageTitle(router.currentRoute.value)
|
||||
language.value = lang
|
||||
}
|
||||
const setLoginType = (type: 'feishu' | 'upwd') => {
|
||||
loginType.value = type
|
||||
}
|
||||
const setSearchHistory = (list: AppRouteRecord[]) => {
|
||||
searchHistory.value = list
|
||||
}
|
||||
|
||||
const setLockStatus = (status: boolean) => {
|
||||
isLock.value = status
|
||||
}
|
||||
|
||||
const setLockPassword = (password: string) => {
|
||||
lockPassword.value = password
|
||||
}
|
||||
|
||||
const setToken = (newAccessToken: string, newRefreshToken?: string, expiresIn?: number) => {
|
||||
accessToken.value = newAccessToken
|
||||
if (newRefreshToken) {
|
||||
refreshToken.value = newRefreshToken
|
||||
}
|
||||
if (expiresIn) {
|
||||
const expireAt = Date.now() + expiresIn * 1000
|
||||
tokenExpireAt.value = expireAt
|
||||
localStorage.setItem('token_expire_at', String(expireAt))
|
||||
// 检查是否需要重新设置定时器
|
||||
const currentLeftSeconds = Math.floor((expireAt - Date.now()) / 1000)
|
||||
if (currentLeftSeconds > 0) {
|
||||
scheduleTokenRefresh(currentLeftSeconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
const setCompany = (id: string, abbr: string) => {
|
||||
companyid.value = id
|
||||
companyabbr.value = abbr
|
||||
}
|
||||
const setSystems = (id: string, abbr: string) => {
|
||||
systemsid.value = id
|
||||
systemsabbr.value = abbr
|
||||
}
|
||||
// 设置首页
|
||||
const sethome_page = (page: string) => {
|
||||
home_page.value = page
|
||||
}
|
||||
function restoreTokenTimer() {
|
||||
const expireAt = Number(localStorage.getItem('token_expire_at') || '0')
|
||||
if (accessToken.value && expireAt > Date.now()) {
|
||||
const leftSeconds = Math.floor((expireAt - Date.now()) / 1000)
|
||||
scheduleTokenRefresh(leftSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
let isScheduled = false
|
||||
|
||||
function scheduleTokenRefresh(expiresIn: number) {
|
||||
if (refreshTimeout) {
|
||||
clearTimeout(refreshTimeout)
|
||||
refreshTimeout = null
|
||||
}
|
||||
|
||||
// 提前1分钟刷新
|
||||
const refreshTime = (expiresIn - 60) * 1000
|
||||
|
||||
if (refreshTime > 0) {
|
||||
if (!isScheduled) {
|
||||
isScheduled = true
|
||||
refreshTimeout = setTimeout(() => {
|
||||
refreshTokenAction().finally(() => {
|
||||
isScheduled = false // 重置标志位
|
||||
})
|
||||
}, refreshTime)
|
||||
} else {
|
||||
console.log('scheduleTokenRefresh is already scheduled, skipping...')
|
||||
}
|
||||
} else {
|
||||
console.error('Invalid refreshTime:', refreshTime)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTokenAction() {
|
||||
try {
|
||||
const { data } = await UserService.refreshToken({ refresh_token: refreshToken.value })
|
||||
// 动态计算剩余时间
|
||||
const currentTime = Date.now()
|
||||
const expireAt = currentTime + data.expires_in * 1000
|
||||
const leftSeconds = Math.floor((expireAt - currentTime) / 1000)
|
||||
setToken(data.access_token, data.refresh_token, leftSeconds)
|
||||
} catch (e) {
|
||||
console.error('Token refresh failed:', e)
|
||||
logOut()
|
||||
}
|
||||
}
|
||||
|
||||
const logOut = () => {
|
||||
// 清空所有本地缓存
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
systemsabbr.value = ''
|
||||
systemsid.value = ''
|
||||
companyabbr.value = ''
|
||||
companyid.value = ''
|
||||
home_page.value = ''
|
||||
info.value = {}
|
||||
isLogin.value = false
|
||||
isLock.value = false
|
||||
lockPassword.value = ''
|
||||
accessToken.value = ''
|
||||
refreshToken.value = ''
|
||||
if (refreshTimeout) clearTimeout(refreshTimeout) // 清理定时器
|
||||
// 清理工作台标签页
|
||||
useWorktabStore().clearAll()
|
||||
// 清理sessionStorage中的用户信息缓存
|
||||
sessionStorage.removeItem('lastUserInfo')
|
||||
sessionStorage.removeItem('iframeRoutes')
|
||||
// 重置路由状态
|
||||
resetRouterState(router)
|
||||
// 跳转到登录页
|
||||
router.push(RoutesAlias.Login)
|
||||
}
|
||||
|
||||
return {
|
||||
language,
|
||||
isLogin,
|
||||
isLock,
|
||||
lockPassword,
|
||||
info,
|
||||
companyid,
|
||||
companyabbr,
|
||||
systemsid,
|
||||
systemsabbr,
|
||||
home_page,
|
||||
searchHistory,
|
||||
loginType,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
tokenExpireAt,
|
||||
getUserInfo,
|
||||
getSettingState,
|
||||
getWorktabState,
|
||||
setUserInfo,
|
||||
setLoginStatus,
|
||||
setLanguage,
|
||||
setSearchHistory,
|
||||
setLockStatus,
|
||||
setLockPassword,
|
||||
setToken,
|
||||
setCompany,
|
||||
setSystems,
|
||||
setLoginType,
|
||||
sethome_page,
|
||||
logOut,
|
||||
restoreTokenTimer
|
||||
}
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: 'user',
|
||||
storage: localStorage,
|
||||
afterRestore: (ctx: { store: { restoreTokenTimer: () => void } }) => {
|
||||
ctx.store.restoreTokenTimer()
|
||||
}
|
||||
} as any
|
||||
}
|
||||
)
|
||||
@@ -25,6 +25,9 @@ export const useUserStore = defineStore(
|
||||
const refreshToken = ref('')
|
||||
const companyid = ref('')
|
||||
const companyabbr = ref('')
|
||||
const systemsid = ref('')
|
||||
const systemsabbr = ref('')
|
||||
const home_page = ref('')
|
||||
const loginType = ref<'feishu' | 'upwd'>('upwd')
|
||||
const tokenExpireAt = ref<number>(0)
|
||||
|
||||
@@ -65,17 +68,28 @@ export const useUserStore = defineStore(
|
||||
refreshToken.value = newRefreshToken
|
||||
}
|
||||
if (expiresIn) {
|
||||
// 记录绝对过期时间
|
||||
const expireAt = Date.now() + expiresIn * 1000
|
||||
tokenExpireAt.value = expireAt
|
||||
localStorage.setItem('token_expire_at', String(expireAt))
|
||||
scheduleTokenRefresh(expiresIn)
|
||||
// 检查是否需要重新设置定时器
|
||||
const currentLeftSeconds = Math.floor((expireAt - Date.now()) / 1000)
|
||||
if (currentLeftSeconds > 0) {
|
||||
scheduleTokenRefresh(currentLeftSeconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
const setCompany= (id: string, abbr: string) => {
|
||||
const setCompany = (id: string, abbr: string) => {
|
||||
companyid.value = id
|
||||
companyabbr.value = abbr
|
||||
}
|
||||
const setSystems = (id: string, abbr: string) => {
|
||||
systemsid.value = id
|
||||
systemsabbr.value = abbr
|
||||
}
|
||||
// 设置首页
|
||||
const sethome_page = (page: string) => {
|
||||
home_page.value = page
|
||||
}
|
||||
function restoreTokenTimer() {
|
||||
const expireAt = Number(localStorage.getItem('token_expire_at') || '0')
|
||||
if (accessToken.value && expireAt > Date.now()) {
|
||||
@@ -84,31 +98,70 @@ export const useUserStore = defineStore(
|
||||
}
|
||||
}
|
||||
|
||||
let isScheduled = false
|
||||
|
||||
function scheduleTokenRefresh(expiresIn: number) {
|
||||
if (refreshTimeout) clearTimeout(refreshTimeout)
|
||||
if (refreshTimeout) {
|
||||
clearTimeout(refreshTimeout)
|
||||
refreshTimeout = null
|
||||
}
|
||||
|
||||
// 提前1分钟刷新
|
||||
const refreshTime = (expiresIn - 100) * 1000
|
||||
const refreshTime = (expiresIn - 60) * 1000
|
||||
|
||||
if (refreshTime > 0) {
|
||||
refreshTimeout = setTimeout(refreshTokenAction, refreshTime)
|
||||
if (!isScheduled) {
|
||||
isScheduled = true
|
||||
refreshTimeout = setTimeout(() => {
|
||||
refreshTokenAction().finally(() => {
|
||||
isScheduled = false // 重置标志位
|
||||
})
|
||||
}, refreshTime)
|
||||
} else {
|
||||
console.log('scheduleTokenRefresh is already scheduled, skipping...')
|
||||
}
|
||||
} else {
|
||||
console.error('Invalid refreshTime:', refreshTime)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTokenAction() {
|
||||
try {
|
||||
const { data } = await UserService.refreshToken({ refresh_token: refreshToken.value })
|
||||
setToken(data.access_token, data.refresh_token, data.expires_in)
|
||||
// 动态计算剩余时间
|
||||
const currentTime = Date.now()
|
||||
const expireAt = currentTime + data.expires_in * 1000
|
||||
const leftSeconds = Math.floor((expireAt - currentTime) / 1000)
|
||||
setToken(data.access_token, data.refresh_token, leftSeconds)
|
||||
} catch (e) {
|
||||
console.error('Token refresh failed:', e)
|
||||
logOut()
|
||||
}
|
||||
}
|
||||
|
||||
const logOut = () => {
|
||||
// 清理徽标系统
|
||||
import('@/composables/useMenuBadge').then(({ useMenuBadge, resetGlobalInitialized }) => {
|
||||
const { cleanupBadgeSystem, clearAllMenuBadges } = useMenuBadge()
|
||||
clearAllMenuBadges()
|
||||
cleanupBadgeSystem()
|
||||
resetGlobalInitialized()
|
||||
})
|
||||
|
||||
// 清理徽标store中的其他用户数据
|
||||
import('@/store/modules/menuBadge').then(({ useMenuBadgeStore }) => {
|
||||
const menuBadgeStore = useMenuBadgeStore()
|
||||
menuBadgeStore.cleanupOtherUserData()
|
||||
})
|
||||
|
||||
// 清空所有本地缓存
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
systemsabbr.value = ''
|
||||
systemsid.value = ''
|
||||
companyabbr.value = ''
|
||||
companyid.value = ''
|
||||
home_page.value = ''
|
||||
info.value = {}
|
||||
isLogin.value = false
|
||||
isLock.value = false
|
||||
@@ -116,17 +169,13 @@ export const useUserStore = defineStore(
|
||||
accessToken.value = ''
|
||||
refreshToken.value = ''
|
||||
if (refreshTimeout) clearTimeout(refreshTimeout) // 清理定时器
|
||||
|
||||
// 清理工作台标签页
|
||||
useWorktabStore().clearAll()
|
||||
|
||||
// 清理sessionStorage中的用户信息缓存
|
||||
sessionStorage.removeItem('lastUserInfo')
|
||||
sessionStorage.removeItem('iframeRoutes')
|
||||
|
||||
// 重置路由状态
|
||||
resetRouterState(router)
|
||||
|
||||
// 跳转到登录页
|
||||
router.push(RoutesAlias.Login)
|
||||
}
|
||||
@@ -139,6 +188,9 @@ export const useUserStore = defineStore(
|
||||
info,
|
||||
companyid,
|
||||
companyabbr,
|
||||
systemsid,
|
||||
systemsabbr,
|
||||
home_page,
|
||||
searchHistory,
|
||||
loginType,
|
||||
accessToken,
|
||||
@@ -155,7 +207,9 @@ export const useUserStore = defineStore(
|
||||
setLockPassword,
|
||||
setToken,
|
||||
setCompany,
|
||||
setSystems,
|
||||
setLoginType,
|
||||
sethome_page,
|
||||
logOut,
|
||||
restoreTokenTimer
|
||||
}
|
||||
@@ -163,7 +217,10 @@ export const useUserStore = defineStore(
|
||||
{
|
||||
persist: {
|
||||
key: 'user',
|
||||
storage: localStorage
|
||||
}
|
||||
storage: localStorage,
|
||||
afterRestore: (ctx: { store: { restoreTokenTimer: () => void } }) => {
|
||||
ctx.store.restoreTokenTimer()
|
||||
}
|
||||
} as any
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { HOME_PAGE } from '@/router/routesAlias'
|
||||
import { router } from '@/router'
|
||||
import { LocationQueryRaw, Router } from 'vue-router'
|
||||
import { WorkTab } from '@/types'
|
||||
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
interface WorktabState {
|
||||
current: Partial<WorkTab>
|
||||
opened: WorkTab[]
|
||||
@@ -144,6 +144,7 @@ export const useWorktabStore = defineStore(
|
||||
* 关闭指定的选项卡
|
||||
*/
|
||||
const removeTab = (path: string): void => {
|
||||
const { home_page } = useUserStore()
|
||||
const targetTab = getTab(path)
|
||||
const targetIndex = findTabIndex(path)
|
||||
|
||||
@@ -167,9 +168,9 @@ export const useWorktabStore = defineStore(
|
||||
|
||||
// 如果关闭后无标签页,跳转首页
|
||||
if (!hasOpenedTabs.value) {
|
||||
if (path !== HOME_PAGE) {
|
||||
if (path !== home_page) {
|
||||
current.value = {}
|
||||
safeRouterPush({ path: HOME_PAGE })
|
||||
safeRouterPush({ path: home_page })
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -287,12 +288,12 @@ export const useWorktabStore = defineStore(
|
||||
*/
|
||||
const removeAll = (): void => {
|
||||
const hasFixedTabs = opened.value.some((tab) => tab.fixedTab)
|
||||
|
||||
const { home_page } = useUserStore()
|
||||
// 获取可关闭的标签页
|
||||
const closableTabs = opened.value.filter((tab) => {
|
||||
if (!isTabClosable(tab)) return false
|
||||
// 如果有固定标签,则所有可关闭的都可以关闭;否则保留首页
|
||||
return hasFixedTabs || tab.path !== HOME_PAGE
|
||||
return hasFixedTabs || tab.path !== home_page
|
||||
})
|
||||
|
||||
if (closableTabs.length === 0) {
|
||||
@@ -305,18 +306,18 @@ export const useWorktabStore = defineStore(
|
||||
|
||||
// 保留不可关闭的标签页和首页(当没有固定标签时)
|
||||
opened.value = opened.value.filter((tab) => {
|
||||
return !isTabClosable(tab) || (!hasFixedTabs && tab.path === HOME_PAGE)
|
||||
return !isTabClosable(tab) || (!hasFixedTabs && tab.path === home_page)
|
||||
})
|
||||
|
||||
// 处理激活状态
|
||||
if (!hasOpenedTabs.value) {
|
||||
current.value = {}
|
||||
safeRouterPush({ path: HOME_PAGE })
|
||||
safeRouterPush({ path: home_page })
|
||||
return
|
||||
}
|
||||
|
||||
// 选择激活的标签页:优先首页,其次第一个可用标签
|
||||
const homeTab = opened.value.find((tab) => tab.path === HOME_PAGE)
|
||||
const homeTab = opened.value.find((tab) => tab.path === home_page)
|
||||
const targetTab = homeTab || opened.value[0]
|
||||
|
||||
current.value = targetTab
|
||||
|
||||
@@ -9,7 +9,7 @@ declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const ElButton: (typeof import('element-plus/es'))['ElButton']
|
||||
const ElMessage: typeof import('element-plus/es')['ElMessage']
|
||||
const ElMessageBox: (typeof import('element-plus/es'))['ElMessageBox']
|
||||
const ElMessageBox: typeof import('element-plus/es')['ElMessageBox']
|
||||
const ElNotification: (typeof import('element-plus/es'))['ElNotification']
|
||||
const ElPopconfirm: (typeof import('element-plus/es'))['ElPopconfirm']
|
||||
const ElPopover: (typeof import('element-plus/es'))['ElPopover']
|
||||
|
||||
@@ -7,6 +7,8 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
'副本': typeof import('./../components/core/layouts/art-page-content/index - 副本.vue')['default']
|
||||
ApprovalTimeline: typeof import('./../components/custom/ApprovalTimeline.vue')['default']
|
||||
ArtBackToTop: typeof import('./../components/core/base/art-back-to-top/index.vue')['default']
|
||||
ArtBarChart: typeof import('./../components/core/charts/ArtBarChart.vue')['default']
|
||||
ArtBarChartCard: typeof import('./../components/core/cards/art-bar-chart-card/index.vue')['default']
|
||||
@@ -72,12 +74,16 @@ declare module 'vue' {
|
||||
CommentItem: typeof import('./../components/custom/comment-widget/widget/CommentItem.vue')['default']
|
||||
CommentWidget: typeof import('./../components/custom/comment-widget/index.vue')['default']
|
||||
ContainerSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/ContainerSettings.vue')['default']
|
||||
copy: typeof import('./../components/custom/ProcurementDetailView copy.vue')['default']
|
||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
|
||||
ElCalendar: typeof import('element-plus/es')['ElCalendar']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCascader: typeof import('element-plus/es')['ElCascader']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
@@ -93,8 +99,11 @@ declare module 'vue' {
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElImage: typeof import('element-plus/es')['ElImage']
|
||||
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElLink: typeof import('element-plus/es')['ElLink']
|
||||
ElLoading: typeof import('element-plus/es')['ElLoading']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
@@ -117,16 +126,27 @@ declare module 'vue' {
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElText: typeof import('element-plus/es')['ElText']
|
||||
ElTimeline: typeof import('element-plus/es')['ElTimeline']
|
||||
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ElTree: typeof import('element-plus/es')['ElTree']
|
||||
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
ElWatermark: typeof import('element-plus/es')['ElWatermark']
|
||||
FilePreview: typeof import('./../components/custom/FilePreview.vue')['default']
|
||||
HorizontalSubmenu: typeof import('./../components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue')['default']
|
||||
'Index - 副本': typeof import('./../components/core/layouts/art-page-content/index - 副本.vue')['default']
|
||||
InternalStaffView: typeof import('./../components/custom/InternalStaffView.vue')['default']
|
||||
InternalStaffViews: typeof import('./../components/custom/InternalStaffViews.vue')['default']
|
||||
LoginLeftView: typeof import('./../components/core/views/login/LoginLeftView.vue')['default']
|
||||
MenuLayoutSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/MenuLayoutSettings.vue')['default']
|
||||
MenuStyleSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/MenuStyleSettings.vue')['default']
|
||||
MultiFileUpload: typeof import('./../components/core/forms/MultiFileUpload.vue')['default']
|
||||
OrderDetailContent: typeof import('./../components/custom/OrderDetailContent.vue')['default']
|
||||
PrintPreview: typeof import('./../components/custom/PrintPreview.vue')['default']
|
||||
ProcurementDetailView: typeof import('./../components/custom/ProcurementDetailView.vue')['default']
|
||||
'ProcurementDetailView copy': typeof import('./../components/custom/ProcurementDetailView copy.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SectionTitle: typeof import('./../components/core/layouts/art-settings-panel/widget/SectionTitle.vue')['default']
|
||||
@@ -135,6 +155,7 @@ declare module 'vue' {
|
||||
SettingItem: typeof import('./../components/core/layouts/art-settings-panel/widget/SettingItem.vue')['default']
|
||||
SidebarSubmenu: typeof import('./../components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue')['default']
|
||||
ThemeSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/ThemeSettings.vue')['default']
|
||||
WorkChatDrawer: typeof import('./../components/custom/chat/WorkChatDrawer.vue')['default']
|
||||
}
|
||||
export interface ComponentCustomProperties {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface RouteMeta extends Record<string | number | symbol, unknown> {
|
||||
authList?: Array<{
|
||||
title: string
|
||||
auth_mark: string
|
||||
authMark?: string // 兼容旧版
|
||||
}>
|
||||
/** 是否为一级菜单 */
|
||||
isFirstLevel?: boolean
|
||||
|
||||
@@ -29,6 +29,32 @@ declare namespace Api {
|
||||
|
||||
/** 启用状态 */
|
||||
type EnableStatus = '1' | '2'
|
||||
|
||||
interface CommonInfo {
|
||||
data: never[]
|
||||
}
|
||||
|
||||
/** 列表数据 */
|
||||
interface CommonListData {
|
||||
records: CommonInfo[]
|
||||
current: number
|
||||
size: number
|
||||
total: number
|
||||
}
|
||||
interface CommonListDatad {
|
||||
data: CommonInfo[]
|
||||
current: number
|
||||
size: number
|
||||
total: number
|
||||
}
|
||||
interface AddResponse {
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
interface CommonListDatad1 {
|
||||
data: never[]
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
/** 认证类型 */
|
||||
@@ -116,17 +142,19 @@ declare namespace Api {
|
||||
interface AddParams {
|
||||
categorie_id: any
|
||||
content: any
|
||||
attachment:any
|
||||
attachment: any
|
||||
}
|
||||
interface AddResponse {
|
||||
code: string
|
||||
message: string
|
||||
status: any
|
||||
data?: any
|
||||
}
|
||||
/** 修改状态参数 */
|
||||
interface StatusParams {
|
||||
id: any
|
||||
msg: any
|
||||
status:any
|
||||
status: any
|
||||
}
|
||||
//分类
|
||||
interface CategorieInfo {
|
||||
@@ -165,6 +193,8 @@ declare namespace Api {
|
||||
|
||||
/** 列表数据 */
|
||||
interface CrmListData {
|
||||
country_id: any
|
||||
channel_id: any
|
||||
data: CrmInfo[]
|
||||
current: number
|
||||
size: number
|
||||
|
||||
@@ -47,6 +47,7 @@ axiosInstance.interceptors.request.use(
|
||||
const { language } = useUserStore()
|
||||
const { loginType } = useUserStore()
|
||||
const { companyabbr } = useUserStore()
|
||||
const { systemsabbr } = useUserStore()
|
||||
request.headers.set('Fdesk-Language', language)
|
||||
request.headers.set('Fdesk-Corp-Abbr', companyabbr)
|
||||
request.headers.set('Fdesk-System-Code', 'fdesk')
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 菜单徽标管理工具
|
||||
* 用于管理菜单徽标功能
|
||||
*/
|
||||
|
||||
import { useMenuBadgeStore } from '@/store/modules/menuBadge'
|
||||
|
||||
/**
|
||||
* 菜单徽标管理类
|
||||
*/
|
||||
export class MenuBadgeTest {
|
||||
private menuBadgeStore = useMenuBadgeStore()
|
||||
|
||||
/**
|
||||
* 直接更新徽标
|
||||
*/
|
||||
updateBadge(path: string, count: number, type: 'dot' | 'text' = 'text') {
|
||||
// console.log(`直接更新徽标: ${path} = ${count} (${type})`)
|
||||
this.menuBadgeStore.updateBadgeData(path, count, type)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新徽标
|
||||
*/
|
||||
batchUpdateBadges(updates: Array<{ path: string; count: number; type?: 'dot' | 'text' }>) {
|
||||
// console.log('批量更新徽标:', updates)
|
||||
this.menuBadgeStore.batchUpdateBadgeData(updates)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除徽标
|
||||
*/
|
||||
clearBadge(path: string) {
|
||||
// console.log(`清除徽标: ${path}`)
|
||||
this.menuBadgeStore.clearBadge(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有徽标
|
||||
*/
|
||||
clearAllBadges() {
|
||||
// console.log('清除所有徽标')
|
||||
this.menuBadgeStore.clearAllBadges()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取徽标状态
|
||||
*/
|
||||
getBadgeStatus(path: string) {
|
||||
const data = this.menuBadgeStore.getBadgeData(path)
|
||||
// console.log(`徽标状态 ${path}:`, data)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有徽标状态
|
||||
*/
|
||||
getAllBadgeStatus() {
|
||||
const allData = this.menuBadgeStore.allBadgeData
|
||||
// console.log('所有徽标状态:', allData)
|
||||
return allData
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待处理更新数量
|
||||
*/
|
||||
getPendingUpdatesCount() {
|
||||
const count = this.menuBadgeStore.getPendingUpdatesCount()
|
||||
// console.log(`待处理更新数量: ${count}`)
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置页面加载状态
|
||||
*/
|
||||
setPageReady(ready: boolean) {
|
||||
// console.log(`设置页面加载状态: ${ready}`)
|
||||
this.menuBadgeStore.setPageReady(ready)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查实际菜单路径
|
||||
*/
|
||||
checkActualMenuPaths() {
|
||||
// console.log('检查实际菜单路径...')
|
||||
|
||||
try {
|
||||
// 获取菜单store
|
||||
import('@/store/modules/menu')
|
||||
.then(({ useMenuStore }) => {
|
||||
const menuStore = useMenuStore()
|
||||
this.printMenuPaths(menuStore.menuList)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('动态导入菜单store失败:', error)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('检查菜单路径失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private printMenuPaths(menuList: any[]) {
|
||||
// console.log('当前菜单列表:', menuList)
|
||||
|
||||
// 递归打印所有菜单路径
|
||||
const printMenuPaths = (menus: any[], level = 0) => {
|
||||
menus.forEach((menu) => {
|
||||
// const indent = ' '.repeat(level)
|
||||
// console.log(`${indent}${menu.path} - ${menu.meta?.title}`)
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
printMenuPaths(menu.children, level + 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (menuList && menuList.length > 0) {
|
||||
printMenuPaths(menuList)
|
||||
} else {
|
||||
console.log('菜单列表为空,可能还未加载完成')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行基础功能测试
|
||||
*/
|
||||
runBasicTest() {
|
||||
// console.log('开始运行基础功能测试...')
|
||||
|
||||
// 1. 测试页面未加载完成时的更新
|
||||
// console.log('\n=== 测试页面未加载完成时的更新 ===')
|
||||
this.setPageReady(false)
|
||||
this.updateBadge('/admin/approval', 3, 'text')
|
||||
this.updateBadge('/admin/stock', 1, 'dot')
|
||||
|
||||
// 2. 设置页面加载完成
|
||||
// console.log('\n=== 设置页面加载完成 ===')
|
||||
this.setPageReady(true)
|
||||
|
||||
// 3. 测试批量更新
|
||||
// console.log('\n=== 测试批量更新 ===')
|
||||
this.batchUpdateBadges([
|
||||
{ path: '/admin/approval', count: 3, type: 'text' },
|
||||
{ path: '/admin/stock', count: 1, type: 'dot' }
|
||||
])
|
||||
|
||||
// 4. 显示最终状态
|
||||
// console.log('\n=== 最终状态 ===')
|
||||
this.getAllBadgeStatus()
|
||||
|
||||
// console.log('\n基础功能测试完成!')
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查WebSocket连接状态
|
||||
*/
|
||||
checkWebSocketStatus() {
|
||||
// console.log('检查WebSocket连接状态...')
|
||||
|
||||
// 检查全局WebSocket状态函数
|
||||
if (typeof window !== 'undefined' && (window as any).getWebSocketStatus) {
|
||||
const status = (window as any).getWebSocketStatus()
|
||||
console.log('WebSocket状态:', status)
|
||||
} else {
|
||||
console.log('WebSocket状态函数不可用')
|
||||
}
|
||||
|
||||
// 检查菜单徽标系统初始化状态
|
||||
const pendingCount = this.menuBadgeStore.getPendingUpdatesCount()
|
||||
console.log('待处理的徽标更新数量:', pendingCount)
|
||||
|
||||
if (pendingCount > 0) {
|
||||
console.log('待处理的更新:', this.menuBadgeStore.getPendingUpdates())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局测试实例
|
||||
const menuBadgeTest = new MenuBadgeTest()
|
||||
|
||||
// 将测试工具挂载到全局对象,方便在控制台使用
|
||||
if (typeof window !== 'undefined') {
|
||||
// @ts-expect-error - 挂载到全局对象用于调试
|
||||
window.menuBadgeTest = menuBadgeTest
|
||||
}
|
||||
|
||||
export default menuBadgeTest
|
||||