init
This commit is contained in:
9
.env.development
Normal file
9
.env.development
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# 开发环境配置
|
||||||
|
VITE_APP_TITLE=趣渡科技
|
||||||
|
VITE_APP_SUB_TITLE=一站式管理系统
|
||||||
|
VITE_APP_COPYRIGHT=上海趣渡网络科技有限公司
|
||||||
|
VITE_APP_VERSION_NAME=开发版
|
||||||
|
VITE_APP_CACHE_PROFIX=logistics_dev_admin
|
||||||
|
|
||||||
|
# API配置
|
||||||
|
VITE_API_BASE_URL=http://127.0.0.1:9081/api
|
||||||
9
.env.production
Normal file
9
.env.production
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# 生产环境配置
|
||||||
|
VITE_APP_TITLE=趣渡科技
|
||||||
|
VITE_APP_SUB_TITLE=一站式管理系统
|
||||||
|
VITE_APP_COPYRIGHT=上海趣渡网络科技有限公司
|
||||||
|
VITE_APP_VERSION_NAME=正式版
|
||||||
|
VITE_APP_CACHE_PROFIX=logistics_prod_admin
|
||||||
|
|
||||||
|
# API配置
|
||||||
|
VITE_API_BASE_URL=/api
|
||||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "tape-springboot-sysadmin",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --mode development",
|
||||||
|
"build": "vite build --mode production",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons-vue": "^7.0.1",
|
||||||
|
"@vueuse/core": "^13.0.0",
|
||||||
|
"ant-design-vue": "^4.2.6",
|
||||||
|
"axios": "^1.8.3",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"pinia": "^3.0.1",
|
||||||
|
"vue-lazyload": "^3.0.0",
|
||||||
|
"vue-router": "^4.5.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"@vitejs/plugin-vue-jsx": "^4.1.1",
|
||||||
|
"less": "^4.2.2",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
2216
pnpm-lock.yaml
generated
Normal file
2216
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
0
public/README.md
Normal file
0
public/README.md
Normal file
1
src/assets/logo.svg
Normal file
1
src/assets/logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg t="1779016191270" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="21892" width="200" height="200"><path d="M886.97857168 763.55H587.02142833v112.69285753h74.95714334a37.54285751 37.54285751 0 0 1 0 75.08571415H362.02142833a37.54285751 37.54285751 0 0 1-1e-8-75.08571415h74.95714336v-112.69285753H137.02142832A75.02142833 75.02142833 0 0 1 62 688.46428583V162.8C62 121.33571417 95.55714247 87.71428583 137.02142832 87.71428583h749.95714336C928.44285753 87.71428583 962 121.33571417 962 162.8v525.66428585c0 41.46428585-33.55714249 75.08571415-75.02142833 75.08571415zM137.02142832 650.92142832h749.95714336V575.83571417H137.02142832v75.08571415z" p-id="21893" fill="#007AFF"></path></svg>
|
||||||
|
After Width: | Height: | Size: 736 B |
4
src/common/image.js
Normal file
4
src/common/image.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
const imageDefaultBase64 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMIAAADDCAYAAADQvc6UAAABRWlDQ1BJQ0MgUHJvZmlsZQAAKJFjYGASSSwoyGFhYGDIzSspCnJ3UoiIjFJgf8LAwSDCIMogwMCcmFxc4BgQ4ANUwgCjUcG3awyMIPqyLsis7PPOq3QdDFcvjV3jOD1boQVTPQrgSkktTgbSf4A4LbmgqISBgTEFyFYuLykAsTuAbJEioKOA7DkgdjqEvQHEToKwj4DVhAQ5A9k3gGyB5IxEoBmML4BsnSQk8XQkNtReEOBxcfXxUQg1Mjc0dyHgXNJBSWpFCYh2zi+oLMpMzyhRcASGUqqCZ16yno6CkYGRAQMDKMwhqj/fAIcloxgHQqxAjIHBEugw5sUIsSQpBobtQPdLciLEVJYzMPBHMDBsayhILEqEO4DxG0txmrERhM29nYGBddr//5/DGRjYNRkY/l7////39v///y4Dmn+LgeHANwDrkl1AuO+pmgAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAwqADAAQAAAABAAAAwwAAAAD9b/HnAAAHlklEQVR4Ae3dP3PTWBSGcbGzM6GCKqlIBRV0dHRJFarQ0eUT8LH4BnRU0NHR0UEFVdIlFRV7TzRksomPY8uykTk/zewQfKw/9znv4yvJynLv4uLiV2dBoDiBf4qP3/ARuCRABEFAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghgg0Aj8i0JO4OzsrPv69Wv+hi2qPHr0qNvf39+iI97soRIh4f3z58/u7du3SXX7Xt7Z2enevHmzfQe+oSN2apSAPj09TSrb+XKI/f379+08+A0cNRE2ANkupk+ACNPvkSPcAAEibACyXUyfABGm3yNHuAECRNgAZLuYPgEirKlHu7u7XdyytGwHAd8jjNyng4OD7vnz51dbPT8/7z58+NB9+/bt6jU/TI+AGWHEnrx48eJ/EsSmHzx40L18+fLyzxF3ZVMjEyDCiEDjMYZZS5wiPXnyZFbJaxMhQIQRGzHvWR7XCyOCXsOmiDAi1HmPMMQjDpbpEiDCiL358eNHurW/5SnWdIBbXiDCiA38/Pnzrce2YyZ4//59F3ePLNMl4PbpiL2J0L979+7yDtHDhw8vtzzvdGnEXdvUigSIsCLAWavHp/+qM0BcXMd/q25n1vF57TYBp0a3mUzilePj4+7k5KSLb6gt6ydAhPUzXnoPR0dHl79WGTNCfBnn1uvSCJdegQhLI1vvCk+fPu2ePXt2tZOYEV6/fn31dz+shwAR1sP1cqvLntbEN9MxA9xcYjsxS1jWR4AIa2Ibzx0tc44fYX/16lV6NDFLXH+YL32jwiACRBiEbf5KcXoTIsQSpzXx4N28Ja4BQoK7rgXiydbHjx/P25TaQAJEGAguWy0+2Q8PD6/Ki4R8EVl+bzBOnZY95fq9rj9zAkTI2SxdidBHqG9+skdw43borCXO/ZcJdraPWdv22uIEiLA4q7nvvCug8WTqzQveOH26fodo7g6uFe/a17W3+nFBAkRYENRdb1vkkz1CH9cPsVy/jrhr27PqMYvENYNlHAIesRiBYwRy0V+8iXP8+/fvX11Mr7L7ECueb/r48eMqm7FuI2BGWDEG8cm+7G3NEOfmdcTQw4h9/55lhm7DekRYKQPZF2ArbXTAyu4kDYB2YxUzwg0gi/41ztHnfQG26HbGel/crVrm7tNY+/1btkOEAZ2M05r4FB7r9GbAIdxaZYrHdOsgJ/wCEQY0J74TmOKnbxxT9n3FgGGWWsVdowHtjt9Nnvf7yQM2aZU/TIAIAxrw6dOnAWtZZcoEnBpNuTuObWMEiLAx1HY0ZQJEmHJ3HNvGCBBhY6jtaMoEiJB0Z29vL6ls58vxPcO8/zfrdo5qvKO+d3Fx8Wu8zf1dW4p/cPzLly/dtv9Ts/EbcvGAHhHyfBIhZ6NSiIBTo0LNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiEC/wGgKKC4YMA4TAAAAABJRU5ErkJggg==";
|
||||||
|
|
||||||
|
export { imageDefaultBase64 };
|
||||||
19
src/components/DisplayObjectView.vue
Normal file
19
src/components/DisplayObjectView.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<template>
|
||||||
|
<slot name="view" :value="objectValue" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps, computed } from "vue";
|
||||||
|
import { toAny } from "@/iview/utils/toany";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
value: {
|
||||||
|
type: [String, Object],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const objectValue = computed(() => {
|
||||||
|
return toAny(props.value, {});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
107
src/components/LogContentPreview.vue
Normal file
107
src/components/LogContentPreview.vue
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<template>
|
||||||
|
<div class="log-content-preview">
|
||||||
|
<div class="log-content-preview__toolbar">
|
||||||
|
<a-button size="small" type="link" :disabled="!hasContent" @click="copyLog">
|
||||||
|
复制日志
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<div class="log-content-preview__viewport">
|
||||||
|
<pre class="log-content-preview__text">{{ displayText }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { showErrorTips, showSuccessTips } from "@/iview/utils/messageTips";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
type: String,
|
||||||
|
default: "暂无任务执行日志",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasContent = computed(() => !!`${props.content || ""}`.trim());
|
||||||
|
|
||||||
|
const displayText = computed(() => {
|
||||||
|
if (hasContent.value) return props.content;
|
||||||
|
return props.emptyText;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function copyLog() {
|
||||||
|
if (!hasContent.value) return;
|
||||||
|
const text = `${props.content || ""}`;
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
showSuccessTips("已复制到剪贴板");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
copyByTextarea(text);
|
||||||
|
showSuccessTips("已复制到剪贴板");
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
copyByTextarea(text);
|
||||||
|
showSuccessTips("已复制到剪贴板");
|
||||||
|
} catch {
|
||||||
|
showErrorTips(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyByTextarea(text) {
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.setAttribute("readonly", "");
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.left = "-9999px";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
const copied = document.execCommand("copy");
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
if (!copied) throw new Error("复制失败,请手动选择日志内容");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.log-content-preview {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-content-preview__toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-content-preview__viewport {
|
||||||
|
/* overflow-x 非 visible 时,overflow-y: visible 会被浏览器算成 auto 而出现纵向滚动条 */
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: clip;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 5px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-content-preview__text {
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||||
|
"Liberation Mono", "Courier New", monospace;
|
||||||
|
white-space: pre;
|
||||||
|
word-break: normal;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
11
src/config/index.js
Normal file
11
src/config/index.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// 导出当前环境信息,方便在应用中使用
|
||||||
|
export default {
|
||||||
|
BUILD_VERSION: `${__BUILD_VERSION__ || 'v1.0.0'}`,
|
||||||
|
BASE_URL: import.meta.env.VITE_API_BASE_URL,
|
||||||
|
APP_TITLE: import.meta.env.VITE_APP_TITLE,
|
||||||
|
APP_SUB_TITLE: import.meta.env.VITE_APP_SUB_TITLE,
|
||||||
|
APP_COPYRIGHT: import.meta.env.VITE_APP_COPYRIGHT,
|
||||||
|
APP_VERSION_NAME: import.meta.env.VITE_APP_VERSION_NAME,
|
||||||
|
APP_CACHE_PROFIX: import.meta.env.VITE_APP_CACHE_PROFIX,
|
||||||
|
MODE: import.meta.env.MODE,
|
||||||
|
}
|
||||||
88
src/config/menu.js
Normal file
88
src/config/menu.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import {
|
||||||
|
TeamOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
} from "@ant-design/icons-vue";
|
||||||
|
|
||||||
|
export const menuConfig = [
|
||||||
|
{
|
||||||
|
key: "business-list",
|
||||||
|
path: "/dashboard/business-list",
|
||||||
|
icon: TeamOutlined,
|
||||||
|
title: "业务管理",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "setting-list",
|
||||||
|
path: "/dashboard/setting-list",
|
||||||
|
icon: SettingOutlined,
|
||||||
|
title: "系统管理",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 根据路径获取菜单key的工具函数
|
||||||
|
export function getMenuKeyByPath(path) {
|
||||||
|
const pathParts = path.split("/");
|
||||||
|
|
||||||
|
if (pathParts.length >= 3) {
|
||||||
|
const pathKey = pathParts[2];
|
||||||
|
|
||||||
|
// 检查是否是已知的二级菜单路径
|
||||||
|
// 遍历所有菜单配置,查找匹配的子菜单
|
||||||
|
for (const menuItem of menuConfig) {
|
||||||
|
if (menuItem.children && menuItem.children.length > 0) {
|
||||||
|
// 查找子菜单中是否有匹配的路径
|
||||||
|
const matchingChild = menuItem.children.find(child => {
|
||||||
|
const childPathParts = child.path.split("/");
|
||||||
|
return childPathParts.length >= 3 && childPathParts[2] === pathKey;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchingChild) {
|
||||||
|
return matchingChild.key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有找到子菜单匹配,返回一级菜单key
|
||||||
|
return pathKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认返回第一个菜单项的key
|
||||||
|
return menuConfig[0]?.key || "home";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据key获取菜单项的工具函数
|
||||||
|
export function getMenuItemByKey(key) {
|
||||||
|
// 先查找一级菜单
|
||||||
|
for (const item of menuConfig) {
|
||||||
|
if (item.key === key) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
// 如果有二级菜单,也要查找
|
||||||
|
if (item.children) {
|
||||||
|
const subItem = item.children.find(child => child.key === key);
|
||||||
|
if (subItem) {
|
||||||
|
return subItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有菜单项(包括子菜单)的工具函数
|
||||||
|
export function getAllMenuItems() {
|
||||||
|
const allItems = [];
|
||||||
|
|
||||||
|
menuConfig.forEach(item => {
|
||||||
|
allItems.push(item);
|
||||||
|
if (item.children) {
|
||||||
|
allItems.push(...item.children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return allItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据父菜单key获取子菜单的工具函数
|
||||||
|
export function getSubMenusByParentKey(parentKey) {
|
||||||
|
const parentItem = menuConfig.find(item => item.key === parentKey);
|
||||||
|
return parentItem?.children || [];
|
||||||
|
}
|
||||||
70
src/config/router.js
Normal file
70
src/config/router.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { createRouter, createWebHashHistory } from "vue-router";
|
||||||
|
import { useUserStore } from "../store/user";
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
redirect: "/dashboard",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/login",
|
||||||
|
name: "Login",
|
||||||
|
component: () => import("../views/Login.vue"),
|
||||||
|
meta: {
|
||||||
|
notLoggedIn: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/dashboard",
|
||||||
|
name: "Dashboard",
|
||||||
|
component: () => import("../views/PageDashboard.vue"),
|
||||||
|
redirect: "/dashboard/business-list",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "business-list",
|
||||||
|
name: "BusinessList",
|
||||||
|
component: () => import("../views/business/index.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "setting-list",
|
||||||
|
name: "SettingList",
|
||||||
|
component: () => import("../views/setting/index.vue"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/:pathMatch(.*)*",
|
||||||
|
name: "NotFound",
|
||||||
|
component: () => import("../views/PageNotFound.vue"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isNotLoggedIn = (to) => {
|
||||||
|
return to.meta.notLoggedIn;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 路由守卫
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
const userStore = useUserStore();
|
||||||
|
if (!userStore.isInitialized) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userToken = userStore.userToken;
|
||||||
|
if (isNotLoggedIn(to)) {
|
||||||
|
next();
|
||||||
|
} else if (!userToken) {
|
||||||
|
next({ path: "/login", query: { redirect: to.fullPath } });
|
||||||
|
} else if (to.path === "/login" && userToken) {
|
||||||
|
next({ path: "/dashboard" });
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
2558
src/iview/ILayoutPage.vue
Normal file
2558
src/iview/ILayoutPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
523
src/iview/IViewConfirm.vue
Normal file
523
src/iview/IViewConfirm.vue
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
<template>
|
||||||
|
<a-popconfirm
|
||||||
|
v-if="showPopconfirm"
|
||||||
|
v-model:open="modalVisible"
|
||||||
|
:ok-text="okText"
|
||||||
|
:cancel-text="cancelText"
|
||||||
|
:ok-button-props="{ danger: true, loading: confirmLoading }"
|
||||||
|
:placement="placement"
|
||||||
|
:arrow="false"
|
||||||
|
:destroy-tooltip-on-hide="true"
|
||||||
|
:overlay-inner-style="popconfirmOverlayInnerStyle"
|
||||||
|
:overlay-style="popconfirmOverlayStyle"
|
||||||
|
@confirm="handleSimpleConfirm"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div :style="normalWidthStyle">{{ title }}</div>
|
||||||
|
</template>
|
||||||
|
<template #icon><warning-filled /></template>
|
||||||
|
<template #default>
|
||||||
|
<span class="iview-confirm-trigger">
|
||||||
|
<slot></slot>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</a-popconfirm>
|
||||||
|
|
||||||
|
<template v-else-if="isImportant">
|
||||||
|
<span v-if="$slots.default" @click="handleOpenModal">
|
||||||
|
<slot></slot>
|
||||||
|
</span>
|
||||||
|
<a-modal
|
||||||
|
v-model:open="modalVisible"
|
||||||
|
@ok="confirmDelete"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
:okButtonProps="{
|
||||||
|
danger: true,
|
||||||
|
disabled: !isInputValid,
|
||||||
|
loading: loading || confirmLoading,
|
||||||
|
}"
|
||||||
|
:title="title"
|
||||||
|
:okText="okText"
|
||||||
|
:cancelText="cancelText"
|
||||||
|
:maskClosable="false"
|
||||||
|
:destroyOnClose="true"
|
||||||
|
:class="{ 'important-modal': isImportant }"
|
||||||
|
:width="importantWidth"
|
||||||
|
class="delete-confirm-modal"
|
||||||
|
>
|
||||||
|
<div class="delete-confirm-content">
|
||||||
|
<div class="delete-info">
|
||||||
|
<p class="delete-guide">
|
||||||
|
{{ inputLabelText }}
|
||||||
|
</p>
|
||||||
|
<a-input
|
||||||
|
class="confirm-input"
|
||||||
|
v-model:value="confirmInput"
|
||||||
|
:placeholder="inputPlaceholderText"
|
||||||
|
@keydown.enter="confirmDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<span v-if="$slots.default" class="iview-confirm-trigger-wrap" @click="handleOpenModal">
|
||||||
|
<slot></slot>
|
||||||
|
</span>
|
||||||
|
<a-modal
|
||||||
|
v-model:open="modalVisible"
|
||||||
|
@ok="handleSimpleConfirm"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
:okButtonProps="{ danger: true, loading: confirmLoading }"
|
||||||
|
:okText="okText"
|
||||||
|
:cancelText="cancelText"
|
||||||
|
:closable="false"
|
||||||
|
:maskClosable="false"
|
||||||
|
:destroyOnClose="true"
|
||||||
|
:width="normalModalWidth"
|
||||||
|
class="delete-confirm-modal normal-confirm-modal"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<span class="normal-modal-title">
|
||||||
|
<warning-filled class="normal-modal-icon" />
|
||||||
|
<span :style="normalWidthStyle">{{ title }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||||
|
import { WarningFilled } from "@ant-design/icons-vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
normalWidth: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: 360,
|
||||||
|
},
|
||||||
|
importantWidth: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: 420,
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: "确认操作?",
|
||||||
|
},
|
||||||
|
okText: {
|
||||||
|
type: String,
|
||||||
|
default: "确认",
|
||||||
|
},
|
||||||
|
cancelText: {
|
||||||
|
type: String,
|
||||||
|
default: "取消",
|
||||||
|
},
|
||||||
|
entityName: {
|
||||||
|
type: String,
|
||||||
|
default: "我确认操作",
|
||||||
|
},
|
||||||
|
importance: {
|
||||||
|
type: String,
|
||||||
|
default: "normal",
|
||||||
|
validator: (value) => ["normal", "important"].includes(value),
|
||||||
|
},
|
||||||
|
placement: {
|
||||||
|
type: String,
|
||||||
|
default: "left",
|
||||||
|
},
|
||||||
|
inputLabel: {
|
||||||
|
type: String,
|
||||||
|
default: "请输入“{{entityName}}”进行确认",
|
||||||
|
},
|
||||||
|
inputPlaceholder: {
|
||||||
|
type: String,
|
||||||
|
default: "{{entityName}}",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
"update:visible",
|
||||||
|
"confirm",
|
||||||
|
"cancel",
|
||||||
|
"visibleChange",
|
||||||
|
"openChange",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const confirmInput = ref("");
|
||||||
|
const confirmLoading = ref(false);
|
||||||
|
const modalVisible = ref(false);
|
||||||
|
const isMobile = ref(
|
||||||
|
typeof window !== "undefined" && window.innerWidth <= 768
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkIsMobile = () => {
|
||||||
|
isMobile.value = typeof window !== "undefined" && window.innerWidth <= 768;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isImportant = computed(() => {
|
||||||
|
return props.importance === "important";
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 桌面端 normal 用 Popconfirm;移动端 normal 用 Modal,避免小屏气泡难点、易溢出 */
|
||||||
|
const showPopconfirm = computed(() => {
|
||||||
|
return !isImportant.value && !isMobile.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalModalWidth = computed(() => {
|
||||||
|
if (isMobile.value) {
|
||||||
|
return "100%";
|
||||||
|
}
|
||||||
|
const w = props.normalWidth;
|
||||||
|
if (typeof w === "number") {
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
const n = parseInt(String(w), 10);
|
||||||
|
return Number.isFinite(n) ? n : 360;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isInputValid = computed(() => {
|
||||||
|
return confirmInput.value === props.entityName;
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputLabelText = computed(() => {
|
||||||
|
return `${props.inputLabel}`.replace("{{entityName}}", props.entityName);
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputPlaceholderText = computed(() => {
|
||||||
|
return `${props.inputPlaceholder}`.replace("{{entityName}}", props.entityName);
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalWidthStyle = computed(() => {
|
||||||
|
if (props.normalWidth) {
|
||||||
|
let maxWidth = props.normalWidth;
|
||||||
|
if (typeof maxWidth === "string") {
|
||||||
|
maxWidth = parseInt(maxWidth, 10);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
maxWidth: `${maxWidth}px`,
|
||||||
|
wordBreak: "break-word",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
wordBreak: "break-word",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 不设 min-width,避免气泡比触发器宽太多导致箭头与视觉中心错位;宽度由标题 maxWidth 约束 */
|
||||||
|
const popconfirmOverlayInnerStyle = computed(() => ({
|
||||||
|
maxWidth: "min(calc(100vw - 24px), 520px)",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const popconfirmOverlayStyle = computed(() => ({
|
||||||
|
maxWidth: "calc(100vw - 12px)",
|
||||||
|
}));
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(val) => {
|
||||||
|
if (val !== undefined) {
|
||||||
|
modalVisible.value = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkIsMobile();
|
||||||
|
window.addEventListener("resize", checkIsMobile);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener("resize", checkIsMobile);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(modalVisible, (val) => {
|
||||||
|
if (val === false) {
|
||||||
|
confirmInput.value = "";
|
||||||
|
}
|
||||||
|
emit("update:visible", val);
|
||||||
|
emit("visibleChange", val);
|
||||||
|
emit("openChange", val);
|
||||||
|
});
|
||||||
|
|
||||||
|
function isThenable(value) {
|
||||||
|
return value != null && typeof value.then === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确认完成后关闭弹层(Modal / Popconfirm 均依赖 modalVisible) */
|
||||||
|
function closeAfterConfirm() {
|
||||||
|
modalVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
modalVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 父组件常见写法:@confirm="() => deleteClick(record)"(返回 Promise)。
|
||||||
|
* - Popconfirm:须 return 该 Promise,内部 ActionButton 才会在异步结束后再 close。
|
||||||
|
* - Modal:OK 不会根据返回值关窗,须在 Promise resolve 后显式关 modalVisible。
|
||||||
|
*/
|
||||||
|
function handleSimpleConfirm(e) {
|
||||||
|
confirmLoading.value = true;
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = emit("confirm", e);
|
||||||
|
} catch {
|
||||||
|
confirmLoading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isThenable(result)) {
|
||||||
|
confirmLoading.value = false;
|
||||||
|
closeAfterConfirm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
.then(() => {
|
||||||
|
closeAfterConfirm();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
confirmLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
if (!isInputValid.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
confirmLoading.value = true;
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = emit("confirm");
|
||||||
|
} catch {
|
||||||
|
confirmLoading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isThenable(result)) {
|
||||||
|
confirmLoading.value = false;
|
||||||
|
closeAfterConfirm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
.then(() => {
|
||||||
|
closeAfterConfirm();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
confirmLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
confirmInput.value = "";
|
||||||
|
emit("cancel");
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.iview-confirm-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-confirm-trigger-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.normal-modal-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.normal-modal-icon {
|
||||||
|
color: #faad14;
|
||||||
|
font-size: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-confirm-modal:not(.normal-confirm-modal) :deep(.ant-modal-body) {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.normal-confirm-modal :deep(.ant-modal-body) {
|
||||||
|
padding: 0 24px 8px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-confirm-content {
|
||||||
|
padding-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-info {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-warning {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-guide {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-input {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式调整 */
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.delete-confirm-content {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-warning {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-modal-footer) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-modal-footer .ant-btn) {
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.important-modal:deep(.ant-modal),
|
||||||
|
.normal-confirm-modal:deep(.ant-modal) {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.important-modal:deep(.ant-modal-content),
|
||||||
|
.normal-confirm-modal:deep(.ant-modal-content) {
|
||||||
|
height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.important-modal:deep(.ant-modal-header),
|
||||||
|
.normal-confirm-modal:deep(.ant-modal-header) {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.important-modal:deep(.ant-modal-title),
|
||||||
|
.normal-confirm-modal:deep(.ant-modal-title) {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.important-modal:deep(.ant-modal-body) {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.normal-confirm-modal:deep(.ant-modal-body) {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.important-modal:deep(.ant-modal-close),
|
||||||
|
.normal-confirm-modal:deep(.ant-modal-close) {
|
||||||
|
top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.important-modal:deep(.ant-modal),
|
||||||
|
.normal-confirm-modal:deep(.ant-modal) {
|
||||||
|
animation: slide-up 0.3s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.important-modal:deep(.ant-modal-body) {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.important-modal:deep(.ant-modal-close) {
|
||||||
|
top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.important-modal:deep(.ant-modal) {
|
||||||
|
animation: slide-up 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.important-modal {
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
animation: warning-pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.important-modal .delete-warning {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.important-modal .confirm-input {
|
||||||
|
height: 44px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes warning-pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
transform: translateY(50px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
203
src/iview/IViewDescriptions.vue
Normal file
203
src/iview/IViewDescriptions.vue
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="items.length > 0">
|
||||||
|
<a-descriptions :column="descriptionColumn" :class="{ 'iview-descriptions--hidden-label': hiddenLabel }">
|
||||||
|
<a-descriptions-item v-for="(item, index) in items" :key="'descriptions-item-' + index"
|
||||||
|
:label="hiddenLabel ? '' : item.title" :span="item.span">
|
||||||
|
<div class="iview-descriptions-item-value-flex" :class="columnValueClass(item, recordData)">
|
||||||
|
<template v-if="$slots['bodyCell'] && item.bodySlot">
|
||||||
|
<slot name="bodyCell" :record="recordData" :column="item" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ formatValueCell(item, recordData) }}
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.iview-descriptions-item-value-flex {
|
||||||
|
width: 100%;
|
||||||
|
/* display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-descriptions--hidden-label :deep(.ant-descriptions-item-label) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-descriptions--hidden-label :deep(.ant-descriptions-item-content) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-descriptions .ant-descriptions-row:last-child>td) {
|
||||||
|
padding-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
|
||||||
|
/* 窄屏:标题与内容上下换行 */
|
||||||
|
:deep(.ant-descriptions-item-container) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-descriptions-item-label),
|
||||||
|
:deep(.ant-descriptions-item-content) {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-descriptions-item-label) {
|
||||||
|
white-space: normal;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onBeforeMount, onUnmounted } from "vue";
|
||||||
|
import { onAttachEvent, offAttachEvent } from "@/iview/utils/event";
|
||||||
|
import { callFunc } from "@/iview/utils/func";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
columnsList: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
hiddenLabel: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
columnSize: {
|
||||||
|
type: Number,
|
||||||
|
default: 2,
|
||||||
|
},
|
||||||
|
recordData: {
|
||||||
|
type: Object,
|
||||||
|
default: () => { },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isMobile = ref(false);
|
||||||
|
|
||||||
|
const items = computed(() => {
|
||||||
|
let columns = props.columnsList
|
||||||
|
.filter((column) => {
|
||||||
|
const key = column.dataIndex || column.key;
|
||||||
|
const isAction = key === "action";
|
||||||
|
const isShow = checkColumnShow(column);
|
||||||
|
return !!key && !isAction && isShow;
|
||||||
|
})
|
||||||
|
.map((column) => {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
span: getColumnSpan(column),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
columns.forEach((column, index) => {
|
||||||
|
column.sortIndex =
|
||||||
|
column.detailSortIndex === undefined ? index : column.detailSortIndex;
|
||||||
|
});
|
||||||
|
columns.sort((a, b) => {
|
||||||
|
return a.sortIndex - b.sortIndex;
|
||||||
|
});
|
||||||
|
let useSpace = 0;
|
||||||
|
columns.forEach((column, index) => {
|
||||||
|
let curSpace = descriptionColumn.value - useSpace;
|
||||||
|
let nextColumn = columns[index + 1];
|
||||||
|
if (nextColumn) {
|
||||||
|
if (curSpace - column.span < nextColumn.span) {
|
||||||
|
column.span = curSpace;
|
||||||
|
useSpace = 0;
|
||||||
|
} else {
|
||||||
|
useSpace += column.span;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
column.span = curSpace;
|
||||||
|
useSpace = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return columns;
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkColumnShow = (column) => {
|
||||||
|
if (column.detailNotShow === true) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const isHide = callFunc(["record"], [props.recordData], column.detailNotShow, false);
|
||||||
|
if (isHide) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return callFunc(["record"], [props.recordData], column.show, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const descriptionColumn = computed(() => {
|
||||||
|
return isMobile.value ? 1 : props.columnSize;
|
||||||
|
});
|
||||||
|
|
||||||
|
const columnValueClass = (column, record) => {
|
||||||
|
if (column.bodySlot) {
|
||||||
|
return column.className;
|
||||||
|
}
|
||||||
|
let value = formatValueCell(column, record);
|
||||||
|
if (
|
||||||
|
value === null ||
|
||||||
|
value === undefined ||
|
||||||
|
value === column.emptyText ||
|
||||||
|
value === ""
|
||||||
|
) {
|
||||||
|
return column.emptyClass || "no-data";
|
||||||
|
}
|
||||||
|
return column.className;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatValueCell = (column, record) => {
|
||||||
|
let key = column.dataIndex || column.key;
|
||||||
|
let text = record[key];
|
||||||
|
if (!column.formatMethod) {
|
||||||
|
if (text === null || text === undefined || text === "") {
|
||||||
|
return column.emptyText || "";
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = column.formatMethod({
|
||||||
|
text,
|
||||||
|
record,
|
||||||
|
column,
|
||||||
|
});
|
||||||
|
if (result === null || result === undefined || result === "") {
|
||||||
|
return column.emptyText || "";
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
return column.emptyText || "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColumnSpan = (column) => {
|
||||||
|
if (column.detailFull === true) {
|
||||||
|
return descriptionColumn.value;
|
||||||
|
}
|
||||||
|
return Math.min(column.span || 1, descriptionColumn.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkIsMobile = () => {
|
||||||
|
isMobile.value = window.innerWidth <= 768;
|
||||||
|
};
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
checkIsMobile();
|
||||||
|
onAttachEvent(window, "resize", checkIsMobile);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
offAttachEvent(window, "resize", checkIsMobile);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
194
src/iview/IViewDrawer.vue
Normal file
194
src/iview/IViewDrawer.vue
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<template>
|
||||||
|
<div class="iview-drawer-host">
|
||||||
|
<a-drawer
|
||||||
|
v-bind="drawerAttrs"
|
||||||
|
:root-class-name="drawerRootClassName"
|
||||||
|
:body-style="mergedBodyStyle"
|
||||||
|
:width="`${responsiveWidth - safetyWidth}px`"
|
||||||
|
:closable="true"
|
||||||
|
:push="false"
|
||||||
|
>
|
||||||
|
<div :style="slotWrapperStyle">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</a-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted, useAttrs } from "vue";
|
||||||
|
import { useUserStore } from "../store/user";
|
||||||
|
|
||||||
|
defineOptions({ inheritAttrs: false });
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
const MOBILE_BODY_PADDING_FALLBACK = "18px";
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const attrs = useAttrs();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
minWidth: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 768,
|
||||||
|
},
|
||||||
|
leftSafetyWidth: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 200,
|
||||||
|
},
|
||||||
|
collapsedLeftSafetyWidth: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 80,
|
||||||
|
},
|
||||||
|
widthPercent: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 100,
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
/** 为 true 时去掉内边距并让插槽区参与 flex 撑满(如 iframe 全屏嵌入) */
|
||||||
|
flushContent: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
/** 移动端抽屉内容区内边距(px 或 CSS 长度);flushContent 时为 0 */
|
||||||
|
mobileBodyPadding: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 18,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isMobile = ref(false);
|
||||||
|
|
||||||
|
function normalizeBodyPadding(value) {
|
||||||
|
if (value === undefined || value === null || value === "") return undefined;
|
||||||
|
return typeof value === "number" ? `${value}px` : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawerAttrs = computed(() => {
|
||||||
|
const {
|
||||||
|
"body-style": _bodyStyleKebab,
|
||||||
|
bodyStyle: _bodyStyleCamel,
|
||||||
|
"root-class-name": _rootClass,
|
||||||
|
rootClassName: _rootClassName,
|
||||||
|
width: _width,
|
||||||
|
closable: _closable,
|
||||||
|
push: _push,
|
||||||
|
...rest
|
||||||
|
} = attrs;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
|
||||||
|
const drawerRootClassName = computed(() => {
|
||||||
|
const extra = attrs["root-class-name"] || attrs.rootClassName || "";
|
||||||
|
const classes = ["iview-drawer-root"];
|
||||||
|
if (isMobile.value) classes.push("iview-drawer-root--mobile");
|
||||||
|
if (props.flushContent) classes.push("iview-drawer-root--flush");
|
||||||
|
if (extra) classes.push(String(extra));
|
||||||
|
return classes.join(" ");
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedBodyStyle = computed(() => {
|
||||||
|
const fromAttrs = attrs["body-style"] || attrs.bodyStyle || {};
|
||||||
|
if (props.flushContent) {
|
||||||
|
return { padding: 0, ...fromAttrs };
|
||||||
|
}
|
||||||
|
if (!isMobile.value) {
|
||||||
|
return fromAttrs;
|
||||||
|
}
|
||||||
|
const padding =
|
||||||
|
normalizeBodyPadding(props.mobileBodyPadding) ?? MOBILE_BODY_PADDING_FALLBACK;
|
||||||
|
return {
|
||||||
|
padding,
|
||||||
|
...fromAttrs,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const slotWrapperStyle = computed(() => {
|
||||||
|
if (props.flushContent) {
|
||||||
|
return {
|
||||||
|
padding: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { padding: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkIsMobile = () => {
|
||||||
|
isMobile.value = window.innerWidth <= MOBILE_BREAKPOINT;
|
||||||
|
};
|
||||||
|
|
||||||
|
const windowWidth = ref(window.innerWidth);
|
||||||
|
|
||||||
|
const safetyWidth = computed(() => {
|
||||||
|
if (isMobile.value) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return userStore.isCollapseSidebar
|
||||||
|
? props.collapsedLeftSafetyWidth
|
||||||
|
: props.leftSafetyWidth;
|
||||||
|
});
|
||||||
|
|
||||||
|
const responsiveWidth = computed(() => {
|
||||||
|
if (props.width !== undefined && props.width !== null) {
|
||||||
|
return props.width;
|
||||||
|
}
|
||||||
|
const minWidth = +props.minWidth || 0;
|
||||||
|
if (isMobile.value) {
|
||||||
|
return windowWidth.value;
|
||||||
|
}
|
||||||
|
const widthPercent = +props.widthPercent || 100;
|
||||||
|
const percentWidth = windowWidth.value * (widthPercent / 100);
|
||||||
|
if (percentWidth < minWidth) {
|
||||||
|
return minWidth;
|
||||||
|
}
|
||||||
|
return percentWidth;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
windowWidth.value = window.innerWidth;
|
||||||
|
checkIsMobile();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
handleResize();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Drawer 挂载到 body,需非 scoped 样式 + rootClassName */
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
/* body padding 由 :body-style / mobileBodyPadding 控制,勿在此写死 !important */
|
||||||
|
|
||||||
|
.iview-drawer-root--mobile.ant-drawer .ant-drawer-header {
|
||||||
|
padding: 10px 12px !important;
|
||||||
|
padding-right: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-drawer-root--mobile.ant-drawer .ant-drawer-header-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-drawer-root--mobile.ant-drawer .ant-drawer-title {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-drawer-root--flush.ant-drawer .ant-drawer-body {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
259
src/iview/IViewFilter.vue
Normal file
259
src/iview/IViewFilter.vue
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
<template>
|
||||||
|
<IViewForm
|
||||||
|
v-if="shouldRenderFilter"
|
||||||
|
layout="inline"
|
||||||
|
class="iview-filter-form"
|
||||||
|
v-model="filterData"
|
||||||
|
ref="formRef"
|
||||||
|
:config="filterFormConfig"
|
||||||
|
@submit="handleSearch"
|
||||||
|
@init="handleInit"
|
||||||
|
>
|
||||||
|
<template #bottom>
|
||||||
|
<!-- 操作按钮区域 -->
|
||||||
|
<div v-if="shouldShowButtons" class="iview-filter-action-buttons">
|
||||||
|
<a-button type="primary" @click="handleSearch">
|
||||||
|
<template #icon><search-outlined /></template>
|
||||||
|
{{ searchButtonText }}
|
||||||
|
</a-button>
|
||||||
|
<a-button @click="handleReset" style="margin-left: 8px">
|
||||||
|
<template #icon><reload-outlined /></template>
|
||||||
|
{{ resetButtonText }}
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
v-if="shouldShowExpandToggle"
|
||||||
|
type="link"
|
||||||
|
class="iview-filter-advanced-toggle"
|
||||||
|
@click="toggleAdvancedExpanded"
|
||||||
|
>
|
||||||
|
{{ expandToggleLabel }}
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</IViewForm>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
computed,
|
||||||
|
watchEffect,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
toRaw,
|
||||||
|
} from "vue";
|
||||||
|
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons-vue";
|
||||||
|
import IViewForm from "@/iview/IViewForm.vue";
|
||||||
|
import { toAny } from "@/iview/utils/toany";
|
||||||
|
import { callFunc } from "@/iview/utils/func";
|
||||||
|
import { onAttachEvent, offAttachEvent } from "@/iview/utils/event";
|
||||||
|
|
||||||
|
// 定义组件的props
|
||||||
|
const props = defineProps({
|
||||||
|
config: {
|
||||||
|
type: [Object, String],
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
|
searchButtonText: {
|
||||||
|
type: String,
|
||||||
|
default: "搜索",
|
||||||
|
},
|
||||||
|
resetButtonText: {
|
||||||
|
type: String,
|
||||||
|
default: "重置",
|
||||||
|
},
|
||||||
|
advancedSearchText: {
|
||||||
|
type: String,
|
||||||
|
default: "展开高级选项",
|
||||||
|
},
|
||||||
|
collapseAdvancedText: {
|
||||||
|
type: String,
|
||||||
|
default: "收起高级选项",
|
||||||
|
},
|
||||||
|
/** 移动端:折叠态下展开其余筛选项 */
|
||||||
|
mobileAdvancedSearchText: {
|
||||||
|
type: String,
|
||||||
|
default: "高级筛选",
|
||||||
|
},
|
||||||
|
mobileCollapseAdvancedText: {
|
||||||
|
type: String,
|
||||||
|
default: "收起筛选",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const showAdvancedExpanded = ref(false);
|
||||||
|
const isMobile = ref(false);
|
||||||
|
|
||||||
|
function checkIsMobile() {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
isMobile.value = window.innerWidth <= 768;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedFilterConfig = computed(() => toAny(props.config, { fields: [] }));
|
||||||
|
const allFields = computed(() => resolvedFilterConfig.value.fields || []);
|
||||||
|
const normalFields = computed(() => allFields.value.filter((f) => !f.advanced));
|
||||||
|
const advancedFields = computed(() => allFields.value.filter((f) => !!f.advanced));
|
||||||
|
const hasAnyFields = computed(() => allFields.value.length > 0);
|
||||||
|
const hasAdvancedFields = computed(() => advancedFields.value.length > 0);
|
||||||
|
const hasNormalFields = computed(() => normalFields.value.length > 0);
|
||||||
|
|
||||||
|
/** 移动端折叠态下是否存在「需收入高级筛选」的字段(非首项且 foldable !== false) */
|
||||||
|
const hasMobileFoldExtra = computed(() => {
|
||||||
|
if (!isMobile.value) return false;
|
||||||
|
return allFields.value.some((field, index) => {
|
||||||
|
const raw = toRaw(field);
|
||||||
|
if (index === 0) return false;
|
||||||
|
if (raw.foldable === false) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** PC:原「高级选项」开关;移动端:是否有折叠筛选项 */
|
||||||
|
const shouldShowExpandToggle = computed(() => {
|
||||||
|
if (isMobile.value) return hasMobileFoldExtra.value;
|
||||||
|
return hasAdvancedFields.value && hasNormalFields.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const expandToggleLabel = computed(() => {
|
||||||
|
if (showAdvancedExpanded.value) {
|
||||||
|
return isMobile.value ? props.mobileCollapseAdvancedText : props.collapseAdvancedText;
|
||||||
|
}
|
||||||
|
return isMobile.value ? props.mobileAdvancedSearchText : props.advancedSearchText;
|
||||||
|
});
|
||||||
|
const shouldRenderFilter = computed(() => shouldShowButtons.value);
|
||||||
|
const shouldShowButtons = computed(() => {
|
||||||
|
if (!hasAnyFields.value) return false;
|
||||||
|
if (hasNormalFields.value) return true;
|
||||||
|
if (isMobile.value) return true;
|
||||||
|
return showAdvancedExpanded.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const filterFormConfig = computed(() => {
|
||||||
|
const cfg = resolvedFilterConfig.value;
|
||||||
|
if (!cfg.fields || !cfg.fields.length) {
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
fields: cfg.fields.map((field, index) => {
|
||||||
|
const originalShow = field.show;
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
show(record) {
|
||||||
|
const baseVisible = callFunc(["record"], [record], originalShow, true);
|
||||||
|
if (!baseVisible) return false;
|
||||||
|
|
||||||
|
if (isMobile.value) {
|
||||||
|
if (!showAdvancedExpanded.value) {
|
||||||
|
const raw = toRaw(field);
|
||||||
|
const pinned = index === 0 || raw.foldable === false;
|
||||||
|
if (!pinned) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.advanced && !showAdvancedExpanded.value) return false;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (hasAdvancedFields.value && !hasNormalFields.value && !isMobile.value) {
|
||||||
|
showAdvancedExpanded.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkIsMobile();
|
||||||
|
onAttachEvent(window, "resize", checkIsMobile);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
offAttachEvent(window, "resize", checkIsMobile);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 定义事件
|
||||||
|
const emit = defineEmits(["update:modelValue", "init", "search", "reset"]);
|
||||||
|
|
||||||
|
const formRef = ref(null);
|
||||||
|
const filterData = ref(null);
|
||||||
|
|
||||||
|
function toggleAdvancedExpanded() {
|
||||||
|
showAdvancedExpanded.value = !showAdvancedExpanded.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInit = () => {
|
||||||
|
emit("update:modelValue", filterData.value);
|
||||||
|
emit("init");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
emit("update:modelValue", filterData.value);
|
||||||
|
emit("search");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (formRef.value) {
|
||||||
|
formRef.value.resetFormData();
|
||||||
|
emit("update:modelValue", filterData.value);
|
||||||
|
emit("reset");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => filterData.value,
|
||||||
|
(newVal, oldVal) => {
|
||||||
|
emit("update:modelValue", newVal);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.iview-filter-form {
|
||||||
|
padding: 30px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-filter-action-buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0 4px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-filter-advanced-toggle {
|
||||||
|
margin-left: 4px;
|
||||||
|
padding-left: 4px;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.iview-filter-form {
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-filter-action-buttons {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-filter-advanced-toggle {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
667
src/iview/IViewForm.vue
Normal file
667
src/iview/IViewForm.vue
Normal file
@@ -0,0 +1,667 @@
|
|||||||
|
<template>
|
||||||
|
<div class="iview-form-editor" v-if="formData && visibleFields.length > 0">
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:initialValues="{}"
|
||||||
|
:model="formData"
|
||||||
|
:label-col="computedLabelCol"
|
||||||
|
:wrapper-col="computedWrapperCol"
|
||||||
|
:layout="layoutResponsive"
|
||||||
|
>
|
||||||
|
<!-- 动态生成的表单字段 -->
|
||||||
|
<template v-for="field in visibleFields" :key="formatFieldName(field.name)">
|
||||||
|
<template v-if="field.type === 'title'">
|
||||||
|
<div class="form-title">{{ field.label }}</div>
|
||||||
|
</template>
|
||||||
|
<a-form-item
|
||||||
|
v-else-if="checkFieldSupport(field)"
|
||||||
|
:id="`form_item_${formatFieldName(field.name)}_field`"
|
||||||
|
:name="formatFieldName(field.name)"
|
||||||
|
:rules="getFieldRules(field)"
|
||||||
|
:style="getFieldStyleValue(field)"
|
||||||
|
:label="field.label"
|
||||||
|
>
|
||||||
|
<template v-if="getFieldErrorMsg(field)">
|
||||||
|
<a-alert type="error" :message="getFieldErrorMsg(field)" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<component
|
||||||
|
:is="resolveIViewFormFieldComponent(field.type)"
|
||||||
|
v-model="formData"
|
||||||
|
:ref="setRef(field.name)"
|
||||||
|
v-bind="getFieldControlBindings(field)"
|
||||||
|
@update:modelValue="delayUpdateModelValue(field)"
|
||||||
|
/>
|
||||||
|
<!-- 使用用户提供的插槽内容 -->
|
||||||
|
<template v-if="hasFormItemEndSoltByType(field.type)">
|
||||||
|
<slot :name="`form-item-end-${field.type}`"
|
||||||
|
v-bind="{
|
||||||
|
field,
|
||||||
|
readOnly: isFieldReadOnly(field),
|
||||||
|
formData: rawFormData,
|
||||||
|
update: updateFormData,
|
||||||
|
}"></slot>
|
||||||
|
</template>
|
||||||
|
<!-- 输入提示说明 -->
|
||||||
|
<IDisplayFormTips :field="field" />
|
||||||
|
</template>
|
||||||
|
</a-form-item>
|
||||||
|
<template v-else>
|
||||||
|
<div style="color: #f00; padding: 10px; border: 1px solid #f00; border-radius: 5px; margin-bottom:20px;">
|
||||||
|
<span>No support field type: {{ field.type }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-form>
|
||||||
|
<slot name="bottom"></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed, onMounted, onBeforeMount, onUnmounted, useSlots } from "vue";
|
||||||
|
import { useListRefs } from "@/iview/utils/useListRefs";
|
||||||
|
import { toAny } from "@/iview/utils/toany";
|
||||||
|
import { callFunc } from "@/iview/utils/func";
|
||||||
|
import { onAttachEvent, offAttachEvent } from "@/iview/utils/event";
|
||||||
|
import IDisplayFormTips from "@/iview/display/IDisplayFormTips.vue";
|
||||||
|
import { resolveIViewFormFieldComponent } from "@/iview/form/iview-form-field-registry";
|
||||||
|
|
||||||
|
const { setRef, getRef } = useListRefs();
|
||||||
|
const slots = useSlots();
|
||||||
|
|
||||||
|
// 生成随机字符串
|
||||||
|
const generateRandomString = () => {
|
||||||
|
return (
|
||||||
|
Math.random().toString(36).substring(2, 15) +
|
||||||
|
Math.random().toString(36).substring(2, 15)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
componentName: {
|
||||||
|
type: String,
|
||||||
|
default: "IViewForm",
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
type: [Object, String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: [Object, String],
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
modelFormat: {
|
||||||
|
type: String,
|
||||||
|
default: "object",
|
||||||
|
validator(value) {
|
||||||
|
return ["json", "object"].includes(value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
readOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
labelCol: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
wrapperCol: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
type: String,
|
||||||
|
default: "vertical",
|
||||||
|
validator(value) {
|
||||||
|
return ["vertical", "horizontal", "inline"].includes(value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
disableResponsive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 与历史模板一致:不向 checkbox / radio 透传 placeholder,避免非 props 警告 */
|
||||||
|
const fieldTypesWithoutPlaceholder = new Set(["checkbox", "radio"]);
|
||||||
|
// 表单实例
|
||||||
|
const formRef = ref(null);
|
||||||
|
// 表单数据
|
||||||
|
const formData = ref(null);
|
||||||
|
// 表单id前缀
|
||||||
|
const idPrefix = ref(generateRandomString());
|
||||||
|
// 表单事件
|
||||||
|
const emit = defineEmits(["update:modelValue", "init", "onChange"]);
|
||||||
|
// 移动设备标志
|
||||||
|
const isMobile = ref(false);
|
||||||
|
// 检测设备类型
|
||||||
|
const checkIsMobile = () => {
|
||||||
|
isMobile.value = window.innerWidth <= 768;
|
||||||
|
};
|
||||||
|
// 初始化时立即检测,避免初始渲染时判断错误
|
||||||
|
checkIsMobile();
|
||||||
|
|
||||||
|
const layoutResponsive = computed(() => {
|
||||||
|
// 如果禁用了响应式,或者布局是 inline,则始终使用用户设置的 layout
|
||||||
|
if (props.disableResponsive || props.layout === "inline") {
|
||||||
|
return props.layout;
|
||||||
|
}
|
||||||
|
// 在移动设备上,非 inline 布局强制使用 vertical
|
||||||
|
if (isMobile.value) {
|
||||||
|
return "vertical";
|
||||||
|
}
|
||||||
|
return props.layout;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 在 inline 布局时,label-col 和 wrapper-col 应该为空,否则会影响布局
|
||||||
|
const computedLabelCol = computed(() => {
|
||||||
|
if (layoutResponsive.value === "inline") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return props.labelCol;
|
||||||
|
});
|
||||||
|
|
||||||
|
const computedWrapperCol = computed(() => {
|
||||||
|
if (layoutResponsive.value === "inline") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return props.wrapperCol;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasFormItemEndSoltByType = (type) => {
|
||||||
|
return !!slots[`form-item-end-${type}`];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFieldData = (obj) => {
|
||||||
|
let newData = {};
|
||||||
|
Object.keys(obj).forEach((key) => {
|
||||||
|
newData[formatFieldName(key)] = obj[key];
|
||||||
|
});
|
||||||
|
return newData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const unformatFieldData = (obj) => {
|
||||||
|
let newData = {};
|
||||||
|
Object.keys(obj || {}).forEach((key) => {
|
||||||
|
newData[unformatFieldName(key)] = obj[key];
|
||||||
|
});
|
||||||
|
return newData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const allFields = computed(() => {
|
||||||
|
if (!formConfig.value || !formConfig.value.fields) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return formConfig.value.fields || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleFields = computed(() => {
|
||||||
|
if (!formConfig.value || !formConfig.value.fields) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return (formConfig.value.fields || []).filter((field) => {
|
||||||
|
return checkFieldShow(field);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const extrasFields = computed(() => {
|
||||||
|
if (!formConfig.value || !formConfig.value.extrasFields) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return formConfig.value.extrasFields || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const valueFormatValue = function (value) {
|
||||||
|
if (formConfig.value && formConfig.value.formatValue) {
|
||||||
|
value = callFunc(["record"], [value], formConfig.value.formatValue, value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const valueUnformatValue = function (value) {
|
||||||
|
if (formConfig.value && formConfig.value.unformatValue) {
|
||||||
|
value = callFunc(["record"], [value], formConfig.value.unformatValue, value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFieldReadOnly = function (field) {
|
||||||
|
if (field.disabled && typeof field.disabled === "function") {
|
||||||
|
return !!callFunc(["record"], [rawFormData.value], field.disabled, false);
|
||||||
|
}
|
||||||
|
if (field.readOnly && typeof field.readOnly === "function") {
|
||||||
|
return !!callFunc(["record"], [rawFormData.value], field.readOnly, false);
|
||||||
|
}
|
||||||
|
return props.readOnly || field.disabled;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawFormData = computed(() => {
|
||||||
|
return unformatFieldData(formData.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const formConfig = computed(() => {
|
||||||
|
return toAny(props.config, { fields: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultFormData = computed(() => {
|
||||||
|
let defData = {
|
||||||
|
_uuid_: generateRandomString(),
|
||||||
|
};
|
||||||
|
if (!formConfig.value || !formConfig.value.fields) {
|
||||||
|
return defData;
|
||||||
|
}
|
||||||
|
formConfig.value.fields.forEach((field) => {
|
||||||
|
if (field.defaultValue !== undefined) {
|
||||||
|
defData[field.name] = field.defaultValue;
|
||||||
|
} else {
|
||||||
|
switch (field.type) {
|
||||||
|
case "checkbox":
|
||||||
|
defData[field.name] = [];
|
||||||
|
break;
|
||||||
|
case "boolean":
|
||||||
|
if (field.falseValue !== undefined) {
|
||||||
|
defData[field.name] = field.falseValue;
|
||||||
|
} else {
|
||||||
|
defData[field.name] = false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "image":
|
||||||
|
case "file":
|
||||||
|
case "fileBase64":
|
||||||
|
case "imageBase64":
|
||||||
|
case "signatureImage":
|
||||||
|
defData[field.name] = "";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
defData[field.name] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return defData;
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkFieldShow = (field) => {
|
||||||
|
return callFunc(["record"], [rawFormData.value], field.show, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFieldErrorMsg = (field) => {
|
||||||
|
return callFunc(["record"], [rawFormData.value], field.errorMsg, null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFormData = (data, field) => {
|
||||||
|
let formatData = formatFieldData(data || {});
|
||||||
|
Object.assign(formData.value, formatData);
|
||||||
|
delayUpdateModelValue(field);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateModelValueCount = ref(0);
|
||||||
|
|
||||||
|
const delayUpdateModelValue = (field) => {
|
||||||
|
try {
|
||||||
|
updateModelValueCount.value++;
|
||||||
|
const formValues = unformatFieldData(formData.value);
|
||||||
|
fullFieldName.value.forEach((name) => {
|
||||||
|
let defaultValue = defaultFormData.value[name];
|
||||||
|
if (formValues[name] === undefined) {
|
||||||
|
formValues[name] = defaultValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
visibleFields.value.forEach((field) => {
|
||||||
|
if (field.unformatValue && typeof field.unformatValue === "function") {
|
||||||
|
Object.assign(formValues, field.unformatValue(formValues[field.name]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let newValue = undefined;
|
||||||
|
if (props.modelFormat === "json") {
|
||||||
|
newValue = JSON.stringify(formValues);
|
||||||
|
} else {
|
||||||
|
newValue = formValues;
|
||||||
|
}
|
||||||
|
let convertedNewValue = valueFormatValue(newValue);
|
||||||
|
emit("update:modelValue", convertedNewValue);
|
||||||
|
if (!field) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callFieldOnChanged(field, convertedNewValue);
|
||||||
|
callFieldValidate(field);
|
||||||
|
} finally {
|
||||||
|
updateModelValueCount.value--;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const callFieldOnChanged = (field, convertedNewValue) => {
|
||||||
|
field.onChanged && field.onChanged(convertedNewValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const callFieldValidate = (field) => {
|
||||||
|
if (field.ignoreValidate === true) {
|
||||||
|
// 忽略验证:先尝试验证,如果验证通过,则调用表单验证,否则不调用表单验证
|
||||||
|
let validate = getFieldValidate(field);
|
||||||
|
let value = formData.value[formatFieldName(field.name)];
|
||||||
|
validate(value, true)
|
||||||
|
.then(() => {
|
||||||
|
if (field.ignoreValueChangedValidate !== true) {
|
||||||
|
validateField(field);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((_) => {});
|
||||||
|
} else {
|
||||||
|
// 正常验证表单
|
||||||
|
validateField(field).catch((_) => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fullFieldName = computed(() => {
|
||||||
|
let newFieldName = [];
|
||||||
|
(allFields.value || []).forEach((field) => {
|
||||||
|
if (field.type !== "title") {
|
||||||
|
newFieldName.push(field.name);
|
||||||
|
} else {
|
||||||
|
let fields = [field.name, ...(field.extraFields || [])];
|
||||||
|
newFieldName.push(...fields);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(extrasFields.value || []).forEach((name) => {
|
||||||
|
newFieldName.push(name);
|
||||||
|
});
|
||||||
|
return newFieldName;
|
||||||
|
});
|
||||||
|
|
||||||
|
const delayInitFormDataTimeout = ref(null);
|
||||||
|
|
||||||
|
const delayInitFormData = (delayTime = 100) => {
|
||||||
|
if (!!delayInitFormDataTimeout.value) {
|
||||||
|
clearTimeout(delayInitFormDataTimeout.value);
|
||||||
|
delayInitFormDataTimeout.value = null;
|
||||||
|
}
|
||||||
|
delayInitFormDataTimeout.value = setTimeout(() => {
|
||||||
|
delayInitFormDataTimeout.value = null;
|
||||||
|
initFormData();
|
||||||
|
}, delayTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const initModelValueFlag = ref(false);
|
||||||
|
|
||||||
|
const initFormData = () => {
|
||||||
|
if (updateModelValueCount.value > 0) {
|
||||||
|
delayInitFormData(200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (initModelValueFlag.value) {
|
||||||
|
initModelValueFlag.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let newFormData = {};
|
||||||
|
let unconvertedModelValue = valueUnformatValue(props.modelValue);
|
||||||
|
let decodedModelValue = toAny(unconvertedModelValue, {});
|
||||||
|
fullFieldName.value.forEach((name) => {
|
||||||
|
let defaultValue = defaultFormData.value[name];
|
||||||
|
if (decodedModelValue[name] === undefined) {
|
||||||
|
newFormData[name] = defaultValue;
|
||||||
|
} else {
|
||||||
|
newFormData[name] = decodedModelValue[name];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
formData.value = formatFieldData(newFormData);
|
||||||
|
initModelValueFlag.value = true;
|
||||||
|
delayUpdateModelValue(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFieldName = (name) => {
|
||||||
|
return idPrefix.value + "-" + name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const unformatFieldName = (name) => {
|
||||||
|
return name.replace(idPrefix.value + "-", "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInputTypeText = (type) => {
|
||||||
|
switch (type) {
|
||||||
|
case "text":
|
||||||
|
return "输入";
|
||||||
|
case "textarea":
|
||||||
|
return "输入";
|
||||||
|
case "json":
|
||||||
|
return "输入";
|
||||||
|
case "json-schema":
|
||||||
|
return "输入";
|
||||||
|
case "markdown":
|
||||||
|
return "输入";
|
||||||
|
case "dataset":
|
||||||
|
return "输入";
|
||||||
|
case "javascript":
|
||||||
|
return "输入";
|
||||||
|
case "number":
|
||||||
|
return "输入";
|
||||||
|
case "radio":
|
||||||
|
return "选择";
|
||||||
|
case "checkbox":
|
||||||
|
return "选择";
|
||||||
|
case "select":
|
||||||
|
return "选择";
|
||||||
|
case "date":
|
||||||
|
case "datetime":
|
||||||
|
return "选择";
|
||||||
|
case "time":
|
||||||
|
return "选择";
|
||||||
|
case "image":
|
||||||
|
return "上传";
|
||||||
|
case "file":
|
||||||
|
return "上传";
|
||||||
|
case "fileBase64":
|
||||||
|
return "输入或上传";
|
||||||
|
case "imageBase64":
|
||||||
|
return "选择";
|
||||||
|
case "signatureImage":
|
||||||
|
return "签名或上传";
|
||||||
|
default:
|
||||||
|
return "填写";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlaceholder = (field) => {
|
||||||
|
if (field.placeholder) {
|
||||||
|
return field.placeholder;
|
||||||
|
}
|
||||||
|
return `请${getInputTypeText(field.type)}${field.label || ""}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkFieldSupport = (field) => {
|
||||||
|
return !!resolveIViewFormFieldComponent(field.type);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFieldStyleValue = (field) => {
|
||||||
|
if (!isMobile.value) {
|
||||||
|
return field.styleValue;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
field.styleValue,
|
||||||
|
{
|
||||||
|
width: "100%",
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
function getFieldControlBindings(field) {
|
||||||
|
const bindings = {
|
||||||
|
field,
|
||||||
|
fieldPrefix: idPrefix.value,
|
||||||
|
readOnly: isFieldReadOnly(field),
|
||||||
|
rawFormData: rawFormData.value,
|
||||||
|
};
|
||||||
|
if (!fieldTypesWithoutPlaceholder.has(field.type)) {
|
||||||
|
bindings.placeholder = getPlaceholder(field);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
field.type === "image" ||
|
||||||
|
field.type === "video" ||
|
||||||
|
field.type === "file" ||
|
||||||
|
field.type === "imageBase64"
|
||||||
|
) {
|
||||||
|
bindings.multipleSelect = field.fileCount > 1;
|
||||||
|
}
|
||||||
|
return bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFieldValidate = (field) => {
|
||||||
|
return (value, ignoreValidate = false) => {
|
||||||
|
if (field.validate && typeof field.validate === "function") {
|
||||||
|
return Promise.resolve()
|
||||||
|
.then(() => {
|
||||||
|
if (ignoreValidate) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return field.validate(value);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
return Promise.reject(new Error(err.message));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let fieldRef = getRef(field.name);
|
||||||
|
if (fieldRef && fieldRef.validate) {
|
||||||
|
return Promise.resolve()
|
||||||
|
.then(() => {
|
||||||
|
return fieldRef.validate(value, ignoreValidate);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
return Promise.reject(new Error(err.message));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let isEmpty = value === undefined || value === null || value === "";
|
||||||
|
if (value instanceof Array && value.length <= 0) {
|
||||||
|
isEmpty = true;
|
||||||
|
}
|
||||||
|
if (isEmpty) {
|
||||||
|
return Promise.reject(new Error(getPlaceholder(field)));
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFieldRules = (field) => {
|
||||||
|
const rules = [];
|
||||||
|
let isRequired = field.required;
|
||||||
|
if (isRequired) {
|
||||||
|
rules.push({
|
||||||
|
required: true,
|
||||||
|
validator: (rule, value) => {
|
||||||
|
let validate = getFieldValidate(field);
|
||||||
|
return validate(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let isText = ["text", "textarea", "fileBase64"].includes(field.type);
|
||||||
|
if (isText && field.textLenMax > 0) {
|
||||||
|
rules.push({
|
||||||
|
max: field.textLenMax,
|
||||||
|
message: `${field.label}最多${field.textLenMax}个字符`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (field.type === "number") {
|
||||||
|
if (field.numMin !== null && field.numMin !== undefined) {
|
||||||
|
rules.push({
|
||||||
|
type: "number",
|
||||||
|
min: field.numMin,
|
||||||
|
message: `${field.label}不能小于${field.numMin}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (field.numMax !== null && field.numMax !== undefined) {
|
||||||
|
rules.push({
|
||||||
|
type: "number",
|
||||||
|
max: field.numMax,
|
||||||
|
message: `${field.label}不能大于${field.numMax}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rules;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFormData = () => {
|
||||||
|
formData.value = formatFieldData(defaultFormData.value);
|
||||||
|
delayUpdateModelValue(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateField = async (field) => {
|
||||||
|
if (!formRef.value) return;
|
||||||
|
return await formRef.value.validateFields([formatFieldName(field.name)]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validate = async () => {
|
||||||
|
if (!formRef.value) return;
|
||||||
|
return await formRef.value.validateFields(
|
||||||
|
visibleFields.value.map((f) => {
|
||||||
|
return formatFieldName(f.name);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.config,
|
||||||
|
() => {
|
||||||
|
initFormData();
|
||||||
|
emit("init");
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newVal, oldVal) => {
|
||||||
|
initFormData();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
checkIsMobile();
|
||||||
|
onAttachEvent(window, "resize", checkIsMobile);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
offAttachEvent(window, "resize", checkIsMobile);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initFormData(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
validate,
|
||||||
|
validateField,
|
||||||
|
resetFormData,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.iview-form-editor {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-title {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #1890ff;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
margin-top: 32px;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-form-inline) {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
:deep(.ant-form-inline .ant-form-item) {
|
||||||
|
width: 100%;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
354
src/iview/IViewInlineEditPopover.vue
Normal file
354
src/iview/IViewInlineEditPopover.vue
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
<template>
|
||||||
|
<a-popover
|
||||||
|
v-if="!isMobile"
|
||||||
|
trigger="click"
|
||||||
|
placement="bottomLeft"
|
||||||
|
:open="open"
|
||||||
|
@openChange="handleOpenChange"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<div class="inline-edit-popover" @click.stop>
|
||||||
|
<div class="inline-edit-popover__title">{{ title }}</div>
|
||||||
|
<div class="inline-edit-popover__field">
|
||||||
|
<a-input-number
|
||||||
|
v-if="inputType === 'number'"
|
||||||
|
v-model:value="draftValue"
|
||||||
|
:min="min"
|
||||||
|
:precision="numberInputPrecision"
|
||||||
|
:style="fieldWidthStyle"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
@pressEnter="submit"
|
||||||
|
/>
|
||||||
|
<a-textarea
|
||||||
|
v-else-if="inputType === 'textarea'"
|
||||||
|
v-model:value="draftValue"
|
||||||
|
:rows="rows"
|
||||||
|
:style="fieldWidthStyle"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
/>
|
||||||
|
<a-input
|
||||||
|
v-else
|
||||||
|
v-model:value="draftValue"
|
||||||
|
:style="fieldWidthStyle"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
@pressEnter="submit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="inline-edit-popover__actions">
|
||||||
|
<a-button size="small" @click="cancel">取消</a-button>
|
||||||
|
<a-button size="small" type="primary" :loading="loading" @click="submit">
|
||||||
|
保存
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="inline-edit-trigger">
|
||||||
|
<div class="inline-edit-trigger__content">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<EditOutlined class="inline-edit-trigger__icon" />
|
||||||
|
</div>
|
||||||
|
</a-popover>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="inline-edit-trigger inline-edit-trigger--mobile" @click.stop="openMobileEditor">
|
||||||
|
<div class="inline-edit-trigger__content">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-modal
|
||||||
|
v-model:open="open"
|
||||||
|
:title="title"
|
||||||
|
:destroy-on-close="true"
|
||||||
|
:mask-closable="true"
|
||||||
|
:keyboard="true"
|
||||||
|
centered
|
||||||
|
:width="mobileModalWidth"
|
||||||
|
wrap-class-name="iview-inline-edit-modal-wrap"
|
||||||
|
@cancel="cancel"
|
||||||
|
>
|
||||||
|
<div class="inline-edit-modal-body" @click.stop>
|
||||||
|
<div class="inline-edit-popover__field">
|
||||||
|
<a-input-number
|
||||||
|
v-if="inputType === 'number'"
|
||||||
|
v-model:value="draftValue"
|
||||||
|
:min="min"
|
||||||
|
:precision="numberInputPrecision"
|
||||||
|
:style="fieldWidthStyle"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
@pressEnter="submit"
|
||||||
|
/>
|
||||||
|
<a-textarea
|
||||||
|
v-else-if="inputType === 'textarea'"
|
||||||
|
v-model:value="draftValue"
|
||||||
|
:rows="rows"
|
||||||
|
:style="fieldWidthStyle"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
/>
|
||||||
|
<a-input
|
||||||
|
v-else
|
||||||
|
v-model:value="draftValue"
|
||||||
|
:style="fieldWidthStyle"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
@pressEnter="submit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<div class="inline-edit-modal-footer">
|
||||||
|
<a-button @click="cancel">取消</a-button>
|
||||||
|
<a-button type="primary" :loading="loading" @click="submit">
|
||||||
|
保存
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed, onMounted, onUnmounted } from "vue";
|
||||||
|
import { EditOutlined } from "@ant-design/icons-vue";
|
||||||
|
import { emit } from "@/iview/utils/emitter";
|
||||||
|
import { onAttachEvent, offAttachEvent } from "@/iview/utils/event";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: "编辑",
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
type: [String, Number, null],
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
inputType: {
|
||||||
|
type: String,
|
||||||
|
default: "text", // text | number | textarea
|
||||||
|
},
|
||||||
|
/** inputType 为 textarea 时的行数 */
|
||||||
|
rows: {
|
||||||
|
type: Number,
|
||||||
|
default: 2,
|
||||||
|
},
|
||||||
|
min: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
precision: {
|
||||||
|
type: Number,
|
||||||
|
default: 2,
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
inputWidth: {
|
||||||
|
type: String,
|
||||||
|
default: "220px",
|
||||||
|
},
|
||||||
|
save: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
/** 可选:用于自动同步 ILayoutPage 列表行 & 详情页 */
|
||||||
|
rowKeyValue: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
/** 可选:用于自动生成 patch 的字段名 */
|
||||||
|
patchField: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
/** 可选:ILayoutPage 的 scope(pageRef.getListViewScope()) */
|
||||||
|
scope: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
/** 保存成功后是否触发 ILayoutPage 重新加载详情页 */
|
||||||
|
reloadDetail: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
/** 保存成功后是否触发 ILayoutPage 重新加载 info 页 */
|
||||||
|
reloadInfo: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mobileModalWidth = "min(calc(100vw - 50px), 400px)";
|
||||||
|
|
||||||
|
function toEditableNumberDraft(value) {
|
||||||
|
if (value == null || value === "") return null;
|
||||||
|
const num = Number(value);
|
||||||
|
if (Number.isNaN(num)) return null;
|
||||||
|
if (num === 0) return null;
|
||||||
|
return parseFloat(String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDraftValue(value, inputType) {
|
||||||
|
if (inputType === "number") return toEditableNumberDraft(value);
|
||||||
|
return value ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSubmittedValue(value, inputType, precision) {
|
||||||
|
if (inputType !== "number") return value;
|
||||||
|
if (value == null || value === "") return value;
|
||||||
|
const num = Number(value);
|
||||||
|
if (Number.isNaN(num)) return value;
|
||||||
|
if (precision != null && precision >= 0) {
|
||||||
|
return Number(num.toFixed(precision));
|
||||||
|
}
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const draftValue = ref(toDraftValue(props.value, props.inputType));
|
||||||
|
const isMobile = ref(false);
|
||||||
|
|
||||||
|
const fieldWidthStyle = computed(() => ({
|
||||||
|
width: isMobile.value ? "100%" : props.inputWidth,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** 编辑中小数不强制补 0,整数仍限制为整型 */
|
||||||
|
const numberInputPrecision = computed(() => {
|
||||||
|
if (props.inputType !== "number") return undefined;
|
||||||
|
return props.precision === 0 ? 0 : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
function syncDraftFromProps() {
|
||||||
|
draftValue.value = toDraftValue(props.value, props.inputType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkIsMobile() {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
isMobile.value = window.innerWidth <= 768;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.value,
|
||||||
|
(v) => {
|
||||||
|
if (!open.value) {
|
||||||
|
draftValue.value = toDraftValue(v, props.inputType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleOpenChange(next) {
|
||||||
|
open.value = next === true;
|
||||||
|
if (open.value) {
|
||||||
|
syncDraftFromProps();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMobileEditor() {
|
||||||
|
open.value = true;
|
||||||
|
syncDraftFromProps();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
open.value = false;
|
||||||
|
syncDraftFromProps();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
loading.value = true;
|
||||||
|
const submittedValue = normalizeSubmittedValue(
|
||||||
|
draftValue.value,
|
||||||
|
props.inputType,
|
||||||
|
props.precision,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await props.save(submittedValue);
|
||||||
|
if (props.rowKeyValue != null && props.patchField) {
|
||||||
|
emit("iview:list-row-patch", {
|
||||||
|
rowKeyValue: props.rowKeyValue,
|
||||||
|
patch: { [props.patchField]: submittedValue },
|
||||||
|
scope: props.scope || undefined,
|
||||||
|
reloadDetail: props.reloadDetail === true,
|
||||||
|
reloadInfo: props.reloadInfo !== false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
open.value = false;
|
||||||
|
} catch {
|
||||||
|
// save 失败时应 reject;此处吞掉避免控制台未处理 Promise,弹窗保持打开
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkIsMobile();
|
||||||
|
onAttachEvent(window, "resize", checkIsMobile);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
offAttachEvent(window, "resize", checkIsMobile);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.inline-edit-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-trigger--mobile {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-trigger__content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-trigger__icon {
|
||||||
|
color: rgba(0, 0, 0, 0.35);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.18s ease, color 0.18s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-trigger:hover .inline-edit-trigger__icon {
|
||||||
|
opacity: 1;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-popover {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-popover__title {
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-modal-body {
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-popover__actions,
|
||||||
|
.inline-edit-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-modal-footer {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.iview-inline-edit-modal-wrap .ant-modal-body {
|
||||||
|
padding-top: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
161
src/iview/IViewMenu.vue
Normal file
161
src/iview/IViewMenu.vue
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<template>
|
||||||
|
<div class="iview-menu" :class="{ collapsed: collapsed }" :style="menuStyle">
|
||||||
|
<div
|
||||||
|
v-for="item in menuConfig"
|
||||||
|
:key="item.key"
|
||||||
|
class="iview-menu-item"
|
||||||
|
:class="{ selected: isSelected(item.key) }"
|
||||||
|
@click="handleItemClick(item)"
|
||||||
|
>
|
||||||
|
<div class="iview-menu-item-icon">
|
||||||
|
<component :is="item.icon" />
|
||||||
|
</div>
|
||||||
|
<span v-if="!collapsed" class="iview-menu-item-title">{{ item.title }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
collapsed: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
menuConfig: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
bgColor: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
textColor: {
|
||||||
|
type: String,
|
||||||
|
default: "#333333",
|
||||||
|
},
|
||||||
|
selectedTextColor: {
|
||||||
|
type: String,
|
||||||
|
default: "#333333",
|
||||||
|
},
|
||||||
|
selectedBgColor: {
|
||||||
|
type: String,
|
||||||
|
default: "#f5f5f5",
|
||||||
|
},
|
||||||
|
hoverTextColor: {
|
||||||
|
type: String,
|
||||||
|
default: "#333333",
|
||||||
|
},
|
||||||
|
hoverBgColor: {
|
||||||
|
type: String,
|
||||||
|
default: "#f5f5f5",
|
||||||
|
},
|
||||||
|
selectedKeys: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["item-click", "update:selectedKeys"]);
|
||||||
|
|
||||||
|
// 计算菜单样式
|
||||||
|
const menuStyle = computed(() => {
|
||||||
|
return {
|
||||||
|
backgroundColor: props.bgColor,
|
||||||
|
color: props.textColor,
|
||||||
|
"--menu-text-color": props.textColor,
|
||||||
|
"--menu-selected-text-color": props.selectedTextColor,
|
||||||
|
"--menu-selected-bg-color": props.selectedBgColor,
|
||||||
|
"--menu-hover-text-color": props.hoverTextColor,
|
||||||
|
"--menu-hover-bg-color": props.hoverBgColor,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 判断菜单项是否被选中
|
||||||
|
function isSelected(key) {
|
||||||
|
return props.selectedKeys.includes(key) || props.selectedKeys.includes(`item-${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理菜单项点击
|
||||||
|
function handleItemClick(item) {
|
||||||
|
emit("item-click", item);
|
||||||
|
// 更新选中状态
|
||||||
|
const newSelectedKeys = [item.key];
|
||||||
|
emit("update:selectedKeys", newSelectedKeys);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.iview-menu {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 12px 12px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-menu.collapsed {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
color: var(--menu-text-color);
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 5px;
|
||||||
|
// padding-left: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-menu-item:hover {
|
||||||
|
background-color: var(--menu-hover-bg-color);
|
||||||
|
color: var(--menu-hover-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-menu-item.selected {
|
||||||
|
background-color: var(--menu-selected-bg-color);
|
||||||
|
color: var(--menu-selected-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-menu-item-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-menu-item-title {
|
||||||
|
margin-left: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-menu.collapsed .iview-menu-item {
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-menu.collapsed .iview-menu-item-title {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-menu.collapsed {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
9
src/iview/IViewTabPane.vue
Normal file
9
src/iview/IViewTabPane.vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<template>
|
||||||
|
<a-tab-pane v-bind="$attrs">
|
||||||
|
<slot />
|
||||||
|
</a-tab-pane>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineOptions({ inheritAttrs: false });
|
||||||
|
</script>
|
||||||
987
src/iview/IViewTable.vue
Normal file
987
src/iview/IViewTable.vue
Normal file
@@ -0,0 +1,987 @@
|
|||||||
|
<template>
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<div class="iview-table-root" :class="{ 'iview-table-root--custom-divider': hasCustomDivider }"
|
||||||
|
:style="dividerStyleVar">
|
||||||
|
<div v-if="!isMobile" ref="tablePcRef" class="iview-table-pc">
|
||||||
|
<!-- PC端表头过滤器 -->
|
||||||
|
<div v-if="showFilter && filterableColumns.length > 0" class="iview-table-pc-filter">
|
||||||
|
<a-popover v-model:open="columnsPopoverVisible" trigger="click" placement="bottomRight"
|
||||||
|
overlay-class-name="iview-table-columns-popover">
|
||||||
|
<template #content>
|
||||||
|
<div class="iview-table-columns-popover-content">
|
||||||
|
<a-checkbox v-for="col in filterableColumns" :key="col.dataIndex || col.key"
|
||||||
|
v-model:checked="visibleColumns[col.dataIndex || col.key]" @change="updateVisibleColumns">
|
||||||
|
{{ col.title }}
|
||||||
|
</a-checkbox>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<a-button>
|
||||||
|
列显示设置 <down-outlined />
|
||||||
|
</a-button>
|
||||||
|
</a-popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PC端表格 -->
|
||||||
|
<div v-if="hasHorizontalScroll" class="iview-table-drag-scroll iview-table-drag-scroll-top"
|
||||||
|
:class="{ 'is-dragging': dragScrollState.isDragging }" @mousedown="handleDragScrollStart"
|
||||||
|
@dblclick="handleDragScrollDoubleClick" />
|
||||||
|
<a-table v-bind="$attrs" :columns="filteredColumns" :dataSource="dataSource" :pagination="false"
|
||||||
|
:scroll="{ x: columnsWidth }" @change="handleTableChange" @resizeColumn="handleResizeColumn">
|
||||||
|
<template #emptyText>
|
||||||
|
<div class="iview-table-empty" :class="{ 'iview-table-empty--custom-image': hasEmptyImageSlot }"
|
||||||
|
@click="emptyClick">
|
||||||
|
<a-empty v-if="showEmptyImage" :description="emptyText" v-bind="emptyAEmptyImageAttrs">
|
||||||
|
<template v-if="hasEmptyImageSlot" #image>
|
||||||
|
<div class="iview-table-empty-image">
|
||||||
|
<slot name="emptyImage" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-empty>
|
||||||
|
<span v-else class="iview-table-empty__text">{{ emptyText }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #bodyCell="{ text, column, record, index }">
|
||||||
|
<div class="iview-table-card-item-value-flex-table">
|
||||||
|
<template v-if="column.bodySlot">
|
||||||
|
<slot name="bodyCell" v-bind="{
|
||||||
|
text,
|
||||||
|
column,
|
||||||
|
record,
|
||||||
|
index,
|
||||||
|
mobile: isMobile,
|
||||||
|
}" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ formatValueCell(column, record) }}
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-if="$slots.expandedRowRender" #expandedRowRender="{ record, index, indent, expanded }">
|
||||||
|
<slot name="expandedRowRender" :record="record" :index="index" :indent="indent" :expanded="expanded"
|
||||||
|
:mobile="isMobile" />
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
<div v-if="hasHorizontalScroll" class="iview-table-drag-scroll iview-table-drag-scroll-bottom"
|
||||||
|
:class="{ 'is-dragging': dragScrollState.isDragging }" @mousedown="handleDragScrollStart"
|
||||||
|
@dblclick="handleDragScrollDoubleClick" />
|
||||||
|
<div v-if="pagination !== false" class="iview-table-pc-pagination">
|
||||||
|
<a-pagination v-bind="paginationBindProps" @change="handlePageChange" @showSizeChange="handleSizeChange" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="iview-table-pc-no-pagination">
|
||||||
|
<slot name="noPagination" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 移动端显示卡片布局 -->
|
||||||
|
<div v-else class="iview-table-mobile" :class="{
|
||||||
|
'iview-table-mobile--with-labels': mobileShowLabels,
|
||||||
|
'iview-table-mobile--panel': mobilePanel,
|
||||||
|
}">
|
||||||
|
<!-- 移动端表头过滤器 (可选) -->
|
||||||
|
<div v-if="showFilter && filterableColumns.length > 0" class="iview-table-mobile-filter">
|
||||||
|
<a-popover v-model:open="columnsPopoverVisible" trigger="click" placement="bottomRight"
|
||||||
|
overlay-class-name="iview-table-columns-popover">
|
||||||
|
<template #content>
|
||||||
|
<div class="iview-table-columns-popover-content">
|
||||||
|
<a-checkbox v-for="col in filterableColumns" :key="col.dataIndex || col.key"
|
||||||
|
v-model:checked="visibleColumns[col.dataIndex || col.key]" @change="updateVisibleColumns">
|
||||||
|
{{ col.title }}
|
||||||
|
</a-checkbox>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<a-button>
|
||||||
|
筛选列 <down-outlined />
|
||||||
|
</a-button>
|
||||||
|
</a-popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 移动端卡片列表 -->
|
||||||
|
<div class="iview-table-mobile-cards">
|
||||||
|
<template v-if="!dataSource || dataSource.length === 0">
|
||||||
|
<div class="iview-table-empty" :class="{ 'iview-table-empty--custom-image': hasEmptyImageSlot }"
|
||||||
|
@click="emptyClick">
|
||||||
|
<a-empty v-if="showEmptyImage" :description="emptyText" v-bind="emptyAEmptyImageAttrs">
|
||||||
|
<template v-if="hasEmptyImageSlot" #image>
|
||||||
|
<div class="iview-table-empty-image">
|
||||||
|
<slot name="emptyImage" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-empty>
|
||||||
|
<span v-else class="iview-table-empty__text">{{ emptyText }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div v-for="(record, index) in dataSource" :key="record.key || index" class="iview-table-mobile-card">
|
||||||
|
<!-- 卡片内容区 -->
|
||||||
|
<div class="iview-table-mobile-card-content">
|
||||||
|
<!-- 展示主要内容 -->
|
||||||
|
<div v-for="col in getMobileCardColumns(record, index)" :key="col.dataIndex || col.key"
|
||||||
|
class="iview-table-mobile-card-item">
|
||||||
|
<div v-if="mobileShowLabels && getColumnTitle(col)" class="iview-table-mobile-card-item-label">
|
||||||
|
{{ getColumnTitle(col) }}
|
||||||
|
</div>
|
||||||
|
<!-- 渲染单元格内容 -->
|
||||||
|
<div class="iview-table-mobile-card-item-value">
|
||||||
|
<template v-if="col.bodySlot">
|
||||||
|
<slot name="bodyCell" :mobile="isMobile" :column="col" :record="record" :index="index"
|
||||||
|
:text="formatValueCell(col, record)" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ formatValueCell(col, record) }}
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="shouldShowMobileToggle" class="iview-table-mobile-card-toggle">
|
||||||
|
<a-button type="link" size="small" @click="toggleMobileCardExpanded(record, index)">
|
||||||
|
{{ isMobileCardExpanded(record, index) ? "收起" : `展开全部 ${mobileContentColumns.length} 项` }}
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<!-- 卡片操作区 -->
|
||||||
|
<div v-if="actionColumnVisible" class="iview-table-card-item-value-flex-table-mobile">
|
||||||
|
<slot name="bodyCell" :mobile="isMobile" :column="actionColumn" :record="record" :index="index"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="pagination !== false" class="iview-table-mobile-pagination">
|
||||||
|
<a-pagination v-bind="paginationBindProps" @change="handlePageChange" @showSizeChange="handleSizeChange" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
computed,
|
||||||
|
useSlots,
|
||||||
|
onBeforeMount,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
nextTick,
|
||||||
|
toRaw,
|
||||||
|
} from "vue";
|
||||||
|
import { DownOutlined } from "@ant-design/icons-vue";
|
||||||
|
import { onAttachEvent, offAttachEvent } from "@/iview/utils/event";
|
||||||
|
import { on as onEvent, off as offEvent } from "@/iview/utils/emitter";
|
||||||
|
import {
|
||||||
|
applyRowPatchToDataSource,
|
||||||
|
LIST_ROW_PATCH_EVENT,
|
||||||
|
} from "@/iview/listRowPatch.js";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
columns: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
dataSource: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
showFilter: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showEmptyImage: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
/** 空状态插图:字符串为图片 URL;false 为不显示图标(仅文案) */
|
||||||
|
emptyImage: {
|
||||||
|
type: [String, Boolean],
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
type: [Object, Boolean],
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
type: String,
|
||||||
|
default: "数据为空",
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 移动端卡片折叠态下,默认可见的字段总数上限(默认 5)。
|
||||||
|
* foldable === false 的列会优先全部展示,占满该上限中的对应个数;
|
||||||
|
* 剩余名额再按顺序展示「可折叠」列。若钉住的列多于该上限,则仍会全部展示钉住列(总数可能超过本配置)。
|
||||||
|
*/
|
||||||
|
mobileCollapsedVisibleCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 5,
|
||||||
|
},
|
||||||
|
/** 移动端卡片是否在每项上方显示列标题(默认 false,避免影响全站其它列表) */
|
||||||
|
mobileShowLabels: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
/** 移动端表格外层卡片:圆角、边框与阴影(用于 ILayoutPage 列表等) */
|
||||||
|
mobilePanel: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
/** 行/卡片分割线颜色(如 #ffccc7) */
|
||||||
|
dividerColor: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
/** 行/卡片分割线粗细,数字视为 px,或传 "1px" / "2px" */
|
||||||
|
dividerWidth: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 是否监听全局 `iview:list-row-patch`,就地更新 dataSource 中匹配行
|
||||||
|
*(与 ILayoutPage / IViewInlineEditPopover 使用同一事件)
|
||||||
|
*/
|
||||||
|
rowPatchListen: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 行匹配键路径,如 `skuInfo.guid`;未传则用 rowKeyField / guid / rowKey
|
||||||
|
*/
|
||||||
|
rowPatchKey: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
/** patch 合并到的嵌套路径,如 `skuInfo` */
|
||||||
|
rowPatchMergePath: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
/** 行主键字段(rowPatchKey 未命中时的回退) */
|
||||||
|
rowKeyField: {
|
||||||
|
type: String,
|
||||||
|
default: "guid",
|
||||||
|
},
|
||||||
|
/** 与 ILayoutPage scope 一致时过滤事件;空则接收全部 */
|
||||||
|
patchScope: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["change", "pagination", "emptyClick", "rowPatch"]);
|
||||||
|
const slots = useSlots();
|
||||||
|
const isMobile = ref(false);
|
||||||
|
|
||||||
|
const hasEmptyImageSlot = computed(() => Boolean(slots.emptyImage));
|
||||||
|
|
||||||
|
const emptyAEmptyImageAttrs = computed(() => {
|
||||||
|
if (hasEmptyImageSlot.value) return {};
|
||||||
|
if (props.emptyImage === false) return { image: false };
|
||||||
|
if (typeof props.emptyImage === "string" && props.emptyImage.trim()) {
|
||||||
|
return { image: props.emptyImage.trim() };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizeDividerWidth(width) {
|
||||||
|
if (width == null || width === "") return "";
|
||||||
|
if (typeof width === "number" && Number.isFinite(width)) return `${width}px`;
|
||||||
|
return `${width}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCustomDivider = computed(() => {
|
||||||
|
return Boolean(`${props.dividerColor || ""}`.trim()) || Boolean(normalizeDividerWidth(props.dividerWidth));
|
||||||
|
});
|
||||||
|
|
||||||
|
const dividerStyleVar = computed(() => {
|
||||||
|
const style = {};
|
||||||
|
const color = `${props.dividerColor || ""}`.trim();
|
||||||
|
const width = normalizeDividerWidth(props.dividerWidth);
|
||||||
|
if (color) style["--iview-table-divider-color"] = color;
|
||||||
|
if (width) style["--iview-table-divider-width"] = width;
|
||||||
|
return style;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tablePcRef = ref(null);
|
||||||
|
const hasHorizontalScroll = ref(false);
|
||||||
|
const resizeColumns = ref({});
|
||||||
|
const visibleColumns = ref({});
|
||||||
|
const columnsPopoverVisible = ref(false);
|
||||||
|
const mobileExpandedCardKeys = ref({});
|
||||||
|
const dragScrollState = ref({
|
||||||
|
isDragging: false,
|
||||||
|
startX: 0,
|
||||||
|
startScrollLeft: 0,
|
||||||
|
scrollElement: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const initVisibleColumns = () => {
|
||||||
|
props.columns.forEach((col) => {
|
||||||
|
const key = col.dataIndex || col.key;
|
||||||
|
if (key) {
|
||||||
|
visibleColumns.value[key] = !col.optional;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isActionColumn = (col) => {
|
||||||
|
return col.key == "action" || col.dataIndex == "action";
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyClick = () => {
|
||||||
|
emit("emptyClick");
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterableColumns = computed(() => {
|
||||||
|
return props.columns.filter((col) => {
|
||||||
|
const isAlways = col.always === true;
|
||||||
|
const isAction = isActionColumn(col);
|
||||||
|
const onlyMobile = col.onlyMobile === true;
|
||||||
|
if (onlyMobile) {
|
||||||
|
return isMobile.value;
|
||||||
|
}
|
||||||
|
return !isAlways && !isAction && col.listNotShow !== true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredColumns = computed(() => {
|
||||||
|
return props.columns.filter((col) => {
|
||||||
|
if (col.listNotShow === true) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const onlyMobile = col.onlyMobile === true;
|
||||||
|
if (onlyMobile) {
|
||||||
|
return isMobile.value;
|
||||||
|
}
|
||||||
|
const key = col.dataIndex || col.key;
|
||||||
|
const width = resizeColumns.value[key];
|
||||||
|
if (width && (!col.minWidth || width > col.minWidth)) {
|
||||||
|
col.width = width;
|
||||||
|
}
|
||||||
|
return !key || visibleColumns.value[key] !== false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const mobileContentColumns = computed(() => {
|
||||||
|
return filteredColumns.value.filter((col) => !isActionColumn(col) && col.mobileNotShow !== true);
|
||||||
|
});
|
||||||
|
|
||||||
|
function getMobileFoldLimit() {
|
||||||
|
const n = Number(props.mobileCollapsedVisibleCount ?? 5);
|
||||||
|
return Number.isFinite(n) ? Math.max(0, n) : 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 不参与移动端折叠的列:foldable 严格为 false(对 reactive 列对象使用 toRaw 读取) */
|
||||||
|
function isMobilePinnedColumn(col) {
|
||||||
|
if (col == null) return false;
|
||||||
|
return toRaw(col).foldable === false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 折叠态:总可见列数不超过 mobileCollapsedVisibleCount(钉住列优先,剩余名额给靠前的可折叠列)。
|
||||||
|
* 列顺序不变。
|
||||||
|
*/
|
||||||
|
function pickCollapsedMobileColumns(cols) {
|
||||||
|
const totalBudget = getMobileFoldLimit();
|
||||||
|
const pinnedCount = cols.reduce(
|
||||||
|
(n, col) => n + (isMobilePinnedColumn(col) ? 1 : 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const foldableQuota = Math.max(0, totalBudget - pinnedCount);
|
||||||
|
let foldableShown = 0;
|
||||||
|
const out = [];
|
||||||
|
for (const col of cols) {
|
||||||
|
if (isMobilePinnedColumn(col)) {
|
||||||
|
out.push(col);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (foldableShown < foldableQuota) {
|
||||||
|
out.push(col);
|
||||||
|
foldableShown += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mobileCollapsedPreviewColumns = computed(() => {
|
||||||
|
return pickCollapsedMobileColumns(mobileContentColumns.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const shouldShowMobileToggle = computed(() => {
|
||||||
|
return (
|
||||||
|
mobileCollapsedPreviewColumns.value.length < mobileContentColumns.value.length
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getMobileCardKey = (record, index) => {
|
||||||
|
return record.key || index;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMobileCardExpanded = (record, index) => {
|
||||||
|
return !!mobileExpandedCardKeys.value[getMobileCardKey(record, index)];
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMobileCardExpanded = (record, index) => {
|
||||||
|
const key = getMobileCardKey(record, index);
|
||||||
|
mobileExpandedCardKeys.value = {
|
||||||
|
...mobileExpandedCardKeys.value,
|
||||||
|
[key]: !mobileExpandedCardKeys.value[key],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMobileCardColumns = (record, index) => {
|
||||||
|
if (!shouldShowMobileToggle.value || isMobileCardExpanded(record, index)) {
|
||||||
|
return mobileContentColumns.value;
|
||||||
|
}
|
||||||
|
return mobileCollapsedPreviewColumns.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleResizeColumn(w, col) {
|
||||||
|
const key = col.dataIndex || col.key;
|
||||||
|
resizeColumns.value[key] = w;
|
||||||
|
updateHorizontalScrollVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnsWidth = computed(() => {
|
||||||
|
const width = filteredColumns.value.reduce((acc, col) => {
|
||||||
|
if (col.width) {
|
||||||
|
acc += col.width;
|
||||||
|
} else if (col.minWidth) {
|
||||||
|
acc += col.minWidth;
|
||||||
|
} else {
|
||||||
|
acc += 180;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, 0);
|
||||||
|
return width;
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateVisibleColumns = () => {
|
||||||
|
// 可以在这里添加更多逻辑,比如保存用户的列首选项等
|
||||||
|
updateHorizontalScrollVisible();
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_PAGINATION_OPTIONS = ["10", "20", "50", "100"];
|
||||||
|
|
||||||
|
const paginationBindProps = computed(() => {
|
||||||
|
if (props.pagination === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const pag = props.pagination || {};
|
||||||
|
const merged = {
|
||||||
|
pageSizeOptions: DEFAULT_PAGINATION_OPTIONS,
|
||||||
|
...pag,
|
||||||
|
current: pag.current ?? 1,
|
||||||
|
pageSize: pag.pageSize ?? 10,
|
||||||
|
total: pag.total ?? 0,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...merged,
|
||||||
|
responsive: merged.responsive ?? false,
|
||||||
|
size: merged.size ?? (isMobile.value ? "small" : "default"),
|
||||||
|
showSizeChanger: merged.showSizeChanger !== false,
|
||||||
|
showQuickJumper: merged.showQuickJumper === true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePageChange = (page, pageSize) => {
|
||||||
|
emit("change", { current: page, pageSize }, {}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSizeChange = (current, size) => {
|
||||||
|
emit("change", { current: 1, pageSize: size }, {}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTableChange = (pagination, filters, sorter) => {
|
||||||
|
emit("change", pagination, filters, sorter);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getHorizontalScrollElement = () => {
|
||||||
|
if (!tablePcRef.value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
tablePcRef.value.querySelector(".ant-table-content") ||
|
||||||
|
tablePcRef.value.querySelector(".ant-table-body")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateHorizontalScrollVisible = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
const scrollElement = getHorizontalScrollElement();
|
||||||
|
hasHorizontalScroll.value =
|
||||||
|
!isMobile.value &&
|
||||||
|
!!scrollElement &&
|
||||||
|
scrollElement.scrollWidth > scrollElement.clientWidth + 1;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragScrollStart = (event) => {
|
||||||
|
const scrollElement = getHorizontalScrollElement();
|
||||||
|
if (!scrollElement || scrollElement.scrollWidth <= scrollElement.clientWidth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
dragScrollState.value = {
|
||||||
|
isDragging: true,
|
||||||
|
startX: event.clientX,
|
||||||
|
startScrollLeft: scrollElement.scrollLeft,
|
||||||
|
scrollElement,
|
||||||
|
};
|
||||||
|
document.addEventListener("mousemove", handleDragScrollMove);
|
||||||
|
document.addEventListener("mouseup", handleDragScrollEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragScrollMove = (event) => {
|
||||||
|
if (!dragScrollState.value.isDragging || !dragScrollState.value.scrollElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deltaX = event.clientX - dragScrollState.value.startX;
|
||||||
|
dragScrollState.value.scrollElement.scrollLeft =
|
||||||
|
dragScrollState.value.startScrollLeft + deltaX;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragScrollDoubleClick = (event) => {
|
||||||
|
const scrollElement = getHorizontalScrollElement();
|
||||||
|
if (!scrollElement || scrollElement.scrollWidth <= scrollElement.clientWidth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
const clickRatio = (event.clientX - rect.left) / rect.width;
|
||||||
|
const maxScrollLeft = scrollElement.scrollWidth - scrollElement.clientWidth;
|
||||||
|
let targetScrollLeft = maxScrollLeft / 2;
|
||||||
|
if (clickRatio <= 1 / 3) {
|
||||||
|
targetScrollLeft = 0;
|
||||||
|
}
|
||||||
|
if (clickRatio >= 2 / 3) {
|
||||||
|
targetScrollLeft = maxScrollLeft;
|
||||||
|
}
|
||||||
|
scrollElement.scrollTo({
|
||||||
|
left: targetScrollLeft,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragScrollEnd = () => {
|
||||||
|
dragScrollState.value = {
|
||||||
|
isDragging: false,
|
||||||
|
startX: 0,
|
||||||
|
startScrollLeft: 0,
|
||||||
|
scrollElement: null,
|
||||||
|
};
|
||||||
|
document.removeEventListener("mousemove", handleDragScrollMove);
|
||||||
|
document.removeEventListener("mouseup", handleDragScrollEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionColumn = computed(() => {
|
||||||
|
return props.columns.find((col) => {
|
||||||
|
let key = col.dataIndex || col.key;
|
||||||
|
return key === "action";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionColumnVisible = computed(() => {
|
||||||
|
return !!actionColumn.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatValueCell = (column, record) => {
|
||||||
|
let key = column.dataIndex || column.key;
|
||||||
|
let text = record[key];
|
||||||
|
if (!column.formatMethod) {
|
||||||
|
if (text === null || text === undefined || text === "") {
|
||||||
|
return column.emptyText || "";
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = column.formatMethod({
|
||||||
|
record,
|
||||||
|
column,
|
||||||
|
});
|
||||||
|
if (result === null || result === undefined || result === "") {
|
||||||
|
return column.emptyText || "";
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
return column.emptyText || "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColumnTitle = (column) => {
|
||||||
|
if (typeof column.title === "string" || typeof column.title === "number") {
|
||||||
|
return String(column.title);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkIsMobile = () => {
|
||||||
|
isMobile.value = window.innerWidth <= 768;
|
||||||
|
updateHorizontalScrollVisible();
|
||||||
|
};
|
||||||
|
|
||||||
|
let listRowPatchListener = null;
|
||||||
|
|
||||||
|
function getRowPatchOptions() {
|
||||||
|
return {
|
||||||
|
rowPatchKey: props.rowPatchKey || undefined,
|
||||||
|
rowKeyField: props.rowKeyField || "guid",
|
||||||
|
rowPatchMergePath: props.rowPatchMergePath || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleListRowPatchEvent(ev) {
|
||||||
|
const detail = ev?.detail ?? ev;
|
||||||
|
if (!detail) return;
|
||||||
|
const scope = detail.scope;
|
||||||
|
if (props.patchScope && scope && scope !== props.patchScope) return;
|
||||||
|
|
||||||
|
const didPatch = applyRowPatchToDataSource(props.dataSource, detail, getRowPatchOptions());
|
||||||
|
if (didPatch) emit("rowPatch", detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
checkIsMobile();
|
||||||
|
onAttachEvent(window, "resize", checkIsMobile);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateHorizontalScrollVisible();
|
||||||
|
if (props.rowPatchListen) {
|
||||||
|
listRowPatchListener = handleListRowPatchEvent;
|
||||||
|
onEvent(LIST_ROW_PATCH_EVENT, listRowPatchListener);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
offAttachEvent(window, "resize", checkIsMobile);
|
||||||
|
handleDragScrollEnd();
|
||||||
|
if (listRowPatchListener) {
|
||||||
|
offEvent(LIST_ROW_PATCH_EVENT, listRowPatchListener);
|
||||||
|
listRowPatchListener = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.columns,
|
||||||
|
() => {
|
||||||
|
initVisibleColumns();
|
||||||
|
updateHorizontalScrollVisible();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => props.dataSource, columnsWidth, filteredColumns],
|
||||||
|
() => {
|
||||||
|
updateHorizontalScrollVisible();
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.iview-table-root {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-root--custom-divider .iview-table-mobile-card {
|
||||||
|
border-bottom-style: solid;
|
||||||
|
border-bottom-width: var(--iview-table-divider-width, 0px);
|
||||||
|
border-bottom-color: var(--iview-table-divider-color, #f0f0f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-root--custom-divider .iview-table-pc :deep(.ant-table-tbody > tr > td),
|
||||||
|
.iview-table-root--custom-divider .iview-table-pc :deep(.ant-table-thead > tr > th) {
|
||||||
|
border-bottom: var(--iview-table-divider-width, 1px) solid var(--iview-table-divider-color, #f0f0f0);
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-root--custom-divider .iview-table-pc :deep(.ant-table-tbody > tr:last-child > td) {
|
||||||
|
border-bottom: var(--iview-table-divider-width, 1px) solid var(--iview-table-divider-color, #f0f0f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-root--custom-divider .iview-table-pc :deep(.ant-table-thead > tr > th:not(:last-child):not(.ant-table-selection-column):not(.ant-table-row-expand-icon-cell):not([colspan])::before) {
|
||||||
|
background-color: var(--iview-table-divider-color, #f0f0f0);
|
||||||
|
width: var(--iview-table-divider-width, 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-pc {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-empty {
|
||||||
|
padding: 60px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-empty :deep(.ant-empty) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-empty--custom-image :deep(.ant-empty-image) {
|
||||||
|
height: auto;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-empty :deep(.ant-empty-description) {
|
||||||
|
margin-top: 0;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-empty__text {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-empty-image {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-empty-image :deep(.anticon),
|
||||||
|
.iview-table-empty-image :deep(svg) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-pc-filter {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-pc-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin: 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-pc-no-pagination {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-mobile {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 与 IViewFilter.iview-filter-form 移动端卡片效果一致 */
|
||||||
|
.iview-table-mobile--panel {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-mobile--panel .iview-table-mobile-filter {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-mobile--panel .iview-table-mobile-cards {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-mobile-filter {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-columns-popover-content {
|
||||||
|
min-width: 160px;
|
||||||
|
max-width: 240px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-mobile-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-mobile-card {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-mobile-card:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-mobile-card-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-mobile--with-labels .iview-table-mobile-card-content {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-mobile-card-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-mobile-card-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-mobile-card-item-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #8c96a8;
|
||||||
|
text-align: left;
|
||||||
|
line-height: 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-left: 2px solid #8c96a8;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding-left: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-mobile-card-item-value {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
text-align: left;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-mobile-card-toggle {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-mobile-card-toggle :deep(.ant-btn-link) {
|
||||||
|
height: auto;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-card-item-value-flex-table-mobile {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-card-item-value-flex-table {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-mobile-card-item-value :deep(a) {
|
||||||
|
color: #1890ff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-mobile-card-item-value :deep(a:hover) {
|
||||||
|
color: #40a9ff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-mobile-card-actions {
|
||||||
|
padding-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-mobile-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-mobile-pagination :deep(.ant-pagination) {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
row-gap: 8px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-mobile-pagination :deep(.ant-pagination-total-text),
|
||||||
|
.iview-table-mobile-pagination :deep(.ant-pagination-options) {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-mobile-pagination :deep(.ant-pagination-options-size-changer) {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-pc :deep(.ant-table-tbody > tr > td) {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-pc :deep(a) {
|
||||||
|
color: #1890ff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-pc :deep(a.delete-link) {
|
||||||
|
color: #f5222d;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-pc :deep(a:hover) {
|
||||||
|
color: #40a9ff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-pc :deep(.ant-table-pagination.ant-pagination) {
|
||||||
|
margin: 16px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-pc :deep(.ant-table-content),
|
||||||
|
.iview-table-pc :deep(.ant-table-body) {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-pc :deep(.ant-table-content::-webkit-scrollbar),
|
||||||
|
.iview-table-pc :deep(.ant-table-body::-webkit-scrollbar) {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-pc :deep(.ant-table-sticky-scroll) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-drag-scroll {
|
||||||
|
width: 100%;
|
||||||
|
height: 20px;
|
||||||
|
background: #f1f1f1;
|
||||||
|
cursor: ew-resize;
|
||||||
|
opacity: 0.3;
|
||||||
|
transition: background 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-drag-scroll-top {
|
||||||
|
/* margin-bottom: 8px; */
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-drag-scroll-bottom {
|
||||||
|
/* margin-top: 8px; */
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-table-drag-scroll:hover,
|
||||||
|
.iview-table-drag-scroll.is-dragging {
|
||||||
|
background: #f1f1f1;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
434
src/iview/IViewTabs.vue
Normal file
434
src/iview/IViewTabs.vue
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="tabsRootClass" :style="tabsStyle">
|
||||||
|
<a-tabs v-bind="tabsBindProps" :class="antTabsClass" v-model:active-key="activeKey" @change="onTabChange">
|
||||||
|
<slot />
|
||||||
|
</a-tabs>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, onBeforeMount, onUnmounted, useAttrs } from "vue";
|
||||||
|
import { onAttachEvent, offAttachEvent } from "@/iview/utils/event";
|
||||||
|
|
||||||
|
defineOptions({ inheritAttrs: false });
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
animated: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
centered: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: "line",
|
||||||
|
},
|
||||||
|
level: {
|
||||||
|
type: String,
|
||||||
|
default: "primary",
|
||||||
|
},
|
||||||
|
embedded: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
tabBarGutter: {
|
||||||
|
type: Number,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
tabsStyle: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeKey = defineModel("activeKey", { type: [String, Number] });
|
||||||
|
|
||||||
|
const emit = defineEmits(["change", "tabClick"]);
|
||||||
|
|
||||||
|
const attrs = useAttrs();
|
||||||
|
const isMobile = ref(false);
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
const MOBILE_MEDIA_QUERY = `(max-width: ${MOBILE_BREAKPOINT}px)`;
|
||||||
|
|
||||||
|
let mobileMediaQuery = null;
|
||||||
|
|
||||||
|
function syncIsMobile(matches) {
|
||||||
|
isMobile.value = matches ?? mobileMediaQuery?.matches ?? window.innerWidth <= MOBILE_BREAKPOINT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkIsMobile() {
|
||||||
|
syncIsMobile();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsRootClass = computed(() => {
|
||||||
|
const extraClass = attrs.class;
|
||||||
|
return [
|
||||||
|
"iview-tabs",
|
||||||
|
`iview-tabs--${props.level}`,
|
||||||
|
{
|
||||||
|
"iview-tabs--mobile": isMobile.value,
|
||||||
|
"iview-tabs--strip": isMobile.value,
|
||||||
|
"iview-tabs--embedded": props.embedded,
|
||||||
|
},
|
||||||
|
extraClass,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const antTabsClass = computed(() => {
|
||||||
|
if (!isMobile.value) return "iview-tabs__ant";
|
||||||
|
return "iview-tabs__ant iview-tabs__ant--strip";
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 移动端用 line,避免 ant-tabs-card 的 CSS-in-JS 覆盖自定义边框 */
|
||||||
|
const tabsType = computed(() => {
|
||||||
|
if (isMobile.value) return "line";
|
||||||
|
if (props.variant === "card") return "card";
|
||||||
|
return "line";
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabsBindProps = computed(() => {
|
||||||
|
const { class: _className, style: _style, ...rest } = attrs;
|
||||||
|
const bind = {
|
||||||
|
...rest,
|
||||||
|
animated: props.animated,
|
||||||
|
centered: props.centered && !isMobile.value,
|
||||||
|
type: tabsType.value,
|
||||||
|
};
|
||||||
|
if (props.size) bind.size = props.size;
|
||||||
|
const gutter = props.tabBarGutter ?? (isMobile.value ? 0 : undefined);
|
||||||
|
if (gutter !== undefined) bind.tabBarGutter = gutter;
|
||||||
|
return bind;
|
||||||
|
});
|
||||||
|
|
||||||
|
function onTabChange(key) {
|
||||||
|
emit("change", key);
|
||||||
|
emit("tabClick", key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMobileMediaChange(event) {
|
||||||
|
syncIsMobile(event.matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
if (typeof window !== "undefined" && window.matchMedia) {
|
||||||
|
mobileMediaQuery = window.matchMedia(MOBILE_MEDIA_QUERY);
|
||||||
|
syncIsMobile(mobileMediaQuery.matches);
|
||||||
|
onAttachEvent(mobileMediaQuery, "change", onMobileMediaChange);
|
||||||
|
} else {
|
||||||
|
checkIsMobile();
|
||||||
|
onAttachEvent(window, "resize", checkIsMobile);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (mobileMediaQuery) {
|
||||||
|
offAttachEvent(mobileMediaQuery, "change", onMobileMediaChange);
|
||||||
|
mobileMediaQuery = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
offAttachEvent(window, "resize", checkIsMobile);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.iview-tabs {
|
||||||
|
width: 100%;
|
||||||
|
--iview-tabs-scroll-end: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 移动端:文件夹式标签条(四边边框 + 底部分割线与 Tab 底边重合) —— */
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.iview-tabs {
|
||||||
|
--iview-tabs-nav-gap: 20px;
|
||||||
|
--iview-tabs-accent: #007aff;
|
||||||
|
--iview-tabs-strip-bg: linear-gradient(to bottom, #fff, #fff calc(100% - 1px), #dcdfe3 calc(100% - 1px), #dcdfe3);
|
||||||
|
--iview-tabs-strip-tab-bg: #ffffff;
|
||||||
|
--iview-tabs-strip-active-bg: linear-gradient(180deg, #f4faff 0%, #ffffff 100%);
|
||||||
|
--iview-tabs-strip-border: #dcdfe3;
|
||||||
|
--iview-tabs-strip-border-active: #007aff;
|
||||||
|
--iview-tabs-strip-active-bottom: #ffffff;
|
||||||
|
--iview-tabs-strip-bottom: #e4e6eb;
|
||||||
|
--iview-tabs-label: rgba(0, 0, 0, 0.88);
|
||||||
|
--iview-tabs-label-active: #007aff;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs.iview-tabs--embedded {
|
||||||
|
--iview-tabs-scroll-end: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs :deep(.ant-tabs) {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs :deep(.ant-tabs-nav) {
|
||||||
|
margin-bottom: var(--iview-tabs-nav-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs :deep(.ant-tabs-nav::before) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs:not(.iview-tabs--strip) :deep(.ant-tabs-nav-wrap) {
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
touch-action: pan-x;
|
||||||
|
scroll-padding-inline-end: var(--iview-tabs-scroll-end);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs :deep(.ant-tabs-nav-wrap::-webkit-scrollbar) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs :deep(.ant-tabs-nav-list) {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 0;
|
||||||
|
padding-right: var(--iview-tabs-scroll-end);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs :deep(.ant-tabs-nav-list)::after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
flex: 0 0 var(--iview-tabs-scroll-end);
|
||||||
|
width: var(--iview-tabs-scroll-end);
|
||||||
|
min-width: var(--iview-tabs-scroll-end);
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs :deep(.ant-tabs-ink-bar) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端标签条(line 类型 + 自定义边框,避免 ant-tabs-card 样式覆盖) */
|
||||||
|
.iview-tabs.iview-tabs--strip :deep(.ant-tabs-nav) {
|
||||||
|
background: var(--iview-tabs-strip-bg);
|
||||||
|
border-bottom: none !important;
|
||||||
|
padding: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs.iview-tabs--strip :deep(.ant-tabs-nav-wrap) {
|
||||||
|
padding: 0;
|
||||||
|
background: var(--iview-tabs-strip-bg);
|
||||||
|
border-bottom: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: visible;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
touch-action: pan-x;
|
||||||
|
scroll-padding-inline-end: var(--iview-tabs-scroll-end);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs.iview-tabs--strip :deep(.ant-tabs-nav-list) {
|
||||||
|
align-items: flex-end;
|
||||||
|
overflow: visible;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 未选中:四边灰框 + 白底;底边与整体分割线重合(仅 Tab 行宽,不铺满 nav-wrap) */
|
||||||
|
.iview-tabs.iview-tabs--strip :deep(.ant-tabs-nav .ant-tabs-tab) {
|
||||||
|
margin: 0 0 0 -1px !important;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--iview-tabs-strip-border) !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
background: var(--iview-tabs-strip-tab-bg) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs.iview-tabs--strip :deep(.ant-tabs-nav .ant-tabs-tab:first-child) {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs.iview-tabs--strip :deep(.ant-tabs-tab-btn) {
|
||||||
|
padding: 10px 14px;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--iview-tabs-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中:上/左/右高亮,下白边,白底;与未选中同盒模型,避免 margin 导致矮 1px */
|
||||||
|
.iview-tabs.iview-tabs--strip :deep(.ant-tabs-nav .ant-tabs-tab-active) {
|
||||||
|
background: var(--iview-tabs-strip-active-bg) !important;
|
||||||
|
border-top: 1px solid var(--iview-tabs-strip-border-active) !important;
|
||||||
|
border-left: 1px solid var(--iview-tabs-strip-border-active) !important;
|
||||||
|
border-right: 1px solid var(--iview-tabs-strip-border-active) !important;
|
||||||
|
border-bottom: 1px solid var(--iview-tabs-strip-active-bottom) !important;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
z-index: 3 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs.iview-tabs--strip :deep(.ant-tabs-tab-active .ant-tabs-tab-btn) {
|
||||||
|
color: var(--iview-tabs-label-active);
|
||||||
|
font-weight: 500;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容区统一无外框(列表卡片由 IViewTable.mobilePanel 负责) */
|
||||||
|
.iview-tabs :deep(.ant-tabs-content-holder),
|
||||||
|
.iview-tabs :deep(.ant-tabs-content),
|
||||||
|
.iview-tabs :deep(.ant-tabs-tabpane),
|
||||||
|
.iview-tabs :deep(.ant-tabs-card > .ant-tabs-content-holder),
|
||||||
|
.iview-tabs :deep(.ant-tabs-card > .ant-tabs-content-holder > .ant-tabs-content) {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs.iview-tabs--primary.iview-tabs--strip :deep(.ant-tabs-tab-btn) {
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs.iview-tabs--secondary {
|
||||||
|
--iview-tabs-nav-gap: 16px;
|
||||||
|
--iview-tabs-strip-tab-bg: #ffffff;
|
||||||
|
--iview-tabs-strip-bg: linear-gradient(to bottom, #fff, #fff calc(100% - 1px), #dcdfe3 calc(100% - 1px), #dcdfe3);
|
||||||
|
--iview-tabs-strip-active-bg: linear-gradient(180deg, #f4faff 0%, #ffffff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs.iview-tabs--secondary.iview-tabs--strip :deep(.ant-tabs-tab-btn) {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ILayoutPage 嵌入:无 Tab 栏外层背景/内边距 */
|
||||||
|
.iview-tabs.iview-tabs--embedded :deep(.ant-tabs-nav) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs.iview-tabs--embedded :deep(.ant-tabs-nav-wrap) {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
/* scroll-padding-inline-end: 12px; */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .iview-tabs.iview-tabs--embedded :deep(.ant-tabs-nav-list) {
|
||||||
|
padding-right: 12px;
|
||||||
|
} */
|
||||||
|
|
||||||
|
/* .iview-tabs.iview-tabs--embedded :deep(.ant-tabs-nav-list)::after {
|
||||||
|
flex: 0 0 12px;
|
||||||
|
width: 12px;
|
||||||
|
min-width: 12px;
|
||||||
|
} */
|
||||||
|
|
||||||
|
.iview-tabs.iview-tabs--embedded.iview-tabs--secondary {
|
||||||
|
--iview-tabs-bar-bg: transparent;
|
||||||
|
--iview-tabs-card-bg: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs.iview-tabs--embedded.iview-tabs--secondary :deep(.ant-tabs-nav-wrap) {
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 模块入口页:间距由 .module-root-page 统一,Tab 栏无额外缩进/上下 padding */
|
||||||
|
.iview-tabs.module-entry-tabs {
|
||||||
|
--iview-tabs-nav-gap: 12px;
|
||||||
|
--iview-tabs-scroll-end: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs.module-entry-tabs :deep(.ant-tabs-nav-wrap) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs.module-entry-tabs :deep(.ant-tabs-nav-list) {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs.module-entry-tabs :deep(.ant-tabs-nav-list)::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 769px) {
|
||||||
|
.iview-tabs.module-entry-tabs :deep(.ant-tabs-nav-list) {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: var(--iview-tabs-scroll-end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- 非 scoped:压过 ant Design CSS-in-JS(尤其原 card 模式) -->
|
||||||
|
<style>
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
|
||||||
|
.iview-tabs.iview-tabs--strip .iview-tabs__ant--strip.ant-tabs-top>.ant-tabs-nav,
|
||||||
|
.iview-tabs.iview-tabs--strip .iview-tabs__ant--strip.ant-tabs-top>div>.ant-tabs-nav {
|
||||||
|
border-bottom: none !important;
|
||||||
|
background: var(--iview-tabs-strip-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs.iview-tabs--strip .iview-tabs__ant--strip.ant-tabs-top>.ant-tabs-nav .ant-tabs-nav-wrap,
|
||||||
|
.iview-tabs.iview-tabs--strip .iview-tabs__ant--strip.ant-tabs-top>div>.ant-tabs-nav .ant-tabs-nav-wrap {
|
||||||
|
border-bottom: none !important;
|
||||||
|
overflow-x: auto !important;
|
||||||
|
overflow-y: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs.iview-tabs--strip .iview-tabs__ant--strip.ant-tabs-top>.ant-tabs-nav,
|
||||||
|
.iview-tabs.iview-tabs--strip .iview-tabs__ant--strip.ant-tabs-top>div>.ant-tabs-nav {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs.iview-tabs--strip .iview-tabs__ant--strip.ant-tabs-top>.ant-tabs-nav .ant-tabs-nav-list,
|
||||||
|
.iview-tabs.iview-tabs--strip .iview-tabs__ant--strip.ant-tabs-top>div>.ant-tabs-nav .ant-tabs-nav-list {
|
||||||
|
align-items: flex-end !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs.iview-tabs--strip .iview-tabs__ant--strip.ant-tabs-top>.ant-tabs-nav .ant-tabs-tab,
|
||||||
|
.iview-tabs.iview-tabs--strip .iview-tabs__ant--strip.ant-tabs-top>div>.ant-tabs-nav .ant-tabs-tab {
|
||||||
|
margin: 0 0 0 -1px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
border: 1px solid var(--iview-tabs-strip-border) !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
background: #ffffff !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs.iview-tabs--strip .iview-tabs__ant--strip.ant-tabs-top>.ant-tabs-nav .ant-tabs-tab:first-child,
|
||||||
|
.iview-tabs.iview-tabs--strip .iview-tabs__ant--strip.ant-tabs-top>div>.ant-tabs-nav .ant-tabs-tab:first-child {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs.iview-tabs--strip .iview-tabs__ant--strip.ant-tabs-top>.ant-tabs-nav .ant-tabs-tab-active,
|
||||||
|
.iview-tabs.iview-tabs--strip .iview-tabs__ant--strip.ant-tabs-top>div>.ant-tabs-nav .ant-tabs-tab-active {
|
||||||
|
background: var(--iview-tabs-strip-active-bg) !important;
|
||||||
|
border-top: 1px solid var(--iview-tabs-strip-border-active) !important;
|
||||||
|
border-left: 1px solid var(--iview-tabs-strip-border-active) !important;
|
||||||
|
border-right: 1px solid var(--iview-tabs-strip-border-active) !important;
|
||||||
|
border-bottom: 1px solid #ffffff !important;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
z-index: 3 !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs.iview-tabs--strip .iview-tabs__ant--strip.ant-tabs-top>.ant-tabs-nav .ant-tabs-tab-active .ant-tabs-tab-btn,
|
||||||
|
.iview-tabs.iview-tabs--strip .iview-tabs__ant--strip.ant-tabs-top>div>.ant-tabs-nav .ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-tabs.iview-tabs--strip .iview-tabs__ant--strip .ant-tabs-ink-bar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
467
src/iview/IViewUrlEmbedHost.vue
Normal file
467
src/iview/IViewUrlEmbedHost.vue
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
<template>
|
||||||
|
<IViewDrawer
|
||||||
|
root-class-name="iview-url-embed-drawer-root"
|
||||||
|
placement="right"
|
||||||
|
flush-content
|
||||||
|
:title="title"
|
||||||
|
:open="visible"
|
||||||
|
:mask="urlEmbedDrawerMask"
|
||||||
|
:mask-closable="urlEmbedDrawerMask"
|
||||||
|
:append-to-body="true"
|
||||||
|
:body-style="urlEmbedDrawerBodyStyle"
|
||||||
|
:header-style="urlEmbedDrawerHeaderStyle"
|
||||||
|
:transition-name="urlEmbedDrawerTransitionName"
|
||||||
|
:mask-transition-name="urlEmbedDrawerTransitionName"
|
||||||
|
@close="handleDrawerClose"
|
||||||
|
@after-open-change="onUrlEmbedDrawerAfterOpenChange"
|
||||||
|
>
|
||||||
|
<div v-if="visible && src" class="layout-page-url-embed">
|
||||||
|
<div v-if="urlEmbedToolbarVisible" class="layout-page-url-embed__toolbar">
|
||||||
|
<a-space size="small" wrap>
|
||||||
|
<template v-if="urlEmbedCanHistoryBack">
|
||||||
|
<a-button type="text" size="small" title="上一步" @click="urlEmbedHistoryBack">
|
||||||
|
<template #icon>
|
||||||
|
<ArrowLeftOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
v-if="showToolbar && urlEmbedShowHistoryNav && contentKind === 'iframe'"
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
title="下一步"
|
||||||
|
@click="urlEmbedHistoryForward"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<ArrowRightOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
<a-button
|
||||||
|
v-if="showNewWindowButton || contentKind === 'file-fallback'"
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
ghost
|
||||||
|
@click="urlEmbedOpenExternal"
|
||||||
|
>
|
||||||
|
{{ contentKind === 'file-fallback' ? '打开文件' : '新窗口打开' }}
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<iframe
|
||||||
|
v-if="contentKind === 'iframe'"
|
||||||
|
ref="urlEmbedFrameRef"
|
||||||
|
class="layout-page-url-embed__frame"
|
||||||
|
:key="`url-embed-${iframeMountKey}`"
|
||||||
|
:src="iframeInitialSrc"
|
||||||
|
:title="title"
|
||||||
|
@load="urlEmbedOnFrameLoad"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else-if="contentKind === 'file-fallback'" class="layout-page-url-embed__file-fallback">
|
||||||
|
<a-empty description="当前环境无法内嵌预览该文件,请点击下方按钮打开">
|
||||||
|
<a-space direction="vertical" align="center">
|
||||||
|
<a-button type="primary" @click="urlEmbedOpenExternal">打开文件</a-button>
|
||||||
|
<span class="layout-page-url-embed__file-hint">微信内请使用右上角菜单选择「在浏览器打开」</span>
|
||||||
|
</a-space>
|
||||||
|
</a-empty>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</IViewDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import IViewDrawer from "@/iview/IViewDrawer.vue";
|
||||||
|
import { computed, ref, onMounted, onUnmounted, watch, nextTick } from "vue";
|
||||||
|
import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons-vue";
|
||||||
|
import { useUrlEmbedStore } from "@/iview/urlEmbed";
|
||||||
|
import { DEFAULT_URL_EMBED_HEADER_BG } from "@/iview/urlEmbed/urlEmbedTheme.js";
|
||||||
|
import { URL_EMBED_MSG } from "@/iview/urlEmbed/urlEmbedMessages.js";
|
||||||
|
import { isMobileViewport, isWeChatBrowser, openUrlByAnchor } from "@/iview/urlEmbed/urlEmbedEnv.js";
|
||||||
|
import {
|
||||||
|
cancelHostEmbedFrameRestoreBurst,
|
||||||
|
scheduleHostEmbedFrameRestoreForEnv,
|
||||||
|
teardownEmbedFrame,
|
||||||
|
} from "@/iview/urlEmbed/urlEmbedWechatFocus.js";
|
||||||
|
import { showErrorTips } from "@/iview/utils/messageTips";
|
||||||
|
import { Modal } from "ant-design-vue";
|
||||||
|
|
||||||
|
const urlEmbedStore = useUrlEmbedStore();
|
||||||
|
const { visible, src, title, showToolbar, showNewWindowButton, contentKind, headerBackgroundColor } =
|
||||||
|
urlEmbedStore;
|
||||||
|
|
||||||
|
const urlEmbedDrawerHeaderStyle = computed(() => ({
|
||||||
|
backgroundColor: headerBackgroundColor.value || DEFAULT_URL_EMBED_HEADER_BG,
|
||||||
|
borderBottomColor: "rgba(255, 255, 255, 0.18)",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const urlEmbedDrawerBodyStyle = Object.freeze({
|
||||||
|
padding: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
flex: "1 1 auto",
|
||||||
|
minHeight: 0,
|
||||||
|
height: "100%",
|
||||||
|
});
|
||||||
|
|
||||||
|
const urlEmbedFrameRef = ref(null);
|
||||||
|
const urlEmbedSameOrigin = ref(false);
|
||||||
|
const urlEmbedPostMessageNavEnabled = ref(false);
|
||||||
|
const isMobile = ref(false);
|
||||||
|
/** iframe 仅首次挂载绑定 src,后续换址通过 frame.src 或 remount 避免微信 bfcache 残留 */
|
||||||
|
const iframeInitialSrc = ref("");
|
||||||
|
const iframeMountKey = ref(0);
|
||||||
|
|
||||||
|
const urlEmbedDrawerMask = computed(() => !isMobile.value);
|
||||||
|
|
||||||
|
/** 微信移动端关闭抽屉动画,避免过渡期间 pointer-events 异常 */
|
||||||
|
const urlEmbedDrawerTransitionName = computed(() =>
|
||||||
|
isWeChatBrowser() && isMobile.value ? "" : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const urlEmbedShowHistoryNav = computed(
|
||||||
|
() => urlEmbedSameOrigin.value || urlEmbedPostMessageNavEnabled.value
|
||||||
|
);
|
||||||
|
|
||||||
|
const urlEmbedCanHistoryBack = computed(
|
||||||
|
() => urlEmbedStore.canGoBackUrlPage() ||
|
||||||
|
(showToolbar.value && urlEmbedShowHistoryNav.value && contentKind.value === "iframe")
|
||||||
|
);
|
||||||
|
|
||||||
|
const urlEmbedToolbarVisible = computed(() => {
|
||||||
|
if (contentKind.value === "file-fallback") {
|
||||||
|
return urlEmbedStore.canGoBackUrlPage() || true;
|
||||||
|
}
|
||||||
|
return urlEmbedCanHistoryBack.value || showNewWindowButton.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateIsMobile() {
|
||||||
|
isMobile.value = isMobileViewport();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFrameNavFlags() {
|
||||||
|
urlEmbedSameOrigin.value = false;
|
||||||
|
urlEmbedPostMessageNavEnabled.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleEmbedFrameFocusRestore() {
|
||||||
|
if (!visible.value || contentKind.value !== "iframe") return;
|
||||||
|
const frame = urlEmbedFrameRef.value;
|
||||||
|
if (!frame) return;
|
||||||
|
scheduleHostEmbedFrameRestoreForEnv(frame, urlEmbedSameOrigin.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function remountEmbedFrameForWechat() {
|
||||||
|
if (!isWeChatBrowser() || !isMobile.value) return;
|
||||||
|
iframeMountKey.value += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUrlEmbedDrawerAfterOpenChange(open) {
|
||||||
|
if (open) nextTick(() => scheduleEmbedFrameFocusRestore());
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(visible, (v) => {
|
||||||
|
if (!v) {
|
||||||
|
cancelHostEmbedFrameRestoreBurst();
|
||||||
|
teardownEmbedFrame(urlEmbedFrameRef.value);
|
||||||
|
resetFrameNavFlags();
|
||||||
|
iframeInitialSrc.value = "";
|
||||||
|
iframeMountKey.value = 0;
|
||||||
|
Modal.destroyAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nextTick(() => scheduleEmbedFrameFocusRestore());
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [contentKind.value, src.value],
|
||||||
|
([kind, nextSrc]) => {
|
||||||
|
if (kind === "iframe" && nextSrc) {
|
||||||
|
iframeInitialSrc.value = nextSrc;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [src.value, contentKind.value, visible.value],
|
||||||
|
([nextSrc, nextKind, open]) => {
|
||||||
|
if (!open || nextKind !== "iframe" || !nextSrc) return;
|
||||||
|
nextTick(() => {
|
||||||
|
const frame = urlEmbedFrameRef.value;
|
||||||
|
if (!frame) return;
|
||||||
|
try {
|
||||||
|
if (frame.src !== nextSrc) {
|
||||||
|
frame.src = nextSrc;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
frame.src = nextSrc;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function urlEmbedOnWindowMessage(ev) {
|
||||||
|
if (!visible.value) return;
|
||||||
|
const frame = urlEmbedFrameRef.value;
|
||||||
|
if (!frame?.contentWindow || ev.source !== frame.contentWindow) return;
|
||||||
|
const data = ev.data;
|
||||||
|
if (!data || typeof data !== "object") return;
|
||||||
|
if (data.type === URL_EMBED_MSG.NAV_AVAILABLE) {
|
||||||
|
urlEmbedPostMessageNavEnabled.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.type === URL_EMBED_MSG.OPEN_URL) {
|
||||||
|
const nextUrl = typeof data.url === "string" ? data.url.trim() : "";
|
||||||
|
if (!nextUrl) return;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(nextUrl, window.location.href);
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.openInNewWindow === true) {
|
||||||
|
openUrlByAnchor(nextUrl);
|
||||||
|
scheduleEmbedFrameFocusRestore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = { url: nextUrl, push: true };
|
||||||
|
if (typeof data.title === "string" && data.title.trim()) {
|
||||||
|
payload.title = data.title.trim();
|
||||||
|
}
|
||||||
|
if (data.showToolbar != null) payload.showToolbar = !!data.showToolbar;
|
||||||
|
if (data.showNewWindowButton != null) {
|
||||||
|
payload.showNewWindowButton = !!data.showNewWindowButton;
|
||||||
|
}
|
||||||
|
if (payload.showToolbar == null) payload.showToolbar = true;
|
||||||
|
urlEmbedStore.openUrl(payload);
|
||||||
|
nextTick(() => scheduleEmbedFrameFocusRestore());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlEmbedOnFrameLoad() {
|
||||||
|
resetFrameNavFlags();
|
||||||
|
const frame = urlEmbedFrameRef.value;
|
||||||
|
const win = frame?.contentWindow;
|
||||||
|
if (!win) return;
|
||||||
|
try {
|
||||||
|
void win.location.href;
|
||||||
|
urlEmbedSameOrigin.value = true;
|
||||||
|
} catch {
|
||||||
|
urlEmbedSameOrigin.value = false;
|
||||||
|
try {
|
||||||
|
win.postMessage({ type: URL_EMBED_MSG.QUERY_NAV }, "*");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduleHostEmbedFrameRestoreForEnv(frame, urlEmbedSameOrigin.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlEmbedHistoryBack() {
|
||||||
|
if (urlEmbedStore.goBackUrlPage()) {
|
||||||
|
remountEmbedFrameForWechat();
|
||||||
|
nextTick(() => scheduleEmbedFrameFocusRestore());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = urlEmbedFrameRef.value?.contentWindow;
|
||||||
|
if (!win) return;
|
||||||
|
if (urlEmbedSameOrigin.value) {
|
||||||
|
if (!win.history) return;
|
||||||
|
try {
|
||||||
|
win.history.back();
|
||||||
|
} catch (err) {
|
||||||
|
showErrorTips((err && err.message) || "无法在嵌入页中后退,可能被跨域安全策略限制");
|
||||||
|
}
|
||||||
|
scheduleEmbedFrameFocusRestore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (urlEmbedPostMessageNavEnabled.value) {
|
||||||
|
try {
|
||||||
|
win.postMessage({ type: URL_EMBED_MSG.NAV, action: "back" }, "*");
|
||||||
|
} catch (err) {
|
||||||
|
showErrorTips((err && err.message) || "无法通知嵌入页后退,请检查 postMessage 实现");
|
||||||
|
}
|
||||||
|
scheduleEmbedFrameFocusRestore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlEmbedHistoryForward() {
|
||||||
|
const win = urlEmbedFrameRef.value?.contentWindow;
|
||||||
|
if (!win) return;
|
||||||
|
if (urlEmbedSameOrigin.value) {
|
||||||
|
if (!win.history) return;
|
||||||
|
try {
|
||||||
|
win.history.forward();
|
||||||
|
} catch (err) {
|
||||||
|
showErrorTips((err && err.message) || "无法在嵌入页中前进,可能被跨域安全策略限制");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (urlEmbedPostMessageNavEnabled.value) {
|
||||||
|
try {
|
||||||
|
win.postMessage({ type: URL_EMBED_MSG.NAV, action: "forward" }, "*");
|
||||||
|
} catch (err) {
|
||||||
|
showErrorTips((err && err.message) || "无法通知嵌入页前进,请检查 postMessage 实现");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlEmbedOpenExternal() {
|
||||||
|
const targetUrl = src.value;
|
||||||
|
if (!targetUrl) return;
|
||||||
|
openUrlByAnchor(targetUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUrlEmbedDrawerMaskCloseEvent(ev) {
|
||||||
|
if (!ev || !ev.target || typeof ev.target.closest !== "function") return false;
|
||||||
|
return Boolean(
|
||||||
|
ev.target.closest(".iview-url-embed-drawer-root .ant-drawer-mask"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrawerClose(ev) {
|
||||||
|
if (isUrlEmbedDrawerMaskCloseEvent(ev)) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: "确认关闭",
|
||||||
|
content: "确定要关闭当前页面吗?",
|
||||||
|
okText: "关闭",
|
||||||
|
cancelText: "取消",
|
||||||
|
transitionName: "ant-fade",
|
||||||
|
maskTransitionName: "ant-fade",
|
||||||
|
mousePosition: false,
|
||||||
|
onOk() {
|
||||||
|
urlEmbedStore.closeUrlPage();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
urlEmbedStore.closeUrlPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHostPopState() {
|
||||||
|
scheduleEmbedFrameFocusRestore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHostVisibilityChange() {
|
||||||
|
if (document.visibilityState === "visible") scheduleEmbedFrameFocusRestore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHostPageShow() {
|
||||||
|
scheduleEmbedFrameFocusRestore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHostWindowFocus() {
|
||||||
|
scheduleEmbedFrameFocusRestore();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateIsMobile();
|
||||||
|
window.addEventListener("resize", updateIsMobile);
|
||||||
|
window.addEventListener("message", urlEmbedOnWindowMessage);
|
||||||
|
window.addEventListener("popstate", onHostPopState);
|
||||||
|
window.addEventListener("pageshow", onHostPageShow);
|
||||||
|
window.addEventListener("focus", onHostWindowFocus);
|
||||||
|
document.addEventListener("visibilitychange", onHostVisibilityChange);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
cancelHostEmbedFrameRestoreBurst();
|
||||||
|
window.removeEventListener("resize", updateIsMobile);
|
||||||
|
window.removeEventListener("message", urlEmbedOnWindowMessage);
|
||||||
|
window.removeEventListener("popstate", onHostPopState);
|
||||||
|
window.removeEventListener("pageshow", onHostPageShow);
|
||||||
|
window.removeEventListener("focus", onHostWindowFocus);
|
||||||
|
document.removeEventListener("visibilitychange", onHostVisibilityChange);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout-page-url-embed {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-page-url-embed__toolbar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-page-url-embed__frame {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-transform: translateZ(0);
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-page-url-embed__file-fallback {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-page-url-embed__file-hint {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 280px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.iview-url-embed-drawer-root .ant-drawer-close,
|
||||||
|
.iview-url-embed-drawer-root .ant-drawer-title {
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-url-embed-drawer-root.ant-drawer .ant-drawer-content-wrapper {
|
||||||
|
max-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-url-embed-drawer-root .ant-drawer-body {
|
||||||
|
padding: 0 !important;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-url-embed-drawer-root .ant-drawer-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.iview-url-embed-drawer-root.ant-drawer-open .ant-drawer-content-wrapper,
|
||||||
|
.iview-url-embed-drawer-root.ant-drawer-open .ant-drawer-content,
|
||||||
|
.iview-url-embed-drawer-root.ant-drawer-open .ant-drawer-body,
|
||||||
|
.iview-url-embed-drawer-root .layout-page-url-embed {
|
||||||
|
pointer-events: auto !important;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-url-embed-drawer-root.ant-drawer-open .ant-drawer-content-wrapper {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
174
src/iview/IViewWebView.vue
Normal file
174
src/iview/IViewWebView.vue
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<template>
|
||||||
|
<div class="iview-web-view">
|
||||||
|
<iframe
|
||||||
|
v-if="effectiveSrc && !loadFailed"
|
||||||
|
:key="effectiveSrc"
|
||||||
|
class="iview-web-view__iframe"
|
||||||
|
:src="effectiveSrc"
|
||||||
|
:title="title"
|
||||||
|
:referrerpolicy="referrerPolicy"
|
||||||
|
@load="onIframeLoad"
|
||||||
|
@error="onIframeError"
|
||||||
|
/>
|
||||||
|
<div v-if="loading" class="iview-web-view__mask">
|
||||||
|
<a-spin :spinning="true" size="large" :tip="spinTip" />
|
||||||
|
</div>
|
||||||
|
<div v-if="loadFailed" class="iview-web-view__mask iview-web-view__mask--error">
|
||||||
|
<a-result
|
||||||
|
status="error"
|
||||||
|
:title="errorTitle"
|
||||||
|
:sub-title="errorSubTitle"
|
||||||
|
>
|
||||||
|
<template #extra>
|
||||||
|
<a-button type="primary" @click="retry">重新加载</a-button>
|
||||||
|
</template>
|
||||||
|
</a-result>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
src: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: "页面",
|
||||||
|
},
|
||||||
|
referrerPolicy: {
|
||||||
|
type: String,
|
||||||
|
default: "strict-origin-when-cross-origin",
|
||||||
|
},
|
||||||
|
loadTimeoutMs: {
|
||||||
|
type: Number,
|
||||||
|
default: 45_000,
|
||||||
|
},
|
||||||
|
spinTip: {
|
||||||
|
type: String,
|
||||||
|
default: "加载中…",
|
||||||
|
},
|
||||||
|
errorTitle: {
|
||||||
|
type: String,
|
||||||
|
default: "页面加载失败",
|
||||||
|
},
|
||||||
|
errorSubTitle: {
|
||||||
|
type: String,
|
||||||
|
default: "请检查网络或地址后重试",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["load", "error", "retry"]);
|
||||||
|
|
||||||
|
const reloadNonce = ref(0);
|
||||||
|
const loading = ref(false);
|
||||||
|
const loadFailed = ref(false);
|
||||||
|
let loadTimeoutId = null;
|
||||||
|
|
||||||
|
const effectiveSrc = computed(() => {
|
||||||
|
if (!props.src) return "";
|
||||||
|
if (reloadNonce.value === 0) return props.src;
|
||||||
|
const sep = props.src.includes("?") ? "&" : "?";
|
||||||
|
return `${props.src}${sep}_wb_retry=${reloadNonce.value}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function clearLoadTimeout() {
|
||||||
|
if (loadTimeoutId != null) {
|
||||||
|
clearTimeout(loadTimeoutId);
|
||||||
|
loadTimeoutId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleLoadTimeout() {
|
||||||
|
clearLoadTimeout();
|
||||||
|
if (!props.src || props.loadTimeoutMs <= 0) return;
|
||||||
|
loadTimeoutId = window.setTimeout(() => {
|
||||||
|
if (!loading.value) return;
|
||||||
|
loading.value = false;
|
||||||
|
loadFailed.value = true;
|
||||||
|
}, props.loadTimeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.src,
|
||||||
|
(src) => {
|
||||||
|
clearLoadTimeout();
|
||||||
|
reloadNonce.value = 0;
|
||||||
|
if (!src) {
|
||||||
|
loading.value = false;
|
||||||
|
loadFailed.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
loadFailed.value = false;
|
||||||
|
scheduleLoadTimeout();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
function onIframeLoad() {
|
||||||
|
clearLoadTimeout();
|
||||||
|
loading.value = false;
|
||||||
|
loadFailed.value = false;
|
||||||
|
emit("load");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onIframeError() {
|
||||||
|
clearLoadTimeout();
|
||||||
|
loading.value = false;
|
||||||
|
loadFailed.value = true;
|
||||||
|
emit("error");
|
||||||
|
}
|
||||||
|
|
||||||
|
function retry() {
|
||||||
|
loadFailed.value = false;
|
||||||
|
loading.value = true;
|
||||||
|
reloadNonce.value += 1;
|
||||||
|
scheduleLoadTimeout();
|
||||||
|
emit("retry");
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearLoadTimeout();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.iview-web-view {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-web-view__iframe {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-web-view__mask {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-web-view__mask--error {
|
||||||
|
background: #fff;
|
||||||
|
padding: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
23
src/iview/display/IDisplayFormTips.vue
Normal file
23
src/iview/display/IDisplayFormTips.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="field.tips" class="iview-form-tips">
|
||||||
|
<div v-html="field.tips" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
field: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.iview-form-tips {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
text-align: left;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
234
src/iview/display/IDisplayImageView.vue
Normal file
234
src/iview/display/IDisplayImageView.vue
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<template>
|
||||||
|
<div class="custom-image-view">
|
||||||
|
<a-image-preview-group v-if="previewImageCount > 1">
|
||||||
|
<div class="image-list">
|
||||||
|
<div v-for="index in displayCount" :key="`image-${index}`" class="image-list-item" :style="computedStyle">
|
||||||
|
<a-image :src="getDisplaySrc(index - 1)" :width="width" :height="height" :alt="alt" :style="computedStyle"
|
||||||
|
:img-style="imageImgStyle" :preview="getPreviewOptions(index - 1)">
|
||||||
|
<template #previewMask>
|
||||||
|
<EyeOutlined />
|
||||||
|
</template>
|
||||||
|
</a-image>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="previewOnlyIndices.length > 0" class="image-preview-only-group" aria-hidden="true">
|
||||||
|
<a-image v-for="index in previewOnlyIndices" :key="`preview-only-${index}`" class="image-preview-only"
|
||||||
|
:src="getImageUrlByIndex(index)" :preview="getPreviewOptions(index)" />
|
||||||
|
</div>
|
||||||
|
</a-image-preview-group>
|
||||||
|
|
||||||
|
<div v-else-if="previewImageCount === 1" :style="computedStyle">
|
||||||
|
<a-image :src="getDisplaySrc(0)" :width="width" :height="height" :alt="alt" :style="computedStyle"
|
||||||
|
:img-style="imageImgStyle" :preview="getPreviewOptions(0)">
|
||||||
|
<template #previewMask>
|
||||||
|
<EyeOutlined />
|
||||||
|
</template>
|
||||||
|
</a-image>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else :style="computedStyle">
|
||||||
|
<a-image :src="imageDefaultBase64" :width="width" :height="height" :alt="alt" :style="computedStyle"
|
||||||
|
:img-style="imageImgStyle" :preview="false" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { EyeOutlined } from "@ant-design/icons-vue";
|
||||||
|
import { imageDefaultBase64 } from "@/common/image";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
imageUrl: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
showCount: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
previewCount: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
splitDelimiter: {
|
||||||
|
type: [String, Array],
|
||||||
|
default: () => [",", "|"],
|
||||||
|
},
|
||||||
|
styleValue: {
|
||||||
|
type: [String, Object],
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
type: String,
|
||||||
|
default: "100%",
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: String,
|
||||||
|
default: "100%",
|
||||||
|
},
|
||||||
|
objectFit: {
|
||||||
|
type: String,
|
||||||
|
default: "cover",
|
||||||
|
},
|
||||||
|
alt: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const computedStyle = computed(() => ({
|
||||||
|
display: "block",
|
||||||
|
width: props.width,
|
||||||
|
height: props.height,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const imageImgStyle = computed(() => ({
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: props.objectFit,
|
||||||
|
objectPosition: "center",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const DEFAULT_SPLIT_DELIMITERS = [",", "|"];
|
||||||
|
|
||||||
|
function normalizeSplitDelimiters(delimiters) {
|
||||||
|
if (typeof delimiters === "string") {
|
||||||
|
const token = delimiters.trim();
|
||||||
|
return token ? [token] : DEFAULT_SPLIT_DELIMITERS;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(delimiters)) return DEFAULT_SPLIT_DELIMITERS;
|
||||||
|
const tokens = delimiters.map((item) => `${item ?? ""}`.trim()).filter(Boolean);
|
||||||
|
return tokens.length > 0 ? tokens : DEFAULT_SPLIT_DELIMITERS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExpChar(char) {
|
||||||
|
return char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按多种分隔符拆分图片 URL(splitDelimiter 支持字符串或数组) */
|
||||||
|
function splitImageUrlByDelimiters({ text, delimiters }) {
|
||||||
|
const raw = `${text ?? ""}`.trim();
|
||||||
|
if (!raw) return [];
|
||||||
|
|
||||||
|
const tokens = normalizeSplitDelimiters(delimiters);
|
||||||
|
if (tokens.length === 0) return [raw];
|
||||||
|
|
||||||
|
const pattern = new RegExp(tokens.map(escapeRegExpChar).join("|"));
|
||||||
|
return raw.split(pattern).map((part) => part.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrls = computed(() =>
|
||||||
|
splitImageUrlByDelimiters({
|
||||||
|
text: props.imageUrl,
|
||||||
|
delimiters: props.splitDelimiter,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayCount = computed(() => {
|
||||||
|
const total = imageUrls.value.length;
|
||||||
|
if (total <= 0) return 0;
|
||||||
|
const showLimit = Number(props.showCount);
|
||||||
|
if (Number.isFinite(showLimit) && showLimit > 0 && showLimit < total) {
|
||||||
|
return showLimit;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewImageCount = computed(() => {
|
||||||
|
const total = imageUrls.value.length;
|
||||||
|
if (total <= 0) return 0;
|
||||||
|
if (props.previewCount == null || props.previewCount === "") {
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
const previewLimit = Number(props.previewCount);
|
||||||
|
if (!Number.isFinite(previewLimit) || previewLimit <= 0) {
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
return Math.min(Math.round(previewLimit), total);
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewOnlyIndices = computed(() => {
|
||||||
|
const start = displayCount.value;
|
||||||
|
const end = previewImageCount.value;
|
||||||
|
if (end <= start) return [];
|
||||||
|
return Array.from({ length: end - start }, (_, index) => start + index);
|
||||||
|
});
|
||||||
|
|
||||||
|
function getImageUrlByIndex(index) {
|
||||||
|
if (imageUrls.value.length > index) {
|
||||||
|
return imageUrls.value[index];
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplaySrc(index) {
|
||||||
|
return getImageUrlByIndex(index) || imageDefaultBase64;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewOptions(index) {
|
||||||
|
const url = getImageUrlByIndex(index);
|
||||||
|
if (!url) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
src: url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.custom-image-view {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.image-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-list-item {
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview-only-group {
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview-only-group .image-preview-only,
|
||||||
|
.image-preview-only-group .ant-image {
|
||||||
|
width: 0 !important;
|
||||||
|
height: 0 !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-image) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-image-img) {
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-image-preview-operations {
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #007AFF;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
19
src/iview/display/IDisplayObjectView.vue
Normal file
19
src/iview/display/IDisplayObjectView.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<template>
|
||||||
|
<slot name="view" :value="objectValue" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { toAny } from "@/iview/utils/toany";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
value: {
|
||||||
|
type: [String, Object],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const objectValue = computed(() => {
|
||||||
|
return toAny(props.value, {});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
85
src/iview/display/IDisplayStringLabel.vue
Normal file
85
src/iview/display/IDisplayStringLabel.vue
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="strListArray && strListArray.length > 0"
|
||||||
|
:style="{
|
||||||
|
gap: tagGap + 'px',
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot name="before"></slot>
|
||||||
|
<template v-if="showAll">
|
||||||
|
<a-tag :color="tagColor" :key="'str-i-' + item" v-for="(item, i) in strListArray">
|
||||||
|
{{ item }} <CloseOutlined v-if="closable" @click="handleCloseClick(i)" />
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span>
|
||||||
|
{{ strListArray[0] }}
|
||||||
|
</span>
|
||||||
|
<span v-if="strListArray.length > 1" style="color: #676767">
|
||||||
|
<span style="margin-right: 5px">...</span>
|
||||||
|
<a-tag :color="tagColor" size="mini">
|
||||||
|
{{ unitPrefix }}{{ strListArray.length }}{{ unitName }}
|
||||||
|
</a-tag>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<slot name="default"></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { CloseOutlined } from "@ant-design/icons-vue";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const emit = defineEmits(["close"]);
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
strList: {
|
||||||
|
type: [String, Array],
|
||||||
|
},
|
||||||
|
showAll: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
closable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
tagColor: {
|
||||||
|
type: String,
|
||||||
|
default: "#c1c1c1",
|
||||||
|
},
|
||||||
|
tagGap: {
|
||||||
|
type: Number,
|
||||||
|
default: 5,
|
||||||
|
},
|
||||||
|
itemSplit: {
|
||||||
|
type: String,
|
||||||
|
default: ",",
|
||||||
|
},
|
||||||
|
unitPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: "共",
|
||||||
|
},
|
||||||
|
unitName: {
|
||||||
|
type: String,
|
||||||
|
default: "项",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const strListArray = computed(() => {
|
||||||
|
if (props.strList && props.strList.length > 0) {
|
||||||
|
if (typeof props.strList === "string") {
|
||||||
|
return `${props.strList}`.split(props.itemSplit).filter((item) => !!item);
|
||||||
|
} else if (Array.isArray(props.strList)) {
|
||||||
|
return props.strList.filter((item) => !!item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCloseClick = (i) => {
|
||||||
|
emit("close", i);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
70
src/iview/form/IViewFormBoolean.vue
Normal file
70
src/iview/form/IViewFormBoolean.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<div class="iview-form-boolean-container">
|
||||||
|
<a-input
|
||||||
|
type="text"
|
||||||
|
style="display: none"
|
||||||
|
:id="'form_item_' + formatFieldName(field.name)"
|
||||||
|
/>
|
||||||
|
<a-form-item-rest>
|
||||||
|
<a-switch
|
||||||
|
v-model:checked="vModel[formatFieldName(field.name)]"
|
||||||
|
:disabled="readOnly"
|
||||||
|
:checkedValue="fieldTrueValue"
|
||||||
|
:unCheckedValue="fieldFalseValue"
|
||||||
|
/>
|
||||||
|
</a-form-item-rest>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useVModel } from "@/iview/utils/useModel";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
field: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
fieldPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
readOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
rawFormData: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
const vModel = useVModel(props, "modelValue", emit);
|
||||||
|
|
||||||
|
const fieldFalseValue = computed(() => {
|
||||||
|
let falseValue = props.field.falseValue;
|
||||||
|
if (typeof falseValue !== "undefined") {
|
||||||
|
return falseValue;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fieldTrueValue = computed(() => {
|
||||||
|
let trueValue = props.field.trueValue;
|
||||||
|
if (typeof trueValue !== "undefined") {
|
||||||
|
return trueValue;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatFieldName = (name) => {
|
||||||
|
if (!props.fieldPrefix) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return props.fieldPrefix + "-" + name;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
260
src/iview/form/IViewFormCheckbox.vue
Normal file
260
src/iview/form/IViewFormCheckbox.vue
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
<template>
|
||||||
|
<div class="iview-form-checkbox-container">
|
||||||
|
<a-input
|
||||||
|
type="text"
|
||||||
|
style="display: none"
|
||||||
|
:id="'form_item_' + formatFieldName(field.name)"
|
||||||
|
/>
|
||||||
|
<a-form-item-rest>
|
||||||
|
<div v-if="supportedRemoteSearch" style="margin-bottom: 8px">
|
||||||
|
<a-input-search
|
||||||
|
v-model:value="searchInputValue"
|
||||||
|
:placeholder="field.searchPlaceholder || '输入关键字搜索'"
|
||||||
|
:loading="remoteSearchLoading"
|
||||||
|
allow-clear
|
||||||
|
@search="handleSearch"
|
||||||
|
@input="handleSearchInput"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="remoteSearchLoading || remoteOptionsLoading" style="padding: 8px 0">
|
||||||
|
<a-spin size="small" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="remoteSearchErrorMsg || remoteOptionsErrorMsg"
|
||||||
|
style="padding: 8px 0; color: #ff4d4f"
|
||||||
|
>
|
||||||
|
{{ remoteSearchErrorMsg || remoteOptionsErrorMsg }}
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
type="default"
|
||||||
|
style="margin-left: 8px"
|
||||||
|
@click="handleRefresh"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<a-checkbox-group
|
||||||
|
v-else
|
||||||
|
v-model:value="vModel[formatFieldName(field.name)]"
|
||||||
|
:disabled="readOnly"
|
||||||
|
>
|
||||||
|
<a-checkbox
|
||||||
|
v-for="option in parseOptions(field.options)"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ readLabel(option.label) }}
|
||||||
|
<template v-if="showValueTag">
|
||||||
|
<span style="color: #999; font-size: 12px"> {{ option.value }} </span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span v-if="!!readTag(option.label)" style="color: #999; font-size: 12px">
|
||||||
|
{{ readTag(option.label) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</a-checkbox>
|
||||||
|
</a-checkbox-group>
|
||||||
|
</a-form-item-rest>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from "vue";
|
||||||
|
import { useVModel } from "@/iview/utils/useModel";
|
||||||
|
import { delayCall } from "@/iview/utils/delayCall";
|
||||||
|
import { callFunc } from "@/iview/utils/func";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
field: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
fieldPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
readOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
rawFormData: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
const vModel = useVModel(props, "modelValue", emit);
|
||||||
|
|
||||||
|
const remoteOptionsData = ref([]);
|
||||||
|
const remoteOptionsLoading = ref(false);
|
||||||
|
const remoteOptionsErrorMsg = ref(null);
|
||||||
|
|
||||||
|
const remoteSearchValue = ref("");
|
||||||
|
const remoteSearchData = ref([]);
|
||||||
|
const remoteSearchLoading = ref(false);
|
||||||
|
const remoteSearchErrorMsg = ref(null);
|
||||||
|
const remoteSearchId = ref(0);
|
||||||
|
const searchInputValue = ref("");
|
||||||
|
|
||||||
|
const supportedRemoteSearch = computed(() => {
|
||||||
|
return (
|
||||||
|
!!props.field.remoteSearchMethod &&
|
||||||
|
typeof props.field.remoteSearchMethod === "function"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const supportedRemoteOptions = computed(() => {
|
||||||
|
return (
|
||||||
|
!!props.field.remoteOptionsMethod &&
|
||||||
|
typeof props.field.remoteOptionsMethod === "function"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const showValueTag = computed(() => {
|
||||||
|
return !!props.field.showValueTag;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 解析选项字符串或数组
|
||||||
|
const parseOptions = (options) => {
|
||||||
|
if (remoteSearchData.value && remoteSearchData.value.length > 0) {
|
||||||
|
return remoteSearchData.value;
|
||||||
|
}
|
||||||
|
if (remoteOptionsData.value && remoteOptionsData.value.length > 0) {
|
||||||
|
return remoteOptionsData.value;
|
||||||
|
}
|
||||||
|
if (!options) return [];
|
||||||
|
|
||||||
|
if (Array.isArray(options)) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof options === "string") {
|
||||||
|
return options
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim() !== "")
|
||||||
|
.map((line) => {
|
||||||
|
const parts = line.split("|");
|
||||||
|
return {
|
||||||
|
label: parts[0]?.trim() || "",
|
||||||
|
value: parts[1]?.trim() || parts[0]?.trim() || "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFieldName = (name) => {
|
||||||
|
if (!props.fieldPrefix) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return props.fieldPrefix + "-" + name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readLabel = (value) => {
|
||||||
|
return `${value}`.split("#")[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const readTag = (value) => {
|
||||||
|
return `${value}`.split("#")[1];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoteOptions = () => {
|
||||||
|
if (!supportedRemoteOptions.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remoteOptionsLoading.value = true;
|
||||||
|
remoteOptionsErrorMsg.value = null;
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => {
|
||||||
|
let remoteOptionsMethod = props.field.remoteOptionsMethod;
|
||||||
|
return callFunc([], [], remoteOptionsMethod, []);
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
remoteOptionsData.value = data || [];
|
||||||
|
remoteOptionsLoading.value = false;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
remoteOptionsErrorMsg.value =
|
||||||
|
(err && err.msg) || (err && err.message) || "连接服务器失败,请检查网络";
|
||||||
|
remoteOptionsLoading.value = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoteSearchValue = delayCall((value) => {
|
||||||
|
remoteSearchId.value += 1;
|
||||||
|
remoteSearchValue.value = value;
|
||||||
|
const currentRemoteSearchId = remoteSearchId.value;
|
||||||
|
remoteSearchLoading.value = true;
|
||||||
|
remoteSearchErrorMsg.value = null;
|
||||||
|
remoteSearchData.value = [];
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => {
|
||||||
|
let remoteSearchMethod = props.field.remoteSearchMethod;
|
||||||
|
return callFunc(["value"], [value], remoteSearchMethod, []);
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (currentRemoteSearchId !== remoteSearchId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remoteSearchData.value = data || [];
|
||||||
|
remoteSearchLoading.value = false;
|
||||||
|
remoteSearchValue.value = null;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
remoteSearchErrorMsg.value =
|
||||||
|
(err && err.msg) || (err && err.message) || "连接服务器失败,请检查网络";
|
||||||
|
remoteSearchLoading.value = false;
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
const handleSearch = (value) => {
|
||||||
|
if (supportedRemoteSearch.value && value) {
|
||||||
|
handleRemoteSearchValue(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchInput = (e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (supportedRemoteSearch.value) {
|
||||||
|
if (value) {
|
||||||
|
handleRemoteSearchValue(value);
|
||||||
|
} else {
|
||||||
|
remoteSearchData.value = [];
|
||||||
|
remoteSearchErrorMsg.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
if (supportedRemoteSearch.value && !!remoteSearchValue.value) {
|
||||||
|
handleRemoteSearchValue(remoteSearchValue.value);
|
||||||
|
}
|
||||||
|
if (supportedRemoteOptions.value) {
|
||||||
|
handleRemoteOptions();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
handleRemoteOptions();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.ant-checkbox-group) {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-checkbox-wrapper) {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 16px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
91
src/iview/form/IViewFormDate.vue
Normal file
91
src/iview/form/IViewFormDate.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<template>
|
||||||
|
<a-date-picker
|
||||||
|
style="width: 100%"
|
||||||
|
:value="currentValue"
|
||||||
|
:id="'form_item_' + formatFieldName(field.name)"
|
||||||
|
:disabled="readOnly"
|
||||||
|
:clearable="field.clearable"
|
||||||
|
:disabled-date="(date) => getDisabledDate(date, field)"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:format="dateFormat"
|
||||||
|
@change="handleChange"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useVModel } from "@/iview/utils/useModel";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
field: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
fieldPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: "请选择",
|
||||||
|
},
|
||||||
|
readOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
rawFormData: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
const vModel = useVModel(props, "modelValue", emit);
|
||||||
|
|
||||||
|
const getDisabledDate = (date, field) => {
|
||||||
|
if (!date) return false;
|
||||||
|
|
||||||
|
if (field.minDate && dayjs(field.minDate).isValid()) {
|
||||||
|
if (date.isBefore(dayjs(field.minDate), "day")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.maxDate && dayjs(field.maxDate).isValid()) {
|
||||||
|
if (date.isAfter(dayjs(field.maxDate), "day")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFieldName = (name) => {
|
||||||
|
if (!props.fieldPrefix) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return props.fieldPrefix + "-" + name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateFormat = computed(() => {
|
||||||
|
return props.field.dateFormat || "YYYY-MM-DD";
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChange = (value) => {
|
||||||
|
vModel.value[formatFieldName(props.field.name)] = !!value
|
||||||
|
? value.format(dateFormat.value)
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentValue = computed(() => {
|
||||||
|
let value = vModel.value[formatFieldName(props.field.name)];
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return dayjs(value, dateFormat.value);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
107
src/iview/form/IViewFormDatetime.vue
Normal file
107
src/iview/form/IViewFormDatetime.vue
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<template>
|
||||||
|
<a-date-picker
|
||||||
|
show-time
|
||||||
|
style="width: 100%"
|
||||||
|
v-model:value="inputValue"
|
||||||
|
:id="'form_item_' + formatFieldName(field.name)"
|
||||||
|
:disabled="readOnly"
|
||||||
|
:clearable="field.clearable"
|
||||||
|
:disabled-date="(date) => getDisabledDate(date, field)"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:presets="presets"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { ref, computed, onMounted, watch } from "vue";
|
||||||
|
import { useVModel } from "@/iview/utils/useModel";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
field: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
fieldPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: "请选择",
|
||||||
|
},
|
||||||
|
readOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
rawFormData: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
const vModel = useVModel(props, "modelValue", emit);
|
||||||
|
const inputValue = ref(undefined);
|
||||||
|
|
||||||
|
const presets = computed(() => {
|
||||||
|
if (props.field.presets) {
|
||||||
|
return props.field.presets;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getDisabledDate = (date, field) => {
|
||||||
|
if (!date) return false;
|
||||||
|
|
||||||
|
if (field.minDate && dayjs(field.minDate).isValid()) {
|
||||||
|
if (date.isBefore(dayjs(field.minDate), "day")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.maxDate && dayjs(field.maxDate).isValid()) {
|
||||||
|
if (date.isAfter(dayjs(field.maxDate), "day")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFieldName = (name) => {
|
||||||
|
if (!props.fieldPrefix) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return props.fieldPrefix + "-" + name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (value) => {
|
||||||
|
vModel.value[formatFieldName(props.field.name)] = !!value ? value.valueOf() : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initInputValue = () => {
|
||||||
|
let currentValue = vModel.value[formatFieldName(props.field.name)];
|
||||||
|
if (!!currentValue && +currentValue > 0) {
|
||||||
|
inputValue.value = dayjs(new Date(currentValue));
|
||||||
|
} else {
|
||||||
|
inputValue.value = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => {
|
||||||
|
return inputValue.value;
|
||||||
|
},
|
||||||
|
(newVal) => {
|
||||||
|
handleChange(newVal);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initInputValue();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
128
src/iview/form/IViewFormFileBase64.vue
Normal file
128
src/iview/form/IViewFormFileBase64.vue
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<template>
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="vModel[formatFieldName(field.name)]"
|
||||||
|
:id="'form_item_' + formatFieldName(field.name)"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:rows="+field.textRows || 4"
|
||||||
|
:maxlength="+field.textLenMax"
|
||||||
|
:show-count="+field.textLenMax > 0 && +field.textLenMax < 10000"
|
||||||
|
:disabled="readOnly"
|
||||||
|
:clearable="field.clearable"
|
||||||
|
/>
|
||||||
|
<div v-if="!field.disabled && !readOnly" class="upload-container">
|
||||||
|
<a-form-item-rest>
|
||||||
|
<a-upload
|
||||||
|
:id="'form_item_' + formatFieldName(field.name) + '_upload'"
|
||||||
|
:file-list="[]"
|
||||||
|
:maxCount="1"
|
||||||
|
:show-upload-list="false"
|
||||||
|
:beforeUpload="beforeUpload"
|
||||||
|
>
|
||||||
|
<a-button :loading="loading">
|
||||||
|
<span>
|
||||||
|
<upload-outlined />
|
||||||
|
选择文件转Base64
|
||||||
|
</span>
|
||||||
|
</a-button>
|
||||||
|
</a-upload>
|
||||||
|
|
||||||
|
<div v-if="field.fileSize" class="upload-hint">
|
||||||
|
<span class="upload-hint-limit">
|
||||||
|
<template v-if="field.fileSize"> 最大{{ field.fileSize }}MB </template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a-form-item-rest>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useVModel } from "@/iview/utils/useModel";
|
||||||
|
import { UploadOutlined } from "@ant-design/icons-vue";
|
||||||
|
import { showErrorTips } from "@/iview/utils/messageTips";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
field: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
fieldPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: "请输入或上传",
|
||||||
|
},
|
||||||
|
readOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
rawFormData: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
const vModel = useVModel(props, "modelValue", emit);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const formatFieldName = (name) => {
|
||||||
|
if (!props.fieldPrefix) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return props.fieldPrefix + "-" + name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const beforeUpload = async (file) => {
|
||||||
|
if (
|
||||||
|
props.field.fileSize &&
|
||||||
|
props.field.fileSize > 0 &&
|
||||||
|
file.size > props.field.fileSize * 1024 * 1024
|
||||||
|
) {
|
||||||
|
let error = new Error(`文件大小不能超过${props.field.fileSize}MB`);
|
||||||
|
showErrorTips(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await uploadFile(file);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFile = async (file) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
let key = formatFieldName(props.field.name);
|
||||||
|
vModel.value[key] = "";
|
||||||
|
const base64 = await fileToBase64(file);
|
||||||
|
vModel.value[key] = base64.split(",")[1];
|
||||||
|
} catch (error) {
|
||||||
|
showErrorTips(error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileToBase64 = (file) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const base64String = reader.result;
|
||||||
|
resolve(base64String);
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.upload-container {
|
||||||
|
margin-top: 10px;
|
||||||
|
color: #999999;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
504
src/iview/form/IViewFormImageBase64.vue
Normal file
504
src/iview/form/IViewFormImageBase64.vue
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
<template>
|
||||||
|
<div class="iqudoo-form-image-base64">
|
||||||
|
<div class="image-grid">
|
||||||
|
<div
|
||||||
|
v-if="fileFormModel.length > 0"
|
||||||
|
v-for="(item, index) in fileFormModel"
|
||||||
|
class="image-item"
|
||||||
|
:class="{
|
||||||
|
dragging: draggingIndex === index,
|
||||||
|
'drag-over': draggedOverIndex === index && draggingIndex !== index,
|
||||||
|
}"
|
||||||
|
:key="index"
|
||||||
|
:draggable="dragToSort && !field.disabled && !readOnly"
|
||||||
|
@dragstart="handleDragStart(index)"
|
||||||
|
@dragend="handleDragEnd"
|
||||||
|
@dragover="handleDragOver"
|
||||||
|
@dragenter="handleDragEnter(index)"
|
||||||
|
@dragleave="handleDragLeave"
|
||||||
|
@drop="handleDrop(index)"
|
||||||
|
>
|
||||||
|
<div style="height: 120px; width: 120px">
|
||||||
|
<IDisplayImageView
|
||||||
|
:image-url="toDisplayUrl(item)"
|
||||||
|
:show-count="1"
|
||||||
|
width="120px"
|
||||||
|
height="120px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<a-popconfirm
|
||||||
|
title="确定要删除这张图片吗?"
|
||||||
|
v-if="!field.disabled && !readOnly"
|
||||||
|
ok-text="删除"
|
||||||
|
cancel-text="取消"
|
||||||
|
@confirm="removeFile(index)"
|
||||||
|
placement="topRight"
|
||||||
|
>
|
||||||
|
<div class="delete-button-wrapper">
|
||||||
|
<a-button type="danger" shape="circle" size="small" class="mobile-delete-btn">
|
||||||
|
<template #icon><delete-outlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</a-popconfirm>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-show="readOnly || fileFormModel.length < maxFileCount"
|
||||||
|
class="upload-trigger"
|
||||||
|
>
|
||||||
|
<a-form-item-rest>
|
||||||
|
<a-upload
|
||||||
|
:id="'form_item_' + formatFieldName(field.name)"
|
||||||
|
:max-count="maxFileCount"
|
||||||
|
:show-upload-list="false"
|
||||||
|
:before-upload="beforeUpload"
|
||||||
|
:accept="acceptedImageTypes"
|
||||||
|
:multiple="multipleSelect"
|
||||||
|
>
|
||||||
|
<div class="upload-button">
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<plus-outlined />
|
||||||
|
<div class="upload-text">
|
||||||
|
{{ multipleSelect ? "选择图片" : "选择图片转Base64" }}
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
</a-upload>
|
||||||
|
</a-form-item-rest>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="upload-hint">
|
||||||
|
<span>
|
||||||
|
{{ fileFormModel.length }}/{{ maxFileCount }}张
|
||||||
|
<template v-if="field.fileSize"> · 最大{{ field.fileSize }}MB </template>
|
||||||
|
<template v-if="supportedFormats"> · 支持{{ supportedFormats }} </template>
|
||||||
|
<template v-if="outputSizeHint"> · 输出 {{ outputSizeHint }} </template>
|
||||||
|
<template v-if="multipleSelect"> · 支持多选和拖动排序 </template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { useVModel } from "@/iview/utils/useModel";
|
||||||
|
import { showErrorTips } from "@/iview/utils/messageTips";
|
||||||
|
import { formatImageOutputHint, processImageFile } from "@/iview/utils/imageOutput";
|
||||||
|
import { DeleteOutlined, PlusOutlined } from "@ant-design/icons-vue";
|
||||||
|
import IDisplayImageView from "@/iview/display/IDisplayImageView.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
field: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
accept: {
|
||||||
|
type: String,
|
||||||
|
default: "image/jpeg,image/png,image/gif",
|
||||||
|
},
|
||||||
|
fieldPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
multipleSelect: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
dragToSort: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
readOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
rawFormData: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
const vModel = useVModel(props, "modelValue", emit);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const draggingIndex = ref(-1);
|
||||||
|
const draggedOverIndex = ref(-1);
|
||||||
|
|
||||||
|
const maxFileCount = computed(() => {
|
||||||
|
const count = +props.field.fileCount;
|
||||||
|
return count > 0 ? count : 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const supportedFormats = computed(() => {
|
||||||
|
return props.accept
|
||||||
|
.split(",")
|
||||||
|
.map((type) => type.replace("image/", ""))
|
||||||
|
.join("、");
|
||||||
|
});
|
||||||
|
|
||||||
|
const acceptedImageTypes = computed(() => {
|
||||||
|
return props.accept || "image/jpeg,image/png,image/gif";
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputSizeHint = computed(() => formatImageOutputHint(props.field));
|
||||||
|
|
||||||
|
const formatFieldName = (name) => {
|
||||||
|
if (!props.fieldPrefix) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return props.fieldPrefix + "-" + name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileFormModel = computed(() => {
|
||||||
|
const value = vModel.value[formatFieldName(props.field.name)];
|
||||||
|
if (!value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return `${value}`.split("|").filter((item) => !!item);
|
||||||
|
});
|
||||||
|
|
||||||
|
function toDisplayUrl(base64) {
|
||||||
|
if (!base64) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (base64.startsWith("data:")) {
|
||||||
|
return base64;
|
||||||
|
}
|
||||||
|
return `data:image/jpeg;base64,${base64}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFile = (index) => {
|
||||||
|
const newFileList = [...fileFormModel.value];
|
||||||
|
newFileList.splice(index, 1);
|
||||||
|
vModel.value[formatFieldName(props.field.name)] = newFileList.join("|");
|
||||||
|
};
|
||||||
|
|
||||||
|
const beforeUpload = async (file, fileList) => {
|
||||||
|
if (
|
||||||
|
props.multipleSelect &&
|
||||||
|
fileList &&
|
||||||
|
fileList.length > 1 &&
|
||||||
|
fileList.indexOf(file) === 0
|
||||||
|
) {
|
||||||
|
await uploadMultipleFiles(fileList);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
props.multipleSelect &&
|
||||||
|
fileList &&
|
||||||
|
fileList.length > 1 &&
|
||||||
|
fileList.indexOf(file) > 0
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateImageFile(file)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await uploadFile(file);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
function validateImageFile(file) {
|
||||||
|
const isAcceptedType = acceptedImageTypes.value.includes(file.type);
|
||||||
|
if (!isAcceptedType) {
|
||||||
|
showErrorTips(new Error(`只能上传${supportedFormats.value}格式的图片!`));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileFormModel.value.length >= maxFileCount.value) {
|
||||||
|
showErrorTips(new Error(`最多只能选择${maxFileCount.value}张图片`));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
props.field.fileSize &&
|
||||||
|
props.field.fileSize > 0 &&
|
||||||
|
file.size > props.field.fileSize * 1024 * 1024
|
||||||
|
) {
|
||||||
|
showErrorTips(new Error(`图片大小不能超过${props.field.fileSize}MB`));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadFile = async (file) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
const base64 = await processImageFile(file, props.field);
|
||||||
|
const newFileList = [...fileFormModel.value];
|
||||||
|
newFileList.push(base64.split(",")[1]);
|
||||||
|
vModel.value[formatFieldName(props.field.name)] = newFileList.join("|");
|
||||||
|
} catch (error) {
|
||||||
|
showErrorTips(error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadMultipleFiles = async (fileList) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
const remainingSlots = maxFileCount.value - fileFormModel.value.length;
|
||||||
|
if (remainingSlots <= 0) {
|
||||||
|
showErrorTips(new Error(`最多只能选择${maxFileCount.value}张图片`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validFiles = [];
|
||||||
|
for (const file of fileList) {
|
||||||
|
if (!acceptedImageTypes.value.includes(file.type)) {
|
||||||
|
showErrorTips(
|
||||||
|
new Error(`图片 ${file.name} 格式不支持,只支持${supportedFormats.value}格式`)
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
props.field.fileSize &&
|
||||||
|
props.field.fileSize > 0 &&
|
||||||
|
file.size > props.field.fileSize * 1024 * 1024
|
||||||
|
) {
|
||||||
|
showErrorTips(
|
||||||
|
new Error(`图片 ${file.name} 大小超过限制,最大${props.field.fileSize}MB`)
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
validFiles.push(file);
|
||||||
|
if (validFiles.length >= remainingSlots) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length === 0) {
|
||||||
|
showErrorTips(new Error("没有有效的图片可以选择"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length < fileList.length) {
|
||||||
|
showErrorTips(
|
||||||
|
new Error(
|
||||||
|
`选择了${fileList.length}张图片,但只有${validFiles.length}张图片符合要求,已自动过滤无效图片`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64List = await Promise.all(
|
||||||
|
validFiles.map(async (file) => {
|
||||||
|
const base64 = await processImageFile(file, props.field);
|
||||||
|
return base64.split(",")[1];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const newFileList = [...fileFormModel.value, ...base64List];
|
||||||
|
vModel.value[formatFieldName(props.field.name)] = newFileList.join("|");
|
||||||
|
} catch (error) {
|
||||||
|
showErrorTips(error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragStart = (index) => {
|
||||||
|
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
|
||||||
|
|
||||||
|
draggingIndex.value = index;
|
||||||
|
draggedOverIndex.value = -1;
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData("text/html", index.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
draggingIndex.value = -1;
|
||||||
|
draggedOverIndex.value = -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event) => {
|
||||||
|
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnter = (index) => {
|
||||||
|
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
|
||||||
|
|
||||||
|
if (draggingIndex.value !== -1 && draggingIndex.value !== index) {
|
||||||
|
draggedOverIndex.value = index;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (event) => {
|
||||||
|
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
|
||||||
|
|
||||||
|
const relatedTarget = event.relatedTarget;
|
||||||
|
if (!relatedTarget || !event.currentTarget.contains(relatedTarget)) {
|
||||||
|
draggedOverIndex.value = -1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (dropIndex) => {
|
||||||
|
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
event.preventDefault();
|
||||||
|
} catch (_e) {}
|
||||||
|
|
||||||
|
const dragIndex = draggingIndex.value;
|
||||||
|
if (dragIndex === -1 || dragIndex === dropIndex) {
|
||||||
|
draggingIndex.value = -1;
|
||||||
|
draggedOverIndex.value = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFileList = [...fileFormModel.value];
|
||||||
|
const draggedItem = newFileList[dragIndex];
|
||||||
|
newFileList.splice(dragIndex, 1);
|
||||||
|
newFileList.splice(dropIndex, 0, draggedItem);
|
||||||
|
vModel.value[formatFieldName(props.field.name)] = newFileList.join("|");
|
||||||
|
|
||||||
|
draggingIndex.value = -1;
|
||||||
|
draggedOverIndex.value = -1;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.iqudoo-form-image-base64 {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.image-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item.dragging {
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: rotate(1deg) scale(1.03);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 1000;
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item.drag-over {
|
||||||
|
border: 2px dashed #1890ff;
|
||||||
|
background-color: rgba(24, 144, 255, 0.05);
|
||||||
|
transform: scale(1.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
right: 5px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button-wrapper .ant-btn-circle {
|
||||||
|
min-width: 24px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-delete-btn {
|
||||||
|
background-color: rgba(255, 77, 79, 0.9) !important;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-delete-btn:hover,
|
||||||
|
.mobile-delete-btn:active {
|
||||||
|
background-color: rgb(255, 77, 79) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-trigger {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-button {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
border: 2px dashed #d9d9d9;
|
||||||
|
background-color: #fafafa;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-button:hover {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-text {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.image-grid {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-button {
|
||||||
|
width: 90px;
|
||||||
|
height: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button-wrapper .ant-btn-circle {
|
||||||
|
min-width: 28px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
54
src/iview/form/IViewFormNumber.vue
Normal file
54
src/iview/form/IViewFormNumber.vue
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<a-input-number
|
||||||
|
style="width: 100%"
|
||||||
|
v-model:value="vModel[formatFieldName(field.name)]"
|
||||||
|
:id="'form_item_' + formatFieldName(field.name)"
|
||||||
|
:disabled="readOnly"
|
||||||
|
:clearable="field.clearable"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:min="field.numMin"
|
||||||
|
:max="field.numMax"
|
||||||
|
:precision="field.numPrecision"
|
||||||
|
:step="field.numStep || 1"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useVModel } from "@/iview/utils/useModel";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
field: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
fieldPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: "请输入",
|
||||||
|
},
|
||||||
|
readOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
rawFormData: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
const vModel = useVModel(props, "modelValue", emit);
|
||||||
|
|
||||||
|
const formatFieldName = (name) => {
|
||||||
|
if (!props.fieldPrefix) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return props.fieldPrefix + "-" + name;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
270
src/iview/form/IViewFormRadio.vue
Normal file
270
src/iview/form/IViewFormRadio.vue
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
<template>
|
||||||
|
<div class="iview-form-radio-container">
|
||||||
|
<a-input
|
||||||
|
type="text"
|
||||||
|
style="display: none"
|
||||||
|
:id="'form_item_' + formatFieldName(field.name)"
|
||||||
|
/>
|
||||||
|
<a-form-item-rest>
|
||||||
|
<div v-if="supportedRemoteSearch" style="margin-bottom: 8px">
|
||||||
|
<a-input-search
|
||||||
|
v-model:value="searchInputValue"
|
||||||
|
:placeholder="field.searchPlaceholder || '输入关键字搜索'"
|
||||||
|
:loading="remoteSearchLoading"
|
||||||
|
allow-clear
|
||||||
|
@search="handleSearch"
|
||||||
|
@input="handleSearchInput"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="remoteSearchLoading || remoteOptionsLoading" style="padding: 8px 0">
|
||||||
|
<a-spin size="small" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="remoteSearchErrorMsg || remoteOptionsErrorMsg"
|
||||||
|
style="padding: 8px 0; color: #ff4d4f"
|
||||||
|
>
|
||||||
|
{{ remoteSearchErrorMsg || remoteOptionsErrorMsg }}
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
type="default"
|
||||||
|
style="margin-left: 8px"
|
||||||
|
@click="handleRefresh"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<a-radio-group
|
||||||
|
v-else
|
||||||
|
v-model:value="vModel[formatFieldName(field.name)]"
|
||||||
|
:disabled="readOnly"
|
||||||
|
>
|
||||||
|
<a-radio
|
||||||
|
v-for="option in parseOptions(field.options)"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ readLabel(option.label) }}
|
||||||
|
<template v-if="showValueTag">
|
||||||
|
<span style="color: #999; font-size: 12px"> {{ option.value }} </span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span v-if="!!readTag(option.label)" style="color: #999; font-size: 12px">
|
||||||
|
{{ readTag(option.label) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
</a-form-item-rest>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from "vue";
|
||||||
|
import { useVModel } from "@/iview/utils/useModel";
|
||||||
|
import { delayCall } from "@/iview/utils/delayCall";
|
||||||
|
import { callFunc } from "@/iview/utils/func";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
field: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
fieldPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
readOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
rawFormData: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
const vModel = useVModel(props, "modelValue", emit);
|
||||||
|
|
||||||
|
const remoteOptionsData = ref([]);
|
||||||
|
const remoteOptionsLoading = ref(false);
|
||||||
|
const remoteOptionsErrorMsg = ref(null);
|
||||||
|
|
||||||
|
const remoteSearchValue = ref("");
|
||||||
|
const remoteSearchData = ref([]);
|
||||||
|
const remoteSearchLoading = ref(false);
|
||||||
|
const remoteSearchErrorMsg = ref(null);
|
||||||
|
const remoteSearchId = ref(0);
|
||||||
|
const searchInputValue = ref("");
|
||||||
|
|
||||||
|
const supportedRemoteSearch = computed(() => {
|
||||||
|
return (
|
||||||
|
!!props.field.remoteSearchMethod &&
|
||||||
|
typeof props.field.remoteSearchMethod === "function"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const supportedRemoteOptions = computed(() => {
|
||||||
|
return (
|
||||||
|
!!props.field.remoteOptionsMethod &&
|
||||||
|
typeof props.field.remoteOptionsMethod === "function"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const showValueTag = computed(() => {
|
||||||
|
return !!props.field.showValueTag;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentValue = computed(() => {
|
||||||
|
let value = vModel.value[formatFieldName(props.field.name)];
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 解析选项字符串或数组
|
||||||
|
const parseOptions = (options) => {
|
||||||
|
if (remoteSearchData.value && remoteSearchData.value.length > 0) {
|
||||||
|
return remoteSearchData.value;
|
||||||
|
}
|
||||||
|
if (remoteOptionsData.value && remoteOptionsData.value.length > 0) {
|
||||||
|
return remoteOptionsData.value;
|
||||||
|
}
|
||||||
|
if (!options) return [];
|
||||||
|
|
||||||
|
// 如果已经是数组,直接返回
|
||||||
|
if (Array.isArray(options)) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是字符串,按换行符分割,再按|分割键值对
|
||||||
|
if (typeof options === "string") {
|
||||||
|
return options
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim() !== "")
|
||||||
|
.map((line) => {
|
||||||
|
const parts = line.split("|");
|
||||||
|
return {
|
||||||
|
label: parts[0]?.trim() || "",
|
||||||
|
value: parts[1]?.trim() || parts[0]?.trim() || "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFieldName = (name) => {
|
||||||
|
if (!props.fieldPrefix) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return props.fieldPrefix + "-" + name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readLabel = (value) => {
|
||||||
|
return `${value}`.split("#")[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const readTag = (value) => {
|
||||||
|
return `${value}`.split("#")[1];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoteOptions = () => {
|
||||||
|
if (!supportedRemoteOptions.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remoteOptionsLoading.value = true;
|
||||||
|
remoteOptionsErrorMsg.value = null;
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => {
|
||||||
|
let remoteOptionsMethod = props.field.remoteOptionsMethod;
|
||||||
|
return callFunc([], [], remoteOptionsMethod, []);
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
remoteOptionsData.value = data || [];
|
||||||
|
remoteOptionsLoading.value = false;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
remoteOptionsErrorMsg.value =
|
||||||
|
(err && err.msg) || (err && err.message) || "连接服务器失败,请检查网络";
|
||||||
|
remoteOptionsLoading.value = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoteSearchValue = delayCall((value) => {
|
||||||
|
remoteSearchId.value += 1;
|
||||||
|
remoteSearchValue.value = value;
|
||||||
|
const currentRemoteSearchId = remoteSearchId.value;
|
||||||
|
remoteSearchLoading.value = true;
|
||||||
|
remoteSearchErrorMsg.value = null;
|
||||||
|
remoteSearchData.value = [];
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => {
|
||||||
|
let remoteSearchMethod = props.field.remoteSearchMethod;
|
||||||
|
return callFunc(["value"], [value], remoteSearchMethod, []);
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (currentRemoteSearchId !== remoteSearchId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remoteSearchData.value = data || [];
|
||||||
|
remoteSearchLoading.value = false;
|
||||||
|
remoteSearchValue.value = null;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
remoteSearchErrorMsg.value =
|
||||||
|
(err && err.msg) || (err && err.message) || "连接服务器失败,请检查网络";
|
||||||
|
remoteSearchLoading.value = false;
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
const handleSearch = (value) => {
|
||||||
|
if (supportedRemoteSearch.value && value) {
|
||||||
|
handleRemoteSearchValue(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchInput = (e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (supportedRemoteSearch.value) {
|
||||||
|
if (value) {
|
||||||
|
handleRemoteSearchValue(value);
|
||||||
|
} else {
|
||||||
|
remoteSearchData.value = [];
|
||||||
|
remoteSearchErrorMsg.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
if (supportedRemoteSearch.value && !!remoteSearchValue.value) {
|
||||||
|
handleRemoteSearchValue(remoteSearchValue.value);
|
||||||
|
}
|
||||||
|
if (supportedRemoteOptions.value) {
|
||||||
|
handleRemoteOptions();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
handleRemoteOptions();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.ant-radio-group) {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-radio-wrapper) {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 16px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
407
src/iview/form/IViewFormSelect.vue
Normal file
407
src/iview/form/IViewFormSelect.vue
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
<template>
|
||||||
|
<template v-if="supportedRemoteSearch">
|
||||||
|
<a-select
|
||||||
|
style="width: 100%"
|
||||||
|
:value="currentValue"
|
||||||
|
:mode="multiple ? 'multiple' : '-'"
|
||||||
|
:id="'form_item_' + formatFieldName(field.name)"
|
||||||
|
:disabled="readOnly"
|
||||||
|
:allowClear="field.clearable"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:filter-option="supportedFilterOption"
|
||||||
|
:show-search="supportedRemoteSearch"
|
||||||
|
@search="handleRemoteSearchValue"
|
||||||
|
@change="handleChange"
|
||||||
|
>
|
||||||
|
<template v-if="remoteSearchLoading || remoteOptionsLoading" #notFoundContent>
|
||||||
|
<a-spin size="small" />
|
||||||
|
</template>
|
||||||
|
<template
|
||||||
|
v-else-if="supportedRemoteSearch || supportedRemoteOptions"
|
||||||
|
#notFoundContent
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
remoteSearchErrorMsg
|
||||||
|
? remoteSearchErrorMsg
|
||||||
|
: remoteOptionsErrorMsg
|
||||||
|
? remoteOptionsErrorMsg
|
||||||
|
: notFoundContent
|
||||||
|
}}
|
||||||
|
<a-button
|
||||||
|
v-if="remoteSearchErrorMsg || remoteOptionsErrorMsg"
|
||||||
|
size="small"
|
||||||
|
type="default"
|
||||||
|
@click="handleRefresh"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
<a-select-option
|
||||||
|
v-for="option in parseOptions(field.options)"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ readLabel(option.label) }}
|
||||||
|
<template v-if="showValueTag">
|
||||||
|
<span style="color: #999; font-size: 12px"> {{ option.value }} </span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span v-if="!!readTag(option.label)" style="color: #999; font-size: 12px">
|
||||||
|
{{ readTag(option.label) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<a-select
|
||||||
|
style="width: 100%"
|
||||||
|
:value="currentValue"
|
||||||
|
:mode="multiple ? 'multiple' : '-'"
|
||||||
|
:id="'form_item_' + formatFieldName(field.name)"
|
||||||
|
:disabled="readOnly"
|
||||||
|
:allowClear="field.clearable"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:filter-option="supportedFilterOption"
|
||||||
|
:show-search="false"
|
||||||
|
@change="handleChange"
|
||||||
|
>
|
||||||
|
<template v-if="remoteSearchLoading || remoteOptionsLoading" #notFoundContent>
|
||||||
|
<a-spin size="small" />
|
||||||
|
</template>
|
||||||
|
<template
|
||||||
|
v-else-if="supportedRemoteSearch || supportedRemoteOptions"
|
||||||
|
#notFoundContent
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
remoteSearchErrorMsg
|
||||||
|
? remoteSearchErrorMsg
|
||||||
|
: remoteOptionsErrorMsg
|
||||||
|
? remoteOptionsErrorMsg
|
||||||
|
: notFoundContent
|
||||||
|
}}
|
||||||
|
<a-button
|
||||||
|
v-if="remoteSearchErrorMsg || remoteOptionsErrorMsg"
|
||||||
|
size="small"
|
||||||
|
type="default"
|
||||||
|
@click="handleRefresh"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
<a-select-option
|
||||||
|
v-for="option in parseOptions(field.options)"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ readLabel(option.label) }}
|
||||||
|
<template v-if="showValueTag">
|
||||||
|
<span style="color: #999; font-size: 12px"> {{ option.value }} </span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span v-if="!!readTag(option.label)" style="color: #999; font-size: 12px">
|
||||||
|
{{ readTag(option.label) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, watch } from "vue";
|
||||||
|
import { useVModel } from "@/iview/utils/useModel";
|
||||||
|
import { delayCall } from "@/iview/utils/delayCall";
|
||||||
|
import { callFunc } from "@/iview/utils/func";
|
||||||
|
import { toAny } from "@/iview/utils/toany";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
field: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
fieldPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: "请选择",
|
||||||
|
},
|
||||||
|
readOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
rawFormData: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
const vModel = useVModel(props, "modelValue", emit);
|
||||||
|
const initValueFlag = ref(false);
|
||||||
|
const remoteOptionsData = ref([]);
|
||||||
|
const remoteOptionsLoading = ref(false);
|
||||||
|
const remoteOptionsErrorMsg = ref(null);
|
||||||
|
|
||||||
|
const remoteSearchValue = ref("");
|
||||||
|
const remoteSearchData = ref([]);
|
||||||
|
const remoteSearchLoading = ref(false);
|
||||||
|
const remoteSearchErrorMsg = ref(null);
|
||||||
|
const remoteSearchId = ref(0);
|
||||||
|
|
||||||
|
const handleChange = (value) => {
|
||||||
|
if (multiple.value && maxSelectCount.value > 0 && value.length > maxSelectCount.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vModel.value[formatFieldName(props.field.name)] = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxSelectCount = computed(() => {
|
||||||
|
return props.field.maxSelectCount || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const showValueTag = computed(() => {
|
||||||
|
return !!props.field.showValueTag;
|
||||||
|
});
|
||||||
|
|
||||||
|
const notFoundContent = computed(() => {
|
||||||
|
if (!supportedRemoteSearch.value) {
|
||||||
|
return "暂无可选项";
|
||||||
|
}
|
||||||
|
return props.field.notFoundContent || !!remoteSearchValue.value
|
||||||
|
? "暂无可选项"
|
||||||
|
: "输入关键字搜索";
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultValueList = computed(() => {
|
||||||
|
return props.field.defaultValueList || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const forceInOptions = computed(() => {
|
||||||
|
return props.field.forceInOptions === true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const multiple = computed(() => {
|
||||||
|
return props.field.multiple || false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 解析选项字符串或数组
|
||||||
|
const parseOptions = (options) => {
|
||||||
|
if (remoteSearchData.value && remoteSearchData.value.length > 0) {
|
||||||
|
return remoteSearchData.value;
|
||||||
|
}
|
||||||
|
if (remoteOptionsData.value && remoteOptionsData.value.length > 0) {
|
||||||
|
return remoteOptionsData.value;
|
||||||
|
}
|
||||||
|
if (supportedRemoteSearch.value && currentValue.value) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: currentValue.value,
|
||||||
|
value: currentValue.value,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!options) return [];
|
||||||
|
// 如果已经是数组,直接返回
|
||||||
|
if (Array.isArray(options)) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
// 如果是字符串,按换行符分割,再按|分割键值对
|
||||||
|
if (typeof options === "string") {
|
||||||
|
return options
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim() !== "")
|
||||||
|
.map((line) => {
|
||||||
|
const parts = line.split("|");
|
||||||
|
return {
|
||||||
|
label: parts[0]?.trim() || "",
|
||||||
|
value: parts[1]?.trim() || parts[0]?.trim() || "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const inOption = (value) => {
|
||||||
|
let optionValues = parseOptions(props.field.options).map((item) => {
|
||||||
|
return item.value;
|
||||||
|
});
|
||||||
|
return optionValues.includes(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFieldName = (name) => {
|
||||||
|
if (!props.fieldPrefix) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return props.fieldPrefix + "-" + name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readLabel = (value) => {
|
||||||
|
return `${value}`.split("#")[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const readTag = (value) => {
|
||||||
|
return `${value}`.split("#")[1];
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentValue = computed(() => {
|
||||||
|
let value = vModel.value[formatFieldName(props.field.name)];
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!!value || inOption(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const supportedRemoteSearch = computed(() => {
|
||||||
|
return (
|
||||||
|
!!props.field.remoteSearchMethod &&
|
||||||
|
typeof props.field.remoteSearchMethod === "function"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const supportedRemoteOptions = computed(() => {
|
||||||
|
return (
|
||||||
|
!!props.field.remoteOptionsMethod &&
|
||||||
|
typeof props.field.remoteOptionsMethod === "function"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const supportedFilterOption = computed(() => {
|
||||||
|
return !!props.field.supportedFilterOption && !supportedRemoteSearch.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
if (!supportedRemoteSearch.value && !supportedRemoteOptions.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (supportedRemoteSearch.value && !!remoteSearchValue.value) {
|
||||||
|
handleRemoteSearchValue(remoteSearchValue.value);
|
||||||
|
}
|
||||||
|
if (supportedRemoteOptions.value) {
|
||||||
|
handleRemoteOptions();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoteOptions = () => {
|
||||||
|
if (!supportedRemoteOptions.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remoteOptionsLoading.value = true;
|
||||||
|
remoteOptionsErrorMsg.value = null;
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => {
|
||||||
|
let remoteOptionsMethod = props.field.remoteOptionsMethod;
|
||||||
|
return callFunc([], [], remoteOptionsMethod, []);
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
remoteOptionsData.value = data || [];
|
||||||
|
remoteOptionsLoading.value = false;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
remoteOptionsErrorMsg.value =
|
||||||
|
(err && err.msg) || (err && err.message) || "连接服务器失败,请检查网络";
|
||||||
|
remoteOptionsLoading.value = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoteSearchValue = delayCall((value) => {
|
||||||
|
remoteSearchId.value += 1;
|
||||||
|
remoteSearchValue.value = value;
|
||||||
|
const currentRemoteSearchId = remoteSearchId.value;
|
||||||
|
remoteSearchLoading.value = true;
|
||||||
|
remoteSearchErrorMsg.value = null;
|
||||||
|
remoteSearchData.value = [];
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => {
|
||||||
|
let remoteSearchMethod = props.field.remoteSearchMethod;
|
||||||
|
return callFunc(["value"], [value], remoteSearchMethod, []);
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (currentRemoteSearchId !== remoteSearchId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remoteSearchData.value = data || [];
|
||||||
|
remoteSearchLoading.value = false;
|
||||||
|
remoteSearchValue.value = null;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
remoteSearchErrorMsg.value =
|
||||||
|
(err && err.msg) || (err && err.message) || "连接服务器失败,请检查网络";
|
||||||
|
remoteSearchLoading.value = false;
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
const readInitValue = () => {
|
||||||
|
if (initValueFlag.value) {
|
||||||
|
initValueFlag.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initValueFlag.value = true;
|
||||||
|
let curValue = [];
|
||||||
|
if (!!currentValue.value) {
|
||||||
|
if (currentValue.value instanceof Array) {
|
||||||
|
curValue = currentValue.value.filter((val) => !!val);
|
||||||
|
} else if (typeof currentValue.value === "string") {
|
||||||
|
curValue = [currentValue.value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let newValue = [];
|
||||||
|
if (forceInOptions.value && !supportedRemoteSearch.value) {
|
||||||
|
let inOptions = [];
|
||||||
|
let options = parseOptions(props.field.options);
|
||||||
|
for (let option of options) {
|
||||||
|
curValue.forEach((val) => {
|
||||||
|
if (option.value === val) {
|
||||||
|
inOptions.push(option.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
newValue = inOptions;
|
||||||
|
} else {
|
||||||
|
newValue = curValue;
|
||||||
|
}
|
||||||
|
if (newValue.length === 0) {
|
||||||
|
newValue = defaultValueList.value || [];
|
||||||
|
}
|
||||||
|
if (newValue !== undefined && newValue !== null) {
|
||||||
|
return multiple.value ? newValue : newValue[0];
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkValue = () => {
|
||||||
|
let newValue = readInitValue();
|
||||||
|
let curValue = currentValue.value;
|
||||||
|
if (!!newValue && toAny(newValue, "") !== toAny(curValue, "")) {
|
||||||
|
vModel.value[formatFieldName(props.field.name)] = newValue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkValue();
|
||||||
|
handleRemoteOptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => forceInOptions.value,
|
||||||
|
() => {
|
||||||
|
checkValue();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => defaultValueList.value,
|
||||||
|
() => {
|
||||||
|
checkValue();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
832
src/iview/form/IViewFormSignatureImage.vue
Normal file
832
src/iview/form/IViewFormSignatureImage.vue
Normal file
@@ -0,0 +1,832 @@
|
|||||||
|
<template>
|
||||||
|
<div class="iqudoo-form-signature-image">
|
||||||
|
<div v-if="signatureBase64" class="signature-preview">
|
||||||
|
<IDisplayImageView :image-url="displayUrl" :show-count="1" :width="previewWidth" :height="previewHeight"
|
||||||
|
object-fit="contain" />
|
||||||
|
<a-popconfirm v-if="!field.disabled && !readOnly" title="确定要删除签名图片吗?" ok-text="删除" cancel-text="取消"
|
||||||
|
placement="topRight" @confirm="clearSignature">
|
||||||
|
<a-button danger size="small">删除</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!readOnly && !field.disabled" class="signature-actions">
|
||||||
|
<a-form-item-rest>
|
||||||
|
<a-upload :id="'form_item_' + formatFieldName(field.name) + '_upload'" :file-list="[]" :max-count="1"
|
||||||
|
:show-upload-list="false" :accept="acceptedImageTypes" :before-upload="beforeUpload">
|
||||||
|
<a-button :loading="loading">
|
||||||
|
<template #icon><upload-outlined /></template>
|
||||||
|
上传图片
|
||||||
|
</a-button>
|
||||||
|
</a-upload>
|
||||||
|
</a-form-item-rest>
|
||||||
|
<a-button type="primary" @click="openSignaturePanel">
|
||||||
|
<template #icon><edit-outlined /></template>
|
||||||
|
手写签名
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="field.fileSize" class="upload-hint">
|
||||||
|
最大{{ field.fileSize }}MB · 支持{{ supportedFormats }} · 输出 {{ SIGNATURE_OUTPUT_WIDTH }}×{{
|
||||||
|
SIGNATURE_OUTPUT_HEIGHT
|
||||||
|
}}px
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div ref="fullscreenRef" v-if="showDraw" class="signature-fullscreen"
|
||||||
|
:class="{ 'signature-fullscreen--rotated': isPortraitLayout }">
|
||||||
|
<div class="signature-toolbar">
|
||||||
|
<span class="signature-toolbar-title">{{ drawPlaceholder }}</span>
|
||||||
|
<span v-if="isPortraitLayout" class="signature-toolbar-tip">
|
||||||
|
请横握手机,在横版画布上签名
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="canvasWrapRef" class="signature-stage">
|
||||||
|
<div ref="canvasBoxRef" class="signature-canvas-box">
|
||||||
|
<canvas ref="canvasRef" class="signature-canvas" @pointerdown="handlePointerDown"
|
||||||
|
@pointermove="handlePointerMove" @pointerup="handlePointerUp" @pointercancel="handlePointerUp"
|
||||||
|
@pointerleave="handlePointerLeave" @contextmenu.prevent="preventBrowserGesture"
|
||||||
|
@selectstart.prevent="preventBrowserGesture" @dragstart.prevent="preventBrowserGesture"
|
||||||
|
@touchstart.prevent="preventBrowserGesture" @touchmove.prevent="preventBrowserGesture"
|
||||||
|
@gesturestart.prevent="preventBrowserGesture" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signature-footer">
|
||||||
|
<a-button size="large" @click="closeSignaturePanel">取消</a-button>
|
||||||
|
<a-button size="large" @click="clearCanvas">清除画布</a-button>
|
||||||
|
<a-button size="large" type="primary" :loading="isSaving" @click="saveSignature">
|
||||||
|
保存签名
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, nextTick, onUnmounted } from "vue";
|
||||||
|
import { useEventListener } from "@vueuse/core";
|
||||||
|
import { useVModel } from "@/iview/utils/useModel";
|
||||||
|
import { showErrorTips } from "@/iview/utils/messageTips";
|
||||||
|
import { UploadOutlined, EditOutlined } from "@ant-design/icons-vue";
|
||||||
|
import IDisplayImageView from "@/iview/display/IDisplayImageView.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
field: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
accept: {
|
||||||
|
type: String,
|
||||||
|
default: "image/jpeg,image/png,image/gif",
|
||||||
|
},
|
||||||
|
fieldPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: "请书写",
|
||||||
|
},
|
||||||
|
readOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
lineWidth: {
|
||||||
|
type: Number,
|
||||||
|
default: 5,
|
||||||
|
},
|
||||||
|
lineColor: {
|
||||||
|
type: String,
|
||||||
|
default: "#000000",
|
||||||
|
},
|
||||||
|
rawFormData: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
const vModel = useVModel(props, "modelValue", emit);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const isSaving = ref(false);
|
||||||
|
const showDraw = ref(false);
|
||||||
|
const isDrawing = ref(false);
|
||||||
|
const isPortraitLayout = ref(false);
|
||||||
|
const SIGNATURE_ASPECT_WIDTH = 5;
|
||||||
|
const SIGNATURE_ASPECT_HEIGHT = 3;
|
||||||
|
const SIGNATURE_OUTPUT_WIDTH = 300;
|
||||||
|
const SIGNATURE_OUTPUT_HEIGHT = 180;
|
||||||
|
const SIGNATURE_JPEG_QUALITY = 0.85;
|
||||||
|
const PREVIEW_BASE_WIDTH = 230;
|
||||||
|
|
||||||
|
const canvasRef = ref(null);
|
||||||
|
const canvasWrapRef = ref(null);
|
||||||
|
const canvasBoxRef = ref(null);
|
||||||
|
const fullscreenRef = ref(null);
|
||||||
|
|
||||||
|
const previewWidth = computed(() => `${PREVIEW_BASE_WIDTH}px`);
|
||||||
|
const previewHeight = computed(
|
||||||
|
() => `${Math.round((PREVIEW_BASE_WIDTH * SIGNATURE_ASPECT_HEIGHT) / SIGNATURE_ASPECT_WIDTH)}px`
|
||||||
|
);
|
||||||
|
|
||||||
|
const supportedFormats = computed(() => {
|
||||||
|
return props.accept
|
||||||
|
.split(",")
|
||||||
|
.map((type) => type.replace("image/", ""))
|
||||||
|
.join("、");
|
||||||
|
});
|
||||||
|
|
||||||
|
const acceptedImageTypes = computed(() => {
|
||||||
|
return props.accept || "image/jpeg,image/png,image/gif";
|
||||||
|
});
|
||||||
|
|
||||||
|
const drawPlaceholder = computed(() => {
|
||||||
|
return props.field.placeholder || props.placeholder || "请书写";
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatFieldName = (name) => {
|
||||||
|
if (!props.fieldPrefix) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return props.fieldPrefix + "-" + name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldKey = computed(() => formatFieldName(props.field.name));
|
||||||
|
|
||||||
|
const signatureBase64 = computed(() => {
|
||||||
|
const value = vModel.value[fieldKey.value];
|
||||||
|
if (!value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return `${value}`.split("|").filter((item) => !!item)[0] || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayUrl = computed(() => {
|
||||||
|
if (!signatureBase64.value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (signatureBase64.value.startsWith("data:")) {
|
||||||
|
return signatureBase64.value;
|
||||||
|
}
|
||||||
|
return `data:image/jpeg;base64,${signatureBase64.value}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function setSignatureValue(base64) {
|
||||||
|
vModel.value[fieldKey.value] = base64 || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSignature() {
|
||||||
|
setSignatureValue("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImageElement(source) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error("图片加载失败"));
|
||||||
|
if (source instanceof HTMLCanvasElement) {
|
||||||
|
image.src = source.toDataURL("image/png");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof source === "string") {
|
||||||
|
image.src = source.startsWith("data:") ? source : `data:image/png;base64,${source}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error("不支持的图片源"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContainDrawRect(sourceWidth, sourceHeight) {
|
||||||
|
const scale = Math.min(
|
||||||
|
SIGNATURE_OUTPUT_WIDTH / sourceWidth,
|
||||||
|
SIGNATURE_OUTPUT_HEIGHT / sourceHeight
|
||||||
|
);
|
||||||
|
const drawWidth = sourceWidth * scale;
|
||||||
|
const drawHeight = sourceHeight * scale;
|
||||||
|
|
||||||
|
return {
|
||||||
|
offsetX: (SIGNATURE_OUTPUT_WIDTH - drawWidth) / 2,
|
||||||
|
offsetY: (SIGNATURE_OUTPUT_HEIGHT - drawHeight) / 2,
|
||||||
|
drawWidth,
|
||||||
|
drawHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function normalizeSignatureImage(source) {
|
||||||
|
const image = await loadImageElement(source);
|
||||||
|
const { offsetX, offsetY, drawWidth, drawHeight } = getContainDrawRect(
|
||||||
|
image.width,
|
||||||
|
image.height
|
||||||
|
);
|
||||||
|
|
||||||
|
const outputCanvas = document.createElement("canvas");
|
||||||
|
outputCanvas.width = SIGNATURE_OUTPUT_WIDTH;
|
||||||
|
outputCanvas.height = SIGNATURE_OUTPUT_HEIGHT;
|
||||||
|
|
||||||
|
const context = outputCanvas.getContext("2d");
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("无法处理图片");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.fillStyle = "#ffffff";
|
||||||
|
context.fillRect(0, 0, SIGNATURE_OUTPUT_WIDTH, SIGNATURE_OUTPUT_HEIGHT);
|
||||||
|
context.drawImage(image, 0, 0, image.width, image.height, offsetX, offsetY, drawWidth, drawHeight);
|
||||||
|
|
||||||
|
return outputCanvas.toDataURL("image/jpeg", SIGNATURE_JPEG_QUALITY).split(",")[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeUpload = async (file) => {
|
||||||
|
const isAcceptedType = acceptedImageTypes.value.includes(file.type);
|
||||||
|
if (!isAcceptedType) {
|
||||||
|
showErrorTips(new Error(`只能上传${supportedFormats.value}格式的图片!`));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
props.field.fileSize &&
|
||||||
|
props.field.fileSize > 0 &&
|
||||||
|
file.size > props.field.fileSize * 1024 * 1024
|
||||||
|
) {
|
||||||
|
showErrorTips(new Error(`图片大小不能超过${props.field.fileSize}MB`));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
const dataUrl = await fileToBase64(file);
|
||||||
|
const base64 = await normalizeSignatureImage(dataUrl);
|
||||||
|
setSignatureValue(base64);
|
||||||
|
} catch (error) {
|
||||||
|
showErrorTips(error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileToBase64 = (file) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateLayoutState() {
|
||||||
|
isPortraitLayout.value = window.innerHeight > window.innerWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBodyScrollLocked(locked) {
|
||||||
|
document.body.style.overflow = locked ? "hidden" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSignaturePanel() {
|
||||||
|
updateLayoutState();
|
||||||
|
showDraw.value = true;
|
||||||
|
setBodyScrollLocked(true);
|
||||||
|
await nextTick();
|
||||||
|
resizeCanvas(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSignaturePanel() {
|
||||||
|
showDraw.value = false;
|
||||||
|
isDrawing.value = false;
|
||||||
|
setBodyScrollLocked(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCanvasContext() {
|
||||||
|
const canvas = canvasRef.value;
|
||||||
|
if (!canvas) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return canvas.getContext("2d");
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStrokeStyle(context) {
|
||||||
|
context.lineCap = "round";
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.lineWidth = getLineWidth();
|
||||||
|
context.strokeStyle = getLineColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitAspectRatio(containerWidth, containerHeight, ratioWidth, ratioHeight) {
|
||||||
|
const ratio = ratioWidth / ratioHeight;
|
||||||
|
let width = containerWidth;
|
||||||
|
let height = width / ratio;
|
||||||
|
if (height > containerHeight) {
|
||||||
|
height = containerHeight;
|
||||||
|
width = height * ratio;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
width: Math.max(Math.floor(width), 1),
|
||||||
|
height: Math.max(Math.floor(height), 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientPoint(event) {
|
||||||
|
const touch = event.touches?.[0] || event.changedTouches?.[0];
|
||||||
|
const clientX = touch?.clientX ?? event.clientX;
|
||||||
|
const clientY = touch?.clientY ?? event.clientY;
|
||||||
|
|
||||||
|
if (clientX === undefined || clientY === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { clientX, clientY };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOffsetWithinAncestor(element, ancestor) {
|
||||||
|
let left = 0;
|
||||||
|
let top = 0;
|
||||||
|
let current = element;
|
||||||
|
|
||||||
|
while (current && current !== ancestor) {
|
||||||
|
left += current.offsetLeft;
|
||||||
|
top += current.offsetTop;
|
||||||
|
current = current.offsetParent;
|
||||||
|
if (!current) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { left, top };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPointFromRect(clientX, clientY, canvas, logicalWidth, logicalHeight) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
if (!rect.width || !rect.height) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
offsetX: clamp(((clientX - rect.left) / rect.width) * logicalWidth, 0, logicalWidth),
|
||||||
|
offsetY: clamp(((clientY - rect.top) / rect.height) * logicalHeight, 0, logicalHeight),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 竖屏横持:全屏层 translate(-50%,-50%) rotate(90deg) 的逆变换
|
||||||
|
* CSS 顺时针 90°:(relX, relY) -> (relY, -relX)
|
||||||
|
*/
|
||||||
|
function mapPointFullscreenRotated(clientX, clientY, logicalWidth, logicalHeight) {
|
||||||
|
const fullscreen = fullscreenRef.value;
|
||||||
|
const box = canvasBoxRef.value;
|
||||||
|
if (!fullscreen || !box) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = fullscreen.getBoundingClientRect();
|
||||||
|
const centerX = rect.left + rect.width / 2;
|
||||||
|
const centerY = rect.top + rect.height / 2;
|
||||||
|
const layoutWidth = fullscreen.offsetWidth;
|
||||||
|
const layoutHeight = fullscreen.offsetHeight;
|
||||||
|
const boxWidth = box.offsetWidth;
|
||||||
|
const boxHeight = box.offsetHeight;
|
||||||
|
|
||||||
|
if (!layoutWidth || !layoutHeight || !boxWidth || !boxHeight) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullscreenLocalX = layoutWidth / 2 + clientY - centerY;
|
||||||
|
const fullscreenLocalY = layoutHeight / 2 + centerX - clientX;
|
||||||
|
|
||||||
|
const { left: boxLeft, top: boxTop } = getOffsetWithinAncestor(box, fullscreen);
|
||||||
|
const canvasLocalX = fullscreenLocalX - boxLeft;
|
||||||
|
const canvasLocalY = fullscreenLocalY - boxTop;
|
||||||
|
|
||||||
|
return {
|
||||||
|
offsetX: clamp((canvasLocalX / boxWidth) * logicalWidth, 0, logicalWidth),
|
||||||
|
offsetY: clamp((canvasLocalY / boxHeight) * logicalHeight, 0, logicalHeight),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPointFromScreenCtm(clientX, clientY, canvas, logicalWidth, logicalHeight) {
|
||||||
|
const ctm = canvas.getScreenCTM?.();
|
||||||
|
if (!ctm) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const point = new DOMPoint(clientX, clientY).matrixTransform(ctm.inverse());
|
||||||
|
return {
|
||||||
|
offsetX: clamp(point.x, 0, logicalWidth),
|
||||||
|
offsetY: clamp(point.y, 0, logicalHeight),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeCanvas(preserveDrawing = true) {
|
||||||
|
const canvas = canvasRef.value;
|
||||||
|
const stage = canvasWrapRef.value;
|
||||||
|
const box = canvasBoxRef.value;
|
||||||
|
if (!canvas || !stage || !box) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot = null;
|
||||||
|
if (preserveDrawing && canvas.width > 0 && canvas.height > 0) {
|
||||||
|
snapshot = canvas.toDataURL("image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { width, height } = fitAspectRatio(
|
||||||
|
Math.max(stage.clientWidth, 1),
|
||||||
|
Math.max(stage.clientHeight, 1),
|
||||||
|
SIGNATURE_ASPECT_WIDTH,
|
||||||
|
SIGNATURE_ASPECT_HEIGHT
|
||||||
|
);
|
||||||
|
|
||||||
|
box.style.width = `${width}px`;
|
||||||
|
box.style.height = `${height}px`;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
|
||||||
|
canvas.width = Math.floor(width * dpr);
|
||||||
|
canvas.height = Math.floor(height * dpr);
|
||||||
|
canvas.style.width = `${width}px`;
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
|
||||||
|
const context = getCanvasContext();
|
||||||
|
if (!context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
context.scale(dpr, dpr);
|
||||||
|
context.fillStyle = "#ffffff";
|
||||||
|
context.fillRect(0, 0, width, height);
|
||||||
|
applyStrokeStyle(context);
|
||||||
|
|
||||||
|
if (!snapshot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => {
|
||||||
|
context.drawImage(image, 0, 0, width, height);
|
||||||
|
applyStrokeStyle(context);
|
||||||
|
};
|
||||||
|
image.src = snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCanvas() {
|
||||||
|
resizeCanvas(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLineWidth() {
|
||||||
|
const baseWidth = props.field.lineWidth || props.lineWidth || 5;
|
||||||
|
const minSide = Math.min(window.innerWidth, window.innerHeight);
|
||||||
|
return Math.max(baseWidth, Math.round(minSide / 120));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLineColor() {
|
||||||
|
return props.field.lineColor || props.lineColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLogicalCanvasSize(canvas) {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
return {
|
||||||
|
width: canvas.width / dpr,
|
||||||
|
height: canvas.height / dpr,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPointerPoint(event) {
|
||||||
|
const canvas = canvasRef.value;
|
||||||
|
if (!canvas) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientPoint = getClientPoint(event);
|
||||||
|
if (!clientPoint) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientX, clientY } = clientPoint;
|
||||||
|
const { width, height } = getLogicalCanvasSize(canvas);
|
||||||
|
|
||||||
|
// 竖屏横持:优先用手动逆变换(避免 WebView 下 getScreenCTM 与旋转层不一致)
|
||||||
|
if (isPortraitLayout.value) {
|
||||||
|
const rotatedPoint = mapPointFullscreenRotated(clientX, clientY, width, height);
|
||||||
|
if (rotatedPoint) {
|
||||||
|
return rotatedPoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPortraitLayout.value) {
|
||||||
|
try {
|
||||||
|
const ctmPoint = mapPointFromScreenCtm(clientX, clientY, canvas, width, height);
|
||||||
|
if (ctmPoint) {
|
||||||
|
return ctmPoint;
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// 部分 WebView 不支持 DOMMatrix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapPointFromRect(clientX, clientY, canvas, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawLine(offsetX, offsetY) {
|
||||||
|
const context = getCanvasContext();
|
||||||
|
if (!context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.lineTo(offsetX, offsetY);
|
||||||
|
applyStrokeStyle(context);
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrawing(event) {
|
||||||
|
const point = getPointerPoint(event);
|
||||||
|
const context = getCanvasContext();
|
||||||
|
if (!point || !context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(point.offsetX, point.offsetY);
|
||||||
|
isDrawing.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawContent(event) {
|
||||||
|
if (!isDrawing.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const point = getPointerPoint(event);
|
||||||
|
if (!point) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drawLine(point.offsetX, point.offsetY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDrawing() {
|
||||||
|
isDrawing.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function preventBrowserGesture(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(event) {
|
||||||
|
if (event.pointerType === "mouse" && event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
preventBrowserGesture(event);
|
||||||
|
canvasRef.value?.setPointerCapture(event.pointerId);
|
||||||
|
startDrawing(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event) {
|
||||||
|
if (!isDrawing.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
drawContent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(event) {
|
||||||
|
const canvas = canvasRef.value;
|
||||||
|
if (canvas?.hasPointerCapture?.(event.pointerId)) {
|
||||||
|
canvas.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
stopDrawing();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerLeave(event) {
|
||||||
|
if (!isDrawing.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (canvasRef.value?.hasPointerCapture?.(event.pointerId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopDrawing();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCanvasBlank(canvas) {
|
||||||
|
const context = getCanvasContext();
|
||||||
|
if (!context) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const { width, height } = canvas;
|
||||||
|
const pixels = context.getImageData(0, 0, width, height).data;
|
||||||
|
for (let i = 0; i < pixels.length; i += 4) {
|
||||||
|
if (pixels[i + 3] !== 0 && (pixels[i] !== 255 || pixels[i + 1] !== 255 || pixels[i + 2] !== 255)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSignature() {
|
||||||
|
const canvas = canvasRef.value;
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
isSaving.value = true;
|
||||||
|
if (isCanvasBlank(canvas)) {
|
||||||
|
throw new Error("请先完成签名");
|
||||||
|
}
|
||||||
|
const base64 = await normalizeSignatureImage(canvas);
|
||||||
|
if (!base64) {
|
||||||
|
throw new Error("签名内容为空");
|
||||||
|
}
|
||||||
|
setSignatureValue(base64);
|
||||||
|
closeSignaturePanel();
|
||||||
|
} catch (error) {
|
||||||
|
showErrorTips(error);
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleViewportChange() {
|
||||||
|
updateLayoutState();
|
||||||
|
if (!showDraw.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
resizeCanvas(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEventListener(window, "resize", handleViewportChange);
|
||||||
|
useEventListener(window, "orientationchange", handleViewportChange);
|
||||||
|
if (window.visualViewport) {
|
||||||
|
useEventListener(window.visualViewport, "resize", handleViewportChange);
|
||||||
|
useEventListener(window.visualViewport, "scroll", handleViewportChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(showDraw, async (open) => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await nextTick();
|
||||||
|
resizeCanvas(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
setBodyScrollLocked(false);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.iqudoo-form-signature-image {
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid #dedede;
|
||||||
|
padding: 12px;
|
||||||
|
|
||||||
|
.signature-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
:deep(.custom-image-view) {
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
aspect-ratio: 5 / 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-fullscreen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
|
||||||
|
.signature-toolbar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-toolbar-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-toolbar-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #1890ff;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-stage {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-canvas-box {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
aspect-ratio: 5 / 3;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: crosshair;
|
||||||
|
touch-action: none;
|
||||||
|
-ms-touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 系统竖屏:整层旋转为横排全屏,横握手机签名 */
|
||||||
|
.signature-fullscreen--rotated {
|
||||||
|
@media (orientation: portrait) {
|
||||||
|
width: 100vh;
|
||||||
|
height: 100vw;
|
||||||
|
max-width: 100vh;
|
||||||
|
max-height: 100vw;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
right: auto;
|
||||||
|
bottom: auto;
|
||||||
|
transform: translate(-50%, -50%) rotate(90deg);
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
.signature-fullscreen .signature-toolbar-tip {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
51
src/iview/form/IViewFormText.vue
Normal file
51
src/iview/form/IViewFormText.vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<a-input
|
||||||
|
v-model:value="vModel[formatFieldName(field.name)]"
|
||||||
|
:id="'form_item_' + formatFieldName(field.name)"
|
||||||
|
:disabled="readOnly"
|
||||||
|
:clearable="field.clearable"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:show-count="field.showCount"
|
||||||
|
:maxlength="+field.textLenMax"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useVModel } from "@/iview/utils/useModel";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
field: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
fieldPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: "请输入",
|
||||||
|
},
|
||||||
|
readOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
rawFormData: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
const vModel = useVModel(props, "modelValue", emit);
|
||||||
|
|
||||||
|
const formatFieldName = (name) => {
|
||||||
|
if (!props.fieldPrefix) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return props.fieldPrefix + "-" + name;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
52
src/iview/form/IViewFormTextarea.vue
Normal file
52
src/iview/form/IViewFormTextarea.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="vModel[formatFieldName(field.name)]"
|
||||||
|
:id="'form_item_' + formatFieldName(field.name)"
|
||||||
|
:disabled="readOnly"
|
||||||
|
:clearable="field.clearable"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:show-count="field.showCount"
|
||||||
|
:maxlength="+field.textLenMax"
|
||||||
|
:rows="+field.textRows || 2"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useVModel } from "@/iview/utils/useModel";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
field: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
fieldPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: "请输入",
|
||||||
|
},
|
||||||
|
readOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
rawFormData: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
const vModel = useVModel(props, "modelValue", emit);
|
||||||
|
|
||||||
|
const formatFieldName = (name) => {
|
||||||
|
if (!props.fieldPrefix) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return props.fieldPrefix + "-" + name;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
50
src/iview/form/IViewFormTime.vue
Normal file
50
src/iview/form/IViewFormTime.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<a-time-picker
|
||||||
|
style="width: 100%"
|
||||||
|
v-model:value="vModel[formatFieldName(field.name)]"
|
||||||
|
:id="'form_item_' + formatFieldName(field.name)"
|
||||||
|
:disabled="readOnly"
|
||||||
|
:clearable="field.clearable"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useVModel } from "@/iview/utils/useModel";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
field: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
fieldPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: "请选择",
|
||||||
|
},
|
||||||
|
readOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
rawFormData: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
const vModel = useVModel(props, "modelValue", emit);
|
||||||
|
|
||||||
|
const formatFieldName = (name) => {
|
||||||
|
if (!props.fieldPrefix) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return props.fieldPrefix + "-" + name;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
665
src/iview/form/IViewFormUploadFile.vue
Normal file
665
src/iview/form/IViewFormUploadFile.vue
Normal file
@@ -0,0 +1,665 @@
|
|||||||
|
<template>
|
||||||
|
<div class="iqudoo-form-file-upload">
|
||||||
|
<div>
|
||||||
|
<!-- 文件列表 -->
|
||||||
|
<div class="file-list-wrapper" v-if="fileFormModel.length > 0">
|
||||||
|
<a-list size="small" :data-source="fileFormModel" class="file-list">
|
||||||
|
<template #renderItem="{ item, index }">
|
||||||
|
<a-list-item>
|
||||||
|
<div
|
||||||
|
class="file-item"
|
||||||
|
:class="{
|
||||||
|
dragging: draggingIndex === index,
|
||||||
|
'drag-over': draggedOverIndex === index && draggingIndex !== index,
|
||||||
|
}"
|
||||||
|
:draggable="dragToSort && !field.disabled && !readOnly"
|
||||||
|
@dragstart="handleDragStart(index)"
|
||||||
|
@dragend="handleDragEnd"
|
||||||
|
@dragover="handleDragOver"
|
||||||
|
@dragenter="handleDragEnter(index)"
|
||||||
|
@dragleave="handleDragLeave"
|
||||||
|
@drop="handleDrop(index)"
|
||||||
|
>
|
||||||
|
<div class="file-icon">
|
||||||
|
<file-text-outlined v-if="isFileType(item, 'txt')" />
|
||||||
|
<file-pdf-outlined v-else-if="isFileType(item, 'pdf')" />
|
||||||
|
<file-excel-outlined v-else-if="isFileType(item, 'excel')" />
|
||||||
|
<file-word-outlined v-else-if="isFileType(item, 'word')" />
|
||||||
|
<file-zip-outlined v-else-if="isFileType(item, 'zip')" />
|
||||||
|
<file-outlined v-else />
|
||||||
|
</div>
|
||||||
|
<div class="file-content">
|
||||||
|
<div class="file-name-row">
|
||||||
|
<a class="file-name" :href="item" target="_blank">
|
||||||
|
{{ getFileName(item) }}
|
||||||
|
</a>
|
||||||
|
<div class="file-actions" v-if="!field.disabled && !readOnly">
|
||||||
|
<a-button type="link" @click="downloadFile(item)">
|
||||||
|
<template #icon><download-outlined /></template>
|
||||||
|
</a-button>
|
||||||
|
<a-button type="link" danger @click="removeFile(index)">
|
||||||
|
<template #icon><delete-outlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-info">{{ getFileTypeLabel(item) }}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="drag-handle"
|
||||||
|
v-if="dragToSort && !field.disabled && !readOnly"
|
||||||
|
>
|
||||||
|
<holder-outlined />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-list-item>
|
||||||
|
</template>
|
||||||
|
</a-list>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 上传触发区域 -->
|
||||||
|
<div
|
||||||
|
v-show="readOnly || fileFormModel.length < field.fileCount"
|
||||||
|
class="upload-trigger"
|
||||||
|
>
|
||||||
|
<a-upload
|
||||||
|
:id="'form_item_' + formatFieldName(field.name)"
|
||||||
|
:max-count="field.fileCount"
|
||||||
|
:show-upload-list="false"
|
||||||
|
:before-upload="beforeUpload"
|
||||||
|
:accept="acceptedImageTypes"
|
||||||
|
:multiple="multipleSelect"
|
||||||
|
list-type="picture-card"
|
||||||
|
>
|
||||||
|
<div class="upload-button">
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<plus-outlined />
|
||||||
|
<div class="upload-text">
|
||||||
|
{{ multipleSelect ? "选择多个文件" : "上传文件" }}
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
</a-upload>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="upload-hint">
|
||||||
|
<span>
|
||||||
|
{{ fileFormModel.length }}/{{ field.fileCount }}个
|
||||||
|
<template v-if="field.fileSize"> · 最大{{ field.fileSize }}MB </template>
|
||||||
|
<template v-if="acceptDescription"> · 支持{{ acceptDescription }} </template>
|
||||||
|
<template v-if="multipleSelect"> · 支持多选和拖动排序 </template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { useVModel } from "@/iview/utils/useModel";
|
||||||
|
import { apiRequest } from "@/iview/utils/request";
|
||||||
|
import { showErrorTips } from "@/iview/utils/messageTips";
|
||||||
|
import {
|
||||||
|
DeleteOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
FilePdfOutlined,
|
||||||
|
FileExcelOutlined,
|
||||||
|
FileWordOutlined,
|
||||||
|
FileZipOutlined,
|
||||||
|
FileOutlined,
|
||||||
|
HolderOutlined,
|
||||||
|
} from "@ant-design/icons-vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
field: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
accept: {
|
||||||
|
type: String,
|
||||||
|
default: ".pdf,.doc,.docx,.xls,.xlsx,.zip,.rar,.txt",
|
||||||
|
},
|
||||||
|
multipleSelect: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
dragToSort: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
fieldPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
readOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
rawFormData: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
const vModel = useVModel(props, "modelValue", emit);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const draggingIndex = ref(-1);
|
||||||
|
const draggedOverIndex = ref(-1);
|
||||||
|
const dragPlaceholder = ref(null);
|
||||||
|
|
||||||
|
const acceptDescription = computed(() => {
|
||||||
|
const types = props.accept
|
||||||
|
.split(",")
|
||||||
|
.map((type) => {
|
||||||
|
return type.replace(".", "").toUpperCase();
|
||||||
|
})
|
||||||
|
.join("、");
|
||||||
|
return types;
|
||||||
|
});
|
||||||
|
|
||||||
|
const acceptedImageTypes = computed(() => {
|
||||||
|
return (
|
||||||
|
props.accept ||
|
||||||
|
"application/pdf,application/excel,application/word,application/zip,text/plain"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatFieldName = (name) => {
|
||||||
|
if (!props.fieldPrefix) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return props.fieldPrefix + "-" + name;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 判断文件类型
|
||||||
|
const isFileType = (url, type) => {
|
||||||
|
const fileName = getFileName(url).toLowerCase();
|
||||||
|
if (type === "pdf") {
|
||||||
|
return fileName.endsWith(".pdf");
|
||||||
|
} else if (type === "excel") {
|
||||||
|
return fileName.endsWith(".xls") || fileName.endsWith(".xlsx");
|
||||||
|
} else if (type === "word") {
|
||||||
|
return fileName.endsWith(".doc") || fileName.endsWith(".docx");
|
||||||
|
} else if (type === "zip") {
|
||||||
|
return fileName.endsWith(".zip") || fileName.endsWith(".rar");
|
||||||
|
} else if (type === "txt") {
|
||||||
|
return fileName.endsWith(".txt") || fileName.endsWith(".log");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取文件类型标签
|
||||||
|
const getFileTypeLabel = (url) => {
|
||||||
|
const fileName = getFileName(url).toLowerCase();
|
||||||
|
let fileType = "文件";
|
||||||
|
|
||||||
|
if (fileName.endsWith(".pdf")) {
|
||||||
|
fileType = "PDF文档";
|
||||||
|
} else if (fileName.endsWith(".doc") || fileName.endsWith(".docx")) {
|
||||||
|
fileType = "Word文档";
|
||||||
|
} else if (fileName.endsWith(".xls") || fileName.endsWith(".xlsx")) {
|
||||||
|
fileType = "Excel表格";
|
||||||
|
} else if (fileName.endsWith(".zip") || fileName.endsWith(".rar")) {
|
||||||
|
fileType = "压缩包";
|
||||||
|
} else if (fileName.endsWith(".txt")) {
|
||||||
|
fileType = "文本文件";
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileType;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileName = (url) => {
|
||||||
|
if (!url) return "未知文件";
|
||||||
|
const parts = url.split("/");
|
||||||
|
return parts[parts.length - 1];
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFile = (index) => {
|
||||||
|
let newFileList = [...fileFormModel.value];
|
||||||
|
newFileList.splice(index, 1);
|
||||||
|
vModel.value[formatFieldName(props.field.name)] = newFileList.join("|");
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadFile = (url) => {
|
||||||
|
if (!url) return;
|
||||||
|
window.open(url, "_blank");
|
||||||
|
};
|
||||||
|
|
||||||
|
const beforeUpload = async (file, fileList) => {
|
||||||
|
// 如果是多选模式且是第一个文件,处理所有文件
|
||||||
|
if (
|
||||||
|
props.multipleSelect &&
|
||||||
|
fileList &&
|
||||||
|
fileList.length > 1 &&
|
||||||
|
fileList.indexOf(file) === 0
|
||||||
|
) {
|
||||||
|
await uploadMultipleFiles(fileList);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是多选模式但不是第一个文件,跳过处理
|
||||||
|
if (
|
||||||
|
props.multipleSelect &&
|
||||||
|
fileList &&
|
||||||
|
fileList.length > 1 &&
|
||||||
|
fileList.indexOf(file) > 0
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单个文件处理
|
||||||
|
if (props.accept) {
|
||||||
|
const acceptTypes = props.accept.split(",");
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
const isAccepted = acceptTypes.some((type) => {
|
||||||
|
return fileName.endsWith(type.replace(".", ""));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isAccepted) {
|
||||||
|
let error = new Error(`只能上传${acceptDescription.value}格式的文件!`);
|
||||||
|
showErrorTips(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
props.field.fileCount &&
|
||||||
|
props.field.fileCount > 0 &&
|
||||||
|
fileFormModel.value.length >= props.field.fileCount
|
||||||
|
) {
|
||||||
|
let error = new Error(`最多只能上传${props.field.fileCount}个文件`);
|
||||||
|
showErrorTips(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
props.field.fileSize &&
|
||||||
|
props.field.fileSize > 0 &&
|
||||||
|
file.size > props.field.fileSize * 1024 * 1024
|
||||||
|
) {
|
||||||
|
let error = new Error(`文件大小不能超过${props.field.fileSize}MB`);
|
||||||
|
showErrorTips(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await uploadFile(file);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFile = async (file) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
const base64 = await fileToBase64(file);
|
||||||
|
|
||||||
|
const result = await apiRequest("api.system.global.file.upload", {
|
||||||
|
fileBase64: base64.split(",")[1],
|
||||||
|
type: "file",
|
||||||
|
fileName: file.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result || !result.url) {
|
||||||
|
throw new Error("文件上传失败,未获取到URL");
|
||||||
|
}
|
||||||
|
let newFileList = [...fileFormModel.value];
|
||||||
|
newFileList.push(result.url);
|
||||||
|
vModel.value[formatFieldName(props.field.name)] = newFileList.join("|");
|
||||||
|
} catch (error) {
|
||||||
|
showErrorTips(error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileToBase64 = (file) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const base64String = reader.result;
|
||||||
|
resolve(base64String);
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileFormModel = computed(() => {
|
||||||
|
return vModel.value[formatFieldName(props.field.name)]
|
||||||
|
.split("|")
|
||||||
|
.filter((item) => !!item);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 多文件上传
|
||||||
|
const uploadMultipleFiles = async (fileList) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
// 检查文件数量限制
|
||||||
|
const currentFileCount = fileFormModel.value.length;
|
||||||
|
const maxFileCount = props.field.fileCount || 0;
|
||||||
|
const remainingSlots = maxFileCount - currentFileCount;
|
||||||
|
|
||||||
|
if (remainingSlots <= 0) {
|
||||||
|
let error = new Error(`最多只能上传${maxFileCount}个文件`);
|
||||||
|
showErrorTips(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证每个文件的大小和类型
|
||||||
|
const validFiles = [];
|
||||||
|
for (const file of fileList) {
|
||||||
|
// 检查文件类型
|
||||||
|
if (props.accept) {
|
||||||
|
const acceptTypes = props.accept.split(",");
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
const isAccepted = acceptTypes.some((type) => {
|
||||||
|
return fileName.endsWith(type.replace(".", ""));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isAccepted) {
|
||||||
|
showErrorTips(
|
||||||
|
new Error(
|
||||||
|
`文件 ${file.name} 格式不支持,只支持${acceptDescription.value}格式`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件大小
|
||||||
|
if (
|
||||||
|
props.field.fileSize &&
|
||||||
|
props.field.fileSize > 0 &&
|
||||||
|
file.size > props.field.fileSize * 1024 * 1024
|
||||||
|
) {
|
||||||
|
showErrorTips(
|
||||||
|
new Error(`文件 ${file.name} 大小超过限制,最大${props.field.fileSize}MB`)
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
validFiles.push(file);
|
||||||
|
|
||||||
|
// 如果已达到剩余槽位限制,停止添加
|
||||||
|
if (validFiles.length >= remainingSlots) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length === 0) {
|
||||||
|
showErrorTips(new Error("没有有效的文件可以上传"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length < fileList.length) {
|
||||||
|
showErrorTips(
|
||||||
|
new Error(
|
||||||
|
`选择了${fileList.length}个文件,但只有${validFiles.length}个文件符合要求,已自动过滤无效文件`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length > remainingSlots) {
|
||||||
|
showErrorTips(
|
||||||
|
new Error(
|
||||||
|
`最多还能上传${remainingSlots}个文件,已自动选择前${remainingSlots}个有效文件`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制本次上传的文件数量
|
||||||
|
const filesToUpload = validFiles.slice(0, remainingSlots);
|
||||||
|
|
||||||
|
const uploadPromises = filesToUpload.map((file) => uploadSingleFile(file));
|
||||||
|
const results = await Promise.all(uploadPromises);
|
||||||
|
|
||||||
|
// 过滤掉失败的上传
|
||||||
|
const successfulUploads = results.filter((result) => result && result.url);
|
||||||
|
|
||||||
|
if (successfulUploads.length > 0) {
|
||||||
|
let newFileList = [...fileFormModel.value];
|
||||||
|
newFileList.push(...successfulUploads.map((result) => result.url));
|
||||||
|
vModel.value[formatFieldName(props.field.name)] = newFileList.join("|");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showErrorTips(error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 单个文件上传(不更新UI状态)
|
||||||
|
const uploadSingleFile = async (file) => {
|
||||||
|
try {
|
||||||
|
const base64 = await fileToBase64(file);
|
||||||
|
const result = await apiRequest("api.system.global.file.upload", {
|
||||||
|
fileBase64: base64.split(",")[1],
|
||||||
|
type: "file",
|
||||||
|
fileName: file.name,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("文件上传失败:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 拖动排序功能
|
||||||
|
const handleDragStart = (index) => {
|
||||||
|
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
|
||||||
|
|
||||||
|
draggingIndex.value = index;
|
||||||
|
draggedOverIndex.value = -1;
|
||||||
|
// 设置拖拽数据
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData("text/html", index.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
draggingIndex.value = -1;
|
||||||
|
draggedOverIndex.value = -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event) => {
|
||||||
|
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnter = (index) => {
|
||||||
|
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
|
||||||
|
|
||||||
|
if (draggingIndex.value !== -1 && draggingIndex.value !== index) {
|
||||||
|
draggedOverIndex.value = index;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (event) => {
|
||||||
|
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
|
||||||
|
|
||||||
|
// 只有当离开整个拖拽区域时才清除dragOver状态
|
||||||
|
const relatedTarget = event.relatedTarget;
|
||||||
|
if (!relatedTarget || !event.currentTarget.contains(relatedTarget)) {
|
||||||
|
draggedOverIndex.value = -1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (dropIndex) => {
|
||||||
|
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
event.preventDefault();
|
||||||
|
} catch (_e) {}
|
||||||
|
|
||||||
|
const dragIndex = draggingIndex.value;
|
||||||
|
if (dragIndex === -1 || dragIndex === dropIndex) {
|
||||||
|
draggingIndex.value = -1;
|
||||||
|
draggedOverIndex.value = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFileList = [...fileFormModel.value];
|
||||||
|
const draggedItem = newFileList[dragIndex];
|
||||||
|
|
||||||
|
// 移除被拖动的项目
|
||||||
|
newFileList.splice(dragIndex, 1);
|
||||||
|
|
||||||
|
// 计算正确的插入位置
|
||||||
|
let insertIndex = dropIndex;
|
||||||
|
|
||||||
|
// 在目标位置插入
|
||||||
|
newFileList.splice(insertIndex, 0, draggedItem);
|
||||||
|
|
||||||
|
// 更新数据
|
||||||
|
vModel.value[formatFieldName(props.field.name)] = newFileList.join("|");
|
||||||
|
|
||||||
|
draggingIndex.value = -1;
|
||||||
|
draggedOverIndex.value = -1;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.iqudoo-form-file-upload {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.ant-list-item {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-wrapper {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: move;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border-radius: 8px;
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item.dragging {
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: rotate(1deg) scale(1.01);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 1000;
|
||||||
|
background-color: #fff;
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item.drag-over {
|
||||||
|
border: 2px dashed #1890ff;
|
||||||
|
background-color: rgba(24, 144, 255, 0.05);
|
||||||
|
transform: scale(1.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-right: 12px;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #1890ff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-actions {
|
||||||
|
display: flex;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: #999;
|
||||||
|
cursor: move;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle:hover {
|
||||||
|
color: #1890ff;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-trigger {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-button {
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 6px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.file-name-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-actions {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
623
src/iview/form/IViewFormUploadImage.vue
Normal file
623
src/iview/form/IViewFormUploadImage.vue
Normal file
@@ -0,0 +1,623 @@
|
|||||||
|
<template>
|
||||||
|
<div class="iqudoo-form-image-upload">
|
||||||
|
<div class="image-grid">
|
||||||
|
<div
|
||||||
|
v-if="fileFormModel.length > 0"
|
||||||
|
v-for="(item, index) in fileFormModel"
|
||||||
|
class="image-item"
|
||||||
|
:class="{
|
||||||
|
dragging: draggingIndex === index,
|
||||||
|
'drag-over': draggedOverIndex === index && draggingIndex !== index,
|
||||||
|
}"
|
||||||
|
:key="index"
|
||||||
|
:draggable="dragToSort && !field.disabled && !readOnly"
|
||||||
|
@dragstart="handleDragStart(index)"
|
||||||
|
@dragend="handleDragEnd"
|
||||||
|
@dragover="handleDragOver"
|
||||||
|
@dragenter="handleDragEnter(index)"
|
||||||
|
@dragleave="handleDragLeave"
|
||||||
|
@drop="handleDrop(index)"
|
||||||
|
>
|
||||||
|
<div style="height: 120px; width: 120px">
|
||||||
|
<IDisplayImageView
|
||||||
|
:image-url="item"
|
||||||
|
:show-count="1"
|
||||||
|
width="120px"
|
||||||
|
height="120px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<a-popconfirm
|
||||||
|
title="确定要删除这张图片吗?"
|
||||||
|
v-if="!field.disabled"
|
||||||
|
ok-text="删除"
|
||||||
|
cancel-text="取消"
|
||||||
|
@confirm="removeFile(index)"
|
||||||
|
placement="topRight"
|
||||||
|
>
|
||||||
|
<div class="delete-button-wrapper">
|
||||||
|
<a-button type="danger" shape="circle" size="small" class="mobile-delete-btn">
|
||||||
|
<template #icon><delete-outlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</a-popconfirm>
|
||||||
|
</div>
|
||||||
|
<!-- 上传触发区域 -->
|
||||||
|
<div
|
||||||
|
v-show="readOnly || fileFormModel.length < field.fileCount"
|
||||||
|
class="upload-trigger"
|
||||||
|
>
|
||||||
|
<a-upload
|
||||||
|
:id="'form_item_' + formatFieldName(field.name)"
|
||||||
|
:max-count="field.fileCount"
|
||||||
|
:show-upload-list="false"
|
||||||
|
:before-upload="beforeUpload"
|
||||||
|
:accept="acceptedImageTypes"
|
||||||
|
:multiple="multipleSelect"
|
||||||
|
>
|
||||||
|
<div class="upload-button">
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<plus-outlined />
|
||||||
|
<div class="upload-text">
|
||||||
|
{{ multipleSelect ? "选择图片" : "上传图片" }}
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
</a-upload>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="upload-hint">
|
||||||
|
<span>
|
||||||
|
{{ fileFormModel.length }}/{{ field.fileCount }}张
|
||||||
|
<template v-if="field.fileSize"> · 最大{{ field.fileSize }}MB </template>
|
||||||
|
<template v-if="supportedFormats"> · 支持{{ supportedFormats }} </template>
|
||||||
|
<template v-if="multipleSelect"> · 支持多选和拖动排序 </template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { useVModel } from "@/iview/utils/useModel";
|
||||||
|
import { apiRequest } from "@/iview/utils/request";
|
||||||
|
import { showErrorTips } from "@/iview/utils/messageTips";
|
||||||
|
import { formatImageOutputHint, processImageFile } from "@/iview/utils/imageOutput";
|
||||||
|
import { DeleteOutlined, PlusOutlined } from "@ant-design/icons-vue";
|
||||||
|
import IDisplayImageView from "@/iview/display/IDisplayImageView.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
field: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
accept: {
|
||||||
|
type: String,
|
||||||
|
default: "image/jpeg,image/png,image/gif",
|
||||||
|
},
|
||||||
|
fieldPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
multipleSelect: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
dragToSort: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
readOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
rawFormData: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
const vModel = useVModel(props, "modelValue", emit);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const draggingIndex = ref(-1);
|
||||||
|
const draggedOverIndex = ref(-1);
|
||||||
|
|
||||||
|
const supportedFormats = computed(() => {
|
||||||
|
return props.accept
|
||||||
|
.split(",")
|
||||||
|
.map((type) => type.replace("image/", ""))
|
||||||
|
.join("、");
|
||||||
|
});
|
||||||
|
|
||||||
|
const acceptedImageTypes = computed(() => {
|
||||||
|
return props.accept || "image/jpeg,image/png,image/gif";
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputSizeHint = computed(() => formatImageOutputHint(props.field));
|
||||||
|
|
||||||
|
const formatFieldName = (name) => {
|
||||||
|
if (!props.fieldPrefix) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return props.fieldPrefix + "-" + name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFile = (index) => {
|
||||||
|
let newFileList = [...fileFormModel.value];
|
||||||
|
newFileList.splice(index, 1);
|
||||||
|
vModel.value[formatFieldName(props.field.name)] = newFileList.join("|");
|
||||||
|
};
|
||||||
|
|
||||||
|
const beforeUpload = async (file, fileList) => {
|
||||||
|
// 如果是多选模式且是第一个文件,处理所有文件
|
||||||
|
if (
|
||||||
|
props.multipleSelect &&
|
||||||
|
fileList &&
|
||||||
|
fileList.length > 1 &&
|
||||||
|
fileList.indexOf(file) === 0
|
||||||
|
) {
|
||||||
|
await uploadMultipleFiles(fileList);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是多选模式但不是第一个文件,跳过处理
|
||||||
|
if (
|
||||||
|
props.multipleSelect &&
|
||||||
|
fileList &&
|
||||||
|
fileList.length > 1 &&
|
||||||
|
fileList.indexOf(file) > 0
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单个文件处理
|
||||||
|
const isAcceptedType = acceptedImageTypes.value.includes(file.type);
|
||||||
|
if (!isAcceptedType) {
|
||||||
|
let error = new Error(`只能上传${supportedFormats.value}格式的图片!`);
|
||||||
|
showErrorTips(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
props.field.fileCount &&
|
||||||
|
props.field.fileCount > 0 &&
|
||||||
|
fileFormModel.value.length >= props.field.fileCount
|
||||||
|
) {
|
||||||
|
let error = new Error(`最多只能上传${props.field.fileCount}张图片`);
|
||||||
|
showErrorTips(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
props.field.fileSize &&
|
||||||
|
props.field.fileSize > 0 &&
|
||||||
|
file.size > props.field.fileSize * 1024 * 1024
|
||||||
|
) {
|
||||||
|
let error = new Error(`图片大小不能超过${props.field.fileSize}MB`);
|
||||||
|
showErrorTips(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await uploadFile(file);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFile = async (file) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
const base64 = await processImageFile(file, props.field);
|
||||||
|
|
||||||
|
const result = await apiRequest("api.system.global.file.upload", {
|
||||||
|
fileBase64: base64.split(",")[1],
|
||||||
|
type: "image",
|
||||||
|
fileName: file.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result || !result.url) {
|
||||||
|
throw new Error("图片上传失败,未获取到URL");
|
||||||
|
}
|
||||||
|
let newFileList = [...fileFormModel.value];
|
||||||
|
newFileList.push(result.url);
|
||||||
|
vModel.value[formatFieldName(props.field.name)] = newFileList.join("|");
|
||||||
|
} catch (error) {
|
||||||
|
showErrorTips(error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileFormModel = computed(() => {
|
||||||
|
return vModel.value[formatFieldName(props.field.name)]
|
||||||
|
.split("|")
|
||||||
|
.filter((item) => !!item);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 多文件上传
|
||||||
|
const uploadMultipleFiles = async (fileList) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
// 检查文件数量限制
|
||||||
|
const currentFileCount = fileFormModel.value.length;
|
||||||
|
const maxFileCount = props.field.fileCount || 0;
|
||||||
|
const remainingSlots = maxFileCount - currentFileCount;
|
||||||
|
|
||||||
|
if (remainingSlots <= 0) {
|
||||||
|
let error = new Error(`最多只能上传${maxFileCount}张图片`);
|
||||||
|
showErrorTips(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证每个文件的大小和类型
|
||||||
|
const validFiles = [];
|
||||||
|
for (const file of fileList) {
|
||||||
|
// 检查文件类型
|
||||||
|
const isAcceptedType = acceptedImageTypes.value.includes(file.type);
|
||||||
|
if (!isAcceptedType) {
|
||||||
|
showErrorTips(
|
||||||
|
new Error(`图片 ${file.name} 格式不支持,只支持${supportedFormats.value}格式`)
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件大小
|
||||||
|
if (
|
||||||
|
props.field.fileSize &&
|
||||||
|
props.field.fileSize > 0 &&
|
||||||
|
file.size > props.field.fileSize * 1024 * 1024
|
||||||
|
) {
|
||||||
|
showErrorTips(
|
||||||
|
new Error(`图片 ${file.name} 大小超过限制,最大${props.field.fileSize}MB`)
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
validFiles.push(file);
|
||||||
|
|
||||||
|
// 如果已达到剩余槽位限制,停止添加
|
||||||
|
if (validFiles.length >= remainingSlots) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length === 0) {
|
||||||
|
showErrorTips(new Error("没有有效的图片可以上传"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length < fileList.length) {
|
||||||
|
showErrorTips(
|
||||||
|
new Error(
|
||||||
|
`选择了${fileList.length}张图片,但只有${validFiles.length}张图片符合要求,已自动过滤无效图片`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length > remainingSlots) {
|
||||||
|
showErrorTips(
|
||||||
|
new Error(
|
||||||
|
`最多还能上传${remainingSlots}张图片,已自动选择前${remainingSlots}张有效图片`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制本次上传的文件数量
|
||||||
|
const filesToUpload = validFiles.slice(0, remainingSlots);
|
||||||
|
|
||||||
|
const uploadPromises = filesToUpload.map((file) => uploadSingleFile(file));
|
||||||
|
const results = await Promise.all(uploadPromises);
|
||||||
|
|
||||||
|
// 过滤掉失败的上传
|
||||||
|
const successfulUploads = results.filter((result) => result && result.url);
|
||||||
|
|
||||||
|
if (successfulUploads.length > 0) {
|
||||||
|
let newFileList = [...fileFormModel.value];
|
||||||
|
newFileList.push(...successfulUploads.map((result) => result.url));
|
||||||
|
vModel.value[formatFieldName(props.field.name)] = newFileList.join("|");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showErrorTips(error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 单个文件上传(不更新UI状态)
|
||||||
|
const uploadSingleFile = async (file) => {
|
||||||
|
try {
|
||||||
|
const base64 = await processImageFile(file, props.field);
|
||||||
|
const result = await apiRequest("api.system.global.file.upload", {
|
||||||
|
fileBase64: base64.split(",")[1],
|
||||||
|
type: "image",
|
||||||
|
fileName: file.name,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("图片上传失败:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 拖动排序功能
|
||||||
|
const handleDragStart = (index) => {
|
||||||
|
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
|
||||||
|
|
||||||
|
draggingIndex.value = index;
|
||||||
|
draggedOverIndex.value = -1;
|
||||||
|
// 设置拖拽数据
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData("text/html", index.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
draggingIndex.value = -1;
|
||||||
|
draggedOverIndex.value = -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event) => {
|
||||||
|
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnter = (index) => {
|
||||||
|
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
|
||||||
|
|
||||||
|
if (draggingIndex.value !== -1 && draggingIndex.value !== index) {
|
||||||
|
draggedOverIndex.value = index;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (event) => {
|
||||||
|
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
|
||||||
|
|
||||||
|
// 只有当离开整个拖拽区域时才清除dragOver状态
|
||||||
|
const relatedTarget = event.relatedTarget;
|
||||||
|
if (!relatedTarget || !event.currentTarget.contains(relatedTarget)) {
|
||||||
|
draggedOverIndex.value = -1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (dropIndex) => {
|
||||||
|
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
|
||||||
|
try {
|
||||||
|
event.preventDefault();
|
||||||
|
} catch (_e) {}
|
||||||
|
|
||||||
|
const dragIndex = draggingIndex.value;
|
||||||
|
if (dragIndex === -1 || dragIndex === dropIndex) {
|
||||||
|
draggingIndex.value = -1;
|
||||||
|
draggedOverIndex.value = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFileList = [...fileFormModel.value];
|
||||||
|
const draggedItem = newFileList[dragIndex];
|
||||||
|
|
||||||
|
// 移除被拖动的项目
|
||||||
|
newFileList.splice(dragIndex, 1);
|
||||||
|
|
||||||
|
// 计算正确的插入位置
|
||||||
|
let insertIndex = dropIndex;
|
||||||
|
|
||||||
|
// 在目标位置插入
|
||||||
|
newFileList.splice(insertIndex, 0, draggedItem);
|
||||||
|
|
||||||
|
// 更新数据
|
||||||
|
vModel.value[formatFieldName(props.field.name)] = newFileList.join("|");
|
||||||
|
|
||||||
|
draggingIndex.value = -1;
|
||||||
|
draggedOverIndex.value = -1;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.iqudoo-form-image-upload {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.image-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item.dragging {
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: rotate(1deg) scale(1.03);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 1000;
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item.drag-over {
|
||||||
|
border: 2px dashed #1890ff;
|
||||||
|
background-color: rgba(24, 144, 255, 0.05);
|
||||||
|
transform: scale(1.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
right: 5px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 5px;
|
||||||
|
left: 5px;
|
||||||
|
z-index: 10;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: move;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button-wrapper .ant-btn-circle {
|
||||||
|
min-width: 24px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-delete-btn {
|
||||||
|
background-color: rgba(255, 77, 79, 0.9) !important;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-delete-btn:hover,
|
||||||
|
.mobile-delete-btn:active {
|
||||||
|
background-color: rgb(255, 77, 79) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview:hover img {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-name {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item:hover .image-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-trigger {
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-button {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
border: 2px dashed #d9d9d9;
|
||||||
|
background-color: #fafafa;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-button:hover {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-text {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.image-grid {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button-wrapper {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button-wrapper .ant-btn-circle {
|
||||||
|
min-width: 28px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-actions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview img {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview:hover img {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-name {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
765
src/iview/form/IViewFormUploadVideo.vue
Normal file
765
src/iview/form/IViewFormUploadVideo.vue
Normal file
@@ -0,0 +1,765 @@
|
|||||||
|
<template>
|
||||||
|
<div class="iqudoo-form-video-upload">
|
||||||
|
<div class="video-grid">
|
||||||
|
<div
|
||||||
|
v-if="fileFormModel.length > 0"
|
||||||
|
v-for="(item, index) in fileFormModel"
|
||||||
|
class="video-item"
|
||||||
|
:class="{
|
||||||
|
dragging: draggingIndex === index,
|
||||||
|
'drag-over': draggedOverIndex === index && draggingIndex !== index,
|
||||||
|
}"
|
||||||
|
:key="index"
|
||||||
|
:draggable="dragToSort && !field.disabled && !readOnly"
|
||||||
|
@dragstart="handleDragStart(index)"
|
||||||
|
@dragend="handleDragEnd"
|
||||||
|
@dragover="handleDragOver"
|
||||||
|
@dragenter="handleDragEnter(index)"
|
||||||
|
@dragleave="handleDragLeave"
|
||||||
|
@drop="handleDrop(index)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="video-preview"
|
||||||
|
:class="{ playing: playingVideos[index] }"
|
||||||
|
@click="toggleVideoPlay(index)"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
:ref="setRef(`video-${index}`)"
|
||||||
|
:data-index="index"
|
||||||
|
:src="item"
|
||||||
|
:poster="getVideoPoster(item)"
|
||||||
|
preload="metadata"
|
||||||
|
loop
|
||||||
|
class="video-player"
|
||||||
|
@play="onVideoPlay(index)"
|
||||||
|
@pause="onVideoPause(index)"
|
||||||
|
@ended="onVideoEnded(index)"
|
||||||
|
>
|
||||||
|
您的浏览器不支持视频播放
|
||||||
|
</video>
|
||||||
|
<div class="video-overlay" :class="{ playing: playingVideos[index] }">
|
||||||
|
<play-circle-outlined v-if="!playingVideos[index]" class="play-icon" />
|
||||||
|
<pause-circle-outlined v-else class="play-icon" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-popconfirm
|
||||||
|
title="确定要删除这个视频吗?"
|
||||||
|
v-if="!field.disabled"
|
||||||
|
ok-text="删除"
|
||||||
|
cancel-text="取消"
|
||||||
|
@confirm="removeFile(index)"
|
||||||
|
placement="topRight"
|
||||||
|
>
|
||||||
|
<div class="delete-button-wrapper">
|
||||||
|
<a-button type="danger" shape="circle" size="small" class="mobile-delete-btn">
|
||||||
|
<template #icon><delete-outlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</a-popconfirm>
|
||||||
|
</div>
|
||||||
|
<!-- 上传触发区域 -->
|
||||||
|
<div
|
||||||
|
v-show="readOnly || fileFormModel.length < field.fileCount"
|
||||||
|
class="upload-trigger"
|
||||||
|
>
|
||||||
|
<a-upload
|
||||||
|
:id="'form_item_' + formatFieldName(field.name)"
|
||||||
|
:max-count="field.fileCount"
|
||||||
|
:show-upload-list="false"
|
||||||
|
:before-upload="beforeUpload"
|
||||||
|
:accept="acceptedVideoTypes"
|
||||||
|
:multiple="multipleSelect"
|
||||||
|
>
|
||||||
|
<div class="upload-button">
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<plus-outlined />
|
||||||
|
<div class="upload-text">
|
||||||
|
{{ multipleSelect ? "选择多个视频" : "上传视频" }}
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
</a-upload>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="upload-hint">
|
||||||
|
<span>
|
||||||
|
{{ fileFormModel.length }}/{{ field.fileCount }}个
|
||||||
|
<template v-if="field.fileSize"> · 最大{{ field.fileSize }}MB </template>
|
||||||
|
<template v-if="supportedFormats"> · 支持{{ supportedFormats }} </template>
|
||||||
|
<template v-if="multipleSelect"> · 支持多选和拖动排序 </template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { useVModel } from "@/iview/utils/useModel";
|
||||||
|
import { apiRequest } from "@/iview/utils/request";
|
||||||
|
import { showErrorTips } from "@/iview/utils/messageTips";
|
||||||
|
import { useListRefs } from "@/iview/utils/useListRefs";
|
||||||
|
import {
|
||||||
|
DeleteOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
PauseCircleOutlined,
|
||||||
|
} from "@ant-design/icons-vue";
|
||||||
|
|
||||||
|
const { setRef, getRef } = useListRefs();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
field: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
accept: {
|
||||||
|
type: String,
|
||||||
|
default: "video/mp4,video/avi,video/mov,video/wmv,video/flv,video/webm",
|
||||||
|
},
|
||||||
|
fieldPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
multipleSelect: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
dragToSort: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
readOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
rawFormData: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
const vModel = useVModel(props, "modelValue", emit);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const draggingIndex = ref(-1);
|
||||||
|
const draggedOverIndex = ref(-1);
|
||||||
|
const playingVideos = ref({});
|
||||||
|
|
||||||
|
const supportedFormats = computed(() => {
|
||||||
|
return props.accept
|
||||||
|
.split(",")
|
||||||
|
.map((type) => type.replace("video/", "").toUpperCase())
|
||||||
|
.join("、");
|
||||||
|
});
|
||||||
|
|
||||||
|
const acceptedVideoTypes = computed(() => {
|
||||||
|
return props.accept || "video/mp4,video/avi,video/mov,video/wmv,video/flv,video/webm";
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatFieldName = (name) => {
|
||||||
|
if (!props.fieldPrefix) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return props.fieldPrefix + "-" + name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFile = (index) => {
|
||||||
|
let newFileList = [...fileFormModel.value];
|
||||||
|
newFileList.splice(index, 1);
|
||||||
|
vModel.value[formatFieldName(props.field.name)] = newFileList.join("|");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取视频封面
|
||||||
|
const getVideoPoster = (url) => {
|
||||||
|
// 这里可以返回一个默认的封面图片或者视频的第一帧
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视频播放控制
|
||||||
|
const toggleVideoPlay = (index) => {
|
||||||
|
const videoRef = getRef(`video-${index}`);
|
||||||
|
if (!videoRef) return;
|
||||||
|
|
||||||
|
if (videoRef.paused) {
|
||||||
|
videoRef.play();
|
||||||
|
} else {
|
||||||
|
videoRef.pause();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onVideoPlay = (index) => {
|
||||||
|
playingVideos.value[index] = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onVideoPause = (index) => {
|
||||||
|
playingVideos.value[index] = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onVideoEnded = (index) => {
|
||||||
|
// 视频结束时自动重新播放(循环播放)
|
||||||
|
const videoRef = getRef(`video-${index}`);
|
||||||
|
if (videoRef && playingVideos.value[index]) {
|
||||||
|
videoRef.currentTime = 0;
|
||||||
|
videoRef.play();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const beforeUpload = async (file, fileList) => {
|
||||||
|
// 如果是多选模式且是第一个文件,处理所有文件
|
||||||
|
if (
|
||||||
|
props.multipleSelect &&
|
||||||
|
fileList &&
|
||||||
|
fileList.length > 1 &&
|
||||||
|
fileList.indexOf(file) === 0
|
||||||
|
) {
|
||||||
|
await uploadMultipleFiles(fileList);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是多选模式但不是第一个文件,跳过处理
|
||||||
|
if (
|
||||||
|
props.multipleSelect &&
|
||||||
|
fileList &&
|
||||||
|
fileList.length > 1 &&
|
||||||
|
fileList.indexOf(file) > 0
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单个文件处理
|
||||||
|
const isAcceptedType = acceptedVideoTypes.value.includes(file.type);
|
||||||
|
if (!isAcceptedType) {
|
||||||
|
let error = new Error(`只能上传${supportedFormats.value}格式的视频!`);
|
||||||
|
showErrorTips(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
props.field.fileCount &&
|
||||||
|
props.field.fileCount > 0 &&
|
||||||
|
fileFormModel.value.length >= props.field.fileCount
|
||||||
|
) {
|
||||||
|
let error = new Error(`最多只能上传${props.field.fileCount}个视频`);
|
||||||
|
showErrorTips(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
props.field.fileSize &&
|
||||||
|
props.field.fileSize > 0 &&
|
||||||
|
file.size > props.field.fileSize * 1024 * 1024
|
||||||
|
) {
|
||||||
|
let error = new Error(`视频大小不能超过${props.field.fileSize}MB`);
|
||||||
|
showErrorTips(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await uploadFile(file);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFile = async (file) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
const base64 = await fileToBase64(file);
|
||||||
|
|
||||||
|
const result = await apiRequest("api.system.global.file.upload", {
|
||||||
|
fileBase64: base64.split(",")[1],
|
||||||
|
type: "video",
|
||||||
|
fileName: file.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result || !result.url) {
|
||||||
|
throw new Error("视频上传失败,未获取到URL");
|
||||||
|
}
|
||||||
|
let newFileList = [...fileFormModel.value];
|
||||||
|
newFileList.push(result.url);
|
||||||
|
vModel.value[formatFieldName(props.field.name)] = newFileList.join("|");
|
||||||
|
} catch (error) {
|
||||||
|
showErrorTips(error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileToBase64 = (file) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const base64String = reader.result;
|
||||||
|
resolve(base64String);
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileFormModel = computed(() => {
|
||||||
|
return vModel.value[formatFieldName(props.field.name)]
|
||||||
|
.split("|")
|
||||||
|
.filter((item) => !!item);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 多文件上传
|
||||||
|
const uploadMultipleFiles = async (fileList) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
// 检查文件数量限制
|
||||||
|
const currentFileCount = fileFormModel.value.length;
|
||||||
|
const maxFileCount = props.field.fileCount || 0;
|
||||||
|
const remainingSlots = maxFileCount - currentFileCount;
|
||||||
|
|
||||||
|
if (remainingSlots <= 0) {
|
||||||
|
let error = new Error(`最多只能上传${maxFileCount}个视频`);
|
||||||
|
showErrorTips(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证每个文件的大小和类型
|
||||||
|
const validFiles = [];
|
||||||
|
for (const file of fileList) {
|
||||||
|
// 检查文件类型
|
||||||
|
const isAcceptedType = acceptedVideoTypes.value.includes(file.type);
|
||||||
|
if (!isAcceptedType) {
|
||||||
|
showErrorTips(
|
||||||
|
new Error(`视频 ${file.name} 格式不支持,只支持${supportedFormats.value}格式`)
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件大小
|
||||||
|
if (
|
||||||
|
props.field.fileSize &&
|
||||||
|
props.field.fileSize > 0 &&
|
||||||
|
file.size > props.field.fileSize * 1024 * 1024
|
||||||
|
) {
|
||||||
|
showErrorTips(
|
||||||
|
new Error(`视频 ${file.name} 大小超过限制,最大${props.field.fileSize}MB`)
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
validFiles.push(file);
|
||||||
|
|
||||||
|
// 如果已达到剩余槽位限制,停止添加
|
||||||
|
if (validFiles.length >= remainingSlots) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length === 0) {
|
||||||
|
showErrorTips(new Error("没有有效的视频可以上传"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length < fileList.length) {
|
||||||
|
showErrorTips(
|
||||||
|
new Error(
|
||||||
|
`选择了${fileList.length}个视频,但只有${validFiles.length}个视频符合要求,已自动过滤无效视频`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length > remainingSlots) {
|
||||||
|
showErrorTips(
|
||||||
|
new Error(
|
||||||
|
`最多还能上传${remainingSlots}个视频,已自动选择前${remainingSlots}个有效视频`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制本次上传的文件数量
|
||||||
|
const filesToUpload = validFiles.slice(0, remainingSlots);
|
||||||
|
|
||||||
|
const uploadPromises = filesToUpload.map((file) => uploadSingleFile(file));
|
||||||
|
const results = await Promise.all(uploadPromises);
|
||||||
|
|
||||||
|
// 过滤掉失败的上传
|
||||||
|
const successfulUploads = results.filter((result) => result && result.url);
|
||||||
|
|
||||||
|
if (successfulUploads.length > 0) {
|
||||||
|
let newFileList = [...fileFormModel.value];
|
||||||
|
newFileList.push(...successfulUploads.map((result) => result.url));
|
||||||
|
vModel.value[formatFieldName(props.field.name)] = newFileList.join("|");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showErrorTips(error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 单个文件上传(不更新UI状态)
|
||||||
|
const uploadSingleFile = async (file) => {
|
||||||
|
try {
|
||||||
|
const base64 = await fileToBase64(file);
|
||||||
|
const result = await apiRequest("api.system.global.file.upload", {
|
||||||
|
fileBase64: base64.split(",")[1],
|
||||||
|
type: "video",
|
||||||
|
fileName: file.name,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 拖动排序功能
|
||||||
|
const handleDragStart = (index) => {
|
||||||
|
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
|
||||||
|
|
||||||
|
draggingIndex.value = index;
|
||||||
|
draggedOverIndex.value = -1;
|
||||||
|
// 设置拖拽数据
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData("text/html", index.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
draggingIndex.value = -1;
|
||||||
|
draggedOverIndex.value = -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event) => {
|
||||||
|
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnter = (index) => {
|
||||||
|
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
|
||||||
|
|
||||||
|
if (draggingIndex.value !== -1 && draggingIndex.value !== index) {
|
||||||
|
draggedOverIndex.value = index;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (event) => {
|
||||||
|
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
|
||||||
|
|
||||||
|
// 只有当离开整个拖拽区域时才清除dragOver状态
|
||||||
|
const relatedTarget = event.relatedTarget;
|
||||||
|
if (!relatedTarget || !event.currentTarget.contains(relatedTarget)) {
|
||||||
|
draggedOverIndex.value = -1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (dropIndex) => {
|
||||||
|
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
|
||||||
|
try {
|
||||||
|
event.preventDefault();
|
||||||
|
} catch (_e) {}
|
||||||
|
|
||||||
|
const dragIndex = draggingIndex.value;
|
||||||
|
if (dragIndex === -1 || dragIndex === dropIndex) {
|
||||||
|
draggingIndex.value = -1;
|
||||||
|
draggedOverIndex.value = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFileList = [...fileFormModel.value];
|
||||||
|
const draggedItem = newFileList[dragIndex];
|
||||||
|
|
||||||
|
// 移除被拖动的项目
|
||||||
|
newFileList.splice(dragIndex, 1);
|
||||||
|
|
||||||
|
// 计算正确的插入位置
|
||||||
|
let insertIndex = dropIndex;
|
||||||
|
|
||||||
|
// 在目标位置插入
|
||||||
|
newFileList.splice(insertIndex, 0, draggedItem);
|
||||||
|
|
||||||
|
// 更新数据
|
||||||
|
vModel.value[formatFieldName(props.field.name)] = newFileList.join("|");
|
||||||
|
|
||||||
|
draggingIndex.value = -1;
|
||||||
|
draggedOverIndex.value = -1;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.iqudoo-form-video-upload {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.video-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-item {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: move;
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-item.dragging {
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: rotate(1deg) scale(1.03);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 1000;
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-item.drag-over {
|
||||||
|
border: 2px dashed #1890ff;
|
||||||
|
background-color: rgba(24, 144, 255, 0.05);
|
||||||
|
transform: scale(1.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-item:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 1;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview.playing::before {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
backdrop-filter: blur(0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
outline: none;
|
||||||
|
background-color: #000;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player::-webkit-media-controls {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player::-webkit-media-controls-panel {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player::-webkit-media-controls-play-button {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player::-webkit-media-controls-timeline {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player::-webkit-media-controls-current-time-display {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player::-webkit-media-controls-time-remaining-display {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player::-webkit-media-controls-mute-button {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player::-webkit-media-controls-volume-slider {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player::-webkit-media-controls-fullscreen-button {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 24px;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-overlay.playing {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-overlay:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-icon {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 40px;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
right: 5px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 5px;
|
||||||
|
left: 5px;
|
||||||
|
z-index: 10;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: move;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button-wrapper .ant-btn-circle {
|
||||||
|
min-width: 24px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-delete-btn {
|
||||||
|
background-color: rgba(255, 77, 79, 0.9) !important;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-delete-btn:hover,
|
||||||
|
.mobile-delete-btn:active {
|
||||||
|
background-color: rgb(255, 77, 79) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview:hover .video-player {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview:hover .video-overlay {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-trigger {
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-button {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
border: 2px dashed #d9d9d9;
|
||||||
|
background-color: #fafafa;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-button:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-text {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.video-grid {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-item {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-button {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button-wrapper {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button-wrapper .ant-btn-circle {
|
||||||
|
min-width: 28px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview:hover .video-player {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
63
src/iview/form/iview-form-field-registry.js
Normal file
63
src/iview/form/iview-form-field-registry.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { shallowReactive, markRaw } from "vue";
|
||||||
|
import IViewFormText from "./IViewFormText.vue";
|
||||||
|
import IViewFormTextarea from "./IViewFormTextarea.vue";
|
||||||
|
import IViewFormNumber from "./IViewFormNumber.vue";
|
||||||
|
import IViewFormRadio from "./IViewFormRadio.vue";
|
||||||
|
import IViewFormCheckbox from "./IViewFormCheckbox.vue";
|
||||||
|
import IViewFormSelect from "./IViewFormSelect.vue";
|
||||||
|
import IViewFormDate from "./IViewFormDate.vue";
|
||||||
|
import IViewFormDatetime from "./IViewFormDatetime.vue";
|
||||||
|
import IViewFormBoolean from "./IViewFormBoolean.vue";
|
||||||
|
import IViewFormFileBase64 from "./IViewFormFileBase64.vue";
|
||||||
|
import IViewFormImageBase64 from "./IViewFormImageBase64.vue";
|
||||||
|
import IViewFormSignatureImage from "./IViewFormSignatureImage.vue";
|
||||||
|
import IViewFormUploadFile from "./IViewFormUploadFile.vue";
|
||||||
|
import IViewFormUploadImage from "./IViewFormUploadImage.vue";
|
||||||
|
import IViewFormUploadVideo from "./IViewFormUploadVideo.vue";
|
||||||
|
import IViewFormTime from "./IViewFormTime.vue";
|
||||||
|
|
||||||
|
/** @type {Record<string, import('vue').Component>} */
|
||||||
|
const builtinFormFieldComponents = {
|
||||||
|
text: markRaw(IViewFormText),
|
||||||
|
textarea: markRaw(IViewFormTextarea),
|
||||||
|
number: markRaw(IViewFormNumber),
|
||||||
|
radio: markRaw(IViewFormRadio),
|
||||||
|
checkbox: markRaw(IViewFormCheckbox),
|
||||||
|
select: markRaw(IViewFormSelect),
|
||||||
|
date: markRaw(IViewFormDate),
|
||||||
|
datetime: markRaw(IViewFormDatetime),
|
||||||
|
time: markRaw(IViewFormTime),
|
||||||
|
boolean: markRaw(IViewFormBoolean),
|
||||||
|
image: markRaw(IViewFormUploadImage),
|
||||||
|
video: markRaw(IViewFormUploadVideo),
|
||||||
|
file: markRaw(IViewFormUploadFile),
|
||||||
|
fileBase64: markRaw(IViewFormFileBase64),
|
||||||
|
imageBase64: markRaw(IViewFormImageBase64),
|
||||||
|
signatureImage: markRaw(IViewFormSignatureImage),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* type → 表单控件组件(内置 + 运行时注册覆盖)
|
||||||
|
* 使用 shallowReactive,便于 register 后视图自动更新。
|
||||||
|
*/
|
||||||
|
export const iviewFormFieldComponentMap = shallowReactive({
|
||||||
|
...builtinFormFieldComponents
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册或覆盖某 field.type 对应的表单控件(可在 main.js 或任意模块调用)。
|
||||||
|
* @param {string} type 与表单配置 field.type 一致
|
||||||
|
* @param {import('vue').Component} component 需与内置控件保持相同 props:field、fieldPrefix、modelValue、readOnly、rawFormData 等
|
||||||
|
*/
|
||||||
|
export function registerIViewFormFieldComponent(type, component) {
|
||||||
|
if (!type || !component) return;
|
||||||
|
iviewFormFieldComponentMap[type] = markRaw(component);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} type
|
||||||
|
* @returns {import('vue').Component | undefined}
|
||||||
|
*/
|
||||||
|
export function resolveIViewFormFieldComponent(type) {
|
||||||
|
return iviewFormFieldComponentMap[type];
|
||||||
|
}
|
||||||
168
src/iview/layout-page-drawer-registry.js
Normal file
168
src/iview/layout-page-drawer-registry.js
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
/** @type {Map<string, Map<string, { instanceId: string, close: () => void }>>} */
|
||||||
|
const drawerSessionsByKey = new Map();
|
||||||
|
|
||||||
|
/** @type {Map<string, string | null>} */
|
||||||
|
const layoutPageInstanceParents = new Map();
|
||||||
|
|
||||||
|
export const IVIEW_LAYOUT_PAGE_INSTANCE_KEY = Symbol("iviewLayoutPageInstance");
|
||||||
|
|
||||||
|
function encodeParamValue(value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLayoutPageConfigValue(config, field, fallback = undefined) {
|
||||||
|
if (!config) return fallback;
|
||||||
|
const value = config[field];
|
||||||
|
if (typeof value === "function") return value();
|
||||||
|
return value ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeParamsKeyParts(paramsKey, record) {
|
||||||
|
const source = record && typeof record === "object" ? record : {};
|
||||||
|
let keys = paramsKey;
|
||||||
|
if (typeof keys === "function") keys = keys();
|
||||||
|
if (!Array.isArray(keys)) return [];
|
||||||
|
|
||||||
|
return keys
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === "string") {
|
||||||
|
return `${item}=${encodeParamValue(source[item])}`;
|
||||||
|
}
|
||||||
|
if (item && typeof item === "object" && item.paramKey && item.recordKey) {
|
||||||
|
return `${item.paramKey}=${encodeParamValue(source[item.recordKey])}`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据页面 name 与 paramsKey 对应字段值生成 drawer 唯一 key
|
||||||
|
*/
|
||||||
|
export function buildLayoutPageDrawerKey({
|
||||||
|
kind,
|
||||||
|
pageName,
|
||||||
|
paramsKey,
|
||||||
|
record,
|
||||||
|
defaultParams,
|
||||||
|
}) {
|
||||||
|
const parts = normalizeParamsKeyParts(paramsKey, record);
|
||||||
|
if (parts.length === 0 && defaultParams && typeof defaultParams === "object") {
|
||||||
|
Object.keys(defaultParams)
|
||||||
|
.sort()
|
||||||
|
.forEach((key) => {
|
||||||
|
parts.push(`${key}=${encodeParamValue(defaultParams[key])}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return `${kind}:${pageName || ""}:${parts.join("&")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferRecordFromCustomParams(params) {
|
||||||
|
if (!params || typeof params !== "object") return params;
|
||||||
|
if (params.inventoryInfo && typeof params.inventoryInfo === "object") {
|
||||||
|
return params.inventoryInfo;
|
||||||
|
}
|
||||||
|
if (params.record && typeof params.record === "object") return params.record;
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLayoutPageCustomDrawerKey(pageInfo) {
|
||||||
|
if (!pageInfo) return "custom:custom:";
|
||||||
|
if (typeof pageInfo.drawerKey === "function") return `${pageInfo.drawerKey()}`;
|
||||||
|
if (typeof pageInfo.drawerKey === "string" && pageInfo.drawerKey.trim()) {
|
||||||
|
return pageInfo.drawerKey.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageName = pageInfo.name || pageInfo.title || "custom";
|
||||||
|
const paramsKey = resolveLayoutPageConfigValue(pageInfo, "paramsKey", ["guid"]);
|
||||||
|
const params =
|
||||||
|
typeof pageInfo.params === "function" ? pageInfo.params() : pageInfo.params || {};
|
||||||
|
const record = inferRecordFromCustomParams(params);
|
||||||
|
|
||||||
|
return buildLayoutPageDrawerKey({
|
||||||
|
kind: "custom",
|
||||||
|
pageName,
|
||||||
|
paramsKey,
|
||||||
|
record,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerLayoutPageInstance({ instanceId, parentInstanceId }) {
|
||||||
|
if (!instanceId) return;
|
||||||
|
layoutPageInstanceParents.set(instanceId, parentInstanceId || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterLayoutPageInstance(instanceId) {
|
||||||
|
if (!instanceId) return;
|
||||||
|
layoutPageInstanceParents.delete(instanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLayoutPageDescendant(instanceId, ancestorInstanceId) {
|
||||||
|
if (!instanceId || !ancestorInstanceId) return false;
|
||||||
|
let current = instanceId;
|
||||||
|
const visited = new Set();
|
||||||
|
while (current && !visited.has(current)) {
|
||||||
|
if (current === ancestorInstanceId) return true;
|
||||||
|
visited.add(current);
|
||||||
|
current = layoutPageInstanceParents.get(current) || null;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDrawerSessionsForKey(key) {
|
||||||
|
let sessions = drawerSessionsByKey.get(key);
|
||||||
|
if (!sessions) {
|
||||||
|
sessions = new Map();
|
||||||
|
drawerSessionsByKey.set(key, sessions);
|
||||||
|
}
|
||||||
|
return sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDrawerSession(key, instanceId) {
|
||||||
|
const sessions = drawerSessionsByKey.get(key);
|
||||||
|
if (!sessions) return;
|
||||||
|
sessions.delete(instanceId);
|
||||||
|
if (sessions.size === 0) drawerSessionsByKey.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function forceCloseDrawerSession(session) {
|
||||||
|
if (!session) return;
|
||||||
|
try {
|
||||||
|
session.close();
|
||||||
|
} catch {
|
||||||
|
// ignore close errors from stale instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开 drawer 前登记会话:
|
||||||
|
* - 嵌套来源打开同 key:不关闭祖先 drawer,允许叠加打开
|
||||||
|
* - 外部来源打开同 key:关闭无嵌套关系的旧 drawer
|
||||||
|
*/
|
||||||
|
export function claimLayoutPageDrawerSession({ key, instanceId, close }) {
|
||||||
|
if (!key || !instanceId) return;
|
||||||
|
|
||||||
|
const sessions = getDrawerSessionsForKey(key);
|
||||||
|
for (const [existingId, existingSession] of sessions.entries()) {
|
||||||
|
if (existingId === instanceId) continue;
|
||||||
|
if (isLayoutPageDescendant(instanceId, existingId)) continue;
|
||||||
|
forceCloseDrawerSession(existingSession);
|
||||||
|
sessions.delete(existingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions.set(instanceId, { instanceId, close });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releaseLayoutPageDrawerSession({ key, instanceId }) {
|
||||||
|
if (!key || !instanceId) return;
|
||||||
|
removeDrawerSession(key, instanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releaseAllLayoutPageDrawerSessions(instanceId) {
|
||||||
|
if (!instanceId) return;
|
||||||
|
for (const key of [...drawerSessionsByKey.keys()]) {
|
||||||
|
removeDrawerSession(key, instanceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/iview/layout-page-registry.js
Normal file
60
src/iview/layout-page-registry.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { shallowReactive, markRaw } from "vue";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ILayoutPage 的表单页/详情页全局注册表:
|
||||||
|
* - 当组件的 props.config.*Pages 未配置或找不到对应 type 时,用全局注册表兜底
|
||||||
|
* - shallowReactive:运行时注册后,使用方 computed 能自动更新
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Record<string, any>} */
|
||||||
|
export const iviewLayoutFormPageMap = shallowReactive({});
|
||||||
|
|
||||||
|
/** @type {Record<string, any>} */
|
||||||
|
export const iviewLayoutDetailPageMap = shallowReactive({});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册/覆盖全局表单页配置(可在 main.js 或任意模块调用)
|
||||||
|
* @param {any | any[]} pages formPages 中的单个 page 或数组
|
||||||
|
*/
|
||||||
|
export function registerIViewLayoutFormPages(pages) {
|
||||||
|
if (!pages) return;
|
||||||
|
const list = Array.isArray(pages) ? pages : [pages];
|
||||||
|
list.forEach((page) => {
|
||||||
|
const name = page && page.name;
|
||||||
|
if (!name) return;
|
||||||
|
iviewLayoutFormPageMap[name] = markRaw(page);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册/覆盖全局详情页配置(可在 main.js 或任意模块调用)
|
||||||
|
* @param {any | any[]} pages detailPages 中的单个 page 或数组
|
||||||
|
*/
|
||||||
|
export function registerIViewLayoutDetailPages(pages) {
|
||||||
|
if (!pages) return;
|
||||||
|
const list = Array.isArray(pages) ? pages : [pages];
|
||||||
|
list.forEach((page) => {
|
||||||
|
const name = page && page.name;
|
||||||
|
if (!name) return;
|
||||||
|
iviewLayoutDetailPageMap[name] = markRaw(page);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} type
|
||||||
|
* @returns {any | undefined}
|
||||||
|
*/
|
||||||
|
export function resolveIViewLayoutFormPage(type) {
|
||||||
|
if (!type) return undefined;
|
||||||
|
return iviewLayoutFormPageMap[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} type
|
||||||
|
* @returns {any | undefined}
|
||||||
|
*/
|
||||||
|
export function resolveIViewLayoutDetailPage(type) {
|
||||||
|
if (!type) return undefined;
|
||||||
|
return iviewLayoutDetailPageMap[type];
|
||||||
|
}
|
||||||
|
|
||||||
101
src/iview/listRowPatch.js
Normal file
101
src/iview/listRowPatch.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { emit as emitEvent } from "@/iview/utils/emitter.js";
|
||||||
|
|
||||||
|
export const LIST_ROW_PATCH_EVENT = "iview:list-row-patch";
|
||||||
|
|
||||||
|
export function getValueByPath(obj, path) {
|
||||||
|
if (!obj || !path) return null;
|
||||||
|
return path.split(".").reduce((acc, key) => acc?.[key], obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePatchRowKeyValue(detail, rowKeyField = "guid") {
|
||||||
|
if (!detail) return null;
|
||||||
|
return (
|
||||||
|
detail.rowKeyValue ??
|
||||||
|
detail.patch?.[rowKeyField] ??
|
||||||
|
detail.patch?.guid ??
|
||||||
|
detail.row?.[rowKeyField] ??
|
||||||
|
detail.row?.guid ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, unknown>} record
|
||||||
|
* @param {{ rowPatchKey?: string, rowKeyField?: string }} [options]
|
||||||
|
*/
|
||||||
|
export function resolveRowMatchKey(record, { rowPatchKey, rowKeyField = "guid" } = {}) {
|
||||||
|
if (!record || typeof record !== "object") return null;
|
||||||
|
if (rowPatchKey) {
|
||||||
|
const nested = getValueByPath(record, rowPatchKey);
|
||||||
|
if (nested != null && nested !== "") return nested;
|
||||||
|
}
|
||||||
|
const row = record;
|
||||||
|
return row[rowKeyField] ?? row.guid ?? row.rowKey ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 就地合并 dataSource 中匹配的行(保持行对象引用,便于嵌套 skuInfo 与抽屉联动)
|
||||||
|
* @param {unknown[]} dataSource
|
||||||
|
* @param {object} detail
|
||||||
|
* @param {{ rowPatchKey?: string, rowKeyField?: string, rowPatchMergePath?: string }} [options]
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function applyRowPatchToDataSource(dataSource, detail, options = {}) {
|
||||||
|
if (!Array.isArray(dataSource) || !detail) return false;
|
||||||
|
|
||||||
|
const rowKeyField = options.rowKeyField || "guid";
|
||||||
|
const rowKeyValue = resolvePatchRowKeyValue(detail, rowKeyField);
|
||||||
|
if (rowKeyValue == null) return false;
|
||||||
|
|
||||||
|
const idx = dataSource.findIndex(
|
||||||
|
(row) => String(resolveRowMatchKey(row, options)) === String(rowKeyValue),
|
||||||
|
);
|
||||||
|
if (idx < 0) return false;
|
||||||
|
|
||||||
|
const row = dataSource[idx];
|
||||||
|
if (!row || typeof row !== "object") return false;
|
||||||
|
|
||||||
|
if (detail.type === "replace" && detail.row && typeof detail.row === "object") {
|
||||||
|
Object.assign(row, detail.row);
|
||||||
|
const mergePath = options.rowPatchMergePath;
|
||||||
|
if (mergePath) {
|
||||||
|
const nested = getValueByPath(row, mergePath);
|
||||||
|
const nestedFromRow = getValueByPath(detail.row, mergePath);
|
||||||
|
if (nested && nestedFromRow && typeof nested === "object" && typeof nestedFromRow === "object") {
|
||||||
|
Object.assign(nested, nestedFromRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = detail.patch;
|
||||||
|
if (!patch || typeof patch !== "object") return false;
|
||||||
|
|
||||||
|
const mergePath = options.rowPatchMergePath;
|
||||||
|
if (mergePath) {
|
||||||
|
const parts = mergePath.split(".");
|
||||||
|
let target = row;
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const key = parts[i];
|
||||||
|
if (!target[key] || typeof target[key] !== "object") {
|
||||||
|
target[key] = {};
|
||||||
|
}
|
||||||
|
target = target[key];
|
||||||
|
}
|
||||||
|
const leafKey = parts[parts.length - 1];
|
||||||
|
if (!target[leafKey] || typeof target[leafKey] !== "object") {
|
||||||
|
target[leafKey] = {};
|
||||||
|
}
|
||||||
|
Object.assign(target[leafKey], patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.keys(patch).forEach((key) => {
|
||||||
|
if (key in row) row[key] = patch[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emitListRowPatch(detail) {
|
||||||
|
emitEvent(LIST_ROW_PATCH_EVENT, detail);
|
||||||
|
}
|
||||||
327
src/iview/urlEmbed/IFRAME.md
Normal file
327
src/iview/urlEmbed/IFRAME.md
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
# iframe 嵌入页对接说明
|
||||||
|
|
||||||
|
本文档面向**被嵌入到工作台 iframe 内的业务页面**(可与宿主不同域、不同技术栈)。宿主在抽屉中加载 iframe,浏览器会拦截 iframe 内的 `window.open`、`target="_blank"` 等跳转;嵌入页需通过 **postMessage** 与宿主协作。
|
||||||
|
|
||||||
|
宿主已实现消息监听,嵌入页按本文协议发送/接收消息即可,**无需引用宿主仓库内任何模块或路径**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 运行环境判断
|
||||||
|
|
||||||
|
页面在 iframe 中且 `window.parent !== window` 时,视为嵌入模式。非嵌入模式可照常使用 `location.href` / `window.open`。
|
||||||
|
|
||||||
|
```js
|
||||||
|
function isEmbedded() {
|
||||||
|
try {
|
||||||
|
return window.parent !== window;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 消息协议
|
||||||
|
|
||||||
|
所有消息的 `data` 均为**普通对象**,且必须包含字符串字段 `type`。建议双方只处理已知 `type`,忽略其余消息。
|
||||||
|
|
||||||
|
| `type` | 方向 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `iview-url-embed:query-nav` | 宿主 → 嵌入页 | iframe 加载后,宿主询问是否支持 postMessage 导航 |
|
||||||
|
| `iview-url-embed:nav-available` | 嵌入页 → 宿主 | 声明支持前进/后退协作 |
|
||||||
|
| `iview-url-embed:nav` | 宿主 → 嵌入页 | 请求嵌入页执行浏览器 history |
|
||||||
|
| `iview-url-embed:open-url` | 嵌入页 → 宿主 | 请求宿主打开 URL(抽屉内切换或新窗口) |
|
||||||
|
| `iview-url-embed:restore-focus` | 宿主 → 嵌入页 | 微信 / bfcache 返回后请求嵌入页重建焦点与触摸交互 |
|
||||||
|
|
||||||
|
### 宿主 → 嵌入页:`query-nav`
|
||||||
|
|
||||||
|
```js
|
||||||
|
{ type: 'iview-url-embed:query-nav' }
|
||||||
|
```
|
||||||
|
|
||||||
|
嵌入页收到后应回复 `nav-available`(见下)。
|
||||||
|
|
||||||
|
### 嵌入页 → 宿主:`nav-available`
|
||||||
|
|
||||||
|
```js
|
||||||
|
{ type: 'iview-url-embed:nav-available' }
|
||||||
|
```
|
||||||
|
|
||||||
|
宿主据此在工具栏显示前进/后退按钮(跨域时无法直接操作 iframe 的 `history`,需嵌入页配合)。
|
||||||
|
|
||||||
|
建议在页面加载完成后**主动发送一次**,并**监听 `query-nav` 后再回复**。
|
||||||
|
|
||||||
|
### 宿主 → 嵌入页:`nav`
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
type: 'iview-url-embed:nav',
|
||||||
|
action: 'back', // 或 'forward'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
嵌入页应调用 `history.back()` / `history.forward()`(或 SPA 路由等价操作)。
|
||||||
|
|
||||||
|
### 嵌入页 → 宿主:`open-url`
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
type: 'iview-url-embed:open-url',
|
||||||
|
url: 'https://example.com/page', // 必填,仅 http / https
|
||||||
|
title: '页面标题', // 可选,宿主抽屉标题
|
||||||
|
showToolbar: true, // 可选,是否显示前进/后退工具栏
|
||||||
|
openInNewWindow: false, // 可选,true 表示尽量在浏览器新窗口打开
|
||||||
|
showNewWindowButton: false, // 可选,是否显示「新窗口打开」按钮
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**宿主行为(嵌入页只需了解结果,无需实现):**
|
||||||
|
|
||||||
|
- 抽屉**已打开**且 `openInNewWindow: false`(或未传):**压入导航栈**并在抽屉 iframe 内打开,「上一步」可回到上一页。
|
||||||
|
- `openInNewWindow: true`:宿主用 `<a target="_blank">` 模拟新窗口打开,**不改动当前 iframe 与导航栈**(原页面保持显示)。
|
||||||
|
- 抽屉**未打开**时:新开抽屉,导航栈仅含当前页。
|
||||||
|
- 宿主主动打开 PDF 等附件(未显式 `openInNewWindow`)时,仍会优先用 a 标签新窗口打开;失败时可能显示「打开文件」页(保留导航栈,可后退)。
|
||||||
|
|
||||||
|
非法 `url`(非 http/https、空字符串)会被宿主忽略。
|
||||||
|
|
||||||
|
### 宿主 → 嵌入页:`restore-focus`
|
||||||
|
|
||||||
|
```js
|
||||||
|
{ type: 'iview-url-embed:restore-focus' }
|
||||||
|
```
|
||||||
|
|
||||||
|
**何时发送:** iframe 加载完成、用户从外链返回(`popstate` / bfcache `pageshow`)、导航栈后退、页面从后台切回前台等场景。宿主会先尝试同源 `focus()`,跨域时通过此消息通知嵌入页自行恢复。
|
||||||
|
|
||||||
|
**嵌入页应:**
|
||||||
|
|
||||||
|
1. 在 `message` 监听中收到后,用 `setTimeout(..., 0)` 延迟执行 `window.focus()`,并对 `body` 或首个可交互元素调用 `focus()`。
|
||||||
|
2. iOS 微信内可额外执行 `window.scrollTo(0, 1)` 再滚回原位置,以恢复 WKWebView 触摸上下文。
|
||||||
|
3. 建议在初始化时为 `document.body` 绑定一次空的 `touchstart`(`passive: true`),减轻返回后点击失效。
|
||||||
|
|
||||||
|
跨域时父页无法直接操作 iframe DOM,**必须**在嵌入页处理此消息;若使用公众号 JS-SDK,请在**宿主页**初始化,勿在 iframe 内直接调用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最小接入示例(复制即用)
|
||||||
|
|
||||||
|
将下列脚本放在嵌入页入口执行一次(Vanilla JS,适用于任意框架):
|
||||||
|
|
||||||
|
```js
|
||||||
|
(function initHostEmbedBridge() {
|
||||||
|
const MSG = {
|
||||||
|
QUERY_NAV: 'iview-url-embed:query-nav',
|
||||||
|
NAV_AVAILABLE: 'iview-url-embed:nav-available',
|
||||||
|
NAV: 'iview-url-embed:nav',
|
||||||
|
OPEN_URL: 'iview-url-embed:open-url',
|
||||||
|
RESTORE_FOCUS: 'iview-url-embed:restore-focus',
|
||||||
|
};
|
||||||
|
|
||||||
|
function isEmbedded() {
|
||||||
|
try {
|
||||||
|
return window.parent !== window;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function postToHost(payload) {
|
||||||
|
if (!isEmbedded()) return;
|
||||||
|
window.parent.postMessage(payload, '*');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请求宿主打开链接(代替 window.open / 顶层跳转) */
|
||||||
|
function requestOpenUrl(url, options) {
|
||||||
|
options = options || {};
|
||||||
|
var nextUrl = typeof url === 'string' ? url.trim() : '';
|
||||||
|
if (!nextUrl) return false;
|
||||||
|
|
||||||
|
if (!isEmbedded()) {
|
||||||
|
if (options.openInNewWindow) {
|
||||||
|
window.open(nextUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
} else {
|
||||||
|
window.location.href = nextUrl;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
postToHost({
|
||||||
|
type: MSG.OPEN_URL,
|
||||||
|
url: nextUrl,
|
||||||
|
title: options.title,
|
||||||
|
showToolbar: options.showToolbar,
|
||||||
|
openInNewWindow: options.openInNewWindow,
|
||||||
|
showNewWindowButton: options.showNewWindowButton,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreFocus() {
|
||||||
|
setTimeout(function () {
|
||||||
|
try {
|
||||||
|
window.focus();
|
||||||
|
document.body && document.body.focus && document.body.focus();
|
||||||
|
var el = document.querySelector(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||||
|
);
|
||||||
|
if (el && el.focus) el.focus({ preventScroll: true });
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
try {
|
||||||
|
var y = window.scrollY || 0;
|
||||||
|
window.scrollTo(0, Math.max(1, y));
|
||||||
|
window.scrollTo(0, y);
|
||||||
|
} catch (e2) { /* ignore */ }
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', function (ev) {
|
||||||
|
var data = ev.data;
|
||||||
|
if (!data || typeof data !== 'object') return;
|
||||||
|
|
||||||
|
if (data.type === MSG.QUERY_NAV) {
|
||||||
|
postToHost({ type: MSG.NAV_AVAILABLE });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === MSG.NAV) {
|
||||||
|
if (data.action === 'back') history.back();
|
||||||
|
else if (data.action === 'forward') history.forward();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === MSG.RESTORE_FOCUS) {
|
||||||
|
restoreFocus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isEmbedded()) {
|
||||||
|
postToHost({ type: MSG.NAV_AVAILABLE });
|
||||||
|
document.body && document.body.addEventListener('touchstart', function () {}, {
|
||||||
|
passive: true,
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 挂到全局,业务代码可直接调用
|
||||||
|
window.__hostEmbed = {
|
||||||
|
isEmbedded: isEmbedded,
|
||||||
|
requestOpenUrl: requestOpenUrl,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
|
||||||
|
业务侧打开链接:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 在抽屉内打开下一页
|
||||||
|
window.__hostEmbed.requestOpenUrl('https://example.com/detail/1', {
|
||||||
|
title: '详情',
|
||||||
|
showToolbar: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 需要新窗口时
|
||||||
|
window.__hostEmbed.requestOpenUrl('https://example.com/report.pdf', {
|
||||||
|
title: '报表',
|
||||||
|
openInNewWindow: true,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 可选:自动拦截链接与 window.open
|
||||||
|
|
||||||
|
嵌入页若大量 `<a target="_blank">` 或 `window.open`,可在初始化时增加:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (window.__hostEmbed.isEmbedded()) {
|
||||||
|
document.addEventListener('click', function (ev) {
|
||||||
|
var a = ev.target && ev.target.closest && ev.target.closest('a[href]');
|
||||||
|
if (!a) return;
|
||||||
|
var target = (a.getAttribute('target') || '').toLowerCase();
|
||||||
|
if (target !== '_blank' && target !== '_top' && target !== '_parent') return;
|
||||||
|
var href = a.getAttribute('href');
|
||||||
|
if (!href || href.indexOf('#') === 0 || href.indexOf('javascript:') === 0) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
window.__hostEmbed.requestOpenUrl(a.href, {
|
||||||
|
openInNewWindow: target === '_blank',
|
||||||
|
});
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
var nativeOpen = window.open.bind(window);
|
||||||
|
window.open = function (url) {
|
||||||
|
if (url && window.__hostEmbed.requestOpenUrl(String(url), { openInNewWindow: true })) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return nativeOpen.apply(window, arguments);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vue / React 示例
|
||||||
|
|
||||||
|
**Vue 3(`onMounted` / `onUnmounted`):**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { onMounted, onUnmounted } from 'vue';
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 执行上文「最小接入示例」中的 initHostEmbedBridge,或自行实现同等逻辑
|
||||||
|
initHostEmbedBridge();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 组件内跳转
|
||||||
|
function goDetail(id) {
|
||||||
|
window.__hostEmbed.requestOpenUrl(`https://example.com/detail/${id}`, {
|
||||||
|
title: '详情',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**React(`useEffect`):**
|
||||||
|
|
||||||
|
```js
|
||||||
|
useEffect(() => {
|
||||||
|
initHostEmbedBridge();
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
框架无关:在单页应用**路由入口**初始化一次即可,不必每个页面重复注册 `message` 监听。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安全与兼容说明
|
||||||
|
|
||||||
|
1. **URL 限制**:仅 `http:`、`https:` 会被宿主接受。
|
||||||
|
2. **`postMessage` 目标源**:示例使用 `'*'` 便于跨域对接;若双方 origin 固定,建议改为具体 origin,例如 `https://workbench.example.com`。
|
||||||
|
3. **消息来源校验(推荐)**:生产环境可在嵌入页校验 `ev.origin` 是否为已知宿主域名后再处理 `nav` / `query-nav`。
|
||||||
|
4. **同源嵌入**:若嵌入页与宿主同源,宿主可能直接操作 iframe 的 `history`,不依赖 `nav-available`;发送 `nav-available` 仍无害。
|
||||||
|
5. **非嵌入环境**:`requestOpenUrl` 应降级为普通跳转或 `window.open`,便于同一套代码在独立访问时可用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 联调检查清单
|
||||||
|
|
||||||
|
- [ ] 嵌入页加载后宿主工具栏是否出现前进/后退(需发送 `nav-available`)
|
||||||
|
- [ ] 点击宿主「后退」是否触发嵌入页 `history.back()`
|
||||||
|
- [ ] 调用 `open-url` 后抽屉是否切换到新 URL 或打开新窗口
|
||||||
|
- [ ] `target="_blank"` 链接是否已通过 `open-url` 交给宿主(若启用拦截)
|
||||||
|
- [ ] 独立打开嵌入页 URL(非 iframe)时业务是否仍可正常跳转
|
||||||
|
- [ ] 微信内:子页打开后返回、导航栈后退后,嵌入页按钮/链接是否仍可点击
|
||||||
|
- [ ] 嵌入页是否处理 `restore-focus`(跨域必选)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 协议常量速查
|
||||||
|
|
||||||
|
```
|
||||||
|
iview-url-embed:query-nav
|
||||||
|
iview-url-embed:nav-available
|
||||||
|
iview-url-embed:nav
|
||||||
|
iview-url-embed:open-url
|
||||||
|
iview-url-embed:restore-focus
|
||||||
|
```
|
||||||
|
|
||||||
|
协议版本与宿主实现保持一致;嵌入页仅依赖上述字符串,无需同步宿主代码仓库。
|
||||||
40
src/iview/urlEmbed/index.js
Normal file
40
src/iview/urlEmbed/index.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* 全局 URL 嵌入(右侧抽屉内 iframe)。
|
||||||
|
*
|
||||||
|
* 需在应用根挂载 {@link IViewUrlEmbedHost}(当前在 PageDashboard),与是否使用 ILayoutPage 无关。
|
||||||
|
*
|
||||||
|
* @example 主应用打开嵌入页
|
||||||
|
* import { openUrl, closeUrlPage } from '@/iview/urlEmbed'
|
||||||
|
* openUrl('https://example.com', '示例')
|
||||||
|
* openUrl({ url: 'https://example.com', title: '示例', headerBackgroundColor: '#007AFF' })
|
||||||
|
* closeUrlPage()
|
||||||
|
*
|
||||||
|
* @example iframe 内页面(见 {@link ./IFRAME.md})
|
||||||
|
* import { initUrlEmbedClient, requestOpenUrl } from '@/iview/urlEmbed'
|
||||||
|
* initUrlEmbedClient({ interceptLinks: true, interceptWindowOpen: true })
|
||||||
|
* requestOpenUrl('https://example.com/next', { title: '下一页' })
|
||||||
|
*
|
||||||
|
* HTTPS 页面下打开 HTTP 链接时,会自动改为浏览器新窗口,避免 iframe 混合内容被拦截。
|
||||||
|
* PDF 等附件链接也会优先新窗口打开,避免 Chrome 在 sandbox iframe 中屏蔽预览。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { URL_EMBED_MSG } from "./urlEmbedMessages.js";
|
||||||
|
export {
|
||||||
|
initUrlEmbedClient,
|
||||||
|
requestOpenUrl,
|
||||||
|
isUrlEmbedFrame,
|
||||||
|
restoreUrlEmbedClientFocus,
|
||||||
|
scheduleUrlEmbedClientRestoreForEnv,
|
||||||
|
} from "./urlEmbedClient.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
urlEmbedState,
|
||||||
|
useUrlEmbedStore,
|
||||||
|
openUrl,
|
||||||
|
closeUrlPage,
|
||||||
|
goBackUrlPage,
|
||||||
|
canGoBackUrlPage,
|
||||||
|
openUrlByAnchor,
|
||||||
|
DEFAULT_URL_EMBED_HEADER_BG,
|
||||||
|
normalizeUrlEmbedHeaderBackground,
|
||||||
|
} from "./urlEmbedState.js";
|
||||||
218
src/iview/urlEmbed/urlEmbedClient.js
Normal file
218
src/iview/urlEmbed/urlEmbedClient.js
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { URL_EMBED_MSG } from "./urlEmbedMessages.js";
|
||||||
|
import { isWeChatBrowser } from "./urlEmbedEnv.js";
|
||||||
|
import {
|
||||||
|
cancelUrlEmbedClientFocusBurst,
|
||||||
|
restoreUrlEmbedClientFocus,
|
||||||
|
scheduleUrlEmbedClientRestoreForEnv,
|
||||||
|
} from "./urlEmbedWechatFocus.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
restoreUrlEmbedClientFocus,
|
||||||
|
scheduleUrlEmbedClientRestoreForEnv,
|
||||||
|
} from "./urlEmbedWechatFocus.js";
|
||||||
|
|
||||||
|
function resolveUrl(url) {
|
||||||
|
try {
|
||||||
|
return new URL(url, typeof window !== "undefined" ? window.location.href : undefined);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否运行在 urlEmbed 抽屉 iframe 内 */
|
||||||
|
export function isUrlEmbedFrame() {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
try {
|
||||||
|
return window.parent !== window;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeHttpUrl(url) {
|
||||||
|
const parsed = resolveUrl(url);
|
||||||
|
if (!parsed) return false;
|
||||||
|
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求父页面打开 URL(iframe 内 window.open / target=_blank 常被拦截时使用)
|
||||||
|
* @param {string} url
|
||||||
|
* @param {{
|
||||||
|
* title?: string,
|
||||||
|
* showToolbar?: boolean,
|
||||||
|
* openInNewWindow?: boolean,
|
||||||
|
* showNewWindowButton?: boolean,
|
||||||
|
* }} [options]
|
||||||
|
* @returns {boolean} 是否已发出请求或已在顶层直接打开
|
||||||
|
*/
|
||||||
|
export function requestOpenUrl(url, options = {}) {
|
||||||
|
const nextUrl = typeof url === "string" ? url.trim() : "";
|
||||||
|
if (!nextUrl || !isSafeHttpUrl(nextUrl)) return false;
|
||||||
|
|
||||||
|
if (!isUrlEmbedFrame()) {
|
||||||
|
if (options.openInNewWindow === true) {
|
||||||
|
window.open(nextUrl, "_blank", "noopener,noreferrer");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
window.location.href = nextUrl;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
type: URL_EMBED_MSG.OPEN_URL,
|
||||||
|
url: nextUrl,
|
||||||
|
title: options.title,
|
||||||
|
showToolbar: options.showToolbar,
|
||||||
|
openInNewWindow: options.openInNewWindow,
|
||||||
|
showNewWindowButton: options.showNewWindowButton,
|
||||||
|
},
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNavMessage(data, onNav) {
|
||||||
|
if (data.type !== URL_EMBED_MSG.NAV || typeof onNav !== "function") return;
|
||||||
|
if (data.action === "back") {
|
||||||
|
onNav("back");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.action === "forward") {
|
||||||
|
onNav("forward");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLinkClickHandler(getOpenOptions) {
|
||||||
|
return function onDocumentClick(ev) {
|
||||||
|
const anchor = ev.target?.closest?.("a[href]");
|
||||||
|
if (!anchor || !isUrlEmbedFrame()) return;
|
||||||
|
|
||||||
|
const href = anchor.getAttribute("href");
|
||||||
|
if (!href || href.startsWith("#") || href.startsWith("javascript:")) return;
|
||||||
|
|
||||||
|
const target = (anchor.getAttribute("target") || "").toLowerCase();
|
||||||
|
const opensExternally =
|
||||||
|
target === "_blank" || target === "_top" || target === "_parent";
|
||||||
|
if (!opensExternally) return;
|
||||||
|
|
||||||
|
const url = anchor.href;
|
||||||
|
if (!isSafeHttpUrl(url)) return;
|
||||||
|
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
const extra = typeof getOpenOptions === "function" ? getOpenOptions(anchor) : {};
|
||||||
|
requestOpenUrl(url, {
|
||||||
|
openInNewWindow: target === "_blank" ? true : undefined,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 嵌入页初始化:响应父页导航查询,并可选拦截链接 / window.open
|
||||||
|
* @param {{
|
||||||
|
* onNav?: (action: 'back' | 'forward') => void,
|
||||||
|
* interceptLinks?: boolean,
|
||||||
|
* interceptWindowOpen?: boolean,
|
||||||
|
* getOpenOptions?: (anchor: HTMLAnchorElement) => Record<string, unknown>,
|
||||||
|
* }} [options]
|
||||||
|
* @returns {() => void} 卸载监听
|
||||||
|
*/
|
||||||
|
export function initUrlEmbedClient(options = {}) {
|
||||||
|
if (typeof window === "undefined") return () => {};
|
||||||
|
|
||||||
|
const onNav = options.onNav;
|
||||||
|
const getOpenOptions = options.getOpenOptions;
|
||||||
|
|
||||||
|
function onWindowMessage(ev) {
|
||||||
|
const data = ev.data;
|
||||||
|
if (!data || typeof data !== "object") return;
|
||||||
|
|
||||||
|
if (data.type === URL_EMBED_MSG.QUERY_NAV) {
|
||||||
|
window.parent.postMessage({ type: URL_EMBED_MSG.NAV_AVAILABLE }, "*");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === URL_EMBED_MSG.RESTORE_FOCUS) {
|
||||||
|
scheduleUrlEmbedClientRestoreForEnv();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleNavMessage(data, onNav);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("message", onWindowMessage);
|
||||||
|
|
||||||
|
if (isUrlEmbedFrame()) {
|
||||||
|
window.parent.postMessage({ type: URL_EMBED_MSG.NAV_AVAILABLE }, "*");
|
||||||
|
}
|
||||||
|
|
||||||
|
let removeTouchStartListener = () => {};
|
||||||
|
if (isWeChatBrowser() && document.body) {
|
||||||
|
const onTouchStart = () => {};
|
||||||
|
document.body.addEventListener("touchstart", onTouchStart, { passive: true, capture: true });
|
||||||
|
removeTouchStartListener = () => {
|
||||||
|
document.body.removeEventListener("touchstart", onTouchStart, { capture: true });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let removeWechatLifecycleListeners = () => {};
|
||||||
|
if (isWeChatBrowser() && isUrlEmbedFrame()) {
|
||||||
|
function onClientPageShow() {
|
||||||
|
scheduleUrlEmbedClientRestoreForEnv();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClientPopState() {
|
||||||
|
scheduleUrlEmbedClientRestoreForEnv();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClientVisibilityChange() {
|
||||||
|
if (document.visibilityState !== "visible") return;
|
||||||
|
scheduleUrlEmbedClientRestoreForEnv();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("pageshow", onClientPageShow);
|
||||||
|
window.addEventListener("popstate", onClientPopState);
|
||||||
|
document.addEventListener("visibilitychange", onClientVisibilityChange);
|
||||||
|
removeWechatLifecycleListeners = () => {
|
||||||
|
window.removeEventListener("pageshow", onClientPageShow);
|
||||||
|
window.removeEventListener("popstate", onClientPopState);
|
||||||
|
document.removeEventListener("visibilitychange", onClientVisibilityChange);
|
||||||
|
cancelUrlEmbedClientFocusBurst();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let removeLinkListener = () => {};
|
||||||
|
if (options.interceptLinks === true) {
|
||||||
|
const onClick = createLinkClickHandler(getOpenOptions);
|
||||||
|
document.addEventListener("click", onClick, true);
|
||||||
|
removeLinkListener = () => document.removeEventListener("click", onClick, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let restoreWindowOpen = null;
|
||||||
|
if (options.interceptWindowOpen === true && isUrlEmbedFrame()) {
|
||||||
|
const nativeOpen = window.open.bind(window);
|
||||||
|
restoreWindowOpen = nativeOpen;
|
||||||
|
window.open = (url, target, features) => {
|
||||||
|
const nextUrl = typeof url === "string" ? url.trim() : "";
|
||||||
|
if (nextUrl && isSafeHttpUrl(nextUrl)) {
|
||||||
|
requestOpenUrl(nextUrl, { openInNewWindow: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return nativeOpen(url, target, features);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return function disposeUrlEmbedClient() {
|
||||||
|
window.removeEventListener("message", onWindowMessage);
|
||||||
|
removeTouchStartListener();
|
||||||
|
removeWechatLifecycleListeners();
|
||||||
|
removeLinkListener();
|
||||||
|
if (restoreWindowOpen) {
|
||||||
|
window.open = restoreWindowOpen;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
90
src/iview/urlEmbed/urlEmbedEnv.js
Normal file
90
src/iview/urlEmbed/urlEmbedEnv.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/** 是否微信内置浏览器(含企业微信常见 UA) */
|
||||||
|
export function isWeChatBrowser() {
|
||||||
|
if (typeof navigator === "undefined") return false;
|
||||||
|
return /MicroMessenger|wxwork/i.test(navigator.userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMobileViewport() {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
return window.innerWidth <= 768;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_PREVIEW_EXT_RE =
|
||||||
|
/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|wps|et|dps|zip|rar|7z|csv|txt|md)(\?|#|$)/i;
|
||||||
|
|
||||||
|
function resolveUrl(url) {
|
||||||
|
try {
|
||||||
|
return new URL(url, typeof window !== "undefined" ? window.location.href : undefined);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 附件 / 文档类链接在 iframe 中常被拦截,优先新窗口打开 */
|
||||||
|
export function isLikelyFilePreviewUrl(url) {
|
||||||
|
const parsed = resolveUrl(url);
|
||||||
|
if (parsed) {
|
||||||
|
if (FILE_PREVIEW_EXT_RE.test(parsed.pathname)) return true;
|
||||||
|
const disposition = `${parsed.searchParams.get("response-content-disposition") || ""}`.toLowerCase();
|
||||||
|
if (disposition.includes("attachment")) return true;
|
||||||
|
}
|
||||||
|
return FILE_PREVIEW_EXT_RE.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模拟 a 标签 target="_blank" 打开(微信等环境比 window.open 更可靠) */
|
||||||
|
export function openUrlByAnchor(url, { target = "_blank" } = {}) {
|
||||||
|
if (typeof document === "undefined") return false;
|
||||||
|
const nextUrl = typeof url === "string" ? url.trim() : "";
|
||||||
|
if (!nextUrl) return false;
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = nextUrl;
|
||||||
|
anchor.target = target;
|
||||||
|
anchor.rel = "noopener noreferrer";
|
||||||
|
anchor.style.display = "none";
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} url
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function tryOpenUrlInNewWindow(url) {
|
||||||
|
return openUrlByAnchor(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} url
|
||||||
|
* @param {{ openInNewWindow?: boolean }} [options]
|
||||||
|
* @returns {'new-window' | 'iframe' | 'file-fallback'}
|
||||||
|
*/
|
||||||
|
export function resolveUrlEmbedContentKind(url, options = {}) {
|
||||||
|
const wantsNewWindow =
|
||||||
|
options.openInNewWindow === true || isLikelyFilePreviewUrl(url);
|
||||||
|
|
||||||
|
if (wantsNewWindow && options.openInNewWindow !== false) {
|
||||||
|
if (openUrlByAnchor(url)) {
|
||||||
|
return "new-window";
|
||||||
|
}
|
||||||
|
if (isLikelyFilePreviewUrl(url)) {
|
||||||
|
return "file-fallback";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "iframe";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldOpenInNewWindow(url, options = {}) {
|
||||||
|
if (options.openInNewWindow === true) return true;
|
||||||
|
if (options.openInNewWindow === false) return false;
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
if (isLikelyFilePreviewUrl(url)) return true;
|
||||||
|
if (window.location.protocol === "https:") {
|
||||||
|
const parsed = resolveUrl(url);
|
||||||
|
if (parsed?.protocol === "http:") return true;
|
||||||
|
return /^http:\/\//i.test(url);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
13
src/iview/urlEmbed/urlEmbedMessages.js
Normal file
13
src/iview/urlEmbed/urlEmbedMessages.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/** iframe 与工作台嵌入页 postMessage / 同源导航约定(与原 ILayoutPage 一致) */
|
||||||
|
export const URL_EMBED_MSG = Object.freeze({
|
||||||
|
/** 嵌入页 → 父页:声明支持 postMessage 前进/后退 */
|
||||||
|
NAV_AVAILABLE: "iview-url-embed:nav-available",
|
||||||
|
/** 父页 → 嵌入页:查询是否支持 postMessage 导航 */
|
||||||
|
QUERY_NAV: "iview-url-embed:query-nav",
|
||||||
|
/** 父页 → 嵌入页:前进 / 后退 */
|
||||||
|
NAV: "iview-url-embed:nav",
|
||||||
|
/** 嵌入页 → 父页:请求打开新页面(iframe 内跳转 / 新窗口常被拦截) */
|
||||||
|
OPEN_URL: "iview-url-embed:open-url",
|
||||||
|
/** 父页 → 嵌入页:微信返回 / bfcache 恢复后请求重建焦点与交互 */
|
||||||
|
RESTORE_FOCUS: "iview-url-embed:restore-focus",
|
||||||
|
});
|
||||||
224
src/iview/urlEmbed/urlEmbedState.js
Normal file
224
src/iview/urlEmbed/urlEmbedState.js
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { reactive, toRefs } from "vue";
|
||||||
|
import {
|
||||||
|
resolveUrlEmbedContentKind,
|
||||||
|
shouldOpenInNewWindow,
|
||||||
|
openUrlByAnchor,
|
||||||
|
} from "./urlEmbedEnv.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_URL_EMBED_HEADER_BG,
|
||||||
|
normalizeUrlEmbedHeaderBackground,
|
||||||
|
} from "./urlEmbedTheme.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{ url: string, title: string, contentKind: 'iframe' | 'file-fallback', headerBackgroundColor?: string }} UrlEmbedNavEntry
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局「抽屉内 iframe 嵌入」状态(仅 UI 层,无业务 store 依赖)。
|
||||||
|
* 由 {@link IViewUrlEmbedHost} 与 {@link openUrl} / {@link closeUrlPage} 读写。
|
||||||
|
*/
|
||||||
|
const urlEmbedState = reactive({
|
||||||
|
visible: false,
|
||||||
|
src: "",
|
||||||
|
title: "网页预览",
|
||||||
|
showToolbar: false,
|
||||||
|
showNewWindowButton: false,
|
||||||
|
contentKind: "iframe",
|
||||||
|
headerBackgroundColor: DEFAULT_URL_EMBED_HEADER_BG,
|
||||||
|
/** @type {UrlEmbedNavEntry[]} */
|
||||||
|
navStack: [],
|
||||||
|
navIndex: -1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const urlEmbedRefs = toRefs(urlEmbedState);
|
||||||
|
|
||||||
|
function createNavEntry(url, title, contentKind, headerBackgroundColor) {
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
title: title || "网页预览",
|
||||||
|
contentKind: contentKind || "iframe",
|
||||||
|
headerBackgroundColor: normalizeUrlEmbedHeaderBackground(headerBackgroundColor),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyNavEntry(entry) {
|
||||||
|
urlEmbedState.src = entry.url;
|
||||||
|
urlEmbedState.title = entry.title;
|
||||||
|
urlEmbedState.contentKind = entry.contentKind;
|
||||||
|
urlEmbedState.headerBackgroundColor = normalizeUrlEmbedHeaderBackground(
|
||||||
|
entry.headerBackgroundColor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetNavStack(entry) {
|
||||||
|
urlEmbedState.navStack = [entry];
|
||||||
|
urlEmbedState.navIndex = 0;
|
||||||
|
applyNavEntry(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushNavEntry(entry) {
|
||||||
|
const stack = urlEmbedState.navStack.slice(0, urlEmbedState.navIndex + 1);
|
||||||
|
stack.push(entry);
|
||||||
|
urlEmbedState.navStack = stack;
|
||||||
|
urlEmbedState.navIndex = stack.length - 1;
|
||||||
|
applyNavEntry(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canGoBackUrlPage() {
|
||||||
|
return urlEmbedState.visible && urlEmbedState.navIndex > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} 是否成功后退 */
|
||||||
|
export function goBackUrlPage() {
|
||||||
|
if (!canGoBackUrlPage()) return false;
|
||||||
|
urlEmbedState.navIndex -= 1;
|
||||||
|
applyNavEntry(urlEmbedState.navStack[urlEmbedState.navIndex]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEmbedDrawer(options) {
|
||||||
|
urlEmbedState.showToolbar = !!options.showToolbar;
|
||||||
|
urlEmbedState.showNewWindowButton = !!options.showNewWindowButton;
|
||||||
|
if (options.headerBackgroundColor != null) {
|
||||||
|
urlEmbedState.headerBackgroundColor = normalizeUrlEmbedHeaderBackground(
|
||||||
|
options.headerBackgroundColor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
urlEmbedState.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string | {
|
||||||
|
* url: string,
|
||||||
|
* title?: string,
|
||||||
|
* showToolbar?: boolean,
|
||||||
|
* openInNewWindow?: boolean,
|
||||||
|
* showNewWindowButton?: boolean,
|
||||||
|
* push?: boolean,
|
||||||
|
* headerBackgroundColor?: string,
|
||||||
|
* headerColor?: string,
|
||||||
|
* }} urlOrPayload
|
||||||
|
* @param {string} [maybeTitle]
|
||||||
|
* @param {boolean} [showToolbarFlag]
|
||||||
|
*/
|
||||||
|
export function openUrl(urlOrPayload, maybeTitle, showToolbarFlag = false) {
|
||||||
|
let url = "";
|
||||||
|
let title = "网页预览";
|
||||||
|
let showToolbar = showToolbarFlag;
|
||||||
|
let openInNewWindow = undefined;
|
||||||
|
let showNewWindowButton = false;
|
||||||
|
let push = false;
|
||||||
|
let headerBackgroundColor = undefined;
|
||||||
|
if (urlOrPayload == null) return;
|
||||||
|
|
||||||
|
if (typeof urlOrPayload === "string") {
|
||||||
|
url = urlOrPayload.trim();
|
||||||
|
if (typeof maybeTitle === "string" && maybeTitle.trim()) {
|
||||||
|
title = maybeTitle.trim();
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
typeof urlOrPayload === "object" &&
|
||||||
|
typeof urlOrPayload.url === "string"
|
||||||
|
) {
|
||||||
|
url = urlOrPayload.url.trim();
|
||||||
|
if (typeof urlOrPayload.title === "string" && urlOrPayload.title.trim()) {
|
||||||
|
title = urlOrPayload.title.trim();
|
||||||
|
}
|
||||||
|
if (urlOrPayload.showToolbar != null) {
|
||||||
|
showToolbar = !!urlOrPayload.showToolbar;
|
||||||
|
}
|
||||||
|
if (urlOrPayload.openInNewWindow != null) {
|
||||||
|
openInNewWindow = !!urlOrPayload.openInNewWindow;
|
||||||
|
}
|
||||||
|
if (urlOrPayload.showNewWindowButton != null) {
|
||||||
|
showNewWindowButton = !!urlOrPayload.showNewWindowButton;
|
||||||
|
}
|
||||||
|
if (urlOrPayload.push != null) {
|
||||||
|
push = !!urlOrPayload.push;
|
||||||
|
}
|
||||||
|
if (urlOrPayload.headerBackgroundColor != null) {
|
||||||
|
headerBackgroundColor = urlOrPayload.headerBackgroundColor;
|
||||||
|
} else if (urlOrPayload.headerColor != null) {
|
||||||
|
headerBackgroundColor = urlOrPayload.headerColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
// 显式新窗口:仅模拟 a[target=_blank],不改动当前抽屉 iframe / 导航栈
|
||||||
|
if (openInNewWindow === true) {
|
||||||
|
openUrlByAnchor(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wantsNewWindow = shouldOpenInNewWindow(url, { openInNewWindow });
|
||||||
|
const contentKind = resolveUrlEmbedContentKind(url, { openInNewWindow: wantsNewWindow });
|
||||||
|
if (contentKind === "new-window") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentKind === "file-fallback") {
|
||||||
|
showNewWindowButton = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = createNavEntry(url, title, contentKind, headerBackgroundColor);
|
||||||
|
const drawerAlreadyOpen = urlEmbedState.visible && urlEmbedState.navStack.length > 0;
|
||||||
|
|
||||||
|
if (drawerAlreadyOpen && push) {
|
||||||
|
pushNavEntry(entry);
|
||||||
|
if (showToolbar) urlEmbedState.showToolbar = true;
|
||||||
|
if (showNewWindowButton) urlEmbedState.showNewWindowButton = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetNavStack(entry);
|
||||||
|
openEmbedDrawer({
|
||||||
|
showToolbar,
|
||||||
|
showNewWindowButton,
|
||||||
|
headerBackgroundColor: entry.headerBackgroundColor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeUrlPage() {
|
||||||
|
urlEmbedState.visible = false;
|
||||||
|
urlEmbedState.src = "";
|
||||||
|
urlEmbedState.title = "网页预览";
|
||||||
|
urlEmbedState.showToolbar = false;
|
||||||
|
urlEmbedState.showNewWindowButton = false;
|
||||||
|
urlEmbedState.contentKind = "iframe";
|
||||||
|
urlEmbedState.headerBackgroundColor = DEFAULT_URL_EMBED_HEADER_BG;
|
||||||
|
urlEmbedState.navStack = [];
|
||||||
|
urlEmbedState.navIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 兼容原先 Pinia Store 写法;返回稳定 ref,供组件解构绑定 */
|
||||||
|
export function useUrlEmbedStore() {
|
||||||
|
return {
|
||||||
|
visible: urlEmbedRefs.visible,
|
||||||
|
src: urlEmbedRefs.src,
|
||||||
|
title: urlEmbedRefs.title,
|
||||||
|
showToolbar: urlEmbedRefs.showToolbar,
|
||||||
|
showNewWindowButton: urlEmbedRefs.showNewWindowButton,
|
||||||
|
contentKind: urlEmbedRefs.contentKind,
|
||||||
|
headerBackgroundColor: urlEmbedRefs.headerBackgroundColor,
|
||||||
|
navStack: urlEmbedRefs.navStack,
|
||||||
|
navIndex: urlEmbedRefs.navIndex,
|
||||||
|
openUrl,
|
||||||
|
closeUrlPage,
|
||||||
|
goBackUrlPage,
|
||||||
|
canGoBackUrlPage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { urlEmbedState, shouldOpenInNewWindow };
|
||||||
|
export {
|
||||||
|
isLikelyFilePreviewUrl,
|
||||||
|
isWeChatBrowser,
|
||||||
|
isMobileViewport,
|
||||||
|
openUrlByAnchor,
|
||||||
|
tryOpenUrlInNewWindow,
|
||||||
|
resolveUrlEmbedContentKind,
|
||||||
|
} from "./urlEmbedEnv.js";
|
||||||
|
export {
|
||||||
|
DEFAULT_URL_EMBED_HEADER_BG,
|
||||||
|
normalizeUrlEmbedHeaderBackground,
|
||||||
|
} from "./urlEmbedTheme.js";
|
||||||
16
src/iview/urlEmbed/urlEmbedTheme.js
Normal file
16
src/iview/urlEmbed/urlEmbedTheme.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/** urlEmbed 抽屉顶栏默认背景色(与全局 ant-drawer-header 一致) */
|
||||||
|
export const DEFAULT_URL_EMBED_HEADER_BG = "#1677ff";
|
||||||
|
|
||||||
|
const HEADER_COLOR_RE =
|
||||||
|
/^(#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})|rgb\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*\)|rgba\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*\))$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string | undefined | null} color
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function normalizeUrlEmbedHeaderBackground(color) {
|
||||||
|
const next = typeof color === "string" ? color.trim() : "";
|
||||||
|
if (!next) return DEFAULT_URL_EMBED_HEADER_BG;
|
||||||
|
if (HEADER_COLOR_RE.test(next)) return next;
|
||||||
|
return DEFAULT_URL_EMBED_HEADER_BG;
|
||||||
|
}
|
||||||
210
src/iview/urlEmbed/urlEmbedWechatFocus.js
Normal file
210
src/iview/urlEmbed/urlEmbedWechatFocus.js
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import { isWeChatBrowser } from "./urlEmbedEnv.js";
|
||||||
|
import { URL_EMBED_MSG } from "./urlEmbedMessages.js";
|
||||||
|
|
||||||
|
export function isIosWechat() {
|
||||||
|
if (!isWeChatBrowser()) return false;
|
||||||
|
return /iPhone|iPad|iPod/i.test(navigator.userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** iOS 微信 WKWebView:轻滚以恢复触摸/滚动上下文 */
|
||||||
|
export function applyIosWechatScrollHack(targetWindow = window) {
|
||||||
|
if (!isIosWechat()) return;
|
||||||
|
try {
|
||||||
|
const y = targetWindow.scrollY || 0;
|
||||||
|
targetWindow.scrollTo(0, Math.max(1, y));
|
||||||
|
targetWindow.scrollTo(0, y);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusSameOriginFrameDocument(frame) {
|
||||||
|
const win = frame.contentWindow;
|
||||||
|
if (!win) return;
|
||||||
|
win.focus?.();
|
||||||
|
const doc = win.document;
|
||||||
|
if (!doc) return;
|
||||||
|
doc.body?.focus?.();
|
||||||
|
const focusable = doc.querySelector(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||||
|
);
|
||||||
|
focusable?.focus?.({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function wakePointerEvents(doc) {
|
||||||
|
if (!doc?.body) return;
|
||||||
|
doc.body.style.pointerEvents = "auto";
|
||||||
|
doc.documentElement.style.pointerEvents = "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 宿主侧重建 iframe 可交互性(同源直接 focus,跨域 postMessage)
|
||||||
|
* @param {HTMLIFrameElement | null | undefined} frame
|
||||||
|
* @param {{ sameOrigin?: boolean }} [options]
|
||||||
|
*/
|
||||||
|
export function restoreHostEmbedFrameInteraction(frame, { sameOrigin = false } = {}) {
|
||||||
|
if (!frame) return;
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
try {
|
||||||
|
frame.style.pointerEvents = "auto";
|
||||||
|
frame.focus({ preventScroll: true });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sameOrigin) {
|
||||||
|
try {
|
||||||
|
focusSameOriginFrameDocument(frame);
|
||||||
|
wakePointerEvents(frame.contentWindow?.document);
|
||||||
|
} catch {
|
||||||
|
postRestoreFocusToIframe(frame);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
postRestoreFocusToIframe(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyIosWechatScrollHack();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postRestoreFocusToIframe(frame) {
|
||||||
|
if (!frame?.contentWindow) return;
|
||||||
|
try {
|
||||||
|
frame.contentWindow.postMessage({ type: URL_EMBED_MSG.RESTORE_FOCUS }, "*");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DELAYS = [0, 80, 160, 320, 640, 1200, 2000, 3200, 4800];
|
||||||
|
|
||||||
|
/** 延时多次尝试,绕过微信 X5 / WKWebView 渲染延迟 */
|
||||||
|
export function scheduleHostEmbedFrameRestore(
|
||||||
|
frame,
|
||||||
|
sameOrigin,
|
||||||
|
delays = DEFAULT_DELAYS,
|
||||||
|
) {
|
||||||
|
delays.forEach((delay) => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
restoreHostEmbedFrameInteraction(frame, { sameOrigin });
|
||||||
|
}, delay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let hostBurstTimerIds = [];
|
||||||
|
|
||||||
|
export function cancelHostEmbedFrameRestoreBurst() {
|
||||||
|
hostBurstTimerIds.forEach((id) => window.clearTimeout(id));
|
||||||
|
hostBurstTimerIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信内持续重试焦点恢复(默认约 6s),比固定几次延时更可靠
|
||||||
|
* @param {HTMLIFrameElement | null | undefined} frame
|
||||||
|
* @param {boolean} sameOrigin
|
||||||
|
* @param {{ durationMs?: number, intervalMs?: number }} [options]
|
||||||
|
*/
|
||||||
|
export function startHostEmbedFrameRestoreBurst(
|
||||||
|
frame,
|
||||||
|
sameOrigin,
|
||||||
|
{ durationMs = 6000, intervalMs = 350 } = {},
|
||||||
|
) {
|
||||||
|
if (!frame) return;
|
||||||
|
cancelHostEmbedFrameRestoreBurst();
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
restoreHostEmbedFrameInteraction(frame, { sameOrigin });
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
if (Date.now() - startedAt >= durationMs) return;
|
||||||
|
restoreHostEmbedFrameInteraction(frame, { sameOrigin });
|
||||||
|
const id = window.setTimeout(tick, intervalMs);
|
||||||
|
hostBurstTimerIds.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstId = window.setTimeout(tick, intervalMs);
|
||||||
|
hostBurstTimerIds.push(firstId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleHostEmbedFrameRestoreForEnv(frame, sameOrigin) {
|
||||||
|
if (!frame) return;
|
||||||
|
if (isWeChatBrowser()) {
|
||||||
|
startHostEmbedFrameRestoreBurst(frame, sameOrigin);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduleHostEmbedFrameRestore(frame, sameOrigin);
|
||||||
|
}
|
||||||
|
|
||||||
|
let clientBurstTimerIds = [];
|
||||||
|
|
||||||
|
export function cancelUrlEmbedClientFocusBurst() {
|
||||||
|
clientBurstTimerIds.forEach((id) => window.clearTimeout(id));
|
||||||
|
clientBurstTimerIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 嵌入页收到 RESTORE_FOCUS 或自行调用 */
|
||||||
|
export function restoreUrlEmbedClientFocus() {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
try {
|
||||||
|
wakePointerEvents(document);
|
||||||
|
window.focus();
|
||||||
|
document.body?.focus?.();
|
||||||
|
const focusable = document.querySelector(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||||
|
);
|
||||||
|
focusable?.focus?.({ preventScroll: true });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
applyIosWechatScrollHack();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 嵌入页侧持续恢复(微信 popstate / pageshow 后) */
|
||||||
|
export function startUrlEmbedClientFocusBurst({
|
||||||
|
durationMs = 6000,
|
||||||
|
intervalMs = 350,
|
||||||
|
} = {}) {
|
||||||
|
cancelUrlEmbedClientFocusBurst();
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
restoreUrlEmbedClientFocus();
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
if (Date.now() - startedAt >= durationMs) return;
|
||||||
|
restoreUrlEmbedClientFocus();
|
||||||
|
const id = window.setTimeout(tick, intervalMs);
|
||||||
|
clientBurstTimerIds.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstId = window.setTimeout(tick, intervalMs);
|
||||||
|
clientBurstTimerIds.push(firstId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleUrlEmbedClientRestoreForEnv() {
|
||||||
|
if (isWeChatBrowser()) {
|
||||||
|
startUrlEmbedClientFocusBurst();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restoreUrlEmbedClientFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭抽屉前清理 iframe,避免微信缓存残留 DOM 状态 */
|
||||||
|
export function teardownEmbedFrame(frame) {
|
||||||
|
cancelHostEmbedFrameRestoreBurst();
|
||||||
|
if (!frame) return;
|
||||||
|
try {
|
||||||
|
frame.src = "about:blank";
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const doc = frame.contentWindow?.document;
|
||||||
|
doc?.open();
|
||||||
|
doc?.write("");
|
||||||
|
doc?.close();
|
||||||
|
} catch {
|
||||||
|
// cross-origin
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/iview/utils/deepClone.js
Normal file
26
src/iview/utils/deepClone.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
/**
|
||||||
|
* 深度克隆对象
|
||||||
|
*
|
||||||
|
* @param {*} obj 要克隆的对象
|
||||||
|
* @returns 克隆后的对象
|
||||||
|
*/
|
||||||
|
export function deepClone(obj) {
|
||||||
|
const cache = new Map();
|
||||||
|
function _deepClone(obj) {
|
||||||
|
if (typeof obj !== 'object' || obj === null) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
if (cache.has(obj)) {
|
||||||
|
return cache.get(obj);
|
||||||
|
}
|
||||||
|
const result = Array.isArray(obj) ? [] : {};
|
||||||
|
cache.set(obj, result);
|
||||||
|
for (const key in obj) {
|
||||||
|
result[key] = _deepClone(obj[key]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return _deepClone(obj);
|
||||||
|
}
|
||||||
|
|
||||||
22
src/iview/utils/delayCall.js
Normal file
22
src/iview/utils/delayCall.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
let delayCallMap = {};
|
||||||
|
|
||||||
|
// 延时防抖执行
|
||||||
|
export const delayCall = (func, delay) => {
|
||||||
|
return (...args) => {
|
||||||
|
if (delayCallMap[func]) {
|
||||||
|
clearTimeout(delayCallMap[func]);
|
||||||
|
delete delayCallMap[func];
|
||||||
|
}
|
||||||
|
let timer = setTimeout(() => {
|
||||||
|
func(...args);
|
||||||
|
delete delayCallMap[func];
|
||||||
|
}, delay);
|
||||||
|
delayCallMap[func] = timer;
|
||||||
|
return {
|
||||||
|
cancel: () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
delete delayCallMap[func];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
38
src/iview/utils/emitter.js
Normal file
38
src/iview/utils/emitter.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
let emitListener = [];
|
||||||
|
|
||||||
|
export const emit = (event, data) => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new CustomEvent(event, { detail: data }));
|
||||||
|
} else {
|
||||||
|
emitListener.forEach((listener) => {
|
||||||
|
if (listener.event === event) {
|
||||||
|
listener.callback(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const on = (event, callback) => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.addEventListener(event, callback);
|
||||||
|
} else {
|
||||||
|
emitListener.push({ event, callback });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const off = (event, callback) => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.removeEventListener(event, callback);
|
||||||
|
} else {
|
||||||
|
emitListener = emitListener.filter(
|
||||||
|
(listener) => listener.event !== event || listener.callback !== callback
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emitOnce = (event, data) => {
|
||||||
|
on(event, () => {
|
||||||
|
emit(event, data);
|
||||||
|
off(event);
|
||||||
|
});
|
||||||
|
};
|
||||||
19
src/iview/utils/event.js
Normal file
19
src/iview/utils/event.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export function onAttachEvent(elem, event, fn, flag = false) {
|
||||||
|
if (elem.addEventListener != null) {
|
||||||
|
return elem.addEventListener(event, fn, flag);
|
||||||
|
} else if (elem.attachEvent != null) {
|
||||||
|
return elem.attachEvent("on" + event, fn);
|
||||||
|
} else {
|
||||||
|
return elem[event] = fn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function offAttachEvent(elem, event, fn, flag = false) {
|
||||||
|
if (elem.removeEventListener != null) {
|
||||||
|
return elem.removeEventListener(event, fn, flag);
|
||||||
|
} else if (elem.detachEvent != null) {
|
||||||
|
return elem.detachEvent("on" + event, fn);
|
||||||
|
} else {
|
||||||
|
delete elem[event];
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/iview/utils/func.js
Normal file
40
src/iview/utils/func.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
export const createFunc = (funcStr, argNames = ['args']) => {
|
||||||
|
if (funcStr === undefined || funcStr === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof funcStr === "function") {
|
||||||
|
return funcStr;
|
||||||
|
} else if (typeof funcStr === "string") {
|
||||||
|
try {
|
||||||
|
let func;
|
||||||
|
const fnStr = funcStr.trim();
|
||||||
|
if (fnStr.includes("=>")) {
|
||||||
|
func = new Function(...argNames, `return ${fnStr}`);
|
||||||
|
} else if (fnStr.startsWith("function")) {
|
||||||
|
func = new Function(...argNames, `return (${fnStr})(${argNames})`);
|
||||||
|
} else {
|
||||||
|
func = new Function(...argNames, `return ${fnStr} `);
|
||||||
|
}
|
||||||
|
return func;
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const callFunc = (argNames, paramList, funcStr, defaultValue) => {
|
||||||
|
try {
|
||||||
|
let func = createFunc(funcStr, argNames);
|
||||||
|
if (func) {
|
||||||
|
let result = func(...paramList);
|
||||||
|
if (result === undefined || result === null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
};
|
||||||
201
src/iview/utils/imageOutput.js
Normal file
201
src/iview/utils/imageOutput.js
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
/** @typedef {'contain' | 'cover'} ImageOutputMode */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, unknown> | undefined | null} field
|
||||||
|
* @returns {{
|
||||||
|
* width: number,
|
||||||
|
* height: number,
|
||||||
|
* mode: ImageOutputMode,
|
||||||
|
* quality: number,
|
||||||
|
* background: string,
|
||||||
|
* mimeType: string,
|
||||||
|
* } | null}
|
||||||
|
*/
|
||||||
|
export function resolveImageOutputConfig(field) {
|
||||||
|
const width = Number(field?.outputWidth);
|
||||||
|
const height = Number(field?.outputHeight);
|
||||||
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = field?.outputMode === "contain" ? "contain" : "cover";
|
||||||
|
const qualityRaw = Number(field?.outputQuality);
|
||||||
|
const quality =
|
||||||
|
Number.isFinite(qualityRaw) && qualityRaw > 0 && qualityRaw <= 1 ? qualityRaw : 0.85;
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: Math.round(width),
|
||||||
|
height: Math.round(height),
|
||||||
|
mode,
|
||||||
|
quality,
|
||||||
|
background: typeof field?.outputBackground === "string" && field.outputBackground
|
||||||
|
? field.outputBackground
|
||||||
|
: "#ffffff",
|
||||||
|
mimeType:
|
||||||
|
typeof field?.outputMimeType === "string" && field.outputMimeType
|
||||||
|
? field.outputMimeType
|
||||||
|
: "image/jpeg",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldProcessImageOutput(field) {
|
||||||
|
return resolveImageOutputConfig(field) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, unknown> | undefined | null} field
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatImageOutputHint(field) {
|
||||||
|
const config = resolveImageOutputConfig(field);
|
||||||
|
if (!config) return "";
|
||||||
|
const modeLabel = config.mode === "cover" ? "裁切" : "适应";
|
||||||
|
return `${config.width}×${config.height}px(${modeLabel})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string | HTMLCanvasElement | HTMLImageElement} source
|
||||||
|
* @returns {Promise<HTMLImageElement>}
|
||||||
|
*/
|
||||||
|
export function loadImageElement(source) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (source instanceof HTMLImageElement) {
|
||||||
|
if (source.complete && source.naturalWidth > 0) {
|
||||||
|
resolve(source);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
source.onload = () => resolve(source);
|
||||||
|
source.onerror = () => reject(new Error("图片加载失败"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error("图片加载失败"));
|
||||||
|
|
||||||
|
if (source instanceof HTMLCanvasElement) {
|
||||||
|
image.src = source.toDataURL("image/png");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof source === "string") {
|
||||||
|
image.src = source.startsWith("data:") ? source : `data:image/png;base64,${source}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error("不支持的图片源"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContainDrawRect(sourceWidth, sourceHeight, outputWidth, outputHeight) {
|
||||||
|
const scale = Math.min(outputWidth / sourceWidth, outputHeight / sourceHeight);
|
||||||
|
const drawWidth = sourceWidth * scale;
|
||||||
|
const drawHeight = sourceHeight * scale;
|
||||||
|
|
||||||
|
return {
|
||||||
|
offsetX: (outputWidth - drawWidth) / 2,
|
||||||
|
offsetY: (outputHeight - drawHeight) / 2,
|
||||||
|
drawWidth,
|
||||||
|
drawHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCoverDrawRect(sourceWidth, sourceHeight, outputWidth, outputHeight) {
|
||||||
|
const scale = Math.max(outputWidth / sourceWidth, outputHeight / sourceHeight);
|
||||||
|
const drawWidth = sourceWidth * scale;
|
||||||
|
const drawHeight = sourceHeight * scale;
|
||||||
|
|
||||||
|
return {
|
||||||
|
offsetX: (outputWidth - drawWidth) / 2,
|
||||||
|
offsetY: (outputHeight - drawHeight) / 2,
|
||||||
|
drawWidth,
|
||||||
|
drawHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDrawRect(mode, sourceWidth, sourceHeight, outputWidth, outputHeight) {
|
||||||
|
if (mode === "cover") {
|
||||||
|
return getCoverDrawRect(sourceWidth, sourceHeight, outputWidth, outputHeight);
|
||||||
|
}
|
||||||
|
return getContainDrawRect(sourceWidth, sourceHeight, outputWidth, outputHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string | HTMLCanvasElement | HTMLImageElement} source
|
||||||
|
* @param {NonNullable<ReturnType<typeof resolveImageOutputConfig>>} config
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
export async function normalizeImageOutput(source, config) {
|
||||||
|
const image = await loadImageElement(source);
|
||||||
|
const outputCanvas = document.createElement("canvas");
|
||||||
|
outputCanvas.width = config.width;
|
||||||
|
outputCanvas.height = config.height;
|
||||||
|
|
||||||
|
const context = outputCanvas.getContext("2d");
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("无法处理图片");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先铺底色,避免 PNG 透明区或 JPEG 导出时透明像素变成黑色
|
||||||
|
context.fillStyle = config.background;
|
||||||
|
context.fillRect(0, 0, config.width, config.height);
|
||||||
|
|
||||||
|
const { offsetX, offsetY, drawWidth, drawHeight } = getDrawRect(
|
||||||
|
config.mode,
|
||||||
|
image.width,
|
||||||
|
image.height,
|
||||||
|
config.width,
|
||||||
|
config.height,
|
||||||
|
);
|
||||||
|
|
||||||
|
context.drawImage(
|
||||||
|
image,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
image.width,
|
||||||
|
image.height,
|
||||||
|
offsetX,
|
||||||
|
offsetY,
|
||||||
|
drawWidth,
|
||||||
|
drawHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
const quality = config.mimeType === "image/jpeg" ? config.quality : undefined;
|
||||||
|
return outputCanvas.toDataURL(config.mimeType, quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} dataUrl
|
||||||
|
* @param {Record<string, unknown> | undefined | null} field
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
export async function processImageDataUrl(dataUrl, field) {
|
||||||
|
const config = resolveImageOutputConfig(field);
|
||||||
|
if (!config) {
|
||||||
|
return dataUrl;
|
||||||
|
}
|
||||||
|
return normalizeImageOutput(dataUrl, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {File} file
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
export function fileToDataUrl(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result || ""));
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {File} file
|
||||||
|
* @param {Record<string, unknown> | undefined | null} field
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
export async function processImageFile(file, field) {
|
||||||
|
const dataUrl = await fileToDataUrl(file);
|
||||||
|
return processImageDataUrl(dataUrl, field);
|
||||||
|
}
|
||||||
230
src/iview/utils/md5.js
Normal file
230
src/iview/utils/md5.js
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
/*
|
||||||
|
* A JavaScript implementation of the RSA Data Security, Inc. MD5 Message
|
||||||
|
* Digest Algorithm, as defined in RFC 1321.
|
||||||
|
* Version 2.1 Copyright (C) Paul Johnston 1999 - 2002.
|
||||||
|
* Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet
|
||||||
|
* Distributed under the BSD License
|
||||||
|
* See http://pajhome.org.uk/crypt/md5 for more info.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Configurable variables. You may need to tweak these to be compatible with
|
||||||
|
* the server-side, but the defaults work in most cases.
|
||||||
|
*/
|
||||||
|
var hexcase = 0; /* hex output format. 0 - lowercase; 1 - uppercase */
|
||||||
|
var b64pad = ""; /* base-64 pad character. "=" for strict RFC compliance */
|
||||||
|
var chrsz = 8; /* bits per input character. 8 - ASCII; 16 - Unicode */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Calculate the MD5 of an array of little-endian words, and a bit length
|
||||||
|
*/
|
||||||
|
function core_md5(x, len) {
|
||||||
|
/* append padding */
|
||||||
|
x[len >> 5] |= 0x80 << ((len) % 32);
|
||||||
|
x[(((len + 64) >>> 9) << 4) + 14] = len;
|
||||||
|
|
||||||
|
var a = 1732584193;
|
||||||
|
var b = -271733879;
|
||||||
|
var c = -1732584194;
|
||||||
|
var d = 271733878;
|
||||||
|
|
||||||
|
for (var i = 0; i < x.length; i += 16) {
|
||||||
|
var olda = a;
|
||||||
|
var oldb = b;
|
||||||
|
var oldc = c;
|
||||||
|
var oldd = d;
|
||||||
|
|
||||||
|
a = md5_ff(a, b, c, d, x[i + 0], 7, -680876936);
|
||||||
|
d = md5_ff(d, a, b, c, x[i + 1], 12, -389564586);
|
||||||
|
c = md5_ff(c, d, a, b, x[i + 2], 17, 606105819);
|
||||||
|
b = md5_ff(b, c, d, a, x[i + 3], 22, -1044525330);
|
||||||
|
a = md5_ff(a, b, c, d, x[i + 4], 7, -176418897);
|
||||||
|
d = md5_ff(d, a, b, c, x[i + 5], 12, 1200080426);
|
||||||
|
c = md5_ff(c, d, a, b, x[i + 6], 17, -1473231341);
|
||||||
|
b = md5_ff(b, c, d, a, x[i + 7], 22, -45705983);
|
||||||
|
a = md5_ff(a, b, c, d, x[i + 8], 7, 1770035416);
|
||||||
|
d = md5_ff(d, a, b, c, x[i + 9], 12, -1958414417);
|
||||||
|
c = md5_ff(c, d, a, b, x[i + 10], 17, -42063);
|
||||||
|
b = md5_ff(b, c, d, a, x[i + 11], 22, -1990404162);
|
||||||
|
a = md5_ff(a, b, c, d, x[i + 12], 7, 1804603682);
|
||||||
|
d = md5_ff(d, a, b, c, x[i + 13], 12, -40341101);
|
||||||
|
c = md5_ff(c, d, a, b, x[i + 14], 17, -1502002290);
|
||||||
|
b = md5_ff(b, c, d, a, x[i + 15], 22, 1236535329);
|
||||||
|
|
||||||
|
a = md5_gg(a, b, c, d, x[i + 1], 5, -165796510);
|
||||||
|
d = md5_gg(d, a, b, c, x[i + 6], 9, -1069501632);
|
||||||
|
c = md5_gg(c, d, a, b, x[i + 11], 14, 643717713);
|
||||||
|
b = md5_gg(b, c, d, a, x[i + 0], 20, -373897302);
|
||||||
|
a = md5_gg(a, b, c, d, x[i + 5], 5, -701558691);
|
||||||
|
d = md5_gg(d, a, b, c, x[i + 10], 9, 38016083);
|
||||||
|
c = md5_gg(c, d, a, b, x[i + 15], 14, -660478335);
|
||||||
|
b = md5_gg(b, c, d, a, x[i + 4], 20, -405537848);
|
||||||
|
a = md5_gg(a, b, c, d, x[i + 9], 5, 568446438);
|
||||||
|
d = md5_gg(d, a, b, c, x[i + 14], 9, -1019803690);
|
||||||
|
c = md5_gg(c, d, a, b, x[i + 3], 14, -187363961);
|
||||||
|
b = md5_gg(b, c, d, a, x[i + 8], 20, 1163531501);
|
||||||
|
a = md5_gg(a, b, c, d, x[i + 13], 5, -1444681467);
|
||||||
|
d = md5_gg(d, a, b, c, x[i + 2], 9, -51403784);
|
||||||
|
c = md5_gg(c, d, a, b, x[i + 7], 14, 1735328473);
|
||||||
|
b = md5_gg(b, c, d, a, x[i + 12], 20, -1926607734);
|
||||||
|
|
||||||
|
a = md5_hh(a, b, c, d, x[i + 5], 4, -378558);
|
||||||
|
d = md5_hh(d, a, b, c, x[i + 8], 11, -2022574463);
|
||||||
|
c = md5_hh(c, d, a, b, x[i + 11], 16, 1839030562);
|
||||||
|
b = md5_hh(b, c, d, a, x[i + 14], 23, -35309556);
|
||||||
|
a = md5_hh(a, b, c, d, x[i + 1], 4, -1530992060);
|
||||||
|
d = md5_hh(d, a, b, c, x[i + 4], 11, 1272893353);
|
||||||
|
c = md5_hh(c, d, a, b, x[i + 7], 16, -155497632);
|
||||||
|
b = md5_hh(b, c, d, a, x[i + 10], 23, -1094730640);
|
||||||
|
a = md5_hh(a, b, c, d, x[i + 13], 4, 681279174);
|
||||||
|
d = md5_hh(d, a, b, c, x[i + 0], 11, -358537222);
|
||||||
|
c = md5_hh(c, d, a, b, x[i + 3], 16, -722521979);
|
||||||
|
b = md5_hh(b, c, d, a, x[i + 6], 23, 76029189);
|
||||||
|
a = md5_hh(a, b, c, d, x[i + 9], 4, -640364487);
|
||||||
|
d = md5_hh(d, a, b, c, x[i + 12], 11, -421815835);
|
||||||
|
c = md5_hh(c, d, a, b, x[i + 15], 16, 530742520);
|
||||||
|
b = md5_hh(b, c, d, a, x[i + 2], 23, -995338651);
|
||||||
|
|
||||||
|
a = md5_ii(a, b, c, d, x[i + 0], 6, -198630844);
|
||||||
|
d = md5_ii(d, a, b, c, x[i + 7], 10, 1126891415);
|
||||||
|
c = md5_ii(c, d, a, b, x[i + 14], 15, -1416354905);
|
||||||
|
b = md5_ii(b, c, d, a, x[i + 5], 21, -57434055);
|
||||||
|
a = md5_ii(a, b, c, d, x[i + 12], 6, 1700485571);
|
||||||
|
d = md5_ii(d, a, b, c, x[i + 3], 10, -1894986606);
|
||||||
|
c = md5_ii(c, d, a, b, x[i + 10], 15, -1051523);
|
||||||
|
b = md5_ii(b, c, d, a, x[i + 1], 21, -2054922799);
|
||||||
|
a = md5_ii(a, b, c, d, x[i + 8], 6, 1873313359);
|
||||||
|
d = md5_ii(d, a, b, c, x[i + 15], 10, -30611744);
|
||||||
|
c = md5_ii(c, d, a, b, x[i + 6], 15, -1560198380);
|
||||||
|
b = md5_ii(b, c, d, a, x[i + 13], 21, 1309151649);
|
||||||
|
a = md5_ii(a, b, c, d, x[i + 4], 6, -145523070);
|
||||||
|
d = md5_ii(d, a, b, c, x[i + 11], 10, -1120210379);
|
||||||
|
c = md5_ii(c, d, a, b, x[i + 2], 15, 718787259);
|
||||||
|
b = md5_ii(b, c, d, a, x[i + 9], 21, -343485551);
|
||||||
|
|
||||||
|
a = safe_add(a, olda);
|
||||||
|
b = safe_add(b, oldb);
|
||||||
|
c = safe_add(c, oldc);
|
||||||
|
d = safe_add(d, oldd);
|
||||||
|
}
|
||||||
|
return Array(a, b, c, d);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* These functions implement the four basic operations the algorithm uses.
|
||||||
|
*/
|
||||||
|
function md5_cmn(q, a, b, x, s, t) {
|
||||||
|
return safe_add(bit_rol(safe_add(safe_add(a, q), safe_add(x, t)), s), b);
|
||||||
|
}
|
||||||
|
function md5_ff(a, b, c, d, x, s, t) {
|
||||||
|
return md5_cmn((b & c) | ((~b) & d), a, b, x, s, t);
|
||||||
|
}
|
||||||
|
function md5_gg(a, b, c, d, x, s, t) {
|
||||||
|
return md5_cmn((b & d) | (c & (~d)), a, b, x, s, t);
|
||||||
|
}
|
||||||
|
function md5_hh(a, b, c, d, x, s, t) {
|
||||||
|
return md5_cmn(b ^ c ^ d, a, b, x, s, t);
|
||||||
|
}
|
||||||
|
function md5_ii(a, b, c, d, x, s, t) {
|
||||||
|
return md5_cmn(c ^ (b | (~d)), a, b, x, s, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Calculate the HMAC-MD5, of a key and some data
|
||||||
|
*/
|
||||||
|
function core_hmac_md5(key, data) {
|
||||||
|
var bkey = str2binl(key);
|
||||||
|
if (bkey.length > 16) bkey = core_md5(bkey, key.length * chrsz);
|
||||||
|
|
||||||
|
var ipad = Array(16), opad = Array(16);
|
||||||
|
for (var i = 0; i < 16; i++) {
|
||||||
|
ipad[i] = bkey[i] ^ 0x36363636;
|
||||||
|
opad[i] = bkey[i] ^ 0x5C5C5C5C;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hash = core_md5(ipad.concat(str2binl(data)), 512 + data.length * chrsz);
|
||||||
|
return core_md5(opad.concat(hash), 512 + 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Add integers, wrapping at 2^32. This uses 16-bit operations internally
|
||||||
|
* to work around bugs in some JS interpreters.
|
||||||
|
*/
|
||||||
|
function safe_add(x, y) {
|
||||||
|
var lsw = (x & 0xFFFF) + (y & 0xFFFF);
|
||||||
|
var msw = (x >> 16) + (y >> 16) + (lsw >> 16);
|
||||||
|
return (msw << 16) | (lsw & 0xFFFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Bitwise rotate a 32-bit number to the left.
|
||||||
|
*/
|
||||||
|
function bit_rol(num, cnt) {
|
||||||
|
return (num << cnt) | (num >>> (32 - cnt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Convert a string to an array of little-endian words
|
||||||
|
* If chrsz is ASCII, characters >255 have their hi-byte silently ignored.
|
||||||
|
*/
|
||||||
|
function str2binl(str) {
|
||||||
|
var bin = Array();
|
||||||
|
var mask = (1 << chrsz) - 1;
|
||||||
|
for (var i = 0; i < str.length * chrsz; i += chrsz)
|
||||||
|
bin[i >> 5] |= (str.charCodeAt(i / chrsz) & mask) << (i % 32);
|
||||||
|
return bin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Convert an array of little-endian words to a string
|
||||||
|
*/
|
||||||
|
function binl2str(bin) {
|
||||||
|
var str = "";
|
||||||
|
var mask = (1 << chrsz) - 1;
|
||||||
|
for (var i = 0; i < bin.length * 32; i += chrsz)
|
||||||
|
str += String.fromCharCode((bin[i >> 5] >>> (i % 32)) & mask);
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Convert an array of little-endian words to a hex string.
|
||||||
|
*/
|
||||||
|
function binl2hex(binarray) {
|
||||||
|
var hex_tab = hexcase ? "0123456789ABCDEF" : "0123456789abcdef";
|
||||||
|
var str = "";
|
||||||
|
for (var i = 0; i < binarray.length * 4; i++) {
|
||||||
|
str += hex_tab.charAt((binarray[i >> 2] >> ((i % 4) * 8 + 4)) & 0xF) +
|
||||||
|
hex_tab.charAt((binarray[i >> 2] >> ((i % 4) * 8)) & 0xF);
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Convert an array of little-endian words to a base-64 string
|
||||||
|
*/
|
||||||
|
function binl2b64(binarray) {
|
||||||
|
var tab = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
var str = "";
|
||||||
|
for (var i = 0; i < binarray.length * 4; i += 3) {
|
||||||
|
var triplet = (((binarray[i >> 2] >> 8 * (i % 4)) & 0xFF) << 16)
|
||||||
|
| (((binarray[i + 1 >> 2] >> 8 * ((i + 1) % 4)) & 0xFF) << 8)
|
||||||
|
| ((binarray[i + 2 >> 2] >> 8 * ((i + 2) % 4)) & 0xFF);
|
||||||
|
for (var j = 0; j < 4; j++) {
|
||||||
|
if (i * 8 + j * 6 > binarray.length * 32) str += b64pad;
|
||||||
|
else str += tab.charAt((triplet >> 6 * (3 - j)) & 0x3F);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* These are the functions you'll usually want to call
|
||||||
|
* They take string arguments and return either hex or base-64 encoded strings
|
||||||
|
*/
|
||||||
|
export function hex_md5(s) { return binl2hex(core_md5(str2binl(s), s.length * chrsz)); }
|
||||||
|
export function b64_md5(s) { return binl2b64(core_md5(str2binl(s), s.length * chrsz)); }
|
||||||
|
export function str_md5(s) { return binl2str(core_md5(str2binl(s), s.length * chrsz)); }
|
||||||
|
export function hex_hmac_md5(key, data) { return binl2hex(core_hmac_md5(key, data)); }
|
||||||
|
export function b64_hmac_md5(key, data) { return binl2b64(core_hmac_md5(key, data)); }
|
||||||
|
export function str_hmac_md5(key, data) { return binl2str(core_hmac_md5(key, data)); }
|
||||||
89
src/iview/utils/messageTips.js
Normal file
89
src/iview/utils/messageTips.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { message } from "ant-design-vue";
|
||||||
|
|
||||||
|
/** 提示文案按字符折行(中英文连续串均可断开),不按英文单词边界留白 */
|
||||||
|
const MESSAGE_CHAR_WRAP_CLASS = "message-tips-char-wrap";
|
||||||
|
|
||||||
|
let charWrapStylesInjected = false;
|
||||||
|
|
||||||
|
function ensureMessageTipsCharWrapStyles() {
|
||||||
|
if (charWrapStylesInjected || typeof document === "undefined") return;
|
||||||
|
charWrapStylesInjected = true;
|
||||||
|
const el = document.createElement("style");
|
||||||
|
el.setAttribute("data-message-tips", "char-wrap");
|
||||||
|
el.textContent = `.${MESSAGE_CHAR_WRAP_CLASS} .ant-message-custom-content {
|
||||||
|
word-break: break-all;
|
||||||
|
text-align: left;
|
||||||
|
}`;
|
||||||
|
document.head.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushSuccess(content) {
|
||||||
|
ensureMessageTipsCharWrapStyles();
|
||||||
|
message.success({
|
||||||
|
content,
|
||||||
|
class: MESSAGE_CHAR_WRAP_CLASS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushError(content) {
|
||||||
|
ensureMessageTipsCharWrapStyles();
|
||||||
|
message.error({
|
||||||
|
content,
|
||||||
|
class: MESSAGE_CHAR_WRAP_CLASS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightFields(errorFields) {
|
||||||
|
try {
|
||||||
|
if (errorFields && errorFields.length > 0) {
|
||||||
|
let fieldName = errorFields[0].name[0];
|
||||||
|
let firstErrorField = document.getElementById(
|
||||||
|
`form_item_${fieldName}_field`
|
||||||
|
);
|
||||||
|
if (!firstErrorField) {
|
||||||
|
firstErrorField = document.getElementById(
|
||||||
|
`form_item_${fieldName}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (firstErrorField) {
|
||||||
|
firstErrorField.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "start",
|
||||||
|
});
|
||||||
|
firstErrorField.classList.add("shake-animation");
|
||||||
|
setTimeout(() => {
|
||||||
|
firstErrorField.classList.remove("shake-animation");
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const showSuccessTips = (msg) => {
|
||||||
|
pushSuccess(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const showErrorTips = (err) => {
|
||||||
|
if (!err) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
highlightFields(err.errorFields);
|
||||||
|
if (err.errorFields && err.errorFields.length > 0) {
|
||||||
|
let errorMsg = err.errorFields
|
||||||
|
.map((item) => item.errors.join(","))
|
||||||
|
.join(",")
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => !!item);
|
||||||
|
if (errorMsg.length > 1) {
|
||||||
|
pushError("【共" + errorMsg.length + "处错误】" + errorMsg[0]);
|
||||||
|
} else if (errorMsg.length > 0) {
|
||||||
|
pushError(errorMsg[0]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let errorMsg =
|
||||||
|
(err && err.msg) || (err && err.message) || "连接服务器失败,请检查网络";
|
||||||
|
pushError(errorMsg);
|
||||||
|
}
|
||||||
|
};
|
||||||
78
src/iview/utils/query.js
Normal file
78
src/iview/utils/query.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
export function stringifyQuery(query) {
|
||||||
|
return Object.keys(query).map(key => {
|
||||||
|
const value = query[key];
|
||||||
|
return `${key}=${encodeURIComponent(value)}`;
|
||||||
|
}).join('&');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeQuery(query) {
|
||||||
|
return Object.keys(query).reduce((result, key) => {
|
||||||
|
const value = query[key];
|
||||||
|
result[key] = decodeURIComponent(value);
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseQuery(path) {
|
||||||
|
let url = '';
|
||||||
|
let params = {};
|
||||||
|
if (!path || typeof path !== 'string') {
|
||||||
|
return { url, params }
|
||||||
|
}
|
||||||
|
let queryString = '';
|
||||||
|
let indexE = path.indexOf('?');
|
||||||
|
let indexW = path.indexOf('#');
|
||||||
|
let indexQ = path.indexOf('=');
|
||||||
|
if (indexE >= 0) {
|
||||||
|
url = path.substring(0, indexE);
|
||||||
|
queryString = path.substring(indexE + 1, indexW > 0 ? indexW : path.length);
|
||||||
|
} else if (indexQ >= 0) {
|
||||||
|
url = '';
|
||||||
|
queryString = path;
|
||||||
|
} else {
|
||||||
|
url = path;
|
||||||
|
queryString = '';
|
||||||
|
}
|
||||||
|
queryString.split('&').map(item => {
|
||||||
|
let index = item.indexOf("=");
|
||||||
|
if (index > 0) {
|
||||||
|
let key = item.substring(0, index);
|
||||||
|
let value = item.substring(index + 1);
|
||||||
|
params[key] = decodeURIComponent(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { url, params };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendQuery(path, query = {}) {
|
||||||
|
const { url, params } = parseQuery(path);
|
||||||
|
Object.keys(query).forEach(key => {
|
||||||
|
let value = query[key];
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
params[key] = value;
|
||||||
|
} else {
|
||||||
|
delete params[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const queryString = Object.keys(params).map(key => {
|
||||||
|
const value = params[key];
|
||||||
|
return `${key}=${encodeURIComponent(value)}`;
|
||||||
|
}).join('&');;
|
||||||
|
if (!url) {
|
||||||
|
return queryString;
|
||||||
|
}
|
||||||
|
if (!queryString) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return `${url}?${queryString}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQueryString(key, def) {
|
||||||
|
let search = location.search;
|
||||||
|
var reg = new RegExp("(^|&)" + key + "=([^&]*)(&|$)");
|
||||||
|
var r = search.substr(1).match(reg);
|
||||||
|
if (r != null) {
|
||||||
|
return decodeURIComponent(unescape(r[2]));
|
||||||
|
}
|
||||||
|
return def;
|
||||||
|
}
|
||||||
552
src/iview/utils/request.js
Normal file
552
src/iview/utils/request.js
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import config from "@/config/index";
|
||||||
|
import { emit } from "@/iview/utils/emitter";
|
||||||
|
import { uuidv4 } from "@/iview/utils/uuid";
|
||||||
|
import { hex_md5 } from "@/iview/utils/md5";
|
||||||
|
import { useUserStore } from "@/store/user";
|
||||||
|
import { toAny } from "@/iview/utils/toany";
|
||||||
|
|
||||||
|
const request = axios.create({
|
||||||
|
timeout: 60000,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token刷新相关状态
|
||||||
|
let isRefreshing = false;
|
||||||
|
let refreshSubscribers = [];
|
||||||
|
|
||||||
|
// 添加请求到刷新队列
|
||||||
|
function subscribeTokenRefresh(cb) {
|
||||||
|
refreshSubscribers.push(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行刷新队列中的所有请求
|
||||||
|
function onTokenRefreshed(token) {
|
||||||
|
refreshSubscribers.forEach((cb) => cb(token));
|
||||||
|
refreshSubscribers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
request.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
// 检查是否是refreshToken请求,如果是则不添加token
|
||||||
|
const requestUrl = config.url || "";
|
||||||
|
const isRefreshTokenRequest = requestUrl.includes("refreshToken");
|
||||||
|
if (!isRefreshTokenRequest) {
|
||||||
|
config.data = config.data || {};
|
||||||
|
if (!config.data.token || config._isRetry) {
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const token = userStore.userToken;
|
||||||
|
if (token) {
|
||||||
|
config.data.token = token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject({ msg: "网络异常,请稍后再试", });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 响应拦截器
|
||||||
|
request.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
const res = response.data;
|
||||||
|
// 检查接口响应状态
|
||||||
|
if (res.code === 0) {
|
||||||
|
return res.data;
|
||||||
|
} else if (res.code === 103) {
|
||||||
|
// 检查是否是重试请求,如果是则直接跳转登录(避免无限循环)
|
||||||
|
if (response.config._isRetry) {
|
||||||
|
emit("auth:token-expired");
|
||||||
|
return Promise.reject({
|
||||||
|
msg: "登录已过期,请重新登录",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 检查是否是refreshToken请求本身,如果是则直接跳转登录
|
||||||
|
const requestUrl = response.config.url || "";
|
||||||
|
const isRefreshTokenRequest = requestUrl.includes("refreshToken");
|
||||||
|
if (isRefreshTokenRequest) {
|
||||||
|
// refreshToken请求失败,直接跳转登录
|
||||||
|
emit("auth:token-expired");
|
||||||
|
return Promise.reject({
|
||||||
|
msg: "登录已过期,请重新登录",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Token过期,尝试刷新
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const refreshToken = userStore.refreshToken;
|
||||||
|
|
||||||
|
// 如果没有refreshToken,直接跳转登录
|
||||||
|
if (!refreshToken) {
|
||||||
|
emit("auth:token-expired");
|
||||||
|
return Promise.reject({
|
||||||
|
msg: "登录已过期,请重新登录",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果正在刷新token,将当前请求加入队列
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
subscribeTokenRefresh(() => {
|
||||||
|
const originalRequest = response.config;
|
||||||
|
const requestData = toAny(originalRequest.data, {});
|
||||||
|
const retryConfig = {
|
||||||
|
...originalRequest,
|
||||||
|
data: {
|
||||||
|
...requestData,
|
||||||
|
},
|
||||||
|
_isRetry: true,
|
||||||
|
};
|
||||||
|
request(retryConfig).then((res) => {
|
||||||
|
resolve(res);
|
||||||
|
}).catch((err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 开始刷新token
|
||||||
|
isRefreshing = true;
|
||||||
|
return userStore
|
||||||
|
.refreshUserToken()
|
||||||
|
.then(() => {
|
||||||
|
isRefreshing = false;
|
||||||
|
onTokenRefreshed();
|
||||||
|
const originalRequest = response.config;
|
||||||
|
const requestData = toAny(originalRequest.data, {});
|
||||||
|
const retryConfig = {
|
||||||
|
...originalRequest,
|
||||||
|
data: {
|
||||||
|
...requestData,
|
||||||
|
_retry: true,
|
||||||
|
},
|
||||||
|
_isRetry: true,
|
||||||
|
};
|
||||||
|
return request(retryConfig);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
isRefreshing = false;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return Promise.reject({
|
||||||
|
msg: res.msg || "网络异常,请稍后再试",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject({
|
||||||
|
msg: "网络异常,请稍后再试",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 增强的缓存管理器
|
||||||
|
class RequestCache {
|
||||||
|
constructor() {
|
||||||
|
// 内存缓存
|
||||||
|
this.memoryCache = new Map();
|
||||||
|
this.cacheTimeout = new Map();
|
||||||
|
// 默认配置
|
||||||
|
this.defaultExpire = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
this.defaultRefreshTime = 5 * 60 * 1000;
|
||||||
|
this.storagePrefix = `${config.APP_CACHE_PROFIX}_api_`;
|
||||||
|
this.loadFromStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成缓存key
|
||||||
|
generateKey(action, params, customKey) {
|
||||||
|
if (customKey) {
|
||||||
|
return customKey;
|
||||||
|
}
|
||||||
|
const sortedParams = JSON.stringify(params, Object.keys(params).sort());
|
||||||
|
return `${action}_${hex_md5(encodeURIComponent(sortedParams))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成localStorage key
|
||||||
|
getStorageKey(key) {
|
||||||
|
return `${this.storagePrefix}${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从localStorage加载缓存
|
||||||
|
loadFromStorage() {
|
||||||
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
const keys = Object.keys(localStorage);
|
||||||
|
|
||||||
|
keys.forEach((storageKey) => {
|
||||||
|
if (storageKey.startsWith(this.storagePrefix)) {
|
||||||
|
try {
|
||||||
|
const cacheData = JSON.parse(localStorage.getItem(storageKey));
|
||||||
|
if (cacheData && cacheData.timestamp && cacheData.expireTime) {
|
||||||
|
// 检查是否已过期
|
||||||
|
if (now - cacheData.timestamp < cacheData.expireTime) {
|
||||||
|
const key = storageKey.replace(this.storagePrefix, "");
|
||||||
|
// 加载到内存缓存中
|
||||||
|
this.memoryCache.set(key, cacheData);
|
||||||
|
|
||||||
|
// 设置过期清理定时器
|
||||||
|
const remainingTime = cacheData.expireTime - (now - cacheData.timestamp);
|
||||||
|
if (remainingTime > 0) {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.delete(key);
|
||||||
|
}, remainingTime);
|
||||||
|
this.cacheTimeout.set(key, timeout);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(storageKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
localStorage.removeItem(storageKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置缓存
|
||||||
|
set(key, data, options = {}) {
|
||||||
|
const {
|
||||||
|
expireTime = this.defaultExpire,
|
||||||
|
refreshTime = this.defaultRefreshTime,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const cacheItem = {
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
expireTime,
|
||||||
|
refreshTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 存储到内存缓存
|
||||||
|
this.memoryCache.set(key, cacheItem);
|
||||||
|
|
||||||
|
// 持久化到localStorage
|
||||||
|
try {
|
||||||
|
localStorage.setItem(this.getStorageKey(key), JSON.stringify(cacheItem));
|
||||||
|
} catch (error) {
|
||||||
|
this.cleanOldCache();
|
||||||
|
try {
|
||||||
|
localStorage.setItem(this.getStorageKey(key), JSON.stringify(cacheItem));
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置过期清理定时器
|
||||||
|
if (this.cacheTimeout.has(key)) {
|
||||||
|
clearTimeout(this.cacheTimeout.get(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.delete(key);
|
||||||
|
}, expireTime);
|
||||||
|
|
||||||
|
this.cacheTimeout.set(key, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取缓存
|
||||||
|
get(key) {
|
||||||
|
const item = this.memoryCache.get(key);
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const age = now - item.timestamp;
|
||||||
|
|
||||||
|
// 检查是否已过期
|
||||||
|
if (age > item.expireTime) {
|
||||||
|
this.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: item.data,
|
||||||
|
isStale: age > item.refreshTime || item.refreshTime == 0,
|
||||||
|
age: age,
|
||||||
|
refreshTime: item.refreshTime,
|
||||||
|
expireTime: item.expireTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查缓存是否需要刷新
|
||||||
|
needsRefresh(key) {
|
||||||
|
const item = this.memoryCache.get(key);
|
||||||
|
if (!item) return false;
|
||||||
|
|
||||||
|
const age = Date.now() - item.timestamp;
|
||||||
|
return age > item.refreshTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除缓存
|
||||||
|
delete(key) {
|
||||||
|
// 从内存缓存删除
|
||||||
|
this.memoryCache.delete(key);
|
||||||
|
|
||||||
|
// 从localStorage删除
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(this.getStorageKey(key));
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理定时器
|
||||||
|
if (this.cacheTimeout.has(key)) {
|
||||||
|
clearTimeout(this.cacheTimeout.get(key));
|
||||||
|
this.cacheTimeout.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理旧缓存(当localStorage空间不足时)
|
||||||
|
cleanOldCache() {
|
||||||
|
try {
|
||||||
|
const keys = Object.keys(localStorage);
|
||||||
|
const cacheKeys = [];
|
||||||
|
|
||||||
|
keys.forEach((storageKey) => {
|
||||||
|
if (storageKey.startsWith(this.storagePrefix)) {
|
||||||
|
try {
|
||||||
|
const cacheData = JSON.parse(localStorage.getItem(storageKey));
|
||||||
|
if (cacheData && cacheData.timestamp) {
|
||||||
|
cacheKeys.push({
|
||||||
|
key: storageKey,
|
||||||
|
timestamp: cacheData.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
localStorage.removeItem(storageKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按时间排序,删除最旧的缓存
|
||||||
|
cacheKeys.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
|
||||||
|
// 删除最旧的30%缓存
|
||||||
|
const deleteCount = Math.ceil(cacheKeys.length * 0.3);
|
||||||
|
for (let i = 0; i < deleteCount; i++) {
|
||||||
|
localStorage.removeItem(cacheKeys[i].key);
|
||||||
|
const key = cacheKeys[i].key.replace(this.storagePrefix, "");
|
||||||
|
this.memoryCache.delete(key);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空所有缓存
|
||||||
|
clear() {
|
||||||
|
// 清空内存缓存
|
||||||
|
this.memoryCache.clear();
|
||||||
|
|
||||||
|
// 清空定时器
|
||||||
|
this.cacheTimeout.forEach((timeout) => clearTimeout(timeout));
|
||||||
|
this.cacheTimeout.clear();
|
||||||
|
|
||||||
|
// 清空localStorage中的缓存
|
||||||
|
try {
|
||||||
|
const keys = Object.keys(localStorage);
|
||||||
|
keys.forEach((key) => {
|
||||||
|
if (key.startsWith(this.storagePrefix)) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取缓存统计
|
||||||
|
getStats() {
|
||||||
|
const memorySize = this.memoryCache.size;
|
||||||
|
let storageSize = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keys = Object.keys(localStorage);
|
||||||
|
keys.forEach((key) => {
|
||||||
|
if (key.startsWith(this.storagePrefix)) {
|
||||||
|
storageSize++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
memorySize,
|
||||||
|
storageSize,
|
||||||
|
memoryKeys: Array.from(this.memoryCache.keys()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建全局缓存实例
|
||||||
|
const requestCache = new RequestCache();
|
||||||
|
const viewIdCache = new Map();
|
||||||
|
|
||||||
|
// API请求函数
|
||||||
|
export const apiRequest = (action, params = {}, options = {}) => {
|
||||||
|
const {
|
||||||
|
viewId = null,
|
||||||
|
cache = false,
|
||||||
|
cacheCallback = null,
|
||||||
|
cacheKey = null,
|
||||||
|
cacheExpire = null,
|
||||||
|
cacheRefreshTime = null,
|
||||||
|
...requestOptions
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let currentRequestUUID = uuidv4();
|
||||||
|
|
||||||
|
if (viewId) {
|
||||||
|
viewIdCache.set(viewId, currentRequestUUID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果启用缓存
|
||||||
|
if (cache) {
|
||||||
|
const key = requestCache.generateKey(action, params, cacheKey);
|
||||||
|
// 尝试获取缓存数据
|
||||||
|
const cachedResult = requestCache.get(key);
|
||||||
|
if (cachedResult) {
|
||||||
|
// 如果有缓存回调,立即调用
|
||||||
|
if (typeof cacheCallback === "function") {
|
||||||
|
try {
|
||||||
|
cacheCallback(cachedResult.data);
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果缓存需要刷新,后台更新
|
||||||
|
if (cachedResult.isStale) {
|
||||||
|
// 后台发起请求更新缓存
|
||||||
|
const backgroundRequest = request(
|
||||||
|
Object.assign({}, requestOptions, {
|
||||||
|
url: config.BASE_URL + "?action=" + action,
|
||||||
|
method: "post",
|
||||||
|
data: { params },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
backgroundRequest
|
||||||
|
.then((data) => {
|
||||||
|
// 更新缓存
|
||||||
|
cacheUtils.setCache(action, params, data, {
|
||||||
|
cacheExpire,
|
||||||
|
cacheRefreshTime,
|
||||||
|
});
|
||||||
|
if (viewId) {
|
||||||
|
let viewRequestUUID = viewIdCache.get(viewId);
|
||||||
|
if (currentRequestUUID != viewRequestUUID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
viewIdCache.delete(viewId);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
cacheCallback(data);
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => { });
|
||||||
|
}
|
||||||
|
// 返回缓存的数据
|
||||||
|
return Promise.resolve(cachedResult.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发起实际请求
|
||||||
|
const requestPromise = request(
|
||||||
|
Object.assign({}, requestOptions, {
|
||||||
|
url: config.BASE_URL + "?action=" + action,
|
||||||
|
method: "post",
|
||||||
|
data: {
|
||||||
|
platform: "common",
|
||||||
|
params
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 如果启用缓存,处理响应结果
|
||||||
|
if (cache) {
|
||||||
|
return requestPromise
|
||||||
|
.then((data) => {
|
||||||
|
cacheUtils.setCache(action, params, data, {
|
||||||
|
cacheExpire,
|
||||||
|
cacheRefreshTime,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(`[Request Failed] ${action}:`, error.message);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出缓存管理器实例,供外部使用
|
||||||
|
export const cache = requestCache;
|
||||||
|
|
||||||
|
// 缓存管理工具方法
|
||||||
|
export const cacheUtils = {
|
||||||
|
// 清空指定action的缓存
|
||||||
|
clearActionCache: (action) => {
|
||||||
|
const stats = requestCache.getStats();
|
||||||
|
stats.memoryKeys.forEach((key) => {
|
||||||
|
if (key.startsWith(action + "_")) {
|
||||||
|
requestCache.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清空所有缓存
|
||||||
|
clearAllCache: () => {
|
||||||
|
requestCache.clear();
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取缓存统计信息
|
||||||
|
getCacheStats: () => {
|
||||||
|
return requestCache.getStats();
|
||||||
|
},
|
||||||
|
|
||||||
|
// 手动设置缓存
|
||||||
|
setCache: (action, params, data, options = {}) => {
|
||||||
|
const { cacheKey, cacheExpire, cacheRefreshTime } = options;
|
||||||
|
const key = requestCache.generateKey(action, params, cacheKey);
|
||||||
|
const cacheOptions = {
|
||||||
|
expireTime: cacheExpire === undefined ? requestCache.defaultExpire : cacheExpire,
|
||||||
|
refreshTime: cacheRefreshTime === undefined ? requestCache.defaultRefreshTime : cacheRefreshTime,
|
||||||
|
};
|
||||||
|
requestCache.set(key, data, cacheOptions);
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 手动获取缓存
|
||||||
|
getCache: (action, params, cacheKey = null) => {
|
||||||
|
const key = requestCache.generateKey(action, params, cacheKey);
|
||||||
|
const result = requestCache.get(key);
|
||||||
|
return result ? result.data : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除指定缓存
|
||||||
|
deleteCache: (action, params, cacheKey = null) => {
|
||||||
|
const key = requestCache.generateKey(action, params, cacheKey);
|
||||||
|
requestCache.delete(key);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 检查缓存是否存在且新鲜
|
||||||
|
isCacheFresh: (action, params, cacheKey = null) => {
|
||||||
|
const key = requestCache.generateKey(action, params, cacheKey);
|
||||||
|
const result = requestCache.get(key);
|
||||||
|
return result && !result.isStale;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取缓存年龄
|
||||||
|
getCacheAge: (action, params, cacheKey = null) => {
|
||||||
|
const key = requestCache.generateKey(action, params, cacheKey);
|
||||||
|
const result = requestCache.get(key);
|
||||||
|
return result ? result.age : null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default request;
|
||||||
80
src/iview/utils/toany.js
Normal file
80
src/iview/utils/toany.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
function _toNumber_(value) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch (ex) {
|
||||||
|
return parseFloat(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _toBoolean_(value) {
|
||||||
|
return !!value && value != 'false' && value != '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _toString_(value, def) {
|
||||||
|
try {
|
||||||
|
let type = typeof value;
|
||||||
|
if (type === 'string') {
|
||||||
|
return value;
|
||||||
|
} else if (type === 'boolean') {
|
||||||
|
return value ? 'true' : 'false';
|
||||||
|
} else if (type === 'number') {
|
||||||
|
return `${value}`;
|
||||||
|
} else if (type === 'object') {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
}
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _toObject_(value, def) {
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch (ex) {
|
||||||
|
}
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _inferType(value) {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return typeof value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return typeof parsed;
|
||||||
|
} catch (ex) {
|
||||||
|
// might be a pure number
|
||||||
|
const number = parseFloat(value);
|
||||||
|
if (!isNaN(number) && `${number}` === value) {
|
||||||
|
return 'number';
|
||||||
|
}
|
||||||
|
return 'string';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAny(value, def) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
// try to infer type and return
|
||||||
|
let type = _inferType(value);
|
||||||
|
if (def !== undefined && def !== null) {
|
||||||
|
type = typeof def;
|
||||||
|
}
|
||||||
|
switch (type) {
|
||||||
|
case 'number':
|
||||||
|
return _toNumber_(value);
|
||||||
|
case 'boolean':
|
||||||
|
return _toBoolean_(value);
|
||||||
|
case 'object':
|
||||||
|
return _toObject_(value, def);
|
||||||
|
case 'string':
|
||||||
|
return _toString_(value, def);
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return def;
|
||||||
|
}
|
||||||
23
src/iview/utils/useListRefs.js
Normal file
23
src/iview/utils/useListRefs.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// useListRefs.js
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export function useListRefs() {
|
||||||
|
const refsMap = ref(new Map());
|
||||||
|
// 返回 ref 绑定函数
|
||||||
|
const setRef = (id) => (el) => {
|
||||||
|
if (el) {
|
||||||
|
refsMap.value.set(id, el);
|
||||||
|
} else {
|
||||||
|
refsMap.value.delete(id); // 元素移除时清理
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 获取特定 ref
|
||||||
|
const getRef = (id) => refsMap.value.get(id);
|
||||||
|
// 获取所有 ref
|
||||||
|
const getAllRefs = () => Array.from(refsMap.value.values());
|
||||||
|
return {
|
||||||
|
setRef,
|
||||||
|
getRef,
|
||||||
|
getAllRefs
|
||||||
|
};
|
||||||
|
}
|
||||||
26
src/iview/utils/useModel.js
Normal file
26
src/iview/utils/useModel.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
export const useVModel = (props, propName, emit) => {
|
||||||
|
return computed({
|
||||||
|
get() {
|
||||||
|
return new Proxy(props[propName], {
|
||||||
|
set(target, key, value) {
|
||||||
|
emit(`update:${propName}`, Object.assign(target, {
|
||||||
|
[key]: value
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
get(target, key, receiver) {
|
||||||
|
let result = Reflect.get(target, key, receiver);
|
||||||
|
if (result === null || result === undefined) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
emit(`update:${propName}`, value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
6
src/iview/utils/uuid.js
Normal file
6
src/iview/utils/uuid.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export function uuidv4() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||||
|
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
247
src/pages/index.css
Normal file
247
src/pages/index.css
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
:root {
|
||||||
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
background-color: #242424;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #646cff;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #535bf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 100vw;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3.2em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.6em 1.2em;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
border-color: #646cff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 4px auto -webkit-focus-ring-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
text-align: center;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
color: #213547;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #747bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-image-preview-mask {
|
||||||
|
background-color: rgba(0, 0, 0, 0.85) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-image-preview-img {
|
||||||
|
max-width: 75% !important;
|
||||||
|
max-height: 75% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-menu-title-content {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-footer {
|
||||||
|
z-index: 10086;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-list-split .ant-list-item {
|
||||||
|
border-block-end: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-empty-description {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-drawer-header {
|
||||||
|
padding: 14px 24px !important;
|
||||||
|
padding-right: 10px !important;
|
||||||
|
background-color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-drawer-close,
|
||||||
|
.ant-drawer-title {
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-drawer-header-title {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-upload-wrapper.ant-upload-picture-card-wrapper .ant-upload.ant-upload-select {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-form-item .ant-form-item-control {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iview-filter-form .ant-form-item .ant-form-item-control {
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-drawer-close {
|
||||||
|
margin-inline-end: 0px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.del {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-tag {
|
||||||
|
margin-inline-end: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data {
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端适配 */
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.page-header h2 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordwrap {
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-all;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-message-notice-content {
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
10%,
|
||||||
|
30%,
|
||||||
|
50%,
|
||||||
|
70%,
|
||||||
|
90% {
|
||||||
|
transform: translateX(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
20%,
|
||||||
|
40%,
|
||||||
|
60%,
|
||||||
|
80% {
|
||||||
|
transform: translateX(5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shake-animation {
|
||||||
|
animation: shake 0.5s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
|
||||||
|
/* border-right: 1px solid red; */
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-result .ant-result-title) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
color: #24292e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-tabs .ant-tabs-tab+.ant-tabs-tab {
|
||||||
|
margin: 0 0 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.ant-tabs .ant-tabs-tab+.ant-tabs-tab {
|
||||||
|
margin: 0 0 0 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 删除状态, 设置划线的颜色 */
|
||||||
|
.is_delete {
|
||||||
|
color: #999;
|
||||||
|
text-decoration: line-through;
|
||||||
|
text-decoration-color: #333333;
|
||||||
|
text-decoration-thickness: 1.5px;
|
||||||
|
text-decoration-style: solid;
|
||||||
|
text-decoration-skip-ink: none;
|
||||||
|
text-decoration-skip: none;
|
||||||
|
text-decoration-skip-ink: none;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-drawer-body::-webkit-scrollbar{
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
16
src/pages/index.html
Normal file
16
src/pages/index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="../assets/logo.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<title>%VITE_APP_TITLE%</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="./index.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
28
src/pages/index.js
Normal file
28
src/pages/index.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import "./index.css";
|
||||||
|
import { createApp } from "vue";
|
||||||
|
import { imageDefaultBase64 } from "@/common/image";
|
||||||
|
import VueLazyload from "vue-lazyload";
|
||||||
|
import Antd from "ant-design-vue";
|
||||||
|
import "ant-design-vue/dist/reset.css";
|
||||||
|
import App from "./index.vue";
|
||||||
|
import router from "@/config/router";
|
||||||
|
import config from "@/config/index";
|
||||||
|
import pinia from "@/store/index";
|
||||||
|
|
||||||
|
// 输出当前环境信息
|
||||||
|
console.log("当前环境:", config.MODE);
|
||||||
|
console.log("API地址:", config.BASE_URL);
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
|
||||||
|
app.use(router);
|
||||||
|
app.use(pinia);
|
||||||
|
app.use(Antd);
|
||||||
|
app.use(VueLazyload, {
|
||||||
|
preLoad: 1,
|
||||||
|
loading: imageDefaultBase64,
|
||||||
|
error: imageDefaultBase64,
|
||||||
|
attempt: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.mount("#app");
|
||||||
158
src/pages/index.vue
Normal file
158
src/pages/index.vue
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<script setup>
|
||||||
|
import zhCN from "ant-design-vue/es/locale/zh_CN";
|
||||||
|
import { useUserStore } from "@/store/user";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const locale = zhCN;
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const isInitialized = ref(false);
|
||||||
|
|
||||||
|
userStore.init().then(() => {
|
||||||
|
isInitialized.value = true;
|
||||||
|
userStore.initTokenExpirationListener();
|
||||||
|
if (userStore.isLoggedIn) {
|
||||||
|
userStore.getUserProfile();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-spin :spinning="!isInitialized">
|
||||||
|
<a-config-provider :locale="locale" v-if="isInitialized">
|
||||||
|
<router-view></router-view>
|
||||||
|
</a-config-provider>
|
||||||
|
<div v-else class="loading-container"></div>
|
||||||
|
</a-spin>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 全局样式和响应式基础设置 */
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
||||||
|
Arial, "Noto Sans", sans-serif;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 设置根元素字体大小,便于使用rem单位 */
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端适配 - 根据屏幕宽度调整基础字体大小 */
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
html {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 576px) {
|
||||||
|
html {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 禁止移动端在页面边界和弹窗容器上的上下回弹 */
|
||||||
|
.layout,
|
||||||
|
.layout-content,
|
||||||
|
.content,
|
||||||
|
.ant-modal-wrap,
|
||||||
|
.ant-modal-content,
|
||||||
|
.ant-modal-body,
|
||||||
|
.ant-drawer-content-wrapper,
|
||||||
|
.ant-drawer-content,
|
||||||
|
.ant-drawer-body {
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 让所有滚动条美观 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #c1c1c1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #a8a8a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 特定区域隐藏滚动条 */
|
||||||
|
.hide-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式工具类 */
|
||||||
|
.hidden-xs {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visible-xs {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.hidden-xs {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visible-xs {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ant Design 表格响应式调整 */
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.ant-table {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-thead > tr > th,
|
||||||
|
.ant-table-tbody > tr > td {
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单在移动端的调整 */
|
||||||
|
.ant-form-item {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-form-item-label {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
5
src/store/index.js
Normal file
5
src/store/index.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
export default pinia
|
||||||
171
src/store/user.js
Normal file
171
src/store/user.js
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { ref } from "vue";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { apiRequest } from "@/iview/utils/request";
|
||||||
|
import { toAny } from "@/iview/utils/toany";
|
||||||
|
import { on } from "@/iview/utils/emitter";
|
||||||
|
import config from "@/config/index";
|
||||||
|
|
||||||
|
const generateKey = (key) => {
|
||||||
|
return `${config.APP_CACHE_PROFIX}_${key}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStorageValue = (key) => {
|
||||||
|
return Promise.resolve().then(() => {
|
||||||
|
key = generateKey(key);
|
||||||
|
const value = localStorage.getItem(key);
|
||||||
|
return toAny(value, {}).value;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStorageValue = (key, value) => {
|
||||||
|
return Promise.resolve().then(() => {
|
||||||
|
key = generateKey(key);
|
||||||
|
localStorage.setItem(
|
||||||
|
key,
|
||||||
|
toAny({ key, value }, "")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeStorageValue = (key) => {
|
||||||
|
return Promise.resolve().then(() => {
|
||||||
|
key = generateKey(key);
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_LOGIN = "api.system.admin.info.auth.login";
|
||||||
|
const API_PROFILE = "api.system.admin.info.auth.profile";
|
||||||
|
const API_REFRESH_TOKEN = `refreshToken`;
|
||||||
|
const KEY_SIDEBAR_COLLAPSE = "state_collapseSidebar";
|
||||||
|
const KEY_LAST_USERNAME = "state_lastUsername";
|
||||||
|
const KEY_REFRESH_TOKEN = "state_refreshToken";
|
||||||
|
const KEY_USER_TOKEN = "state_userToken";
|
||||||
|
const KEY_USER_INFO = "state_userInfo";
|
||||||
|
|
||||||
|
/** 防止 103 / 刷新失败 连续触发时重复跳转 */
|
||||||
|
let sessionExpiryHandling = false;
|
||||||
|
|
||||||
|
export const useUserStore = defineStore("user", {
|
||||||
|
state: () => ({
|
||||||
|
initialized: ref(false),
|
||||||
|
collapseSidebar: ref(false),
|
||||||
|
lastUsername: ref(null),
|
||||||
|
refreshToken: ref(null),
|
||||||
|
userToken: ref(null),
|
||||||
|
userInfo: ref(null),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
isLoggedIn: (state) => !!state.userToken,
|
||||||
|
isInitialized: (state) => state.initialized,
|
||||||
|
isCollapseSidebar: (state) => state.collapseSidebar,
|
||||||
|
isSuper: (state) => state.userInfo?.optSuper === 1,
|
||||||
|
username: (state) => state.userInfo?.username,
|
||||||
|
nickname: (state) => state.userInfo?.nickname,
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async init() {
|
||||||
|
const collapseSidebar = await getStorageValue(KEY_SIDEBAR_COLLAPSE);
|
||||||
|
const lastUsername = await getStorageValue(KEY_LAST_USERNAME);
|
||||||
|
const refreshToken = await getStorageValue(KEY_REFRESH_TOKEN);
|
||||||
|
const userToken = await getStorageValue(KEY_USER_TOKEN);
|
||||||
|
const userInfo = await getStorageValue(KEY_USER_INFO);
|
||||||
|
this.initialized = true;
|
||||||
|
this.collapseSidebar = collapseSidebar === "true";
|
||||||
|
this.lastUsername = lastUsername;
|
||||||
|
this.refreshToken = refreshToken;
|
||||||
|
this.userToken = userToken;
|
||||||
|
this.userInfo = userInfo;
|
||||||
|
},
|
||||||
|
// 登录
|
||||||
|
async login(username, password) {
|
||||||
|
const data = await apiRequest(API_LOGIN, {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
this.userToken = data.token;
|
||||||
|
this.refreshToken = data.refreshToken;
|
||||||
|
this.lastUsername = username;
|
||||||
|
await setStorageValue(KEY_LAST_USERNAME, username);
|
||||||
|
await setStorageValue(KEY_REFRESH_TOKEN, data.refreshToken);
|
||||||
|
await setStorageValue(KEY_USER_TOKEN, data.token);
|
||||||
|
await this.getUserProfile();
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
// 刷新Token
|
||||||
|
async refreshUserToken() {
|
||||||
|
if (!this.refreshToken) {
|
||||||
|
throw new Error("No refresh token available");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await apiRequest(API_REFRESH_TOKEN, {
|
||||||
|
refreshToken: this.refreshToken,
|
||||||
|
});
|
||||||
|
this.userToken = data.token;
|
||||||
|
this.refreshToken = data.refreshToken;
|
||||||
|
await setStorageValue(KEY_USER_TOKEN, data.token);
|
||||||
|
await setStorageValue(KEY_REFRESH_TOKEN, data.refreshToken);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
token: data.token,
|
||||||
|
refreshToken: data.refreshToken
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.refreshToken = null;
|
||||||
|
await removeStorageValue(KEY_REFRESH_TOKEN);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
async getUserProfile() {
|
||||||
|
const userInfo = await apiRequest(API_PROFILE, {});
|
||||||
|
this.userInfo = userInfo;
|
||||||
|
await setStorageValue(KEY_USER_INFO, userInfo);
|
||||||
|
return { success: true, data: userInfo };
|
||||||
|
},
|
||||||
|
|
||||||
|
// 退出登录
|
||||||
|
async logout() {
|
||||||
|
this.userToken = "";
|
||||||
|
this.refreshToken = "";
|
||||||
|
this.userInfo = {};
|
||||||
|
await removeStorageValue(KEY_USER_INFO);
|
||||||
|
await removeStorageValue(KEY_USER_TOKEN);
|
||||||
|
await removeStorageValue(KEY_REFRESH_TOKEN);
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveCollapseSidebar(collapse) {
|
||||||
|
this.collapseSidebar = !!collapse;
|
||||||
|
await setStorageValue(KEY_SIDEBAR_COLLAPSE, this.collapseSidebar ? "true" : "false");
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 登录失效:清状态并跳到登录(用动态 import router,避免与 request 循环依赖且保证能拿到路由器实例) */
|
||||||
|
async handleSessionExpired() {
|
||||||
|
if (sessionExpiryHandling) return;
|
||||||
|
sessionExpiryHandling = true;
|
||||||
|
try {
|
||||||
|
await this.logout();
|
||||||
|
const { default: router } = await import("@/config/router");
|
||||||
|
const current = router.currentRoute.value;
|
||||||
|
if (current.path === "/login") return;
|
||||||
|
await router.replace({
|
||||||
|
path: "/login",
|
||||||
|
query: { redirect: current.fullPath },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
sessionExpiryHandling = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
initTokenExpirationListener() {
|
||||||
|
on("auth:token-expired", () => {
|
||||||
|
void this.handleSessionExpired();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
387
src/views/Login.vue
Normal file
387
src/views/Login.vue
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-container">
|
||||||
|
<!-- 背景动画效果 -->
|
||||||
|
<div class="background-animation">
|
||||||
|
<div class="shape shape1"></div>
|
||||||
|
<div class="shape shape2"></div>
|
||||||
|
<div class="shape shape3"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 登录卡片 -->
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<div class="logo-container">
|
||||||
|
<img src="@/assets/logo.svg" alt="Logo" class="logo" />
|
||||||
|
</div>
|
||||||
|
<h1 class="login-title">{{ config.APP_TITLE }}</h1>
|
||||||
|
</div>
|
||||||
|
<p class="login-subtitle">
|
||||||
|
{{ config.APP_SUB_TITLE }}
|
||||||
|
</p>
|
||||||
|
<a-form :model="formState" name="login" @finish="onFinish" class="login-form">
|
||||||
|
<a-form-item
|
||||||
|
name="username"
|
||||||
|
:rules="[{ required: true, message: '请输入用户名!' }]"
|
||||||
|
>
|
||||||
|
<a-input v-model:value="formState.username" placeholder="用户名" size="large">
|
||||||
|
<template #prefix>
|
||||||
|
<user-outlined class="input-icon" />
|
||||||
|
</template>
|
||||||
|
</a-input>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item
|
||||||
|
name="password"
|
||||||
|
:rules="[{ required: true, message: '请输入密码!' }]"
|
||||||
|
>
|
||||||
|
<a-input
|
||||||
|
v-model:value="formState.password"
|
||||||
|
placeholder="密码"
|
||||||
|
size="large"
|
||||||
|
type="password"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<lock-outlined class="input-icon" />
|
||||||
|
</template>
|
||||||
|
</a-input>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
html-type="submit"
|
||||||
|
class="login-button"
|
||||||
|
size="large"
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
|
||||||
|
<div class="login-footer">
|
||||||
|
<div>
|
||||||
|
<span>{{ config.BUILD_VERSION }}</span>
|
||||||
|
<span>{{ config.APP_VERSION_NAME }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>© {{ new Date().getFullYear() }}</span>
|
||||||
|
<span>{{ config.APP_COPYRIGHT }}</span>
|
||||||
|
<span>版权所有</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from "vue";
|
||||||
|
import { useRouter, useRoute } from "vue-router";
|
||||||
|
import { showErrorTips, showSuccessTips } from "@/iview/utils/messageTips";
|
||||||
|
import { UserOutlined, LockOutlined } from "@ant-design/icons-vue";
|
||||||
|
import { useUserStore } from "@/store/user";
|
||||||
|
import config from "@/config/index";
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const formState = reactive({
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
formState.username = userStore.lastUsername || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
// 提交登录表单
|
||||||
|
const onFinish = async (values) => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await doLogin(values.username, values.password);
|
||||||
|
} catch (error) {
|
||||||
|
showErrorTips(error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 执行登录操作
|
||||||
|
const doLogin = async (username, password) => {
|
||||||
|
const result = await userStore.login(username, password);
|
||||||
|
if (result.success) {
|
||||||
|
showSuccessTips("登录成功");
|
||||||
|
const redirect = route.query.redirect;
|
||||||
|
if (redirect) {
|
||||||
|
router.push(redirect);
|
||||||
|
} else {
|
||||||
|
router.push("/dashboard");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let error = new Error(result.message || "登录失败,请重试");
|
||||||
|
showErrorTips(error);
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||||
|
padding: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 背景动画效果 */
|
||||||
|
.background-animation {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
animation: float 20s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape1 {
|
||||||
|
width: 600px;
|
||||||
|
height: 600px;
|
||||||
|
top: -300px;
|
||||||
|
right: -200px;
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape2 {
|
||||||
|
width: 400px;
|
||||||
|
height: 400px;
|
||||||
|
bottom: -200px;
|
||||||
|
left: -100px;
|
||||||
|
animation-delay: 2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape3 {
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
bottom: 30%;
|
||||||
|
right: 10%;
|
||||||
|
animation-delay: 4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0) rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translateY(-20px) rotate(5deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translateY(0) rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 登录卡片 */
|
||||||
|
.login-card {
|
||||||
|
position: relative;
|
||||||
|
width: 420px;
|
||||||
|
max-width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.2);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form :deep(.ant-form-item-explain-error) {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form :deep(.ant-form-item-explain) {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-icon {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-form-item {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-form-item :deep(.ant-form-item-control-input) {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #636366;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-link {
|
||||||
|
margin: 0 2px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1890ff;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-link:hover {
|
||||||
|
color: #40a9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
width: 100%;
|
||||||
|
height: 45px;
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 应用选择对话框 */
|
||||||
|
.app-select-modal :deep(.ant-modal-content) {
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-select-content {
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-select-tip {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #666;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-list {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-select-button {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
height: 45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端适配 */
|
||||||
|
@media screen and (max-width: 576px) {
|
||||||
|
.login-container {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
padding: 40px 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-animation {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape1 {
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape2,
|
||||||
|
.shape3 {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
441
src/views/PageDashboard.vue
Normal file
441
src/views/PageDashboard.vue
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
<template>
|
||||||
|
<a-layout class="layout" :style="menuStyle">
|
||||||
|
<!-- 移动设备上的遮罩层 -->
|
||||||
|
<div v-if="isMobile && siderVisible" class="mobile-mask" @click="toggleSider"></div>
|
||||||
|
<!-- 侧边栏 -->
|
||||||
|
<a-layout-sider class="sider hide-scrollbar" @collapse="handleCollapse" :collapsed="collapsed"
|
||||||
|
:class="{ 'mobile-sider': isMobile, 'mobile-sider-visible': siderVisible }">
|
||||||
|
<div class="logo" v-if="!collapsed">
|
||||||
|
<svg t="1779016191270" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="21892"
|
||||||
|
width="256" height="256" class="custom-svg">
|
||||||
|
<path
|
||||||
|
d="M886.97857168 763.55H587.02142833v112.69285753h74.95714334a37.54285751 37.54285751 0 0 1 0 75.08571415H362.02142833a37.54285751 37.54285751 0 0 1-1e-8-75.08571415h74.95714336v-112.69285753H137.02142832A75.02142833 75.02142833 0 0 1 62 688.46428583V162.8C62 121.33571417 95.55714247 87.71428583 137.02142832 87.71428583h749.95714336C928.44285753 87.71428583 962 121.33571417 962 162.8v525.66428585c0 41.46428585-33.55714249 75.08571415-75.02142833 75.08571415zM137.02142832 650.92142832h749.95714336V575.83571417H137.02142832v75.08571415z"
|
||||||
|
p-id="21893" fill="#ffffff"></path>
|
||||||
|
</svg>
|
||||||
|
<div class="logo-text-container">
|
||||||
|
<span class="logo-text">
|
||||||
|
{{ config.APP_TITLE }}
|
||||||
|
</span>
|
||||||
|
<div class="logo-subtitle">
|
||||||
|
{{ config.APP_SUB_TITLE }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="logo" v-else>
|
||||||
|
<svg t="1779016191270" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="21892"
|
||||||
|
width="256" height="256" class="custom-svg">
|
||||||
|
<path
|
||||||
|
d="M886.97857168 763.55H587.02142833v112.69285753h74.95714334a37.54285751 37.54285751 0 0 1 0 75.08571415H362.02142833a37.54285751 37.54285751 0 0 1-1e-8-75.08571415h74.95714336v-112.69285753H137.02142832A75.02142833 75.02142833 0 0 1 62 688.46428583V162.8C62 121.33571417 95.55714247 87.71428583 137.02142832 87.71428583h749.95714336C928.44285753 87.71428583 962 121.33571417 962 162.8v525.66428585c0 41.46428585-33.55714249 75.08571415-75.02142833 75.08571415zM137.02142832 650.92142832h749.95714336V575.83571417H137.02142832v75.08571415z"
|
||||||
|
p-id="21893" fill="#ffffff"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<IViewMenu v-model:selectedKeys="selectedKeys" :collapsed="collapsed" :menuConfig="menuConfig"
|
||||||
|
:bgColor="themeInfo.bgColor" :textColor="themeInfo.textColor" :selectedTextColor="themeInfo.selectedTextColor"
|
||||||
|
:selectedBgColor="themeInfo.selectedBgColor" :hoverTextColor="themeInfo.hoverTextColor"
|
||||||
|
:hoverBgColor="themeInfo.hoverBgColor" @item-click="handleMenuItemClick" />
|
||||||
|
</a-layout-sider>
|
||||||
|
|
||||||
|
<a-layout class="layout-content" :class="{ collapsed: collapsed && !isMobile, mobile: isMobile }">
|
||||||
|
<a-layout-header class="header" :class="{ 'mobile-header': isMobile }">
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="header-left-icon">
|
||||||
|
<menu-unfold-outlined v-if="isMobile ? !siderVisible : collapsed" class="trigger" @click="toggleSider" />
|
||||||
|
<menu-fold-outlined v-else class="trigger" @click="toggleSider" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-right">
|
||||||
|
<a-dropdown :trigger="['click']">
|
||||||
|
<a class="user-dropdown">
|
||||||
|
<span class="username" :title="unitName">{{ unitName }}</span>
|
||||||
|
<down-outlined />
|
||||||
|
</a>
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu>
|
||||||
|
<a-menu-item key="1" @click="handleLogout">
|
||||||
|
<logout-outlined />
|
||||||
|
退出登录
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</div>
|
||||||
|
</a-layout-header>
|
||||||
|
<a-layout-content class="content hide-scrollbar">
|
||||||
|
<router-view></router-view>
|
||||||
|
</a-layout-content>
|
||||||
|
</a-layout>
|
||||||
|
<IViewUrlEmbedHost />
|
||||||
|
</a-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed, onMounted, onBeforeMount, onUnmounted } from "vue";
|
||||||
|
import { useRouter, useRoute } from "vue-router";
|
||||||
|
import { useUserStore } from "../store/user";
|
||||||
|
import { showSuccessTips } from "@/iview/utils/messageTips";
|
||||||
|
import {
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
MenuFoldOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
DownOutlined,
|
||||||
|
} from "@ant-design/icons-vue";
|
||||||
|
import { menuConfig, getMenuKeyByPath } from "../config/menu";
|
||||||
|
import config from "@/config/index";
|
||||||
|
import IViewMenu from "@/iview/IViewMenu.vue";
|
||||||
|
import IViewUrlEmbedHost from "@/iview/IViewUrlEmbedHost.vue";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const themeInfo = ref({
|
||||||
|
siderColor: "#1677ff",
|
||||||
|
logoColor: "#FFFFFF",
|
||||||
|
textColor: "#FFFFFF",
|
||||||
|
selectedTextColor: "#ffffff",
|
||||||
|
selectedBgColor: "#0b68ff",
|
||||||
|
hoverTextColor: "#ffffff",
|
||||||
|
hoverBgColor: "#0b68ff",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 侧边栏折叠状态(桌面端使用)
|
||||||
|
const collapsed = ref(true); // 默认折叠
|
||||||
|
// 是否是移动设备
|
||||||
|
const isMobile = ref(false);
|
||||||
|
// 侧边栏是否显示(移动端使用)
|
||||||
|
const siderVisible = ref(false);
|
||||||
|
|
||||||
|
// 监听窗口大小变化
|
||||||
|
const checkIsMobile = () => {
|
||||||
|
const newIsMobile = window.innerWidth <= 768;
|
||||||
|
// 如果状态变了才更新,避免不必要的渲染
|
||||||
|
if (newIsMobile !== isMobile.value) {
|
||||||
|
isMobile.value = newIsMobile;
|
||||||
|
// 如果变成移动设备,确保侧边栏隐藏
|
||||||
|
if (isMobile.value) {
|
||||||
|
siderVisible.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 在组件挂载前初始化移动设备检测
|
||||||
|
onBeforeMount(() => {
|
||||||
|
checkIsMobile();
|
||||||
|
window.addEventListener("resize", checkIsMobile);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 组件卸载时移除事件监听
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener("resize", checkIsMobile);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 当前选中的菜单项
|
||||||
|
const selectedKeys = ref([]);
|
||||||
|
|
||||||
|
// 侧边栏折叠状态
|
||||||
|
const handleCollapse = (collapsed) => {
|
||||||
|
collapsed.value = collapsed;
|
||||||
|
userStore.saveCollapseSidebar(collapsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算菜单样式
|
||||||
|
const menuStyle = computed(() => {
|
||||||
|
return {
|
||||||
|
"--theme-sider-color": themeInfo.value.siderColor,
|
||||||
|
"--theme-logo-color": themeInfo.value.logoColor,
|
||||||
|
border: "none",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 根据当前路由路径设置选中菜单项和展开状态
|
||||||
|
const setSelectedMenu = () => {
|
||||||
|
const path = route.path;
|
||||||
|
const menuKey = getMenuKeyByPath(path);
|
||||||
|
|
||||||
|
// IViewMenu 只支持一级菜单,直接使用 menuKey
|
||||||
|
selectedKeys.value = [menuKey];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化菜单选中状态
|
||||||
|
onMounted(() => {
|
||||||
|
setSelectedMenu();
|
||||||
|
// 重新获取用户信息
|
||||||
|
if (userStore.isLoggedIn) {
|
||||||
|
userStore.getUserProfile();
|
||||||
|
}
|
||||||
|
collapsed.value = userStore.isCollapseSidebar;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听路由变化,更新选中的菜单项
|
||||||
|
watch(
|
||||||
|
() => route.path,
|
||||||
|
(newPath) => {
|
||||||
|
setSelectedMenu();
|
||||||
|
// 在移动设备上,路由变化时隐藏侧边栏
|
||||||
|
if (isMobile.value) {
|
||||||
|
siderVisible.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 用户信息
|
||||||
|
const unitName = computed(() => {
|
||||||
|
const info = userStore.userInfo;
|
||||||
|
if (!info) return "";
|
||||||
|
return [info?.name, info?.username].filter(Boolean).join(" ");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 切换侧边栏状态
|
||||||
|
const toggleSider = () => {
|
||||||
|
if (isMobile.value) {
|
||||||
|
// 在移动设备上,切换侧边栏显示/隐藏
|
||||||
|
siderVisible.value = !siderVisible.value;
|
||||||
|
} else {
|
||||||
|
// 在桌面设备上,切换侧边栏折叠/展开
|
||||||
|
collapsed.value = !collapsed.value;
|
||||||
|
userStore.saveCollapseSidebar(collapsed.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 退出登录
|
||||||
|
const handleLogout = () => {
|
||||||
|
userStore.logout();
|
||||||
|
router.push("/login");
|
||||||
|
showSuccessTips("已退出登录");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 菜单点击处理,在移动端时点击菜单项后自动隐藏侧边栏
|
||||||
|
const handleMenuClick = (path) => {
|
||||||
|
router.push(path);
|
||||||
|
if (isMobile.value) {
|
||||||
|
siderVisible.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理菜单项点击(IViewMenu 组件使用)
|
||||||
|
const handleMenuItemClick = (item) => {
|
||||||
|
if (item.path) {
|
||||||
|
handleMenuClick(item.path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--theme-sider-color, #001529);
|
||||||
|
box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);
|
||||||
|
transition: width 0.3s, transform 0.3s;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sider {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
/* 默认隐藏 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sider-visible {
|
||||||
|
transform: translateX(0);
|
||||||
|
/* 显示 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-mask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.45);
|
||||||
|
z-index: 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 64px;
|
||||||
|
padding: 16px;
|
||||||
|
text-align: left;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.3s;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--theme-logo-color, #ffffff);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #ffffff;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-svg {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-content {
|
||||||
|
position: relative;
|
||||||
|
margin-left: 200px;
|
||||||
|
width: calc(100% - 200px);
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-content.mobile {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 200px;
|
||||||
|
z-index: 8;
|
||||||
|
height: 64px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||||
|
width: calc(100% - 200px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header {
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
padding: 0 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-center {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
padding: 0 16px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left-icon {
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger {
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger:hover {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 8px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
margin-right: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 24px;
|
||||||
|
background: #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-top: 64px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-view {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 当侧边栏折叠时调整布局 */
|
||||||
|
.layout-content.collapsed {
|
||||||
|
margin-left: 80px;
|
||||||
|
width: calc(100% - 80px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-content.collapsed .header {
|
||||||
|
left: 80px;
|
||||||
|
width: calc(100% - 80px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端适配样式 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.layout-content {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
32
src/views/PageNotFound.vue
Normal file
32
src/views/PageNotFound.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<div class="not-found">
|
||||||
|
<a-result
|
||||||
|
status="404"
|
||||||
|
title="404"
|
||||||
|
sub-title="抱歉,您访问的页面不存在。"
|
||||||
|
>
|
||||||
|
<template #extra>
|
||||||
|
<a-button type="primary" @click="goHome">返回首页</a-button>
|
||||||
|
</template>
|
||||||
|
</a-result>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const goHome = () => {
|
||||||
|
router.push('/dashboard')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.not-found {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
491
src/views/business/IndexDemo.vue
Normal file
491
src/views/business/IndexDemo.vue
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
<template>
|
||||||
|
<div class="demo-page">
|
||||||
|
<div class="demo-intro">
|
||||||
|
<p>本页演示项目中 <code>@/iview</code> 封装组件的常见用法,数据均为本地 Mock,无需后端接口。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IViewTabs v-model:activeKey="activeSection" :animated="false" embedded>
|
||||||
|
<IViewTabPane key="form" tab="表单 IViewForm">
|
||||||
|
<div v-if="activeSection === 'form'" class="demo-section">
|
||||||
|
<IViewForm
|
||||||
|
ref="formRef"
|
||||||
|
v-model="formData"
|
||||||
|
:config="formConfig"
|
||||||
|
:read-only="formReadOnly"
|
||||||
|
/>
|
||||||
|
<div class="demo-actions">
|
||||||
|
<a-button type="primary" @click="handleFormSubmit">提交校验</a-button>
|
||||||
|
<a-button @click="handleFormReset">重置</a-button>
|
||||||
|
<a-button @click="formReadOnly = !formReadOnly">
|
||||||
|
{{ formReadOnly ? "切换为编辑" : "切换为只读" }}
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<a-alert
|
||||||
|
v-if="formResult"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
:message="'表单数据:' + formResult"
|
||||||
|
style="margin-top: 16px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</IViewTabPane>
|
||||||
|
|
||||||
|
<IViewTabPane key="list" tab="列表 IViewTable">
|
||||||
|
<div v-if="activeSection === 'list'" class="demo-section">
|
||||||
|
<IViewFilter
|
||||||
|
v-model="filterParams"
|
||||||
|
:config="filterConfig"
|
||||||
|
@search="handleFilterSearch"
|
||||||
|
@reset="handleFilterReset"
|
||||||
|
/>
|
||||||
|
<div class="demo-list-header">
|
||||||
|
<span class="demo-list-title">订单列表示例(共 {{ filteredList.length }} 条)</span>
|
||||||
|
<a-button type="primary" @click="handleAddRecord">
|
||||||
|
<template #icon><plus-outlined /></template>
|
||||||
|
新增
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<IViewTable
|
||||||
|
:loading="listLoading"
|
||||||
|
:columns="listColumns"
|
||||||
|
:data-source="filteredList"
|
||||||
|
row-key="id"
|
||||||
|
:pagination="tablePagination"
|
||||||
|
@change="handleTableChange"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.dataIndex === 'status'">
|
||||||
|
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-if="column.dataIndex === 'action'">
|
||||||
|
<a-button size="small" type="link" @click="handleViewRecord(record)">查看</a-button>
|
||||||
|
<IViewConfirm
|
||||||
|
importance="normal"
|
||||||
|
title="确认删除该记录吗?"
|
||||||
|
@confirm="() => handleDeleteRecord(record)"
|
||||||
|
>
|
||||||
|
<a-button size="small" type="link" danger>删除</a-button>
|
||||||
|
</IViewConfirm>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</IViewTable>
|
||||||
|
</div>
|
||||||
|
</IViewTabPane>
|
||||||
|
|
||||||
|
<IViewTabPane key="detail" tab="详情 IViewDescriptions">
|
||||||
|
<div v-if="activeSection === 'detail'" class="demo-section">
|
||||||
|
<IViewDescriptions
|
||||||
|
:record-data="detailRecord"
|
||||||
|
:columns-list="detailColumns"
|
||||||
|
:column-size="2"
|
||||||
|
:hidden-label="false"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.dataIndex === 'status'">
|
||||||
|
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</IViewDescriptions>
|
||||||
|
</div>
|
||||||
|
</IViewTabPane>
|
||||||
|
|
||||||
|
<IViewTabPane key="layout" tab="页面 ILayoutPage">
|
||||||
|
<div v-if="activeSection === 'layout'" class="demo-section">
|
||||||
|
<a-alert
|
||||||
|
type="warning"
|
||||||
|
show-icon
|
||||||
|
message="ILayoutPage 需配置真实 API,完整示例请参考「系统设置 → 管理员账号」页面。"
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
/>
|
||||||
|
<pre class="demo-code">{{ layoutPageExample }}</pre>
|
||||||
|
</div>
|
||||||
|
</IViewTabPane>
|
||||||
|
</IViewTabs>
|
||||||
|
|
||||||
|
<IViewDrawer :open="drawerOpen" title="订单详情" @close="drawerOpen = false">
|
||||||
|
<IViewDescriptions
|
||||||
|
v-if="currentRecord"
|
||||||
|
:record-data="currentRecord"
|
||||||
|
:columns-list="detailColumns"
|
||||||
|
:column-size="1"
|
||||||
|
:hidden-label="false"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.dataIndex === 'status'">
|
||||||
|
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</IViewDescriptions>
|
||||||
|
</IViewDrawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { PlusOutlined } from "@ant-design/icons-vue";
|
||||||
|
import IViewTabs from "@/iview/IViewTabs.vue";
|
||||||
|
import IViewTabPane from "@/iview/IViewTabPane.vue";
|
||||||
|
import IViewForm from "@/iview/IViewForm.vue";
|
||||||
|
import IViewFilter from "@/iview/IViewFilter.vue";
|
||||||
|
import IViewTable from "@/iview/IViewTable.vue";
|
||||||
|
import IViewDescriptions from "@/iview/IViewDescriptions.vue";
|
||||||
|
import IViewConfirm from "@/iview/IViewConfirm.vue";
|
||||||
|
import IViewDrawer from "@/iview/IViewDrawer.vue";
|
||||||
|
import { showSuccessTips, showErrorTips } from "@/iview/utils/messageTips";
|
||||||
|
|
||||||
|
const activeSection = ref("form");
|
||||||
|
|
||||||
|
// ── 表单示例 ──────────────────────────────────────────
|
||||||
|
const formRef = ref(null);
|
||||||
|
const formReadOnly = ref(false);
|
||||||
|
const formResult = ref("");
|
||||||
|
const formData = ref({
|
||||||
|
name: "张三",
|
||||||
|
age: 28,
|
||||||
|
gender: "male",
|
||||||
|
tags: ["vip"],
|
||||||
|
city: "guangzhou",
|
||||||
|
birthday: "1998-05-20",
|
||||||
|
enabled: 1,
|
||||||
|
remark: "这是一段备注说明",
|
||||||
|
});
|
||||||
|
|
||||||
|
const formConfig = ref({
|
||||||
|
fields: [
|
||||||
|
{ type: "title", label: "基础信息" },
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
label: "姓名",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
maxlength: 20,
|
||||||
|
placeholder: "请输入姓名",
|
||||||
|
tips: "2~20 个字符",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "age",
|
||||||
|
label: "年龄",
|
||||||
|
type: "number",
|
||||||
|
required: true,
|
||||||
|
min: 1,
|
||||||
|
max: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gender",
|
||||||
|
label: "性别",
|
||||||
|
type: "radio",
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
{ value: "male", label: "男" },
|
||||||
|
{ value: "female", label: "女" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tags",
|
||||||
|
label: "标签",
|
||||||
|
type: "checkbox",
|
||||||
|
options: [
|
||||||
|
{ value: "vip", label: "VIP" },
|
||||||
|
{ value: "new", label: "新用户" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "city",
|
||||||
|
label: "城市",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
{ value: "guangzhou", label: "广州" },
|
||||||
|
{ value: "shenzhen", label: "深圳" },
|
||||||
|
{ value: "beijing", label: "北京" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "birthday",
|
||||||
|
label: "生日",
|
||||||
|
type: "date",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled",
|
||||||
|
label: "启用状态",
|
||||||
|
type: "boolean",
|
||||||
|
trueValue: 1,
|
||||||
|
falseValue: 0,
|
||||||
|
defaultValue: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remark",
|
||||||
|
label: "备注",
|
||||||
|
type: "textarea",
|
||||||
|
maxlength: 200,
|
||||||
|
textRows: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFormSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await formRef.value.validate();
|
||||||
|
formResult.value = JSON.stringify(formData.value);
|
||||||
|
showSuccessTips("表单校验通过");
|
||||||
|
} catch (error) {
|
||||||
|
showErrorTips(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormReset = () => {
|
||||||
|
formRef.value?.resetFormData?.();
|
||||||
|
formResult.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 列表示例 ──────────────────────────────────────────
|
||||||
|
const listLoading = ref(false);
|
||||||
|
const filterParams = ref({});
|
||||||
|
const drawerOpen = ref(false);
|
||||||
|
const currentRecord = ref(null);
|
||||||
|
|
||||||
|
const mockRecords = ref([
|
||||||
|
{ id: 1, orderNo: "ORD-20250605001", customer: "广州物流有限公司", amount: 1280.5, status: 1, createTime: "2025-06-05 09:30:00" },
|
||||||
|
{ id: 2, orderNo: "ORD-20250605002", customer: "深圳科技股份", amount: 5600, status: 2, createTime: "2025-06-05 10:15:00" },
|
||||||
|
{ id: 3, orderNo: "ORD-20250605003", customer: "北京贸易集团", amount: 890, status: 0, createTime: "2025-06-05 11:00:00" },
|
||||||
|
{ id: 4, orderNo: "ORD-20250604004", customer: "上海仓储中心", amount: 3200, status: 3, createTime: "2025-06-04 16:20:00" },
|
||||||
|
{ id: 5, orderNo: "ORD-20250604005", customer: "杭州电商公司", amount: 450, status: 1, createTime: "2025-06-04 18:45:00" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const filterConfig = ref({
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "keyword",
|
||||||
|
label: "关键词",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "订单号 / 客户名称",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
label: "状态",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: 0, label: "待处理" },
|
||||||
|
{ value: 1, label: "进行中" },
|
||||||
|
{ value: 2, label: "已完成" },
|
||||||
|
{ value: 3, label: "已取消" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "createTime",
|
||||||
|
label: "创建日期",
|
||||||
|
type: "date",
|
||||||
|
advanced: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const listColumns = ref([
|
||||||
|
{ title: "订单号", dataIndex: "orderNo", width: 160 },
|
||||||
|
{ title: "客户", dataIndex: "customer", ellipsis: true },
|
||||||
|
{ title: "金额", dataIndex: "amount", width: 100, align: "right" },
|
||||||
|
{ title: "状态", dataIndex: "status", width: 90, bodySlot: true },
|
||||||
|
{ title: "创建时间", dataIndex: "createTime", width: 170 },
|
||||||
|
{ title: "操作", dataIndex: "action", width: 130, bodySlot: true, fixed: "right" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const filteredList = computed(() => {
|
||||||
|
let rows = [...mockRecords.value];
|
||||||
|
const { keyword, status } = filterParams.value;
|
||||||
|
if (keyword) {
|
||||||
|
const kw = String(keyword).trim().toLowerCase();
|
||||||
|
rows = rows.filter(
|
||||||
|
(r) =>
|
||||||
|
r.orderNo.toLowerCase().includes(kw) ||
|
||||||
|
r.customer.toLowerCase().includes(kw)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status !== undefined && status !== null && status !== "") {
|
||||||
|
rows = rows.filter((r) => r.status === Number(status));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tablePagination = computed(() => ({
|
||||||
|
current: paginationState.value.current,
|
||||||
|
pageSize: paginationState.value.pageSize,
|
||||||
|
total: filteredList.value.length,
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const paginationState = ref({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusLabel = (status) => {
|
||||||
|
const map = { 0: "待处理", 1: "进行中", 2: "已完成", 3: "已取消" };
|
||||||
|
return map[status] ?? "未知";
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColor = (status) => {
|
||||||
|
const map = { 0: "default", 1: "processing", 2: "success", 3: "error" };
|
||||||
|
return map[status] ?? "default";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterSearch = () => {
|
||||||
|
paginationState.value.current = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterReset = () => {
|
||||||
|
filterParams.value = {};
|
||||||
|
paginationState.value.current = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTableChange = (pagination) => {
|
||||||
|
paginationState.value.current = pagination.current;
|
||||||
|
paginationState.value.pageSize = pagination.pageSize;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewRecord = (record) => {
|
||||||
|
currentRecord.value = record;
|
||||||
|
drawerOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteRecord = (record) => {
|
||||||
|
mockRecords.value = mockRecords.value.filter((r) => r.id !== record.id);
|
||||||
|
showSuccessTips(`已删除 ${record.orderNo}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddRecord = () => {
|
||||||
|
const id = mockRecords.value.length + 1;
|
||||||
|
mockRecords.value.unshift({
|
||||||
|
id,
|
||||||
|
orderNo: `ORD-202506050${String(id).padStart(2, "0")}`,
|
||||||
|
customer: "新客户示例",
|
||||||
|
amount: 999,
|
||||||
|
status: 0,
|
||||||
|
createTime: "2025-06-05 12:00:00",
|
||||||
|
});
|
||||||
|
showSuccessTips("已添加一条示例记录");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 详情示例 ──────────────────────────────────────────
|
||||||
|
const detailRecord = ref({
|
||||||
|
orderNo: "ORD-20250605001",
|
||||||
|
customer: "广州物流有限公司",
|
||||||
|
amount: 1280.5,
|
||||||
|
status: 1,
|
||||||
|
createTime: "2025-06-05 09:30:00",
|
||||||
|
remark: "优先配送,请联系收货人确认时间。",
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailColumns = ref([
|
||||||
|
{ title: "订单号", dataIndex: "orderNo" },
|
||||||
|
{ title: "客户", dataIndex: "customer" },
|
||||||
|
{ title: "金额", dataIndex: "amount" },
|
||||||
|
{ title: "状态", dataIndex: "status", bodySlot: true },
|
||||||
|
{ title: "创建时间", dataIndex: "createTime" },
|
||||||
|
{ title: "备注", dataIndex: "remark", span: 2 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── ILayoutPage 配置示例 ──────────────────────────────
|
||||||
|
const layoutPageExample = `const config = ref({
|
||||||
|
listPages: [{
|
||||||
|
title: "列表标题",
|
||||||
|
name: "recordList",
|
||||||
|
api: "api.xxx.list", // 列表接口
|
||||||
|
rowKey: "guid",
|
||||||
|
isDefault: true,
|
||||||
|
showFilter: true,
|
||||||
|
pagination: { pageSize: 10 },
|
||||||
|
columns: [/* 列配置 */],
|
||||||
|
filters: { fields: [/* 筛选项 */] },
|
||||||
|
}],
|
||||||
|
formPages: [{
|
||||||
|
name: "create",
|
||||||
|
title: "新增",
|
||||||
|
submitApi: "api.xxx.create",
|
||||||
|
formConfig: { fields: [/* 表单字段 */] },
|
||||||
|
}, {
|
||||||
|
name: "update",
|
||||||
|
title: "编辑",
|
||||||
|
paramsKey: ["guid"],
|
||||||
|
recordApi: "api.xxx.detail",
|
||||||
|
submitApi: "api.xxx.update",
|
||||||
|
formConfig: { fields: [/* 表单字段 */] },
|
||||||
|
}],
|
||||||
|
detailPages: [{
|
||||||
|
name: "detail",
|
||||||
|
title: "详情",
|
||||||
|
paramsKey: ["guid"],
|
||||||
|
recordApi: "api.xxx.detail",
|
||||||
|
columns: [/* 详情字段 */],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 模板中使用
|
||||||
|
<ILayoutPage ref="pageRef" :config="config">
|
||||||
|
<template #listActions="{ refresh }">
|
||||||
|
<a-button type="primary" @click="pageRef.showFormPage('create')">添加</a-button>
|
||||||
|
</template>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<!-- 自定义列渲染 -->
|
||||||
|
</template>
|
||||||
|
</ILayoutPage>`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.demo-page {
|
||||||
|
width: 100%;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-intro {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-intro code {
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-section {
|
||||||
|
padding: 4px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-list-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin: 16px 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-list-title {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-code {
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
98
src/views/business/index.vue
Normal file
98
src/views/business/index.vue
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<div class="module-root-page">
|
||||||
|
<div class="module-page-header">
|
||||||
|
<h2>业务管理</h2>
|
||||||
|
</div>
|
||||||
|
<IViewTabs v-model:activeKey="activeKey" :animated="false" class="module-entry-tabs">
|
||||||
|
<IViewTabPane key="demo" tab="使用示例">
|
||||||
|
<div class="tab-content" v-if="activeKey === 'demo'">
|
||||||
|
<IndexDemo />
|
||||||
|
</div>
|
||||||
|
</IViewTabPane>
|
||||||
|
</IViewTabs>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import IViewTabs from "@/iview/IViewTabs.vue";
|
||||||
|
import IViewTabPane from "@/iview/IViewTabPane.vue";
|
||||||
|
import IndexDemo from "./IndexDemo.vue";
|
||||||
|
|
||||||
|
const activeKey = ref("demo");
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query.tab,
|
||||||
|
(tab) => {
|
||||||
|
if (tab && ["demo"].includes(tab)) {
|
||||||
|
activeKey.value = tab;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(activeKey, (newKey) => {
|
||||||
|
router.push({
|
||||||
|
query: { ...route.query, tab: newKey },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.module-root-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: left;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-page-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.module-root-page {
|
||||||
|
--module-page-inset: 16px;
|
||||||
|
width: calc(100% + 2 * var(--module-page-inset));
|
||||||
|
margin: calc(-1 * var(--module-page-inset));
|
||||||
|
padding: var(--module-page-inset);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-root-page .module-page-header {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-root-page .module-page-header h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-root-page .module-entry-tabs {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 769px) {
|
||||||
|
.module-page-header h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
min-height: 350px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.tab-content {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
252
src/views/setting/IndexAdmin.vue
Normal file
252
src/views/setting/IndexAdmin.vue
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
<template>
|
||||||
|
<div class="admin-page">
|
||||||
|
<ILayoutPage ref="pageRef" :config="config">
|
||||||
|
<template #listActions="{ type, refresh }">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>
|
||||||
|
管理员账号列表
|
||||||
|
</h2>
|
||||||
|
<a-button type="primary" @click="addClick">
|
||||||
|
<template #icon><plus-outlined /></template>
|
||||||
|
添加
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #bodyCell="{ column, record, type }">
|
||||||
|
<template v-if="column.dataIndex === 'recordDetail'">
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 14px; color: #333;">
|
||||||
|
{{ record.nickname }}
|
||||||
|
<a-tag v-if="record.optStatus === 0" style="margin-left: 10px" color="#FF0000">
|
||||||
|
<span>停用</span>
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="item-detail">
|
||||||
|
<span class="item-title">账号</span>
|
||||||
|
<span class="item-value">{{ record.username }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-detail">
|
||||||
|
<span class="item-title">角色</span>
|
||||||
|
<span class="item-value">{{ record.optSuper === 1 ? '超级管理员' : '普通管理员' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!!record.optRemark" class="item-detail">
|
||||||
|
<span class="item-title">备注</span>
|
||||||
|
<span class="item-value">{{ record.optRemark }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-if="column.dataIndex === 'action'">
|
||||||
|
<a-button size="small" type="default" @click="editClick(record)">
|
||||||
|
编辑
|
||||||
|
</a-button>
|
||||||
|
<IViewConfirm importance="normal" :title="`确认要删除管理员吗?`" @confirm="() => deleteClick(record)">
|
||||||
|
<a-button size="small" danger> 删除 </a-button>
|
||||||
|
</IViewConfirm>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</ILayoutPage>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { showErrorTips, showSuccessTips } from "@/iview/utils/messageTips";
|
||||||
|
import { apiRequest } from "@/iview/utils/request";
|
||||||
|
import { PlusOutlined } from "@ant-design/icons-vue";
|
||||||
|
import ILayoutPage from "@/iview/ILayoutPage.vue";
|
||||||
|
import IViewConfirm from "@/iview/IViewConfirm.vue";
|
||||||
|
|
||||||
|
const pageRef = ref(null);
|
||||||
|
|
||||||
|
const refreshList = () => {
|
||||||
|
pageRef.value.refreshList();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addClick = () => {
|
||||||
|
pageRef.value.showFormPage("create");
|
||||||
|
};
|
||||||
|
|
||||||
|
const editClick = (record) => {
|
||||||
|
pageRef.value.showFormPage("update", record);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteClick = async (record) => {
|
||||||
|
try {
|
||||||
|
record.actionLoading = true;
|
||||||
|
await apiRequest("api.system.admin.info.delete", {
|
||||||
|
guid: record.guid,
|
||||||
|
});
|
||||||
|
showSuccessTips("管理员删除成功");
|
||||||
|
refreshList();
|
||||||
|
} catch (error) {
|
||||||
|
showErrorTips(error);
|
||||||
|
} finally {
|
||||||
|
record.actionLoading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordColumns = ref([
|
||||||
|
{
|
||||||
|
title: "管理员信息",
|
||||||
|
dataIndex: "recordDetail",
|
||||||
|
bodySlot: true,
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
width: 160,
|
||||||
|
title: "操作",
|
||||||
|
dataIndex: "action",
|
||||||
|
bodySlot: true,
|
||||||
|
fixed: "right",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const formConfig = ref({
|
||||||
|
extrasFields: ["guid"],
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "username",
|
||||||
|
label: "登录账号",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
minLength: 3,
|
||||||
|
maxlength: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nickname",
|
||||||
|
label: "账号昵称",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
maxlength: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "password",
|
||||||
|
label: "登录密码",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
minLength: 6,
|
||||||
|
placeholder: "密码长度至少6个字符",
|
||||||
|
show: "!record.guid",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "password",
|
||||||
|
label: "重置密码",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "不修改密码请留空",
|
||||||
|
show: "!!record.guid",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "optSuper",
|
||||||
|
label: "超级管理员",
|
||||||
|
type: "radio",
|
||||||
|
options: [
|
||||||
|
{ value: 1, label: "是" },
|
||||||
|
{ value: 0, label: "否" },
|
||||||
|
],
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "optRemark",
|
||||||
|
label: "备注信息",
|
||||||
|
type: "textarea",
|
||||||
|
maxlength: 200,
|
||||||
|
textRows: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "optStatus",
|
||||||
|
label: "状态开关",
|
||||||
|
type: "boolean",
|
||||||
|
defaultValue: 1,
|
||||||
|
falseValue: 0,
|
||||||
|
trueValue: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = ref({
|
||||||
|
listPages: [
|
||||||
|
{
|
||||||
|
title: "管理员列表",
|
||||||
|
name: "recordList",
|
||||||
|
api: "api.system.admin.info.list",
|
||||||
|
rowKey: "guid",
|
||||||
|
defaultParams: {},
|
||||||
|
showFilter: false,
|
||||||
|
isDefault: true,
|
||||||
|
pagination: {
|
||||||
|
pageSize: 10,
|
||||||
|
showTotal: (total) => total > 0 ? `共 ${total} 条` : "暂无数据",
|
||||||
|
},
|
||||||
|
columns: recordColumns.value,
|
||||||
|
filters: {
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
detailPages: [],
|
||||||
|
formPages: [
|
||||||
|
{
|
||||||
|
name: "create",
|
||||||
|
title: "添加管理员",
|
||||||
|
submitApi: "api.system.admin.info.create",
|
||||||
|
formConfig: formConfig.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update",
|
||||||
|
title: "编辑管理员",
|
||||||
|
paramsKey: ["guid"],
|
||||||
|
recordApi: "api.system.admin.info.detail",
|
||||||
|
submitApi: "api.system.admin.info.update",
|
||||||
|
formConfig: formConfig.value,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-page {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #d1d5db;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-detail {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
margin-right: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-value {
|
||||||
|
color: rgba(0, 0, 0, 0.75);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
word-break: break-all;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
429
src/views/setting/IndexSetting.vue
Normal file
429
src/views/setting/IndexSetting.vue
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
<template>
|
||||||
|
<div class="setting-config">
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<div class="config-box" v-if="settingList">
|
||||||
|
<template v-if="settingList && settingList.length > 0">
|
||||||
|
<div class="config-wrap">
|
||||||
|
<!-- 左侧设置菜单 -->
|
||||||
|
<div class="config-left">
|
||||||
|
<a-menu
|
||||||
|
mode="inline"
|
||||||
|
v-model:selectedKeys="selectedKeys"
|
||||||
|
class="setting-menu"
|
||||||
|
>
|
||||||
|
<a-menu-item
|
||||||
|
v-for="item in settingList"
|
||||||
|
:key="item.key"
|
||||||
|
@click="handleMenuClick(item.key)"
|
||||||
|
>
|
||||||
|
{{ item.title }}
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧配置表单 -->
|
||||||
|
<div class="config-right">
|
||||||
|
<a-spin :spinning="detailLoading">
|
||||||
|
<div v-if="currentSetting && currentSetting.settingInfo">
|
||||||
|
<div class="config-title">
|
||||||
|
<span>
|
||||||
|
{{ currentSetting.settingInfo.title }}
|
||||||
|
<small v-if="currentSetting.settingInfo.desc">
|
||||||
|
({{ currentSetting.settingInfo.desc }})
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!!currentSetting.settingInfo.readme">
|
||||||
|
<a-alert :message="currentSetting.settingInfo.readme" banner />
|
||||||
|
</div>
|
||||||
|
<div class="config-form">
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="editModel"
|
||||||
|
@submit.prevent
|
||||||
|
layout="vertical"
|
||||||
|
>
|
||||||
|
<a-form-item>
|
||||||
|
<IViewForm
|
||||||
|
ref="configFormRef"
|
||||||
|
v-model="editModel.settingValue"
|
||||||
|
model-format="json"
|
||||||
|
:config="configFormJson"
|
||||||
|
:read-only="false"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item>
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
:loading="submitLoading"
|
||||||
|
:disabled="submitLoading || detailLoading"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-empty
|
||||||
|
v-else-if="!detailLoading"
|
||||||
|
:description="detailErrorMsg || '请选择左侧配置项'"
|
||||||
|
style="margin-top: 60px"
|
||||||
|
>
|
||||||
|
<!-- <a-button
|
||||||
|
v-if="detailErrorMsg && currentSettingKey"
|
||||||
|
@click="fetchSettingDetail"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
重新加载
|
||||||
|
</a-button> -->
|
||||||
|
</a-empty>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<a-empty v-else description="暂无可配置选项">
|
||||||
|
<a-button @click="refresh" size="small"> 刷新列表 </a-button>
|
||||||
|
</a-empty>
|
||||||
|
</div>
|
||||||
|
<a-empty v-else-if="!loading" :description="errorMsg" style="margin-top: 60px">
|
||||||
|
<a-button v-if="errorMsg" @click="refresh"> 重新加载 </a-button>
|
||||||
|
</a-empty>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted } from "vue";
|
||||||
|
import { showErrorTips, showSuccessTips } from "@/iview/utils/messageTips";
|
||||||
|
import { apiRequest } from "@/iview/utils/request";
|
||||||
|
import IViewForm from "@/iview/IViewForm.vue";
|
||||||
|
|
||||||
|
// 响应式变量
|
||||||
|
const loading = ref(false);
|
||||||
|
const errorMsg = ref(null);
|
||||||
|
const settingList = ref(null);
|
||||||
|
const detailLoading = ref(false);
|
||||||
|
const detailErrorMsg = ref(null);
|
||||||
|
const currentSetting = ref(null);
|
||||||
|
const currentSettingKey = ref("");
|
||||||
|
const selectedKeys = ref([]);
|
||||||
|
const submitLoading = ref(false);
|
||||||
|
|
||||||
|
// 编辑表单模型
|
||||||
|
const editModel = reactive({
|
||||||
|
settingKey: "",
|
||||||
|
settingValue: "{}",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表单引用
|
||||||
|
const formRef = ref(null);
|
||||||
|
const configFormRef = ref(null);
|
||||||
|
|
||||||
|
// 表单配置字段列表
|
||||||
|
const configFormFields = computed(() => {
|
||||||
|
if (
|
||||||
|
!currentSetting.value ||
|
||||||
|
!currentSetting.value.settingInfo ||
|
||||||
|
!currentSetting.value.settingInfo.fields
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理特殊的条件显示逻辑
|
||||||
|
const processedFields = currentSetting.value.settingInfo.fields.map((field) => {
|
||||||
|
// 克隆字段以避免修改原始数据
|
||||||
|
const processedField = { ...field };
|
||||||
|
// 处理条件逻辑 - 保留原始的条件表达式
|
||||||
|
// CustomFormEditor组件会根据表达式动态处理显示/隐藏逻辑
|
||||||
|
return processedField;
|
||||||
|
});
|
||||||
|
|
||||||
|
return processedFields;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表单配置JSON
|
||||||
|
const configFormJson = computed(() => {
|
||||||
|
return JSON.stringify({
|
||||||
|
fields: configFormFields.value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理菜单点击事件
|
||||||
|
const handleMenuClick = (key) => {
|
||||||
|
currentSettingKey.value = key;
|
||||||
|
selectedKeys.value = [key];
|
||||||
|
fetchSettingDetail();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取配置详情
|
||||||
|
const fetchSettingDetail = async () => {
|
||||||
|
if (!currentSettingKey.value) return;
|
||||||
|
|
||||||
|
detailErrorMsg.value = null;
|
||||||
|
detailLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用真实接口获取配置详情
|
||||||
|
const res = await apiRequest("api.system.global.setting.detail", {
|
||||||
|
settingKey: currentSettingKey.value,
|
||||||
|
});
|
||||||
|
currentSetting.value = res;
|
||||||
|
// 初始化编辑表单
|
||||||
|
editModel.settingKey = currentSettingKey.value;
|
||||||
|
editModel.settingValue = res.settingValue;
|
||||||
|
} catch (err) {
|
||||||
|
detailErrorMsg.value =
|
||||||
|
(err && err.msg) || (err && err.message) || "连接服务器失败,请检查网络";
|
||||||
|
currentSetting.value = null;
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取配置列表
|
||||||
|
const refresh = async () => {
|
||||||
|
errorMsg.value = null;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
// 调用真实接口获取配置列表
|
||||||
|
const res = await apiRequest("api.system.global.setting.list", {});
|
||||||
|
// 过滤掉隐藏的设置项
|
||||||
|
settingList.value = (res || []).filter((item) => !item.hidden);
|
||||||
|
if (settingList.value.length > 0) {
|
||||||
|
// 默认选中第一项
|
||||||
|
currentSettingKey.value = settingList.value[0].key;
|
||||||
|
selectedKeys.value = [currentSettingKey.value];
|
||||||
|
fetchSettingDetail();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorMsg.value =
|
||||||
|
(err && err.msg) || (err && err.message) || "连接服务器失败,请检查网络";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
// 如果有表单校验,进行校验
|
||||||
|
if (configFormRef.value) {
|
||||||
|
await configFormRef.value.validate();
|
||||||
|
}
|
||||||
|
submitLoading.value = true;
|
||||||
|
// 调用真实接口保存配置
|
||||||
|
await apiRequest("api.system.global.setting.edit", {
|
||||||
|
settingKey: editModel.settingKey,
|
||||||
|
settingValue: editModel.settingValue,
|
||||||
|
});
|
||||||
|
showSuccessTips("保存成功");
|
||||||
|
// 重新获取配置详情,确保数据最新
|
||||||
|
await fetchSettingDetail();
|
||||||
|
} catch (err) {
|
||||||
|
showErrorTips(err);
|
||||||
|
} finally {
|
||||||
|
submitLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 组件挂载时获取数据
|
||||||
|
onMounted(() => {
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.setting-config {
|
||||||
|
margin-top: 0;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-box {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 18px;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-left {
|
||||||
|
width: 210px;
|
||||||
|
flex: 0 0 210px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, #eef6ff 0%, #ffffff 72%);
|
||||||
|
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-left-header {
|
||||||
|
padding: 18px 18px 12px;
|
||||||
|
/* background: linear-gradient(135deg, #eef6ff 0%, #ffffff 72%); */
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-left-title {
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-left-desc {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #8c96a8;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-menu {
|
||||||
|
padding: 8px;
|
||||||
|
border-right: none;
|
||||||
|
background: transparent;
|
||||||
|
border-inline-end: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-right {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 300px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-panel {
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 300px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 22px 24px 18px;
|
||||||
|
background: linear-gradient(135deg, #f7fbff 0%, #ffffff 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-title-text {
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-title-desc {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #8c96a8;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-readme {
|
||||||
|
margin: 0 24px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-form {
|
||||||
|
padding: 20px 24px 6px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-btns {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-empty {
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 769px) {
|
||||||
|
.setting-config,
|
||||||
|
.config-box,
|
||||||
|
.config-wrap,
|
||||||
|
.config-left,
|
||||||
|
.config-right,
|
||||||
|
.config-panel {
|
||||||
|
min-height: calc(100vh - 220px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 修复表单叠加问题 */
|
||||||
|
:deep(.ant-form) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-form-item) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.custom-form-editor) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-form-item-control) {
|
||||||
|
display: block;
|
||||||
|
flex: 1 1 auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-form-item-control-input) {
|
||||||
|
min-height: unset !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-textarea-wrapper) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-input-textarea) {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.setting-menu .ant-menu-item) {
|
||||||
|
height: 40px;
|
||||||
|
margin: 4px 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.setting-menu .ant-menu-item-selected) {
|
||||||
|
color: #1677ff;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(22, 119, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.config-readme .ant-alert) {
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f6faff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式布局 */
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.config-wrap {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-left {
|
||||||
|
width: 100%;
|
||||||
|
flex: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-right {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-btns {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
</style>
|
||||||
338
src/views/setting/IndexTask.vue
Normal file
338
src/views/setting/IndexTask.vue
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
<template>
|
||||||
|
<div class="record-page">
|
||||||
|
<ILayoutPage ref="pageRef" :config="config">
|
||||||
|
<template #listActions="{ type, refresh }">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>
|
||||||
|
异步任务列表
|
||||||
|
</h2>
|
||||||
|
<div :style="{ display: 'flex', gap: '10px' }">
|
||||||
|
<a-button type="default" @click="refreshPage">
|
||||||
|
<template #icon><reload-outlined /></template>
|
||||||
|
刷新
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #bodyCell="{ column, record, type }">
|
||||||
|
<template v-if="column.dataIndex === 'recordDetail'">
|
||||||
|
<div>
|
||||||
|
<div :class="type !== 'detail' ? 'name-click' : 'name-text'"
|
||||||
|
@click="type !== 'detail' ? detailClick(record) : null">
|
||||||
|
{{ record.taskType }}
|
||||||
|
</div>
|
||||||
|
<div class="item-detail" v-if="displayBizInfo(record)">
|
||||||
|
<span class="item-title">业务编号</span>
|
||||||
|
<span class="item-value">{{ displayBizInfo(record) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-detail">
|
||||||
|
<span class="item-title">创建时间</span>
|
||||||
|
<span class="item-value">{{ displayTime(record.createTime) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-detail" v-if="record.startTime > 0">
|
||||||
|
<span class="item-title">开始时间</span>
|
||||||
|
<span class="item-value">{{ displayTime(record.startTime) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-detail" v-else-if="record.callTiming > 0">
|
||||||
|
<span class="item-title">执行时间</span>
|
||||||
|
<span class="item-value">{{ displayTime(record.callTiming) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-detail" v-else>
|
||||||
|
<span class="item-title">开始时间</span>
|
||||||
|
<span class="item-value">{{ displayTime(record.createTime) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-detail">
|
||||||
|
<span class="item-title">执行进度</span>
|
||||||
|
<span class="item-value">
|
||||||
|
<span v-html="displayProgress(record)"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-detail">
|
||||||
|
<span class="item-title">执行状态</span>
|
||||||
|
<span class="item-value">
|
||||||
|
<span v-html="displayOptStatus(record.optStatus)"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-detail">
|
||||||
|
<span class="item-title">执行用时</span>
|
||||||
|
<span class="item-value">{{ displayUseTime(record.useTime) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="type === 'detail'" style="margin: 10px 0px;">
|
||||||
|
<IViewConfirm importance="normal" :title="`确认要重新执行异步任务吗?`" @confirm="() => restartClick(record)">
|
||||||
|
<a-button size="small" type="default"> 重新执行 </a-button>
|
||||||
|
</IViewConfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-if="column.dataIndex === 'action'">
|
||||||
|
<a-button size="small" type="default" @click="detailClick(record)">
|
||||||
|
详情
|
||||||
|
</a-button>
|
||||||
|
<IViewConfirm importance="normal" :title="`确认要重新执行异步任务吗?`" @confirm="() => restartClick(record)">
|
||||||
|
<a-button size="small" type="default"> 重新执行 </a-button>
|
||||||
|
</IViewConfirm>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #detailTab="{ record, tab, type, refresh }">
|
||||||
|
<template v-if="tab.key === 'logs' && record">
|
||||||
|
<TaskLogs :taskInfo="record" />
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</ILayoutPage>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { ReloadOutlined } from "@ant-design/icons-vue";
|
||||||
|
import { showErrorTips, showSuccessTips } from "@/iview/utils/messageTips";
|
||||||
|
import { apiRequest } from "@/iview/utils/request";
|
||||||
|
import IViewConfirm from "@/iview/IViewConfirm.vue";
|
||||||
|
import ILayoutPage from "@/iview/ILayoutPage.vue";
|
||||||
|
import TaskLogs from "./TaskLogs.vue";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
|
||||||
|
const pageRef = ref(null);
|
||||||
|
|
||||||
|
/*
|
||||||
|
PENDING(0, "待处理"),
|
||||||
|
RUNNING(1, "处理中"),
|
||||||
|
FULFILLED(2, "已结束"),
|
||||||
|
WAIL_RETRY(3, "待重试"),
|
||||||
|
CANCELING(4, "取消中"),
|
||||||
|
CANCELED(5, "已取消");
|
||||||
|
*/
|
||||||
|
const optStatusMap = {
|
||||||
|
0: "待处理",
|
||||||
|
1: "处理中",
|
||||||
|
2: "已结束",
|
||||||
|
3: "待重试",
|
||||||
|
4: "取消中",
|
||||||
|
5: "已取消",
|
||||||
|
};
|
||||||
|
|
||||||
|
const optStatusColorMap = {
|
||||||
|
0: "color: #999999;",
|
||||||
|
1: "color: #3399ff;",
|
||||||
|
2: "color: #34C759;",
|
||||||
|
3: "color: #ff0000;",
|
||||||
|
4: "color: #999999;",
|
||||||
|
5: "color: #999999;",
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayTime = (time) => {
|
||||||
|
return dayjs(time).format("YYYY-MM-DD HH:mm:ss");
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayBizInfo = (record) => {
|
||||||
|
if (record.bizType && record.bizId
|
||||||
|
&& record.bizType != 'unknown' && record.bizId != 'unknown') {
|
||||||
|
return record.bizType + '_' + record.bizId;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayUseTime = (useTime) => {
|
||||||
|
// 毫秒转时分秒
|
||||||
|
const hours = Math.floor(useTime / 3600000);
|
||||||
|
const minutes = Math.floor((useTime % 3600000) / 60000);
|
||||||
|
const seconds = Math.floor((useTime % 60000) / 1000);
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}小时${minutes}分钟${seconds}秒`;
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return `${minutes}分钟${seconds}秒`;
|
||||||
|
} else {
|
||||||
|
return `${seconds}秒`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayProgress = (record) => {
|
||||||
|
let totalCount = record.totalCount; // 总数量
|
||||||
|
let successCount = record.successCount; // 成功数量
|
||||||
|
let failureCount = record.failureCount; // 失败数量
|
||||||
|
let ignoreCount = record.ignoreCount; // 忽略数量
|
||||||
|
let retryCount = record.retryCount; // 重试次数
|
||||||
|
let text = "";
|
||||||
|
text += `<span style="color: #333333;">${totalCount}</span> 总数 `;
|
||||||
|
text += `<span style="color: #34C759;">${successCount}</span> 成功 `;
|
||||||
|
if (failureCount > 0) {
|
||||||
|
text += `<span style="color: #ff0000;">${failureCount}</span> 失败 `;
|
||||||
|
}
|
||||||
|
if (ignoreCount > 0) {
|
||||||
|
text += `<span style="color: #999999;">${ignoreCount}</span> 忽略 `;
|
||||||
|
}
|
||||||
|
if (retryCount > 0) {
|
||||||
|
text += `<span style="color: #333333;">${retryCount}</span> 重试 `;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayOptStatus = (optStatus) => {
|
||||||
|
return `<span style="${optStatusColorMap[optStatus]}">${optStatusMap[optStatus] || "未知"}</span>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshPage = () => {
|
||||||
|
pageRef.value.refreshPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
const detailClick = (record) => {
|
||||||
|
pageRef.value.showDetailPage("detail", record);
|
||||||
|
};
|
||||||
|
|
||||||
|
const restartClick = async (record) => {
|
||||||
|
try {
|
||||||
|
record.actionLoading = true;
|
||||||
|
await apiRequest("api.system.async.task.info.restart", {
|
||||||
|
guid: record.guid,
|
||||||
|
});
|
||||||
|
showSuccessTips("异步任务重新执行成功");
|
||||||
|
refreshPage();
|
||||||
|
} catch (error) {
|
||||||
|
showErrorTips(error);
|
||||||
|
} finally {
|
||||||
|
record.actionLoading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordColumns = ref([
|
||||||
|
{
|
||||||
|
title: "任务信息",
|
||||||
|
dataIndex: "recordDetail",
|
||||||
|
bodySlot: true,
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
width: 160,
|
||||||
|
title: "操作",
|
||||||
|
dataIndex: "action",
|
||||||
|
bodySlot: true,
|
||||||
|
fixed: "right",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const config = ref({
|
||||||
|
listPages: [
|
||||||
|
{
|
||||||
|
title: "异步任务列表",
|
||||||
|
name: "recordList",
|
||||||
|
api: "api.system.async.task.info.list",
|
||||||
|
rowKey: "guid",
|
||||||
|
defaultParams: {},
|
||||||
|
emptyText: "暂无异步任务",
|
||||||
|
showFilter: false,
|
||||||
|
isDefault: true,
|
||||||
|
pagination: {
|
||||||
|
pageSize: 10,
|
||||||
|
showTotal: (total) => total > 0 ? `共 ${total} 条` : "暂无数据",
|
||||||
|
},
|
||||||
|
columns: recordColumns.value,
|
||||||
|
filters: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: "搜索关键词",
|
||||||
|
name: "searchKeyword",
|
||||||
|
foldable: false,
|
||||||
|
type: "text",
|
||||||
|
placeholder: "异步任务名称/任务类型/任务状态关键词搜索",
|
||||||
|
styleValue: {
|
||||||
|
width: "380px"
|
||||||
|
},
|
||||||
|
clearable: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
detailPages: [
|
||||||
|
{
|
||||||
|
name: "detail",
|
||||||
|
title: "异步任务详情",
|
||||||
|
recordApi: "api.system.async.task.info.detail",
|
||||||
|
paramsKey: ["guid"],
|
||||||
|
recordMap: (record) => {
|
||||||
|
return record;
|
||||||
|
},
|
||||||
|
hiddenLabel: true,
|
||||||
|
columns: recordColumns.value,
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
label: "执行日志",
|
||||||
|
key: "logs",
|
||||||
|
forceRender: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
formPages: [
|
||||||
|
],
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.record-page {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #d1d5db;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-detail {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
margin-right: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-value {
|
||||||
|
color: rgba(0, 0, 0, 0.75);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
word-break: break-all;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-click {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333333;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-text {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333333;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
91
src/views/setting/TaskLogs.vue
Normal file
91
src/views/setting/TaskLogs.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<template>
|
||||||
|
<div class="record-page">
|
||||||
|
<ILayoutPage ref="pageRef" :config="config">
|
||||||
|
<template #bodyCell="{ column, record, type }">
|
||||||
|
<template v-if="column.dataIndex === 'recordDetail'">
|
||||||
|
<pre class="log-params">{{ displayParams(taskInfo.taskParams) }}</pre>
|
||||||
|
<LogContentPreview :content="record.logsContent" />
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</ILayoutPage>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import ILayoutPage from "@/iview/ILayoutPage.vue";
|
||||||
|
import LogContentPreview from "@/components/LogContentPreview.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
taskInfo: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageRef = ref(null);
|
||||||
|
|
||||||
|
const refreshPage = () => {
|
||||||
|
pageRef.value.refreshPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayParams = (params) => {
|
||||||
|
try {
|
||||||
|
let paramsObj = JSON.parse(params);
|
||||||
|
return JSON.stringify(paramsObj, null, 2);
|
||||||
|
} catch (_) {
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordColumns = ref([
|
||||||
|
{
|
||||||
|
title: "执行日志",
|
||||||
|
dataIndex: "recordDetail",
|
||||||
|
bodySlot: true,
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const config = ref({
|
||||||
|
infoPages: [
|
||||||
|
{
|
||||||
|
title: "兽药企业信息",
|
||||||
|
name: "info",
|
||||||
|
recordApi: "api.system.async.task.info.logs",
|
||||||
|
recordMap: (record) => {
|
||||||
|
return record;
|
||||||
|
},
|
||||||
|
defaultParams: () => {
|
||||||
|
return {
|
||||||
|
guid: props.taskInfo.guid,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
hiddenLabel: true,
|
||||||
|
columns: recordColumns.value,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
detailPages: [],
|
||||||
|
formPages: [
|
||||||
|
],
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.record-page {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-params {
|
||||||
|
color: #999;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
117
src/views/setting/index.vue
Normal file
117
src/views/setting/index.vue
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<div class="module-root-page">
|
||||||
|
<div class="module-page-header">
|
||||||
|
<h2>系统管理</h2>
|
||||||
|
</div>
|
||||||
|
<IViewTabs v-model:activeKey="activeKey" :animated="false" class="module-entry-tabs">
|
||||||
|
<IViewTabPane key="admin" tab="账号管理">
|
||||||
|
<div class="tab-content" v-if="activeKey === 'admin'">
|
||||||
|
<IndexAdmin />
|
||||||
|
</div>
|
||||||
|
</IViewTabPane>
|
||||||
|
<IViewTabPane key="task" tab="任务中心">
|
||||||
|
<div class="tab-content" v-if="activeKey === 'task'">
|
||||||
|
<IndexTask />
|
||||||
|
</div>
|
||||||
|
</IViewTabPane>
|
||||||
|
<IViewTabPane key="setting" tab="功能设置">
|
||||||
|
<div class="tab-content" v-if="activeKey === 'setting'">
|
||||||
|
<IndexSetting />
|
||||||
|
</div>
|
||||||
|
</IViewTabPane>
|
||||||
|
</IViewTabs>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import IViewTabs from "@/iview/IViewTabs.vue";
|
||||||
|
import IViewTabPane from "@/iview/IViewTabPane.vue";
|
||||||
|
import IndexAdmin from "./IndexAdmin.vue";
|
||||||
|
import IndexSetting from "./IndexSetting.vue";
|
||||||
|
import IndexTask from "./IndexTask.vue";
|
||||||
|
|
||||||
|
const activeKey = ref("admin");
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query.tab,
|
||||||
|
(tab) => {
|
||||||
|
if (tab && ["admin", "task", "setting"].includes(tab)) {
|
||||||
|
activeKey.value = tab;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(activeKey, (newKey) => {
|
||||||
|
router.push({
|
||||||
|
query: { ...route.query, tab: newKey },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.module-root-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: left;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-page-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.module-root-page {
|
||||||
|
--module-page-inset: 16px;
|
||||||
|
width: calc(100% + 2 * var(--module-page-inset));
|
||||||
|
margin: calc(-1 * var(--module-page-inset));
|
||||||
|
padding: var(--module-page-inset);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-root-page .module-page-header {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-root-page .module-page-header h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-root-page .module-entry-tabs {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 769px) {
|
||||||
|
.module-page-header h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
min-height: 350px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.custom-form-config),
|
||||||
|
:deep(.custom-form-editor) {
|
||||||
|
position: relative;
|
||||||
|
box-shadow: none;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.tab-content {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
92
vite.config.js
Normal file
92
vite.config.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
const packageJson = require('./package.json');
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueJsx from '@vitejs/plugin-vue-jsx';
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import path from 'path'
|
||||||
|
import fs from 'fs'
|
||||||
|
|
||||||
|
/** publicDir 复制完成后,从产物中移除 .md(Vite 本身不支持 publicDir ignore) */
|
||||||
|
function excludePublicMarkdown({ publicRoot, outDir }) {
|
||||||
|
function removeMarkdownFromDist() {
|
||||||
|
if (!fs.existsSync(publicRoot)) return
|
||||||
|
|
||||||
|
const stack = [publicRoot]
|
||||||
|
while (stack.length) {
|
||||||
|
const dir = stack.pop()
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const fullPath = path.join(dir, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
stack.push(fullPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!entry.name.endsWith('.md')) continue
|
||||||
|
|
||||||
|
const distPath = path.join(outDir, path.relative(publicRoot, fullPath))
|
||||||
|
if (fs.existsSync(distPath)) fs.unlinkSync(distPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'exclude-public-markdown',
|
||||||
|
closeBundle: removeMarkdownFromDist,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig(() => {
|
||||||
|
const projectRoot = __dirname;
|
||||||
|
const pagesRoot = path.resolve(projectRoot, 'src/pages');
|
||||||
|
const publicRoot = path.resolve(projectRoot, 'public');
|
||||||
|
const outDir = path.resolve(projectRoot, 'dist');
|
||||||
|
const buildDate = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10)
|
||||||
|
.replace(/-/g, '');
|
||||||
|
const buildVersion = ["v" + packageJson.version,
|
||||||
|
buildDate].filter(item => !!item).join('-');
|
||||||
|
|
||||||
|
return {
|
||||||
|
define: {
|
||||||
|
__BUILD_VERSION__: JSON.stringify(buildVersion),
|
||||||
|
},
|
||||||
|
// root 为 src/pages 时,默认 public 目录为 src/pages/public;
|
||||||
|
// 显式指向仓库根目录的 public,打包时会复制到 dist 根目录
|
||||||
|
publicDir: publicRoot,
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
vueJsx(),
|
||||||
|
excludePublicMarkdown({ publicRoot, outDir }),
|
||||||
|
],
|
||||||
|
envDir: projectRoot,
|
||||||
|
build: {
|
||||||
|
outDir,
|
||||||
|
emptyOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: path.resolve(pagesRoot, 'index.html'),
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (id.includes('node_modules')) {
|
||||||
|
return 'vendor';
|
||||||
|
}
|
||||||
|
return "app";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
base: './',
|
||||||
|
root: pagesRoot,
|
||||||
|
server: {
|
||||||
|
open: true,
|
||||||
|
cors: true,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user