Compare commits
31
Commits
f4dff79f5a
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
431371a13c | ||
|
|
41d9c49508 | ||
|
|
cf7d21cd04 | ||
|
|
01a5360bd9 | ||
|
|
66304c28e9 | ||
|
|
528f2e0b3d | ||
|
|
9283fa88d1 | ||
|
|
23dd3532bc | ||
|
|
cfa8eb6d8d | ||
|
|
5c8fa04585 | ||
|
|
fbf163e3f2 | ||
|
|
fb3f8ae277 | ||
|
|
677d3e1154 | ||
|
|
1fa2f11057 | ||
|
|
3e744b63f1 | ||
|
|
d86bc810cc | ||
|
|
82d2911766 | ||
|
|
dec9fd96e3 | ||
|
|
3e3bd02583 | ||
|
|
aaeb519bbc | ||
|
|
530cfcd939 | ||
|
|
cce677e3ff | ||
|
|
69a28cce40 | ||
|
|
cb2e95562a | ||
|
|
c8ff18d190 | ||
|
|
07dedf45f9 | ||
|
|
dbe543ae9a | ||
|
|
deefac848b | ||
|
|
75a36d36ad | ||
|
|
f16c1e6eb7 | ||
|
|
18aa169b69 |
No files matched your search
@@ -21,7 +21,8 @@ VITE_ADMIN_PROXY_PATH = http://localhost:9999
|
|||||||
VITE_PWD_ENC_KEY='pigxpigxpigxpigx'
|
VITE_PWD_ENC_KEY='pigxpigxpigxpigx'
|
||||||
|
|
||||||
# OAUTH2 密码模式客户端信息
|
# OAUTH2 密码模式客户端信息
|
||||||
VITE_OAUTH2_PASSWORD_CLIENT='pig:pig'
|
VITE_OAUTH2_PASSWORD_CLIENT='pjcloud:picloudqqq'
|
||||||
|
# VITE_OAUTH2_PASSWORD_CLIENT='pig:pig'
|
||||||
|
|
||||||
# OAUTH2 短信客户端信息
|
# OAUTH2 短信客户端信息
|
||||||
VITE_OAUTH2_MOBILE_CLIENT='app:app'
|
VITE_OAUTH2_MOBILE_CLIENT='app:app'
|
||||||
|
|||||||
+4
-4
@@ -10,15 +10,15 @@ ENV = 'development'
|
|||||||
# ADMIN 服务地址
|
# ADMIN 服务地址
|
||||||
# VITE_ADMIN_PROXY_PATH = http://123.157.250.162:8089/api/ # 测试线上
|
# VITE_ADMIN_PROXY_PATH = http://123.157.250.162:8089/api/ # 测试线上
|
||||||
# VITE_ADMIN_PROXY_PATH = http://123.156.230.15:8089/api/ # 测试线上-过期
|
# VITE_ADMIN_PROXY_PATH = http://123.156.230.15:8089/api/ # 测试线上-过期
|
||||||
# VITE_ADMIN_PROXY_PATH = http://localhost:9999/
|
VITE_ADMIN_PROXY_PATH = http://localhost:9999/
|
||||||
VITE_ADMIN_PROXY_PATH = https://dzc-test-1.fy.takin.cc # 邓-本地
|
# VITE_ADMIN_PROXY_PATH = https://dzc-test-1.fy.takin.cc # 邓-本地
|
||||||
# VITE_ADMIN_PROXY_PATH = https://www.pjzhhq.cn/api # 正式环境线上
|
# VITE_ADMIN_PROXY_PATH = https://www.pjzhhq.cn/api # 正式环境线上
|
||||||
|
|
||||||
# 图片访问基础地址
|
# 图片访问基础地址
|
||||||
# VITE_IMAGE_BASE_PATH = http://123.157.250.162:8089/api/ # 测试线上
|
# VITE_IMAGE_BASE_PATH = http://123.157.250.162:8089/api/ # 测试线上
|
||||||
# VITE_IMAGE_BASE_PATH = http://123.156.230.15:8089/api/ # 测试线上-过期
|
# VITE_IMAGE_BASE_PATH = http://123.156.230.15:8089/api/ # 测试线上-过期
|
||||||
# VITE_IMAGE_BASE_PATH = http://localhost:9999
|
VITE_IMAGE_BASE_PATH = http://localhost:9999
|
||||||
VITE_IMAGE_BASE_PATH = https://dzc-test-1.fy.takin.cc # 邓-本地
|
# VITE_IMAGE_BASE_PATH = https://dzc-test-1.fy.takin.cc # 邓-本地
|
||||||
# VITE_IMAGE_BASE_PATH = https://www.pjzhhq.cn/api # 正式环境线上
|
# VITE_IMAGE_BASE_PATH = https://www.pjzhhq.cn/api # 正式环境线上
|
||||||
|
|
||||||
# 代码生成服务地址
|
# 代码生成服务地址
|
||||||
|
|||||||
@@ -21,3 +21,9 @@ VITE_IMAGE_BASE_PATH = https://www.pjzhhq.cn/api
|
|||||||
|
|
||||||
# 代码生成服务地址
|
# 代码生成服务地址
|
||||||
VITE_GEN_PROXY_PATH = http://123.156.230.15:5003
|
VITE_GEN_PROXY_PATH = http://123.156.230.15:5003
|
||||||
|
|
||||||
|
|
||||||
|
VITE_FILE_ACCESS_APP_ID="admin"
|
||||||
|
VITE_FILE_ACCESS_KEY_ID="v1"
|
||||||
|
VITE_FILE_ACCESS_KEY="5UhL2DT+y//+0qCF4Abm9h6MPbEOvJfGuLf2eZ28QSw="
|
||||||
|
VITE_FILE_ACCESS_ALLOWED_ORIGINS="https://www.pjzhhq.cn,https://stgl.pj.gov.cn"
|
||||||
+5
-1
@@ -51,4 +51,8 @@ coverage/
|
|||||||
.v2c/
|
.v2c/
|
||||||
.video_agent/
|
.video_agent/
|
||||||
.mimosa/
|
.mimosa/
|
||||||
.zcode/
|
.zcode/
|
||||||
|
.zcodeignore
|
||||||
|
|
||||||
|
# Local file-access credentials
|
||||||
|
.env.*.local
|
||||||
+3
-3
@@ -7,10 +7,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --force",
|
"dev": "vite --force",
|
||||||
"dev:local": "vite --force --mode dev-local",
|
"dev:local": "vite --force --mode dev-local",
|
||||||
"build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build",
|
"build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build --mode prod",
|
||||||
"build:prod": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build --mode prod",
|
"build:prod": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build --mode prod",
|
||||||
"build:dev": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build --mode dev",
|
"build:dev": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build --mode dev",
|
||||||
"build:docker": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build --outDir ./docker/dist/",
|
"build:docker": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build --mode prod --outDir ./docker/dist/",
|
||||||
"lint:eslint": "eslint --fix --ext .js,.ts,.vue ./src",
|
"lint:eslint": "eslint --fix --ext .js,.ts,.vue ./src",
|
||||||
"prettier": "prettier --write ."
|
"prettier": "prettier --write ."
|
||||||
},
|
},
|
||||||
@@ -119,4 +119,4 @@
|
|||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://gitee.com/log4j/pig"
|
"url": "https://gitee.com/log4j/pig"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
@@ -47,8 +47,9 @@ export function refreshCache() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getDetails(obj: Object) {
|
export function getDetails(obj: Object) {
|
||||||
|
// 走需登录的查询接口;getClientDetailsById 仅服务间 Inner 调用,禁止前端匿名访问
|
||||||
return request({
|
return request({
|
||||||
url: '/admin/client/getClientDetailsById/' + obj,
|
url: '/admin/client/' + obj,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -64,5 +65,8 @@ export function validateclientId(rule: any, value: any, callback: any, isEdit: b
|
|||||||
} else {
|
} else {
|
||||||
callback();
|
callback();
|
||||||
}
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
// 无权限或不存在时按不存在处理,避免误拦新增
|
||||||
|
callback();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import request from "/@/utils/request"
|
import request from "/@/utils/request"
|
||||||
|
import { submitExportTask } from "/@/api/asyncTask"
|
||||||
|
|
||||||
|
export const submitDeptExport = (query: Record<string, unknown>) => submitExportTask("/admin/dept/export-async", query)
|
||||||
|
|
||||||
export const deptTree = (params?: Object) => {
|
export const deptTree = (params?: Object) => {
|
||||||
return request({
|
return request({
|
||||||
|
|||||||
+53
-53
@@ -1,138 +1,138 @@
|
|||||||
import request from '/@/utils/request';
|
import request from "/@/utils/request"
|
||||||
|
|
||||||
export const getDicts = (type: String) => {
|
export const getDicts = (type: String) => {
|
||||||
return request({
|
return request({
|
||||||
url: `/admin/dict/type/${type}`,
|
url: `/admin/dict/type/${type}`,
|
||||||
method: 'get',
|
method: "get",
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
export function fetchList(query: any) {
|
export function fetchList(query: any) {
|
||||||
return request({
|
return request({
|
||||||
url: '/admin/dict/list',
|
url: "/admin/dict/list",
|
||||||
method: 'get',
|
method: "get",
|
||||||
params: query,
|
params: query,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchItemList(query: any) {
|
export function fetchItemList(query: any) {
|
||||||
return request({
|
return request({
|
||||||
url: '/admin/dict/item/page',
|
url: "/admin/dict/item/page",
|
||||||
method: 'get',
|
method: "get",
|
||||||
params: query,
|
params: query,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addItemObj(obj: any) {
|
export function addItemObj(obj: any) {
|
||||||
return request({
|
return request({
|
||||||
url: '/admin/dict/item',
|
url: "/admin/dict/item",
|
||||||
method: 'post',
|
method: "post",
|
||||||
data: obj,
|
data: obj,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getItemObj(id: string) {
|
export function getItemObj(id: string) {
|
||||||
return request({
|
return request({
|
||||||
url: '/admin/dict/item/details/' + id,
|
url: "/admin/dict/item/details/" + id,
|
||||||
method: 'get',
|
method: "get",
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getItemDetails(obj: object) {
|
export function getItemDetails(obj: object) {
|
||||||
return request({
|
return request({
|
||||||
url: '/admin/dict/item/details',
|
url: "/admin/dict/item/details",
|
||||||
method: 'get',
|
method: "get",
|
||||||
params: obj,
|
params: obj,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function delItemObj(id: string) {
|
export function delItemObj(id: string) {
|
||||||
return request({
|
return request({
|
||||||
url: '/admin/dict/item/' + id,
|
url: "/admin/dict/item/" + id,
|
||||||
method: 'delete',
|
method: "delete",
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function putItemObj(obj: any) {
|
export function putItemObj(obj: any) {
|
||||||
return request({
|
return request({
|
||||||
url: '/admin/dict/item',
|
url: "/admin/dict/item",
|
||||||
method: 'put',
|
method: "put",
|
||||||
data: obj,
|
data: obj,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addObj(obj: any) {
|
export function addObj(obj: any) {
|
||||||
return request({
|
return request({
|
||||||
url: '/admin/dict',
|
url: "/admin/dict",
|
||||||
method: 'post',
|
method: "post",
|
||||||
data: obj,
|
data: obj,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getObj(id: string) {
|
export function getObj(id: string) {
|
||||||
return request({
|
return request({
|
||||||
url: '/admin/dict/details/' + id,
|
url: "/admin/dict/details/" + id,
|
||||||
method: 'get',
|
method: "get",
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getObjDetails(obj: object) {
|
export function getObjDetails(obj: object) {
|
||||||
return request({
|
return request({
|
||||||
url: '/admin/dict/details',
|
url: "/admin/dict/details",
|
||||||
method: 'get',
|
method: "get",
|
||||||
params: obj,
|
params: obj,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function delObj(ids: Object) {
|
export function delObj(ids: Object) {
|
||||||
return request({
|
return request({
|
||||||
url: '/admin/dict',
|
url: "/admin/dict",
|
||||||
method: 'delete',
|
method: "delete",
|
||||||
data: ids,
|
data: ids,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function putObj(obj: any) {
|
export function putObj(obj: any) {
|
||||||
return request({
|
return request({
|
||||||
url: '/admin/dict',
|
url: "/admin/dict",
|
||||||
method: 'put',
|
method: "put",
|
||||||
data: obj,
|
data: obj,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function refreshCache() {
|
export function refreshCache() {
|
||||||
return request({
|
return request({
|
||||||
url: '/admin/dict/sync',
|
url: "/admin/dict/sync",
|
||||||
method: 'put',
|
method: "put",
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateDictType(rule: any, value: any, callback: any, isEdit: boolean) {
|
export function validateDictType(rule: any, value: any, callback: any, isEdit: boolean) {
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
return callback();
|
return callback()
|
||||||
}
|
}
|
||||||
|
|
||||||
getObjDetails({ dictType: value }).then((response) => {
|
getObjDetails({ dictType: value }).then((response) => {
|
||||||
const result = response.data;
|
const result = response.data
|
||||||
if (result !== null) {
|
if (result !== null) {
|
||||||
callback(new Error('字典已经存在'));
|
callback(new Error("字典已经存在"))
|
||||||
} else {
|
} else {
|
||||||
callback();
|
callback()
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateDictItemLabel(rule: any, value: any, callback: any, type: string, isEdit: boolean) {
|
export function validateDictItemLabel(rule: any, value: any, callback: any, type: string, isEdit: boolean) {
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
return callback();
|
return callback()
|
||||||
}
|
}
|
||||||
|
|
||||||
getItemDetails({ dictType: type, label: value }).then((response) => {
|
getItemDetails({ dictType: type, label: value }).then((response) => {
|
||||||
const result = response.data;
|
const result = response.data
|
||||||
if (result !== null) {
|
if (result !== null) {
|
||||||
callback(new Error('标签已经存在'));
|
callback(new Error("标签已经存在"))
|
||||||
} else {
|
} else {
|
||||||
callback();
|
callback()
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import request from '/@/utils/request';
|
import request from '/@/utils/request';
|
||||||
|
import { submitExportTask } from '/@/api/asyncTask';
|
||||||
|
|
||||||
|
export const submitLogExport = (query: Record<string, unknown>) => submitExportTask('/admin/log/export-async', query);
|
||||||
|
|
||||||
export const pageList = (params?: Object) => {
|
export const pageList = (params?: Object) => {
|
||||||
return request({
|
return request({
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import request from '/@/utils/request'
|
||||||
|
|
||||||
|
/** Seata 提交探测 */
|
||||||
|
export function seataProbeCommit() {
|
||||||
|
return request({
|
||||||
|
url: '/admin/seataProbe/commit',
|
||||||
|
method: 'post',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seata 回滚探测 */
|
||||||
|
export function seataProbeRollback() {
|
||||||
|
return request({
|
||||||
|
url: '/admin/seataProbe/rollback',
|
||||||
|
method: 'post',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { submitExportTask } from "/@/api/asyncTask"
|
||||||
import request from "/@/utils/request"
|
import request from "/@/utils/request"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -165,3 +166,16 @@ export function getSimpleUserAll(params?: { name?: string; mobile?: string; user
|
|||||||
params,
|
params,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按原筛选条件提交后台分批导出。 */
|
||||||
|
export const submitUserExport = (query: Record<string, unknown>) => submitExportTask("/admin/user/export-async", query)
|
||||||
|
|
||||||
|
/** 线上已执行完,暂时关闭修复弱口令接口
|
||||||
|
export const fixPhoneAsPassword = (password: string) => {
|
||||||
|
return request({
|
||||||
|
url: "/admin/user/fix-phone-as-password",
|
||||||
|
method: "post",
|
||||||
|
data: { password },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
*/
|
||||||
Regular → Executable
File mode changed.
@@ -0,0 +1,84 @@
|
|||||||
|
import request from "/@/utils/request"
|
||||||
|
|
||||||
|
/** 通用异步任务进度状态,由后端 Redis 进度接口返回。 */
|
||||||
|
export interface AsyncTaskProgress {
|
||||||
|
/** 提交接口返回的不透明任务 ID。 */
|
||||||
|
taskId: string
|
||||||
|
/** 业务 Redis Key 命名空间,不包含租户、用户等服务端隔离信息。 */
|
||||||
|
redisKey: string
|
||||||
|
/** 任务展示名称。 */
|
||||||
|
taskName: string
|
||||||
|
/** 固定状态编码:PENDING、RUNNING、SUCCESS、FAILED。 */
|
||||||
|
status: string
|
||||||
|
/** 本次任务需要处理的总项目数。 */
|
||||||
|
total: number
|
||||||
|
/** 当前已经处理的项目数。 */
|
||||||
|
completed: number
|
||||||
|
/** 当前完成百分比,取值为 0 至 100。 */
|
||||||
|
percentage: number
|
||||||
|
/** 当前进度或最终结果说明。 */
|
||||||
|
message: string
|
||||||
|
/** 是否已进入成功或失败终态。 */
|
||||||
|
finished: boolean
|
||||||
|
/** 终态是否成功;任务未结束时为 false。 */
|
||||||
|
success: boolean
|
||||||
|
/** 导出等任务成功后生成的文件下载地址;导入任务为空。 */
|
||||||
|
resultFileUrl?: string
|
||||||
|
/** 导出等任务成功后建议使用的下载文件名称;导入任务为空。 */
|
||||||
|
resultFileName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用异步任务轮询配置,导入、导出等业务只需提供自身命名空间和必要覆盖项。 */
|
||||||
|
export interface AsyncTaskPollingConfig {
|
||||||
|
/** 通用进度查询接口地址。 */
|
||||||
|
progressUrl: string
|
||||||
|
/** 当前业务使用的 Redis Key 命名空间。 */
|
||||||
|
redisKey: string
|
||||||
|
/** 提交接口响应数据中的任务 ID 字段名,默认 taskId。 */
|
||||||
|
taskIdField?: string
|
||||||
|
/** 两次进度查询之间的间隔,单位毫秒,默认 1000。 */
|
||||||
|
pollIntervalMs?: number
|
||||||
|
/** 单次持续轮询的最长时间,单位毫秒,默认 2 小时。 */
|
||||||
|
pollTimeoutMs?: number
|
||||||
|
/** 连续查询失败多少次后暂停轮询并允许用户重试,默认 5 次。 */
|
||||||
|
maxConsecutiveErrors?: number
|
||||||
|
/** 是否等任务进入终态或轮询中断后再显示弹窗,默认 false。 */
|
||||||
|
showDialogOnFinished?: boolean
|
||||||
|
/** 任务执行期间展示的等待提示;为空时不额外展示。 */
|
||||||
|
waitingMessage?: string
|
||||||
|
/** 成功终态存在结果文件时的下载按钮文案,默认“下载结果”。 */
|
||||||
|
downloadButtonText?: string
|
||||||
|
/** 任务成功后由业务组件展示的覆盖文案。 */
|
||||||
|
successMessage?: string
|
||||||
|
/** 任务失败后由业务组件展示的覆盖文案。 */
|
||||||
|
failureMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用异步任务进度查询参数。 */
|
||||||
|
export interface AsyncTaskProgressQuery {
|
||||||
|
/** 业务 Redis Key 命名空间。 */
|
||||||
|
redisKey: string
|
||||||
|
/** 提交接口返回的任务 ID。 */
|
||||||
|
taskId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前登录用户创建的异步任务进度。
|
||||||
|
*
|
||||||
|
* @param progressUrl 业务配置的通用进度接口地址
|
||||||
|
* @param query Redis Key 命名空间和任务 ID
|
||||||
|
* @returns 包含当前任务进度的统一响应
|
||||||
|
*/
|
||||||
|
export const getAsyncTaskProgress = (progressUrl: string, query: AsyncTaskProgressQuery) => {
|
||||||
|
return request({
|
||||||
|
url: progressUrl,
|
||||||
|
method: "get",
|
||||||
|
params: query,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交导出快照;仅透传原有排序参数,分页由服务端导出批次控制。 */
|
||||||
|
export const submitExportTask = (url: string, query: Record<string, unknown>) => request({
|
||||||
|
url, method: "post", data: query,
|
||||||
|
params: { ascs: query.ascs, descs: query.descs },
|
||||||
|
})
|
||||||
+62
-43
@@ -1,3 +1,4 @@
|
|||||||
|
import { submitExportTask } from "/@/api/asyncTask"
|
||||||
import request from "/@/utils/request"
|
import request from "/@/utils/request"
|
||||||
|
|
||||||
/** 订单详情中的单张识别证据,字段来自 HardOrderEvidenceImageVo。 */
|
/** 订单详情中的单张识别证据,字段来自 HardOrderEvidenceImageVo。 */
|
||||||
@@ -54,58 +55,73 @@ export interface HardOrderDetail {
|
|||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 订单列表统计查询条件;字段与订单分页筛选保持一致。 */
|
/** 与订单列表一致的筛选条件,不含分页、排序和勾选记录。 */
|
||||||
export type HardOrderStatisticsQuery = Record<string, any>
|
export type HardOrderStatisticsQuery = Record<string, unknown>
|
||||||
|
|
||||||
/** 订单按餐段汇总的统计,金额单位均为分。 */
|
/** 金额为整数分字符串;计数为字符串,避免累计精度丢失。 */
|
||||||
export interface HardOrderMealStatistics {
|
|
||||||
/** 餐段字典编码;历史订单未记录时为空。 */
|
|
||||||
hourType?: string | null
|
|
||||||
/** 订单数。 */
|
|
||||||
orderCount?: number
|
|
||||||
/** 购物车商品份数。 */
|
|
||||||
productCount?: number
|
|
||||||
/** 商品总价。 */
|
|
||||||
totalPrice?: number
|
|
||||||
/** 额外支付。 */
|
|
||||||
extraPrice?: number
|
|
||||||
/** 配送费。 */
|
|
||||||
deliveryPrice?: number
|
|
||||||
/** 实际支付。 */
|
|
||||||
payPrice?: number
|
|
||||||
/** 已退款金额。 */
|
|
||||||
refundPrice?: number
|
|
||||||
/** 订单实际金额,即实际支付减已退款。 */
|
|
||||||
actualPrice?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 订单统计通用汇总,金额单位均为分。 */
|
|
||||||
export interface HardOrderStatisticsSummary {
|
export interface HardOrderStatisticsSummary {
|
||||||
orderCount?: number
|
orderCount: string
|
||||||
productCount?: number
|
totalPrice: string
|
||||||
totalPrice?: number
|
extraPrice: string
|
||||||
extraPrice?: number
|
couponPrice: string
|
||||||
deliveryPrice?: number
|
payPrice: string
|
||||||
payPrice?: number
|
refundPrice: string
|
||||||
refundPrice?: number
|
netPrice: string
|
||||||
actualPrice?: number
|
|
||||||
meals?: HardOrderMealStatistics[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 订单按预计使用日期拆分的统计。 */
|
|
||||||
export interface HardOrderDailyStatistics extends HardOrderStatisticsSummary {
|
export interface HardOrderDailyStatistics extends HardOrderStatisticsSummary {
|
||||||
date?: string
|
date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HardOrderPayTypeStatistics extends HardOrderStatisticsSummary {
|
||||||
|
payType: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HardOrderMerchantStatistics extends HardOrderStatisticsSummary {
|
||||||
|
merchantId: string | null
|
||||||
|
merchantName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HardOrderStatisticsData {
|
||||||
|
summary: HardOrderStatisticsSummary
|
||||||
|
daily: HardOrderDailyStatistics[]
|
||||||
|
payTypes: HardOrderPayTypeStatistics[]
|
||||||
|
withoutPayTime: HardOrderStatisticsSummary
|
||||||
|
generatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 订单统计接口统一响应。 */
|
|
||||||
export interface HardOrderStatisticsResponse {
|
export interface HardOrderStatisticsResponse {
|
||||||
data: {
|
data: HardOrderStatisticsData
|
||||||
total?: HardOrderStatisticsSummary
|
|
||||||
daily?: HardOrderDailyStatistics[]
|
|
||||||
}
|
|
||||||
msg?: string
|
msg?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HardOrderMerchantStatisticsPage {
|
||||||
|
records: HardOrderMerchantStatistics[]
|
||||||
|
current: number
|
||||||
|
size: number
|
||||||
|
hasMore: boolean
|
||||||
|
generatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statistics(query: HardOrderStatisticsQuery) {
|
||||||
|
return request<HardOrderStatisticsData, HardOrderStatisticsResponse>({
|
||||||
|
url: "/hard/hard-order/statistics",
|
||||||
|
method: "post",
|
||||||
|
data: query,
|
||||||
|
timeout: 25000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function merchantStatistics(query: HardOrderStatisticsQuery, current = 1, size = 20) {
|
||||||
|
return request<HardOrderMerchantStatisticsPage, { data: HardOrderMerchantStatisticsPage }>({
|
||||||
|
url: "/hard/hard-order/statistics/merchants",
|
||||||
|
method: "post",
|
||||||
|
params: { current, size },
|
||||||
|
data: query,
|
||||||
|
timeout: 25000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 离线订单手动支付
|
* 离线订单手动支付
|
||||||
*/
|
*/
|
||||||
@@ -126,7 +142,7 @@ export interface HardDoorManualSettleItem {
|
|||||||
/**
|
/**
|
||||||
* 智能柜订单手动结算(第三方识别不可用时,人工核对视频后结算)
|
* 智能柜订单手动结算(第三方识别不可用时,人工核对视频后结算)
|
||||||
*/
|
*/
|
||||||
export function manualSettle(data: { orderId: string | number; items: HardDoorManualSettleItem[] }) {
|
export function manualSettle(data: { orderId: string | number; items: HardDoorManualSettleItem[]; reason?: string }) {
|
||||||
return request({
|
return request({
|
||||||
url: "/hard/hard-order/manual-settle",
|
url: "/hard/hard-order/manual-settle",
|
||||||
method: "post",
|
method: "post",
|
||||||
@@ -137,7 +153,7 @@ export function manualSettle(data: { orderId: string | number; items: HardDoorMa
|
|||||||
/**
|
/**
|
||||||
* 智能柜订单手动取消(用户未拿商品)
|
* 智能柜订单手动取消(用户未拿商品)
|
||||||
*/
|
*/
|
||||||
export function manualCancel(data: { orderId: string | number }) {
|
export function manualCancel(data: { orderId: string | number; reason?: string }) {
|
||||||
return request({
|
return request({
|
||||||
url: "/hard/hard-order/manual-cancel",
|
url: "/hard/hard-order/manual-cancel",
|
||||||
method: "post",
|
method: "post",
|
||||||
@@ -207,3 +223,6 @@ export function putObj(obj?: Object) {
|
|||||||
data: obj,
|
data: obj,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按原筛选条件提交后台分批导出。 */
|
||||||
|
export const submitExport = (query: Record<string, unknown>) => submitExportTask("/hard/hard-order/export-async", query)
|
||||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
@@ -0,0 +1,87 @@
|
|||||||
|
import request from "/@/utils/request"
|
||||||
|
|
||||||
|
export type ThirdOrderId = string | number
|
||||||
|
|
||||||
|
/** 第三方应用支付写入的硬件底单,所有金额均为分。 */
|
||||||
|
export interface ThirdOrder {
|
||||||
|
id: ThirdOrderId
|
||||||
|
orderNo?: string
|
||||||
|
orderType: string
|
||||||
|
orderStatus: string
|
||||||
|
payType?: string
|
||||||
|
userId?: ThirdOrderId
|
||||||
|
username?: string
|
||||||
|
userMobile?: string
|
||||||
|
userAvatar?: string
|
||||||
|
userDeptName?: string
|
||||||
|
merchantId?: ThirdOrderId
|
||||||
|
merchantName?: string
|
||||||
|
merchantType?: string
|
||||||
|
walletId?: ThirdOrderId
|
||||||
|
walletTypeUniqueCode?: string
|
||||||
|
parentCarId?: ThirdOrderId
|
||||||
|
productDesc?: string
|
||||||
|
totalPrice?: number
|
||||||
|
extraPrice?: number
|
||||||
|
payPrice?: number
|
||||||
|
refundPrice?: number
|
||||||
|
refundStatus?: string
|
||||||
|
payRefundId?: ThirdOrderId
|
||||||
|
afterSaleNo?: string
|
||||||
|
createTime?: string
|
||||||
|
payTime?: string
|
||||||
|
refundTime?: string
|
||||||
|
updateTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThirdOrderProduct {
|
||||||
|
id: ThirdOrderId
|
||||||
|
productName?: string
|
||||||
|
productPrice?: number
|
||||||
|
productCount?: number
|
||||||
|
totalPrice?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThirdOrderRefund {
|
||||||
|
id: ThirdOrderId
|
||||||
|
no?: string
|
||||||
|
status: string
|
||||||
|
refundPrice?: number
|
||||||
|
applyReason?: string
|
||||||
|
applyDescription?: string
|
||||||
|
auditReason?: string
|
||||||
|
createTime?: string
|
||||||
|
auditTime?: string
|
||||||
|
refundTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThirdOrderDetail {
|
||||||
|
order: ThirdOrder
|
||||||
|
products: ThirdOrderProduct[]
|
||||||
|
refunds: ThirdOrderRefund[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThirdOrderRefundParams {
|
||||||
|
orderId: ThirdOrderId
|
||||||
|
refundPrice: number
|
||||||
|
requestNo: string
|
||||||
|
applyReason: string
|
||||||
|
applyDescription?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function page(query: Record<string, unknown>) {
|
||||||
|
return request({
|
||||||
|
url: "/hard/hard-third-order/page",
|
||||||
|
method: "post",
|
||||||
|
params: { current: query.current, size: query.size },
|
||||||
|
data: query,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detail(id: ThirdOrderId) {
|
||||||
|
return request({ url: `/hard/hard-third-order/${id}`, method: "get" })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refund(data: ThirdOrderRefundParams) {
|
||||||
|
return request({ url: "/hard/hard-third-order/refund", method: "post", data })
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { submitExportTask } from "/@/api/asyncTask"
|
||||||
import request from "/@/utils/request"
|
import request from "/@/utils/request"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,3 +127,12 @@ export function pageListCsByMonth(query?: any) {
|
|||||||
data: query,
|
data: query,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按原筛选条件提交后台分批导出。 */
|
||||||
|
export const submitMerchantExport = (query: Record<string, unknown>) => submitExportTask("/hard/hard-total/export-cs-async", query)
|
||||||
|
|
||||||
|
/** 按原筛选条件提交后台分批导出。 */
|
||||||
|
export const submitMerchantMonthExport = (query: Record<string, unknown>) => submitExportTask("/hard/hard-total/export-cs-by-month-async", query)
|
||||||
|
|
||||||
|
/** 按原筛选条件提交后台分批导出。 */
|
||||||
|
export const submitDepartmentExport = (query: Record<string, unknown>) => submitExportTask("/hard/hard-total/export-dept-async", query)
|
||||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
File mode changed.
Vendored
+25
@@ -0,0 +1,25 @@
|
|||||||
|
import request from '/@/utils/request'
|
||||||
|
|
||||||
|
export function fetchList(query?: Object) {
|
||||||
|
return request({
|
||||||
|
url: '/thirdparty/zzdPhoneBind/page',
|
||||||
|
method: 'get',
|
||||||
|
params: query,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getObj(id?: string) {
|
||||||
|
return request({
|
||||||
|
url: '/thirdparty/zzdPhoneBind/' + id,
|
||||||
|
method: 'get',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修正核验手机号 */
|
||||||
|
export function updatePhone(obj: { id: string | number; verifiedPhone: string }) {
|
||||||
|
return request({
|
||||||
|
url: '/thirdparty/zzdPhoneBind/phone',
|
||||||
|
method: 'put',
|
||||||
|
data: obj,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export type WalletCode = "1" | "2" | "3"
|
||||||
|
export type WalletAmount = number | string
|
||||||
|
|
||||||
|
export interface InactiveWallet {
|
||||||
|
code: WalletCode
|
||||||
|
name: string
|
||||||
|
status: "AVAILABLE" | "UNOPENED" | "RESTRICTED"
|
||||||
|
balance: WalletAmount | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletInactiveUser {
|
||||||
|
userId: string
|
||||||
|
username?: string
|
||||||
|
userMobile?: string
|
||||||
|
userAvatar?: string
|
||||||
|
deptNames: string[]
|
||||||
|
wallets: InactiveWallet[]
|
||||||
|
totalBalance: WalletAmount
|
||||||
|
partialBalance: boolean
|
||||||
|
firstWalletCreatedTime: string | null
|
||||||
|
lastTransactionTime: string | null
|
||||||
|
inactiveSinceTime: string
|
||||||
|
inactiveMonths: number
|
||||||
|
neverTransacted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletInactiveQuery {
|
||||||
|
userId?: string
|
||||||
|
unactiveMonthsNumber: number
|
||||||
|
deptIds: string[]
|
||||||
|
isInclude: boolean
|
||||||
|
current: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletInactivePage {
|
||||||
|
records: WalletInactiveUser[]
|
||||||
|
total: number | string
|
||||||
|
current: number | string
|
||||||
|
size: number | string
|
||||||
|
asOfTime: string
|
||||||
|
cutoffTime: string
|
||||||
|
unactiveMonthsNumber: number
|
||||||
|
walletNames: Record<WalletCode, string>
|
||||||
|
}
|
||||||
@@ -1,9 +1,72 @@
|
|||||||
import request from "/@/utils/request"
|
import request from "/@/utils/request"
|
||||||
|
import { submitExportTask } from "/@/api/asyncTask"
|
||||||
|
import type { WalletInactivePage, WalletInactiveQuery } from "/@/api/wallet/wallet-inactive"
|
||||||
|
|
||||||
|
/** 按当前筛选条件提交账户余额后台分批导出。 */
|
||||||
|
export const submitExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-manage/export-async", query)
|
||||||
|
|
||||||
|
/** 按所选月份与原筛选条件提交钱包历史余额分批导出。 */
|
||||||
|
export const submitQbExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-manage/export-qb-async", query)
|
||||||
|
|
||||||
|
/** 用户月末余额汇总筛选;金额边界单位为分,包含上下界,空值表示不限。 */
|
||||||
|
export interface WalletQbUserQuery {
|
||||||
|
walletRecordMonth: string
|
||||||
|
userId?: string | number
|
||||||
|
walletTypeIdList?: Array<string | number>
|
||||||
|
deptIds?: Array<string | number>
|
||||||
|
isInclude?: boolean
|
||||||
|
minBalance?: number
|
||||||
|
maxBalance?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每个用户一行的月末余额;金额均为分,计入当前筛选范围的钱包。 */
|
||||||
|
export interface WalletQbUserRow {
|
||||||
|
userId: string | number
|
||||||
|
username?: string
|
||||||
|
userAvatar?: string
|
||||||
|
userMobile?: string
|
||||||
|
deptName?: string
|
||||||
|
balance: number
|
||||||
|
eatBalance: number
|
||||||
|
payBalance: number
|
||||||
|
topupBalance: number
|
||||||
|
walletCount: number
|
||||||
|
lastTransactionTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前筛选范围内全部用户的余额汇总,独立于列表分页。 */
|
||||||
|
export interface WalletQbUserTotal {
|
||||||
|
total: number
|
||||||
|
eatTotal: number
|
||||||
|
payTotal: number
|
||||||
|
topupTotal: number
|
||||||
|
userCount: number
|
||||||
|
walletCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户合计余额及各钱包余额在数据库完成全量筛选、排序和分页。 */
|
||||||
|
export function fetchQbUsers(query: WalletQbUserQuery & { current: number; size: number; ascs?: string; descs?: string }, signal?: AbortSignal) {
|
||||||
|
return request({
|
||||||
|
url: "/wallet/wallet-manage/page-qb-users",
|
||||||
|
method: "post",
|
||||||
|
params: { current: query.current, size: query.size, ascs: query.ascs || undefined, descs: query.descs || undefined },
|
||||||
|
data: query,
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按当前用户合计余额区间计算全部匹配用户的分类余额。 */
|
||||||
|
export function qbUsersTotal(query: WalletQbUserQuery, signal?: AbortSignal) {
|
||||||
|
return request({ url: "/wallet/wallet-manage/qb-users-total", method: "post", data: query, signal })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出每用户一行的全量汇总,保持列表月份、区间及部门筛选。 */
|
||||||
|
export const submitQbUsersExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-manage/export-qb-users-async", query)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 不活跃用户统计分页
|
* 不活跃用户统计分页
|
||||||
*/
|
*/
|
||||||
export function pageJoinActive(data: any) {
|
export function pageJoinActive(data: WalletInactiveQuery, signal?: AbortSignal): Promise<{ data: WalletInactivePage }> {
|
||||||
return request({
|
return request({
|
||||||
url: "/wallet/wallet-manage/page-join-active",
|
url: "/wallet/wallet-manage/page-join-active",
|
||||||
method: "post",
|
method: "post",
|
||||||
@@ -12,7 +75,8 @@ export function pageJoinActive(data: any) {
|
|||||||
size: data.size,
|
size: data.size,
|
||||||
},
|
},
|
||||||
data: data,
|
data: data,
|
||||||
})
|
signal,
|
||||||
|
}) as unknown as Promise<{ data: WalletInactivePage }>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,14 +98,16 @@ export function fetchList(query?: Object) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function qbTotal(data: any) {
|
export function qbTotal(data: any, signal?: AbortSignal) {
|
||||||
return request({
|
return request({
|
||||||
url: "/wallet/wallet-manage/qb-total",
|
url: "/wallet/wallet-manage/qb-total",
|
||||||
method: "post",
|
method: "post",
|
||||||
data: data,
|
data: data,
|
||||||
|
signal,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 查询月末钱包余额;排序参数随分页参数传入 URL,由后端完成全量排序后分页。 */
|
||||||
export function fetchListJoinQb(query: any) {
|
export function fetchListJoinQb(query: any) {
|
||||||
return request({
|
return request({
|
||||||
url: "/wallet/wallet-manage/page-join-qb",
|
url: "/wallet/wallet-manage/page-join-qb",
|
||||||
@@ -49,6 +115,8 @@ export function fetchListJoinQb(query: any) {
|
|||||||
params: {
|
params: {
|
||||||
current: query.current,
|
current: query.current,
|
||||||
size: query.size,
|
size: query.size,
|
||||||
|
ascs: query.ascs || undefined,
|
||||||
|
descs: query.descs || undefined,
|
||||||
},
|
},
|
||||||
data: query,
|
data: query,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { submitExportTask } from "/@/api/asyncTask"
|
||||||
import request from "/@/utils/request"
|
import request from "/@/utils/request"
|
||||||
|
|
||||||
export function saveTopup(data?: Object) {
|
export function saveTopup(data?: Object) {
|
||||||
@@ -87,3 +88,6 @@ export function validateError(params: any) {
|
|||||||
params: params,
|
params: params,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按原筛选条件提交后台分批导出。 */
|
||||||
|
export const submitExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-recharge-apply/export-async", query)
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { submitExportTask } from "/@/api/asyncTask"
|
||||||
import request from "/@/utils/request"
|
import request from "/@/utils/request"
|
||||||
|
|
||||||
export function pageJoin(query: any) {
|
export function pageJoin(query: any) {
|
||||||
@@ -58,3 +59,6 @@ export function recover(params?: Object) {
|
|||||||
params,
|
params,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按原筛选条件提交后台分批导出。 */
|
||||||
|
export const submitExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-recharge-import/export-async", query)
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { submitExportTask } from "/@/api/asyncTask"
|
||||||
import request from "/@/utils/request"
|
import request from "/@/utils/request"
|
||||||
|
|
||||||
export function invoiceBack(query: any) {
|
export function invoiceBack(query: any) {
|
||||||
@@ -107,3 +108,6 @@ export function putObj(obj?: Object) {
|
|||||||
data: obj,
|
data: obj,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按原筛选条件提交后台分批导出。 */
|
||||||
|
export const submitExport = (query: Record<string, unknown>) => submitExportTask("/wallet/walletRechargeInvoice/export-async", query)
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { submitExportTask } from "/@/api/asyncTask"
|
||||||
import request from "/@/utils/request"
|
import request from "/@/utils/request"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -238,3 +239,18 @@ export function putObj(obj?: Object) {
|
|||||||
data: obj,
|
data: obj,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按原筛选条件提交后台分批导出。 */
|
||||||
|
export const submitThExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-transaction/export-th-async", query)
|
||||||
|
|
||||||
|
/** 按原筛选条件提交后台分批导出。 */
|
||||||
|
export const submitTkExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-transaction/export-tk-async", query)
|
||||||
|
|
||||||
|
/** 按原筛选条件提交后台分批导出。 */
|
||||||
|
export const submitXfExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-transaction/export-xf-async", query)
|
||||||
|
|
||||||
|
/** 按当前筛选条件提交充值流水分批导出。 */
|
||||||
|
export const submitCzExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-transaction/export-cz-async", query)
|
||||||
|
|
||||||
|
/** 按当前筛选条件提交记账扣款流水分批导出。 */
|
||||||
|
export const submitJzExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-transaction/export-jz-async", query)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog v-model="state.visible" :title="state.progress?.taskName || '导出进度'" width="520px"
|
||||||
|
:close-on-click-modal="false" :close-on-press-escape="false" :show-close="canClose" draggable>
|
||||||
|
<div class="space-y-4" aria-live="polite">
|
||||||
|
<el-progress :percentage="state.progress?.percentage || 0" :status="progressStatus" :stroke-width="14" />
|
||||||
|
<div class="flex justify-between text-sm gap-4">
|
||||||
|
<span>{{ state.progress?.message || "正在提交导出任务…" }}</span>
|
||||||
|
<span class="shrink-0" v-if="state.progress?.total">{{ state.progress?.completed || 0 }} / {{ state.progress.total }} 步</span>
|
||||||
|
</div>
|
||||||
|
<el-alert v-if="state.polling && config.waitingMessage" :title="config.waitingMessage" type="info" :closable="false" show-icon />
|
||||||
|
<el-alert v-if="state.queryError" :title="state.queryError" type="warning" :closable="false" show-icon />
|
||||||
|
</div>
|
||||||
|
<template #footer v-if="canClose">
|
||||||
|
<el-button v-if="state.queryError && activeContext" type="primary" @click="retry">重新查询</el-button>
|
||||||
|
<el-button v-if="canDownload" type="primary" @click="downloadResult">{{ config.downloadButtonText || "下载 Excel" }}</el-button>
|
||||||
|
<el-button @click="state.visible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" name="async-task-progress">
|
||||||
|
import { computed, reactive, onBeforeUnmount } from "vue"
|
||||||
|
import type { AsyncTaskPollingConfig, AsyncTaskProgress } from "/@/api/asyncTask"
|
||||||
|
import { getAsyncTaskProgress } from "/@/api/asyncTask"
|
||||||
|
import { downloadExportFile } from "/@/utils/exportDownload"
|
||||||
|
import { useMessage } from "/@/hooks/message"
|
||||||
|
|
||||||
|
const props = defineProps<{ config: AsyncTaskPollingConfig }>()
|
||||||
|
const emit = defineEmits<{ (event: "success" | "failure", progress: AsyncTaskProgress): void }>()
|
||||||
|
/** 轮询上下文在启动时复制,页面切换商户标签不会改变已有任务。 */
|
||||||
|
let activeContext: (Required<Pick<AsyncTaskPollingConfig, "progressUrl" | "redisKey" | "pollIntervalMs" | "pollTimeoutMs" | "maxConsecutiveErrors">> & { taskId: string }) | null = null
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let startedAt = 0
|
||||||
|
let errors = 0
|
||||||
|
let version = 0
|
||||||
|
let disposed = false
|
||||||
|
let terminalEmitted = false
|
||||||
|
const state = reactive({ visible: false, submitting: false, polling: false, progress: null as AsyncTaskProgress | null, queryError: "" })
|
||||||
|
const canClose = computed(() => !state.submitting && !state.polling)
|
||||||
|
const canDownload = computed(() => Boolean(state.progress?.finished && state.progress.success && state.progress.resultFileUrl))
|
||||||
|
const progressStatus = computed(() => state.progress?.finished ? (state.progress.success ? "success" : "exception") : undefined)
|
||||||
|
|
||||||
|
/** 停止下一次查询;版本号同时隔离已经发出而尚未返回的请求。 */
|
||||||
|
const stopPolling = () => {
|
||||||
|
state.polling = false
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
timer = undefined
|
||||||
|
version += 1
|
||||||
|
}
|
||||||
|
const pause = (message: string) => {
|
||||||
|
stopPolling()
|
||||||
|
state.queryError = message
|
||||||
|
state.visible = true
|
||||||
|
}
|
||||||
|
/** 递归定时器保证查询不重叠,短时网络错误不会改变后端任务状态。 */
|
||||||
|
const poll = async () => {
|
||||||
|
if (disposed || !state.polling || !activeContext) return
|
||||||
|
if (Date.now() - startedAt >= activeContext.pollTimeoutMs) {
|
||||||
|
pause("进度查询已超过 2 小时,后台任务可能仍在运行,可重新查询")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const currentVersion = version
|
||||||
|
try {
|
||||||
|
const result = await getAsyncTaskProgress(activeContext.progressUrl, { taskId: activeContext.taskId, redisKey: activeContext.redisKey })
|
||||||
|
if (disposed || currentVersion !== version) return
|
||||||
|
if (!result?.data?.taskId) throw new Error("进度接口未返回任务数据")
|
||||||
|
state.progress = result.data
|
||||||
|
state.queryError = ""
|
||||||
|
errors = 0
|
||||||
|
if (state.progress?.finished) {
|
||||||
|
stopPolling()
|
||||||
|
if (!terminalEmitted) {
|
||||||
|
terminalEmitted = true
|
||||||
|
emit(state.progress.success ? "success" : "failure", state.progress)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (disposed || currentVersion !== version) return
|
||||||
|
if (++errors >= activeContext.maxConsecutiveErrors) {
|
||||||
|
pause(error?.msg || error?.message || "获取进度失败,后台任务不会因此取消,请重新查询")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!disposed && state.polling) timer = setTimeout(poll, activeContext.pollIntervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交前立即显示反馈,避免慢提交期间重复点击。 */
|
||||||
|
const startSubmitting = (taskName: string) => {
|
||||||
|
stopPolling()
|
||||||
|
activeContext = null
|
||||||
|
state.progress = { taskName, percentage: 0, message: "正在提交导出任务…" } as AsyncTaskProgress
|
||||||
|
state.queryError = ""
|
||||||
|
state.submitting = true
|
||||||
|
state.visible = true
|
||||||
|
}
|
||||||
|
const submissionFailed = (message: string) => {
|
||||||
|
stopPolling()
|
||||||
|
state.submitting = false
|
||||||
|
state.queryError = message
|
||||||
|
state.progress = { ...state.progress, status: "FAILED", finished: true, success: false, message } as AsyncTaskProgress
|
||||||
|
activeContext = null
|
||||||
|
}
|
||||||
|
/** 接管提交响应,只在文件就绪终态发出一次成功事件。 */
|
||||||
|
const start = (progress: AsyncTaskProgress) => {
|
||||||
|
stopPolling()
|
||||||
|
if (!progress.taskId || !props.config.progressUrl) throw new Error("导出任务进度配置不完整")
|
||||||
|
activeContext = {
|
||||||
|
progressUrl: props.config.progressUrl,
|
||||||
|
redisKey: progress.redisKey || props.config.redisKey,
|
||||||
|
taskId: progress.taskId,
|
||||||
|
pollIntervalMs: Math.max(500, props.config.pollIntervalMs || 1000),
|
||||||
|
pollTimeoutMs: props.config.pollTimeoutMs || 2 * 60 * 60 * 1000,
|
||||||
|
maxConsecutiveErrors: props.config.maxConsecutiveErrors || 5,
|
||||||
|
}
|
||||||
|
state.submitting = false
|
||||||
|
state.progress = { ...progress }
|
||||||
|
state.queryError = ""
|
||||||
|
state.visible = true
|
||||||
|
state.polling = true
|
||||||
|
errors = 0
|
||||||
|
terminalEmitted = false
|
||||||
|
startedAt = Date.now()
|
||||||
|
void poll()
|
||||||
|
}
|
||||||
|
/** 中断后只查询原 taskId,保持导出提交互斥状态。 */
|
||||||
|
const retry = () => {
|
||||||
|
if (!activeContext || state.polling) return
|
||||||
|
state.queryError = ""
|
||||||
|
errors = 0
|
||||||
|
startedAt = Date.now()
|
||||||
|
state.polling = true
|
||||||
|
void poll()
|
||||||
|
}
|
||||||
|
const show = () => { state.visible = true }
|
||||||
|
const downloadResult = () => {
|
||||||
|
if (!canDownload.value) return
|
||||||
|
try { downloadExportFile(state.progress!.resultFileUrl!, state.progress!.resultFileName || "导出结果.xlsx") }
|
||||||
|
catch (error: any) { useMessage().error(error?.message || "下载导出文件失败") }
|
||||||
|
}
|
||||||
|
onBeforeUnmount(() => { disposed = true; stopPolling() })
|
||||||
|
defineExpose({ start, startSubmitting, submissionFailed, show })
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col border border-br" :style="styles">
|
<div ref="editorContainer" class="flex flex-col border border-br" :style="styles">
|
||||||
<Toolbar class="border-b border-br" :editor="editorRef" :mode="mode" />
|
<Toolbar class="border-b border-br" :editor="editorRef" :mode="mode" />
|
||||||
<Editor
|
<Editor
|
||||||
class="flex-1 overflow-y-auto"
|
class="flex-1 overflow-y-auto"
|
||||||
@@ -18,8 +18,11 @@ import { reactive, shallowRef, watch, onBeforeUnmount, CSSProperties } from 'vue
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { IDomEditor } from '@wangeditor/editor';
|
import { IDomEditor } from '@wangeditor/editor';
|
||||||
import { Toolbar, Editor } from '@wangeditor/editor-for-vue';
|
import { Toolbar, Editor } from '@wangeditor/editor-for-vue';
|
||||||
|
import { stripFileHtml, resolveFileUrl } from '/@/utils/file-access';
|
||||||
|
import { bindFileAccessDom } from '/@/utils/file-access-dom';
|
||||||
import { Session } from '/@/utils/storage';
|
import { Session } from '/@/utils/storage';
|
||||||
import other from '/@/utils/other';
|
import other from '/@/utils/other';
|
||||||
|
import request from '/@/utils/request';
|
||||||
const { proxy } = getCurrentInstance();
|
const { proxy } = getCurrentInstance();
|
||||||
|
|
||||||
// 定义父组件传过来的值
|
// 定义父组件传过来的值
|
||||||
@@ -73,15 +76,28 @@ const headers = computed(() => {
|
|||||||
|
|
||||||
// 定义上传需要的字段信息
|
// 定义上传需要的字段信息
|
||||||
const uploadAttr = reactive({
|
const uploadAttr = reactive({
|
||||||
|
async customUpload(file: File, insertFn: (url: string) => void) {
|
||||||
|
const data = new FormData();
|
||||||
|
data.append('file', file);
|
||||||
|
try {
|
||||||
|
const response = await request<any, { code: number; data?: { url: string }; msg?: string }>({ url: props.uploadFileUrl, method: 'post', data });
|
||||||
|
if (response.code !== 0 || !response.data?.url) throw new Error(response.msg || '文件上传失败');
|
||||||
|
insertFn(resolveFileUrl(response.data.url));
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(error?.msg || error?.message || '文件上传失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
fieldName: 'file',
|
fieldName: 'file',
|
||||||
server: proxy.baseURL + props.uploadFileUrl,
|
server: proxy.baseURL + props.uploadFileUrl,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
customInsert(res, insertFn) {
|
customInsert(res, insertFn) {
|
||||||
insertFn(proxy.baseURL + res.data.url);
|
insertFn(resolveFileUrl(res.data.url));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const editorRef = shallowRef();
|
const editorRef = shallowRef();
|
||||||
|
const editorContainer = shallowRef<HTMLElement>();
|
||||||
|
let stopResourceRenewal: (() => void) | undefined;
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
editorConfig: {
|
editorConfig: {
|
||||||
placeholder: props.placeholder,
|
placeholder: props.placeholder,
|
||||||
@@ -90,7 +106,7 @@ const state = reactive({
|
|||||||
uploadVideo: uploadAttr,
|
uploadVideo: uploadAttr,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
editorVal: props.getHtml,
|
editorVal: stripFileHtml(props.getHtml || ""),
|
||||||
});
|
});
|
||||||
|
|
||||||
const styles = computed<CSSProperties>(() => ({
|
const styles = computed<CSSProperties>(() => ({
|
||||||
@@ -102,16 +118,21 @@ const styles = computed<CSSProperties>(() => ({
|
|||||||
// 编辑器回调函数
|
// 编辑器回调函数
|
||||||
const handleCreated = (editor: IDomEditor) => {
|
const handleCreated = (editor: IDomEditor) => {
|
||||||
editorRef.value = editor;
|
editorRef.value = editor;
|
||||||
|
nextTick(() => {
|
||||||
|
stopResourceRenewal?.();
|
||||||
|
if (editorContainer.value) stopResourceRenewal = bindFileAccessDom(editorContainer.value);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 编辑器内容改变时
|
// 编辑器内容改变时
|
||||||
const handleChange = (editor: IDomEditor) => {
|
const handleChange = (editor: IDomEditor) => {
|
||||||
emit('update:getHtml', editor.getHtml());
|
emit('update:getHtml', stripFileHtml(editor.getHtml()));
|
||||||
emit('update:getText', editor.getText());
|
emit('update:getText', editor.getText());
|
||||||
};
|
};
|
||||||
|
|
||||||
// 页面销毁时
|
// 页面销毁时
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
stopResourceRenewal?.();
|
||||||
const editor = editorRef.value;
|
const editor = editorRef.value;
|
||||||
if (editor == null) return;
|
if (editor == null) return;
|
||||||
emit('update:getHtml', '');
|
emit('update:getHtml', '');
|
||||||
@@ -132,7 +153,7 @@ onMounted(() =>{
|
|||||||
watch(
|
watch(
|
||||||
() => props.getHtml,
|
() => props.getHtml,
|
||||||
(val) => {
|
(val) => {
|
||||||
state.editorVal = val;
|
if (stripFileHtml(state.editorVal || "") !== stripFileHtml(val || "")) state.editorVal = stripFileHtml(val || "");
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
deep: true,
|
deep: true,
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { shallowRef, watch } from 'vue'
|
||||||
|
import { ElImage } from 'element-plus'
|
||||||
|
import { accessFileUrl, fileUrls, originalFileUrl, resolveFileUrl, fileAccessRevision, retryFileAccess } from '/@/utils/file-access'
|
||||||
|
|
||||||
|
defineOptions({ inheritAttrs: false })
|
||||||
|
const props = withDefaults(defineProps<{ src?: string; previewSrcList?: string[] }>(), {
|
||||||
|
src: '', previewSrcList: () => [],
|
||||||
|
})
|
||||||
|
const emit = defineEmits<{
|
||||||
|
load: [event: Event]
|
||||||
|
error: [event: Event]
|
||||||
|
show: []
|
||||||
|
switch: [index: number]
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
const displaySrc = shallowRef('')
|
||||||
|
const previewSources = shallowRef<string[]>([])
|
||||||
|
let generation = 0
|
||||||
|
let loaded = false
|
||||||
|
let failed = false
|
||||||
|
let pending = false
|
||||||
|
let lastError: Event
|
||||||
|
|
||||||
|
const identity = (source: string) => resolveFileUrl(originalFileUrl(source))
|
||||||
|
const refreshPreview = () => { previewSources.value = fileUrls(props.previewSrcList) }
|
||||||
|
|
||||||
|
// An already decoded image remains visible after its request signature expires.
|
||||||
|
// Only a changed business reference or a failed request needs a new thumbnail request.
|
||||||
|
watch(() => identity(props.src), () => {
|
||||||
|
generation++
|
||||||
|
loaded = failed = pending = false
|
||||||
|
displaySrc.value = accessFileUrl(props.src)
|
||||||
|
}, { immediate: true })
|
||||||
|
watch(() => JSON.stringify(props.previewSrcList.map(identity)), refreshPreview, { immediate: true })
|
||||||
|
|
||||||
|
function onLoad(event: Event) { loaded = true; failed = false; emit('load', event) }
|
||||||
|
async function onError(event: Event) {
|
||||||
|
failed = true
|
||||||
|
lastError = event
|
||||||
|
if (pending) return
|
||||||
|
const current = generation
|
||||||
|
pending = true
|
||||||
|
try {
|
||||||
|
const retried = await retryFileAccess(props.src)
|
||||||
|
if (current !== generation) return
|
||||||
|
if (retried) {
|
||||||
|
failed = false
|
||||||
|
displaySrc.value = accessFileUrl(props.src)
|
||||||
|
} else emit('error', event)
|
||||||
|
} finally { if (current === generation) pending = false }
|
||||||
|
}
|
||||||
|
watch(fileAccessRevision, () => {
|
||||||
|
if (loaded || pending) return
|
||||||
|
const renewed = accessFileUrl(props.src)
|
||||||
|
if (renewed !== displaySrc.value) {
|
||||||
|
failed = false
|
||||||
|
displaySrc.value = renewed
|
||||||
|
} else if (failed) void onError(lastError)
|
||||||
|
})
|
||||||
|
function onShow() { refreshPreview(); emit('show') }
|
||||||
|
function onSwitch(index: number) { refreshPreview(); emit('switch', index) }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElImage v-bind="$attrs" :src="displaySrc" :preview-src-list="previewSources"
|
||||||
|
@load="onLoad" @error="onError" @show="onShow" @switch="onSwitch" @close="emit('close')">
|
||||||
|
<template v-for="(_, name) in $slots" #[name]="scope">
|
||||||
|
<slot :name="name" v-bind="scope || {}" />
|
||||||
|
</template>
|
||||||
|
</ElImage>
|
||||||
|
</template>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="relative flex items-center justify-center file-item" :style="{ height: fileSize, width: fileSize }">
|
<div class="relative flex items-center justify-center file-item" :style="{ height: fileSize, width: fileSize }">
|
||||||
<el-image class="image" v-if="type === 'image'" fit="contain" :src="uri"></el-image>
|
<file-image class="image" v-if="type === 'image'" fit="contain" :src="$fileUrl(uri)" @error="$fileRetry(uri)"></file-image>
|
||||||
<video class="video" v-else-if="type === 'video'" :src="uri"></video>
|
<video class="video" v-else-if="type === 'video'" :src="$fileUrl(uri)"></video>
|
||||||
<div
|
<div
|
||||||
v-if="type == 'video'"
|
v-if="type == 'video'"
|
||||||
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] rounded-full w-5 h-5 flex justify-center items-center bg-[rgba(0,0,0,0.3)]"
|
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] rounded-full w-5 h-5 flex justify-center items-center bg-[rgba(0,0,0,0.3)]"
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="type === 'file'" class="flex items-center justify-center">
|
<div v-if="type === 'file'" class="flex items-center justify-center">
|
||||||
<img class="w-16" :src="getFileImage(uri)" />
|
<img class="w-16" :src="$fileUrl(getFileImage(uri))" @error="$fileRetry(getFileImage(uri))" />
|
||||||
</div>
|
</div>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -150,7 +150,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="名称" min-width="100" show-overflow-tooltip>
|
<el-table-column label="名称" min-width="100" show-overflow-tooltip>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-link @click.stop="handlePreview(getFileUri(row))" :underline="false">
|
<el-link @click.stop="handlePreview(row)" :underline="false">
|
||||||
{{ row.original }}
|
{{ row.original }}
|
||||||
</el-link>
|
</el-link>
|
||||||
</template>
|
</template>
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
</popover-input>
|
</popover-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="inline-block">
|
<div class="inline-block">
|
||||||
<el-button type="primary" link @click.stop="handlePreview(getFileUri(row))"> 查看</el-button>
|
<el-button type="primary" link @click.stop="handlePreview(row)"> 查看</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="inline-block">
|
<div class="inline-block">
|
||||||
<el-button type="primary" link @click.stop="batchFileDelete([row.id])"> 删除</el-button>
|
<el-button type="primary" link @click.stop="batchFileDelete([row.id])"> 删除</el-button>
|
||||||
@@ -330,7 +330,7 @@ const handlePreview = (item: { fileName: string }) => {
|
|||||||
* @param {any} item - 文件项对象
|
* @param {any} item - 文件项对象
|
||||||
*/
|
*/
|
||||||
const handleDownFile = (item: any) => {
|
const handleDownFile = (item: any) => {
|
||||||
other.downBlobFile(`/admin/sys-file/oss/file?fileName=${item.fileName}`, {}, item.original);
|
other.downBlobFile(`/admin/sys-file/oss/file?fileName=${encodeURIComponent(item.fileName)}`, {}, item.original);
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -371,7 +371,7 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getFileUri = (item: any) => {
|
const getFileUri = (item: any) => {
|
||||||
return `${proxy.baseURL}/admin/sys-file/oss/file?fileName=${item.fileName}`;
|
return `${proxy.baseURL}/admin/sys-file/oss/file?fileName=${encodeURIComponent(item.fileName)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-show="modelValue">
|
<div v-show="modelValue">
|
||||||
<div v-if="type == 'image'">
|
<div v-if="type == 'image'">
|
||||||
<el-image-viewer v-if="previewLists.length" :url-list="previewLists" hide-on-click-modal @close="handleClose"/>
|
<el-image-viewer v-if="previewLists.length" :url-list="$fileUrls(previewLists)" hide-on-click-modal @close="handleClose"/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="type == 'video'">
|
<div v-if="type == 'video'">
|
||||||
<el-dialog v-model="visible" width="740px" :title="$t('material.preview')" :before-close="handleClose">
|
<el-dialog v-model="visible" width="740px" :title="$t('material.preview')" :before-close="handleClose">
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {Base64} from 'js-base64';
|
import {Base64} from 'js-base64';
|
||||||
|
import { accessFileUrl, resolveFileUrl } from '/@/utils/file-access';
|
||||||
import {validateNull} from "/@/utils/validate";
|
import {validateNull} from "/@/utils/validate";
|
||||||
|
|
||||||
const VideoPlayer = defineAsyncComponent(() => import('/@/components/VideoPlayer/index.vue'));
|
const VideoPlayer = defineAsyncComponent(() => import('/@/components/VideoPlayer/index.vue'));
|
||||||
@@ -88,6 +89,10 @@ const src = computed(() => {
|
|||||||
if (validateNull(kkServerURL)) {
|
if (validateNull(kkServerURL)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return `${kkServerURL}?url=` + encodeURIComponent(Base64.encode(`${localURL}${props.url}&fullfilename=${props.fileName}`));
|
const signed = accessFileUrl(props.url);
|
||||||
|
const resolved = /^https?:\/\//i.test(signed) ? signed : `${localURL?.replace(/\/$/, '') || location.origin}${resolveFileUrl(signed)}`;
|
||||||
|
const source = new URL(resolved);
|
||||||
|
source.searchParams.set('fullfilename', props.fileName);
|
||||||
|
return `${kkServerURL}?url=` + encodeURIComponent(Base64.encode(source.toString()));
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="mode == 'square' ? 'chatface' : 'brround avatar cover-image'" :style="transform" style="overflow: hidden; width: 40px; height: 40px">
|
<div :class="mode == 'square' ? 'chatface' : 'brround avatar cover-image'" :style="transform" style="overflow: hidden; width: 40px; height: 40px">
|
||||||
<img v-if="faceUrl && !num" :src="faceUrl" class="w-100 h-100" />
|
<img v-if="faceUrl && !num" :src="$fileUrl(faceUrl)" class="w-100 h-100" @error="$fileRetry(faceUrl)" />
|
||||||
<div v-else :style="styles" class="w-100 h-100 d-flex ai-center jc-center">{{ text }}</div>
|
<div v-else :style="styles" class="w-100 h-100 d-flex ai-center jc-center">{{ text }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<!-- 图片预览 -->
|
<!-- 图片预览 -->
|
||||||
<script lang="ts" setup name="PreviewImage">
|
<script lang="ts" setup name="PreviewImage">
|
||||||
|
import { resolveFileUrl } from '/@/utils/file-access'
|
||||||
import { useMessage } from "/@/hooks/message"
|
import { useMessage } from "/@/hooks/message"
|
||||||
import { downBlobFile } from "/@/utils/other"
|
import { downBlobFile } from "/@/utils/other"
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ const realHeight = computed(() => (typeof props.height == "string" ? props.heigh
|
|||||||
|
|
||||||
function dispatchUrl(url?: string) {
|
function dispatchUrl(url?: string) {
|
||||||
if (!url) return ""
|
if (!url) return ""
|
||||||
return url.includes("http") ? url : import.meta.env.VITE_IMAGE_BASE_PATH + url
|
return resolveFileUrl(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 图片下载
|
// 图片下载
|
||||||
@@ -67,21 +68,21 @@ const download = (src: string) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="props.src" class="flex flex-col items-center">
|
<div v-if="props.src" class="flex flex-col items-center">
|
||||||
<el-image
|
<file-image
|
||||||
hide-on-click-modal
|
hide-on-click-modal
|
||||||
preview-teleported
|
preview-teleported
|
||||||
:src="`${realSrc}`"
|
:src="$fileUrl(`${realSrc}`)"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
:style="`width:${realWidth};height:${realHeight};`"
|
:style="`width:${realWidth};height:${realHeight};`"
|
||||||
:preview-src-list="realSrcList"
|
:preview-src-list="$fileUrls(realSrcList)"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
>
|
@error="$fileRetry(`${realSrc}`)">
|
||||||
<template #error>
|
<template #error>
|
||||||
<div class="image-slot">
|
<div class="image-slot">
|
||||||
<i class="el-icon-picture-outline"></i>
|
<i class="el-icon-picture-outline"></i>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-image>
|
</file-image>
|
||||||
<el-button v-if="props.download" size="small" type="primary" link @click="download(props.src)">下载</el-button>
|
<el-button v-if="props.download" size="small" type="primary" link @click="download(props.src)">下载</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else :style="`width:${realWidth};height:${realHeight};`" />
|
<div v-else :style="`width:${realWidth};height:${realHeight};`" />
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
<el-tooltip placement="top" effect="dark">
|
||||||
|
<template #content>
|
||||||
|
<div class="max-w-[360px] leading-5">{{ tip }}</div>
|
||||||
|
</template>
|
||||||
|
<el-icon class="cursor-help text-[var(--el-color-info)]" tabindex="0" :aria-label="`${label}计算说明`">
|
||||||
|
<InfoFilled />
|
||||||
|
</el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" name="statistics-table-header">
|
||||||
|
import { InfoFilled } from "@element-plus/icons-vue"
|
||||||
|
|
||||||
|
/** 统计表头展示内容和计算口径提示。 */
|
||||||
|
defineProps({
|
||||||
|
/** 统计列展示名称。 */
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
/** 鼠标悬停或键盘聚焦信息图标时展示的计算口径。 */
|
||||||
|
tip: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<component :is="getIconName" />
|
<component :is="getIconName" />
|
||||||
</i>
|
</i>
|
||||||
<div v-else-if="isShowIconImg" :style="setIconImgOutStyle">
|
<div v-else-if="isShowIconImg" :style="setIconImgOutStyle">
|
||||||
<img :src="getIconName" :style="setIconSvgInsStyle" />
|
<img :src="$fileUrl(getIconName)" :style="setIconSvgInsStyle" @error="$fileRetry(getIconName)" />
|
||||||
</div>
|
</div>
|
||||||
<svg v-else-if="isShowLocalSvg" class="svg-icon icon" :style="setIconImgOutStyle">
|
<svg v-else-if="isShowLocalSvg" class="svg-icon icon" :style="setIconImgOutStyle">
|
||||||
<use :href="`#${getIconName}`" :fill="color"/>
|
<use :href="`#${getIconName}`" :fill="color"/>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
>
|
>
|
||||||
<template v-if="imageUrl">
|
<template v-if="imageUrl">
|
||||||
<!-- 如果返回的是OSS 地址则不需要增加 baseURL -->
|
<!-- 如果返回的是OSS 地址则不需要增加 baseURL -->
|
||||||
<img :src="imageUrl.includes('http') ? imageUrl : baseURL + imageUrl" class="upload-image" />
|
<img :src="$fileUrl(imageUrl)" class="upload-image" @error="$fileRetry(imageUrl)" />
|
||||||
<div class="upload-handle" @click.stop>
|
<div class="upload-handle" @click.stop>
|
||||||
<div class="handle-icon" @click="editImg" v-if="!self_disabled">
|
<div class="handle-icon" @click="editImg" v-if="!self_disabled">
|
||||||
<el-icon :size="props.iconSize"><Edit /></el-icon>
|
<el-icon :size="props.iconSize"><Edit /></el-icon>
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
:teleported="true"
|
:teleported="true"
|
||||||
v-if="imgViewVisible"
|
v-if="imgViewVisible"
|
||||||
@close="imgViewVisible = false"
|
@close="imgViewVisible = false"
|
||||||
:url-list="[imageUrl.includes('http') ? imageUrl : baseURL + imageUrl]"
|
:url-list="$fileUrls([imageUrl])"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -66,7 +66,7 @@ interface UploadFileProps {
|
|||||||
drag?: boolean // 是否支持拖拽上传 ==> 非必传(默认为 true)
|
drag?: boolean // 是否支持拖拽上传 ==> 非必传(默认为 true)
|
||||||
disabled?: boolean // 是否禁用上传组件 ==> 非必传(默认为 false)
|
disabled?: boolean // 是否禁用上传组件 ==> 非必传(默认为 false)
|
||||||
fileSize?: number // 图片大小限制 ==> 非必传(默认为 5M)
|
fileSize?: number // 图片大小限制 ==> 非必传(默认为 5M)
|
||||||
fileType?: File.ImageMimeType[] // 图片类型限制 ==> 非必传(默认为 ["image/jpeg", "image/png", "image/gif"])
|
fileType?: string[] // 图片类型限制 ==> 非必传(默认 JPEG、PNG、GIF、WebP)
|
||||||
height?: string // 组件高度 ==> 非必传(默认为 150px)
|
height?: string // 组件高度 ==> 非必传(默认为 150px)
|
||||||
width?: string // 组件宽度 ==> 非必传(默认为 150px)
|
width?: string // 组件宽度 ==> 非必传(默认为 150px)
|
||||||
borderRadius?: string // 组件边框圆角 ==> 非必传(默认为 8px)
|
borderRadius?: string // 组件边框圆角 ==> 非必传(默认为 8px)
|
||||||
@@ -82,7 +82,7 @@ const props = withDefaults(defineProps<UploadFileProps>(), {
|
|||||||
drag: true,
|
drag: true,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
fileSize: 5,
|
fileSize: 5,
|
||||||
fileType: () => ["image/jpeg", "image/png", "image/gif"],
|
fileType: () => ["image/jpeg", "image/png", "image/gif", "image/webp"],
|
||||||
height: "150px",
|
height: "150px",
|
||||||
width: "150px",
|
width: "150px",
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
@@ -116,21 +116,17 @@ const handleHttpUpload = async (options: UploadRequestOptions) => {
|
|||||||
let formData = new FormData()
|
let formData = new FormData()
|
||||||
formData.append("file", options.file)
|
formData.append("file", options.file)
|
||||||
formData.append("dir", props.dir)
|
formData.append("dir", props.dir)
|
||||||
try {
|
const { data } = await request({
|
||||||
const { data } = await request({
|
url: props.uploadFileUrl,
|
||||||
url: props.uploadFileUrl,
|
method: "post",
|
||||||
method: "post",
|
headers: {
|
||||||
headers: {
|
"Content-Type": "multipart/form-data",
|
||||||
"Content-Type": "multipart/form-data",
|
},
|
||||||
},
|
data: formData,
|
||||||
data: formData,
|
})
|
||||||
})
|
emit("update:imageUrl", data.url)
|
||||||
emit("update:imageUrl", data.url)
|
// 调用 el-form 内部的校验方法(可自动校验)
|
||||||
// 调用 el-form 内部的校验方法(可自动校验)
|
formItemContext?.prop && formContext?.validateField([formItemContext.prop as string])
|
||||||
formItemContext?.prop && formContext?.validateField([formItemContext.prop as string])
|
|
||||||
} catch (error) {
|
|
||||||
options.onError(error as any)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,10 +182,10 @@ const uploadSuccess = () => {
|
|||||||
/**
|
/**
|
||||||
* @description 图片上传错误
|
* @description 图片上传错误
|
||||||
* */
|
* */
|
||||||
const uploadError = () => {
|
const uploadError = (error: any) => {
|
||||||
ElNotification({
|
ElNotification({
|
||||||
title: "温馨提示",
|
title: "温馨提示",
|
||||||
message: "图片上传失败,请您重新上传!",
|
message: error?.msg || error?.message || "图片上传失败,请您重新上传!",
|
||||||
type: "error",
|
type: "error",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="upload-box">
|
<div ref="imageContainer" class="upload-box">
|
||||||
<el-upload
|
<el-upload
|
||||||
v-model:file-list="fileList"
|
v-model:file-list="fileList"
|
||||||
list-type="picture-card"
|
list-type="picture-card"
|
||||||
@@ -19,12 +19,14 @@
|
|||||||
</el-upload>
|
</el-upload>
|
||||||
|
|
||||||
<el-dialog v-model="dialogVisible">
|
<el-dialog v-model="dialogVisible">
|
||||||
<img class="m-auto" w-full :src="dialogImageUrl" alt="Preview Image" />
|
<img class="m-auto" w-full :src="$fileUrl(dialogImageUrl)" alt="Preview Image" @error="$fileRetry(dialogImageUrl)" />
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { fileUrl, originalFileUrl, resolveFileUrl } from '/@/utils/file-access'
|
||||||
|
import { bindFileAccessDom } from '/@/utils/file-access-dom'
|
||||||
import { ref, watch } from "vue"
|
import { ref, watch } from "vue"
|
||||||
import { Plus } from "@element-plus/icons-vue"
|
import { Plus } from "@element-plus/icons-vue"
|
||||||
import { ElUpload, ElDialog, UploadRequestOptions, ElNotification, UploadFile, UploadProps, formContextKey } from "element-plus"
|
import { ElUpload, ElDialog, UploadRequestOptions, ElNotification, UploadFile, UploadProps, formContextKey } from "element-plus"
|
||||||
@@ -40,16 +42,20 @@ interface UploadFileProps {
|
|||||||
}
|
}
|
||||||
const props = withDefaults(defineProps<UploadFileProps>(), {
|
const props = withDefaults(defineProps<UploadFileProps>(), {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
fileType: () => ["image/jpeg", "image/png", "image/gif"],
|
fileType: () => ["image/jpeg", "image/png", "image/gif", "image/webp"],
|
||||||
fileSize: 5,
|
fileSize: 5,
|
||||||
height: "150px",
|
height: "150px",
|
||||||
width: "150px",
|
width: "150px",
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const imageContainer = ref<HTMLElement>()
|
||||||
|
let stopResourceRenewal: (() => void) | undefined
|
||||||
|
onMounted(() => { if (imageContainer.value) stopResourceRenewal = bindFileAccessDom(imageContainer.value) })
|
||||||
|
onBeforeUnmount(() => stopResourceRenewal?.())
|
||||||
|
|
||||||
const imageUrls = defineModel("modelValue", { default: "" })
|
const imageUrls = defineModel("modelValue", { default: "" })
|
||||||
|
|
||||||
const imageBaseUrl = import.meta.env.VITE_IMAGE_BASE_PATH
|
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const fileList = ref<any>([])
|
const fileList = ref<any>([])
|
||||||
const dialogImageUrl = ref("")
|
const dialogImageUrl = ref("")
|
||||||
@@ -60,20 +66,16 @@ const formContext = inject(formContextKey, void 0)
|
|||||||
const handleHttpUpload = async (options: UploadRequestOptions) => {
|
const handleHttpUpload = async (options: UploadRequestOptions) => {
|
||||||
let formData = new FormData()
|
let formData = new FormData()
|
||||||
formData.append("file", options.file)
|
formData.append("file", options.file)
|
||||||
try {
|
const res = await request({
|
||||||
const res = await request({
|
url: "/admin/sys-file/upload",
|
||||||
url: "/admin/sys-file/upload",
|
method: "post",
|
||||||
method: "post",
|
headers: {
|
||||||
headers: {
|
"Content-Type": "multipart/form-data",
|
||||||
"Content-Type": "multipart/form-data",
|
},
|
||||||
},
|
data: formData,
|
||||||
data: formData,
|
})
|
||||||
})
|
if (res.code == 0) {
|
||||||
if (res.code == 0) {
|
return res
|
||||||
return res
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
options.onError(error as any)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,19 +107,18 @@ const beforeUpload: UploadProps["beforeUpload"] = (rawFile) => {
|
|||||||
* @description 图片上传成功
|
* @description 图片上传成功
|
||||||
* */
|
* */
|
||||||
const uploadSuccess = (response: any, uploadFile: UploadFile, uploadFiles: any) => {
|
const uploadSuccess = (response: any, uploadFile: UploadFile, uploadFiles: any) => {
|
||||||
console.log("success", uploadFiles)
|
|
||||||
|
|
||||||
const urls = uploadFiles
|
const urls = uploadFiles
|
||||||
.map((item: any) => {
|
.map((item: any) => {
|
||||||
// 处理新上传和已有的图片URL
|
// 处理新上传和已有的图片URL
|
||||||
return item.response ? item.response.data.url : item.fileUrl
|
return originalFileUrl(item.response ? item.response.data.url : item.fileUrl)
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(",")
|
.join(",")
|
||||||
imageUrls.value = urls
|
imageUrls.value = urls
|
||||||
|
|
||||||
console.log("fileList.value", fileList.value)
|
|
||||||
console.log("urls", urls)
|
|
||||||
|
|
||||||
ElNotification({
|
ElNotification({
|
||||||
title: "温馨提示",
|
title: "温馨提示",
|
||||||
@@ -136,7 +137,7 @@ const handlePictureCardPreview = (uploadFile: any) => {
|
|||||||
const handleRemove = (uploadFile: any, uploadFiles: any[]) => {
|
const handleRemove = (uploadFile: any, uploadFiles: any[]) => {
|
||||||
const urls = uploadFiles
|
const urls = uploadFiles
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
return item.response ? item.response.data.url : item.fileUrl
|
return originalFileUrl(item.response ? item.response.data.url : item.fileUrl)
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(",")
|
.join(",")
|
||||||
@@ -146,10 +147,10 @@ const handleRemove = (uploadFile: any, uploadFiles: any[]) => {
|
|||||||
/**
|
/**
|
||||||
* @description 图片上传错误
|
* @description 图片上传错误
|
||||||
* */
|
* */
|
||||||
const uploadError = () => {
|
const uploadError = (error: any) => {
|
||||||
ElNotification({
|
ElNotification({
|
||||||
title: "温馨提示",
|
title: "温馨提示",
|
||||||
message: "图片上传失败,请您重新上传!",
|
message: error?.msg || error?.message || "图片上传失败,请您重新上传!",
|
||||||
type: "error",
|
type: "error",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -171,16 +172,15 @@ watch(
|
|||||||
fileList.value = newValue
|
fileList.value = newValue
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((item: string) => {
|
.map((item: string) => {
|
||||||
const url = item.startsWith("http") ? item : imageBaseUrl + item
|
const url = resolveFileUrl(item)
|
||||||
return {
|
return {
|
||||||
name: item.split("/").pop() || "",
|
name: item.split("/").pop() || "",
|
||||||
url: url,
|
url: fileUrl(url),
|
||||||
fileUrl: item,
|
fileUrl: item,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((item) => item.url)
|
.filter((item) => item.url)
|
||||||
|
|
||||||
console.log("fileList.value", fileList.value)
|
|
||||||
} else {
|
} else {
|
||||||
fileList.value = newValue
|
fileList.value = newValue
|
||||||
.split("?")
|
.split("?")
|
||||||
@@ -189,16 +189,15 @@ watch(
|
|||||||
item.endsWith(".jpg") || item.endsWith(".png") || item.endsWith(".gif") || item.endsWith(".jpeg") || item.endsWith(".webp"),
|
item.endsWith(".jpg") || item.endsWith(".png") || item.endsWith(".gif") || item.endsWith(".jpeg") || item.endsWith(".webp"),
|
||||||
)
|
)
|
||||||
.map((item: string) => {
|
.map((item: string) => {
|
||||||
const url = item.startsWith("http") ? item : imageBaseUrl + item
|
const url = resolveFileUrl(item)
|
||||||
return {
|
return {
|
||||||
name: item.split("/").pop() || "",
|
name: item.split("/").pop() || "",
|
||||||
url: url,
|
url: fileUrl(url),
|
||||||
fileUrl: item,
|
fileUrl: item,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((item) => item.url)
|
.filter((item) => item.url)
|
||||||
|
|
||||||
console.log("fileList.value", fileList.value)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full upload-file">
|
<div class="w-full upload-file">
|
||||||
<el-upload ref="fileUpload" v-if="props.type === 'default'"
|
<el-upload ref="fileUpload" v-if="props.type === 'default'"
|
||||||
|
:http-request="handleHttpUpload"
|
||||||
:action="baseURL + other.adaptationUrl(props.uploadFileUrl)" :before-upload="handleBeforeUpload"
|
:action="baseURL + other.adaptationUrl(props.uploadFileUrl)" :before-upload="handleBeforeUpload"
|
||||||
:file-list="fileList" :headers="headers" :limit="limit" :on-error="handleUploadError"
|
:file-list="fileList" :headers="headers" :limit="limit" :on-error="handleUploadError"
|
||||||
:on-remove="handleRemove" :on-preview="handlePreview" :on-exceed="handleExceed" :data="formData"
|
:on-remove="handleRemove" :on-preview="handlePreview" :on-exceed="handleExceed" :data="formData"
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
<el-upload ref="fileUpload" v-if="props.type === 'simple'"
|
<el-upload ref="fileUpload" v-if="props.type === 'simple'"
|
||||||
|
:http-request="handleHttpUpload"
|
||||||
:action="baseURL + other.adaptationUrl(props.uploadFileUrl)" :before-upload="handleBeforeUpload"
|
:action="baseURL + other.adaptationUrl(props.uploadFileUrl)" :before-upload="handleBeforeUpload"
|
||||||
:file-list="fileList" :headers="headers" :limit="limit" :auto-upload="autoUpload"
|
:file-list="fileList" :headers="headers" :limit="limit" :auto-upload="autoUpload"
|
||||||
:on-error="handleUploadError" :on-remove="handleRemove" :on-exceed="handleExceed" :data="formData"
|
:on-error="handleUploadError" :on-remove="handleRemove" :on-exceed="handleExceed" :data="formData"
|
||||||
@@ -38,6 +40,8 @@ import { useMessage } from '/@/hooks/message';
|
|||||||
import { Session } from '/@/utils/storage';
|
import { Session } from '/@/utils/storage';
|
||||||
import other from '/@/utils/other';
|
import other from '/@/utils/other';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import request from '/@/utils/request';
|
||||||
|
import type { UploadRequestOptions } from 'element-plus';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: [String, Array],
|
modelValue: [String, Array],
|
||||||
@@ -53,7 +57,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
fileType: {
|
fileType: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => ['png', 'jpg', 'jpeg', 'doc', 'xls', 'ppt', 'txt', 'pdf', 'docx', 'xlsx', 'pptx', 'apk'],
|
default: () => ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'svg', 'pdf', 'apk', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv', 'zip', 'mp4', 'mov', 'webm', 'avi', 'mp3', 'wav', 'm4a', 'aac', 'ogg', 'amr', 'flac'],
|
||||||
},
|
},
|
||||||
// 是否显示提示
|
// 是否显示提示
|
||||||
isShowTip: {
|
isShowTip: {
|
||||||
@@ -93,6 +97,16 @@ const uploadList = ref<{ name: string; url: string; fileUrl: string; fileSize: n
|
|||||||
const fileUpload = ref();
|
const fileUpload = ref();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
// Axios applies the gateway prefix, architecture adaptation and fresh signature once.
|
||||||
|
const handleHttpUpload = (options: UploadRequestOptions) => {
|
||||||
|
const data = new FormData();
|
||||||
|
for (const [name, value] of Object.entries(options.data || {})) {
|
||||||
|
if (value != null) data.append(name, value instanceof Blob ? value : String(value));
|
||||||
|
}
|
||||||
|
data.append(options.filename || 'file', options.file);
|
||||||
|
return request({ url: props.uploadFileUrl, method: 'post', data, timeout: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
// 请求头处理
|
// 请求头处理
|
||||||
const headers = computed(() => {
|
const headers = computed(() => {
|
||||||
return {
|
return {
|
||||||
@@ -111,8 +125,8 @@ const handleBeforeUpload = (file: File) => {
|
|||||||
// 校检文件类型
|
// 校检文件类型
|
||||||
if (props.fileType.length) {
|
if (props.fileType.length) {
|
||||||
const fileName = file.name.split('.');
|
const fileName = file.name.split('.');
|
||||||
const fileExt = fileName[fileName.length - 1];
|
const fileExt = fileName[fileName.length - 1].toLowerCase();
|
||||||
const isTypeOk = props.fileType.indexOf(fileExt) >= 0;
|
const isTypeOk = props.fileType.some(value => String(value).toLowerCase() === fileExt);
|
||||||
if (!isTypeOk) {
|
if (!isTypeOk) {
|
||||||
useMessage().error(`${t('excel.typeErrorText')} ${props.fileType.join('/')}!`);
|
useMessage().error(`${t('excel.typeErrorText')} ${props.fileType.join('/')}!`);
|
||||||
return false;
|
return false;
|
||||||
@@ -193,8 +207,10 @@ const listToString = (list: { url: string }[], separator = ','): string => {
|
|||||||
return strs !== '' ? strs.substr(0, strs.length - 1) : '';
|
return strs !== '' ? strs.substr(0, strs.length - 1) : '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUploadError = () => {
|
const handleUploadError = (error: any) => {
|
||||||
useMessage().error('上传文件失败');
|
number.value = Math.max(0, number.value - 1);
|
||||||
|
useMessage().error(error?.msg || error?.message || '上传文件失败');
|
||||||
|
uploadedSuccessfully();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
</el-upload>
|
</el-upload>
|
||||||
|
|
||||||
<el-dialog v-model="dialogVisible">
|
<el-dialog v-model="dialogVisible">
|
||||||
<img class="m-auto" w-full :src="dialogImageUrl" alt="Preview Image" />
|
<img class="m-auto" w-full :src="$fileUrl(dialogImageUrl)" alt="Preview Image" @error="$fileRetry(dialogImageUrl)" />
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -31,7 +31,7 @@ interface UploadFileProps {
|
|||||||
}
|
}
|
||||||
const props = withDefaults(defineProps<UploadFileProps>(), {
|
const props = withDefaults(defineProps<UploadFileProps>(), {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
fileType: () => ["image/jpeg", "image/png", "image/gif"],
|
fileType: () => ["image/jpeg", "image/png", "image/gif", "image/webp"],
|
||||||
fileSize: 5,
|
fileSize: 5,
|
||||||
height: "150px",
|
height: "150px",
|
||||||
width: "150px",
|
width: "150px",
|
||||||
@@ -51,20 +51,16 @@ const formContext = inject(formContextKey, void 0)
|
|||||||
const handleHttpUpload = async (options: UploadRequestOptions) => {
|
const handleHttpUpload = async (options: UploadRequestOptions) => {
|
||||||
let formData = new FormData()
|
let formData = new FormData()
|
||||||
formData.append("file", options.file)
|
formData.append("file", options.file)
|
||||||
try {
|
const res = await request({
|
||||||
const res = await request({
|
url: "/admin/sys-file/qn/upload",
|
||||||
url: "/admin/sys-file/qn/upload",
|
method: "post",
|
||||||
method: "post",
|
headers: {
|
||||||
headers: {
|
"Content-Type": "multipart/form-data",
|
||||||
"Content-Type": "multipart/form-data",
|
},
|
||||||
},
|
data: formData,
|
||||||
data: formData,
|
})
|
||||||
})
|
if (res.code == 0) {
|
||||||
if (res.code == 0) {
|
return res
|
||||||
return res
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
options.onError(error as any)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,10 +133,10 @@ const handleRemove = (uploadFile: any, uploadFiles: any[]) => {
|
|||||||
/**
|
/**
|
||||||
* @description 图片上传错误
|
* @description 图片上传错误
|
||||||
* */
|
* */
|
||||||
const uploadError = () => {
|
const uploadError = (error: any) => {
|
||||||
ElNotification({
|
ElNotification({
|
||||||
title: "温馨提示",
|
title: "温馨提示",
|
||||||
message: "图片上传失败,请您重新上传!",
|
message: error?.msg || error?.message || "图片上传失败,请您重新上传!",
|
||||||
type: "error",
|
type: "error",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex min-w-0 items-center gap-3 text-left">
|
<div v-if="props.variant === 'compact'" class="user-info-cell--compact">
|
||||||
<el-avatar :size="props.avatarSize" :src="props.user.avatar || undefined" :alt="displayName" class="user-info-cell__avatar shrink-0">
|
<el-avatar :size="resolvedAvatarSize" :src="$fileUrl(compactAvatar) || undefined" @error="$fileRetry(compactAvatar)"
|
||||||
|
aria-hidden="true" class="user-info-cell__compact-avatar">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
</el-avatar>
|
||||||
|
<div class="user-info-cell__compact-text">
|
||||||
|
<strong :title="compactName">{{ compactName }}</strong>
|
||||||
|
<span :title="compactSecondary">{{ compactSecondary }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex min-w-0 items-center gap-3 text-left">
|
||||||
|
<el-avatar :size="resolvedAvatarSize" :src="$fileUrl(props.user.avatar) || undefined" @error="$fileRetry(props.user.avatar)" :alt="displayName" class="user-info-cell__avatar shrink-0">
|
||||||
{{ avatarFallback }}
|
{{ avatarFallback }}
|
||||||
</el-avatar>
|
</el-avatar>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
@@ -37,13 +47,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts" name="UserInfoCell">
|
<script setup lang="ts" name="UserInfoCell">
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { User } from "@element-plus/icons-vue"
|
||||||
|
|
||||||
/** 可复用用户信息单元格需要的最小用户摘要。 */
|
/** 可复用用户信息单元格需要的最小用户摘要。 */
|
||||||
interface UserInfoCellUser {
|
interface UserInfoCellUser {
|
||||||
/** 用户头像地址;为空时组件展示姓名或账号首字。 */
|
/** 用户头像地址;为空或加载失败时,详细模式显示姓名或账号首字,紧凑模式显示用户图标。 */
|
||||||
avatar?: string | null
|
avatar?: string | null
|
||||||
/** 用户姓名;为空时显示明确的缺失占位。 */
|
/** 用户姓名;为空时显示明确的缺失占位。 */
|
||||||
name?: string | null
|
name?: string | null
|
||||||
/** 用户登录账号;为空时显示未分配。 */
|
/** 详细模式表示登录账号;紧凑模式兼容钱包接口的用户姓名。 */
|
||||||
username?: string | null
|
username?: string | null
|
||||||
/** 用户绑定手机号;为空时显示未绑定。 */
|
/** 用户绑定手机号;为空时显示未绑定。 */
|
||||||
phone?: string | null
|
phone?: string | null
|
||||||
@@ -51,13 +64,23 @@ interface UserInfoCellUser {
|
|||||||
gender?: string | number | null
|
gender?: string | number | null
|
||||||
/** 用户所属部门名称;为空时显示未分配。 */
|
/** 用户所属部门名称;为空时显示未分配。 */
|
||||||
deptName?: string | null
|
deptName?: string | null
|
||||||
|
/** 规范用户摘要的主键;紧凑模式缺手机号时用于 ID 提示,保留字符串大整数。 */
|
||||||
|
id?: string | number | null
|
||||||
|
/** 钱包接口的用户主键;与钱包或订单行的 id 同时存在时优先用于身份提示。 */
|
||||||
|
userId?: string | number | null
|
||||||
|
/** 钱包接口的用户头像地址,紧凑模式下作为 avatar 的兼容字段。 */
|
||||||
|
userAvatar?: string | null
|
||||||
|
/** 钱包接口的用户手机号,紧凑模式下作为 phone 的兼容字段。 */
|
||||||
|
userMobile?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用户信息单元格的展示配置。 */
|
/** 用户信息单元格的展示配置。 */
|
||||||
interface UserInfoCellProps {
|
interface UserInfoCellProps {
|
||||||
/** 后端接口或上层列表传入的用户摘要。 */
|
/** 后端接口或上层列表传入的用户摘要。 */
|
||||||
user: UserInfoCellUser
|
user: UserInfoCellUser
|
||||||
/** 头像直径,单位为像素。 */
|
/** 展示密度:details 保留完整资料,compact 仅显示头像、姓名和手机号或用户 ID。 */
|
||||||
|
variant?: "details" | "compact"
|
||||||
|
/** 头像直径,单位为像素;未传时详细模式为 38,紧凑模式为 32。 */
|
||||||
avatarSize?: number
|
avatarSize?: number
|
||||||
/** 是否展示性别文本。 */
|
/** 是否展示性别文本。 */
|
||||||
showGender?: boolean
|
showGender?: boolean
|
||||||
@@ -71,7 +94,7 @@ interface UserInfoCellProps {
|
|||||||
|
|
||||||
/** 当前组件的输入与默认展示策略。 */
|
/** 当前组件的输入与默认展示策略。 */
|
||||||
const props = withDefaults(defineProps<UserInfoCellProps>(), {
|
const props = withDefaults(defineProps<UserInfoCellProps>(), {
|
||||||
avatarSize: 38,
|
variant: "details",
|
||||||
showGender: true,
|
showGender: true,
|
||||||
showAccount: true,
|
showAccount: true,
|
||||||
showPhone: true,
|
showPhone: true,
|
||||||
@@ -90,6 +113,23 @@ const formatText = (value: string | null | undefined, emptyText: string) => {
|
|||||||
return text || emptyText
|
return text || emptyText
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 头像尺寸集中由展示模式决定,已有页面的显式尺寸优先。 */
|
||||||
|
const resolvedAvatarSize = computed(() => props.avatarSize ?? (props.variant === "compact" ? 32 : 38))
|
||||||
|
|
||||||
|
/** 紧凑模式兼容规范摘要与钱包列表字段,姓名缺失时展示明确占位。 */
|
||||||
|
const compactName = computed(() => formatText(props.user.name?.trim() || props.user.username, "用户信息不可用"))
|
||||||
|
|
||||||
|
/** 紧凑头像仍由统一文件访问能力解析,不在单元格中增加用户资料请求。 */
|
||||||
|
const compactAvatar = computed(() => props.user.avatar?.trim() || props.user.userAvatar?.trim() || undefined)
|
||||||
|
|
||||||
|
/** 紧凑次行优先展示手机号;缺失时以用户 ID 帮助识别,零值主键也保留。 */
|
||||||
|
const compactSecondary = computed(() => {
|
||||||
|
const phone = props.user.phone?.trim() || props.user.userMobile?.trim()
|
||||||
|
if (phone) return phone
|
||||||
|
const id = props.user.userId ?? props.user.id
|
||||||
|
return id !== undefined && id !== null && String(id).trim() ? `ID ${id}` : "用户信息不可用"
|
||||||
|
})
|
||||||
|
|
||||||
/** 用户姓名展示文本。 */
|
/** 用户姓名展示文本。 */
|
||||||
const displayName = computed(() => formatText(props.user.name, "未填写姓名"))
|
const displayName = computed(() => formatText(props.user.name, "未填写姓名"))
|
||||||
|
|
||||||
@@ -126,6 +166,43 @@ const departmentTitle = computed(() => formatText(props.user.deptName, "未分
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
.user-info-cell--compact {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.user-info-cell__compact-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
background: var(--el-fill-color);
|
||||||
|
}
|
||||||
|
.user-info-cell__compact-text {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.user-info-cell__compact-text strong {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 18px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.user-info-cell__compact-text span {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.user-info-cell__avatar {
|
.user-info-cell__avatar {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--el-color-primary);
|
color: var(--el-color-primary);
|
||||||
|
|||||||
@@ -14,12 +14,12 @@
|
|||||||
<i class="iconfont icon-refresh"></i>
|
<i class="iconfont icon-refresh"></i>
|
||||||
</div>
|
</div>
|
||||||
<img
|
<img
|
||||||
:src="'data:image/png;base64,' + pointBackImgBase"
|
:src="$fileUrl('data:image/png;base64,' + pointBackImgBase)"
|
||||||
ref="canvas"
|
ref="canvas"
|
||||||
alt=""
|
alt=""
|
||||||
style="width: 100%; height: 100%; display: block"
|
style="width: 100%; height: 100%; display: block"
|
||||||
@click="bindingClick ? canvasClick($event) : undefined"
|
@click="bindingClick ? canvasClick($event) : undefined"
|
||||||
/>
|
@error="$fileRetry('data:image/png;base64,' + pointBackImgBase)" />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="(tempPoint, index) in tempPoints"
|
v-for="(tempPoint, index) in tempPoints"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div style="position: relative">
|
<div style="position: relative">
|
||||||
<div v-if="type === '2'" :style="{ height: parseInt(setSize.imgHeight) + vSpace + 'px' }" class="verify-img-out">
|
<div v-if="type === '2'" :style="{ height: parseInt(setSize.imgHeight) + vSpace + 'px' }" class="verify-img-out">
|
||||||
<div :style="{ width: setSize.imgWidth, height: setSize.imgHeight }" class="verify-img-panel modern-shadow">
|
<div :style="{ width: setSize.imgWidth, height: setSize.imgHeight }" class="verify-img-panel modern-shadow">
|
||||||
<img :src="'data:image/png;base64,' + backImgBase" alt="" style="width: 100%; height: 100%; display: block" />
|
<img :src="$fileUrl('data:image/png;base64,' + backImgBase)" alt="" style="width: 100%; height: 100%; display: block" @error="$fileRetry('data:image/png;base64,' + backImgBase)" />
|
||||||
<div v-show="showRefresh" class="verify-refresh" @click="refresh"><i class="iconfont icon-refresh"></i></div>
|
<div v-show="showRefresh" class="verify-refresh" @click="refresh"><i class="iconfont icon-refresh"></i></div>
|
||||||
<transition name="tips">
|
<transition name="tips">
|
||||||
<span v-if="tipWords" :class="passFlag ? 'suc-bg' : 'err-bg'" class="verify-tips">{{ tipWords }}</span>
|
<span v-if="tipWords" :class="passFlag ? 'suc-bg' : 'err-bg'" class="verify-tips">{{ tipWords }}</span>
|
||||||
@@ -46,10 +46,10 @@
|
|||||||
class="verify-sub-block"
|
class="verify-sub-block"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="'data:image/png;base64,' + blockBackImgBase"
|
:src="$fileUrl('data:image/png;base64,' + blockBackImgBase)"
|
||||||
alt=""
|
alt=""
|
||||||
style="width: 100%; height: 100%; display: block; -webkit-user-drag: none"
|
style="width: 100%; height: 100%; display: block; -webkit-user-drag: none"
|
||||||
/>
|
@error="$fileRetry('data:image/png;base64,' + blockBackImgBase)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
<video-play
|
<video-play
|
||||||
ref="playerRef"
|
ref="playerRef"
|
||||||
v-bind="options"
|
v-bind="options"
|
||||||
:src="src"
|
:src="$fileUrl(src)"
|
||||||
|
:poster="$fileUrl(poster)"
|
||||||
|
@error="$fileRetry(src)"
|
||||||
@play="onPlay"
|
@play="onPlay"
|
||||||
@pause="onPause"
|
@pause="onPause"
|
||||||
@timeupdate="onTimeupdate"
|
@timeupdate="onTimeupdate"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div v-if="objData.type === 'image'">
|
<div v-if="objData.type === 'image'">
|
||||||
<div class="waterfall" v-loading="state.loading">
|
<div class="waterfall" v-loading="state.loading">
|
||||||
<div class="waterfall-item" v-for="item in state.dataList" :key="item.mediaId">
|
<div class="waterfall-item" v-for="item in state.dataList" :key="item.mediaId">
|
||||||
<img class="material-img" :src="item.url" />
|
<img class="material-img" :src="$fileUrl(item.url)" @error="$fileRetry(item.url)" />
|
||||||
<p class="item-name">{{ item.name }}</p>
|
<p class="item-name">{{ item.name }}</p>
|
||||||
<el-row class="ope-row">
|
<el-row class="ope-row">
|
||||||
<el-button type="success" @click="selectMaterial(item)"
|
<el-button type="success" @click="selectMaterial(item)"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="item.repType === 'text'">{{ item.repContent }}</div>
|
<div v-if="item.repType === 'text'">{{ item.repContent }}</div>
|
||||||
<div v-if="item.repType === 'image'">
|
<div v-if="item.repType === 'image'">
|
||||||
<a target="_blank" :href="item.repUrl"><img :src="item.repUrl" style="width: 100px" /></a>
|
<a target="_blank" :href="$fileUrl(item.repUrl)"><img :src="$fileUrl(item.repUrl)" style="width: 100px" @error="$fileRetry(item.repUrl)" /></a>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="item.repType === 'voice'">
|
<div v-if="item.repType === 'voice'">
|
||||||
<SvgIcon name="local-wx-voice" :size="80" @click="loadVideo(item)"></SvgIcon>
|
<SvgIcon name="local-wx-voice" :size="80" @click="loadVideo(item)"></SvgIcon>
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
<el-link
|
<el-link
|
||||||
type="primary"
|
type="primary"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
:href="
|
:href="$fileUrl(
|
||||||
'https://map.qq.com/?type=marker&isopeninfowin=1&markertype=1&pointx=' +
|
'https://map.qq.com/?type=marker&isopeninfowin=1&markertype=1&pointx=' +
|
||||||
item.repLocationY +
|
item.repLocationY +
|
||||||
'&pointy=' +
|
'&pointy=' +
|
||||||
@@ -62,23 +62,29 @@
|
|||||||
'&name=' +
|
'&name=' +
|
||||||
item.repContent +
|
item.repContent +
|
||||||
'&ref=joolun'
|
'&ref=joolun'
|
||||||
"
|
)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="
|
:src="$fileUrl(
|
||||||
'https://apis.map.qq.com/ws/staticmap/v2/?zoom=10&markers=color:blue|label:A|' +
|
'https://apis.map.qq.com/ws/staticmap/v2/?zoom=10&markers=color:blue|label:A|' +
|
||||||
item.repLocationX +
|
item.repLocationX +
|
||||||
',' +
|
',' +
|
||||||
item.repLocationY +
|
item.repLocationY +
|
||||||
'&key=PFFBZ-RBM3V-IEEPP-UH6KE-6QUQE-C4BVJ&size=250*180'
|
'&key=PFFBZ-RBM3V-IEEPP-UH6KE-6QUQE-C4BVJ&size=250*180'
|
||||||
"
|
)"
|
||||||
/>
|
@error="$fileRetry(
|
||||||
|
'https://apis.map.qq.com/ws/staticmap/v2/?zoom=10&markers=color:blue|label:A|' +
|
||||||
|
item.repLocationX +
|
||||||
|
',' +
|
||||||
|
item.repLocationY +
|
||||||
|
'&key=PFFBZ-RBM3V-IEEPP-UH6KE-6QUQE-C4BVJ&size=250*180'
|
||||||
|
)" />
|
||||||
<p />
|
<p />
|
||||||
<i class="el-icon-map-location"></i>{{ item.repContent }}
|
<i class="el-icon-map-location"></i>{{ item.repContent }}
|
||||||
</el-link>
|
</el-link>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="item.repType === 'link'" class="avue-card__detail">
|
<div v-if="item.repType === 'link'" class="avue-card__detail">
|
||||||
<el-link type="success" :underline="false" target="_blank" :href="item.repUrl">
|
<el-link type="success" :underline="false" target="_blank" :href="$fileUrl(item.repUrl)">
|
||||||
<div class="avue-card__title"><i class="el-icon-link"></i>{{ item.repName }}</div>
|
<div class="avue-card__title"><i class="el-icon-link"></i>{{ item.repName }}</div>
|
||||||
</el-link>
|
</el-link>
|
||||||
<div class="avue-card__info" style="height: unset">{{ item.repDesc }}</div>
|
<div class="avue-card__info" style="height: unset">{{ item.repDesc }}</div>
|
||||||
@@ -87,9 +93,9 @@
|
|||||||
<wx-news :obj-data="JSON.parse(item.content)"></wx-news>
|
<wx-news :obj-data="JSON.parse(item.content)"></wx-news>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="item.repType === 'music'">
|
<div v-if="item.repType === 'music'">
|
||||||
<el-link type="success" :underline="false" target="_blank" :href="item.repUrl">
|
<el-link type="success" :underline="false" target="_blank" :href="$fileUrl(item.repUrl)">
|
||||||
<div class="avue-card__body" style="padding: 10px; background-color: #fff; border-radius: 5px">
|
<div class="avue-card__body" style="padding: 10px; background-color: #fff; border-radius: 5px">
|
||||||
<div class="avue-card__avatar"><img :src="item.repThumbUrl" alt="" /></div>
|
<div class="avue-card__avatar"><img :src="$fileUrl(item.repThumbUrl)" alt="" @error="$fileRetry(item.repThumbUrl)" /></div>
|
||||||
<div class="avue-card__detail">
|
<div class="avue-card__detail">
|
||||||
<div class="avue-card__title" style="margin-bottom: unset">{{ item.repName }}</div>
|
<div class="avue-card__title" style="margin-bottom: unset">{{ item.repName }}</div>
|
||||||
<div class="avue-card__info" style="height: unset">{{ item.repDesc }}</div>
|
<div class="avue-card__info" style="height: unset">{{ item.repDesc }}</div>
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="news-home">
|
<div class="news-home">
|
||||||
<div v-for="(news, index) in props.objData" :key="index" class="news-div">
|
<div v-for="(news, index) in props.objData" :key="index" class="news-div">
|
||||||
<a v-if="index === 0" target="_blank" :href="news.url">
|
<a v-if="index === 0" target="_blank" :href="$fileUrl(news.url)">
|
||||||
<div class="news-main">
|
<div class="news-main">
|
||||||
<div class="news-content">
|
<div class="news-content">
|
||||||
<img class="material-img" :src="news.thumbUrl" style="width: 280; height: 120" />
|
<img class="material-img" :src="$fileUrl(news.thumbUrl)" style="width: 280; height: 120" @error="$fileRetry(news.thumbUrl)" />
|
||||||
<div class="news-content-title">
|
<div class="news-content-title">
|
||||||
<span>{{ news.title }}</span>
|
<span>{{ news.title }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<a v-if="index > 0" target="_blank" :href="news.url">
|
<a v-if="index > 0" target="_blank" :href="$fileUrl(news.url)">
|
||||||
<div class="news-main-item">
|
<div class="news-main-item">
|
||||||
<div class="news-content-item">
|
<div class="news-content-item">
|
||||||
<div class="news-content-item-title">{{ news.title }}</div>
|
<div class="news-content-item-title">{{ news.title }}</div>
|
||||||
<div class="news-content-item-img">
|
<div class="news-content-item-img">
|
||||||
<img class="material-img" :src="news.thumbUrl" height="100%" />
|
<img class="material-img" :src="$fileUrl(news.thumbUrl)" height="100%" @error="$fileRetry(news.thumbUrl)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<template #label><i class="el-icon-picture"></i> 图片</template>
|
<template #label><i class="el-icon-picture"></i> 图片</template>
|
||||||
<el-row>
|
<el-row>
|
||||||
<div v-if="objData.repUrl" class="select-item">
|
<div v-if="objData.repUrl" class="select-item">
|
||||||
<img class="material-img" :src="objData.repUrl" />
|
<img class="material-img" :src="$fileUrl(objData.repUrl)" @error="$fileRetry(objData.repUrl)" />
|
||||||
<p v-if="objData.repName" class="item-name">{{ objData.repName }}</p>
|
<p v-if="objData.repName" class="item-name">{{ objData.repName }}</p>
|
||||||
<el-row class="ope-row">
|
<el-row class="ope-row">
|
||||||
<el-button type="danger" icon="el-icon-delete" circle @click="deleteObj"></el-button>
|
<el-button type="danger" icon="el-icon-delete" circle @click="deleteObj"></el-button>
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
<div v-if="objData.repName" class="select-item">
|
<div v-if="objData.repName" class="select-item">
|
||||||
<p class="item-name">{{ objData.repName }}</p>
|
<p class="item-name">{{ objData.repName }}</p>
|
||||||
<div class="item-infos">
|
<div class="item-infos">
|
||||||
<img :src="WxVoice" style="width: 100px" @click="loadVideo(item)" />
|
<img :src="$fileUrl(WxVoice)" style="width: 100px" @click="loadVideo(item)" @error="$fileRetry(WxVoice)" />
|
||||||
</div>
|
</div>
|
||||||
<el-row class="ope-row">
|
<el-row class="ope-row">
|
||||||
<el-button type="danger" icon="el-icon-delete" circle @click="deleteObj"></el-button>
|
<el-button type="danger" icon="el-icon-delete" circle @click="deleteObj"></el-button>
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
</el-row>
|
</el-row>
|
||||||
<el-row style="text-align: center">
|
<el-row style="text-align: center">
|
||||||
<el-col :span="12" class="col-select">
|
<el-col :span="12" class="col-select">
|
||||||
<a v-if="objData.repUrl" target="_blank" :href="objData.repUrl"> <SvgIcon name="local-wx-video" :size="45" /> </a
|
<a v-if="objData.repUrl" target="_blank" :href="$fileUrl(objData.repUrl)"> <SvgIcon name="local-wx-video" :size="45" /> </a
|
||||||
></el-col>
|
></el-col>
|
||||||
<el-col :span="12" class="col-add">
|
<el-col :span="12" class="col-add">
|
||||||
<el-button type="success" @click="openMaterial({ type: 'video', accountId: props.objData.appId })">素材库选择 </el-button>
|
<el-button type="success" @click="openMaterial({ type: 'video', accountId: props.objData.appId })">素材库选择 </el-button>
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ import Tip from "/@/components/Tip/index.vue"
|
|||||||
import TagList from "/@/components/TagList/index.vue"
|
import TagList from "/@/components/TagList/index.vue"
|
||||||
import SvgIcon from "/@/components/SvgIcon/index.vue"
|
import SvgIcon from "/@/components/SvgIcon/index.vue"
|
||||||
import PreviewImage from "/@/components/PreviewImage/index.vue"
|
import PreviewImage from "/@/components/PreviewImage/index.vue"
|
||||||
|
import FileImage from "/@/components/FileImage/index.vue"
|
||||||
import PreviewJson from "/@/components/PreviewJson/index.vue"
|
import PreviewJson from "/@/components/PreviewJson/index.vue"
|
||||||
import PreviewText from "/@/components/PreviewText/index.vue"
|
import PreviewText from "/@/components/PreviewText/index.vue"
|
||||||
import PreviewXml from "/@/components/PreviewXml/index.vue"
|
import PreviewXml from "/@/components/PreviewXml/index.vue"
|
||||||
import DraggableDepTree from "/@/components/DraggableDepTree/index.vue"
|
import DraggableDepTree from "/@/components/DraggableDepTree/index.vue"
|
||||||
import DraggableDepMchTree from "/@/components/DraggableDepMchTree/index.vue"
|
import DraggableDepMchTree from "/@/components/DraggableDepMchTree/index.vue"
|
||||||
|
import StatisticsTableHeader from "/@/components/StatisticsTableHeader/index.vue"
|
||||||
import SearchPanel from "/@/components/SearchPanel/index.vue"
|
import SearchPanel from "/@/components/SearchPanel/index.vue"
|
||||||
import UserInfoCell from "/@/components/UserInfoCell/index.vue"
|
import UserInfoCell from "/@/components/UserInfoCell/index.vue"
|
||||||
import UserIdentityInfo from "/@/components/UserIdentityInfo/index.vue"
|
import UserIdentityInfo from "/@/components/UserIdentityInfo/index.vue"
|
||||||
@@ -85,11 +87,13 @@ export default {
|
|||||||
app.component("TagList", TagList)
|
app.component("TagList", TagList)
|
||||||
app.component("SvgIcon", SvgIcon)
|
app.component("SvgIcon", SvgIcon)
|
||||||
app.component("PreviewImage", PreviewImage)
|
app.component("PreviewImage", PreviewImage)
|
||||||
|
app.component("FileImage", FileImage)
|
||||||
app.component("PreviewJson", PreviewJson)
|
app.component("PreviewJson", PreviewJson)
|
||||||
app.component("PreviewText", PreviewText)
|
app.component("PreviewText", PreviewText)
|
||||||
app.component("PreviewXml", PreviewXml)
|
app.component("PreviewXml", PreviewXml)
|
||||||
app.component("DraggableDepTree", DraggableDepTree)
|
app.component("DraggableDepTree", DraggableDepTree)
|
||||||
app.component("DraggableDepMchTree", DraggableDepMchTree)
|
app.component("DraggableDepMchTree", DraggableDepMchTree)
|
||||||
|
app.component("StatisticsTableHeader", StatisticsTableHeader)
|
||||||
app.component("SearchPanel", SearchPanel)
|
app.component("SearchPanel", SearchPanel)
|
||||||
app.component("UserInfoCell", UserInfoCell)
|
app.component("UserInfoCell", UserInfoCell)
|
||||||
app.component("UserIdentityInfo", UserIdentityInfo)
|
app.component("UserIdentityInfo", UserIdentityInfo)
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { ref, toRaw, unref } from "vue"
|
||||||
|
import type { AsyncTaskPollingConfig, AsyncTaskProgress } from "/@/api/asyncTask"
|
||||||
|
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||||
|
import { useMessage } from "/@/hooks/message"
|
||||||
|
import { downloadExportFile } from "/@/utils/exportDownload"
|
||||||
|
|
||||||
|
/** 页面只声明业务接口、名称及命名空间,任务状态由公共组件管理。 */
|
||||||
|
interface BatchExportOptions {
|
||||||
|
progressUrl: string
|
||||||
|
redisKey: string
|
||||||
|
taskName: string
|
||||||
|
fileName: string
|
||||||
|
submitTask: (query: Record<string, unknown>) => Promise<any>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 固定筛选快照,保留排序;当前列表页码和页大小不限制全量导出。 */
|
||||||
|
export const snapshotExportQuery = (query: Record<string, unknown>) => {
|
||||||
|
const snapshot = JSON.parse(JSON.stringify(toRaw(unref(query))))
|
||||||
|
delete snapshot.current
|
||||||
|
delete snapshot.size
|
||||||
|
return snapshot as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复用任务提交互斥、进度跟踪及下载。查询中断后继续跟踪原任务,不重新提交。 */
|
||||||
|
export const useBatchExport = (options: BatchExportOptions) => {
|
||||||
|
const exportProgressRef = ref<InstanceType<typeof AsyncTaskProgressComponent>>()
|
||||||
|
const exportSubmitting = ref(false)
|
||||||
|
const exportTaskConfig: AsyncTaskPollingConfig = {
|
||||||
|
progressUrl: options.progressUrl,
|
||||||
|
redisKey: options.redisKey,
|
||||||
|
waitingMessage: "正在后台分批生成文件,进度达到 100% 表示文件已就绪",
|
||||||
|
downloadButtonText: "重新下载 Excel",
|
||||||
|
}
|
||||||
|
const submitExport = async (query: Record<string, unknown>) => {
|
||||||
|
if (exportSubmitting.value) {
|
||||||
|
exportProgressRef.value?.show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exportSubmitting.value = true
|
||||||
|
exportProgressRef.value?.startSubmitting(options.taskName)
|
||||||
|
try {
|
||||||
|
const response = await options.submitTask(snapshotExportQuery(query))
|
||||||
|
if (!response?.data?.taskId) throw new Error("导出任务响应缺少任务 ID")
|
||||||
|
exportProgressRef.value?.start(response.data)
|
||||||
|
} catch (error: any) {
|
||||||
|
exportSubmitting.value = false
|
||||||
|
const message = error?.msg || error?.message || "导出任务提交失败,请重试"
|
||||||
|
exportProgressRef.value?.submissionFailed(message)
|
||||||
|
useMessage().error(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleExportSuccess = (progress: AsyncTaskProgress) => {
|
||||||
|
exportSubmitting.value = false
|
||||||
|
try {
|
||||||
|
downloadExportFile(progress.resultFileUrl || "", progress.resultFileName || options.fileName || "导出结果.xlsx")
|
||||||
|
} catch (error: any) {
|
||||||
|
useMessage().error(error?.message || "下载导出文件失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleExportFailure = (progress: AsyncTaskProgress) => {
|
||||||
|
exportSubmitting.value = false
|
||||||
|
useMessage().error(progress.message || "导出任务失败")
|
||||||
|
}
|
||||||
|
return { exportProgressRef, exportTaskConfig, exportSubmitting, submitExport, handleExportSuccess, handleExportFailure }
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, shallowRef } from "vue"
|
||||||
|
import { pageJoinActive } from "/@/api/wallet/wallet-manage"
|
||||||
|
import type { WalletInactivePage, WalletInactiveQuery, WalletInactiveUser } from "/@/api/wallet/wallet-inactive"
|
||||||
|
|
||||||
|
/** 本页独立的查询生命周期;取消请求和序号校验同时防止旧响应覆盖。 */
|
||||||
|
export function useWalletInactive() {
|
||||||
|
const form = reactive({
|
||||||
|
userId: undefined as string | undefined,
|
||||||
|
unactiveMonthsNumber: 3 as number | undefined,
|
||||||
|
deptIds: [] as string[],
|
||||||
|
isInclude: true,
|
||||||
|
})
|
||||||
|
const pagination = reactive({ current: 1, size: 20, total: 0 })
|
||||||
|
const rows = shallowRef<WalletInactiveUser[]>([])
|
||||||
|
const metadata = shallowRef<WalletInactivePage>()
|
||||||
|
const loading = ref(false)
|
||||||
|
const loaded = ref(false)
|
||||||
|
const error = ref("")
|
||||||
|
const monthsError = ref("")
|
||||||
|
let sequence = 0
|
||||||
|
let controller: AbortController | undefined
|
||||||
|
let pendingKey = ""
|
||||||
|
let pending: Promise<void> | undefined
|
||||||
|
let departmentTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let disposed = false
|
||||||
|
|
||||||
|
const summary = computed(() => ({
|
||||||
|
neverUsed: rows.value.filter((row) => row.neverTransacted).length,
|
||||||
|
balance: rows.value.reduce((total, row) => total + Number(row.totalBalance), 0),
|
||||||
|
partialBalance: rows.value.some((row) => row.partialBalance),
|
||||||
|
}))
|
||||||
|
const stopPending = () => {
|
||||||
|
sequence++
|
||||||
|
controller?.abort()
|
||||||
|
controller = undefined
|
||||||
|
pending = undefined
|
||||||
|
pendingKey = ""
|
||||||
|
clearTimeout(departmentTimer)
|
||||||
|
departmentTimer = undefined
|
||||||
|
}
|
||||||
|
const clearResult = () => {
|
||||||
|
rows.value = []
|
||||||
|
// 保留最后一次分页总数,避免 Element Plus 在请求中把 total=0 解释为跳回第一页。
|
||||||
|
// 概览由 loaded/loading 控制,请求中和失败时不会展示旧人数。
|
||||||
|
metadata.value = undefined
|
||||||
|
loaded.value = false
|
||||||
|
error.value = ""
|
||||||
|
}
|
||||||
|
const query = (): Promise<void> => {
|
||||||
|
if (disposed) return Promise.resolve()
|
||||||
|
clearTimeout(departmentTimer)
|
||||||
|
departmentTimer = undefined
|
||||||
|
const months = form.unactiveMonthsNumber
|
||||||
|
if (!Number.isInteger(months) || months! < 1 || months! > 120) {
|
||||||
|
stopPending()
|
||||||
|
clearResult()
|
||||||
|
loading.value = false
|
||||||
|
monthsError.value = "请输入1至120的整数月数"
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
monthsError.value = ""
|
||||||
|
const snapshot: WalletInactiveQuery = {
|
||||||
|
userId: form.userId ? String(form.userId) : undefined,
|
||||||
|
unactiveMonthsNumber: months!,
|
||||||
|
deptIds: [...new Set(form.deptIds.map(String))].sort(),
|
||||||
|
isInclude: form.isInclude,
|
||||||
|
current: pagination.current,
|
||||||
|
size: pagination.size,
|
||||||
|
}
|
||||||
|
const key = JSON.stringify(snapshot)
|
||||||
|
if (pending && key === pendingKey) return pending
|
||||||
|
stopPending()
|
||||||
|
const requestSequence = sequence
|
||||||
|
const requestController = new AbortController()
|
||||||
|
controller = requestController
|
||||||
|
pendingKey = key
|
||||||
|
clearResult()
|
||||||
|
loading.value = true
|
||||||
|
pending = (async () => {
|
||||||
|
try {
|
||||||
|
const response = await pageJoinActive(snapshot, requestController.signal)
|
||||||
|
if (disposed || requestSequence !== sequence) return
|
||||||
|
const page = response.data
|
||||||
|
if (
|
||||||
|
!page ||
|
||||||
|
!Array.isArray(page.records) ||
|
||||||
|
typeof page.asOfTime !== "string" ||
|
||||||
|
typeof page.cutoffTime !== "string" ||
|
||||||
|
!page.walletNames ||
|
||||||
|
!page.records.every((row) => typeof row.userId === "string" && Array.isArray(row.wallets))
|
||||||
|
) {
|
||||||
|
throw new Error("统计结果不完整,请刷新页面后重试")
|
||||||
|
}
|
||||||
|
rows.value = page.records
|
||||||
|
pagination.current = Number(page.current)
|
||||||
|
pagination.size = Number(page.size)
|
||||||
|
pagination.total = Number(page.total)
|
||||||
|
metadata.value = page
|
||||||
|
loaded.value = true
|
||||||
|
} catch (cause: any) {
|
||||||
|
if (disposed || requestSequence !== sequence || requestController.signal.aborted) return
|
||||||
|
error.value = cause?.msg || cause?.message || "统计查询失败,请重试"
|
||||||
|
} finally {
|
||||||
|
if (requestSequence === sequence) {
|
||||||
|
loading.value = false
|
||||||
|
pending = undefined
|
||||||
|
pendingKey = ""
|
||||||
|
controller = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = () => {
|
||||||
|
pagination.current = 1
|
||||||
|
return query()
|
||||||
|
}
|
||||||
|
const reset = () => {
|
||||||
|
Object.assign(form, { userId: undefined, unactiveMonthsNumber: 3, deptIds: [], isInclude: true })
|
||||||
|
monthsError.value = ""
|
||||||
|
return search()
|
||||||
|
}
|
||||||
|
const setMonths = (months: number) => {
|
||||||
|
form.unactiveMonthsNumber = months
|
||||||
|
return search()
|
||||||
|
}
|
||||||
|
const changePage = (current: number) => {
|
||||||
|
if (!loaded.value || current === pagination.current) return
|
||||||
|
pagination.current = current
|
||||||
|
return query()
|
||||||
|
}
|
||||||
|
const changeSize = (size: number) => {
|
||||||
|
if (size === pagination.size) return
|
||||||
|
pagination.size = size
|
||||||
|
return search()
|
||||||
|
}
|
||||||
|
const changeDepartments = (ids: Array<string | number>, include: boolean) => {
|
||||||
|
form.deptIds = ids.map(String)
|
||||||
|
form.isInclude = include
|
||||||
|
pagination.current = 1
|
||||||
|
stopPending()
|
||||||
|
clearResult()
|
||||||
|
loading.value = true
|
||||||
|
departmentTimer = setTimeout(() => {
|
||||||
|
void query()
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
onMounted(query)
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disposed = true
|
||||||
|
stopPending()
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
pagination,
|
||||||
|
rows,
|
||||||
|
metadata,
|
||||||
|
loading,
|
||||||
|
loaded,
|
||||||
|
error,
|
||||||
|
monthsError,
|
||||||
|
summary,
|
||||||
|
search,
|
||||||
|
refresh: query,
|
||||||
|
reset,
|
||||||
|
setMonths,
|
||||||
|
changePage,
|
||||||
|
changeSize,
|
||||||
|
changeDepartments,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div :class="themeConfig.columnsAsideLayout" v-else>
|
<div :class="themeConfig.columnsAsideLayout" v-else>
|
||||||
<a :href="v.meta.isLink" target="_blank">
|
<a :href="$fileUrl(v.meta.isLink)" target="_blank">
|
||||||
<SvgIcon :name="v.meta.icon" />
|
<SvgIcon :name="v.meta.icon" />
|
||||||
<div class="columns-vertical-title font12">
|
<div class="columns-vertical-title font12">
|
||||||
{{
|
{{
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<div v-show="state.isShowLoockLogin" class="layout-lock-screen-login">
|
<div v-show="state.isShowLoockLogin" class="layout-lock-screen-login">
|
||||||
<div class="layout-lock-screen-login-box">
|
<div class="layout-lock-screen-login-box">
|
||||||
<div class="layout-lock-screen-login-box-img">
|
<div class="layout-lock-screen-login-box-img">
|
||||||
<img :src="baseURL + formData.avatar" />
|
<img :src="$fileUrl(formData.avatar)" @error="$fileRetry(formData.avatar)" />
|
||||||
</div>
|
</div>
|
||||||
<div class="layout-lock-screen-login-box-name">{{ formData.username }}</div>
|
<div class="layout-lock-screen-login-box-name">{{ formData.username }}</div>
|
||||||
<div class="layout-lock-screen-login-box-value">
|
<div class="layout-lock-screen-login-box-value">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<span :style="{color:setFontColor}">{{ themeConfig.globalTitle }}</span>
|
<span :style="{color:setFontColor}">{{ themeConfig.globalTitle }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="layout-logo-size" v-else @click="onThemeConfigChange">
|
<div class="layout-logo-size" v-else @click="onThemeConfigChange">
|
||||||
<img :src="logoMini" class="layout-logo-size-img"/>
|
<img :src="$fileUrl(logoMini)" class="layout-logo-size-img" @error="$fileRetry(logoMini)" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<el-dropdown :show-timeout="70" :hide-timeout="50" @command="onHandleCommandClick">
|
<el-dropdown :show-timeout="70" :hide-timeout="50" @command="onHandleCommandClick">
|
||||||
<span class="layout-navbars-breadcrumb-user-link">
|
<span class="layout-navbars-breadcrumb-user-link">
|
||||||
<img :src="baseURL + userInfos.user.avatar" class="layout-navbars-breadcrumb-user-link-photo mr5" />
|
<img :src="$fileUrl(userInfos.user.avatar)" class="layout-navbars-breadcrumb-user-link-photo mr5" @error="$fileRetry(userInfos.user.avatar)" />
|
||||||
{{ userInfos.user.username }}
|
{{ userInfos.user.username }}
|
||||||
<el-icon class="el-icon--right">
|
<el-icon class="el-icon--right">
|
||||||
<ele-ArrowDown />
|
<ele-ArrowDown />
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { installFileAccess } from './utils/file-access'
|
||||||
|
import { downBlobFile } from './utils/other'
|
||||||
import { createApp } from "vue"
|
import { createApp } from "vue"
|
||||||
import pinia from "/@/stores/index"
|
import pinia from "/@/stores/index"
|
||||||
import App from "./App.vue"
|
import App from "./App.vue"
|
||||||
@@ -23,6 +25,7 @@ import "/@/theme/tailwind.css"
|
|||||||
import "/@/theme/index.scss"
|
import "/@/theme/index.scss"
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
installFileAccess(app, downBlobFile)
|
||||||
|
|
||||||
// 全局自定方法挂载
|
// 全局自定方法挂载
|
||||||
properties(app)
|
properties(app)
|
||||||
|
|||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
import 'vue'
|
||||||
|
declare module '@vue/runtime-core' {
|
||||||
|
interface ComponentCustomProperties {
|
||||||
|
$fileUrl: (source?: string | null) => string
|
||||||
|
$fileUrls: (sources: any) => string[]
|
||||||
|
$fileHtml: (html: string) => string
|
||||||
|
$fileNodes: (nodes: any) => any
|
||||||
|
$fileRetry: (source?: string | null) => Promise<boolean>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import other from "/@/utils/other"
|
||||||
|
|
||||||
|
/** 文件中心结果由浏览器原生下载,避免 Axios Blob 将整个大文件读进 JS 内存。 */
|
||||||
|
export const downloadExportFile = (fileUrl: string, fileName: string) => {
|
||||||
|
if (!fileUrl) throw new Error("导出完成,但未返回文件下载地址")
|
||||||
|
const url = /^https?:\/\//i.test(fileUrl)
|
||||||
|
? fileUrl
|
||||||
|
: `${String(import.meta.env.VITE_API_URL || "").replace(/\/$/, "")}${other.adaptationUrl(fileUrl)}`
|
||||||
|
const link = document.createElement("a")
|
||||||
|
link.href = url
|
||||||
|
link.style.display = "none"
|
||||||
|
link.download = (fileName || "导出结果.xlsx").replace(/[<>:"/\\|?*]/g, "_")
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import * as CryptoJS from 'crypto-js'
|
||||||
|
|
||||||
|
export const FILE_ACCESS_FIELDS = ['appId', 'kid', 'ts', 'sign']
|
||||||
|
const FILE_PATHS = ['/sys-file/oss/file', '/admin/sys-file/oss/file', '/api/admin/sys-file/oss/file']
|
||||||
|
export interface FileAccessOptions {
|
||||||
|
appId: string; kid: string; key: string; baseUrl: () => string
|
||||||
|
allowedOrigins?: string[]; now?: () => number
|
||||||
|
}
|
||||||
|
const decode = (s: string) => decodeURIComponent(s.replace(/\+/g, ' '))
|
||||||
|
const encode = (s: string) => encodeURIComponent(s).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase())
|
||||||
|
const origin = (s: string) => {
|
||||||
|
const match = /^(https?):\/\/([^/?#]+)/i.exec(s)
|
||||||
|
if (!match || match[2].includes('@')) return ''
|
||||||
|
return (match[1] + '://' + match[2]).toLowerCase().replace(match[1].toLowerCase() === 'https' ? /:443$/ : /:80$/, '')
|
||||||
|
}
|
||||||
|
export function fileAccessCanonical(file: string, app: string, kid: string, ts: number): string {
|
||||||
|
const name = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(file)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
|
return ['v1', 'oss-file-read', app, kid, String(ts), name].join('\n')
|
||||||
|
}
|
||||||
|
export function fileAccessSignature(file: string, app: string, kid: string, ts: number, key: string): string {
|
||||||
|
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(key) || CryptoJS.enc.Base64.parse(key).sigBytes < 32) throw new Error('Invalid file access key')
|
||||||
|
return CryptoJS.HmacSHA256(fileAccessCanonical(file, app, kid, ts), CryptoJS.enc.Base64.parse(key)).toString(CryptoJS.enc.Hex)
|
||||||
|
}
|
||||||
|
export function fileUploadCanonical(target: string, app: string, kid: string, ts: number, nonce: string): string {
|
||||||
|
if (!['upload', 'inner-upload', 'qn-upload'].includes(target)) throw new Error('Invalid file upload target')
|
||||||
|
return ['v1', 'oss-file-upload', 'POST', target, app, kid, String(ts), nonce].join('\n')
|
||||||
|
}
|
||||||
|
export function fileUploadSignature(target: string, app: string, kid: string, ts: number, nonce: string, key: string): string {
|
||||||
|
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(key) || CryptoJS.enc.Base64.parse(key).sigBytes < 32) throw new Error('Invalid file upload key')
|
||||||
|
return CryptoJS.HmacSHA256(fileUploadCanonical(target, app, kid, ts, nonce), CryptoJS.enc.Base64.parse(key)).toString(CryptoJS.enc.Hex)
|
||||||
|
}
|
||||||
|
export function createFileAccess(options: FileAccessOptions) {
|
||||||
|
const now = options.now || Date.now
|
||||||
|
let offset = 0, ttl = 1800
|
||||||
|
const cache = new Map<string, { url: string; ts: number }>()
|
||||||
|
let uploadSequence = 0
|
||||||
|
const base = () => (options.baseUrl() || '').replace(/\/+$/, '')
|
||||||
|
const allowed = () => [origin(base()), ...(options.allowedOrigins || []).map(origin)].filter(Boolean)
|
||||||
|
function resolve(source?: string | null): string {
|
||||||
|
const raw = typeof source === 'string' ? source.trim() : ''
|
||||||
|
if (!raw || /^(?:data:|blob:|file:|wxfile:|content:|\/static\/|static\/|\/assets\/|\/src\/|@\/|#)/i.test(raw)) return raw
|
||||||
|
if (/^[a-z][a-z0-9+.-]*:/i.test(raw)) return raw
|
||||||
|
if (raw.startsWith('//')) return (base().startsWith('http:') ? 'http:' : 'https:') + raw
|
||||||
|
const root = base()
|
||||||
|
if (/^[^/?#:\\]+\.[a-z0-9]{1,10}$/i.test(raw)) return root + '/admin/sys-file/oss/file?fileName=' + encode(raw)
|
||||||
|
const path = raw.startsWith('/') ? raw : '/' + raw
|
||||||
|
if (/^(?:\/api)?\/admin\/sys-file\/oss\/(?:file|access-info)(?:[?#]|$)/.test(path)) return root + path.replace(/^\/api\//, '/')
|
||||||
|
const prefix = root.replace(/^https?:\/\/[^/?#]+/i, '')
|
||||||
|
if ((prefix && path.startsWith(prefix + '/')) || path.startsWith('/api/')) return (origin(root) || '') + path
|
||||||
|
if (path.startsWith('/sys-file/oss/')) return root + '/admin' + path
|
||||||
|
if (raw.startsWith('?')) return root + '/admin/sys-file/oss/file' + raw
|
||||||
|
return root + path
|
||||||
|
}
|
||||||
|
function parse(source?: string | null) {
|
||||||
|
const resolved = resolve(source)
|
||||||
|
const ownOrigin = origin(resolved)
|
||||||
|
if (ownOrigin && !allowed().includes(ownOrigin)) return null
|
||||||
|
if (/^[a-z][a-z0-9+.-]*:/i.test(resolved) && !ownOrigin) return null
|
||||||
|
const relative = ownOrigin ? resolved.replace(/^https?:\/\/[^/?#]+/i, '') : resolved
|
||||||
|
const path = relative.split(/[?#]/)[0]
|
||||||
|
const prefix = base().replace(/^https?:\/\/[^/?#]+/i, '')
|
||||||
|
if (!FILE_PATHS.includes(path) && path !== prefix + '/admin/sys-file/oss/file') return null
|
||||||
|
const hashAt = resolved.indexOf('#'), head = hashAt < 0 ? resolved : resolved.slice(0, hashAt)
|
||||||
|
const hash = hashAt < 0 ? '' : resolved.slice(hashAt)
|
||||||
|
const question = head.indexOf('?')
|
||||||
|
const endpoint = question < 0 ? head : head.slice(0, question)
|
||||||
|
const pairs = (question < 0 ? '' : head.slice(question + 1)).split('&').filter(Boolean).map(pair => {
|
||||||
|
const at = pair.indexOf('='); return [decode(at < 0 ? pair : pair.slice(0, at)), decode(at < 0 ? '' : pair.slice(at + 1))]
|
||||||
|
})
|
||||||
|
const names = pairs.filter(([key]) => key === 'fileName')
|
||||||
|
if (names.length !== 1 || !names[0][1] || /[\r\n]/.test(names[0][1])) throw new Error('Invalid file-center URL')
|
||||||
|
const extras = pairs.filter(([key]) => key !== 'fileName' && !FILE_ACCESS_FIELDS.includes(key))
|
||||||
|
const unsigned = endpoint + '?' + [...extras, ['fileName', names[0][1]]].map(([k, v]) => encode(k) + '=' + encode(v)).join('&') + hash
|
||||||
|
return { endpoint, file: names[0][1], extras, hash, unsigned }
|
||||||
|
}
|
||||||
|
function url(source?: string | null, force = false): string {
|
||||||
|
const parsed = parse(source)
|
||||||
|
if (!parsed) return resolve(source)
|
||||||
|
const seconds = Math.floor((now() + offset) / 1000), cached = cache.get(parsed.unsigned)
|
||||||
|
if (!force && cached && seconds >= cached.ts - 300 && seconds < cached.ts + ttl - Math.min(300, ttl / 6)) return cached.url
|
||||||
|
if (!options.appId || !options.kid) throw new Error('Missing file access configuration')
|
||||||
|
const sign = fileAccessSignature(parsed.file, options.appId, options.kid, seconds, options.key)
|
||||||
|
const pairs = [...parsed.extras, ['appId', options.appId], ['kid', options.kid], ['ts', String(seconds)], ['sign', sign], ['fileName', parsed.file]]
|
||||||
|
const result = parsed.endpoint + '?' + pairs.map(([k, v]) => encode(k) + '=' + encode(v)).join('&') + parsed.hash
|
||||||
|
if (cache.size >= 2048) cache.delete(cache.keys().next().value as string)
|
||||||
|
cache.set(parsed.unsigned, { url: result, ts: seconds })
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
function strip(source?: string | null): string {
|
||||||
|
if (!source) return source || ''
|
||||||
|
const parsed = parse(source)
|
||||||
|
if (!parsed) return source
|
||||||
|
// Preserve the original origin/path form in persisted values.
|
||||||
|
const hashAt = source.indexOf('#'), hash = hashAt < 0 ? '' : source.slice(hashAt)
|
||||||
|
const head = hashAt < 0 ? source : source.slice(0, hashAt), at = head.indexOf('?')
|
||||||
|
if (at < 0) return source
|
||||||
|
const query = head.slice(at + 1).split('&').filter(p => !FILE_ACCESS_FIELDS.includes(decode(p.split('=')[0])))
|
||||||
|
return head.slice(0, at) + (query.length ? '?' + query.join('&') : '') + hash
|
||||||
|
}
|
||||||
|
function uploadTarget(source: string): string | null {
|
||||||
|
const resolved = resolve(source), host = origin(resolved)
|
||||||
|
if ((host && !allowed().includes(host)) || (/^[a-z][a-z0-9+.-]*:/i.test(resolved) && !host)) return null
|
||||||
|
const path = (host ? resolved.replace(/^https?:\/\/[^/?#]+/i, '') : resolved).split(/[?#]/)[0]
|
||||||
|
const prefix = base().replace(/^https?:\/\/[^/?#]+/i, '')
|
||||||
|
for (const [target, suffix] of [['upload', 'upload'], ['inner-upload', 'inner-upload'], ['qn-upload', 'qn/upload']]) {
|
||||||
|
if (['/sys-file/', '/admin/sys-file/', '/api/sys-file/', '/api/admin/sys-file/', prefix + '/admin/sys-file/', prefix + '/sys-file/']
|
||||||
|
.some(start => path === start + suffix)) return target
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
function uploadHeaders(source: string, fixedNonce?: string): Record<string, string> {
|
||||||
|
const target = uploadTarget(source)
|
||||||
|
if (!target) return {}
|
||||||
|
const seconds = Math.floor((now() + offset) / 1000)
|
||||||
|
// The nonce is a unique request identifier, not a secret; HMAC provides authorization.
|
||||||
|
const nonce = fixedNonce || Math.floor(now()).toString(36) + '-' + (++uploadSequence).toString(36) + '-'
|
||||||
|
+ Array.from({ length: 4 }, () => Math.floor(Math.random() * 0x100000000).toString(16).padStart(8, '0')).join('')
|
||||||
|
return {
|
||||||
|
'X-File-App-Id': options.appId, 'X-File-Key-Id': options.kid, 'X-File-Timestamp': String(seconds), 'X-File-Nonce': nonce,
|
||||||
|
'X-File-Signature': fileUploadSignature(target, options.appId, options.kid, seconds, nonce, options.key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function synchronize(serverTime: number, ttlSeconds = ttl, roundTripMs = 0) {
|
||||||
|
if (!Number.isFinite(serverTime) || serverTime <= 0) return
|
||||||
|
offset = serverTime + roundTripMs / 2 - now()
|
||||||
|
if (Number.isFinite(ttlSeconds) && ttlSeconds > 300 && ttlSeconds <= 86400) ttl = ttlSeconds
|
||||||
|
// url() already checks cached timestamps against the corrected clock. Keep
|
||||||
|
// signatures that are still usable so a small clock correction cannot reload every image.
|
||||||
|
}
|
||||||
|
function invalidate(source?: string | null) {
|
||||||
|
const parsed = parse(source)
|
||||||
|
if (parsed) cache.delete(parsed.unsigned)
|
||||||
|
}
|
||||||
|
return { uploadHeaders, uploadTarget, url, resolve, strip, protected: (s?: string | null) => !!parse(s), synchronize, invalidate, clear: () => cache.clear(), ttl: () => ttl }
|
||||||
|
}
|
||||||
|
|
||||||
|
const unescapeAttribute = (s: string) => s.replace(/&/gi, '&').replace(/"/gi, '"').replace(/&#(?:39|x27);/gi, "'").replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n) => { const code = n[0].toLowerCase() === 'x' ? parseInt(n.slice(1), 16) : Number(n); return code <= 0x10ffff ? String.fromCodePoint(code) : ''; })
|
||||||
|
const escapeAttribute = (s: string) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''')
|
||||||
|
/** Rewrite only resource attributes; no DOM/window dependency (also used by mini programs). */
|
||||||
|
export function rewriteFileHtml(html: string, transform: (url: string) => string): string {
|
||||||
|
if (typeof html !== 'string' || !html) return html
|
||||||
|
return html.replace(/<[a-z][^>]*>/gi, tag => tag.replace(/\b(src|href|poster|srcset)\s*=\s*(["'])([\s\S]*?)\2/gi, (attribute, name, quote, raw) => {
|
||||||
|
const value = unescapeAttribute(raw)
|
||||||
|
const next = name.toLowerCase() === 'srcset' && !value.startsWith('data:')
|
||||||
|
? value.split(',').map(item => item.trim().replace(/^\S+/, transform)).join(', ')
|
||||||
|
: transform(value)
|
||||||
|
return next === value ? attribute : name + '=' + quote + escapeAttribute(next) + quote
|
||||||
|
}).replace(/url\(\s*(["']?)([^)'"\s]+)\1\s*\)/gi, (all, quote, value) => {
|
||||||
|
const next = transform(unescapeAttribute(value)); return next === value ? all : 'url(' + quote + escapeAttribute(next) + quote + ')'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
export function rewriteFileNodes(nodes: any, transform: (url: string) => string): any {
|
||||||
|
if (typeof nodes === 'string') return rewriteFileHtml(nodes, transform)
|
||||||
|
if (Array.isArray(nodes)) return nodes.map(node => rewriteFileNodes(node, transform))
|
||||||
|
if (!nodes || typeof nodes !== 'object') return nodes
|
||||||
|
const copy = { ...nodes }
|
||||||
|
if (copy.attrs) {
|
||||||
|
copy.attrs = { ...copy.attrs }
|
||||||
|
for (const key of ['src', 'href', 'poster']) if (typeof copy.attrs[key] === 'string') copy.attrs[key] = transform(copy.attrs[key])
|
||||||
|
}
|
||||||
|
if (copy.children) copy.children = rewriteFileNodes(copy.children, transform)
|
||||||
|
return copy
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { watch } from 'vue';
|
||||||
|
import { accessFileUrl, fileAccessRevision, retryFileAccess } from './file-access';
|
||||||
|
|
||||||
|
/** Renew editor resources without replacing the document or changing its undo history. */
|
||||||
|
export function bindFileAccessDom(root: HTMLElement) {
|
||||||
|
let scheduled = false;
|
||||||
|
let stopped = false;
|
||||||
|
const refresh = () => {
|
||||||
|
if (stopped) return;
|
||||||
|
for (const node of root.querySelectorAll<HTMLElement>('[src], [href], [poster]')) {
|
||||||
|
for (const attr of ['src', 'href', 'poster']) {
|
||||||
|
const source = node.getAttribute(attr);
|
||||||
|
if (!source) continue;
|
||||||
|
const target = accessFileUrl(source);
|
||||||
|
if (target !== source) node.setAttribute(attr, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const schedule = () => {
|
||||||
|
if (scheduled || stopped) return;
|
||||||
|
scheduled = true;
|
||||||
|
queueMicrotask(() => { scheduled = false; refresh(); });
|
||||||
|
};
|
||||||
|
const observer = new MutationObserver(schedule);
|
||||||
|
observer.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ['src', 'href', 'poster'] });
|
||||||
|
const unwatch = watch(fileAccessRevision, schedule, { flush: 'post' });
|
||||||
|
const onError = (event: Event) => {
|
||||||
|
const node = event.target;
|
||||||
|
if (node instanceof HTMLElement) void retryFileAccess(node.getAttribute('src')).then(schedule);
|
||||||
|
};
|
||||||
|
root.addEventListener('error', onError, true);
|
||||||
|
refresh();
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
observer.disconnect();
|
||||||
|
unwatch();
|
||||||
|
root.removeEventListener('error', onError, true);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { createFileAccess, rewriteFileHtml, rewriteFileNodes } from './file-access-core'
|
||||||
|
|
||||||
|
|
||||||
|
export const fileAccessRevision = ref(0)
|
||||||
|
const client = createFileAccess({
|
||||||
|
appId: import.meta.env.VITE_FILE_ACCESS_APP_ID,
|
||||||
|
kid: import.meta.env.VITE_FILE_ACCESS_KEY_ID,
|
||||||
|
key: import.meta.env.VITE_FILE_ACCESS_KEY,
|
||||||
|
baseUrl: () => import.meta.env.VITE_IMAGE_BASE_PATH || import.meta.env.VITE_API_URL || '/api',
|
||||||
|
allowedOrigins: [import.meta.env.VITE_IMAGE_BASE_PATH, typeof location === 'undefined' ? '' : location.origin, ...(import.meta.env.VITE_FILE_ACCESS_ALLOWED_ORIGINS || '').split(',')],
|
||||||
|
})
|
||||||
|
let timer: ReturnType<typeof setInterval> | undefined
|
||||||
|
let pending: Promise<void> | undefined
|
||||||
|
let lastSync = 0
|
||||||
|
const retried = new Map<string, number>()
|
||||||
|
const retrying = new Map<string, Promise<boolean>>()
|
||||||
|
export function fileUrl(source?: string | null): string {
|
||||||
|
void fileAccessRevision.value
|
||||||
|
try { return client.url(source) } catch { return '' }
|
||||||
|
}
|
||||||
|
export const accessFileUrl = (source?: string | null) => isProtectedFile(source) ? fileUrl(source) : (source || '')
|
||||||
|
export const fileUrls = (sources: any) => Array.isArray(sources) ? sources.map(accessFileUrl) : []
|
||||||
|
export const originalFileUrl = (source?: string | null) => client.strip(source)
|
||||||
|
export const resolveFileUrl = (source?: string | null) => client.resolve(source)
|
||||||
|
export const fileUploadHeaders = (source: string) => client.uploadHeaders(source)
|
||||||
|
export const fileHtml = (html: string) => rewriteFileHtml(html, accessFileUrl)
|
||||||
|
export const stripFileHtml = (html: string) => rewriteFileHtml(html, originalFileUrl)
|
||||||
|
export const fileNodes = (nodes: any) => rewriteFileNodes(nodes, accessFileUrl)
|
||||||
|
export const isProtectedFile = (source?: string | null) => { try { return client.protected(source) } catch { return false } }
|
||||||
|
export async function refreshFileAccess(force = false): Promise<void> {
|
||||||
|
if (pending) return pending
|
||||||
|
if (!force && Date.now() - lastSync < 60000) return
|
||||||
|
pending = (async () => {
|
||||||
|
const started = Date.now()
|
||||||
|
try {
|
||||||
|
const result: any = await readAccessInfo()
|
||||||
|
const info = result?.data || result
|
||||||
|
if (info?.serverTime) client.synchronize(Number(info.serverTime), Number(info.ttlSeconds), Date.now() - started)
|
||||||
|
lastSync = Date.now()
|
||||||
|
} catch { /* Retain the last clock offset and signed access; never fall back to a bare URL. */ }
|
||||||
|
finally { fileAccessRevision.value++; pending = undefined }
|
||||||
|
})()
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
export function startFileAccess() {
|
||||||
|
if (!timer) timer = setInterval(() => { fileAccessRevision.value++ }, 60000)
|
||||||
|
void refreshFileAccess()
|
||||||
|
}
|
||||||
|
export function stopFileAccess() { if (timer) clearInterval(timer); timer = undefined }
|
||||||
|
export async function retryFileAccess(source?: string | null): Promise<boolean> {
|
||||||
|
if (!isProtectedFile(source)) return false
|
||||||
|
const raw = originalFileUrl(source), previous = retried.get(raw) || 0
|
||||||
|
const active = retrying.get(raw)
|
||||||
|
if (active) return active
|
||||||
|
if (Date.now() - previous < 30 * 60 * 1000) return false
|
||||||
|
if (retried.size >= 2048) retried.clear()
|
||||||
|
retried.set(raw, Date.now())
|
||||||
|
const task = (async () => {
|
||||||
|
const previousUrl = fileUrl(source)
|
||||||
|
await refreshFileAccess(true)
|
||||||
|
client.invalidate(source)
|
||||||
|
if (fileUrl(source) === previousUrl) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1100))
|
||||||
|
client.invalidate(source)
|
||||||
|
}
|
||||||
|
fileAccessRevision.value++
|
||||||
|
return true
|
||||||
|
})()
|
||||||
|
retrying.set(raw, task)
|
||||||
|
try { return await task } finally { retrying.delete(raw) }
|
||||||
|
}
|
||||||
|
type FileDownloader = (url: string, query: Record<string, never>, name: string) => unknown
|
||||||
|
/** Keep owned file links inside the application's fetch/blob download flow. */
|
||||||
|
export function handleFileLinkClick(event: MouseEvent, download: FileDownloader): boolean {
|
||||||
|
const anchor = (event.target as Element | null)?.closest?.('a[href]')
|
||||||
|
const href = anchor?.getAttribute('href')
|
||||||
|
if (event.defaultPrevented || !href || !isProtectedFile(href)) return false
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
const name = /[?&]fileName=([^&#]*)/.exec(resolveFileUrl(href))
|
||||||
|
const filename = anchor?.getAttribute('download') || (name ? decodeURIComponent(name[1].replace(/\+/g, ' ')) : '文件')
|
||||||
|
void download(originalFileUrl(href), {}, filename)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
export function installFileAccess(app: any, download?: FileDownloader) {
|
||||||
|
Object.assign(app.config.globalProperties, {
|
||||||
|
$fileUrl: accessFileUrl, $fileUrls: fileUrls, $fileHtml: fileHtml, $fileNodes: fileNodes, $fileRetry: retryFileAccess,
|
||||||
|
})
|
||||||
|
startFileAccess()
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
if (download) document.addEventListener('click', event => { handleFileLinkClick(event, download) }, true)
|
||||||
|
window.addEventListener('online', () => { retried.clear(); void refreshFileAccess(true) })
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) stopFileAccess()
|
||||||
|
else { retried.clear(); startFileAccess() }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAccessInfo() {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 5000)
|
||||||
|
try {
|
||||||
|
const response = await fetch(client.resolve('/admin/sys-file/oss/access-info'), { credentials: 'omit', cache: 'no-store', signal: controller.signal })
|
||||||
|
if (!response.ok) throw new Error('File clock unavailable')
|
||||||
|
return await response.json()
|
||||||
|
} finally { clearTimeout(timeout) }
|
||||||
|
}
|
||||||
+8
-2
@@ -1,3 +1,4 @@
|
|||||||
|
import { accessFileUrl, refreshFileAccess, isProtectedFile } from './file-access'
|
||||||
import { nextTick } from "vue"
|
import { nextTick } from "vue"
|
||||||
import router from "/@/router/index"
|
import router from "/@/router/index"
|
||||||
import pinia from "/@/stores/index"
|
import pinia from "/@/stores/index"
|
||||||
@@ -255,9 +256,10 @@ export function base64Encrypt(src: string) {
|
|||||||
* @param fileName 文件名称
|
* @param fileName 文件名称
|
||||||
* @returns {*}
|
* @returns {*}
|
||||||
*/
|
*/
|
||||||
export function downBlobFile(url: any, query: any, fileName: string) {
|
export function downBlobFile(url: any, query: any, fileName: string, retried = false) {
|
||||||
return request({
|
return request({
|
||||||
url: url,
|
url: accessFileUrl(url),
|
||||||
|
...(isProtectedFile(url) ? { baseURL: '' } : {}),
|
||||||
method: "get",
|
method: "get",
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
params: query,
|
params: query,
|
||||||
@@ -266,6 +268,10 @@ export function downBlobFile(url: any, query: any, fileName: string) {
|
|||||||
handleBlobFile(response, fileName)
|
handleBlobFile(response, fileName)
|
||||||
})
|
})
|
||||||
.catch(async (error) => {
|
.catch(async (error) => {
|
||||||
|
if (!retried && isProtectedFile(url)) {
|
||||||
|
await refreshFileAccess(true)
|
||||||
|
return downBlobFile(url, query, fileName, true)
|
||||||
|
}
|
||||||
let msg = "下载失败"
|
let msg = "下载失败"
|
||||||
try {
|
try {
|
||||||
if (error instanceof Blob) {
|
if (error instanceof Blob) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Session } from "/@/utils/storage"
|
|||||||
import { useMessageBox } from "/@/hooks/message"
|
import { useMessageBox } from "/@/hooks/message"
|
||||||
import qs from "qs"
|
import qs from "qs"
|
||||||
import other from "./other"
|
import other from "./other"
|
||||||
|
import { fileUploadHeaders } from './file-access'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建并配置一个 Axios 实例对象
|
* 创建并配置一个 Axios 实例对象
|
||||||
@@ -48,6 +49,7 @@ service.interceptors.request.use(
|
|||||||
|
|
||||||
// 自动适配单体和微服务架构不同的URL
|
// 自动适配单体和微服务架构不同的URL
|
||||||
config.url = other.adaptationUrl(config.url)
|
config.url = other.adaptationUrl(config.url)
|
||||||
|
if (config.method?.toLowerCase() === 'post') Object.assign(config.headers, fileUploadHeaders(axios.getUri(config)))
|
||||||
|
|
||||||
// 处理完毕,返回config对象
|
// 处理完毕,返回config对象
|
||||||
return config
|
return config
|
||||||
@@ -82,6 +84,8 @@ const handleResponse = (response: AxiosResponse<any>) => {
|
|||||||
* 添加 Axios 的响应拦截器,用于全局响应结果处理
|
* 添加 Axios 的响应拦截器,用于全局响应结果处理
|
||||||
*/
|
*/
|
||||||
service.interceptors.response.use(handleResponse, (error) => {
|
service.interceptors.response.use(handleResponse, (error) => {
|
||||||
|
// 超时和网络中断没有 response,保留原错误供页面展示和重试。
|
||||||
|
if (!error.response) return Promise.reject(error)
|
||||||
const status = Number(error.response.status) || 200
|
const status = Number(error.response.status) || 200
|
||||||
if (status === 423) {
|
if (status === 423) {
|
||||||
return Promise.reject({ msg: '"演示环境,仅供预览"' })
|
return Promise.reject({ msg: '"演示环境,仅供预览"' })
|
||||||
|
|||||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
@@ -7,31 +7,33 @@
|
|||||||
<div class="layout-padding-auto layout-padding-view">
|
<div class="layout-padding-auto layout-padding-view">
|
||||||
<el-row>
|
<el-row>
|
||||||
<div class="mb8" style="width: 100%">
|
<div class="mb8" style="width: 100%">
|
||||||
<el-button @click="dicDialogRef.openDialog()" class="ml10" icon="folder-add" type="primary">
|
<el-button @click="dicDialogRef.openDialog()" class="ml10" icon="folder-add"
|
||||||
|
type="primary">
|
||||||
{{ $t("common.addBtn") }}
|
{{ $t("common.addBtn") }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button plain @click="handleRefreshCache()" class="ml10" icon="refresh-left" type="primary">
|
<el-button plain @click="handleRefreshCache()" class="ml10" icon="refresh-left"
|
||||||
|
type="primary">
|
||||||
{{ $t("common.refreshCacheBtn") }}
|
{{ $t("common.refreshCacheBtn") }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-scrollbar>
|
<el-scrollbar>
|
||||||
<query-tree ref="dictTreeRef" :query="state.queryList" @node-click="handleNodeClick" placeholder="请输入字典项或名称">
|
<query-tree ref="dictTreeRef" :query="state.queryList" @node-click="handleNodeClick"
|
||||||
|
placeholder="请输入字典项或名称">
|
||||||
<template #default="{ data }">
|
<template #default="{ data }">
|
||||||
<span class="custom-tree-node">
|
<span class="custom-tree-node">
|
||||||
<span class="label">{{ data.description }}</span>
|
<span class="label">{{ data.description }}</span>
|
||||||
<span class="code">{{ data.dictType }}</span>
|
<span class="code">{{ data.dictType }}</span>
|
||||||
<span class="do">
|
<span class="do">
|
||||||
<el-button-group>
|
<el-button-group>
|
||||||
<el-button icon="el-icon-edit" size="small" @click.stop="dicDialogRef.openDialog(data.id)"></el-button>
|
<el-button icon="el-icon-edit" size="small"
|
||||||
<el-tooltip :content="$t('sysdict.deleteDisabledTip')" :disabled="data.systemFlag === '0'" placement="top">
|
@click.stop="dicDialogRef.openDialog(data.id)"></el-button>
|
||||||
|
<el-tooltip :content="$t('sysdict.deleteDisabledTip')"
|
||||||
|
:disabled="data.systemFlag === '0'" placement="top">
|
||||||
<span style="margin-left: 12px">
|
<span style="margin-left: 12px">
|
||||||
<el-button
|
<el-button :disabled="data.systemFlag !== '0'"
|
||||||
:disabled="data.systemFlag !== '0'"
|
icon="el-icon-delete" size="small"
|
||||||
icon="el-icon-delete"
|
@click.stop="handleDelete([data.id])"></el-button>
|
||||||
size="small"
|
|
||||||
@click.stop="handleDelete([data.id])"
|
|
||||||
></el-button>
|
|
||||||
</span>
|
</span>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</el-button-group>
|
</el-button-group>
|
||||||
@@ -41,9 +43,10 @@
|
|||||||
</query-tree>
|
</query-tree>
|
||||||
</el-scrollbar>
|
</el-scrollbar>
|
||||||
<el-footer style="height: 40px; line-height: 40px">
|
<el-footer style="height: 40px; line-height: 40px">
|
||||||
<el-button type="primary" size="small" icon="Download" style="width: 100%" @click="exportExcel">{{
|
<el-button type="primary" size="small" icon="Download" style="width: 100%"
|
||||||
$t("common.exportBtn")
|
@click="exportExcel">{{
|
||||||
}}</el-button>
|
$t("common.exportBtn")
|
||||||
|
}}</el-button>
|
||||||
</el-footer>
|
</el-footer>
|
||||||
</div>
|
</div>
|
||||||
</pane>
|
</pane>
|
||||||
@@ -130,6 +133,7 @@ const handleDelete = async (ids: string[]) => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-tree-node {
|
.custom-tree-node {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -139,16 +143,20 @@ const handleDelete = async (ids: string[]) => {
|
|||||||
padding-right: 24px;
|
padding-right: 24px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-tree-node .code {
|
.custom-tree-node .code {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-tree-node .do {
|
.custom-tree-node .do {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-tree-node:hover .code {
|
.custom-tree-node:hover .code {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-tree-node:hover .do {
|
.custom-tree-node:hover .do {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
v-model="state.queryForm.createTime"
|
v-model="state.queryForm.createTime"
|
||||||
value-format="YYYY-MM-DD HH:mm:ss"
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
/>
|
/>
|
||||||
|
<div class="log-export-time-tip">导出需选择完整时间范围,最多支持一个自然月</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</SearchPanel>
|
</SearchPanel>
|
||||||
</el-row>
|
</el-row>
|
||||||
@@ -109,15 +110,23 @@
|
|||||||
<pagination @current-change="currentChangeHandle" @size-change="sizeChangeHandle" v-bind="state.pagination"></pagination>
|
<pagination @current-change="currentChangeHandle" @size-change="sizeChangeHandle" v-bind="state.pagination"></pagination>
|
||||||
<log-detail ref="LogDetailRef"></log-detail>
|
<log-detail ref="LogDetailRef"></log-detail>
|
||||||
</div>
|
</div>
|
||||||
|
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess"
|
||||||
|
@failure="handleExportFailure" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { BasicTableProps, useTable } from "/@/hooks/table"
|
import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||||
import { delObj, pageList } from "/@/api/admin/log"
|
import { delObj, pageList, submitLogExport } from "/@/api/admin/log"
|
||||||
|
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||||
|
import { useBatchExport } from "/@/hooks/batchExport"
|
||||||
import { useI18n } from "vue-i18n"
|
import { useI18n } from "vue-i18n"
|
||||||
import { useMessage, useMessageBox } from "/@/hooks/message"
|
import { useMessage, useMessageBox } from "/@/hooks/message"
|
||||||
import { useDict } from "/@/hooks/dict"
|
import { useDict } from "/@/hooks/dict"
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
import customParseFormat from "dayjs/plugin/customParseFormat"
|
||||||
|
|
||||||
|
dayjs.extend(customParseFormat)
|
||||||
|
|
||||||
const LogDetail = defineAsyncComponent(() => import("./detail.vue"))
|
const LogDetail = defineAsyncComponent(() => import("./detail.vue"))
|
||||||
const LogLineChart = defineAsyncComponent(() => import("./line-chart.vue"))
|
const LogLineChart = defineAsyncComponent(() => import("./line-chart.vue"))
|
||||||
@@ -152,7 +161,6 @@ const state: BasicTableProps = reactive<BasicTableProps>({
|
|||||||
|
|
||||||
// table hook
|
// table hook
|
||||||
const {
|
const {
|
||||||
downBlobFile,
|
|
||||||
getDataList,
|
getDataList,
|
||||||
currentChangeHandle: baseCurrentChangeHandle,
|
currentChangeHandle: baseCurrentChangeHandle,
|
||||||
sortChangeHandle,
|
sortChangeHandle,
|
||||||
@@ -175,8 +183,36 @@ const resetQuery = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 导出excel
|
// 导出excel
|
||||||
|
const { exportProgressRef, exportTaskConfig, exportSubmitting, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||||
|
progressUrl: "/admin/async-task/progress", redisKey: "admin:export:log",
|
||||||
|
taskName: "操作日志", fileName: "操作日志.xlsx", submitTask: submitLogExport,
|
||||||
|
})
|
||||||
|
|
||||||
const exportExcel = () => {
|
const exportExcel = () => {
|
||||||
downBlobFile("/admin/log/export", state.queryForm, "操作日志.xlsx")
|
if (exportSubmitting.value) {
|
||||||
|
exportProgressRef.value?.show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const range = state.queryForm.createTime
|
||||||
|
let error = ""
|
||||||
|
if (!Array.isArray(range) || range.length !== 2 || !range[0] || !range[1]) {
|
||||||
|
error = "导出前请选择完整的日志创建时间范围"
|
||||||
|
} else {
|
||||||
|
const start = dayjs(range[0], "YYYY-MM-DD HH:mm:ss", true)
|
||||||
|
const end = dayjs(range[1], "YYYY-MM-DD HH:mm:ss", true)
|
||||||
|
if (!start.isValid() || !end.isValid()) error = "请选择有效的日志创建时间范围"
|
||||||
|
else if (start.isAfter(end)) error = "日志创建时间的开始时间不能晚于结束时间"
|
||||||
|
else if (end.isAfter(start.add(1, "month"))) error = "日志导出时间范围不能超过一个月"
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
showSearch.value = true
|
||||||
|
useMessage().error(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return submitExport({
|
||||||
|
...state.queryForm,
|
||||||
|
createTime: range,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 多选事件
|
// 多选事件
|
||||||
@@ -214,6 +250,13 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
.log-export-time-tip {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
pre code.hljs {
|
pre code.hljs {
|
||||||
width: 65%;
|
width: 65%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<div class="layout-padding">
|
||||||
|
<div class="layout-padding-auto layout-padding-view">
|
||||||
|
<el-alert
|
||||||
|
title="用于验证 Seata Server(如升级到 2.1)后全局事务是否正常。请先在业务库执行 db/seata_tx_probe.sql。"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="mb8"
|
||||||
|
/>
|
||||||
|
<div class="mb8">
|
||||||
|
<el-button type="primary" :loading="commitLoading" @click="runCommit" v-auth="'admin_seata_probe'">
|
||||||
|
测试提交(应落库)
|
||||||
|
</el-button>
|
||||||
|
<el-button type="warning" :loading="rollbackLoading" @click="runRollback" v-auth="'admin_seata_probe'">
|
||||||
|
测试回滚(应不落库)
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-descriptions v-if="lastResult" title="最近一次结果" :column="1" border>
|
||||||
|
<el-descriptions-item label="是否通过">
|
||||||
|
<el-tag :type="lastResult.pass ? 'success' : 'danger'">{{ lastResult.pass ? '通过' : '未通过' }}</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="说明">{{ lastResult.message }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="类型">{{ lastResult.caseType }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="mark">{{ lastResult.mark }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="xid">{{ lastResult.xid || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="库中行数">{{ lastResult.rowCount }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<div v-else style="color: #909399">点击上方按钮开始测试</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" name="adminSeataProbe">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useMessage } from '/@/hooks/message'
|
||||||
|
import { seataProbeCommit, seataProbeRollback } from '/@/api/admin/seataProbe'
|
||||||
|
|
||||||
|
const commitLoading = ref(false)
|
||||||
|
const rollbackLoading = ref(false)
|
||||||
|
const lastResult = ref<any>(null)
|
||||||
|
|
||||||
|
const pickBody = (res: any) => {
|
||||||
|
if (res?.data && typeof res.data === 'object' && ('pass' in res.data || 'mark' in res.data)) {
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
return res?.data ?? res
|
||||||
|
}
|
||||||
|
|
||||||
|
const runCommit = async () => {
|
||||||
|
commitLoading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await seataProbeCommit()
|
||||||
|
const body = pickBody(res)
|
||||||
|
lastResult.value = body
|
||||||
|
if (res?.code === 0 && body?.pass) {
|
||||||
|
useMessage().success(body.message || '提交探测通过')
|
||||||
|
} else {
|
||||||
|
useMessage().error(res?.msg || body?.message || '提交探测失败')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
useMessage().error(e?.msg || e?.message || '请求失败')
|
||||||
|
} finally {
|
||||||
|
commitLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runRollback = async () => {
|
||||||
|
rollbackLoading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await seataProbeRollback()
|
||||||
|
const body = pickBody(res)
|
||||||
|
lastResult.value = body
|
||||||
|
if (res?.code === 0 && body?.pass) {
|
||||||
|
useMessage().success(body.message || '回滚探测通过')
|
||||||
|
} else {
|
||||||
|
useMessage().error(res?.msg || body?.message || '回滚探测失败')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
useMessage().error(e?.msg || e?.message || '请求失败')
|
||||||
|
} finally {
|
||||||
|
rollbackLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
匹配结果
|
匹配结果
|
||||||
<tip content="可点击敏感词加入白名单"/>
|
<tip content="可点击敏感词加入白名单"/>
|
||||||
</template>
|
</template>
|
||||||
<div v-html="matchResult" @click="handleChildClick"/>
|
<div v-html="$fileHtml(matchResult)" @click="handleChildClick"/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|||||||
@@ -58,11 +58,15 @@
|
|||||||
@refreshDataList="getDataList"
|
@refreshDataList="getDataList"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig"
|
||||||
|
@success="handleExportSuccess" @failure="handleExportFailure" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" name="systemDept" setup>
|
<script lang="ts" name="systemDept" setup>
|
||||||
import { downBlobFile } from "/@/utils/other"
|
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||||
|
import { submitDeptExport } from "/@/api/admin/dept"
|
||||||
|
import { useBatchExport } from "/@/hooks/batchExport"
|
||||||
import { useI18n } from "vue-i18n"
|
import { useI18n } from "vue-i18n"
|
||||||
import { useUserInfo } from "/@/stores/userInfo"
|
import { useUserInfo } from "/@/stores/userInfo"
|
||||||
|
|
||||||
@@ -120,8 +124,13 @@ const handleExpand = () => {
|
|||||||
/**
|
/**
|
||||||
* 导出Excel
|
* 导出Excel
|
||||||
*/
|
*/
|
||||||
|
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||||
|
progressUrl: "/admin/async-task/progress", redisKey: "admin:export:dept",
|
||||||
|
taskName: "部门管理", fileName: "部门管理.xlsx", submitTask: submitDeptExport,
|
||||||
|
})
|
||||||
|
|
||||||
const exportExcel = () => {
|
const exportExcel = () => {
|
||||||
downBlobFile("/admin/dept/export", state.queryForm, "部门管理.xlsx")
|
return submitExport(state.queryForm)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDataList = () => {
|
const getDataList = () => {
|
||||||
|
|||||||
@@ -2,51 +2,33 @@
|
|||||||
<div class="layout-padding">
|
<div class="layout-padding">
|
||||||
<div class="layout-padding-auto layout-padding-view">
|
<div class="layout-padding-auto layout-padding-view">
|
||||||
<el-row v-show="showSearch" class="ml10">
|
<el-row v-show="showSearch" class="ml10">
|
||||||
<SearchPanel
|
<SearchPanel v-show="showSearch" ref="queryRef" :model="state.queryForm"
|
||||||
v-show="showSearch"
|
:loading="Boolean(state.loading)" :search-text="$t('common.queryBtn')"
|
||||||
ref="queryRef"
|
:reset-text="$t('common.resetBtn')" @search="getDataList" @reset="resetQuery">
|
||||||
:model="state.queryForm"
|
|
||||||
:loading="Boolean(state.loading)"
|
|
||||||
:search-text="$t('common.queryBtn')"
|
|
||||||
:reset-text="$t('common.resetBtn')"
|
|
||||||
@search="getDataList"
|
|
||||||
@reset="resetQuery"
|
|
||||||
>
|
|
||||||
<el-form-item :label="$t('sysmenu.name')" prop="menuName">
|
<el-form-item :label="$t('sysmenu.name')" prop="menuName">
|
||||||
<el-input :placeholder="$t('sysmenu.inputNameTip')" clearable style="max-width: 180px" v-model="state.queryForm.menuName" />
|
<el-input :placeholder="$t('sysmenu.inputNameTip')" clearable style="max-width: 180px"
|
||||||
|
v-model="state.queryForm.menuName" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</SearchPanel>
|
</SearchPanel>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-row>
|
<el-row>
|
||||||
<div class="mb8" style="width: 100%">
|
<div class="mb8" style="width: 100%">
|
||||||
<el-button @click="onOpenAddMenu" class="ml10" icon="folder-add" type="primary" v-auth="'sys_menu_add'">
|
<el-button @click="onOpenAddMenu" class="ml10" icon="folder-add" type="primary"
|
||||||
|
v-auth="'sys_menu_add'">
|
||||||
{{ $t("common.addBtn") }}
|
{{ $t("common.addBtn") }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<right-toolbar
|
<right-toolbar v-model:showSearch="showSearch" class="ml10" style="float: right; margin-right: 20px"
|
||||||
v-model:showSearch="showSearch"
|
@queryTable="query"></right-toolbar>
|
||||||
class="ml10"
|
|
||||||
style="float: right; margin-right: 20px"
|
|
||||||
@queryTable="query"
|
|
||||||
></right-toolbar>
|
|
||||||
</div>
|
</div>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-table
|
<el-table class="el-table" ref="tableRef" :data="tableList" row-key="id" style="width: 100%"
|
||||||
class="el-table"
|
v-loading="state.loading" border :header-cell-style="tableStyle?.headerCellStyle"
|
||||||
ref="tableRef"
|
:expand-row-keys="expandRowKeys" lazy :load="load"
|
||||||
:data="tableList"
|
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }" @expand-change="handleExpandChange">
|
||||||
row-key="id"
|
<el-table-column :label="$t('sysmenu.name')" fixed prop="name" align="left" show-overflow-tooltip
|
||||||
style="width: 100%"
|
min-width="200" />
|
||||||
v-loading="state.loading"
|
<el-table-column :label="$t('sysmenu.sortOrder')" prop="sortOrder" align="center"
|
||||||
border
|
show-overflow-tooltip />
|
||||||
:header-cell-style="tableStyle?.headerCellStyle"
|
|
||||||
:expand-row-keys="expandRowKeys"
|
|
||||||
lazy
|
|
||||||
:load="load"
|
|
||||||
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
|
|
||||||
@expand-change="handleExpandChange"
|
|
||||||
>
|
|
||||||
<el-table-column :label="$t('sysmenu.name')" fixed prop="name" align="left" show-overflow-tooltip min-width="200" />
|
|
||||||
<el-table-column :label="$t('sysmenu.sortOrder')" prop="sortOrder" align="center" show-overflow-tooltip />
|
|
||||||
<el-table-column :label="$t('sysmenu.icon')" prop="icon" show-overflow-tooltip align="center">
|
<el-table-column :label="$t('sysmenu.icon')" prop="icon" show-overflow-tooltip align="center">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<SvgIcon :name="scope.row.meta.icon" />
|
<SvgIcon :name="scope.row.meta.icon" />
|
||||||
@@ -66,26 +48,23 @@
|
|||||||
<el-tag type="info" v-else>关闭</el-tag>
|
<el-tag type="info" v-else>关闭</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="$t('sysmenu.permission')" :show-overflow-tooltip="true" prop="permission" align="center" />
|
<el-table-column :label="$t('sysmenu.permission')" :show-overflow-tooltip="true" prop="permission"
|
||||||
|
align="center" />
|
||||||
<el-table-column :label="$t('common.action')" show-overflow-tooltip width="250" align="center">
|
<el-table-column :label="$t('common.action')" show-overflow-tooltip width="250" align="center">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button icon="folder-add" @click="onOpenAddMenu('add', scope.row)" text type="primary" v-auth="'sys_menu_add'">
|
<el-button icon="folder-add" @click="onOpenAddMenu('add', scope.row)" text type="primary"
|
||||||
|
v-auth="'sys_menu_add'">
|
||||||
{{ $t("common.addBtn") }}
|
{{ $t("common.addBtn") }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button icon="edit-pen" @click="onOpenEditMenu('edit', scope.row)" text type="primary" v-auth="'sys_menu_edit'"
|
<el-button icon="edit-pen" @click="onOpenEditMenu('edit', scope.row)" text type="primary"
|
||||||
>{{ $t("common.editBtn") }}
|
v-auth="'sys_menu_edit'">{{ $t("common.editBtn") }}
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|
||||||
<el-tooltip icon="delete" :content="$t('sysmenu.deleteDisabledTip')" :disabled="!deleteMenuDisabled(scope.row)" placement="top">
|
<el-tooltip icon="delete" :content="$t('sysmenu.deleteDisabledTip')"
|
||||||
|
:disabled="!deleteMenuDisabled(scope.row)" placement="top">
|
||||||
<span style="margin-left: 12px">
|
<span style="margin-left: 12px">
|
||||||
<el-button
|
<el-button icon="delete" :disabled="deleteMenuDisabled(scope.row)"
|
||||||
icon="delete"
|
@click="handleDelete(scope.row)" text type="primary" v-auth="'sys_menu_del'">
|
||||||
:disabled="deleteMenuDisabled(scope.row)"
|
|
||||||
@click="handleDelete(scope.row)"
|
|
||||||
text
|
|
||||||
type="primary"
|
|
||||||
v-auth="'sys_menu_del'"
|
|
||||||
>
|
|
||||||
{{ $t("common.delBtn") }}
|
{{ $t("common.delBtn") }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12" class="mb20">
|
<el-col :span="12" class="mb20">
|
||||||
<el-form-item :label="$t('sysuser.password')" prop="password">
|
<el-form-item :label="$t('sysuser.password')" prop="password">
|
||||||
<el-input clearable placeholder="请输入密码" type="password"
|
<el-input clearable placeholder="字母+数字,至少8位" type="password"
|
||||||
v-model="dataForm.password"></el-input>
|
v-model="dataForm.password"></el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -170,11 +170,33 @@ const dataRules = ref({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
password: [
|
password: [
|
||||||
{ required: true, message: "密码不能为空", trigger: "blur" },
|
|
||||||
{
|
{
|
||||||
min: 6,
|
validator: (_rule: any, value: any, callback: any) => {
|
||||||
max: 20,
|
// 编辑时占位 ****** 不校验;新增或修改了明文密码则校验
|
||||||
message: "用户密码长度必须介于 5 和 20 之间",
|
if (dataForm.userId && (!value || String(value).includes("******"))) {
|
||||||
|
return callback()
|
||||||
|
}
|
||||||
|
if (!value) {
|
||||||
|
return callback(new Error("密码不能为空"))
|
||||||
|
}
|
||||||
|
const pwd = String(value)
|
||||||
|
if (pwd.length < 8) {
|
||||||
|
return callback(new Error("密码长度不能少于8位"))
|
||||||
|
}
|
||||||
|
if (pwd.length > 20) {
|
||||||
|
return callback(new Error("密码长度不能超过20位"))
|
||||||
|
}
|
||||||
|
if (!/[A-Za-z]/.test(pwd) || !/[0-9]/.test(pwd)) {
|
||||||
|
return callback(new Error("密码必须同时包含字母和数字"))
|
||||||
|
}
|
||||||
|
if (dataForm.phone && pwd === String(dataForm.phone).trim()) {
|
||||||
|
return callback(new Error("密码不能与手机号相同"))
|
||||||
|
}
|
||||||
|
if (dataForm.username && pwd.toLowerCase() === String(dataForm.username).trim().toLowerCase()) {
|
||||||
|
return callback(new Error("密码不能与用户名相同"))
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
trigger: "blur",
|
trigger: "blur",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -75,6 +75,19 @@
|
|||||||
>
|
>
|
||||||
审批
|
审批
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<!-- 线上已执行完,暂时注释入口
|
||||||
|
<el-button
|
||||||
|
plain
|
||||||
|
v-auth="'sys_user_edit'"
|
||||||
|
class="ml10"
|
||||||
|
icon="Key"
|
||||||
|
type="warning"
|
||||||
|
:loading="fixWeakPwdLoading"
|
||||||
|
@click="openFixWeakPwdDialog"
|
||||||
|
>
|
||||||
|
修复弱口令
|
||||||
|
</el-button>
|
||||||
|
-->
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<el-tabs class="flex-1" v-model="activeName" @tab-click="handleClickTabItem">
|
<el-tabs class="flex-1" v-model="activeName" @tab-click="handleClickTabItem">
|
||||||
<el-tab-pane v-for="v in tabItems" :key="v.id" :label="v.label" :name="v.value" />
|
<el-tab-pane v-for="v in tabItems" :key="v.id" :label="v.label" :name="v.value" />
|
||||||
@@ -224,10 +237,26 @@
|
|||||||
|
|
||||||
<!-- 批量审批对话框 -->
|
<!-- 批量审批对话框 -->
|
||||||
<batch-deal ref="batchDealRef" @task-submit-event="taskSubmitEvent" />
|
<batch-deal ref="batchDealRef" @task-submit-event="taskSubmitEvent" />
|
||||||
|
|
||||||
|
<!-- 线上已执行完,暂时注释修复弱口令入口
|
||||||
|
<el-dialog v-model="fixWeakPwdVisible" title="修复弱口令(密码=手机号)" width="480px" destroy-on-close>
|
||||||
|
...
|
||||||
|
</el-dialog>
|
||||||
|
<el-dialog v-model="fixWeakPwdResultVisible" title="弱口令修复结果" width="640px" destroy-on-close>
|
||||||
|
...
|
||||||
|
</el-dialog>
|
||||||
|
<async-task-progress-component ref="fixWeakPwdProgressRef" :config="fixWeakPwdTaskConfig" @success="handleFixWeakPwdSuccess" @failure="handleFixWeakPwdFailure" />
|
||||||
|
-->
|
||||||
|
|
||||||
|
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess" @failure="handleExportFailure" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" name="systemUser" setup>
|
<script lang="ts" name="systemUser" setup>
|
||||||
|
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||||
|
import { useBatchExport } from "/@/hooks/batchExport"
|
||||||
|
import { submitUserExport as submitBatchExport } from "/@/api/admin/user"
|
||||||
|
|
||||||
import { delObj, pageList, putObj } from "/@/api/admin/user"
|
import { delObj, pageList, putObj } from "/@/api/admin/user"
|
||||||
import { putGlobalFace } from "/@/api/admin/userFace"
|
import { putGlobalFace } from "/@/api/admin/userFace"
|
||||||
import { BasicTableProps, useTable } from "/@/hooks/table"
|
import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||||
@@ -274,7 +303,7 @@ const state: BasicTableProps = reactive<BasicTableProps>({
|
|||||||
},
|
},
|
||||||
pageList: pageList,
|
pageList: pageList,
|
||||||
})
|
})
|
||||||
const { getDataList, currentChangeHandle, sizeChangeHandle, postDownBlobFile, tableStyle } = useTable(state)
|
const { getDataList, currentChangeHandle, sizeChangeHandle, tableStyle } = useTable(state)
|
||||||
|
|
||||||
const { user_staffing_type } = useDict("user_staffing_type")
|
const { user_staffing_type } = useDict("user_staffing_type")
|
||||||
|
|
||||||
@@ -325,14 +354,17 @@ const taskSubmitEvent = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 导出excel
|
// 导出excel
|
||||||
|
/** 当前页面只声明原导出场景,进度及重复提交由公共 hook 管理。 */
|
||||||
|
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||||
|
progressUrl: "/admin/async-task/progress", redisKey: "admin:export:user",
|
||||||
|
taskName: "用户管理", fileName: "用户管理.xlsx", submitTask: submitBatchExport,
|
||||||
|
})
|
||||||
|
|
||||||
const exportExcel = () => {
|
const exportExcel = () => {
|
||||||
const _data = { ...state.queryForm }
|
const query = { ...state.queryForm }
|
||||||
if (_data.role && !Array.isArray(_data.role)) {
|
// 保留原导出角色参数的转换规则及勾选元数据。
|
||||||
_data.role = [_data.role]
|
query.role = query.role && !Array.isArray(query.role) ? [query.role] : []
|
||||||
} else {
|
return submitExport({ ...query, ids: [...selectObjs.value] })
|
||||||
_data.role = []
|
|
||||||
}
|
|
||||||
postDownBlobFile("/admin/user/export", Object.assign(_data, { ids: selectObjs.value }), "用户管理.xlsx")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 是否可以多选
|
// 是否可以多选
|
||||||
@@ -394,4 +426,6 @@ const changePassword = async (row: any) => {
|
|||||||
useMessage().success(t("common.optSuccessText"))
|
useMessage().success(t("common.optSuccessText"))
|
||||||
getDataList()
|
getDataList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 线上已执行完,暂时注释修复弱口令入口相关逻辑(按钮/弹窗/异步进度)
|
||||||
</script>
|
</script>
|
||||||
@@ -32,6 +32,16 @@
|
|||||||
<el-form-item label="手机号" prop="phone">
|
<el-form-item label="手机号" prop="phone">
|
||||||
<el-input v-model="state.queryForm.phone" placeholder="请输入手机号" clearable />
|
<el-input v-model="state.queryForm.phone" placeholder="请输入手机号" clearable />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item style="width: 180px" label="人脸质量" prop="qualityCategory">
|
||||||
|
<el-select v-model="state.queryForm.qualityCategory" placeholder="全部" clearable>
|
||||||
|
<el-option
|
||||||
|
v-for="option in FACE_QUALITY_FILTER_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
</SearchPanel>
|
</SearchPanel>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-row>
|
<el-row>
|
||||||
@@ -83,6 +93,27 @@
|
|||||||
<preview-image :src="row.faceImg" />
|
<preview-image :src="row.faceImg" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="人脸质量" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="getFaceQualityTooltip(row)" placement="top" :show-after="250">
|
||||||
|
<span
|
||||||
|
tabindex="0"
|
||||||
|
role="status"
|
||||||
|
:aria-label="getFaceQualityTooltip(row)"
|
||||||
|
class="inline-flex cursor-help rounded-sm focus:outline-none focus:ring-2 focus:ring-primary-light-5"
|
||||||
|
>
|
||||||
|
<el-tag
|
||||||
|
:type="getFaceQualityTagType(row)"
|
||||||
|
size="small"
|
||||||
|
effect="plain"
|
||||||
|
class="min-w-[112px] justify-center whitespace-nowrap tabular-nums"
|
||||||
|
>
|
||||||
|
{{ getFaceQualityLabel(row) }}
|
||||||
|
</el-tag>
|
||||||
|
</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="审核状态" prop="processStatus" show-overflow-tooltip min-width="100">
|
<el-table-column label="审核状态" prop="processStatus" show-overflow-tooltip min-width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<dict-tag :options="check_status" :value="row.processStatus" />
|
<dict-tag :options="check_status" :value="row.processStatus" />
|
||||||
@@ -141,6 +172,104 @@ const processDrawerRef = ref()
|
|||||||
const batchDealRef = ref()
|
const batchDealRef = ref()
|
||||||
const loading = ref()
|
const loading = ref()
|
||||||
|
|
||||||
|
/** PC 当前阶段用于判定低质量人脸的固定阈值,与终端默认在库阈值保持一致。 */
|
||||||
|
const LIBRARY_FACE_QUALITY_THRESHOLD = 0.6
|
||||||
|
|
||||||
|
/** 人脸质量查询分类,与后端分页接口的 qualityCategory 协议值保持一致。 */
|
||||||
|
const FACE_QUALITY_FILTER_OPTIONS = [
|
||||||
|
{ label: "合格", value: "qualified" },
|
||||||
|
{ label: "低质量", value: "low" },
|
||||||
|
{ label: "检测失败", value: "failed" },
|
||||||
|
{ label: "未检测", value: "pending" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** Element Plus 小号状态标签允许使用的语义类型。 */
|
||||||
|
type FaceQualityTagType = "success" | "warning" | "danger" | "info"
|
||||||
|
|
||||||
|
/** 审批列表中与服务端人脸质量字段有关的最小行结构。 */
|
||||||
|
interface UserFaceQualityRow {
|
||||||
|
/** 虹软综合质量分,数据库 decimal 可能由不同网关序列化为数字或文本。 */
|
||||||
|
qualityScore?: number | string | null
|
||||||
|
/** 服务端质量状态;历史未检测记录为空。 */
|
||||||
|
qualityStatus?: string | null
|
||||||
|
/** 终端完成质量检测的时间。 */
|
||||||
|
qualityCheckedAt?: string | null
|
||||||
|
/** 虹软 SDK 或终端插件错误码。 */
|
||||||
|
qualityErrorCode?: number | string | null
|
||||||
|
/** 最后一次成功写入质量结果的终端设备主键。 */
|
||||||
|
qualityDeviceId?: number | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 人脸质量单元格的紧凑展示模型。 */
|
||||||
|
interface FaceQualityDisplay {
|
||||||
|
/** 标签内可直接理解的状态与分数文案。 */
|
||||||
|
label: string
|
||||||
|
/** 与状态对应的 Element Plus 语义颜色。 */
|
||||||
|
tagType: FaceQualityTagType
|
||||||
|
/** 包含阈值、时间、来源和排查动作的完整辅助说明。 */
|
||||||
|
tooltip: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将服务端人脸质量字段归一化为不依赖颜色的表格状态。
|
||||||
|
*
|
||||||
|
* @param row 审批列表人脸记录
|
||||||
|
* @returns 标签文案、语义颜色和完整诊断说明
|
||||||
|
*/
|
||||||
|
const resolveFaceQuality = (row: UserFaceQualityRow): FaceQualityDisplay => {
|
||||||
|
const checkedAt = row.qualityCheckedAt || "未知"
|
||||||
|
const deviceId = row.qualityDeviceId ?? "未知"
|
||||||
|
if (row.qualityStatus === "detected") {
|
||||||
|
const score = Number(row.qualityScore)
|
||||||
|
if (row.qualityScore == null || row.qualityScore === "" || !Number.isFinite(score) || score < 0 || score > 1) {
|
||||||
|
return {
|
||||||
|
label: "数据异常",
|
||||||
|
tagType: "danger",
|
||||||
|
tooltip: `服务端质量状态为已检测,但质量分无效。检测时间:${checkedAt};来源设备:${deviceId}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const scoreText = score.toFixed(2)
|
||||||
|
if (score < LIBRARY_FACE_QUALITY_THRESHOLD) {
|
||||||
|
return {
|
||||||
|
label: `低质量 ${scoreText}`,
|
||||||
|
tagType: "warning",
|
||||||
|
tooltip: `质量分 ${scoreText},低于当前阈值 ${LIBRARY_FACE_QUALITY_THRESHOLD.toFixed(
|
||||||
|
2,
|
||||||
|
)}。请排查照片清晰度、光照、人脸角度及人脸区域大小。检测时间:${checkedAt};来源设备:${deviceId}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: `合格 ${scoreText}`,
|
||||||
|
tagType: "success",
|
||||||
|
tooltip: `质量分 ${scoreText},达到当前阈值 ${LIBRARY_FACE_QUALITY_THRESHOLD.toFixed(
|
||||||
|
2,
|
||||||
|
)}。检测时间:${checkedAt};来源设备:${deviceId}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (row.qualityStatus === "failed") {
|
||||||
|
const errorCode = row.qualityErrorCode == null ? "未知" : row.qualityErrorCode
|
||||||
|
return {
|
||||||
|
label: "检测失败",
|
||||||
|
tagType: "danger",
|
||||||
|
tooltip: `质量检测失败,错误码:${errorCode}。请确认终端虹软质量授权和本地图片有效性。检测时间:${checkedAt};来源设备:${deviceId}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: "未检测",
|
||||||
|
tagType: "info",
|
||||||
|
tooltip: "尚未收到终端质量检测结果,可在终端人脸管理中执行存量检测。",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回人脸质量标签文案。 */
|
||||||
|
const getFaceQualityLabel = (row: UserFaceQualityRow) => resolveFaceQuality(row).label
|
||||||
|
|
||||||
|
/** 返回人脸质量标签语义类型。 */
|
||||||
|
const getFaceQualityTagType = (row: UserFaceQualityRow) => resolveFaceQuality(row).tagType
|
||||||
|
|
||||||
|
/** 返回人脸质量 Tooltip 与无障碍说明。 */
|
||||||
|
const getFaceQualityTooltip = (row: UserFaceQualityRow) => resolveFaceQuality(row).tooltip
|
||||||
|
|
||||||
// 多选rows
|
// 多选rows
|
||||||
const selectObjs = ref([]) as any
|
const selectObjs = ref([]) as any
|
||||||
const selectIds = ref([]) as any
|
const selectIds = ref([]) as any
|
||||||
@@ -153,6 +282,7 @@ const state: BasicTableProps = reactive<BasicTableProps>({
|
|||||||
deptId: "",
|
deptId: "",
|
||||||
username: "",
|
username: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
|
qualityCategory: "",
|
||||||
isDeepDept: false,
|
isDeepDept: false,
|
||||||
processStatus: "2",
|
processStatus: "2",
|
||||||
},
|
},
|
||||||
|
|||||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-image :style="styles" v-bind="props" :src="src.includes('http') ? src : baseURL + src">
|
<file-image :style="styles" v-bind="props" :src="$fileUrl(src)" @error="$fileRetry(src)">
|
||||||
<template #placeholder>
|
<template #placeholder>
|
||||||
<div class="image-slot"></div>
|
<div class="image-slot"></div>
|
||||||
</template>
|
</template>
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
<el-icon><Picture /></el-icon>
|
<el-icon><Picture /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-image>
|
</file-image>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="flex items-center news-title mx-[10px] my-[15px] text-[17px] font-medium">最新资讯</div>
|
<div class="flex items-center news-title mx-[10px] my-[15px] text-[17px] font-medium">最新资讯</div>
|
||||||
<div v-for="item in newsList" :key="item.id" class="news-card flex bg-white px-[10px] py-[16px] text-[#333] border-[#f2f2f2] border-b">
|
<div v-for="item in newsList" :key="item.id" class="news-card flex bg-white px-[10px] py-[16px] text-[#333] border-[#f2f2f2] border-b">
|
||||||
<div class="mr-[10px]" v-if="item.image">
|
<div class="mr-[10px]" v-if="item.image">
|
||||||
<img :src="item.image.includes('http') ? item.image : baseURL + item.image" class="w-[120px] h-[90px]" />
|
<img :src="$fileUrl(item.image)" class="w-[120px] h-[90px]" @error="$fileRetry(item.image)" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col justify-between flex-1">
|
<div class="flex flex-col justify-between flex-1">
|
||||||
<div class="text-[15px] font-medium line-clamp-2">{{ item.title }}</div>
|
<div class="text-[15px] font-medium line-clamp-2">{{ item.title }}</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="pages-preview mx-[30px]">
|
<div class="pages-preview mx-[30px]">
|
||||||
<div class="flex tabbar dark:bg-gray-700">
|
<div class="flex tabbar dark:bg-gray-700">
|
||||||
<div class="flex flex-col items-center justify-center flex-1 tabbar-item" v-for="(item, index) in tabbar.list" :key="index">
|
<div class="flex flex-col items-center justify-center flex-1 tabbar-item" v-for="(item, index) in tabbar.list" :key="index">
|
||||||
<img class="w-[22px] h-[22px]" :src="item.unselected.includes('http') ? item.unselected: baseURL + item.unselected" alt="" />
|
<img class="w-[22px] h-[22px]" :src="$fileUrl(item.unselected)" alt="" @error="$fileRetry(item.unselected)" />
|
||||||
<div class="leading-3 text-[12px] mt-[4px] dark:text-gray-300">{{ item.name }}</div>
|
<div class="leading-3 text-[12px] mt-[4px] dark:text-gray-300">{{ item.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -75,10 +75,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="scope.row.repType === 'text'">{{ scope.row.repContent }}</div>
|
<div v-if="scope.row.repType === 'text'">{{ scope.row.repContent }}</div>
|
||||||
<div v-if="scope.row.repType === 'image'">
|
<div v-if="scope.row.repType === 'image'">
|
||||||
<a target="_blank" :href="scope.row.repUrl"><img :src="scope.row.repUrl" style="width: 100px" /></a>
|
<a target="_blank" :href="$fileUrl(scope.row.repUrl)"><img :src="$fileUrl(scope.row.repUrl)" style="width: 100px" @error="$fileRetry(scope.row.repUrl)" /></a>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="['video', 'voice', 'link', 'shortvideo'].includes(scope.row.repType)">
|
<div v-if="['video', 'voice', 'link', 'shortvideo'].includes(scope.row.repType)">
|
||||||
<el-tag>链接</el-tag>:<a :href="scope.row.repUrl" target="_blank">{{ scope.row.repName }}</a>
|
<el-tag>链接</el-tag>:<a :href="$fileUrl(scope.row.repUrl)" target="_blank">{{ scope.row.repName }}</a>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<div v-for="(news, index) in articlesAdd" :key="news.id">
|
<div v-for="(news, index) in articlesAdd" :key="news.id">
|
||||||
<div v-if="index == 0" class="news-main father" :class="{ activeAddNews: isActiveAddNews === index }" @click="activeNews(index)">
|
<div v-if="index == 0" class="news-main father" :class="{ activeAddNews: isActiveAddNews === index }" @click="activeNews(index)">
|
||||||
<div class="news-content">
|
<div class="news-content">
|
||||||
<img v-if="news.thumbUrl" class="material-img" :src="news.thumbUrl" />
|
<img v-if="news.thumbUrl" class="material-img" :src="$fileUrl(news.thumbUrl)" @error="$fileRetry(news.thumbUrl)" />
|
||||||
<div class="news-content-title">{{ news.title }}</div>
|
<div class="news-content-title">{{ news.title }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="articlesAdd.length > 1" class="child">
|
<div v-if="articlesAdd.length > 1" class="child">
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<div class="news-content-item">
|
<div class="news-content-item">
|
||||||
<div class="news-content-item-title">{{ news.title }}</div>
|
<div class="news-content-item-title">{{ news.title }}</div>
|
||||||
<div class="news-content-item-img">
|
<div class="news-content-item-img">
|
||||||
<img v-if="news.thumbUrl" class="material-img" :src="news.thumbUrl" height="100%" />
|
<img v-if="news.thumbUrl" class="material-img" :src="$fileUrl(news.thumbUrl)" height="100%" @error="$fileRetry(news.thumbUrl)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="child">
|
<div class="child">
|
||||||
@@ -52,9 +52,9 @@
|
|||||||
<img
|
<img
|
||||||
v-if="articlesAdd[isActiveAddNews].thumbUrl"
|
v-if="articlesAdd[isActiveAddNews].thumbUrl"
|
||||||
class="material-img"
|
class="material-img"
|
||||||
:src="articlesAdd[isActiveAddNews].thumbUrl"
|
:src="$fileUrl(articlesAdd[isActiveAddNews].thumbUrl)"
|
||||||
:class="isActiveAddNews === 0 ? 'avatar' : 'avatar1'"
|
:class="isActiveAddNews === 0 ? 'avatar' : 'avatar1'"
|
||||||
/>
|
@error="$fileRetry(articlesAdd[isActiveAddNews].thumbUrl)" />
|
||||||
<i v-else class="el-icon-plus avatar-uploader-icon" :class="isActiveAddNews === 0 ? 'avatar' : 'avatar1'"></i>
|
<i v-else class="el-icon-plus avatar-uploader-icon" :class="isActiveAddNews === 0 ? 'avatar' : 'avatar1'"></i>
|
||||||
<div class="thumb-but">
|
<div class="thumb-but">
|
||||||
<wx-file-upload :uploadData="uploadData" @success="handleImageChange"></wx-file-upload>
|
<wx-file-upload :uploadData="uploadData" @success="handleImageChange"></wx-file-upload>
|
||||||
|
|||||||
@@ -22,8 +22,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-loading="state.loading" class="waterfall">
|
<div v-loading="state.loading" class="waterfall">
|
||||||
<div v-for="item in state.dataList" :key="item.id" class="waterfall-item">
|
<div v-for="item in state.dataList" :key="item.id" class="waterfall-item">
|
||||||
<a target="_blank" :href="item.url">
|
<a target="_blank" :href="$fileUrl(item.url)">
|
||||||
<img class="material-img" :src="item.url" />
|
<img class="material-img" :src="$fileUrl(item.url)" @error="$fileRetry(item.url)" />
|
||||||
<div class="item-name">{{ item.name }}</div>
|
<div class="item-name">{{ item.name }}</div>
|
||||||
</a>
|
</a>
|
||||||
<el-row class="ope-row">
|
<el-row class="ope-row">
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
<td v-if="!loading" class="text-center" @click="addFood(1)">早餐</td>
|
<td v-if="!loading" class="text-center" @click="addFood(1)">早餐</td>
|
||||||
<div class="food_list">
|
<div class="food_list">
|
||||||
<td class="food_list_item" v-for="(item, index) in foodData1" :key="index" @click="delteData(item.tid)">
|
<td class="food_list_item" v-for="(item, index) in foodData1" :key="index" @click="delteData(item.tid)">
|
||||||
<el-image style="width: 80px; height: 80px" :src="imageBaseUrl + item.picUrl" fit="cover" />
|
<file-image style="width: 80px; height: 80px" :src="$fileUrl(imageBaseUrl + item.picUrl)" fit="cover" @error="$fileRetry(imageBaseUrl + item.picUrl)" />
|
||||||
<div class="w-full flex justify-around items-center text-black mt-2">
|
<div class="w-full flex justify-around items-center text-black mt-2">
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
<span class="mx-1">|</span>
|
<span class="mx-1">|</span>
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
<td v-if="!loading" class="text-center" @click="addFood(2)">中餐</td>
|
<td v-if="!loading" class="text-center" @click="addFood(2)">中餐</td>
|
||||||
<div class="food_list">
|
<div class="food_list">
|
||||||
<td class="food_list_item" v-for="(item, index) in foodData2" :key="index" @click="delteData(item.tid)">
|
<td class="food_list_item" v-for="(item, index) in foodData2" :key="index" @click="delteData(item.tid)">
|
||||||
<el-image style="width: 80px; height: 80px" :src="imageBaseUrl + item.picUrl" fit="cover" />
|
<file-image style="width: 80px; height: 80px" :src="$fileUrl(imageBaseUrl + item.picUrl)" fit="cover" @error="$fileRetry(imageBaseUrl + item.picUrl)" />
|
||||||
<div class="w-full flex justify-around items-center text-black mt-2">
|
<div class="w-full flex justify-around items-center text-black mt-2">
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
<span class="mx-1">|</span>
|
<span class="mx-1">|</span>
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
<td v-if="!loading" class="text-center" @click="addFood(3)">晚餐</td>
|
<td v-if="!loading" class="text-center" @click="addFood(3)">晚餐</td>
|
||||||
<div class="food_list">
|
<div class="food_list">
|
||||||
<td class="food_list_item" v-for="(item, index) in foodData3" :key="index" @click="delteData(item.tid)">
|
<td class="food_list_item" v-for="(item, index) in foodData3" :key="index" @click="delteData(item.tid)">
|
||||||
<el-image style="width: 80px; height: 80px" loading="lazy" :src="imageBaseUrl + item.picUrl" fit="cover" />
|
<file-image style="width: 80px; height: 80px" loading="lazy" :src="$fileUrl(imageBaseUrl + item.picUrl)" fit="cover" @error="$fileRetry(imageBaseUrl + item.picUrl)" />
|
||||||
<div class="w-full flex justify-around items-center text-black mt-2">
|
<div class="w-full flex justify-around items-center text-black mt-2">
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
<span class="mx-1">|</span>
|
<span class="mx-1">|</span>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
>
|
>
|
||||||
<CircleClose />
|
<CircleClose />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<el-image style="width: 80px; height: 80px" :src="imageBaseUrl + item.picUrl" fit="cover" />
|
<file-image style="width: 80px; height: 80px" :src="$fileUrl(imageBaseUrl + item.picUrl)" fit="cover" @error="$fileRetry(imageBaseUrl + item.picUrl)" />
|
||||||
<div class="w-full flex justify-around items-center text-black mt-2">
|
<div class="w-full flex justify-around items-center text-black mt-2">
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
<span class="mx-1">|</span>
|
<span class="mx-1">|</span>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
<td v-if="!loading" class="text-center" @click="addFood(1)">早餐</td>
|
<td v-if="!loading" class="text-center" @click="addFood(1)">早餐</td>
|
||||||
<div class="food_list">
|
<div class="food_list">
|
||||||
<td class="food_list_item" v-for="(item, index) in foodData1" :key="index" @click="delteData(item.tid)">
|
<td class="food_list_item" v-for="(item, index) in foodData1" :key="index" @click="delteData(item.tid)">
|
||||||
<el-image style="width: 80px; height: 80px" :src="imageBaseUrl + item.picUrl" fit="cover" />
|
<file-image style="width: 80px; height: 80px" :src="$fileUrl(imageBaseUrl + item.picUrl)" fit="cover" @error="$fileRetry(imageBaseUrl + item.picUrl)" />
|
||||||
<div class="w-full flex justify-around items-center text-black mt-2">
|
<div class="w-full flex justify-around items-center text-black mt-2">
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
<span class="mx-1">|</span>
|
<span class="mx-1">|</span>
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
<td v-if="!loading" class="text-center" @click="addFood(2)">中餐</td>
|
<td v-if="!loading" class="text-center" @click="addFood(2)">中餐</td>
|
||||||
<div class="food_list">
|
<div class="food_list">
|
||||||
<td class="food_list_item" v-for="(item, index) in foodData2" :key="index" @click="delteData(item.tid)">
|
<td class="food_list_item" v-for="(item, index) in foodData2" :key="index" @click="delteData(item.tid)">
|
||||||
<el-image style="width: 80px; height: 80px" :src="imageBaseUrl + item.picUrl" fit="cover" />
|
<file-image style="width: 80px; height: 80px" :src="$fileUrl(imageBaseUrl + item.picUrl)" fit="cover" @error="$fileRetry(imageBaseUrl + item.picUrl)" />
|
||||||
<div class="w-full flex justify-around items-center text-black mt-2">
|
<div class="w-full flex justify-around items-center text-black mt-2">
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
<span class="mx-1">|</span>
|
<span class="mx-1">|</span>
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
<td v-if="!loading" class="text-center" @click="addFood(3)">晚餐</td>
|
<td v-if="!loading" class="text-center" @click="addFood(3)">晚餐</td>
|
||||||
<div class="food_list">
|
<div class="food_list">
|
||||||
<td class="food_list_item" v-for="(item, index) in foodData3" :key="index" @click="delteData(item.tid)">
|
<td class="food_list_item" v-for="(item, index) in foodData3" :key="index" @click="delteData(item.tid)">
|
||||||
<el-image style="width: 80px; height: 80px" loading="lazy" :src="imageBaseUrl + item.picUrl" fit="cover" />
|
<file-image style="width: 80px; height: 80px" loading="lazy" :src="$fileUrl(imageBaseUrl + item.picUrl)" fit="cover" @error="$fileRetry(imageBaseUrl + item.picUrl)" />
|
||||||
<div class="w-full flex justify-around items-center text-black mt-2">
|
<div class="w-full flex justify-around items-center text-black mt-2">
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
<span class="mx-1">|</span>
|
<span class="mx-1">|</span>
|
||||||
|
|||||||
@@ -41,8 +41,8 @@
|
|||||||
<div class="w-full flex flex-wrap items-center" v-for="(hourType, index) in foodData" :key="index">
|
<div class="w-full flex flex-wrap items-center" v-for="(hourType, index) in foodData" :key="index">
|
||||||
<div v-for="dish in hourType" :key="dish.id" class="p-2" :style="cardStyle">
|
<div v-for="dish in hourType" :key="dish.id" class="p-2" :style="cardStyle">
|
||||||
<div class="relative overflow-hidden rounded-tl rounded-tr" :style="foodCardStyle">
|
<div class="relative overflow-hidden rounded-tl rounded-tr" :style="foodCardStyle">
|
||||||
<img :src="imageBaseUrl + dish.picUrl" :alt="dish.name"
|
<img :src="$fileUrl(imageBaseUrl + dish.picUrl)" :alt="dish.name"
|
||||||
class="h-full w-full object-cover" />
|
class="h-full w-full object-cover" @error="$fileRetry(imageBaseUrl + dish.picUrl)" />
|
||||||
<div class="ribbon">午餐</div>
|
<div class="ribbon">午餐</div>
|
||||||
<div class="absolute left-1 top-1">
|
<div class="absolute left-1 top-1">
|
||||||
<star-medal
|
<star-medal
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="form.perm === 'R'" v-html="form.props.value" />
|
<div v-if="form.perm === 'R'" v-html="$fileHtml(form.props.value)" />
|
||||||
<editor v-else v-model:get-html="form.props.value" :placeholder="form.placeholder" />
|
<editor v-else v-model:get-html="form.props.value" :placeholder="form.placeholder" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="form.perm === 'R'">
|
<div v-if="form.perm === 'R'">
|
||||||
<img :src="form.props.value" />
|
<img :src="$fileUrl(form.props.value)" @error="$fileRetry(form.props.value)" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<sign
|
<sign
|
||||||
|
|||||||
@@ -1,114 +1,115 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-loading="loading">
|
<div v-loading="loading" class="door-log">
|
||||||
<el-descriptions v-if="detail" title="智能柜日志">
|
<template v-if="detail && detail.id">
|
||||||
<el-descriptions-item labelClassName="no-colon">
|
<div class="door-log__head">
|
||||||
<el-timeline>
|
<span class="door-log__title">智能柜流程日志</span>
|
||||||
<el-timeline-item placement="top">
|
<el-tag v-if="flowStatusText" size="small" :type="flowStatusTagType" effect="plain">当前节点:{{ flowStatusText }}</el-tag>
|
||||||
<div class="el-timeline-right-content">
|
<el-tag v-if="detail.skuSubmitRecogId" size="small" type="info" effect="plain">识别ID:{{ detail.skuSubmitRecogId }}</el-tag>
|
||||||
<div>
|
</div>
|
||||||
<el-tag type="info" style="width: 120px">用户授权</el-tag>
|
|
||||||
<span class="color-primary ml-2">{{ detail.createTime }}</span>
|
<div class="door-log__flow">
|
||||||
</div>
|
<div
|
||||||
<div>
|
v-for="node in flowNodes"
|
||||||
<div class="flow_coll">
|
:key="node.key"
|
||||||
<span>授权用户:</span>
|
class="flow-node"
|
||||||
<span>{{ detail.username }}</span>
|
:class="[`is-${node.status}`, { 'is-idle': !node.time }]"
|
||||||
</div>
|
>
|
||||||
</div>
|
<div class="flow-node__rail">
|
||||||
|
<span class="flow-node__dot" />
|
||||||
|
</div>
|
||||||
|
<div class="flow-node__card">
|
||||||
|
<div class="flow-node__head">
|
||||||
|
<span class="flow-node__title">{{ node.title }}</span>
|
||||||
|
<el-tag v-if="node.tagText" size="small" :type="node.tagType" effect="light">{{ node.tagText }}</el-tag>
|
||||||
|
<span class="flow-node__time">{{ node.time || "--" }}</span>
|
||||||
</div>
|
</div>
|
||||||
</el-timeline-item>
|
<div v-if="node.desc" class="flow-node__desc">{{ node.desc }}</div>
|
||||||
</el-timeline>
|
<div v-if="node.hint" class="flow-node__hint">{{ node.hint }}</div>
|
||||||
<el-timeline v-if="detail.requestOpenTime">
|
<div v-if="node.payloads?.length" class="flow-node__payloads">
|
||||||
<el-timeline-item placement="top">
|
<el-button
|
||||||
<div class="el-timeline-right-content">
|
v-for="payload in node.payloads"
|
||||||
<div>
|
:key="payload.label"
|
||||||
<el-tag type="info" style="width: 120px">智能柜申请开门</el-tag>
|
link
|
||||||
<span class="color-primary ml-2">{{ detail.requestOpenTime }}</span>
|
type="primary"
|
||||||
</div>
|
size="small"
|
||||||
|
@click="openPayload(node.title + ' - ' + payload.label, payload.json)"
|
||||||
|
>
|
||||||
|
{{ payload.label }}
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-timeline-item>
|
<!-- 错误节点:人工处理入口 -->
|
||||||
</el-timeline>
|
<div v-if="node.key === 'error'" class="flow-node__actions">
|
||||||
<el-timeline v-if="detail.openCallbackTime">
|
<el-button v-if="!detail?.hardAuth?.manualChangeUserId" type="primary" link size="small" @click="visibleErrorChangeDiaog = true">
|
||||||
<el-timeline-item placement="top">
|
|
||||||
<div class="el-timeline-right-content">
|
|
||||||
<div>
|
|
||||||
<el-tag type="info" style="width: 120px">智能柜开门响应</el-tag>
|
|
||||||
<span class="color-primary ml-2">{{ detail.openCallbackTime }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-timeline-item>
|
|
||||||
</el-timeline>
|
|
||||||
<el-timeline v-if="detail.videoCallbackTime">
|
|
||||||
<el-timeline-item placement="top">
|
|
||||||
<div class="el-timeline-right-content">
|
|
||||||
<div>
|
|
||||||
<el-tag type="info" style="width: 120px">智能柜视频回调</el-tag>
|
|
||||||
<span class="color-primary ml-2">{{ detail.videoCallbackTime }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-timeline-item>
|
|
||||||
</el-timeline>
|
|
||||||
<el-timeline v-if="detail.skuSubmitTime">
|
|
||||||
<el-timeline-item placement="top">
|
|
||||||
<div class="el-timeline-right-content">
|
|
||||||
<div>
|
|
||||||
<el-tag type="info" style="width: 120px">商品提交识别</el-tag>
|
|
||||||
<span class="color-primary ml-2">{{ detail.skuSubmitTime }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-timeline-item>
|
|
||||||
</el-timeline>
|
|
||||||
<el-timeline v-if="detail.skuCallbackTime">
|
|
||||||
<el-timeline-item placement="top">
|
|
||||||
<div class="el-timeline-right-content">
|
|
||||||
<div>
|
|
||||||
<el-tag type="info" style="width: 120px">商品识别响应</el-tag>
|
|
||||||
<span class="color-primary ml-2">{{ detail.skuCallbackTime }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-timeline-item>
|
|
||||||
</el-timeline>
|
|
||||||
<el-timeline v-if="detail.payTime">
|
|
||||||
<el-timeline-item placement="top">
|
|
||||||
<div class="el-timeline-right-content">
|
|
||||||
<div>
|
|
||||||
<el-tag type="info" style="width: 120px">订单结算</el-tag>
|
|
||||||
<span class="color-primary ml-2">{{ detail.payTime }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-timeline-item>
|
|
||||||
</el-timeline>
|
|
||||||
<el-timeline v-if="detail.error && detail.errorDesc">
|
|
||||||
<el-timeline-item placement="top">
|
|
||||||
<div class="el-timeline-right-content">
|
|
||||||
<el-tag type="danger" style="width: 120px">错误</el-tag>
|
|
||||||
<span class="color-danger ml-2">{{ detail.errorDesc }}</span>
|
|
||||||
<el-button v-if="!detail?.hardAuth?.manualChangeUserId" class="ml-2" link
|
|
||||||
@click="visibleErrorChangeDiaog = true">
|
|
||||||
处理
|
处理
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-timeline-item>
|
</div>
|
||||||
</el-timeline>
|
</div>
|
||||||
<el-timeline v-if="detail?.hardAuth?.manualChangeUserId">
|
|
||||||
<el-timeline-item placement="top">
|
<!-- 手动结算/取消操作快照 -->
|
||||||
<div class="el-timeline-right-content">
|
<div v-if="manualVo" class="flow-node is-manual">
|
||||||
<el-tag type="warning" style="width: 120px">手动处理智能柜订单</el-tag>
|
<div class="flow-node__rail">
|
||||||
<span class="color-warning ml-2">{{ detail.hardAuth.manualChangeTime }}</span>
|
<span class="flow-node__dot" />
|
||||||
<span class="color-warning ml-2">{{ detail.hardAuth.manualChangeRemark }}</span>
|
</div>
|
||||||
<div class="ml-2" v-if="detail.hardAuth.manualChangePic">
|
<div class="flow-node__card">
|
||||||
<preview-image width="40px" height="40px" :src="detail.hardAuth.manualChangePic" />
|
<div class="flow-node__head">
|
||||||
|
<span class="flow-node__title">{{ manualVo.manualCancel ? "手动取消订单" : "手动结算" }}</span>
|
||||||
|
<el-tag size="small" type="warning" effect="light">{{ manualVo.manualCancel ? "人工取消" : "人工结算" }}</el-tag>
|
||||||
|
<span class="flow-node__time">{{ manualVo.time || "--" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flow-node__desc">
|
||||||
|
操作人:{{ manualVo.operator || "--" }}
|
||||||
|
<template v-if="manualVo.reason"> 备注:{{ manualVo.reason }}</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="!manualVo.manualCancel && manualItems.length" class="flow-node__cart">
|
||||||
|
<div v-for="(item, index) in manualItems" :key="index" class="flow-node__cart-row">
|
||||||
|
<span class="flow-node__cart-name">{{ item.name }}</span>
|
||||||
|
<span class="flow-node__cart-count">{{ displayPrice(item.price) }} × {{ item.productCount }}</span>
|
||||||
|
<span class="flow-node__cart-price">{{ displayPrice(item.totalPrice) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flow-node__cart-total">
|
||||||
|
<span>合计:<span class="flow-node__cart-price">{{ displayPrice(manualVo.totalPrice) }}</span></span>
|
||||||
|
<span v-if="manualVo.payChannel" class="flow-node__cart-channel">({{ manualVo.payChannel }})</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-timeline-item>
|
<div class="flow-node__payloads">
|
||||||
</el-timeline>
|
<el-button link type="primary" size="small" @click="openPayload((manualVo.manualCancel ? '手动取消订单' : '手动结算') + ' - 快照报文', manualRaw)">
|
||||||
</el-descriptions-item>
|
快照报文
|
||||||
</el-descriptions>
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-dialog title="手动处理智能柜订单" width="30%" @close="dialogClose" v-model="visibleErrorChangeDiaog"
|
<!-- 手动处理智能柜订单记录 -->
|
||||||
:close-on-click-modal="false" draggable>
|
<div v-if="detail?.hardAuth?.manualChangeUserId" class="flow-node is-manual">
|
||||||
<el-form ref="formRef" :model="form" :rules="dataRules" formDialogRef label-width="90px"
|
<div class="flow-node__rail">
|
||||||
v-loading="formLoading">
|
<span class="flow-node__dot" />
|
||||||
|
</div>
|
||||||
|
<div class="flow-node__card">
|
||||||
|
<div class="flow-node__head">
|
||||||
|
<span class="flow-node__title">手动处理智能柜订单</span>
|
||||||
|
<el-tag size="small" type="warning" effect="light">人工处理</el-tag>
|
||||||
|
<span class="flow-node__time">{{ detail.hardAuth.manualChangeTime }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="detail.hardAuth.manualChangeRemark" class="flow-node__desc">{{ detail.hardAuth.manualChangeRemark }}</div>
|
||||||
|
<div v-if="detail.hardAuth.manualChangePic" class="flow-node__desc">
|
||||||
|
<preview-image width="40px" height="40px" :src="detail.hardAuth.manualChangePic" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-empty v-else-if="!loading" description="暂无智能柜流程日志" :image-size="60" />
|
||||||
|
|
||||||
|
<!-- 报文查看抽屉 -->
|
||||||
|
<el-drawer v-model="payloadVisible" :title="payloadTitle" size="45%" append-to-body>
|
||||||
|
<vue-json-pretty v-if="payloadData !== null" :data="payloadData" showLength showLineNumber showIcon :collapsedOnClickBrackets="false" />
|
||||||
|
<pre v-else class="door-log__raw">{{ payloadRaw }}</pre>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
|
<!-- 手动处理智能柜订单 -->
|
||||||
|
<el-dialog title="手动处理智能柜订单" width="30%" @close="dialogClose" v-model="visibleErrorChangeDiaog" :close-on-click-modal="false" draggable>
|
||||||
|
<el-form ref="formRef" :model="form" :rules="dataRules" formDialogRef label-width="90px" v-loading="formLoading">
|
||||||
<el-row :gutter="24">
|
<el-row :gutter="24">
|
||||||
<el-col :span="24" class="mb20">
|
<el-col :span="24" class="mb20">
|
||||||
<el-form-item label="设备名" prop="image">
|
<el-form-item label="设备名" prop="image">
|
||||||
@@ -117,8 +118,7 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="24" class="mb20">
|
<el-col :span="24" class="mb20">
|
||||||
<el-form-item label="处理原因" prop="remark">
|
<el-form-item label="处理原因" prop="remark">
|
||||||
<el-input v-model="form.remark" placeholder="请输入处理原因" type="textarea"
|
<el-input v-model="form.remark" placeholder="请输入处理原因" type="textarea" :autosize="{ minRows: 6 }" />
|
||||||
:autosize="{ minRows: 6 }" />
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@@ -133,9 +133,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts" name="hard-door-log">
|
<script setup lang="ts" name="hard-door-log">
|
||||||
|
import VueJsonPretty from "vue-json-pretty"
|
||||||
|
import "vue-json-pretty/lib/styles.css"
|
||||||
import * as hardFlowManager from "/@/api/hard/hard-door-flow"
|
import * as hardFlowManager from "/@/api/hard/hard-door-flow"
|
||||||
import { useMessage } from "/@/hooks/message"
|
import { useMessage } from "/@/hooks/message"
|
||||||
import { auth, auths, authAll } from '/@/utils/authFunction';
|
import { auth } from "/@/utils/authFunction"
|
||||||
|
import { displayPrice } from "/@/utils/formatMoney"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
orderId?: string
|
orderId?: string
|
||||||
@@ -150,6 +153,12 @@ const detail = ref<any>({})
|
|||||||
const loading = ref<boolean>(false)
|
const loading = ref<boolean>(false)
|
||||||
const formLoading = ref<boolean>(false)
|
const formLoading = ref<boolean>(false)
|
||||||
|
|
||||||
|
// 报文抽屉
|
||||||
|
const payloadVisible = ref(false)
|
||||||
|
const payloadTitle = ref("")
|
||||||
|
const payloadRaw = ref("")
|
||||||
|
const payloadData = ref<any>(null)
|
||||||
|
|
||||||
// 提交表单数据
|
// 提交表单数据
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
flowId: "",
|
flowId: "",
|
||||||
@@ -162,11 +171,181 @@ const dataRules = ref({
|
|||||||
remark: [{ required: true, message: "处理原因不能为空", trigger: "blur" }],
|
remark: [{ required: true, message: "处理原因不能为空", trigger: "blur" }],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 流程节点状态。 */
|
||||||
|
type FlowNodeStatus = "done" | "waiting" | "warning" | "error"
|
||||||
|
|
||||||
|
/** 一个流程节点;payloads 为可查看的请求/回调报文。 */
|
||||||
|
interface FlowNode {
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
time?: string
|
||||||
|
status: FlowNodeStatus
|
||||||
|
desc?: string
|
||||||
|
hint?: string
|
||||||
|
payloads?: { label: string; json?: string }[]
|
||||||
|
tagText?: string
|
||||||
|
tagType?: "success" | "info" | "warning" | "danger"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 流程状态编码 → 名称。 */
|
||||||
|
const FLOW_STATUS_MAP: Record<string, string> = {
|
||||||
|
"10": "用户授权",
|
||||||
|
"20": "申请开门",
|
||||||
|
"30": "开门响应",
|
||||||
|
"40": "视频回调",
|
||||||
|
"50": "已提交识别",
|
||||||
|
"60": "识别响应",
|
||||||
|
"70": "订单结算",
|
||||||
|
}
|
||||||
|
|
||||||
|
const flowStatusText = computed(() => FLOW_STATUS_MAP[String(detail.value?.flowStatus || "")] || "")
|
||||||
|
|
||||||
|
const flowStatusTagType = computed(() => {
|
||||||
|
if (detail.value?.payTime) return "success"
|
||||||
|
if (detail.value?.error) return "danger"
|
||||||
|
return "warning"
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 手动结算/取消快照原始报文:优先独立快照字段,兼容旧版本写入 skuCallbackVo 的手动标记。 */
|
||||||
|
const manualRaw = computed<string>(() => {
|
||||||
|
const dedicated = detail.value?.manualSettleVo
|
||||||
|
if (dedicated) return dedicated
|
||||||
|
const legacy = detail.value?.skuCallbackVo
|
||||||
|
if (!legacy) return ""
|
||||||
|
try {
|
||||||
|
const vo = typeof legacy === "string" ? JSON.parse(legacy) : legacy
|
||||||
|
return vo?.manual === true ? (typeof legacy === "string" ? legacy : JSON.stringify(legacy)) : ""
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 手动结算/取消快照对象。 */
|
||||||
|
const manualVo = computed<any>(() => {
|
||||||
|
if (!manualRaw.value) return null
|
||||||
|
try {
|
||||||
|
const vo = typeof manualRaw.value === "string" ? JSON.parse(manualRaw.value) : manualRaw.value
|
||||||
|
return vo?.manual === true ? vo : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 快照中的购物车明细。 */
|
||||||
|
const manualItems = computed<any[]>(() => (Array.isArray(manualVo.value?.items) ? manualVo.value.items : []))
|
||||||
|
|
||||||
|
const STATUS_TAGS: Record<FlowNodeStatus, { text: string; type: "success" | "info" | "warning" | "danger" }> = {
|
||||||
|
done: { text: "成功", type: "success" },
|
||||||
|
waiting: { text: "等待中", type: "info" },
|
||||||
|
warning: { text: "未响应", type: "warning" },
|
||||||
|
error: { text: "错误", type: "danger" },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按数据到达情况推导各节点状态与可查看报文。 */
|
||||||
|
const flowNodes = computed<FlowNode[]>(() => {
|
||||||
|
const d = detail.value || {}
|
||||||
|
const withTag = (node: FlowNode): FlowNode => {
|
||||||
|
if (node.time) {
|
||||||
|
node.tagText = STATUS_TAGS[node.status].text
|
||||||
|
node.tagType = STATUS_TAGS[node.status].type
|
||||||
|
}
|
||||||
|
// 只保留有报文数据的入口
|
||||||
|
node.payloads = node.payloads?.filter((payload) => !!payload.json)
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
const nodes: FlowNode[] = [
|
||||||
|
withTag({
|
||||||
|
key: "auth",
|
||||||
|
title: "用户授权",
|
||||||
|
time: d.createTime,
|
||||||
|
status: "done",
|
||||||
|
desc: d.username ? `授权用户:${d.username}` : "",
|
||||||
|
}),
|
||||||
|
withTag({
|
||||||
|
key: "apply",
|
||||||
|
title: "智能柜申请开门",
|
||||||
|
time: d.requestOpenTime,
|
||||||
|
status: d.requestOpenTime ? "done" : "waiting",
|
||||||
|
payloads: [
|
||||||
|
{ label: "请求参数", json: d.requestOpenDto },
|
||||||
|
{ label: "请求返回", json: d.requestOpenVo },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
withTag({
|
||||||
|
key: "open",
|
||||||
|
title: "智能柜开门响应",
|
||||||
|
time: d.openCallbackTime,
|
||||||
|
status: d.openCallbackTime ? "done" : "waiting",
|
||||||
|
desc: d.isOpen != null && d.isOpen !== "" ? `开门结果:${String(d.isOpen) === "1" ? "已开门" : "未开门"}` : "",
|
||||||
|
payloads: [{ label: "回调报文", json: d.openCallbackVo }],
|
||||||
|
}),
|
||||||
|
withTag({
|
||||||
|
key: "video",
|
||||||
|
title: "智能柜视频回调",
|
||||||
|
time: d.videoCallbackTime,
|
||||||
|
status: d.videoCallbackTime ? "done" : "waiting",
|
||||||
|
payloads: [{ label: "回调报文", json: d.videoCallbackVo }],
|
||||||
|
}),
|
||||||
|
withTag({
|
||||||
|
key: "submit",
|
||||||
|
title: "提交商品识别",
|
||||||
|
time: d.skuSubmitTime,
|
||||||
|
status: d.skuSubmitTime ? "done" : "warning",
|
||||||
|
payloads: [
|
||||||
|
{ label: "提交参数", json: d.skuSubmitDto },
|
||||||
|
{ label: "提交返回", json: d.skuSubmitVo },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
withTag({
|
||||||
|
key: "recog",
|
||||||
|
title: "商品识别响应",
|
||||||
|
time: d.skuCallbackTime,
|
||||||
|
status: d.skuCallbackTime ? "done" : d.skuSubmitTime ? "warning" : "waiting",
|
||||||
|
desc: !d.skuCallbackTime && manualVo.value ? `已通过${manualVo.value.manualCancel ? "手动取消" : "手动结算"}人工处理,见下方快照节点` : "",
|
||||||
|
hint:
|
||||||
|
!d.skuCallbackTime && d.skuSubmitTime && !manualVo.value
|
||||||
|
? "第三方识别服务未响应(服务可能不可用),可使用「手动结算」人工核对视频后结算"
|
||||||
|
: "",
|
||||||
|
payloads: [{ label: "识别结果", json: d.skuCallbackVo }],
|
||||||
|
}),
|
||||||
|
withTag({
|
||||||
|
key: "pay",
|
||||||
|
title: "订单结算",
|
||||||
|
time: d.payTime,
|
||||||
|
status: d.payTime ? "done" : "waiting",
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
if (d.error && d.errorDesc) {
|
||||||
|
nodes.push({
|
||||||
|
key: "error",
|
||||||
|
title: "流程错误",
|
||||||
|
time: d.updateTime,
|
||||||
|
status: "error",
|
||||||
|
desc: d.errorDesc,
|
||||||
|
tagText: "错误",
|
||||||
|
tagType: "danger",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nodes
|
||||||
|
})
|
||||||
|
|
||||||
|
const openPayload = (title: string, json?: string) => {
|
||||||
|
payloadTitle.value = title
|
||||||
|
payloadRaw.value = json || ""
|
||||||
|
payloadData.value = null
|
||||||
|
if (json) {
|
||||||
|
try {
|
||||||
|
payloadData.value = typeof json === "string" ? JSON.parse(json) : json
|
||||||
|
} catch {
|
||||||
|
payloadData.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payloadVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.orderId,
|
() => props.orderId,
|
||||||
() => {
|
() => getFlow(),
|
||||||
getFlow()
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const getFlow = async () => {
|
const getFlow = async () => {
|
||||||
@@ -193,7 +372,7 @@ const dialogClose = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
const valid = await formRef.value.validate().catch(() => { })
|
const valid = await formRef.value.validate().catch(() => {})
|
||||||
if (!valid) return false
|
if (!valid) return false
|
||||||
try {
|
try {
|
||||||
formLoading.value = true
|
formLoading.value = true
|
||||||
@@ -213,77 +392,210 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.flow_coll {
|
.door-log {
|
||||||
display: flex;
|
&__head {
|
||||||
align-items: center;
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
span {
|
align-items: center;
|
||||||
display: inline-block;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
span:nth-child(1) {
|
&__title {
|
||||||
display: inline-block;
|
font-weight: 600;
|
||||||
width: 80px;
|
font-size: 14px;
|
||||||
text-align: left;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
span:nth-child(2) {
|
&__raw {
|
||||||
display: inline-block;
|
padding: 12px;
|
||||||
flex: 1;
|
margin: 0;
|
||||||
min-width: 50px;
|
overflow: auto;
|
||||||
white-space: nowrap;
|
font-size: 12px;
|
||||||
overflow: hidden;
|
line-height: 20px;
|
||||||
text-overflow: ellipsis;
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 时间线样式调整
|
.flow-node {
|
||||||
:deep(.el-timeline) {
|
display: flex;
|
||||||
margin: 10px 0 0 160px;
|
gap: 12px;
|
||||||
|
|
||||||
.el-timeline-item__wrapper {
|
&:last-of-type .flow-node__rail::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rail {
|
||||||
position: relative;
|
position: relative;
|
||||||
top: -20px;
|
|
||||||
|
|
||||||
.el-timeline-item__timestamp {
|
|
||||||
position: absolute !important;
|
|
||||||
top: 10px;
|
|
||||||
left: -150px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-timeline-right-content {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 0 0 14px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
min-height: 30px;
|
justify-content: flex-start;
|
||||||
padding: 10px;
|
padding-top: 16px;
|
||||||
background-color: #b0d8ff;
|
|
||||||
|
|
||||||
&::before {
|
&::after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 30px;
|
||||||
left: 13px;
|
bottom: -16px;
|
||||||
border-color: transparent #b0d8ff transparent transparent;
|
left: 50%;
|
||||||
border-style: solid;
|
width: 2px;
|
||||||
border-width: 8px;
|
|
||||||
content: "";
|
content: "";
|
||||||
|
background-color: var(--el-border-color-lighter);
|
||||||
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-timeline-item__node {
|
&__dot {
|
||||||
position: absolute;
|
z-index: 1;
|
||||||
left: -5px;
|
box-sizing: content-box;
|
||||||
top: -50%;
|
width: 8px;
|
||||||
transform: translateY(100%);
|
height: 8px;
|
||||||
display: flex;
|
background-color: var(--el-color-info);
|
||||||
width: 20px;
|
border: 3px solid #fff;
|
||||||
height: 20px;
|
|
||||||
font-size: 10px;
|
|
||||||
background: #b0d8ff;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
justify-content: center;
|
box-shadow: 0 0 0 2px currentColor;
|
||||||
|
color: var(--el-color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--el-box-shadow-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__desc {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--el-color-warning);
|
||||||
|
background-color: var(--el-color-warning-light-9);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动结算快照的购物车明细
|
||||||
|
&__cart {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cart-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cart-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cart-count {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cart-price {
|
||||||
|
min-width: 64px;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cart-total {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-top: 1px dashed var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cart-channel {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__payloads,
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未发生节点弱化
|
||||||
|
&.is-idle .flow-node__card {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-done .flow-node__dot {
|
||||||
|
color: var(--el-color-success);
|
||||||
|
background-color: var(--el-color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-warning .flow-node__dot {
|
||||||
|
color: var(--el-color-warning);
|
||||||
|
background-color: var(--el-color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-error {
|
||||||
|
.flow-node__dot {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
background-color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-node__card {
|
||||||
|
border-color: var(--el-color-danger-light-7);
|
||||||
|
background-color: var(--el-color-danger-light-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-manual .flow-node__dot {
|
||||||
|
color: var(--el-color-warning);
|
||||||
|
background-color: var(--el-color-warning);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,97 +1,92 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-loading="loading" class="manual-settlement">
|
<div v-loading="loading" class="manual-settlement">
|
||||||
<el-alert
|
<el-alert type="warning" :closable="false" show-icon
|
||||||
type="warning"
|
title="第三方商品识别服务不可用,请核对开门视频,人工选择用户拿走的商品后手动结算;若用户未拿商品请取消订单。" />
|
||||||
:closable="false"
|
|
||||||
show-icon
|
|
||||||
title="第三方商品识别服务不可用,请核对开门视频,人工选择用户拿走的商品后手动结算;若用户未拿商品请取消订单。"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="settle-layout">
|
<div class="settle-layout">
|
||||||
<!-- 左侧:按柜层选择商品 -->
|
<!-- 左侧:柜内商品选择 -->
|
||||||
<div class="settle-goods">
|
<div class="settle-goods">
|
||||||
<div class="settle-block__title">柜内商品({{ cabinetNoLabel }})</div>
|
<div class="settle-head">
|
||||||
<template v-if="floors.length">
|
<span class="settle-head__title">柜内商品</span>
|
||||||
<div v-for="floor in floors" :key="floor.floorNo" class="settle-floor">
|
<el-tag v-if="cabinetNoLabel" size="small" type="info" effect="plain">{{ cabinetNoLabel }}</el-tag>
|
||||||
<div class="settle-floor__title">第 {{ floor.floorNo }} 层</div>
|
</div>
|
||||||
<div class="settle-floor__list">
|
<div class="settle-goods__scroll">
|
||||||
<div v-for="product in floor.products" :key="product.productId" class="settle-product">
|
<template v-if="floors.length">
|
||||||
<preview-image width="64px" height="56px" :src="product.pic" />
|
<div v-for="floor in floors" :key="floor.floorNo" class="settle-floor">
|
||||||
|
<div class="settle-floor__tag">第 {{ floor.floorNo }} 层</div>
|
||||||
|
<div v-for="product in floor.products" :key="product.productId" class="settle-product"
|
||||||
|
:class="{ 'is-selected': Number(cartCount[product.productId] || 0) > 0 }">
|
||||||
|
<preview-image width="56px" height="56px" :src="product.pic" />
|
||||||
<div class="settle-product__info">
|
<div class="settle-product__info">
|
||||||
<el-tooltip effect="dark" :content="product.name">
|
<el-tooltip effect="dark" :content="product.name">
|
||||||
<div class="settle-product__name">{{ product.name }}</div>
|
<div class="settle-product__name">{{ product.name }}</div>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
<div class="settle-product__meta">
|
<div class="settle-product__meta">
|
||||||
<span class="color-primary">{{ displayPrice(product.price) }}</span>
|
<span class="settle-product__price">{{ displayPrice(product.price) }}</span>
|
||||||
<span class="settle-product__stock">库存 {{ product.stock }}</span>
|
<span class="settle-product__stock">库存 {{ product.stock }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-input-number
|
<el-input-number v-model="cartCount[product.productId]" class="settle-product__stepper"
|
||||||
v-model="cartCount[product.productId]"
|
:min="0" controls-position="right" />
|
||||||
:min="0"
|
|
||||||
:max="Math.max(product.stock, 1)"
|
|
||||||
size="small"
|
|
||||||
controls-position="right"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<el-empty v-else-if="!loading" description="未找到该柜的商品配置" :image-size="60" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 右侧:已选购物车 + 视频 + 操作 -->
|
|
||||||
<div class="settle-panel">
|
|
||||||
<div class="settle-cart">
|
|
||||||
<div class="settle-block__title">已选商品</div>
|
|
||||||
<template v-if="cartItems.length">
|
|
||||||
<div class="settle-cart__list">
|
|
||||||
<div v-for="item in cartItems" :key="item.productId" class="settle-cart__item">
|
|
||||||
<span class="settle-cart__name">{{ item.name }}</span>
|
|
||||||
<span class="settle-cart__count">× {{ item.count }}</span>
|
|
||||||
<span class="color-primary">{{ displayPrice(item.price * item.count) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="settle-cart__total">
|
|
||||||
合计:<span class="color-primary">{{ displayPrice(cartTotalPrice) }}</span>
|
|
||||||
<span class="settle-cart__tip">(金额仅供参考,以实际结算结果为准)</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<el-empty v-else description="请从左侧选择用户拿走的商品" :image-size="60" />
|
<el-empty v-else-if="!loading" description="未找到该柜的商品配置" :image-size="60" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settle-video">
|
<!-- 右侧:开门视频 + 已选购物车 + 操作 -->
|
||||||
<div class="settle-video__head">
|
<div class="settle-side">
|
||||||
<div class="settle-block__title">开门视频</div>
|
<div class="settle-card">
|
||||||
<el-radio-group v-if="videos.length > 1" v-model="currentVideoIndex" size="small">
|
<div class="settle-head">
|
||||||
<el-radio-button v-for="(video, index) in videos" :key="index" :label="index">
|
<span class="settle-head__title">开门视频</span>
|
||||||
{{ video.label }}
|
<span class="settle-head__extra">共 {{ videos.length }} 路,同步播放</span>
|
||||||
</el-radio-button>
|
</div>
|
||||||
</el-radio-group>
|
<div v-if="videos.length" class="settle-video__grid" :class="{ 'is-single': videos.length === 1 }">
|
||||||
|
<div v-for="(video, index) in videos" :key="index" class="settle-video__item">
|
||||||
|
<el-tag size="small" effect="plain" :type="index === 0 ? 'primary' : 'info'"
|
||||||
|
class="settle-video__label">
|
||||||
|
{{ video.label }}
|
||||||
|
</el-tag>
|
||||||
|
<video-player :src="video.url" width="100%" height="320px" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<video-player v-if="currentVideoUrl" :src="currentVideoUrl" width="100%" height="240px" />
|
|
||||||
<el-empty v-else description="无开门视频(视频未回调或回调失败)" :image-size="60" />
|
<el-empty v-else description="无开门视频(视频未回调或回调失败)" :image-size="60" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settle-actions">
|
<div class="settle-card">
|
||||||
<el-button
|
<div class="settle-head">
|
||||||
v-auth="'hard_hardwareOrder_manualSettle'"
|
<span class="settle-head__title">已选商品</span>
|
||||||
type="primary"
|
<span v-if="cartItems.length" class="settle-head__extra">共 {{ cartCountTotal }} 件</span>
|
||||||
:disabled="!cartItems.length"
|
</div>
|
||||||
:loading="submitting"
|
<div v-if="cartItems.length" class="settle-cart">
|
||||||
@click="onSettle"
|
<div class="settle-cart__head">
|
||||||
>
|
<span>商品</span>
|
||||||
手动结算
|
<span>数量</span>
|
||||||
</el-button>
|
<span>小计</span>
|
||||||
<el-button
|
</div>
|
||||||
v-auth="'hard_hardwareOrder_manualSettle'"
|
<div v-for="item in cartItems" :key="item.productId" class="settle-cart__row">
|
||||||
type="danger"
|
<span class="settle-cart__name">{{ item.name }}</span>
|
||||||
plain
|
<span class="settle-cart__count">× {{ item.count }}</span>
|
||||||
:loading="cancelling"
|
<span class="settle-cart__price">{{ displayPrice(item.price * item.count) }}</span>
|
||||||
@click="onCancel"
|
</div>
|
||||||
>
|
<div class="settle-cart__total">
|
||||||
用户未拿商品,取消订单
|
<span>合计</span>
|
||||||
</el-button>
|
<span class="settle-cart__total-price">{{ displayPrice(cartTotalPrice) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settle-cart__tip">金额仅供参考,以实际结算结果为准</div>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else description="请从左侧选择用户拿走的商品" :image-size="60" />
|
||||||
|
<div class="settle-actions">
|
||||||
|
<el-button v-auth="'hard_hardwareOrder_manualSettle'" type="danger" plain :loading="cancelling"
|
||||||
|
@click="onCancel">
|
||||||
|
未拿商品,取消订单
|
||||||
|
</el-button>
|
||||||
|
<el-button v-auth="'hard_hardwareOrder_manualSettle'" type="primary"
|
||||||
|
:disabled="!cartItems.length" :loading="submitting" @click="onSettle">
|
||||||
|
手动结算
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,9 +134,6 @@ const cabinetNoLabel = ref("")
|
|||||||
/** 商品 id -> 手动选择的数量。 */
|
/** 商品 id -> 手动选择的数量。 */
|
||||||
const cartCount = reactive<Record<string, number>>({})
|
const cartCount = reactive<Record<string, number>>({})
|
||||||
const videos = ref<{ label: string; url: string }[]>([])
|
const videos = ref<{ label: string; url: string }[]>([])
|
||||||
const currentVideoIndex = ref(0)
|
|
||||||
|
|
||||||
const currentVideoUrl = computed(() => videos.value[currentVideoIndex.value]?.url || "")
|
|
||||||
|
|
||||||
const cartItems = computed(() => {
|
const cartItems = computed(() => {
|
||||||
const items: { productId: string; name: string; price: number; count: number }[] = []
|
const items: { productId: string; name: string; price: number; count: number }[] = []
|
||||||
@@ -154,20 +146,15 @@ const cartItems = computed(() => {
|
|||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
const cartTotalPrice = computed(() => cartItems.value.reduce((sum, item) => sum + item.price * item.count, 0))
|
const cartCountTotal = computed(() => cartItems.value.reduce((sum, item) => sum + item.count, 0))
|
||||||
|
|
||||||
watch(
|
const cartTotalPrice = computed(() => cartItems.value.reduce((sum, item) => sum + item.price * item.count, 0))
|
||||||
() => [props.orderId, props.hardId],
|
|
||||||
() => load(),
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!props.orderId || !props.hardId) return
|
if (!props.orderId || !props.hardId) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
floors.value = []
|
floors.value = []
|
||||||
videos.value = []
|
videos.value = []
|
||||||
currentVideoIndex.value = 0
|
|
||||||
cabinetNoLabel.value = ""
|
cabinetNoLabel.value = ""
|
||||||
Object.keys(cartCount).forEach((key) => delete cartCount[key])
|
Object.keys(cartCount).forEach((key) => delete cartCount[key])
|
||||||
try {
|
try {
|
||||||
@@ -185,6 +172,12 @@ const load = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.orderId, props.hardId],
|
||||||
|
() => load(),
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
/** 把 /hard-spu/get-by-cabinet-no 的层结构转成层数组,同层同商品多槽位合并、库存求和。 */
|
/** 把 /hard-spu/get-by-cabinet-no 的层结构转成层数组,同层同商品多槽位合并、库存求和。 */
|
||||||
const parseFloors = (cabinetData: any): Floor[] => {
|
const parseFloors = (cabinetData: any): Floor[] => {
|
||||||
if (!Array.isArray(cabinetData)) return []
|
if (!Array.isArray(cabinetData)) return []
|
||||||
@@ -244,12 +237,17 @@ const onSettle = async () => {
|
|||||||
h("div", null, `合计金额:${displayPrice(cartTotalPrice.value)}(实际金额以结算结果为准)`),
|
h("div", null, `合计金额:${displayPrice(cartTotalPrice.value)}(实际金额以结算结果为准)`),
|
||||||
h("div", { style: "color:var(--el-color-danger)" }, "确认后将从用户钱包强制扣款或通过支付宝免密扣款,请谨慎操作。"),
|
h("div", { style: "color:var(--el-color-danger)" }, "确认后将从用户钱包强制扣款或通过支付宝免密扣款,请谨慎操作。"),
|
||||||
])
|
])
|
||||||
|
let reason = ""
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(summary, "手动结算确认", {
|
const { value } = await ElMessageBox.prompt(summary, "手动结算确认", {
|
||||||
confirmButtonText: "确认结算",
|
confirmButtonText: "确认结算",
|
||||||
cancelButtonText: "取消",
|
cancelButtonText: "取消",
|
||||||
type: "warning",
|
type: "warning",
|
||||||
|
inputPlaceholder: "操作备注(选填):记录判断依据,如 视频02:35拿走一瓶,便于事后复核",
|
||||||
|
inputPattern: /^[\s\S]{0,200}$/,
|
||||||
|
inputErrorMessage: "备注最多 200 字",
|
||||||
})
|
})
|
||||||
|
reason = (value || "").trim()
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -258,6 +256,7 @@ const onSettle = async () => {
|
|||||||
await manualSettle({
|
await manualSettle({
|
||||||
orderId: props.orderId,
|
orderId: props.orderId,
|
||||||
items: items.map((item) => ({ productId: item.productId, productCount: item.count })),
|
items: items.map((item) => ({ productId: item.productId, productCount: item.count })),
|
||||||
|
reason,
|
||||||
})
|
})
|
||||||
useMessage().success("手动结算成功")
|
useMessage().success("手动结算成功")
|
||||||
emit("refresh")
|
emit("refresh")
|
||||||
@@ -270,18 +269,23 @@ const onSettle = async () => {
|
|||||||
|
|
||||||
const onCancel = async () => {
|
const onCancel = async () => {
|
||||||
if (cancelling.value) return
|
if (cancelling.value) return
|
||||||
|
let reason = ""
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm("确认该订单用户未拿任何商品?取消后订单状态置为已取消,不可恢复。", "取消订单确认", {
|
const { value } = await ElMessageBox.prompt("确认该订单用户未拿任何商品?取消后订单状态置为已取消,不可恢复。", "取消订单确认", {
|
||||||
confirmButtonText: "确认取消订单",
|
confirmButtonText: "确认取消订单",
|
||||||
cancelButtonText: "再想想",
|
cancelButtonText: "再想想",
|
||||||
type: "warning",
|
type: "warning",
|
||||||
|
inputPlaceholder: "操作备注(选填):记录判断依据,便于事后复核",
|
||||||
|
inputPattern: /^[\s\S]{0,200}$/,
|
||||||
|
inputErrorMessage: "备注最多 200 字",
|
||||||
})
|
})
|
||||||
|
reason = (value || "").trim()
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cancelling.value = true
|
cancelling.value = true
|
||||||
try {
|
try {
|
||||||
await manualCancel({ orderId: props.orderId })
|
await manualCancel({ orderId: props.orderId, reason })
|
||||||
useMessage().success("订单已取消")
|
useMessage().success("订单已取消")
|
||||||
emit("refresh")
|
emit("refresh")
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -296,132 +300,219 @@ const onCancel = async () => {
|
|||||||
.manual-settlement {
|
.manual-settlement {
|
||||||
.settle-layout {
|
.settle-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 20px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settle-block__title {
|
.settle-head {
|
||||||
font-weight: 600;
|
display: flex;
|
||||||
line-height: 24px;
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__extra {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 左侧商品列:适度固定宽,超出内部滚动,避免把视频挤小
|
||||||
.settle-goods {
|
.settle-goods {
|
||||||
flex: 1;
|
display: flex;
|
||||||
|
flex: 0 0 420px;
|
||||||
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 12px;
|
padding: 16px;
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|
||||||
|
&__scroll {
|
||||||
|
flex: 1;
|
||||||
|
max-height: 560px;
|
||||||
|
padding-right: 4px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.settle-floor {
|
.settle-floor {
|
||||||
&:not(:first-child) {
|
&:not(:first-child) {
|
||||||
margin-top: 10px;
|
margin-top: 12px;
|
||||||
padding-top: 10px;
|
|
||||||
border-top: 1px dashed var(--el-border-color-lighter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__tag {
|
||||||
|
padding-left: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
}
|
border-left: 3px solid var(--el-color-primary);
|
||||||
|
line-height: 18px;
|
||||||
&__list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.settle-product {
|
.settle-product {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 6px 8px;
|
padding: 10px;
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|
||||||
|
&+.settle-product {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-selected {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
background-color: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
&__info {
|
&__info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__name {
|
&__name {
|
||||||
max-width: 220px;
|
max-width: 180px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
font-size: 14px;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__meta {
|
&__meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__price {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
&__stock {
|
&__stock {
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 数量步进器固定紧凑宽度,不随弹性布局拉伸
|
||||||
|
&__stepper {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 112px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.settle-panel {
|
// 右侧视频 + 购物车占满剩余宽度
|
||||||
|
.settle-side {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 360px;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settle-card {
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多路视频同屏并排播放;单路时占满整行
|
||||||
|
.settle-video__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settle-cart {
|
.settle-video__item {
|
||||||
padding: 12px;
|
position: relative;
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
min-width: 0;
|
||||||
border-radius: 6px;
|
|
||||||
|
|
||||||
&__item {
|
.settle-video__label {
|
||||||
display: flex;
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settle-cart {
|
||||||
|
|
||||||
|
&__head,
|
||||||
|
&__row,
|
||||||
|
&__total {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 72px 96px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 4px 0;
|
}
|
||||||
line-height: 20px;
|
|
||||||
|
&__head {
|
||||||
|
padding-bottom: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
|
||||||
|
span:nth-child(2),
|
||||||
|
span:nth-child(3) {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
border-bottom: 1px dashed var(--el-border-color-lighter);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__name {
|
&__name {
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__count {
|
&__count,
|
||||||
|
&__price {
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__price {
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__total {
|
&__total {
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
margin-top: 4px;
|
font-weight: 600;
|
||||||
border-top: 1px solid var(--el-border-color-lighter);
|
|
||||||
|
span:first-child {
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__total-price {
|
||||||
|
grid-column: 3;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__tip {
|
&__tip {
|
||||||
|
margin-top: 2px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
}
|
text-align: right;
|
||||||
}
|
|
||||||
|
|
||||||
.settle-video {
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
|
||||||
border-radius: 6px;
|
|
||||||
|
|
||||||
&__head {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +520,7 @@ const onCancel = async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
<div class="mb-2 flex flex-wrap items-center gap-2">
|
<div class="mb-2 flex flex-wrap items-center gap-2">
|
||||||
<span class="text-sm font-medium text-[var(--el-text-color-secondary)]">订单售后详情</span>
|
<span class="text-sm font-medium text-[var(--el-text-color-secondary)]">订单售后详情</span>
|
||||||
<dict-tag :options="hard_order_type" :value="globalData.detail.orderType" />
|
<dict-tag :options="hard_order_type" :value="globalData.detail.orderType" />
|
||||||
<dict-tag :options="hard_order_status" :value="globalData.detail.orderStatus" />
|
<el-tag v-if="isOfflinePaidCompleted" type="success" :disable-transitions="true">已完成</el-tag>
|
||||||
|
<dict-tag v-else :options="hard_order_status" :value="globalData.detail.orderStatus" />
|
||||||
<dict-tag :options="hard_pay_type" :value="globalData.detail.payType" />
|
<dict-tag :options="hard_pay_type" :value="globalData.detail.payType" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
@@ -733,10 +734,10 @@
|
|||||||
class="flex min-h-[180px] gap-4 rounded-[6px] border border-[var(--el-border-color-light)] bg-[var(--el-fill-color-blank)] p-3">
|
class="flex min-h-[180px] gap-4 rounded-[6px] border border-[var(--el-border-color-light)] bg-[var(--el-fill-color-blank)] p-3">
|
||||||
<div
|
<div
|
||||||
class="flex h-[156px] w-40 flex-none items-center justify-center overflow-hidden rounded bg-[var(--el-fill-color-light)]">
|
class="flex h-[156px] w-40 flex-none items-center justify-center overflow-hidden rounded bg-[var(--el-fill-color-light)]">
|
||||||
<el-image
|
<file-image
|
||||||
v-if="evidence.status === 'AVAILABLE' && evidence.imageUrl && !failedEvidenceImages.has(evidence.id)"
|
v-if="evidence.status === 'AVAILABLE' && evidence.imageUrl && !failedEvidenceImages.has(evidence.id)"
|
||||||
class="h-full w-full" :src="evidenceImageUrl(evidence.imageUrl)"
|
class="h-full w-full" :src="$fileUrl(evidenceImageUrl(evidence.imageUrl))"
|
||||||
:preview-src-list="[evidenceImageUrl(evidence.imageUrl)]" fit="contain"
|
:preview-src-list="$fileUrls([evidenceImageUrl(evidence.imageUrl)])" fit="contain"
|
||||||
preview-teleported loading="lazy"
|
preview-teleported loading="lazy"
|
||||||
@error="markEvidenceImageFailed(evidence.id)" />
|
@error="markEvidenceImageFailed(evidence.id)" />
|
||||||
<span v-else-if="evidence.status === 'PENDING'"
|
<span v-else-if="evidence.status === 'PENDING'"
|
||||||
@@ -779,7 +780,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="isSmartCabinetOrder" id="section-cabinet" class="detail-section detail-anchor">
|
<section v-if="isSmartCabinetOrder" id="section-cabinet" class="detail-section detail-anchor">
|
||||||
<hard-door-log v-auth="'dzc_hardDoorFlow_view'" :order-id="String(globalData.detail.id)" />
|
<hard-door-log v-auth="'dzc_hardDoorFlow_view'" :key="cabinetLogKey" :order-id="String(globalData.detail.id)" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="showManualSettlementSection" id="section-manual-settlement" class="detail-section detail-anchor">
|
<section v-if="showManualSettlementSection" id="section-manual-settlement" class="detail-section detail-anchor">
|
||||||
@@ -839,6 +840,8 @@ const HARD_DOOR_FREE_PAY_TYPE = "9"
|
|||||||
const DOOR_ORDER_PENDING_SETTLE_STATUS = "1"
|
const DOOR_ORDER_PENDING_SETTLE_STATUS = "1"
|
||||||
/** 消费机支付订单类型编码,后端允许该类型没有购物车明细。 */
|
/** 消费机支付订单类型编码,后端允许该类型没有购物车明细。 */
|
||||||
const SIMPLE_PAYMENT_ORDER_TYPE = "9"
|
const SIMPLE_PAYMENT_ORDER_TYPE = "9"
|
||||||
|
/** 线下购买订单类型编码,终端支付完成即履约完成,没有后续核销或配送流程。 */
|
||||||
|
const OFFLINE_ORDER_TYPE = "1"
|
||||||
/** 商城订单类型编码,使用发货、收货履约状态。 */
|
/** 商城订单类型编码,使用发货、收货履约状态。 */
|
||||||
const MARKETPLACE_ORDER_TYPE = "7"
|
const MARKETPLACE_ORDER_TYPE = "7"
|
||||||
/** 外卖订单类型编码,使用接单、配送履约状态。 */
|
/** 外卖订单类型编码,使用接单、配送履约状态。 */
|
||||||
@@ -1230,6 +1233,10 @@ const { getDataList, tableStyle } = useTable(state)
|
|||||||
const orderTypeCode = computed(() => String(globalData.detail.orderType || ""))
|
const orderTypeCode = computed(() => String(globalData.detail.orderType || ""))
|
||||||
/** 当前订单状态协议编码。 */
|
/** 当前订单状态协议编码。 */
|
||||||
const orderStatusCode = computed(() => String(globalData.detail.orderStatus || ""))
|
const orderStatusCode = computed(() => String(globalData.detail.orderStatus || ""))
|
||||||
|
/** 线下订单支付即完成:该类型没有核销流程,状态停在已支付(5/6)即代表订单已完成。 */
|
||||||
|
const isOfflinePaidCompleted = computed(
|
||||||
|
() => orderTypeCode.value === OFFLINE_ORDER_TYPE && ["5", "6"].includes(orderStatusCode.value),
|
||||||
|
)
|
||||||
/** 是否为商城订单。 */
|
/** 是否为商城订单。 */
|
||||||
const isMarketplaceOrder = computed(() => orderTypeCode.value === MARKETPLACE_ORDER_TYPE)
|
const isMarketplaceOrder = computed(() => orderTypeCode.value === MARKETPLACE_ORDER_TYPE)
|
||||||
/** 是否为外卖订单。 */
|
/** 是否为外卖订单。 */
|
||||||
@@ -1251,6 +1258,13 @@ const showManualSettlementSection = computed(
|
|||||||
isSmartCabinetOrder.value &&
|
isSmartCabinetOrder.value &&
|
||||||
orderStatusCode.value === DOOR_ORDER_PENDING_SETTLE_STATUS,
|
orderStatusCode.value === DOOR_ORDER_PENDING_SETTLE_STATUS,
|
||||||
)
|
)
|
||||||
|
/** 智能柜日志组件重建键:订单结算/取消(payTime/unfollowTime/updateTime 变化)后强制重新拉取流程,展示手动结算快照。 */
|
||||||
|
const cabinetLogKey = computed(
|
||||||
|
() =>
|
||||||
|
`${globalData.detail.id}-${globalData.detail.payTime || ""}-${globalData.detail.unfollowTime || ""}-${
|
||||||
|
globalData.detail.updateTime || ""
|
||||||
|
}`,
|
||||||
|
)
|
||||||
|
|
||||||
/** 当前订单在管理端的业务流程摘要。 */
|
/** 当前订单在管理端的业务流程摘要。 */
|
||||||
const orderScenarioLabel = computed(() => getOrderScenarioLabel(orderTypeCode.value))
|
const orderScenarioLabel = computed(() => getOrderScenarioLabel(orderTypeCode.value))
|
||||||
@@ -1742,13 +1756,15 @@ const lifecycleStages = computed<LifecycleStage[]>(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type !== SIMPLE_PAYMENT_ORDER_TYPE) {
|
if (type !== SIMPLE_PAYMENT_ORDER_TYPE) {
|
||||||
|
// 线下订单支付即完成,完成时间回退到支付时间。
|
||||||
|
const offlineCompleted = isOfflinePaidCompleted.value
|
||||||
stages.push(
|
stages.push(
|
||||||
createLifecycleStage(
|
createLifecycleStage(
|
||||||
"completed",
|
"completed",
|
||||||
"订单完成",
|
"订单完成",
|
||||||
detail.verificationTime || detail.deliveredTime,
|
detail.verificationTime || detail.deliveredTime || (offlineCompleted ? detail.payTime : undefined),
|
||||||
Boolean(detail.verificationTime || detail.deliveredTime) || status === "20",
|
Boolean(detail.verificationTime || detail.deliveredTime) || status === "20" || offlineCompleted,
|
||||||
["5", "6"].includes(status),
|
!offlineCompleted && ["5", "6"].includes(status),
|
||||||
"等待履约完成",
|
"等待履约完成",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,9 +16,13 @@
|
|||||||
<select-wallet-type multiple v-model="state.queryForm.walletTypeIdList"
|
<select-wallet-type multiple v-model="state.queryForm.walletTypeIdList"
|
||||||
placeholder="请选择钱包类型" />
|
placeholder="请选择钱包类型" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item style="width: 400px" label="时间" prop="createTimeList">
|
<el-form-item style="width: 400px" label="交易时间" prop="createTimeList">
|
||||||
|
<template #label>
|
||||||
|
<statistics-table-header label="交易时间" :tip="transactionTimeTip" />
|
||||||
|
</template>
|
||||||
<el-date-picker v-model="state.queryForm.createTimeList"
|
<el-date-picker v-model="state.queryForm.createTimeList"
|
||||||
value-format="YYYY-MM-DD HH:mm:ss" type="datetimerange" />
|
value-format="YYYY-MM-DD HH:mm:ss" type="datetimerange"
|
||||||
|
start-placeholder="开始时间(含)" end-placeholder="结束时间(不含)" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button icon="Search" type="primary" @click="getDataList">{{ $t("common.queryBtn")
|
<el-button icon="Search" type="primary" @click="getDataList">{{ $t("common.queryBtn")
|
||||||
@@ -42,47 +46,71 @@
|
|||||||
<el-table-column type="index" label="#" width="80" />
|
<el-table-column type="index" label="#" width="80" />
|
||||||
<el-table-column prop="deptName" min-width="150" label="部门" />
|
<el-table-column prop="deptName" min-width="150" label="部门" />
|
||||||
<el-table-column prop="walletPrice" label="批量充值金额" align="center" min-width="110">
|
<el-table-column prop="walletPrice" label="批量充值金额" align="center" min-width="110">
|
||||||
|
<template #header>
|
||||||
|
<statistics-table-header label="批量充值金额" :tip="batchRechargeAmountHeaderTip" />
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div :class="{ 'color-success': row.walletPrice != 0 }">{{ displayPrice(row.walletPrice)
|
<div :class="{ 'color-success': row.walletPrice != 0 }">{{ displayPrice(row.walletPrice)
|
||||||
}}</div>
|
}}</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="walletCount" label="批量充值次数" align="center" min-width="110">
|
<el-table-column prop="walletCount" label="批量充值次数" align="center" min-width="110">
|
||||||
|
<template #header>
|
||||||
|
<statistics-table-header label="批量充值次数" :tip="batchRechargeCountHeaderTip" />
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div :class="{ 'color-success': row.walletCount != 0 }">{{ row.walletCount }}次</div>
|
<div :class="{ 'color-success': row.walletCount != 0 }">{{ row.walletCount }}次</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="walletOncePrice" label="单次充值金额" align="center" min-width="110">
|
<el-table-column prop="walletOncePrice" label="单次充值金额" align="center" min-width="110">
|
||||||
|
<template #header>
|
||||||
|
<statistics-table-header label="单次充值金额" :tip="singleRechargeAmountHeaderTip" />
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div :class="{ 'color-success': row.walletOncePrice != 0 }">{{
|
<div :class="{ 'color-success': row.walletOncePrice != 0 }">{{
|
||||||
displayPrice(row.walletOncePrice) }}</div>
|
displayPrice(row.walletOncePrice) }}</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="walletOnceCount" label="单次充值次数" align="center" min-width="110">
|
<el-table-column prop="walletOnceCount" label="单次充值次数" align="center" min-width="110">
|
||||||
|
<template #header>
|
||||||
|
<statistics-table-header label="单次充值次数" :tip="singleRechargeCountHeaderTip" />
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div :class="{ 'color-success': row.walletOnceCount != 0 }">{{ row.walletOnceCount }}次
|
<div :class="{ 'color-success': row.walletOnceCount != 0 }">{{ row.walletOnceCount }}次
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="walletReducePrice" label="批量扣减金额" align="center" min-width="110">
|
<el-table-column prop="walletReducePrice" label="批量扣减金额" align="center" min-width="110">
|
||||||
|
<template #header>
|
||||||
|
<statistics-table-header label="批量扣减金额" :tip="batchDeductionAmountHeaderTip" />
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div :class="{ 'color-success': row.walletReducePrice != 0 }">{{
|
<div :class="{ 'color-success': row.walletReducePrice != 0 }">{{
|
||||||
displayPrice(row.walletReducePrice) }}</div>
|
displayPrice(row.walletReducePrice) }}</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="walletReduceCount" label="批量扣减次数" align="center" min-width="110">
|
<el-table-column prop="walletReduceCount" label="批量扣减次数" align="center" min-width="110">
|
||||||
|
<template #header>
|
||||||
|
<statistics-table-header label="批量扣减次数" :tip="batchDeductionCountHeaderTip" />
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div :class="{ 'color-success': row.walletReduceCount != 0 }">{{ row.walletReduceCount
|
<div :class="{ 'color-success': row.walletReduceCount != 0 }">{{ row.walletReduceCount
|
||||||
}}次</div>
|
}}次</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="walletOnceReducePrice" label="单次扣减金额" align="center" min-width="110">
|
<el-table-column prop="walletOnceReducePrice" label="单次扣减金额" align="center" min-width="110">
|
||||||
|
<template #header>
|
||||||
|
<statistics-table-header label="单次扣减金额" :tip="singleDeductionAmountHeaderTip" />
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div :class="{ 'color-success': row.walletOnceReducePrice != 0 }">{{
|
<div :class="{ 'color-success': row.walletOnceReducePrice != 0 }">{{
|
||||||
displayPrice(row.walletOnceReducePrice) }}</div>
|
displayPrice(row.walletOnceReducePrice) }}</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="walletOnceReduceCount" label="单次扣减次数" align="center" min-width="110">
|
<el-table-column prop="walletOnceReduceCount" label="单次扣减次数" align="center" min-width="110">
|
||||||
|
<template #header>
|
||||||
|
<statistics-table-header label="单次扣减次数" :tip="singleDeductionCountHeaderTip" />
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div :class="{ 'color-success': row.walletOnceReduceCount != 0 }">{{
|
<div :class="{ 'color-success': row.walletOnceReduceCount != 0 }">{{
|
||||||
row.walletOnceReduceCount }}次</div>
|
row.walletOnceReduceCount }}次</div>
|
||||||
@@ -91,6 +119,9 @@
|
|||||||
<!-- start 批量退换食堂 start -->
|
<!-- start 批量退换食堂 start -->
|
||||||
<el-table-column prop="walletReturnCanteenPrice" label="批量退还各自食堂金额" align="center"
|
<el-table-column prop="walletReturnCanteenPrice" label="批量退还各自食堂金额" align="center"
|
||||||
min-width="170">
|
min-width="170">
|
||||||
|
<template #header>
|
||||||
|
<statistics-table-header label="批量退还各自食堂金额" :tip="returnCanteenAmountHeaderTip" />
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div :class="{ 'color-success': row.walletReturnCanteenPrice != 0 }">{{
|
<div :class="{ 'color-success': row.walletReturnCanteenPrice != 0 }">{{
|
||||||
displayPrice(row.walletReturnCanteenPrice) }}</div>
|
displayPrice(row.walletReturnCanteenPrice) }}</div>
|
||||||
@@ -98,6 +129,9 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="walletReturnCanteenCount" label="批量退还各自食堂次数" align="center"
|
<el-table-column prop="walletReturnCanteenCount" label="批量退还各自食堂次数" align="center"
|
||||||
min-width="170">
|
min-width="170">
|
||||||
|
<template #header>
|
||||||
|
<statistics-table-header label="批量退还各自食堂次数" :tip="returnCanteenCountHeaderTip" />
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div :class="{ 'color-success': row.walletReturnCanteenCount != 0 }">{{
|
<div :class="{ 'color-success': row.walletReturnCanteenCount != 0 }">{{
|
||||||
row.walletReturnCanteenCount }}次</div>
|
row.walletReturnCanteenCount }}次</div>
|
||||||
@@ -105,39 +139,57 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<!-- end 批量退换食堂 end -->
|
<!-- end 批量退换食堂 end -->
|
||||||
|
|
||||||
<el-table-column prop="orderPayPrice" label="流水金额" align="center" min-width="90">
|
<el-table-column prop="orderPayPrice" label="流水金额" align="center" min-width="110">
|
||||||
|
<template #header>
|
||||||
|
<statistics-table-header label="流水金额" :tip="payAmountHeaderTip" />
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div :class="{ 'color-primary': row.orderPayPrice != 0 }">{{
|
<div :class="{ 'color-primary': row.orderPayPrice != 0 }">{{
|
||||||
displayPrice(row.orderPayPrice) }}</div>
|
displayPrice(row.orderPayPrice) }}</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="orderPayCount" label="流水次数" align="center" min-width="90">
|
<el-table-column prop="orderPayCount" label="流水次数" align="center" min-width="110">
|
||||||
|
<template #header>
|
||||||
|
<statistics-table-header label="流水次数" :tip="payCountHeaderTip" />
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div :class="{ 'color-primary': row.orderPayCount != 0 }">{{ row.orderPayCount }}次</div>
|
<div :class="{ 'color-primary': row.orderPayCount != 0 }">{{ row.orderPayCount }}次</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="orderRefundPrice" label="退款金额" align="center" min-width="90">
|
<el-table-column prop="orderRefundPrice" label="退款金额" align="center" min-width="110">
|
||||||
|
<template #header>
|
||||||
|
<statistics-table-header label="退款金额" :tip="refundAmountHeaderTip" />
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div :class="{ 'color-danger': row.orderRefundPrice != 0 }">
|
<div :class="{ 'color-danger': row.orderRefundPrice != 0 }">
|
||||||
{{ displayPrice(row.orderRefundPrice) }}
|
{{ displayPrice(row.orderRefundPrice) }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="orderRefundCont" label="退款次数" align="center" min-width="90">
|
<el-table-column prop="orderRefundCont" label="退款次数" align="center" min-width="110">
|
||||||
|
<template #header>
|
||||||
|
<statistics-table-header label="退款次数" :tip="refundCountHeaderTip" />
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div :class="{ 'color-danger': row.orderRefundCont != 0 }">
|
<div :class="{ 'color-danger': row.orderRefundCont != 0 }">
|
||||||
{{ row.orderRefundCont }}次
|
{{ row.orderRefundCont }}次
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="orderProfitPrice" label="消费净金额" align="center" min-width="120">
|
<el-table-column prop="orderProfitPrice" label="消费净金额" align="center" min-width="140">
|
||||||
|
<template #header>
|
||||||
|
<statistics-table-header label="消费净金额" :tip="netAmountHeaderTip" />
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div :class="{ 'color-success': row.orderProfitPrice != 0 }">
|
<div :class="{ 'color-success': row.orderProfitPrice != 0 }">
|
||||||
{{ displayPrice(row.orderProfitPrice) }}
|
{{ displayPrice(row.orderProfitPrice) }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="orderProfitCont" label="消费净次数" align="center" min-width="120">
|
<el-table-column prop="orderProfitCont" label="消费净次数" align="center" min-width="140">
|
||||||
|
<template #header>
|
||||||
|
<statistics-table-header label="消费净次数" :tip="netCountHeaderTip" />
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div :class="{ 'color-success': row.orderProfitCont != 0 }">
|
<div :class="{ 'color-success': row.orderProfitCont != 0 }">
|
||||||
{{ row.orderProfitCont }}次
|
{{ row.orderProfitCont }}次
|
||||||
@@ -149,13 +201,18 @@
|
|||||||
</template>
|
</template>
|
||||||
</DraggableDepTree>
|
</DraggableDepTree>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess" @failure="handleExportFailure" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts" name="hard-dept-total">
|
<script setup lang="ts" name="hard-dept-total">
|
||||||
|
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||||
|
import { useBatchExport } from "/@/hooks/batchExport"
|
||||||
|
import { submitDepartmentExport as submitBatchExport } from "/@/api/hard/hard-total"
|
||||||
|
|
||||||
import { BasicTableProps, useTable } from "/@/hooks/table"
|
import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||||
import { displayPrice } from "/@/utils/formatMoney"
|
import { displayPrice } from "/@/utils/formatMoney"
|
||||||
import { useDict } from "/@/hooks/dict"
|
|
||||||
import * as hardTotalApi from "/@/api/hard/hard-total"
|
import * as hardTotalApi from "/@/api/hard/hard-total"
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
|
|
||||||
@@ -167,6 +224,37 @@ const selectObjs = ref([]) as any
|
|||||||
const multiple = ref(true)
|
const multiple = ref(true)
|
||||||
const draggableDepTreeRef = ref() // 树组件ref引用
|
const draggableDepTreeRef = ref() // 树组件ref引用
|
||||||
|
|
||||||
|
const transactionTimeTip = "按钱包流水的交易时间统计,包含开始时间,不包含结束时间。例如,统计 9 月可选择 9 月 1 日 00:00:00 到 10 月 1 日 00:00:00。"
|
||||||
|
/** 批量充值金额说明批量入账金额的计算方式。 */
|
||||||
|
const batchRechargeAmountHeaderTip = "通过批量充值给该部门人员增加的金额合计。"
|
||||||
|
/** 批量充值次数说明按人员入账记录计次。 */
|
||||||
|
const batchRechargeCountHeaderTip = "批量充值产生的入账记录数。一次批量操作涉及多人时,每个人的入账各算 1 次。"
|
||||||
|
/** 单次充值金额说明逐人充值金额的计算方式。 */
|
||||||
|
const singleRechargeAmountHeaderTip = "通过单次充值给该部门人员增加的金额合计。"
|
||||||
|
/** 单次充值次数说明逐人入账记录的计算方式。 */
|
||||||
|
const singleRechargeCountHeaderTip = "单次充值产生的入账记录数,每次成功增加余额算 1 次。"
|
||||||
|
/** 批量扣减金额说明批量扣款金额的计算方式。 */
|
||||||
|
const batchDeductionAmountHeaderTip = "通过批量扣减从该部门人员账户扣除的金额合计。"
|
||||||
|
/** 批量扣减次数说明按人员扣款记录计次。 */
|
||||||
|
const batchDeductionCountHeaderTip = "批量扣减产生的扣款记录数。一次批量操作涉及多人时,每个人的扣款各算 1 次。"
|
||||||
|
/** 单次扣减金额说明逐人扣款金额的计算方式。 */
|
||||||
|
const singleDeductionAmountHeaderTip = "通过单次扣减从该部门人员账户扣除的金额合计。"
|
||||||
|
/** 单次扣减次数说明逐人扣款记录的计算方式。 */
|
||||||
|
const singleDeductionCountHeaderTip = "单次扣减产生的扣款记录数,每次成功扣减余额算 1 次。"
|
||||||
|
/** 退还食堂金额说明余额退回原食堂的计算方式。 */
|
||||||
|
const returnCanteenAmountHeaderTip = "批量退还余额时,退回该部门人员各自所属食堂的金额合计。"
|
||||||
|
/** 退还食堂次数说明按人员退还记录计次。 */
|
||||||
|
const returnCanteenCountHeaderTip = "批量退还余额产生的记录数;同一次操作涉及多人时,每个人的退还记录各算 1 次。"
|
||||||
|
/** 支付和退款都按钱包流水的交易时间统计。 */
|
||||||
|
const payAmountHeaderTip = "所选交易时间内的钱包支付流水金额合计;退款金额单独显示,不会从这里扣除。"
|
||||||
|
const payCountHeaderTip = "所选交易时间内的钱包支付流水记录数,每条流水算 1 次;同一订单使用多个钱包支付时分别计次。"
|
||||||
|
const refundAmountHeaderTip = "所选交易时间内的钱包退款流水金额合计,包含非订单退款。"
|
||||||
|
const refundCountHeaderTip = "所选交易时间内的钱包退款流水记录数,每条流水算 1 次,包含非订单退款。"
|
||||||
|
/** 消费净金额说明支付与退款的相减关系。 */
|
||||||
|
const netAmountHeaderTip = "流水金额减去退款金额。支付和退款分别按各自钱包流水的交易时间统计,结果可能为负数。"
|
||||||
|
/** 消费净次数说明支付流水与退款流水的相减关系。 */
|
||||||
|
const netCountHeaderTip = "钱包支付流水次数减去钱包退款流水次数,每条流水分别计次,结果可能为负数。"
|
||||||
|
|
||||||
const defaultCreateTimeList = [
|
const defaultCreateTimeList = [
|
||||||
dayjs().subtract(3, "month").startOf("day").format("YYYY-MM-DD HH:mm:ss"),
|
dayjs().subtract(3, "month").startOf("day").format("YYYY-MM-DD HH:mm:ss"),
|
||||||
dayjs().hour(23).minute(23).second(23).format("YYYY-MM-DD HH:mm:ss"),
|
dayjs().hour(23).minute(23).second(23).format("YYYY-MM-DD HH:mm:ss"),
|
||||||
@@ -181,14 +269,18 @@ const state: BasicTableProps = reactive<BasicTableProps>({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// table hook
|
// table hook
|
||||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, postDownBlobFile, tableStyle } = useTable(state)
|
const { getDataList, sortChangeHandle, tableStyle } = useTable(state)
|
||||||
|
|
||||||
// 导出excel
|
// 导出excel
|
||||||
const exportExcel = () => {
|
/** 当前页面只声明原导出场景,进度及重复提交由公共 hook 管理。 */
|
||||||
postDownBlobFile("/hard/hard-total/export-dept", Object.assign(state.queryForm), "部门交易记录导出.xlsx")
|
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||||
}
|
progressUrl: "/hard/async-task/progress", redisKey: "hard:export:department",
|
||||||
|
taskName: "部门交易记录导出", fileName: "部门交易记录导出.xlsx", submitTask: submitBatchExport,
|
||||||
|
})
|
||||||
|
|
||||||
const cellClassName = ({ row, column, rowIndex }: any) => {
|
const exportExcel = () => submitExport({ ...state.queryForm })
|
||||||
|
|
||||||
|
const cellClassName = ({ column }: any) => {
|
||||||
if (column.property) {
|
if (column.property) {
|
||||||
if (column.property.includes("Count")) {
|
if (column.property.includes("Count")) {
|
||||||
return 'success-cell'
|
return 'success-cell'
|
||||||
@@ -272,4 +364,4 @@ const handleGetSelectNode = (curClickedNode: any, checkedTreeNodes: any, isDeepT
|
|||||||
.el-table .success-cell {
|
.el-table .success-cell {
|
||||||
background: var(--el-color-success-light-9);
|
background: var(--el-color-success-light-9);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
<template>
|
||||||
|
<el-drawer :model-value="modelValue" title="订单统计" direction="rtl" size="min(1120px, 100vw)"
|
||||||
|
class="order-statistics-drawer" append-to-body :close-on-click-modal="false"
|
||||||
|
@opened="resizeCharts" @update:model-value="changeVisible">
|
||||||
|
<div class="statistics-heading">
|
||||||
|
<div>
|
||||||
|
<div>当前筛选条件的全部订单</div>
|
||||||
|
<div class="statistics-muted">{{ rangeDescription }}</div>
|
||||||
|
</div>
|
||||||
|
<el-button icon="Refresh" :loading="loading" @click="refresh">重新统计</el-button>
|
||||||
|
</div>
|
||||||
|
<el-tabs v-model="activeTab" @tab-change="changeTab">
|
||||||
|
<el-tab-pane label="概览" name="overview">
|
||||||
|
<div v-loading="overviewLoading" class="statistics-content">
|
||||||
|
<el-result v-if="overviewError" icon="error" title="统计失败" :sub-title="overviewError">
|
||||||
|
<template #extra><el-button type="primary" @click="loadOverview">重试</el-button></template>
|
||||||
|
</el-result>
|
||||||
|
<template v-else-if="overview">
|
||||||
|
<div class="statistics-updated">统计生成时间:{{ overview.generatedAt }}</div>
|
||||||
|
<div class="statistics-cards">
|
||||||
|
<div class="statistics-card">
|
||||||
|
<div class="statistics-muted">订单数</div>
|
||||||
|
<div class="statistics-value">{{ formatStatisticsCount(overview.summary.orderCount) }}<small>笔</small></div>
|
||||||
|
</div>
|
||||||
|
<div v-for="metric in moneyMetrics" :key="metric.key" class="statistics-card">
|
||||||
|
<div class="statistics-muted">{{ metric.label }}</div>
|
||||||
|
<div class="statistics-value">{{ formatStatisticsMoney(overview.summary[metric.key]) }}<small>元</small></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="statistics-muted statistics-note">按订单原始金额汇总,不额外排除订单状态;净额=实际支付-已退款金额。</p>
|
||||||
|
<el-empty v-if="overview.summary.orderCount === '0'" description="当前条件下暂无订单" />
|
||||||
|
<template v-else>
|
||||||
|
<el-alert v-if="overview.withoutPayTime.orderCount !== '0'" type="info" :closable="false" show-icon
|
||||||
|
:title="`无支付时间的订单共 ${formatStatisticsCount(overview.withoutPayTime.orderCount)} 笔,实际支付金额 ${formatStatisticsMoney(overview.withoutPayTime.payPrice)} 元;已计入总汇总,未归入按日趋势。`" />
|
||||||
|
<el-card shadow="never" class="statistics-section">
|
||||||
|
<template #header>按日支付趋势</template>
|
||||||
|
<p v-if="overview.daily.length === 1" class="statistics-muted statistics-chart-note">仅 {{ overview.daily[0].date }} 有支付时间记录,以下展示当日金额与订单数。</p>
|
||||||
|
<v-chart v-if="overview.daily.length && activeTab === 'overview'" ref="trendChart" class="statistics-chart"
|
||||||
|
:style="{ height: overview.daily.length === 1 ? '240px' : '320px' }" :option="trendOption" autoresize />
|
||||||
|
<el-empty v-else-if="!overview.daily.length" description="当前订单均无支付时间" />
|
||||||
|
</el-card>
|
||||||
|
<el-card shadow="never" class="statistics-section">
|
||||||
|
<template #header>支付方式占比<span class="statistics-muted statistics-caption">按实际支付金额</span></template>
|
||||||
|
<div class="statistics-payment">
|
||||||
|
<v-chart v-if="hasPositivePayment && activeTab === 'overview'" ref="paymentChart" class="statistics-chart" :option="paymentOption" autoresize />
|
||||||
|
<el-empty v-else-if="!hasPositivePayment" description="暂无正向支付金额" />
|
||||||
|
<el-table :data="overview.payTypes" border max-height="320">
|
||||||
|
<el-table-column label="支付方式" min-width="150">
|
||||||
|
<template #default="{ row }">{{ payTypeName(row.payType) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="订单数" min-width="100" align="right">
|
||||||
|
<template #default="{ row }">{{ formatStatisticsCount(row.orderCount) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="实际支付(元)" min-width="160" align="right">
|
||||||
|
<template #default="{ row }">{{ formatStatisticsMoney(row.payPrice) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
<el-collapse class="statistics-section">
|
||||||
|
<el-collapse-item title="每日金额明细(元)" name="daily">
|
||||||
|
<el-table :data="overview.daily" border max-height="360">
|
||||||
|
<el-table-column prop="date" label="支付日期" width="120" fixed />
|
||||||
|
<el-table-column label="订单数" width="100" align="right">
|
||||||
|
<template #default="{ row }">{{ formatStatisticsCount(row.orderCount) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-for="metric in moneyMetrics" :key="metric.key" :label="metric.label" min-width="140" align="right">
|
||||||
|
<template #default="{ row }">{{ formatStatisticsMoney(row[metric.key]) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="商户消费" name="merchants" :disabled="overviewLoading">
|
||||||
|
<div v-loading="merchantLoading" class="statistics-content">
|
||||||
|
<el-result v-if="merchantError" icon="error" title="商户统计失败" :sub-title="merchantError">
|
||||||
|
<template #extra><el-button type="primary" @click="loadMerchants(requestedPage)">重试</el-button></template>
|
||||||
|
</el-result>
|
||||||
|
<template v-else-if="merchantPage">
|
||||||
|
<div class="statistics-updated">统计生成时间:{{ merchantPage.generatedAt }}</div>
|
||||||
|
<el-empty v-if="!merchantPage.records.length && merchantPage.current === 1" description="当前条件下暂无商户消费" />
|
||||||
|
<template v-else>
|
||||||
|
<el-card v-if="ranking.length" shadow="never" class="statistics-section">
|
||||||
|
<template #header>商户消费前十名<span class="statistics-muted statistics-caption">按实际支付金额</span></template>
|
||||||
|
<p class="statistics-muted statistics-chart-note">展示 {{ ranking.length }} 个商户<span v-if="ranking.every(row => Number(row.payPrice) === 0)">,实际支付金额均为 0 元</span></p>
|
||||||
|
<v-chart v-if="activeTab === 'merchants'" ref="merchantChart" class="statistics-chart"
|
||||||
|
:style="{ height: `${Math.max(120, ranking.length * 34 + 58)}px` }" :option="merchantOption" autoresize />
|
||||||
|
</el-card>
|
||||||
|
<el-table :data="merchantPage.records" border max-height="440">
|
||||||
|
<el-table-column label="商户" min-width="210" fixed show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>{{ row.merchantName }}</div>
|
||||||
|
<small v-if="row.merchantId" class="statistics-muted">{{ row.merchantId }}</small>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="订单数" min-width="100" align="right">
|
||||||
|
<template #default="{ row }">{{ formatStatisticsCount(row.orderCount) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-for="metric in moneyMetrics" :key="metric.key" :label="`${metric.label}(元)`" min-width="155" align="right">
|
||||||
|
<template #default="{ row }">{{ formatStatisticsMoney(row[metric.key]) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="statistics-pagination">
|
||||||
|
<el-button :disabled="merchantLoading || merchantPage.current <= 1" @click="loadMerchants(merchantPage.current - 1)">上一页</el-button>
|
||||||
|
<span>第 {{ merchantPage.current }} 页 · 每页 {{ merchantPage.size }} 条</span>
|
||||||
|
<el-button :disabled="merchantLoading || !merchantPage.hasMore" @click="loadMerchants(merchantPage.current + 1)">下一页</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</el-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" name="hardOrderStatisticsDrawer">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
|
||||||
|
import VChart from "vue-echarts"
|
||||||
|
import { use } from "echarts/core"
|
||||||
|
import { BarChart, LineChart, PieChart } from "echarts/charts"
|
||||||
|
import { AriaComponent, DataZoomComponent, GridComponent, LegendComponent, TooltipComponent } from "echarts/components"
|
||||||
|
import { CanvasRenderer } from "echarts/renderers"
|
||||||
|
import type { EChartsOption } from "echarts"
|
||||||
|
import { merchantStatistics, statistics } from "/@/api/hard/hard-order"
|
||||||
|
import type { HardOrderMerchantStatistics, HardOrderMerchantStatisticsPage, HardOrderStatisticsData, HardOrderStatisticsQuery, HardOrderStatisticsSummary } from "/@/api/hard/hard-order"
|
||||||
|
import { useDict } from "/@/hooks/dict"
|
||||||
|
import { escapeChartText, formatStatisticsCount, formatStatisticsMoney } from "./utils"
|
||||||
|
|
||||||
|
use([BarChart, LineChart, PieChart, AriaComponent, DataZoomComponent, GridComponent, LegendComponent, TooltipComponent, CanvasRenderer])
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: boolean; query: HardOrderStatisticsQuery }>()
|
||||||
|
const emit = defineEmits<{ (event: "update:modelValue", value: boolean): void }>()
|
||||||
|
const { hard_pay_type } = useDict("hard_pay_type")
|
||||||
|
const activeTab = ref("overview")
|
||||||
|
const overview = ref<HardOrderStatisticsData>()
|
||||||
|
const merchantPage = ref<HardOrderMerchantStatisticsPage>()
|
||||||
|
const ranking = ref<HardOrderMerchantStatistics[]>([])
|
||||||
|
const overviewLoading = ref(false)
|
||||||
|
const merchantLoading = ref(false)
|
||||||
|
const overviewError = ref("")
|
||||||
|
const merchantError = ref("")
|
||||||
|
const requestedPage = ref(1)
|
||||||
|
const trendChart = ref<InstanceType<typeof VChart>>()
|
||||||
|
const paymentChart = ref<InstanceType<typeof VChart>>()
|
||||||
|
const merchantChart = ref<InstanceType<typeof VChart>>()
|
||||||
|
const loading = computed(() => overviewLoading.value || merchantLoading.value)
|
||||||
|
let alive = true
|
||||||
|
let overviewRequest = 0
|
||||||
|
let merchantRequest = 0
|
||||||
|
|
||||||
|
type MoneyKey = Exclude<keyof HardOrderStatisticsSummary, "orderCount">
|
||||||
|
const moneyMetrics: { key: MoneyKey; label: string }[] = [
|
||||||
|
{ key: "totalPrice", label: "商品总价" }, { key: "extraPrice", label: "额外支付" },
|
||||||
|
{ key: "couponPrice", label: "优惠金额" }, { key: "payPrice", label: "实际支付" },
|
||||||
|
{ key: "refundPrice", label: "已退款金额" }, { key: "netPrice", label: "净额" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const rangeDescription = computed(() => [
|
||||||
|
["createTimeList", "创建时间"], ["payTimeList", "支付时间"],
|
||||||
|
].flatMap(([key, label]) => {
|
||||||
|
const value = props.query[key]
|
||||||
|
return Array.isArray(value) && value.length === 2 ? [`${label}:${value[0]} 至 ${value[1]}`] : []
|
||||||
|
}).join(";"))
|
||||||
|
|
||||||
|
function payTypeName(code: string | null) {
|
||||||
|
return hard_pay_type.value.find((item: { value: string; label: string }) => String(item.value) === code)?.label
|
||||||
|
|| (code ? `未知方式(${code})` : "未记录支付方式")
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
const detail = error as { code?: string; msg?: string; message?: string }
|
||||||
|
if (detail?.code === "ECONNABORTED" || detail?.code === "ETIMEDOUT") return "统计查询超时,请缩小查询范围后重试"
|
||||||
|
return detail?.msg || detail?.message || "统计查询失败,请重试或缩小查询范围"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOverview() {
|
||||||
|
if (overviewLoading.value) return
|
||||||
|
const request = ++overviewRequest
|
||||||
|
overviewLoading.value = true
|
||||||
|
overviewError.value = ""
|
||||||
|
try {
|
||||||
|
const response = await statistics(props.query)
|
||||||
|
if (alive && request === overviewRequest) overview.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
if (alive && request === overviewRequest) overviewError.value = errorMessage(error)
|
||||||
|
} finally {
|
||||||
|
if (alive && request === overviewRequest) overviewLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMerchants(current = 1) {
|
||||||
|
if (merchantLoading.value) return
|
||||||
|
const request = ++merchantRequest
|
||||||
|
requestedPage.value = current
|
||||||
|
merchantLoading.value = true
|
||||||
|
merchantError.value = ""
|
||||||
|
try {
|
||||||
|
const response = await merchantStatistics(props.query, current)
|
||||||
|
if (alive && request === merchantRequest) {
|
||||||
|
merchantPage.value = response.data
|
||||||
|
if (current === 1) ranking.value = response.data.records.slice(0, 10)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (alive && request === merchantRequest) merchantError.value = errorMessage(error)
|
||||||
|
} finally {
|
||||||
|
if (alive && request === merchantRequest) merchantLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeTab(name: string | number) {
|
||||||
|
if (name === "merchants" && !merchantPage.value && !merchantError.value) void loadMerchants()
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (loading.value) return
|
||||||
|
if (activeTab.value === "overview") void loadOverview()
|
||||||
|
else void loadMerchants(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeVisible(value: boolean) {
|
||||||
|
if (!value) {
|
||||||
|
alive = false
|
||||||
|
overviewRequest++
|
||||||
|
merchantRequest++
|
||||||
|
}
|
||||||
|
emit("update:modelValue", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resizeCharts() {
|
||||||
|
await nextTick()
|
||||||
|
if (!alive || !props.modelValue) return
|
||||||
|
const charts = activeTab.value === "overview" ? [trendChart.value, paymentChart.value] : [merchantChart.value]
|
||||||
|
charts.forEach(chart => chart?.resize())
|
||||||
|
}
|
||||||
|
|
||||||
|
const trendOption = computed<EChartsOption>(() => {
|
||||||
|
const rows = overview.value?.daily ?? []
|
||||||
|
const singleDay = rows.length === 1
|
||||||
|
const hasZoom = rows.length > 14
|
||||||
|
return {
|
||||||
|
aria: { enabled: true },
|
||||||
|
color: ["#409eff", "#67c23a"],
|
||||||
|
legend: { data: ["实际支付", "订单数"] },
|
||||||
|
grid: { left: 12, right: 12, top: 48, bottom: hasZoom ? 52 : 12, containLabel: true },
|
||||||
|
tooltip: {
|
||||||
|
trigger: "axis",
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const row = rows[params[0]?.dataIndex]
|
||||||
|
return row ? `${escapeChartText(row.date)}<br/>实际支付:${formatStatisticsMoney(row.payPrice)} 元<br/>订单数:${formatStatisticsCount(row.orderCount)} 笔` : ""
|
||||||
|
},
|
||||||
|
},
|
||||||
|
xAxis: singleDay ? { type: "category", data: rows.map(row => row.date) }
|
||||||
|
: { type: "time", minInterval: 24 * 60 * 60 * 1000, axisLabel: { formatter: "{MM}-{dd}", hideOverlap: true } },
|
||||||
|
yAxis: [{ type: "value", name: "金额(元)" }, { type: "value", name: "订单数", minInterval: 1 }],
|
||||||
|
dataZoom: hasZoom ? [{ type: "inside" }, { type: "slider", height: 18, bottom: 8 }] : [],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: "实际支付", type: singleDay ? "bar" : "line", barMaxWidth: 36,
|
||||||
|
data: rows.map(row => singleDay ? Number(row.payPrice) / 100 : [row.date, Number(row.payPrice) / 100]),
|
||||||
|
showSymbol: rows.length < 32, symbolSize: 7,
|
||||||
|
label: { show: singleDay, position: "top", formatter: () => `${formatStatisticsMoney(rows[0]?.payPrice)} 元` },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "订单数", type: singleDay ? "bar" : "line", yAxisIndex: 1, barMaxWidth: 36,
|
||||||
|
lineStyle: { type: "dashed" }, data: rows.map(row => singleDay ? Number(row.orderCount) : [row.date, Number(row.orderCount)]),
|
||||||
|
showSymbol: rows.length < 32, symbolSize: 7,
|
||||||
|
label: { show: singleDay, position: "top", formatter: () => `${formatStatisticsCount(rows[0]?.orderCount ?? '0')} 笔` },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasPositivePayment = computed(() => overview.value?.payTypes.some(row => Number(row.payPrice) > 0))
|
||||||
|
const paymentOption = computed<EChartsOption>(() => ({
|
||||||
|
aria: { enabled: true },
|
||||||
|
tooltip: {
|
||||||
|
trigger: "item",
|
||||||
|
formatter: (params: any) => `${escapeChartText(params.name)}<br/>实际支付:${formatStatisticsMoney(params.data.rawAmount)} 元<br/>订单数:${formatStatisticsCount(params.data.orderCount)} 笔<br/>占比:${params.percent}%`,
|
||||||
|
},
|
||||||
|
legend: { type: "scroll", bottom: 0 },
|
||||||
|
series: [{
|
||||||
|
type: "pie", radius: ["40%", "65%"], center: ["50%", "44%"],
|
||||||
|
label: { formatter: "{b}\n{d}%" },
|
||||||
|
data: (overview.value?.payTypes ?? []).filter(row => Number(row.payPrice) > 0).map(row => ({
|
||||||
|
name: payTypeName(row.payType), value: Number(row.payPrice) / 100, rawAmount: row.payPrice, orderCount: row.orderCount,
|
||||||
|
})),
|
||||||
|
}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const merchantOption = computed<EChartsOption>(() => ({
|
||||||
|
aria: { enabled: true },
|
||||||
|
grid: { left: 12, right: 32, top: 12, bottom: 12, containLabel: true },
|
||||||
|
tooltip: {
|
||||||
|
trigger: "item",
|
||||||
|
formatter: (params: any) => `${escapeChartText(params.name)}<br/>实际支付:${formatStatisticsMoney(params.data.rawAmount)} 元`,
|
||||||
|
},
|
||||||
|
color: ["#409eff"],
|
||||||
|
// 商户名称可能相同,以位置作为类目并在标签中显示名称,避免同名商户合并。
|
||||||
|
xAxis: { type: "value", name: "元" },
|
||||||
|
yAxis: { type: "category", inverse: true, data: ranking.value.map((_, index) => String(index)), axisLabel: {
|
||||||
|
width: 120, overflow: "truncate", formatter: (value: string) => ranking.value[Number(value)]?.merchantName ?? "",
|
||||||
|
} },
|
||||||
|
series: [{ type: "bar", barMaxWidth: 26, data: ranking.value.map((row, index) => ({
|
||||||
|
name: row.merchantName, value: [Number(row.payPrice) / 100, index], rawAmount: row.payPrice,
|
||||||
|
label: Number(row.payPrice) === 0 ? { show: true, position: "right", formatter: "0.00" } : { show: false },
|
||||||
|
})), encode: { x: 0, y: 1 } }],
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 只在当前标签可见时挂载图表;抽屉动画结束、数据更新与切换标签后重新测量容器。
|
||||||
|
watch([activeTab, overview, merchantPage], resizeCharts, { flush: "post" })
|
||||||
|
onMounted(() => void loadOverview())
|
||||||
|
onBeforeUnmount(() => { alive = false; overviewRequest++; merchantRequest++ })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
:global(.order-statistics-drawer .el-drawer__header) { margin-bottom: 0; padding-bottom: 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||||
|
:global(.order-statistics-drawer .el-drawer__body) { min-width: 0; overflow: auto; }
|
||||||
|
.statistics-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; }
|
||||||
|
.statistics-muted { color: var(--el-text-color-secondary); font-size: 13px; }
|
||||||
|
.statistics-heading .statistics-muted { margin-top: 6px; line-height: 1.6; }
|
||||||
|
.statistics-content { min-height: 220px; }
|
||||||
|
.statistics-updated { margin-bottom: 14px; text-align: right; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||||
|
.statistics-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
||||||
|
.statistics-card { padding: 16px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; background: var(--el-fill-color-blank); }
|
||||||
|
.statistics-value { margin-top: 10px; font-size: 22px; font-weight: 600; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
||||||
|
.statistics-value small { margin-left: 6px; font-size: 12px; font-weight: normal; color: var(--el-text-color-secondary); }
|
||||||
|
.statistics-note { margin: 12px 0 18px; }
|
||||||
|
.statistics-section { margin-top: 16px; }
|
||||||
|
.statistics-caption { margin-left: 12px; }
|
||||||
|
.statistics-chart { width: 100%; height: 320px; }
|
||||||
|
.statistics-chart-note { margin: 0 0 12px; }
|
||||||
|
.statistics-payment { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: center; gap: 20px; }
|
||||||
|
.statistics-pagination { display: flex; align-items: center; justify-content: flex-end; gap: 16px; margin-top: 16px; }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.statistics-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.statistics-payment { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.statistics-heading { align-items: flex-start; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import dayjs from "dayjs"
|
||||||
|
import type { HardOrderStatisticsQuery } from "/@/api/hard/hard-order"
|
||||||
|
|
||||||
|
const ignoredFields = new Set(["current", "size", "descs", "ascs", "ids", "selectObjs", "tenantId", "mchTypeCodes"])
|
||||||
|
|
||||||
|
/** 在点击时复制表单,避免分页、导出勾选或后续表单编辑污染统计。 */
|
||||||
|
export function snapshotStatisticsQuery(form: HardOrderStatisticsQuery): HardOrderStatisticsQuery {
|
||||||
|
const query = JSON.parse(JSON.stringify(Object.fromEntries(Object.entries(form).filter(([key]) => !ignoredFields.has(key)))))
|
||||||
|
for (const [key, label] of [["createTimeList", "创建时间"], ["payTimeList", "支付时间"]]) {
|
||||||
|
const range = query[key]
|
||||||
|
if (range == null || (Array.isArray(range) && range.length === 0)) continue
|
||||||
|
if (!Array.isArray(range) || range.length !== 2 || !range[0] || !range[1]) {
|
||||||
|
throw new Error(`${label}需填写完整`)
|
||||||
|
}
|
||||||
|
const start = dayjs(range[0])
|
||||||
|
const end = dayjs(range[1])
|
||||||
|
if (!start.isValid() || !end.isValid() || end.isBefore(start)) {
|
||||||
|
throw new Error(`${label}开始时间不能晚于结束时间`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 直接移动整数分的小数点;卡片、表格和提示均不先转 Number。 */
|
||||||
|
export function formatStatisticsMoney(value: string | number | null | undefined): string {
|
||||||
|
const raw = String(value ?? "0")
|
||||||
|
if (!/^-?\d+$/.test(raw)) return "0.00"
|
||||||
|
const negative = raw.startsWith("-")
|
||||||
|
const digits = raw.replace(/^-/, "").replace(/^0+(?=\d)/, "").padStart(3, "0")
|
||||||
|
const yuan = digits.slice(0, -2).replace(/\B(?=(\d{3})+(?!\d))/g, ",")
|
||||||
|
return `${negative && /[1-9]/.test(digits) ? "-" : ""}${yuan}.${digits.slice(-2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatStatisticsCount(value: string | number): string {
|
||||||
|
return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeChartText(value: unknown): string {
|
||||||
|
return String(value ?? "").replace(/[&<>"']/g, (character) => ({
|
||||||
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||||
|
}[character]!))
|
||||||
|
}
|
||||||
@@ -34,6 +34,13 @@
|
|||||||
<dict-select v-model="state.queryForm.orderPayTypeCodes" dict-code="hard_pay_type"
|
<dict-select v-model="state.queryForm.orderPayTypeCodes" dict-code="hard_pay_type"
|
||||||
multiple />
|
multiple />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item style="width: 250px" label="是否有额外支付" prop="hasExtraPrice">
|
||||||
|
<el-select v-model="state.queryForm.hasExtraPrice" placeholder="请选择" clearable
|
||||||
|
@clear="state.queryForm.hasExtraPrice = null">
|
||||||
|
<el-option label="是" :value="true" />
|
||||||
|
<el-option label="否" :value="false" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item style="width: 250px" label="订单状态" prop="orderStatus">
|
<el-form-item style="width: 250px" label="订单状态" prop="orderStatus">
|
||||||
<dict-select v-model="state.queryForm.orderStatus" dict-code="hard_order_status" />
|
<dict-select v-model="state.queryForm.orderStatus" dict-code="hard_order_status" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -67,7 +74,12 @@
|
|||||||
</el-tabs>
|
</el-tabs>
|
||||||
<right-toolbar v-model:showSearch="showSearch" :export="'sys_audit_export'"
|
<right-toolbar v-model:showSearch="showSearch" :export="'sys_audit_export'"
|
||||||
@exportExcel="exportExcel" class="ml10" style="float: right; margin-right: 20px"
|
@exportExcel="exportExcel" class="ml10" style="float: right; margin-right: 20px"
|
||||||
@queryTable="getDataList" />
|
@queryTable="getDataList">
|
||||||
|
<el-tooltip content="统计" placement="top">
|
||||||
|
<el-button icon="DataLine" circle aria-label="统计" v-auth="'hard_hardwareOrder_view'"
|
||||||
|
:disabled="statisticsVisible" @click="openStatistics" />
|
||||||
|
</el-tooltip>
|
||||||
|
</right-toolbar>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||||
:header-cell-style="tableStyle.headerCellStyle" @selection-change="selectionChangHandle"
|
:header-cell-style="tableStyle.headerCellStyle" @selection-change="selectionChangHandle"
|
||||||
@@ -271,10 +283,18 @@
|
|||||||
<form-dialog ref="formDialogRef" @refresh="getDataList(false)" />
|
<form-dialog ref="formDialogRef" @refresh="getDataList(false)" />
|
||||||
<admin-refund-dialog ref="adminRefundDailogRef" @refresh="getDataList(false)" />
|
<admin-refund-dialog ref="adminRefundDailogRef" @refresh="getDataList(false)" />
|
||||||
<shop-car ref="shopCarDialogRef" />
|
<shop-car ref="shopCarDialogRef" />
|
||||||
|
<order-statistics-drawer v-if="statisticsVisible" v-model="statisticsVisible" :query="statisticsQuery" />
|
||||||
|
|
||||||
|
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess"
|
||||||
|
@failure="handleExportFailure" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts" name="systemHardwareOrder">
|
<script setup lang="ts" name="systemHardwareOrder">
|
||||||
|
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||||
|
import { useBatchExport } from "/@/hooks/batchExport"
|
||||||
|
import { submitExport as submitBatchExport } from "/@/api/hard/hard-order"
|
||||||
|
|
||||||
import { BasicTableProps, useTable } from "/@/hooks/table"
|
import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||||
import * as hardOrder from "/@/api/hard/hard-order"
|
import * as hardOrder from "/@/api/hard/hard-order"
|
||||||
import * as hardInvoice from "/@/api/hard/hard-invoice"
|
import * as hardInvoice from "/@/api/hard/hard-invoice"
|
||||||
@@ -285,6 +305,7 @@ import { displayPrice } from "/@/utils/formatMoney"
|
|||||||
import { ElLoading, TableColumnCtx } from "element-plus"
|
import { ElLoading, TableColumnCtx } from "element-plus"
|
||||||
import { VNode } from "vue"
|
import { VNode } from "vue"
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
|
import { snapshotStatisticsQuery } from "./components/statistics-drawer/utils"
|
||||||
|
|
||||||
const { push } = useRouter()
|
const { push } = useRouter()
|
||||||
|
|
||||||
@@ -293,6 +314,7 @@ const FormDialog = defineAsyncComponent(() => import("./form.vue"))
|
|||||||
const AdminRefundDialog = defineAsyncComponent(() => import("./components/admin-refund-dialog/index.vue"))
|
const AdminRefundDialog = defineAsyncComponent(() => import("./components/admin-refund-dialog/index.vue"))
|
||||||
const ShopCar = defineAsyncComponent(() => import("../components/shop-car.vue"))
|
const ShopCar = defineAsyncComponent(() => import("../components/shop-car.vue"))
|
||||||
const TransactionList = defineAsyncComponent(() => import("/@/views/wallet/components/transaction-list.vue"))
|
const TransactionList = defineAsyncComponent(() => import("/@/views/wallet/components/transaction-list.vue"))
|
||||||
|
const OrderStatisticsDrawer = defineAsyncComponent(() => import("./components/statistics-drawer/index.vue"))
|
||||||
// 定义查询字典
|
// 定义查询字典
|
||||||
const { hard_pay_type, hard_order_type, hard_order_status, hard_type, hard_refund_status, daily_menu, wallet_type } = useDict(
|
const { hard_pay_type, hard_order_type, hard_order_status, hard_type, hard_refund_status, daily_menu, wallet_type } = useDict(
|
||||||
"hard_pay_type",
|
"hard_pay_type",
|
||||||
@@ -315,6 +337,18 @@ const draggableDepTreeRef = ref()
|
|||||||
// 搜索变量
|
// 搜索变量
|
||||||
const queryRef = ref()
|
const queryRef = ref()
|
||||||
const showSearch = ref(true)
|
const showSearch = ref(true)
|
||||||
|
const statisticsVisible = ref(false)
|
||||||
|
const statisticsQuery = ref<hardOrder.HardOrderStatisticsQuery>({})
|
||||||
|
|
||||||
|
const openStatistics = () => {
|
||||||
|
if (statisticsVisible.value) return
|
||||||
|
try {
|
||||||
|
statisticsQuery.value = snapshotStatisticsQuery(state.queryForm)
|
||||||
|
statisticsVisible.value = true
|
||||||
|
} catch (error) {
|
||||||
|
useMessage().warning((error as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 多选变量
|
// 多选变量
|
||||||
const selectObjs = ref([]) as any
|
const selectObjs = ref([]) as any
|
||||||
@@ -328,12 +362,13 @@ const defaultCreateTimeList = [
|
|||||||
const state: BasicTableProps = reactive<BasicTableProps>({
|
const state: BasicTableProps = reactive<BasicTableProps>({
|
||||||
queryForm: {
|
queryForm: {
|
||||||
createTimeList: defaultCreateTimeList,
|
createTimeList: defaultCreateTimeList,
|
||||||
|
hasExtraPrice: null,
|
||||||
},
|
},
|
||||||
pageList: hardOrder.pageJoin,
|
pageList: hardOrder.pageJoin,
|
||||||
})
|
})
|
||||||
|
|
||||||
// table hook
|
// table hook
|
||||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, downBlobFile, tableStyle } = useTable(state)
|
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, tableStyle } = useTable(state)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const { data } = await getDicts("hard_order_type")
|
const { data } = await getDicts("hard_order_type")
|
||||||
@@ -386,9 +421,13 @@ const handleGetSelectNode = (curClickedNode: any, checkedTreeNodes: any, isDeepT
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 导出excel
|
// 导出excel
|
||||||
const exportExcel = () => {
|
/** 当前页面只声明原导出场景,进度及重复提交由公共 hook 管理。 */
|
||||||
downBlobFile("/hard/hard-order/export", Object.assign(state.queryForm, { ids: selectObjs }), "订单记录.xlsx")
|
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||||
}
|
progressUrl: "/hard/async-task/progress", redisKey: "hard:export:order",
|
||||||
|
taskName: "订单记录", fileName: "订单记录.xlsx", submitTask: submitBatchExport,
|
||||||
|
})
|
||||||
|
|
||||||
|
const exportExcel = () => submitExport({ ...state.queryForm, ids: [...selectObjs.value] })
|
||||||
|
|
||||||
// 多选事件
|
// 多选事件
|
||||||
const selectionChangHandle = (objs: { id: string }[]) => {
|
const selectionChangHandle = (objs: { id: string }[]) => {
|
||||||
|
|||||||
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user