This commit is contained in:
iqudoo
2026-06-05 17:22:32 +08:00
commit eb4e8f1a04
90 changed files with 21224 additions and 0 deletions

9
.env.development Normal file
View 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
View 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
View 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?

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# tape-springboot-sysadmin
> 后台管理

30
package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

0
public/README.md Normal file
View File

1
src/assets/logo.svg Normal file
View 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
View 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 };

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

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

File diff suppressed because it is too large Load Diff

523
src/iview/IViewConfirm.vue Normal file
View 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。
* - ModalOK 不会根据返回值关窗,须在 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>

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

View 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 的 scopepageRef.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
View 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>

View 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
View 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,
},
/** 空状态插图:字符串为图片 URLfalse 为不显示图标(仅文案) */
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
View 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>

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

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

View 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, "\\$&");
}
/** 按多种分隔符拆分图片 URLsplitDelimiter 支持字符串或数组) */
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>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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 需与内置控件保持相同 propsfield、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];
}

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

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

View 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
```
协议版本与宿主实现保持一致;嵌入页仅依赖上述字符串,无需同步宿主代码仓库。

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

View 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:";
}
/**
* 请求父页面打开 URLiframe 内 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;
}
};
}

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,5 @@
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia

171
src/store/user.js Normal file
View 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
View 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
View 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>

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

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

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

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

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

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

View 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
View 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
View 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 复制完成后,从产物中移除 .mdVite 本身不支持 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'),
},
},
}
})